lib/openagents/settlement.ex

main at 58e6347eeb72 · 38 KB

defmodule OpenAgents.Settlement do
  @moduledoc """
  Bounty settlement: price a forge issue, claim it, verify the delivery, and pay
  the claimant from the treasury against an inspectable receipt chain.

  The domain owns authority and evidence, never custody. Every payment needs an
  operator-admitted treasury policy, an approval reference, a specification
  fingerprint that has not moved, an accepted verification at the exact commit,
  and an idempotency key. The configured payment gateway
  (`OpenAgents.Settlement.PaymentGateway`) performs the transfer to a
  self-custodial destination and returns exact evidence.

  A specification change, a failed verifier, a stale commit, a missing
  approval, an exhausted budget, an expired claim, a dispute, or a duplicate
  request each stop a payment. A lost acknowledgement reconciles through the
  same idempotency key, so it never becomes a second payment.
  """

  import Ecto.Query

  alias OpenAgents.Forge.Visibility
  alias OpenAgents.Issues.Issue
  alias OpenAgents.Provenance.Canonical
  alias OpenAgents.Repo

  alias OpenAgents.Settlement.{
    Adjustment,
    BountySpec,
    Claim,
    PaymentGateway,
    PaymentIntent,
    PaymentReceipt,
    TreasuryPolicy,
    Verification
  }

  @policy_id "openagents.settlement.bounty.v1"
  @policy_version 1

  @default_rules %{
    "unit" => "sat",
    "custody" => "claimant_self_custodial_destination",
    "destination_kinds" => ["bolt12_offer"],
    "max_payment_sats" => 10_000,
    "daily_budget_sats" => 50_000,
    "max_attempts" => 3,
    "requires_accepted_verification" => true,
    "requires_approval_receipt" => true,
    "expiry" => "an expired claim releases the specification and never pays",
    "failed_payment" => "the same idempotency key retries until max_attempts",
    "lost_acknowledgement" => "reconcile the idempotency key, never pay twice",
    "dispute" => "a dispute freezes settlement until an operator resolves it",
    "refund" => "a refund is an append-only adjustment, never a receipt rewrite"
  }

  @doc "The settlement policy identifier."
  def policy_id, do: @policy_id

  @doc "The default treasury rules an operator can narrow before admission."
  def default_rules, do: @default_rules

  @doc """
  Admits the treasury policy that bounds every later payment.

  `operator` carries `actor_id`, `auth_method`, and `approval_receipt_ref`.
  """
  @spec admit_treasury_policy(map(), map()) :: {:ok, TreasuryPolicy.t()} | {:error, term()}
  def admit_treasury_policy(operator, rules \\ %{}) do
    rules = Map.merge(@default_rules, rules)

    with :ok <- validate_operator(operator),
         :ok <- validate_rules(rules) do
      digest =
        Canonical.digest!(%{
          "policy_id" => @policy_id,
          "version" => @policy_version,
          "rules" => rules
        })

      %TreasuryPolicy{}
      |> TreasuryPolicy.changeset(%{
        policy_id: @policy_id,
        version: @policy_version,
        policy_digest: digest,
        rules: rules,
        actor_id: operator.actor_id,
        auth_method: operator.auth_method,
        approval_receipt_ref: operator.approval_receipt_ref
      })
      |> Repo.insert()
    end
  end

  @doc "The admitted treasury policy, or `{:error, :treasury_policy_missing}`."
  @spec treasury_policy() :: {:ok, TreasuryPolicy.t()} | {:error, :treasury_policy_missing}
  def treasury_policy do
    case Repo.get_by(TreasuryPolicy, policy_id: @policy_id, version: @policy_version) do
      %TreasuryPolicy{} = policy -> {:ok, policy}
      nil -> {:error, :treasury_policy_missing}
    end
  end

  @doc """
  Prices one issue and fingerprints the specification.

  Repricing the same issue appends a revision with a new fingerprint. Claims
  pinned to the previous fingerprint can no longer be paid.
  """
  @spec price_bounty(Issue.t(), map(), map()) :: {:ok, BountySpec.t()} | {:error, term()}
  def price_bounty(%Issue{} = issue, attributes, operator) do
    with :ok <- validate_operator(operator),
         {:ok, policy} <- treasury_policy(),
         {:ok, priced} <- validate_price(policy, attributes) do
      revision = next_revision(issue)

      fingerprint =
        Canonical.digest!(%{
          "policy_digest" => policy.policy_digest,
          "issue_id" => issue.id,
          "revision" => revision,
          "buyer_ref" => priced.buyer_ref,
          "amount_sats" => priced.amount_sats,
          "acceptance_criteria" => priced.acceptance_criteria,
          "verification_policy" => priced.verification_policy,
          "destination_kind" => priced.destination_kind,
          "expires_at" => DateTime.to_iso8601(priced.expires_at)
        })

      %BountySpec{}
      |> BountySpec.changeset(%{
        treasury_policy_id: policy.id,
        issue_id: issue.id,
        revision: revision,
        buyer_ref: priced.buyer_ref,
        amount_sats: priced.amount_sats,
        acceptance_criteria: priced.acceptance_criteria,
        verification_policy: priced.verification_policy,
        destination_kind: priced.destination_kind,
        expires_at: priced.expires_at,
        spec_fingerprint: fingerprint,
        actor_id: operator.actor_id,
        auth_method: operator.auth_method,
        approval_receipt_ref: operator.approval_receipt_ref
      })
      |> Repo.insert()
    end
  end

  @doc "The current priced specification for an issue, or nil."
  @spec current_spec(Issue.t()) :: BountySpec.t() | nil
  def current_spec(%Issue{} = issue) do
    BountySpec
    |> where(issue_id: ^issue.id)
    |> order_by(desc: :revision)
    |> limit(1)
    |> Repo.one()
  end

  @doc """
  Claims the current specification and pins its fingerprint.

  `claimant` carries `claimant_ref`, `work_job_ref`, `destination_kind`, and
  `destination`. The destination is the claimant's own; the settlement never
  creates or holds a wallet for them.
  """
  @spec claim_bounty(BountySpec.t(), map()) :: {:ok, Claim.t()} | {:error, term()}
  def claim_bounty(%BountySpec{} = spec, claimant) do
    with {:ok, policy} <- treasury_policy(),
         :ok <- require_current_spec(spec),
         :ok <- require_unexpired_spec(spec),
         {:ok, destination} <- validate_destination(policy, spec, claimant),
         {:ok, refs} <- validate_claimant(claimant) do
      digest =
        Canonical.digest!(%{
          "spec_fingerprint" => spec.spec_fingerprint,
          "claimant_ref" => refs.claimant_ref,
          "work_job_ref" => refs.work_job_ref,
          "destination_digest" => destination.digest
        })

      %Claim{}
      |> Claim.changeset(%{
        bounty_spec_id: spec.id,
        spec_fingerprint: spec.spec_fingerprint,
        claimant_ref: refs.claimant_ref,
        work_job_ref: refs.work_job_ref,
        destination_kind: destination.kind,
        destination: destination.value,
        destination_digest: destination.digest,
        state: "claimed",
        claim_digest: digest,
        expires_at: spec.expires_at
      })
      |> Repo.insert()
    end
  end

  @doc """
  Records the qualification receipt for one claim at one exact commit.

  An accepted verification moves the claim to `verified`; a rejected one moves
  it to `rejected` and releases the specification for another claimant.
  """
  @spec verify_claim(Claim.t(), map()) :: {:ok, Verification.t()} | {:error, term()}
  def verify_claim(%Claim{} = claim, attributes) do
    with {:ok, claim} <- reload_claim(claim),
         {:ok, spec} <- claim_spec(claim),
         :ok <- require_settleable_claim(claim),
         :ok <- require_pinned_fingerprint(claim, spec),
         :ok <- require_unexpired_claim(claim),
         {:ok, decision} <- validate_verification(claim, spec, attributes) do
      Repo.transaction(fn ->
        verification =
          %Verification{}
          |> Verification.changeset(%{
            claim_id: claim.id,
            spec_fingerprint: spec.spec_fingerprint,
            commit_sha: decision.commit_sha,
            work_job_ref: claim.work_job_ref,
            verifier_ref: decision.verifier_ref,
            verifier_policy_digest: decision.verifier_policy_digest,
            evidence_digest: decision.evidence_digest,
            outcome: decision.outcome,
            reason_code: decision.reason_code,
            auth_method: decision.auth_method,
            decision_receipt_ref: decision.decision_receipt_ref
          })
          |> Repo.insert()
          |> or_rollback()

        claim_state = if decision.outcome == "accepted", do: "verified", else: "rejected"

        claim
        |> Claim.state_changeset(claim_state)
        |> Repo.update()
        |> or_rollback()

        verification
      end)
      |> transaction_result()
    end
  end

  @doc """
  Settles a verified claim from the treasury, once.

  `request` carries `commit_sha`, `idempotency_key`, `actor_id`, `auth_method`,
  and `approval_receipt_ref`. A duplicate request with the same key returns the
  original receipt without paying again.
  """
  @spec settle(Claim.t(), map()) ::
          {:ok, %{claim: Claim.t(), intent: PaymentIntent.t(), receipt: PaymentReceipt.t()}}
          | {:pending, PaymentIntent.t()}
          | {:error, term()}
  def settle(%Claim{} = claim, request) do
    with {:ok, operator} <- validate_settlement_request(request),
         {:ok, claim} <- reload_claim(claim),
         {:ok, prior} <- prior_intent(claim, operator.idempotency_key) do
      case prior do
        %PaymentIntent{state: "paid"} = intent -> settled_intent(intent)
        _open -> authorize(claim, operator)
      end
    end
  end

  defp prior_intent(%Claim{} = claim, idempotency_key) do
    case Repo.get_by(PaymentIntent, idempotency_key: idempotency_key) do
      nil -> {:ok, nil}
      %PaymentIntent{claim_id: claim_id} = intent when claim_id == claim.id -> {:ok, intent}
      %PaymentIntent{} -> {:error, :idempotency_key_conflict}
    end
  end

  defp settled_intent(%PaymentIntent{} = intent) do
    case receipt_for(intent) do
      %PaymentReceipt{} = receipt -> settled_result(intent, receipt)
      nil -> reconcile_open(intent)
    end
  end

  defp authorize(%Claim{} = claim, operator) do
    with {:ok, spec} <- claim_spec(claim),
         {:ok, policy} <- treasury_policy(),
         :ok <- require_settleable_claim(claim),
         :ok <- require_pinned_fingerprint(claim, spec),
         :ok <- require_current_spec(spec),
         :ok <- require_unexpired_claim(claim),
         {:ok, verification} <- accepted_verification(claim, operator.commit_sha, spec),
         :ok <- require_payment_authority(policy, spec),
         {:ok, intent} <- payment_intent(claim, spec, verification, operator) do
      dispatch(claim, spec, policy, intent)
    end
  end

  @doc """
  Reconciles one dispatched idempotency key against the gateway.

  A settled key that never acknowledged becomes its receipt here. A key the
  gateway does not know stays unpaid.
  """
  @spec reconcile(String.t()) ::
          {:ok, %{claim: Claim.t(), intent: PaymentIntent.t(), receipt: PaymentReceipt.t()}}
          | {:pending, PaymentIntent.t()}
          | {:error, term()}
  def reconcile(idempotency_key) when is_binary(idempotency_key) do
    case Repo.get_by(PaymentIntent, idempotency_key: idempotency_key) do
      nil -> {:error, :payment_intent_missing}
      %PaymentIntent{} = intent -> settled_intent(intent)
    end
  end

  @doc "Expires a live claim and releases the specification."
  @spec expire_claim(Claim.t(), map(), String.t()) :: {:ok, Adjustment.t()} | {:error, term()}
  def expire_claim(%Claim{} = claim, operator, reason_code),
    do: adjust(claim, operator, "expiry", reason_code, "expired")

  @doc "Freezes settlement for a claim under dispute."
  @spec open_dispute(Claim.t(), map(), String.t()) :: {:ok, Adjustment.t()} | {:error, term()}
  def open_dispute(%Claim{} = claim, operator, reason_code),
    do: adjust(claim, operator, "dispute", reason_code, "disputed")

  @doc "Records a refund for a paid claim without rewriting its receipt."
  @spec refund(Claim.t(), map(), String.t()) :: {:ok, Adjustment.t()} | {:error, term()}
  def refund(%Claim{} = claim, operator, reason_code) do
    with {:ok, claim} <- reload_claim(claim),
         :ok <- require_paid_claim(claim) do
      adjust(claim, operator, "refund", reason_code, "refunded")
    end
  end

  @doc """
  The public projection for an issue's settlement, bounded by the repository's
  disclosure level (TRANSPARENCY-001).

  Returns nil when the repository publishes nothing, when the level is below
  `:l1`, or when the issue has no priced specification.
  """
  @spec public_projection(Issue.t()) :: map() | nil
  def public_projection(%Issue{} = issue) do
    issue = Repo.preload(issue, :repository)
    level = Visibility.level(issue.repository && issue.repository.name)

    case {level, current_spec(issue)} do
      {:l0, _spec} -> nil
      {_level, nil} -> nil
      {level, spec} -> projection(issue, spec, level)
    end
  end

  @doc """
  The claimant's exportable payment receipt.

  The export carries the claimant's own destination and the evidence chain that
  proves the payment. It never carries operator identity, approval references,
  or gateway credentials, and it does not depend on a hosted wallet.
  """
  @spec export_payment_receipt(Claim.t(), String.t()) :: {:ok, map()} | {:error, term()}
  def export_payment_receipt(%Claim{} = claim, claimant_ref) when is_binary(claimant_ref) do
    with {:ok, claim} <- reload_claim(claim),
         :ok <- require_claimant(claim, claimant_ref),
         {:ok, spec} <- claim_spec(claim),
         {:ok, receipt} <- paid_receipt(claim),
         {:ok, verification} <- receipt_verification(receipt) do
      issue = Repo.preload(Repo.get!(Issue, spec.issue_id), :repository)

      {:ok,
       %{
         "contract" => "openagents.settlement.payment-receipt.v1",
         "issue" => issue_reference(issue),
         "buyer_kind" => reference_kind(spec.buyer_ref),
         "amount_sats" => receipt.amount_sats,
         "fee_sats" => receipt.fee_sats,
         "unit" => "sat",
         "state" => claim.state,
         "spec_fingerprint" => spec.spec_fingerprint,
         "acceptance_criteria" => spec.acceptance_criteria,
         "commit_sha" => verification.commit_sha,
         "work_job_ref" => claim.work_job_ref,
         "verifier_kind" => reference_kind(verification.verifier_ref),
         "verification_evidence_digest" => verification.evidence_digest,
         "destination_kind" => claim.destination_kind,
         "destination" => claim.destination,
         "payment_hash" => receipt.payment_hash,
         "preimage_digest" => receipt.preimage_digest,
         "paid_at" => DateTime.to_iso8601(receipt.paid_at),
         "receipt_digest" => receipt.receipt_digest
       }}
    end
  end

  @doc "The claim of a specification, live or terminal, or nil."
  @spec claim_for(BountySpec.t()) :: Claim.t() | nil
  def claim_for(%BountySpec{} = spec) do
    Claim
    |> where(bounty_spec_id: ^spec.id)
    |> order_by(desc: :inserted_at)
    |> limit(1)
    |> Repo.one()
  end

  defp dispatch(_claim, _spec, _policy, %PaymentIntent{state: "paid"} = intent),
    do: settled_intent(intent)

  defp dispatch(_claim, _spec, _policy, %PaymentIntent{state: "refunded"}),
    do: {:error, :payment_refunded}

  defp dispatch(claim, spec, policy, %PaymentIntent{attempts: 0} = intent) do
    with :ok <- require_attempts_remaining(policy, intent),
         :ok <- require_daily_budget(policy, intent) do
      pay(claim, spec, intent)
    end
  end

  defp dispatch(claim, spec, policy, %PaymentIntent{} = intent) do
    with :ok <- require_attempts_remaining(policy, intent),
         :ok <- require_daily_budget(policy, intent) do
      case PaymentGateway.lookup(intent.idempotency_key) do
        {:ok, settled} -> record_settlement(intent, settled)
        {:pending, reason_code} -> hold(intent, reason_code)
        _unsettled -> pay(claim, spec, intent)
      end
    end
  end

  defp pay(claim, spec, intent) do
    request = %{
      idempotency_key: intent.idempotency_key,
      amount_sats: intent.amount_sats,
      destination_kind: claim.destination_kind,
      destination: claim.destination,
      memo: "bounty #{spec.spec_fingerprint} commit #{intent.commit_sha}"
    }

    case PaymentGateway.pay(request) do
      {:ok, settled} -> record_settlement(intent, settled)
      {:pending, reason_code} -> hold(intent, reason_code)
      {:error, reason_code} -> record_failure(intent, reason_code)
      {:unknown, _nothing} -> record_failure(intent, "payment_gateway_unknown")
    end
  end

  defp reconcile_open(%PaymentIntent{} = intent) do
    case PaymentGateway.lookup(intent.idempotency_key) do
      {:ok, settled} -> record_settlement(intent, settled)
      {:pending, reason_code} -> hold(intent, reason_code)
      _unsettled -> {:error, :payment_unsettled}
    end
  end

  defp record_settlement(%PaymentIntent{} = intent, settled) do
    with {:ok, evidence} <- validate_settled(intent, settled) do
      Repo.transaction(fn ->
        digest =
          Canonical.digest!(%{
            "intent_digest" => intent.intent_digest,
            "amount_sats" => intent.amount_sats,
            "fee_sats" => evidence.fee_sats,
            "payment_hash" => evidence.payment_hash,
            "preimage_digest" => evidence.preimage_digest,
            "paid_at" => DateTime.to_iso8601(evidence.paid_at)
          })

        receipt =
          %PaymentReceipt{}
          |> PaymentReceipt.changeset(%{
            payment_intent_id: intent.id,
            claim_id: intent.claim_id,
            amount_sats: intent.amount_sats,
            fee_sats: evidence.fee_sats,
            payment_hash: evidence.payment_hash,
            preimage_digest: evidence.preimage_digest,
            gateway_ref: evidence.gateway_ref,
            paid_at: evidence.paid_at,
            receipt_digest: digest
          })
          |> Repo.insert()
          |> or_rollback()

        paid_intent =
          intent
          |> PaymentIntent.result_changeset(%{
            state: "paid",
            attempts: intent.attempts + 1,
            failure_reason_code: nil
          })
          |> Repo.update()
          |> or_rollback()

        claim =
          Claim
          |> Repo.get!(intent.claim_id)
          |> Claim.state_changeset("paid")
          |> Repo.update()
          |> or_rollback()

        %{claim: claim, intent: paid_intent, receipt: receipt}
      end)
      |> transaction_result()
    end
  end

  defp record_failure(%PaymentIntent{} = intent, reason_code) do
    result =
      intent
      |> PaymentIntent.result_changeset(%{
        state: "failed",
        attempts: intent.attempts + 1,
        failure_reason_code: reason_code
      })
      |> Repo.update()

    case result do
      {:ok, _failed} -> {:error, {:payment_failed, reason_code}}
      {:error, changeset} -> {:error, changeset}
    end
  end

  defp hold(%PaymentIntent{} = intent, reason_code) do
    result =
      intent
      |> PaymentIntent.result_changeset(%{
        state: "pending",
        attempts: intent.attempts + 1,
        failure_reason_code: reason_code
      })
      |> Repo.update()

    case result do
      {:ok, pending} -> {:pending, pending}
      {:error, changeset} -> {:error, changeset}
    end
  end

  defp settled_result(%PaymentIntent{} = intent, %PaymentReceipt{} = receipt) do
    {:ok, %{claim: Repo.get!(Claim, intent.claim_id), intent: intent, receipt: receipt}}
  end

  defp payment_intent(claim, spec, verification, operator) do
    case Repo.get_by(PaymentIntent, idempotency_key: operator.idempotency_key) do
      %PaymentIntent{} = intent ->
        require_matching_intent(intent, claim, spec, verification, operator)

      nil ->
        insert_intent(claim, spec, verification, operator)
    end
  end

  defp insert_intent(claim, spec, verification, operator) do
    digest =
      Canonical.digest!(%{
        "claim_digest" => claim.claim_digest,
        "spec_fingerprint" => spec.spec_fingerprint,
        "commit_sha" => verification.commit_sha,
        "amount_sats" => spec.amount_sats,
        "idempotency_key" => operator.idempotency_key
      })

    result =
      %PaymentIntent{}
      |> PaymentIntent.changeset(%{
        claim_id: claim.id,
        verification_id: verification.id,
        idempotency_key: operator.idempotency_key,
        amount_sats: spec.amount_sats,
        commit_sha: verification.commit_sha,
        destination_digest: claim.destination_digest,
        spec_fingerprint: spec.spec_fingerprint,
        state: "pending",
        attempts: 0,
        intent_digest: digest,
        actor_id: operator.actor_id,
        auth_method: operator.auth_method,
        approval_receipt_ref: operator.approval_receipt_ref
      })
      |> Repo.insert()

    case result do
      {:ok, intent} ->
        {:ok, intent}

      {:error, changeset} ->
        case Repo.get_by(PaymentIntent, idempotency_key: operator.idempotency_key) do
          %PaymentIntent{} = intent ->
            require_matching_intent(intent, claim, spec, verification, operator)

          nil ->
            {:error, changeset}
        end
    end
  end

  defp require_matching_intent(intent, claim, spec, verification, operator) do
    matches? =
      intent.claim_id == claim.id and intent.spec_fingerprint == spec.spec_fingerprint and
        intent.commit_sha == verification.commit_sha and intent.amount_sats == spec.amount_sats and
        intent.approval_receipt_ref == operator.approval_receipt_ref

    if matches?, do: {:ok, intent}, else: {:error, :idempotency_key_conflict}
  end

  defp require_attempts_remaining(policy, intent) do
    max_attempts = Map.get(policy.rules, "max_attempts", 1)

    if intent.attempts < max_attempts,
      do: :ok,
      else: {:error, :payment_attempts_exhausted}
  end

  defp require_daily_budget(policy, intent) do
    budget = Map.get(policy.rules, "daily_budget_sats", 0)
    window = DateTime.add(DateTime.utc_now(), -86_400, :second)

    paid =
      PaymentIntent
      |> where([intent], intent.state == "paid" and intent.updated_at >= ^window)
      |> select([intent], sum(intent.amount_sats))
      |> Repo.one()
      |> Kernel.||(0)

    if paid + intent.amount_sats <= budget,
      do: :ok,
      else: {:error, :daily_budget_exhausted}
  end

  defp require_payment_authority(policy, spec) do
    max_payment = Map.get(policy.rules, "max_payment_sats", 0)

    if spec.amount_sats <= max_payment,
      do: :ok,
      else: {:error, :amount_exceeds_treasury_authority}
  end

  defp accepted_verification(claim, commit_sha, spec) do
    verification = Repo.get_by(Verification, claim_id: claim.id, commit_sha: commit_sha)

    cond do
      is_nil(verification) and Repo.exists?(where(Verification, claim_id: ^claim.id)) ->
        {:error, :stale_commit}

      is_nil(verification) ->
        {:error, :verification_missing}

      verification.outcome != "accepted" ->
        {:error, :verification_rejected}

      verification.spec_fingerprint != spec.spec_fingerprint ->
        {:error, :spec_fingerprint_mismatch}

      true ->
        {:ok, verification}
    end
  end

  defp adjust(claim, operator, kind, reason_code, claim_state) do
    with {:ok, claim} <- reload_claim(claim),
         :ok <- validate_operator(operator),
         :ok <- validate_reason_code(reason_code) do
      digest =
        Canonical.digest!(%{
          "claim_digest" => claim.claim_digest,
          "kind" => kind,
          "reason_code" => reason_code,
          "approval_receipt_ref" => operator.approval_receipt_ref
        })

      Repo.transaction(fn ->
        adjustment =
          %Adjustment{}
          |> Adjustment.changeset(%{
            claim_id: claim.id,
            kind: kind,
            reason_code: reason_code,
            actor_id: operator.actor_id,
            auth_method: operator.auth_method,
            approval_receipt_ref: operator.approval_receipt_ref,
            adjustment_digest: digest
          })
          |> Repo.insert()
          |> or_rollback()

        claim
        |> Claim.state_changeset(claim_state)
        |> Repo.update()
        |> or_rollback()

        adjustment
      end)
      |> transaction_result()
    end
  end

  defp projection(issue, spec, level) do
    claim = claim_for(spec)
    receipt = claim && paid_receipt_or_nil(claim)

    pulse = %{
      "contract" => "openagents.settlement.public.v1",
      "issue_number" => issue.number,
      "unit" => "sat",
      "amount_sats" => spec.amount_sats,
      "state" => public_state(claim),
      "buyer_kind" => reference_kind(spec.buyer_ref),
      "claimant_kind" => claim && reference_kind(claim.claimant_ref),
      "acceptance_criteria_count" => length(spec.acceptance_criteria),
      "paid" => not is_nil(receipt)
    }

    if level == :l1, do: pulse, else: Map.merge(pulse, ledger(spec, claim, receipt))
  end

  defp ledger(spec, claim, receipt) do
    verification = claim && accepted_verification_or_nil(claim)

    %{
      "spec_fingerprint" => spec.spec_fingerprint,
      "acceptance_criteria" => spec.acceptance_criteria,
      "expires_at" => DateTime.to_iso8601(spec.expires_at),
      "commit_sha" => verification && verification.commit_sha,
      "verifier_kind" => verification && reference_kind(verification.verifier_ref),
      "verification_evidence_digest" => verification && verification.evidence_digest,
      "payment_hash" => receipt && receipt.payment_hash,
      "fee_sats" => receipt && receipt.fee_sats,
      "paid_at" => receipt && DateTime.to_iso8601(receipt.paid_at),
      "receipt_digest" => receipt && receipt.receipt_digest
    }
  end

  defp public_state(nil), do: "priced"
  defp public_state(%Claim{state: state}), do: state

  defp accepted_verification_or_nil(claim) do
    Verification
    |> where([verification], verification.claim_id == ^claim.id)
    |> where([verification], verification.outcome == "accepted")
    |> order_by(desc: :inserted_at)
    |> limit(1)
    |> Repo.one()
  end

  defp paid_receipt_or_nil(claim) do
    PaymentReceipt
    |> where(claim_id: ^claim.id)
    |> order_by(desc: :inserted_at)
    |> limit(1)
    |> Repo.one()
  end

  defp paid_receipt(claim) do
    case paid_receipt_or_nil(claim) do
      %PaymentReceipt{} = receipt -> {:ok, receipt}
      nil -> {:error, :payment_receipt_missing}
    end
  end

  defp receipt_verification(%PaymentReceipt{} = receipt) do
    intent = Repo.get!(PaymentIntent, receipt.payment_intent_id)

    case Repo.get(Verification, intent.verification_id) do
      %Verification{} = verification -> {:ok, verification}
      nil -> {:error, :verification_missing}
    end
  end

  defp receipt_for(%PaymentIntent{} = intent),
    do: Repo.get_by(PaymentReceipt, payment_intent_id: intent.id)

  defp issue_reference(%Issue{} = issue) do
    %{
      "owner" => issue.repository && issue.repository.owner,
      "repository" => issue.repository && issue.repository.name,
      "number" => issue.number
    }
  end

  defp reference_kind(reference) when is_binary(reference) do
    case String.split(reference, ":", parts: 2) do
      [kind, _identifier] -> kind
      [_identifier] -> "unattributed"
    end
  end

  defp reference_kind(_reference), do: "unattributed"

  defp next_revision(issue) do
    revision =
      BountySpec
      |> where(issue_id: ^issue.id)
      |> select([spec], max(spec.revision))
      |> Repo.one()

    (revision || 0) + 1
  end

  defp claim_spec(%Claim{} = claim) do
    case Repo.get(BountySpec, claim.bounty_spec_id) do
      %BountySpec{} = spec -> {:ok, spec}
      nil -> {:error, :bounty_spec_missing}
    end
  end

  defp reload_claim(%Claim{id: id}) do
    case Repo.get(Claim, id) do
      %Claim{} = claim -> {:ok, claim}
      nil -> {:error, :claim_missing}
    end
  end

  defp require_current_spec(%BountySpec{} = spec) do
    latest =
      BountySpec
      |> where(issue_id: ^spec.issue_id)
      |> select([candidate], max(candidate.revision))
      |> Repo.one()

    if latest == spec.revision, do: :ok, else: {:error, :spec_superseded}
  end

  defp require_pinned_fingerprint(%Claim{} = claim, %BountySpec{} = spec) do
    if claim.spec_fingerprint == spec.spec_fingerprint,
      do: :ok,
      else: {:error, :spec_fingerprint_mismatch}
  end

  defp require_unexpired_spec(%BountySpec{} = spec) do
    if DateTime.compare(spec.expires_at, DateTime.utc_now()) == :gt,
      do: :ok,
      else: {:error, :bounty_expired}
  end

  defp require_unexpired_claim(%Claim{} = claim) do
    if DateTime.compare(claim.expires_at, DateTime.utc_now()) == :gt,
      do: :ok,
      else: {:error, :claim_expired}
  end

  defp require_settleable_claim(%Claim{state: state}) when state in ~w(claimed verified),
    do: :ok

  defp require_settleable_claim(%Claim{state: state}),
    do: {:error, {:claim_not_settleable, state}}

  defp require_paid_claim(%Claim{state: "paid"}), do: :ok
  defp require_paid_claim(%Claim{state: state}), do: {:error, {:claim_not_paid, state}}

  defp require_claimant(%Claim{claimant_ref: claimant_ref}, claimant_ref), do: :ok
  defp require_claimant(_claim, _claimant_ref), do: {:error, :not_the_claimant}

  defp validate_operator(operator) when is_map(operator) do
    with :ok <- require_text(operator, :actor_id, 256),
         :ok <- require_text(operator, :auth_method, 128),
         :ok <- require_text(operator, :approval_receipt_ref, 256) do
      :ok
    end
  end

  defp validate_operator(_operator), do: {:error, :operator_invalid}

  defp validate_settlement_request(request) when is_map(request) do
    with :ok <- validate_approval(request),
         :ok <- validate_operator(request),
         :ok <- require_text(request, :idempotency_key, 256),
         {:ok, commit_sha} <- validate_commit_sha(request) do
      {:ok,
       %{
         actor_id: request.actor_id,
         auth_method: request.auth_method,
         approval_receipt_ref: request.approval_receipt_ref,
         idempotency_key: request.idempotency_key,
         commit_sha: commit_sha
       }}
    end
  end

  defp validate_settlement_request(_request), do: {:error, :settlement_request_invalid}

  defp validate_approval(request) do
    case Map.get(request, :approval_receipt_ref) do
      value when is_binary(value) and value != "" -> :ok
      _missing -> {:error, :approval_missing}
    end
  end

  defp validate_commit_sha(request) do
    case Map.get(request, :commit_sha) do
      value when is_binary(value) ->
        if Regex.match?(~r/\A[0-9a-f]{40}\z/, value),
          do: {:ok, value},
          else: {:error, :commit_sha_invalid}

      _missing ->
        {:error, :commit_sha_invalid}
    end
  end

  defp validate_rules(rules) do
    max_payment = Map.get(rules, "max_payment_sats")
    daily_budget = Map.get(rules, "daily_budget_sats")
    max_attempts = Map.get(rules, "max_attempts")
    destinations = Map.get(rules, "destination_kinds")

    cond do
      not (is_integer(max_payment) and max_payment > 0) -> {:error, :max_payment_invalid}
      not (is_integer(daily_budget) and daily_budget >= max_payment) -> {:error, :budget_invalid}
      not (is_integer(max_attempts) and max_attempts > 0) -> {:error, :max_attempts_invalid}
      not (is_list(destinations) and destinations != []) -> {:error, :destination_kinds_invalid}
      Map.get(rules, "requires_accepted_verification") != true -> {:error, :verification_optional}
      Map.get(rules, "requires_approval_receipt") != true -> {:error, :approval_optional}
      true -> :ok
    end
  end

  defp validate_price(policy, attributes) when is_map(attributes) do
    with {:ok, amount_sats} <- validate_amount(policy, attributes),
         {:ok, criteria} <- validate_criteria(attributes),
         {:ok, verification_policy} <- validate_verification_policy(attributes),
         {:ok, destination_kind} <- validate_destination_kind(policy, attributes),
         {:ok, expires_at} <- validate_expiry(attributes),
         :ok <- require_text(attributes, :buyer_ref, 256) do
      {:ok,
       %{
         buyer_ref: attributes.buyer_ref,
         amount_sats: amount_sats,
         acceptance_criteria: criteria,
         verification_policy: verification_policy,
         destination_kind: destination_kind,
         expires_at: expires_at
       }}
    end
  end

  defp validate_price(_policy, _attributes), do: {:error, :bounty_price_invalid}

  defp validate_amount(policy, attributes) do
    amount = Map.get(attributes, :amount_sats)
    max_payment = Map.get(policy.rules, "max_payment_sats", 0)

    cond do
      not is_integer(amount) or amount <= 0 -> {:error, :amount_invalid}
      amount > max_payment -> {:error, :amount_exceeds_treasury_authority}
      true -> {:ok, amount}
    end
  end

  defp validate_criteria(attributes) do
    case Map.get(attributes, :acceptance_criteria) do
      [_first | _rest] = criteria ->
        if Enum.all?(criteria, &(is_binary(&1) and &1 != "")),
          do: {:ok, criteria},
          else: {:error, :acceptance_criteria_invalid}

      _missing ->
        {:error, :acceptance_criteria_invalid}
    end
  end

  defp validate_verification_policy(attributes) do
    case Map.get(attributes, :verification_policy) do
      policy when is_map(policy) and map_size(policy) > 0 -> {:ok, policy}
      _missing -> {:error, :verification_policy_invalid}
    end
  end

  defp validate_destination_kind(policy, attributes) do
    kinds = Map.get(policy.rules, "destination_kinds", [])
    kind = Map.get(attributes, :destination_kind)

    if is_binary(kind) and kind in kinds,
      do: {:ok, kind},
      else: {:error, :destination_kind_not_admitted}
  end

  defp validate_expiry(attributes) do
    case Map.get(attributes, :expires_at) do
      %DateTime{} = expires_at ->
        if DateTime.compare(expires_at, DateTime.utc_now()) == :gt,
          do: {:ok, expires_at},
          else: {:error, :expiry_in_the_past}

      _missing ->
        {:error, :expiry_invalid}
    end
  end

  defp validate_destination(policy, spec, claimant) when is_map(claimant) do
    kinds = Map.get(policy.rules, "destination_kinds", [])
    kind = Map.get(claimant, :destination_kind)
    value = Map.get(claimant, :destination)

    cond do
      not (is_binary(kind) and kind in kinds) ->
        {:error, :destination_kind_not_admitted}

      kind != spec.destination_kind ->
        {:error, :destination_kind_not_admitted}

      not (is_binary(value) and byte_size(value) >= 16) ->
        {:error, :destination_invalid}

      true ->
        {:ok, %{kind: kind, value: value, digest: Canonical.sha256(value)}}
    end
  end

  defp validate_destination(_policy, _spec, _claimant), do: {:error, :destination_invalid}

  defp validate_claimant(claimant) do
    with :ok <- require_text(claimant, :claimant_ref, 256),
         :ok <- require_text(claimant, :work_job_ref, 256) do
      {:ok, %{claimant_ref: claimant.claimant_ref, work_job_ref: claimant.work_job_ref}}
    end
  end

  defp validate_verification(claim, spec, attributes) when is_map(attributes) do
    expected_policy_digest = Canonical.digest!(spec.verification_policy)

    with {:ok, commit_sha} <- validate_commit_sha(attributes),
         :ok <- require_text(attributes, :verifier_ref, 256),
         :ok <- require_text(attributes, :auth_method, 128),
         :ok <- require_text(attributes, :decision_receipt_ref, 256),
         :ok <- validate_reason_code(Map.get(attributes, :reason_code)),
         {:ok, outcome} <- validate_outcome(attributes),
         {:ok, evidence_digest} <- validate_evidence_digest(attributes),
         :ok <- require_verifier_policy(attributes, expected_policy_digest),
         :ok <- require_claim_work_job(claim, attributes) do
      {:ok,
       %{
         commit_sha: commit_sha,
         verifier_ref: attributes.verifier_ref,
         verifier_policy_digest: expected_policy_digest,
         evidence_digest: evidence_digest,
         outcome: outcome,
         reason_code: attributes.reason_code,
         auth_method: attributes.auth_method,
         decision_receipt_ref: attributes.decision_receipt_ref
       }}
    end
  end

  defp validate_verification(_claim, _spec, _attributes), do: {:error, :verification_invalid}

  defp validate_outcome(attributes) do
    case Map.get(attributes, :outcome) do
      outcome when outcome in ~w(accepted rejected) -> {:ok, outcome}
      _invalid -> {:error, :verification_outcome_invalid}
    end
  end

  defp validate_evidence_digest(attributes) do
    case Map.get(attributes, :evidence_digest) do
      digest when is_binary(digest) ->
        if Regex.match?(~r/\A[0-9a-f]{64}\z/, digest),
          do: {:ok, digest},
          else: {:error, :evidence_digest_invalid}

      _missing ->
        {:error, :evidence_digest_invalid}
    end
  end

  defp require_verifier_policy(attributes, expected) do
    case Map.get(attributes, :verifier_policy_digest) do
      nil -> :ok
      ^expected -> :ok
      _other -> {:error, :verifier_policy_mismatch}
    end
  end

  defp require_claim_work_job(claim, attributes) do
    case Map.get(attributes, :work_job_ref) do
      nil -> :ok
      work_job_ref when work_job_ref == claim.work_job_ref -> :ok
      _other -> {:error, :work_job_mismatch}
    end
  end

  defp validate_settled(_intent, settled) when is_map(settled) do
    with {:ok, payment_hash} <- digest_field(settled, :payment_hash, :payment_hash_invalid),
         {:ok, preimage_digest} <- digest_field(settled, :preimage_digest, :preimage_invalid),
         {:ok, paid_at} <- paid_at(settled),
         {:ok, fee_sats} <- fee_sats(settled),
         :ok <- require_text(settled, :gateway_ref, 256) do
      {:ok,
       %{
         payment_hash: payment_hash,
         preimage_digest: preimage_digest,
         paid_at: paid_at,
         fee_sats: fee_sats,
         gateway_ref: settled.gateway_ref
       }}
    end
  end

  defp validate_settled(_intent, _settled), do: {:error, :payment_evidence_invalid}

  defp digest_field(settled, key, error) do
    case Map.get(settled, key) do
      value when is_binary(value) ->
        if Regex.match?(~r/\A[0-9a-f]{64}\z/, value), do: {:ok, value}, else: {:error, error}

      _missing ->
        {:error, error}
    end
  end

  defp paid_at(settled) do
    case Map.get(settled, :paid_at) do
      %DateTime{} = paid_at -> {:ok, paid_at}
      _missing -> {:error, :paid_at_invalid}
    end
  end

  defp fee_sats(settled) do
    case Map.get(settled, :fee_sats) do
      fee when is_integer(fee) and fee >= 0 -> {:ok, fee}
      _invalid -> {:error, :fee_sats_invalid}
    end
  end

  defp validate_reason_code(reason_code) do
    if is_binary(reason_code) and reason_code != "" and byte_size(reason_code) <= 128,
      do: :ok,
      else: {:error, :reason_code_invalid}
  end

  defp require_text(source, key, max_bytes) do
    case Map.get(source, key) do
      value when is_binary(value) ->
        if value != "" and byte_size(value) <= max_bytes,
          do: :ok,
          else: {:error, {:invalid_field, key}}

      _missing ->
        {:error, {:invalid_field, key}}
    end
  end

  defp or_rollback({:ok, record}), do: record
  defp or_rollback({:error, reason}), do: Repo.rollback(reason)

  defp transaction_result({:ok, value}), do: {:ok, value}
  defp transaction_result({:error, reason}), do: {:error, reason}
end