diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml new file mode 100644 index 0000000..355e7e1 --- /dev/null +++ b/.gitea/workflows/build-and-publish.yaml @@ -0,0 +1,79 @@ +name: Build and Publish +on: + push: + branches: + - main +env: + CHART_NAME: ${{ github.event.repository.name }} + IMAGE_NAME: ${{ github.event.repository.name }} +jobs: + build-release: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: DeterminateSystems/determinate-nix-action@v3 + + - uses: DeterminateSystems/flake-checker-action@main + with: + flake-lock-path: ./build/flake.lock + + - name: Setup Attic cache + uses: ryanccn/attic-action@v0 + with: + endpoint: ${{ secrets.ATTIC_ENDPOINT }} + cache: ${{ secrets.ATTIC_CACHE }} + token: ${{ secrets.ATTIC_TOKEN }} + + - name: Build Docker Image via Nix Flake + run: | + nix build .#dockerImage --print-build-logs + docker load < result + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ github.server_url }} + username: ${{ secrets.CI_USER }} + password: ${{ secrets.CI_PASSWORD }} + + - name: Tag and Push Docker Image + run: | + VERSION=${{ github.run_number }} + + # Strip https from server URL + REGISTRY=${GITHUB_SERVER_URL#https://} + + TARGET_IMAGE=$REGISTRY/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + + # Auto-detect the built image name (better version) + SOURCE_IMAGE=$(docker load < result | awk '{print $3}') + + docker tag $SOURCE_IMAGE $TARGET_IMAGE:$VERSION + docker tag $SOURCE_IMAGE $TARGET_IMAGE:latest + docker push $TARGET_IMAGE:$VERSION + docker push $TARGET_IMAGE:latest + + - name: Setup Helm + uses: azure/setup-helm@v4 + with: + version: v3.14.0 + + - name: Package Helm Chart + run: | + VERSION=${{ github.run_number }} + helm package ops/chart --version $VERSION --app-version $VERSION + + - name: Push Helm Chart to Gitea Registry + run: | + VERSION=${{ github.run_number }} + CHART_FILE=${{ env.CHART_NAME }}-${VERSION}.tgz + + curl -f --user "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" \ + -X POST \ + --upload-file ./$CHART_FILE \ + "${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts" diff --git a/config/config.exs b/config/config.exs index db10f68..729dbea 100644 --- a/config/config.exs +++ b/config/config.exs @@ -43,6 +43,8 @@ config :commanded, config :commanded_ecto_projections, repo: PolicyService.Repo +config :flop, repo: PolicyService.Repo + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index fc6d18a..f3f46a1 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -73,3 +73,7 @@ config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache + +config :policy_service, + provider_service_url: "http://localhost:4002", + solicitation_service_url: "http://localhost:8081" diff --git a/docker-compose.yml b/docker-compose.yml index 221a29e..de68501 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,54 @@ services: interval: 10s timeout: 5s retries: 5 + postgres: + image: postgres:16-alpine + container_name: policy_postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: policy_service_dev + ports: + - "5432:5432" + volumes: + - customer_pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + minio: + image: minio/minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + + minio_init: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 minioadmin minioadmin; + mc mb --ignore-existing local/policy-bucket; + mc anonymous set download local/policy-bucket/solicitations; + exit 0; + " volumes: + customer_pg_data: rabbitmq_data: + minio_data: diff --git a/flake.lock b/flake.lock index 05379bc..57640e9 100644 --- a/flake.lock +++ b/flake.lock @@ -20,15 +20,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1770827874, - "narHash": "sha256-c46lN+QyhURJIGO2ZjpEHGjhAcQCEn+L0Er219ridNs=", - "owner": "nixos", + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "owner": "NixOS", "repo": "nixpkgs", - "rev": "f4a37e804018a73d81c2bdc2643a64c944b57d92", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", "type": "github" }, "original": { - "owner": "nixos", + "owner": "NixOS", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 1f49a87..160f3db 100644 --- a/flake.nix +++ b/flake.nix @@ -1,25 +1,60 @@ { - description = "test"; + description = "Policy Service - Phoenix/Commanded CQRS/ES service"; inputs = { - nixpkgs.url = "github:nixos/nixpkgs"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils, ... }: - flake-utils.lib.eachDefaultSystem (system: + outputs = { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: let pkgs = nixpkgs.legacyPackages.${system}; - - in { - devShell = - pkgs.mkShell { - buildInputs = with pkgs; - [ - elixir - elixir-ls - git - ]; + beamPackages = pkgs.beamPackages; + pname = "policy_service"; + version = "1.0.0"; + mixFodDeps = beamPackages.fetchMixDeps { + inherit pname version; + src = ./.; + sha256 = "sha256-yqxq5pB7dKEhCZiJWXrKKCra45hxfyyfpP/zyNLEF7A="; }; - }); + package = beamPackages.mixRelease { + inherit pname version mixFodDeps; + src = ./.; + meta = { + mainProgram = "policy_service"; + }; + }; + dockerImage = pkgs.dockerTools.buildImage { + name = "policy_service"; + fromImageName = "hexpm/elixir"; + fromImageTag = "1.17.5-erlang-27.0-debian-bookworm-20240612"; + copyToRoot = pkgs.buildEnv { + name = "policy-service"; + paths = [ package ]; + pathsToLink = [ "/bin" ]; + }; + config = { + Cmd = [ "/bin/bash" ]; + WorkingDir = "/"; + }; + }; + in + { + packages.default = package; + packages.dockerImage = dockerImage; + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + elixir + elixir-ls + git + ]; + }; + } + ); } diff --git a/lib/policy_service/aggregates/car_policy_application.ex b/lib/policy_service/aggregates/car_policy_application.ex index e7024a6..1e324fb 100644 --- a/lib/policy_service/aggregates/car_policy_application.ex +++ b/lib/policy_service/aggregates/car_policy_application.ex @@ -1,309 +1,34 @@ defmodule PolicyService.Aggregates.CarPolicyApplication do - @moduledoc """ - Aggregate for managing car insurance policy applications. + use PolicyService.Aggregates.PolicyApplication, + policy_type: "car", + commands: PolicyService.Commands.CarPolicy - 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 + @valid_use_types ~w(private commercial bus taxi school) + @valid_car_types ~w(sedan suv hatchback coupe convertible pickup van minivan truck) + def validate_details(%{ + "plate" => plate, + "make" => make, + "model" => model, + "year" => year, + "car_value" => car_value, + "use_type" => use_type, + "car_type" => car_type, + "chassis_number" => chassis, + "engine_number" => engine + }) + 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) and is_binary(engine) do 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 + year < 1886 or year > Date.utc_today().year + 1 -> {:error, :invalid_car_year} + use_type not in @valid_use_types -> {:error, :invalid_use_type} + car_type not in @valid_car_types -> {:error, :invalid_car_type} + byte_size(chassis) == 0 -> {:error, :missing_chassis_number} + byte_size(engine) == 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 + def validate_details(_), do: {:error, :invalid_car_details} end diff --git a/lib/policy_service/aggregates/fire_policy_application.ex b/lib/policy_service/aggregates/fire_policy_application.ex new file mode 100644 index 0000000..652be12 --- /dev/null +++ b/lib/policy_service/aggregates/fire_policy_application.ex @@ -0,0 +1,11 @@ +defmodule PolicyService.Aggregates.FirePolicyApplication do + use PolicyService.Aggregates.PolicyApplication, + policy_type: "fire", + commands: PolicyService.Commands.FirePolicy + + def validate_details(%{property_address: addr, property_value: val}) + when is_binary(addr) and byte_size(addr) > 0 and is_number(val) and val > 0, + do: :ok + + def validate_details(_), do: {:error, :invalid_fire_details} +end diff --git a/lib/policy_service/aggregates/policy_application.ex b/lib/policy_service/aggregates/policy_application.ex new file mode 100644 index 0000000..b86a51c --- /dev/null +++ b/lib/policy_service/aggregates/policy_application.ex @@ -0,0 +1,284 @@ +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, + SolicitationSent, + PolicyIssued + } + + @policy_type unquote(policy_type) + + defstruct [ + :id, + :submitted_by, + :applicant_info, + :policy_details, + :selected_providers, + :accepted_quote_id, + :accepted_plan_id, + :accepted_provider_id, + :solicitation_id, + :policy_number, + :effective_date, + :expiry_date, + :state, + quotes: %{}, + pending_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 + with {:ok, quote} <- + PolicyService.Aggregates.PolicyApplication.find_quote(agg, cmd.quote_id), + {:ok, provider} <- + PolicyService.Aggregates.PolicyApplication.find_provider(agg, quote.provider_id), + {:ok, plan} <- + PolicyService.Aggregates.PolicyApplication.find_plan(quote, cmd.plan_id) do + %QuoteAccepted{ + id: agg.id, + quote: quote, + plan: plan, + provider: provider, + accepted_at: DateTime.utc_now() + } + end + end + + def execute(%__MODULE__{state: :issued}, %RecordPolicyIssued{}), + do: {:error, :already_issued} + + def execute(%__MODULE__{} = agg, %RecordPolicyIssued{} = cmd) do + %PolicyIssued{ + id: agg.id, + policy_number: cmd.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_quote_id: e.quote.quote_id, + accepted_plan_id: e.plan.plan_id, + accepted_provider_id: e.provider.provider_id, + state: :solicitation_sent + } + end + + def apply(%__MODULE__{} = agg, %SolicitationSent{} = e) do + %__MODULE__{agg | solicitation_id: e.solicitation_id} + end + + def apply(%__MODULE__{} = agg, %PolicyIssued{} = e) do + %__MODULE__{ + agg + | policy_number: e.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 diff --git a/lib/policy_service/aggregates/policy_id.ex b/lib/policy_service/aggregates/policy_id.ex new file mode 100644 index 0000000..da9ff5f --- /dev/null +++ b/lib/policy_service/aggregates/policy_id.ex @@ -0,0 +1,48 @@ +defmodule PolicyService.Aggregates.PolicyId do + @derive Jason.Encoder + defstruct [:org_id, :policy_type, :application_id] + + def new(org_id, policy_type, application_id) do + %__MODULE__{ + org_id: org_id, + policy_type: policy_type, + application_id: application_id + } + end + + def parse(string) when is_binary(string) do + case String.split(string, ":", parts: 3) do + [org_id, policy_type, application_id] -> + {:ok, + %__MODULE__{ + org_id: org_id, + policy_type: policy_type, + application_id: application_id + }} + + _ -> + {:error, :invalid_policy_id} + end + end + + def parse!(string) do + case parse(string) do + {:ok, id} -> id + {:error, reason} -> raise ArgumentError, "invalid policy id #{inspect(string)}: #{reason}" + end + end + + defimpl String.Chars do + def to_string(%PolicyService.Aggregates.PolicyId{ + org_id: org_id, + policy_type: policy_type, + application_id: application_id + }) do + org_id <> ":" <> policy_type <> ":" <> application_id + end + end + + defimpl Commanded.Serialization.JsonDecoder do + def decode(id), do: id + end +end diff --git a/lib/policy_service/application.ex b/lib/policy_service/application.ex index ca09b57..3159d25 100644 --- a/lib/policy_service/application.ex +++ b/lib/policy_service/application.ex @@ -10,6 +10,10 @@ defmodule PolicyService.Application do children = [ PolicyService.CommandedApp, PolicyService.Handlers.QuoteRequestHandler, + PolicyService.Consumers.QuoteReceivedConsumer, + PolicyService.Projectors.PolicyProjector, + PolicyService.Consumers.PolicyIssuedConsumer, + PolicyService.Handlers.SolicitationRequestHandler, PolicyServiceWeb.Telemetry, PolicyService.Repo, {DNSCluster, query: Application.get_env(:policy_service, :dns_cluster_query) || :ignore}, diff --git a/lib/policy_service/commanded_app.ex b/lib/policy_service/commanded_app.ex index 302005e..c874b22 100644 --- a/lib/policy_service/commanded_app.ex +++ b/lib/policy_service/commanded_app.ex @@ -1,17 +1,28 @@ defmodule PolicyService.Router do use Commanded.Commands.Router - alias PolicyService.Commands.Car - alias PolicyService.Aggregates + # Route Car commands to Car Aggregate dispatch( [ - Car.SubmitCarPolicyApplication, - Car.RecordCarProviderQuote, - Car.AcceptCarQuoteAndSolicit, - Car.RecordCarPolicyIssued + PolicyService.Commands.CarPolicy.SubmitPolicyApplication, + PolicyService.Commands.CarPolicy.RecordProviderQuote, + PolicyService.Commands.CarPolicy.AcceptQuoteAndSolicit, + PolicyService.Commands.CarPolicy.RecordPolicyIssued ], to: PolicyService.Aggregates.CarPolicyApplication, - identity: :application_id + identity: :id + ) + + # Route Fire commands to Fire Aggregate + dispatch( + [ + PolicyService.Commands.FirePolicy.SubmitPolicyApplication, + PolicyService.Commands.FirePolicy.RecordProviderQuote, + PolicyService.Commands.FirePolicy.AcceptQuoteAndSolicit, + PolicyService.Commands.FirePolicy.RecordPolicyIssued + ], + to: PolicyService.Aggregates.FirePolicyApplication, + identity: :id ) end diff --git a/lib/policy_service/commands/car.ex b/lib/policy_service/commands/car.ex deleted file mode 100644 index 789a939..0000000 --- a/lib/policy_service/commands/car.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule PolicyService.Commands.Car.SubmitCarPolicyApplication do - defstruct [ - :application_id, - :org_id, - :submitted_by, - :applicant_info, - :car_details, - :selected_providers - ] -end - -defmodule PolicyService.Commands.Car.RecordCarProviderQuote do - defstruct [ - :application_id, - :org_id, - :recorded_by, - :provider_id, - :quote_id, - :premium, - :coverage_details, - :valid_until - ] -end - -defmodule PolicyService.Commands.Car.AcceptCarQuoteAndSolicit do - defstruct [:application_id, :org_id, :accepted_by, :quote_id] -end - -defmodule PolicyService.Commands.Car.RecordCarPolicyIssued do - defstruct [ - :application_id, - :org_id, - :recorded_by, - :policy_number, - :provider_id, - :effective_date, - :expiry_date - ] -end diff --git a/lib/policy_service/commands/car_policy.ex b/lib/policy_service/commands/car_policy.ex new file mode 100644 index 0000000..f1556df --- /dev/null +++ b/lib/policy_service/commands/car_policy.ex @@ -0,0 +1,8 @@ +defmodule PolicyService.Commands.CarPolicy do + alias PolicyService.Commands.Policy + + defmodule SubmitPolicyApplication, do: use(Policy.SubmitPolicyApplication) + defmodule RecordProviderQuote, do: use(Policy.RecordProviderQuote) + defmodule AcceptQuoteAndSolicit, do: use(Policy.AcceptQuoteAndSolicit) + defmodule RecordPolicyIssued, do: use(Policy.RecordPolicyIssued) +end diff --git a/lib/policy_service/commands/fire_policy.ex b/lib/policy_service/commands/fire_policy.ex new file mode 100644 index 0000000..6bc5b7f --- /dev/null +++ b/lib/policy_service/commands/fire_policy.ex @@ -0,0 +1,8 @@ +defmodule PolicyService.Commands.FirePolicy do + alias PolicyService.Commands.Policy + + defmodule SubmitPolicyApplication, do: use(Policy.SubmitPolicyApplication) + defmodule RecordProviderQuote, do: use(Policy.RecordProviderQuote) + defmodule AcceptQuoteAndSolicit, do: use(Policy.AcceptQuoteAndSolicit) + defmodule RecordPolicyIssued, do: use(Policy.RecordPolicyIssued) +end diff --git a/lib/policy_service/commands/policy.ex b/lib/policy_service/commands/policy.ex new file mode 100644 index 0000000..5edc53b --- /dev/null +++ b/lib/policy_service/commands/policy.ex @@ -0,0 +1,65 @@ +defmodule PolicyService.Commands.Policy do + @moduledoc """ + Base templates for Policy commands. + Use these macros to ensure all policy types share the same structure. + """ + + defmodule SubmitPolicyApplication do + defmacro __using__(_opts) do + quote do + defstruct [ + :id, + :submitted_by, + :applicant_info, + :policy_details, + :selected_providers + ] + end + end + end + + defmodule RecordProviderQuote do + defmacro __using__(_opts) do + quote do + defstruct [ + :id, + :recorded_by, + :provider_id, + :quote_id, + :premium, + :coverage_details, + :valid_until, + :plans + ] + end + end + end + + defmodule AcceptQuoteAndSolicit do + defmacro __using__(_opts) do + quote do + defstruct [ + :id, + :accepted_by, + :quote_id, + :plan_id, + :solicitation_fields + ] + end + end + end + + defmodule RecordPolicyIssued do + defmacro __using__(_opts) do + quote do + defstruct [ + :id, + :policy_number, + :effective_date, + :expiry_date, + :issued_at + ] + end + end + end +end diff --git a/lib/policy_service/common/car_info.ex b/lib/policy_service/common/car_info.ex deleted file mode 100644 index 187e041..0000000 --- a/lib/policy_service/common/car_info.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule PolicyService.Common.CarInfo do - use ExConstructor - - @derive Jason.Encoder - defstruct [ - :plate, - :make, - :model, - :year, - :car_value, - :use_type, - :car_type, - :chassis_number, - :engine_number - ] -end diff --git a/lib/policy_service/common/client_info.ex b/lib/policy_service/common/client_info.ex deleted file mode 100644 index 915f79b..0000000 --- a/lib/policy_service/common/client_info.ex +++ /dev/null @@ -1,6 +0,0 @@ -defmodule PolicyService.Common.ClientInfo do - use ExConstructor - - @derive Jason.Encoder - defstruct [:first_name, :last_name, :birth_date, :gender, :email, :phone, :user_id] -end diff --git a/lib/policy_service/consumers/policy_issued.ex b/lib/policy_service/consumers/policy_issued.ex new file mode 100644 index 0000000..5dc3f45 --- /dev/null +++ b/lib/policy_service/consumers/policy_issued.ex @@ -0,0 +1,73 @@ +defmodule PolicyService.Consumers.PolicyIssuedConsumer do + use GenServer + require Logger + + alias PolicyService.CommandedApp + alias PolicyService.Commands.CarPolicy + alias PolicyService.Aggregates.PolicyId + + @exchange "carrier_inbox.events" + @queue "policy_service.policy_issued" + @routing_key "policy.issued" + + def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + def init(_) do + {:ok, conn} = AMQP.Connection.open(amqp_url()) + {:ok, channel} = AMQP.Channel.open(conn) + + AMQP.Exchange.topic(channel, @exchange, durable: true) + AMQP.Queue.declare(channel, @queue, durable: true) + AMQP.Queue.bind(channel, @queue, @exchange, routing_key: @routing_key) + AMQP.Basic.qos(channel, prefetch_count: 10) + {:ok, _tag} = AMQP.Basic.consume(channel, @queue) + + {:ok, %{channel: channel}} + end + + def handle_info({:basic_consume_ok, _}, state), do: {:noreply, state} + def handle_info({:basic_cancel, _}, state), do: {:stop, :normal, state} + def handle_info({:basic_cancel_ok, _}, state), do: {:noreply, state} + + def handle_info({:basic_deliver, payload, meta}, state) do + case Jason.decode(payload) do + {:ok, event} -> + process(event, meta, state) + + {:error, _} -> + Logger.error("PolicyIssuedConsumer: failed to decode payload") + AMQP.Basic.reject(state.channel, meta.delivery_tag, requeue: false) + end + + {:noreply, state} + end + + defp process(event, meta, state) do + %{policy_type: policy_type} = PolicyId.parse!(event["id"]) + + command = + case policy_type do + "car" -> + %CarPolicy.RecordPolicyIssued{ + id: event["id"], + policy_number: event["policy_number"], + effective_date: event["effective_date"], + expiry_date: event["expiry_date"], + issued_at: DateTime.utc_now() + } + end + + case CommandedApp.dispatch(command) do + :ok -> + AMQP.Basic.ack(state.channel, meta.delivery_tag) + + {:error, reason} -> + Logger.error("PolicyIssuedConsumer: dispatch failed: #{inspect(reason)}") + AMQP.Basic.reject(state.channel, meta.delivery_tag, requeue: true) + end + end + + defp amqp_url do + Application.get_env(:policy_service, :amqp_url, "amqp://guest:guest@localhost:5672") + end +end diff --git a/lib/policy_service/consumers/quote_received.ex b/lib/policy_service/consumers/quote_received.ex new file mode 100644 index 0000000..8b23804 --- /dev/null +++ b/lib/policy_service/consumers/quote_received.ex @@ -0,0 +1,120 @@ +defmodule PolicyService.Consumers.QuoteReceivedConsumer do + use GenServer + + require Logger + + alias PolicyService.CommandedApp + alias PolicyService.Commands.CarPolicy + alias PolicyService.Aggregates.PolicyId + + @exchange "carrier_inbox.events" + @queue "policy_service.quote_received" + @routing_key "quote.received" + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_opts) do + amqp_url = Application.fetch_env!(:policy_service, :amqp_url) + + {:ok, conn} = AMQP.Connection.open(amqp_url) + {:ok, channel} = AMQP.Channel.open(conn) + + AMQP.Exchange.declare(channel, @exchange, :topic, durable: true) + + AMQP.Queue.declare(channel, @queue, durable: true) + AMQP.Queue.bind(channel, @queue, @exchange, routing_key: @routing_key) + + AMQP.Basic.consume(channel, @queue, nil, no_ack: false) + + Logger.info("QuoteReceivedConsumer started, listening on #{@queue}") + + {:ok, %{conn: conn, channel: channel}} + end + + # --------------------------------------------------------------------------- + # AMQP callbacks + # --------------------------------------------------------------------------- + + def handle_info({:basic_consume_ok, _}, state), do: {:noreply, state} + def handle_info({:basic_cancel, _}, state), do: {:stop, :normal, state} + def handle_info({:basic_cancel_ok, _}, state), do: {:noreply, state} + + def handle_info({:basic_deliver, payload, %{delivery_tag: tag}}, state) do + case process(payload) do + :ok -> + AMQP.Basic.ack(state.channel, tag) + + {:error, reason} -> + Logger.error("Failed to process quote.received: #{inspect(reason)}") + AMQP.Basic.nack(state.channel, tag, requeue: false) + end + + {:noreply, state} + end + + # --------------------------------------------------------------------------- + # Processing + # --------------------------------------------------------------------------- + + defp process(payload) do + with {:ok, event} <- Jason.decode(payload), + {:ok, cmd} <- build_command(event), + :ok <- CommandedApp.dispatch(cmd, consistency: :strong) do + :ok + end + end + + defp build_command(event) do + case event["policy_type"] do + "car" -> build_car_command(event) + type -> {:error, {:unsupported_policy_type, type}} + end + end + + defp build_car_command(event) do + %{policy_type: policy_type} = PolicyId.parse!(event["id"]) + + case policy_type do + "car" -> + cmd = %CarPolicy.RecordProviderQuote{ + id: PolicyId.parse!(event["id"]), + recorded_by: event["entered_by"], + provider_id: event["provider_id"], + quote_id: event["quote_id"], + valid_until: parse_date(event["valid_until"]), + plans: parse_plans(event["plans"]) + } + + {:ok, cmd} + end + rescue + e -> {:error, e} + end + + defp parse_plans(nil), do: [] + + defp parse_plans(plans) when is_list(plans) do + Enum.map(plans, fn p -> + %{ + plan_id: p["plan_id"], + name: p["name"], + premium: p["premium"], + coverage_details: p["coverage_details"], + deductible: p["deductible"], + coverage_limit: p["coverage_limit"] + } + end) + end + + defp parse_date(nil), do: nil + defp parse_date(%Date{} = d), do: d + + defp parse_date(s) when is_binary(s) do + case Date.from_iso8601(s) do + {:ok, d} -> d + _ -> nil + end + end +end diff --git a/lib/policy_service/events/car.ex b/lib/policy_service/events/car.ex deleted file mode 100644 index 6dbff34..0000000 --- a/lib/policy_service/events/car.ex +++ /dev/null @@ -1,69 +0,0 @@ -defmodule PolicyService.Events.Car.CarPolicyApplicationSubmitted do - @derive Jason.Encoder - defstruct [ - :application_id, - :org_id, - :submitted_by, - :applicant_info, - :car_details, - :selected_providers, - :submitted_at - ] -end - -defmodule PolicyService.Events.Car.CarQuoteRequestSent do - @derive Jason.Encoder - defstruct [ - :application_id, - :org_id, - :provider_id, - :provider_email, - :applicant_info, - :car_details, - :requested_at - ] -end - -defmodule PolicyService.Events.Car.CarProviderQuoteReceived do - @derive Jason.Encoder - defstruct [ - :application_id, - :org_id, - :recorded_by, - :provider_id, - :quote_id, - :premium, - :coverage_details, - :valid_until, - :received_at - ] -end - -defmodule PolicyService.Events.Car.AllCarQuotesReceived do - @derive Jason.Encoder - defstruct [:application_id, :org_id, :quote_count] -end - -defmodule PolicyService.Events.Car.CarQuoteAccepted do - @derive Jason.Encoder - defstruct [:application_id, :org_id, :accepted_by, :quote_id, :provider_id, :accepted_at] -end - -defmodule PolicyService.Events.Car.CarSolicitationSent do - @derive Jason.Encoder - defstruct [:application_id, :org_id, :provider_id, :quote_id, :sent_at] -end - -defmodule PolicyService.Events.Car.CarPolicyIssued do - @derive Jason.Encoder - defstruct [ - :application_id, - :org_id, - :recorded_by, - :policy_number, - :provider_id, - :effective_date, - :expiry_date, - :issued_at - ] -end diff --git a/lib/policy_service/events/policy.ex b/lib/policy_service/events/policy.ex new file mode 100644 index 0000000..6323f3d --- /dev/null +++ b/lib/policy_service/events/policy.ex @@ -0,0 +1,80 @@ +defmodule PolicyService.Events.Policy do + defmodule PolicyApplicationSubmitted do + @derive Jason.Encoder + defstruct [ + :id, + :submitted_by, + :applicant_info, + :policy_details, + :selected_providers, + :submitted_at + ] + end + + defmodule QuoteRequestSent do + @derive Jason.Encoder + defstruct [ + :id, + :provider_id, + :provider_email, + :applicant_info, + :policy_details, + :requested_at + ] + end + + defmodule ProviderQuoteReceived do + @derive Jason.Encoder + defstruct [ + :id, + :recorded_by, + :provider_id, + :quote_id, + :premium, + :coverage_details, + :valid_until, + :plans, + :received_at + ] + end + + defmodule AllQuotesReceived do + @derive Jason.Encoder + defstruct [:id, :org_id, :quote_count] + end + + defmodule QuoteAccepted do + @derive Jason.Encoder + defstruct [ + :id, + :accepted_by, + :quote, + :plan, + :provider, + :accepted_at + ] + end + + defmodule SolicitationSent do + @derive Jason.Encoder + defstruct [ + :id, + :solicitation_id, + :provider_id, + :template_id, + :s3_key, + :sent_at + ] + end + + defmodule PolicyIssued do + @derive Jason.Encoder + defstruct [ + :id, + :policy_number, + :effective_date, + :expiry_date, + :issued_at + ] + end +end diff --git a/lib/policy_service/handlers/quote_request_handler.ex b/lib/policy_service/handlers/quote_request_handler.ex index 7177834..bf2ceae 100644 --- a/lib/policy_service/handlers/quote_request_handler.ex +++ b/lib/policy_service/handlers/quote_request_handler.ex @@ -3,19 +3,9 @@ defmodule PolicyService.Handlers.QuoteRequestHandler do application: PolicyService.CommandedApp, name: __MODULE__ - alias PolicyService.Events.Car.CarQuoteRequestSent - # alias PolicyService.Events.Life.LifeQuoteRequestSent - # alias PolicyService.Events.Fire.FireQuoteRequestSent + alias PolicyService.Events.Policy.QuoteRequestSent - def handle(%CarQuoteRequestSent{} = e, _metadata) do - PolicyService.MessageBus.publish("carquote.requested", e) + def handle(%QuoteRequestSent{} = e, _metadata) do + PolicyService.MessageBus.publish("quote.requested", e) end - - # def handle(%LifeQuoteRequestSent{} = e, _metadata) do - # PolicyService.MessageBus.publish("quote.requested", e) - # end - - # def handle(%FireQuoteRequestSent{} = e, _metadata) do - # PolicyService.MessageBus.publish("quote.requested", e) - # end end diff --git a/lib/policy_service/handlers/solicitation_request_handler.ex b/lib/policy_service/handlers/solicitation_request_handler.ex new file mode 100644 index 0000000..9c9ae25 --- /dev/null +++ b/lib/policy_service/handlers/solicitation_request_handler.ex @@ -0,0 +1,15 @@ +defmodule PolicyService.Handlers.SolicitationRequestHandler do + use Commanded.Event.Handler, + application: PolicyService.CommandedApp, + name: "SolicitationRequestHandler" + + require Logger + + alias PolicyService.Events.Policy.QuoteAccepted + alias PolicyService.MessageBus + + def handle(%QuoteAccepted{} = event, _metadata) do + MessageBus.publish("quote.accepted", event) + :ok + end +end diff --git a/lib/policy_service/message_bus.ex b/lib/policy_service/message_bus.ex index 612bb32..3d182ec 100644 --- a/lib/policy_service/message_bus.ex +++ b/lib/policy_service/message_bus.ex @@ -7,7 +7,6 @@ defmodule PolicyService.MessageBus do :ok = AMQP.Basic.publish(channel(), "policy_service.events", routing_key, payload, content_type: "application/json", - # survives RabbitMQ restart persistent: true ) end diff --git a/lib/policy_service/policy/filters.ex b/lib/policy_service/policy/filters.ex new file mode 100644 index 0000000..d42f7d1 --- /dev/null +++ b/lib/policy_service/policy/filters.ex @@ -0,0 +1,17 @@ +defmodule PolicyService.Filters.PolicyApplicationFilters do + import Ecto.Query + + def search(query, %Flop.Filter{value: value}, _opts) do + term = "%#{value}%" + + where( + query, + [p], + fragment("?->>'name' ilike ?", p.applicant_info, ^term) or + fragment("?->>'company_name' ilike ?", p.applicant_info, ^term) or + fragment("?->>'document_id' ilike ?", p.applicant_info, ^term) or + fragment("?->>'ruc' ilike ?", p.applicant_info, ^term) or + ilike(p.policy_number, ^term) + ) + end +end diff --git a/lib/policy_service/policy/queries.ex b/lib/policy_service/policy/queries.ex new file mode 100644 index 0000000..18f2f6b --- /dev/null +++ b/lib/policy_service/policy/queries.ex @@ -0,0 +1,21 @@ +defmodule PolicyService.Queries.PolicyQueries do + import Ecto.Query + + alias PolicyService.Repo + alias PolicyService.Projections.PolicyApplication + + def list_by_org(org_id, params \\ %{}) do + base = from(p in PolicyApplication, where: p.org_id == ^org_id) + Flop.validate_and_run(base, params, for: PolicyApplication) + end + + def get_by_application_id(org_id, application_id) do + case Repo.get_by(PolicyApplication, + application_id: application_id, + org_id: org_id + ) do + nil -> {:error, :not_found} + p -> {:ok, p} + end + end +end diff --git a/lib/policy_service/projections/policy.ex b/lib/policy_service/projections/policy.ex new file mode 100644 index 0000000..bd3856f --- /dev/null +++ b/lib/policy_service/projections/policy.ex @@ -0,0 +1,85 @@ +defmodule PolicyService.Projections.PolicyApplication do + use Ecto.Schema + + @derive {Jason.Encoder, + only: [ + :id, + :application_id, + :org_id, + :submitted_by, + :policy_type, + :applicant_info, + :policy_details, + :selected_providers, + :quotes, + :accepted_quote_id, + :accepted_plan_id, + :accepted_provider_id, + :accepted_by, + :accepted_at, + :solicitation_id, + :solicitation_s3_key, + :policy_number, + :premium, + :effective_date, + :expiry_date, + :status, + :submitted_at, + :solicitation_sent_at, + :issued_at, + :inserted_at, + :updated_at + ]} + + @derive { + Flop.Schema, + filterable: [:org_id, :policy_type, :status, :search], + sortable: [:submitted_at, :policy_type, :status], + default_limit: 20, + max_limit: 100, + custom_fields: [ + search: [ + filter: {PolicyService.Projections.PolicyApplicationFilters, :search, []}, + ecto_type: :string, + operators: [:==] + ] + ] + } + + @primary_key {:id, :string, autogenerate: false} + @timestamps_opts [type: :utc_datetime_usec] + + schema "policy_applications" do + field :application_id, :string + field :org_id, :string + field :submitted_by, :string + field :policy_type, :string + + field :applicant_info, :map + field :policy_details, :map + + field :selected_providers, {:array, :string}, default: [] + field :quotes, :map, default: %{} + + field :accepted_quote_id, :string + field :accepted_plan_id, :string + field :accepted_provider_id, :string + field :accepted_by, :string + field :accepted_at, :utc_datetime_usec + + field :solicitation_id, :string + field :solicitation_s3_key, :string + + field :policy_number, :string + field :premium, :decimal + field :effective_date, :date + field :expiry_date, :date + + field :status, :string + field :submitted_at, :utc_datetime_usec + field :solicitation_sent_at, :utc_datetime_usec + field :issued_at, :utc_datetime_usec + + timestamps() + end +end diff --git a/lib/policy_service/projectors/policy_projector.ex b/lib/policy_service/projectors/policy_projector.ex new file mode 100644 index 0000000..b1194ef --- /dev/null +++ b/lib/policy_service/projectors/policy_projector.ex @@ -0,0 +1,144 @@ +defmodule PolicyService.Projectors.PolicyProjector do + use Commanded.Projections.Ecto, + application: PolicyService.CommandedApp, + repo: PolicyService.Repo, + name: "PolicyApplicationProjection", + consistency: :strong + + alias PolicyService.Events.Policy.{ + PolicyApplicationSubmitted, + ProviderQuoteReceived, + AllQuotesReceived, + QuoteAccepted, + SolicitationSent, + PolicyIssued + } + + alias PolicyService.Projections.PolicyApplication + alias PolicyService.Aggregates.PolicyId + import Ecto.Query + + project(%PolicyApplicationSubmitted{} = e, _meta, fn multi -> + %{policy_type: policy_type, application_id: application_id, org_id: org_id} = e.id + + Ecto.Multi.insert(multi, :policy_application, %PolicyApplication{ + id: to_string(PolicyId.new(org_id, policy_type, application_id)), + application_id: application_id, + org_id: org_id, + submitted_by: e.submitted_by, + policy_type: policy_type, + applicant_info: atomize(e.applicant_info), + policy_details: atomize(e.policy_details), + selected_providers: Enum.map(e.selected_providers, & &1["provider_id"]), + quotes: %{}, + status: "quote_requested", + submitted_at: parse_datetime(e.submitted_at) + }) + end) + + project(%ProviderQuoteReceived{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> + {:ok, repo.get!(PolicyApplication, to_string(e.id))} + end) + |> Ecto.Multi.update(:policy_application, fn %{fetch: p} -> + quote_data = %{ + "quote_id" => e.quote_id, + "provider_id" => e.provider_id, + "valid_until" => e.valid_until, + "received_at" => parse_datetime(e.received_at), + "plans" => e.plans || [] + } + + Ecto.Changeset.change(p, quotes: Map.put(p.quotes, e.provider_id, quote_data)) + end) + end) + + project(%AllQuotesReceived{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> + {:ok, repo.get!(PolicyApplication, to_string(e.id))} + end) + |> Ecto.Multi.update(:policy_application, fn %{fetch: p} -> + Ecto.Changeset.change(p, status: "quotes_received") + end) + end) + + project(%QuoteAccepted{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> + {:ok, repo.get!(PolicyApplication, to_string(e.id))} + end) + |> Ecto.Multi.update(:policy_application, fn %{fetch: p} -> + Ecto.Changeset.change(p, + accepted_quote_id: e.quote.quote_id, + accepted_plan_id: e.plan.plan_id, + accepted_provider_id: e.provider.id, + accepted_at: parse_datetime(e.accepted_at), + status: "solicitation_sent" + ) + end) + end) + + project(%SolicitationSent{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> + {:ok, repo.get!(PolicyApplication, to_string(e.id))} + end) + |> Ecto.Multi.update(:policy_application, fn %{fetch: p} -> + Ecto.Changeset.change(p, + solicitation_id: e.solicitation_id, + solicitation_s3_key: e.s3_key, + solicitation_sent_at: parse_datetime(e.sent_at) + ) + end) + end) + + project(%PolicyIssued{} = e, _meta, fn multi -> + multi + |> Ecto.Multi.run(:fetch, fn repo, _ -> + {:ok, repo.get!(PolicyApplication, to_string(e.id))} + end) + |> Ecto.Multi.update(:policy_application, fn %{fetch: p} -> + Ecto.Changeset.change(p, + policy_number: e.policy_number, + effective_date: parse_date(e.effective_date), + expiry_date: parse_date(e.expiry_date), + issued_at: parse_datetime(e.issued_at), + status: "issued" + ) + end) + end) + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp atomize(nil), do: nil + + defp atomize(map) when is_map(map) do + Map.new(map, fn {k, v} -> + {if(is_atom(k), do: Atom.to_string(k), else: k), v} + end) + end + + defp parse_datetime(nil), do: nil + defp parse_datetime(%DateTime{} = dt), do: dt + + defp parse_datetime(str) when is_binary(str) do + case DateTime.from_iso8601(str) do + {:ok, dt, _} -> dt + _ -> nil + end + end + + defp parse_date(nil), do: nil + defp parse_date(%Date{} = d), do: d + + defp parse_date(str) when is_binary(str) do + case Date.from_iso8601(str) do + {:ok, d} -> d + _ -> nil + end + end +end diff --git a/lib/policy_service_web/controllers/car_policy_controller.ex b/lib/policy_service_web/controllers/car_policy_controller.ex deleted file mode 100644 index 6d23e12..0000000 --- a/lib/policy_service_web/controllers/car_policy_controller.ex +++ /dev/null @@ -1,85 +0,0 @@ -# lib/policy_service_web/controllers/car_policy_controller.ex - -defmodule PolicyServiceWeb.CarPolicyController do - use PolicyServiceWeb, :controller - use OpenApiSpex.ControllerSpecs - - alias OpenApiSpex.Schema - alias PolicyServiceWeb.Schemas.CarPolicy.{QuoteRequest, QuoteResponse} - alias PolicyService.Commands.Car.SubmitCarPolicyApplication - - tags(["Car Policy"]) - security([%{"bearerAuth" => []}]) - - operation(:request_quote, - summary: "Solicitar cotización de seguro de auto", - description: "Envía una solicitud de cotización a los proveedores seleccionados", - request_body: {"Quote request body", "application/json", QuoteRequest, required: true}, - responses: [ - created: {"Solicitud creada exitosamente", "application/json", QuoteResponse}, - unprocessable_entity: - {"Error de validación", "application/json", - %Schema{ - type: :object, - properties: %{ - errors: %Schema{type: :object} - } - }} - ] - ) - - def request_quote(conn, params) do - user = %{"id" => "test", "org_id" => "test"} - - cmd = %SubmitCarPolicyApplication{ - application_id: Ecto.UUID.generate(), - org_id: user["org_id"], - submitted_by: user["id"], - applicant_info: %{ - name: params["applicant_info"]["name"], - date_of_birth: Date.from_iso8601!(params["applicant_info"]["date_of_birth"]), - document_id: params["applicant_info"]["document_id"] - }, - car_details: %{ - plate: params["car_details"]["plate"], - make: params["car_details"]["make"], - model: params["car_details"]["model"], - year: params["car_details"]["year"], - car_value: parse_number(params["car_details"]["car_value"]), - use_type: String.to_atom(params["car_details"]["use_type"]), - car_type: String.to_atom(params["car_details"]["car_type"]), - chassis_number: params["car_details"]["chassis_number"], - engine_number: params["car_details"]["engine_number"] - }, - selected_providers: - Enum.map(params["selected_providers"], fn p -> - %{id: p["id"], email: p["email"]} - end) - } - - case PolicyService.CommandedApp.dispatch(cmd) do - :ok -> - conn - |> put_status(:created) - |> json(%{ - application_id: cmd.applicant_info, - status: "awaiting_quotes" - }) - - {:error, reason} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: reason}) - end - end - - defp parse_number(val) when is_float(val), do: val - defp parse_number(val) when is_integer(val), do: val * 1.0 - - defp parse_number(val) when is_binary(val) do - case Float.parse(val) do - {f, _} -> f - :error -> raise "invalid number: #{val}" - end - end -end diff --git a/lib/policy_service_web/controllers/policy_controller.ex b/lib/policy_service_web/controllers/policy_controller.ex new file mode 100644 index 0000000..425a703 --- /dev/null +++ b/lib/policy_service_web/controllers/policy_controller.ex @@ -0,0 +1,378 @@ +defmodule PolicyServiceWeb.PolicyController do + use PolicyServiceWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias PolicyService.CommandedApp + alias PolicyService.Queries.PolicyQueries + alias PolicyService.Aggregates.PolicyId + + alias PolicyService.Commands.CarPolicy + + alias PolicyServiceWeb.Schemas.Policy, as: S + + tags(["Policies"]) + security([%{"bearerAuth" => []}]) + + # --------------------------------------------------------------------------- + # GET /api/policies + # --------------------------------------------------------------------------- + + operation(:index, + summary: "List policies", + parameters: [ + "page[number]": [in: :query, type: :integer, required: false], + "page[size]": [in: :query, type: :integer, required: false], + "filters[0][field]": [in: :query, type: :string, required: false], + "filters[0][op]": [in: :query, type: :string, required: false], + "filters[0][value]": [in: :query, type: :string, required: false], + "filters[1][field]": [in: :query, type: :string, required: false], + "filters[1][op]": [in: :query, type: :string, required: false], + "filters[1][value]": [in: :query, type: :string, required: false], + "order_by[]": [in: :query, type: :string, required: false] + ], + responses: [ + ok: {"Policy list", "application/json", S.PolicyListResponse}, + bad_request: {"Invalid params", "application/json", S.ErrorResponse} + ] + ) + + def index(conn, params) do + org_id = conn.assigns[:org_id] || "test" + + case PolicyQueries.list_by_org(org_id, params) do + {:ok, {policies, meta}} -> + conn + |> put_status(:ok) + |> json(%{ + data: Enum.map(policies, &policy_summary/1), + meta: meta_json(meta) + }) + + {:error, _} -> + conn |> put_status(:bad_request) |> json(%{error: "invalid parameters"}) + end + end + + # --------------------------------------------------------------------------- + # GET /api/policies/:application_id + # --------------------------------------------------------------------------- + + operation(:show, + summary: "Get policy detail", + parameters: [ + application_id: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Policy detail", "application/json", S.PolicyDetailResponse}, + not_found: {"Not found", "application/json", S.ErrorResponse} + ] + ) + + def show(conn, %{"application_id" => application_id}) do + org_id = conn.assigns[:org_id] || "test" + + case PolicyQueries.get_by_application_id(org_id, application_id) do + {:ok, policy} -> + conn |> put_status(:ok) |> json(%{data: policy_detail(policy)}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "policy not found"}) + end + end + + # --------------------------------------------------------------------------- + # POST /api/policies + # --------------------------------------------------------------------------- + + operation(:create, + summary: "Submit a policy quote request", + request_body: {"Quote request", "application/json", S.CreatePolicyRequest, required: true}, + responses: [ + created: {"Submitted", "application/json", S.QuoteResponse}, + unprocessable_entity: {"Validation error", "application/json", S.ErrorResponse} + ] + ) + + def create(conn, params) do + application_id = Ecto.UUID.generate() + org_id = conn.assigns[:org_id] || "test" + submitted_by = conn.assigns[:user_id] || "test" + + with {:ok, policy_type} <- parse_policy_type(params["policy_type"]), + {:ok, applicant_info} <- parse_applicant_info(params["applicant_info"]), + {:ok, policy_details} <- parse_policy_details(policy_type, params["policy_details"]), + {:ok, providers} <- parse_providers(params["selected_providers"]) do + command = + case policy_type do + "car" -> + %CarPolicy.SubmitPolicyApplication{ + id: PolicyId.new(org_id, policy_type, application_id), + submitted_by: submitted_by, + applicant_info: applicant_info, + policy_details: policy_details, + selected_providers: providers + } + end + + case CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + conn + |> put_status(:created) + |> json(%{application_id: application_id, status: "awaiting_quotes"}) + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + else + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + end + + # --------------------------------------------------------------------------- + # POST /api/policies/:application_id/accept + # --------------------------------------------------------------------------- + + operation(:accept, + summary: "Accept a quote plan and trigger solicitation", + parameters: [ + application_id: [in: :path, type: :string, required: true] + ], + request_body: {"Accept quote", "application/json", S.AcceptQuoteRequest, required: true}, + responses: [ + ok: {"Accepted", "application/json", S.PolicyDetailResponse}, + not_found: {"Not found", "application/json", S.ErrorResponse}, + unprocessable_entity: {"Error", "application/json", S.ErrorResponse} + ] + ) + + def accept(conn, %{"application_id" => application_id} = params) do + org_id = conn.assigns[:org_id] || "test" + + with {:ok, policy} <- PolicyQueries.get_by_application_id(org_id, application_id) do + command = + case policy.policy_type do + "car" -> + %CarPolicy.AcceptQuoteAndSolicit{ + id: PolicyId.new(org_id, policy.policy_type, application_id), + quote_id: params["quote_id"], + plan_id: params["plan_id"], + solicitation_fields: params["solicitation_fields"] || %{} + } + end + + case CommandedApp.dispatch(command, consistency: :strong) do + :ok -> + {:ok, updated} = PolicyQueries.get_by_application_id(org_id, application_id) + conn |> put_status(:ok) |> json(%{data: policy_detail(updated)}) + + {:error, :quote_not_found} -> + conn |> put_status(:not_found) |> json(%{error: "quote not found"}) + + {:error, :plan_not_found} -> + conn |> put_status(:not_found) |> json(%{error: "plan not found"}) + + {:error, reason} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: inspect(reason)}) + end + else + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "policy not found"}) + end + end + + # --------------------------------------------------------------------------- + # GET /api/policies/:application_id/solicitation-url + # --------------------------------------------------------------------------- + + operation(:solicitation_url, + summary: "Get fresh presigned download URL for solicitation PDF", + parameters: [ + application_id: [in: :path, type: :string, required: true], + version: [in: :query, type: :integer, required: false] + ], + responses: [ + ok: {"Presigned URL", "application/json", S.SolicitationUrlResponse}, + not_found: {"Not found", "application/json", S.ErrorResponse} + ] + ) + + def solicitation_url(conn, %{"application_id" => application_id} = params) do + org_id = conn.assigns[:org_id] || "test" + version = String.to_integer(params["version"] || "1") + + case PolicyQueries.get_by_application_id(org_id, application_id) do + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "policy not found"}) + + {:ok, %{solicitation_id: nil}} -> + conn |> put_status(:not_found) |> json(%{error: "no solicitation yet"}) + + {:ok, policy} -> + url = + "#{solicitation_service_url()}/api/solicitations/#{policy.solicitation_id}/download-url" + + case Req.get(url, + params: [org_id: org_id, application_id: application_id, version: version] + ) do + {:ok, %{status: 200, body: body}} -> + conn |> put_status(:ok) |> json(body) + + {:ok, %{status: status, body: body}} -> + conn + |> put_status(:bad_gateway) + |> json(%{error: "solicitation service returned #{status}: #{inspect(body)}"}) + + {:error, reason} -> + conn |> put_status(:bad_gateway) |> json(%{error: inspect(reason)}) + end + end + end + + # --------------------------------------------------------------------------- + # Serializers + # --------------------------------------------------------------------------- + + defp policy_summary(p) do + %{ + application_id: p.application_id, + policy_type: p.policy_type, + status: p.status, + applicant_info: p.applicant_info, + policy_details: p.policy_details, + policy_number: p.policy_number, + submitted_at: p.submitted_at + } + end + + defp policy_detail(p) do + %{ + application_id: p.application_id, + org_id: p.org_id, + submitted_by: p.submitted_by, + policy_type: p.policy_type, + status: p.status, + applicant_info: p.applicant_info, + policy_details: p.policy_details, + selected_providers: p.selected_providers, + quotes: p.quotes, + accepted_quote_id: p.accepted_quote_id, + accepted_plan_id: p.accepted_plan_id, + accepted_provider_id: p.accepted_provider_id, + accepted_at: p.accepted_at, + solicitation_id: p.solicitation_id, + solicitation_s3_key: p.solicitation_s3_key, + policy_number: p.policy_number, + premium: p.premium, + effective_date: p.effective_date, + expiry_date: p.expiry_date, + submitted_at: p.submitted_at, + solicitation_sent_at: p.solicitation_sent_at, + issued_at: p.issued_at + } + end + + defp meta_json(meta) do + %{ + total_count: meta.total_count, + total_pages: meta.total_pages, + current_page: meta.current_page, + page_size: meta.page_size, + has_next: meta.has_next_page?, + has_prev: meta.has_previous_page? + } + end + + # --------------------------------------------------------------------------- + # Parse helpers + # --------------------------------------------------------------------------- + + defp parse_policy_type(type) when type in ["car", "life", "fire"], do: {:ok, type} + defp parse_policy_type(_), do: {:error, :invalid_policy_type} + + # individual — has document_id + defp parse_applicant_info(%{"document_id" => doc} = info) + when is_binary(doc) and byte_size(doc) > 0 do + case info["date_of_birth"] do + nil -> + {:error, :missing_date_of_birth} + + dob -> + {:ok, + %{ + "name" => info["name"], + "date_of_birth" => dob, + "document_id" => doc + }} + end + end + + # corporate — has ruc + defp parse_applicant_info(%{"ruc" => ruc} = info) + when is_binary(ruc) and byte_size(ruc) > 0 do + {:ok, + %{ + "company_name" => info["company_name"], + "ruc" => ruc, + "legal_rep_name" => info["legal_rep_name"], + "legal_rep_document" => info["legal_rep_document"] + }} + end + + defp parse_applicant_info(_), do: {:error, :invalid_applicant_info} + + # car details + defp parse_policy_details("car", nil), do: {:error, :missing_policy_details} + + defp parse_policy_details("car", d) do + {:ok, + %{ + "plate" => d["plate"], + "make" => d["make"], + "model" => d["model"], + "year" => d["year"], + "car_value" => d["car_value"], + "use_type" => d["use_type"], + "car_type" => d["car_type"], + "chassis_number" => d["chassis_number"], + "engine_number" => d["engine_number"] + }} + end + + # life details + defp parse_policy_details("life", nil), do: {:error, :missing_policy_details} + + defp parse_policy_details("life", d) do + {:ok, + %{ + "coverage_amount" => d["coverage_amount"], + "beneficiary" => d["beneficiary"] + }} + end + + # fire details + defp parse_policy_details("fire", nil), do: {:error, :missing_policy_details} + + defp parse_policy_details("fire", d) do + {:ok, + %{ + "property_address" => d["property_address"], + "property_value" => d["property_value"] + }} + end + + defp parse_policy_details(_, _), do: {:error, :invalid_policy_details} + + defp parse_providers(nil), do: {:error, :missing_providers} + defp parse_providers([]), do: {:error, :no_providers_selected} + + defp parse_providers(list) when is_list(list) do + {:ok, Enum.map(list, fn p -> %{provider_id: p["provider_id"], email: p["email"]} end)} + end + + defp parse_providers(_), do: {:error, :invalid_providers} + + defp solicitation_service_url do + Application.get_env(:policy_service, :solicitation_service_url, "http://localhost:8081") + end +end diff --git a/lib/policy_service_web/endpoint.ex b/lib/policy_service_web/endpoint.ex index e21f913..139ca0e 100644 --- a/lib/policy_service_web/endpoint.ex +++ b/lib/policy_service_web/endpoint.ex @@ -42,6 +42,7 @@ defmodule PolicyServiceWeb.Endpoint do pass: ["*/*"], json_decoder: Phoenix.json_library() + plug CORSPlug, origin: ["http://localhost:3000"] plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options diff --git a/lib/policy_service_web/router.ex b/lib/policy_service_web/router.ex index d6735e6..d7b2c06 100644 --- a/lib/policy_service_web/router.ex +++ b/lib/policy_service_web/router.ex @@ -1,6 +1,8 @@ defmodule PolicyServiceWeb.Router do use PolicyServiceWeb, :router + alias PolicyServiceWeb.PolicyController + pipeline :api do plug OpenApiSpex.Plug.PutApiSpec, module: PolicyServiceWeb.ApiSpec end @@ -11,13 +13,14 @@ defmodule PolicyServiceWeb.Router do get "/openapi", OpenApiSpex.Plug.RenderSpec, [] scope "/v1" do - scope "/car-policies" do - post "/quotes", PolicyServiceWeb.CarPolicyController, :request_quote - end + get "/policies", PolicyController, :index + get "/policies/:application_id", PolicyController, :show + post "/policies", PolicyController, :create + post "/policies/:application_id/accept", PolicyController, :accept + get "/policies/:application_id/solicitation-url", PolicyController, :solicitation_url end end - # Swagger UI — only in dev if Mix.env() == :dev do scope "/swaggerui" do get "/", OpenApiSpex.Plug.SwaggerUI, path: "/api/openapi" diff --git a/lib/policy_service_web/schemas/car_policy.ex b/lib/policy_service_web/schemas/car_policy.ex deleted file mode 100644 index d817649..0000000 --- a/lib/policy_service_web/schemas/car_policy.ex +++ /dev/null @@ -1,114 +0,0 @@ -defmodule PolicyServiceWeb.Schemas.CarPolicy do - alias OpenApiSpex.Schema - - defmodule ApplicantInfo do - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ApplicantInfo", - type: :object, - required: [:name, :date_of_birth, :document_id], - properties: %{ - name: %Schema{type: :string, example: "Juan Pérez"}, - date_of_birth: %Schema{type: :string, format: :date, example: "1985-06-15"}, - document_id: %Schema{type: :string, example: "V-12345678"} - } - }) - end - - defmodule CarDetails do - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "CarDetails", - type: :object, - required: [ - :plate, - :make, - :model, - :year, - :car_value, - :use_type, - :car_type, - :chassis_number, - :engine_number - ], - properties: %{ - plate: %Schema{type: :string, example: "ABC-1234"}, - make: %Schema{type: :string, example: "Toyota"}, - model: %Schema{type: :string, example: "Corolla"}, - year: %Schema{type: :integer, example: 2022}, - car_value: %Schema{type: :number, example: 18000}, - use_type: %Schema{ - type: :string, - enum: ["private", "commercial", "bus", "taxi", "school"], - example: "private" - }, - car_type: %Schema{ - type: :string, - enum: [ - "sedan", - "suv", - "hatchback", - "coupe", - "convertible", - "pickup", - "van", - "minivan", - "truck" - ], - example: "sedan" - }, - chassis_number: %Schema{type: :string, example: "9BWZZZ377VT004251"}, - engine_number: %Schema{type: :string, example: "1NZ-FE-1234567"} - } - }) - end - - defmodule Provider do - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "Provider", - type: :object, - required: [:id, :email], - properties: %{ - id: %Schema{type: :string, example: "provider-uuid"}, - email: %Schema{type: :string, format: :email, example: "cotizaciones@aseguradora.com"} - } - }) - end - - defmodule QuoteRequest do - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "QuoteRequest", - type: :object, - required: [:applicant_info, :car_details, :selected_providers], - properties: %{ - applicant_info: ApplicantInfo, - car_details: CarDetails, - selected_providers: %Schema{ - type: :array, - items: Provider, - minItems: 1, - example: [%{id: "provider-uuid", email: "cotizaciones@aseguradora.com"}] - } - } - }) - end - - defmodule QuoteResponse do - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "QuoteResponse", - type: :object, - properties: %{ - application_id: %Schema{type: :string, example: "550e8400-e29b-41d4-a716-446655440000"}, - status: %Schema{type: :string, example: "awaiting_quotes"} - } - }) - end -end diff --git a/lib/policy_service_web/schemas/policy.ex b/lib/policy_service_web/schemas/policy.ex new file mode 100644 index 0000000..fa53b7a --- /dev/null +++ b/lib/policy_service_web/schemas/policy.ex @@ -0,0 +1,368 @@ +defmodule PolicyServiceWeb.Schemas.Policy do + alias OpenApiSpex.Schema + + defmodule PaginationMeta do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PaginationMeta", + type: :object, + properties: %{ + total_count: %Schema{type: :integer}, + total_pages: %Schema{type: :integer}, + current_page: %Schema{type: :integer}, + page_size: %Schema{type: :integer}, + has_next: %Schema{type: :boolean}, + has_prev: %Schema{type: :boolean} + } + }) + end + + # --------------------------------------------------------------------------- + # Applicant — discriminated by presence of keys + # --------------------------------------------------------------------------- + + defmodule ApplicantIndividual do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ApplicantIndividual", + type: :object, + required: [:name, :date_of_birth, :document_id], + properties: %{ + name: %Schema{type: :string, example: "Juan Pérez"}, + date_of_birth: %Schema{type: :string, format: :date, example: "1985-06-15"}, + document_id: %Schema{type: :string, example: "8-123-456"} + } + }) + end + + defmodule ApplicantCorporate do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ApplicantCorporate", + type: :object, + required: [:company_name, :ruc, :legal_rep_name, :legal_rep_document], + properties: %{ + company_name: %Schema{type: :string, example: "Empresa ABC S.A."}, + ruc: %Schema{type: :string, example: "123456-1-123456"}, + legal_rep_name: %Schema{type: :string, example: "María García"}, + legal_rep_document: %Schema{type: :string, example: "8-456-789"} + } + }) + end + + defmodule ApplicantInfo do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ApplicantInfo", + oneOf: [ApplicantIndividual, ApplicantCorporate] + }) + end + + # --------------------------------------------------------------------------- + # Policy details — one per policy type + # --------------------------------------------------------------------------- + + defmodule CarPolicyDetails do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CarPolicyDetails", + type: :object, + required: [ + :plate, + :make, + :model, + :year, + :car_value, + :use_type, + :car_type, + :chassis_number, + :engine_number + ], + properties: %{ + plate: %Schema{type: :string, example: "ABC-1234"}, + make: %Schema{type: :string, example: "Toyota"}, + model: %Schema{type: :string, example: "Corolla"}, + year: %Schema{type: :integer, example: 2022}, + car_value: %Schema{type: :number, example: 18000}, + use_type: %Schema{type: :string, enum: ["private", "commercial", "bus", "taxi", "school"]}, + car_type: %Schema{ + type: :string, + enum: [ + "sedan", + "suv", + "hatchback", + "coupe", + "convertible", + "pickup", + "van", + "minivan", + "truck" + ] + }, + chassis_number: %Schema{type: :string, example: "9BWZZZ377VT004251"}, + engine_number: %Schema{type: :string, example: "1NZ-FE-1234567"} + } + }) + end + + defmodule LifePolicyDetails do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "LifePolicyDetails", + type: :object, + required: [:coverage_amount, :beneficiary], + properties: %{ + coverage_amount: %Schema{type: :number, example: 100_000}, + beneficiary: %Schema{type: :string, example: "María Pérez"} + } + }) + end + + defmodule FirePolicyDetails do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "FirePolicyDetails", + type: :object, + required: [:property_address, :property_value], + properties: %{ + property_address: %Schema{type: :string, example: "Calle 50, Panama City"}, + property_value: %Schema{type: :number, example: 250_000} + } + }) + end + + defmodule PolicyDetails do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PolicyDetails", + oneOf: [CarPolicyDetails, LifePolicyDetails, FirePolicyDetails] + }) + end + + # --------------------------------------------------------------------------- + # Shared + # --------------------------------------------------------------------------- + + defmodule SelectedProvider do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "SelectedProvider", + type: :object, + required: [:provider_id, :email], + properties: %{ + provider_id: %Schema{type: :string, format: :uuid}, + email: %Schema{type: :string, format: :email} + } + }) + end + + defmodule Plan do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Plan", + type: :object, + properties: %{ + plan_id: %Schema{type: :string}, + name: %Schema{type: :string}, + premium: %Schema{type: :number}, + coverage_details: %Schema{type: :string}, + deductible: %Schema{type: :number, nullable: true}, + coverage_limit: %Schema{type: :number, nullable: true} + } + }) + end + + defmodule QuoteData do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "QuoteData", + type: :object, + properties: %{ + quote_id: %Schema{type: :string}, + valid_until: %Schema{type: :string, format: :date}, + received_at: %Schema{type: :string, format: :"date-time"}, + plans: %Schema{type: :array, items: Plan} + } + }) + end + + # --------------------------------------------------------------------------- + # Requests + # --------------------------------------------------------------------------- + + defmodule CreatePolicyRequest do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CreatePolicyRequest", + type: :object, + required: [:policy_type, :applicant_info, :policy_details, :selected_providers], + properties: %{ + policy_type: %Schema{ + type: :string, + enum: ["car", "life", "fire"], + description: "Determines the shape of policy_details" + }, + applicant_info: ApplicantInfo, + policy_details: PolicyDetails, + selected_providers: %Schema{type: :array, items: SelectedProvider, minItems: 1} + } + }) + end + + defmodule AcceptQuoteRequest do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AcceptQuoteRequest", + type: :object, + required: [:quote_id, :plan_id], + properties: %{ + quote_id: %Schema{type: :string}, + plan_id: %Schema{type: :string}, + solicitation_fields: %Schema{ + type: :object, + additionalProperties: %Schema{type: :string}, + description: "Optional flat map of AcroForm field names to values", + nullable: true + } + } + }) + end + + # --------------------------------------------------------------------------- + # Responses + # --------------------------------------------------------------------------- + + defmodule QuoteResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "QuoteResponse", + type: :object, + properties: %{ + application_id: %Schema{type: :string}, + status: %Schema{type: :string} + } + }) + end + + defmodule SolicitationUrlResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "SolicitationUrlResponse", + type: :object, + properties: %{ + download_url: %Schema{type: :string}, + s3_key: %Schema{type: :string}, + version: %Schema{type: :integer} + } + }) + end + + defmodule PolicySummary do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PolicySummary", + type: :object, + properties: %{ + application_id: %Schema{type: :string}, + policy_type: %Schema{type: :string, enum: ["car", "life", "fire"]}, + status: %Schema{ + type: :string, + enum: ["quote_requested", "quotes_received", "solicitation_sent", "issued"] + }, + applicant_info: ApplicantInfo, + policy_details: PolicyDetails, + policy_number: %Schema{type: :string, nullable: true}, + submitted_at: %Schema{type: :string, format: :"date-time"} + } + }) + end + + defmodule PolicyDetail do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PolicyDetail", + type: :object, + properties: %{ + application_id: %Schema{type: :string}, + org_id: %Schema{type: :string}, + submitted_by: %Schema{type: :string}, + policy_type: %Schema{type: :string, enum: ["car", "life", "fire"]}, + status: %Schema{ + type: :string, + enum: ["quote_requested", "quotes_received", "solicitation_sent", "issued"] + }, + applicant_info: ApplicantInfo, + policy_details: PolicyDetails, + selected_providers: %Schema{type: :array, items: %Schema{type: :string}}, + quotes: %Schema{type: :object, additionalProperties: QuoteData}, + accepted_quote_id: %Schema{type: :string, nullable: true}, + accepted_plan_id: %Schema{type: :string, nullable: true}, + accepted_provider_id: %Schema{type: :string, nullable: true}, + accepted_at: %Schema{type: :string, format: :"date-time", nullable: true}, + solicitation_id: %Schema{type: :string, nullable: true}, + solicitation_s3_key: %Schema{type: :string, nullable: true}, + policy_number: %Schema{type: :string, nullable: true}, + premium: %Schema{type: :number, nullable: true}, + effective_date: %Schema{type: :string, format: :date, nullable: true}, + expiry_date: %Schema{type: :string, format: :date, nullable: true}, + submitted_at: %Schema{type: :string, format: :"date-time"}, + solicitation_sent_at: %Schema{type: :string, format: :"date-time", nullable: true}, + issued_at: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule PolicyListResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PolicyListResponse", + type: :object, + properties: %{ + data: %Schema{type: :array, items: PolicySummary}, + meta: PaginationMeta + } + }) + end + + defmodule PolicyDetailResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PolicyDetailResponse", + type: :object, + properties: %{ + data: PolicyDetail + } + }) + end + + defmodule ErrorResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ErrorResponse", + type: :object, + properties: %{ + error: %Schema{type: :string} + } + }) + end +end diff --git a/mix.exs b/mix.exs index 623b31c..e335d76 100644 --- a/mix.exs +++ b/mix.exs @@ -53,7 +53,10 @@ defmodule PolicyService.MixProject do {:commanded, "~> 1.4"}, {:amqp, "~> 4.1"}, {:exconstructor, "~> 1.3.1"}, - {:open_api_spex, "~> 3.20"} + {:open_api_spex, "~> 3.20"}, + {:cors_plug, "~> 3.0"}, + {:flop, "~> 0.26"}, + {:req, "~> 0.5"} ] end diff --git a/mix.lock b/mix.lock index 233538d..ec5a187 100644 --- a/mix.lock +++ b/mix.lock @@ -7,6 +7,7 @@ "commanded": {:hex, :commanded, "1.4.9", "289bc371943cf082f1161b1560563f5451ca176c967670cccd63fc3988fcd225", [:mix], [{:backoff, "~> 1.1", [hex: :backoff, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "a4f49c23041a23687aa10e99f3db7ee3b8ae470bb615b73b9f887b86437263e7"}, "commanded_ecto_projections": {:hex, :commanded_ecto_projections, "1.4.0", "a1b220577577d5e0aee4c92b2d9bc6de221f9c1ac2ab36932cba15881761332f", [:mix], [{:commanded, "~> 1.4", [hex: :commanded, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8919a6173cd8f30fe2f948c2967f9289c7f5fe4eeca7abc67966bfca31f4aa9f"}, "commanded_eventstore_adapter": {:hex, :commanded_eventstore_adapter, "1.4.2", "4f2d9d9bd8ef7807a5a4c278b4344adddbbbb4d9c86c693872bc85b944be1fe8", [:mix], [{:commanded, "~> 1.4", [hex: :commanded, repo: "hexpm", optional: false]}, {:eventstore, "~> 1.4", [hex: :eventstore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "26eaa68515e3e73834d769b73bddfea76c3fdcaff085d735c22b82a66ba19b10"}, + "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, "crc32cer": {:hex, :crc32cer, "0.1.8", "c6c2275c5fb60a95f4935d414f30b50ee9cfed494081c9b36ebb02edfc2f48db", [:rebar3], [], "hexpm", "251499085482920deb6c9b7aadabf9fb4c432f96add97ab42aee4501e5b6f591"}, "credentials_obfuscation": {:hex, :credentials_obfuscation, "3.5.0", "61e282adfb4439486b3994faaec69543c7ee6cc7e70c6340e8853fd9deaf8219", [:rebar3], [], "hexpm", "843adbe3246861ce0f1a0fa3222f384834eb31defd8d6b9cba7afd2977c957bc"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, @@ -16,12 +17,17 @@ "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, "eventstore": {:hex, :eventstore, "1.4.8", "26778c991cfb078f3906a4267060efc7bb5e5943f69ddb8ae6fb60f07042a66e", [:mix], [{:fsm, "~> 0.3", [hex: :fsm, repo: "hexpm", optional: false]}, {:gen_stage, "~> 1.2", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "30c914602fdea8db5992a90ecb1f84068531e764cf0c066be71ff0eec4e3bcb9"}, "exconstructor": {:hex, :exconstructor, "1.3.1", "2c8b19b4702b195782e0cba46c7764df815c0beb8633383a9afb01199c47c3bd", [:mix], [], "hexpm", "5b7b2b043023e4643a44a66750d47587f01f3459d2fb4e7de05406b3a093fa6e"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "flop": {:hex, :flop, "0.26.3", "9bc700b34f96a57e56aaa89b850926356311372556eacd5a1abe0fdd0ea40bf2", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "cd77588229778ac55560c90dfbe15ab6486773f067d6e52db9fa703b8c9a9d2d"}, "fsm": {:hex, :fsm, "0.3.1", "087aa9b02779a84320dc7a2d8464452b5308e29877921b2bde81cdba32a12390", [:mix], [], "hexpm", "fbf0d53f89e9082b326b0b5828b94b4c549ff9d1452bbfd00b4d1ac082208e96"}, "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "kafka_protocol": {:hex, :kafka_protocol, "4.1.5", "d15e64994a8ca99716ab47db4132614359ac1bfa56d6c5b4341fdc1aa4041518", [:rebar3], [{:crc32cer, "0.1.8", [hex: :crc32cer, repo: "hexpm", optional: false]}], "hexpm", "c956c9357fef493b7072a35d0c3e2be02aa5186c804a412d29e62423bb15e5d9"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "open_api_spex": {:hex, :open_api_spex, "3.22.2", "0b3c4f572ee69cb6c936abf426b9d84d8eebd34960871fd77aead746f0d69cb0", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "0a4fc08472d75e9cfe96e0748c6b1565b3b4398f97bf43fcce41b41b6fd3fb33"}, "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, @@ -33,6 +39,7 @@ "rabbit_common": {:hex, :rabbit_common, "4.2.1", "1d64e391e12116b76b1425eb96b7552de51f0301093eba669b5334f4759cc1e8", [:make, :rebar3], [{:credentials_obfuscation, "3.5.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:ranch, "2.2.0", [hex: :ranch, repo: "hexpm", optional: false]}, {:recon, "2.5.6", [hex: :recon, repo: "hexpm", optional: false]}, {:thoas, "1.2.1", [hex: :thoas, repo: "hexpm", optional: false]}], "hexpm", "ff509b07e639b1784898c28031e5204fea14260172e4fc339f94405586037e40"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "snappyer": {:hex, :snappyer, "1.2.9", "9cc58470798648ce34c662ca0aa6daae31367667714c9a543384430a3586e5d3", [:rebar3], [], "hexpm", "18d00ca218ae613416e6eecafe1078db86342a66f86277bd45c95f05bf1c8b29"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, diff --git a/ops/chart/Chart.yaml b/ops/chart/Chart.yaml new file mode 100644 index 0000000..05e5b38 --- /dev/null +++ b/ops/chart/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +name: policy-service +description: Policy service for insurance quotes and policies +type: application +version: 0.1.0 +appVersion: "1.0.0" +keywords: + - elixir + - commanded + - cqrs +dependencies: + - name: common + version: "4.6.2" + repository: https://bjw-s-labs.github.io/helm-charts/ diff --git a/ops/chart/templates/common.tpl b/ops/chart/templates/common.tpl new file mode 100644 index 0000000..b70187e --- /dev/null +++ b/ops/chart/templates/common.tpl @@ -0,0 +1,4 @@ +{{/* +Render all resources provided by the common library +*/}} +{{- include "bjw-s.common.loader.all" . -}} diff --git a/ops/chart/templates/postgresql.yaml b/ops/chart/templates/postgresql.yaml new file mode 100644 index 0000000..2eb8e67 --- /dev/null +++ b/ops/chart/templates/postgresql.yaml @@ -0,0 +1,36 @@ +{{- /* +Policy Service PostgreSQL Cluster using CNPG +*/ -}} +{{- if .Values.postgresql.enabled -}} +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: {{ include "bjw-s.common.lib.chart.names.fullname" . }}-pg + namespace: {{ .Release.Namespace }} + labels: + {{- include "bjw-s.common.lib.chart.names.labels" . | nindent 4 }} +spec: + description: "PostgreSQL cluster for {{ .Release.Name }}" + imageName: {{ .Values.postgresql.image | default "ghcr.io/cloudnative-pg/container-image:1.23.1" }} + instances: {{ .Values.postgresql.instances | default 1 }} + + bootstrap: + initdb: + database: {{ .Values.postgresql.database | default "policy_service" }} + owner: {{ .Values.postgresql.owner | default "policy_service" }} + + storage: + storageClass: {{ .Values.postgresql.storageClass | default "local-path" }} + size: {{ .Values.postgresql.storageSize | default "1Gi" }} + + resources: + requests: + cpu: {{ .Values.postgresql.resources.requests.cpu | default "100m" }} + memory: {{ .Values.postgresql.resources.requests.memory | default "128Mi" }} + limits: + cpu: {{ .Values.postgresql.resources.limits.cpu | default "500m" }} + memory: {{ .Values.postgresql.resources.limits.memory | default "512Mi" }} + + monitoring: + enablePodMonitoring: true +{{- end -}} diff --git a/ops/chart/values.yaml b/ops/chart/values.yaml new file mode 100644 index 0000000..47ef990 --- /dev/null +++ b/ops/chart/values.yaml @@ -0,0 +1,98 @@ +controllers: + main: + enabled: true + type: deployment + replicas: 1 + initContainers: + migrate: + image: + repository: gitea.corredorconect.com/software-engineering/policy-service + tag: latest + pullPolicy: IfNotPresent + command: + - sh + - -c + - > + mix ecto.create && + mix ecto.migrate && + mix event_store.create && + mix event_store.init + env: + MIX_ENV: prod + containers: + main: + image: + repository: gitea.corredorconect.com/software-engineering/policy-service + tag: latest + pullPolicy: IfNotPresent + env: + MIX_ENV: prod + probes: + liveness: + enabled: true + custom: true + spec: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + readiness: + enabled: true + custom: true + spec: + httpGet: + path: /health/ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +service: + main: + enabled: true + controller: main + primary: true + type: ClusterIP + ports: + http: + enabled: true + primary: true + port: 8080 + protocol: HTTP + +ingress: + main: + enabled: false + className: nginx + hosts: + - host: policy-service.local + paths: + - path: / + pathType: Prefix + service: + identifier: main + port: http + +postgresql: + enabled: true + image: ghcr.io/cloudnative-pg/container-image:1.23.1 + instances: 1 + database: policy_service + owner: policy_service + storageClass: local-path + storageSize: 1Gi + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + diff --git a/priv/repo/migrations/20260310213733_create_car_policies.exs b/priv/repo/migrations/20260310213733_create_car_policies.exs new file mode 100644 index 0000000..08e0a5a --- /dev/null +++ b/priv/repo/migrations/20260310213733_create_car_policies.exs @@ -0,0 +1,55 @@ +defmodule PolicyService.Repo.Migrations.CreatePolicyApplications do + use Ecto.Migration + + def change do + create table(:policy_applications, primary_key: false) do + add :id, :string, primary_key: true + add :application_id, :string, null: false + add :org_id, :string, null: false + add :submitted_by, :string, null: false + add :policy_type, :string, null: false # "car" | "life" | "fire" + + # Applicant — full map, shape varies by individual vs corporate + add :applicant_info, :map, default: %{} + + # Policy-type-specific details — shape varies by policy_type + add :policy_details, :map, default: %{} + + # Providers + quotes + add :selected_providers, {:array, :string}, default: [] + add :quotes, :map, default: %{} + + # Accepted plan + add :accepted_quote_id, :string + add :accepted_plan_id, :string + add :accepted_provider_id, :string + add :accepted_by, :string + add :accepted_at, :utc_datetime_usec + + # Solicitation + add :solicitation_id, :string + add :solicitation_s3_key, :string + + # Issued policy + add :policy_number, :string + add :premium, :decimal + add :effective_date, :date + add :expiry_date, :date + + # Status + timestamps + add :status, :string, null: false + add :submitted_at, :utc_datetime_usec + add :solicitation_sent_at, :utc_datetime_usec + add :issued_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:policy_applications, [:org_id]) + create index(:policy_applications, [:policy_type]) + create index(:policy_applications, [:status]) + create index(:policy_applications, [:org_id, :status]) + create index(:policy_applications, [:org_id, :policy_type]) + create index(:policy_applications, [:org_id, :policy_type, :status]) + end +end diff --git a/priv/repo/migrations/20260310215156_create_projection_versions.exs b/priv/repo/migrations/20260310215156_create_projection_versions.exs new file mode 100644 index 0000000..ae6a3de --- /dev/null +++ b/priv/repo/migrations/20260310215156_create_projection_versions.exs @@ -0,0 +1,12 @@ +defmodule PolicyService.Repo.Migrations.CreateProjectionVersions do + use Ecto.Migration + + def change do + create table(:projection_versions, primary_key: false) do + add(:projection_name, :text, primary_key: true) + add(:last_seen_event_number, :bigint) + + timestamps(type: :naive_datetime_usec) + end + end +end diff --git a/rabbitmq/definitions.json b/rabbitmq/definitions.json index b966943..dae0458 100644 --- a/rabbitmq/definitions.json +++ b/rabbitmq/definitions.json @@ -46,6 +46,13 @@ "auto_delete": false, "arguments": {} }, + { + "name": "carsolicitation.requested", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, { "name": "policy_service.quote_received", "vhost": "/",