backend phase 13 & frontend phase 6

This commit is contained in:
hamid
2026-07-09 04:09:35 +03:30
parent dc64472631
commit de53f9d8a6
97 changed files with 11969 additions and 77 deletions
@@ -12,6 +12,25 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## backend-phase-13 — Weekly nurse payouts (mocked PAYA/SATNA) — 2026-07-09
- **Shipped:** new `payouts` schema, 3 tables — `NursePayoutBatches` (holiday-shifted period/processing dates),
`NursePayouts` (net-split CHECK, encrypted `iban_snapshot`, forward-only `PayoutStatus`), `NursePayoutBookingLinks`
(**unconditional `UNIQUE(booking_id)`** = one-payout-per-booking-ever). One migration (`NursePayoutEngine`).
`Features/Payouts/*` (compute-eligible / generate-batch / process / retry / mark-failed + admin batch-detail/list +
nurse history; shared `PayoutSettlement` step). `IPayoutRepository`. Controllers `AdminPayouts` (admin, rate-limited)
+ `NursePayouts` (nurse, tenancy-scoped). New seam **`IBankTransferProvider`** (mock PAYA/SATNA). Swapped
`INursePayoutStatus` to the authoritative link-based `NursePayoutLinkStatusService` (deleted the interim one). Added
2 config keys (`payout_satna_threshold_irr`, `require_bnpl_settlement_for_payout`).
- **Contracts:** `dev/contracts/domains/payouts.md` + openapi snapshot refreshed (yes — 7 payout paths).
- **Mocked:** `IBankTransferProvider` → 🟡; `INursePayoutStatus` → 🟢 (real link lookup). Reuse `IHolidayCalendar`,
`IFieldEncryptor`, `IDistributedLock`, `ICacheService`. See reports/mocks-registry.md.
- **Gate:** build clean (0 new code warnings) / tests green (329: 223 foundation + 102 api + 4 identity; +9 payout
unit + 6 payout api). Migration builds; swagger serves all 7 payout paths.
- **Handoff:** backend/handoff/after-backend-phase-13.md
- **Notes for frontend:** f12-b13 = nurse `nurse_payouts/history` (own payouts, masked IBAN, digit-string money) +
admin payout console (`admin_payouts/eligible|batches|batches/{id}|batches/{id}/process|{payoutId}/retry|mark_failed`).
Query params camelCase (`page`/`pageSize`/`status`/`periodStart`/`periodEnd`). Money is a digit string.
## 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/
@@ -0,0 +1,51 @@
# Handoff — after backend-phase-13 (Weekly nurse payouts)
**The payout engine is live — a nurse's earnings are now real money.** This is the last money-out leg of the
payments arc (b10 ledger → b11 refunds/clawbacks → b12 BNPL → **b13 payouts**). An admin previews eligible
earnings, opens a weekly batch, and submits it to a **mocked PAYA/SATNA bank rail**; each booking is paid in
**exactly one** payout across all batches (a `nurse_payout_booking_links.booking_id` UNIQUE), pending clawbacks are
netted, the verified primary IBAN is snapshotted, and the outbound `nurse_payable → escrow_held` ledger movement is
posted. Everything is **holiday-aware** (a Nowruz-landing batch shifts off bank-closed days). No `payout_released`
boolean exists — paid-ness is derived from a link row + the ledger.
## What the frontend (f12-b13) can now build
- **Nurse earnings / payout history** — `GET nurse_payouts/history` (authenticated nurse, own payouts only,
paginated): `status` (`pending|submitted|paid|failed`), `netAmountIrr`, `grossEarningsIrr`, `clawbackAppliedIrr`,
the **masked** IBAN, `transferReference`, `paidAt`, and the batch window. Money is a **digit string**. Show the
clawback line when `clawbackAppliedIrr > "0"` ("earnings held to recover a prior overpayment").
- **Admin payout console:**
- `GET admin_payouts/eligible?periodStart=&periodEnd=` — dry-run preview per nurse; flags any nurse with
`hasVerifiedPrimaryIban=false` (they won't be paid until they register a verified primary account).
- `POST admin_payouts/batches {periodStart, periodEnd}` — open a `draft` batch → returns the batch + payouts +
the `skipped` nurses (with reasons). No money moves yet.
- `POST admin_payouts/batches/{id}/process` — submit to the rail → `completed` / `partially_failed`.
- `POST admin_payouts/{payoutId}/retry` and `POST admin_payouts/{payoutId}/mark_failed {failureReason}`.
- `GET admin_payouts/batches/{id}` (header + payouts + linked bookings) and `GET admin_payouts/batches?status=`.
## Contracts
- **`dev/contracts/domains/payouts.md`** — all 8 endpoints, the `PayoutBatchStatus`/`PayoutStatus` enums, and the
batch/payout/link/history DTO shapes (IRR digit strings, **masked** IBAN). Query params are **camelCase**
(`page`/`pageSize`/`status`/`periodStart`/`periodEnd`).
- **`dev/contracts/openapi/swagger.v1.json`** refreshed — the 7 payout paths are in the snapshot.
## What is mocked (and how it becomes real)
- **`IBankTransferProvider`** (new) — the PAYA/SATNA rail. `MockBankTransferProvider` moves no money: it returns a
deterministic `transfer_reference` and settles every instruction `paid`, honouring the PAYA/SATNA method the
handler picked by `payout_satna_threshold_irr`. Config forces failures for testing (`Seams:BankTransfer:ForceFailure`
whole-batch, `Seams:BankTransfer:FailIban` one row). Make it real → a Jibit/Vandar/Sadad payout adapter with a
registered source settlement account + the async reconciliation callback (see reports/mocks-registry.md).
- **`INursePayoutStatus`** — b13 shipped the authoritative `NursePayoutLinkStatusService` (paid iff a link ties the
booking to a `paid` payout); the interim dispute-window derivation was deleted. **The b11 refund fork now forks on
the true paid-state** — no refund-side change needed.
## Load-bearing rules (don't regress)
- **One payout per booking, ever** — the `booking_id` UNIQUE is unconditional (not soft-delete-filtered).
- **Eligibility ≠ completed** — needs `dispute_window_ends_at < now`, no active refund, not already linked.
- **Clawback netting recovers *whole* clawbacks up to earnings** (never a negative net, never a partial single row);
the recovery is a real `DEBIT nurse_payable / CREDIT nurse_clawback_receivable` posting + `recovered` status.
- **Process is idempotent** — forward-only `PayoutStatus` + a batch idempotency key + the ledger-exists guard.
## Deferred (flagged, not built)
- The weekly **cron scheduler** — batches are admin-triggered; cadence in `nurse_payout_interval_days` (default 7).
- **On-demand / instant withdrawal**, **per-nurse payout frequency**, **automated clawback recovery beyond netting**.
- The **BNPL `settled_at` guard** — exposed as `require_bnpl_settlement_for_payout` (config, default off).
@@ -12,6 +12,29 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
## frontend-phase-6-b7 — Search & discovery (find a verified, same-gender nurse) — 2026-07-09
- **Shipped:** the family discovery slice — `services/search` (types/keys/constants/apis/hooks + a shared
`filterParams.ts` C1↔C2 URL serializer) and screens **C1** `/search` (reused category grid + f3 region
picker + prominent same-gender facet + Toman price + live-count CTA; `useSearchFilters` colocated
controller with debounced price), **C2** `/search/results` (rating-sorted `NurseResultCard` list, all
four states incl. "relax filters" empty, load-more), **C3** `/search/nurse/[nurseId]` (TrustBadge + نظام
پرستاری badges, attribute chips, `ServicePriceRow` services, latest review, "درخواست رزرو" handoff).
Two shared tested components: `NurseResultCard`, `ServicePriceRow`. `/bookings/request` = f7 handoff stub.
i18n `search` (filled) + `booking` (seeded) in both locales. Reused f4 category grid, f5 TrustBadge, f3
geo picker, f0 money util — none rebuilt.
- **Headline caching:** the **filter object IS the query key** — reverting to a prior filter set is a cache
hit with zero network (keepPreviousData avoids flashing); price input debounced.
- **Consumes:** dev/contracts/domains/search.md (b7) + b6 trust badge / b5 variant reads.
- **Mocked client-side:** `services/search` via `searchMockApi` (**USE_SEARCH_MOCK=true, primary**) — b7's
index row + b5/b6 reads don't yet expose nurse name/avatar/distance or an aggregated profile
(name/bio/specialties/services list/latest review). Real `searchClientApi` maps what exists; swap is one
line once REQ-012 lands. Recorded in the phase report (not mocks-registry — that's for backend DI seams).
- **Gate:** npm run check green · npm run test:ci green (165 tests, +8). `npm run build` compiles + types
clean; prerender fails only on the pre-existing f5 `/nurse/verification` "Missing .env variable!" (needs
env set — unrelated to f6).
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-012: search row name/avatar/distance +
`GET nurses/{id}/profile` aggregation).
## 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
@@ -150,3 +150,24 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **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
## REQ-012 — Search result + nurse-profile enrichment for discovery (C2/C3) — filed by frontend-phase-6-b7 — 2026-07-09
- **Need:** Two extra read surfaces the discovery UI renders but b7/b6/b5 don't yet expose:
1. **On the `search/nurses` result row** (`NurseSearchResultDto`): the nurse's **display name** and
**avatar URL** (the C2 card's identity) and a **distance** value (km from the searched area) — b7's
index row today carries only ids + price/rating/gender/geo ids. Without the name/avatar the card falls
back to a generic label + initials; distance is simply hidden.
2. **An aggregated public nurse-profile endpoint** for C3 — proposed `GET api/v1/nurses/{id}/profile`
`{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews,
totalCompletedBookings, isVerified, inoMembership, attributeChips: string[] (specialty codes),
services: [{ variantId, displayName, priceIrr (string), priceUnit, sessionCount? }],
latestReview?: { rating, body, authorMasked, createdAt } }`. Today only the b6 public **trust badge**
(`nurses/{id}/trust_badge`, giving `isVerified` + `credentialTypes`) and the b5 single-variant read are
public — there is no name/bio/specialties/**full services list**/latest-review aggregation.
- **Why:** C2/C3 are the trust funnel — the family chooses a real, named, priced nurse here. The
`services/search` domain is **mock-primary** (`USE_SEARCH_MOCK = true`) precisely because these fields
aren't available; the real `searchClientApi` maps everything b7/b6 do provide and leaves the above blank.
When both land, the swap is a single config flip (no hook/component change).
- **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add
`GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings.
- **Status:** open
@@ -0,0 +1,76 @@
# Backend Phase 13 report — Weekly nurse payouts (mocked bank transfer)
**Date:** 2026-07-09 · **Track:** backend · **Status:** complete, gate green.
## What was built
- **`payouts` schema, 3 tables** (one migration `NursePayoutEngine`):
- `NursePayoutBatches` — weekly aggregation; `period_end`/`processing_date` holiday-shifted; `total_amount` /
`payout_count`; `status` (`draft|processing|partially_failed|completed|failed`); `initiated_by_admin_id` FK.
- `NursePayouts` — one per nurse per batch; DB CHECK `net = gross clawback` + all ≥ 0; **encrypted
`iban_snapshot`** (EF converter) frozen from the verified primary account; `status` (`pending|submitted|paid|failed`,
forward-only); `transfer_reference`, `paid_at`, `failure_reason`.
- `NursePayoutBookingLinks`**unconditional `UNIQUE(booking_id)`** (the one-payout-per-booking-ever guard);
nullable `session_id` for a future per-session model.
- **Domain:** `PayoutBatchStatus`/`PayoutStatus` + `*Transitions`; `LedgerPosting.NursePayout` (DEBIT nurse_payable /
CREDIT escrow_held) + `LedgerPosting.ClawbackRecovery` (DEBIT nurse_payable / CREDIT nurse_clawback_receivable);
`NurseClawback.Recover(payoutId, now)`.
- **Application:** `Features/Payouts/{Commands|Queries}``ComputeEligibleEarnings`, `GeneratePayoutBatch`
(build+link inline), `ExecutePayoutBatch`, `RetryFailedPayout`, `MarkPayoutFailed`, `GetBatchDetail`,
`ListPayoutBatches`, `GetNursePayoutHistory`; shared `PayoutSettlement` (ledger post + clawback netting).
`IPayoutRepository` on `IUnitOfWork`.
- **Infrastructure:** `PayoutRepository`, `PayoutsConfig/*`, `MockBankTransferProvider` (+ `BankTransferOptions`),
the authoritative `NursePayoutLinkStatusService` (swapped for the deleted interim `NursePayoutStatusService`),
`iban_snapshot` encryption wired in `ApplicationDbContext`, 2 new `platform_configs` seeds.
- **API:** `AdminPayoutsController` (admin, rate-limited) + `NursePayoutsController` (nurse, tenancy-scoped).
## What is now testable and exactly how (per §7 of the phase)
Seed a few **completed** bookings via the admin flow: some with `dispute_window_ends_at` in the past (eligible), some
future (not yet), one disputed, one with a pending clawback; one nurse with a verified primary IBAN, one without.
1. **Eligibility preview**`GET admin_payouts/eligible?periodStart=&periodEnd=` → only completed + dispute-window-
closed, unpaid bookings appear, grouped by nurse; future + disputed excluded; the no-IBAN nurse flagged
(`hasVerifiedPrimaryIban=false`). *(unit: `Preview_includes_only_closed_window_and_flags_missing_iban`)*
2. **Generate a batch**`POST admin_payouts/batches` → a `draft` batch, one payout per eligible nurse; the nurse
with a pending clawback shows `clawbackAppliedIrr>0` and `net = gross clawback`; `total_amount = Σ net`;
`iban_snapshot` populated (encrypted, served masked). *(unit: `Generate_materializes_one_payout_per_nurse_and_nets_clawback`,
`Generate_skips_nurse_without_verified_primary_iban_with_reason`)*
3. **Double-pay guard** — a second generate over the same window doesn't re-select the linked bookings.
*(unit: `Double_pay_guard_second_generate_does_not_reselect_linked_bookings`)*
4. **Holiday shift**`period_end`/`processing_date` shift off a seeded bank-closed day.
*(unit: `Holiday_shifts_period_end_and_processing_date`)*
5. **Execute**`POST admin_payouts/batches/{id}/process` → payouts go `paid` with a `transfer_reference`; the ledger
shows balanced `DEBIT nurse_payable / CREDIT escrow_held` per payout (the payable balance drops by the paid amount);
a netted clawback is marked `recovered` with `recovered_in_payout_id`. *(unit:
`Execute_posts_balanced_payout_ledger_and_drains_payable`, `Execute_recovers_clawback_and_posts_recovery_leg`)*
6. **Idempotency** — re-process → no second transfer / ledger group. *(unit: `Reprocess_is_idempotent_no_second_ledger_group`)*
7. **Failure / retry** — force a rail failure → `partially_failed`; `retry` (rail back to success) → `paid`, batch
`completed`. *(unit: `Partial_failure_then_retry_completes_the_batch`)*
8. **Nurse history**`GET nurse_payouts/history` as the nurse → their payouts (masked IBAN, net, reference);
another nurse's are invisible. *(api: `NursePayoutsApiTests`)*
API happy-path/401/400: `AdminPayoutsApiTests` (generate→process pays the nurse; 401 unauth; 400 bad period; list)
and `NursePayoutsApiTests` (401 unauth; own paid payout with masked IBAN).
## What is mocked + how to make it real
- **`IBankTransferProvider`** (🟡) — PAYA/SATNA rail. Make real = a Jibit/Vandar/Sadad payout adapter with a
registered source settlement account, per-nurse verified Sheba, PAYA-vs-SATNA selection, batch caps/minimums, and
the async reconciliation callback that flips `submitted → paid/failed`. Config keys `Seams:BankTransfer:*`.
- **`INursePayoutStatus`** (🟢) — now the real link-based lookup (`NursePayoutLinkStatusService`).
- Reused mocks: `IHolidayCalendar`, `IFieldEncryptor`, `IDistributedLock`, `ICacheService`.
## Contracts produced
- `dev/contracts/domains/payouts.md` (new) · `dev/contracts/openapi/swagger.v1.json` refreshed (7 payout paths).
## Confirmed rules recorded (product/business/10-payouts.md §d1)
- Clawback netting recovers **whole** clawbacks up to a batch's earnings (never negative net, never a partial single
clawback); a clawback larger than a batch's earnings waits for a later batch. Recovery is a real ledger movement.
- A booking with an active refund is held out of payouts (the operational reading of "no open dispute").
- `payout_satna_threshold_irr` picks PAYA vs SATNA; `require_bnpl_settlement_for_payout` (default off) gates BNPL.
## Follow-ups (deferred)
- The weekly **cron scheduler** (PAYA-aligned) — entry point is `GeneratePayoutBatchCommand`; cadence in
`nurse_payout_interval_days`. On-demand/instant withdrawal; per-nurse payout frequency; automated clawback recovery
beyond next-batch netting; the BNPL `settled_at` timing guard (flag shipped, off).
## Gate
`dotnet build Baya.sln` — 0 errors, 0 new code warnings (only pre-existing NU1510/NETSDK1057/NU1903).
`dotnet test Baya.sln` — 329 pass (223 foundation + 102 api + 4 identity), 0 fail.
@@ -0,0 +1,88 @@
# Frontend Phase 6 — Search & discovery (C1/C2/C3) — report
**Date:** 2026-07-09 · **Track:** frontend · **Depends on:** f4 (catalog/category grid), f5
(TrustBadge), f3 (geo picker), f0 (money util, services pattern) · **Consumes:** b7 `search.md` +
b6 trust badge / b5 variant reads · **Unlocks:** f7 booking request.
## What was built
A vertical discovery slice — the trust funnel where a family picks a real, verified nurse.
### `services/search/` (the domain, copies the f0/auth shape)
- **`types.ts`** — `NurseSearchFilters` (the cache key/URL shape), `NurseSearchResult` (C2 card row),
`NurseProfile` + `NurseProfileServiceRow` + `NurseReviewSnippet` (C3), the `SearchApi` seam. Derived
from the b7 contract; the fields b7 doesn't expose are documented inline + filed (REQ-012).
- **`keys.ts`** — `searchKeys.results(filters)` / `searchKeys.profile(id)` + `canonicalizeSearchFilters`
(stable key order, absent optionals omitted) — the filter-object-as-query-key caching contract.
- **`constants.ts`** — `USE_SEARCH_MOCK` (true, primary), stale/gc times, page size, debounce ms.
- **`filterParams.ts`** — the single C1↔C2 URL (de)serializer (snake_case, matching b7 params) so the
screen that writes the URL and the screen that reads it never drift.
- **`apis/`** — `mockApi.ts` (primary; real-shaped verified fixtures in `seed.ts`, reproduces b7
filter + whole-city geography + rating-sort semantics), `clientApi.ts` (real b7/b6 mapping scaffold,
gaps left blank), `index.ts` (seam selection by `USE_SEARCH_MOCK`).
- **`hooks/`** — `useNurseSearch` (keepPreviousData, enabled on category+city), `useNurseProfile`
(enabled on id), `useDebouncedValue` (generic, used by the C1 controller). `index.ts` re-exports hooks.
### Screens (all RTL/i18n/dark-mode, under the customer bottom-tab shell)
- **C1** `/search` — reused f4 category grid (selectable) + f3 `CascadingRegionSelect` (district
optional = whole city) + **prominent same-gender toggle** (خانم/آقا/فرقی ندارد) with a why-line +
intent-only date + Toman price range; a **live result count** drives the "مشاهده N پرستار" CTA into
C2. Fast-changing filter state in the colocated `useSearchFilters` controller (debounced price).
- **C2** `/search/results` — result count + rating sort control (one option; other sorts DEFERRED),
rating-sorted `NurseResultCard` list, **all four states** (skeleton / empty "relax filters" with
concrete suggestions / error-retry / populated), load-more. Filters live in the URL.
- **C3** `/search/nurse/[nurseId]` — avatar/name/rating, ✓ تاییدشده (reused TrustBadge) + نظام پرستاری
(rendered only when `inoMembership`), attribute chips (specialty codes → i18n + years-experience),
`ServicePriceRow` services list, latest-review snippet (+ "no reviews" empty), loading/not-found/error
states, and the **"درخواست رزرو"** CTA that hands off to `/bookings/request`.
### Shared components (tested)
- **`NurseResultCard`** — presentational + memoized; avatar, name, reused verified badge, rating +
review count, optional distance chip, "from X تومان/unit" via `PriceDisplay`.
- **`ServicePriceRow`** — service name + `PriceDisplay` (money util + i18n unit label); reused by the
booking summary in f7+.
### Other
- Added `star` + `tune` icons to the AppIcon registry. Routes: `SEARCH_RESULTS`, `SEARCH_NURSE`,
`BOOKING_REQUEST`. i18n: `search` filled + `booking` seeded, both locales in sync.
## Now testable, and exactly how (§7 of the phase)
Run `npm run dev` (mock is primary — no backend needed; or point `NEXT_PUBLIC_API_URL` at a b7 server
and flip `USE_SEARCH_MOCK=false`, noting the REQ-012 gaps).
- **Discovery E2E:** Home → tap a category (e.g. مراقبت سالمند) → C1 preselects it → set city (تهران),
gender (خانم) → CTA shows a real count → tap → C2 lists only verified nurses, rating-sorted, each with
photo/initials, name, ✓ تاییدشده, rating + review count, distance, "from X تومان/ساعت".
- **Profile:** tap a card → C3 shows badges, attribute chips, services + Persian unit labels, latest
review → "درخواست رزرو" → `/bookings/request` echoing nurse + variant + gender intent.
- **Empty state:** search Mashhad/Isfahan/Shiraz (seeded empty) → "relax your filters" with suggestions.
- **Caching (headline):** React Query Devtools → filter set A → set B (one fetch) → **revert to A**
instant, **zero** new requests. Type in the price field → one debounced request, not one per keystroke.
- **i18n/RTL:** flip fa↔en — all labels/badges/units/empty copy translate + mirror; dark mode holds.
## Mocked behind the seam (how f-next swaps it)
`services/search` is **mock-primary** (`USE_SEARCH_MOCK = true`) because b7's `NurseSearchResultDto`
row omits the nurse **name/avatar/distance**, and there is **no aggregated public nurse-profile
endpoint** (only the b6 trust badge + b5 single-variant read). `searchMockApi` supplies real-shaped
fixtures so C1/C2/C3 fully demo. The real `searchClientApi` already maps everything b7/b6 provide and
leaves the missing fields blank; once **REQ-012** lands (enrich the search row + add
`GET nurses/{id}/profile`), the swap is flipping one flag — no hook/component change. (This is a
client-side mock; it is recorded here, not in `mocks-registry.md`, which tracks backend DI seams.)
## Contract consumed / requests filed
- **Consumed (not edited):** `dev/contracts/domains/search.md` (b7) — `services/search/types.ts` derives
from it; b6 trust badge + b5 variant read for the profile scaffold.
- **Filed:** `frontend/requests/for-backend.md` **REQ-012** — search-row `nurseName`/`avatarUrl`/
`distanceKm` + an aggregated `GET api/v1/nurses/{id}/profile`.
## Follow-ups
- **f7 booking:** the "درخواست رزرو" handoff carries `nurse_id`, `variant_id`, `required_gender`
(the C1 same-gender intent → `required_caregiver_gender`/b8), `city_id`, `service_category_id`, `date`
as query params to `/bookings/request` (currently a DEFERRED stub). f7 builds the form + captures the
gender into the booking request.
- **C3 reviews tab:** DEFERRED → f13 (only the latest-review snippet ships now).
- **DEFERRED (per contract):** availability-window hard filter, sorts beyond rating, map/radius discovery.
## Gate
`npm run check` green · `npm run test:ci` green (165 tests, +8). `npm run build` compiles and
type-checks clean; the only prerender failure is the **pre-existing** f5 `/nurse/verification`
"Missing .env variable!" (needs env set) — unrelated to this phase's routes.
@@ -20,7 +20,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `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 | 🔴 |
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout rail — `MockBankTransferProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call, no money moves**: `SubmitPayoutBatchAsync(batchId, instructions, idempotencyKey)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference` and settles every row `Paid` (collapsing the real `submitted → paid` reconciliation); it **honours** the PAYA/SATNA `method` the handler chose by the `payout_satna_threshold_irr` config and echoes it. A config switch forces deterministic failures so `partially_failed`/retry are testable: `ForceFailure` fails the whole batch, `FailIban` fails one destination. `GetPayoutStatusAsync` echoes `Paid`. Registered singleton in `AddCrossCuttingSeams` | `Seams:BankTransfer:ForceFailure` (default `false`), `Seams:BankTransfer:FailIban` (default empty) | 1) pick a transferor (Jibit/Vandar/Sadad payout API), add its client package to `Directory.Packages.props`; 2) add `Seams:BankTransfer:{ApiKey,BaseUrl,SourceSettlementAccount}`; 3) implement `SubmitPayoutBatchAsync` to register the batch against the registered **source settlement account** and route each transfer PAYA (batch, low-value) vs SATNA (real-time, above the threshold) to each nurse's **verified Sheba** (the b3 `matched_national_id` gate), honouring batch caps/minimums; 4) implement the async **reconciliation callback** that flips a payout `submitted → paid/failed` (the mock collapses this — the real rail is async); 5) swap the registration (config-selected) — the payout status machine + `nurse_payout_booking_links` UNIQUE remain the irreversible-transfer backstop; 6) test PAYA/SATNA selection, whole-batch + single-row failure → retry | 🟡 |
| `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 | 🟡 |
| `IJobScheduler` (retention + booking expiry) | backend-phase-1 | Scheduling — in-process interval `BackgroundService`s: `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) and **b8** `BookingRequestExpiryHostedService` (`Persistence/Services/Booking/`) running the idempotent booking-request expiry sweep every minute | _none_ | Swap to Hangfire/Quartz; register **both** jobs there; keep the purge predicate (`is_read=1 AND age>90d`) and the booking-expiry command | 🟡 |
@@ -45,7 +45,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `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` | **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 | 🟡 |
| `INursePayoutStatus` | backend-phase-11 (interim) → **backend-phase-13 (authoritative)** | "Was the nurse already paid for this booking?" — **b13 shipped the real `NursePayoutLinkStatusService`** (`Persistence/Services/Payments/`): a booking is paid iff a `nurse_payout_booking_links` row ties it to a `nurse_payouts` row in status `paid`. This **supersedes** the interim `NursePayoutStatusService` (dispute-window derivation, now deleted); the `refund_assume_nurse_paid` config override still forces the paid answer for ops/testing. Not a mock of an external — a real ledger-backed derivation. Registered scoped in `AddPersistenceServices`. The refund pre-payout/clawback fork is unchanged | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | Nothing further — this is the real implementation. (A future on-demand-withdrawal model would extend the "paid?" definition, not replace it.) | 🟢 |
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.