Skip to main content

lib/crosswake/commerce/reconciliation.ex

defmodule Crosswake.Commerce.Reconciliation do
  @moduledoc """
  Typed backend-owned reconciliation vocabulary for commerce.
  
  This module encodes the canonical flow:
  1. Device/native evidence enters Phoenix.
  2. Phoenix records a reconciliation attempt.
  3. Host-owned workers verify it.
  4. Backend publishes a refreshed authoritative entitlement snapshot.
  
  These states represent reconciliation or freshness outcomes, not automatic access grants
  or silent denials. Device success is evidence, not entitlement.
  """

  alias Crosswake.Commerce.Contracts

  @type outcome ::
          :pending_purchase
          | :pending_restore
          | :awaiting_verification
          | :projection_refreshed
          | :conflict
          | :verification_failed
          | :stale_authority

  @outcome_vocabulary [
    :pending_purchase,
    :pending_restore,
    :awaiting_verification,
    :projection_refreshed,
    :conflict,
    :verification_failed,
    :stale_authority
  ]

  @unresolved_outcomes [:pending_purchase, :pending_restore, :awaiting_verification]
  @workflow_reporting_outcomes [:projection_refreshed, :verification_failed, :conflict, :stale_authority]

  @spec outcome_vocabulary() :: [outcome()]
  def outcome_vocabulary do
    @outcome_vocabulary
  end

  @spec reconciliation_outcome?(term()) :: boolean()
  def reconciliation_outcome?(state), do: state in @outcome_vocabulary

  @spec unresolved_outcome?(term()) :: boolean()
  def unresolved_outcome?(state), do: state in @unresolved_outcomes

  @spec workflow_reporting_outcome?(term()) :: boolean()
  def workflow_reporting_outcome?(state), do: state in @workflow_reporting_outcomes

  @spec outcome_implies_authority_grant?(term()) :: boolean()
  def outcome_implies_authority_grant?(state) when state in @outcome_vocabulary, do: false
  def outcome_implies_authority_grant?(_state), do: false

  @spec outcome_implies_access_granted?(term()) :: boolean()
  def outcome_implies_access_granted?(state) when state in @outcome_vocabulary, do: false
  def outcome_implies_access_granted?(_state), do: false

  defmodule Attempt do
    @moduledoc """
    A typed record of a backend-owned reconciliation attempt.
    """
    @enforce_keys [:provider, :provider_reference, :event_kind, :status]
    defstruct [:provider, :provider_reference, :event_kind, :status, :evidence_ref, :idempotency_ref]

    @type t :: %__MODULE__{
            provider: String.t(),
            provider_reference: String.t(),
            event_kind: String.t(),
            status: Crosswake.Commerce.Reconciliation.outcome(),
            evidence_ref: String.t() | nil,
            idempotency_ref: String.t() | nil
          }
  end

  defmodule IdempotencyKey do
    @moduledoc """
    Provider-aware and backend-owned idempotency fields.
    Transient device correlation ids are not part of this key.
    """
    @enforce_keys [:provider, :provider_reference, :event_kind]
    defstruct [:provider, :provider_reference, :event_kind]

    @type t :: %__MODULE__{
            provider: String.t(),
            provider_reference: String.t(),
            event_kind: String.t()
          }
  end

  defmodule EvidenceResult do
    @moduledoc """
    Represents an evidence-only result state for device purchase, restore, or native callback success.
    These states do not directly mutate entitlement authority.
    """
    @enforce_keys [:source, :status, :attempt, :idempotency_key, :replay?]
    defstruct [:source, :status, :attempt, :idempotency_key, :replay?]

    @type t :: %__MODULE__{
            source: Contracts.ReconciliationEvidence.source(),
            status: Crosswake.Commerce.Reconciliation.outcome(),
            attempt: Crosswake.Commerce.Reconciliation.Attempt.t(),
            idempotency_key: Crosswake.Commerce.Reconciliation.IdempotencyKey.t(),
            replay?: boolean()
          }
  end

  @success_like_event_kinds MapSet.new(["purchase", "restore", "renewal", "grace_period", "billing_retry"])

  @spec ingest_evidence(Contracts.ReconciliationEvidence.t(), keyword()) ::
          {:ok, EvidenceResult.t()} | {:error, term()}
  def ingest_evidence(%Contracts.ReconciliationEvidence{} = evidence, opts \\ []) do
    with :ok <- reject_direct_authority_override(opts),
         {:ok, source} <- normalize_evidence_source(evidence.source) do
      idempotency_key = to_idempotency_key(evidence)
      replay? = seen_idempotency_key?(idempotency_key, Keyword.get(opts, :seen_idempotency_keys, []))
      status = evidence_status(evidence, opts)

      attempt = %Attempt{
        provider: evidence.provider,
        provider_reference: evidence.provider_reference,
        event_kind: evidence.event_kind,
        status: status,
        evidence_ref: evidence.evidence_ref,
        idempotency_ref: evidence.idempotency_ref
      }

      {:ok,
       %EvidenceResult{
         source: source,
         status: status,
         attempt: attempt,
         idempotency_key: idempotency_key,
         replay?: replay?
       }}
    end
  end

  @spec authority_mutation_allowed_from_evidence?(Contracts.ReconciliationEvidence.t()) :: false
  def authority_mutation_allowed_from_evidence?(%Contracts.ReconciliationEvidence{}), do: false

  defp to_idempotency_key(%Contracts.ReconciliationEvidence{} = evidence) do
    %IdempotencyKey{
      provider: evidence.provider,
      provider_reference: evidence.provider_reference,
      event_kind: evidence.event_kind
    }
  end

  defp seen_idempotency_key?(idempotency_key, %MapSet{} = seen), do: MapSet.member?(seen, idempotency_key)
  defp seen_idempotency_key?(idempotency_key, seen) when is_list(seen), do: Enum.member?(seen, idempotency_key)
  defp seen_idempotency_key?(_idempotency_key, _seen), do: false

  defp evidence_status(evidence, opts) do
    cond do
      Keyword.get(opts, :verified_projection, false) -> :projection_refreshed
      success_like_evidence?(evidence.event_kind) -> :awaiting_verification
      true -> :verification_failed
    end
  end

  defp success_like_evidence?(event_kind),
    do: MapSet.member?(@success_like_event_kinds, to_string(event_kind))

  defp reject_direct_authority_override(opts) do
    case Keyword.fetch(opts, :authority_state) do
      {:ok, _state} -> {:error, :authority_lane_mutation_forbidden}
      :error -> :ok
    end
  end

  defp normalize_evidence_source(source) do
    case Contracts.canonical_reconciliation_evidence_source(source) do
      {:ok, canonical_source} -> {:ok, canonical_source}
      {:error, {:invalid_source, details}} -> {:error, [source: {:invalid_source, details}]}
    end
  end
end