Files
baya-monorepo/archive/build-chain/working-context/reports/backend-phase-11-report.md
T
2026-08-02 18:48:32 +03:30

6.7 KiB
Raw Blame History

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).
  • NurseClawbacksstatus (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 refundPOST 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. InvoicePOST 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-offPOST admin_clawbacks/{id}/write_offwritten_off + DEBIT bad_debt / CREDIT nurse_clawback_receivable + resolved_at.
  7. Worklist + tenancyGET 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.mdIMoadianClient (سامانه مودیان 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.
  • b15tickets FK on refunds (+ flip refund_ticket_required on), partner_centers on invoices, nurse_payouts FKs on nurse_clawbacks.