add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
+405
View File
@@ -0,0 +1,405 @@
# 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`.