refinement phase 6

This commit is contained in:
hamid
2026-07-13 17:03:45 +03:30
parent d4147342da
commit 70268ecc06
33 changed files with 7262 additions and 53 deletions
+28 -4
View File
@@ -65,10 +65,29 @@ ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created).
- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin ·
`404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused.
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; posts the balanced ledger reversal via
b10's helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is
deferred to reconciliation for BNPL/manual. `ticketId` is required only when `refund_ticket_required` config is
on (off until b15). Notifies the customer.
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; the refund row is persisted (`approved`)
**before** the external channel executes (crash-window fix), then the balanced ledger reversal posts via b10's
helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is
**deferred to reconciliation for BNPL/manual** (settled later via `confirm_settlement`, below). `ticketId` is
optional — one is auto-opened when omitted (b15), so a refund is always ticket-anchored. Notifies the customer.
### `POST api/v1/admin_refunds/{id}/confirm_settlement`
- **Purpose:** reconciliation confirmed the customer cash-back for a `processing` BNPL/manual refund — transitions
it `processing → succeeded`, stamps the settled instant, and posts the deferred `refund_payable ↔ escrow_held`
clearing in the same commit. (Also reached automatically by the BNPL provider cash-back callback.)
- **Auth:** admin · **Rate-limited:** yes (sensitive) · **Idempotent:** a replay against an already-`succeeded`
refund is a no-op success (the clearing never posts twice).
- **Request body:** none (id in the route).
- **Success `200` (`data`):** `RefundSettlement` — `{ "refundId": 7, "bookingId": 42, "status": "succeeded",
"completedAt": "2026-08-12T10:00:00Z" }`.
- **Failure:** `404` refund not found · `409` refund not in `processing` (e.g. still `approved`, already `failed`).
### `POST api/v1/admin_refunds/{id}/mark_failed`
- **Purpose:** reconciliation reported the BNPL/manual customer cash-back did **not** land — transitions the
`processing` refund to `failed`. No ledger moves (the clearing was never posted for a processing refund).
- **Auth:** admin · **Rate-limited:** yes · **Idempotent:** a replay against an already-`failed` refund is a no-op.
- **Request body:** `{ "reason": "bank_rejected" }` (optional).
- **Success `200` (`data`):** `RefundSettlement` (as above, `status: "failed"`). **Failure:** `404` · `409` not `processing`.
### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=`
- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100).
@@ -125,6 +144,11 @@ ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
## Changelog
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
- refinement-phase-6 — added `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` (the BNPL/manual
`processing → succeeded/failed` settlement, `RefundSettlement` shape), so the deferred `refund_payable ↔
escrow_held` clearing is now reachable. Refunds are persisted before the channel call (crash-window fix). The
`refund_ticket_required` gate was retired (a refund ticket is always auto-opened). Forward-dep FKs added on
`refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`, `invoices.partner_center_id`.
---
+220
View File
@@ -3606,6 +3606,172 @@
]
}
},
"/api/v1/admin_refunds/{id}/confirm_settlement": {
"post": {
"tags": [
"AdminRefunds"
],
"operationId": "AdminRefunds_ConfirmSettlement",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
},
"x-position": 1
}
],
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfRefundSettlementResult"
}
}
}
}
},
"security": [
{
"Bearer": []
}
]
}
},
"/api/v1/admin_refunds/{id}/mark_failed": {
"post": {
"tags": [
"AdminRefunds"
],
"operationId": "AdminRefunds_MarkFailed",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
},
"x-position": 1
}
],
"requestBody": {
"x-name": "body",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MarkRefundFailedBody"
}
}
},
"required": true,
"x-position": 2
},
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfRefundSettlementResult"
}
}
}
}
},
"security": [
{
"Bearer": []
}
]
}
},
"/api/v1/admin/reviews/moderation_queue": {
"get": {
"tags": [
@@ -17189,6 +17355,60 @@
}
}
},
"ApiResultOfRefundSettlementResult": {
"allOf": [
{
"$ref": "#/components/schemas/ApiResult"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"data": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/RefundSettlementResult"
}
]
}
}
}
]
},
"RefundSettlementResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"refundId": {
"type": "integer",
"format": "int64"
},
"bookingId": {
"type": "integer",
"format": "int64"
},
"status": {
"type": "string"
},
"completedAt": {
"type": "string",
"format": "date-time",
"nullable": true
}
}
},
"MarkRefundFailedBody": {
"type": "object",
"description": "The mark-failed body (the id comes from the route).",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"nullable": true
}
}
},
"ApiResultOfPagedResultOfModerationQueueItemDto": {
"allOf": [
{
@@ -0,0 +1,31 @@
# After refinement-phase-6 — Money-path correctness completion
**Track:** backend (money path). Gate: `dotnet build` 0 new warnings · `dotnet test` 396 pass (+13). Migration
`RefinementPhase6MoneyFks` scaffolded (additive: 3 FK sets + invoice index + delete of a dead config seed row).
## What's now live (new endpoints — admin)
- **`POST api/v1/admin_refunds/{id}/confirm_settlement`** — settle a `processing` BNPL/manual refund
(`processing → succeeded`, posts the deferred `refund_payable ↔ escrow_held` clearing). Idempotent.
- **`POST api/v1/admin_refunds/{id}/mark_failed`** — fail a `processing` refund (no ledger). Body
`{ "reason": "..." }` (optional). Idempotent.
- Both return `RefundSettlement` `{ refundId, bookingId, status, completedAt }`. Contract:
`dev/contracts/domains/refunds-invoices.md`; snapshot `swagger.v1.json` refreshed.
## What changed under the hood (no client-visible shape change)
- The **BNPL provider cash-back callback** now auto-settles the matching `processing` refund (a
`refund/revert completed|confirmed|settled` / `cashback` event) — so a real BNPL revert reaches `succeeded`
without an admin click.
- **Refund create** persists the row (`approved`) before the external channel call (crash-window fix) — an
interrupted refund is now a reconcilable `approved` row, not a lost execution.
- **Forward-dep FKs** added: `refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`,
`invoices.partner_center_id` (+ index). All nullable, `NO ACTION`. No behavior change; integrity backstop only.
- **Audit:** `Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification` are now `IAuditable`
→ admin decisions leave an `audit_logs` diff row (IBAN redacted).
- **Retired** the orphaned `refund_ticket_required` config key (a refund ticket is always auto-opened).
## Frontend notes
- The customer refund-status flow is **unchanged** — a BNPL refund still shows `processing` with the ETA, and now
actually flips to `succeeded` once settled (via admin confirm or the provider callback). No new client work
required for the customer side.
- If/when an admin refund console surfaces settlement, the two new endpoints are the actions (staff-gated,
`sensitive` rate policy).
@@ -0,0 +1,95 @@
# 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).