# Refinement Phase 6 — Money-path correctness completion — Report (2026-07-13)

**Track:** backend (money path) · **Depends on:** nothing hard (do before real BNPL/manual refunds — phase 8)
· **Gate:** `dotnet build` 0 new warnings · `dotnet test` 396 pass (383 prior + 13 new).

## The headline fix (6.1) — the unreachable BNPL/manual refund settlement is now wired

Before this phase, a card refund cleared its `refund_payable ↔ escrow_held` leg immediately, but a
BNPL-revert / manual-bank refund was left in `processing` with the clearing "deferred to reconciliation" — and
**no reconciliation path existed**: `Refund.MarkSucceededAsync` had zero callers, and nothing performed
`processing → succeeded`. Every BNPL/manual refund permanently overstated `escrow_held` and stranded
`refund_payable`; the ledger could never reconcile with the bank.

Now:
- **`ConfirmRefundSettlementCommand`** (`Features/Refunds/Commands/ConfirmRefundSettlement/`) transitions
  `processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
  **same commit**. It runs under the same `booking:{id}:refund` lock as `CreateRefundCommand` and **re-reads the
  tracked refund inside the lock**, so a racing/replayed confirm sees committed truth and no-ops (never
  double-clears). Idempotent: an already-`succeeded` refund is a no-op success.
- **`MarkRefundSettlementFailedCommand`** (`.../MarkRefundSettlementFailed/`) is the counterpart —
  `processing → failed`, no ledger moves.
- **Admin surface:** `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` on `AdminRefundsController`.
- **BNPL callback branch:** `HandleBnplCallback` gained a `RefundConfirmed` action. A provider event whose type
  says the revert/refund cash-back **completed/confirmed/settled** (or "cashback") resolves the `processing`
  refund created against that order's `payment_transaction` (`GetProcessingRefundIdForTransactionAsync`) and
  dispatches `ConfirmRefundSettlementCommand`. `ResolveAction` checks this **before** the order-level `settle`
  branch so `revert_settled` doesn't fall through.
- Domain: the misnamed `Refund.MarkSucceededAsync` (not async, uncalled) was renamed `MarkSucceededReconciled`.

**Proof:** `RefundSettlementTests` — a BNPL refund lands `processing` (3 reversal legs, no clearing) → confirm →
`succeeded`, clearing posts, the ledger reconciles (Σdebit = Σcredit, `refund_payable` fully drained,
`escrow_held` credited back); a replayed confirm stays at 5 legs (no double-clear); `mark_failed` leaves 3 legs
and blocks a later confirm (409).

## 6.4 — crash-window closed

`CreateRefundCommand` now persists the refund row (`approved`) **and commits** *before* calling the external
channel; then executes the channel against the persisted row and commits the outcome (succeeded/processing/failed
+ ledger). Same claim-first / execute-second shape the webhook handler uses — a crash between provider success
and our commit now leaves a reconcilable `approved` row instead of a silently-executed refund with no record.

## 6.2 — forward-dep FKs added (additive migration `RefinementPhase6MoneyFks`)

Real FKs (all nullable, `ON DELETE NO ACTION`) on the columns b11 shipped FK-less "until the target ships"
(the targets all shipped in b13/b15): `refunds.ticket_id → messaging.Tickets`,
`nurse_clawbacks.original_payout_id`/`recovered_in_payout_id → payouts.NursePayouts`,
`invoices.partner_center_id → partner.PartnerCenters` (**+ index**). The three now-false config-doc comments were
corrected. Referential integrity no longer rests on application discipline alone.

## 6.3 — IAuditable extended to the admin-decided money & trust entities

`Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification` are now `IAuditable`, so the
`AuditFieldInterceptor` writes an append-only `audit_logs` diff row on create + every admin decision (approve /
reject / process / settle, and the verification `is_verified` decision). `NursePayout.IbanSnapshot` (encrypted)
carries `[AuditRedacted]` so the diff records a marker, never the plaintext IBAN.

## 6.6 — orphaned config key retired

`refund_ticket_required` (seed row id 19) had no consumer left (b15 unconditionally auto-opens a refund ticket).
The seed row is deleted by the migration and the false description removed; the test-host stub was dropped.

## 6.5 — the previously-untested admin money paths now have tests (+13 tests)

- **`ClawbackWriteOffTests`** — write-off posts a balanced `DEBIT bad_debt / CREDIT nurse_clawback_receivable`
  group and resolves the clawback; 404 unknown; 409 second write-off. (Was zero-coverage.)
- **`Racing_same_key_insert_is_caught_as_an_idempotent_no_op`** (added to `PaymentWebhookTests`) — a provider
  that omits `external_event_id` skips the read-dedup, so the `(provider_code, external_event_id)` UNIQUE is the
  sole backstop (the exact state a true concurrent insert reaches); a colliding insert hits `DbUpdateException`
  and is treated as an idempotent duplicate no-op (no confirm, no ledger).
- **`MessagingInternalBoundaryTests`** (Foundation, handler-level) — the `is_internal` boundary: user thread view
  strips internal notes; admin view returns them; non-staff admin-view request is forbidden; non-staff can't
  post an internal note; a staff internal note never surfaces in the user view.
- **`RefundSettlementTests`** — the 6.1 settlement (both channels) + idempotency + mark_failed.

## Test-infra note (why a refund test host changed)

Adding the `refunds.ticket_id` FK means SQLite (which EF enables FK enforcement on) rejects a refund whose
`ticket_id` points at a non-existent ticket. `RefundsTestHost` now **seeds a real `Ticket`** and exposes
`Senders()` whose `OpenTicket` hook returns that real id (replacing the `TestSenders.WithTicketHooks()` fake id 1
in the refund tests). `PaymentsTestHost`/`PayoutsTestHost` were unaffected (they leave the new FK columns null).

## Contracts / docs updated (same change)

- `dev/contracts/domains/refunds-invoices.md` — the two new endpoints + `RefundSettlement` shape + changelog +
  corrected the `refund_ticket_required` note.
- `dev/contracts/openapi/swagger.v1.json` — refreshed (additive: the two routes + `RefundSettlementResult`).
- `server/CLAUDE.md` — refunds/payments section (settlement wiring + crash-window + FKs + retired config), the
  audit-interceptor note (expanded IAuditable set + `[AuditRedacted]`), and the feature map.

## Follow-ups (out of scope, for later phases)

- **A `mark_failed` after a successful provider revert** leaves the reversal ledger posted with no clearing (the
  money is genuinely in limbo — an ops reconciliation case). Deliberate: the reversal is not un-posted.
- The BNPL/PSP mocks stay until phase 8 (external rails); the settlement path is now complete behind them.
- `audit_logs` growth (2.3 grows it faster) — retention/archival is phase 9 (§7.4).
