Files
baya-monorepo/dev/phases/backend/backend-phase-11.md
T
2026-06-28 21:59:59 +03:30

38 KiB
Raw Blame History

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_payable reversal; post-payout it opens a first-class nurse_clawbacks receivable, because an Iranian IBAN transfer is effectively irreversible. Same phase adds the minimal invoices record (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 idempotencyb10 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 (the succeeded capturing row, filtered UNIQUE(booking_id) WHERE status='succeeded', UNIQUE(gateway_reference_code)), payment_webhook_events (UNIQUE(provider_code, external_event_id)), the card-capture posting (DEBIT escrow_held gross / CREDIT platform_revenue commission + nurse_payable payout), the ledger posting helper, the IPaymentProvider seam (incl. RefundAsync), the IWebhookVerifier seam, and the IDistributedLock Redis-lock pattern on the money path. Reuse the ledger posting helper, the webhook idempotency path, the IPaymentProvider seam, and the lock — do not re-implement them.
  • Bookings, cancellation policies & the dispute windowb9 built bookings (the three-amount split gross_price_irr = balinyaar_commission_irr + nurse_payout_amount, platform_fee_rate snapshot, 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 the CancelBooking / CancelSession commands that resolve the applicable policy and snapshot cancellation_policy_code + the resolved refund percentage onto the cancellation event. This phase reads that resolved policy snapshot to populate the refund's cancellation_policy_code / refund_percentage_applied; it does not re-resolve policy from live config.
  • VAT config & the typed config accessorb1's platform_configs table with a typed, cached accessor (behind ICacheService). The vat_rate key (default 0.10) and any refund-ETA config are read through that accessor, never hardcoded. b1 also built notifications + the INotificationDispatcher real in-app write, and support_alerts.
  • The b0 foundation: the REST surface, BaseController, OperationResult<T>, CQRS via martinothamar/Mediator (ISender/ICommand/IQuery, internal sealed handlers), 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 the tickets table arrives in b15. Make refunds.ticket_id a 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 a tickets table in this phase.

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/backend-conventions-checklist.md — especially the Performance, caching, money, idempotency block (IRR BIGINT, 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.mdthe 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.mdQ1 the BNPL refund unwind: money always flows customer ↔ provider ↔ Balinyaar, never direct; revert (full) vs update (partial, strictly-lower amount); the async 710 business-day customer window surfaced as expected_customer_refund_eta; refund_status = processing until reconciled; the nullable provider_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.mdthe canonical schema for refunds (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_number UNIQUE, platform_commission_irr the 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; the refund_payableescrow_held confirm step; the clawback receivable leg).
  • Code to mirror: b10's ledger posting helper + IDistributedLock usage + payment_webhook_events idempotency + the IPaymentProvider/IWebhookVerifier seams + the Features/Payments/** command structure; b9's bookings/cancellation_policies configs and the policy-snapshot fields; b1's typed config accessor and INotificationDispatcher; b0's IFieldEncryptor/IObjectStorage + seam registration via ServiceConfiguration/ extensions.
  • Contract conventions: ../../contracts/conventions/api-conventions.md and money-and-types.md (IRR BIGINT, money as a digit-string on the wire, the refund_channel enum, masking, the envelope).
  • Prior handoffs: dev/shared-working-context/backend/handoff/after-backend-phase-10.md, …-9.md, …-1.md, and reports/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 (FK payment_transactions), booking_id (FK bookings), requested_by_customer_id (FK customer_profiles — the customer the refund is for, not the actor), ticket_id (FK NULLABLE — forward-dep on tickets in 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 of balinyaar_commission_irr being reversed.
    • nurse_payout_refunded_irr (BIGINT) — the portion of nurse_payout_amount being reversed (drives a clawback if the nurse was already paid).
    • refund_channel (enum) — psp_card | bnpl_revert | manual (the data-model also writes manual_bank; use manual as the canonical wire code per money-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 ~710 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_status enum (status): requested | approved | processing | succeeded | failed | rejected. (processing is 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 ≤ captured is enforced in the handler (sum of prior succeeded/processing refund amount for the transaction + this one ≤ the captured payment_transactions.amount) — it is not a single-row DB CHECK. Likewise amount = 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 (FK nurse_profiles), booking_id (FK bookings), refund_id (FK refunds), original_payout_id (FK nurse_payouts NULLnurse_payouts arrives 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 the nurse_payout_refunded_irr leg), status, recovered_in_payout_id (FK nurse_payouts NULL — set by b13 when a batch nets it; this phase only ever leaves it null/pending), created_at, resolved_at (nullable), audit fields.
  • clawback_status enum (status): pending | recovered | written_off. This phase only ever creates rows in pending (and supports an admin write_off); recovered is 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 (FK bookings), 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, default 0.10), vat_irr (BIGINT — computed round(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 — an IObjectStorage key), issued_at (DATETIME2), audit fields.
  • invoice_number is 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: cardIPaymentProvider.RefundAsync(gatewayReferenceCode, amountIrr, idempotencyKey, ct) (channel psp_card), storing gateway_refund_reference, status → succeeded (card refunds are effectively immediate, expected_customer_refund_eta = null); BNPLIBnplProvider.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_idDEBIT 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_payableescrow_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 710-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 / CancelSession resolves the policy and computes the refundable amount per un-started session. This phase exposes CreateRefundCommand as 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 per CONVENTIONS.md §11), AdminClawbacksController (admin policy), AdminInvoicesController (admin policy), and a customer-facing RefundsController / InvoicesController (authenticated, tenancy-scoped) for GetRefundStatusQuery / GetInvoiceQuery. All sealed : BaseController, inject ISender, return base.OperationResult(...), snake_case [controller] / [action] routes, CancellationToken threaded.
  • Validators: FluentValidation on CreateRefundCommand (positive amount; legs sum to amount; amount > 0; ticket_id required 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 pending receivable + supports write_off. Leave recovered_in_payout_id / original_payout_id as 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 uses CreateRefundCommand (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_status to registered and the BNPL-revert reconciliation job that clears refund_payable ↔ escrow_held are 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) is long/BIGINT. VAT is round(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 legsamount = platform_fee_refunded_irr + nurse_payout_refunded_irr, derived pro-rata from the booking's balinyaar_commission_irr / nurse_payout_amount at the resolved %. The two legs are never conflated and must always sum to the refunded amount.
  • Σ refunded ≤ captured (handler invariant). Refunds are 1:N per payment_transaction; the sum of all succeeded/processing refunds for a transaction may never exceed the captured amount. Enforce in the handler under lock(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 filtering ledger_entries, never a stored column.
  • Refund-before-payout is a clean reversal; refund-after-payout drives a nurse_clawbacks receivable. Pre-payout: DEBIT platform_revenue + DEBIT nurse_payable / CREDIT refund_payable. Post-payout: DEBIT nurse_clawback_receivable (+ DEBIT platform_revenue) / CREDIT refund_payable and a pending nurse_clawbacks row — 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 on dispute_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 — only GetRefundStatusQuery is customer-visible. The ticket_id requirement is enforced (config-gated until b15 ships tickets); the FK is nullable now only for that forward-dep.
  • VAT applies to the platform COMMISSION only — never the nurse's earnings. vat_irr is computed on platform_commission_irr with a config-driven rate (default 0.10); the nurse is the taxable seller of the care service (Snapp/Tapsi precedent). A vat_rate = 0 exemption sets vat_irr = 0. Never apply VAT to nurse_payout_amount.
  • invoice_number is 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_revert post the SAME ledger legs. The only differences are refund_channel, the external reference (gateway_refund_reference vs external_revert_reference), and the ETA (expected_customer_refund_eta null for card vs ~710 business days for BNPL, with status = processing until 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 ↔ Balinyaarnever nurse→customer or Balinyaar→customer direct. Full = revert, partial/shortened = update (strictly-lower amount). The provider's own commission reversal is provider_commission_reversed_amountnullable, reconciled from the response, never hardcoded.
  • Idempotency on the money path. Channel calls carry an idempotencyKey; the async cash-back confirmation flows through payment_webhook_events (UNIQUE(provider_code, external_event_id)) so a replayed "refunded"/"reverted" callback can't double-clear refund_payable or double-post. The refund status state machine (requested → approved → processing → succeeded|failed) is forward-only.
  • Tenancy & scope. GetRefundStatusQuery / GetInvoiceQuery are scoped to the booking's customer via ICurrentUser; 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 its IEntityTypeConfiguration<T>: refunds with the nullable ticket_id FK, the decomposition columns, refund_channel, the amount = fee_leg + payout_leg CHECK where possible; nurse_clawbacks with nullable original_payout_id / recovered_in_payout_id; invoices with invoice_number UNIQUE + the sequential generator and vat_irr on the commission line; soft-delete/audit wiring.
  • All §3.2 commands/queries implemented (CQRS, OperationResult, projected + paginated reads, validators), with the admin + customer controllers.
  • IMoadianClient introduced (Application interface, Infrastructure mock, DI registration via a ServiceConfiguration/ extension, config-selected). No if (mock) in handlers. IBnplProvider reused (with a noted pre-b12 local stub if b12 isn't merged), IPaymentProvider/IWebhookVerifier/ IDistributedLock/INotificationDispatcher reused.
  • Refund decomposition + Σ refunded ≤ captured correct; the pre-payout reversal and the post-payout clawback both post balanced ledger groups; the refund_payable ↔ escrow_held clearing posts on confirm; the invoice computes vat_irr from config on the commission with a sequential number.
  • Handler unit tests (NSubstitute) for: pre-payout balanced reversal; partial-refund leg decomposition + Σ refunded ≤ captured rejection; post-payout clawback creation + receivable leg; invoice VAT computed from config + sequential numbering; channel parity (card vs bnpl_revert same legs). ≥1 WebApplicationFactory integration test per controller (happy path, 401, validation 400, 409 on over-refund). dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green.
  • The Baya.Application/Features/Refunds/** + …/Invoices/** areas are reflected in the Project map in server/CLAUDE.md; the IMoadianClient seam noted where seams are documented; the tickets forward-dep and the manual/manual_bank channel-code decision recorded.
  • The contract dev/contracts/domains/refunds-invoices.md written and the swagger.json snapshot 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.

  1. Pre-payout full refund (clean reversal)POST api/v1/admin_refunds for the captured card booking with the resolved % and a ticket → a refunds row with refund_channel = psp_card, decomposed platform_fee_refunded_irr + nurse_payout_refunded_irr summing to amount; the ledger shows a balanced DEBIT platform_revenue + DEBIT nurse_payable / CREDIT refund_payable, and on confirm a DEBIT refund_payable / CREDIT escrow_held clearing leg (Σdebit = Σcredit); status → succeeded.
  2. Partial refund + over-refund guard — issue a partial refund (e.g. 50%): legs decompose correctly and sum to the partial amount; Σ refunded for the transaction stays ≤ captured. Then attempt a second refund that would push the total over the captured amount → rejected with 409 (or validation 400); no ledger posting occurs.
  3. Issue an invoicePOST api/v1/admin_invoices for the booking → an invoices row with a sequential invoice_number, vat_irr = round(platform_commission_irr * 0.10) (verify it is computed from config on the commission, not the nurse payout, and vat_irr = 0 when vat_rate is 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).
  4. Refund on an already-paid booking (clawback)POST api/v1/admin_refunds for the already-paid-to-nurse booking → instead of debiting nurse_payable, the ledger posts DEBIT nurse_clawback_receivable (+ DEBIT platform_revenue) / CREDIT refund_payable, a nurse_clawbacks row is created in pending (amount_irr = nurse_payout_refunded_irr), and a support_alert is raised. Confirm it is not auto-recovered (recovery is b13).
  5. BNPL revert (channel parity + ETA)POST api/v1/admin_refunds for the BNPL booking → refund_channel = bnpl_revert, IBnplProvider.RevertAsync called, external_revert_reference stored, expected_customer_refund_eta ≈ now + 10 business days, status processing; the ledger legs are identical to the card case. GET api/v1/refunds/{id}/status as the customer shows the ETA window.
  6. Write-offPOST api/v1/admin_clawbacks/{id}/write_off → the pending clawback → written_off with a balancing DEBIT bad_debt / CREDIT nurse_clawback_receivable and resolved_at set.
  7. Admin worklist + tenancyGET api/v1/admin_refunds?status=processing lists channel/legs/ETA; GET api/v1/refunds/{id}/status as 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 the Features/Refunds/** + Features/Invoices/** areas + the IMoadianClient seam). If you discover/confirm a rule the product docs don't capture — e.g. the canonical manual vs manual_bank channel code, the bnpl_refund_eta_business_days default, the vat_rate = 0 exemption behaviour, or the ticket_id-config-gate until b15 — record it in product/business/07-cancellation-and-refunds.md / product/data-model/06-payments-ledger-and-refunds.md (and regenerate the HTML view per product/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-facing refunds/{id}/status + invoices/{booking_id}; the refund_status / refund_channel / clawback_status / moadian_status enums; the refund/invoice DTO shapes (IRR BIGINT as 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 the swagger.json snapshot 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 behind IMoadianClient, that clawback recovery waits on b13 and tickets on b15), append to backend/STATUS.md, write dev/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 clearing refund_payable ↔ escrow_held, clawback netting in b13, the tickets FK wire-up in b15), and update dev/shared-working-context/reports/mocks-registry.md (the IMoadianClient row → 🟡; reconfirm the reused seam rows).
  • Memory: save a project memory 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), the refund_payable ↔ escrow_held two-step clearing, channel parity (card vs bnpl_revert post the same legs), VAT-on-commission-only with a config rate, the sequential invoice_number generator, and the tickets/nurse_payouts forward-dep nullable FKs — with a one-line pointer in MEMORY.md.