backend phase 11

This commit is contained in:
hamid
2026-07-09 02:13:30 +03:30
parent 23605591eb
commit 465f75c29e
78 changed files with 9555 additions and 27 deletions
+127
View File
@@ -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` (01 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` ~710 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).
+892 -1
View File
@@ -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 710-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.
@@ -44,6 +44,24 @@
</ul>
<h2 id="d-supporting-database-entities">(d) Supporting database entities <a class="anchor" href="#d-supporting-database-entities" aria-hidden="true">#</a></h2>
<p><strong><code>cancellation_policies</code></strong>, <code>bookings</code> (policy snapshot, <code>dispute_window_ends_at</code>), <code>refunds</code> (admin-only, <code>ticket_id</code>, fee-leg decomposition, <code>refund_channel</code>), <code>tickets</code>, <code>nurse_clawbacks</code> (post-payout case), <code>ledger_entries</code>.</p>
<h2 id="e-as-built-backend-phase-11">(e) As built (backend-phase-11) <a class="anchor" href="#e-as-built-backend-phase-11" aria-hidden="true">#</a></h2>
<p>Decisions fixed while building the refund/clawback/invoice engine (config-driven where possible):</p>
<ul>
<li><strong>Refund channel canonical code:</strong> the out-of-band bank refund is stored and served as <strong><code>manual</code></strong> (the wire</li>
</ul>
<p> code), not <code>manual_bank</code> — they are the same channel. Full set: <code>psp_card</code> | <code>bnpl_revert</code> | <code>manual</code>.</p>
<ul>
<li><strong>Ticket link is config-gated until b15.</strong> <code>refunds.ticket_id</code> is a nullable column with <strong>no FK yet</strong></li>
</ul>
<p> (<code>tickets</code> arrives in b15). "A refund must link a ticket" is enforced by the <code>refund_ticket_required</code> config flag, <strong>default <code>false</code></strong> so admin refunds are testable today; b15 wires the FK and flips it on.</p>
<ul>
<li><strong>BNPL refund ETA</strong> is <code>now + config(bnpl_refund_eta_business_days, default 10)</code> business days (Fridays skipped),</li>
</ul>
<p> surfaced as <code>expected_customer_refund_eta</code>; the refund sits in <code>processing</code> until reconciled. Card refunds are immediate (<code>succeeded</code>, no ETA). Card and BNPL post the <strong>same</strong> reversal ledger legs.</p>
<ul>
<li><strong>Pre-payout vs post-payout fork</strong> is decided by whether the nurse was already paid — derived from the booking's</li>
</ul>
<p> <code>dispute_window_ends_at</code> close until b13 ships <code>nurse_payouts</code> (a <code>refund_assume_nurse_paid</code> override exists). Clawback <strong>recovery/netting is b13</strong>; b11 only opens the <code>pending</code> receivable + supports admin write-off.</p>
<blockquote><p><strong>Related:</strong> Data model — <a href="../data-model/06-payments-ledger-and-refunds.html">Payments Ledger &amp; Refunds</a>.</p>
</blockquote>
<a class="back-to-top" href="#">↑ Back to top</a>
@@ -25,4 +25,18 @@
## (d) Supporting database entities
**`cancellation_policies`**, `bookings` (policy snapshot, `dispute_window_ends_at`), `refunds` (admin-only, `ticket_id`, fee-leg decomposition, `refund_channel`), `tickets`, `nurse_clawbacks` (post-payout case), `ledger_entries`.
## (e) As built (backend-phase-11)
Decisions fixed while building the refund/clawback/invoice engine (config-driven where possible):
- **Refund channel canonical code:** the out-of-band bank refund is stored and served as **`manual`** (the wire
code), not `manual_bank` — they are the same channel. Full set: `psp_card` | `bnpl_revert` | `manual`.
- **Ticket link is config-gated until b15.** `refunds.ticket_id` is a nullable column with **no FK yet**
(`tickets` arrives in b15). "A refund must link a ticket" is enforced by the `refund_ticket_required` config
flag, **default `false`** so admin refunds are testable today; b15 wires the FK and flips it on.
- **BNPL refund ETA** is `now + config(bnpl_refund_eta_business_days, default 10)` business days (Fridays skipped),
surfaced as `expected_customer_refund_eta`; the refund sits in `processing` until reconciled. Card refunds are
immediate (`succeeded`, no ETA). Card and BNPL post the **same** reversal ledger legs.
- **Pre-payout vs post-payout fork** is decided by whether the nurse was already paid — derived from the booking's
`dispute_window_ends_at` close until b13 ships `nurse_payouts` (a `refund_assume_nurse_paid` override exists).
Clawback **recovery/netting is b13**; b11 only opens the `pending` receivable + supports admin write-off.
> **Related:** Data model — [Payments Ledger & Refunds](../data-model/06-payments-ledger-and-refunds.md).
@@ -111,6 +111,27 @@
<tr><td><code>issued_at</code></td><td>DATETIME2</td><td></td></tr>
</tbody></table></div>
<p><strong>Relations:</strong> 1:1 → <code>bookings</code>; N:1 → <code>partner_centers</code> (when issuer).</p>
<h3 id="as-built-backend-phase-11">As built (backend-phase-11) <a class="anchor" href="#as-built-backend-phase-11" aria-hidden="true">#</a></h3>
<ul>
<li><strong><code>refunds.refund_channel</code> canonical code is <code>manual</code></strong> (not <code>manual_bank</code>) for the out-of-band bank refund —</li>
</ul>
<p> the two are the same channel; <code>manual</code> is the value stored and served. Set: <code>psp_card</code> | <code>bnpl_revert</code> | <code>manual</code>.</p>
<ul>
<li><strong><code>refunds.ticket_id</code></strong> ships as a <strong>nullable column with no FK</strong> (the <code>tickets</code> table arrives in b15); the</li>
</ul>
<p> "ticket required" rule is gated by the <code>refund_ticket_required</code> config flag (default off). <strong><code>nurse_clawbacks.original_payout_id</code> / <code>recovered_in_payout_id</code></strong> and <strong><code>invoices.partner_center_id</code></strong> are likewise nullable, FK-less join points that b13 / b15 fill.</p>
<ul>
<li><strong><code>invoices.vat_irr = round(platform_commission_irr × vat_rate)</code></strong>, integer-only, on the commission line only; a</li>
</ul>
<p> <code>vat_rate = 0</code> exemption yields <code>vat_irr = 0</code>.</p>
<ul>
<li><strong><code>invoices.invoice_number</code></strong> is drawn from a single-row <strong>counter table</strong> (<code>invoice_number_sequences</code>), locked</li>
</ul>
<p> and committed with the invoice insert — gap-free, unique, portable across SQL Server / SQLite (no DB sequence), never random/timestamp-derived. One issued invoice per booking (idempotent).</p>
<ul>
<li>The refund posts the balanced reversal via the b10 ledger helper; the <strong><code>refund_payable ↔ escrow_held</code> clearing</strong></li>
</ul>
<p> posts immediately for a succeeded card refund and is deferred to reconciliation for a <code>processing</code> BNPL/manual refund. Card and BNPL post the <strong>same</strong> reversal legs.</p>
<a class="back-to-top" href="#">↑ Back to top</a>
</div></main>
</div>
@@ -118,3 +118,19 @@ This is the most-changed domain. The previous model **inferred** money state fro
| `issued_at` | DATETIME2 | |
**Relations:** 1:1 → `bookings`; N:1 → `partner_centers` (when issuer).
### As built (backend-phase-11)
- **`refunds.refund_channel` canonical code is `manual`** (not `manual_bank`) for the out-of-band bank refund —
the two are the same channel; `manual` is the value stored and served. Set: `psp_card` | `bnpl_revert` | `manual`.
- **`refunds.ticket_id`** ships as a **nullable column with no FK** (the `tickets` table arrives in b15); the
"ticket required" rule is gated by the `refund_ticket_required` config flag (default off).
**`nurse_clawbacks.original_payout_id` / `recovered_in_payout_id`** and **`invoices.partner_center_id`** are
likewise nullable, FK-less join points that b13 / b15 fill.
- **`invoices.vat_irr = round(platform_commission_irr × vat_rate)`**, integer-only, on the commission line only; a
`vat_rate = 0` exemption yields `vat_irr = 0`.
- **`invoices.invoice_number`** is drawn from a single-row **counter table** (`invoice_number_sequences`), locked
and committed with the invoice insert — gap-free, unique, portable across SQL Server / SQLite (no DB sequence),
never random/timestamp-derived. One issued invoice per booking (idempotent).
- The refund posts the balanced reversal via the b10 ledger helper; the **`refund_payable ↔ escrow_held` clearing**
posts immediately for a succeeded card refund and is deferred to reconciliation for a `processing` BNPL/manual
refund. Card and BNPL post the **same** reversal legs.
+42 -2
View File
@@ -82,14 +82,14 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies), appsettings*.json
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
@@ -323,6 +323,46 @@ one migration (`PaymentsCoreLedger`). Features under `Baya.Application/Features/
`InProcessDistributedLock`), registered by `AddCrossCuttingSeams`. `payment_gateways.config_json` is
encrypted through the b0 `IFieldEncryptor` (converter wired in `ApplicationDbContext`).
**Refunds, clawbacks & invoices (backend-phase-11).** The `payments` schema gains three tables — `Refunds`,
`NurseClawbacks`, `Invoices` (+ the single-row `InvoiceNumberSequences` counter) — entities in
`Domain/Entities/Refunds/` + `…/Invoices/`, configs in `Persistence/Configuration/{RefundsConfig|InvoicesConfig}/`,
one migration (`RefundsClawbacksInvoices`). Features under `Baya.Application/Features/{Refunds|Invoices}/`;
per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; controllers `AdminRefundsController`
/ `AdminClawbacksController` / `AdminInvoicesController` (admin policy, rate-limited) + customer-facing
`RefundsController` (`refunds/{id}/status`) / `InvoicesController` (`invoices/{booking_id}`). Load-bearing rules:
- **A refund decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` (the whole
money-path under `lock(booking:{id}:refund)`) reads the booking's frozen split + b9 cancellation snapshot +
captured transaction (`IRefundRepository.GetRefundContextAsync`), splits `amount = platform_fee_refunded_irr +
nurse_payout_refunded_irr` pro-rata at the resolved %, enforces **`Σ refunded ≤ captured`** (handler backstop),
executes the channel behind its seam, and posts the balanced reversal via **b10's `LedgerPosting`** helper
(extended with `RefundReversalPrePayout` / `ClawbackReversalPostPayout` / `RefundPayableClearing` /
`ClawbackWriteOff`). The channel-execution/ledger "internal step" commands from the phase are cohesive private
steps in the handler (mirroring b10's `ConfirmPaymentAndPostLedger`) so they stay atomic.
- **Pre-payout reversal vs post-payout clawback fork.** `INursePayoutStatus` (Application `Contracts/Payments`;
DB-backed `NursePayoutStatusService` in `Persistence/Services/Payments`) answers "was the nurse already paid?"
— pre-payout debits `nurse_payable` (clean reversal); post-payout debits `nurse_clawback_receivable` **and**
opens a `pending` `nurse_clawbacks` row + raises a `nurse_clawback` support alert, because an Iranian IBAN
transfer is irreversible. Until b13 ships `nurse_payouts`, "paid?" is derived from the booking's
`dispute_window_ends_at` close (+ a `refund_assume_nurse_paid` config override); b13 swaps the registration.
Clawback **recovery/netting is b13** — this phase only opens the receivable + supports admin `write_off`.
- **Channel parity.** `psp_card` and `bnpl_revert` post the **same** reversal legs — only the channel, the
external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card =
immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config
business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only
once the customer cash-back confirms.
- **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes
`vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0),
never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row
(locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per
booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits
to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`).
- **Forward-deps as nullable columns, no FK.** `refunds.ticket_id` (tickets → b15; "ticket required" is the
config-gated `refund_ticket_required` rule, off by default), `nurse_clawbacks.original_payout_id` /
`recovered_in_payout_id` (nurse_payouts → b13), `invoices.partner_center_id` (partner_centers → b15). The
data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is
introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
seam definition**.
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Refunds.Commands.WriteOffClawback;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>Admin-only nurse-clawback management. Write-off marks a pending receivable uncollectable and posts
/// the balancing bad-debt correction; recovery via payout netting is b13.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin_clawbacks")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin nurse-clawback write-off")]
public sealed class AdminClawbacksController(ISender sender) : BaseController
{
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> WriteOff(long id, WriteOffClawbackBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new WriteOffClawbackCommand(id, body.Reason), cancellationToken));
}
/// <summary>The write-off body (the id comes from the route).</summary>
public record WriteOffClawbackBody(string Reason);
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Invoices.Commands.IssueInvoice;
using Baya.Application.Models.Invoices;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>Admin-only invoice issuance. Issuing an invoice draws the next sequential number, computes VAT on
/// the commission line from config, and submits to (mocked) مودیان. Idempotent per booking.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin_invoices")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin invoice issuance")]
public sealed class AdminInvoicesController(ISender sender) : BaseController
{
[HttpPost]
[ProducesOkApiResponseType<InvoiceDto>]
public async Task<IActionResult> Issue(IssueInvoiceCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
}
@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Queries.ListRefunds;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// Admin-only refund console. Creating a refund reverses a captured booking payment across both fee legs, posts
/// the balanced ledger reversal, forks on whether the nurse was already paid (clawback), and is rate-limited as
/// a money endpoint. There is no customer refund-initiation path.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin_refunds")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin refund creation + worklist")]
public sealed class AdminRefundsController(ISender sender) : BaseController
{
[HttpPost]
[ProducesOkApiResponseType<CreateRefundResult>]
public async Task<IActionResult> Create(CreateRefundCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpGet]
[ProducesOkApiResponseType<PagedResult<RefundListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Invoices.Queries.GetInvoice;
using Baya.Application.Models.Invoices;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>The booking's invoice for the customer (tenancy-scoped) or an admin. Read-only.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/invoices")]
[Authorize]
[Display(Description = "Booking invoice (customer/admin)")]
public sealed class InvoicesController(ISender sender) : BaseController
{
[HttpGet("{bookingId}")]
[ProducesOkApiResponseType<InvoiceDto>]
public async Task<IActionResult> Get(long bookingId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetInvoiceQuery(bookingId), cancellationToken));
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Refunds.Queries.GetRefundStatus;
using Baya.Application.Models.Refunds;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>The customer-facing refund status — the only customer-visible refund surface (there is no
/// self-service refund initiation). Tenancy-scoped: a customer sees only their own refund.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/refunds")]
[Authorize]
[Display(Description = "Customer refund status")]
public sealed class RefundsController(ISender sender) : BaseController
{
[HttpGet("{id}/status")]
[ProducesOkApiResponseType<RefundStatusDto>]
public async Task<IActionResult> Status(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetRefundStatusQuery(id), cancellationToken));
}
@@ -0,0 +1,23 @@
namespace Baya.Application.Common;
/// <summary>
/// Business-day arithmetic for customer-facing ETAs (the BNPL 710-day refund window). Fridays are skipped as
/// the Iranian bank weekend; Thursday half-days and public holidays are not modelled here (the ETA is an
/// estimate surfaced in the UI, not a settlement guarantee — the authoritative confirmation is the provider
/// reconciliation callback). A later refinement can route this through <c>IHolidayCalendar</c>.
/// </summary>
public static class BusinessDays
{
public static DateOnly Add(DateOnly start, int businessDays)
{
var date = start;
var added = 0;
while (added < businessDays)
{
date = date.AddDays(1);
if (date.DayOfWeek != DayOfWeek.Friday)
added++;
}
return date;
}
}
@@ -0,0 +1,33 @@
#nullable enable
namespace Baya.Application.Contracts.Invoices;
/// <summary>
/// The سامانه مودیان e-invoicing rail seam (introduced by b11). Handlers depend only on this contract; the
/// mock leaves a newly issued invoice at <c>moadian_status = pending</c> with no reference (no external call).
/// A config switch can force a deterministic <c>registered</c> result (with a fake 22-digit reference) so the
/// reconciliation/registered path is testable. The real مودیان adapter — enrollment, the معاملات/invoice
/// submission API, the <c>pending → submitted → registered</c> reconciliation callback — is a drop-in that
/// swaps only this registration.
/// </summary>
public interface IMoadianClient
{
ValueTask<MoadianSubmissionResult> SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default);
}
/// <summary>The minimal facts مودیان needs to register a commission invoice. Money is IRR <c>long</c>.</summary>
/// <param name="InvoiceNumber">The platform's own sequential number.</param>
/// <param name="BookingId">The booking the invoice is for.</param>
/// <param name="GrossIrr">The booking gross.</param>
/// <param name="PlatformCommissionIrr">The VAT-relevant commission line.</param>
/// <param name="VatIrr">The computed VAT on the commission.</param>
public sealed record InvoiceSubmission(
string InvoiceNumber,
long BookingId,
long GrossIrr,
long PlatformCommissionIrr,
long VatIrr);
/// <summary>The outcome of a مودیان submission.</summary>
/// <param name="Status">A <c>moadian_status</c> code — <c>pending</c> from the mock by default.</param>
/// <param name="ReferenceNumber">The 22-digit مودیان reference when registered; null otherwise.</param>
public sealed record MoadianSubmissionResult(string Status, string? ReferenceNumber);
@@ -0,0 +1,29 @@
#nullable enable
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// The Buy-Now-Pay-Later provider seam (SnappPay / Tara / …). <b>b12 owns the real, full definition of this
/// seam</b>; b11 introduces this minimal shape (revert/update) plus a thin local mock so the <c>bnpl_revert</c>
/// refund path is exercised before b12 merges. Money <b>always</b> flows <c>customer ↔ provider ↔ Balinyaar</c>
/// — never nurse→customer or Balinyaar→customer direct. A <b>full</b> reversal is <see cref="RevertAsync"/>; a
/// <b>partial/shortened</b> one is <see cref="UpdateAsync"/> with a strictly-lower amount. Every amount is IRR
/// <c>long</c>; the <paramref name="idempotencyKey"/> makes a retried revert a no-op rather than a double refund.
/// </summary>
public interface IBnplProvider
{
/// <summary>Full reversal of a BNPL order back through the provider.</summary>
ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>Partial revert — reduces the order to a strictly-lower <paramref name="newAmountIrr"/>.</summary>
ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
}
/// <summary>The outcome of a BNPL revert/update.</summary>
/// <param name="Status">Whether the provider accepted the reversal.</param>
/// <param name="ExternalRevertReference">The provider's revert id, persisted on the refund.</param>
/// <param name="ProviderCommissionReversedAmount">The provider's own commission it returned — <b>nullable</b>,
/// reconciled from the response, never hardcoded (some providers keep their fee on a refund).</param>
public sealed record BnplRevertResult(
PaymentProviderStatus Status,
string? ExternalRevertReference,
long? ProviderCommissionReversedAmount);
@@ -0,0 +1,18 @@
#nullable enable
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// Answers the one question a refund forks on: <b>has the nurse already been paid for this booking?</b> If not
/// (the common case — b13 gates payout on <c>dispute_window_ends_at</c>), a refund is a clean <c>nurse_payable</c>
/// reversal. If so, the money is gone to an irreversible IBAN transfer, so the refund opens a
/// <c>nurse_clawbacks</c> receivable instead.
/// <para>
/// <b>b13 owns the authoritative implementation</b> (a <c>nurse_payout_booking_links</c> lookup). Until then
/// this is derived from the booking's dispute-window close (the same gate b13 pays out on) with a config
/// override — a DB-backed facade, registered in Persistence, that b13 swaps in place.
/// </para>
/// </summary>
public interface INursePayoutStatus
{
ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default);
}
@@ -18,9 +18,9 @@ public interface IPaymentProvider
/// amount + reference against the gateway before a success callback is allowed to confirm.</summary>
ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default);
/// <summary>Reverses a captured payment (partial or full). Exposed here so b11 refunds can call it; this
/// phase builds no refund flow.</summary>
ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default);
/// <summary>Reverses a captured payment (partial or full). The <paramref name="idempotencyKey"/> makes a
/// retried refund a no-op rather than a double reversal (b11 refunds carry the booking+refund key).</summary>
ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
}
/// <summary>The outcome verb of a provider verify/refund — mirrors the wire <c>payment</c> status codes.</summary>
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Application.Models.Invoices;
using Baya.Domain.Entities.Invoices;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The invoices aggregate. One issued invoice per booking (idempotent). The sequential <c>invoice_number</c> is
/// drawn from a concurrency-safe counter row via <see cref="ReserveNextInvoiceNumberAsync"/>, whose increment
/// commits in the same transaction as the invoice insert. Money is IRR <c>long</c>.
/// </summary>
public interface IInvoiceRepository
{
/// <summary>The booking facts the invoice copies + the owning customer's user id (tenancy). Null when the
/// booking is absent.</summary>
Task<InvoiceBookingAmounts?> GetBookingAmountsAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>The existing issued invoice for a booking, if any — the idempotency check (re-issue returns it).</summary>
Task<Invoice?> GetTrackedByBookingIdAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>The booking's invoice projected for the read, with the owning customer's user id for tenancy.
/// Null when none is issued yet.</summary>
Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken);
Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken);
/// <summary>Reads and increments the tracked counter row and returns the reserved value. The increment is
/// <b>not</b> committed here — the caller commits it alongside the invoice insert so numbers stay gap-free
/// under a rollback. Call under the invoice-number lock.</summary>
Task<long> ReserveNextInvoiceNumberAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Refunds;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The refunds/clawbacks aggregate. Writes load tracked rows; reads project to DTOs. The ledger legs
/// themselves are appended through <see cref="IPaymentRepository.AddLedgerEntriesAsync"/> (b10's helper) — this
/// repo owns only the refund/clawback rows and the money facts a refund is validated against. Money is IRR
/// <c>long</c>; the <c>Σ refunded ≤ captured</c> invariant is enforced in the handler from
/// <see cref="GetRefundedSumForTransactionAsync"/> under the booking refund lock.
/// </summary>
public interface IRefundRepository
{
/// <summary>The booking's frozen money split + b9 cancellation snapshot + its captured (succeeded)
/// transaction — everything a refund is decomposed and channel-picked from. Null when the booking has no
/// captured payment (refund is impossible).</summary>
Task<RefundMoneyContext?> GetRefundContextAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>The sum of prior non-failed/-rejected refund <c>amount</c> for a transaction — the authoritative
/// backstop for <c>Σ refunded ≤ captured</c> (this refund's amount is added on top in the handler).</summary>
Task<long> GetRefundedSumForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken);
Task AddRefundAsync(Refund refund, CancellationToken cancellationToken);
Task AddClawbackAsync(NurseClawback clawback, CancellationToken cancellationToken);
/// <summary>Tracked pending clawback — for the admin write-off. Null when absent or already resolved.</summary>
Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken);
Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The customer-facing status of a single refund, with the owning customer's user id for tenancy.
/// Null when absent.</summary>
Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
}
@@ -19,6 +19,8 @@ public interface IUnitOfWork
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -0,0 +1,90 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Invoices;
using Baya.Domain.Entities.Invoices;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace Baya.Application.Features.Invoices.Commands.IssueInvoice;
internal sealed class IssueInvoiceCommandHandler(
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
IDistributedLock distributedLock,
IMoadianClient moadianClient,
IObjectStorage objectStorage)
: IRequestHandler<IssueInvoiceCommand, OperationResult<InvoiceDto>>
{
public async ValueTask<OperationResult<InvoiceDto>> Handle(IssueInvoiceCommand request, CancellationToken cancellationToken)
{
// Idempotent per booking — re-issue returns the existing invoice, never a second number.
var existing = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken);
if (existing is not null)
return OperationResult<InvoiceDto>.SuccessResult(InvoiceDtoFactory.FromEntity(existing, PdfUrl(existing)));
var amounts = await unitOfWork.InvoiceRepository.GetBookingAmountsAsync(request.BookingId, cancellationToken);
if (amounts is null)
return OperationResult<InvoiceDto>.NotFoundResult("Booking not found.");
// VAT applies to the commission line ONLY — never the nurse payout — at a config-driven rate. A
// vat_rate = 0 exemption yields vat_irr = 0. Integer-only, no float path.
var vatRate = await platformConfig.GetConfig<decimal>("vat_rate", cancellationToken);
var vatIrr = (long)Math.Round(amounts.PlatformCommissionIrr * vatRate, MidpointRounding.AwayFromZero);
var now = dateTimeProvider.UtcNow.UtcDateTime;
await using var _ = await distributedLock.AcquireAsync("invoice:number", cancellationToken);
// Re-check under the lock in case a concurrent issue won the race.
existing = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken);
if (existing is not null)
return OperationResult<InvoiceDto>.SuccessResult(InvoiceDtoFactory.FromEntity(existing, PdfUrl(existing)));
var sequence = await unitOfWork.InvoiceRepository.ReserveNextInvoiceNumberAsync(cancellationToken);
var invoiceNumber = $"INV-{sequence:D10}";
var submission = new InvoiceSubmission(invoiceNumber, request.BookingId, amounts.GrossIrr, amounts.PlatformCommissionIrr, vatIrr);
var moadian = await moadianClient.SubmitAsync(submission, cancellationToken);
var invoice = new Invoice
{
BookingId = request.BookingId,
InvoiceNumber = invoiceNumber,
IssuingEntityType = InvoiceIssuingEntityType.Platform,
GrossIrr = amounts.GrossIrr,
PlatformCommissionIrr = amounts.PlatformCommissionIrr,
BnplCommissionIrr = amounts.BnplCommissionIrr,
VatRate = vatRate,
VatIrr = vatIrr,
IssuedAt = now
};
invoice.ApplyMoadianResult(moadian.Status, moadian.ReferenceNumber);
await unitOfWork.InvoiceRepository.AddInvoiceAsync(invoice, cancellationToken);
try
{
// Commits the counter increment + the invoice row together, so the sequence stays gap-free.
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
// Lost the UNIQUE(booking_id) race — return the invoice the winner created (idempotent no-op).
await unitOfWork.RollBackAsync();
var winner = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken);
return winner is null
? OperationResult<InvoiceDto>.ConflictResult("An invoice already exists for this booking.")
: OperationResult<InvoiceDto>.SuccessResult(InvoiceDtoFactory.FromEntity(winner, PdfUrl(winner)));
}
return OperationResult<InvoiceDto>.SuccessResult(InvoiceDtoFactory.FromEntity(invoice, PdfUrl(invoice)));
}
private string? PdfUrl(Invoice invoice)
=> invoice.PdfStorageKey is { } key ? objectStorage.GetUrl(key) : null;
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Invoices.Commands.IssueInvoice;
public sealed class IssueInvoiceCommandValidator : AbstractValidator<IssueInvoiceCommand>
{
public IssueInvoiceCommandValidator()
{
RuleFor(x => x.BookingId).GreaterThan(0);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Invoices;
using Mediator;
namespace Baya.Application.Features.Invoices.Commands.IssueInvoice;
/// <summary>Issues the booking's official commission invoice — a sequential number, config-driven VAT on the
/// commission line only, and a (mocked) مودیان submission. Idempotent per booking: re-issue returns the
/// existing invoice.</summary>
public record IssueInvoiceCommand(long BookingId) : IRequest<OperationResult<InvoiceDto>>;
@@ -0,0 +1,25 @@
#nullable enable
using Baya.Application.Models.Invoices;
using Baya.Domain.Entities.Invoices;
namespace Baya.Application.Features.Invoices;
/// <summary>Maps an <see cref="Invoice"/> to its wire DTO, stringifying every IRR amount per the money
/// convention. The <paramref name="pdfUrl"/> is resolved by the handler through <c>IObjectStorage</c>.</summary>
internal static class InvoiceDtoFactory
{
public static InvoiceDto FromEntity(Invoice invoice, string? pdfUrl) => new(
invoice.Id,
invoice.BookingId,
invoice.InvoiceNumber,
invoice.IssuingEntityType,
invoice.GrossIrr.ToString(),
invoice.PlatformCommissionIrr.ToString(),
invoice.BnplCommissionIrr?.ToString(),
invoice.VatRate,
invoice.VatIrr.ToString(),
invoice.MoadianReferenceNumber,
invoice.MoadianStatus,
pdfUrl,
invoice.IssuedAt);
}
@@ -0,0 +1,32 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Invoices;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Invoices.Queries.GetInvoice;
internal sealed class GetInvoiceQueryHandler(
IUnitOfWork unitOfWork,
ICurrentUser currentUser,
IObjectStorage objectStorage)
: IRequestHandler<GetInvoiceQuery, OperationResult<InvoiceDto>>
{
public async ValueTask<OperationResult<InvoiceDto>> Handle(GetInvoiceQuery request, CancellationToken cancellationToken)
{
var projection = await unitOfWork.InvoiceRepository.GetByBookingIdAsync(request.BookingId, cancellationToken);
if (projection is null)
return OperationResult<InvoiceDto>.NotFoundResult("Invoice not found.");
// Admins read any invoice; a customer reads only their own booking's — a cross-customer read is a
// clean not-found, never a leak.
var isAdmin = currentUser.Roles.Contains(RoleNames.Admin);
if (!isAdmin && projection.CustomerUserId != currentUser.UserId)
return OperationResult<InvoiceDto>.NotFoundResult("Invoice not found.");
var pdfUrl = projection.PdfStorageKey is { } key ? objectStorage.GetUrl(key) : null;
return OperationResult<InvoiceDto>.SuccessResult(projection.Invoice with { PdfUrl = pdfUrl });
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Invoices;
using Mediator;
namespace Baya.Application.Features.Invoices.Queries.GetInvoice;
/// <summary>The booking's invoice for the customer (tenancy-scoped) or an admin. Surfaces the number, amounts,
/// VAT, مودیان status and a PDF download URL when present.</summary>
public record GetInvoiceQuery(long BookingId) : IRequest<OperationResult<InvoiceDto>>;
@@ -0,0 +1,244 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.SupportAlerts;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.CreateRefund;
/// <summary>
/// The money-side of a cancellation/dispute refund. Runs the whole reversal under
/// <c>lock(booking:{id}:refund)</c> so a cancellation-driven and a webhook-driven refund can't both fire and
/// breach <c>Σ refunded ≤ captured</c>. It decomposes the refund across both fee legs, forks on whether the
/// nurse was already paid (clean <c>nurse_payable</c> reversal vs a <c>nurse_clawbacks</c> receivable), executes
/// the channel behind its seam, and posts the balanced ledger group(s) via b10's helper. The channel-execution
/// and ledger-posting "internal steps" from the phase are cohesive private steps here (mirroring b10's
/// <c>ConfirmPaymentAndPostLedger</c>) so they stay atomic under one lock.
/// </summary>
internal sealed class CreateRefundCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
ICurrentUser currentUser,
IPaymentProvider paymentProvider,
IBnplProvider bnplProvider,
INursePayoutStatus nursePayoutStatus,
ISupportAlertService supportAlerts,
INotificationDispatcher notifications)
: IRequestHandler<CreateRefundCommand, OperationResult<CreateRefundResult>>
{
public async ValueTask<OperationResult<CreateRefundResult>> Handle(CreateRefundCommand request, CancellationToken cancellationToken)
{
// Ticket link is required, but config-gated off until b15 ships the tickets table (the FK is nullable
// now for exactly this forward-dep). Read the gate through the typed config accessor, never hardcoded.
var ticketRequired = await platformConfig.GetConfig<bool>("refund_ticket_required", cancellationToken);
if (ticketRequired && request.TicketId is null)
return OperationResult<CreateRefundResult>.FailureResult("ticket_id", "A support ticket is required to issue a refund.");
await using var _ = await distributedLock.AcquireAsync($"booking:{request.BookingId}:refund", cancellationToken);
var context = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken);
if (context is null)
return OperationResult<CreateRefundResult>.NotFoundResult("No captured payment exists for this booking to refund.");
var decomposition = ResolveDecomposition(request, context);
if (decomposition is null)
return OperationResult<CreateRefundResult>.FailureResult(
"refund_percentage", "Provide a refund percentage, explicit legs, or a booking cancellation snapshot.");
var (platformFeeRefunded, nursePayoutRefunded, resolvedPct) = decomposition.Value;
var amount = platformFeeRefunded + nursePayoutRefunded;
if (amount <= 0)
return OperationResult<CreateRefundResult>.FailureResult("amount", "The refund amount must be positive.");
if (platformFeeRefunded > context.CommissionIrr || nursePayoutRefunded > context.PayoutIrr)
return OperationResult<CreateRefundResult>.FailureResult("amount", "A refund leg exceeds the captured booking leg.");
// Σ refunded ≤ captured — the authoritative backstop, summed under the lock.
var priorRefunded = await unitOfWork.RefundRepository.GetRefundedSumForTransactionAsync(context.PaymentTransactionId, cancellationToken);
if (priorRefunded + amount > context.CapturedAmount)
return OperationResult<CreateRefundResult>.ConflictResult(
"This refund would push total refunds over the captured amount.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
var channel = SelectChannel(request, context.GatewayType);
var idempotencyKey = $"booking:{request.BookingId}:refund:{context.PaymentTransactionId}:{priorRefunded + amount}";
var refund = new Refund
{
PaymentTransactionId = context.PaymentTransactionId,
BookingId = context.BookingId,
RequestedByCustomerId = context.CustomerId,
TicketId = request.TicketId,
Amount = amount,
PlatformFeeRefundedIrr = platformFeeRefunded,
NursePayoutRefundedIrr = nursePayoutRefunded,
RefundPercentage = resolvedPct,
RefundChannel = channel,
ReasonCategory = request.ReasonCategory,
ReasonNotes = request.ReasonNotes,
AdminNotes = request.AdminNotes,
ApprovedByAdminId = currentUser.UserId,
CancellationPolicyCode = context.CancellationPolicyCode,
RefundPercentageApplied = context.CancellationRefundPercentage
};
// Execute the channel (external, behind its seam) BEFORE persisting, so a provider refusal leaves the
// refund row failed with no ledger. The idempotency key makes a retried channel call a no-op.
var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken);
await unitOfWork.RefundRepository.AddRefundAsync(refund, cancellationToken);
await unitOfWork.CommitAsync();
if (!executed)
return OperationResult<CreateRefundResult>.FailureResult("channel", "The refund channel refused the reversal.");
// Pre-payout (clean reversal) vs post-payout (clawback receivable) fork — an Iranian IBAN transfer is
// irreversible, so a paid-out nurse's payout leg becomes owed-back, never silently absorbed.
var isNursePaid = await nursePayoutStatus.IsNursePaidForBookingAsync(context.BookingId, cancellationToken);
long? clawbackId = null;
var reversal = isNursePaid
? LedgerPosting.ClawbackReversalPostPayout(context.BookingId, context.NurseId, platformFeeRefunded, nursePayoutRefunded, refund.Id, now)
: LedgerPosting.RefundReversalPrePayout(context.BookingId, context.NurseId, platformFeeRefunded, nursePayoutRefunded, refund.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(reversal, cancellationToken);
NurseClawback? clawback = null;
if (isNursePaid && nursePayoutRefunded > 0)
{
clawback = new NurseClawback
{
NurseId = context.NurseId,
BookingId = context.BookingId,
RefundId = refund.Id,
AmountIrr = nursePayoutRefunded
};
await unitOfWork.RefundRepository.AddClawbackAsync(clawback, cancellationToken);
}
// The customer cash-back clears refund_payable ↔ escrow_held only once confirmed — immediate for a
// succeeded card refund; deferred to reconciliation while a BNPL/manual refund sits in processing.
if (refund.Status == RefundStatus.Succeeded)
{
var clearing = LedgerPosting.RefundPayableClearing(context.BookingId, amount, refund.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(clearing, cancellationToken);
}
await unitOfWork.CommitAsync();
// Self-committing facades run only after the atomic commit (they flush the shared DbContext).
if (clawback is not null)
{
clawbackId = clawback.Id;
await supportAlerts.RaiseAsync(
SupportAlertType.NurseClawback, entityType: "refund", entityId: refund.Id.ToString(),
severity: SupportAlertSeverity.High, bookingId: context.BookingId, cancellationToken: cancellationToken);
}
await NotifyCustomerAsync(context, refund, cancellationToken);
return OperationResult<CreateRefundResult>.SuccessResult(new CreateRefundResult(
refund.Id, context.BookingId, refund.Status, channel,
amount.ToString(), platformFeeRefunded.ToString(), nursePayoutRefunded.ToString(),
refund.ExpectedCustomerRefundEta, clawbackId));
}
private static (long PlatformFee, long NursePayout, decimal Pct)? ResolveDecomposition(CreateRefundCommand request, RefundMoneyContext context)
{
// Admin-supplied explicit legs take precedence; they must sum to the amount (validator ensures both set).
if (request.PlatformFeeRefundedIrr is { } fee && request.NursePayoutRefundedIrr is { } payout)
{
var amount = fee + payout;
var impliedPct = context.GrossPriceIrr > 0 ? (decimal)amount / context.GrossPriceIrr : 0m;
return (fee, payout, impliedPct);
}
// Otherwise pro-rata each booking leg at the resolved fraction: the command's, else the b9 snapshot's
// (percentage stored 0100 on the booking) — never re-resolved from live config.
var pct = request.RefundPercentage
?? (context.CancellationRefundPercentage is { } snap ? snap / 100m : (decimal?)null);
if (pct is not { } fraction || fraction <= 0)
return null;
var feeLeg = (long)Math.Round(context.CommissionIrr * fraction, MidpointRounding.AwayFromZero);
var payoutLeg = (long)Math.Round(context.PayoutIrr * fraction, MidpointRounding.AwayFromZero);
return (feeLeg, payoutLeg, fraction);
}
private static string SelectChannel(CreateRefundCommand request, string gatewayType)
{
if (!string.IsNullOrWhiteSpace(request.ManualBankReference))
return RefundChannel.Manual;
return gatewayType == PaymentGatewayType.Bnpl ? RefundChannel.BnplRevert : RefundChannel.PspCard;
}
private async Task<bool> ExecuteChannelAsync(
Refund refund, string channel, RefundMoneyContext context, string? manualBankReference,
long amount, long priorRefunded, string idempotencyKey, DateTime now, CancellationToken cancellationToken)
{
switch (channel)
{
case RefundChannel.PspCard:
{
var result = await paymentProvider.RefundAsync(
context.GatewayReferenceCode ?? string.Empty, amount, idempotencyKey, cancellationToken);
if (result.Status != PaymentProviderStatus.Succeeded)
{
refund.MarkFailed(result.Status.ToString());
return false;
}
refund.MarkSucceededCard(result.GatewayRefundReference ?? idempotencyKey, now);
return true;
}
case RefundChannel.BnplRevert:
{
// Full = revert; partial/shortened = update to the strictly-lower remaining amount.
var isFull = priorRefunded == 0 && amount == context.CapturedAmount;
var result = isFull
? await bnplProvider.RevertAsync(context.GatewayReferenceCode ?? string.Empty, amount, idempotencyKey, cancellationToken)
: await bnplProvider.UpdateAsync(context.GatewayReferenceCode ?? string.Empty, context.CapturedAmount - (priorRefunded + amount), idempotencyKey, cancellationToken);
if (result.Status == PaymentProviderStatus.Failed)
{
refund.MarkFailed(result.Status.ToString());
return false;
}
var eta = await ExpectedEtaAsync(now, cancellationToken);
refund.MarkProcessing(result.ExternalRevertReference, eta);
return true;
}
default: // manual out-of-band bank refund — admin records the bank ref; the cash-back confirms later.
{
refund.MarkProcessing(manualBankReference, null);
return true;
}
}
}
private async Task<DateOnly> ExpectedEtaAsync(DateTime now, CancellationToken cancellationToken)
{
var days = await platformConfig.GetConfig<int>("bnpl_refund_eta_business_days", cancellationToken);
return BusinessDays.Add(DateOnly.FromDateTime(now), days);
}
private async Task NotifyCustomerAsync(RefundMoneyContext context, Refund refund, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { booking_id = context.BookingId, refund_id = refund.Id, refund.Status });
var body = refund.Status == RefundStatus.Succeeded
? "Your refund has been processed."
: "Your refund is on its way and should arrive within a few business days.";
await notifications.DispatchAsync(
new Notification(context.CustomerUserId, "refund_issued", "Refund issued", body, payload),
cancellationToken);
}
}
@@ -0,0 +1,35 @@
using FluentValidation;
namespace Baya.Application.Features.Refunds.Commands.CreateRefund;
public sealed class CreateRefundCommandValidator : AbstractValidator<CreateRefundCommand>
{
public CreateRefundCommandValidator()
{
RuleFor(x => x.BookingId).GreaterThan(0);
When(x => x.RefundPercentage.HasValue, () =>
{
RuleFor(x => x.RefundPercentage!.Value)
.GreaterThan(0m).LessThanOrEqualTo(1m)
.WithMessage("refund_percentage must be a fraction in (0, 1].");
});
// Explicit legs are all-or-nothing and non-negative; the handler enforces amount = fee + payout and the
// Σ refunded ≤ captured invariant under the booking refund lock.
When(x => x.PlatformFeeRefundedIrr.HasValue || x.NursePayoutRefundedIrr.HasValue, () =>
{
RuleFor(x => x.PlatformFeeRefundedIrr)
.NotNull().GreaterThanOrEqualTo(0)
.WithMessage("Both refund legs must be supplied together.");
RuleFor(x => x.NursePayoutRefundedIrr)
.NotNull().GreaterThanOrEqualTo(0)
.WithMessage("Both refund legs must be supplied together.");
});
RuleFor(x => x.ReasonCategory).MaximumLength(50);
RuleFor(x => x.ReasonNotes).MaximumLength(1000);
RuleFor(x => x.AdminNotes).MaximumLength(1000);
RuleFor(x => x.ManualBankReference).MaximumLength(200);
}
}
@@ -0,0 +1,26 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.CreateRefund;
/// <summary>
/// Admin-initiated, ticket-linked reversal of a captured booking payment. Either supply <see cref="RefundPercentage"/>
/// (a 01 fraction applied pro-rata to the booking's commission/payout legs) or the explicit
/// <see cref="PlatformFeeRefundedIrr"/> + <see cref="NursePayoutRefundedIrr"/> legs (which must still sum to the
/// refunded amount). When neither the command nor the booking's cancellation snapshot resolves a percentage the
/// request is rejected. Providing <see cref="ManualBankReference"/> forces the <c>manual</c> (out-of-band bank)
/// channel; otherwise the channel is derived from the original payment type (card ⇒ <c>psp_card</c>, BNPL ⇒
/// <c>bnpl_revert</c>).
/// </summary>
public record CreateRefundCommand(
long BookingId,
long? TicketId,
decimal? RefundPercentage,
long? PlatformFeeRefundedIrr,
long? NursePayoutRefundedIrr,
string? ReasonCategory,
string? ReasonNotes,
string? AdminNotes,
string? ManualBankReference) : IRequest<OperationResult<CreateRefundResult>>;
@@ -0,0 +1,33 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Payments;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback;
internal sealed class WriteOffClawbackCommandHandler(
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<WriteOffClawbackCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(WriteOffClawbackCommand request, CancellationToken cancellationToken)
{
var clawback = await unitOfWork.RefundRepository.GetTrackedClawbackByIdAsync(request.ClawbackId, cancellationToken);
if (clawback is null)
return OperationResult<bool>.NotFoundResult("Clawback not found.");
if (!clawback.IsPending)
return OperationResult<bool>.ConflictResult("Only a pending clawback can be written off.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
clawback.WriteOff(request.Reason, now);
var correction = LedgerPosting.ClawbackWriteOff(
clawback.BookingId, clawback.NurseId, clawback.AmountIrr, clawback.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(correction, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback;
public sealed class WriteOffClawbackCommandValidator : AbstractValidator<WriteOffClawbackCommand>
{
public WriteOffClawbackCommandValidator()
{
RuleFor(x => x.ClawbackId).GreaterThan(0);
RuleFor(x => x.Reason).NotEmpty().MaximumLength(500);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback;
/// <summary>Admin marks a <c>pending</c> nurse clawback uncollectable, posting the balancing
/// <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> correction. Recovery via payout netting is b13.</summary>
public record WriteOffClawbackCommand(long ClawbackId, string Reason) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,25 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Queries.GetRefundStatus;
internal sealed class GetRefundStatusQueryHandler(
IUnitOfWork unitOfWork,
ICurrentUser currentUser)
: IRequestHandler<GetRefundStatusQuery, OperationResult<RefundStatusDto>>
{
public async ValueTask<OperationResult<RefundStatusDto>> Handle(GetRefundStatusQuery request, CancellationToken cancellationToken)
{
var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken);
// A cross-customer access is indistinguishable from "not found" — never confirm the refund exists.
if (projection is null || projection.CustomerUserId != currentUser.UserId)
return OperationResult<RefundStatusDto>.NotFoundResult("Refund not found.");
return OperationResult<RefundStatusDto>.SuccessResult(projection.Refund);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Queries.GetRefundStatus;
/// <summary>The customer-facing status of their own refund — status, channel, amount and the BNPL ETA window.
/// Tenancy-scoped to the booking's customer via <c>ICurrentUser</c>; another customer's refund is not visible.</summary>
public record GetRefundStatusQuery(long RefundId) : IRequest<OperationResult<RefundStatusDto>>;
@@ -0,0 +1,20 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Queries.ListRefunds;
internal sealed class ListRefundsQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<ListRefundsQuery, OperationResult<PagedResult<RefundListItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<RefundListItemDto>>> Handle(ListRefundsQuery request, CancellationToken cancellationToken)
{
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var result = await unitOfWork.RefundRepository.ListAsync(request.BookingId, request.Status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<RefundListItemDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Queries.ListRefunds;
/// <summary>Admin refund worklist — projected + paginated; surfaces channel, decomposed legs, status, the BNPL
/// ETA and the policy snapshot. Optional <paramref name="BookingId"/> / <paramref name="Status"/> filters.</summary>
public record ListRefundsQuery(long? BookingId = null, string? Status = null, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<RefundListItemDto>>>;
@@ -0,0 +1,32 @@
#nullable enable
namespace Baya.Application.Models.Invoices;
/// <summary>The booking facts <c>IssueInvoiceCommand</c> copies onto the invoice, plus the owning customer's
/// user id for the tenancy-scoped read. Money is IRR <c>long</c>.</summary>
public record InvoiceBookingAmounts(
long BookingId,
int CustomerUserId,
long GrossIrr,
long PlatformCommissionIrr,
long? BnplCommissionIrr);
/// <summary>The booking's official invoice. Money crosses the wire as digit strings; the PDF URL is derived
/// from the stored object key when present.</summary>
public record InvoiceDto(
long Id,
long BookingId,
string InvoiceNumber,
string IssuingEntityType,
string GrossIrr,
string PlatformCommissionIrr,
string? BnplCommissionIrr,
decimal VatRate,
string VatIrr,
string? MoadianReferenceNumber,
string? MoadianStatus,
string? PdfUrl,
DateTime IssuedAt);
/// <summary>The tenancy envelope for the customer invoice read: the owning customer's user id plus the DTO
/// (with the raw PDF key so the handler can turn it into a URL through <c>IObjectStorage</c>).</summary>
public record InvoiceProjection(int CustomerUserId, string? PdfStorageKey, InvoiceDto Invoice);
@@ -0,0 +1,72 @@
#nullable enable
namespace Baya.Application.Models.Refunds;
/// <summary>
/// Everything <c>CreateRefundCommand</c> needs in one read to decompose, pick the channel and enforce
/// <c>Σ refunded ≤ captured</c>: the booking's frozen three-amount split, the b9 cancellation snapshot (never
/// re-resolved live), and the captured transaction it reverses. Money is IRR <c>long</c>.
/// </summary>
public record RefundMoneyContext(
long BookingId,
long CustomerId,
int CustomerUserId,
long NurseId,
long GrossPriceIrr,
long CommissionIrr,
long PayoutIrr,
string? CancellationPolicyCode,
decimal? CancellationRefundPercentage,
long? RefundableAmountIrr,
long PaymentTransactionId,
string? GatewayReferenceCode,
long CapturedAmount,
string GatewayType);
/// <summary>The admin refund worklist row — channel, decomposed legs, status, policy snapshot and the BNPL ETA.
/// Money crosses the wire as digit strings.</summary>
public record RefundListItemDto(
long Id,
long BookingId,
long PaymentTransactionId,
string Amount,
string PlatformFeeRefundedIrr,
string NursePayoutRefundedIrr,
string RefundChannel,
string Status,
decimal RefundPercentage,
string? ReasonCategory,
string? CancellationPolicyCode,
decimal? RefundPercentageApplied,
DateOnly? ExpectedCustomerRefundEta,
string? GatewayRefundReference,
string? ExternalRevertReference,
DateTime? ProcessedAt,
DateTimeOffset CreatedAt);
/// <summary>The customer-facing status of their own refund — the reference is masked, money is a digit string,
/// and the BNPL 710-business-day window is surfaced so the UI can show "on its way, ~N days".</summary>
public record RefundStatusDto(
long Id,
long BookingId,
string Status,
string RefundChannel,
string Amount,
DateOnly? ExpectedCustomerRefundEta,
string? Reference);
/// <summary>The tenancy envelope for the customer refund-status read: the owning customer's user id (compared
/// to <c>ICurrentUser</c>) plus the DTO. A cross-customer access is a clean not-found, never a leak.</summary>
public record RefundStatusProjection(int CustomerUserId, RefundStatusDto Refund);
/// <summary>What <c>CreateRefundCommand</c> returns — the created refund's identity, channel, terminal-ish
/// status, decomposed legs and (BNPL) ETA, and whether it opened a clawback. Money is a digit string.</summary>
public record CreateRefundResult(
long RefundId,
long BookingId,
string Status,
string RefundChannel,
string Amount,
string PlatformFeeRefundedIrr,
string NursePayoutRefundedIrr,
DateOnly? ExpectedCustomerRefundEta,
long? ClawbackId);
@@ -0,0 +1,62 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Invoices;
/// <summary>
/// The minimal official receipt per booking. <b>VAT applies to the platform commission line only</b>
/// (<see cref="PlatformCommissionIrr"/>) — never the nurse's earnings — following the Snapp/Tapsi precedent
/// that the nurse is the taxable seller of the care service and the platform's commission is its own taxable
/// revenue. <see cref="VatIrr"/> is computed integer-only from a <b>config-driven</b> <see cref="VatRate"/>
/// (default 0.10); a <c>vat_rate = 0</c> exemption yields <see cref="VatIrr"/> = 0.
/// <para>
/// <see cref="InvoiceNumber"/> is UNIQUE and <b>sequential</b>, drawn from a concurrency-safe counter row
/// (never random/timestamp-derived). One issued invoice per booking (idempotent).
/// </para>
/// </summary>
public class Invoice : BaseEntity<long>
{
public long BookingId { get; set; }
/// <summary>Official sequential number (UNIQUE) — drawn from <see cref="InvoiceNumberSequence"/>.</summary>
public string InvoiceNumber { get; set; } = null!;
/// <summary>An <see cref="InvoiceIssuingEntityType"/> code.</summary>
public string IssuingEntityType { get; set; } = InvoiceIssuingEntityType.Platform;
/// <summary>The licensed center that is merchant-of-record, when the issuer is a partner center (b15).</summary>
public long? PartnerCenterId { get; set; }
public long GrossIrr { get; set; }
/// <summary>The VAT-relevant line — VAT is computed on this, never on the nurse payout.</summary>
public long PlatformCommissionIrr { get; set; }
public long? BnplCommissionIrr { get; set; }
/// <summary>Config-driven VAT rate snapshot (default 0.10) frozen at issue time.</summary>
public decimal VatRate { get; set; }
/// <summary>round(<see cref="PlatformCommissionIrr"/> × <see cref="VatRate"/>) — integer-only, no float path.</summary>
public long VatIrr { get; set; }
/// <summary>The سامانه مودیان 22-digit reference when registered; null until then.</summary>
public string? MoadianReferenceNumber { get; set; }
/// <summary>A <see cref="MoadianStatus"/> code.</summary>
public string? MoadianStatus { get; set; }
/// <summary>An <c>IObjectStorage</c> key for the optional invoice PDF.</summary>
public string? PdfStorageKey { get; set; }
public DateTime IssuedAt { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
/// <summary>Records the outcome of a مودیان submission (mock: pending/no-ref; forced: registered/ref).</summary>
public void ApplyMoadianResult(string status, string? referenceNumber)
{
MoadianStatus = status;
MoadianReferenceNumber = referenceNumber;
}
}
@@ -0,0 +1,9 @@
namespace Baya.Domain.Entities.Invoices;
/// <summary>The closed <c>invoices.issuing_entity_type</c> code set — who issues the receipt. A partner center
/// (b15) is the merchant-of-record for its employees' bookings; otherwise the platform issues it.</summary>
public static class InvoiceIssuingEntityType
{
public const string Platform = "platform";
public const string PartnerCenter = "partner_center";
}
@@ -0,0 +1,19 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Invoices;
/// <summary>
/// The gap-free, concurrency-safe counter behind <c>invoices.invoice_number</c>. A single seeded row holds the
/// next value; issuing an invoice takes the money-path lock, reads and increments <see cref="NextValue"/>, and
/// commits the increment <b>in the same transaction</b> as the invoice insert — so a rollback rolls both back
/// and numbers stay sequential and unique. A dedicated counter row (not <c>MAX()+1</c>) keeps this correct under
/// concurrency and portable across SQL Server and the SQLite test provider (no provider-specific sequence).
/// </summary>
public class InvoiceNumberSequence : IEntity
{
/// <summary>Fixed singleton key — there is exactly one counter row (id 1).</summary>
public int Id { get; set; }
/// <summary>The next number to hand out. Incremented under the invoice lock.</summary>
public long NextValue { get; set; }
}
@@ -0,0 +1,22 @@
namespace Baya.Domain.Entities.Invoices;
/// <summary>
/// The closed <c>invoices.moadian_status</c> code set — the سامانه مودیان e-invoicing submission state. The mock
/// <c>IMoadianClient</c> leaves a newly issued invoice at <see cref="Pending"/> with no reference; a config
/// switch can force <see cref="Registered"/> (with a fake 22-digit ref) so the reconciliation path is testable.
/// The real adapter walks <see cref="Pending"/> → <see cref="Submitted"/> → <see cref="Registered"/>.
/// </summary>
public static class MoadianStatus
{
/// <summary>Issued locally; not yet sent to مودیان.</summary>
public const string Pending = "pending";
/// <summary>Sent to مودیان; awaiting the registered reference.</summary>
public const string Submitted = "submitted";
/// <summary>مودیان returned the 22-digit reference — the invoice is officially registered.</summary>
public const string Registered = "registered";
/// <summary>مودیان rejected the submission.</summary>
public const string Failed = "failed";
}
@@ -28,25 +28,119 @@ public static class LedgerPosting
$"Card-capture group would not balance: gross {grossIrr} != commission {commissionIrr} + payout {payoutIrr}.");
var group = Guid.NewGuid();
LedgerEntry Leg(string account, string direction, long amount, long? nurse) => new()
{
TransactionGroupId = group,
AccountType = account,
Direction = direction,
AmountIrr = amount,
NurseId = nurse,
BookingId = bookingId,
SourceRefType = LedgerSourceRefType.PaymentTransaction,
SourceRefId = paymentTransactionId,
CreatedAt = createdAt
};
return
[
Leg(LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null),
Leg(LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null),
Leg(LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId)
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt),
Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt),
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt)
];
}
/// <summary>
/// The <b>pre-payout refund reversal</b> (the nurse has not been paid, the common case): <c>DEBIT
/// platform_revenue fee + DEBIT nurse_payable payout / CREDIT refund_payable (sum)</c> under one group.
/// Simply un-accrues what capture posted. The customer cash-back is cleared separately by
/// <see cref="RefundPayableClearing"/> once the provider confirms it.
/// </summary>
public static IReadOnlyList<LedgerEntry> RefundReversalPrePayout(
long bookingId,
long nurseId,
long platformFeeRefundedIrr,
long nursePayoutRefundedIrr,
long refundId,
DateTime createdAt)
{
var group = Guid.NewGuid();
var total = platformFeeRefundedIrr + nursePayoutRefundedIrr;
var legs = new List<LedgerEntry>();
if (platformFeeRefundedIrr > 0)
legs.Add(Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit, platformFeeRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
if (nursePayoutRefundedIrr > 0)
legs.Add(Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, nursePayoutRefundedIrr, nurseId, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
legs.Add(Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Credit, total, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
return legs;
}
/// <summary>
/// The <b>post-payout clawback reversal</b> (the nurse was already paid — an irreversible IBAN transfer):
/// identical to the pre-payout group except the payout leg debits <c>nurse_clawback_receivable</c> (money
/// owed back by the nurse) instead of un-accruing <c>nurse_payable</c>: <c>DEBIT platform_revenue fee +
/// DEBIT nurse_clawback_receivable payout / CREDIT refund_payable (sum)</c>. A <c>nurse_clawbacks</c> row
/// tracks the workflow; b13 nets the receivable out of a later payout.
/// </summary>
public static IReadOnlyList<LedgerEntry> ClawbackReversalPostPayout(
long bookingId,
long nurseId,
long platformFeeRefundedIrr,
long nursePayoutRefundedIrr,
long refundId,
DateTime createdAt)
{
var group = Guid.NewGuid();
var total = platformFeeRefundedIrr + nursePayoutRefundedIrr;
var legs = new List<LedgerEntry>();
if (platformFeeRefundedIrr > 0)
legs.Add(Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit, platformFeeRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
if (nursePayoutRefundedIrr > 0)
legs.Add(Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Debit, nursePayoutRefundedIrr, nurseId, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
legs.Add(Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Credit, total, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt));
return legs;
}
/// <summary>
/// Clears the customer cash-back once the provider confirms it (card immediately; BNPL/manual on
/// reconciliation): <c>DEBIT refund_payable / CREDIT escrow_held</c> for the refunded total. Path- and
/// channel-independent — the same second leg follows either the pre-payout or the clawback reversal.
/// </summary>
public static IReadOnlyList<LedgerEntry> RefundPayableClearing(
long bookingId,
long totalRefundedIrr,
long refundId,
DateTime createdAt)
{
var group = Guid.NewGuid();
return
[
Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Debit, totalRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt),
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, totalRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt)
];
}
/// <summary>
/// The clawback <b>write-off</b> correction: <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> for
/// the amount, when an admin declares the receivable uncollectable. A new balancing group — never an edit.
/// </summary>
public static IReadOnlyList<LedgerEntry> ClawbackWriteOff(
long bookingId,
long nurseId,
long amountIrr,
long clawbackId,
DateTime createdAt)
{
var group = Guid.NewGuid();
return
[
Leg(group, LedgerAccountType.BadDebt, LedgerDirection.Debit, amountIrr, null, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt),
Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt)
];
}
private static LedgerEntry Leg(
Guid group, string account, string direction, long amount, long? nurse,
long bookingId, string sourceType, long sourceId, DateTime createdAt) => new()
{
TransactionGroupId = group,
AccountType = account,
Direction = direction,
AmountIrr = amount,
NurseId = nurse,
BookingId = bookingId,
SourceRefType = sourceType,
SourceRefId = sourceId,
CreatedAt = createdAt
};
}
@@ -0,0 +1,18 @@
namespace Baya.Domain.Entities.Refunds;
/// <summary>
/// The closed <c>nurse_clawbacks.status</c> code set. This phase only ever creates rows in <see cref="Pending"/>
/// (and supports an admin <see cref="WrittenOff"/>); <see cref="Recovered"/> is set by <b>b13</b>'s payout
/// netting — recovery is not implemented here. Persisted as these stable snake_case codes.
/// </summary>
public static class ClawbackStatus
{
/// <summary>Open receivable owed by the nurse. The only state this phase creates.</summary>
public const string Pending = "pending";
/// <summary>Netted out of a later payout batch. Set by b13 — never here.</summary>
public const string Recovered = "recovered";
/// <summary>Admin-declared uncollectable; balanced by a <c>bad_debt</c> posting.</summary>
public const string WrittenOff = "written_off";
}
@@ -0,0 +1,52 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Refunds;
/// <summary>
/// A first-class receivable opened when a booking is refunded/disputed <b>after</b> the nurse was already paid.
/// Because an Iranian IBAN transfer is effectively irreversible, that money is already gone and must be
/// recorded as owed-back — never silently absorbed. The receivable's ledger leg is
/// <c>DEBIT nurse_clawback_receivable</c>; the balance derives from the ledger, this row tracks the workflow.
/// <para>
/// This phase only ever creates rows in <see cref="ClawbackStatus.Pending"/> and supports an admin write-off.
/// <see cref="RecoveredInPayoutId"/> / <see cref="OriginalPayoutId"/> are the (nullable) join points b13 fills
/// when a payout batch nets the clawback out — <c>nurse_payouts</c> arrives in b13.
/// </para>
/// </summary>
public class NurseClawback : BaseEntity<long>
{
public long NurseId { get; set; }
public long BookingId { get; set; }
public long RefundId { get; set; }
/// <summary>The payout that already paid the nurse. FK target (<c>nurse_payouts</c>) arrives in b13; the
/// column/index are in place now and the value is set once b13 exists.</summary>
public long? OriginalPayoutId { get; set; }
/// <summary>Equals the refund's <c>nurse_payout_refunded_irr</c> leg (IRR).</summary>
public long AmountIrr { get; set; }
public string Status { get; private set; } = ClawbackStatus.Pending;
/// <summary>The batch that netted it out. Set by <b>b13</b> only — always null here.</summary>
public long? RecoveredInPayoutId { get; set; }
public DateTime? ResolvedAt { get; private set; }
public string? ResolutionNotes { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public bool IsPending => Status == ClawbackStatus.Pending;
/// <summary>Admin declares the receivable uncollectable. The balancing <c>bad_debt</c> posting is the
/// handler's job; this records the workflow outcome.</summary>
public void WriteOff(string notes, DateTime now)
{
if (Status != ClawbackStatus.Pending)
throw new InvalidOperationException($"Only a pending clawback can be written off (was {Status}).");
Status = ClawbackStatus.WrittenOff;
ResolutionNotes = notes;
ResolvedAt = now;
}
}
@@ -0,0 +1,116 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Refunds;
/// <summary>
/// An admin-initiated, ticket-linked reversal of a captured booking payment. Refunds are <b>1:N</b> per
/// <c>payment_transaction</c> — partials exist (a shortened visit) — so the "Σ refunded ≤ captured" invariant
/// is a <b>handler</b> check under the booking refund lock, not a single-row constraint.
/// <para>
/// A refund <b>decomposes across both fee legs</b>: <see cref="Amount"/> = <see cref="PlatformFeeRefundedIrr"/>
/// (portion of the platform commission reversed) + <see cref="NursePayoutRefundedIrr"/> (portion of the nurse
/// payout reversed — this leg drives a <c>nurse_clawbacks</c> receivable when the nurse was already paid).
/// Money is IRR <c>BIGINT</c> only. The channel is chosen from the original payment type; the ledger legs are
/// the same across channels — only <see cref="RefundChannel"/>, the external reference and the customer ETA
/// differ.
/// </para>
/// </summary>
public class Refund : BaseEntity<long>
{
/// <summary>The captured transaction being reversed (N:1 — a transaction may have several partial refunds).</summary>
public long PaymentTransactionId { get; set; }
public long BookingId { get; set; }
/// <summary>The customer the refund is <i>for</i> (not the admin actor).</summary>
public long RequestedByCustomerId { get; set; }
/// <summary>Forward-dep on <c>tickets</c> (b15). Nullable now; "ticket required" is a config-gated
/// handler/validator rule so admin refunds are testable before b15 wires the real FK target.</summary>
public long? TicketId { get; set; }
/// <summary>Total refunded (IRR) = <see cref="PlatformFeeRefundedIrr"/> + <see cref="NursePayoutRefundedIrr"/>.</summary>
public long Amount { get; set; }
/// <summary>Portion of <c>balinyaar_commission_irr</c> being reversed (IRR).</summary>
public long PlatformFeeRefundedIrr { get; set; }
/// <summary>Portion of <c>nurse_payout_amount</c> being reversed (IRR) — drives a clawback if already paid.</summary>
public long NursePayoutRefundedIrr { get; set; }
/// <summary>The resolved refund fraction applied to the booking amounts to derive the legs (01).</summary>
public decimal RefundPercentage { get; set; }
/// <summary>A <see cref="RefundChannel"/> code — how the money physically flows back.</summary>
public string RefundChannel { get; set; } = null!;
public string? ReasonCategory { get; set; }
public string? ReasonNotes { get; set; }
/// <summary>Guarded — mutated only through the cohesive methods so every write goes through the machine.</summary>
public string Status { get; private set; } = RefundStatus.Approved;
public int? ApprovedByAdminId { get; set; }
public string? RejectedReason { get; private set; }
public string? AdminNotes { get; set; }
/// <summary>The PSP card-refund reference, when the channel is <c>psp_card</c>.</summary>
public string? GatewayRefundReference { get; private set; }
/// <summary>The BNPL provider revert id, when the channel is <c>bnpl_revert</c> (or the manual bank ref).</summary>
public string? ExternalRevertReference { get; private set; }
/// <summary>The ~710 business-day BNPL customer window; null for instant card refunds.</summary>
public DateOnly? ExpectedCustomerRefundEta { get; private set; }
/// <summary>Snapshot of the b9 cancellation policy <c>code</c> that produced this refund — never re-resolved live.</summary>
public string? CancellationPolicyCode { get; set; }
/// <summary>Snapshot of the resolved refund percentage (0100) frozen at cancel time.</summary>
public decimal? RefundPercentageApplied { get; set; }
public DateTime? ProcessedAt { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public bool CanTransitionTo(string target) => RefundTransitions.CanTransition(Status, target);
private void Transition(string target)
{
if (!RefundTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal refund transition {Status} → {target}.");
Status = target;
}
/// <summary>Card path — the reversal is effectively immediate; no customer-facing ETA.</summary>
public void MarkSucceededCard(string gatewayRefundReference, DateTime now)
{
Transition(RefundStatus.Succeeded);
GatewayRefundReference = gatewayRefundReference;
ExpectedCustomerRefundEta = null;
ProcessedAt = now;
}
/// <summary>BNPL/manual path — the provider revert is accepted but the customer cash-back is async; the
/// refund waits in <c>processing</c> until reconciliation confirms it and surfaces the ETA meanwhile.</summary>
public void MarkProcessing(string? externalRevertReference, DateOnly? expectedCustomerRefundEta)
{
Transition(RefundStatus.Processing);
ExternalRevertReference = externalRevertReference;
ExpectedCustomerRefundEta = expectedCustomerRefundEta;
}
/// <summary>Reconciliation confirmed the customer cash-back for a <c>processing</c> refund (BNPL/manual).</summary>
public void MarkSucceededAsync(DateTime now)
{
Transition(RefundStatus.Succeeded);
ProcessedAt = now;
}
public void MarkFailed(string? reason)
{
Transition(RefundStatus.Failed);
RejectedReason = reason;
}
}
@@ -0,0 +1,20 @@
namespace Baya.Domain.Entities.Refunds;
/// <summary>
/// The closed <c>refunds.refund_channel</c> code set — <b>how the money physically flows back</b>. The ledger
/// legs are identical across channels (see <c>LedgerPosting.RefundReversal</c>); only the execution + metadata
/// differ. The data-model doc writes the out-of-band bank code as <c>manual_bank</c>; the canonical <b>wire</b>
/// code is <see cref="Manual"/> (per <c>dev/contracts/conventions/money-and-types.md</c>) — they are the same
/// channel, and this is the value stored and serialized.
/// </summary>
public static class RefundChannel
{
/// <summary>PSP card reversal — effectively immediate, no customer-facing ETA.</summary>
public const string PspCard = "psp_card";
/// <summary>BNPL provider revert/update — async, surfaces a ~710 business-day customer ETA.</summary>
public const string BnplRevert = "bnpl_revert";
/// <summary>Out-of-band bank refund the admin executes and records the reference for (data-model: <c>manual_bank</c>).</summary>
public const string Manual = "manual";
}
@@ -0,0 +1,28 @@
namespace Baya.Domain.Entities.Refunds;
/// <summary>
/// The closed <c>refunds.status</c> code set — a <b>forward-only</b> lifecycle. A card refund is effectively
/// immediate (<see cref="Approved"/> → <see cref="Succeeded"/>); a BNPL revert sits in <see cref="Processing"/>
/// until the async 710-business-day customer cash-back is reconciled. Persisted as these stable snake_case
/// codes; the allowed edges live in <see cref="RefundTransitions"/>.
/// </summary>
public static class RefundStatus
{
/// <summary>Recorded but not yet approved. Reserved — admin refunds are created already approved today.</summary>
public const string Requested = "requested";
/// <summary>Admin-approved; the channel execution + ledger posting run under the same lock.</summary>
public const string Approved = "approved";
/// <summary>Channel accepted but the customer cash-back is not yet confirmed (the BNPL/manual wait state).</summary>
public const string Processing = "processing";
/// <summary>The customer refund is confirmed (card immediately; BNPL on reconciliation). Terminal.</summary>
public const string Succeeded = "succeeded";
/// <summary>The channel refused the reversal. Terminal — a fresh attempt is a new refund row.</summary>
public const string Failed = "failed";
/// <summary>The refund was declined by the admin before any money moved. Terminal.</summary>
public const string Rejected = "rejected";
}
@@ -0,0 +1,24 @@
namespace Baya.Domain.Entities.Refunds;
/// <summary>
/// The forward-only allowed-edge table for the <see cref="RefundStatus"/> machine. Every write goes through
/// <see cref="Refund"/>'s cohesive methods, which assert the edge here — an illegal transition is a
/// programming error (the handler pre-checks the expected cases), so the entity fails fast rather than
/// silently overwriting a terminal state.
/// </summary>
public static class RefundTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[RefundStatus.Requested] = [RefundStatus.Approved, RefundStatus.Rejected],
[RefundStatus.Approved] = [RefundStatus.Processing, RefundStatus.Succeeded, RefundStatus.Failed, RefundStatus.Rejected],
[RefundStatus.Processing] = [RefundStatus.Succeeded, RefundStatus.Failed],
[RefundStatus.Succeeded] = [],
[RefundStatus.Failed] = [],
[RefundStatus.Rejected] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -45,9 +45,13 @@ public static class SupportAlertType
public const string PaymentAnomaly = "payment_anomaly";
public const string FraudSignal = "fraud_signal";
/// <summary>A refund on an already-paid booking opened a nurse clawback receivable (b11) — staff must
/// track recovery. Iranian IBAN transfers are irreversible, so this is always worth a human look.</summary>
public const string NurseClawback = "nurse_clawback";
public static readonly IReadOnlyList<string> All =
[
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal, NurseClawback
];
}
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// A thin, deterministic mock <see cref="IBnplProvider"/> so b11's <c>bnpl_revert</c> refund path is exercised
/// before b12 merges — <b>b12 owns the real seam definition and its full adapter</b> (SnappPay/Tara). Revert
/// and update both succeed, echo a deterministic <c>external_revert_reference</c> derived from the order +
/// idempotency key, and report a nullable provider commission reversal (null by default — some providers keep
/// their fee on a refund; the amount is reconciled from the response, never hardcoded).
/// </summary>
public sealed class MockBnplProvider(IOptions<SeamOptions> options) : IBnplProvider
{
private readonly BnplOptions _options = options.Value.Bnpl;
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
public ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
private ValueTask<BnplRevertResult> Result(string providerOrderReference, string idempotencyKey)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplRevertResult(PaymentProviderStatus.Failed, null, null));
return ValueTask.FromResult(new BnplRevertResult(
PaymentProviderStatus.Succeeded,
ExternalRevertReference: $"mock-bnpl-revert-{providerOrderReference}-{idempotencyKey}",
ProviderCommissionReversedAmount: _options.ReverseProviderCommission ? 0 : null));
}
}
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Contracts.Invoices;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="IMoadianClient"/> (b11) — no external call. By default a submission leaves the
/// invoice at <c>moadian_status = pending</c> with no reference (the real reconciliation later flips it to
/// <c>registered</c>). Set <see cref="MoadianOptions.ForceRegistered"/> to have it return <c>registered</c> with
/// a deterministic fake 22-digit reference so the <c>registered</c>/reconciliation path is testable. The real
/// سامانه مودیان adapter (enrollment, the معاملات/invoice submission API, the 22-digit reference) swaps only this
/// registration.
/// </summary>
public sealed class MockMoadianClient(IOptions<SeamOptions> options) : IMoadianClient
{
private readonly MoadianOptions _options = options.Value.Moadian;
public ValueTask<MoadianSubmissionResult> SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default)
{
if (!_options.ForceRegistered)
return ValueTask.FromResult(new MoadianSubmissionResult(Baya.Domain.Entities.Invoices.MoadianStatus.Pending, null));
// A deterministic 22-digit reference derived from the booking id (right-aligned, zero-padded).
var reference = submission.BookingId.ToString().PadLeft(22, '0');
return ValueTask.FromResult(new MoadianSubmissionResult(Baya.Domain.Entities.Invoices.MoadianStatus.Registered, reference));
}
}
@@ -24,6 +24,6 @@ public sealed class MockPaymentProvider : IPaymentProvider
public ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentVerifyResult(PaymentProviderStatus.Succeeded, expectedAmountIrr));
public ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}"));
public ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}-{idempotencyKey}"));
}
@@ -16,6 +16,34 @@ public sealed class SeamOptions
public IdentityKycOptions IdentityKyc { get; set; } = new();
public PaymentCaptureOptions PaymentCapture { get; set; } = new();
public PaymentsOptions Payments { get; set; } = new();
public MoadianOptions Moadian { get; set; } = new();
public BnplOptions Bnpl { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IMoadianClient</c> (b11 e-invoicing). By default a submission stays <c>pending</c> with no
/// reference. Set <see cref="ForceRegistered"/> to make it return <c>registered</c> with a fake 22-digit ref so
/// the reconciliation/registered path is testable. The real سامانه مودیان adapter ignores these.
/// </summary>
public sealed class MoadianOptions
{
/// <summary>When true, a submission returns <c>registered</c> + a deterministic fake 22-digit reference.</summary>
public bool ForceRegistered { get; set; }
}
/// <summary>
/// Tunes the thin local mock <c>IBnplProvider</c> b11 registers until b12 ships the real seam. By default a
/// revert/update succeeds and the provider keeps its commission (null reversal). The real b12 adapter ignores
/// these.
/// </summary>
public sealed class BnplOptions
{
/// <summary>When true, every revert/update fails so the refund-channel-refused path is testable.</summary>
public bool ForceFailure { get; set; }
/// <summary>When true, the mock reports the provider returned its commission (a non-null, zero reversal
/// placeholder) so the <c>provider_commission_reversed_amount</c> reconciliation is exercised.</summary>
public bool ReverseProviderCommission { get; set; }
}
/// <summary>
@@ -1,4 +1,5 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Payments;
using Baya.Infrastructure.CrossCutting.Seams;
using Microsoft.Extensions.Configuration;
@@ -58,6 +59,12 @@ public static class ServiceCollectionExtension
services.AddSingleton<IWebhookVerifier, MockWebhookVerifier>();
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
// Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by
// default; config can force registered). IBnplProvider is a thin local stub so the bnpl_revert refund
// path runs before b12 merges — b12 owns the real seam. Both swap in by a registration change only.
services.AddSingleton<IMoadianClient, MockMoadianClient>();
services.AddSingleton<IBnplProvider, MockBnplProvider>();
return services;
}
}
@@ -46,6 +46,9 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
(17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."),
(18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."),
(19, "refund_ticket_required", "false", ConfigDataType.Bool, "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table."),
(20, "bnpl_refund_eta_business_days", "10", ConfigDataType.Int, "Business days shown as the customer BNPL refund ETA (b11)."),
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
];
return rows
@@ -0,0 +1,34 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
/// <summary>
/// <c>invoices</c> — one issued invoice per booking (UNIQUE <c>booking_id</c>) with a UNIQUE, sequential
/// <c>invoice_number</c> drawn from <see cref="InvoiceNumberSequence"/>. VAT (<c>vat_irr</c>) is computed on the
/// commission line only. <c>partner_center_id</c> is a nullable column with <b>no FK</b> — <c>partner_centers</c>
/// is a forward-dep on b15.
/// </summary>
internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
{
public void Configure(EntityTypeBuilder<Invoice> builder)
{
builder.ToTable("Invoices", "payments");
builder.Property(i => i.InvoiceNumber).HasMaxLength(40).IsRequired();
builder.Property(i => i.IssuingEntityType).HasMaxLength(20).IsRequired();
builder.Property(i => i.MoadianReferenceNumber).HasMaxLength(40);
builder.Property(i => i.MoadianStatus).HasMaxLength(20);
builder.Property(i => i.PdfStorageKey).HasMaxLength(512);
builder.Property(i => i.VatRate).HasPrecision(5, 4);
builder.HasIndex(i => i.InvoiceNumber).IsUnique();
builder.HasIndex(i => i.BookingId).IsUnique();
builder.HasOne<Booking>().WithMany().HasForeignKey(i => i.BookingId).IsRequired();
builder.HasQueryFilter(i => i.DeletedAt == null);
}
}
@@ -0,0 +1,23 @@
using Baya.Domain.Entities.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
/// <summary>
/// The single-row counter behind the sequential <c>invoice_number</c>. Seeded with one row (id 1, next = 1) so
/// <c>EnsureCreated</c> (tests) and the migration both start the sequence. The id is fixed (never generated) —
/// there is exactly one counter. Portable across SQL Server and SQLite (no provider-specific DB sequence).
/// </summary>
internal sealed class InvoiceNumberSequenceConfig : IEntityTypeConfiguration<InvoiceNumberSequence>
{
public void Configure(EntityTypeBuilder<InvoiceNumberSequence> builder)
{
builder.ToTable("InvoiceNumberSequences", "payments");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).ValueGeneratedNever();
builder.HasData(new InvoiceNumberSequence { Id = 1, NextValue = 1 });
}
}
@@ -0,0 +1,36 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary>
/// <c>nurse_clawbacks</c> — a first-class receivable opened when a booking is refunded after the nurse was
/// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable columns + indexes now
/// with <b>no FK</b> — <c>nurse_payouts</c> is a forward-dep on b13, which sets the values and wires the FKs.
/// </summary>
internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawback>
{
public void Configure(EntityTypeBuilder<NurseClawback> builder)
{
builder.ToTable("NurseClawbacks", "payments");
builder.Property(c => c.Status).HasMaxLength(30).IsRequired();
builder.Property(c => c.ResolutionNotes).HasMaxLength(500);
builder.HasIndex(c => c.NurseId);
builder.HasIndex(c => c.BookingId);
builder.HasIndex(c => c.RefundId).IsUnique();
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.OriginalPayoutId);
builder.HasIndex(c => c.RecoveredInPayoutId);
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(c => c.NurseId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(c => c.BookingId).IsRequired();
builder.HasOne<Refund>().WithMany().HasForeignKey(c => c.RefundId).IsRequired();
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -0,0 +1,50 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary>
/// <c>refunds</c> — 1:N per <c>payment_transaction</c>. The <c>amount = fee_leg + payout_leg</c> reconciliation
/// is a DB CHECK (the "Σ refunded ≤ captured" invariant is a handler check under the booking refund lock, not a
/// single-row constraint). <c>ticket_id</c> is a nullable column + index now with <b>no FK</b> — the
/// <c>tickets</c> table is a forward-dep on b15, which wires the real FK target.
/// </summary>
internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
{
public void Configure(EntityTypeBuilder<Refund> builder)
{
builder.ToTable("Refunds", "payments", t => t.HasCheckConstraint(
"CK_Refunds_LegSplit",
"[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] " +
"AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"));
builder.Property(r => r.RefundChannel).HasMaxLength(20).IsRequired();
builder.Property(r => r.Status).HasMaxLength(20).IsRequired();
builder.Property(r => r.ReasonCategory).HasMaxLength(50);
builder.Property(r => r.ReasonNotes).HasMaxLength(1000);
builder.Property(r => r.AdminNotes).HasMaxLength(1000);
builder.Property(r => r.RejectedReason).HasMaxLength(500);
builder.Property(r => r.GatewayRefundReference).HasMaxLength(200);
builder.Property(r => r.ExternalRevertReference).HasMaxLength(200);
builder.Property(r => r.CancellationPolicyCode).HasMaxLength(50);
builder.Property(r => r.RefundPercentage).HasPrecision(6, 4);
builder.Property(r => r.RefundPercentageApplied).HasPrecision(5, 2);
builder.HasIndex(r => r.PaymentTransactionId);
builder.HasIndex(r => r.BookingId);
builder.HasIndex(r => r.RequestedByCustomerId);
builder.HasIndex(r => r.Status);
// Index in place for the b15 tickets wire-up; no FK yet (tickets does not exist).
builder.HasIndex(r => r.TicketId);
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(r => r.PaymentTransactionId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired();
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.RequestedByCustomerId).IsRequired();
builder.HasQueryFilter(r => r.DeletedAt == null);
}
}
@@ -0,0 +1,313 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefundsClawbacksInvoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "InvoiceNumberSequences",
schema: "payments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
NextValue = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoiceNumberSequences", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Invoices",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingId = table.Column<long>(type: "bigint", nullable: false),
InvoiceNumber = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
IssuingEntityType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
PartnerCenterId = table.Column<long>(type: "bigint", nullable: true),
GrossIrr = table.Column<long>(type: "bigint", nullable: false),
PlatformCommissionIrr = table.Column<long>(type: "bigint", nullable: false),
BnplCommissionIrr = table.Column<long>(type: "bigint", nullable: true),
VatRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: false),
VatIrr = table.Column<long>(type: "bigint", nullable: false),
MoadianReferenceNumber = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: true),
MoadianStatus = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
PdfStorageKey = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
IssuedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Invoices", x => x.Id);
table.ForeignKey(
name: "FK_Invoices_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Refunds",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PaymentTransactionId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: false),
RequestedByCustomerId = table.Column<long>(type: "bigint", nullable: false),
TicketId = table.Column<long>(type: "bigint", nullable: true),
Amount = table.Column<long>(type: "bigint", nullable: false),
PlatformFeeRefundedIrr = table.Column<long>(type: "bigint", nullable: false),
NursePayoutRefundedIrr = table.Column<long>(type: "bigint", nullable: false),
RefundPercentage = table.Column<decimal>(type: "decimal(6,4)", precision: 6, scale: 4, nullable: false),
RefundChannel = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ReasonCategory = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
ReasonNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ApprovedByAdminId = table.Column<int>(type: "int", nullable: true),
RejectedReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
AdminNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
GatewayRefundReference = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExternalRevertReference = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExpectedCustomerRefundEta = table.Column<DateOnly>(type: "date", nullable: true),
CancellationPolicyCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
RefundPercentageApplied = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: true),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Refunds", x => x.Id);
table.CheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0");
table.ForeignKey(
name: "FK_Refunds_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Refunds_CustomerProfiles_RequestedByCustomerId",
column: x => x.RequestedByCustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Refunds_PaymentTransactions_PaymentTransactionId",
column: x => x.PaymentTransactionId,
principalSchema: "payments",
principalTable: "PaymentTransactions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NurseClawbacks",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: false),
RefundId = table.Column<long>(type: "bigint", nullable: false),
OriginalPayoutId = table.Column<long>(type: "bigint", nullable: true),
AmountIrr = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
RecoveredInPayoutId = table.Column<long>(type: "bigint", nullable: true),
ResolvedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ResolutionNotes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NurseClawbacks", x => x.Id);
table.ForeignKey(
name: "FK_NurseClawbacks_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseClawbacks_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseClawbacks_Refunds_RefundId",
column: x => x.RefundId,
principalSchema: "payments",
principalTable: "Refunds",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "payments",
table: "InvoiceNumberSequences",
columns: new[] { "Id", "NextValue" },
values: new object[] { 1, 1L });
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[,]
{
{ 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", "refund_ticket_required", null, null, "false" },
{ 20L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Business days shown as the customer BNPL refund ETA (b11).", "bnpl_refund_eta_business_days", null, null, "10" },
{ 21L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", "refund_assume_nurse_paid", null, null, "false" }
});
migrationBuilder.CreateIndex(
name: "IX_Invoices_BookingId",
schema: "payments",
table: "Invoices",
column: "BookingId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Invoices_InvoiceNumber",
schema: "payments",
table: "Invoices",
column: "InvoiceNumber",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_BookingId",
schema: "payments",
table: "NurseClawbacks",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_NurseId",
schema: "payments",
table: "NurseClawbacks",
column: "NurseId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_OriginalPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "OriginalPayoutId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_RecoveredInPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "RecoveredInPayoutId");
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_RefundId",
schema: "payments",
table: "NurseClawbacks",
column: "RefundId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseClawbacks_Status",
schema: "payments",
table: "NurseClawbacks",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_Refunds_BookingId",
schema: "payments",
table: "Refunds",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_PaymentTransactionId",
schema: "payments",
table: "Refunds",
column: "PaymentTransactionId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_RequestedByCustomerId",
schema: "payments",
table: "Refunds",
column: "RequestedByCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Refunds_Status",
schema: "payments",
table: "Refunds",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_Refunds_TicketId",
schema: "payments",
table: "Refunds",
column: "TicketId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InvoiceNumberSequences",
schema: "payments");
migrationBuilder.DropTable(
name: "Invoices",
schema: "payments");
migrationBuilder.DropTable(
name: "NurseClawbacks",
schema: "payments");
migrationBuilder.DropTable(
name: "Refunds",
schema: "payments");
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 19L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 20L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 21L);
}
}
}
@@ -1155,6 +1155,33 @@ namespace Baya.Infrastructure.Persistence.Migrations
Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).",
Key = "no_show_scan_cadence_hours",
Value = "1"
},
new
{
Id = 19L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.",
Key = "refund_ticket_required",
Value = "false"
},
new
{
Id = 20L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Business days shown as the customer BNPL refund ETA (b11).",
Key = "bnpl_refund_eta_business_days",
Value = "10"
},
new
{
Id = 21L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.",
Key = "refund_assume_nurse_paid",
Value = "false"
});
});
@@ -2630,6 +2657,107 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("Patients", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BnplCommissionIrr")
.HasColumnType("bigint");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("GrossIrr")
.HasColumnType("bigint");
b.Property<string>("InvoiceNumber")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTime>("IssuedAt")
.HasColumnType("datetime2");
b.Property<string>("IssuingEntityType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("MoadianReferenceNumber")
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("MoadianStatus")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long?>("PartnerCenterId")
.HasColumnType("bigint");
b.Property<string>("PdfStorageKey")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<long>("PlatformCommissionIrr")
.HasColumnType("bigint");
b.Property<long>("VatIrr")
.HasColumnType("bigint");
b.Property<decimal>("VatRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.HasKey("Id");
b.HasIndex("BookingId")
.IsUnique();
b.HasIndex("InvoiceNumber")
.IsUnique();
b.ToTable("Invoices", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<long>("NextValue")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("InvoiceNumberSequences", "payments");
b.HasData(
new
{
Id = 1,
NextValue = 1L
});
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.Property<long>("Id")
@@ -2949,6 +3077,194 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("PaymentWebhookEvents", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("AmountIrr")
.HasColumnType("bigint");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<long?>("OriginalPayoutId")
.HasColumnType("bigint");
b.Property<long?>("RecoveredInPayoutId")
.HasColumnType("bigint");
b.Property<long>("RefundId")
.HasColumnType("bigint");
b.Property<string>("ResolutionNotes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("NurseId");
b.HasIndex("OriginalPayoutId");
b.HasIndex("RecoveredInPayoutId");
b.HasIndex("RefundId")
.IsUnique();
b.HasIndex("Status");
b.ToTable("NurseClawbacks", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AdminNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<long>("Amount")
.HasColumnType("bigint");
b.Property<int?>("ApprovedByAdminId")
.HasColumnType("int");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<string>("CancellationPolicyCode")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateOnly?>("ExpectedCustomerRefundEta")
.HasColumnType("date");
b.Property<string>("ExternalRevertReference")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("GatewayRefundReference")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NursePayoutRefundedIrr")
.HasColumnType("bigint");
b.Property<long>("PaymentTransactionId")
.HasColumnType("bigint");
b.Property<long>("PlatformFeeRefundedIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("ReasonCategory")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ReasonNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("RefundChannel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<decimal>("RefundPercentage")
.HasPrecision(6, 4)
.HasColumnType("decimal(6,4)");
b.Property<decimal?>("RefundPercentageApplied")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<string>("RejectedReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<long>("RequestedByCustomerId")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long?>("TicketId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("PaymentTransactionId");
b.HasIndex("RequestedByCustomerId");
b.HasIndex("Status");
b.HasIndex("TicketId");
b.ToTable("Refunds", "payments", t =>
{
t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.Property<long>("Id")
@@ -4186,6 +4502,15 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("Customer");
});
modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -4231,6 +4556,48 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Refunds.Refund", null)
.WithMany()
.HasForeignKey("RefundId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null)
.WithMany()
.HasForeignKey("PaymentTransactionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null)
.WithMany()
.HasForeignKey("RequestedByCustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
@@ -23,6 +23,8 @@ public class UnitOfWork : IUnitOfWork
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -44,6 +46,8 @@ public class UnitOfWork : IUnitOfWork
BookingRepository = new BookingRepository(_db);
CancellationPolicyRepository = new CancellationPolicyRepository(_db);
PaymentRepository = new PaymentRepository(_db);
RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,67 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Invoices;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Invoices;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class InvoiceRepository : BaseAsyncRepository<Invoice>, IInvoiceRepository
{
public InvoiceRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<InvoiceBookingAmounts?> GetBookingAmountsAsync(long bookingId, CancellationToken cancellationToken)
=> (from b in DbContext.Set<Booking>().AsNoTracking()
where b.Id == bookingId
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new InvoiceBookingAmounts(
b.Id,
c.UserId,
b.GrossPriceIrr,
b.BalinyaarCommissionIrr,
(long?)null))
.FirstOrDefaultAsync(cancellationToken);
public Task<Invoice?> GetTrackedByBookingIdAsync(long bookingId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(i => i.BookingId == bookingId, cancellationToken);
public async Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken)
{
var row = await (from i in TableNoTracking
where i.BookingId == bookingId
join b in DbContext.Set<Booking>() on i.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new { CustomerUserId = c.UserId, Invoice = i })
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
// The PDF URL is resolved by the handler through IObjectStorage — the repo carries the raw key.
var dto = new InvoiceDto(
row.Invoice.Id, row.Invoice.BookingId, row.Invoice.InvoiceNumber, row.Invoice.IssuingEntityType,
row.Invoice.GrossIrr.ToString(), row.Invoice.PlatformCommissionIrr.ToString(),
row.Invoice.BnplCommissionIrr?.ToString(), row.Invoice.VatRate, row.Invoice.VatIrr.ToString(),
row.Invoice.MoadianReferenceNumber, row.Invoice.MoadianStatus, PdfUrl: null, row.Invoice.IssuedAt);
return new InvoiceProjection(row.CustomerUserId, row.Invoice.PdfStorageKey, dto);
}
public Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken)
=> base.AddAsync(invoice);
public async Task<long> ReserveNextInvoiceNumberAsync(CancellationToken cancellationToken)
{
var counter = await DbContext.Set<InvoiceNumberSequence>().FirstOrDefaultAsync(s => s.Id == 1, cancellationToken)
?? throw new InvalidOperationException("The invoice-number counter row is missing.");
var reserved = counter.NextValue;
counter.NextValue = reserved + 1;
return reserved;
}
}
@@ -0,0 +1,144 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRepository
{
public RefundRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<RefundMoneyContext?> GetRefundContextAsync(long bookingId, CancellationToken cancellationToken)
=> (from t in DbContext.Set<PaymentTransaction>().AsNoTracking()
where t.BookingId == bookingId && t.Status == PaymentTransactionStatus.Succeeded
join b in DbContext.Set<Booking>() on t.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
join g in DbContext.Set<PaymentGateway>() on t.GatewayId equals g.Id
select new RefundMoneyContext(
b.Id,
b.CustomerId,
c.UserId,
b.NurseId,
b.GrossPriceIrr,
b.BalinyaarCommissionIrr,
b.NursePayoutAmount,
b.CancellationPolicyCode,
b.CancellationRefundPercentage,
b.RefundableAmountIrr,
t.Id,
t.GatewayReferenceCode,
t.Amount,
g.Type))
.FirstOrDefaultAsync(cancellationToken);
public async Task<long> GetRefundedSumForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> await TableNoTracking
.Where(r => r.PaymentTransactionId == paymentTransactionId
&& r.Status != RefundStatus.Failed && r.Status != RefundStatus.Rejected)
.SumAsync(r => (long?)r.Amount, cancellationToken) ?? 0;
public Task AddRefundAsync(Refund refund, CancellationToken cancellationToken)
=> base.AddAsync(refund);
public Task AddClawbackAsync(NurseClawback clawback, CancellationToken cancellationToken)
=> DbContext.Set<NurseClawback>().AddAsync(clawback, cancellationToken).AsTask();
public Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken)
=> DbContext.Set<NurseClawback>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public async Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking;
if (bookingId is { } bid)
query = query.Where(r => r.BookingId == bid);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(r => r.Status == status);
var total = await query.CountAsync(cancellationToken);
var rows = await query
.OrderByDescending(r => r.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(r => new
{
r.Id,
r.BookingId,
r.PaymentTransactionId,
r.Amount,
r.PlatformFeeRefundedIrr,
r.NursePayoutRefundedIrr,
r.RefundChannel,
r.Status,
r.RefundPercentage,
r.ReasonCategory,
r.CancellationPolicyCode,
r.RefundPercentageApplied,
r.ExpectedCustomerRefundEta,
r.GatewayRefundReference,
r.ExternalRevertReference,
r.ProcessedAt,
r.CreatedAt
})
.ToListAsync(cancellationToken);
var items = rows
.Select(r => new RefundListItemDto(
r.Id, r.BookingId, r.PaymentTransactionId,
r.Amount.ToString(), r.PlatformFeeRefundedIrr.ToString(), r.NursePayoutRefundedIrr.ToString(),
r.RefundChannel, r.Status, r.RefundPercentage, r.ReasonCategory,
r.CancellationPolicyCode, r.RefundPercentageApplied, r.ExpectedCustomerRefundEta,
r.GatewayRefundReference, r.ExternalRevertReference, r.ProcessedAt, r.CreatedAt))
.ToList();
return new PagedResult<RefundListItemDto>(items, total, page, pageSize);
}
public async Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken)
{
var row = await (from r in TableNoTracking
where r.Id == id
join b in DbContext.Set<Booking>() on r.BookingId equals b.Id
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
select new
{
c.UserId,
r.Id,
r.BookingId,
r.Status,
r.RefundChannel,
r.Amount,
r.ExpectedCustomerRefundEta,
r.GatewayRefundReference,
r.ExternalRevertReference
})
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
var reference = Mask(row.GatewayRefundReference ?? row.ExternalRevertReference);
var dto = new RefundStatusDto(row.Id, row.BookingId, row.Status, row.RefundChannel, row.Amount.ToString(),
row.ExpectedCustomerRefundEta, reference);
return new RefundStatusProjection(row.UserId, dto);
}
// Show only the last 4 characters of an external reference to the customer — never the full PSP/BNPL id.
private static string? Mask(string? reference)
{
if (string.IsNullOrEmpty(reference))
return reference;
return reference.Length <= 4
? new string('•', reference.Length)
: $"{new string('•', reference.Length - 4)}{reference[^4..]}";
}
}
@@ -4,6 +4,7 @@ using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
@@ -16,6 +17,7 @@ using Baya.Infrastructure.Persistence.Services.Booking;
using Baya.Infrastructure.Persistence.Services.Configuration;
using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
using Baya.Infrastructure.Persistence.Services.Payments;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
using Microsoft.AspNetCore.Builder;
@@ -52,6 +54,11 @@ public static class ServiceCollectionExtensions
// Supersedes the b0 log/no-op stub with the real in-app notifications write.
services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>();
// "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). DB-backed because b13's real
// impl reads nurse_payout_booking_links; until then it derives from the dispute-window close. b13 swaps
// this registration for the authoritative payout-link lookup.
services.AddScoped<INursePayoutStatus, NursePayoutStatusService>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
@@ -0,0 +1,35 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Microsoft.EntityFrameworkCore;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Infrastructure.Persistence.Services.Payments;
/// <summary>
/// The interim implementation of <see cref="INursePayoutStatus"/> until b13 ships <c>nurse_payouts</c> /
/// <c>nurse_payout_booking_links</c>. It derives "already paid?" from the booking's dispute-window close — the
/// exact gate b13 pays out on — so the pre-payout (clean reversal) path is the common one and the clawback path
/// is the fallback. A <c>refund_assume_nurse_paid</c> config switch forces the paid answer for ops/testing. b13
/// swaps this registration for the authoritative payout-link lookup.
/// </summary>
internal sealed class NursePayoutStatusService(
ApplicationDbContext dbContext,
IDateTimeProvider dateTimeProvider,
IPlatformConfig platformConfig) : INursePayoutStatus
{
public async ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default)
{
if (await platformConfig.GetConfig<bool>("refund_assume_nurse_paid", cancellationToken))
return true;
var now = dateTimeProvider.UtcNow.UtcDateTime;
var windowEnd = await dbContext.Set<BookingEntity>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => b.DisputeWindowEndsAt)
.FirstOrDefaultAsync(cancellationToken);
return windowEnd is { } endsAt && endsAt <= now;
}
}
@@ -0,0 +1,41 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class AdminInvoicesApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Issue_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId = 1 });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Issue_computes_vat_on_commission_and_returns_sequential_number()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901501");
var bookingId = await AdminRefundsApiTests.SeedCapturedBookingAsync(factory, "09131901502");
var issue = await admin.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId });
Assert.Equal(HttpStatusCode.OK, issue.StatusCode);
var data = await AuthTestClient.ReadDataAsync(issue);
Assert.False(string.IsNullOrEmpty(data.GetProperty("invoiceNumber").GetString()));
Assert.Equal("1500000", data.GetProperty("platformCommissionIrr").GetString());
Assert.Equal("150000", data.GetProperty("vatIrr").GetString()); // 10% of the commission line
Assert.Equal("pending", data.GetProperty("moadianStatus").GetString());
// Re-issue is idempotent — same number, no second invoice.
var reissue = await admin.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId });
var reissued = await AuthTestClient.ReadDataAsync(reissue);
Assert.Equal(data.GetProperty("invoiceNumber").GetString(), reissued.GetProperty("invoiceNumber").GetString());
// The booking's invoice is readable via the customer/admin GET.
var get = await admin.GetAsync($"/api/v1/invoices/{bookingId}");
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
}
}
@@ -0,0 +1,175 @@
using System.Net;
using System.Net.Http.Json;
using Baya.Application.Contracts.Identity;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Api;
public class AdminRefundsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Create_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId = 1, refundPercentage = 1m });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Create_InvalidBookingId_Returns400()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901401");
var response = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId = 0, refundPercentage = 1m });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Create_full_refund_posts_balanced_reversal_then_over_refund_conflicts()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901402");
var bookingId = await SeedCapturedBookingAsync(factory, "09131901403");
var create = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 1m });
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var data = await AuthTestClient.ReadDataAsync(create);
Assert.Equal("succeeded", data.GetProperty("status").GetString());
Assert.Equal("10000000", data.GetProperty("amount").GetString());
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var legs = db.Set<LedgerEntry>().AsNoTracking()
.Where(l => l.BookingId == bookingId && l.SourceRefType == LedgerSourceRefType.Refund).ToList();
Assert.Equal(5, legs.Count);
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
}
// A second refund on the fully-refunded booking pushes over captured → 409.
var again = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 0.5m });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
[Fact]
public async Task List_AsAdmin_ReturnsPagedEnvelope()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901404");
var response = await admin.GetAsync("/api/v1/admin_refunds?status=succeeded&page=1&pageSize=20");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.True(data.GetProperty("total").GetInt32() >= 0);
}
/// <summary>Seeds a confirmed booking (gross 10M, commission 1.5M) with a captured card transaction, owned
/// by the given customer phone. Returns the booking id.</summary>
internal static async Task<long> SeedCapturedBookingAsync(BayaApiFactory factory, string customerPhone)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
var customerUser = await userManager.GetUserByPhoneNumber(customerPhone);
if (customerUser is null)
{
await userManager.CreateUser(new User { UserName = $"cust_{Guid.NewGuid():N}", PhoneNumber = customerPhone });
customerUser = await userManager.GetUserByPhoneNumber(customerPhone);
}
var customer = db.Set<CustomerProfile>().FirstOrDefault(c => c.UserId == customerUser!.Id);
if (customer is null)
{
customer = new CustomerProfile { UserId = customerUser!.Id };
db.Set<CustomerProfile>().Add(customer);
db.SaveChanges();
}
var province = new Province { NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true };
db.Set<Province>().Add(province);
db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true };
db.Set<City>().Add(city);
var category = new ServiceCategory { NameFa = "س", NameEn = "E", SortOrder = 1, IsActive = true };
db.Set<ServiceCategory>().Add(category);
db.SaveChanges();
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پ", FirstName = "ح", LastName = "ر", Gender = "male", IsActive = true };
db.Set<Patient>().Add(patient);
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خ", AddressLine = "خیابان",
PostalCode = "1111111111", RecipientName = "ع", RecipientPhone = customerPhone, IsPrimary = true
};
db.Set<CustomerAddress>().Add(address);
var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", Gender = "female", Name = "ز", FamilyName = "ا", IsActive = true };
db.Users.Add(nurseUser);
db.SaveChanges();
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
db.Set<NurseProfile>().Add(nurse);
db.SaveChanges();
var variant = new NurseServiceVariant
{
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = 10_000_000, PriceUnit = "per_day",
SessionCount = null, DisplayName = "مراقبت", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
db.Set<NurseServiceVariant>().Add(variant);
db.SaveChanges();
var request = new BookingRequest
{
CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, VariantId = variant.Id,
CustomerAddressId = address.Id, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "n", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
db.Set<BookingRequest>().Add(request);
db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
db.SaveChanges();
var booking = new BookingEntity
{
BookingRequestId = request.Id, CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id,
VariantId = variant.Id, CustomerAddressId = address.Id, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
GrossPriceIrr = 10_000_000, BalinyaarCommissionIrr = 1_500_000, PlatformFeeRate = 0.15m,
NursePayoutAmount = 8_500_000, SessionCount = 1,
ScheduledDate = new DateOnly(2026, 8, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
};
booking.TransitionTo(BookingStatus.Confirmed, new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc));
db.Set<BookingEntity>().Add(booking);
db.SaveChanges();
var gateway = new PaymentGateway { ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0 };
db.Set<PaymentGateway>().Add(gateway);
db.SaveChanges();
var txn = new PaymentTransaction
{
BookingRequestId = request.Id, CustomerId = customer.Id, GatewayId = gateway.Id,
Amount = 10_000_000, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}"
};
txn.MarkSucceeded(booking.Id, "ok", null);
db.Set<PaymentTransaction>().Add(txn);
db.SaveChanges();
return booking.Id;
}
}
@@ -0,0 +1,41 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class RefundStatusApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task RefundStatus_is_visible_to_owner_and_hidden_from_another_customer()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131901601");
var bookingId = await AdminRefundsApiTests.SeedCapturedBookingAsync(factory, "09131901602");
var create = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 1m });
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var refundId = (await AuthTestClient.ReadDataAsync(create)).GetProperty("refundId").GetInt64();
// The owning customer sees their refund status + amount.
var owner = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, owner, "09131901602", "customer");
var ownerResponse = await owner.GetAsync($"/api/v1/refunds/{refundId}/status");
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
var ownerData = await AuthTestClient.ReadDataAsync(ownerResponse);
Assert.Equal("10000000", ownerData.GetProperty("amount").GetString());
// A different customer cannot — a cross-customer read is a clean not-found.
var other = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, other, "09131901603", "customer");
var otherResponse = await other.GetAsync($"/api/v1/refunds/{refundId}/status");
Assert.Equal(HttpStatusCode.NotFound, otherResponse.StatusCode);
}
[Fact]
public async Task RefundStatus_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/refunds/1/status");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -0,0 +1,73 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Invoices;
using Baya.Application.Features.Invoices.Commands.IssueInvoice;
using Baya.Domain.Entities.Invoices;
using NSubstitute;
namespace Baya.Test.Foundation.Refunds;
public class InvoiceHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private static IssueInvoiceCommandHandler Handler(RefundsTestHost host, decimal vatRate = 0.10m)
{
var moadian = Substitute.For<IMoadianClient>();
moadian.SubmitAsync(Arg.Any<InvoiceSubmission>(), Arg.Any<CancellationToken>())
.Returns(new MoadianSubmissionResult(MoadianStatus.Pending, null));
var storage = Substitute.For<IObjectStorage>();
return new IssueInvoiceCommandHandler(
host.UnitOfWork, host.Config(vatRate: vatRate), host.Clock(Now), host.Lock(), moadian, storage);
}
[Fact]
public async Task Issue_computes_vat_on_commission_and_numbers_sequentially()
{
using var host = new RefundsTestHost();
var (bookingA, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var (bookingB, _) = host.SeedCapturedBooking(gross: 20_000_000, commission: 3_000_000);
var handler = Handler(host);
var a = await handler.Handle(new IssueInvoiceCommand(bookingA), CancellationToken.None);
var b = await handler.Handle(new IssueInvoiceCommand(bookingB), CancellationToken.None);
Assert.True(a.IsSuccess);
Assert.Equal("INV-0000000001", a.Result.InvoiceNumber);
Assert.Equal("1500000", a.Result.PlatformCommissionIrr);
Assert.Equal("150000", a.Result.VatIrr); // 10% of the commission line, integer-only
Assert.Equal(MoadianStatus.Pending, a.Result.MoadianStatus);
Assert.Null(a.Result.MoadianReferenceNumber);
// Gap-free next number, and VAT on that booking's own commission.
Assert.Equal("INV-0000000002", b.Result.InvoiceNumber);
Assert.Equal("300000", b.Result.VatIrr);
}
[Fact]
public async Task Reissue_is_idempotent_and_returns_the_same_invoice()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking();
var handler = Handler(host);
var first = await handler.Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None);
var second = await handler.Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None);
Assert.Equal(first.Result.Id, second.Result.Id);
Assert.Equal(first.Result.InvoiceNumber, second.Result.InvoiceNumber);
}
[Fact]
public async Task Zero_vat_rate_yields_zero_vat()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var result = await Handler(host, vatRate: 0m).Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal("0", result.Result.VatIrr);
}
}
@@ -0,0 +1,138 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Refunds;
public class RefundHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private static CreateRefundCommandHandler Handler(RefundsTestHost host, bool nursePaid)
=> new(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>());
private static CreateRefundCommand FullRefund(long bookingId)
=> new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null);
[Fact]
public async Task PrePayout_full_refund_posts_balanced_reversal_and_clearing()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var result = await Handler(host, nursePaid: false).Handle(FullRefund(bookingId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(RefundStatus.Succeeded, result.Result.Status);
Assert.Equal("10000000", result.Result.Amount);
Assert.Equal("1500000", result.Result.PlatformFeeRefundedIrr);
Assert.Equal("8500000", result.Result.NursePayoutRefundedIrr);
Assert.Null(result.Result.ClawbackId);
var legs = host.LedgerFor(bookingId);
Assert.Equal(5, legs.Count); // reversal (3) + clearing (2)
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
Assert.Equal(1_500_000, Leg(legs, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit));
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Debit));
Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.RefundPayable, LedgerDirection.Credit));
Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.RefundPayable, LedgerDirection.Debit));
Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Credit));
// No clawback pre-payout.
Assert.Empty(host.Db.Set<NurseClawback>().AsNoTracking().ToList());
}
[Fact]
public async Task Partial_refund_decomposes_legs_and_second_over_refund_is_rejected()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var handler = Handler(host, nursePaid: false);
var half = new CreateRefundCommand(bookingId, null, RefundPercentage: 0.5m, null, null, "shortened_visit", null, null, null);
var first = await handler.Handle(half, CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.Equal("5000000", first.Result.Amount);
Assert.Equal("750000", first.Result.PlatformFeeRefundedIrr);
Assert.Equal("4250000", first.Result.NursePayoutRefundedIrr);
// A second 60% refund would push the total to 11M > 10M captured → rejected, no new ledger.
var ledgerBefore = host.LedgerFor(bookingId).Count;
var tooMuch = new CreateRefundCommand(bookingId, null, RefundPercentage: 0.6m, null, null, "again", null, null, null);
var second = await handler.Handle(tooMuch, CancellationToken.None);
Assert.False(second.IsSuccess);
Assert.True(second.IsConflict);
Assert.Equal(ledgerBefore, host.LedgerFor(bookingId).Count);
}
[Fact]
public async Task PostPayout_refund_opens_clawback_and_posts_receivable_leg()
{
using var host = new RefundsTestHost();
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var result = await Handler(host, nursePaid: true).Handle(FullRefund(bookingId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Result.ClawbackId);
var legs = host.LedgerFor(bookingId);
// The payout leg debits the receivable (not nurse_payable); the platform fee leg is unchanged.
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Debit));
Assert.Equal(0, legs.Count(l => l.AccountType == LedgerAccountType.NursePayable));
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
var clawback = Assert.Single(host.Db.Set<NurseClawback>().AsNoTracking().ToList());
Assert.Equal(ClawbackStatus.Pending, clawback.Status);
Assert.Equal(8_500_000, clawback.AmountIrr);
Assert.Equal(host.NurseId, clawback.NurseId);
}
[Fact]
public async Task Card_and_bnpl_post_the_same_reversal_legs()
{
using var cardHost = new RefundsTestHost();
var (cardBooking, _) = cardHost.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Standard);
var cardResult = await Handler(cardHost, nursePaid: false).Handle(FullRefund(cardBooking), CancellationToken.None);
using var bnplHost = new RefundsTestHost();
var (bnplBooking, _) = bnplHost.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Bnpl);
var bnplResult = await Handler(bnplHost, nursePaid: false).Handle(FullRefund(bnplBooking), CancellationToken.None);
Assert.Equal(RefundChannel.PspCard, cardResult.Result.RefundChannel);
Assert.Equal(RefundChannel.BnplRevert, bnplResult.Result.RefundChannel);
// The card refund is immediate (succeeded, no ETA); the BNPL revert waits in processing with an ETA.
Assert.Equal(RefundStatus.Succeeded, cardResult.Result.Status);
Assert.Null(cardResult.Result.ExpectedCustomerRefundEta);
Assert.Equal(RefundStatus.Processing, bnplResult.Result.Status);
Assert.NotNull(bnplResult.Result.ExpectedCustomerRefundEta);
// Reversal legs (account × direction × amount) are identical across channels.
var cardReversal = Reversal(cardHost.LedgerFor(cardBooking));
var bnplReversal = Reversal(bnplHost.LedgerFor(bnplBooking));
Assert.Equal(cardReversal, bnplReversal);
}
private static long Leg(IReadOnlyList<LedgerEntry> legs, string account, string direction)
=> legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr);
private static IReadOnlyList<(string, string, long)> Reversal(IReadOnlyList<LedgerEntry> legs)
=> legs.Where(l => l.SourceRefType == LedgerSourceRefType.Refund && l.AccountType != LedgerAccountType.EscrowHeld)
.Where(l => !(l.AccountType == LedgerAccountType.RefundPayable && l.Direction == LedgerDirection.Debit))
.Select(l => (l.AccountType, l.Direction, l.AmountIrr))
.OrderBy(t => t.Item1).ThenBy(t => t.Item2).ToList();
}
@@ -0,0 +1,225 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Refunds;
/// <summary>
/// A self-contained SQLite host exercising the real EF model (schema, CHECK, filtered indexes, query filters,
/// the invoice-number counter seed) for the b11 refund/clawback/invoice engine. Seeds one bookable nurse + one
/// customer, and can create a confirmed booking with a captured (succeeded) card/BNPL transaction so a test can
/// drive the real <see cref="UnitOfWork"/> against the real handlers with substituted seams.
/// </summary>
public sealed class RefundsTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public UnitOfWork UnitOfWork { get; }
public long CustomerId { get; }
public int CustomerUserId { get; }
public long NurseId { get; }
private readonly long _cityId;
private readonly long _categoryId;
private readonly long _patientId;
private readonly long _addressId;
public RefundsTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
UnitOfWork = new UnitOfWork(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
Db.Set<ServiceCategory>().Add(category);
Db.SaveChanges();
_cityId = city.Id;
_categoryId = category.Id;
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
Db.Users.Add(customerUser);
Db.SaveChanges();
CustomerUserId = customerUser.Id;
var customer = new CustomerProfile { UserId = customerUser.Id };
Db.Set<CustomerProfile>().Add(customer);
Db.SaveChanges();
CustomerId = customer.Id;
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
Db.Set<Patient>().Add(patient);
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول",
PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001",
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
};
Db.Set<CustomerAddress>().Add(address);
Db.SaveChanges();
_patientId = patient.Id;
_addressId = address.Id;
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
Db.Users.Add(nurseUser);
Db.SaveChanges();
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
NurseId = nurse.Id;
}
/// <summary>Seeds a confirmed booking + its captured (succeeded) transaction. Amounts satisfy
/// <c>gross = commission + payout</c>. <paramref name="disputeWindowEndsAt"/> sets the paid-proxy window.</summary>
public (long BookingId, long TransactionId) SeedCapturedBooking(
long gross = 10_000_000, long commission = 1_500_000, string gatewayType = PaymentGatewayType.Standard,
DateTime? disputeWindowEndsAt = null)
{
var variant = new NurseServiceVariant
{
NurseId = NurseId, ServiceCategoryId = _categoryId, Price = gross, PriceUnit = "per_day",
SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
var request = new BookingRequest
{
CustomerId = CustomerId, NurseId = NurseId, PatientId = _patientId, VariantId = variant.Id,
CustomerAddressId = _addressId, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
Db.Set<BookingRequest>().Add(request);
Db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Db.SaveChanges();
var booking = new BookingEntity
{
BookingRequestId = request.Id, CustomerId = CustomerId, NurseId = NurseId, PatientId = _patientId,
VariantId = variant.Id, CustomerAddressId = _addressId, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
GrossPriceIrr = gross, BalinyaarCommissionIrr = commission, PlatformFeeRate = 0.15m,
NursePayoutAmount = gross - commission, SessionCount = 1,
ScheduledDate = new DateOnly(2026, 8, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
};
booking.TransitionTo(BookingStatus.Confirmed, new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc));
if (disputeWindowEndsAt is { } d)
booking.SetDisputeWindow(d);
Db.Set<BookingEntity>().Add(booking);
Db.SaveChanges();
var gateway = new PaymentGateway
{
ProviderCode = gatewayType == PaymentGatewayType.Bnpl ? "snapppay" : "zarinpal",
Type = gatewayType, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0
};
Db.Set<PaymentGateway>().Add(gateway);
Db.SaveChanges();
var txn = new PaymentTransaction
{
BookingRequestId = request.Id, CustomerId = CustomerId, GatewayId = gateway.Id,
Amount = gross, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}"
};
txn.MarkSucceeded(booking.Id, "ok", null);
Db.Set<PaymentTransaction>().Add(txn);
Db.SaveChanges();
return (booking.Id, txn.Id);
}
public ICurrentUser AsAdmin(int userId = 9999)
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(userId);
u.Roles.Returns(new[] { RoleNames.Admin });
return u;
}
public IDateTimeProvider Clock(DateTimeOffset now)
{
var c = Substitute.For<IDateTimeProvider>();
c.UtcNow.Returns(now);
return c;
}
public IPlatformConfig Config(decimal vatRate = 0.10m, bool ticketRequired = false, int bnplEtaDays = 10)
{
var cfg = Substitute.For<IPlatformConfig>();
cfg.GetConfig<decimal>("vat_rate", Arg.Any<CancellationToken>()).Returns(vatRate);
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(0.15m);
cfg.GetConfig<bool>("refund_ticket_required", Arg.Any<CancellationToken>()).Returns(ticketRequired);
cfg.GetConfig<int>("bnpl_refund_eta_business_days", Arg.Any<CancellationToken>()).Returns(bnplEtaDays);
return cfg;
}
public INursePayoutStatus PayoutStatus(bool paid)
{
var s = Substitute.For<INursePayoutStatus>();
s.IsNursePaidForBookingAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(paid);
return s;
}
public IPaymentProvider Card()
{
var p = Substitute.For<IPaymentProvider>();
p.RefundAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"card-refund-{ci.ArgAt<string>(0)}"));
return p;
}
public IBnplProvider Bnpl()
{
var p = Substitute.For<IBnplProvider>();
p.RevertAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new BnplRevertResult(PaymentProviderStatus.Succeeded, "bnpl-revert", null));
p.UpdateAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new BnplRevertResult(PaymentProviderStatus.Succeeded, "bnpl-update", null));
return p;
}
public IDistributedLock Lock() => new NoOpLock();
public IReadOnlyList<LedgerEntry> LedgerFor(long bookingId)
=> Db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList();
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
private sealed class NoOpLock : IDistributedLock
{
public ValueTask<IAsyncDisposable> AcquireAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IAsyncDisposable>(new Handle());
private sealed class Handle : IAsyncDisposable
{
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
}