cleanup phases 6

This commit is contained in:
hamid
2026-08-02 18:48:32 +03:30
parent e2db97392a
commit 51e86a1e5f
239 changed files with 118 additions and 70 deletions
@@ -0,0 +1,36 @@
# Contract — <Domain> (backend phase bN)
> One-line: what this domain's API covers. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the
> machine schema: [`../openapi/`](../openapi/README.md).
**Status:** live as of backend-phase-bN · **Frontend consumer:** frontend-phase-fM
## Enums used
- `<enum_name>`: `value_a` | `value_b` | … — meaning of each.
## Endpoints
### `<HTTP> api/v1/<controller>/<action>`
- **Purpose:** …
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
- **Path/query params:** `name` (type) — meaning; pagination `page`/`pageSize` (default/max) for lists.
- **Request body:**
```json
{ "field": "example" }
```
- **Success `200` payload (`data`):**
```json
{ "field": "example" }
```
- **Failure cases:** `400` …, `401` …, `403` …, `404` …, `409` … (when/why each).
- **Notes:** masking, two-stage disclosure, tenancy, side effects (notifications/ledger/audit), etc.
_(repeat per endpoint)_
## Shared shapes
- `<DtoName>`: field-by-field (name, type, nullable, masked?, meaning).
## Changelog
- bN — initial contract.
@@ -0,0 +1,164 @@
# Contract — BNPL provider-financed installments (backend phase b12)
> One-line: the "pay with installments" checkout alternative. A family checks eligibility, starts a BNPL order
> and is handed off to the provider; the provider callback (or an admin) verifies + settles it — which, in our
> books, is **a card payment that lands net-of-fee** (the provider pays the full booking amount in one lump minus
> its merchant commission and owns 100% of the customer's installments + default risk). Admins can revert. 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-b12 · **Frontend consumer:** frontend-phase-f11-b12
All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). We do **not** model the
customer's repayment schedule — `installment_count` is informational (default 4). Timestamps are UTC ISO-8601;
`settled_at` is **nullable** (settlement is contract-defined and not instant); `expected_customer_refund_eta` is a
**date** (`"2026-08-24"`). Internal ledger `account_type`s are never exposed.
## Enums used
- `bnpl_status` (`bnpl_transactions.status`): `eligible` | `token_issued` | `verified` | `settled` | `reverted` |
`cancelled` | `failed`. **Forward-only** (`eligible → token_issued → verified → settled → reverted`); a replayed
callback that would re-drive a completed transition is an idempotent no-op.
- `bnpl_eligibility_status` (`bnpl_transactions.eligibility_status`): `eligible` | `not_eligible` |
`ceiling_exceeded`. On anything but `eligible` the client falls back to card.
- `provider_code`: `snapppay` | `digipay` | `tara` | `torobpay` — selects the provider adapter.
- `refund_channel` (on the revert's refund): always `bnpl_revert` here (see
[`refunds-invoices.md`](refunds-invoices.md)).
## Endpoints
### `POST api/v1/checkout_bnpl/eligibility`
- **Purpose:** check whether the caller can finance an `accepted_awaiting_payment` booking request with a
provider, and record the outcome on a created/updated `bnpl_transactions` row (status `eligible`).
- **Auth:** authenticated (customer, tenancy-scoped) · **Rate-limited:** yes (sensitive) · **Idempotency key:** no
- **Request body:**
```json
{ "bookingRequestId": 42, "providerCode": "snapppay" }
```
- **Success `200` payload (`data`):**
```json
{
"eligibilityStatus": "eligible",
"isEligible": true,
"installmentCount": 4,
"planSummary": "4 interest-free installments, 0% interest, provider-financed.",
"creditCeilingIrr": "2000000000"
}
```
- **Failure cases:** `400` invalid `provider_code` / non-positive id; `401` unauthenticated; `404` request not
found **or not owned by the caller** (tenancy — a cross-customer request is indistinguishable from missing);
`409` already paid / not awaiting payment.
- **Notes:** the order amount is the request's frozen gross (variant price × session count), never client-supplied.
### `POST api/v1/checkout_bnpl/initiate`
- **Purpose:** start the BNPL order — issue the provider payment token + redirect and walk `eligible →
token_issued`.
- **Auth:** authenticated (customer, tenancy-scoped) · **Rate-limited:** yes (sensitive) · **Idempotency key:**
`Idempotency-Key` header (a retried initiate reuses the same token).
- **Request body:**
```json
{ "bookingRequestId": 42, "providerCode": "snapppay" }
```
- **Success `200` payload (`data`):**
```json
{
"bnplTransactionId": 7,
"paymentTransactionId": 15,
"status": "token_issued",
"externalPaymentToken": "mock-bnpl-token-10000000-bnpl-br-42",
"redirectUrl": "https://provider.example/checkout/…"
}
```
- **Failure cases:** `400` invalid input; `401` unauthenticated; `404` request not found / not owned; `409` already
paid / not awaiting payment / payment window lapsed / order no longer startable; `400` provider declined the
order.
- **Notes:** the row is **1:1** with a `payment_transaction` (`UNIQUE(payment_transaction_id)`); a second initiate
reuses the same row. Runs under `lock(booking-request:{id}:payment)`.
### `GET api/v1/checkout_bnpl/{id}`
- **Purpose:** the customer reads **their own** BNPL order.
- **Auth:** authenticated (tenancy-scoped) · **Rate-limited:** yes (sensitive)
- **Success `200`:** the `BnplOrderStatus` shape (below).
- **Failure cases:** `401`; `404` not found **or another customer's** order (clean not-found).
### `POST api/v1/webhooks_bnpl/{provider}`
- **Purpose:** inbound provider callback — verify/settle/revert an order by event type.
- **Auth:** anonymous, **signature-authenticated** · **Rate-limited:** yes (per-IP) · **Idempotency key:**
`(provider_code, external_event_id)` deduped in `payment_webhook_events` before any money moves.
- **Request body:** raw provider payload; the mock verifier reads
`{ "external_event_id": "...", "event_type": "order.settled", "gateway_reference_code": "<token>" }`. Event type
routes: contains `verif` → verify, `settl` → settle, `revert`/`refund` → revert.
- **Success `200` payload (`data`):**
```json
{ "processingStatus": "processed", "duplicate": false }
```
- **Notes:** always `200` (at-least-once tolerant). A bad signature is stored `ignored`; a duplicate is a no-op
(`duplicate: true`); an unknown token is `failed` (retryable). A replayed settle never double-posts the ledger
(webhook dedup + the forward-only state guard).
### `POST api/v1/admin_bnpl/{id}/verify` · `POST api/v1/admin_bnpl/{id}/settle`
- **Purpose:** manually drive verify / settle (also driven by the callback).
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Success `200`:** `true`.
- **Failure cases:** `401`/`403`; `404` order not found; `409` wrong state (e.g. settle before verify); `400`
provider declined / settlement does not reconcile.
- **Settle side effects:** records `settled_amount_irr` = `order commission`, `bnpl_commission_irr`, `settled_at`
(nullable — read from the settlement); posts the **net-of-fee ledger group** (card-capture legs **plus** `DEBIT
bnpl_fee_expense / CREDIT escrow_held`, one balanced group, so escrow reflects the **net** cash); confirms the
parent `payment_transaction` → **converts the booking**. The nurse's `nurse_payable` accrual equals the
card-path amount (payout **invariant to payment method**). Runs under `lock(bnpl:{id}:settle)`.
### `POST api/v1/admin_bnpl/{id}/revert`
- **Purpose:** reverse a settled BNPL order through the provider.
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Request body:** (all optional; omit `refund_percentage` for a full revert)
```json
{ "refundPercentage": 1.0, "ticketId": null, "reasonNotes": "customer cancelled" }
```
- **Success `200` payload (`data`):**
```json
{
"bnplTransactionId": 7,
"refundId": 3,
"status": "reverted",
"revertTransactionId": "…",
"revertedAmountIrr": "8000000",
"expectedCustomerRefundEta": "2026-08-24"
}
```
- **Failure cases:** `401`/`403`; `404` not found; `409` not settled / already reverted; `400` provider refused.
- **Notes:** creates a `refunds` row with `refund_channel='bnpl_revert'` and posts the reversal ledger via the b11
refund path (fee + payout legs; a clawback if the nurse was already paid). Money flows **customer ↔ provider ↔
Balinyaar** only; the customer cash-back is async ~710 business days (`expected_customer_refund_eta`). A
partial (`refund_percentage < 1`) maps to the provider's update-to-strictly-lower verb.
### `GET api/v1/admin_bnpl/{id}`
- **Purpose:** admin reads any BNPL order.
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Success `200`:** the `BnplOrderStatus` shape (below).
## Shared shapes
- `BnplOrderStatus` — `id` (long), `paymentTransactionId` (long), `bookingId` (long?, set at settle),
`providerCode` (string), `status` (`bnpl_status`), `eligibilityStatus` (`bnpl_eligibility_status`?),
`orderAmountIrr` (digit string), `settledAmountIrr` (digit string?), `bnplCommissionIrr` (digit string?),
`currency` (string, `IRR`), `installmentCount` (int, informational), `settledAt` (datetime?, **nullable —
not instant**), `revertTransactionId` (string?), `revertedAmountIrr` (digit string?), `revertedAt` (datetime?),
`providerCommissionReversedAmount` (digit string?), `refundChannel` (string?, `bnpl_revert`),
`expectedCustomerRefundEta` (date?), `createdAt` (datetime).
## Changelog
- b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).
---
## Refinement phase 3 additions (REQ-022/023/024)
- `balinyaar` added to the `provider_code` enum (in-house plan; identical net-of-fee mechanics, resolves to the
same adapter). The set is now `snapppay|digipay|tara|torobpay|balinyaar`.
- `POST checkout_bnpl/eligibility` accepts optional `{ nationalId, mobile, consent }` (consent required when the
KYC inputs are present; a supplied mobile drives the provider inquiry, else the account mobile).
- `GET api/v1/checkout_bnpl/by_request/{bookingRequestId}` (owner-scoped) → `BnplOrderStatusDto`; `bookingId` on
the settled order was already present on the DTO.
- **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` + `wallet_installments` — b12 deliberately does not model
the customer repayment schedule / per-installment status, and there is no installment ledger to serve them from.
Keep the D1/D2/D4/D5 plan visualization mocked until a provider-schedule integration (or a schedule table) lands.
@@ -0,0 +1,166 @@
# Contract — Booking requests (backend phase b8)
> The **pre-payment intent** layer of the engagement lifecycle: a customer requests a nurse; the nurse
> accepts/rejects before a config-driven response deadline; on accept a config-driven **30-minute payment
> window** opens. **No money and no `bookings` row exist here** — accept only opens the window (conversion is
> b9/b10). 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) (refreshed for b8).
**Status:** live as of backend-phase-b8 · **Frontend consumer:** frontend-phase-f7-b8
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased). All responses use the
> standard `{ succeeded, statusCode, data }` envelope; `data` shapes are below. Request bodies are camelCase.
## Key semantics (read first)
- **Two-table split, no money on a request.** A `booking_requests` row never carries a price/total and never
creates a booking. Accept moves it to `accepted_awaiting_payment` and opens the payment window; b9 later
consumes that and creates the `bookings` row (+ money), setting the request to `converted`.
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited
`customer_notes` — never a full clinical/care instruction (those are b9's encrypted, post-confirmation
`booking_care_instructions`). The **nurse view of a request masks the full address** (address line / postal
code / recipient) and shows only a coarse city/district location; the **customer/admin view** returns the
full address.
- **Tenancy invariant (enforced at create).** The `patient` and `customer_address` must belong to the caller's
customer; the `variant` must belong to the requested `nurse_id`. A mismatch is a clean `404` — never a leak.
- **Same-gender match is first-class.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against
the nurse's gender at request time. `male`/`female` must equal the nurse's gender; `any` matches either. A
mismatch is a `400`. It is required on create and never silently defaulted.
- **Deadlines are frozen from config.** `nurse_response_deadline_at` = create-time `now +
nurse_response_deadline_hours` (default 24h); `payment_deadline_at` = accept-time `now +
booking_payment_deadline_minutes` (**30**). Both are absolute UTC timestamps stored on the row — a later
config change never moves an existing request's deadlines.
- **Forward-only status machine.** Illegal transitions return `409`. Terminal states
(`converted`/`rejected_by_nurse`/`expired_no_response`/`payment_deadline_expired`/`cancelled_by_customer`)
have no outgoing edges. An accept after the response deadline, or on a non-pending request, is `409`.
- **Auto-expiry.** A background sweep (also an admin manual trigger) moves `pending_nurse_response →
expired_no_response` past the response deadline and `accepted_awaiting_payment → payment_deadline_expired`
past the payment window, and notifies the customer.
- **Timestamps** are UTC ISO-8601. **Ids** are `BIGINT`. **There is no money field anywhere in this domain.**
## Enums used
- `booking_request_status`: `pending_nurse_response` | `accepted_awaiting_payment` | `converted` |
`rejected_by_nurse` | `expired_no_response` | `payment_deadline_expired` | `cancelled_by_customer`.
- `required_caregiver_gender`: `male` | `female` | `any`.
## Endpoints
### `POST api/v1/booking_requests/create`
- **Purpose:** a customer requests a nurse for a patient/variant/address/date.
- **Auth:** authenticated **customer (owner)** · **Rate-limited:** no · **Idempotency key:** no
- **Request body:**
```json
{
"nurseId": 42,
"variantId": 8,
"patientId": 5,
"customerAddressId": 6,
"requestedDate": "2026-08-01",
"requestedTimeStart": "09:00:00",
"requestedTimeEnd": "13:00:00",
"requiredCaregiverGender": "female",
"customerNotes": "Careful with the IV line."
}
```
- **Success `200` payload (`data`):** a `BookingRequestDto` (customer view — full address), status
`pending_nurse_response`, `nurseResponseDeadlineAt` set, `paymentDeadlineAt` null.
- **Failure cases:** `400` validation (missing/invalid gender, `requestedTimeEnd ≤ requestedTimeStart`,
past date, notes > 1000, inactive variant, nurse not verified/accepting, **same-gender mismatch**);
`401` unauthenticated; `403` not a customer / no customer profile; `404` patient/address not owned or
variant not the nurse's or nurse absent.
- **Side effects:** in-app `booking_request_received` notification to the nurse (`data_json`:
`booking_request_id`, `patient_display_name`, `requested_date`).
### `POST api/v1/booking_requests/accept/{id}`
- **Purpose:** the assigned nurse accepts a pending request, opening the 30-minute payment window.
- **Auth:** authenticated **nurse (assigned)** · path param `id` (BIGINT).
- **Request body:** none.
- **Success `200` payload (`data`):** `BookingRequestDto` (nurse view — masked address), status
`accepted_awaiting_payment`, `paymentDeadlineAt = now + booking_payment_deadline_minutes` (30 min).
- **Failure cases:** `401`; `403` not a nurse; `404` not the caller's request; `409` not pending / already
past the response deadline.
- **Side effects:** in-app `booking_request_accepted` notification to the customer (`data_json`:
`booking_request_id`, `payment_deadline_at`). **No `bookings` row and no money are created.**
### `POST api/v1/booking_requests/reject/{id}`
- **Purpose:** the assigned nurse declines a pending request with a reason.
- **Auth:** authenticated **nurse (assigned)** · path param `id`.
- **Request body:** `{ "reason": "Fully booked that week." }` (required, ≤ 500 chars).
- **Success `200` payload (`data`):** `BookingRequestDto`, status `rejected_by_nurse`, `nurseRejectionReason` set.
- **Failure cases:** `400` empty/too-long reason; `401`; `403`; `404`; `409` not pending.
- **Side effects:** in-app `booking_request_rejected` notification to the customer.
### `POST api/v1/booking_requests/cancel/{id}`
- **Purpose:** the customer withdraws a request that is still `pending_nurse_response` or
`accepted_awaiting_payment` (before paying).
- **Auth:** authenticated **customer (owner)** · path param `id`.
- **Request body:** none.
- **Success `200` payload (`data`):** `BookingRequestDto` (customer view), status `cancelled_by_customer`.
- **Failure cases:** `401`; `403`; `404` not owned; `409` request is terminal (converted/rejected/expired).
### `GET api/v1/booking_requests/list`
- **Purpose:** the role-scoped inbox (paginated).
- **Auth:** authenticated (customer **or** nurse).
- **Query params:** `status` (optional enum filter); `role` (`customer`|`nurse`, optional — disambiguates a
user who holds both roles; inferred from the caller's profile when omitted); `page`/`pageSize` (default
1 / 50, max 100).
- **Success `200` payload (`data`):** `PagedResult<BookingRequestListItemDto>` (`items`, `total`, `page`,
`pageSize`). Actionable rows sort first. The **customer** inbox sets `counterpartyName` = nurse name +
`nurseRating`; the **nurse** inbox sets `counterpartyName` = patient name + `customerNotes` (stage-1 only).
- **Failure cases:** `400` holds both roles and no `role` given; `401`. A caller with neither profile gets an
empty page.
### `GET api/v1/booking_requests/get/{id}`
- **Purpose:** a single request.
- **Auth:** its **customer** (full address), its **nurse** (masked address), or an **admin** (full).
- **Success `200` payload (`data`):** `BookingRequestDto`.
- **Failure cases:** `401`; `404` absent **or** caller is neither party nor admin (existence not leaked).
### `POST api/v1/admin_booking_requests/expire`
- **Purpose:** admin/test manual trigger of the expiry sweep (the same command the recurring job runs).
- **Auth:** **admin** (`DynamicPermission`) · **Rate-limited:** no.
- **Request body:** none.
- **Success `200` payload (`data`):** `{ "expiredNoResponse": 0, "paymentDeadlineExpired": 0 }` — counts moved
this run (idempotent; re-running on a drained set returns zeros).
- **Failure cases:** `401`; `403` non-admin.
## Shared shapes
- `BookingRequestDto` (full single-request view):
`id` (long), `status` (enum), `nurseId` (long), `nurseName` (string), `nurseRating` (decimal),
`nurseTotalReviews` (int), `patientId` (long), `patientName` (string), `variantId` (long),
`variantLabel` (string), `variantPriceUnit` (string), `customerAddressId` (long), `addressTitle` (string),
`cityId` (long), `cityNameFa`/`cityNameEn` (string), `districtId` (long?, null = whole city),
`districtNameFa`/`districtNameEn` (string?), `addressLine`/`postalCode`/`recipientName`/`recipientPhone`
(string?, **null in the nurse view** — masked), `requiredCaregiverGender` (enum?), `requestedDate` (date),
`requestedTimeStart`/`requestedTimeEnd` (time), `customerNotes` (string?, stage-1 plaintext),
`nurseResponseDeadlineAt` (UTC datetime), `paymentDeadlineAt` (UTC datetime?, null until accept),
`nurseRejectionReason` (string?), `createdAt` (UTC datetime).
- `BookingRequestListItemDto` (inbox row):
`id`, `status`, `counterpartyName` (nurse name for customer view / patient name for nurse view),
`nurseRating` (decimal?, customer view only), `requiredCaregiverGender` (enum?), `requestedDate`,
`requestedTimeStart`, `requestedTimeEnd`, `nurseResponseDeadlineAt`, `paymentDeadlineAt`,
`customerNotes` (string?, **nurse view only**).
- `ExpireBookingRequestsResult`: `expiredNoResponse` (int), `paymentDeadlineExpired` (int).
## Changelog
- b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).
---
## Refinement phase 3 additions (REQ-013/014/016/017)
- **`BookingRequestDto`** gains `variantPrice` (IRR digit-string — the chosen variant's *display rate*, not
an engagement total; the request stays money-free), `nurseAvatarUrl` (nullable), and `bookingId`
(nullable — the booking created once the request is `converted`, for the confirmation deep-link).
- **`BookingRequestListItemDto`** gains `variantLabel` (self-describing inbox row) and `patientAge`
(nullable coarse triage age).
- **`GET api/v1/booking_requests/checkout_summary/{id}`** (owner-scoped) — the C6 money breakdown:
`{ bookingRequestId, requestStatus, nurseName, patientName, variantLabel, variantPriceUnit, sessionCount,
requestedDate, requestedTimeStart, requestedTimeEnd, paymentDeadlineAt, serviceCostIrr, commissionIrr,
vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr, nursePayoutAmount }`. All IRR
digit-strings, computed server-side. **Canonical rates:** `platform_fee_rate = 0.15`, `vat_rate = 0.10`.
VAT is **carved out of the commission** so `serviceCostIrr + commissionIrr + vatIrr = totalIrr = gross`
(the captured amount); `commissionIrr` is the commission **net of VAT**, and the raw b10 amounts
(`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`) are surfaced alongside.
@@ -0,0 +1,132 @@
# Contract — Bookings, Sessions, EVV & Cancellation (backend phase b9)
> One-line: the post-payment engagement — convert a paid request into a booking + N sessions, the two-stage
> care-instructions boundary, per-session EVV check-in/out, dispute-window gating, and cancellation. 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-b9 · **Frontend consumer:** frontend-phase-f8-b9
All money is **IRR Rials, integer, on the wire as a string of digits** (`"15000000"`). The three booking
amounts always satisfy `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`. Timestamps are UTC
ISO-8601. Enums cross as their stable string codes.
## Enums used
- `BookingStatus`: `pending_payment` | `confirmed` | `in_progress` | `completed` | `disputed` | `closed` | `cancelled`.
- `BookingSessionStatus`: `scheduled` | `in_progress` | `completed` | `missed` | `cancelled`.
- `VisitVerificationStatus`: `pending` | `checked_in` | `completed`.
- `CancellationActor` (`applies_to` / `cancelled_by`): `customer` | `nurse` | `admin`.
## Endpoints
### `POST api/v1/bookings/convert`
- **Purpose:** The (mock) payment-capture conversion — creates the booking 1:1 from an
`accepted_awaiting_payment` request. In b10 the real card capture calls this path directly.
- **Auth:** authenticated (owning customer or admin) · **Rate-limited:** yes (sensitive) · **Idempotent:** yes
(replay returns the existing booking).
- **Request body:** `{ "bookingRequestId": 123 }`
- **Success `200` (`data`):** the `Booking` shape (below), `status = confirmed`.
- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` request not
awaiting payment / no longer convertible, `400` capture failed (no booking created).
- **Notes:** computes the three amounts (commission = round(`gross × platform_fee_rate`), rate snapshotted),
writes `variant_snapshot_json` + **encrypted** `address_snapshot_json`, generates ≥ 1 session with
`Σ visit_payout_amount = nurse_payout_amount`, flips the request → `converted`, notifies both parties.
### `GET api/v1/bookings/get/{id}`
- **Purpose:** Booking header + money summary + sessions + timeline. **Auth:** authenticated, tenancy-scoped
(customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes
care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak).
### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&pageSize=`
- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise).
**Success:** `PagedResult<BookingListItem>`.
### `POST api/v1/bookings/transition/{id}`
- **Purpose:** admin/dispute status move. **Auth:** admin (`403` otherwise). **Body:**
`{ "targetStatus": "disputed", "reason": "…" }`. **Failure:** `409` (illegal edge, or contradicts EVV —
e.g. `in_progress` with no session checked in, `completed` with a live session), `400` (use `cancel` for
cancellation). Completing here also opens the dispute window.
### `POST api/v1/bookings/cancel/{id}`
- **Purpose:** cancel a whole booking. **Auth:** owning customer / assigned nurse / admin · **Rate-limited:** yes.
**Body:** `{ "reason": "…" }`. **Success:** `CancellationResult`. Resolves + **snapshots** the policy
(`code` + `refund_percentage`) onto the booking, cancels only un-started (`scheduled`) sessions, computes the
refundable amount. **No refund ledger is posted (b11).** **Failure:** `400` no reason, `409` not cancellable.
### `POST api/v1/bookings/submit_care_instructions/{id}`
- **Purpose:** write/update the encrypted stage-2 `booking_care_instructions`. **Auth:** owning customer or
admin, booking must be `confirmed`+. **Body:** `CareInstructions` (all optional strings). **Failure:** `409`
not confirmed, `404` not found.
### `GET api/v1/bookings/care_instructions/{id}`
- **Purpose:** the **gated** stage-2 read. **Auth:** **assigned nurse or admin only, post-confirmation.** Any
other caller (customer, unassigned nurse, pre-confirmation) → `404` (never leaks). Returns decrypted
`CareInstructions`. **This is the two-stage disclosure boundary.**
### `POST api/v1/booking_sessions/check_in/{id}`
- **Purpose:** assigned nurse clocks in. **Auth:** assigned nurse. **Body:** `{ "latitude": 35.6892,
"longitude": 51.389 }` (both nullable — GPS-denied still checks in, flagged). Moves the session + booking to
`in_progress`; computes the **advisory** address match against `evv_location_tolerance_meters`. A mismatch
raises a `location_mismatch` support alert + notifies **without blocking**. **Success:** `VisitVerification`.
**Failure:** `401`, `403` (not a nurse), `404` (not the nurse's session), `409` (not startable).
### `POST api/v1/booking_sessions/check_out/{id}`
- **Purpose:** assigned nurse clocks out — must follow an open check-in. Completes the session's EVV, sets its
`payout_eligible_at`, and — when all sessions are settled — completes the booking + sets
`dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able.
### `GET api/v1/booking_sessions/today?date=&page=&pageSize=`
- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse,
tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`.
### `GET api/v1/booking_sessions/evv/{id}`
- **Purpose:** per-session EVV detail. **Auth:** owning nurse + admin only (raw GPS gated); others → `404`.
### `POST api/v1/booking_sessions/cancel/{id}`
- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the
session's refundable share. **Failure:** `409` if the session already started.
### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&pageSize=`
- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:**
`PagedResult<AdminEvvItem>`.
### `POST api/v1/admin_evv/detect_no_shows`
- **Purpose:** the manual no-show sweep trigger (the recurring cron is DEFERRED). **Auth:** admin. Marks
overdue scheduled sessions `missed`, raises `no_show` alerts + notifies. **Success:** `{ "missed": N }`.
### `POST api/v1/admin_cancellation_policies/upsert` · `GET api/v1/admin_cancellation_policies/list`
- **Purpose:** admin CRUD of cancellation tiers (keyed by unique `code`). **Auth:** admin policy. Editing a
policy never mutates an already-snapshotted cancellation. **Failure:** `400` (percentage not 0100, bad
actor, min ≥ max).
## Shared shapes
- **`Booking`** (`bookings/get`, `convert`, `transition`): `id`, `bookingRequestId`, `status` (`BookingStatus`),
`nurseId`, `nurseName`, `patientId`, `patientName`, `variantId`, `variantSnapshotJson` (string),
`customerAddressId`, `addressSnapshotJson` (string, **null for the nurse view**), `grossPriceIrr`,
`balinyaarCommissionIrr`, `nursePayoutAmount`, `pspFeeAmount` (money strings; psp nullable),
`platformFeeRate` (decimal), `sessionCount` (int), `scheduledDate`/`scheduledTimeStart`/`scheduledTimeEnd`,
`confirmedAt`/`completedAt`/`cancelledAt` (nullable), `cancelledBy`/`cancellationReason`/
`cancellationPolicyCode` (nullable), `cancellationRefundPercentage` (nullable decimal), `refundableAmountIrr`
(nullable money string), `disputeWindowEndsAt` (nullable), `createdAt`, `sessions[]`.
- **`BookingSessionSummary`** (embedded): `id`, `sessionIndex`, schedule, `status` (`BookingSessionStatus`),
`visitPayoutAmount` (money string), `payoutEligibleAt` (nullable), `evvStatus` (`VisitVerificationStatus`),
`checkInAt`/`checkOutAt` (nullable), `checkInAddressMatch` (nullable bool).
- **`BookingListItem`**: `id`, `status`, `counterpartyName`, `scheduledDate`, `sessionCount`, `amountIrr`
(gross for customer / payout for nurse), `disputeWindowEndsAt`, `createdAt`.
- **`BookingSessionListItem`**: `sessionId`, `bookingId`, `sessionIndex`, `patientName`, schedule, `status`,
`evvStatus`.
- **`CareInstructions`**: `bookingId` + `currentConditions`/`medications`/`allergies`/`specialInstructions`/
`emergencyContactName`/`emergencyContactPhone` (all nullable). **Encrypted at rest; gated read.**
- **`VisitVerification`**: `id`, `bookingSessionId`, `status`, `checkInAt`/`checkInLat`/`checkInLng`,
`checkOutAt`/`checkOutLat`/`checkOutLng`, `checkInAddressMatch`, `checkInDistanceMeters` (raw GPS gated).
- **`AdminEvvItem`**: `sessionId`, `bookingId`, `nurseId`, `sessionStatus`, `scheduledDate`,
`scheduledTimeStart`, `checkInAt`, `checkInAddressMatch`, `checkInDistanceMeters`.
- **`CancellationResult`**: `bookingId`, `sessionId` (nullable), `bookingStatus`, `policyCode`,
`refundPercentage`, `refundableAmountIrr` (money string).
- **`CancellationPolicy`**: `id`, `code`, `appliesTo`, `hoursBeforeStartMin`/`Max` (nullable), `refundPercentage`,
`feeAmountIrr` (money string), `feeRate` (nullable), `isActive`.
## Changelog
- b9 — initial contract (bookings + sessions + care instructions + EVV + cancellation; capture mocked via
`IPaymentCaptureSimulator`, real trigger arrives with b10).
@@ -0,0 +1,142 @@
# Contract — Service catalog & nurse pricing variants (backend phase b5)
> The admin catalog skeleton (categories → option groups → option values) and the nurse pricing layer
> (variants — the atomic bookable unit). 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) (refreshed for b5).
**Status:** live as of backend-phase-b5 · **Frontend consumer:** frontend-phase-f4-b5
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the
> codebase convention and the dynamic-permission key scheme — e.g. create-a-category is
> `POST api/v1/admin_catalog/create_category`, not `POST api/v1/admin/catalog/categories`. Mutations use
> **POST**; ids for edit/toggle come from the **route**, never the body. All responses use the standard
> `{ succeeded, statusCode, data }` envelope; `data` shapes are below. JSON bodies/fields are **camelCase**.
## Enums used
- `price_unit`: `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h` — the unit a variant's
`price` is quoted in. `per_24h` (شبانه‌روزی / live-in) and `per_day` are first-class. Stable string codes;
the client maps them to i18n labels, never derives a label from the code.
## Key semantics (read first)
- **The bookable unit is the VARIANT, not the nurse.** A nurse with no active variant is not bookable.
Search (b7) and booking (b8) operate on a variant.
- **`price` is IRR Rials, integer, on the wire as a string of digits** (e.g. `"8000000"`). No floats, no
Toman. The engagement **total is `price` + `price_unit` + `session_count`** — never derive a total from
`price` alone.
- **A NULL-category option group is cross-category** — it applies to *every* category. The applicable
groups for a category = its own groups **plus** every cross-category group.
- **All required dimensions must be answered** on variant create (including required cross-category ones);
**one value per dimension**; a value must belong to its group and be active.
- **Duplicate identical listings are rejected** — same nurse + same category + identical answered
option-set → **409**.
- **`display_name` auto-generates** from the category + chosen value labels but is nurse-editable.
- **Deactivate, never delete.** Categories/groups/values/variants soft-deactivate; a deactivated variant is
unbookable and drops out of the public view.
- **Every catalog row carries `nameFa` (primary) + `nameEn`.** The client picks by locale.
## Public catalog browse — `CatalogController` (no auth)
### `GET api/v1/catalog/categories?page=&pageSize=`
- Active categories ordered by `sortOrder`, **paginated** (default `pageSize` 50, max 100). Cached. `data`:
`PagedResult<ServiceCategoryDto>`.
### `GET api/v1/catalog/option_groups?category_id={id}`
- A category's **applicable** option groups — its own active groups **plus** every cross-category (NULL)
active group — each with its active values, ordered by `sortOrder`. Cached. **Empty list is valid**
(no dimensions defined yet). `data`: `OptionGroupDto[]`.
## Admin catalog curation — `AdminCatalogController` (admin / dynamic-permission)
Every write **invalidates the catalog cache**. Both labels required (`nameFa`/`nameEn`). No hard delete.
| Route | Body | Result |
| --- | --- | --- |
| `POST admin_catalog/create_category` | `{ nameFa, nameEn, descriptionFa?, descriptionEn?, iconKey?, sortOrder }` | `ServiceCategoryDto` |
| `POST admin_catalog/update_category/{id}` | `{ nameFa, nameEn, descriptionFa?, descriptionEn?, iconKey?, sortOrder }` | `ServiceCategoryDto` |
| `POST admin_catalog/set_category_active/{id}` | `{ isActive }` | `true` |
| `POST admin_catalog/create_option_group` | `{ serviceCategoryId?, nameFa, nameEn, isRequired, sortOrder }` | `OptionGroupDto` |
| `POST admin_catalog/update_option_group/{id}` | `{ serviceCategoryId?, nameFa, nameEn, isRequired, sortOrder }` | `OptionGroupDto` |
| `POST admin_catalog/create_option_value` | `{ optionGroupId, nameFa, nameEn, sortOrder }` | `OptionValueDto` |
| `POST admin_catalog/update_option_value/{id}` | `{ nameFa, nameEn, sortOrder, isActive }` | `OptionValueDto` |
- **`serviceCategoryId = null` on a group = cross-category** (applies to every category).
- `update_option_value` intentionally does **not** re-parent a value to another group (it would change the
meaning of variants that already answered with it).
- **Failure cases:** `400` empty labels / unknown parent (`serviceCategoryId`/`optionGroupId`); `401`
unauthenticated; `403` non-admin; `404` unknown id on update/toggle.
## Nurse variants — `NurseVariantsController` (authenticated; nurse-owner-scoped in handler)
### `POST api/v1/nurse_variants/create`
- **Body:** `{ serviceCategoryId, options: [{ optionGroupId, optionValueId }], price, priceUnit, sessionCount?, displayName? }`
`price` is a string of digits; `options` answers the dimensions (one value per group). Omit
`displayName` to auto-generate it.
- **`data`:** `VariantDto` (`isActive: true`, `displayName` auto-generated from labels unless overridden).
- **Failure cases:** `400` invalid price (non-digits/≤0)/`priceUnit`/`sessionCount`; a **missing required
dimension** (names it); a value not belonging to its group; the same group answered twice; an unknown/
inapplicable group or value; missing/inactive category. `401` unauthenticated; `403` caller is not a
nurse (or has no nurse profile). **`409`** a duplicate identical listing (same category + option-set) —
never a `500`.
- **Tenancy/side effects:** the nurse is derived from the caller, never the body. (Deferred: this is the
trigger point for the b7 `nurse_search_index` fan-out.)
### `POST api/v1/nurse_variants/update/{id}`
- **Body:** `{ price, priceUnit, sessionCount?, displayName? }` — edits price/unit/session/display only. The
**option-set is immutable** here (change dimensions = create-new + deactivate-old). A blank `displayName`
leaves the current one unchanged. `data`: `VariantDto`. `404` if not owned/absent (existence not leaked).
### `POST api/v1/nurse_variants/set_active/{id}`
- **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned.
### `GET api/v1/nurse_variants/list?page=&pageSize=`
- The nurse's own offerings — **active and inactive**, active-first, paginated. `data`:
`PagedResult<VariantDto>`.
### `GET api/v1/nurse_variants/get/{id}`
- **Auth:** none required (owner/admin get the full view; any other caller gets the **public** projection).
- The owning nurse and an admin see the variant in any state; anyone else sees it only when **active**.
`data`: `VariantDto`. `404` when absent, or inactive to a non-owner.
## Shared shapes
- `ServiceCategoryDto`: `id` (long), `nameFa`, `nameEn`, `descriptionFa` (string?), `descriptionEn`
(string?), `iconKey` (string?), `sortOrder` (int), `isActive` (bool).
- `OptionValueDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `isActive`.
- `OptionGroupDto`: `id`, `serviceCategoryId` (long?, **null = cross-category**), `nameFa`, `nameEn`,
`isRequired` (bool), `sortOrder`, `isActive`, `values` (`OptionValueDto[]`).
- `VariantOptionDto`: `optionGroupId`, `groupNameFa`, `groupNameEn`, `optionValueId`, `valueNameFa`,
`valueNameEn`.
- `VariantDto`: `id`, `serviceCategoryId`, `categoryNameFa`, `categoryNameEn`, `price` (**string of IRR
digits**), `priceUnit` (enum), `sessionCount` (int?), `displayName`, `isActive` (bool), `options`
(`VariantOptionDto[]`).
- `PagedResult<T>`: `items` (`T[]`), `total` (int), `page` (int), `pageSize` (int).
## Seed (available on a fresh DB)
Five categories, ordered by `sortOrder`, `nameFa` + `nameEn`: Elderly Care (id 1, مراقبت از سالمند),
Post-Surgery Recovery (2, مراقبت پس از جراحی), Infant Care (3, مراقبت از نوزاد), Chronic Illness
Management (4, مدیریت بیماری مزمن), Companionship (5, همراهی و مراقبت روزمره). **Option groups/values are
not seeded** — an admin authors them per category (EAV; no migration needed).
## Example — build a variant
```
# 1) admin defines a dimension for Elderly Care
POST /api/v1/admin_catalog/create_option_group
{ "serviceCategoryId": 1, "nameFa": "نوع شیفت", "nameEn": "Shift type", "isRequired": true, "sortOrder": 1 }
-> data.id = 11
POST /api/v1/admin_catalog/create_option_value
{ "optionGroupId": 11, "nameFa": "شبانه‌روزی", "nameEn": "Live-in", "sortOrder": 1 } -> data.id = 101
# 2) nurse builds a priced variant
POST /api/v1/nurse_variants/create
{ "serviceCategoryId": 1, "options": [{ "optionGroupId": 11, "optionValueId": 101 }],
"price": "8000000", "priceUnit": "per_24h" }
-> 200 { id, isActive: true, price: "8000000", displayName: "مراقبت از سالمند · شبانه‌روزی", options: [...] }
# 3) repeating the exact same create -> 409 (duplicate identical listing)
# 4) omitting the required shift-type value -> 400 (missing required dimension)
```
## Changelog
- b5 — initial contract: public catalog browse (categories + applicable option groups), admin catalog CRUD
+ set-active, nurse variant create/update/set-active/list/get; `price_unit` enum; IRR-string money;
`409` duplicate listing / `400` missing required dimension.
@@ -0,0 +1,113 @@
# Contract — Config, Reference & Platform Signals (backend phase b1)
> Admin config/holiday/audit/support-alert endpoints + the current-user notification endpoints. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema (authoritative
> for exact field/param casing): [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts)
All responses are the standard `OperationResult``ApiResult` envelope (camelCase body, snake_case URLs).
Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `pageSize`
(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`.
## Enums used
- **config `data_type`**: `decimal` | `int` | `bool` | `string` | `json` — how to parse a config `value`.
- **holiday `type`**: `official` | `religious` | `national`.
- **support-alert `type`**: `low_rating` | `evv_no_show` | `evv_location_mismatch` | `verification_expired` | `payment_anomaly` | `fraud_signal`.
- **support-alert `severity`**: `low` | `medium` | `high`.
- **support-alert `status`**: `open` | `assigned` | `resolved` (forward-only).
- **notification `type`**: open string code the front-end renders/deep-links on (e.g. `booking_confirmed`); its shape is the `data_json` contract (below), versioned per type.
---
## Admin — Platform config (`platform_config` controller, `[Authorize(DynamicPermission)]`)
### `GET api/v1/platform_config/get_platform_configs`
- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no.
- **Query:** `page`, `pageSize`.
- **200 `data`:** `PagedResult<PlatformConfigDto>``{ items:[{ key, value, dataType, description }], total, page, pageSize }`.
### `POST api/v1/platform_config/update_platform_config`
- **Purpose:** update one existing config row; writes an `audit_logs` entry in the same transaction and evicts the cache. **Auth:** admin.
- **Body:** `{ "key": "platform_fee_rate", "value": "0.18" }`.
- **200 `data`:** `true` (empty-body success).
- **Failures:** `400` validation (empty key); `404` key does not exist. **Notes:** value is the raw string parsed per the row's `data_type`; changing a rate never retroactively re-prices already-computed rows.
### `GET api/v1/platform_config/get_config_change_history`
- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin.
- **Query:** `key` (required), `page`, `pageSize`.
- **200 `data`:** `PagedResult<ConfigChangeDto>``{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first.
---
## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`)
### `GET api/v1/holidays/get_holidays`
- **Query:** `from` (date, optional), `to` (date, optional), `page`, `pageSize`.
- **200 `data`:** `PagedResult<HolidayDto>``{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date.
### `POST api/v1/holidays/upsert_holiday`
- **Body:** `{ "holidayDate": "2026-03-21", "nameFa": "نوروز", "type": "national", "isBankClosed": true }`.
- **200 `data`:** `true`. **Failures:** `400` (bad `type`, empty `nameFa`, default date). **Notes:** upsert keyed on `holidayDate`.
### `POST api/v1/holidays/delete_holiday`
- **Body:** `{ "holidayDate": "2026-03-21" }`. **200:** `true`; **404** if no holiday on that date.
---
## Admin — Audit (`audit` controller, `[Authorize(DynamicPermission)]`)
### `GET api/v1/audit/get_audit_trail`
- **Purpose:** the immutable trail for one entity. **Auth:** admin.
- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `pageSize`.
- **200 `data`:** `PagedResult<AuditLogDto>``{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows.
---
## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing)
### `GET api/v1/support_alerts/get_support_alerts`
- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `pageSize`.
- **200 `data`:** `PagedResult<SupportAlertDto>``{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`.
### `POST api/v1/support_alerts/assign_support_alert`
- **Body:** `{ "alertId": 42, "ownerUserId": 7 }`. **200:** `true` (open → assigned); **404** if missing or already resolved.
### `POST api/v1/support_alerts/resolve_support_alert`
- **Body:** `{ "alertId": 42, "note": "handled" }`. **200:** `true` (→ resolved); **404** if missing or already resolved.
---
## Current user — Notifications (`notifications` controller, `[Authorize]`, tenant-scoped)
Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id.
### `GET api/v1/notifications/get_notifications`
- **Query:** `page`, `pageSize`.
- **200 `data`:** `PagedResult<NotificationDto>``{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first.
### `GET api/v1/notifications/get_unread_count`
- **200 `data`:** `{ count }` — cheap index-backed count for the polling bell.
### `POST api/v1/notifications/mark_notification_read`
- **Body:** `{ "notificationId": 100 }`. **200:** `true`; **404** if it isn't the caller's or doesn't exist.
### `POST api/v1/notifications/mark_all_read`
- **No body. 200:** `true`.
> **Not exposed via REST** (internal contracts other backend domains call): `CreateNotification` (via
> `INotificationDispatcher.DispatchAsync`), `RaiseSupportAlert` (`ISupportAlertService.RaiseAsync`),
> `EmitSystemEvent` (`IAnalyticsSink.EmitAsync`), `WriteAuditLog` (`IAuditLogger.WriteAsync`). The
> notification retention purge runs on a background hosted service, not an endpoint.
## Shared shapes
- **`PlatformConfigDto`**: `key` (string), `value` (string, raw — parse per `dataType`), `dataType` (enum), `description` (string, nullable).
- **`ConfigChangeDto`**: `id` (long), `action` (`created`/`updated`/`deleted`), `changedFieldsJson` (string, nullable — `{ "Field": { "old": …, "new": … } }`; encrypted/PII fields redacted as `"<redacted>"`), `actorUserId` (int, nullable), `occurredAt` (UTC ISO-8601).
- **`HolidayDto`**: `id` (long), `holidayDate` (date), `nameFa` (string), `type` (enum), `isBankClosed` (bool).
- **`AuditLogDto`**: `id` (long), `entityType` (string), `entityId` (string), `action`, `changedFieldsJson` (nullable), `actorUserId` (nullable), `occurredAt`.
- **`NotificationDto`**: `id` (long), `type` (string code), `title` (string), `body` (string, nullable), `dataJson` (string, nullable — a **typed, versioned deep-link payload**; shape depends on `type`, e.g. `{"booking_id": 1}`), `isRead` (bool), `readAt` (UTC, nullable), `createdAt` (UTC).
- **`SupportAlertDto`**: `id`, `type`, `severity`, `status`, `entityType` (string), `entityId` (string), `bookingId` (long, nullable), `reviewId` (long, nullable), `ownerUserId` (int, nullable), `resolutionNote` (string, nullable), `resolvedAt` (UTC, nullable), `createdAt` (UTC).
## Changelog
- b1 — initial contract (config, holidays, audit, support alerts, notifications).
@@ -0,0 +1,135 @@
# Contract — Geography, addresses & nurse service areas (backend phase b4)
> The province→city→district reference hierarchy (public cascading dropdowns + admin curation), a nurse's
> declared service areas, and a customer's saved (encrypted, geocoded) addresses. 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) (refreshed for b4).
**Status:** live as of backend-phase-b4 · **Frontend consumer:** frontend-phase-f3-b4
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the
> codebase convention and the dynamic-permission key scheme — e.g. create-a-city is
> `POST api/v1/admin_geo/create_city`, not `POST api/v1/admin_geo/cities`. Ids for edit/toggle/remove come
> from the **route**, never the body. All responses use the standard `{ succeeded, statusCode, data }`
> envelope; `data` shapes are below.
## Key semantics (read first)
- **`districtId = null` ⇒ whole city.** For a nurse service area it is a real coverage choice ("I cover
the entire city"), not missing data. Search (b7) treats a whole-city row as matching every district in
that city. A city with **no districts** (e.g. Mashhad) is whole-city-only and its district list is a
valid **empty** result.
- **Coverage is named districts, never a GPS radius.** Address coordinates exist only for the later EVV
distance check (b9), not for matching.
- **`is_active` hides, never deletes.** A deactivated province/city/district disappears from the public
dropdowns (parent-active is honoured on the join) without deleting the region or orphaning rows.
- **Exactly one primary address** per customer; the first address is primary by default.
- **Address PII is encrypted at rest** and decrypted only in the owner's own read.
## Public geo lookups — `GeoController` (no auth)
### `GET api/v1/geo/provinces`
- Active provinces, ordered by `sortOrder`. Cached. `data`: `ProvinceDto[]`.
### `GET api/v1/geo/cities?province_id={id}`
- Active cities under an (active) province, ordered. Empty if the province is inactive/absent. `data`: `CityDto[]`.
### `GET api/v1/geo/districts?city_id={id}`
- Active districts under an (active) city, ordered. **Empty list is valid** (whole-city-only city). `data`: `DistrictDto[]`.
### `GET api/v1/geo/tree`
- The full active province→city→district tree in one cached payload. `data`: `ProvinceTreeDto[]`.
## Admin geo curation — `AdminGeoController` (admin / dynamic-permission)
Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `409` is not used here.
| Route | Body | Result |
| --- | --- | --- |
| `POST admin_geo/create_province` | `{ nameFa, nameEn, sortOrder }` | `ProvinceDto` |
| `POST admin_geo/update_province/{id}` | `{ nameFa, nameEn, sortOrder }` | `ProvinceDto` |
| `POST admin_geo/set_province_active/{id}` | `{ isActive }` | `true` |
| `POST admin_geo/create_city` | `{ provinceId, nameFa, nameEn, sortOrder }` | `CityDto` |
| `POST admin_geo/update_city/{id}` | `{ nameFa, nameEn, sortOrder }` | `CityDto` |
| `POST admin_geo/set_city_active/{id}` | `{ isActive }` | `true` |
| `POST admin_geo/create_district` | `{ cityId, nameFa, nameEn, sortOrder }` | `DistrictDto` |
| `POST admin_geo/update_district/{id}` | `{ nameFa, nameEn, sortOrder }` | `DistrictDto` |
| `POST admin_geo/set_district_active/{id}` | `{ isActive }` | `true` |
- **Failure cases:** `400` invalid names / unknown parent (`provinceId`/`cityId`); `401` unauthenticated;
`403` non-admin; `404` unknown id on update/toggle.
## Nurse service areas — `NurseServiceAreasController` (authenticated; nurse-scoped in handler)
### `POST api/v1/nurse_service_areas/add`
- **Body:** `{ cityId, districtId? }` — omit/`null` `districtId` = whole city.
- **`data`:** `NurseServiceAreaDto`.
- **Failure cases:** `400` invalid/inactive city, or district not in the (active) city; `401`
unauthenticated; `403` caller is not a nurse; **`409`** the nurse already declared this exact coverage
(including a duplicate **whole-city** row) — never a `500`.
- **Tenancy/side effects:** `nurseId` from the caller, never the body. (Deferred: this is the trigger
point for the b7 `nurse_search_index` fan-out.)
### `DELETE api/v1/nurse_service_areas/remove/{id}`
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
### `GET api/v1/nurse_service_areas/list?page=&pageSize=`
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
### `POST api/v1/customer_addresses/create`
- **Body:** `{ title, cityId, districtId?, addressLine, postalCode?, recipientName?, recipientPhone?, isPrimary? }`.
- **`data`:** `CustomerAddressDto` (with `latitude`/`longitude` set from the geocoder, or `null` if
unresolved).
- **Behaviour:** encrypts the PII columns; geocodes via `IGeocoder`; the first address (or `isPrimary:true`)
becomes the single primary (prior primary cleared in the same unit of work). A thin customer profile is
auto-provisioned on first address if needed.
- **Failure cases:** `400` empty `title`/`addressLine`, invalid/inactive city, district not in the city,
bad postal-code format; `401`; `403` caller is not a customer.
### `POST api/v1/customer_addresses/update/{id}`
- Edits an owned address; re-geocodes when `addressLine`/`cityId`/`districtId` changes; re-encrypts PII.
`data`: `CustomerAddressDto`. `404` if not owned.
### `POST api/v1/customer_addresses/set_primary/{id}`
- Atomically makes the owned address primary and clears the previous. `data`: `true`. `404` if not owned.
### `DELETE api/v1/customer_addresses/delete/{id}`
- Soft-deletes the owned address. `data`: `true`. `404` if not owned.
### `GET api/v1/customer_addresses/list?page=&pageSize=`
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
`PagedResult<CustomerAddressDto>`.
## Shared shapes
- `ProvinceDto`: `id` (long), `nameFa` (string), `nameEn` (string), `sortOrder` (int).
- `CityDto`: `id`, `provinceId`, `nameFa`, `nameEn`, `sortOrder`.
- `DistrictDto`: `id`, `cityId`, `nameFa`, `nameEn`, `sortOrder`.
- `CityTreeDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `districts` (`DistrictDto[]`).
- `ProvinceTreeDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `cities` (`CityTreeDto[]`).
- `NurseServiceAreaDto`: `id`, `cityId`, `cityNameFa`, `cityNameEn`, `districtId` (long?, null = whole
city), `districtNameFa` (null when whole city), `districtNameEn` (null when whole city), `isWholeCity`
(bool), `isActive` (bool).
- `CustomerAddressDto`: `id`, `title`, `cityId`, `cityNameFa`, `cityNameEn`, `districtId` (long?),
`districtNameFa` (null?), `districtNameEn` (null?), `addressLine` (decrypted, owner-only), `postalCode`
(decrypted, owner-only, null?), `latitude` (decimal?, null when ungeocoded), `longitude` (decimal?),
`isPrimary` (bool), `recipientName` (decrypted, null?), `recipientPhone` (decrypted, null?).
## Seed (available on a fresh DB)
31 provinces (Tehran first, `sortOrder` deterministic), each province's capital city (covers Tehran,
Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom), and Tehran's 22 مناطق. Tehran province id `1`,
Tehran city id `101`, Tehran districts `1001…1022`; other cities have no districts at seed time.
## Changelog
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
addresses; `IGeocoder` seam; `409` conflict added to the envelope.
---
## Refinement phase 3 additions (REQ-008/009)
- **`CustomerAddressDto`** gains `provinceId` (joined from `cities.province_id`) so the edit form can
prefill the province → city cascade from a server-loaded address.
- **`customer_addresses/create` + `update/{id}`** now accept optional `latitude`/`longitude` (both or
neither). When present, the user's dropped pin is stored (`geocode_source = user_pin`, preferred for the
EVV distance check); when absent the server geocodes as before (`geocode_source = geocoder`).
@@ -0,0 +1,141 @@
# Contract — Identity & Auth (backend phase b2)
> One-line: phone-OTP login, revocable refresh-token sessions with rotation + reuse detection, the
> current-user profile (`/me`) and public role selection. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the
> machine schema: [`../openapi/`](../openapi/README.md) (`swagger.v1.json`, refreshed for b2).
**Status:** live as of backend-phase-2 · **Frontend consumer:** frontend-phase-f1-b2
> **Exact paths** (snake_case transformer output — differs from early sketches that showed
> `otp/request` / `me/role`): `auth/request_otp`, `auth/verify_otp`, `auth/refresh`, `auth/logout`,
> `me`, `me/select_role`. JSON bodies are **camelCase** (confirmed against the live envelope).
## Enums used
- `role` (self-selectable): `customer` | `nurse` — a user may hold **both**. Admin sub-roles
(`admin`, `support`, `finance`, `moderation`, `super_admin`) exist but are **internal-only**;
sending one to `me/select_role` returns `403`.
- `gender`: `male` | `female` — load-bearing for same-gender matching; **null until the profile flow
(b3) sets it**; never defaulted.
- `nurseVerificationStatus`: `not_started` until the b6 verification pipeline exists.
## Endpoints
### `POST api/v1/auth/request_otp`
- **Purpose:** send a one-time login code to an Iranian mobile; silently creates an
inactive-until-verified account for a new phone.
- **Auth:** none · **Rate-limited:** yes (`otp` per-IP policy → `429`) · **Idempotency key:** no
- **Request body:**
```json
{ "phone": "09121112233" }
```
Accepts `+98…`/`0098…`/Persian digits; normalized server-side to `09xxxxxxxxx`.
- **Success `200` payload (`data`):**
```json
{ "otpSent": true, "resendAvailableInSeconds": 120 }
```
Inside the per-phone resend window the same shape returns with `otpSent: false` and the remaining
seconds. **The shape never reveals whether the phone already had an account** (no enumeration).
- **Failure cases:** `400` invalid phone; `429` over the per-IP OTP limit.
- **Notes:** in the mock environment the code is written to the server log (`ISmsSender` mock); the
OTP itself expires on the TOTP provider's window (~3 min).
### `POST api/v1/auth/verify_otp`
- **Purpose:** verify the code, activate the account, mint the token pair + a revocable session.
- **Auth:** none · **Rate-limited:** yes (`otp` policy) · **Idempotency key:** no
- **Request body:**
```json
{ "phone": "09121112233", "code": "466036", "deviceInfo": "iPhone 15 / app 1.0 (optional)" }
```
- **Success `200` payload (`data`):** `AuthTokensResult` (below). `isNewUser: true` on the first
successful verify; `roles` is empty for a fresh user — **route them to role selection**.
- **Failure cases:** `400` wrong/expired code (same safe message whether the phone exists or the
code is wrong — no enumeration); `400` "too many failed attempts" after `auth_otp_max_attempts`
wrong codes (request a new OTP to reset); `429` over limit.
### `POST api/v1/auth/refresh`
- **Purpose:** rotate the refresh token: the presented session is revoked, a new pair is issued.
- **Auth:** none required (`RequireTokenWithoutAuthorization` — the refresh token is the
credential) · **Rate-limited:** yes (`auth` policy) · **Idempotency key:** no
- **Request body:**
```json
{ "refreshToken": "<64-hex refresh token>", "deviceInfo": "optional" }
```
- **Success `200` payload (`data`):** `AuthTokensResult` (`isNewUser` always `false`).
- **Failure cases:** `401` unknown token; `401` expired session; **`401` reuse-detection** — a token
that hashes to an *already-revoked* session is treated as stolen: **all** of that user's sessions
are revoked (logout-everywhere) and the client must sign in again.
### `POST api/v1/auth/logout`
- **Purpose:** revoke the session server-side and kill outstanding access tokens.
- **Auth:** authenticated (Bearer) · **Rate-limited:** no
- **Request body:** (send `{}` at minimum)
```json
{ "refreshToken": "optional — revoke just this session", "everywhere": false }
```
With `everywhere: true` **or no `refreshToken`**, every active session is revoked.
- **Success `200`:** empty envelope (no `data`).
- **Failure cases:** `401` unauthenticated.
- **Notes:** the security stamp rotates on every logout, so **all** of the user's outstanding access
tokens fail immediately (other devices recover by refreshing their still-valid refresh tokens).
### `GET api/v1/me`
- **Purpose:** the signed-in user's identity, roles and onboarding state (drives the role router).
- **Auth:** authenticated · **Rate-limited:** no
- **Success `200` payload (`data`):** `MeResult` (below).
- **Failure cases:** `401` missing/expired/stamp-invalidated token.
### `POST api/v1/me/select_role`
- **Purpose:** self-assign a public actor role. Idempotent; `customer` and `nurse` can coexist.
- **Auth:** authenticated · **Rate-limited:** no
- **Request body:**
```json
{ "role": "customer" }
```
- **Success `200` payload (`data`):** the updated `MeResult`.
- **Failure cases:** **`403` for any non-public role** (`super_admin`, `support`, …); `400` empty
role; `401` unauthenticated.
- **Notes:** role claims live inside the (JWE) access token — after selecting a role, **refresh the
token pair** so subsequent role-gated calls carry the new claim. `/me` reads roles from the DB and
reflects the change immediately.
## Shared shapes
- `AuthTokensResult`:
| field | type | notes |
|---|---|---|
| `accessToken` | string | JWE bearer token (send as `Authorization: Bearer …`) |
| `refreshToken` | string | 64-hex opaque token; **store securely**, only its hash exists server-side |
| `accessExpiresAt` | ISO-8601 | absolute access-token expiry |
| `refreshExpiresAt` | ISO-8601 | session expiry (`auth_session_ttl_days`, default 30d) |
| `isNewUser` | bool | `true` only on the first successful verify for the phone |
| `roles` | string[] | active roles; empty ⇒ send the user to role selection |
- `MeResult`:
| field | type | notes |
|---|---|---|
| `id` | int | user id |
| `phone` | string | **always masked** (`0912*****33`) — full phone is never returned |
| `firstName` / `lastName` | string \| null | null until profile (b3) |
| `gender` | `male`/`female` \| null | null until profile (b3); never defaulted |
| `isActive` | bool | phone-verified account |
| `roles` | string[] | active roles (revoked grants excluded) |
| `hasCustomerProfile` / `hasNurseProfile` | bool | `false` until b3 populates the profile tables |
| `nurseVerificationStatus` | string | `not_started` until b6 |
- `RequestOtpResult`: `otpSent` (bool) + `resendAvailableInSeconds` (int).
## Changelog
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
---
## Refinement phase 3 additions (REQ-002/003)
- **`RequestOtpResult`** gains `codeLength` (6) and `expiresInSeconds` (60) so the OTP box count + expiry
hint are contract-driven.
- **`verify_otp` failures** now carry a stable machine `code` on the envelope: `otp_invalid` (wrong **or**
expired — collapsed for anti-enumeration) and `otp_locked` with `data: { retryAfterSeconds }` on lockout.
The coded-error envelope is `{ isSuccess: false, statusCode: 400, message, code, data? }` (the optional
`code` is omitted from every other response).
@@ -0,0 +1,100 @@
# Contract — Identity profiles, patients & nurse bank accounts (backend phase b3)
> Role-attached identity data on top of the b2 auth spine: the nurse seller profile, the customer payer
> profile, the customer's patients, and the nurse's payout bank accounts. 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-b3 · **Frontend consumer:** frontend-phase-f2-b3
All endpoints require a **Bearer access token** (`[Authorize]`); unauthenticated calls return `401`.
Role scoping is enforced in the handler and returns `403` when the caller lacks the required role — and
**role claims are baked into the access token at mint time**, so a client must refresh (or re-login)
after `me/select_role` before these endpoints see the new role. Request bodies are camelCase JSON; URL
segments are snake_case; responses use the standard `OperationResult``ApiResult` envelope (payload in
`data`).
## Enums used
- `gender`: `male` | `female` — load-bearing for same-gender caregiver matching; required on a patient.
- `blood_type`: free-form short string (e.g. `O+`, `AB-`), nullable — not a fixed enum at MVP.
## Shared shapes
- `NurseProfileDto`: `id` (int64), `bio` (string), `yearsOfExperience` (int), `educationLevel` (string),
`educationField` (string), `specializationsJson` (string — raw JSON array), `isVerified` (bool,
**read-only** — always false until b6 verification), `isAcceptingBookings` (bool),
`averageRating` (decimal), `totalReviews` (int), `totalCompletedBookings` (int) — the last three are
**read-only aggregates**, 0 until reviews/bookings phases.
- `CustomerProfileDto`: `id` (int64), `defaultEmergencyContactName` (string), `defaultEmergencyContactPhone`
(string) — decrypted and returned **in full** to the owning customer (self).
- `PatientDto`: `id` (int64), `displayName`, `firstName`, `lastName` (strings), `birthDate` (date
`YYYY-MM-DD`), `gender` (`male`/`female`), `bloodType` (string, nullable), `initialMedicalNotes`
(string — decrypted, owner-only), `isActive` (bool).
- `NurseBankAccountDto`: `id` (int64), `bankName` (string), `ibanMasked` (string — **last 4 only**, e.g.
`••••3456`; the full IBAN is never returned), `isPrimary` (bool), `isVerified` (bool),
`matchedNationalId` (bool **nullable** — null until the ownership inquiry runs).
## Endpoints
### Nurse profile — role `nurse`
- `POST api/v1/nurse_profiles/upsert` — create/update own profile. Body: `{ bio, yearsOfExperience,
educationLevel, educationField, specializationsJson }`. Returns `NurseProfileDto`. **Never accepts
`isVerified` or the aggregates.** `400` if `yearsOfExperience` ∉ [0,80]; `403` non-nurse.
- `POST api/v1/nurse_profiles/set_accepting_bookings` — body `{ accepting: bool }`. Empty `200` on
success; `404` if no profile yet. Never touches `isVerified`.
- `GET api/v1/nurse_profiles/me` — returns `NurseProfileDto`; `404` if none.
### Customer profile — role `customer`
- `POST api/v1/customer_profiles/upsert` — body `{ defaultEmergencyContactName, defaultEmergencyContactPhone }`
(phone stored **encrypted**). Returns `CustomerProfileDto`. `400` invalid phone / empty name; `403` non-customer.
- `GET api/v1/customer_profiles/me` — returns `CustomerProfileDto`; `404` if none.
### Patients — role `customer` (tenancy-scoped to the caller)
- `POST api/v1/patients/create` — body `{ displayName, firstName, lastName, birthDate, gender, bloodType,
initialMedicalNotes }`. `customerId` is derived from the caller (a thin customer profile is
auto-provisioned on first patient) — **never taken from the body**. Returns `PatientDto`. `400` missing/invalid
`gender` or future `birthDate`.
- `GET api/v1/patients/list?page=&pageSize=` — paginated (`page` 1-based, `pageSize` ≤100, default 50).
Returns `PagedResult<PatientDto>` (`items`, `total`, `page`, `pageSize`) of the caller's **own** patients only.
- `GET api/v1/patients/get/{id}` — returns `PatientDto`; `404` if not owned (existence not leaked).
- `POST api/v1/patients/update/{id}` — body as create (id from the route). Returns `PatientDto`; `404` if not owned.
- `POST api/v1/patients/archive/{id}` — soft-archive (`isActive=false`, not a delete). Empty `200`; `404` if not owned.
### Nurse bank accounts — role `nurse` (tenancy-scoped)
- `POST api/v1/nurse_bank_accounts/add` — **rate-limited**. Body `{ bankName, accountHolderName, iban }`
(IBAN `IR`+24 digits; stored encrypted). Runs the استعلام شبا ownership inquiry and returns
`NurseBankAccountDto` with `matchedNationalId` set. Becomes primary if it is the nurse's first account.
`400` invalid IBAN, **duplicate IBAN** (via `iban_hash` uniqueness — a clean failure, not a 500), or no nurse profile.
- `POST api/v1/nurse_bank_accounts/set_primary/{id}` — makes the account primary and clears the prior
primary atomically (the filtered single-primary index never trips). Empty `200`; `404` if not owned.
- `GET api/v1/nurse_bank_accounts/list` — returns `NurseBankAccountDto[]` with **masked** IBANs.
- `POST api/v1/nurse_bank_accounts/verify_ownership/{id}` — **rate-limited**. Re-runs the ownership
inquiry (idempotent: same input → same vendor ref). Returns the updated `NurseBankAccountDto`; `404` if not owned.
## Side effects & rules the API enforces
- **Guarded `isVerified`** — there is no field or endpoint to set it; a nurse profile is created
unverified and stays so until the b6 verification-confirm transaction.
- **Tenancy** — a customer only ever sees/mutates their own patients; a nurse only their own bank
accounts. Cross-tenant access returns `404` (never leaks existence).
- **IBAN masking** — the full IBAN is never returned; lists/DTOs carry last-4 only. The full value is
encrypted at rest.
- **`matchedNationalId` gates the first payout (b13)** — set here by the (mocked)
`IBankAccountOwnershipVerifier`, not by admin eyeballing; `null` until the inquiry has run.
- **Deferred:** saved service addresses & nurse coverage areas (b4); customer national-ID KYC (not
collected, never gates browsing/booking).
## Changelog
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
---
## Refinement phase 3 additions (REQ-005/006/007)
- **`PatientDto` + create/update** gain `relation` (`parent|spouse|child|self`, nullable) and `conditions`
(`string[]` of stable codes; empty, never null). Stored as a nullable code + a JSON array column.
- **`NurseProfileDto`** and **`CustomerProfileDto`** gain `avatarUrl` (nullable). `CustomerProfileDto` also
gains `preferredLanguage` (nullable); the customer `upsert` body now accepts `firstName`/`lastName`
(persisted on the base `users` row) and `preferredLanguage`.
- **Avatar upload (multipart):** `POST api/v1/nurse_profiles/avatar` and
`POST api/v1/customer_profiles/avatar` — `multipart/form-data` field `file` (JPEG/PNG/WebP, ≤ 5 MB),
stored via `IObjectStorage`, returns `{ url }` and persists it on the profile.
@@ -0,0 +1,195 @@
# Contract — Messaging (tickets), partner centers & admin backoffice (backend phase b15)
> One-line: the ticket system (the only sanctioned post-booking channel, admin-readable, with a hard
> `is_internal` boundary), the licensed **partner centers** (sponsor / merchant-of-record → invoice issuer +
> settlement target), and the consolidated admin backoffice (support-alert worklist + audit viewer + the
> verify/refund/payout/moderation surfaces built in prior phases). 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-b15 · **Frontend consumers:** frontend-phase-14-b15 (messaging/notifications),
frontend-phase-15-b15 (admin + partner consoles)
Timestamps are UTC ISO-8601. IDs are numbers. Pagination is `page` / `pageSize` (default 50, max 100), response
`{ items, total, page, pageSize }`. All settlement money is IRR `BIGINT`; the `settlement_iban` is **never**
returned in plaintext — only a masked last-4 (`"••••0001"`).
---
## Critical rules the frontend must respect
- **`is_internal` is a hard boundary enforced at the query layer.** `GET /tickets/{id}` (the **user** view)
never contains an internal message; `GET /admin/tickets/{id}` (the **admin** view, staff only) contains them.
A non-staff caller cannot set `is_internal` on a message (→ `403`) and can never read one. Do not rely on the
UI to hide internal notes — the backend already strips them from the user payload.
- **No direct nurse↔customer channel.** All post-booking communication is ticket-mediated. Never surface a
phone number. The emergency flow (`POST /tickets/emergency`) records the *aftermath* of an out-of-platform
call; it exposes no contact.
- **Ticket ↔ booking/refund links are optional.** `bookingId` and `refundId` are both nullable — a pure support
ticket has neither.
- **`referenceCode` is stable + unique** (`"TKT-9F3K2A7Q"`), quoted to users; never mutated.
- **Merchant-of-record follows `partner_centers`.** `GET /internal/bookings/{bookingId}/center` returns
`issuingEntityType = partner_center` (+ the center id) only when the booking's nurse is sponsored by a
merchant-of-record center, else `platform`. Invoices + settlement follow this, not a hardcoded platform.
- **Admin endpoints are internal-only + RBAC-gated + audited.** Every admin state change writes an append-only
`audit_logs` row (never mutate prior rows). `support_alerts` are internal-only — never in a user response.
## Enums
- `ticket.status`: `open` | `closed`.
- `ticket.category`: `coordination` | `support` | `refund` | `emergency`.
- `ticket_participant.role_on_ticket`: `customer` | `nurse` | `admin` (display label, not an auth source).
- `support_alert.status`: `open` | `assigned` | `resolved` (forward-only).
- `support_alert.type`: `low_rating` | `evv_no_show` | `evv_location_mismatch` | `verification_expired` |
`shared_sim` | `payment_anomaly` | `fraud_signal` | `nurse_clawback` | `emergency`.
- `invoice.issuing_entity_type` (resolver output): `platform` | `partner_center`.
---
## Tickets — authenticated (participant-scoped)
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /api/v1/tickets` | open a ticket | authenticated |
| `POST /api/v1/tickets/emergency` | log an emergency ticket (+ optional alert) | assigned nurse / staff |
| `POST /api/v1/tickets/{id}/messages` | post a message | participant (staff may set `isInternal`) |
| `POST /api/v1/tickets/{id}/participants` | add a participant | staff / ticket owner |
| `DELETE /api/v1/tickets/{id}/participants/{userId}` | soft-remove a participant | staff / ticket owner |
| `POST /api/v1/tickets/{id}/close` · `/reopen` | status transitions | participant / staff |
| `GET /api/v1/tickets` | my tickets (paginated) | authenticated (own) |
| `GET /api/v1/tickets/{id}` | thread — **user view, internal stripped** | participant / staff |
### `POST /api/v1/tickets`
Request:
```json
{ "category": "support", "subject": "Reschedule", "body": "Can we move to 5pm?", "bookingId": 42, "refundId": null }
```
`bookingId`/`refundId` optional. A booking link requires the caller to be a party to the booking (staff bypass);
a refund link is staff-only. Response `data`:
```json
{ "ticketId": 12, "referenceCode": "TKT-9F3K2A7Q", "status": "open", "category": "support" }
```
### `POST /api/v1/tickets/{id}/messages`
```json
{ "body": "internal note", "isInternal": true }
```
`isInternal` defaults `false`; a non-staff caller sending `true``403`; posting to a closed ticket as a
non-staff caller → `403`. Response `data`: `{ "messageId", "ticketId", "sentAt" }`.
### `POST /api/v1/tickets/emergency`
```json
{ "bookingId": 42, "body": "Called 115; patient stable.", "raiseAlert": true }
```
Only the assigned nurse (or staff). Response is the same shape as opening a ticket (`category: "emergency"`).
### `GET /api/v1/tickets/{id}` (user) / `GET /api/v1/admin/tickets/{id}` (admin)
Response `data` (admin view shown; the user view omits internal messages):
```json
{
"id": 12, "referenceCode": "TKT-9F3K2A7Q", "subject": "Reschedule",
"status": "open", "category": "support", "bookingId": 42, "refundId": null,
"openedById": 7, "closedAt": null,
"participants": [ { "userId": 7, "roleOnTicket": "customer" }, { "userId": 3, "roleOnTicket": "admin" } ],
"messages": [ { "id": 1, "senderId": 7, "body": "…", "isInternal": false, "sentAt": "2026-07-10T…Z" } ]
}
```
A duplicate `POST …/participants` returns **409** (backed by `UNIQUE(ticket_id, user_id)`), never a 500.
## Tickets — admin (`support`/`admin`)
| Verb & route | Maps to |
| --- | --- |
| `GET /api/v1/admin/tickets` | global queue (filter `status`/`category`, search `referenceCode`, `bookingId`/`refundId`) |
| `GET /api/v1/admin/tickets/{id}` | thread — **admin view, internal included** |
---
## Partner centers — admin (`admin`/`super_admin`)
| Verb & route | Maps to |
| --- | --- |
| `POST /api/v1/admin/partner-centers` | create (inactive until verified) |
| `PATCH /api/v1/admin/partner-centers/{id}` | update (replace semantics) |
| `POST /api/v1/admin/partner-centers/{id}/verify` | record licensing approval + activate |
| `POST /api/v1/admin/partner-centers/{id}/sponsor-nurse` | set/clear `nurse_profiles.partner_center_id` |
| `GET /api/v1/admin/partner-centers` | list (no IBAN, sponsored-nurse counts) |
| `GET /api/v1/admin/partner-centers/{id}` | detail (**IBAN masked**) |
### `POST /api/v1/admin/partner-centers`
```json
{
"name": "Asanism Center", "legalEntityType": "llc", "mohEstablishmentPermitNo": "MOH-12345",
"technicalDirectorNurseUserId": null, "technicalDirectorLicenseNo": null, "enamadCode": "EN-999",
"settlementIban": "IR062960000000100324200001", "isMerchantOfRecord": true,
"commissionRate": 0.05, "adminUserId": 8
}
```
Validation: `commissionRate ∈ [0, 1)`; `settlementIban` required when `isMerchantOfRecord=true`;
`mohEstablishmentPermitNo` non-empty. Response `data` (detail):
```json
{
"id": 1, "name": "Asanism Center", "legalEntityType": "llc", "mohEstablishmentPermitNo": "MOH-12345",
"technicalDirectorNurseUserId": null, "technicalDirectorLicenseNo": null, "enamadCode": "EN-999",
"settlementIbanMasked": "••••0001", "isMerchantOfRecord": true, "commissionRate": 0.05,
"adminUserId": 8, "isActive": false, "verifiedAt": null, "sponsoredNurseCount": 0, "createdAt": "…Z"
}
```
### `POST /api/v1/admin/partner-centers/{id}/sponsor-nurse`
```json
{ "nurseProfileId": 15, "unlink": false }
```
Staff, or the center's own `adminUserId`, may sponsor within that center. `unlink: true` clears the link.
## Partner center — portal + internal resolver
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `GET /api/v1/centers/{id}/dashboard` | sponsored nurses + booking/invoice counts + masked settlement | center `adminUserId` / staff |
| `GET /api/v1/internal/bookings/{bookingId}/center` | issuer/settlement resolution | internal / admin |
`GET /internal/bookings/{bookingId}/center` response `data`:
```json
{ "bookingId": 42, "issuingEntityType": "partner_center", "partnerCenterId": 1, "partnerCenterName": "Asanism Center", "isMerchantOfRecord": true }
```
For an unsponsored / non-merchant-of-record nurse: `{ "issuingEntityType": "platform", "partnerCenterId": null, … }`.
---
## Admin backoffice (surfaced, built in prior phases)
The support-alert worklist and audit viewer existed since b1; b15 confirms them as the backoffice surface (no
rebuild). All are `[Authorize(DynamicPermission)]` (admin role passes; other staff scopes via seeded claims).
| Verb & route | Maps to | Scope |
| --- | --- | --- |
| `GET /api/v1/support_alerts/get_support_alerts` | list (filter `type`/`status`/`ownerUserId`) | `support`/`admin` |
| `POST /api/v1/support_alerts/assign_support_alert` | set owner | `support`/`admin` |
| `POST /api/v1/support_alerts/resolve_support_alert` | resolve + note | `support`/`admin` |
| `GET /api/v1/audit/get_audit_trail` | append-only audit log (filter entity/actor/date) | `super_admin`/`admin` |
| Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 |
`support_alerts` are internal-only and must never appear in a user-facing response or join.
---
## Refinement phase 3 additions (REQ-029/030/031/032/033/034/035/036/037)
**Delivered:**
- **REQ-029** `PlatformConfigDto` gains `updatedAt` + `updatedBy` (from the entity audit fields).
- **REQ-030** `GET audit/get_audit_trail` filters also by `actorId`, `action`, `from`, `to` (all optional; `entityType`/
`entityId` now optional too). **Query params bind camelCase** (`actorId`/`from`/`to`), not `actor_id`.
- **REQ-037** `tagCodes: string[]` on `ModerationQueueItemDto`. **REQ-033** `totalIrr` on `InvoiceDto`
(= platform commission + BNPL commission + VAT).
- **REQ-032** activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }`. **Route casing
pinned:** the admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an
intentional b15 divergence from the `snake_case` convention; the frontend's kebab-case client is CORRECT.
**Deferred (admin-console polish, documented in the tracker):** REQ-031 (RBAC `admin_roles/list|grant|revoke`),
REQ-032 `centers/me` + split portal reads (need the user↔center admin association REQ-038 deferred), REQ-033
center-scoped invoice list, REQ-034 verification nurse-queue/signed-url/whole-approve, REQ-035 refund admin
preview+approve/reject (the customer preview REQ-020 IS delivered), REQ-036 payout admin preview/holidayShifted/
transfer-reference.
@@ -0,0 +1,30 @@
# messaging — MERGED into `docs/integration/domains/`
> ⛔ **This headerless 851-byte fragment silently amended
> [`messaging-notifications-admin.md`](messaging-notifications-admin.md) next to it** — two live files
> describing one domain, which is contradiction **C-8**.
>
> Both are superseded. Phase 2 split the merged content three ways, along the client's actual domain
> boundaries:
>
> | Content | Now |
> | --- | --- |
> | Tickets, threads, messages, `is_internal`, the staff queue | [`docs/integration/domains/tickets.md`](../../../docs/integration/domains/tickets.md) |
> | The notification feed and unread badge | [`docs/integration/domains/notifications.md`](../../../docs/integration/domains/notifications.md) |
> | Platform config, holidays, audit, support alerts | [`docs/integration/domains/admin.md`](../../../docs/integration/domains/admin.md) |
>
> Every REQ-028 amendment below is folded into `tickets.md` as current fact, not as a change log. Phase 6
> archives this folder; until then, read it as a record.
---
## Refinement phase 3 additions (REQ-028)
- **`TicketSummaryDto`** gains `lastMessageAt` (last non-internal activity) + `unreadCount` (the caller's unread
non-internal messages from others; 0 on the admin queue). Unread is computed against the participant's
`last_read_at`, **stamped when the participant fetches the user-facing thread**.
- **`GET /tickets`** gains a `bookingId` query filter (jump to a booking's coordination ticket).
- **`POST /tickets/{id}/messages`** accepts an optional `clientMessageId` — a retried send with the same key is
deduplicated (returns the original) and the key is echoed on `PostMessageResult`.
- **Message author = role label, not a name** (confirmed intentional, privacy): the DTO carries `senderId`; the
client derives the author label from the participant role. No raw identity/name is exposed.
@@ -0,0 +1,79 @@
# Contract — Payments core: ledger, transactions, webhooks & card capture (backend phase b10)
> One-line: the inbound money rail — start a card payment against an accepted request, a PSP webhook confirms
> it, the balanced card-capture ledger group posts, and the booking confirms. 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-b10 · **Frontend consumer:** frontend-phase-f9-b10
All money is **IRR Rials, integer, on the wire as a string of digits** (`"23300000"`). The card-capture ledger
group is always **balanced** (Σ debit = Σ credit). **Internal `account_type`s are never exposed to the
customer** — the checkout UI shows gross + the commission/VAT breakdown only. Timestamps are UTC ISO-8601.
## Enums used
- `payment` status (`payment_transactions.status`): `pending` | `succeeded` | `failed`.
- `payment_gateways.type`: `standard` (card IPG) | `bnpl`.
- `payment_webhook_events.processing_status`: `received` | `processed` | `failed` | `ignored`.
- `account_type` (internal, never on the customer wire): `escrow_held` | `platform_revenue` | `nurse_payable`
| `refund_payable` | `bnpl_fee_expense` | `psp_fee_expense` | `nurse_clawback_receivable` | `bad_debt`.
b10 posts only the first three (card capture); the rest are reserved for b11/b12/b13.
## Endpoints
### `POST api/v1/bookings/{bookingRequestId}/payments`
- **Purpose:** start a card payment for an `accepted_awaiting_payment` booking request owned by the caller.
A `bookings` row exists only on capture (b9), so payment is initiated against the **request**; the amount
charged is the request's **frozen gross** (variant price × session count), never client-supplied.
- **Auth:** authenticated (the owning customer) · **Rate-limited:** yes (sensitive) · **Idempotency:** send an
**`Idempotency-Key`** header — a retried start reuses the same attempt/reference.
- **Request body:** none (the id is in the route; the key is a header).
- **Success `200` (`data`):** `{ "transactionId": 42, "redirectUrl": "https://…", "gatewayReferenceCode": "…" }`.
No ledger rows yet; the booking is not created yet.
- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` already paid /
not awaiting payment / payment window lapsed, `400` no active gateway configured.
### `POST api/v1/webhooks/payments/{provider}`
- **Purpose:** the inbound PSP/BNPL callback — verify-then-dedup-then-mutate.
- **Auth:** **none (signature-authenticated)**, anonymous to the auth pipeline · **Rate-limited:** yes (global
per-IP) · **Idempotent:** yes, at-least-once tolerant.
- **Request:** the raw provider callback body (stored verbatim in `payload_json`); signature material in headers.
- **Behaviour:** upserts `payment_webhook_events` **first** on `(provider, external_event_id)` and **no-ops on
a duplicate**; an **invalid signature** is stored `ignored` and mutates nothing; on a **new success event**
it re-verifies server-side (never trusts the callback alone), then captures — posts the balanced card-capture
group and creates/confirms the booking — all under a `lock(booking:{id}:payment)` with the DB uniques as the
authoritative backstop.
- **Success `200` (`data`):** `{ "processingStatus": "processed" | "ignored" | "failed", "duplicate": false }`
(`duplicate: true` on a replayed event).
### `GET api/v1/nurses/{nurseId}/payable_balance`
- **Purpose:** the IRR balance currently owed a nurse — the **signed sum** over `nurse_payable` ledger legs
(credit adds, debit subtracts), **derived, never a stored column**. This is what b13 payouts read.
- **Auth:** authenticated — the **nurse themself or an admin/finance role** (`403` otherwise).
- **Success `200` (`data`):** `{ "nurseId": 7, "balanceIrr": "19805000" }`.
## The card-capture ledger group (posted on webhook confirm)
One `transaction_group_id`, `amount_irr` positive with `direction` carrying the sign, Σdebit = Σcredit:
```
DEBIT escrow_held gross_price_irr (e.g. 23300000)
CREDIT platform_revenue balinyaar_commission_irr (e.g. 3495000)
CREDIT nurse_payable nurse_payout_amount (e.g. 19805000, nurse_id set)
```
## 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.
- **A booking is created & confirmed on capture** (the webhook), not on initiate — after `initiate` the
redirect is shown; the booking appears once the PSP callback confirms.
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
---
## Refinement phase 3 additions (REQ-018)
- **Invoice auto-issue on capture/settle:** the commission invoice is now issued automatically when a card
capture (`ConfirmPaymentAndPostLedger`) or a BNPL settle first creates the booking — idempotent per
booking — so a paying customer's `GET api/v1/invoices/{bookingId}` resolves right away (was admin-only).
@@ -0,0 +1,112 @@
# Contract — Payouts (backend phase b13)
> The weekly nurse-payout engine: an admin previews eligible earnings, opens a draft batch, submits it to the
> (mocked) PAYA/SATNA bank rail, retries/marks failed payouts, and reads batches; a nurse reads their own payout
> history. 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/swagger.v1.json).
**Status:** live as of backend-phase-b13 · **Frontend consumer:** frontend-phase-f12-b13
All money is IRR `BIGINT` and crosses the wire as a **digit string** (`"8500000"`). Dates are `yyyy-MM-dd`.
List query params are **camelCase** (`page`, `pageSize`, `status`, `periodStart`, `periodEnd`) — not snake_case.
The response envelope is the standard `{ data, … }`; the shapes below are the `data`.
## Enums used
- `PayoutBatchStatus`: `draft` | `processing` | `partially_failed` | `completed` | `failed` — the batch lifecycle.
A draft is materialized but unsubmitted; `partially_failed` has some paid + some failed (retryable).
- `PayoutStatus`: `pending` | `submitted` | `paid` | `failed` — the per-payout lifecycle (forward-only; `paid` is an
irreversible transfer with no outgoing edge; `failed` re-submits on retry).
## Endpoints
### `GET api/v1/admin_payouts/eligible`
- **Purpose:** Preview the payout-eligible, unpaid earnings for a window, grouped by nurse (the dry-run before a batch).
- **Auth:** admin (dynamic-permission policy) · **Rate-limited:** yes · **Idempotency key:** n/a (read).
- **Query params:** `periodStart` (date, required), `periodEnd` (date, required, ≤ today, ≥ periodStart), `page` (default 1), `pageSize` (default 20, max 100).
- **Success `200` (`data`):** `PagedResult<EligibleNurseEarningsDto>`.
- **Failure cases:** `400` periodStart > periodEnd or periodEnd in the future; `401` unauthenticated; `403` non-admin.
- **Notes:** Eligible = booking `status='completed'` AND `dispute_window_ends_at < now` AND no active refund AND not already paid. The `periodEnd` is holiday-shifted the same way a generate would shift it. A nurse without a verified primary IBAN is **flagged** (`hasVerifiedPrimaryIban=false`), not dropped. Pending clawbacks are netted into the preview.
### `POST api/v1/admin_payouts/batches`
- **Purpose:** Open a `draft` batch: select eligible bookings, materialize one payout per nurse (net of clawbacks), link each booking under the UNIQUE guard, snapshot the verified primary IBAN. **No money moves.**
- **Auth:** admin · **Rate-limited:** yes · **Idempotency:** the `booking_id` UNIQUE link makes a re-run over an overlapping window unable to re-select an already-paid booking.
- **Request body:** `{ "periodStart": "2026-06-01", "periodEnd": "2026-06-30" }`
- **Success `200` (`data`):** `GeneratePayoutBatchResult` — the draft batch, its materialized payouts, and the nurses skipped (with reasons).
- **Failure cases:** `400` invalid period; `401`/`403`; a plain failure when **no eligible bookings** in the window or **no eligible nurse has a verified primary IBAN**; `409` a concurrent run already claimed one of the bookings (the UNIQUE backstop).
- **Notes:** `period_end`/`processing_date` are shifted off bank-closed days via `IHolidayCalendar`. `total_amount = Σ net_amount_irr`, `payout_count = COUNT(payouts)`.
### `POST api/v1/admin_payouts/batches/{id}/process`
- **Purpose:** Submit a draft (or partially-failed) batch to the bank rail — the one irreversible money-out step.
- **Auth:** admin · **Rate-limited:** yes · **Idempotency key:** yes (`payout-batch:{id}`; a retried process never re-sends a paid payout or re-posts the ledger).
- **Path params:** `id` (long) — the batch id. **Body:** none.
- **Success `200` (`data`):** `ExecutePayoutBatchResult`.
- **Failure cases:** `401`/`403`; `404` batch not found; `409` the batch already `failed` (open a new one). A re-process of a `completed` batch is an idempotent `200`.
- **Notes:** Per accepted transfer it posts `DEBIT nurse_payable / CREDIT escrow_held` (paid net) and, for a netted clawback, `DEBIT nurse_payable / CREDIT nurse_clawback_receivable` + marks the `nurse_clawbacks` row `recovered`. Batch ends `completed` (all paid) or `partially_failed` (some failed). PAYA vs SATNA is chosen by `payout_satna_threshold_irr`.
### `POST api/v1/admin_payouts/{payoutId}/retry`
- **Purpose:** Re-submit a single `failed` payout (holiday-aware).
- **Auth:** admin · **Rate-limited:** yes · **Idempotency key:** yes (`payout:{id}:retry`).
- **Path params:** `payoutId` (long). **Body:** none.
- **Success `200` (`data`):** `true`.
- **Failure cases:** `400` a `processing_date` failure when banks are closed today, or a `channel` failure when the rail declines again; `401`/`403`; `404` payout not found; `409` the payout is not `failed`. An already-`paid` payout returns an idempotent `200`.
- **Notes:** On success it posts the ledger + nets clawbacks like the first process and re-settles the batch (`partially_failed → completed` when it was the last failure).
### `POST api/v1/admin_payouts/{payoutId}/mark_failed`
- **Purpose:** Record a reconciled bank rejection on a payout — no ledger movement (no money left).
- **Auth:** admin · **Rate-limited:** yes.
- **Path params:** `payoutId` (long). **Request body:** `{ "failureReason": "invalid_sheba" }`
- **Success `200` (`data`):** `true`.
- **Failure cases:** `400` empty reason; `401`/`403`; `404` not found; `409` the payout is `paid` (a confirmed transfer can't be failed). An already-`failed` payout is an idempotent `200`.
### `GET api/v1/admin_payouts/batches/{id}`
- **Purpose:** Batch header + its paginated payouts (status, net, masked IBAN, transfer reference) + the bookings each covers.
- **Auth:** admin · **Rate-limited:** yes.
- **Path params:** `id` (long). **Query:** `page` (default 1), `pageSize` (default 50, max 200).
- **Success `200` (`data`):** `PayoutBatchDetailDto`.
- **Failure cases:** `401`/`403`; `404` not found.
### `GET api/v1/admin_payouts/batches`
- **Purpose:** Admin reconciliation list of batches.
- **Auth:** admin · **Rate-limited:** yes.
- **Query:** `status` (optional `PayoutBatchStatus`), `page` (default 1), `pageSize` (default 20, max 100).
- **Success `200` (`data`):** `PagedResult<PayoutBatchDto>`.
### `GET api/v1/nurse_payouts/history`
- **Purpose:** The signed-in nurse's own payouts (tenancy-scoped) — status, net, masked IBAN + transfer reference, clawback applied, the batch window.
- **Auth:** authenticated (nurse) · **Rate-limited:** no.
- **Query:** `page` (default 1), `pageSize` (default 20, max 100).
- **Success `200` (`data`):** `PagedResult<NursePayoutHistoryDto>`.
- **Failure cases:** `401` unauthenticated. A caller who is not a nurse gets an empty page (never another nurse's data).
## Shared shapes
- `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool).
- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int?, **null = system-initiated / scheduled batch** — refinement-phase-7), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime).
- `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`).
- `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string).
- `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int).
- `SkippedNurseDto`: `nurseId` (long), `nurseName` (string?), `grossEarningsIrr` (string), `reason` (string, e.g. `no_verified_primary_iban`).
- `GeneratePayoutBatchResult`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `skipped` (`SkippedNurseDto[]`).
- `ExecutePayoutBatchResult`: `batchId` (long), `status` (`PayoutBatchStatus`), `paidCount` (int), `failedCount` (int), `totalPaid` (string).
- `NursePayoutHistoryDto`: `id` (long), `batchId` (long), `status` (`PayoutStatus`), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr` (string), `maskedIban` (string), `transferReference` (string?), `paidAt` (datetime?), `periodStart`/`periodEnd` (date).
## Side effects
- **Ledger:** process/retry post balanced groups out of `nurse_payable` (payout + clawback-recovery). Never a `payout_released` boolean — paid-ness derives from a link row + the ledger.
- **One payout per booking, forever** via the `nurse_payout_booking_links.booking_id` UNIQUE.
- **Bank rail** is mocked behind `IBankTransferProvider` (PAYA/SATNA) — no real transfer.
## Changelog
- b13 — initial contract.
---
## Refinement phase 3 additions (REQ-025 — nurse earnings)
- **`GET api/v1/nurse_payouts/earnings_balance`** → `{ pendingTotalIrr, eligibleTotalIrr, paidTotalIrr,
clawbackOutstandingIrr, netPayableBalanceIrr }`. `netPayableBalanceIrr` is the **ledger-derived, SIGNED**
nurse_payable balance (may be negative = "owed back"; never clamped); `paidTotalIrr` is lifetime, not in the net.
- **`GET api/v1/nurse_payouts/earnings?state=&page=&pageSize=`** → `PagedResult<NurseEarningsItem>`; `state`
(`pending|eligible|paid|clawback_applied`) is **derived server-side** from `bookings.status` +
`dispute_window_ends_at < now` + the payout link + any clawback. Filterable by `state`.
- **`GET api/v1/nurse_payouts/{id}`** → nurse-scoped payout detail (batch window + covered bookings).
- **`NursePayoutHistoryDto`** gains `failureReason`.
@@ -0,0 +1,172 @@
# Contract — Refunds, clawbacks & invoices (backend phase b11)
> One-line: the outbound money leg — an admin reverses a captured booking payment across both fee legs (posting
> the balanced ledger reversal, forking on whether the nurse was already paid), and issues the commission
> invoice with VAT. Customers can only **read** their refund status + invoice. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b11 · **Frontend consumer:** frontend-phase-f10-b11
All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). Refunds are **admin-only**
— there is no customer refund-initiation path. Internal `account_type`s are never exposed. Timestamps are UTC
ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
## Enums used
- `refund_status` (`refunds.status`): `requested` | `approved` | `processing` | `succeeded` | `failed` | `rejected`.
A card refund goes `approved → succeeded` immediately; a BNPL/manual refund sits in `processing` until the
async customer cash-back reconciles. Forward-only.
- `refund_channel` (`refunds.refund_channel`): `psp_card` | `bnpl_revert` | `manual`. (The data-model's
`manual_bank` is stored/served as the canonical **`manual`**.)
- `clawback_status` (`nurse_clawbacks.status`): `pending` | `recovered` | `written_off`. This phase only ever
creates `pending` and supports `written_off`; `recovered` is set by b13 payout netting.
- `moadian_status` (`invoices.moadian_status`): `pending` | `submitted` | `registered` | `failed`. Mock leaves a
new invoice `pending`.
## Endpoints
### `POST api/v1/admin_refunds`
- **Purpose:** create (and immediately execute) a refund on a booking with a captured payment.
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive) · **Idempotency key:** internal
(booking + transaction + cumulative amount) — a retried channel call never double-refunds.
- **Request body:**
```json
{
"bookingId": 42,
"ticketId": null,
"refundPercentage": 1.0,
"platformFeeRefundedIrr": null,
"nursePayoutRefundedIrr": null,
"reasonCategory": "customer_request",
"reasonNotes": "shortened visit",
"adminNotes": null,
"manualBankReference": null
}
```
Supply **either** `refundPercentage` (01 fraction, pro-rata across the booking's commission/payout legs) **or**
the explicit `platformFeeRefundedIrr` + `nursePayoutRefundedIrr` legs (both together). If neither is given the
booking's b9 cancellation snapshot percentage is used. `manualBankReference` forces the `manual` channel.
- **Success `200` payload (`data`):**
```json
{
"refundId": 7,
"bookingId": 42,
"status": "succeeded",
"refundChannel": "psp_card",
"amount": "10000000",
"platformFeeRefundedIrr": "1500000",
"nursePayoutRefundedIrr": "8500000",
"expectedCustomerRefundEta": null,
"clawbackId": null
}
```
For BNPL: `status: "processing"`, `refundChannel: "bnpl_revert"`, `expectedCustomerRefundEta: "2026-08-24"`.
Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created).
- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin ·
`404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused.
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; the refund row is persisted (`approved`)
**before** the external channel executes (crash-window fix), then the balanced ledger reversal posts 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** (settled later via `confirm_settlement`, below). `ticketId` is
optional — one is auto-opened when omitted (b15), so a refund is always ticket-anchored. Notifies the customer.
### `POST api/v1/admin_refunds/{id}/confirm_settlement`
- **Purpose:** reconciliation confirmed the customer cash-back for a `processing` BNPL/manual refund — transitions
it `processing → succeeded`, stamps the settled instant, and posts the deferred `refund_payable ↔ escrow_held`
clearing in the same commit. (Also reached automatically by the BNPL provider cash-back callback.)
- **Auth:** admin · **Rate-limited:** yes (sensitive) · **Idempotent:** a replay against an already-`succeeded`
refund is a no-op success (the clearing never posts twice).
- **Request body:** none (id in the route).
- **Success `200` (`data`):** `RefundSettlement` — `{ "refundId": 7, "bookingId": 42, "status": "succeeded",
"completedAt": "2026-08-12T10:00:00Z" }`.
- **Failure:** `404` refund not found · `409` refund not in `processing` (e.g. still `approved`, already `failed`).
### `POST api/v1/admin_refunds/{id}/mark_failed`
- **Purpose:** reconciliation reported the BNPL/manual customer cash-back did **not** land — transitions the
`processing` refund to `failed`. No ledger moves (the clearing was never posted for a processing refund).
- **Auth:** admin · **Rate-limited:** yes · **Idempotent:** a replay against an already-`failed` refund is a no-op.
- **Request body:** `{ "reason": "bank_rejected" }` (optional).
- **Success `200` (`data`):** `RefundSettlement` (as above, `status: "failed"`). **Failure:** `404` · `409` not `processing`.
### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=`
- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100).
- **Auth:** admin · **Success `200` (`data`):** `PagedResult<RefundListItem>` (see shapes) — channel, decomposed
legs, status, `expectedCustomerRefundEta`, the policy snapshot.
### `POST api/v1/admin_clawbacks/{id}/write_off`
- **Purpose:** mark a `pending` nurse clawback uncollectable; posts the balancing `DEBIT bad_debt / CREDIT
nurse_clawback_receivable` correction and sets `resolved_at`.
- **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "reason": "uncollectable" }`
- **Success `200` (`data`):** `true`. **Failure:** `404` not found · `409` not pending.
### `POST api/v1/admin_invoices`
- **Purpose:** issue the booking's official commission invoice. Idempotent per booking (re-issue returns the same).
- **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "bookingId": 42 }`
- **Success `200` (`data`):** `Invoice` (see shapes) — sequential `invoiceNumber`, `vatIrr = round(commission ×
vat_rate)` on the **commission line only**, `moadianStatus: "pending"`, `moadianReferenceNumber: null`.
- **Failure:** `404` booking not found.
### `GET api/v1/refunds/{id}/status` *(customer-visible)*
- **Purpose:** the customer-facing status of **their own** refund.
- **Auth:** authenticated; **tenancy-scoped** to the booking's customer — another customer's refund is a clean `404`.
- **Success `200` (`data`):**
```json
{ "id": 7, "bookingId": 42, "status": "processing", "refundChannel": "bnpl_revert",
"amount": "10000000", "expectedCustomerRefundEta": "2026-08-24", "reference": "••••••ab12" }
```
The external reference is **masked** (last 4 only).
- **Failure:** `401` unauth · `404` not found / not the caller's.
### `GET api/v1/invoices/{booking_id}` *(customer/admin)*
- **Purpose:** the booking's invoice. **Auth:** authenticated — the owning customer or an admin (else `404`).
- **Success `200` (`data`):** `Invoice` (see shapes), with `pdfUrl` when a PDF is stored.
## Shared shapes
- `RefundListItem`: `id` (int), `bookingId` (int), `paymentTransactionId` (int), `amount` / `platformFeeRefundedIrr`
/ `nursePayoutRefundedIrr` (IRR digit-strings), `refundChannel` (enum), `status` (enum), `refundPercentage`
(decimal), `reasonCategory` (string?), `cancellationPolicyCode` (string?), `refundPercentageApplied` (decimal?),
`expectedCustomerRefundEta` (date?), `gatewayRefundReference` (string?), `externalRevertReference` (string?),
`processedAt` (datetime?), `createdAt` (datetime).
- `Invoice`: `id` (int), `bookingId` (int), `invoiceNumber` (string, unique/sequential), `issuingEntityType`
(`platform`|`partner_center`), `grossIrr` / `platformCommissionIrr` (IRR digit-strings), `bnplCommissionIrr`
(digit-string?), `vatRate` (decimal), `vatIrr` (IRR digit-string), `moadianReferenceNumber` (string?),
`moadianStatus` (enum?), `pdfUrl` (string?), `issuedAt` (datetime).
## Load-bearing rules the client must honour
- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math.
- **Refunds are admin-only.** The only customer-visible surface is `refunds/{id}/status` — there is no
self-service refund initiation.
- **A card refund is immediate** (`succeeded`, no ETA); **a BNPL refund is `processing`** with an
`expectedCustomerRefundEta` ~710 business days out — surface it as "on its way, ~N days".
- **VAT is on the platform commission only** — never the nurse payout.
- **External references are masked** in the customer status view.
## Changelog
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
- refinement-phase-6 — added `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` (the BNPL/manual
`processing → succeeded/failed` settlement, `RefundSettlement` shape), so the deferred `refund_payable ↔
escrow_held` clearing is now reachable. Refunds are persisted before the channel call (crash-window fix). The
`refund_ticket_required` gate was retired (a refund ticket is always auto-opened). Forward-dep FKs added on
`refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`, `invoices.partner_center_id`.
---
## Refinement phase 3 additions (REQ-019/020/021 — customer refunds)
- **`POST api/v1/bookings/{id}/cancel`** (customer) — cancels the booking (freezing the policy snapshot) AND
opens its refund in one call → `RefundStatusDto`. Body `{ reasonCategory, reasonNotes?, sessionIds? }`
(MVP cancels all un-started sessions; `sessionIds` is accepted for forward-compat).
- **`GET api/v1/bookings/{id}/cancellation_policy`** (customer) — pre-cancel disclosure: resolves the
applicable policy by **current** lead time + per-session refundability →
`{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage, refundAmountIrr,
feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr, appliesTo, leadTimeLabel,
refundChannel, expectedCustomerRefundEta (null in preview), sessions: [{ bookingSessionId, sessionIndex,
scheduledDate, refundable, reasonCode }] }`. `refundAmountIrr + feeAmountIrr = refundableAmountIrr`.
- **`GET api/v1/refunds/by_booking/{bookingId}`** (customer) — the booking's latest refund status (404 if none).
- **`RefundStatusDto`** gains `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`,
`cancellationPolicyCode`, `createdAt`, `completedAt` (the fee-leg transparency split).
- **Canonical `cancellation_policy_code` set** (seeded, stable — the frontend's `free_24h`/`partial_under_24h`/
`customer_no_show` were invented): **`standard_24h`** (customer ≥24h → full refund), **`standard_inside_24h`**
(customer <24h → partial), **`nurse_no_show`** (nurse-initiated → full refund + penalty), **`admin_cancellation`**
(admin → full refund). Per-session `reasonCode`: **`un_started`** when refundable, else the blocking session status.
@@ -0,0 +1,137 @@
# Contract — Reviews & Patient Care Records (backend phase b14)
> The trust-and-continuity surface: a customer leaves **one moderated review per completed booking**; an
> admin/moderator transitions it (recomputing the nurse's public rating from source on every transition); the
> public reads only ever see published reviews + the aggregate; and nurses author **encrypted, patient-scoped**
> clinical notes readable only under a strict clinical-access rule. 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/swagger.v1.json).
**Status:** live as of backend-phase-b14 · **Frontend consumer:** frontend-phase-f13-b14
There is **no money** in this domain. Ratings are integers 15; the aggregate `averageRating` is a decimal
(2-dp, e.g. `4.5`). Timestamps are ISO-8601. List query params are **camelCase** (`page`, `pageSize`,
`status`). The response envelope is the standard `{ data, … }`; the shapes below are the `data`.
## Enums used
- `moderationStatus`: `pending_moderation` | `published` | `hidden` | `rejected`. A review is born
`pending_moderation` and is **never public / never counted** until `published`. Only `published` reviews are
returned by any public read and only `published` reviews feed the aggregate.
- Moderation **action** (the `PATCH` body): `publish` | `hide` | `reject` | `unpublish`. `hide`/`reject`
require a `reason`; `unpublish` returns a published review to `pending_moderation`.
- Review **tag codes** (seeded vocabulary): `punctual` | `professional` | `clean` | `kind` | `communicative`.
## Reviews
### `POST api/v1/bookings/{bookingId}/review`
- **Purpose:** The customer submits the one review for a completed booking.
- **Auth:** authenticated customer who **owns** the booking (tenancy enforced in the handler).
- **Path params:** `bookingId` (long).
- **Request body:** `{ "rating": 5, "body": "great care", "tagCodes": ["punctual","kind"] }``rating` 15
required; `body` optional (≤ 2000); `tagCodes` optional (validated against the active vocabulary).
- **Success `200` (`data`):** `SubmitReviewResult``{ id, moderationStatus, lowRatingAlertRaised }`. The status
is `pending_moderation` by default (the AI pre-screen keeps clean text pending; a banned-word hit auto-hides).
- **Failure cases:** `400` rating out of 15 / unknown tag code; `401` unauthenticated; `403` caller is not a
customer; `404` booking not found **or not owned** (a cross-tenant booking is a not-found, never a leak); a
plain failure when the booking is **not completed/closed**; `409` the booking is **already reviewed** (1:1).
- **Notes:** A rating **`min_rating_for_support_alert`** (config, default 2) raises an internal `low_rating`
`support_alert` (never surfaced on any user response). The new review does **not** appear in the public list
until published.
### `POST api/v1/reviews/{reviewId}/tags`
- **Purpose:** Replace a review's standardized tags with exactly the requested set.
- **Auth:** the review's **author** or a moderator (admin/super_admin/moderation) — enforced in the handler.
- **Path params:** `reviewId` (long). **Request body:** `{ "tagCodes": ["punctual","professional"] }`.
- **Success `200` (`data`):** `ReviewTagsResult``{ reviewId, tagCodes }`.
- **Failure cases:** `400` unknown tag code; `401`; `403` not the author and not a moderator; `404` review not
found. The `UNIQUE(reviewId, tagCode)` forbids a duplicate tag (the set is de-duplicated server-side).
### `PATCH api/v1/reviews/{reviewId}/status`
- **Purpose:** Admin/moderator moderation transition (the human decision authority; always overrides the AI).
- **Auth:** admin / moderator (dynamic-permission policy).
- **Path params:** `reviewId` (long). **Request body:** `{ "action": "publish", "reason": null }``hide`/`reject`
require a non-empty `reason` (≤ 500).
- **Success `200` (`data`):** `ModerateReviewResult``{ id, moderationStatus, averageRating, totalReviews }`
(the recomputed-from-source nurse aggregate).
- **Failure cases:** `400` unknown action / missing reason on hide|reject; `401`; `403` non-admin; `404` review
not found.
- **Notes:** **Every** transition recomputes `nurse_profiles.averageRating`/`totalReviews` from the nurse's
currently-`published` reviews (not an incremental delta) and refreshes the search index — in the **same
transaction** as the status change — so hiding a low rating lowers the count and re-derives the average.
### `GET api/v1/nurses/{nurseProfileId}/reviews` — public
- **Purpose:** The public reviews for a nurse: the rating aggregate + a page of **published** reviews.
- **Auth:** anonymous. **Path params:** `nurseProfileId` (long). **Query:** `page` (default 1), `pageSize`
(default 20, max 100).
- **Success `200` (`data`):** `NurseReviewsResult` — `{ aggregate: { averageRating, publishedCount },
reviews: PagedResult<ReviewListItemDto> }`. `ReviewListItemDto` = `{ id, rating, body, tagCodes[], createdAt }`
— **never** carries moderation internals. An unknown nurse returns a zero aggregate + empty page (`200`).
- **Notes:** The publish gate is enforced at the query layer — a `pending_moderation`/`hidden`/`rejected` review
is never returned and never counted. The aggregate is cached and invalidated on every transition.
### `GET api/v1/nurses/{nurseProfileId}/review_tags` — public
- **Purpose:** The per-nurse tag rollup ("% punctual") over published reviews.
- **Auth:** anonymous. **Path params:** `nurseProfileId` (long).
- **Success `200` (`data`):** `NurseTagAggregatesResult` — `{ publishedReviewCount, tags: TagAggregateDto[] }`
where `TagAggregateDto` = `{ code, labelFa, labelEn, count, percentage }` (percentage of published reviews, 1-dp).
The active seeded vocabulary is always returned (zero counts for a nurse with no published reviews).
### `GET api/v1/admin/reviews/moderation_queue` — admin
- **Purpose:** The moderation worklist.
- **Auth:** admin / moderator. **Query:** `status` (default `pending_moderation`; any `moderationStatus`),
`page`, `pageSize`.
- **Success `200` (`data`):** `PagedResult<ModerationQueueItemDto>` — `{ id, bookingId, nurseProfileId,
customerProfileId, rating, body, moderationStatus, moderationReason, lowRatingAlertId, createdAt }`. The
`lowRatingAlertId` is the linked internal alert (id only — support alerts stay internal).
- **Failure cases:** `400` unknown status; `401`; `403` non-admin.
## Patient care records
Clinical bodies are **encrypted at rest** and returned **decrypted only after the access check passes**. The
access rule is enforced in the handler, not just the route policy.
**Access matrix** (both endpoints):
| Caller | Write | Read |
| --- | --- | --- |
| Nurse with a **confirmed** (or in-progress/completed/disputed/closed) booking for the patient | ✅ | ✅ |
| Nurse **without** such a booking | ❌ `403` | ❌ `403` |
| The patient's **owning customer** | ❌ (only nurses author) | ✅ |
| Admin / super_admin | ❌ (only nurses author) | ✅ |
| Anyone else | ❌ | ❌ `403` |
### `POST api/v1/patients/{patientId}/care_records`
- **Purpose:** A nurse authors a clinical note for a patient (patient-scoped; the note is encrypted before persist).
- **Auth:** authenticated nurse with a qualifying booking for the patient.
- **Path params:** `patientId` (long). **Request body:** `{ "bookingId": 123, "body": "…" }` — `bookingId`
optional provenance (which visit produced the note); `body` required (≤ 8000).
- **Success `200` (`data`):** `WriteCareRecordResult` — `{ id, patientId, recordedAt }`.
- **Failure cases:** `401`; `403` caller is not a nurse **or** has no qualifying booking for the patient; `404`
patient not found; `400` empty body.
### `GET api/v1/patients/{patientId}/care_records`
- **Purpose:** The patient-scoped longitudinal history, newest first.
- **Auth:** owning customer / nurse with a qualifying booking / admin (see the matrix).
- **Path params:** `patientId` (long). **Query:** `page`, `pageSize`.
- **Success `200` (`data`):** `PagedResult<CareRecordDto>` — `CareRecordDto` = `{ id, patientId, bookingId,
nurseProfileId, nurseName, body, recordedAt }` (`body` decrypted). Ordered by `recordedAt DESC`.
- **Failure cases:** `401`; `403` no clinical access; `404` patient not found.
- **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole
history (not just their own booking's notes).
---
## Refinement phase 3 additions (REQ-026/027)
- **`GET api/v1/bookings/{bookingId}/review_eligibility`** → `{ canReview, reason?:
not_completed|already_reviewed|not_owner|not_found }`.
- **`GET api/v1/bookings/{bookingId}/my_review`** → `{ moderationStatus:
pending_moderation|published|hidden|rejected|none, rating?, body?, tagCodes[], createdAt? }`. Masked-author
omission on the public list is **intentional** (privacy).
- **Family-owned care plan (new entity `usr.PatientCarePlans`):** `GET/PUT api/v1/patients/{patientId}/care_record`
→ `{ patientId, medications:[{id,name,dosage?,frequency,timingNote?}], routine:[{id,label,timeOfDay?,note?}],
tasks:[{id,label,done}] }`. Read = owner/nurse-with-booking/admin; write = owning customer only.
- **`GET api/v1/patients/{patientId}/record_access`** → `{ canView, canEdit, canAppendNote, deniedReason? }`
(always 200; non-leaking `not_found`/`not_authorized`).
- **Structured `taskResults`** (`[{ label, done }]`) added to the visit-note write body + the history DTO.
@@ -0,0 +1,117 @@
# Contract — Nurse search & matching (backend phase b7)
> The single public nurse-discovery endpoint (category + city/district geo, same-gender filter, price range,
> rating sort, paginated) plus the admin search-index rebuild. Reads a denormalized, maintained-on-write
> projection and returns **only searchable (verified + accepting + not-suspended + active) nurses**. 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) (refreshed for b7).
**Status:** live as of backend-phase-b7 · **Frontend consumer:** frontend-phase-f6-b7
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased). All responses use the
> standard `{ succeeded, statusCode, data }` envelope; `data` shapes are below. Query parameters are
> **snake_case** (`service_category_id`, `city_id`, …).
## Key semantics (read first)
- **Only `is_searchable = 1` rows are ever returned.** A row is searchable **only** when the nurse is
`is_verified` AND not suspended AND `is_accepting_bookings` AND the variant `is_active`. An unverified,
paused, suspended, or deactivated nurse/variant never appears — this is the phase's highest-stakes rule.
- **The result unit is the variant, not the nurse.** Each hit is a bookable `nurse_service_variant` matched
in a covered area; a nurse with multiple variants/areas can appear as multiple hits.
- **`district_id = null` ⇒ whole city**, both directions:
- A **city-only** search (no `district_id`) matches every row in the city — both the whole-city (NULL) rows
and every district row.
- A **district** search matches that district's rows **plus** the whole-city (NULL) rows (a whole-city
nurse covers every district).
- **Same-gender matching is a first-class facet.** `nurse_gender` (`male`/`female`) is an up-front filter;
it is never silently defaulted or dropped. (Carrying the chosen gender *into* the booking request —
`booking_requests.required_caregiver_gender` — lands in b8.)
- **Money is IRR `BIGINT`.** `price` in results is a **digit string** (`"500000"`); `min_price`/`max_price`
filters are integers. No floats anywhere.
- **Rating sort only (MVP).** Results are ordered by `averageRating` desc, tiebroken by `totalReviews` desc
then `nurseId`/`variantId` so paging is deterministic.
- **Availability is not a filter.** Availability slots are soft guidance; they never hard-filter search (b7).
## Enums used
- `nurse_gender`: `male` | `female`.
- `price_unit`: `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h` (copied from the variant).
## Endpoints
### `GET api/v1/search/nurses`
- **Purpose:** the single family-facing discovery query over the maintained search index.
- **Auth:** none (public, pre-auth discovery) · **Rate-limited:** yes (per-IP global limiter) · **Idempotency key:** no
- **Query params:**
- `service_category_id` (long, **required**) — the primary search dimension.
- `city_id` (long, **required**).
- `district_id` (long, optional) — omit for a whole-city search; see geography rule above.
- `nurse_gender` (`male`|`female`, optional) — the same-gender facet.
- `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`.
- `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`).
- `page` (int, default 1), `pageSize` (int, default 50, max 100).
- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):**
```json
{
"items": [
{
"variantId": 12,
"nurseId": 5,
"serviceCategoryId": 1,
"price": "8000000",
"priceUnit": "per_24h",
"nurseGender": "female",
"averageRating": 4.8,
"totalReviews": 9,
"totalCompletedBookings": 12,
"cityId": 101,
"districtId": 1003
}
],
"total": 1,
"page": 1,
"pageSize": 50
}
```
- **Failure cases:** `400` — `service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`,
`min_price > max_price`, invalid `price_unit`, or `pageSize > 100`.
- **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers
the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan.
### `POST api/v1/admin_search/rebuild_index`
- **Purpose:** idempotent full rebuild of the search index from source — the convergence/reconciliation path
(first-launch / nightly / after a bulk data fix).
- **Auth:** admin (dynamic-permission policy) · **Rate-limited:** yes (`sensitive`) · **Idempotency key:** no
- **Request body:** none.
- **Success `200` payload (`data` = `SearchIndexRebuildResult`):**
```json
{ "nursesProcessed": 128, "rowsWritten": 342 }
```
- **Failure cases:** `401` unauthenticated · `403` non-admin.
- **Notes:** truncates and repopulates the whole index in nurse-batches; the rebuilt index's live/searchable
rows must match the incrementally-maintained state (no duplicate variant×area rows). Writes an audit-log row.
## Shared shapes
- `NurseSearchResultDto`: `variantId` (long), `nurseId` (long), `serviceCategoryId` (long),
`price` (string, IRR digits), `priceUnit` (enum), `nurseGender` (`male`/`female`), `averageRating` (decimal),
`totalReviews` (int), `totalCompletedBookings` (int), `cityId` (long), `districtId` (long?, null = whole city).
- `SearchIndexRebuildResult`: `nursesProcessed` (int), `rowsWritten` (int).
## Backend seam (not a wire shape)
- **`INurseSearch`** — the search-service seam. MVP impl `SqlNurseSearch` (real, over `nurse_search_index`).
Config key `Search:Backend` (default `sql`); a later `ElasticNurseSearch` is a config-selected drop-in.
## Changelog
- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`.
---
## Refinement phase 3 additions (REQ-012)
- **`NurseSearchResultDto`** gains `nurseName` + `avatarUrl` (denormalized onto `nurse_search_index`, so no
per-row join) and `distanceKm` (nullable — the covering index carries no coordinate, so it is null today).
- **`GET api/v1/nurses/{id}/profile`** (public) — the aggregated discovery detail:
`{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews,
totalCompletedBookings, isVerified, inoMembership, attributeChips[], services: [{ variantId, displayName,
priceIrr, priceUnit, sessionCount? }], latestReview?: { rating, body, authorMasked (null by design),
createdAt } }`. No encrypted credential number is ever exposed.
@@ -0,0 +1,269 @@
# Contract — Nurse verification & credentials (backend phase b6)
> The trust engine: a data-driven verification pipeline (checklist of steps), the admin review queue, the
> structured credential registry, the transactional `nurse_profiles.is_verified` flip, the admin-triggered
> credential-expiry scan, and the public trust badge. 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) (refreshed for b6 — all 15 endpoints).
**Status:** live as of backend-phase-b6 · **Frontend consumer:** frontend-phase-f5-b6 (public trust badge → f6)
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the codebase
> convention and the dynamic-permission key scheme — e.g. `POST api/v1/nurse_verification/submit`,
> `POST api/v1/admin_verifications/steps/{stepId}/decide`. Mutations use **POST**; ids come from the
> **route**, never the body. All responses use the standard `ApiResult<T>` envelope (payload under `data`);
> JSON bodies/fields are **camelCase**.
## Enums used
- `verification_status` (`nurse_verifications.status` — the aggregate): `not_started` | `pending` |
`in_review` | `approved` | `rejected` | `suspended`. **This is the single source of verification truth.**
- `verification_step_status` (`verification_steps.status`): `not_started` | `pending` | `in_review` |
`passed` | `failed` | `expired`.
- `step_type_code` (the six seeded, **stable** codes): `identity_kyc` | `shahkar_match` |
`moh_competency_license` | `ino_membership` | `criminal_record` | `bank_account_verification`.
- `credential_type`: `moh_competency_license` | `ino_membership` | `criminal_record` — the three
credential-bearing steps that record a `nurse_credentials` row on approval.
- `verification_method` (how a credential was verified): `manual` | `portal` | `api`. Today every real
credential resolves `manual` (admin review); `api` is reserved for a future MoH/INO portal lookup.
## Key semantics (read first)
- **`nurse_verifications.status` is the SINGLE source of verification truth.** `nurse_profiles.is_verified`
is the **only derived boolean** and is flipped **only inside the finalize transaction** (a re-aggregate
after a step decision) or reversed inside a suspend/expiry transaction. The client never sets or infers
`is_verified` — it reads `isBookable` / `isVerified` off the API.
- **A nurse becomes bookable only when the aggregate reaches `approved`.** `VerificationStatusDto.isBookable`
is the flag to gate the nurse UI on; `blockingSteps` names what still stands in the way.
- **Steps are automated or manual.** Automated steps (`isAutomated:true``identity_kyc`, `shahkar_match`,
`bank_account_verification`) run via a `/run` endpoint against a mocked vendor seam. Manual steps
(`moh_competency_license`, `ino_membership`, `criminal_record`) take a document upload and wait for an
admin decision.
- **Credentials never leak their number.** `nurse_credentials.credential_number` is **encrypted at rest and
NEVER serialized** on any DTO. The public **trust badge exposes credential TYPES only**, never numbers.
- **Identity is cross-checked.** A credential's `holderName` is checked against the verified identity name;
a mismatch → **400** and **no credential is recorded**. Bank verification enforces a **money-mule guard**:
the IBAN holder's national id must equal the verified nurse national id.
- **Deactivate step-types, never delete.** `DELETE` on a step-type sets `is_active=false`; a step-type
`code` is **immutable once in use**; a duplicate `code`**409**.
- **Expiry is admin-triggered for now.** Time-limited steps (e.g. `criminal_record`) lapse to `expired`;
`scan_expiring` is the manual entry point that reverts them and re-gates bookability. The scheduled cron is
**deferred** (config key `verification_expiry_scan_cadence_hours`, int, default `24`).
- **All vendor/money calls are mocked** behind DI seams (deterministic) — no real KYC, Shahkar, credential,
or bank call happens. See [Mocks](#mocks).
## Nurse verification — `NurseVerificationController` (`[Authorize]`; nurse role enforced in handler; tenancy-scoped to the signed-in nurse)
### `POST api/v1/nurse_verification/submit`
- **Purpose:** open (or re-open) the nurse's verification and seed the checklist.
- **Body:** none.
- **`data`:** `VerificationStatusDto`.
- **Notes:** upserts the `nurse_verifications` header and **seeds one `verification_step` per active required
step-type** (snapshotting `is_automated` at seed time). **Idempotent** — never duplicates a step; adds only
newly-required ones on a re-submit. `400` if the caller has no nurse profile; `401` unauthenticated;
`403` non-nurse.
### `GET api/v1/nurse_verification`
- **Purpose:** the nurse's own checklist + aggregate status + blocking summary.
- **`data`:** `VerificationStatusDto``status`, `isBookable`, `blockingSteps` (step codes still blocking),
`steps[]`. Returns a **`not_started` empty checklist** if the nurse never submitted (not a 404).
### `POST api/v1/nurse_verification/steps/{stepId}/upload_url`
- **Purpose:** get a signed PUT URL for a manual step's document.
- **Body:** `{ contentType, fileName? }`.
- **`data`:** `UploadUrlResult` (`objectStorageKey`, `uploadUrl`). **Manual (non-automated) steps only.**
Echo `objectStorageKey` back on confirm. `400` on an automated step / bad content type; `404` if the step
isn't the caller's.
### `POST api/v1/nurse_verification/steps/{stepId}/documents`
- **Purpose:** confirm an uploaded document and move the manual step to `in_review`.
- **Body:** `{ objectStorageKey, integrityHash, contentType, fileSizeBytes, originalFileName? }`.
- **`data`:** `DocumentConfirmedResult` (`documentId`, `stepStatus`).
- **Notes:** persists a `verification_documents` **metadata row only** (bytes never touch the DB) and moves
the manual step to `in_review`. `404` if the step isn't the caller's.
### `POST api/v1/nurse_verification/steps/identity_kyc/run`
- **Purpose:** run the automated national-ID + liveness check.
- **Body:** `{ nationalId (10 digits), livenessPayload? }`.
- **`data`:** `RunStepResult` (`stepId`, `stepStatus`, `failureReason?`).
- **Side effects:** on **pass** populates `users.national_id` + `users.national_id_verified_at`. `400` on a
malformed national id; a vendor fail comes back as `stepStatus:"failed"` + `failureReason` (still `200`).
### `POST api/v1/nurse_verification/steps/shahkar_match/run`
- **Purpose:** run the phone↔national-id Shahkar match.
- **Body:** none.
- **`data`:** `RunStepResult`.
- **Notes:** **requires identity KYC passed** (a verified national id must be present) → else `400`.
**Shared-SIM is an explicit handled failure** — it fails the step and raises a `shared_sim` **support
alert**. On **pass** sets `users.shahkar_verified_at`.
### `POST api/v1/nurse_verification/steps/bank_account_verification/run`
- **Purpose:** run the استعلام شبا IBAN-owner ↔ national-id match (money-mule guard).
- **Body:** none.
- **`data`:** `RunStepResult`.
- **Notes:** requires KYC passed **and** a **primary `nurse_bank_accounts` row** → else `400`. Reuses the b3
`IBankAccountOwnershipVerifier`; the **holder national id must equal the verified nurse national id**. On
match sets the account's `matched_national_id=1` (the b13 first-payout gate).
## Admin step-type catalog — `AdminVerificationStepTypesController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
### `GET api/v1/admin_verification_step_types?includeInactive={bool}`
- **`data`:** `IReadOnlyList<VerificationStepTypeDto>`. **Cached** (generation-token; any write invalidates).
`includeInactive=false` (default) hides deactivated step-types.
### `POST api/v1/admin_verification_step_types`
- **Purpose:** create or update a step-type (upsert on `id`).
- **Body:** `{ id?, code, displayName, description?, isRequired, isAutomated, automationProvider?, sortOrder, isActive }`.
`id` **null → create**; else update.
- **`data`:** `VerificationStepTypeDto`.
- **Notes:** `code` must be **snake_case** (`[a-z][a-z0-9_]*`) and is **immutable once the step-type is in
use**. `400` invalid code/labels; `404` unknown `id` on update; **`409`** duplicate `code`.
### `DELETE api/v1/admin_verification_step_types/{id}`
- **Purpose:** **deactivate** a step-type (`is_active=false`) — **never a hard delete**.
- **`data`:** `bool` (success). `404` unknown id.
## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
### `GET api/v1/admin_verifications?status=&page=&pageSize=`
- **Purpose:** the review queue — one row per step awaiting attention.
- **Params:** `status` (default `in_review`) + pagination `page`/`pageSize`.
- **`data`:** `PagedResult<AdminPendingStepDto>`. Documents carry **signed GET URLs**.
### `GET api/v1/admin_verifications/{nurseVerificationId}`
- **Purpose:** the full case for a nurse.
- **`data`:** `AdminVerificationDetailDto` — all steps + their documents (signed URLs) + credentials + the
**identity name** for cross-check. `404` if the verification doesn't exist.
### `POST api/v1/admin_verifications/steps/{stepId}/decide`
- **Purpose:** approve or reject a manual step (and, on a credential-bearing step, record the credential).
- **Body:** `{ approve, rejectionReason?, credentialNumber?, holderName?, issuingAuthority?, issuedAt?, expiresAt?, verificationSource? }`
`rejectionReason` **required when `approve=false`**.
- **`data`:** `ReviewStepResult` (`stepId`, `stepStatus`, `credentialId?`).
- **Notes:** **manual steps only.** On **approving a credential-bearing step**
(`moh_competency_license` / `ino_membership` / `criminal_record`) it records a `nurse_credentials` row —
`credential_number` **ENCRYPTED** (never serialized); `holderName` **cross-checked** against the verified
identity name (**mismatch → 400, no credential recorded**); **`criminal_record` requires `expiresAt`**.
Writes an `audit_logs` decision record, then **re-aggregates** the verification (**may flip
`is_verified`**). `400` missing `rejectionReason` / holder-name mismatch / missing required `expiresAt`;
`404` step not found.
### `POST api/v1/admin_verifications/{nurseVerificationId}/suspend`
- **Purpose:** suspend a verified nurse.
- **Body:** `{ reason }`.
- **`data`:** `bool`.
- **Notes:** sets `status=suspended` and **reverses `is_verified=0` in the same transaction**. Writes an
`audit_logs` record. `404` unknown verification.
### `POST api/v1/admin_verifications/scan_expiring`
- **Purpose:** the admin-triggered credential-expiry scan (the cron entry point until the scheduler ships).
- **Body:** `{ page?, pageSize? }`.
- **`data`:** `ScanExpiringResult` (`scannedSteps`, `revertedNurses`).
- **Notes:** reverts lapsed time-limited steps to `expired`, raises a `verification_expired` **support
alert** + a `verification_expiry_prompt` **notification**, and **re-gates bookability**. The scheduled cron
is **deferred** (config `verification_expiry_scan_cadence_hours`, int, default `24`).
## Public trust badge — `NursesController` (`[AllowAnonymous]`)
### `GET api/v1/nurses/{nurseId}/trust_badge`
- **Purpose:** the public trust signal for a nurse.
- **`data`:** `TrustBadgeDto``isVerified`, `approvedAt?`, and the **credential TYPES held** (never the
numbers). **Cached** (short TTL; evicted on suspension / expiry / a step decision). `404` for an unknown
nurse.
## Shared shapes
_(records; camelCase on the wire; `?` = nullable; `credential_number` is never present)_
- `VerificationStepTypeDto`: `id` (long), `code` (string), `displayName` (string), `description` (string?),
`isRequired` (bool), `isAutomated` (bool), `automationProvider` (string?), `sortOrder` (int),
`isActive` (bool).
- `VerificationStepDto`: `id` (long), `code` (string), `displayName` (string), `status` (enum), `isAutomated`
(bool), `expiresAt` (datetime?), `failureReason` (string?).
- `VerificationStatusDto`: `status` (enum), `isBookable` (bool), `blockingSteps` (string[] — step codes),
`steps` (`VerificationStepDto[]`).
- `VerificationDocumentDto`: `id` (long), `contentType` (string), `fileSizeBytes` (long), `originalFileName`
(string?), `url` (string — a **short-lived signed** URL).
- `NurseCredentialDto`: `id` (long), `credentialType` (enum), `holderNameSnapshot` (string),
`issuingAuthority` (string), `issuedAt` (date?), `expiresAt` (date?), `verificationMethod` (enum).
**`credential_number` is NEVER serialized.**
- `TrustBadgeDto`: `nurseId` (long), `isVerified` (bool), `approvedAt` (datetime?), `credentialTypes`
(string[] — credential **types** only).
- `AdminPendingStepDto`: `nurseVerificationId` (long), `nurseId` (long), `nurseName` (string), `stepId`
(long), `stepCode` (string), `stepDisplayName` (string), `status` (enum), `submittedAt` (datetime?),
`documents` (`VerificationDocumentDto[]`).
- `AdminStepDetailDto`: `stepId` (long), `code` (string), `displayName` (string), `status` (enum),
`isAutomated` (bool), `expiresAt` (datetime?), `failureReason` (string?), `documents`
(`VerificationDocumentDto[]`).
- `AdminVerificationDetailDto`: `nurseVerificationId` (long), `nurseId` (long), `identityName` (string),
`status` (enum), `steps` (`AdminStepDetailDto[]`), `credentials` (`NurseCredentialDto[]`).
- `UploadUrlResult`: `objectStorageKey` (string), `uploadUrl` (string).
- `DocumentConfirmedResult`: `documentId` (long), `stepStatus` (enum).
- `RunStepResult`: `stepId` (long), `stepStatus` (enum), `failureReason` (string?).
- `ReviewStepResult`: `stepId` (long), `stepStatus` (enum), `credentialId` (long?).
- `ScanExpiringResult`: `scannedSteps` (int), `revertedNurses` (int).
- `PagedResult<T>`: `items` (`T[]`), `total` (int), `page` (int), `pageSize` (int).
## Side effects to know
The finalize/reverse of `nurse_profiles.is_verified` (one transaction) · `users.national_id` population ·
`users.shahkar_verified_at` · `nurse_bank_accounts.matched_national_id` · `support_alerts` (`shared_sim`,
`verification_expired`) · `notifications` (`verification_expiry_prompt`) · `audit_logs` decision records.
## Mocks
All vendor/money calls are **mocked behind DI seams** (deterministic — use the test values below). See
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md).
- `IShahkarVerifier``MockShahkarVerifier`: **pass** unless the configured shared-SIM phone `09120000000`
(→ shared-SIM handled failure) or the mismatch national id `1111111111`.
- `IIdentityKycProvider``MockIdentityKycProvider`: passes any well-formed 10-digit national id **except**
the configured fail id `0000000000`.
- `ICredentialVerifier``MockCredentialVerifier`: the manual-admin default — always `RequiresManualReview`
/ `verification_method=manual`.
- **Reused:** `IBankAccountOwnershipVerifier` (b3; mismatch IBAN `IR000000000000000000000000` → mismatch),
`IObjectStorage` (b0; local-disk, signed PUT/GET URLs), `IFieldEncryptor` (b0; encrypts
`credential_number`).
## Example — a nurse gets verified
```
# 1) open the checklist
POST /api/v1/nurse_verification/submit -> data.steps seeded (one per active required step-type)
GET /api/v1/nurse_verification -> { status: "pending", isBookable: false, blockingSteps: [...] }
# 2) automated identity + shahkar
POST /api/v1/nurse_verification/steps/identity_kyc/run { "nationalId": "1234567891" } -> stepStatus "passed"
POST /api/v1/nurse_verification/steps/shahkar_match/run -> stepStatus "passed"
# (a 09120000000 SIM would come back "failed" + raise a shared_sim support alert)
# 3) manual credential (e.g. MoH license): upload then wait for admin
POST /api/v1/nurse_verification/steps/{stepId}/upload_url { "contentType": "application/pdf" }
-> { objectStorageKey, uploadUrl } # PUT the bytes to uploadUrl
POST /api/v1/nurse_verification/steps/{stepId}/documents { objectStorageKey, integrityHash, contentType, fileSizeBytes }
-> step -> in_review
# 4) admin decides -> records the (encrypted) credential, re-aggregates, may flip is_verified
POST /api/v1/admin_verifications/steps/{stepId}/decide
{ "approve": true, "credentialNumber": "…", "holderName": "<verified identity name>", "issuingAuthority": "MoH", "issuedAt": "2026-01-01" }
-> { credentialId }
# holderName != verified identity -> 400 (no credential recorded)
# 5) public badge (types only, never numbers)
GET /api/v1/nurses/{nurseId}/trust_badge -> { isVerified: true, approvedAt, credentialTypes: ["moh_competency_license"] }
```
## Changelog
- b6 — initial contract: nurse verification checklist (submit/get/upload/confirm/run), automated
identity-KYC / Shahkar / bank-ownership runs, admin step-type catalog (CRUD + deactivate),
admin review queue (list/detail/decide/suspend/scan-expiring), public trust badge;
`verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums;
transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor
seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred.
---
## Refinement phase 3 additions (REQ-011)
- **`VerificationStepDto`** gains `isRequired` (mirrors the step-type catalog; an optional step never blocks
bookability).
- **`POST api/v1/nurse_verification/credential_details`** (nurse) — captures the structured credential
fields collected with the uploads: `{ inoNumber (required), specialties: string[], licenseNumber?,
issuingAuthority?, holderName?, issuedAt?, expiresAt? }``VerificationStatusDto`. Upserts an
`ino_membership` (and, if a license number is sent, `moh_competency_license`) `nurse_credentials` row
(unverified — admin still decides) and persists `specialties` on the profile. The INO number is encrypted.