# 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](./backend-phase-10.md) (ledger / transactions / webhook idempotency / capture), [b9](./backend-phase-9.md) (cancellation policies / bookings / dispute window), [b1](./backend-phase-1.md) (VAT config / typed config accessor) · **Unlocks:** payout clawback netting ([b13](./backend-phase-13.md)); frontend **f10-b11** > **Before you start, read [`../_shared/agent-operating-rules.md`](../_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`](../../../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](./backend-phase-10.md) 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 window** — [b9](./backend-phase-9.md) 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 accessor** — [b1](./backend-phase-1.md)'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`, 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](./backend-phase-15.md). 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`](../_shared/agent-operating-rules.md) and [`../_shared/backend-conventions-checklist.md`](../_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.md`](../../../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`](../../../product/payments/cancellation-and-payout.md) — **Q1 the BNPL refund unwind**: money *always* flows `customer ↔ provider ↔ Balinyaar`, never direct; `revert` (full) vs `update` (partial, strictly-lower amount); the async **7–10 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.md`](../../../product/data-model/06-payments-ledger-and-refunds.md) — **the 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`](../../../product/payments/escrow-ledger.md) — the **refund and clawback postings** in depth (pre-payout reversal; the `refund_payable` ↔ `escrow_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`](../../contracts/conventions/api-conventions.md) and [`money-and-types.md`](../../contracts/conventions/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}//` (refunds + clawbacks) and `Baya.Application/Features/Invoices/{Commands|Queries}//`; entities in `Baya.Domain/Entities/Refunds/` and `…/Invoices/`; one `IEntityTypeConfiguration` 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](../../../product/data-model/06-payments-ledger-and-refunds.md)): `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`](../../contracts/conventions/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 ~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_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](../../../product/data-model/06-payments-ledger-and-refunds.md)): `id`, `nurse_id` (FK `nurse_profiles`), `booking_id` (FK `bookings`), `refund_id` (FK `refunds`), `original_payout_id` (FK `nurse_payouts` **NULL** — `nurse_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](../../../product/data-model/06-payments-ledger-and-refunds.md)): `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: **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` / `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](./backend-phase-13.md). 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`](../../../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`](../../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 legs** — `amount = 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 ~7–10 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 ↔ Balinyaar` — **never** nurse→customer or Balinyaar→customer direct. Full = `revert`, partial/shortened = `update` (strictly-lower amount). The provider's own commission reversal is `provider_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 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](../_shared/definition-of-done.md), plus: - [ ] The three tables (`refunds`, `nurse_clawbacks`, `invoices`) exist via one migration, each with its `IEntityTypeConfiguration`: `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 invoice** — `POST 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-off** — `POST 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 + tenancy** — `GET 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`](../../../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/business/07-cancellation-and-refunds.md) / [`product/data-model/06-payments-ledger-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`](../../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`](../../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`.