doc clean up phase 2
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# addresses — customer addresses
|
||||
|
||||
> Client seam `client/src/services/addresses/` · `USE_ADDRESSES_MOCK = false` (**real**) · 5 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The customer's saved addresses. One address is primary; the rest are ordered by recency. The address a
|
||||
booking uses is **snapshotted** onto the booking, so editing an address later never rewrites history.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/customer_addresses/list` | `[Authorize]` | wired · paginated (`Page`/`PageSize`) |
|
||||
| POST | `/api/v1/customer_addresses/create` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/customer_addresses/update/{id}` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/customer_addresses/set_primary/{id}` | `[Authorize]` | wired |
|
||||
| DELETE | `/api/v1/customer_addresses/delete/{id}` | `[Authorize]` | wired · **soft delete** |
|
||||
|
||||
No phantoms. The domain maps 1:1.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`provinceId` is on `CustomerAddressDto`** (REQ-009, delivered). The client needs it to preselect the
|
||||
province in the city cascade without a reverse lookup.
|
||||
- **The client-picked map pin is accepted on create and update** (REQ-008, delivered) — the server does
|
||||
**not** re-geocode over a pin the user placed. When no pin is given, `IGeocoder` resolves one.
|
||||
- **`latitude`/`longitude` are nullable.** Null means the geocoder could not resolve the address and the
|
||||
user placed no pin; the UI shows "saved without a map pin" rather than an error. `IGeocoder`'s mock
|
||||
forces this path for any address whose text contains `NO_GEO`
|
||||
(`Seams:Geocoding:LowConfidenceMarker`).
|
||||
- **The full address line is encrypted at rest** and returned decrypted only to its owner. A *booking
|
||||
request* sees a city/district-coarse mask instead — see [booking-requests.md](booking-requests.md).
|
||||
- **`set_primary` touches two rows** (demote the old, promote the new) in one transaction. The client
|
||||
invalidates the whole list key rather than patching one item.
|
||||
- Delete is a soft delete behind the entity's global query filter; a booking that snapshotted the address
|
||||
is unaffected.
|
||||
|
||||
## Enums
|
||||
|
||||
None of its own. `provinceId` / `cityId` / `districtId` are geography ids — see
|
||||
[geography.md](geography.md), where **`districtId = null` means whole-city**.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. REQ-008 and REQ-009 were both delivered in refinement-phase-3 and are folded into the rules above.
|
||||
@@ -0,0 +1,99 @@
|
||||
# admin — platform config, holidays, audit, support alerts
|
||||
|
||||
> Client seam `client/src/services/admin/` · `USE_ADMIN_MOCK = true` (**mock is primary**) · 14 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The ops console's cross-cutting reads and writes. Domain-specific admin surfaces live with their domain —
|
||||
verification admin in [verification.md](verification.md), refunds in [refunds.md](refunds.md), payouts in
|
||||
[payouts.md](payouts.md), catalog in [catalog.md](catalog.md), geo in [geography.md](geography.md),
|
||||
partner centers in [partner-center.md](partner-center.md), the ticket queue in [tickets.md](tickets.md).
|
||||
|
||||
Every endpoint here is `[Authorize(ConstantPolicies.DynamicPermission)]` + `sensitive` rate limit
|
||||
(20/min), **except** the three `platform_config`/`holidays`/`audit`/`support_alerts` controllers, which
|
||||
carry the dynamic-permission policy without the sensitive limit — they fall to the 100/min global limiter.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/platform_config/get_platform_configs` | wired · paginated |
|
||||
| POST | `/api/v1/platform_config/update_platform_config` | wired |
|
||||
| GET | `/api/v1/platform_config/get_config_change_history` | wired · paginated |
|
||||
| GET | `/api/v1/holidays/get_holidays` | wired · paginated |
|
||||
| POST | `/api/v1/holidays/upsert_holiday` | wired |
|
||||
| POST | `/api/v1/holidays/delete_holiday` | **unwired** — no real client caller; the console offers no delete |
|
||||
| GET | `/api/v1/audit/get_audit_trail` | wired · paginated |
|
||||
| GET | `/api/v1/support_alerts/get_support_alerts` | wired · paginated |
|
||||
| POST | `/api/v1/support_alerts/assign_support_alert` | wired |
|
||||
| POST | `/api/v1/support_alerts/resolve_support_alert` | wired |
|
||||
| GET | `/api/v1/admin_cancellation_policies/list` | **unwired** — the tier table is read through [refunds.md](refunds.md)'s policy preview instead |
|
||||
| POST | `/api/v1/admin_cancellation_policies/upsert` | **unwired** — no console screen edits tiers |
|
||||
| POST | `/api/v1/admin_search/rebuild_index` | **unwired** — an ops one-shot, no UI |
|
||||
| POST | `/api/v1/admin_booking_requests/expire` | **unwired** — an ops one-shot; the scheduler does this unattended |
|
||||
|
||||
### Phantom — 5
|
||||
|
||||
The client's real `clientApi.ts` calls five routes the server does not expose. Both groups are
|
||||
**deliberately written real-shaped** so flipping the seam is one line once they ship.
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/admin_roles/list_roles` | REQ-031 | **Deferred** in refinement-phase-3. The admin sub-role vocabulary and phone-OTP admins are seeded, not managed |
|
||||
| `POST /api/v1/admin_roles/grant_role` | REQ-031 | |
|
||||
| `POST /api/v1/admin_roles/revoke_role` | REQ-031 | |
|
||||
| `GET /api/v1/admin_users/search` | **REQ-061 — never filed** | Backs `UserPicker`/`NursePicker` |
|
||||
| `POST /api/v1/admin_users/lookup` | **REQ-061 — never filed** | Batch id→label resolve for `AuditLogRow` |
|
||||
|
||||
> **REQ-061 does not exist in the ledger.** `ui-phase-11-report.md` records "REQ-061…064 appended", but
|
||||
> only 062/063/064 were. Ten live client files cite REQ-061 for the admin user directory. Phase 4 must
|
||||
> file it rather than assume it is tracked.
|
||||
|
||||
## Two live drifts
|
||||
|
||||
**1. The client sends `page_size`; these endpoints declare `PageSize`.** `admin/apis/clientApi.ts`'s
|
||||
`pageQuery()` builds `page` + `page_size` "per b1 api-conventions". Model binding is case-*insensitive*,
|
||||
not separator-insensitive, so `page_size` does **not** bind to `PageSize` — every admin list would
|
||||
silently fall back to the server's default page size. Invisible today because the mock is primary; it
|
||||
becomes a real defect the moment `USE_ADMIN_MOCK` flips. See
|
||||
[../api-contract.md](../api-contract.md#pagination).
|
||||
|
||||
**2. `updatedAt`/`updatedBy` and the audit filters *are* on the wire.** REQ-029 (config audit fields) and
|
||||
REQ-030 (`actorId`/`action`/`from`/`to` filters on `audit/get_audit_trail`) were both **delivered** in
|
||||
refinement-phase-3. `admin/constants.ts` still gives them as reasons the mock is primary. The only
|
||||
remaining reason is REQ-031 + REQ-061.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Platform config is rows read at compute time**, never hardcoded, and **a rate change is never
|
||||
retroactive** — the effective rate is snapshotted onto the row when the amount is computed.
|
||||
- `platform_fee_rate` and `vat_rate` are **rates in `[0, 1)`** — the console validates the closed-open
|
||||
interval before writing (`RATE_CONFIG_KEYS` in `admin/constants.ts`). The canonical values are
|
||||
`0.15` fee / `0.10` VAT (refinement-phase-3).
|
||||
- The audit trail's `changedFieldsJson` is a **string containing JSON**, not an object:
|
||||
`{"Field": {"old": …, "new": …}}`. The client parses it defensively and yields `null` on malformed input.
|
||||
- `POST update_platform_config` and the holiday/alert writes go through self-committing facades that call
|
||||
`SaveChanges` on the shared scoped context — they run **after** the handler's own `CommitAsync`.
|
||||
- Holidays drive **payout date shifting**: the server resolves a bank-closure-safe payout date from this
|
||||
calendar and the client never computes one.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `ConfigDataType` | `string` `int` `decimal` `bool` `json` |
|
||||
| `AuditAction` | `created` `updated` `deleted` |
|
||||
| `HolidayType` | `official` `religious` `national` |
|
||||
| `SupportAlertType` | `low_rating` `evv_no_show` `evv_location_mismatch` `verification_expired` `shared_sim` `payment_anomaly` `fraud_signal` `nurse_clawback` `emergency` |
|
||||
| `SupportAlertSeverity` | `low` `medium` `high` |
|
||||
| `SupportAlertStatus` | `open` `assigned` `resolved` |
|
||||
| `AdminRole` *(phantom surface)* | `super_admin` `admin` `support` `finance` `moderation` |
|
||||
| `DirectoryUserRole` *(phantom surface)* | `customer` `nurse` `admin` `partner` |
|
||||
|
||||
The last two describe the REQ-031/REQ-061 shapes and are **not on the wire**.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-031 | deferred | No RBAC console. Admin roles are seeded |
|
||||
| **REQ-061** | **never filed** | No admin user directory. `AuditLogRow` shows `#id` instead of a name |
|
||||
@@ -0,0 +1,79 @@
|
||||
# auth — phone OTP, sessions, `/me`, role selection
|
||||
|
||||
> Client seam `client/src/services/auth/` · `USE_AUTH_MOCK = false` (**real**) · 7 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The only way into the platform. Phone + OTP, no passwords. The transport rules — bearer header, cookie
|
||||
storage, silent refresh, rotation, reuse detection — are in
|
||||
[../api-contract.md](../api-contract.md#auth); this file is the endpoints and the payload semantics.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Rate limit | Verdict |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| POST | `/api/v1/auth/request_otp` | **anonymous** | `otp` 5/min | wired |
|
||||
| POST | `/api/v1/auth/verify_otp` | **anonymous** | `otp` 5/min | wired |
|
||||
| POST | `/api/v1/auth/refresh` | **anonymous** | `auth` 10/min | wired · called by both `services/auth` **and** the fetch layer |
|
||||
| POST | `/api/v1/auth/logout` | `[Authorize]` | — | wired |
|
||||
| GET | `/api/v1/me` | `[Authorize]` | — | wired |
|
||||
| POST | `/api/v1/me/select_role` | `[Authorize]` | — | wired |
|
||||
| GET | `/api/v1/dev/last_otp/{phone}` | **anonymous** | — | **Development only** — see below |
|
||||
|
||||
No phantoms.
|
||||
|
||||
`AUTH_API_BASE` is `/api/v1`; the routes above are exactly what the client sends.
|
||||
|
||||
## `dev/last_otp` — live on the deployment
|
||||
|
||||
`DevController` is registered unconditionally, and the OTP-capture bridge behind it is wired only when
|
||||
`IsDevelopment()` **and** the SMS provider is capture-safe (`mock` or `telegram`). The
|
||||
`balinyaar.ir` deployment runs `ASPNETCORE_ENVIRONMENT=Development` with `Seams:Sms:Provider = telegram`,
|
||||
so **both conditions hold and the endpoint is reachable on `api.balinyaar.ir`.** Anyone who knows a
|
||||
registered phone number can read its login code. Recorded in [DEPLOY.md](../../../DEPLOY.md) as the
|
||||
deployment's largest exposure; the fix is the environment switch, not a code change.
|
||||
|
||||
Selecting a real gateway (`kavenegar`, …) disables the bridge — the OTP must never be logged or captured
|
||||
once real SMS ships.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`RequestOtpResult` carries the code length and expiry** (REQ-002, delivered) so the client sizes the
|
||||
input and runs the countdown from server truth rather than a hardcoded constant.
|
||||
- **`verify_otp` failures carry a machine-readable `code`** on the envelope (REQ-003, delivered) —
|
||||
e.g. `otp_locked` — so the client branches on the state instead of matching a message string. This is
|
||||
the `code` field described in [../api-contract.md](../api-contract.md#the-envelope).
|
||||
- **`refresh` returns a new pair and retires the old refresh token.** Presenting a retired token is
|
||||
treated as theft: the session is killed, not merely refused. A 401 from `/auth/refresh` is therefore
|
||||
terminal and the client must not retry it.
|
||||
- **`/me` is the only source of identity.** The JWE is opaque; the client reads role, gender and
|
||||
profile-completeness from `/me`, never from a decoded claim.
|
||||
- **Multi-role users**: `/me` reports the roles the caller holds. `POST /me/select_role` commits to one.
|
||||
REQ-004 was **resolved as a client concern** — the client owns the disambiguation and the "resolved vs.
|
||||
pending" role hydration; no backend change was needed. See
|
||||
[docs/rules/client/auth.md](../../rules/client/auth.md).
|
||||
- **`logout` returns an empty envelope** — no `data`. The client awaits the revocation and must not
|
||||
`unwrap()` it.
|
||||
- **Phone numbers are encrypted at rest.** Login looks the user up by a deterministic HMAC hash
|
||||
(`users.PhoneHash`, derived from `Seams:FieldEncryption:HashKey`), never by comparing the encrypted
|
||||
column. This is why that key is immutable — see [../config-matrix.md](../config-matrix.md).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `PublicRole` | `customer` `nurse` |
|
||||
| `AdminRole` | `admin` `support` `finance` `moderation` `super_admin` |
|
||||
| `Gender` | `male` `female` — **load-bearing**, drives same-gender caregiver matching |
|
||||
| `NurseVerificationStatus` *(as surfaced on `/me`)* | `not_started` `in_progress` `pending_review` `verified` `rejected` |
|
||||
|
||||
> The `/me` verification summary uses a **different vocabulary** from the verification domain's own
|
||||
> aggregate status (`not_started` `pending` `in_review` `approved` `rejected` `suspended`). They are two
|
||||
> read models over the same source of truth, not a drift — but do not treat the strings as
|
||||
> interchangeable. See [verification.md](verification.md).
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-038 | open | `/me` carries no signal that the caller administers a partner center, so partner auto-routing cannot be driven off it. See [partner-center.md](partner-center.md) |
|
||||
| REQ-039 | open | The OTP SMS template is not WebOTP-conformant, so the browser's one-tap autofill never fires. The client's WebOTP hook ships anyway and degrades silently |
|
||||
@@ -0,0 +1,92 @@
|
||||
# bnpl — provider-financed installments
|
||||
|
||||
> Client seam `client/src/services/bnpl/` · `USE_BNPL_MOCK = true` (**mock is primary**) · 9 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The second checkout rail. **Balinyaar does not finance anything** — a provider (SnappPay, Digipay, …) pays
|
||||
the platform net of its commission and carries the customer's installments itself. Card checkout is
|
||||
[payment.md](payment.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/v1/checkout_bnpl/eligibility` | `[Authorize]` · `sensitive` | wired |
|
||||
| POST | `/api/v1/checkout_bnpl/initiate` | `[Authorize]` · `sensitive` | wired · **`Idempotency-Key`** |
|
||||
| GET | `/api/v1/checkout_bnpl/{id}` | `[Authorize]` · `sensitive` | wired |
|
||||
| GET | `/api/v1/checkout_bnpl/by_request/{bookingRequestId}` | `[Authorize]` · `sensitive` | wired |
|
||||
| POST | `/api/v1/webhooks_bnpl/{provider}` | **anonymous** · `webhook` 120/min | server-only — the provider calls it |
|
||||
| GET | `/api/v1/admin_bnpl/{id}` | admin · `sensitive` | **unwired** — no console screen |
|
||||
| POST | `/api/v1/admin_bnpl/{id}/verify` | admin · `sensitive` | **unwired** |
|
||||
| POST | `/api/v1/admin_bnpl/{id}/settle` | admin · `sensitive` | **unwired** |
|
||||
| POST | `/api/v1/admin_bnpl/{id}/revert` | admin · `sensitive` | **unwired** — the reversal is driven from [refunds.md](refunds.md) instead |
|
||||
|
||||
### Phantom — 3
|
||||
|
||||
All three are REQ-022's deferred half. Written real-shaped so the swap is one line.
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/checkout_bnpl/options/{bookingRequestId}` | REQ-022 | The D1/D2 provider + plan list, per-plan monthly / down-payment / total |
|
||||
| `GET /api/v1/checkout_bnpl/schedule/{id}` | REQ-022 | The D4 repayment schedule |
|
||||
| `GET /api/v1/checkout_bnpl/wallet_installments` | REQ-024 | The D5 wallet installment list |
|
||||
|
||||
REQ-022 was **partially** delivered: `balinyaar` was added to the `provider_code` enum; `options` and
|
||||
`schedule` were deferred. `BnplEligibilityDto` carries a single `planSummary` + `installmentCount`, not a
|
||||
list of plans — which is exactly why the client needs `options`.
|
||||
|
||||
## The money shape
|
||||
|
||||
Two facts that make BNPL different from card, and both are easy to get wrong:
|
||||
|
||||
1. **The card payment is recorded net of the provider's fee.** The provider deducts its commission before
|
||||
remitting, so the platform receives `orderAmount − bnplCommission`. `settledAmountIrr` and
|
||||
`bnplCommissionIrr` are both on `BnplOrderStatusDto`, and the handler reads the **actual deducted
|
||||
amount from the settlement response** — never a rate from config. `Seams:Bnpl:CommissionRate` tunes the
|
||||
*mock* only.
|
||||
2. **Settlement is not necessarily instant.** `settledAt` is nullable, modelling the deferred / T+1–3 /
|
||||
weekly reality. A null `settledAt` on a `settled` order is normal, not an inconsistency.
|
||||
|
||||
`BnplStatus` is **forward-only**. A reversal is `reverted`, with `revertTransactionId`,
|
||||
`revertedAmountIrr`, `revertedAt` and — when the provider returns it — `providerCommissionReversedAmount`,
|
||||
which the reconciliation needs and which most providers do not send. See [refunds.md](refunds.md) for the
|
||||
`bnpl_revert` refund channel.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Eligibility accepts the credit-check inputs** `{ nationalId, mobile, consent }` (REQ-023, delivered).
|
||||
Consent is **required** when the KYC inquiry runs — it is a legal precondition, not a checkbox.
|
||||
- **`eligibilityStatus` distinguishes three outcomes**, and the third is not a failure:
|
||||
`not_eligible` (provider declined) vs `ceiling_exceeded` (order above `creditCeilingIrr` — offer card
|
||||
instead) vs `eligible`. The UI must fall back to card, not show an error, on either negative.
|
||||
- **`bookingId` is on the settled order** (REQ-024, confirmed) so the wallet can link an installment plan
|
||||
to its booking.
|
||||
- **D5 installment status is provider-reported, not ledger-derived.** The platform does not track the
|
||||
customer's repayment; whatever the provider says is the truth. Never compute an installment state from
|
||||
Balinyaar's own ledger.
|
||||
- **`currency` is on the wire and matters.** `Seams:Bnpl:WireCurrency` is `IRR` by default; SnappPay and
|
||||
Digipay speak Rial. Conversion happens **only** inside the adapter via `ICurrencyNormalizer`.
|
||||
- Provider credentials proper live in the encrypted `payment_gateways.config_json`; only non-secret
|
||||
connection facts (base URL, sandbox flag, merchant handle) come from `Seams:Bnpl:Providers`.
|
||||
- `Seams:Bnpl:NotEligibleMobile` (`09120000099`) is the designated test mobile that returns
|
||||
`not_eligible`, so the fall-back-to-card path is testable.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `BnplStatus` | `eligible` `token_issued` `verified` `settled` `reverted` `cancelled` `failed` |
|
||||
| `BnplEligibilityStatus` | `eligible` `not_eligible` `ceiling_exceeded` |
|
||||
| `ProviderCode` | `snapppay` `digipay` `tara` `torobpay` `balinyaar` |
|
||||
| `BnplInstallmentStatus` *(D5, provider-reported)* | `paid` `due_soon` `upcoming` `overdue` |
|
||||
| `BnplHandoffOutcome` *(client, from the return URL)* | `success` `failure` |
|
||||
|
||||
The first three are verified identical to `Entities/Bnpl/BnplStatus.cs`,
|
||||
`BnplEligibilityStatus.cs` and `BnplProviderCodes.cs`. Note `snapppay` has **three** `p`s.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-022 | partially delivered | `options` and `schedule` deferred → 3 phantom routes. D1/D2/D4 are mock-only |
|
||||
| REQ-024 | partially delivered | `bookingId` confirmed present; the wallet installment list is deferred |
|
||||
@@ -0,0 +1,104 @@
|
||||
# booking-requests — the money-free pre-payment request
|
||||
|
||||
> Client seam `client/src/services/bookingRequests/` · `USE_BOOKING_REQUESTS_MOCK = false` (**real**) · 7 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Stage one of the booking flow: the customer asks, the nurse answers, and **no money exists yet**. A
|
||||
`booking_requests` row becomes a `bookings` row only on payment capture — see
|
||||
[bookings.md](bookings.md) and [payment.md](payment.md).
|
||||
|
||||
> `Features/Booking` (singular, this domain) and `Features/Bookings` (plural, the post-payment engine) are
|
||||
> **different server areas, not a rename.**
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Caller | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/v1/booking_requests/create` | customer | wired |
|
||||
| GET | `/api/v1/booking_requests/list` | both | wired · paginated · role-scoped |
|
||||
| GET | `/api/v1/booking_requests/get/{id}` | both | wired |
|
||||
| POST | `/api/v1/booking_requests/accept/{id}` | nurse | wired |
|
||||
| POST | `/api/v1/booking_requests/reject/{id}` | nurse | wired |
|
||||
| POST | `/api/v1/booking_requests/cancel/{id}` | customer | wired |
|
||||
| GET | `/api/v1/booking_requests/checkout_summary/{id}` | customer | wired — but read by the **[payment](payment.md)** domain, not this one |
|
||||
|
||||
All `[Authorize]`. No phantoms.
|
||||
|
||||
`checkout_summary` is the C6 money read (gross / commission / VAT breakdown, REQ-016 delivered). It lives
|
||||
on this controller because the request is what gets paid for, and is documented in
|
||||
[payment.md](payment.md) where it is consumed.
|
||||
|
||||
## The lifecycle
|
||||
|
||||
```
|
||||
pending_nurse_response ──accept──▸ accepted_awaiting_payment ──capture──▸ converted
|
||||
│ │
|
||||
├──reject──▸ rejected_by_nurse └──window lapses──▸ payment_deadline_expired
|
||||
├──deadline──▸ expired_no_response
|
||||
└──customer──▸ cancelled_by_customer
|
||||
```
|
||||
|
||||
**Forward-only.** A backward or sideways transition is a clean `409`, never a 500. Two deadlines are
|
||||
server-owned and config-driven (`booking_request_response_deadline_minutes`,
|
||||
`payment_window_minutes` in [admin.md](admin.md)):
|
||||
|
||||
- the nurse's response window → `expired_no_response`
|
||||
- the customer's payment window after acceptance → `payment_deadline_expired`
|
||||
|
||||
`POST /api/v1/admin_booking_requests/expire` is the ops one-shot for both; the in-process scheduler runs
|
||||
it unattended. See [admin.md](admin.md).
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Stage-one disclosure is deliberately partial.** Before payment the nurse sees only unencrypted
|
||||
`customerNotes` and a **city/district-coarse masked address**. Encrypted care instructions and the full
|
||||
address are unreadable until the booking is confirmed. This is a hard server rule, not a UI choice.
|
||||
- **The countdown is server-frozen.** `CountdownTimer` renders a deadline the server sent; the client
|
||||
never computes an expiry from a local clock.
|
||||
- **`variantPrice` is on `BookingRequestDto`** (REQ-013, delivered) so the customer sees the price they are
|
||||
committing to without a second variant fetch. It is a **digit string**.
|
||||
- **The inbox list item carries `variantLabel` + `patientAge`** (REQ-014, delivered).
|
||||
|
||||
### The two list shapes, exactly
|
||||
|
||||
Confirmed field-by-field against the live swagger, because three in-repo comments disagree about this:
|
||||
|
||||
| | `BookingRequestListItemDto` (list) | `BookingRequestDto` (detail) |
|
||||
| --- | --- | --- |
|
||||
| `variantLabel` | **yes** | yes |
|
||||
| `patientAge` | **yes** | — (`patientName` instead) |
|
||||
| `variantPrice` · `variantPriceUnit` | **no** | yes |
|
||||
| address / notes | `customerNotes` only | full masked address block |
|
||||
| `nurseRejectionReason` | — | yes, **free text** |
|
||||
|
||||
Two consequences:
|
||||
|
||||
- **`client/src/services/bookingRequests/types.ts` is behind the wire.** It marks `variantLabel` as
|
||||
"client-augmented … `undefined` on the real path", but the server serves it. Widening the client type is
|
||||
safe and would let the real inbox card render its decision-first headline today.
|
||||
- **REQ-050 is partly stale.** It states the list DTO carries neither field, "confirmed against
|
||||
`services/bookingRequests/types.ts`" — i.e. confirmed against the client type, not the wire. What the
|
||||
wire genuinely lacks is `variantPrice`/`variantPriceUnit` on the *list* row and the `status=answered`
|
||||
group filter.
|
||||
- **`requiredCaregiverGender` is never defaulted or dropped.** `any` is an explicit choice, distinct from
|
||||
absent.
|
||||
- The address the request references is snapshotted at create time; later edits to the saved address do
|
||||
not rewrite it.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `BookingRequestStatus` | `pending_nurse_response` `accepted_awaiting_payment` `converted` `rejected_by_nurse` `expired_no_response` `payment_deadline_expired` `cancelled_by_customer` |
|
||||
| `RequiredCaregiverGender` | `male` `female` `any` |
|
||||
| `RequestRole` *(client-side list filter)* | `customer` `nurse` |
|
||||
|
||||
Verified identical to `Baya.Domain/Entities/Booking/BookingRequestStatus.cs`. REQ-015 confirmed these
|
||||
serialise as the exact snake_case codes.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-044 | open | No structured `nurseRejectionReasonCode` — the wire carries free-text `nurseRejectionReason`. The client runs a keyword heuristic over it, documented in-code as a known approximation |
|
||||
| REQ-050 | open, **narrower than filed** | The list row already has `variantLabel`; it lacks `variantPrice`/`variantPriceUnit`. The `status=answered` group filter is genuinely absent, so the «پاسخداده» tab fires three page-1 queries and concatenates — an unpaged workaround a nurse with many answered requests will hit |
|
||||
@@ -0,0 +1,97 @@
|
||||
# bookings — the post-payment engine, sessions and EVV
|
||||
|
||||
> Client seam `client/src/services/bookings/` · `USE_BOOKINGS_MOCK = false` (**real**) · 16 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Stage two: a paid booking, its per-visit sessions, and electronic visit verification. A `bookings` row is
|
||||
created **only on payment capture**, from an accepted [booking request](booking-requests.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Caller | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/bookings/list` | both | wired · paginated · `role=customer\|nurse\|all` |
|
||||
| GET | `/api/v1/bookings/get/{id}` | both | wired |
|
||||
| GET | `/api/v1/bookings/care_instructions/{id}` | nurse, admin | wired · **stage-2 disclosure gate** |
|
||||
| POST | `/api/v1/bookings/submit_care_instructions/{id}` | customer | **unwired** — the client has no care-instructions form |
|
||||
| POST | `/api/v1/bookings/convert` | — | **unwired** — Development-only capture simulator; the PSP webhook supersedes it |
|
||||
| POST | `/api/v1/bookings/transition/{id}` | admin | **unwired** — a raw state-machine escape hatch, no UI |
|
||||
| POST | `/api/v1/bookings/cancel/{id}` | — | **unwired, superseded** by `{id}/cancel` |
|
||||
| POST | `/api/v1/bookings/{id}/cancel` | customer | wired — by **[refunds](refunds.md)** (cancel *and* refund, REQ-019) |
|
||||
| GET | `/api/v1/bookings/{id}/cancellation_policy` | customer | wired — by **[refunds](refunds.md)** (REQ-020) |
|
||||
| GET | `/api/v1/booking_sessions/today` | nurse | wired · paginated |
|
||||
| GET | `/api/v1/booking_sessions/evv/{id}` | nurse | wired |
|
||||
| POST | `/api/v1/booking_sessions/check_in/{id}` | nurse | wired |
|
||||
| POST | `/api/v1/booking_sessions/check_out/{id}` | nurse | wired · `sensitive` 20/min — **this is what releases the payout clock** |
|
||||
| POST | `/api/v1/booking_sessions/cancel/{id}` | nurse | **unwired** — no per-session cancel in the UI |
|
||||
| GET | `/api/v1/admin_evv/list` | admin | **unwired** — no console screen; the mock demonstrates it |
|
||||
| POST | `/api/v1/admin_evv/detect_no_shows` | admin | **unwired** — an ops one-shot; the scheduler runs it |
|
||||
|
||||
All `[Authorize]`; the two `admin_evv` routes are `DynamicPermission` + `sensitive`. No phantoms.
|
||||
|
||||
> **Two cancel routes exist on the same controller.** `POST bookings/cancel/{id}` (action-style, b9) and
|
||||
> `POST bookings/{id}/cancel` (REST-style, b11 cancel-and-refund). Only the second is wired. They are
|
||||
> not aliases — the second also drives the refund. Treat the first as legacy.
|
||||
|
||||
## The lifecycle
|
||||
|
||||
```
|
||||
pending_payment ──capture──▸ confirmed ──first check-in──▸ in_progress
|
||||
│ │
|
||||
│ all sessions out
|
||||
▼ ▼
|
||||
cancelled ◂──cancel── completed ──dispute window──▸ closed
|
||||
└──dispute──▸ disputed
|
||||
```
|
||||
|
||||
Forward-only, through the transition table. `status` has a private setter; only cohesive domain methods
|
||||
mutate it and the handler pre-checks, returning a clean `409`.
|
||||
|
||||
Sessions run their own machine: `scheduled → in_progress → completed`, or `missed` / `cancelled`.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Two-stage clinical disclosure is enforced server-side.** `care_instructions/{id}` decrypts and returns
|
||||
the care plan **only** post-confirmation and **only** to the assigned nurse or an admin. It is never
|
||||
projected into a list and never logged. The client's UI gate mirrors this; it does not create it.
|
||||
- **EVV is advisory, and `checkInAddressMatch` is a tri-state**: `true` (inside tolerance), `false`
|
||||
(outside), `null` (**no reading** — permission denied or unavailable). Null is not a failure. A
|
||||
mismatch does not block check-in; it raises a support alert
|
||||
(`evv_location_mismatch`, see [admin.md](admin.md)). Tolerance is
|
||||
`evv_location_tolerance_meters` config. REQ-015 confirmed the tri-state.
|
||||
- **`BookingDetailDto.variantSnapshotJson` and `addressSnapshotJson` are strings containing JSON**, not
|
||||
typed objects — the point of a snapshot is that later edits to the variant or address cannot rewrite
|
||||
history. The client parses defensively across multiple key spellings (REQ-045 open).
|
||||
- **The three-amount split is guaranteed by a DB CHECK**:
|
||||
`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`. `platformFeeRate` is **snapshotted onto
|
||||
the row** at compute time, so a later rate change is not retroactive. Never recompute any of these.
|
||||
- **`payoutEligibleAt` per session** is the payout clock, started by check-out and resolved against the
|
||||
holiday calendar server-side. See [payouts.md](payouts.md).
|
||||
- **`disputeWindowEndsAt`** gates `completed → closed`; `dispute_window_hours` is config.
|
||||
- `BookingListItemDto` is deliberately thin: `id, status, counterpartyName, scheduledDate, sessionCount,
|
||||
amountIrr, disputeWindowEndsAt, createdAt`. **No `patientId`** — which is what blocks REQ-057's care
|
||||
teaser.
|
||||
- `NEXT_PUBLIC_EVV_MOCK_GPS` overrides the GPS reading for local testing (`off` = real capture). See
|
||||
[../config-matrix.md](../config-matrix.md).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `BookingStatus` | `pending_payment` `confirmed` `in_progress` `completed` `disputed` `closed` `cancelled` |
|
||||
| `BookingSessionStatus` | `scheduled` `in_progress` `completed` `missed` `cancelled` |
|
||||
| `VisitVerificationStatus` (`evvStatus`) | `pending` `checked_in` `completed` |
|
||||
| `BookingListRole` *(query param)* | `customer` `nurse` `all` |
|
||||
| `EvvGpsMode` *(client test knob)* | `off` `in_range` `out_of_range` `denied` |
|
||||
|
||||
Verified identical to `Baya.Domain/Entities/Booking/*.cs`.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-045 | open | `variantSnapshot`/`addressSnapshot` are untyped JSON strings. The client keeps a defensive multi-key parse; no user-facing defect |
|
||||
| REQ-051 | open | The nurse view of a confirmed+ booking still masks the address. The nurse sees a quiet fallback note, not a crash |
|
||||
| REQ-052 | open | The today feed carries no service label — it renders patient name + visit index only |
|
||||
| REQ-054 | deferred | No web push for new requests; a 15 s poll remains the only signal |
|
||||
| REQ-057 | open | `BookingListItemDto` has no `patientId` and the patient read has no `lastVisitAt`, so the card renders no care teaser. See [patients.md](patients.md) |
|
||||
@@ -0,0 +1,63 @@
|
||||
# catalog — service categories, option groups, nurse pricing variants
|
||||
|
||||
> Client seam `client/src/services/catalog/` · `USE_CATALOG_MOCK = false` (**real**) · 14 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
An EAV catalogue: admin defines categories and their option groups; each nurse composes **variants** —
|
||||
a category + a chosen set of option values + a price. A variant is what a customer actually books.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/catalog/categories` | **anonymous** | wired · paginated |
|
||||
| GET | `/api/v1/catalog/option_groups` | **anonymous** | wired |
|
||||
| GET | `/api/v1/nurse_variants/list` | `[Authorize]` | wired · paginated |
|
||||
| GET | `/api/v1/nurse_variants/get/{id}` | **anonymous** | wired |
|
||||
| POST | `/api/v1/nurse_variants/create` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/nurse_variants/update/{id}` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/nurse_variants/set_active/{id}` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/admin_catalog/create_category` | admin | **unwired** — no console screen |
|
||||
| POST | `/api/v1/admin_catalog/update_category/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/set_category_active/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/create_option_group` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/update_option_group/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/create_option_value` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/update_option_value/{id}` | admin | **unwired** |
|
||||
|
||||
No phantoms. The seven `admin_catalog` routes are `DynamicPermission` + `sensitive`; the catalogue is
|
||||
seeded and managed out of band today, so the console has no editor. That is a UI gap, not a contract gap.
|
||||
|
||||
> Mutations are **action-style, not REST**: `POST admin_catalog/create_category`, never
|
||||
> `POST admin/catalog/categories`. The old contract doc calls this out explicitly and it still holds.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`GET nurse_variants/get/{id}` is anonymous** while `list` requires auth. That asymmetry is deliberate —
|
||||
a public nurse profile links to a specific variant.
|
||||
- **A duplicate variant is rejected by `option_set_hash`.** The server hashes the chosen option-value set
|
||||
per (nurse, category) and enforces uniqueness, so a nurse cannot list the same configuration twice at two
|
||||
prices. The client surfaces the resulting `409`, it does not pre-check.
|
||||
- **The variant snapshot is serialised at booking time** (`IVariantSnapshotSerializer`) onto the booking
|
||||
row — see [bookings.md](bookings.md). Editing or deactivating a variant never changes a past booking.
|
||||
- **`set_active` is the only way to retire a variant.** There is no delete; a variant referenced by
|
||||
bookings must remain resolvable.
|
||||
- **Prices are IRR digit strings** outbound. The nurse enters Toman in the UI and the client converts at
|
||||
the input boundary — the wire is always IRR.
|
||||
- Reference names come as **both** `nameFa` and `nameEn`; the client picks by locale. `OptionGroupDto`
|
||||
carries its `values` inline, so the variant builder needs one round trip, not one per group.
|
||||
- `isRequired` + `sortOrder` on an option group drive the builder's validation and layout — the client
|
||||
does not hardcode either.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `PriceUnit` | `per_hour` `per_session` `per_half_day` `per_day` `per_24h` |
|
||||
|
||||
`PriceUnit` is a label vocabulary, never a multiplier — the client must not derive a total from it. Display
|
||||
strings are i18n keys, never the code.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. The variant builder (b7) and the Home category grid (A5) both read the contract as served.
|
||||
@@ -0,0 +1,64 @@
|
||||
# geography — provinces, cities, districts
|
||||
|
||||
> Client seam `client/src/services/geography/` · `USE_GEOGRAPHY_MOCK = false` (**real**) · 13 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The reference hierarchy every address, service area and search filter is keyed on. Also the home of the
|
||||
single most load-bearing null in the schema.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/geo/provinces` | **anonymous** | wired |
|
||||
| GET | `/api/v1/geo/cities` | **anonymous** | wired |
|
||||
| GET | `/api/v1/geo/districts` | **anonymous** | wired |
|
||||
| GET | `/api/v1/geo/tree` | **anonymous** | **unwired** — the client fetches the three levels separately and caches each |
|
||||
| POST | `/api/v1/admin_geo/create_province` | admin | **unwired** — no console screen |
|
||||
| POST | `/api/v1/admin_geo/update_province/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/set_province_active/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/create_city` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/update_city/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/set_city_active/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/create_district` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/update_district/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/set_district_active/{id}` | admin | **unwired** |
|
||||
|
||||
No phantoms. The nine `admin_geo` routes are `DynamicPermission` + `sensitive`; geography is seeded, so
|
||||
there is no console editor. Mutations are **action-style** (`admin_geo/create_city`, never
|
||||
`admin_geo/cities`).
|
||||
|
||||
## `districtId = null` means whole-city
|
||||
|
||||
This is the one rule to get right, and it reads in **both** directions:
|
||||
|
||||
- **On a nurse service area** (see [service-areas.md](service-areas.md)), `districtId = null` means the
|
||||
nurse covers the **entire city**, not "no district".
|
||||
- **On a search query**, a customer in district *D* must match both a nurse whose area names *D* and a
|
||||
nurse whose area is whole-city. See [search.md](search.md).
|
||||
- **On an address** it is genuinely optional metadata — a missing district does not widen anything.
|
||||
|
||||
Never coerce the null to 0 or to a sentinel id, and never write a query that drops whole-city rows.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- Every level returns **both** `nameFa` and `nameEn`; the client picks by locale.
|
||||
- `isActive` filters the pickers. An inactive city must stay **resolvable** — an existing address or
|
||||
booking references it — so it is filtered from selection, never deleted.
|
||||
- **Two Neshan keys exist and they are different products.** The server's geocoder key is
|
||||
`Seams:Geocoding:ApiKey` (server-side address→point); the client's map key is
|
||||
`NEXT_PUBLIC_NESHAN_KEY` (a *web* key for the embeddable map/search). Never share one value between
|
||||
them. With the client key unset, `AddressMapPicker` falls back to a bounded-canvas grid, which is why
|
||||
dev, CI and jsdom all work without it. See [../config-matrix.md](../config-matrix.md).
|
||||
- Geocoding is a seam: `Seams:Geocoding:Provider` = `mock` (default) or `neshan`. The mock resolves a
|
||||
deterministic point near the city centroid; an address containing `NO_GEO` resolves to null coordinates
|
||||
so the "saved without a map pin" state is testable per-request.
|
||||
|
||||
## Enums
|
||||
|
||||
None. All three levels are integer ids with localised names.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. REQ-008 (accept the client-picked pin) and REQ-009 (`provinceId` on the address DTO) were delivered
|
||||
and are documented in [addresses.md](addresses.md).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Domain contracts
|
||||
|
||||
One file per client `services/` domain — **22 files, 22 domains, one-to-one.** Each names every server
|
||||
endpoint that belongs to it, verdicted against the live swagger and the real client code.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and
|
||||
> [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json) (2026-07-29).
|
||||
|
||||
Read [`../api-contract.md`](../api-contract.md) first — the envelope, casing, pagination, errors, auth,
|
||||
idempotency and money rules hold everywhere and are not restated per domain.
|
||||
|
||||
---
|
||||
|
||||
## How to read a domain file
|
||||
|
||||
Every endpoint carries one verdict:
|
||||
|
||||
| Verdict | Means |
|
||||
| --- | --- |
|
||||
| **wired** | In swagger **and** called by the domain's real `apis/clientApi.ts` |
|
||||
| **unwired** | In swagger, no real client caller. Server-only, admin-only, or superseded — the reason is given |
|
||||
| **phantom** | The client calls it; **the server has no such route.** It 404s. Always carries its REQ |
|
||||
|
||||
`phantom` rows are the frontend's proposed routes, filed as REQs and mocked behind the domain seam
|
||||
meanwhile. They are not bugs in the client — they are the contract's open edge — but on a domain whose
|
||||
mock is **off** they are live 404s, and that is called out where it happens.
|
||||
|
||||
## The census
|
||||
|
||||
186 operations, each in exactly one file below. 184 belong to a domain; 2 (`ping`) are platform
|
||||
endpoints and live in [`../api-contract.md`](../api-contract.md#platform-endpoints-outside-every-domain).
|
||||
|
||||
| Domain file | `client/src/services/` | Seam | Server ops | Phantom |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [addresses.md](addresses.md) | `addresses` | real | 5 | — |
|
||||
| [admin.md](admin.md) | `admin` | **mock** | 14 | 5 |
|
||||
| [auth.md](auth.md) | `auth` | real | 7 | — |
|
||||
| [bnpl.md](bnpl.md) | `bnpl` | **mock** | 9 | 3 |
|
||||
| [booking-requests.md](booking-requests.md) | `bookingRequests` | real | 7 | — |
|
||||
| [bookings.md](bookings.md) | `bookings` | real | 16 | — |
|
||||
| [catalog.md](catalog.md) | `catalog` | real | 14 | — |
|
||||
| [geography.md](geography.md) | `geography` | real | 13 | — |
|
||||
| [notifications.md](notifications.md) | `notifications` | real | 4 | — |
|
||||
| [nurse.md](nurse.md) | `nurse` | real | 4 | — |
|
||||
| [partner-center.md](partner-center.md) | `partnerCenter` | **mock** | 9 | 6 |
|
||||
| [patient-records.md](patient-records.md) | `patientRecords` | **mock** | 5 | — |
|
||||
| [patients.md](patients.md) | `patients` | real | 5 | — |
|
||||
| [payment.md](payment.md) | `payment` | real | 3 | 1 |
|
||||
| [payouts.md](payouts.md) | `payouts` | **mock** | 13 | 1 |
|
||||
| [profiles.md](profiles.md) | `profiles` | real | 7 | — |
|
||||
| [refunds.md](refunds.md) | `refunds` | **mock** | 8 | 4 |
|
||||
| [reviews.md](reviews.md) | `reviews` | real | 8 | — |
|
||||
| [search.md](search.md) | `search` | real | 2 | — |
|
||||
| [service-areas.md](service-areas.md) | `serviceAreas` | real | 3 | — |
|
||||
| [tickets.md](tickets.md) | `tickets` | real | 11 | 1 |
|
||||
| [verification.md](verification.md) | `verification` | **mock** | 17 | 3 |
|
||||
| | | | **184** | **24** |
|
||||
|
||||
"Seam" is the domain's `constants.ts` flag (`USE_<DOMAIN>_MOCK`): **15 real, 7 mock.** A mocked domain
|
||||
still has a complete real client — flipping the flag is one line in `apis/index.ts`.
|
||||
|
||||
**Two phantoms sit on a domain whose mock is off**, so they are reachable and they 404:
|
||||
`GET /api/v1/bookings/payment_history` ([payment.md](payment.md), REQ-047) and
|
||||
`POST /api/v1/tickets/{id}/assign` ([tickets.md](tickets.md), REQ-063). Both are guarded in the client —
|
||||
the first renders an empty state, the second is behind a default-off capability flag.
|
||||
|
||||
## Route-shape exceptions
|
||||
|
||||
The routing convention is snake_case segments generated from `[controller]`/`[action]` tokens. **Four
|
||||
controllers hardcode a route string instead**, and three of those introduce hyphens:
|
||||
|
||||
| Route | Controller | Note |
|
||||
| --- | --- | --- |
|
||||
| `api/v1/admin/partner-centers` | `AdminPartnerCentersController` | hyphens **and** a nested `admin/` segment; children add `/set-active`, `/sponsor-nurse` |
|
||||
| `api/v1/admin/tickets` | `AdminTicketsController` | nested `admin/` segment |
|
||||
| `api/v1/admin/reviews/moderation_queue` | `AdminReviewsController` | nested `admin/`, then snake_case |
|
||||
| `api/v1/internal/bookings/{bookingId}/center` | `InternalCentersController` | an `internal/` namespace |
|
||||
|
||||
Every other admin controller uses a flat `admin_*` prefix (`admin_geo`, `admin_catalog`, `admin_refunds`,
|
||||
…). The split is historical, not meaningful. Since the route also derives the dynamic-permission key,
|
||||
normalising it is a breaking change to permissions as well as URLs — it is recorded here, not fixed.
|
||||
|
||||
## Enum vocabularies
|
||||
|
||||
Swagger declares **no** string enums (see
|
||||
[`../api-contract.md`](../api-contract.md#enums)), so each domain file carries its own vocabulary. Every
|
||||
one was cross-checked against the server's `Baya.Domain` code set **and** the client's string-literal
|
||||
union. All match except one, noted in [tickets.md](tickets.md).
|
||||
|
||||
| Vocabulary | Domain file | Server source |
|
||||
| --- | --- | --- |
|
||||
| `BookingRequestStatus` · `RequiredCaregiverGender` | [booking-requests.md](booking-requests.md) | `Entities/Booking/BookingRequestStatus.cs` |
|
||||
| `BookingStatus` · `BookingSessionStatus` · `VisitVerificationStatus` | [bookings.md](bookings.md) | `Entities/Booking/*.cs` |
|
||||
| `PriceUnit` | [catalog.md](catalog.md) | catalog config rows |
|
||||
| `BnplStatus` · `BnplEligibilityStatus` · `ProviderCode` | [bnpl.md](bnpl.md) | `Entities/Bnpl/*.cs` |
|
||||
| `PaymentTransactionStatus` · `MoadianStatus` | [payment.md](payment.md) | `Entities/Payments/`, `Entities/Invoices/` |
|
||||
| `RefundStatus` · `RefundChannel` · `ClawbackStatus` · cancellation codes | [refunds.md](refunds.md) | `Entities/Refunds/*.cs` |
|
||||
| `PayoutStatus` · `PayoutBatchStatus` · `EarningsState` | [payouts.md](payouts.md) | `Entities/Payouts/*.cs` |
|
||||
| `VerificationStatus` · `VerificationStepStatus` · `StepTypeCode` | [verification.md](verification.md) | `Entities/Verification/*.cs` |
|
||||
| `ModerationStatus` · `ModerationAction` | [reviews.md](reviews.md) | `Entities/Reviews/ReviewModerationStatus.cs` |
|
||||
| `TicketStatus` · `TicketCategory` · `TicketAuthorRole` | [tickets.md](tickets.md) | `Entities/Messaging/TicketCodes.cs` |
|
||||
| `CenterOnboardingState` | [partner-center.md](partner-center.md) | `Entities/PartnerCenters/` |
|
||||
| `BankAccountStatus` | [nurse.md](nurse.md) | bank-account entity |
|
||||
| roles · `Gender` | [auth.md](auth.md) | identity seed |
|
||||
| config/audit/holiday/alert codes | [admin.md](admin.md) | `Entities/Configuration/`, `Audit/`, `Holidays/`, `SupportAlerts/` |
|
||||
|
||||
## What replaced what
|
||||
|
||||
These files supersede [`dev/contracts/domains/`](../../../dev/contracts/domains/) — 17 hand-written files
|
||||
frozen 2026-07-13, plus the two `conventions/` files. Route-level content there held up well: an audit of
|
||||
every route those files name found **zero** that the live swagger lacks. What did not hold up:
|
||||
|
||||
| Was | Now |
|
||||
| --- | --- |
|
||||
| `conventions/api-conventions.md`: body casing is "typically `snake_case` … derive from swagger" | **camelCase**, proven mechanically. [`../api-contract.md`](../api-contract.md#casing) |
|
||||
| `conventions/api-conventions.md`: server default `https://localhost:5002` | `http://localhost:5002` — plain HTTP (contradiction **C-3**) |
|
||||
| The envelope has 5 fields | It has **6** — `code` was added for machine-readable errors (REQ-003) |
|
||||
| Enum vocabularies spread across 17 files and the REQ ledger | One vocabulary block per domain file, cross-checked both ways |
|
||||
| `messaging.md` (851 B, headerless) silently amending `messaging-notifications-admin.md` | Merged: [tickets.md](tickets.md) + [notifications.md](notifications.md) + [admin.md](admin.md) (contradiction **C-8**) |
|
||||
| 17 files whose names matched *backend phases* | 22 files whose names match the **client's domains**, which is how the seam is actually consumed |
|
||||
| The REQ ledger as the change log you had to read to know the current shape | Each domain file states the current shape and lists only its **open** REQs |
|
||||
@@ -0,0 +1,48 @@
|
||||
# notifications — the in-app feed and unread badge
|
||||
|
||||
> Client seam `client/src/services/notifications/` · `USE_NOTIFICATIONS_MOCK = false` (**real**) · 4 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Server-raised, user-scoped notifications. In-app only — there is no push channel and no email channel.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/notifications/get_notifications` | wired · paginated |
|
||||
| GET | `/api/v1/notifications/get_unread_count` | wired — polled for the bell badge |
|
||||
| POST | `/api/v1/notifications/mark_notification_read` | wired |
|
||||
| POST | `/api/v1/notifications/mark_all_read` | wired |
|
||||
|
||||
All `[Authorize]`. No phantoms. The domain maps 1:1.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`dataJson` is a string containing JSON**, not an object. It is the deep-link payload: the client parses
|
||||
it (`parse.ts`) and resolves a route from it (`deepLink.ts`), both with unit tests, and **falls back to
|
||||
an inert notification rather than throwing** on anything unrecognised. A notification whose payload
|
||||
cannot be parsed still renders — it just is not tappable.
|
||||
- **Unread count is a separate read, not derived from the list.** The badge must be correct without
|
||||
fetching a page, so `get_unread_count` is its own cheap query.
|
||||
- **`mark_all_read` is a bulk write**; the client invalidates both the list and the count keys, and does not
|
||||
patch items locally.
|
||||
- The feed is **day-grouped in the UI** with Shamsi headers — a client-side transform over UTC
|
||||
`createdAt`. The server sends no grouping.
|
||||
- Notifications are raised through a self-committing facade (`DispatchAsync`) that runs **after** the
|
||||
originating handler's `CommitAsync` — so a notification never exists for a transaction that rolled back.
|
||||
|
||||
## Enums
|
||||
|
||||
`type` is a **bare string** on the wire and the vocabulary is open-ended by design — new server-side
|
||||
notification types must not break an older client. The client models the *payload* as a discriminated
|
||||
union (`NotificationData`, keyed on a `kind` inside `dataJson`) and treats an unknown `type` as
|
||||
non-actionable rather than an error.
|
||||
|
||||
Consequence for the server: **adding a notification type is safe; changing an existing type's `dataJson`
|
||||
shape is not.** The deep-link parser keys off the payload, not the type string.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-054 | deferred, non-blocking | No web push for new booking requests. The nurse dashboard's 15 s poll remains the only signal. Nothing was built for it |
|
||||
@@ -0,0 +1,53 @@
|
||||
# nurse — nurse bank accounts
|
||||
|
||||
> Client seam `client/src/services/nurse/` · `USE_NURSE_BANK_MOCK = false` (**real**) · 4 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The nurse's payout destination. Narrow domain, high stakes: a payout cannot be paid to an unverified IBAN.
|
||||
|
||||
> The domain is named `nurse`, not `nurse-bank-accounts`, because that is the client folder name. The
|
||||
> nurse's *profile* lives in [profiles.md](profiles.md); coverage in
|
||||
> [service-areas.md](service-areas.md); verification in [verification.md](verification.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Rate limit | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/nurse_bank_accounts/list` | — | wired |
|
||||
| POST | `/api/v1/nurse_bank_accounts/add` | `sensitive` 20/min | wired |
|
||||
| POST | `/api/v1/nurse_bank_accounts/set_primary/{id}` | — | wired |
|
||||
| POST | `/api/v1/nurse_bank_accounts/verify_ownership/{id}` | `sensitive` 20/min | wired |
|
||||
|
||||
All `[Authorize]`. No phantoms. The domain maps 1:1.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`iban_hash` is UNIQUE across the platform.** The same IBAN cannot be registered by two nurses; the
|
||||
second `add` returns a `409`. The uniqueness is enforced on a deterministic hash, not the encrypted
|
||||
column.
|
||||
- **The IBAN is encrypted at rest and returned masked** — `maskedIban` on every read model, including the
|
||||
admin-side `PayoutDto` and the nurse's own `NursePayoutHistoryDto`. **The full IBAN is never returned
|
||||
after the write that created it.** Write-then-masked is the pattern.
|
||||
- **`verify_ownership` is استعلام شبا** — a Shahkar-class inquiry that confirms the account holder's
|
||||
national id matches the nurse's. It is a seam: `Seams:BankOwnership:Provider` = `mock` (default) or
|
||||
`finnotech`. The mock returns a match for every IBAN **except** `Seams:BankOwnership:MismatchIban`
|
||||
(`IR000000000000000000000000`), which exists so the payout-gating path is testable.
|
||||
- **Ownership verification gates payouts, and the gate lives in the payout engine, not here.**
|
||||
`EligibleNurseEarningsDto.hasVerifiedPrimaryIban` is the flag the admin console reads before generating
|
||||
a batch — see [payouts.md](payouts.md). A nurse with earnings and no verified primary IBAN accrues a
|
||||
balance and is simply not paid.
|
||||
- Client-side IBAN handling (`iban.ts`) does checksum validation and formatting only. It is a UX
|
||||
affordance; the server re-validates.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `BankAccountStatus` | `pending` `verified` `mismatch` |
|
||||
|
||||
`mismatch` is a terminal, actionable state — the holder's national id did not match — and is distinct from
|
||||
`pending`, which only means the inquiry has not run.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,99 @@
|
||||
# partner-center — nursing companies
|
||||
|
||||
> Client seam `client/src/services/partnerCenter/` · `USE_PARTNER_MOCK = true` (**mock is primary**) · 9 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Nursing companies («مراکز») that sponsor nurses onto the platform. The domain with the **widest gap between
|
||||
what the client wants and what the server serves** — 6 of its client calls are phantom.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/admin/partner-centers` | wired · paginated |
|
||||
| POST | `/api/v1/admin/partner-centers` | wired |
|
||||
| GET | `/api/v1/admin/partner-centers/{id}` | wired |
|
||||
| PATCH | `/api/v1/admin/partner-centers/{id}` | wired |
|
||||
| POST | `/api/v1/admin/partner-centers/{id}/set-active` | wired — REQ-032's delivered half |
|
||||
| POST | `/api/v1/admin/partner-centers/{id}/sponsor-nurse` | wired |
|
||||
| POST | `/api/v1/admin/partner-centers/{id}/verify` | wired |
|
||||
| GET | `/api/v1/centers/{id}/dashboard` | **unwired** — the client wants `centers/me/*` splits instead |
|
||||
| GET | `/api/v1/internal/bookings/{bookingId}/center` | **unwired** — the MoR resolver, server-internal |
|
||||
|
||||
The seven `admin/partner-centers` routes are `DynamicPermission` + `sensitive`; `centers` is `[Authorize]`;
|
||||
`internal/bookings` is `DynamicPermission`.
|
||||
|
||||
This domain owns **three of the four route-shape exceptions** in the API: hyphens (`partner-centers`,
|
||||
`set-active`, `sponsor-nurse`), a nested `admin/` segment, and an `internal/` namespace. It also has one of
|
||||
only two `PATCH` verbs. See [index.md](index.md#route-shape-exceptions).
|
||||
|
||||
### Phantom — 6
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/centers/me` | REQ-032 | The portal's own-center read |
|
||||
| `GET /api/v1/centers/me/nurses` | REQ-032 | Split read — the server serves one aggregate instead |
|
||||
| `GET /api/v1/centers/me/bookings` | REQ-032 | Split read |
|
||||
| `GET /api/v1/centers/me/bookings/{id}` | REQ-064 | Partner-scoped booking detail |
|
||||
| `GET /api/v1/centers/me/settlement` | REQ-033 | Per-booking commission invoices |
|
||||
| `GET /api/v1/admin/partner-centers/{id}/nurses` | REQ-032 | The admin-side sponsored-nurse list |
|
||||
|
||||
**The shape mismatch is the point.** The server serves **one aggregate**, `GET centers/{id}/dashboard` →
|
||||
`CenterDashboardDto` with `sponsoredNurses` inline. The portal wants **`/me` plus paginated splits** — it
|
||||
cannot page an inline array, and it does not know its own center id without REQ-038. Until REQ-032 lands,
|
||||
the portal is mock-only.
|
||||
|
||||
## Merchant of record
|
||||
|
||||
The one business rule that changes where money goes:
|
||||
|
||||
- **`isMerchantOfRecord = true`** → the *center* is the seller. It invoices the customer, holds the
|
||||
commercial relationship, and Balinyaar's cut is a commission **against the center**.
|
||||
- **`isMerchantOfRecord = false`** → the nurse is the seller and the center is a sponsor only.
|
||||
|
||||
`GET internal/bookings/{bookingId}/center` is the **MoR resolver** the invoice pipeline calls to decide
|
||||
which entity issues the invoice — which is why `InvoiceDto.issuingEntityType` exists. See
|
||||
[payment.md](payment.md). `commissionRate` on the center is a **per-center override** of the platform
|
||||
default, snapshotted at compute time like every other rate.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **The settlement IBAN is write-then-masked.** `settlementIbanMasked` is the only form on every read model;
|
||||
the full value never comes back after the write that set it. Same pattern as
|
||||
[nurse.md](nurse.md).
|
||||
- **`verify` is a licence check behind a seam.** `ILicenseVerificationService` checks the eNamad code and the
|
||||
MoH establishment permit; by default it returns `NeedsManualReview`, so `verify` records a **human admin
|
||||
decision**. `Seams:LicenseVerification:AutoApprove` makes the mock return `Valid` to test the
|
||||
auto-approve path. A real eNamad/MoH registry adapter ignores the knob.
|
||||
- **`technicalDirectorNurseUserId` links to a real verified nurse**, not a free-text name — Iranian
|
||||
regulation requires a named technical director («مدیر فنی») with a valid licence.
|
||||
- **`set-active` is suspend/activate, not delete.** A suspended center's sponsored nurses and past bookings
|
||||
stay resolvable.
|
||||
- `sponsoredNurseCount` is denormalised onto both the list item and the detail so the queue needs no
|
||||
per-row count.
|
||||
- `legalEntityType` and `mohEstablishmentPermitNo` are the regulatory identity; `enamadCode` is the
|
||||
e-commerce trust seal. All three are distinct and none substitutes for another.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `CenterOnboardingState` | `draft` `pending_verification` `verified` `suspended` |
|
||||
| `MoadianStatus` *(on the center's invoices)* | `pending` `submitted` `registered` `failed` |
|
||||
|
||||
`MoadianStatus` is shared with [payment.md](payment.md) — it is the سامانه مودیان submission state, and it
|
||||
is the same vocabulary on both sides.
|
||||
|
||||
> `CenterOnboardingState` is the client's model of the center's position in onboarding. On the wire the
|
||||
> server carries the **facts** it is derived from — `isActive` and `verifiedAt` on both
|
||||
> `PartnerCenterListItemDto` and `PartnerCenterDetailDto` — not the state string itself. Derive, do not
|
||||
> expect a field.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-032 | partially delivered | `set-active` landed. The `/me` split reads and the IBAN write-then-masked flow are deferred → 5 phantom routes. **The main reason this seam is mocked** |
|
||||
| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the per-booking commission invoice list is deferred → 1 phantom |
|
||||
| REQ-064 | open | No partner-scoped booking detail → 1 phantom |
|
||||
| REQ-038 | open | `/me` carries no signal that the caller administers a center, so the portal cannot auto-route or discover its own center id. See [auth.md](auth.md) |
|
||||
@@ -0,0 +1,83 @@
|
||||
# patient-records — the care plan and visit records
|
||||
|
||||
> Client seam `client/src/services/patientRecords/` · `USE_PATIENT_RECORDS_MOCK = true` (**mock is primary**) · 5 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The clinical content: a family-owned **care plan** (medications, routine, tasks) and the **append-only**
|
||||
visit records nurses write against it. The patient rows themselves are [patients.md](patients.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/patients/{patientId}/care_record` | wired — the care plan |
|
||||
| PUT | `/api/v1/patients/{patientId}/care_record` | wired — **the only `PUT` in the API** |
|
||||
| GET | `/api/v1/patients/{patientId}/care_records` | wired · paginated (**`page`/`pageSize`**) — visit history |
|
||||
| POST | `/api/v1/patients/{patientId}/care_records` | wired — a nurse writes one visit record |
|
||||
| GET | `/api/v1/patients/{patientId}/record_access` | wired — **ask before you read** |
|
||||
|
||||
All `[Authorize]`. **No phantoms — every route the client calls exists.** The seam is mocked for UI
|
||||
completeness, not because the contract is missing.
|
||||
|
||||
> Singular vs. plural is load-bearing here: `care_record` (no `s`) is the **plan**; `care_records` is the
|
||||
> **visit history**. They are different resources on adjacent paths.
|
||||
|
||||
## Two different ownership models on one path
|
||||
|
||||
| | `care_record` (plan) | `care_records` (visits) |
|
||||
| --- | --- | --- |
|
||||
| Owner | the **family** (the customer) | the **nurse** who performed the visit |
|
||||
| Write | `PUT` — upsert, replaces | `POST` — **append only** |
|
||||
| Edit after the fact | yes, it is a living plan | **no** |
|
||||
| Delete | no | **no** |
|
||||
|
||||
**A nurse can never edit or delete a visit record.** It is a clinical record: append-only is the whole
|
||||
point, and there is no endpoint that would allow otherwise. Do not add one.
|
||||
|
||||
## `record_access` — check before you read
|
||||
|
||||
`GET record_access` answers "may this caller read this patient's records, and if not, why". The client
|
||||
calls it **first** and renders the denial state rather than firing a read and interpreting an error.
|
||||
|
||||
Two reasons come back, and they are deliberately hard to tell apart from outside:
|
||||
|
||||
| `RecordAccessDeniedReason` | Means |
|
||||
| --- | --- |
|
||||
| `no_access` | The patient exists; you are not authorised |
|
||||
| `not_found` | No such patient — **or** a tenancy mismatch |
|
||||
|
||||
That second row is the platform's tenancy rule: a row you do not own is a **404, never a 403**, because a
|
||||
403 confirms it exists. See [../api-contract.md](../api-contract.md#status-codes).
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Every record body is encrypted at rest** and decrypted only for an authorised caller. Care content is
|
||||
**never** projected into a list, never logged, and never included in a search index.
|
||||
- **Nurse read access is scoped by an active booking**, not by having ever cared for the patient. The
|
||||
two-stage clinical disclosure rule applies: full care content is readable only post-confirmation, only by
|
||||
the assigned nurse and admin. See [bookings.md](bookings.md).
|
||||
- **`CarePlanDto` is `{ patientId, medications, routine, tasks }`** — three structured lists, not free text,
|
||||
so the client can render a schedule and a checklist rather than a blob.
|
||||
- **`CareRecordDto.taskResults` is structured** (REQ-027, delivered): each visit reports per-task outcomes
|
||||
against the plan's tasks, which is what lets the family see whether the routine was actually followed.
|
||||
- **`nurseName` is on the visit record** so the history is attributable without a per-row lookup.
|
||||
- Dose units, frequency presets and times-of-day are **codes**; the UI labels are i18n keys. Never render
|
||||
the code, and never parse a frequency into a schedule client-side.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `DoseUnit` | `tablet` `capsule` `drop` `cc` `unit` |
|
||||
| `FrequencyPreset` | `once_daily` `twice_daily` `three_times_daily` `every_8_hours` `as_needed` |
|
||||
| `TimeOfDayCode` | `morning` `noon` `evening` `night` |
|
||||
| `RecordAccessDeniedReason` | `no_access` `not_found` |
|
||||
| `CareRecordTab` *(client UI only)* | `medications` `routine` `history` `tasks` |
|
||||
|
||||
`as_needed` (PRN) has **no** time-of-day and must not be rendered on a schedule grid.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-027 | delivered | The family-owned care record (medications/routine/tasks), `record_access`, and structured task results are all served |
|
||||
@@ -0,0 +1,52 @@
|
||||
# patients — the care circle
|
||||
|
||||
> Client seam `client/src/services/patients/` · `USE_PATIENTS_MOCK = false` (**real**) · 5 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The people a customer books care *for* — «حلقهٔ مراقبت» in the UI. A patient is owned by the customer who
|
||||
created them, never by a nurse. Clinical content about a patient lives in
|
||||
[patient-records.md](patient-records.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/patients/list` | wired · paginated (`Page`/`PageSize`) |
|
||||
| GET | `/api/v1/patients/get/{id}` | wired |
|
||||
| POST | `/api/v1/patients/create` | wired |
|
||||
| POST | `/api/v1/patients/update/{id}` | wired |
|
||||
| POST | `/api/v1/patients/archive/{id}` | wired — **archive, not delete** |
|
||||
|
||||
All `[Authorize]`. No phantoms. The domain maps 1:1.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`relation` and `conditions` are on `PatientDto`** (REQ-005, delivered). `relation` is the family
|
||||
relationship shown on the record sheet; `conditions` is the coarse condition list used for triage.
|
||||
- **`gender` is load-bearing.** It drives same-gender caregiver matching, which is a near-hard requirement
|
||||
in this market. Never defaulted, never dropped, never inferred from a name.
|
||||
- **`initialMedicalNotes` is encrypted at rest** and readable only by the owning customer (and, post-
|
||||
confirmation, the assigned nurse via the booking's care-instructions read — see
|
||||
[bookings.md](bookings.md)). It is not the care record.
|
||||
- **Archive, never delete.** A patient referenced by a booking must stay resolvable; `isActive = false`
|
||||
removes them from pickers. There is no delete endpoint and there should not be.
|
||||
- **`displayName` is server-composed** from first/last name. The client renders `displayName` and uses the
|
||||
parts only in the edit form — so a naming-convention change is a server change, not a client one.
|
||||
- `birthDate` is a date; the client derives the age band (`age.ts`) for display. The **server** stamps
|
||||
`patientAge` on the nurse-facing booking-request list row (see
|
||||
[booking-requests.md](booking-requests.md)) — the nurse never receives a birth date.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `Gender` | `male` `female` |
|
||||
|
||||
`bloodType` is a free string, not an enum.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-057 | open | `PatientDto` has no `lastVisitAt` (or `visitCount`), and `BookingListItemDto` has no `patientId`, so the booking card can render no care teaser. Either field unblocks it |
|
||||
| REQ-058 | deferred, non-blocking | No patient photo upload. `PatientDto` has no `avatarUrl` and no UI reads one — `InitialsAvatar` ships either way |
|
||||
@@ -0,0 +1,95 @@
|
||||
# payment — card checkout, the PSP webhook, invoices
|
||||
|
||||
> Client seam `client/src/services/payment/` · `USE_PAYMENT_MOCK = false` (**real**) · 3 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The card money path. Three endpoints on the wire; the domain reads three more that belong to neighbours.
|
||||
Installment checkout is [bnpl.md](bnpl.md); reversals are [refunds.md](refunds.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/v1/bookings/{bookingRequestId}/payments` | `[Authorize]` · `sensitive` 20/min | wired · **`Idempotency-Key`** |
|
||||
| GET | `/api/v1/invoices/{bookingId}` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/webhooks/payments/{provider}` | **anonymous** · `webhook` 120/min | server-only — the PSP calls it |
|
||||
|
||||
Also read by this domain, documented with their owners:
|
||||
`GET booking_requests/checkout_summary/{id}` and `GET booking_requests/get/{id}`
|
||||
([booking-requests.md](booking-requests.md)).
|
||||
|
||||
### Phantom — 1
|
||||
|
||||
| Client call | REQ | Live? |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/bookings/payment_history` | REQ-047 | **Yes — this domain's mock is off.** The wallet «پرداختها» tab calls it, gets a 404, and renders its empty state. Guarded, but a real 404 on every visit |
|
||||
|
||||
## The flow, and why it is shaped this way
|
||||
|
||||
```
|
||||
accepted request ──initiate──▸ PSP hosted page ──customer pays──▸ PSP webhook
|
||||
(money-free) (redirectUrl) │
|
||||
▼
|
||||
server re-verifies, then creates + confirms the booking
|
||||
```
|
||||
|
||||
Four rules that follow, and they are the whole design:
|
||||
|
||||
1. **Payment is initiated against the accepted *request*, not a booking.** The `bookings` row does not
|
||||
exist yet. `POST bookings/{bookingRequestId}/payments` takes a **request** id despite the `bookings/`
|
||||
prefix — the route is misleading and the parameter name is the truth.
|
||||
2. **There is no client verify endpoint.** The server re-verifies with the acquirer *inside* the webhook
|
||||
handler. A client-reported "success" is never trusted.
|
||||
3. **The client learns the outcome by polling.** `getPaymentOutcome` maps the request status
|
||||
(`converted` → succeeded) with backoff. A first-class transaction-status read is REQ-017's remaining
|
||||
half.
|
||||
4. **One `Idempotency-Key` per attempt**, reused across retries of that attempt; a new attempt takes a new
|
||||
key. A `409` on initiate means "already in progress / already captured" — a benign convergence, and the
|
||||
client must not surface it as an error.
|
||||
|
||||
**Webhook idempotency does not use the header.** The handler upserts the provider event first, keyed on
|
||||
`external_event_id`, and no-ops on a duplicate; `bookings.booking_request_id` is `UNIQUE` so a replay
|
||||
cannot create a second booking; a unique-violation on confirm is treated as idempotent success. The DB
|
||||
constraint is the backstop, not the handler's `if`.
|
||||
|
||||
`POST bookings/convert` ([bookings.md](bookings.md)) is the **Development-only** capture simulator that
|
||||
stands in for the webhook locally. It is fail-closed outside Development/Testing.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`CheckoutSummaryDto` serves the money breakdown so the client never derives it** (REQ-016, delivered):
|
||||
`serviceCostIrr`, `commissionIrr`, `vatIrr`, `vatRate`, `totalIrr` **and** the three-amount split
|
||||
`grossPriceIrr` / `balinyaarCommissionIrr` / `nursePayoutAmount`. All digit strings.
|
||||
- **VAT is on Balinyaar's commission only** — the platform's taxable supply — never on the nurse payout.
|
||||
- **`InitiatePaymentResult` is `{ transactionId, redirectUrl, gatewayReferenceCode }`.** The client hands
|
||||
off to `redirectUrl` and keeps `transactionId` to poll.
|
||||
- **`InvoiceDto` carries `totalIrr`** (REQ-033, partial) = platform commission + BNPL commission + VAT, and
|
||||
`moadianStatus`/`moadianReferenceNumber` for the سامانه مودیان e-invoicing submission.
|
||||
`issuingEntityType` distinguishes a platform-issued invoice from a partner-center one — see
|
||||
[partner-center.md](partner-center.md).
|
||||
- **The acquirer is a seam.** `Seams:Payments:Provider` = `mock` (default) / `zarinpal` / `sadad` /
|
||||
`vandar` / `jibit`; `IPaymentProvider`, `ISettlementSplitProvider` and `IWebhookVerifier` swap together.
|
||||
Webhook signature secrets are per-provider (`Seams:Payments:WebhookSigningSecrets`), read from the
|
||||
`X-Signature` header by default. A provider with no signature falls back to the mandatory server-side
|
||||
re-verify.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `PaymentTransactionStatus` | `pending` `succeeded` `failed` |
|
||||
| `MoadianStatus` | `pending` `submitted` `registered` `failed` |
|
||||
| `GatewayReturnOutcome` *(client-side, from the return URL)* | `success` `failure` |
|
||||
|
||||
Verified against `Entities/Payments/PaymentTransactionStatus.cs` and `Entities/Invoices/MoadianStatus.cs`.
|
||||
`GatewayReturnOutcome` is a client reading of the acquirer's redirect and is **advisory only** — the
|
||||
authoritative outcome is the polled request status.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-017 | delivered (partial in practice) | `bookingId` is on the converted request; the first-class transaction-status read is not, so the client polls the request status instead |
|
||||
| REQ-046 | open | No nurse identity on the checkout summary and no client-readable payment reference. The real receipt hides the identity avatar/badge and the tracking line |
|
||||
| REQ-047 | open | No customer payment-transactions list. **Live 404** on `bookings/payment_history`; the wallet tab renders empty |
|
||||
| REQ-049 | open | `InvoiceDto` has no payment method, transaction reference, or seller fiscal identity. The invoice renders the money breakdown and مودیان status unconditionally |
|
||||
@@ -0,0 +1,102 @@
|
||||
# payouts — weekly nurse settlement
|
||||
|
||||
> Client seam `client/src/services/payouts/` · `USE_PAYOUTS_MOCK = true` (**mock is primary**) · 13 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Money going out, weekly, in batches. The nurse's view is read-only; the admin's view is the one place in
|
||||
the platform where an irreversible transfer is triggered by hand.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/nurse_payouts/earnings` | `[Authorize]` | wired · paginated — per-booking earnings |
|
||||
| GET | `/api/v1/nurse_payouts/earnings_balance` | `[Authorize]` | wired — the four-bucket balance |
|
||||
| GET | `/api/v1/nurse_payouts/history` | `[Authorize]` | wired · paginated |
|
||||
| GET | `/api/v1/nurse_payouts/{id}` | `[Authorize]` | wired — payout detail |
|
||||
| GET | `/api/v1/nurses/{nurseId}/payable_balance` | `[Authorize]` | **unwired** — the nurse reads `earnings_balance` instead |
|
||||
| GET | `/api/v1/admin_payouts/eligible` | admin · `sensitive` | wired · paginated |
|
||||
| GET | `/api/v1/admin_payouts/batches` | admin · `sensitive` | wired · paginated |
|
||||
| POST | `/api/v1/admin_payouts/batches` | admin · `sensitive` | **unwired** — generation is automatic (below) |
|
||||
| GET | `/api/v1/admin_payouts/batches/{id}` | admin · `sensitive` | wired · paginated (**`page`/`pageSize`**) |
|
||||
| POST | `/api/v1/admin_payouts/batches/{id}/process` | admin · `sensitive` | **unwired** — the irreversible step; no console button yet |
|
||||
| POST | `/api/v1/admin_payouts/{payoutId}/retry` | admin · `sensitive` | wired |
|
||||
| POST | `/api/v1/admin_payouts/{payoutId}/mark_failed` | admin · `sensitive` | **unwired** |
|
||||
| POST | `/api/v1/webhooks/payouts/{provider}` | **anonymous** · `webhook` | server-only — the transferor's reconciliation callback |
|
||||
|
||||
This domain absorbed the **entire** wire-level drift between the 2026-07-13 contract freeze and the
|
||||
2026-07-29 snapshot — one added endpoint and one changed schema, both here:
|
||||
|
||||
- **Added (C-6):** `POST /api/v1/webhooks/payouts/{provider}` — the transferor's reconciliation callback.
|
||||
- **Changed (C-7):** `GeneratePayoutBatchCommand` gained **`systemInitiated: boolean`**, alongside the
|
||||
existing `periodStart`/`periodEnd` dates. That is the refinement-phase-7 scheduler flag: it distinguishes
|
||||
a batch the recurring job generated from one an admin generated, which is what keeps the
|
||||
"generation is automatic, processing is not" rule auditable.
|
||||
|
||||
### Phantom — 1
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/admin_payouts/{id}/transfer_reference` | REQ-036 | Deferred. The reference is *readable* on `PayoutDto.transferReference`; what is missing is a route to record one manually |
|
||||
|
||||
## Generation is automatic; processing is not
|
||||
|
||||
This is a hard platform rule, not a convention:
|
||||
|
||||
- A **scheduled job may generate** a `draft` batch. `IRecurringJob` + `RecurringJobSchedulerHostedService`
|
||||
do this weekly, in-process (refinement-phase-7).
|
||||
- The **irreversible `process` step is always an explicit admin action.** No job, no schedule, no retry
|
||||
loop may trigger a real transfer.
|
||||
|
||||
Which is why `POST batches` is unwired (the job does it) and `POST batches/{id}/process` is unwired (the
|
||||
console has no button yet — that is the gap, and it is deliberate that nothing automated fills it).
|
||||
|
||||
## The money rules
|
||||
|
||||
- **`UNIQUE` on booking id: one payout per booking, ever.** The DB constraint is the authority; the handler
|
||||
does not rely on an `if`.
|
||||
- **The net balance is SIGNED and must never be clamped to zero.** A nurse with a clawback larger than
|
||||
their eligible earnings has a **negative** `netAmountIrr`. Rendering it as 0 tells them they have nothing
|
||||
owing when in fact they owe. The client displays the signed value.
|
||||
- **Clawback netting is whole-clawback greedy**, never partial: a clawback either fits in this batch or
|
||||
waits for the next. See [refunds.md](refunds.md).
|
||||
- **Payout dates are resolved against the bank-holiday calendar server-side.** The client never computes
|
||||
one — see [admin.md](admin.md). `nurse_payout_interval_days` and `payout_satna_threshold_irr` are config;
|
||||
the threshold selects PAYA vs SATNA.
|
||||
- **A verified primary IBAN gates payment, not accrual.** `EligibleNurseEarningsDto.hasVerifiedPrimaryIban`
|
||||
is what the console checks; a nurse without one accrues a balance and is not paid. See
|
||||
[nurse.md](nurse.md).
|
||||
- The IBAN is **always masked** on every read model, nurse-side and admin-side alike.
|
||||
|
||||
## A resolved drift
|
||||
|
||||
`payouts/apis/clientApi.ts` states that `NursePayoutHistoryDto` "carries **no** `failureReason` — that field
|
||||
lives on the admin-only `PayoutDto` … until REQ-025 adds it". **The live wire has it**: `failureReason` is
|
||||
on `NursePayoutHistoryDto` *and* `NursePayoutDetailDto` *and* `PayoutDto`. The comment predates the
|
||||
delivery; a `failed` payout can show its reason to the nurse today.
|
||||
|
||||
The client also sends `Idempotency-Key` on process/retry. **The server does not read it there** — only
|
||||
`PaymentsController` and `CheckoutBnplController` do. Harmless (those writes are idempotent by
|
||||
constraint), but the header is decorative on this domain. See
|
||||
[../api-contract.md](../api-contract.md#idempotency).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `PayoutStatus` | `pending` `submitted` `paid` `failed` |
|
||||
| `PayoutBatchStatus` | `draft` `processing` `partially_failed` `completed` `failed` |
|
||||
| `EarningsState` | `pending` `eligible` `paid` `clawback_applied` |
|
||||
|
||||
`PayoutStatus` and `PayoutBatchStatus` are verified identical to `Entities/Payouts/*.cs` and are
|
||||
forward-only through `PayoutStatusTransitions`. `partially_failed` is a real batch outcome — some
|
||||
destinations settled, some did not — and drives the single-payout `retry`; `Seams:BankTransfer:FailIban`
|
||||
exists to make it testable.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-025 | delivered | The four-bucket balance, per-booking earnings list and payout detail are all served (including `failureReason`, above) |
|
||||
| REQ-036 | deferred | No single-payout preview, no `holidayShifted` flag, no record-transfer-reference route → 1 phantom |
|
||||
| REQ-053 | open | No payout forecast (next batch date + expected eligible amount). The nurse dashboard's forecast line renders nothing on the real path |
|
||||
@@ -0,0 +1,57 @@
|
||||
# profiles — customer and nurse profiles
|
||||
|
||||
> Client seam `client/src/services/profiles/` · `USE_PROFILES_MOCK = false` (**real**) · 7 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
One client domain over two server controllers, because the client's profile screens are one feature with
|
||||
two actor variants. Identity and roles come from [auth.md](auth.md)'s `/me`, not from here.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/customer_profiles/me` | wired |
|
||||
| POST | `/api/v1/customer_profiles/upsert` | wired |
|
||||
| POST | `/api/v1/customer_profiles/avatar` | **unwired** — the client's avatar upload calls the nurse route only |
|
||||
| GET | `/api/v1/nurse_profiles/me` | wired |
|
||||
| POST | `/api/v1/nurse_profiles/upsert` | wired |
|
||||
| POST | `/api/v1/nurse_profiles/avatar` | wired |
|
||||
| POST | `/api/v1/nurse_profiles/set_accepting_bookings` | wired |
|
||||
|
||||
All `[Authorize]`. No phantoms.
|
||||
|
||||
> **`customer_profiles/avatar` exists and is unused.** The nurse avatar upload is wired; the customer one
|
||||
> is not, so a customer cannot set a photo even though the endpoint is live. A one-line client gap, not a
|
||||
> contract gap.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Avatar upload is `multipart/form-data`.** This is the one place the client must **not** set
|
||||
`Content-Type` — `clientFetch` detects a `FormData` body and lets the browser write the multipart
|
||||
boundary. A manual JSON content-type there breaks the upload. See
|
||||
[../api-contract.md](../api-contract.md).
|
||||
- **`avatarUrl` is served by the object-storage seam.** `Seams:ObjectStorage:Provider` = `local` (default,
|
||||
writes under `RootPath`) or `s3`. In the deployment `RootPath` is a **named docker volume** — without it,
|
||||
uploads vanish on the next `up --build`. See [../topology.md](../topology.md).
|
||||
- **`isAcceptingBookings` is a real toggle with real consequences.** It is one of the four conditions the
|
||||
search index's `is_searchable` requires — flipping it off removes the nurse from discovery. See
|
||||
[search.md](search.md).
|
||||
- **`isVerified` on `NurseProfileDto` is derived, never writable.** It is written **only** by the
|
||||
verification finalize transaction when the aggregate reaches `approved`. Never set it from a profile
|
||||
write. See [verification.md](verification.md).
|
||||
- **`averageRating` / `totalReviews` / `totalCompletedBookings` are recomputed from source**, not
|
||||
incremented. A moderation change that hides a review recomputes the aggregate. See
|
||||
[reviews.md](reviews.md).
|
||||
- **`specializationsJson` is a string containing JSON**, like the other `*Json` fields on this wire.
|
||||
- `preferredLanguage` on `CustomerProfileDto` (REQ-007, delivered) alongside the name update.
|
||||
- `defaultEmergencyContactName`/`Phone` are the customer-level fallback used when a booking supplies none.
|
||||
|
||||
## Enums
|
||||
|
||||
None of its own. `educationLevel` and `educationField` are free strings. Gender lives on the user
|
||||
(see [auth.md](auth.md)), not on the profile.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. REQ-006 (avatar/object-storage upload route) and REQ-007 (name + preferred language) were both
|
||||
delivered in refinement-phase-3.
|
||||
@@ -0,0 +1,88 @@
|
||||
# refunds — cancellation, reversal, clawbacks, invoices
|
||||
|
||||
> Client seam `client/src/services/refunds/` · `USE_REFUNDS_MOCK = true` (**mock is primary**) · 8 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Money going back. The customer half is thin and real; the admin half is largely deferred, which is why the
|
||||
seam is mocked.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/refunds/by_booking/{bookingId}` | `[Authorize]` | wired — the customer's refund for a booking |
|
||||
| GET | `/api/v1/refunds/{id}/status` | `[Authorize]` | wired |
|
||||
| GET | `/api/v1/admin_refunds` | admin · `sensitive` | wired · paginated |
|
||||
| POST | `/api/v1/admin_refunds` | admin · `sensitive` | **unwired** — creates **and executes** in one call; the client wants preview → approve (REQ-035) |
|
||||
| POST | `/api/v1/admin_refunds/{id}/confirm_settlement` | admin · `sensitive` | **unwired** — the manual-channel settlement confirm |
|
||||
| POST | `/api/v1/admin_refunds/{id}/mark_failed` | admin · `sensitive` | **unwired** |
|
||||
| POST | `/api/v1/admin_clawbacks/{id}/write_off` | admin · `sensitive` | **unwired** — no console screen |
|
||||
| POST | `/api/v1/admin_invoices` | admin · `sensitive` | **unwired** — owner-issue an invoice (REQ-018's fallback path) |
|
||||
|
||||
Also wired by this domain, documented with their owner ([bookings.md](bookings.md)):
|
||||
`POST bookings/{id}/cancel` (cancel **and** refund, REQ-019) and
|
||||
`GET bookings/{id}/cancellation_policy` (the pre-cancel preview, REQ-020).
|
||||
|
||||
### Phantom — 4
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/admin_refunds/preview` | REQ-035 | Deferred. Today's single `POST admin_refunds` creates+executes with no preview step |
|
||||
| `POST /api/v1/admin_refunds/{id}/approve` | REQ-035 | Deferred |
|
||||
| `POST /api/v1/admin_refunds/{id}/reject` | REQ-035 | Deferred |
|
||||
| `GET /api/v1/refunds/my` | REQ-048 | The customer's "all my refunds" list. The wallet «استردادها» tab renders empty on the real path |
|
||||
|
||||
## The money rules
|
||||
|
||||
Four, and none of them are the client's to compute:
|
||||
|
||||
1. **A refund is a reversal leg, not a deletion.** `ledger_entries` is append-only; every posting group
|
||||
balances. Nothing is ever edited or removed.
|
||||
2. **Fee-leg decomposition is served, not derived.** `RefundStatusDto` carries
|
||||
`platformFeeRefundedIrr` and `nursePayoutRefundedIrr` separately (REQ-021, delivered) — a partial refund
|
||||
does not necessarily refund the commission and the payout in the same proportion. Never split a total.
|
||||
3. **Pre-payout and post-payout fork.** If the nurse has not been paid, the payout leg is simply reduced.
|
||||
If they have, the platform raises a **clawback**, which the payout engine nets against the nurse's next
|
||||
batch — whole-clawback greedy netting, never a partial. See [payouts.md](payouts.md).
|
||||
4. **VAT is on commission only**, so a refund's VAT leg follows the commission leg, never the payout.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`refundChannel` decides the mechanics and is not a display detail**: `psp_card` reverses through the
|
||||
acquirer, `bnpl_revert` calls the BNPL provider's revert (see [bnpl.md](bnpl.md)), `manual` is a bank
|
||||
transfer an admin confirms with `confirm_settlement`. Each has a different ETA, and
|
||||
`expectedCustomerRefundEta` is server-computed per channel — the client displays it verbatim.
|
||||
- **`CancellationPolicyPreviewDto` is the complete pre-cancel answer** (REQ-020, delivered):
|
||||
`cancellable`, `cancellationPolicyCode`, `refundPercentageApplied`, `feePercentage`, `refundAmountIrr`,
|
||||
`feeAmountIrr`, `refundableAmountIrr`, the two fee legs, `appliesTo`, `leadTimeLabel`, `refundChannel`,
|
||||
`expectedCustomerRefundEta`, and a **per-session** breakdown. The client shows it and asks for
|
||||
confirmation; it computes none of it.
|
||||
- **Cancellation tiers are config rows**, not code — `cancellation_tier1/2/3_refund_rate` in
|
||||
[admin.md](admin.md) — and the applied rate is **snapshotted onto the refund** at compute time, so a
|
||||
later tier change is not retroactive.
|
||||
- **`refundPercentage` on `CancellationPolicyDto` is a rate, `refundPercentageApplied` on the refund is the
|
||||
snapshot.** They can legitimately differ; that is the point.
|
||||
- The crash-window fix in refinement-phase-6 wired the previously unreachable BNPL and manual settlement
|
||||
paths — a refund created against those channels now actually clears.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `RefundStatus` | `requested` `approved` `processing` `succeeded` `failed` `rejected` |
|
||||
| `RefundChannel` | `psp_card` `bnpl_revert` `manual` |
|
||||
| `ClawbackStatus` | `pending` `recovered` `written_off` |
|
||||
| `CancellationPolicyCode` | `free_24h` `partial_under_24h` `customer_no_show` |
|
||||
| `CancellationLeadTime` | `gt_24h` `lt_24h` `started` |
|
||||
| `CancellationScope` | `whole_booking` `remaining_sessions` |
|
||||
| `CancelReasonCategory` *(client)* | `changed_mind` `schedule_conflict` `found_other_care` `other` |
|
||||
|
||||
The first three are verified identical to `Entities/Refunds/RefundStatus.cs` and `ClawbackStatus.cs`.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-035 | deferred | No admin preview / approve / reject → 3 phantom routes. The one live route creates and executes together, which the console will not call |
|
||||
| REQ-048 | open | No customer "all my refunds" list → 1 phantom. The wallet tab renders empty on the real path |
|
||||
| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the partner per-booking commission invoice list is deferred. See [partner-center.md](partner-center.md) |
|
||||
@@ -0,0 +1,85 @@
|
||||
# reviews — ratings, tags, moderation
|
||||
|
||||
> Client seam `client/src/services/reviews/` · `USE_REVIEWS_MOCK = false` (**real**) · 8 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Customer reviews of a completed booking, pre-screened and then human-moderated before they are public.
|
||||
Clinical care records are a different domain — [patient-records.md](patient-records.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/bookings/{bookingId}/review_eligibility` | `[Authorize]` | wired |
|
||||
| GET | `/api/v1/bookings/{bookingId}/my_review` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/bookings/{bookingId}/review` | `[Authorize]` | wired |
|
||||
| GET | `/api/v1/nurses/{nurseProfileId}/reviews` | **anonymous** | wired · paginated (**`page`/`pageSize`**) |
|
||||
| GET | `/api/v1/nurses/{nurseProfileId}/review_tags` | **anonymous** | **unwired** — the client reads tags off the review rows |
|
||||
| PATCH | `/api/v1/reviews/{reviewId}/status` | `[Authorize]` | wired — the moderation decision |
|
||||
| POST | `/api/v1/reviews/{reviewId}/tags` | `[Authorize]` | **unwired** — no console screen edits tags |
|
||||
| GET | `/api/v1/admin/reviews/moderation_queue` | admin | wired · paginated |
|
||||
|
||||
No phantoms. Note `PATCH` — one of only two `PATCH` verbs in the whole API (the other is on
|
||||
[partner-center.md](partner-center.md)); everything else mutates with `POST`. Note also the hardcoded
|
||||
nested `admin/reviews/` route segment — see [index.md](index.md#route-shape-exceptions).
|
||||
|
||||
## Aggregates are recomputed, never incremented
|
||||
|
||||
`averageRating`, `totalReviews` and `totalCompletedBookings` on the nurse profile are **recomputed from
|
||||
source** whenever a review's moderation status changes. Hiding a published review must lower the average;
|
||||
an incrementing counter cannot do that correctly. Never `+= 1` a review aggregate. See
|
||||
[profiles.md](profiles.md).
|
||||
|
||||
## The moderation gate
|
||||
|
||||
```
|
||||
submit ──AI pre-screen──▸ pending_moderation ──admin──▸ published
|
||||
│ │
|
||||
banned word hit unpublish ──▸ hidden
|
||||
▼
|
||||
rejected
|
||||
```
|
||||
|
||||
- **A review is not public on submit.** `IReviewModerationService` pre-screens; by default clean text
|
||||
returns a human-review **flag**, keeping the gate on. `Seams:ReviewModeration:AutoApproveClean` makes
|
||||
clean text auto-publish; `BannedWords` (default `scam`, `fraud`, `کلاهبردار`) forces `reject`. Both are
|
||||
mock knobs — a real classifier ignores them.
|
||||
- **A low rating raises a support alert** (`low_rating`), linked as `lowRatingAlertId` on the queue item.
|
||||
See [admin.md](admin.md).
|
||||
- **`unpublish` is a distinct action from `hide`** in the client's `ModerationAction` union even though both
|
||||
land on `hidden` — the audit trail records which was chosen.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`ReviewEligibilityDto` is `{ canReview, reason }`** and the reason is a **code**, not a message:
|
||||
`not_completed` `already_reviewed` `not_owner` `not_found`. The client maps it to an i18n key. Eligibility
|
||||
is server-decided — the client must not infer it from booking status.
|
||||
- **The author is masked** (REQ-026, confirmed): a public review carries `authorMasked`, never a full name.
|
||||
This is a privacy decision, not a display choice.
|
||||
- **`tagCodes` is on `ModerationQueueItemDto`** (REQ-037, delivered) so the queue shows what the reviewer
|
||||
tagged without a second fetch.
|
||||
- **Review tags are codes; labels are i18n keys.** Never render a tag code, and never build a display
|
||||
string from one.
|
||||
- `moderationReason` is admin-facing free text and is **not** returned on the public review read.
|
||||
- The public reviews list is **anonymous and paginated with `page`/`pageSize`** (camelCase — unlike
|
||||
`search/nurses`, which uses `page_size`). See [../api-contract.md](../api-contract.md#pagination).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `ModerationStatus` | `pending_moderation` `published` `hidden` `rejected` |
|
||||
| `ModerationAction` | `publish` `hide` `reject` `unpublish` |
|
||||
| `ReviewIneligibilityReason` | `not_completed` `already_reviewed` `not_owner` `not_found` |
|
||||
|
||||
`ModerationStatus` and `ModerationAction` are both defined in
|
||||
`Entities/Reviews/ReviewModerationStatus.cs` and verified identical to the client's unions — the file
|
||||
carries the four states and the four actions together.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-026 | delivered | Eligibility + my-review-for-booking reads, and masked-author confirmation |
|
||||
| REQ-037 | delivered | `tagCodes` on the moderation queue item |
|
||||
| REQ-040 | open | No `topReviewTag` on the search index row — the C2 card renders without the tag chip. Owned by [search.md](search.md) |
|
||||
@@ -0,0 +1,81 @@
|
||||
# search — nurse discovery
|
||||
|
||||
> Client seam `client/src/services/search/` · `USE_SEARCH_MOCK = false` (**real**) · 2 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Two endpoints, both anonymous, both reading a **projected index** rather than joining live tables.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/search/nurses` | **anonymous** | wired · paginated — **`page` + `page_size`** |
|
||||
| GET | `/api/v1/nurses/{nurseId}/profile` | **anonymous** | wired |
|
||||
|
||||
No phantoms. `POST /api/v1/admin_search/rebuild_index` is the index rebuild one-shot and lives in
|
||||
[admin.md](admin.md); `nurses/{id}/reviews` and `/review_tags` are in [reviews.md](reviews.md);
|
||||
`nurses/{id}/trust_badge` is in [verification.md](verification.md).
|
||||
|
||||
## The one endpoint with snake_case query params
|
||||
|
||||
`GET /api/v1/search/nurses` is the **only** endpoint whose query parameters are snake_case:
|
||||
|
||||
```
|
||||
service_category_id city_id district_id nurse_gender min_price max_price page page_size
|
||||
```
|
||||
|
||||
`service_category_id` and `city_id` are required. This matters because it is also the only endpoint where
|
||||
paging is declared as **`page_size`**, not `pageSize` — and model binding is case-insensitive, not
|
||||
separator-insensitive, so sending `pageSize` here binds nothing and silently yields the default page size.
|
||||
The client's `searchClientApi` already sends `page_size` correctly. See
|
||||
[../api-contract.md](../api-contract.md#pagination).
|
||||
|
||||
## `is_searchable` — the four conditions
|
||||
|
||||
A nurse variant appears in results only when **all four** hold. Anything that flips one of them must
|
||||
maintain the index in the same unit of work (`ISearchIndexMaintainer`):
|
||||
|
||||
1. the nurse's verification aggregate is `approved` — [verification.md](verification.md)
|
||||
2. `isAcceptingBookings` is true — [profiles.md](profiles.md)
|
||||
3. the variant is active — [catalog.md](catalog.md)
|
||||
4. the nurse has at least one service area covering the queried city — [service-areas.md](service-areas.md)
|
||||
|
||||
**`districtId = null` means whole-city on both sides of the match.** A customer filtering by district *D*
|
||||
must see a nurse whose area names *D* **and** a nurse whose area is whole-city. A query that drops the
|
||||
nulls silently hides every whole-city nurse. See [geography.md](geography.md).
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **The index row is denormalised** (REQ-012, delivered): `nurseName`, `avatarUrl` and `distanceKm` are on
|
||||
`NurseSearchResultDto`, so the result card needs no per-row fetch. `price` is a **digit string**.
|
||||
- **`distanceKm` is nullable** — null when either side has no resolved coordinates. The card omits the
|
||||
distance chip rather than showing 0.
|
||||
- **`GET nurses/{id}/profile` aggregates** identity, bio, specialties, the full services list and the
|
||||
latest review into `NursePublicProfileDto` (REQ-012). One request builds the whole profile screen.
|
||||
- **`attributeChips` is a server-composed display list**, not a code vocabulary — render it, do not map it.
|
||||
- **`inoMembership` on the public profile is a boolean summary** of the INO verification step, not the step
|
||||
itself. The per-step detail is the still-open REQ-043.
|
||||
- The index is maintained **inline inside each source write's transaction**, not by a background job — so a
|
||||
profile change is visible in search immediately, and a failed index write fails the source write.
|
||||
- `Search:Backend` selects the implementation: unset or `sql` → `SqlNurseSearch`. Any other value **throws
|
||||
at startup** — Elasticsearch is deferred, and the config fails loudly rather than silently degrading.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `NurseGender` *(filter + result field)* | `male` `female` |
|
||||
| `SearchSort` *(client)* | `rating` — the only sort implemented |
|
||||
|
||||
`nurse_gender` accepts `male`/`female`; **omit the param for "any"** — there is no `any` value on this
|
||||
filter, unlike `requiredCaregiverGender` on a booking request.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-040 | open | No `topReviewTag` on the index row. The C2 card renders without the tag chip |
|
||||
| REQ-041 | open | No free-text `q` over nurse/variant/category names. Discovery is filter-only |
|
||||
| REQ-042 | open | `NursePublicProfileDto` has no `nurseGender` — the *index row* has it, the profile does not, so the C3 screen cannot show the gender chip |
|
||||
| REQ-066 | open, **narrower than filed** | `search/nurses` is **already anonymous**. What is missing is the rate limit — `SearchController` carries no `[EnableRateLimiting]`, so guest browse falls to the 100/min global per-IP limiter |
|
||||
| REQ-067 | open, **narrower than filed** | `nurses/{id}/profile` is **already anonymous**. What is missing is the privacy review of the payload for unauthenticated callers |
|
||||
@@ -0,0 +1,53 @@
|
||||
# service-areas — nurse coverage
|
||||
|
||||
> Client seam `client/src/services/serviceAreas/` · `USE_SERVICE_AREAS_MOCK = false` (**real**) · 3 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Where a nurse will travel. Three endpoints, one rule that everything else depends on.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/nurse_service_areas/list` | wired · paginated |
|
||||
| POST | `/api/v1/nurse_service_areas/add` | wired |
|
||||
| DELETE | `/api/v1/nurse_service_areas/remove/{id}` | wired |
|
||||
|
||||
All `[Authorize]`. No phantoms. The domain maps 1:1. There is no update — coverage is add/remove.
|
||||
|
||||
## `districtId = null` means whole-city
|
||||
|
||||
A service area is `(cityId, districtId?)`. **`districtId = null` is the affirmative claim "I cover this
|
||||
entire city"** — not missing data, not "no district".
|
||||
|
||||
The consequences reach three other domains:
|
||||
|
||||
- **Search** must match a whole-city area against a district-filtered query, in both directions. A query
|
||||
that drops nulls silently hides every whole-city nurse. See [search.md](search.md).
|
||||
- **`is_searchable`** requires at least one area covering the queried city — coverage is one of its four
|
||||
conditions. See [search.md](search.md).
|
||||
- **Geography** owns the ids and the same null convention. See [geography.md](geography.md).
|
||||
|
||||
The client models this as a **single control**: the coverage editor offers "whole city" as a first-class
|
||||
choice alongside individual districts, so a nurse can never accidentally express it as an empty district
|
||||
list. Do not reintroduce a two-step "city, then optionally districts" flow — an empty selection is
|
||||
ambiguous in a way `null` is not.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **A duplicate area is a `409`.** Adding `(city, null)` when district rows for that city already exist —
|
||||
or the reverse — is a conflict the server resolves; the client surfaces it rather than pre-checking.
|
||||
- **Removing the last area for a city removes the nurse from search in that city** in the same
|
||||
transaction, via `ISearchIndexMaintainer`. There is no lag and no reconciliation job.
|
||||
- Coverage is independent of `isAcceptingBookings`: a nurse can keep coverage while pausing bookings, and
|
||||
both are conditions of `is_searchable`.
|
||||
- `list` is paginated even though the practical row count is small — the platform paginates every
|
||||
unbounded list without exception.
|
||||
|
||||
## Enums
|
||||
|
||||
None. Both fields are geography ids; `districtId` is nullable and the null is meaningful.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. REQ-008/REQ-009 concern addresses, not coverage — see [addresses.md](addresses.md).
|
||||
@@ -0,0 +1,91 @@
|
||||
# tickets — coordination, support, emergency
|
||||
|
||||
> Client seam `client/src/services/tickets/` · `USE_TICKETS_MOCK = false` (**real**) · 11 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Threaded messaging between customer, nurse and staff. Also the emergency channel. The in-app notification
|
||||
feed is a separate domain — [notifications.md](notifications.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/tickets` | wired · paginated · `bookingId` filter |
|
||||
| POST | `/api/v1/tickets` | wired |
|
||||
| GET | `/api/v1/tickets/{id}` | wired — **stamps `last_read_at`** |
|
||||
| POST | `/api/v1/tickets/{id}/messages` | wired · optional `clientMessageId` |
|
||||
| POST | `/api/v1/tickets/{id}/close` | wired |
|
||||
| POST | `/api/v1/tickets/{id}/reopen` | wired |
|
||||
| POST | `/api/v1/tickets/emergency` | **unwired** — no emergency entry point in the UI yet |
|
||||
| POST | `/api/v1/tickets/{id}/participants` | **unwired** |
|
||||
| DELETE | `/api/v1/tickets/{id}/participants/{userId}` | **unwired** |
|
||||
| GET | `/api/v1/admin/tickets` | wired · paginated — the staff queue |
|
||||
| GET | `/api/v1/admin/tickets/{id}` | wired — the staff thread, **internal notes visible** |
|
||||
|
||||
All `[Authorize]`; the two `admin/tickets` routes are `DynamicPermission`. Note the hardcoded nested
|
||||
`admin/` route segment — see [index.md](index.md#route-shape-exceptions).
|
||||
|
||||
### Phantom — 1
|
||||
|
||||
| Client call | REQ | Live? |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/tickets/{id}/assign` | REQ-063 | **Yes — this domain's mock is off.** Gated behind `TICKET_LIFECYCLE_ENABLED`, default **off**, so nothing calls it today |
|
||||
|
||||
> **`tickets/constants.ts` is stale on this.** It says the gate is off because "the backend has no
|
||||
> close/reopen/assign routes yet (REQ-063)". `close` and `reopen` **exist and are wired.** Only `assign`
|
||||
> is missing. The gate could be turned on for close/reopen alone.
|
||||
|
||||
## `isInternal` is a query-layer boundary
|
||||
|
||||
The hardest rule in this domain, and the easiest to get wrong in a UI:
|
||||
|
||||
- `TicketMessageDto` carries `isInternal`, so the field **is** on the wire shape.
|
||||
- **The filtering is not.** A non-staff caller's thread query never returns an internal row, and a
|
||||
non-staff caller can never *set* one. Both are enforced at the query layer, in the handler — **never in
|
||||
the UI**.
|
||||
- Consequence for the client: it must not model internal notes as "rows to hide". They do not arrive. A
|
||||
client-side filter would be a second, weaker gate that hides a leak rather than preventing one.
|
||||
- Consequence for the server: any new ticket read must repeat the filter. There is no global interceptor
|
||||
doing it.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Unread is computed server-side against `last_read_at`**, which is stamped **when the participant
|
||||
fetches the user-facing thread** (`GET /tickets/{id}`) — not by a separate mark-read call. So opening a
|
||||
thread is what clears its badge. `unreadCount` counts non-internal messages from *others* after that
|
||||
stamp, and is **0 on the admin queue** by definition.
|
||||
- **`clientMessageId` is optimistic-send idempotency** (REQ-028): a retried send with the same key returns
|
||||
the original message and echoes the key back on `PostMessageResult`. This is what makes the client's
|
||||
retry-in-place send safe. It is **not** the `Idempotency-Key` header — it is a body field, and it is the
|
||||
only place in the API that works this way.
|
||||
- **The message author is a role label, not a name** — confirmed intentional, for privacy.
|
||||
`TicketMessageDto` carries `senderId` only; the client derives the author label from the participant
|
||||
role. No raw identity is exposed, and none should be added.
|
||||
- **`referenceCode` is the human-facing id** shown to users and quoted in support. Treat it as opaque.
|
||||
- `bookingId` and `refundId` on the summary link a ticket to what it is about; the `bookingId` query
|
||||
filter is how the client jumps from a booking to its coordination thread.
|
||||
- Ticket bodies are **encrypted at rest** (refinement-phase-9).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values | Note |
|
||||
| --- | --- | --- |
|
||||
| `TicketStatus` | `open` `closed` | matches `Entities/Messaging/TicketCodes.cs` |
|
||||
| `TicketCategory` | `coordination` `support` `refund` `emergency` | matches |
|
||||
| `TicketAuthorRole` | `customer` `nurse` `admin` **`system`** | **the one cross-side mismatch in the API** |
|
||||
| `MessageSendStatus` *(client-only)* | `sent` `sending` `failed` | optimistic-send UI state, never on the wire |
|
||||
|
||||
> **`system` is client-only.** `TicketCodes` defines `customer`, `nurse`, `admin`. The client's
|
||||
> `TicketAuthorRole` adds `system` for platform-generated messages. Widening a union on the *reading* side
|
||||
> is safe — the client can render a value the server never sends — but it means a reader of the client
|
||||
> types would wrongly conclude the server emits `system`. Either the server should define it or the client
|
||||
> should drop it; today it is a documented asymmetry.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-028 | delivered | `unreadCount` + `lastMessageAt` on the summary, `bookingId` filter, `clientMessageId` dedupe, role-label authors — all present |
|
||||
| REQ-059 | open | No last-message preview and no author role on the summary, and no unread-*total* read. The real inbox shows subject + status + time with no preview |
|
||||
| REQ-060 | deferred | No message photo attachments. The affordance is designed and gated off |
|
||||
| REQ-063 | open, **narrower than filed** | `close` and `reopen` are delivered and wired. Only `assign` is missing → 1 phantom |
|
||||
@@ -0,0 +1,117 @@
|
||||
# verification — the nurse trust pipeline
|
||||
|
||||
> Client seam `client/src/services/verification/` · `USE_VERIFICATION_MOCK = true` (**mock is primary**) · 17 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The largest domain, and the one the whole marketplace's trust claim rests on. A nurse is not bookable until
|
||||
this pipeline says so.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Nurse-facing
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/nurse_verification` | wired — **the one cached status query** |
|
||||
| POST | `/api/v1/nurse_verification/submit` | wired |
|
||||
| POST | `/api/v1/nurse_verification/steps/{stepId}/upload_url` | wired — presigned PUT |
|
||||
| POST | `/api/v1/nurse_verification/steps/{stepId}/documents` | wired — confirm the upload |
|
||||
| POST | `/api/v1/nurse_verification/steps/identity_kyc/run` | wired |
|
||||
| POST | `/api/v1/nurse_verification/steps/shahkar_match/run` | wired |
|
||||
| POST | `/api/v1/nurse_verification/steps/bank_account_verification/run` | wired |
|
||||
| POST | `/api/v1/nurse_verification/credential_details` | **unwired** — the write half of REQ-011; the client has no read-back (REQ-056) |
|
||||
|
||||
### Public
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/nurses/{nurseId}/trust_badge` | **anonymous** | wired |
|
||||
|
||||
### Admin — all `DynamicPermission` + `sensitive`
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/admin_verifications` | wired · paginated |
|
||||
| GET | `/api/v1/admin_verifications/{nurseVerificationId}` | wired |
|
||||
| POST | `/api/v1/admin_verifications/steps/{stepId}/decide` | wired — **per-step** decision |
|
||||
| POST | `/api/v1/admin_verifications/{nurseVerificationId}/suspend` | **unwired** |
|
||||
| POST | `/api/v1/admin_verifications/scan_expiring` | **unwired** — an ops one-shot; the scheduler runs it |
|
||||
| GET | `/api/v1/admin_verification_step_types` | **unwired** — the step catalogue is data-driven; no editor |
|
||||
| POST | `/api/v1/admin_verification_step_types` | **unwired** |
|
||||
| DELETE | `/api/v1/admin_verification_step_types/{id}` | **unwired** |
|
||||
|
||||
### Phantom — 3
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/admin_verifications/documents/{id}/url` | REQ-034 | Deferred. No on-demand signed document URL, so the queue cannot open an uploaded document |
|
||||
| `POST /api/v1/admin_verifications/{id}/approve` | REQ-034 | Deferred. Today approval is **per-step** only — the aggregate flips when the last required step passes |
|
||||
| `POST /api/v1/admin_verifications/{id}/reject` | REQ-034 | Deferred |
|
||||
|
||||
## The two rules that must not be broken
|
||||
|
||||
1. **`status` is the source of truth; `nurse_profiles.is_verified` is derived.** The boolean is written
|
||||
**only** by the finalize transaction when the aggregate reaches `approved`, as one guarded
|
||||
cross-aggregate flip: load both tracked, mutate through one pure domain helper, commit once. Never set
|
||||
`is_verified` from a profile write, a controller, or out of band. See [profiles.md](profiles.md).
|
||||
2. **The step catalogue is data, not code.** `verification_step_types` rows define which steps exist,
|
||||
which are required, and which are automated. Adding a step is a data change. The client must not
|
||||
hardcode the step list — it renders whatever `VerificationStatusDto.steps` contains.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`VerificationStatusDto` is `{ status, isBookable, blockingSteps, steps }`** — the server computes
|
||||
`isBookable` and names the `blockingSteps`. The client does not derive bookability from the step array.
|
||||
This is why the client keeps **one cached status query** and every screen reads it.
|
||||
- **`expiresAt` on a step is real.** MoH competency licences and INO membership lapse; `scan_expiring`
|
||||
reverts a lapsed step to `expired`, which re-gates bookability. `expired` is therefore a normal state,
|
||||
not an error.
|
||||
- **Document upload is a two-step presign flow**: `upload_url` returns a presigned PUT, the client uploads
|
||||
**directly to storage**, then `documents` confirms. The document bytes never transit the API.
|
||||
`Seams:ObjectStorage:PresignExpirySeconds` (900) bounds the window.
|
||||
- **The three `run` steps are seams, each independently switchable** —
|
||||
`Seams:IdentityKyc:Provider`, `Seams:Shahkar:Provider`, `Seams:BankOwnership:Provider`, all `mock` by
|
||||
default, all `finnotech` for the real bridge (sharing `Seams:Finnotech` credentials). Designated test
|
||||
values make each failure path reachable: `SharedSimPhone` `09120000000`,
|
||||
`MismatchNationalId` `1111111111`, `FailNationalId` `0000000000`,
|
||||
`MismatchIban` `IR0000…0000`.
|
||||
- **`shahkar_match` failing as *shared SIM* is a distinct outcome** from a plain phone↔national-id
|
||||
mismatch, and the UI must say which — a shared family SIM is a common, innocent case.
|
||||
- **National id and licence numbers are encrypted at rest**; the admin queue sees them only where the
|
||||
decision requires it.
|
||||
- The nurse's INO membership field is **locked once submitted** (it feeds the public trust badge).
|
||||
|
||||
## Two vocabularies for one thing
|
||||
|
||||
`GET /me` reports a *nurse verification summary* using `not_started` `in_progress` `pending_review`
|
||||
`verified` `rejected`. This domain's aggregate uses `not_started` `pending` `in_review` `approved`
|
||||
`rejected` `suspended`. **They are two read models over the same source of truth, not a drift** — but they
|
||||
are not interchangeable strings. Map deliberately. See [auth.md](auth.md).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `VerificationStatus` (aggregate) | `not_started` `pending` `in_review` `approved` `rejected` `suspended` |
|
||||
| `VerificationStepStatus` | `not_started` `pending` `in_review` `passed` `failed` `expired` |
|
||||
| `StepTypeCode` | `identity_kyc` `shahkar_match` `moh_competency_license` `ino_membership` `criminal_record` `bank_account_verification` |
|
||||
| `CredentialType` | `moh_competency_license` `ino_membership` `criminal_record` |
|
||||
| `VerificationMethod` | `manual` `portal` `api` |
|
||||
| `BadgeState` *(client, from `TrustBadgeDto`)* | `verified` `unverified` `expired` |
|
||||
|
||||
The first two are C# enums serialised as snake_case codes (`Entities/Verification/VerificationStatus.cs`,
|
||||
`VerificationStepStatus.cs`); the rest are string constants in `VerificationStepTypeCodes.cs`. All verified
|
||||
identical to the client's unions.
|
||||
|
||||
`TrustBadgeDto` is `{ nurseId, isVerified, approvedAt, credentialTypes }` — a summary, with no per-step
|
||||
detail. That absence is REQ-043.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-034 | deferred | No nurse-grouped queue, no on-demand document URL, no whole-verification approve/reject → 3 phantom routes. This is the main reason the seam is mocked |
|
||||
| REQ-043 | open | `TrustBadgeDto` has no per-step detail (step codes + decision dates), so the public verification panel shows a summary only |
|
||||
| REQ-055 | open | No `submittedAt` on `VerificationStatusDto` — the real B6 screen omits the timestamp line |
|
||||
| REQ-056 | open | No nurse-facing read-back of submitted credential details. `credential_details` writes; nothing reads. The real form degrades to blank |
|
||||
| REQ-062 | open | No name/phone search and no per-status counts on the admin queue |
|
||||
Reference in New Issue
Block a user