frontend phase 5 & backend phase 12

This commit is contained in:
hamid
2026-07-09 03:05:14 +03:30
parent 465f75c29e
commit dc64472631
98 changed files with 11847 additions and 136 deletions
@@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## backend-phase-12 — BNPL: provider-financed installments (mocked) — 2026-07-09
- **Shipped:** `payments.BnplTransactions` (1:1 with `payment_transaction`, `UNIQUE(payment_transaction_id)`,
settle-split CHECK, forward-only `BnplStatus` machine); `Features/Bnpl/*` (eligibility/initiate/verify/settle/
revert/callback/status); `CheckoutBnplController` (customer) + `WebhooksBnplController` (anon, signed) +
`AdminBnplController` (admin), all rate-limited; `LedgerPosting.BnplSettle` (net-of-fee group w/
`bnpl_fee_expense`); extracted shared `Features/Bookings/BookingConversion` (used by b10 card + b12 settle).
- **Contracts:** dev/contracts/domains/bnpl.md + openapi snapshot refreshed (yes).
- **Mocked:** `IBnplProvider` (per `provider_code` via `IBnplProviderResolver`) + `ICurrencyNormalizer` → 🟡
(configurable mock commission %, non-instant `settled_at`). See reports/mocks-registry.md.
- **Gate:** build clean (0 new code warnings) / tests green (314 pass: 4 identity + 214 foundation + 96 api;
+14 new). Migration `BnplTransactions` created (not applied to a live DB this session — SQLite
`EnsureCreated` builds it for tests).
- **Handoff:** backend/handoff/after-backend-phase-12.md
- **Notes for frontend:** f11-b12 = the "pay with installments" checkout (`checkout_bnpl/eligibility`
`initiate` → provider redirect; declined → fall back to card), the customer order view (`checkout_bnpl/{id}`),
and the admin BNPL revert path with the ~710-day ETA. Money is IRR digit-strings; `settledAt` is nullable
(not instant). A BNPL revert opens a `refund_channel='bnpl_revert'` refund (read via `refunds/{id}/status`).
## backend-phase-11 — Refunds, invoices & nurse clawbacks — 2026-07-09
- **Shipped:** the reversal leg via one migration in the **`payments`** schema — **3 tables** `Refunds`
(fee-leg decomposition + `refund_channel` + `amount = fee_leg + payout_leg` CHECK + **nullable `ticket_id`, no FK**
@@ -0,0 +1,52 @@
# Handoff — after backend-phase-12 (BNPL: provider-financed installments)
**BNPL checkout is live.** A family can now pay for a booking with a provider-financed installment plan
(SnappPay / Digipay / Tara / Torob Pay). The decisive, verified truth: an Iranian provider-financed BNPL order
**settles the full booking amount to Balinyaar in one lump, net of the provider's merchant commission**, and the
provider owns the customer's installments and **100% of default risk**. So in our books a BNPL order is **a card
payment that lands net-of-fee** — one `bnpl_transactions` row (1:1 with its `payment_transaction`), a forward-only
`eligible → token_issued → verified → settled → reverted` state machine, and a settle that posts the card-capture
ledger legs **plus** a `bnpl_fee_expense` leg so escrow reflects the *net* cash. **The nurse's payout is invariant
to payment method.** We do **not** model the customer's repayment schedule.
## What the frontend (f11-b12) can now build
- **"Pay with installments" option at checkout** — `POST checkout_bnpl/eligibility` returns
`eligible`/`not_eligible`/`ceiling_exceeded` + the plan summary ("4 interest-free installments, provider-financed",
`installmentCount`, `creditCeilingIrr`). On anything but `eligible`, **fall back to card**.
- **Start the plan + provider handoff**`POST checkout_bnpl/initiate``token_issued` + `externalPaymentToken`
+ `redirectUrl` (send the customer to the provider). Carries an `Idempotency-Key` header.
- **Order status**`GET checkout_bnpl/{id}` (customer, own order only): status, gross/net/commission, the
**non-instant** `settledAt`, and the revert audit + async customer ETA.
- **Admin BNPL console**`POST admin_bnpl/{id}/verify|settle`, `POST admin_bnpl/{id}/revert` (full or partial),
`GET admin_bnpl/{id}`. The revert surfaces `expectedCustomerRefundEta` (~710 business days) and opens a
`refund_channel='bnpl_revert'` refund (visible via the b11 `GET refunds/{id}/status`).
## Live endpoints / contract
- Contract: **`dev/contracts/domains/bnpl.md`** (enums, DTO shapes, IRR digit-strings, nullable `settled_at`,
failure codes, the net-of-fee settle + customer↔provider↔Balinyaar refund routing). Machine schema:
`dev/contracts/openapi/swagger.v1.json` **refreshed**.
- All money is IRR integer, on the wire as a **digit-string**; `settled_at` is **nullable** (not instant);
`expectedCustomerRefundEta` is a **date**.
- Checkout is customer-scoped + **rate-limited**; the webhook is anonymous (signature-verified) + rate-limited;
admin endpoints are behind the admin policy + rate-limited.
## What is mocked / waiting
- **The provider + currency are mocked** behind **`IBnplProvider`** (per `provider_code` via
**`IBnplProviderResolver`**) and **`ICurrencyNormalizer`** — deterministic, no network; the settle commission is
a configurable mock % (`Seams:Bnpl:CommissionRate`, default 10%). See `reports/mocks-registry.md` for the exact
real-provider steps (SnappPay/Digipay verb sets, encrypted creds).
- **Settlement timing is not modelled as instant**`settled_at` is nullable and read from the settlement
(`Seams:Bnpl:SettlementInstant` toggles the mock). **b13 must not assume BNPL cash funds a payout.**
- **`bnpl_settlement_entries`** (tranched settlement) is **DEFERRED — modeled-but-not-built**; adding it later is a
purely additive migration.
- **Multi-provider routing / failover** is DEFERRED — one active route, config-driven selection.
- The revert reuses the **b11 refund path**; `provider_commission_reversed_amount` is left null there (reconciled
from the provider response later).
## Notes for the next backend phases
- **b13 (payouts):** the `settled_at`-gates-payout coupling lives here — add the
`require_bnpl_settlement_for_payout` config flag and gate a BNPL booking's payout on `bnpl_transactions.settled_at`
actually being set (never pay a nurse before Balinyaar holds the cash). The `nurse_payable` accrual is already
identical to the card path, so payout amounts need no BNPL-specific logic.
- **Shared conversion:** b10's booking-creation was extracted to `Features/Bookings/BookingConversion` — both the
card capture and the BNPL settle use it. Reuse it, don't fork.
@@ -12,6 +12,25 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
## frontend-phase-5-b6 — Nurse verification flow (trust engine) — 2026-07-09
- **Shipped:** `services/verification` domain (types/keys/constants/validation/apis[client+mock(primary)+seam]/
hooks/index) — ONE cached `status()` query drives B3+B6, every mutation invalidates it. The nurse
verification route subtree `nurse/verification/{page(B3),identity(B4),credentials(B5),review(B6)}` +
co-located `VerificationChecklist` / `verificationSteps` (data-driven step rendering, synthetic mobile step).
Two shared components with tests: `<DocumentUpload>` (client type/size validation, progress %, success/
retry, re-upload-on-reject, server-metadata truth + local-capture mode) and `<TrustBadge>` (verified/
unverified/expired off `--bal-*`, reused by f6). Publish gate wired on `nurse/services` (`PublishGate`
disabled until `approved`); trust badge on the nurse profile (own state from `ownBadgeState`). 6 new AppIcons,
4 route constants, `verification` i18n namespace (123 keys) in both locales. Honest copy: manual steps
never claim an automated authority check; a step reads "تاییدشده" only when `passed`.
- **Consumes:** dev/contracts/domains/verification.md + openapi/swagger.v1.json (backend-phase-6). Wire is
**camelCase**; action-style routes under `api/v1/nurse_verification/*` + `api/v1/nurses/{id}/trust_badge`.
- **Mocked client-side:** `services/verification` via `verificationMockApi` behind `USE_VERIFICATION_MOCK`
(**default true** — runs the whole journey standalone incl. a dev-only admin-decision sim, since b6 isn't
reachable here). One-line swap to `verificationClientApi`. See mocks-registry.
- **Gate:** npm run check green · npm run test:ci green (157 tests, +9) · en/fa in sync (123 verification keys).
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-011 nurse credential-details endpoint).
## frontend-phase-4-b5 — Catalog browse (Home A5) & nurse service builder (B7) — 2026-07-05
- **Shipped:** `services/catalog` domain (types/keys/constants/apis[client+mock+seam]/hooks/index) — the b5
catalog skeleton + nurse pricing layer. Hooks: `useServiceCategories`, `useCategoryOptionGroups` (both
@@ -132,3 +132,21 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
if it binds `page_size`, tell us and we'll switch the client (one line per list call).
- **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`.
- **Status:** open
## REQ-011 — Nurse-facing endpoint for structured professional-credential details — filed by frontend-phase-5-b6 — 2026-07-09
- **Need:** A nurse-facing command to submit the **structured** credential fields B5 collects alongside the
document uploads: `inoNumber` (شماره نظام پرستاری), `specialties` (string[] — stable codes + free-text),
and optionally `licenseNumber`, `issuingAuthority`, `holderName`, `issuedAt`, `expiresAt`. Proposed:
`POST api/v1/nurse_verification/credential_details` (or fold into the manual-step `documents` confirm body).
- **Why:** The b6 contract records these structured fields **only on the admin `decide`** — there is no
nurse-facing endpoint to capture them. B5 needs them at submission time (the INO number is the nurse's
primary registry key, specialties feed f6 search facets). Today the client collects them and the mock
persists them; the real `verificationClientApi.submitCredentialDetails` **no-ops** (the document uploads
it accompanies ARE contract-backed via `upload_url` → PUT → `documents`). Without this, a swap to the real
backend silently drops the INO number + specialties until an admin re-enters them.
- **Proposed shape:** `POST api/v1/nurse_verification/credential_details` body
`{ inoNumber, specialties: string[], licenseNumber?, issuingAuthority?, holderName?, issuedAt?, expiresAt? }`
`VerificationStatusDto`. Alternatively, extend the manual-step `documents` confirm body with these fields.
- **Also (minor):** the contract's `VerificationStepDto` has no `isRequired` — the client treats **every**
seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`.
- **Status:** open
@@ -0,0 +1,97 @@
# Backend phase 12 — BNPL: provider-financed installments (mocked) — report
**Mission:** let a family pay for a booking with a provider-financed BNPL plan, and record it correctly — a BNPL
order is, in Balinyaar's books, **a card payment that lands net-of-fee**.
## What was built
### Domain (`Baya.Domain/Entities/Bnpl/`)
- **`BnplTransaction`** — one row per order, **1:1 with its `payment_transaction`**. Guarded `Status` mutated only
through cohesive `MarkTokenIssued`/`MarkVerified`/`MarkSettled`/`MarkReverted`/`MarkCancelled`/`MarkFailed`;
`MarkSettled` enforces `settled = order commission` (≥0). Money is IRR `long`; `settled_at`,
`provider_commission_reversed_amount`, and all settle/revert amounts are nullable.
- **`BnplStatus`** + **`BnplTransitions`** — the forward-only state machine (`eligible → token_issued → verified →
settled → reverted/cancelled/failed`), the idempotency spine.
- **`BnplEligibilityStatus`** (`eligible`/`not_eligible`/`ceiling_exceeded`), **`BnplProviderCodes`**
(`snapppay`/`digipay`/`tara`/`torobpay`).
- **`LedgerPosting.BnplSettle`** — the net-of-fee group (card-capture legs **plus** `DEBIT bnpl_fee_expense /
CREDIT escrow_held`), one balanced `transaction_group_id`, `SourceRefType = bnpl_transaction`. Throws if the
capture legs don't reconcile.
### Application (`Baya.Application/Features/Bnpl/`)
- Queries: **`CheckBnplEligibilityQuery`** (records `eligibility_status` on a created/updated row),
**`GetBnplOrderStatusQuery`** (admin/customer, tenancy-scoped).
- Commands: **`InitiateBnplOrderCommand`** (token + `eligible → token_issued`, under
`lock(booking-request:{id}:payment)`, idempotency-keyed), **`VerifyBnplOrderCommand`**, **`SettleBnplOrderCommand`**
(net-of-fee ledger + booking conversion, under `lock(bnpl:{id}:settle)`), **`RevertBnplOrderCommand`** (reuses
b11 `CreateRefundCommand`), **`HandleBnplCallbackCommand`** (webhook dedup + dispatch by event type).
- **`BnplOrderInitializer`** (shared find-or-create of the pending `payment_transaction` + 1:1 BNPL row) and the
extracted **`BookingConversion`** helper (shared by b10 card capture + b12 settle — b10 was refactored to use it).
- New seams in `Contracts/Payments/`: **`IBnplProvider`** (full verb set, supersedes the b11 revert-only stub),
**`IBnplProviderResolver`**, **`ICurrencyNormalizer`**; `IBnplRepository` on `IUnitOfWork`.
### Infrastructure
- **`Persistence`**: `BnplTransactionConfig` (`payments.BnplTransactions`, `UNIQUE(payment_transaction_id)`,
`CK_BnplTransactions_SettleSplit`, filtered token index), `BnplRepository`, `UnitOfWork` wiring, one migration
**`BnplTransactions`**. `IRefundRepository.GetExternalRevertReferenceAsync` added for the revert audit.
- **`CrossCutting/Seams`**: `MockBnplProvider` (deterministic full state machine), `MockBnplProviderResolver`,
`MockCurrencyNormalizer`; `SeamOptions` extended (`BnplOptions` + `CurrencyOptions`); DI registration in
`AddCrossCuttingSeams`.
- **`API`**: `CheckoutBnplController`, `WebhooksBnplController`, `AdminBnplController`.
## What is now testable and exactly how (per phase §7)
Seed a `pending_payment`/accepted booking request with a known three-amount split and a `payment_gateways` row
`type='bnpl', provider_code='snapppay'`; mock commission % via `Seams:Bnpl:CommissionRate` (default 10%).
1. **Eligibility**`POST api/v1/checkout_bnpl/eligibility``eligible` + plan summary; a `bnpl_transactions`
row exists with `eligibility_status` set and `status='eligible'`. *(Covered:
`BnplHandlerTests.Eligibility_creates_row_eligible_and_returns_plan`, `BnplApiTests` eligibility.)*
2. **Initiate**`POST api/v1/checkout_bnpl/initiate``status='token_issued'`, deterministic token + redirect;
1:1 with the `payment_transaction`; a second initiate reuses the same row.
*(`BnplHandlerTests.Initiate_issues_token_and_is_strictly_one_to_one`.)*
3. **Verify → settle (the ledger)** — drive `POST api/v1/webhooks_bnpl/snapppay` (`order.verified` then
`order.settled`) or the admin settle → `verified → settled`; `settled_amount = order commission`, ledger shows
the balanced net-of-fee group and net `escrow_held = settled_amount`.
*(`BnplHandlerTests.Settle_posts_net_of_fee_group_…`, `BnplApiTests.Full_flow_…`, `BnplLedgerAndStateTests`.)*
4. **Payout invariance**`nurse_payable` credited = `gross balinyaar_commission`, **identical to the card path**
and independent of the BNPL commission. *(Asserted in both the handler + api full-flow tests, compared against
`LedgerPosting.CardCapture`.)*
5. **Replayed settle is a no-op** — re-deliver the same settle callback → webhook dedup + state guard reject it;
no second ledger group. *(`BnplApiTests.Replayed_settle_callback_is_idempotent_no_second_ledger`,
`BnplHandlerTests.Replayed_settle_is_a_noop_…`.)*
6. **Revert**`POST api/v1/admin_bnpl/{id}/revert``status='reverted'`, revert audit set; a `refunds` row with
`refund_channel='bnpl_revert'` + `expected_customer_refund_eta`; reversal ledger posts.
*(`BnplApiTests.Admin_revert_reverses_a_settled_order_…`.)*
7. **Status**`GET api/v1/admin_bnpl/{id}` (admin) / `GET api/v1/checkout_bnpl/{id}` (customer, own only).
8. **Guards** — settle-before-verify is a 409; the state machine rejects illegal edges.
*(`BnplHandlerTests.Settle_before_verify_…`, `BnplLedgerAndStateTests.State_machine_…`.)*
Full suite: **314 pass** (4 identity + 214 foundation + 96 api). Build clean, 0 new code warnings.
## What is mocked + how to make it real
- **`IBnplProvider`** (per `provider_code` via **`IBnplProviderResolver`**) — deterministic mock, no network;
settle returns `order round(order × Seams:Bnpl:CommissionRate)` with the commission read from the response.
Real: one adapter per code — **SnappPay** OAuth `api/online/v1/oauth/token` + `offer/v1/eligible` +
`payment/v1/token|verify|settle|revert|cancel|update|status`, or **Digipay** UPG `tickets/business?type=13` +
`purchases/verify` + `purchases/deliver?type=13` + `refunds`/`reverse`; creds from the encrypted
`payment_gateways.config_json`; per-contract commission read from the settle response. **Do not use the unrelated
Canadian `SnapPayInc/open-api-java-sdk`.**
- **`ICurrencyNormalizer`** — mock ×10 Toman→IRR (`Seams:Currency:TomanToIrrMultiplier`). Real: read the
multiplier/unit from provider config at the boundary.
- **`settled_at` is nullable / non-instant** (`Seams:Bnpl:SettlementInstant`) — the mock models the
daily/T+13/weekly reality. **b13 must not assume BNPL cash funds a payout.**
## Contracts produced / consumed
- **Produced:** `dev/contracts/domains/bnpl.md`; `dev/contracts/openapi/swagger.v1.json` refreshed
(386K → 411K, all BNPL paths present).
- **Consumed:** b10 `payment_transactions`/`ledger_entries`/`payment_webhook_events`/`IWebhookVerifier`/
`IDistributedLock`/`LedgerPosting`; b11 `refunds`/`CreateRefundCommand`/`refund_channel`; b9 booking split +
`BookingFactory`; b1 typed config accessor.
## Follow-ups
- **b13:** the `settled_at`-gates-payout coupling — add `require_bnpl_settlement_for_payout` and gate a BNPL
booking's payout on `bnpl_transactions.settled_at`.
- **DEFERRED:** `bnpl_settlement_entries` (tranched settlement — modeled-but-not-built, additive migration later);
multi-provider routing/failover (one active route today); `provider_commission_reversed_amount` reconciliation
on revert (left null when the b11 refund path drives it).
- The `BnplTransactions` migration was **not applied to a live DB** this session (no reachable SQL Server in the
agent env); apply with `dotnet ef database update` before the next live run, and seed a `type='bnpl'` gateway.
@@ -0,0 +1,111 @@
# Frontend Phase 5 — Nurse verification flow (trust engine) — Report (2026-07-09)
Builds the trust engine's front end: the staged, platform-owned verification a nurse walks before any
service can go live. A nurse lands on a **status checklist (B3)**, submits **identity (B4)**, submits
**professional credentials (B5)**, and waits on **under-review (B6)** until an admin decides — then a
**trust badge** renders and the **publish gate** unlocks. Consumes the b6 `verification` contract. Unlocks
a bookable verified nurse + the `<TrustBadge>` f6 reuses.
## What was built
### `services/verification` domain (`client/src/services/verification/`)
- **`types.ts`** — DTOs mirrored from [`verification.md`](../../contracts/domains/verification.md)
(**camelCase** wire): `VerificationStatus` (aggregate `status` + `isBookable` + `blockingSteps` +
ordered `steps[]`), `VerificationStep`, `RunStepResult`, `UploadUrlResult`, `DocumentConfirmedResult`,
`VerificationDocument` (metadata only), `NurseCredential`, `TrustBadge`, the `VerificationApi` seam, and
the string-literal enums (aggregate/per-step status, the six step codes, credential type, verification
method, `BadgeState`). Helpers: `isApproved`, `ownBadgeState` (own-profile, computes `expired`),
`publicBadgeState` (public/search — verified/unverified only), `SPECIALTY_PRESETS`.
- **`validation.ts`** — `isValidNationalId` (10-digit mod-11 checksum; rejects all-same-digit).
- **`keys.ts`** — `verificationKeys.status()` (the single cached source B3+B6 read), `.documents(code)`,
`.badge(nurseId)`.
- **`constants.ts`** — `USE_VERIFICATION_MOCK` (**default true**), the status `staleTime` (30 s) + badge
`staleTime`/`gcTime`, the document type/size caps (jpg/png/pdf, 5 MB), `NATIONAL_ID_LENGTH`.
- **`apis/`** — `clientApi.ts` (real HTTP, action-style routes, XHR signed-URL PUT for upload progress +
SHA-256 integrity hash), `mockApi.ts` (**primary**, full journey + dev-only admin sim), selecting
`index.ts`. Both implement `VerificationApi`; swap is one line.
- **`hooks/`** — one per file: `useVerificationStatus` (query), `useStartVerification` (setQueryData),
`useSubmitIdentity` (runs KYC → chained Shahkar), `useRunBankVerification`, `useUploadVerificationDocument`
(progress via vars), `useSubmitCredentials`, `useNurseTrustBadge`. **Every mutation invalidates
`status()`** (badge where relevant), so the checklist re-renders from cache with no manual refetch.
### Screens — nurse verification route subtree (`app/[locale]/(private-routes)/nurse/verification/`)
- **B3 `page.tsx`** — the hub: loading skeleton, error+retry, `not_started` start-CTA, the in-progress
**"X از Y" meter + data-driven checklist** (`VerificationChecklist` reusing the shared `StatusChip`), a
**blocking summary**, a single **continue CTA** that routes to the next actionable step (calls
`useStartVerification` first when `not_started`), and the terminal **`approved`** state (publish link).
- **B4 `identity/page.tsx`** — national-ID field (checksum) + national-ID card + liveness selfie **local
captures** (identity stores no server document — they feed the automated KYC), the honest auto-registry
note, submit → `useSubmitIdentity`. Handles national-ID mismatch (`failed`) and the **non-accusatory
shared-SIM** Shahkar failure distinctly.
- **B5 `credentials/page.tsx`** — INO number + specialty chips (presets + add-your-own) + **a
`<DocumentUpload>` per manual credential step (data-driven from `status`)** each moving its step to
`in_review`, + an optional education-cert local upload + optional registry fields; submit →
`useSubmitCredentials`, lands on B6. Copy reflects **manual review**, never an automated authority check.
- **B6 `review/page.tsx`** — the under-review resting screen: "در حال بررسی" + the 2448h note + a
**condensed mini-checklist** (reusing `StatusChip`) — a focused **second view of the same cached
`status()` query**, not a second fetch. CTA back to B3.
- **`verificationSteps.ts`** — the data-driven glue: `displaySteps` (prepends a synthetic passed **mobile**
step), `progressCounts`, `stepLabelKey`/`stepDescriptionKey`, `stepStatusChip` (the green/amber/grey/red
legend), `routeForStep`, `nextActionRoute`/`nextActionableIndex`.
- **Journey stepper:** B4/B5/B6 reuse the shared `StepperHeader` (identity → credentials → review).
### Shared components (with co-located tests)
- **`<DocumentUpload>`** — the reusable uploader: client type/size validation **before** upload, the full
idle → uploading(%) → success(✓ + name / local image preview) → error(retry) machine, re-upload on
reject, server-metadata as the "uploaded" truth, and a **local-capture mode** (B4). Its state chrome uses
the `verification` namespace; the caller passes the field label/hint.
- **`<TrustBadge state=…>`** — verified / unverified / expired off `--bal-*` tokens. Rendered on the nurse's
own profile; **exported so f6 reuses it** in search results + the public profile.
### Wiring
- **Publish gate**`nurse/services/PublishGate.tsx` (in `MyServicesList`): the go-live CTA is **disabled
with a blocked-until-verified explanation** (+ link to B3) until the aggregate is `approved`. Mirrors the
server's guarded `is_verified` flip.
- **Trust badge on the nurse profile** — sourced from the own `VerificationStatus` via `ownBadgeState`; the
unverified banner now shows only until `verified`.
- **6 AppIcons** (`upload/document/refresh/identity/license/publish`), **4 route constants**, the
`verification` **i18n namespace (123 keys)** in both locales in sync.
## What is now testable, and exactly how
Run `npm run dev`, sign in as a **nurse** (mock auth code `123456` if `USE_AUTH_MOCK`), open
**/nurse/verification**:
1. **Checklist:** B3 shows "X از Y" with the mobile step already green, the rest `not_started`. The continue
CTA calls `start` (seeds steps) then routes to B4.
2. **Identity (B4):** a valid کد ملی + card image (watch the uploader progress → ✓) + selfie → submit → the
`identity_kyc` + `shahkar_match` steps update on B3 **without a refresh**. National-ID `0000000000`
failed KYC; national-ID `1111111111` → the non-accusatory **shared-SIM** Shahkar message.
3. **Credentials (B5):** INO number + upload each manual document (→ `in_review`) + specialty chips → submit
→ lands on **B6** ("در حال بررسی", 2448h, mini-checklist).
4. **Approval flips verified:** the **dev-only "Simulate admin review" panel** on B3/B6 (mock-flag-gated —
stands in for the deferred f15 admin queue) → *Approve all* → B3 shows `approved`, the **trust badge**
on the nurse profile shows **verified**, and the **publish CTA on /nurse/services is enabled**.
5. **Rejected step:** *Reject a document* → the step shows its **rejection reason** + a working re-upload
path; the publish CTA stays blocked.
6. React Query Devtools shows **one `verification.status` query** feeding B3 + B6 and invalidating on each
mutation. Toggle `/en``/fa` (RTL) and dark mode — strings + layout flip.
## What is mocked / waiting on a real service
`services/verification` runs behind **`verificationMockApi`** (`USE_VERIFICATION_MOCK = true`) — b6 isn't
reachable in this environment, so the mock drives the full journey (identity/Shahkar/bank runs, document
uploads, admin-decision sim). The **swap is one line** (`USE_VERIFICATION_MOCK = false`): `verificationClientApi`
is wired to every b6 route (`nurse_verification/*`, `nurses/{id}/trust_badge`), same hook signatures + query
keys, no call-site change. See the mocks-registry row for the deterministic test triggers. Vendor calls
(KYC/Shahkar/credential/IBAN/object-storage) are mocked **server-side** by b6 — the front end consumes them
as if real.
## Contracts consumed + gaps filed
- **Consumed:** [`verification.md`](../../contracts/domains/verification.md) + `openapi/swagger.v1.json` (b6).
- **Filed:** **REQ-011** — a nurse-facing endpoint for the structured credential details (INO number,
specialties, license fields) B5 collects; the contract records these only on the admin `decide`, so the
real `submitCredentialDetails` no-ops until it lands (the document uploads are contract-backed). Also
flagged: `VerificationStepDto` has no `isRequired` (the client treats every seeded step as required).
## Follow-ups for later phases
- **f6** reuses `<TrustBadge>` (import from `@/components`) on search results (C2) + the public nurse profile
(C3), sourced from `useNurseTrustBadge(nurseId)` / `publicBadgeState`. The `expired` state is own-profile
only (the public badge payload carries `isVerified` only).
- **f12 payout** depends on the `bank_account_verification` step (this phase deep-links it to the f2 bank screen).
- The **admin verification review queue** (pass/reject, doc viewer, credential entry) is **f15** — the
dev-only mock sim here is a stand-in, gated on `USE_VERIFICATION_MOCK`.
- Deliver **REQ-011** then wire `submitCredentialDetails` + flip `USE_VERIFICATION_MOCK=false`.
@@ -17,8 +17,9 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم split — accepts any balanced legs | _tbd_ | Provider split-by-ratio to registered Shebas | 🔴 |
| `IWebhookVerifier` | backend-phase-10 | Callback auth — always valid | _tbd_ | Per-provider HMAC/signature + server-side re-verify | 🔴 |
| `IBnplProvider` | backend-phase-12 | BNPL — drives state machine, fake settle/revert | _tbd_ | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🔴 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 | _tbd_ | Config-driven per provider boundary | 🔴 |
| `IBnplProvider` | backend-phase-12 | BNPL — `MockBnplProvider` drives the full state machine (eligible→settled→reverted), settle returns `order commission%` | `Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission}` | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🟡 |
| `IBnplProviderResolver` | backend-phase-12 | Per-`provider_code` selection — maps every known code to the one mock | _none_ | One concrete adapter per code; resolver returns the right one | 🟡 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 at the boundary | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Config-driven per provider boundary | 🟡 |
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout — fake transfer ref | _tbd_ | Jibit/Vandar/Sadad payout; source account; PAYA vs SATNA | 🔴 |
| `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 |
| `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 |
@@ -42,7 +43,8 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL**`SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoicing — `MockMoadianClient` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `SubmitAsync` leaves a new invoice `moadian_status = pending` with `moadian_reference_number = null`; a config switch forces a deterministic `registered` result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in `AddCrossCuttingSeams` | `Seams:Moadian:ForceRegistered` (default `false`) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement `SubmitAsync` to POST the معاملات/invoice (`صورتحساب`) to the مودیان API, sign the payload, map the 22-digit `reference_number`; 3) walk the async `pending → submitted → registered`/`failed` states via a reconciliation callback/poll (**cron deferred/manual today** — a job flips `moadian_status` + fills the ref); 4) swap the registration (config-selected) — the `IssueInvoice` handler is unchanged | 🟡 |
| `IBnplProvider` | **owned by backend-phase-12**; thin local stub added in **b11** | BNPL revert/update`MockBnplProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), a **temporary stub so b11's `bnpl_revert` refund path runs before b12 merges**. `RevertAsync`/`UpdateAsync` succeed, echo a deterministic `external_revert_reference`, and report a **nullable** `provider_commission_reversed_amount` (null by default — reconciled from the response, never hardcoded). Registered singleton in `AddCrossCuttingSeams` | `Seams:Bnpl:ForceFailure` (default `false`), `Seams:Bnpl:ReverseProviderCommission` (default `false`) | **b12 owns the real seam definition + adapter** (SnappPay/Tara): settle flow, order lifecycle, real `RevertAsync`(full)/`UpdateAsync`(strictly-lower partial). When b12 lands its real registration supersedes this stub and the refund `bnpl_revert` path calls the real client unchanged | 🟡 (pre-b12 stub) |
| `IBnplProvider` | **backend-phase-12** (superset of the b11 revert-only stub) | BNPL provider`MockBnplProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**, drives the full SnappPay-superset verb set `CheckEligibilityAsync`/`CreatePaymentTokenAsync`/`VerifyAsync`/`SettleAsync`/`GetStatusAsync`/`CancelAsync`/`RevertAsync`/`UpdateAsync` and the `eligible → token_issued → verified → settled → reverted/cancelled` machine. Eligibility is `eligible` unless the mobile = `NotEligibleMobile` (→`not_eligible`) or the order exceeds `CreditCeilingIrr` (→`ceiling_exceeded`); token/redirect are deterministic; **settle returns `settledAmountIrr = order round(order × CommissionRate)` + the commission read from the response (never hardcoded) + a nullable `settledAt`** (null when `SettlementInstant=false`, modelling non-instant settlement); revert echoes a deterministic `external_revert_reference` + nullable `provider_commission_reversed_amount`. Selected per `provider_code` by **`IBnplProviderResolver`** (`MockBnplProviderResolver` → the one mock for every known code); the b11 refund `bnpl_revert` path still injects `IBnplProvider` directly. Registered singleton in `AddCrossCuttingSeams` | `Seams:Bnpl:CommissionRate` (default `0.10`), `Seams:Bnpl:SettlementInstant` (default `true`), `Seams:Bnpl:CreditCeilingIrr` (default `2000000000`), `Seams:Bnpl:NotEligibleMobile` (default `09120000099`), `Seams:Bnpl:ForceFailure` (default `false`), `Seams:Bnpl:ReverseProviderCommission` (default `false`) | 1) implement one concrete adapter per `provider_code` (**SnappPay** OAuth `api/online/v1/oauth/token` + `offer/v1/eligible` + `payment/v1/token\|verify\|settle\|revert\|cancel\|update\|status`, or **Digipay** UPG `tickets/business?type=13` + `purchases/verify` + `purchases/deliver?type=13` + `refunds`/`reverse`); 2) read credentials from the **encrypted** `payment_gateways.config_json`; 3) do Toman↔Rial via `ICurrencyNormalizer` at the adapter boundary; 4) read the **per-contract commission from the settle response**, never hardcode; 5) map the provider event shape into the callback so `HandleBnplCallback` dispatch is unchanged; 6) register per-code in `IBnplProviderResolver` (config-selected) — handlers unchanged. **Warn: do NOT use the unrelated Canadian `SnapPayInc/open-api-java-sdk`.** | 🟡 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR at the provider boundary — `MockCurrencyNormalizer` (`Baya.Infrastructure.CrossCutting/Seams/`): `ToIrr(amount,"TOMAN")` = `amount × TomanToIrrMultiplier`, IRR passes through; `ToDisplayToman` divides back. **Conversion happens ONLY here, never internally.** Registered singleton in `AddCrossCuttingSeams` | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Read the multiplier (or a per-provider unit) from provider config; the interface stays — a currency redenomination is a config change | 🟡 |
| `INursePayoutStatus` | backend-phase-11 (interim; **b13** owns the real impl) | "Was the nurse already paid for this booking?" — `NursePayoutStatusService` (`Persistence/Services/Payments/`) derives it from the booking's `dispute_window_ends_at` close (the same gate b13 pays out on), with a `refund_assume_nurse_paid` config override. Not a mock of an external — a **temporary derivation** standing in for the b13 `nurse_payout_booking_links` lookup. Registered scoped in `AddPersistenceServices` | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | In b13: implement `IsNursePaidForBookingAsync` as a real `nurse_payout_booking_links` join (a booking linked to a paid-out `nurse_payouts` batch ⇒ paid), swap the registration — the refund pre-payout/clawback fork is unchanged | 🟡 |
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
@@ -64,3 +66,4 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟡 |
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟡 |
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |