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