add build development phases
This commit is contained in:
@@ -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 **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}/<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 ~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<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`.
|
||||
Reference in New Issue
Block a user