Files
2026-06-28 21:59:59 +03:30

406 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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](./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 **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.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}/<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](../../../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 ~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](../../../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 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](./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 ~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 ↔ 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<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 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`.