defmodule PolicyService.Aggregates.PolicyApplication do @moduledoc """ Behaviour and __using__ macro for policy application aggregates. Each policy type implements validate_details/1 and declares its detail fields. Usage: defmodule PolicyService.Aggregates.CarPolicyApplication do use PolicyService.Aggregates.PolicyApplication, policy_type: "car" end """ @callback validate_details(map()) :: :ok | {:error, term()} defmacro __using__(opts) do policy_type = Keyword.fetch!(opts, :policy_type) commands_module = Keyword.get(opts, :commands, PolicyService.Commands.Policy) quote do @behaviour Commanded.Aggregates.Aggregate alias unquote(commands_module).SubmitPolicyApplication alias unquote(commands_module).RecordProviderQuote alias unquote(commands_module).AcceptQuoteAndSolicit alias unquote(commands_module).RecordPolicyIssued alias PolicyService.Events.Policy.{ PolicyApplicationSubmitted, QuoteRequestSent, ProviderQuoteReceived, AllQuotesReceived, QuoteAccepted, SolicitationRequestSent, PolicyIssued } @policy_type unquote(policy_type) defstruct [ :id, :submitted_by, :applicant_info, :policy_details, :selected_providers, :accepted_plan_id, :accepted_by, :provider_policy_number, :effective_date, :expiry_date, :state, quotes: %{}, endorsements: %{} ] # ── Execute ──────────────────────────────────────────────────────────── @impl Commanded.Aggregates.Aggregate def execute(%__MODULE__{state: nil}, %SubmitPolicyApplication{} = cmd) do with :ok <- PolicyService.Aggregates.PolicyApplication.validate_policy_id(cmd.id), :ok <- PolicyService.Aggregates.PolicyApplication.validate_applicant(cmd.applicant_info), :ok <- validate_details(cmd.policy_details), :ok <- PolicyService.Aggregates.PolicyApplication.validate_providers( cmd.selected_providers ) do quote_requests = Enum.map(cmd.selected_providers, fn provider -> %QuoteRequestSent{ id: cmd.id, provider_id: provider.provider_id, provider_email: provider.email, applicant_info: cmd.applicant_info, policy_details: cmd.policy_details, requested_at: DateTime.utc_now() } end) [ %PolicyApplicationSubmitted{ id: cmd.id, submitted_by: cmd.submitted_by, applicant_info: cmd.applicant_info, policy_details: cmd.policy_details, selected_providers: cmd.selected_providers, submitted_at: DateTime.utc_now() } | quote_requests ] end end def execute(%__MODULE__{state: state}, %SubmitPolicyApplication{}) do {:error, {:invalid_state, "cannot submit in state: #{state}"}} end def execute(%__MODULE__{state: :awaiting_quotes} = agg, %RecordProviderQuote{} = cmd) do if Map.has_key?(agg.quotes, cmd.provider_id) do {:error, {:duplicate_quote, "quote from #{cmd.provider_id} already received"}} else quote_event = %ProviderQuoteReceived{ id: cmd.id, recorded_by: cmd.recorded_by, provider_id: cmd.provider_id, quote_id: cmd.quote_id, valid_until: cmd.valid_until, plans: cmd.plans, received_at: DateTime.utc_now() } new_quote_count = map_size(agg.quotes) + 1 if new_quote_count == length(agg.selected_providers) do [ quote_event, %AllQuotesReceived{ id: cmd.id, quote_count: new_quote_count } ] else quote_event end end end def execute(%__MODULE__{state: state}, %RecordProviderQuote{}) do {:error, {:invalid_state, "cannot record quote in state: #{state}"}} end def execute(%__MODULE__{state: :awaiting_quotes}, %AcceptQuoteAndSolicit{}) do {:error, :no_quotes_received} end def execute(%__MODULE__{state: state}, %AcceptQuoteAndSolicit{}) when state not in [:quotes_received] do {:error, :invalid_state} end def execute(%__MODULE__{} = agg, %AcceptQuoteAndSolicit{} = cmd) do case Enum.find_value(agg.quotes, fn {provider_id, quote} -> case Enum.find(quote.plans, &(&1.plan_id == cmd.accepted_plan_id)) do nil -> nil plan -> %{quote: quote, provider: provider_id, plan: plan} end end) do nil -> {:error, :plan_not_found} result -> [ %QuoteAccepted{ id: agg.id, quote: result.quote, plan: result.plan, provider: result.provider, accepted_by: cmd.accepted_by }, %SolicitationRequestSent{ id: agg.id, plan: result.plan, provider_id: result.provider } ] end end def execute(%__MODULE__{state: :issued}, %RecordPolicyIssued{}), do: {:error, :already_issued} def execute(%__MODULE__{} = agg, %RecordPolicyIssued{} = cmd) do %PolicyIssued{ id: agg.id, provider_policy_number: cmd.provider_policy_number, effective_date: cmd.effective_date, expiry_date: cmd.expiry_date, issued_at: cmd.issued_at || DateTime.utc_now() } end # ── Apply ────────────────────────────────────────────────────────────── @impl Commanded.Aggregates.Aggregate def apply(%__MODULE__{} = agg, %PolicyApplicationSubmitted{} = e) do %__MODULE__{ agg | id: e.id, submitted_by: e.submitted_by, applicant_info: e.applicant_info, policy_details: e.policy_details, selected_providers: e.selected_providers, quotes: %{}, state: :awaiting_quotes } end def apply(%__MODULE__{} = agg, %QuoteRequestSent{}), do: agg def apply(%__MODULE__{} = agg, %ProviderQuoteReceived{} = e) do quote_data = %{ quote_id: e.quote_id, provider_id: e.provider_id, valid_until: e.valid_until, plans: e.plans || [] } %__MODULE__{agg | quotes: Map.put(agg.quotes, e.provider_id, quote_data)} end def apply(%__MODULE__{} = agg, %AllQuotesReceived{}) do %__MODULE__{agg | state: :quotes_received} end def apply(%__MODULE__{} = agg, %QuoteAccepted{} = e) do %__MODULE__{ agg | accepted_plan_id: e.plan.plan_id, accepted_by: e.accepted_by } end def apply(%__MODULE__{} = agg, %SolicitationRequestSent{} = _e) do %__MODULE__{ agg | state: :awaiting_policy } end def apply(%__MODULE__{} = agg, %PolicyIssued{} = e) do %__MODULE__{ agg | provider_policy_number: e.provider_policy_number, effective_date: e.effective_date, expiry_date: e.expiry_date, state: :issued } end # allow each aggregate to override any callback defoverridable execute: 2, apply: 2 end end def validate_policy_id(%PolicyService.Aggregates.PolicyId{policy_type: _}), do: :ok def validate_policy_id(_), do: {:error, :invalid_policy_id_format} def validate_user(id) when is_binary(id) and byte_size(id) > 0, do: :ok def validate_user(_), do: {:error, :missing_user_id} def validate_applicant(%{"name" => n, "date_of_birth" => _, "document_id" => d}) when is_binary(n) and is_binary(d) and byte_size(n) > 0 and byte_size(d) > 0, do: :ok # Match on string keys for Company def validate_applicant(%{ "company_name" => c, "ruc" => r, "legal_rep_name" => rep, "legal_rep_document" => rd }) when is_binary(c) and is_binary(r) and is_binary(rep) and is_binary(rd) and byte_size(c) > 0 and byte_size(r) > 0, do: :ok def validate_applicant(_), do: {:error, :invalid_applicant_info} def validate_providers(p) when is_list(p) and length(p) > 0, do: :ok def validate_providers(_), do: {:error, :no_providers_selected} def find_quote(agg, quote_id) do case Enum.find(agg.quotes, fn {_, q} -> q.quote_id == quote_id end) do nil -> {:error, :quote_not_found} {_, quote} -> {:ok, quote} end end def find_plan(quote, plan_id) do case Enum.find(quote.plans || [], fn p -> Map.get(p, :plan_id) == plan_id or Map.get(p, "plan_id") == plan_id end) do nil -> {:error, :plan_not_found} plan -> {:ok, plan} end end def find_provider(agg, provider_id) do case Enum.find(agg.selected_providers || [], fn p -> Map.get(p, :provider_id) == provider_id end) do nil -> {:error, :provider_not_found} provider -> {:ok, provider} end end end