backend phase 11
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Contract — Refunds, clawbacks & invoices (backend phase b11)
|
||||
|
||||
> One-line: the outbound money leg — an admin reverses a captured booking payment across both fee legs (posting
|
||||
> the balanced ledger reversal, forking on whether the nurse was already paid), and issues the commission
|
||||
> invoice with VAT. Customers can only **read** their refund status + invoice. Assumes
|
||||
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
|
||||
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
|
||||
> [`../openapi/swagger.v1.json`](../openapi/README.md).
|
||||
|
||||
**Status:** live as of backend-phase-b11 · **Frontend consumer:** frontend-phase-f10-b11
|
||||
|
||||
All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). Refunds are **admin-only**
|
||||
— there is no customer refund-initiation path. Internal `account_type`s are never exposed. Timestamps are UTC
|
||||
ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
|
||||
|
||||
## Enums used
|
||||
- `refund_status` (`refunds.status`): `requested` | `approved` | `processing` | `succeeded` | `failed` | `rejected`.
|
||||
A card refund goes `approved → succeeded` immediately; a BNPL/manual refund sits in `processing` until the
|
||||
async customer cash-back reconciles. Forward-only.
|
||||
- `refund_channel` (`refunds.refund_channel`): `psp_card` | `bnpl_revert` | `manual`. (The data-model's
|
||||
`manual_bank` is stored/served as the canonical **`manual`**.)
|
||||
- `clawback_status` (`nurse_clawbacks.status`): `pending` | `recovered` | `written_off`. This phase only ever
|
||||
creates `pending` and supports `written_off`; `recovered` is set by b13 payout netting.
|
||||
- `moadian_status` (`invoices.moadian_status`): `pending` | `submitted` | `registered` | `failed`. Mock leaves a
|
||||
new invoice `pending`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `POST api/v1/admin_refunds`
|
||||
- **Purpose:** create (and immediately execute) a refund on a booking with a captured payment.
|
||||
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive) · **Idempotency key:** internal
|
||||
(booking + transaction + cumulative amount) — a retried channel call never double-refunds.
|
||||
- **Request body:**
|
||||
```json
|
||||
{
|
||||
"bookingId": 42,
|
||||
"ticketId": null,
|
||||
"refundPercentage": 1.0,
|
||||
"platformFeeRefundedIrr": null,
|
||||
"nursePayoutRefundedIrr": null,
|
||||
"reasonCategory": "customer_request",
|
||||
"reasonNotes": "shortened visit",
|
||||
"adminNotes": null,
|
||||
"manualBankReference": null
|
||||
}
|
||||
```
|
||||
Supply **either** `refundPercentage` (0–1 fraction, pro-rata across the booking's commission/payout legs) **or**
|
||||
the explicit `platformFeeRefundedIrr` + `nursePayoutRefundedIrr` legs (both together). If neither is given the
|
||||
booking's b9 cancellation snapshot percentage is used. `manualBankReference` forces the `manual` channel.
|
||||
- **Success `200` payload (`data`):**
|
||||
```json
|
||||
{
|
||||
"refundId": 7,
|
||||
"bookingId": 42,
|
||||
"status": "succeeded",
|
||||
"refundChannel": "psp_card",
|
||||
"amount": "10000000",
|
||||
"platformFeeRefundedIrr": "1500000",
|
||||
"nursePayoutRefundedIrr": "8500000",
|
||||
"expectedCustomerRefundEta": null,
|
||||
"clawbackId": null
|
||||
}
|
||||
```
|
||||
For BNPL: `status: "processing"`, `refundChannel: "bnpl_revert"`, `expectedCustomerRefundEta: "2026-08-24"`.
|
||||
Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created).
|
||||
- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin ·
|
||||
`404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused.
|
||||
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; posts the balanced ledger reversal via
|
||||
b10's helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is
|
||||
deferred to reconciliation for BNPL/manual. `ticketId` is required only when `refund_ticket_required` config is
|
||||
on (off until b15). Notifies the customer.
|
||||
|
||||
### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=`
|
||||
- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100).
|
||||
- **Auth:** admin · **Success `200` (`data`):** `PagedResult<RefundListItem>` (see shapes) — channel, decomposed
|
||||
legs, status, `expectedCustomerRefundEta`, the policy snapshot.
|
||||
|
||||
### `POST api/v1/admin_clawbacks/{id}/write_off`
|
||||
- **Purpose:** mark a `pending` nurse clawback uncollectable; posts the balancing `DEBIT bad_debt / CREDIT
|
||||
nurse_clawback_receivable` correction and sets `resolved_at`.
|
||||
- **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "reason": "uncollectable" }`
|
||||
- **Success `200` (`data`):** `true`. **Failure:** `404` not found · `409` not pending.
|
||||
|
||||
### `POST api/v1/admin_invoices`
|
||||
- **Purpose:** issue the booking's official commission invoice. Idempotent per booking (re-issue returns the same).
|
||||
- **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "bookingId": 42 }`
|
||||
- **Success `200` (`data`):** `Invoice` (see shapes) — sequential `invoiceNumber`, `vatIrr = round(commission ×
|
||||
vat_rate)` on the **commission line only**, `moadianStatus: "pending"`, `moadianReferenceNumber: null`.
|
||||
- **Failure:** `404` booking not found.
|
||||
|
||||
### `GET api/v1/refunds/{id}/status` *(customer-visible)*
|
||||
- **Purpose:** the customer-facing status of **their own** refund.
|
||||
- **Auth:** authenticated; **tenancy-scoped** to the booking's customer — another customer's refund is a clean `404`.
|
||||
- **Success `200` (`data`):**
|
||||
```json
|
||||
{ "id": 7, "bookingId": 42, "status": "processing", "refundChannel": "bnpl_revert",
|
||||
"amount": "10000000", "expectedCustomerRefundEta": "2026-08-24", "reference": "••••••ab12" }
|
||||
```
|
||||
The external reference is **masked** (last 4 only).
|
||||
- **Failure:** `401` unauth · `404` not found / not the caller's.
|
||||
|
||||
### `GET api/v1/invoices/{booking_id}` *(customer/admin)*
|
||||
- **Purpose:** the booking's invoice. **Auth:** authenticated — the owning customer or an admin (else `404`).
|
||||
- **Success `200` (`data`):** `Invoice` (see shapes), with `pdfUrl` when a PDF is stored.
|
||||
|
||||
## Shared shapes
|
||||
- `RefundListItem`: `id` (int), `bookingId` (int), `paymentTransactionId` (int), `amount` / `platformFeeRefundedIrr`
|
||||
/ `nursePayoutRefundedIrr` (IRR digit-strings), `refundChannel` (enum), `status` (enum), `refundPercentage`
|
||||
(decimal), `reasonCategory` (string?), `cancellationPolicyCode` (string?), `refundPercentageApplied` (decimal?),
|
||||
`expectedCustomerRefundEta` (date?), `gatewayRefundReference` (string?), `externalRevertReference` (string?),
|
||||
`processedAt` (datetime?), `createdAt` (datetime).
|
||||
- `Invoice`: `id` (int), `bookingId` (int), `invoiceNumber` (string, unique/sequential), `issuingEntityType`
|
||||
(`platform`|`partner_center`), `grossIrr` / `platformCommissionIrr` (IRR digit-strings), `bnplCommissionIrr`
|
||||
(digit-string?), `vatRate` (decimal), `vatIrr` (IRR digit-string), `moadianReferenceNumber` (string?),
|
||||
`moadianStatus` (enum?), `pdfUrl` (string?), `issuedAt` (datetime).
|
||||
|
||||
## Load-bearing rules the client must honour
|
||||
- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math.
|
||||
- **Refunds are admin-only.** The only customer-visible surface is `refunds/{id}/status` — there is no
|
||||
self-service refund initiation.
|
||||
- **A card refund is immediate** (`succeeded`, no ETA); **a BNPL refund is `processing`** with an
|
||||
`expectedCustomerRefundEta` ~7–10 business days out — surface it as "on its way, ~N days".
|
||||
- **VAT is on the platform commission only** — never the nurse payout.
|
||||
- **External references are masked** in the customer status view.
|
||||
|
||||
## Changelog
|
||||
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://localhost:5002"
|
||||
"url": "http://localhost"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
@@ -805,6 +805,95 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin_clawbacks/{id}/write_off": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"AdminClawbacks"
|
||||
],
|
||||
"operationId": "AdminClawbacks_WriteOff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 1
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"x-name": "body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WriteOffClawbackBody"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 2
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfBoolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin_evv/list": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1729,6 +1818,264 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin_invoices": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"AdminInvoices"
|
||||
],
|
||||
"operationId": "AdminInvoices_Issue",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IssueInvoiceCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfInvoiceDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin_refunds": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"AdminRefunds"
|
||||
],
|
||||
"summary": "Creates a AdminRefund",
|
||||
"operationId": "AdminRefunds_Create",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"description": "A AdminRefund representation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateRefundCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfCreateRefundResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"AdminRefunds"
|
||||
],
|
||||
"operationId": "AdminRefunds_List",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "BookingId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 1
|
||||
},
|
||||
{
|
||||
"name": "Status",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 2
|
||||
},
|
||||
{
|
||||
"name": "Page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"x-position": 3
|
||||
},
|
||||
{
|
||||
"name": "PageSize",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"x-position": 4
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfPagedResultOfRefundListItemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin_search/rebuild_index": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -5627,6 +5974,85 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/invoices/{bookingId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Invoices"
|
||||
],
|
||||
"summary": "Retrieves a Invoice by unique id",
|
||||
"operationId": "Invoices_Get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "bookingId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "A unique id for the Invoice",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 1
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfInvoiceDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -8770,6 +9196,83 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/refunds/{id}/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Refunds"
|
||||
],
|
||||
"operationId": "Refunds_Status",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 1
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfRefundStatusDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/search/nurses": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -9824,6 +10327,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfBoolean": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"WriteOffClawbackBody": {
|
||||
"type": "object",
|
||||
"description": "The write-off body (the id comes from the route).",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfAdminEvvItemDto": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -10248,6 +10778,314 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfInvoiceDto": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvoiceDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"InvoiceDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"bookingId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"invoiceNumber": {
|
||||
"type": "string"
|
||||
},
|
||||
"issuingEntityType": {
|
||||
"type": "string"
|
||||
},
|
||||
"grossIrr": {
|
||||
"type": "string"
|
||||
},
|
||||
"platformCommissionIrr": {
|
||||
"type": "string"
|
||||
},
|
||||
"bnplCommissionIrr": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"vatRate": {
|
||||
"type": "number",
|
||||
"format": "decimal"
|
||||
},
|
||||
"vatIrr": {
|
||||
"type": "string"
|
||||
},
|
||||
"moadianReferenceNumber": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"moadianStatus": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"pdfUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"issuedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"IssueInvoiceCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bookingId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfCreateRefundResult": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/CreateRefundResult"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"CreateRefundResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"refundId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"bookingId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"refundChannel": {
|
||||
"type": "string"
|
||||
},
|
||||
"amount": {
|
||||
"type": "string"
|
||||
},
|
||||
"platformFeeRefundedIrr": {
|
||||
"type": "string"
|
||||
},
|
||||
"nursePayoutRefundedIrr": {
|
||||
"type": "string"
|
||||
},
|
||||
"expectedCustomerRefundEta": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"nullable": true
|
||||
},
|
||||
"clawbackId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateRefundCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bookingId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"ticketId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"refundPercentage": {
|
||||
"type": "number",
|
||||
"format": "decimal",
|
||||
"nullable": true
|
||||
},
|
||||
"platformFeeRefundedIrr": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"nursePayoutRefundedIrr": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"reasonCategory": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"reasonNotes": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"adminNotes": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"manualBankReference": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfRefundListItemDto": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PagedResultOfRefundListItemDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"PagedResultOfRefundListItemDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"nullable": true,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RefundListItemDto"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"pageSize": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RefundListItemDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"bookingId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"paymentTransactionId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"amount": {
|
||||
"type": "string"
|
||||
},
|
||||
"platformFeeRefundedIrr": {
|
||||
"type": "string"
|
||||
},
|
||||
"nursePayoutRefundedIrr": {
|
||||
"type": "string"
|
||||
},
|
||||
"refundChannel": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"refundPercentage": {
|
||||
"type": "number",
|
||||
"format": "decimal"
|
||||
},
|
||||
"reasonCategory": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"cancellationPolicyCode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"refundPercentageApplied": {
|
||||
"type": "number",
|
||||
"format": "decimal",
|
||||
"nullable": true
|
||||
},
|
||||
"expectedCustomerRefundEta": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"nullable": true
|
||||
},
|
||||
"gatewayRefundReference": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"externalRevertReference": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"processedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfSearchIndexRebuildResult": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -14008,6 +14846,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfRefundStatusDto": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RefundStatusDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"RefundStatusDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"bookingId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"refundChannel": {
|
||||
"type": "string"
|
||||
},
|
||||
"amount": {
|
||||
"type": "string"
|
||||
},
|
||||
"expectedCustomerRefundEta": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"nullable": true
|
||||
},
|
||||
"reference": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfNurseSearchResultDto": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -12,6 +12,28 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-11 — Refunds, invoices & nurse clawbacks — 2026-07-09
|
||||
- **Shipped:** the reversal leg via one migration in the **`payments`** schema — **3 tables** `Refunds`
|
||||
(fee-leg decomposition + `refund_channel` + `amount = fee_leg + payout_leg` CHECK + **nullable `ticket_id`, no FK**
|
||||
until b15) / `NurseClawbacks` (nullable `original_payout_id`/`recovered_in_payout_id` until b13) / `Invoices`
|
||||
(**UNIQUE `invoice_number`** sequential + UNIQUE `booking_id`), plus the `InvoiceNumberSequences` counter row.
|
||||
Features: `CreateRefund` (whole money-path under `lock(booking:{id}:refund)`; decompose → Σ≤captured → channel →
|
||||
balanced ledger reversal via b10's helper; pre-payout `nurse_payable` vs post-payout `nurse_clawback_receivable`
|
||||
fork + `pending` clawback + support alert), `WriteOffClawback`, `ListRefunds`, `GetRefundStatus`, `IssueInvoice`
|
||||
(VAT on commission only from config, sequential number, mocked مودیان), `GetInvoice`. Controllers: admin
|
||||
`AdminRefunds`/`AdminClawbacks`/`AdminInvoices` (rate-limited) + customer `Refunds`/`Invoices`.
|
||||
- **Contracts:** dev/contracts/domains/refunds-invoices.md + openapi snapshot refreshed — **yes**.
|
||||
- **Mocked:** `IMoadianClient` (new), `IBnplProvider` (thin pre-b12 stub), `INursePayoutStatus` (interim derivation;
|
||||
b13 owns real); reused `IPaymentProvider`/`IWebhookVerifier`/`IDistributedLock`/`INotificationDispatcher`. See
|
||||
reports/mocks-registry.md.
|
||||
- **Gate:** build clean (0 new warnings) / tests green (300 pass: 205 Foundation + 91 Api + 4 Identity). Migration
|
||||
applied to the dev DB; swagger snapshot republished.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-11.md
|
||||
- **Notes for frontend:** refunds are **admin-only** (no self-service); the only customer surfaces are
|
||||
`GET refunds/{id}/status` (with the BNPL `expected_customer_refund_eta` date + masked reference) and
|
||||
`GET invoices/{booking_id}`. Money is IRR digit-strings. Card refund = immediate `succeeded`; BNPL = `processing`
|
||||
+ ETA.
|
||||
|
||||
## backend-phase-10 — Payments core: ledger, transactions, webhooks & card capture — 2026-07-06
|
||||
- **Shipped:** the money core via one migration in a new **`payments`** schema — **4 tables** `PaymentGateways`
|
||||
(encrypted `config_json`) / `PaymentTransactions` (the **two filtered uniques** — `gateway_reference_code` WHERE
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Handoff — after backend-phase-11 (Refunds, clawbacks & invoices)
|
||||
|
||||
**The reversal leg of the payments arc is live.** Money can now flow *backwards*: an admin reverses a captured
|
||||
booking payment across both fee legs, the balanced reversal posts to the append-only ledger, and the flow forks
|
||||
on whether the nurse was already paid (clean `nurse_payable` reversal vs a first-class `nurse_clawbacks`
|
||||
receivable). The minimal commission **invoice** (VAT on the commission line, sequential number, mocked مودیان)
|
||||
ships too.
|
||||
|
||||
## What the frontend (f10-b11) can now build
|
||||
- **Admin refund console** — create a refund (`POST admin_refunds`: full/partial by percentage or explicit legs),
|
||||
the refund worklist (`GET admin_refunds` — channel, decomposed legs, status, ETA, policy snapshot), and the
|
||||
clawback write-off (`POST admin_clawbacks/{id}/write_off`).
|
||||
- **Admin invoice issuance** — `POST admin_invoices` (sequential number, VAT on commission, مودیان pending).
|
||||
- **Customer cancellation/refund-status view** — `GET refunds/{id}/status` (tenancy-scoped; status, channel,
|
||||
amount, **`expected_customer_refund_eta`** for the BNPL 7–10-business-day window, masked reference). This is the
|
||||
**only** customer-visible refund surface — refunds are admin-initiated, there is no self-service initiation.
|
||||
- **Customer/admin invoice view** — `GET invoices/{booking_id}` (number, gross, commission, VAT, مودیان status,
|
||||
PDF URL when present).
|
||||
|
||||
## Live endpoints / contract
|
||||
- Contract: **`dev/contracts/domains/refunds-invoices.md`** (enums, DTO shapes, IRR digit-strings, masked
|
||||
references, failure codes, side-effects). Machine schema: `dev/contracts/openapi/swagger.v1.json` **refreshed**.
|
||||
- All money is IRR integer, on the wire as a **digit-string**. `expected_customer_refund_eta` is a **date**.
|
||||
- Admin endpoints are behind the admin policy and **rate-limited** (sensitive). Refunds are **never** customer
|
||||
self-service.
|
||||
|
||||
## What is mocked / waiting
|
||||
- **سامانه مودیان** is mocked behind **`IMoadianClient`** (pending/no-ref by default; a config switch forces a
|
||||
registered 22-digit ref). See `reports/mocks-registry.md`.
|
||||
- **BNPL revert** runs against **`IBnplProvider`** — a **thin local stub** now; **b12 owns the real seam** and its
|
||||
adapter. The `bnpl_revert` ledger legs are already identical to the card path.
|
||||
- **"Was the nurse paid?"** is derived from the booking's dispute-window close (`INursePayoutStatus`) until **b13**
|
||||
ships `nurse_payouts`; **clawback recovery/netting is b13** (this phase only opens the `pending` receivable +
|
||||
supports write-off).
|
||||
- **`tickets`** arrives in **b15**: `refunds.ticket_id` is a nullable column (no FK yet); the "ticket required"
|
||||
rule is config-gated off (`refund_ticket_required = false`).
|
||||
- **Deferred crons** (thin/manual today): the مودیان reconciliation job (`pending → registered`) and the
|
||||
BNPL-revert reconciliation job that clears `refund_payable ↔ escrow_held` for a `processing` refund.
|
||||
|
||||
## Notes for the next backend phases
|
||||
- **b12 (BNPL):** owns the real `IBnplProvider`; the refund `bnpl_revert` path already calls it through the seam.
|
||||
- **b13 (payouts):** implement the real `INursePayoutStatus` (`nurse_payout_booking_links`) and net `pending`
|
||||
`nurse_clawbacks` out of a payout batch (set `recovered_in_payout_id` / `original_payout_id`, post `DEBIT
|
||||
nurse_payable / CREDIT nurse_clawback_receivable`).
|
||||
- **b15 (tickets/partner centers):** wire the real `refunds.ticket_id` FK + flip `refund_ticket_required` on;
|
||||
wire `invoices.partner_center_id` + `nurse_clawbacks` payout FKs.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Backend Phase 11 Report — Refunds, invoices & nurse clawbacks
|
||||
|
||||
**Mission:** make money flow *backwards* correctly — the admin-only refund engine that reverses a captured
|
||||
booking payment across both fee legs, posts the balanced reversal into the append-only ledger, forks on whether
|
||||
the nurse was already paid (clean reversal vs a `nurse_clawbacks` receivable), and issues the minimal commission
|
||||
invoice with VAT.
|
||||
|
||||
## What was built
|
||||
|
||||
**Three tables (one migration `RefundsClawbacksInvoices`, `payments` schema) + a counter row:**
|
||||
- `Refunds` — 1:N per `payment_transaction`; `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`
|
||||
(DB CHECK); `refund_channel` (`psp_card`|`bnpl_revert`|`manual`), `external_revert_reference`,
|
||||
`expected_customer_refund_eta` (DATE), `cancellation_policy_code` + `refund_percentage_applied` snapshot,
|
||||
forward-only `status`. **`ticket_id` is a nullable column with NO FK** (tickets → b15).
|
||||
- `NurseClawbacks` — `status` (`pending`|`recovered`|`written_off`; only `pending`/`written_off` here),
|
||||
`amount_irr`, `refund_id` (1:1 UNIQUE), nullable `original_payout_id`/`recovered_in_payout_id` (no FK, → b13).
|
||||
- `Invoices` — UNIQUE `invoice_number` (sequential) + UNIQUE `booking_id`; `vat_rate`/`vat_irr` on the commission
|
||||
line; `moadian_reference_number`/`moadian_status`; nullable `partner_center_id` (no FK, → b15).
|
||||
- `InvoiceNumberSequences` — single seeded counter row (id 1, next 1); locked + committed with the invoice so
|
||||
numbers are gap-free and portable across SQL Server / SQLite (no DB sequence).
|
||||
|
||||
**Features (CQRS, `OperationResult`, validators):** `CreateRefundCommand`, `WriteOffClawbackCommand`,
|
||||
`ListRefundsQuery`, `GetRefundStatusQuery`, `IssueInvoiceCommand`, `GetInvoiceQuery`. The phase's
|
||||
channel-execution / ledger-posting / clawback "internal step" commands are realized as cohesive **private steps**
|
||||
inside `CreateRefundCommandHandler` under one lock + transaction (mirroring b10's `ConfirmPaymentAndPostLedger`) so
|
||||
they stay atomic — this is the correct shape; separate dispatched commands would each commit and break atomicity.
|
||||
|
||||
**Ledger postings** added to b10's `LedgerPosting` (balanced, append-only): `RefundReversalPrePayout`,
|
||||
`ClawbackReversalPostPayout`, `RefundPayableClearing`, `ClawbackWriteOff`.
|
||||
|
||||
**Controllers:** `AdminRefundsController` (`POST`/`GET admin_refunds`), `AdminClawbacksController`
|
||||
(`POST admin_clawbacks/{id}/write_off`), `AdminInvoicesController` (`POST admin_invoices`) — all admin policy +
|
||||
rate-limited; `RefundsController` (`GET refunds/{id}/status`), `InvoicesController` (`GET invoices/{bookingId}`) —
|
||||
authenticated, tenancy-scoped.
|
||||
|
||||
**Seams:** introduced `IMoadianClient` (+ `MockMoadianClient`) and, because b12 isn't merged, a thin
|
||||
`IBnplProvider` (+ `MockBnplProvider`) stub; interim `INursePayoutStatus` (`NursePayoutStatusService`, Persistence).
|
||||
Extended `IPaymentProvider.RefundAsync` to carry an `idempotencyKey` (updated the mock + b10 call sites — no b10
|
||||
behaviour change). Reused `IWebhookVerifier`/`IDistributedLock`/`INotificationDispatcher`/`ISupportAlertService`/
|
||||
`IObjectStorage`/`IPlatformConfig`. Added config rows `refund_ticket_required` (false), `bnpl_refund_eta_business_days`
|
||||
(10), `refund_assume_nurse_paid` (false).
|
||||
|
||||
## What is now testable and exactly how (mirrors the phase §7)
|
||||
|
||||
Seed a confirmed booking with a captured card transaction (the `RefundsTestHost`/`AdminRefundsApiTests.SeedCapturedBookingAsync`
|
||||
helpers do this; gross 10,000,000 / commission 1,500,000 / payout 8,500,000).
|
||||
|
||||
1. **Pre-payout full refund** — `POST admin_refunds {bookingId, refundPercentage:1}` → `refund_channel psp_card`,
|
||||
legs `1,500,000` + `8,500,000` summing to `10,000,000`; ledger shows a balanced `DEBIT platform_revenue` +
|
||||
`DEBIT nurse_payable` / `CREDIT refund_payable`, and a `DEBIT refund_payable` / `CREDIT escrow_held` clearing
|
||||
leg (5 legs, Σdebit=Σcredit); status `succeeded`.
|
||||
2. **Partial + over-refund guard** — a 50% refund decomposes to `750,000` + `4,250,000`; a second refund that would
|
||||
exceed the captured amount → **409**, no ledger posted.
|
||||
3. **Invoice** — `POST admin_invoices {bookingId}` → sequential `INV-0000000001`,
|
||||
`vat_irr = round(1,500,000 × 0.10) = 150,000` (on the commission, `0` when `vat_rate = 0`), `moadian_status
|
||||
pending` / null ref; a second booking → `INV-0000000002` (gap-free). Re-issue returns the same invoice.
|
||||
4. **Post-payout clawback** — with the nurse flagged paid (seed a past `dispute_window_ends_at`, or
|
||||
`refund_assume_nurse_paid = true`) → the payout leg debits `nurse_clawback_receivable` (not `nurse_payable`), a
|
||||
`pending` `nurse_clawbacks` row (`amount_irr = 8,500,000`) is created, and a `nurse_clawback` support alert is
|
||||
raised. Not auto-recovered (b13).
|
||||
5. **BNPL revert** — seed a `bnpl` gateway → `refund_channel bnpl_revert`, `IBnplProvider.RevertAsync` called,
|
||||
`external_revert_reference` stored, `expected_customer_refund_eta ≈ now + 10 business days`, status `processing`;
|
||||
the reversal ledger legs are identical to the card case. `GET refunds/{id}/status` shows the ETA.
|
||||
6. **Write-off** — `POST admin_clawbacks/{id}/write_off` → `written_off` + `DEBIT bad_debt` / `CREDIT
|
||||
nurse_clawback_receivable` + `resolved_at`.
|
||||
7. **Worklist + tenancy** — `GET admin_refunds?status=…` lists channel/legs/ETA; `GET refunds/{id}/status` as a
|
||||
different customer → **404**.
|
||||
|
||||
**Tests:** 8 Foundation handler tests (`Refunds/RefundHandlerTests` + `InvoiceHandlerTests`) + 8 Api integration
|
||||
tests (`AdminRefundsApiTests`, `AdminInvoicesApiTests`, `RefundStatusApiTests`). Full suite green (300).
|
||||
`dotnet build Baya.sln` 0 new warnings.
|
||||
|
||||
## What is mocked / how to make it real
|
||||
See `reports/mocks-registry.md` — `IMoadianClient` (سامانه مودیان enrollment + submission API + 22-digit ref +
|
||||
reconciliation callback), `IBnplProvider` (b12 owns the real adapter), `INursePayoutStatus` (b13 real lookup).
|
||||
|
||||
## Contracts produced/consumed
|
||||
- **Produced:** `dev/contracts/domains/refunds-invoices.md`; `dev/contracts/openapi/swagger.v1.json` refreshed.
|
||||
- **Consumed:** b9 booking/cancellation snapshot, b10 ledger/transaction/gateway, b1 VAT config + notifications +
|
||||
support alerts.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **مودیان reconciliation cron** — flip `moadian_status pending → registered` + fill the 22-digit ref (thin/manual
|
||||
today).
|
||||
- **BNPL-revert reconciliation cron** — clear `refund_payable ↔ escrow_held` for a `processing` refund when the
|
||||
provider confirms the customer cash-back (webhook via `IWebhookVerifier`; deferred/manual today).
|
||||
- **b12** — real `IBnplProvider`.
|
||||
- **b13** — clawback netting/recovery + real `INursePayoutStatus`.
|
||||
- **b15** — `tickets` FK on `refunds` (+ flip `refund_ticket_required` on), `partner_centers` on `invoices`,
|
||||
`nurse_payouts` FKs on `nurse_clawbacks`.
|
||||
@@ -35,12 +35,16 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
|
||||
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
|
||||
| `IPaymentCaptureSimulator` | backend-phase-9 | The **temporary conversion trigger** standing in for b10's real card capture. `MockPaymentCaptureSimulator` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic *succeeded* capture (a fake `gateway_reference` + a configurable `psp_fee_amount`) so `ConvertRequestToBookingCommand` is exercisable now; a config switch forces a *failed* capture (→ no booking is created). **This is the trigger, not a parallel money path** — registered singleton in `AddCrossCuttingSeams` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | In b10: 1) build the real card capture (`payment_transactions`, PSP/IPG client, webhook verify); 2) on a real `payment_transactions.succeeded`, call `ConvertRequestToBooking` **directly** (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the `IPaymentCaptureSimulator` registration + `MockPaymentCaptureSimulator`; the conversion/idempotency logic is unchanged | 🟡 |
|
||||
| `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 |
|
||||
| `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync` → always `Succeeded` (for b11 to call). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 |
|
||||
| `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync(ref, amount, idempotencyKey, ct)` → always `Succeeded`, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 |
|
||||
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 |
|
||||
| `IWebhookVerifier` | backend-phase-10 | PSP callback signature verify — `MockWebhookVerifier` (`.../Seams/`) treats the signature as valid unless the body carries `Seams:Payments:InvalidSignatureMarker`, and extracts `external_event_id`/`event_type`/`gateway_reference_code` from a small JSON body (so tests can replay duplicates + exercise the invalid-signature path). Registered singleton in `AddCrossCuttingSeams` | `Seams:Payments:InvalidSignatureMarker` (default `INVALID_SIGNATURE`) | 1) implement the per-provider HMAC/signature scheme (verify the raw body against the provider's signing key from the gateway config); 2) where a provider offers no signature, fall back to the mandatory server-side `verify` re-check (amount + reference) via `IPaymentProvider.VerifyAsync`; 3) parse the real provider event shape into `WebhookVerification`; 4) swap the registration (config-selected) — the `HandlePaymentWebhook` upsert-first/no-op-on-duplicate ordering is unchanged | 🟡 |
|
||||
| `IDistributedLock` | backend-phase-10 | Money-path mutex — `InProcessDistributedLock` (`.../Seams/`): a per-key `SemaphoreSlim` so the capture path runs the same acquire/release shape it will with real Redis, **within one process only**. **Not** a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs a Redis connection string | 1) add `StackExchange.Redis` to `Directory.Packages.props`; 2) implement `AcquireAsync(key)` with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention `booking:{id}:payment`; 3) bind `Seams:Payments:Redis` (or reuse the `ICacheService` Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired | 🟡 |
|
||||
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL** — `SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |
|
||||
|
||||
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoicing — `MockMoadianClient` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `SubmitAsync` leaves a new invoice `moadian_status = pending` with `moadian_reference_number = null`; a config switch forces a deterministic `registered` result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in `AddCrossCuttingSeams` | `Seams:Moadian:ForceRegistered` (default `false`) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement `SubmitAsync` to POST the معاملات/invoice (`صورتحساب`) to the مودیان API, sign the payload, map the 22-digit `reference_number`; 3) walk the async `pending → submitted → registered`/`failed` states via a reconciliation callback/poll (**cron deferred/manual today** — a job flips `moadian_status` + fills the ref); 4) swap the registration (config-selected) — the `IssueInvoice` handler is unchanged | 🟡 |
|
||||
| `IBnplProvider` | **owned by backend-phase-12**; thin local stub added in **b11** | BNPL revert/update — `MockBnplProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), a **temporary stub so b11's `bnpl_revert` refund path runs before b12 merges**. `RevertAsync`/`UpdateAsync` succeed, echo a deterministic `external_revert_reference`, and report a **nullable** `provider_commission_reversed_amount` (null by default — reconciled from the response, never hardcoded). Registered singleton in `AddCrossCuttingSeams` | `Seams:Bnpl:ForceFailure` (default `false`), `Seams:Bnpl:ReverseProviderCommission` (default `false`) | **b12 owns the real seam definition + adapter** (SnappPay/Tara): settle flow, order lifecycle, real `RevertAsync`(full)/`UpdateAsync`(strictly-lower partial). When b12 lands its real registration supersedes this stub and the refund `bnpl_revert` path calls the real client unchanged | 🟡 (pre-b12 stub) |
|
||||
| `INursePayoutStatus` | backend-phase-11 (interim; **b13** owns the real impl) | "Was the nurse already paid for this booking?" — `NursePayoutStatusService` (`Persistence/Services/Payments/`) derives it from the booking's `dispute_window_ends_at` close (the same gate b13 pays out on), with a `refund_assume_nurse_paid` config override. Not a mock of an external — a **temporary derivation** standing in for the b13 `nurse_payout_booking_links` lookup. Registered scoped in `AddPersistenceServices` | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | In b13: implement `IsNursePaidForBookingAsync` as a real `nurse_payout_booking_links` join (a booking linked to a paid-out `nurse_payouts` batch ⇒ paid), swap the registration — the refund pre-payout/clawback fork is unchanged | 🟡 |
|
||||
|
||||
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
|
||||
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user