defmodule PolicyService.Aggregates.CarPolicyApplication do @moduledoc """ Aggregate for managing car insurance policy applications. Lifecycle: nil → :awaiting_quotes → :solicitation_sent → :issued """ defstruct [ :application_id, :org_id, :submitted_by, :applicant_info, :car_details, :selected_providers, :quotes, :accepted_quote_id, :accepted_provider_id, :policy_number, :state ] alias PolicyService.Commands.Car.{ SubmitCarPolicyApplication, RecordCarProviderQuote, AcceptCarQuoteAndSolicit, RecordCarPolicyIssued } alias PolicyService.Events.Car.{ CarPolicyApplicationSubmitted, CarProviderQuoteReceived, AllCarQuotesReceived, CarQuoteAccepted, CarSolicitationSent, CarPolicyIssued, CarQuoteRequestSent } # --------------------------------------------------------------------------- # Submit — establishes org ownership # --------------------------------------------------------------------------- def execute(%__MODULE__{state: nil}, %SubmitCarPolicyApplication{} = cmd) do with :ok <- validate_org(cmd.org_id), :ok <- validate_user(cmd.submitted_by), :ok <- validate_applicant(cmd.applicant_info), :ok <- validate_car_details(cmd.car_details), :ok <- validate_providers(cmd.selected_providers) do quote_requests = Enum.map(cmd.selected_providers, fn provider -> %CarQuoteRequestSent{ application_id: cmd.application_id, org_id: cmd.org_id, provider_id: provider.id, provider_email: provider.email, applicant_info: cmd.applicant_info, car_details: cmd.car_details, requested_at: DateTime.utc_now() } end) [ %CarPolicyApplicationSubmitted{ application_id: cmd.application_id, org_id: cmd.org_id, submitted_by: cmd.submitted_by, applicant_info: cmd.applicant_info, car_details: cmd.car_details, selected_providers: cmd.selected_providers, submitted_at: DateTime.utc_now() } | quote_requests ] end end def execute(%__MODULE__{state: state}, %SubmitCarPolicyApplication{}) do {:error, {:invalid_state, "cannot submit in state: #{state}"}} end # --------------------------------------------------------------------------- # Record provider quote — external webhook, verify org # --------------------------------------------------------------------------- def execute(%__MODULE__{state: :awaiting_quotes} = agg, %RecordCarProviderQuote{} = cmd) do with :ok <- verify_org(agg, cmd) do if Map.has_key?(agg.quotes, cmd.provider_id) do {:error, {:duplicate_quote, "quote from provider #{cmd.provider_id} already received"}} else quote_event = %CarProviderQuoteReceived{ application_id: cmd.application_id, org_id: agg.org_id, recorded_by: cmd.recorded_by, provider_id: cmd.provider_id, quote_id: cmd.quote_id, premium: cmd.premium, coverage_details: cmd.coverage_details, valid_until: cmd.valid_until, received_at: DateTime.utc_now() } new_quote_count = map_size(agg.quotes) + 1 if new_quote_count == length(agg.selected_providers) do [ quote_event, %AllCarQuotesReceived{ application_id: cmd.application_id, org_id: agg.org_id, quote_count: new_quote_count } ] else quote_event end end end end def execute(%__MODULE__{state: state}, %RecordCarProviderQuote{}) do {:error, {:invalid_state, "cannot record quote in state: #{state}"}} end # --------------------------------------------------------------------------- # Accept quote and solicit — internal user action, verify org # --------------------------------------------------------------------------- def execute(%__MODULE__{state: :awaiting_quotes} = agg, %AcceptCarQuoteAndSolicit{} = cmd) do with :ok <- verify_org(agg, cmd) do case find_quote(agg.quotes, cmd.quote_id) do nil -> {:error, {:quote_not_found, "quote #{cmd.quote_id} not found"}} {provider_id, _quote} -> [ %CarQuoteAccepted{ application_id: cmd.application_id, org_id: agg.org_id, accepted_by: cmd.accepted_by, quote_id: cmd.quote_id, provider_id: provider_id, accepted_at: DateTime.utc_now() }, %CarSolicitationSent{ application_id: cmd.application_id, org_id: agg.org_id, provider_id: provider_id, quote_id: cmd.quote_id, sent_at: DateTime.utc_now() } ] end end end def execute(%__MODULE__{state: state}, %AcceptCarQuoteAndSolicit{}) do {:error, {:invalid_state, "cannot accept quote in state: #{state}"}} end # --------------------------------------------------------------------------- # Record policy issued — external or internal, verify org # --------------------------------------------------------------------------- def execute(%__MODULE__{state: :solicitation_sent} = agg, %RecordCarPolicyIssued{} = cmd) do with :ok <- verify_org(agg, cmd) do if cmd.provider_id != agg.accepted_provider_id do {:error, {:provider_mismatch, "policy issued by unexpected provider"}} else %CarPolicyIssued{ application_id: cmd.application_id, org_id: agg.org_id, recorded_by: cmd.recorded_by, policy_number: cmd.policy_number, provider_id: cmd.provider_id, effective_date: cmd.effective_date, expiry_date: cmd.expiry_date, issued_at: DateTime.utc_now() } end end end def execute(%__MODULE__{state: state}, %RecordCarPolicyIssued{}) do {:error, {:invalid_state, "cannot record policy in state: #{state}"}} end # --------------------------------------------------------------------------- # Apply events # --------------------------------------------------------------------------- def apply(%__MODULE__{} = agg, %CarPolicyApplicationSubmitted{} = e) do %__MODULE__{ agg | application_id: e.application_id, org_id: e.org_id, submitted_by: e.submitted_by, applicant_info: e.applicant_info, car_details: e.car_details, selected_providers: e.selected_providers, quotes: %{}, state: :awaiting_quotes } end def apply(%__MODULE__{} = agg, %CarQuoteRequestSent{}), do: agg def apply(%__MODULE__{} = agg, %CarProviderQuoteReceived{} = e) do quote_data = %{ quote_id: e.quote_id, premium: e.premium, coverage_details: e.coverage_details, valid_until: e.valid_until } %__MODULE__{agg | quotes: Map.put(agg.quotes, e.provider_id, quote_data)} end def apply(%__MODULE__{} = agg, %AllCarQuotesReceived{}), do: agg def apply(%__MODULE__{} = agg, %CarQuoteAccepted{} = e) do %__MODULE__{agg | accepted_quote_id: e.quote_id, accepted_provider_id: e.provider_id} end def apply(%__MODULE__{} = agg, %CarSolicitationSent{}) do %__MODULE__{agg | state: :solicitation_sent} end def apply(%__MODULE__{} = agg, %CarPolicyIssued{} = e) do %__MODULE__{agg | policy_number: e.policy_number, state: :issued} end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp verify_org(%__MODULE__{org_id: org_id}, %{org_id: cmd_org_id}) do if org_id == cmd_org_id, do: :ok, else: {:error, :org_mismatch} end defp validate_org(org_id) when is_binary(org_id) and byte_size(org_id) > 0, do: :ok defp validate_org(_), do: {:error, :missing_org_id} defp validate_user(user_id) when is_binary(user_id) and byte_size(user_id) > 0, do: :ok defp validate_user(_), do: {:error, :missing_user_id} defp validate_applicant(%{name: name, date_of_birth: dob, document_id: doc}) when is_binary(name) and is_binary(doc), do: :ok defp validate_applicant(_), do: {:error, :invalid_applicant_info} @valid_use_types ~w(private commercial bus taxi school)a @valid_car_types ~w(sedan suv hatchback coupe convertible pickup van minivan truck)a defp validate_car_details(%{ plate: plate, make: make, model: model, year: year, car_value: car_value, use_type: use_type, car_type: car_type, chassis_number: chassis_number, engine_number: engine_number }) when is_binary(plate) and is_binary(make) and is_binary(model) and is_integer(year) and is_number(car_value) and car_value > 0 and is_binary(chassis_number) and is_binary(engine_number) do current_year = Date.utc_today().year cond do year < 1886 -> {:error, :invalid_car_year} year > current_year + 1 -> {:error, :invalid_car_year} use_type not in @valid_use_types -> {:error, {:invalid_use_type, "must be one of: #{inspect(@valid_use_types)}"}} car_type not in @valid_car_types -> {:error, {:invalid_car_type, "must be one of: #{inspect(@valid_car_types)}"}} byte_size(chassis_number) == 0 -> {:error, :missing_chassis_number} byte_size(engine_number) == 0 -> {:error, :missing_engine_number} true -> :ok end end defp validate_car_details(_), do: {:error, :invalid_car_details} defp validate_providers(providers) when is_list(providers) and length(providers) > 0, do: :ok defp validate_providers(_), do: {:error, :no_providers_selected} defp find_quote(quotes, quote_id) do Enum.find(quotes, fn {_provider_id, q} -> q.quote_id == quote_id end) end end