6.7 KiB
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 perpayment_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_appliedsnapshot, forward-onlystatus.ticket_idis a nullable column with NO FK (tickets → b15).NurseClawbacks—status(pending|recovered|written_off; onlypending/written_offhere),amount_irr,refund_id(1:1 UNIQUE), nullableoriginal_payout_id/recovered_in_payout_id(no FK, → b13).Invoices— UNIQUEinvoice_number(sequential) + UNIQUEbooking_id;vat_rate/vat_irron the commission line;moadian_reference_number/moadian_status; nullablepartner_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).
- Pre-payout full refund —
POST admin_refunds {bookingId, refundPercentage:1}→refund_channel psp_card, legs1,500,000+8,500,000summing to10,000,000; ledger shows a balancedDEBIT platform_revenue+DEBIT nurse_payable/CREDIT refund_payable, and aDEBIT refund_payable/CREDIT escrow_heldclearing leg (5 legs, Σdebit=Σcredit); statussucceeded. - 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. - Invoice —
POST admin_invoices {bookingId}→ sequentialINV-0000000001,vat_irr = round(1,500,000 × 0.10) = 150,000(on the commission,0whenvat_rate = 0),moadian_status pending/ null ref; a second booking →INV-0000000002(gap-free). Re-issue returns the same invoice. - Post-payout clawback — with the nurse flagged paid (seed a past
dispute_window_ends_at, orrefund_assume_nurse_paid = true) → the payout leg debitsnurse_clawback_receivable(notnurse_payable), apendingnurse_clawbacksrow (amount_irr = 8,500,000) is created, and anurse_clawbacksupport alert is raised. Not auto-recovered (b13). - BNPL revert — seed a
bnplgateway →refund_channel bnpl_revert,IBnplProvider.RevertAsynccalled,external_revert_referencestored,expected_customer_refund_eta ≈ now + 10 business days, statusprocessing; the reversal ledger legs are identical to the card case.GET refunds/{id}/statusshows the ETA. - Write-off —
POST admin_clawbacks/{id}/write_off→written_off+DEBIT bad_debt/CREDIT nurse_clawback_receivable+resolved_at. - Worklist + tenancy —
GET admin_refunds?status=…lists channel/legs/ETA;GET refunds/{id}/statusas 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.jsonrefreshed. - 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_heldfor aprocessingrefund when the provider confirms the customer cash-back (webhook viaIWebhookVerifier; deferred/manual today). - b12 — real
IBnplProvider. - b13 — clawback netting/recovery + real
INursePayoutStatus. - b15 —
ticketsFK onrefunds(+ fliprefund_ticket_requiredon),partner_centersoninvoices,nurse_payoutsFKs onnurse_clawbacks.