cleanup phases 6
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# Backend Phase 11 Report — Refunds, invoices & nurse clawbacks
|
||||
|
||||
**Mission:** make money flow *backwards* correctly — the admin-only refund engine that reverses a captured
|
||||
booking payment across both fee legs, posts the balanced reversal into the append-only ledger, forks on whether
|
||||
the nurse was already paid (clean reversal vs a `nurse_clawbacks` receivable), and issues the minimal commission
|
||||
invoice with VAT.
|
||||
|
||||
## What was built
|
||||
|
||||
**Three tables (one migration `RefundsClawbacksInvoices`, `payments` schema) + a counter row:**
|
||||
- `Refunds` — 1:N per `payment_transaction`; `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`
|
||||
(DB CHECK); `refund_channel` (`psp_card`|`bnpl_revert`|`manual`), `external_revert_reference`,
|
||||
`expected_customer_refund_eta` (DATE), `cancellation_policy_code` + `refund_percentage_applied` snapshot,
|
||||
forward-only `status`. **`ticket_id` is a nullable column with NO FK** (tickets → b15).
|
||||
- `NurseClawbacks` — `status` (`pending`|`recovered`|`written_off`; only `pending`/`written_off` here),
|
||||
`amount_irr`, `refund_id` (1:1 UNIQUE), nullable `original_payout_id`/`recovered_in_payout_id` (no FK, → b13).
|
||||
- `Invoices` — UNIQUE `invoice_number` (sequential) + UNIQUE `booking_id`; `vat_rate`/`vat_irr` on the commission
|
||||
line; `moadian_reference_number`/`moadian_status`; nullable `partner_center_id` (no FK, → b15).
|
||||
- `InvoiceNumberSequences` — single seeded counter row (id 1, next 1); locked + committed with the invoice so
|
||||
numbers are gap-free and portable across SQL Server / SQLite (no DB sequence).
|
||||
|
||||
**Features (CQRS, `OperationResult`, validators):** `CreateRefundCommand`, `WriteOffClawbackCommand`,
|
||||
`ListRefundsQuery`, `GetRefundStatusQuery`, `IssueInvoiceCommand`, `GetInvoiceQuery`. The phase's
|
||||
channel-execution / ledger-posting / clawback "internal step" commands are realized as cohesive **private steps**
|
||||
inside `CreateRefundCommandHandler` under one lock + transaction (mirroring b10's `ConfirmPaymentAndPostLedger`) so
|
||||
they stay atomic — this is the correct shape; separate dispatched commands would each commit and break atomicity.
|
||||
|
||||
**Ledger postings** added to b10's `LedgerPosting` (balanced, append-only): `RefundReversalPrePayout`,
|
||||
`ClawbackReversalPostPayout`, `RefundPayableClearing`, `ClawbackWriteOff`.
|
||||
|
||||
**Controllers:** `AdminRefundsController` (`POST`/`GET admin_refunds`), `AdminClawbacksController`
|
||||
(`POST admin_clawbacks/{id}/write_off`), `AdminInvoicesController` (`POST admin_invoices`) — all admin policy +
|
||||
rate-limited; `RefundsController` (`GET refunds/{id}/status`), `InvoicesController` (`GET invoices/{bookingId}`) —
|
||||
authenticated, tenancy-scoped.
|
||||
|
||||
**Seams:** introduced `IMoadianClient` (+ `MockMoadianClient`) and, because b12 isn't merged, a thin
|
||||
`IBnplProvider` (+ `MockBnplProvider`) stub; interim `INursePayoutStatus` (`NursePayoutStatusService`, Persistence).
|
||||
Extended `IPaymentProvider.RefundAsync` to carry an `idempotencyKey` (updated the mock + b10 call sites — no b10
|
||||
behaviour change). Reused `IWebhookVerifier`/`IDistributedLock`/`INotificationDispatcher`/`ISupportAlertService`/
|
||||
`IObjectStorage`/`IPlatformConfig`. Added config rows `refund_ticket_required` (false), `bnpl_refund_eta_business_days`
|
||||
(10), `refund_assume_nurse_paid` (false).
|
||||
|
||||
## What is now testable and exactly how (mirrors the phase §7)
|
||||
|
||||
Seed a confirmed booking with a captured card transaction (the `RefundsTestHost`/`AdminRefundsApiTests.SeedCapturedBookingAsync`
|
||||
helpers do this; gross 10,000,000 / commission 1,500,000 / payout 8,500,000).
|
||||
|
||||
1. **Pre-payout full refund** — `POST admin_refunds {bookingId, refundPercentage:1}` → `refund_channel psp_card`,
|
||||
legs `1,500,000` + `8,500,000` summing to `10,000,000`; ledger shows a balanced `DEBIT platform_revenue` +
|
||||
`DEBIT nurse_payable` / `CREDIT refund_payable`, and a `DEBIT refund_payable` / `CREDIT escrow_held` clearing
|
||||
leg (5 legs, Σdebit=Σcredit); status `succeeded`.
|
||||
2. **Partial + over-refund guard** — a 50% refund decomposes to `750,000` + `4,250,000`; a second refund that would
|
||||
exceed the captured amount → **409**, no ledger posted.
|
||||
3. **Invoice** — `POST admin_invoices {bookingId}` → sequential `INV-0000000001`,
|
||||
`vat_irr = round(1,500,000 × 0.10) = 150,000` (on the commission, `0` when `vat_rate = 0`), `moadian_status
|
||||
pending` / null ref; a second booking → `INV-0000000002` (gap-free). Re-issue returns the same invoice.
|
||||
4. **Post-payout clawback** — with the nurse flagged paid (seed a past `dispute_window_ends_at`, or
|
||||
`refund_assume_nurse_paid = true`) → the payout leg debits `nurse_clawback_receivable` (not `nurse_payable`), a
|
||||
`pending` `nurse_clawbacks` row (`amount_irr = 8,500,000`) is created, and a `nurse_clawback` support alert is
|
||||
raised. Not auto-recovered (b13).
|
||||
5. **BNPL revert** — seed a `bnpl` gateway → `refund_channel bnpl_revert`, `IBnplProvider.RevertAsync` called,
|
||||
`external_revert_reference` stored, `expected_customer_refund_eta ≈ now + 10 business days`, status `processing`;
|
||||
the reversal ledger legs are identical to the card case. `GET refunds/{id}/status` shows the ETA.
|
||||
6. **Write-off** — `POST admin_clawbacks/{id}/write_off` → `written_off` + `DEBIT bad_debt` / `CREDIT
|
||||
nurse_clawback_receivable` + `resolved_at`.
|
||||
7. **Worklist + tenancy** — `GET admin_refunds?status=…` lists channel/legs/ETA; `GET refunds/{id}/status` as a
|
||||
different customer → **404**.
|
||||
|
||||
**Tests:** 8 Foundation handler tests (`Refunds/RefundHandlerTests` + `InvoiceHandlerTests`) + 8 Api integration
|
||||
tests (`AdminRefundsApiTests`, `AdminInvoicesApiTests`, `RefundStatusApiTests`). Full suite green (300).
|
||||
`dotnet build Baya.sln` 0 new warnings.
|
||||
|
||||
## What is mocked / how to make it real
|
||||
See `reports/mocks-registry.md` — `IMoadianClient` (سامانه مودیان enrollment + submission API + 22-digit ref +
|
||||
reconciliation callback), `IBnplProvider` (b12 owns the real adapter), `INursePayoutStatus` (b13 real lookup).
|
||||
|
||||
## Contracts produced/consumed
|
||||
- **Produced:** `dev/contracts/domains/refunds-invoices.md`; `dev/contracts/openapi/swagger.v1.json` refreshed.
|
||||
- **Consumed:** b9 booking/cancellation snapshot, b10 ledger/transaction/gateway, b1 VAT config + notifications +
|
||||
support alerts.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **مودیان reconciliation cron** — flip `moadian_status pending → registered` + fill the 22-digit ref (thin/manual
|
||||
today).
|
||||
- **BNPL-revert reconciliation cron** — clear `refund_payable ↔ escrow_held` for a `processing` refund when the
|
||||
provider confirms the customer cash-back (webhook via `IWebhookVerifier`; deferred/manual today).
|
||||
- **b12** — real `IBnplProvider`.
|
||||
- **b13** — clawback netting/recovery + real `INursePayoutStatus`.
|
||||
- **b15** — `tickets` FK on `refunds` (+ flip `refund_ticket_required` on), `partner_centers` on `invoices`,
|
||||
`nurse_payouts` FKs on `nurse_clawbacks`.
|
||||
Reference in New Issue
Block a user