38 KiB
Backend Phase 11 — Refunds, invoices & nurse clawbacks
Mission: make money flow backwards correctly. Build the admin-only refund engine that reverses a captured booking payment across both fee legs (platform commission vs nurse payout), posts the balanced reversal into the append-only ledger, and forks hard on one question — has the nurse already been paid? Pre-payout it is a clean
nurse_payablereversal; post-payout it opens a first-classnurse_clawbacksreceivable, because an Iranian IBAN transfer is effectively irreversible. Same phase adds the minimalinvoicesrecord (VAT on the commission line, sequential number, optional مودیان submission behind a seam). Refunds are admin-initiated, ticket-linked, channel-aware (card vs BNPL revert), and never customer self-service. After this phase, a cancellation can actually return money and the books stay balanced.Track: backend · Depends on: b10 (ledger / transactions / webhook idempotency / capture), b9 (cancellation policies / bookings / dispute window), b1 (VAT config / typed config accessor) · Unlocks: payout clawback netting (b13); frontend f10-b11 Before you start, read
../_shared/agent-operating-rules.md. It is not optional.
1. Context — where this sits
This is backend phase b11, the reversal leg of the payments arc (b10 ledger/capture → b11
refunds·invoices·clawbacks → b12 BNPL → b13 payouts). The platform never custodies cash: "escrow" is an
internal double-entry ledger state (product/payments/escrow-ledger.md),
and a booking's money already sits posted as escrow_held / platform_revenue / nurse_payable from
b10's capture. A refund un-does some or all of that. The hard problem is timing: if the nurse has not
yet been paid (the common case, because b13 gates payout on dispute_window_ends_at), the refund simply
reverses the nurse_payable accrual — nothing leaves Balinyaar. If the nurse has been paid, the money
is already gone to an irreversible bank transfer, so the refund becomes platform-funded and opens a
clawback receivable the next payout batch nets out. This phase also issues the minimal commission
invoice with config-driven VAT, because Iranian commission marketplaces owe VAT on their commission
(the Snapp/Tapsi precedent), not on the nurse's earnings.
What this phase does not do: it does not build the cancellation policy resolver or the
CancelBooking flow (that is b9 — this phase consumes the resolved policy snapshot); it does not
build the card/BNPL provider adapters (b10/b12 — this phase calls their refund/revert methods through
seams); it does not net or recover clawbacks into a payout (that is b13 — this phase only opens the
receivable + posts its ledger leg).
What already exists (do not rebuild) — built by prior phases:
- The ledger, transactions & webhook idempotency — b10 built
ledger_entries(append-only, balanced,transaction_group_id, the account types incl.escrow_held,platform_revenue,nurse_payable,refund_payable,nurse_clawback_receivable),payment_transactions(thesucceededcapturing row, filteredUNIQUE(booking_id) WHERE status='succeeded',UNIQUE(gateway_reference_code)),payment_webhook_events(UNIQUE(provider_code, external_event_id)), the card-capture posting (DEBIT escrow_heldgross /CREDIT platform_revenuecommission +nurse_payablepayout), the ledger posting helper, theIPaymentProviderseam (incl.RefundAsync), theIWebhookVerifierseam, and theIDistributedLockRedis-lock pattern on the money path. Reuse the ledger posting helper, the webhook idempotency path, theIPaymentProviderseam, and the lock — do not re-implement them. - Bookings, cancellation policies & the dispute window — b9 built
bookings(the three-amount splitgross_price_irr = balinyaar_commission_irr + nurse_payout_amount,platform_fee_ratesnapshot,dispute_window_ends_at),booking_sessions(visit_payout_amount,payout_eligible_at,cancellation_event_id),cancellation_policies(config-driven tiers by lead time × actor,code,is_active), and theCancelBooking/CancelSessioncommands that resolve the applicable policy and snapshotcancellation_policy_code+ the resolved refund percentage onto the cancellation event. This phase reads that resolved policy snapshot to populate the refund'scancellation_policy_code/refund_percentage_applied; it does not re-resolve policy from live config. - VAT config & the typed config accessor — b1's
platform_configstable with a typed, cached accessor (behindICacheService). Thevat_ratekey (default0.10) and any refund-ETA config are read through that accessor, never hardcoded. b1 also builtnotifications+ theINotificationDispatcherreal in-app write, andsupport_alerts. - The b0 foundation: the REST surface,
BaseController,OperationResult<T>, CQRS viamartinothamar/Mediator(ISender/ICommand/IQuery,internal sealedhandlers),IFieldEncryptor,ICurrentUser+ audit interceptor, rate limiting,IDateTimeProvider,IObjectStorage(for the invoice PDF key, optional), and the mock-report discipline.
What this phase introduces: the three tables (refunds, nurse_clawbacks, invoices), the
refund/clawback/invoice capabilities, and one new seam — IMoadianClient (the mocked سامانه مودیان
e-invoicing rail). The BNPL revert path targets the IBnplProvider.RevertAsync seam introduced in
b12; until b12 lands, the bnpl_revert channel is exercised through the same ledger legs with the
BNPL provider call behind its seam (see §3.6 + §4).
Forward dependency (tickets): refunds must link a
ticket_id, but theticketstable arrives in b15. Makerefunds.ticket_ida nullable FK now with the column + index in place, enforce "ticket required" as a validator/handler rule that is config-gated off until b15 (so admin refunds are testable today), and note the forward-dep in the report. b15 wires the real FK target and flips the rule on. Do not invent aticketstable in this phase.
2. Required reading (do this first)
../_shared/agent-operating-rules.mdand../_shared/backend-conventions-checklist.md— especially the Performance, caching, money, idempotency block (IRRBIGINT, append-only balanced ledger, idempotent money writes, Redis lock on the money path, config read through the typed accessor).product/business/07-cancellation-and-refunds.md— the business rules: tiered/snapshotted policy, admin-only + ticket-linked refunds, fee-leg decomposition, per-session (un-started only), pre- vs post-payout fork, BNPL-via-provider-revert-only, MVP vs DEFERRED (automated nurse-no-show penalty is a manual admin action; self-service partial-refund UI and holiday overrides are DEFERRED).product/payments/cancellation-and-payout.md— Q1 the BNPL refund unwind: money always flowscustomer ↔ provider ↔ Balinyaar, never direct;revert(full) vsupdate(partial, strictly-lower amount); the async 7–10 business-day customer window surfaced asexpected_customer_refund_eta;refund_status = processinguntil reconciled; the nullableprovider_commission_reversed_amount(do not hardcode whether the provider returns its commission); the same fee-leg decomposition applies.product/data-model/06-payments-ledger-and-refunds.md— the canonical schema forrefunds(the new 1:N cardinality,platform_fee_refunded_irr/nurse_payout_refunded_irr,refund_channel,external_revert_reference,expected_customer_refund_eta,cancellation_policy_code/refund_percentage_applied),nurse_clawbacks(status,original_payout_id,recovered_in_payout_id),invoices(invoice_numberUNIQUE,platform_commission_irrthe VAT-relevant line,vat_rate/vat_irr,moadian_reference_number/moadian_status), and the canonical postings table. Mirror these field names exactly.product/payments/escrow-ledger.md— the refund and clawback postings in depth (pre-payout reversal; therefund_payable↔escrow_heldconfirm step; the clawback receivable leg).- Code to mirror: b10's ledger posting helper +
IDistributedLockusage +payment_webhook_eventsidempotency + theIPaymentProvider/IWebhookVerifierseams + theFeatures/Payments/**command structure; b9'sbookings/cancellation_policiesconfigs and the policy-snapshot fields; b1's typed config accessor andINotificationDispatcher; b0'sIFieldEncryptor/IObjectStorage+ seam registration viaServiceConfiguration/extensions. - Contract conventions:
../../contracts/conventions/api-conventions.mdandmoney-and-types.md(IRRBIGINT, money as a digit-string on the wire, therefund_channelenum, masking, the envelope). - Prior handoffs:
dev/shared-working-context/backend/handoff/after-backend-phase-10.md,…-9.md,…-1.md, andreports/mocks-registry.md(seam rows you reuse / the one you add).
3. Scope — build this
All money is IRR long / BIGINT — no floats anywhere. Features live under
Baya.Application/Features/Refunds/{Commands|Queries}/<Name>/ (refunds + clawbacks) and
Baya.Application/Features/Invoices/{Commands|Queries}/<Name>/; entities in
Baya.Domain/Entities/Refunds/ and …/Invoices/; one IEntityTypeConfiguration<T> per entity in
Persistence/Configuration/RefundsConfig/ and …/InvoicesConfig/; one EF migration for the three tables.
3.1 Entities + migration
refunds [CORE] — admin-initiated, ticket-linked, 1:N per payment_transaction, fee-leg
decomposed, channel-aware.
- Fields (baseline + new, mirror data-model 06):
id,payment_transaction_id(FKpayment_transactions),booking_id(FKbookings),requested_by_customer_id(FKcustomer_profiles— the customer the refund is for, not the actor),ticket_id(FK NULLABLE — forward-dep onticketsin b15, see §1 callout),amount(BIGINT, the total refunded = fee leg + payout leg),refund_percentage(resolved %),reason_category,reason_notes,status, approval/rejection fields (approved_by_admin_id,rejected_reason),gateway_refund_reference(the PSP card-refund ref),processed_at(nullable),admin_notes, audit fields; plus the new decomposition/channel fields:platform_fee_refunded_irr(BIGINT) — the portion ofbalinyaar_commission_irrbeing reversed.nurse_payout_refunded_irr(BIGINT) — the portion ofnurse_payout_amountbeing reversed (drives a clawback if the nurse was already paid).refund_channel(enum) —psp_card|bnpl_revert|manual(the data-model also writesmanual_bank; usemanualas the canonical wire code permoney-and-types.md, and document the mapping).external_revert_reference(NVARCHAR(200) NULL) — the BNPL provider revert id.expected_customer_refund_eta(DATE NULL) — the ~7–10 business-day BNPL window, surfaced in UI/reconciliation; null for instant card refunds.cancellation_policy_code(NVARCHAR NULL) +refund_percentage_applied(DECIMAL NULL) — snapshot of the policy that produced this refund (read from b9's cancellation event; never re-resolved live).
refund_statusenum (status):requested|approved|processing|succeeded|failed|rejected. (processingis the state a BNPL revert sits in until the reconciliation job confirms the customer cash-back.)- Cardinality / invariant: 1:N per
payment_transaction. The app invariantΣ refunded ≤ capturedis enforced in the handler (sum of prior succeeded/processing refundamountfor the transaction + this one ≤ the capturedpayment_transactions.amount) — it is not a single-row DB CHECK. Likewiseamount = platform_fee_refunded_irr + nurse_payout_refunded_irr(handler invariant + a CHECK where SQL Server allows). - Relations: N:1 →
payment_transactions,bookings,customer_profiles,tickets(nullable); 1:1 →nurse_clawbacks(only when refunding a booking whose nurse was already paid).
nurse_clawbacks [CORE] — first-class receivable when a booking is refunded/disputed after the
nurse was already paid.
- Fields (mirror data-model 06):
id,nurse_id(FKnurse_profiles),booking_id(FKbookings),refund_id(FKrefunds),original_payout_id(FKnurse_payoutsNULL —nurse_payoutsarrives in b13, so this FK is nullable now and the column/index are in place; the value is set once b13 exists),amount_irr(BIGINT — equals thenurse_payout_refunded_irrleg),status,recovered_in_payout_id(FKnurse_payoutsNULL — set by b13 when a batch nets it; this phase only ever leaves it null/pending),created_at,resolved_at(nullable), audit fields. clawback_statusenum (status):pending|recovered|written_off. This phase only ever creates rows inpending(and supports an adminwrite_off);recoveredis set by b13's payout netting — do not implement recovery here.- Relations: N:1 →
nurse_profiles,bookings; 1:1 →refunds; →nurse_payouts(original + recovering, both nullable until b13).
invoices [MVP] — minimal official receipt per booking; VAT on the commission line only.
- Fields (mirror data-model 06):
id,booking_id(FKbookings),invoice_number(NVARCHAR(40) UNIQUE — official, sequential),issuing_entity_type(platform|partner_center),gross_irr(BIGINT),platform_commission_irr(BIGINT — the VAT-relevant line),bnpl_commission_irr(BIGINT NULL),vat_rate(DECIMAL(5,4) — read from config, default0.10),vat_irr(BIGINT — computedround(platform_commission_irr * vat_rate), integer-only),moadian_reference_number(NVARCHAR(40) NULL — the 22-digit سامانه مودیان ref when issued),moadian_status(NVARCHAR(20) NULL —pending|submitted|registered|failed),pdf_storage_key(NVARCHAR(512) NULL — anIObjectStoragekey),issued_at(DATETIME2), audit fields. invoice_numberis UNIQUE and sequential — generate it from a gap-free, concurrency-safe sequence (a dedicated DB sequence / a locked counter row), never a random or timestamp-derived value. Relate 1:1 →bookings; N:1 →partner_centers(when the issuer is a partner center).
3.2 Commands & queries (CQRS, OperationResult, never throw for expected failures)
| Capability | Type | Route (admin/customer) | What it does |
|---|---|---|---|
CreateRefundCommand |
Command | POST api/v1/admin_refunds |
Admin-only. Validates the booking has a captured (succeeded) payment_transaction; requires a ticket_id (config-gated off until b15, see §1); reads the resolved cancellation policy snapshot from b9's cancellation event for the booking to populate cancellation_policy_code / refund_percentage_applied; decomposes the refund into platform_fee_refunded_irr + nurse_payout_refunded_irr (pro-rata of the booking's balinyaar_commission_irr / nurse_payout_amount at the resolved %, or admin-supplied legs that must still sum to amount); enforces Σ refunded ≤ captured; picks refund_channel from the original payment type (psp_card for card, bnpl_revert for BNPL, manual for an out-of-band bank refund); creates the refunds row in requested/approved. Under lock(booking:{id}:refund). Then dispatches the channel execution + ledger posting (below). |
ExecuteRefundChannelCommand |
Command (internal step) | — | Calls the channel: card → IPaymentProvider.RefundAsync(gatewayReferenceCode, amountIrr, idempotencyKey, ct) (channel psp_card), storing gateway_refund_reference, status → succeeded (card refunds are effectively immediate, expected_customer_refund_eta = null); BNPL → IBnplProvider.RevertAsync(...) (b12 seam; full = revert, partial/shortened = update with a strictly-lower amount), storing external_revert_reference, setting expected_customer_refund_eta = now + config(bnpl_refund_eta_business_days, 10) (business-day shifted), status stays processing until the reconciliation job/webhook confirms cash-back; manual → records the admin-entered bank ref, status processing/succeeded per admin. Carries an idempotencyKey so a retried call never double-refunds. |
PostRefundLedgerCommand |
Command (internal step) | — | Pre-payout path (nurse not yet paid): posts the balanced reversal in one transaction_group_id — DEBIT platform_revenue platform_fee_refunded_irr + DEBIT nurse_payable nurse_payout_refunded_irr, CREDIT refund_payable (sum). When the provider confirms the customer cash-back (card immediately; BNPL via reconciliation), a second balanced posting clears refund_payable ↔ escrow_held (DEBIT refund_payable / CREDIT escrow_held). Uses b10's posting helper; append-only; Σdebit = Σcredit. |
CreateClawbackCommand |
Command (internal step) | — | Post-payout path (nurse already paid — detected via b13's nurse_payout_booking_links for the booking, or, until b13 exists, a config/flag indicating the booking was paid): instead of debiting nurse_payable, posts DEBIT nurse_clawback_receivable nurse_payout_refunded_irr (+ the DEBIT platform_revenue fee leg) / CREDIT refund_payable, and creates a nurse_clawbacks row in pending (nurse_id, booking_id, refund_id, amount_irr = nurse_payout_refunded_irr, original_payout_id when available). Raises a support_alert (b1) on every clawback. Does not net or recover it — that is b13. |
WriteOffClawbackCommand |
Command | POST api/v1/admin_clawbacks/{id}/write_off |
Admin marks a pending clawback written_off (uncollectable) with a reason; posts the balancing ledger correction (DEBIT bad_debt / CREDIT nurse_clawback_receivable) and sets resolved_at. (Recovery via payout netting is b13.) |
IssueInvoiceCommand |
Command | POST api/v1/admin_invoices (and reused on confirmation) |
Creates an invoices row for a booking: sequential invoice_number from the safe sequence; copies gross_irr / platform_commission_irr / bnpl_commission_irr from the booking; reads vat_rate from config (default 0.10); computes vat_irr = round(platform_commission_irr * vat_rate) (integer-only, VAT on the commission only, never the nurse's earnings — set vat_irr = 0 when a medical-service exemption sets vat_rate = 0); attempts IMoadianClient.SubmitAsync which (mock) returns no ref → moadian_reference_number = null, moadian_status = pending. Idempotent per booking (one issued invoice per booking; re-issue returns the existing). |
ListRefundsQuery |
Query | GET api/v1/admin_refunds?booking_id=&status=&page=&page_size= |
Admin refund worklist: projected (AsNoTracking + .Select) + paginated; surfaces channel, decomposed legs, status, expected_customer_refund_eta, the policy snapshot. |
GetRefundStatusQuery |
Query | GET api/v1/refunds/{id}/status (customer-visible, tenancy-scoped) |
The customer-facing status of their refund: status, refund_channel, amount, and expected_customer_refund_eta (the BNPL 7–10-business-day window) — so f10 can show "on its way, ~N days". Tenancy-scoped to the booking's customer via ICurrentUser. |
GetInvoiceQuery |
Query | GET api/v1/invoices/{booking_id} (customer/admin) |
The booking's invoice: invoice_number, gross_irr, platform_commission_irr, vat_rate, vat_irr, moadian_status, and a pdf_storage_key-derived download URL when present. Tenancy-scoped. |
- Cancellation integration (b9 → refund): b9's
CancelBooking/CancelSessionresolves the policy and computes the refundable amount per un-started session. This phase exposesCreateRefundCommandas the money-side of that flow — b9 (or admin) calls it with the booking/session, the resolved %, and the ticket. Do not duplicate the policy resolver; consume its snapshot. - Controllers:
AdminRefundsController(admin policy; refund endpoints rate-limited — refund-sensitive perCONVENTIONS.md§11),AdminClawbacksController(admin policy),AdminInvoicesController(admin policy), and a customer-facingRefundsController/InvoicesController(authenticated, tenancy-scoped) forGetRefundStatusQuery/GetInvoiceQuery. Allsealed : BaseController, injectISender, returnbase.OperationResult(...), snake_case[controller]/[action]routes,CancellationTokenthreaded. - Validators: FluentValidation on
CreateRefundCommand(positiveamount; legs sum toamount;amount > 0;ticket_idrequired when the gate is on; channel matches the transaction type) and the id-bearing commands.
3.3 DEFERRED (build the seam/flag, not the feature)
- Clawback recovery / netting into a payout — DEFERRED to b13. This phase
only opens the
pendingreceivable + supportswrite_off. Leaverecovered_in_payout_id/original_payout_idas the (nullable) join points b13 fills. - Automated nurse no-show penalty / forfeiture — a manual admin action at launch per
product/business/07-cancellation-and-refunds.md§(c); do not automate. The admin usesCreateRefundCommand(full customer refund) and records the nurse penalty manually. - Self-service partial-refund UI and holiday-specific cancellation overrides — DEFERRED (no customer refund-initiation path; the policy override model is out of scope).
- Real مودیان automation — DEFERRED; the seam returns a null/pending ref now (see §4). The
reconciliation job that flips
moadian_statustoregisteredand the BNPL-revert reconciliation job that clearsrefund_payable ↔ escrow_heldare thin/manual-trigger now; note the cron in the report.
4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
|---|---|---|---|
IMoadianClient |
introduced here | SubmitAsync(InvoiceSubmission, ct) → leaves moadian_reference_number = null, returns moadian_status = pending (no external call). A config switch can force a deterministic registered (with a fake 22-digit ref) so the reconciliation/registered path is testable. The real سامانه مودیان adapter is a drop-in. |
add a new row (🟡) |
IPaymentProvider |
reuse from b10 | RefundAsync(gatewayReferenceCode, amountIrr, idempotencyKey, ct) → deterministic gateway_refund_reference, instant Succeeded, echoes amount; channel psp_card. |
reuse row |
IBnplProvider |
reuse from b12 | RevertAsync / UpdateAsync → echoes the reverted/new amount, returns an external_revert_reference + nullable provider_commission_reversed_amount, settledAt-style lag; channel bnpl_revert. Until b12 lands, register a thin local mock behind this interface so the bnpl_revert path is exercised; b12 owns the real seam definition. |
reuse row (note pre-b12) |
IWebhookVerifier |
reuse from b10 | verifies the async BNPL cash-back/reconciliation callback that flips a processing refund to succeeded and posts the refund_payable ↔ escrow_held clearing leg. |
reuse row |
IDistributedLock |
reuse from b10 | in-memory mock lock; lock(booking:{id}:refund) around the whole refund money-path so a cancellation-driven and a webhook-driven refund can't both fire (keeps Σ refunded ≤ captured). |
reuse row |
IFieldEncryptor |
reuse from b0 | local symmetric key; never logs plaintext. | reuse row |
INotificationDispatcher |
reuse from b1 | in-app write; notifies the customer on refund issued/completed; raises a support_alert on every clawback. |
reuse row |
ICacheService |
reuse from b0/b1 | in-memory; behind the typed config accessor (vat_rate, ETA config). |
reuse row |
IObjectStorage |
reuse from b0 | local-disk/in-memory; stores the optional invoice pdf_storage_key. |
reuse row |
The mock lives behind a DI-registered interface in Infrastructure (real impl is a drop-in later);
provider/مودیان selection is config-driven, never an if (mock) branch in a handler. Append the
IMoadianClient row to
../../shared-working-context/reports/mocks-registry.md
(seam, file, what's faked, config keys, step-by-step how to make it real — سامانه مودیان enrollment,
the معاملات/invoice submission API, the 22-digit reference shape, the pending → submitted → registered
reconciliation callback). Confirm the BNPL IBnplProvider row notes the pre-b12 local stub if you add one.
5. Critical rules you must not get wrong
Money correctness is sacred — the following must hold verbatim:
- Money is IRR
BIGINT, no floats, ever. Every amount (amount,platform_fee_refunded_irr,nurse_payout_refunded_irr,amount_irr,gross_irr,platform_commission_irr,vat_irr) islong/BIGINT. VAT isround(platform_commission_irr * vat_rate)computed integer-only; no float path. Toman conversion happens only inside a provider adapter at its boundary. - Gross = commission + payout. A refund decomposes across both fee legs —
amount = platform_fee_refunded_irr + nurse_payout_refunded_irr, derived pro-rata from the booking'sbalinyaar_commission_irr/nurse_payout_amountat the resolved %. The two legs are never conflated and must always sum to the refundedamount. Σ refunded ≤ captured(handler invariant). Refunds are 1:N perpayment_transaction; the sum of all succeeded/processing refunds for a transaction may never exceed the captured amount. Enforce in the handler underlock(booking:{id}:refund); the lock is the fast first line, the summed check is the authoritative backstop.- Append-only, balanced ledger. Every refund/clawback posts balanced legs (Σdebit = Σcredit) under one
transaction_group_id, via b10's helper. Never UPDATE/DELETE a ledger row; corrections (e.g. a write-off) are new balancing postings. Balances are derived by filteringledger_entries, never a stored column. - Refund-before-payout is a clean reversal; refund-after-payout drives a
nurse_clawbacksreceivable. Pre-payout:DEBIT platform_revenue+DEBIT nurse_payable/CREDIT refund_payable. Post-payout:DEBIT nurse_clawback_receivable(+DEBIT platform_revenue) /CREDIT refund_payableand apendingnurse_clawbacksrow — because an Iranian IBAN transfer is irreversible, so the money is already gone and must be recorded as owed-back, never silently absorbed. Gate payout ondispute_window_ends_at(b9/b13) so the pre-payout path is the common one; the clawback is the fallback, not the plan. - Refunds are admin-only (no customer self-service) and must link a
ticket_id. There is no customer refund-initiation path — onlyGetRefundStatusQueryis customer-visible. Theticket_idrequirement is enforced (config-gated until b15 shipstickets); the FK is nullable now only for that forward-dep. - VAT applies to the platform COMMISSION only — never the nurse's earnings.
vat_irris computed onplatform_commission_irrwith a config-driven rate (default0.10); the nurse is the taxable seller of the care service (Snapp/Tapsi precedent). Avat_rate = 0exemption setsvat_irr = 0. Never apply VAT tonurse_payout_amount. invoice_numberis unique + sequential. Generate gap-free from a concurrency-safe sequence/locked counter — never random or timestamp-derived. One issued invoice per booking (idempotent).- Card refund and
bnpl_revertpost the SAME ledger legs. The only differences arerefund_channel, the external reference (gateway_refund_referencevsexternal_revert_reference), and the ETA (expected_customer_refund_etanull for card vs ~7–10 business days for BNPL, withstatus = processinguntil reconciled). Do not branch the ledger on channel — only the execution + metadata. - BNPL refunds go through the provider revert API only. Money always flows
customer ↔ provider ↔ Balinyaar— never nurse→customer or Balinyaar→customer direct. Full =revert, partial/shortened =update(strictly-lower amount). The provider's own commission reversal isprovider_commission_reversed_amount— nullable, reconciled from the response, never hardcoded. - Idempotency on the money path. Channel calls carry an
idempotencyKey; the async cash-back confirmation flows throughpayment_webhook_events(UNIQUE(provider_code, external_event_id)) so a replayed "refunded"/"reverted" callback can't double-clearrefund_payableor double-post. The refundstatusstate machine (requested → approved → processing → succeeded|failed) is forward-only. - Tenancy & scope.
GetRefundStatusQuery/GetInvoiceQueryare scoped to the booking's customer viaICurrentUser; a customer can never read another customer's refund/invoice. All create/write endpoints sit behind the admin policy and are rate-limited.
6. Definition of Done
The shared definition-of-done.md, plus:
- The three tables (
refunds,nurse_clawbacks,invoices) exist via one migration, each with itsIEntityTypeConfiguration<T>:refundswith the nullableticket_idFK, the decomposition columns,refund_channel, theamount = fee_leg + payout_legCHECK where possible;nurse_clawbackswith nullableoriginal_payout_id/recovered_in_payout_id;invoiceswithinvoice_numberUNIQUE + the sequential generator andvat_irron the commission line; soft-delete/audit wiring. - All §3.2 commands/queries implemented (CQRS,
OperationResult, projected + paginated reads, validators), with the admin + customer controllers. IMoadianClientintroduced (Application interface, Infrastructure mock, DI registration via aServiceConfiguration/extension, config-selected). Noif (mock)in handlers.IBnplProviderreused (with a noted pre-b12 local stub if b12 isn't merged),IPaymentProvider/IWebhookVerifier/IDistributedLock/INotificationDispatcherreused.- Refund decomposition +
Σ refunded ≤ capturedcorrect; the pre-payout reversal and the post-payout clawback both post balanced ledger groups; therefund_payable ↔ escrow_heldclearing posts on confirm; the invoice computesvat_irrfrom config on the commission with a sequential number. - Handler unit tests (NSubstitute) for: pre-payout balanced reversal; partial-refund leg decomposition
+
Σ refunded ≤ capturedrejection; post-payout clawback creation + receivable leg; invoice VAT computed from config + sequential numbering; channel parity (card vs bnpl_revert same legs). ≥1WebApplicationFactoryintegration test per controller (happy path, 401, validation 400, 409 on over-refund).dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen. - The
Baya.Application/Features/Refunds/**+…/Invoices/**areas are reflected in the Project map inserver/CLAUDE.md; theIMoadianClientseam noted where seams are documented; theticketsforward-dep and themanual/manual_bankchannel-code decision recorded. - The contract
dev/contracts/domains/refunds-invoices.mdwritten and theswagger.jsonsnapshot republished.
7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a booking with a captured card payment (b10) and a resolved cancellation policy snapshot (b9), plus one booking flagged as already-paid-to-nurse and one BNPL booking.
- Pre-payout full refund (clean reversal) —
POST api/v1/admin_refundsfor the captured card booking with the resolved % and a ticket → arefundsrow withrefund_channel = psp_card, decomposedplatform_fee_refunded_irr+nurse_payout_refunded_irrsumming toamount; the ledger shows a balancedDEBIT platform_revenue+DEBIT nurse_payable/CREDIT refund_payable, and on confirm aDEBIT refund_payable/CREDIT escrow_heldclearing leg (Σdebit = Σcredit); status →succeeded. - Partial refund + over-refund guard — issue a partial refund (e.g. 50%): legs decompose
correctly and sum to the partial
amount;Σ refundedfor the transaction stays ≤ captured. Then attempt a second refund that would push the total over the captured amount → rejected with409(or validation400); no ledger posting occurs. - Issue an invoice —
POST api/v1/admin_invoicesfor the booking → aninvoicesrow with a sequentialinvoice_number,vat_irr = round(platform_commission_irr * 0.10)(verify it is computed from config on the commission, not the nurse payout, andvat_irr = 0whenvat_rateis set to 0);moadian_reference_number = null,moadian_status = pending. Issue a second invoice for a second booking → the number is the next in sequence (gap-free, unique). - Refund on an already-paid booking (clawback) —
POST api/v1/admin_refundsfor the already-paid-to-nurse booking → instead of debitingnurse_payable, the ledger postsDEBIT nurse_clawback_receivable(+DEBIT platform_revenue) /CREDIT refund_payable, anurse_clawbacksrow is created inpending(amount_irr = nurse_payout_refunded_irr), and asupport_alertis raised. Confirm it is not auto-recovered (recovery is b13). - BNPL revert (channel parity + ETA) —
POST api/v1/admin_refundsfor the BNPL booking →refund_channel = bnpl_revert,IBnplProvider.RevertAsynccalled,external_revert_referencestored,expected_customer_refund_eta≈ now + 10 business days, statusprocessing; the ledger legs are identical to the card case.GET api/v1/refunds/{id}/statusas the customer shows the ETA window. - Write-off —
POST api/v1/admin_clawbacks/{id}/write_off→ thependingclawback →written_offwith a balancingDEBIT bad_debt/CREDIT nurse_clawback_receivableandresolved_atset. - Admin worklist + tenancy —
GET api/v1/admin_refunds?status=processinglists channel/legs/ETA;GET api/v1/refunds/{id}/statusas a different customer is not visible (403/404).
8. Hand off & document (close the phase)
- Docs to update: the Project map in
server/CLAUDE.md(add theFeatures/Refunds/**+Features/Invoices/**areas + theIMoadianClientseam). If you discover/confirm a rule the product docs don't capture — e.g. the canonicalmanualvsmanual_bankchannel code, thebnpl_refund_eta_business_daysdefault, thevat_rate = 0exemption behaviour, or theticket_id-config-gate until b15 — record it inproduct/business/07-cancellation-and-refunds.md/product/data-model/06-payments-ledger-and-refunds.md(and regenerate the HTML view perproduct/CLAUDE.md). Don't invent rules. - Contract to write:
dev/contracts/domains/refunds-invoices.md(per../../contracts/domains/_TEMPLATE.md) — the admin refund/ clawback/invoice endpoints (create refund, write-off clawback, issue invoice, list refunds) and the customer-facingrefunds/{id}/status+invoices/{booking_id}; therefund_status/refund_channel/clawback_status/moadian_statusenums; the refund/invoice DTO shapes (IRRBIGINTas digit-strings, the decomposed legs, masked references,expected_customer_refund_eta); auth/rate-limit/idempotency notes; the admin-only + ticket-link + dispute-window/clawback side-effects. Republish theswagger.jsonsnapshot per../../contracts/openapi/README.md. This is what f10-b11 consumes. - Handoff & report: write
dev/shared-working-context/backend/handoff/after-backend-phase-11.md(the refund/clawback/invoice engine is live, what f10 can now build — admin refund console + the customer-facing cancellation/refund-status + invoice views — which endpoints/contracts are live, that مودیان is mocked behindIMoadianClient, that clawback recovery waits on b13 andticketson b15), append tobackend/STATUS.md, writedev/shared-working-context/reports/backend-phase-11-report.md(what was built, what is now testable and exactly how per §7, what is mocked + how to make it real, contracts produced, follow-ups: the مودیان reconciliation cron, the BNPL-revert reconciliation cron clearingrefund_payable ↔ escrow_held, clawback netting in b13, theticketsFK wire-up in b15), and updatedev/shared-working-context/reports/mocks-registry.md(theIMoadianClientrow → 🟡; reconfirm the reused seam rows). - Memory: save a
projectmemory note for the non-obvious decisions this phase fixes — the fee-leg/payout-leg decomposition, the pre-payout reversal vs post-payout clawback fork (and why Iranian transfers are irreversible), therefund_payable ↔ escrow_heldtwo-step clearing, channel parity (card vs bnpl_revert post the same legs), VAT-on-commission-only with a config rate, the sequentialinvoice_numbergenerator, and thetickets/nurse_payoutsforward-dep nullable FKs — with a one-line pointer inMEMORY.md.