cleanup phases 6

This commit is contained in:
hamid
2026-08-02 18:48:32 +03:30
parent e2db97392a
commit 51e86a1e5f
239 changed files with 118 additions and 70 deletions
@@ -0,0 +1,45 @@
# After backend-phase-0 — what f0/b1 can rely on
**The spine is clean and REST works.** The inherited `Order` demo (entity/feature/repo/config/gRPC) and
the three pre-marketplace migrations are gone; Identity + JWE auth + dynamic permissions + the CQRS
behaviors + observability are untouched and the existing tests stay green. A real REST controller is
live and proves the full pipeline.
## What the frontend (f0) can rely on now
- **The response envelope is fixed.** Every endpoint returns the server's `ApiResult` envelope, not a
bare body. Success shape:
```json
{ "isSuccess": true, "statusCode": 0, "message": "Success", "requestId": "<trace>", "data": <T> }
```
Failure returns `400/401/403/404` with the same envelope (field-level errors for validation). This is
what `clientFetch`/`serverFetch` must unwrap. JSON casing is the server default (camelCase for the
envelope/body properties; **URL segments are snake_case**).
- **A working endpoint to wire the type pipeline against:**
`GET /api/v1/ping/get_status` → `ApiResult<{ service, status, serverTimeUtc }>`.
- **Rate limiting exists.** Over-limit requests get `429`; `get_status_rate_limited` demonstrates it
(first 5/10s OK, then 429).
- **swagger.json is published:** `dev/contracts/openapi/swagger.v1.json` (envelope schemas
`ApiResult`, `ApiResultStatusCode`, `PingQueryResult`). Generate frontend types from it.
## What b1 can rely on now
- **Audit base type:** `BaseEntity`/`IAuditableEntity` (`Domain/Common/BaseEntity.cs`) with
`CreatedAt`/`ModifiedAt` (`DateTimeOffset`) + `CreatedById`/`ModifiedById` (`int?`). New entities that
derive `BaseEntity` get audit stamping for free.
- **Audit interceptor:** `AuditFieldInterceptor` (`Infrastructure.Persistence/Interceptors/`) stamps
those fields on save from `ICurrentUser` + `IDateTimeProvider`. **b1 extends this** to also write the
append-only `audit_logs` rows — the stamping plumbing and the clean extension point are in place.
- **Cross-cutting seams (DI-registered, mocked):** `IDateTimeProvider`, `IFieldEncryptor` (use it for
every encrypted PII column — phone/national_id/IBAN/addresses/clinical notes; deterministic `Hash`
for `*_hash` lookup columns), `ICacheService` (cache `platform_configs` / read-heavy data through it),
`IObjectStorage`, `INotificationDispatcher`. Contracts in `Application/Contracts/Common`; mocks in
`Infrastructure.CrossCutting/Seams/`; registered by `AddCrossCuttingSeams`.
- **Money rule:** IRR is `long`/`BIGINT`, integer-only, no floats (CONVENTIONS §6).
- **Migration baseline:** `20260628191947_InitialBaseline`. Add the marketplace schema on top of it;
generate with the documented `dotnet ef migrations add` command.
- **Rate-limit policies** `otp`/`auth`/`sensitive` are defined and ready to apply via
`[EnableRateLimiting("...")]` on the relevant endpoints (b2 auth/OTP).
## Known caveat
Non-Development environments route Serilog to the `logDb` connection (`Server=sql_server2022`); if that
host isn't reachable, the API won't start there. Development logs to console/file and boots cleanly
against the configured `SqlServer` DB. This is pre-existing infra config, unrelated to phase-0 code.
@@ -0,0 +1,58 @@
# After backend-phase-1 — what b2…b15 and the frontend can rely on
The **platform backbone** is live. Six cross-cutting tables exist in a new **`ops` schema** on top of
b0's `InitialBaseline`, seeded with real config + a sample holiday calendar. The mechanisms every later
phase needs — typed config, holiday math, audit trail, analytics, in-app notifications, support alerts —
are built **once, here**, behind Application contracts. **Reuse them; do not re-create the tables.**
## Internal contracts b2…b15 must depend on (never reinvent)
All are DI-registered (Scoped) and implemented in `Baya.Infrastructure.Persistence/Services/`:
- **`IPlatformConfig`** — `GetConfig<T>(key)` (cached, parsed by `data_type`), `SetConfig(key,value)`
(audited, evicts cache), `ListAsync`, `GetConfigChangeHistory(key)`. **Read money-critical constants
here at compute time — never hardcode.** Seeded keys: `platform_fee_rate`, `vat_rate` (0.10),
`dispute_window_hours` (72), `booking_payment_deadline_minutes` (30), `nurse_response_deadline_hours`,
`nurse_payout_interval_days`, `evv_location_tolerance_meters`, `min_rating_for_support_alert`,
`bnpl_merchant_of_record`, `bnpl_provider_commission_rate`, `bnpl_settlement_timing`,
`cancellation_tiers`. **Snapshot the rate you use onto the priced row** — a later config change must not
re-price it.
- **`IHolidayCalendar`** — `IsHoliday`, `IsBankClosed`, `NextBusinessDay` (skips bank-closed days + the
Iranian banking weekend = Friday), plus admin CRUD. **b13 payout scheduling calls `NextBusinessDay`.**
- **`IAuditLogger`** — `WriteAsync(entityType, entityId, action, changedFields?)` for state changes with
no row diff, plus `GetTrailAsync`. Row-level diffs on **`IAuditable`** entities are written
automatically by the extended `AuditFieldInterceptor` (mark an entity `IAuditable`; annotate encrypted
props `[AuditRedacted]`). `platform_configs` is the first `IAuditable` entity. **`audit_logs` is
append-only — never update/delete it.**
- **`IAnalyticsSink`** — `EmitAsync(name, props)`; fire-and-forget (`system_events`). **Never** route
compliance facts here — those go to `IAuditLogger`.
- **`INotificationDispatcher`** — `DispatchAsync(Notification(userId, type, title, body?, dataJson?))`
now writes a **real in-app `notifications` row** (b0 stub gone). This is how booking/payment/review
domains mint a user notification. `data_json` is a **typed, versioned deep-link payload** — version it.
- **`INotificationService`** — per-user reads/commands (list unread-first, unread count, mark read/all,
purge). Always tenant-scoped to `ICurrentUser`.
- **`ISupportAlertService`** — `RaiseAsync(type, entityType, entityId, severity, bookingId?, reviewId?)`
for review/EVV/verification/payment flows to call, plus assign/resolve/list. **Support alerts are
admin-only — never surface them on a user-facing route or in a user `notification`.**
## Live endpoints (contract: `dev/contracts/domains/config-reference.md`)
Admin (`DynamicPermission`): `platform_config/*`, `holidays/*`, `audit/get_audit_trail`,
`support_alerts/*`. Current-user (`Authorize`): `notifications/*` (f14 notification center;
f15 admin config/holidays/audit/alerts). Envelope unchanged (camelCase body, snake_case URLs); lists
paginated `page`/`page_size`.
## Migration / schema
New migration **`20260701193257_InitialMarketplaceBaseline`** — the marketplace baseline every later
phase adds onto. Applied cleanly to the dev DB (tables + indexes + seed present). Tables live in **`ops`**
(keep new marketplace tables off `usr`).
## Follow-ups later phases must close
- **FK constraints for `support_alerts.booking_id` / `review_id`.** The columns exist now (no FK yet).
**b9** (bookings) adds `FK SupportAlerts.BookingId → Bookings`; **b14** (reviews) adds
`FK SupportAlerts.ReviewId → Reviews`. Do it in the migration that creates those tables.
- **Make-it-real seams (🟡):** `IHolidayCalendar` (real feed/sync), `IAnalyticsSink` (warehouse),
`IJobScheduler` retention (Hangfire/Quartz), `INotificationDispatcher` SMS/push channels — see
`reports/mocks-registry.md`.
## Caveat (unchanged from b0)
Non-Development Serilog targets the `logDb` connection; Development boots against the configured
`SqlServer`. A reachable SQL Server is required to run the API (it applies migrations + seeds on boot).
@@ -0,0 +1,55 @@
# Handoff — after backend phase 10 (Payments core: ledger, transactions, webhooks & card capture)
**The money core is live.** A family can now pay the gross price by card: **initiate → PSP webhook confirms →
the balanced card-capture ledger group posts → the booking converts/confirms**. The PSP acquirer, تسهیم split,
webhook signature verify, and the distributed lock are **mocked behind seams**; the DB constraints are the
authoritative money-path backstops.
## What f9-b10 can now build
- **Summary & pay (C6)** — after the nurse accepts, `POST api/v1/bookings/{bookingRequestId}/payments` (as the
customer, with an `Idempotency-Key` header) → `{ transactionId, redirectUrl, gatewayReferenceCode }`. Show
the checkout summary (gross + commission/VAT/escrow notice) and send the payer to `redirectUrl`. **Never show
the internal `account_type`s** — gross + the commission/VAT breakdown only.
- **Card payment redirect + confirmation** — the (mock) redirect completes; the PSP callback hits
`POST api/v1/webhooks/payments/{provider}` and confirms. Poll the booking (b9 `bookings/list` /
`bookings/get`) → it appears **`confirmed`** once the webhook captures. (A booking exists only on capture.)
- **Nurse payable balance** — `GET api/v1/nurses/{nurseId}/payable_balance` (the nurse themself or admin) →
`{ nurseId, balanceIrr }` as a digit-string, derived from the ledger.
## Live endpoints / contracts
- Contract: [`dev/contracts/domains/payments.md`](../../contracts/domains/payments.md).
- Enums: `payment` status (`pending`/`succeeded`/`failed`), `payment_gateways.type` (`standard`/`bnpl`),
`processing_status` (`received`/`processed`/`failed`/`ignored`), the eight `account_type`s (internal).
## Load-bearing rules the client must honour
- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math.
- **The booking is created & confirmed on capture (the webhook), not on initiate.** After `initiate` you have a
redirect only; the `bookings` row appears when the PSP callback confirms.
- **Payment is idempotent end-to-end** — a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
- **Internal account types are never exposed to the customer** — checkout shows gross + commission/VAT only.
## Design note (reconciling b10 with b9's as-built)
b9 creates the `bookings` row **on capture** (it never persists a standalone `pending_payment` booking). So b10
initiates payment against the `accepted_awaiting_payment` **request**; `payment_transactions.booking_id` is
**nullable**, bound only at confirm. Confirm reuses b9's conversion/amount logic through the extracted
**`BookingFactory`** (no duplication) and posts the ledger + registers the split. The mock
`IPaymentCaptureSimulator` + the `POST bookings/convert` endpoint **stay** (b9's own tests use them); the real
capture path uses `BookingFactory` directly, not that seam.
## Mocked here → make real later (see reports/mocks-registry.md)
- **`IPaymentProvider`** (🟡) — deterministic ref + fake redirect; `VerifyAsync` instant-succeeds. Real:
ZarinPal/Sadad/Vandar/Jibit acquirer-with-تسهیم, merchant id from encrypted `config_json`, server-side verify.
- **`ISettlementSplitProvider`** (🟡) — records split intent, returns `Settled`. Real: split-by-ratio to
registered SHEBAs; provider credits IBANs directly.
- **`IWebhookVerifier`** (🟡) — signature valid unless a marker; parses a test JSON body. Real: per-provider
HMAC / mandatory server-side verify.
- **`IDistributedLock`** (🟡) — in-process semaphore. Real: StackExchange.Redis lease, key `booking:{id}:payment`.
## Consumed by later backend phases
- **b11** — refunds/clawbacks/invoices post against the ledger (`refund_payable`/`nurse_clawback_receivable`
account types are already defined); `IPaymentProvider.RefundAsync` is ready to call.
- **b12** — BNPL settle routes callbacks through the **same** `payment_webhook_events` idempotency store and
posts the BNPL-settle group (adds `bnpl_fee_expense`).
- **b13** — payouts read `GetNursePayableBalance` (the signed `nurse_payable` ledger sum) and post
DEBIT `nurse_payable` / CREDIT `escrow_held`, gated on b9's `dispute_window_ends_at`.
@@ -0,0 +1,46 @@
# Handoff — after backend-phase-11 (Refunds, clawbacks & invoices)
**The reversal leg of the payments arc is live.** Money can now flow *backwards*: an admin reverses a captured
booking payment across both fee legs, the balanced reversal posts to the append-only ledger, and the flow forks
on whether the nurse was already paid (clean `nurse_payable` reversal vs a first-class `nurse_clawbacks`
receivable). The minimal commission **invoice** (VAT on the commission line, sequential number, mocked مودیان)
ships too.
## What the frontend (f10-b11) can now build
- **Admin refund console** — create a refund (`POST admin_refunds`: full/partial by percentage or explicit legs),
the refund worklist (`GET admin_refunds` — channel, decomposed legs, status, ETA, policy snapshot), and the
clawback write-off (`POST admin_clawbacks/{id}/write_off`).
- **Admin invoice issuance** — `POST admin_invoices` (sequential number, VAT on commission, مودیان pending).
- **Customer cancellation/refund-status view** — `GET refunds/{id}/status` (tenancy-scoped; status, channel,
amount, **`expected_customer_refund_eta`** for the BNPL 710-business-day window, masked reference). This is the
**only** customer-visible refund surface — refunds are admin-initiated, there is no self-service initiation.
- **Customer/admin invoice view** — `GET invoices/{booking_id}` (number, gross, commission, VAT, مودیان status,
PDF URL when present).
## Live endpoints / contract
- Contract: **`dev/contracts/domains/refunds-invoices.md`** (enums, DTO shapes, IRR digit-strings, masked
references, failure codes, side-effects). Machine schema: `dev/contracts/openapi/swagger.v1.json` **refreshed**.
- All money is IRR integer, on the wire as a **digit-string**. `expected_customer_refund_eta` is a **date**.
- Admin endpoints are behind the admin policy and **rate-limited** (sensitive). Refunds are **never** customer
self-service.
## What is mocked / waiting
- **سامانه مودیان** is mocked behind **`IMoadianClient`** (pending/no-ref by default; a config switch forces a
registered 22-digit ref). See `reports/mocks-registry.md`.
- **BNPL revert** runs against **`IBnplProvider`** — a **thin local stub** now; **b12 owns the real seam** and its
adapter. The `bnpl_revert` ledger legs are already identical to the card path.
- **"Was the nurse paid?"** is derived from the booking's dispute-window close (`INursePayoutStatus`) until **b13**
ships `nurse_payouts`; **clawback recovery/netting is b13** (this phase only opens the `pending` receivable +
supports write-off).
- **`tickets`** arrives in **b15**: `refunds.ticket_id` is a nullable column (no FK yet); the "ticket required"
rule is config-gated off (`refund_ticket_required = false`).
- **Deferred crons** (thin/manual today): the مودیان reconciliation job (`pending → registered`) and the
BNPL-revert reconciliation job that clears `refund_payable ↔ escrow_held` for a `processing` refund.
## Notes for the next backend phases
- **b12 (BNPL):** owns the real `IBnplProvider`; the refund `bnpl_revert` path already calls it through the seam.
- **b13 (payouts):** implement the real `INursePayoutStatus` (`nurse_payout_booking_links`) and net `pending`
`nurse_clawbacks` out of a payout batch (set `recovered_in_payout_id` / `original_payout_id`, post `DEBIT
nurse_payable / CREDIT nurse_clawback_receivable`).
- **b15 (tickets/partner centers):** wire the real `refunds.ticket_id` FK + flip `refund_ticket_required` on;
wire `invoices.partner_center_id` + `nurse_clawbacks` payout FKs.
@@ -0,0 +1,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.
@@ -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).
@@ -0,0 +1,56 @@
# Handoff — after backend-phase-14 (Reviews, ratings & patient care records)
**The trust loop and the continuity-of-care loop are live.** A customer leaves **one moderated review per
completed booking**; an admin/moderator publishes/hides/rejects it; the nurse's public rating is recomputed
**from source on every transition** (so hiding a 1-star lowers the count and re-derives the average — no
inflated-after-hide drift); low ratings auto-raise an internal `support_alert`; and nurses author **encrypted,
patient-scoped** clinical notes readable only under a strict clinical-access rule.
## What the frontend (f13-b14) can now build
- **Leave a review** — `POST bookings/{bookingId}/review` `{ rating 15, body?, tagCodes? }` (customer who owns a
completed booking). Returns `{ id, moderationStatus: "pending_moderation", lowRatingAlertRaised }`. The review is
**not public** until an admin publishes it — build the "submitted, awaiting moderation" state.
- **Public nurse reviews** — `GET nurses/{nurseProfileId}/reviews?page=&pageSize=` → `{ aggregate: { averageRating,
publishedCount }, reviews: PagedResult<{ id, rating, body, tagCodes[], createdAt }> }`. **Published only.**
- **Public tag rollup** — `GET nurses/{nurseProfileId}/review_tags` → `{ publishedReviewCount, tags: [{ code,
labelFa, labelEn, count, percentage }] }` ("% punctual"). The seeded vocab (`punctual/professional/clean/kind/
communicative`) is always returned.
- **Tag your own review** — `POST reviews/{reviewId}/tags` `{ tagCodes }` (author or moderator; replaces the set).
- **Admin moderation console** — `GET admin/reviews/moderation_queue?status=&page=&pageSize=` (default
`pending_moderation`; each row carries the linked `lowRatingAlertId` for triage) and
`PATCH reviews/{reviewId}/status` `{ action: publish|hide|reject|unpublish, reason? }` (hide/reject need a reason).
The PATCH returns the recomputed `{ averageRating, totalReviews }`.
- **Patient care records** — `POST patients/{patientId}/care_records` `{ bookingId?, body }` (nurse with a
confirmed booking) and `GET patients/{patientId}/care_records?page=&pageSize=` (owning customer / nurse with a
confirmed booking / admin) → decrypted `{ id, patientId, bookingId, nurseProfileId, nurseName, body, recordedAt }`
newest first. The history is **patient-scoped** — a new nurse taking over reads the whole history.
## Contracts
- **`dev/contracts/domains/reviews-records.md`** — all 8 endpoints, the `moderationStatus`/action enums, tag
codes, DTO shapes, and the care-record **access matrix**.
- **`dev/contracts/openapi/swagger.v1.json`** refreshed — the 8 review/care-record paths are in the snapshot.
## What is mocked (and how it becomes real)
- **`IReviewModerationService`** (new) — AI review pre-screen. `MockReviewModerationService` is a keyword filter:
clean text → a human-review **Flag** by default (so the publish gate holds — reviews land `pending_moderation`);
a banned-word substring → **Reject** (auto-hidden). Config `Seams:ReviewModeration:{AutoApproveClean,BannedWords}`.
Make it real → a text classifier / LLM endpoint (see reports/mocks-registry.md). `ModerateReviewCommand` keeps
decision authority + the human override, so the real impl never touches the handler.
## Load-bearing rules (don't regress)
- **Recompute from source, not delta** — every publish/hide/reject/unpublish re-derives `average_rating`/
`total_reviews` over currently-published reviews (exclude-the-changed-review then fold in its new status), in the
same transaction, then refreshes the search index.
- **Publish gate** — `pending_moderation`/`hidden`/`rejected` are never in a public read and never counted.
- **1:1 per completed booking** — `UNIQUE(booking_id)` + handler pre-check; cross-tenant is a 404.
- **Low rating (≤ config `min_rating_for_support_alert`, default 2)** raises an internal `low_rating` alert —
internal-only, never in a user response (only its id shows on the admin queue).
- **Care records are patient-scoped, encrypted at rest, strict access** — a nurse without a confirmed booking for
the patient is denied read + write.
## Deferred (flagged, not built)
- Two-way (nurse-reviews-customer) double-blind reviews with timed reveal.
- First-class `incidents` entity + ML fraud scoring (manual suspension + `support_alerts` cover it now).
- The ticket system, partner centers, and the admin **support-alert worklist console** → **b15** (this phase only
*raises* alerts).
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions → b15.
@@ -0,0 +1,63 @@
# Handoff — after backend phase 15 (Messaging, partner centers & admin backoffice)
**This is the final backend phase. The backend chain is complete.** Every domain the admin backoffice acts on
now exists and is wired together.
## What is now live (frontend can build against it)
### Tickets — the post-booking channel (f14 messaging)
- `POST /api/v1/tickets` — open a ticket (`category``support|coordination|refund|emergency`; optional
`bookingId`/`refundId`; body optional). Opener is auto-added as the first participant. Returns
`{ ticketId, referenceCode, status, category }`.
- `POST /api/v1/tickets/{id}/messages` — post a message. **`isInternal` is staff-only**; a non-staff caller
sending `true``403`; posting to a closed ticket as non-staff → `403`.
- `POST /api/v1/tickets/{id}/participants` (add) / `DELETE …/participants/{userId}` (soft-remove) — staff or
ticket owner. A **duplicate add is `409`** (backed by `UNIQUE(ticket_id, user_id)`), never a 500.
- `POST /api/v1/tickets/{id}/close` · `/reopen` — participant or staff (idempotent).
- `POST /api/v1/tickets/emergency` — assigned nurse (or staff) logs an emergency (+ optional support alert).
- `GET /api/v1/tickets` — my tickets (paginated, filter `status`, search `referenceCode`).
- `GET /api/v1/tickets/{id}`**user thread view: internal notes are stripped** in the projection.
- `GET /api/v1/admin/tickets` + `GET /api/v1/admin/tickets/{id}` — admin queue + **admin thread view: internal
notes included** (`support`/`admin`).
**The rule f14 must respect:** never build a direct nurse↔customer channel, never surface a phone number, and
never rely on the UI to hide internal notes — the backend already strips them from the user payload. The
coordination ticket for a booking is auto-created on confirmation (you don't create it).
### Partner centers + merchant-of-record (f15 admin + partner consoles)
- `POST /api/v1/admin/partner-centers` (create, inactive) · `PATCH …/{id}` (update) ·
`POST …/{id}/verify` (activate) · `POST …/{id}/sponsor-nurse` · `GET …` (list) · `GET …/{id}` (detail) —
`admin`/`super_admin`. **`settlementIbanMasked` (last 4) is the only IBAN ever returned** — never plaintext.
`commissionRate ∈ [0,1)`; a merchant-of-record center requires a `settlementIban`.
- `GET /api/v1/centers/{id}/dashboard` — the center's own account (or staff): sponsored nurses + booking/invoice
counts + masked settlement summary.
- `GET /api/v1/internal/bookings/{bookingId}/center` — the issuer/settlement resolver
(`platform` | `partner_center`).
### Admin backoffice (surfaced, not rebuilt)
- Support-alert worklist: `GET support_alerts/get_support_alerts`, `POST …/assign_support_alert`,
`POST …/resolve_support_alert` (built b1). Audit viewer: `GET audit/get_audit_trail` (built b1). Both
`DynamicPermission`. Verification queue / refunds / payout dashboard / moderation queue are their own phases'
routes — surface them under the admin console with the right RBAC scope.
## RBAC the frontend must respect (per route)
- Authenticated (own) ticket routes: any logged-in user; participation is enforced server-side.
- Admin ticket queue + admin thread: `support`/`admin`. Partner centers: `admin`/`super_admin`. Center
dashboard: the center's `adminUserId` (or staff). Support alerts: `support`/`admin`. Audit: `super_admin`/`admin`.
- The admin role passes every `DynamicPermission` check; narrower staff scopes (`support`/`finance`/`moderation`)
are granted via seeded role claims.
## What's mocked
- **`ILicenseVerificationService`** (eNamad / MoH establishment-permit) — `MockLicenseVerificationService`,
manual-approve at MVP (`NeedsManualReview`); `VerifyPartnerCenter` records the human decision. Config
`Seams:LicenseVerification:AutoApprove` forces `Valid`. See the mock registry (🟡). There is **no** telephony
seam — the emergency call is an out-of-platform `tel:` link by design.
## Contracts
- `dev/contracts/domains/messaging-notifications-admin.md` (this phase). `swagger.v1.json` refreshed (now includes
`/tickets`, `/admin/tickets`, `/admin/partner-centers`, `/centers`, `/internal/bookings/{id}/center`).
## Types / wire notes
- Envelope unchanged (camelCase body, snake_case URL tokens where `[action]`-based; the new controllers use
explicit REST routes). Pagination `page`/`pageSize` (default 50, max 100). `sentAt`/`closedAt`/`verifiedAt` are
UTC ISO-8601; ids are numbers; the settlement IBAN is a masked string (`"••••0001"`).
@@ -0,0 +1,50 @@
# After backend-phase-2 — auth is live over REST
The marketplace has its front door: **phone-OTP login, revocable sessions with refresh-token
rotation + stolen-token detection, `/me`, and public role selection** — all over REST, wrapping the
pre-existing JWE/TOTP/RBAC engine (nothing was rebuilt). Contract:
[`dev/contracts/domains/identity-auth.md`](../../../contracts/domains/identity-auth.md); machine
schema: `dev/contracts/openapi/swagger.v1.json` (refreshed — 22 paths).
## What the frontend (f1-b2) can now build
- **Login flow:** `POST api/v1/auth/request_otp``POST api/v1/auth/verify_otp` (camelCase bodies;
exact snake_case paths per the contract — note `request_otp`, **not** `otp/request`).
- **Session handling:** store the token pair; `POST api/v1/auth/refresh` rotates it (never reuse an
old refresh token — replay = 401 + logout-everywhere); `POST api/v1/auth/logout` (send `{}`).
- **`AuthContext` roles + role router:** `GET api/v1/me` returns masked phone, `roles[]`,
profile-completion flags (false until b3) and `nurseVerificationStatus` (`not_started` until b6).
Fresh users have `roles: []` → route to `POST api/v1/me/select_role` (`customer`/`nurse`, both
allowed, 403 for anything else). **Refresh tokens after role selection** — role claims are baked
into the access token.
- **Errors:** 400 invalid phone/code (safe, non-enumerating message), 401 with the standard
envelope (also written by the auth stack itself), 403 admin self-assign, 429 over the OTP/auth
per-IP limits. The envelope is unchanged (camelCase body, snake_case URLs).
## What's mocked
- **SMS delivery (`ISmsSender` → 🟡).** The OTP code is written to the server log instead of a SIM.
Local testing: call `request_otp`, read the code from the API console log, `verify_otp` with it.
## Rules baked into the API (don't fight them client-side)
- Phone is the only public credential; email is optional and never a login key.
- One resend per `auth_otp_resend_seconds` (response says `otpSent: false` + wait time).
- After `auth_otp_max_attempts` wrong codes, verification refuses until a fresh OTP is requested.
- Logout rotates the security stamp: **all** devices' access tokens die; they recover via refresh.
## Schema / migration
Migration **`20260701222425_IdentitySessionsAndUserExtensions`** applied to the dev DB on top of
b1's baseline: `usr.Users` gains `Gender`, `NationalId` (enc, NULL until b6 KYC),
`NationalIdVerifiedAt`, `ShahkarVerifiedAt` (auto-reset on phone change), `PhoneHash` (UNIQUE),
`PhoneVerifiedAt`, `IsActive`, `DeletedAt` (+ soft-delete filter); new `usr.UserSessions`;
`usr.UserRoles` gains `GrantedById`/`GrantedAt`/`RevokedAt` (revoked grants filtered out globally).
`PhoneNumber`/`Email`/`NationalId` are now **encrypted at rest** — never query them by equality;
use `PhoneHash`. Roles seeded: `customer`, `nurse`, `admin`, `support`, `finance`, `moderation`,
`super_admin`. Config keys added: `auth_otp_resend_seconds` (120), `auth_otp_max_attempts` (5),
`auth_session_ttl_days` (30).
## Follow-ups later phases must close
- **b3:** profiles/patients/addresses/bank accounts; `gender` + names become settable; the `/me`
profile-completion flags start reading real tables.
- **b6:** Shahkar + KYC set `NationalId`/`ShahkarVerifiedAt`; `nurseVerificationStatus` becomes real.
- **Legacy `UserRefreshTokens`** still backs the gRPC path only; retire it when gRPC moves to
sessions (or gRPC is dropped).
- **`ISmsSender` → real gateway** (see mocks-registry row).
@@ -0,0 +1,57 @@
# After backend-phase-3 — profiles, patients & nurse bank accounts are live
On top of the b2 auth spine, the *people behind the accounts* now exist. A nurse has a seller profile
(that b6 will verify and b5 will hang service variants off), a customer has a payer profile and their
patients, and a nurse has payout bank accounts with an automated IBAN-ownership inquiry. Contract:
[`dev/contracts/domains/identity-profiles.md`](../../../contracts/domains/identity-profiles.md); machine
schema: `dev/contracts/openapi/swagger.v1.json` (refreshed).
## What the frontend (f2-b3) can now build
- **Nurse profile bootstrap:** `POST api/v1/nurse_profiles/upsert` (bio, experience, education,
`specializationsJson`), `GET api/v1/nurse_profiles/me`, and the pause/resume toggle
`POST api/v1/nurse_profiles/set_accepting_bookings`. `isVerified` and the rating/booking aggregates are
**read-only** (verification is b6) — render them, never send them.
- **Customer profile:** `POST api/v1/customer_profiles/upsert` (emergency contact) + `GET …/me`.
- **"Who is care for" (patients):** `create` / `list` (paginated) / `get/{id}` / `update/{id}` /
`archive/{id}` under `api/v1/patients`. `gender` (`male`/`female`) is **required**. A customer only ever
sees/edits their own patients — someone else's id returns **404**.
- **Nurse bank-account settings:** `add` (returns the account with `matchedNationalId` set by the شبا
inquiry), `list` (IBAN **masked**, last-4), `set_primary/{id}`, and `verify_ownership/{id}` to re-run
the inquiry.
## Rules baked into the API (don't fight them client-side)
- **Refresh after `select_role`.** These endpoints authorize on the **role claim in the access token**;
the token minted before role selection lacks it. Login → `select_role`**refresh** (or re-login) →
then call profile/patient/bank endpoints. Otherwise you get `403`.
- **Guarded verification** — no field/endpoint sets `isVerified`; a nurse is not bookable until b6.
- **Tenancy** — patients and bank accounts are strictly owner-scoped; cross-tenant reads/writes are `404`.
- **IBAN is masked on the wire** (last-4 only); the full value is encrypted at rest.
- **`matchedNationalId`** is the money-mule-prevention gate for the first payout (enforced in b13). It is
`null` until the inquiry runs; `add` runs it automatically. The bank rail is **mocked** at MVP.
- Duplicate IBAN → a clean `400` (via `iban_hash` uniqueness), not a server error.
## What's mocked
- **IBAN ownership (`IBankAccountOwnershipVerifier` → 🟡).** Deterministic fake استعلام شبا: every IBAN
matches except the configured mismatch IBAN (`Seams:BankOwnership:MismatchIban`, default
`IR000000000000000000000000`) which returns `matchedNationalId=false`. No real bank/KYC call.
## Schema / migration
Migration **`20260702042131_IdentityProfilesPatientsBankAccounts`** (applied to the dev DB on startup):
`usr.NurseProfiles`, `usr.CustomerProfiles`, `usr.Patients`, `usr.NurseBankAccounts` — with the 1:1
uniques, `UNIQUE(iban_hash)`, filtered single-primary index, guarded `is_verified`, encrypted PII columns
(`iban`, `account_holder_name`, emergency contacts, `initial_medical_notes`), and soft-delete on
`NurseProfiles`. None of the CUT columns (`verification_status`, `response_rate`, … ,
`customer_profiles.national_id_verified_at`) exist.
## Deferred to later phases (do not build against these yet)
- **Addresses & nurse service areas → b4** (need province/city/district + geocoder).
- **`is_verified` flip → b6** (verification pipeline).
- **Payout gating on `matched_national_id` → b13.**
- **Aggregate recompute (rating/reviews/completed) → b9/b14.**
- **Customer national-ID KYC** — intentionally not collected; never gate browsing/booking on it.
## Note for the whole backend chain
FluentValidation was previously inert (no validators registered). b3 activates it in
`AddApplicationServices` — every `AbstractValidator<T>` now runs via `ValidateCommandBehavior` and the
`ModelStateValidationAttribute` controller filter. **Consequence:** for route-supplied ids, don't add a
body validator rule on that id (e.g. `patients/update/{id}` validates the body, whose `Id` is 0).
@@ -0,0 +1,61 @@
# After backend-phase-4 — geography, addresses & nurse service areas are live
The geographic spine the marketplace stands on now exists. There is a real province→city→district
hierarchy (tables, not code lists), nurses can declare where they travel, and customers can save
encrypted, geocoded service addresses. Contract:
[`dev/contracts/domains/geography-addresses.md`](../../../contracts/domains/geography-addresses.md);
machine schema: `dev/contracts/openapi/swagger.v1.json` (refreshed).
## What the frontend (f3-b4) can now build
- **Cascading province/city/district dropdowns** — `GET api/v1/geo/provinces`,
`…/geo/cities?province_id=`, `…/geo/districts?city_id=` (each active-only, ordered by `sortOrder`), or
the whole active tree in one call via `GET api/v1/geo/tree`. **An empty district list is normal** — that
city is whole-city-only; let the user pick "whole city".
- **Nurse coverage-area editor** — `POST api/v1/nurse_service_areas/add` `{ cityId, districtId? }`
(omit `districtId` = whole city), `DELETE …/nurse_service_areas/remove/{id}`,
`GET …/nurse_service_areas/list`. Each row carries an `isWholeCity` flag. Requires a nurse profile first
(b3 `nurse_profiles/upsert`).
- **Address book + map-pin picker** — `POST api/v1/customer_addresses/create`
`{ title, cityId, districtId?, addressLine, postalCode?, recipientName?, recipientPhone?, isPrimary? }`,
`update/{id}`, `set_primary/{id}`, `delete/{id}`, `list` (primary first). The create/update response and
the list return `latitude`/`longitude` for the map pin (**nullable** — render a "pin not set" state when
null), and the address is **decrypted for the owner**.
## Rules baked into the API (don't fight them client-side)
- **`districtId = null` means "the entire city"** — a deliberate coverage choice, not "unset". Show it as a
first-class option; a whole-city service area matches every district in that city when search lands (b7).
- **Duplicate service area → `409`** (including a duplicate whole-city row), never a `500`. Surface it as
"you already cover this".
- **Single primary address** — the first address is primary automatically; setting another primary clears
the previous one. There is always exactly one.
- **`is_active` hides, never deletes** — a deactivated region simply drops out of the dropdowns.
- **Address PII is encrypted at rest** and only ever returned to the owning customer. Coordinates and the
`title` label are not PII.
- **Tenancy** — service areas and addresses are strictly owner-scoped; another owner's id returns `404`.
- **Refresh after `select_role`** still applies (nurse/customer scoping reads the role claim in the token).
- **Routes are action-style** (`admin_geo/create_city`, `nurse_service_areas/add`,
`customer_addresses/create`, …) — see the contract for the full list.
## What's mocked
- **Geocoding (`IGeocoder` → 🟡).** `MockGeocoder` returns deterministic coordinates around the city
centroid with no network call. A config switch (`Seams:Geocoding:ReturnNullCoordinates`) or a `NO_GEO`
marker in the address text forces the null-coordinate path so the "saved without a map pin" UI state is
testable. Real Neshan/Google geocoding is a drop-in registration swap (see mocks-registry).
## Schema / migration
Migration **`20260702093332_GeographyAddressesServiceAreas`** (applies on startup): `geo.Provinces`,
`geo.Cities`, `geo.Districts`, `geo.NurseServiceAreas`, `usr.CustomerAddresses`. Whole-city uniqueness is a
**filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS NULL AND deleted_at IS NULL` +
`UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL AND deleted_at IS NULL`); addresses
carry a filtered `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL`; coordinates are
`decimal(9,6)`; address PII columns are encrypted. Seed: 31 provinces (Tehran id `1`), capital cities
(Tehran city id `101`), Tehran districts `1001…1022`.
## Deferred to later phases (do not build against these yet)
- **`nurse_search_index` fan-out** on service-area add/remove → **b7** (the add/remove handlers are the
clean trigger point).
- **GPS-radius / "nurses near me" map discovery** → not planned; coverage is named districts, full stop.
- **EVV distance check** that *consumes* the address `latitude`/`longitude`**b9** (this phase only
*produces* the coordinates).
- **Region bulk-import feed** (`IGeoDataImporter`) → deferred; the idempotent seed + admin CRUD is enough
for MVP.
@@ -0,0 +1,82 @@
# After backend-phase-5 — the service catalog & nurse pricing variants are live
The two-tier service model the whole marketplace is priced and searched on now exists. Admins curate a
catalog skeleton (categories → configurable option groups → option values) that ships as **data, not
migrations**, and each nurse turns that skeleton into **variants** — the atomic bookable unit: a category +
one chosen value per dimension, at the nurse's own price and price unit. Contract:
[`dev/contracts/domains/catalog.md`](../../../contracts/domains/catalog.md); machine schema:
`dev/contracts/openapi/swagger.v1.json` (refreshed for b5).
## What the frontend (f4-b5) can now build
- **Category grid / browse** — `GET api/v1/catalog/categories` (active, ordered, **paginated**, cached).
Five categories are seeded (Elderly, Post-Surgery, Infant, Chronic, Companionship) with `nameFa`+`nameEn`.
- **The nurse service-builder** — for a chosen category, `GET api/v1/catalog/option_groups?category_id=`
returns the **applicable** dimensions (the category's own groups **plus** every cross-category group),
each with `isRequired` and its active values. The nurse then `POST api/v1/nurse_variants/create`
`{ serviceCategoryId, options: [{ optionGroupId, optionValueId }], price, priceUnit, sessionCount?, displayName? }`.
Manage with `update/{id}`, `set_active/{id}`, `list`, `get/{id}`. Requires a nurse profile first
(b3 `nurse_profiles/upsert`).
- **Admin catalog console** — `admin_catalog/create_category|update_category/{id}|set_category_active/{id}`,
`create_option_group|update_option_group/{id}`, `create_option_value|update_option_value/{id}` (admin
token / dynamic-permission).
- **Public variant view** — `GET api/v1/nurse_variants/get/{id}` returns the public (active-only) projection
for anyone, backing a nurse-profile offerings list.
## Rules baked into the API (don't fight them client-side)
- **The bookable unit is the variant, not the nurse.** Price/book against a specific variant.
- **`price` is a string of IRR-Rial digits** (e.g. `"8000000"`) — integer money, no floats, no Toman. The
**total is `price` + `priceUnit` + `sessionCount`**; never compute it from `price` alone. Parse with a
BigInt-safe helper, format for display, map `priceUnit` to an i18n label (never off the code).
- **A NULL-category option group is cross-category** — it applies to every category. Render the cross-
category groups in the builder for *every* category; the required ones must be answered.
- **All required dimensions must be answered**, **one value per dimension**, a value must belong to its
group. A missing required dimension → **400** (names it); a duplicate identical listing → **409**; surface
both cleanly.
- **`displayName` auto-generates** (category + chosen value labels) — show it, let the nurse override it.
- **Deactivate, never delete.** A deactivated variant stays in the nurse's own `list` (flagged
`isActive:false`) but is unbookable and 404s on the public `get`.
- **Every catalog row has `nameFa` (primary) + `nameEn`** — pick by locale, never label off a code.
- **Tenancy** — variants are strictly owner-scoped; another nurse's id on write → **404** (existence not
leaked). Catalog skeleton writes are **admin-only**.
- **Refresh after `select_role`** still applies (nurse scoping reads the role claim in the token).
- **Routes are action-style** (`admin_catalog/create_category`, `nurse_variants/create`, …), POST for
mutations, camelCase bodies — see the contract for the full list.
## What b7 (search & matching) must read from the variant
The variant is a clean, projection-friendly source. b7 owns the denormalized `nurse_search_index` and the
`INurseSearch` seam (**not built here**). Its fan-out reads, per active variant: `service_category_id`,
`price`, `price_unit`, `is_active`, `nurse_id` — joined to the nurse's `nurse_service_areas` (b4) to emit one
index row per covered city/district, and to `nurse_profiles` for `is_verified`/accepting/gender/rating. The
**single write trigger points** to maintain that index are `CreateVariantCommand`, `SetVariantActiveCommand`
(and later option/price edits) — hook the index maintenance there.
## What b8 (booking) consumes
`IVariantSnapshotSerializer` (`Application/Contracts/Common`, single real impl in `Application/Common`) is
ready: `string Serialize(VariantSnapshot)` emits the canonical `variant_snapshot_json` (category id + labels,
each `(group label, value label)`, `price` as a digit string, `price_unit`, `session_count`, `display_name`,
`variant_id`). b8 owns the `booking_requests.variant_snapshot_json` **column** and calls the serializer at
booking time so later variant edits/deactivation never mutate past bookings. The snapshot column is **not**
added here.
## Schema / migration
Migration **`20260702132758_ServiceCatalogAndNurseVariants`** (applies on startup), new **`catalog`** schema:
`ServiceCategories`, `ServiceOptionGroups` (nullable `ServiceCategoryId` = cross-category),
`ServiceOptionValues`, `NurseServiceVariants` (`Price` **BIGINT**, `PriceUnit` code, `SessionCount?`,
`DisplayName`, `OptionSetHash`), `NurseServiceVariantOptions`. Constraints: `UNIQUE(VariantId, OptionGroupId)`
(one value per dimension); filtered `UNIQUE(NurseId, ServiceCategoryId, OptionSetHash) WHERE DeletedAt IS
NULL` (duplicate-listing backstop); soft-delete query filters. Seed: five categories (`nameFa`+`nameEn`),
ids 15. Option groups/values are admin-authored data (not seeded).
## Deferred to later phases (do not build against these yet)
- **`nurse_search_index`, `INurseSearch`, the search query & index fan-out** → **b7** (variant writes are the
clean trigger point; the variant shape is projection-ready).
- **`variant_snapshot_json` persistence** → **b8** (the serializer is shipped and unit-tested here).
- **`nurse_availability_slots` / `nurse_availability_exceptions`** → **deferred** (soft scheduling guidance,
not on the money/safety path) — not built.
- **Holiday/surge pricing, a Companionship *tier* pricing model, tiered per-category commission** →
**deferred**. Companionship ships only as a seeded category (data), not a special pricing path.
## What's mocked
**Nothing.** Catalog and variant data are fully owned by Balinyaar's DB — this phase introduces **no**
cross-cutting seam and adds **no** row to `reports/mocks-registry.md`. It reuses `ICacheService` (b0) for the
public catalog reads with invalidate-on-mutation.
@@ -0,0 +1,90 @@
# After backend-phase-6 — nurse verification & credentials are live (vendors mocked)
The trust engine now exists. A nurse opens a **verification checklist**, clears automated checks
(identity-KYC, Shahkar, bank-ownership) and uploads documents for manual credential steps; an admin reviews,
decides, and — when every required step passes — the platform flips `nurse_profiles.is_verified` **inside a
single transaction**, making the nurse bookable. Credential **numbers are encrypted and never leave the API**;
the public **trust badge** exposes only the credential **types** a nurse holds. Contract:
[`dev/contracts/domains/verification.md`](../../../contracts/domains/verification.md); machine schema:
`dev/contracts/openapi/swagger.v1.json` (refreshed for b6 — all 15 endpoints). **All vendor/money calls are
mocked** behind deterministic DI seams.
## What the frontend (f5-b6) can now build
- **The nurse verification checklist screen** — `GET api/v1/nurse_verification` returns `status`,
`isBookable`, `blockingSteps[]`, and `steps[]` (each with `code`, `displayName`, `status`, `isAutomated`,
`expiresAt?`, `failureReason?`). `POST api/v1/nurse_verification/submit` opens/seeds it (idempotent).
Requires a nurse profile first (b3 `nurse_profiles/upsert`). Render the checklist off `steps`, gate the
"you're bookable" state on `isBookable`, and surface `blockingSteps` as the to-do list.
- **Identity submit (automated)** — `POST api/v1/nurse_verification/steps/identity_kyc/run`
`{ nationalId, livenessPayload? }``RunStepResult` (`stepStatus`, `failureReason?`). A pass populates the
nurse's national id; drive the next steps off it.
- **Shahkar & bank runs (automated)** — `POST .../steps/shahkar_match/run` (no body; **needs KYC passed**)
and `POST .../steps/bank_account_verification/run` (no body; **needs KYC + a primary bank account from b3**).
Both return `RunStepResult`. Shared-SIM comes back as a clean `failed` + reason (not an error) — show it.
- **Document upload for manual steps** — two-step: `POST .../steps/{stepId}/upload_url`
`{ contentType, fileName? }``{ objectStorageKey, uploadUrl }`; PUT the file to `uploadUrl`; then
`POST .../steps/{stepId}/documents` `{ objectStorageKey, integrityHash, contentType, fileSizeBytes, originalFileName? }`
→ the step moves to `in_review`. Manual steps only (an automated step 400s the upload).
- **The credentials / under-review / rejected states** — render each `step.status`
(`not_started`/`pending`/`in_review`/`passed`/`failed`/`expired`) with the right affordance; `in_review`
= "with our team", `failed`/`expired` = show `failureReason` + a redo path, `passed` = done.
- **The public trust badge (f6)** — `GET api/v1/nurses/{nurseId}/trust_badge` (**anonymous**) → `isVerified`,
`approvedAt?`, `credentialTypes[]`. Types only — never a credential number. Back the nurse-profile trust
chip / verified marker with it.
## Live endpoints (all under `api/v1`, action-style, camelCase bodies)
Nurse (`[Authorize]`, nurse-scoped): `nurse_verification/submit`, `GET nurse_verification`,
`nurse_verification/steps/{stepId}/upload_url`, `nurse_verification/steps/{stepId}/documents`,
`nurse_verification/steps/identity_kyc/run`, `nurse_verification/steps/shahkar_match/run`,
`nurse_verification/steps/bank_account_verification/run`.
Admin step-types (dynamic-permission): `GET admin_verification_step_types`, `POST admin_verification_step_types`,
`DELETE admin_verification_step_types/{id}`.
Admin review (dynamic-permission): `GET admin_verifications`, `GET admin_verifications/{nurseVerificationId}`,
`admin_verifications/steps/{stepId}/decide`, `admin_verifications/{nurseVerificationId}/suspend`,
`admin_verifications/scan_expiring`.
Public (`[AllowAnonymous]`): `GET nurses/{nurseId}/trust_badge`.
## Rules baked into the API (don't fight them client-side)
- **`isBookable` / `isVerified` are read-only, server-derived.** Never infer verification client-side —
read `VerificationStatusDto.isBookable` (nurse view) or `TrustBadgeDto.isVerified` (public view). The
aggregate `nurse_verifications.status` is the single source of truth; `is_verified` is flipped only inside
the finalize transaction (and reversed on suspend/expiry).
- **Credential numbers never cross the wire.** `NurseCredentialDto` / `TrustBadgeDto` carry **types** and
metadata only. Don't build a UI that expects to display a number.
- **Automated vs manual steps drive different UI.** `step.isAutomated=true` → a `/run` button; `false` → the
upload flow (`upload_url` → PUT → `documents`). The list tells you which.
- **Ordering / prerequisites** — Shahkar needs identity-KYC passed; bank verification needs KYC **and** a
primary bank account (b3). Calling out of order → **400**; disable/guide accordingly.
- **Shared-SIM and vendor fails are `200` with `stepStatus:"failed"` + `failureReason`**, not HTTP errors —
surface the reason, don't treat as a crash. (Shared-SIM also raises an internal support alert.)
- **Documents come back as short-lived signed URLs** (`VerificationDocumentDto.url`) — fetch/display fresh;
don't cache the URL.
- **Refresh after `select_role`** still applies (nurse scoping reads the role claim in the token).
- **Routes are action-style POST** (`nurse_verification/submit`, `admin_verifications/steps/{id}/decide`, …),
ids from the route, camelCase bodies — see the contract for the full list.
## Schema / migration
New **`verif`** schema, one additive migration, **5 tables**: `nurse_verifications` (header; `status` = single
source of truth), `verification_step_types` (seeded catalog — six stable codes), `verification_steps`
(one per required step-type; snapshots `is_automated`), `verification_documents` (**metadata only** — bytes
never in the DB), `nurse_credentials` (`credential_number` **encrypted**, never serialized). The only derived
boolean is `nurse_profiles.is_verified`, flipped/reversed transactionally.
## What's mocked
All vendor/money calls (deterministic seams; test values in the contract):
- `IShahkarVerifier``MockShahkarVerifier` (pass unless shared-SIM `09120000000` / mismatch id `1111111111`).
- `IIdentityKycProvider``MockIdentityKycProvider` (passes any well-formed 10-digit id except `0000000000`).
- `ICredentialVerifier``MockCredentialVerifier` (manual-admin default; `verification_method=manual`).
- **Reused:** `IBankAccountOwnershipVerifier` (b3; mismatch IBAN `IR000000000000000000000000`),
`IObjectStorage` (b0; local-disk signed URLs), `IFieldEncryptor` (b0; encrypts `credential_number`).
See [`reports/mocks-registry.md`](../../reports/mocks-registry.md) for the make-it-real steps.
## Deferred to later phases (do not build against these yet)
- **Scheduled expiry cron** (`CredentialExpiryScannerJob`) → the scan logic ships as
`ScanExpiringCredentialsCommand`; today only the admin `scan_expiring` endpoint triggers it.
- **Automated MoH/INO license lookup** (`ICredentialVerifier`, `verification_method=api`) → deferred.
- **`fraud_flags` / ML fraud scoring** → deferred.
- **Professional-liability-insurance step** → addable later as a step-type row (no schema change).
- **b7 (search & matching)** reads `nurse_profiles.is_verified` for `nurse_search_index.is_searchable` — b6
owns the flip, b7 owns the index (**not built here**).
@@ -0,0 +1,50 @@
# Handoff — after backend-phase-7 (Search & matching)
**Search is live.** Verified nurses are now discoverable through one public endpoint backed by a
denormalized, maintained-on-write index. This unblocks **frontend f6-b7**: search + filters (C1), results
list (C2), and the nurse profile (C3) can be built against a real API.
## What the frontend can now build (f6-b7)
- **Search + filters (C1)** → `GET api/v1/search/nurses` (public, no auth). Query params (snake_case):
`service_category_id` (**required**), `city_id` (**required**), `district_id` (optional),
`nurse_gender` (`male`/`female`, optional), `min_price`/`max_price` (IRR long, optional),
`price_unit` (optional), `page`/`page_size` (default 1 / 50, max 100).
- **Results list (C2)** → the `data` is a `PagedResult<NurseSearchResultDto>` (`items`, `total`, `page`,
`pageSize`). Each item: `variantId`, `nurseId`, `serviceCategoryId`, `price` (IRR **digit string**),
`priceUnit`, `nurseGender`, `averageRating`, `totalReviews`, `totalCompletedBookings`, `cityId`,
`districtId` (null = whole city).
- **Nurse profile (C3)** → reuse the b6 public trust badge (`GET api/v1/nurses/{id}/trust_badge`) and the b5
public variant read (`GET api/v1/nurse_variants/get/{id}`) already live; b7 adds no new profile route.
Categories/cities/districts for the filter dropdowns come from the **b4** geo lookups (`geo/*`) and **b5**
catalog (`catalog/*`) — unchanged.
## Rules the UI must respect
- **Only searchable nurses come back.** The backend returns a nurse **only** when verified + not suspended +
accepting + variant active. No client-side re-check needed; an empty page is a valid result.
- **`districtId = null` = whole city.** A city-only search returns both whole-city and district rows; a
district search returns that district's rows **plus** whole-city rows. Show whole-city hits as covering the
district the user searched.
- **Same-gender filter is first-class.** Surface `nurse_gender` prominently; never default it silently.
(Carrying the chosen gender into the booking request — `required_caregiver_gender` — is **b8**, not here.)
- **`price` is an IRR digit string** — render with a formatter; never parse to a float. Combine with
`priceUnit` (+ `sessionCount` from the variant, when booking) for the engagement total.
- **Sort is rating-desc only** (MVP). No client sort options beyond what the API returns.
## Contracts
- New: [`dev/contracts/domains/search.md`](../../contracts/domains/search.md).
- `swagger.v1.json` refreshed (adds `search/nurses` + `admin_search/rebuild_index` + the DTOs).
## Backend notes (not frontend-facing)
- The index is a **read-only projection** maintained inline inside each source write's transaction
(`ISearchIndexMaintainer`, wired into the b3/b4/b5/b6 handlers). The read seam is **`INurseSearch`**
(SQL now; Elasticsearch is a config-selected drop-in later, `Search:Backend`).
- Admin `POST api/v1/admin_search/rebuild_index` (dynamic-permission) does an idempotent full rebuild — the
reconciliation path; incremental maintenance and rebuild converge.
- **Deferred to b8:** `booking_requests.required_caregiver_gender` capture (carry the chosen gender into the
booking). **Deferred:** Elasticsearch backend + feeder, availability hard-filter, map/radius discovery,
ranking beyond rating.
@@ -0,0 +1,68 @@
# Handoff — after backend-phase-8 (Booking requests · pre-payment intent)
**The booking-request lifecycle is live end-to-end.** A customer can request a nurse; the nurse accepts
(opening a config-driven 30-minute payment window) or rejects; both sides read their role-scoped inbox and a
single request; unanswered/unpaid requests auto-expire on a recurring sweep (also an admin manual trigger).
**No money and no `bookings` row exist yet** — that conversion is b9/b10.
## What b9 must consume (the next backend phase)
- **Convert an `accepted_awaiting_payment` request → a `bookings` row** once payment is captured (b10), then
set the request to `converted` **through the `BookingRequestTransitions` guard** (`request.MarkConverted()`
already exists and is the only sanctioned path; b8 never writes this edge itself). The request↔booking link
is 1:1 and b9-owned.
- **b8 deliberately exposes only `customer_notes` (stage 1).** The full **encrypted** clinical/care
instructions are b9's **stage 2** (`booking_care_instructions`, readable only post-confirmation by the
assigned nurse + admin). Do **not** add an encrypted clinical field to `booking_requests`.
- **The money split, snapshots, sessions, EVV, dispute window** are all b9/b10 — b8 persists **no**
`variant_snapshot_json`/`address_snapshot_json`, no price, no ledger entry. The b5 `IVariantSnapshotSerializer`
is still the tool for the booking snapshot at conversion time.
- **Reuse the forward-only status-machine pattern** for the `bookings` state machine (see CONVENTIONS §6 —
"Forward-only status machine"). Same shape: const codes + a static `CanTransition` edge table + entity-owned
transitions + handler pre-check → 409.
## What f7-b8 can now build (frontend)
All routes are **action-style** (`[controller]/[action]`, snake_case) with the standard
`{ succeeded, statusCode, data }` envelope. Full shapes in
[`dev/contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md); types come from the
refreshed `swagger.v1.json`**do not guess**.
- **Request form (C4)** → `POST api/v1/booking_requests/create` (customer). Body: `nurseId`, `variantId`,
`patientId`, `customerAddressId`, `requestedDate` (`YYYY-MM-DD`), `requestedTimeStart`/`requestedTimeEnd`
(`HH:mm:ss`), `requiredCaregiverGender` (`male`|`female`|`any`, **required**), `customerNotes` (≤ 1000, the
**only** clinical text the nurse sees). Inputs come from search (nurse+variant), patients (b3), addresses (b4).
- **Awaiting-acceptance / status tracker (C5)** → `GET api/v1/booking_requests/get/{id}` + the customer inbox
`GET api/v1/booking_requests/list?role=customer`. Show two countdowns: to `nurseResponseDeadlineAt` (pending)
and to `paymentDeadlineAt` (accepted — the 30-min window). Customer can `POST …/cancel/{id}` while pending or
accepted-awaiting-payment.
- **Nurse incoming-requests inbox** → `GET api/v1/booking_requests/list?role=nurse` (+ `status=` filter) with
`POST …/accept/{id}` and `POST …/reject/{id}` (reason required). The nurse row shows `counterpartyName` =
patient name + `customerNotes` only, plus the response countdown — **never** a full address or any clinical
field beyond `customerNotes`.
## Rules the UI must respect
- **Same-gender is first-class and required.** Send `requiredCaregiverGender` explicitly (never default it);
`male`/`female` must match the nurse's gender or the create is a `400`. `any` matches either.
- **Deadlines are server-frozen absolute UTC timestamps.** Render countdowns from them; never recompute a
deadline client-side. `paymentDeadlineAt` is null until the nurse accepts.
- **Two-stage disclosure.** The nurse detail view returns a **masked** address (city/district only,
`addressLine`/`postalCode`/recipient are null); the customer/admin view returns the full address. Do not
expect (or request) clinical instructions here — they don't exist until b9.
- **Status drives the UI.** Terminal states (`converted`/`rejected_by_nurse`/`expired_no_response`/
`payment_deadline_expired`/`cancelled_by_customer`) allow no further actions; a stale action returns `409`.
- **`get/{id}` is party-scoped.** A non-party caller gets `404` (existence not leaked).
## Contracts
- New: [`dev/contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md).
- `swagger.v1.json` refreshed (adds `booking_requests/{create,accept,reject,cancel,list,get}` +
`admin_booking_requests/expire` + `BookingRequestDto`/`BookingRequestListItemDto`/`ExpireBookingRequestsResult`).
## Nothing new is mocked
b8 introduces **no** external seam and adds **no** new `mocks-registry.md` row. It reuses `IPlatformConfig`,
`INotificationDispatcher`, `IJobScheduler`/`BackgroundService`, `IDateTimeProvider`, `ICurrentUser`. The
expiry sweep is an internal hosted service (the registry's `IJobScheduler` row was updated to note the second
job), not an external integration.
@@ -0,0 +1,46 @@
# Handoff — after backend phase 9 (Bookings, sessions, care instructions & EVV)
**The booking engine is live.** A paid request now becomes a real engagement: `bookings` + N `booking_sessions`,
encrypted `booking_care_instructions`, per-session `visit_verifications` (EVV), and the dispute-window gate.
Capture is **mocked** behind `IPaymentCaptureSimulator`; the real conversion trigger arrives with **b10 payments**.
## What f8-b9 can now build
- **Booking detail & "My bookings"** — `GET bookings/get/{id}`, `GET bookings/list?role=customer|nurse|all&status=`.
Header + money summary (three amounts as **digit-strings**, `platformFeeRate`, `pspFeeAmount`) + sessions +
status timeline. The **nurse view omits `addressSnapshotJson`**.
- **Nurse EVV** — `GET booking_sessions/today?date=` (today's visits + CTA state), `POST booking_sessions/check_in/{id}`
and `POST booking_sessions/check_out/{id}` (send `{latitude, longitude}`; both nullable if GPS denied),
`GET booking_sessions/evv/{id}` (raw GPS, owning nurse + admin only).
- **Care instructions** — `POST bookings/submit_care_instructions/{id}` (customer/admin, booking must be confirmed+),
`GET bookings/care_instructions/{id}` (**assigned nurse + admin only, post-confirmation** — the two-stage
disclosure boundary; everyone else gets 404).
- **Status timeline & cancel** — `POST bookings/cancel/{id}` (customer/nurse/admin; returns the policy snapshot +
refundable amount), `POST booking_sessions/cancel/{id}` (single un-started session).
- **Admin** — `GET admin_evv/list?type=mismatch|no_show`, `POST admin_evv/detect_no_shows`,
`POST admin_cancellation_policies/upsert` + `GET admin_cancellation_policies/list`, `POST bookings/transition/{id}`.
## Live endpoints / contracts
- Contract: [`dev/contracts/domains/bookings-evv.md`](../../contracts/domains/bookings-evv.md); machine schema in
the refreshed [`swagger.v1.json`](../../contracts/openapi/swagger.v1.json).
- Enums: `BookingStatus`, `BookingSessionStatus`, `VisitVerificationStatus`, `CancellationActor`.
## Load-bearing rules the client must honour
- **Money is IRR integer, on the wire as a digit-string.** `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`.
Never coerce to a JS number for math.
- **Payout eligibility is derived from `disputeWindowEndsAt` / per-session `payoutEligibleAt`, not from `completed`.**
- **EVV address mismatch is advisory** — a flagged check-in still succeeds; surface it, don't block.
- **Care-instruction clinical fields never appear in a list or the booking detail** — only via the gated read.
## Mocked here → make real later
- **`IPaymentCaptureSimulator`** (🟡) — the temporary conversion trigger. In **b10**, the real card capture calls
`ConvertRequestToBooking` directly on a `payment_transactions.succeeded`; this seam is then removed. A config
switch (`Seams:PaymentCapture:ForceFailure`) exercises the "capture failed → no booking" path today.
- The **no-show cron** is DEFERRED — `POST admin_evv/detect_no_shows` runs the same idempotent command a scheduler
will later call (config `no_show_scan_cadence_hours`).
## Consumed by later backend phases
- **b10** — real capture posts the ledger and calls the conversion; sets the real `psp_fee_amount`.
- **b11** — refund execution consumes the frozen `cancellationPolicyCode` + `refundableAmountIrr` (no ledger posted here).
- **b13** — payout batching consumes `disputeWindowEndsAt` / `payoutEligibleAt`.
- **b14** — reviews on a completed booking.
- **b15** — `partner_centers` wires the nullable `partner_center_id`.
@@ -0,0 +1,35 @@
# Handoff — after refinement-phase-0 (Local end-to-end bring-up)
**Date:** 2026-07-12 · **Track:** integration (both projects) · **Unlocks:** every other refinement phase.
## What the frontend can now do
- **Actually call the backend cross-origin.** The API now has a CORS policy (`BalinyaarWebClient`) allowing
`http://localhost:3000` (config `Cors:AllowedOrigins`, default in Dev) with the four headers the client
sends (`Authorization`, `Content-Type`, `Accept-Language`, `Idempotency-Key`). A browser at `:3000` calling
`https://localhost:5002` is no longer blocked by same-origin policy.
- **Stand the stack up in ~5 minutes** via `dev/post-phase/refinement/RUNBOOK.md` (dev-cert trust → local SQL
Server via `docker compose` → connection string via `dotnet user-secrets``dotnet run` + `npm run dev`).
- **Complete a real login end-to-end.** Request an OTP, read the 6-digit code from the **server console**
(`MOCK SMS — OTP code … for phone ending in …`) or from the Development-only helper
`GET /api/v1/dev/last_otp/{phone}`, verify → real tokens + `/me`.
## What did NOT change (important)
- **No `USE_*_MOCK` flag was flipped.** `auth` is still the only real client domain; the home/search/bookings
are still in-browser mocks until [Refinement Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md).
- **No client app code changed** — only verified `client/.env.development` (already `https://localhost:5002`)
and added the runbook. If you run the API elsewhere, override with `client/.env.local`.
- **Auth crypto, the money path, and the `ApiResult` envelope are untouched.**
## New endpoint (Development only — not a contract)
- `GET /api/v1/dev/last_otp/{phone}``{ data: { phone, code } }` when a code was issued, else 404. **404 in
every non-Development environment** (the capture isn't even wired there). For manual/e2e login only;
superseded by real SMS in [Refinement Phase 8](../../../post-phase/refinement/refinement-phase-8-external-rails.md).
Do not build client features on it.
## Gotchas
- The API binds **HTTP/2** (`Kestrel:Protocols = Http2`, for gRPC). Browsers negotiate h2-over-TLS via ALPN
automatically, so `fetch` works; `curl` needs `--http2`.
- The dev HTTPS cert **must be trusted** (`dotnet dev-certs https --trust`) or `fetch` to `:5002` fails with an
opaque network error.
- Committed `appsettings*.json` connection strings are **placeholders** — the API will not boot until you set
`ConnectionStrings:SqlServer` via user-secrets (or the `ConnectionStrings__SqlServer` env var).
@@ -0,0 +1,46 @@
# Handoff — after refinement-phase-1 (Database: local-dev story, demo seed & migration hygiene)
**Date:** 2026-07-13 · **Track:** backend (+ DB) · **Unlocks:** real search/discovery/booking data for
[Refinement Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md).
## What the frontend can now do
- **A fresh Development DB is a populated marketplace, not an empty shell.** On boot (Development only) the API
now seeds a coherent demo world *after* the reference/lookup seeds. Every real-path discovery/search/booking
screen has data on the real path — no frontend change required to see it.
- **Log in as real demo accounts** (phone-OTP flow; read the code from the server console or
`GET /api/v1/dev/last_otp/{phone}`). The accounts already hold the right role, so the role router lands them
on the correct shell:
| Phone | Role | Who | State |
| --- | --- | --- | --- |
| `09120000001` | nurse | زهرا عزیزی (female) | **verified**, accepting, 3 variants, whole-city + 2 districts |
| `09120000002` | nurse | علی کریمی (male) | **verified**, accepting, 2 variants, 3 districts |
| `09120000003` | nurse | مریم احمدی (female) | **unverified** (pending) — never surfaces in search |
| `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address (coords) |
| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address (coords) |
- **Search returns real nurses.** `GET /api/v1/search/nurses?service_category_id=1&city_id=101` (Elderly Care in
Tehran) returns the two verified nurses' priced variants. Categories offered only by the unverified nurse
(Infant Care, `service_category_id=3`) return an empty page — the `is_searchable` invariant holds.
- **Trust badges are real.** `GET /api/v1/nurses/{id}/trust_badge` returns `isVerified=true` + credential types
for the verified nurses, `false` for the unverified one.
- **The variant builder's required-option step renders on the real path.** The demo seeder adds one
cross-category **required** option group — شیفت / *Shift Type* (`Daytime`/`Night`/`Live-in`) — so
`GET /api/v1/catalog/...` option-group reads are non-empty in Development (the "categories but no option
groups" data gap). This group is **Development-only demo data**, not a production catalog decision.
## What did NOT change (important)
- **No `USE_*_MOCK` flag was flipped** — de-mocking the client is still [Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md).
This phase only makes the *backend* real path return data.
- **No new endpoints, no contract changes, no new migration.** The 17 migrations are unchanged and current;
the reference `HasData` seeds are untouched. The demo seeder writes through the existing entities/handlers.
- **Nothing runs in Production/Staging.** The seeder is gated on `IsDevelopment()`.
## How to reset / re-seed
- `docker compose down -v` (wipe the DB volume) then `dotnet run` → migrate + reference seed + demo seed from
scratch. Re-running `dotnet run` against an already-seeded DB is a **no-op** (guarded on each persona's phone).
## Gotchas
- **Money is IRR Rials** (e.g. per-24h live-in = `3500000`); on the wire variant prices are digit strings.
- The unverified nurse **has** a variant and a covered area but `is_verified=0`, so the search maintainer
computes `is_searchable=0`. Do not treat "has a variant" as "is discoverable".
@@ -0,0 +1,31 @@
# After refinement-phase-6 — Money-path correctness completion
**Track:** backend (money path). Gate: `dotnet build` 0 new warnings · `dotnet test` 396 pass (+13). Migration
`RefinementPhase6MoneyFks` scaffolded (additive: 3 FK sets + invoice index + delete of a dead config seed row).
## What's now live (new endpoints — admin)
- **`POST api/v1/admin_refunds/{id}/confirm_settlement`** — settle a `processing` BNPL/manual refund
(`processing → succeeded`, posts the deferred `refund_payable ↔ escrow_held` clearing). Idempotent.
- **`POST api/v1/admin_refunds/{id}/mark_failed`** — fail a `processing` refund (no ledger). Body
`{ "reason": "..." }` (optional). Idempotent.
- Both return `RefundSettlement` `{ refundId, bookingId, status, completedAt }`. Contract:
`dev/contracts/domains/refunds-invoices.md`; snapshot `swagger.v1.json` refreshed.
## What changed under the hood (no client-visible shape change)
- The **BNPL provider cash-back callback** now auto-settles the matching `processing` refund (a
`refund/revert completed|confirmed|settled` / `cashback` event) — so a real BNPL revert reaches `succeeded`
without an admin click.
- **Refund create** persists the row (`approved`) before the external channel call (crash-window fix) — an
interrupted refund is now a reconcilable `approved` row, not a lost execution.
- **Forward-dep FKs** added: `refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`,
`invoices.partner_center_id` (+ index). All nullable, `NO ACTION`. No behavior change; integrity backstop only.
- **Audit:** `Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification` are now `IAuditable`
→ admin decisions leave an `audit_logs` diff row (IBAN redacted).
- **Retired** the orphaned `refund_ticket_required` config key (a refund ticket is always auto-opened).
## Frontend notes
- The customer refund-status flow is **unchanged** — a BNPL refund still shows `processing` with the ETA, and now
actually flips to `succeeded` once settled (via admin confirm or the provider callback). No new client work
required for the customer side.
- If/when an admin refund console surfaces settlement, the two new endpoints are the actions (staff-gated,
`sensitive` rate policy).
@@ -0,0 +1,38 @@
# After refinement-phase-7 — Unattended operation (scheduler, locking, migrations-from-boot)
**For the frontend / next backend phase. Backend-owned; frontend reads.**
## What changed for a client
Almost nothing user-facing — this is infrastructure. One wire delta:
- **`PayoutBatchDto.initiatedByAdminId` is now nullable.** `null` = a **system-initiated / scheduled** payout
batch (the weekly cron generated it, no human initiator). Admin payout UIs should render a "system"/"scheduled"
label instead of assuming an admin id. Contract + `swagger.v1.json` updated.
## What the platform now does on its own
The four previously admin-click-only sweeps run on schedule (each reading its `platform_configs` cadence key):
credential-expiry scan, EVV no-show sweep, and **weekly payout-batch generation** — plus the two re-homed sweeps
(booking-request expiry, notification retention). **Admin manual triggers are unchanged and remain overrides.**
- **Payout generation only.** The cron opens a `draft` batch; **processing (money movement) is still an explicit
admin action** (`POST admin_payouts/batches/{id}/process`). Do not build a client flow that auto-processes.
## For the next backend phase (Phase 8 — external rails)
- **Register new crons via the seam, not a new host.** Implement `IRecurringJob`
(`Persistence/Services/Scheduling/`) + one `services.AddSingleton<IRecurringJob, YourJob>()` in
`AddPersistenceServices`. Phase 8 owns the **Moadian reconciliation poll** and the **refund-settlement
reconciliation** this way (each reads/adds its own cadence key). The scheduler already provides the per-tick
scope, the `scheduler:{name}` lock, and error isolation.
- **Jobs must stay idempotent** — a retry (or a second instance once the lock is Redis-backed) must never
double-pay/double-post; the DB uniques/state-machines are the backstop.
## Ops / deployment
- **DDL is a deploy step now:** run `dotnet run -- migrate` (applies migrations + idempotent seeders, then exits)
before starting the API in a deployed environment. A normal deployed boot only *checks* the schema and **fails
fast** if a migration is pending. Development still migrates + seeds on boot.
- **Redis is the >1-instance gate** (shared cache + the cross-instance scheduler/money lock). Single-instance MVP
does not need it; the in-proc seams are correct for one instance. Elasticsearch is never MVP.
@@ -0,0 +1,56 @@
# After refinement-phase-8 — External rails go real (config-selected vendor adapters)
**For the frontend / next backend phase. Backend-owned; frontend reads.**
## What changed for a client
**Nothing user-facing changed by default** — the mocks stay the default registration, so every existing flow
behaves exactly as before. This phase makes each vendor rail *swappable to real by config*, not on by default.
One **new endpoint** (backend-to-backend, not for the browser): `POST /api/v1/webhooks/payouts/{provider}` — the
async PAYA/SATNA reconciliation callback (signature-authenticated, anonymous, `webhook` rate policy). It flips a
`submitted` payout to `paid`/`failed`. No client calls it.
## How a rail goes real (ops)
Set the rail's **`Seams:{rail}:Provider`** + its credentials (user-secrets/env) and restart — no code change,
no deploy of new binaries. Provider tokens (mock stays default; a typo falls closed to mock):
| Rail | Key | Real value | Also needs |
| --- | --- | --- | --- |
| SMS (**launch-critical**) | `Seams:Sms:Provider` | `kavenegar` | `Seams:Sms:{ApiKey,SenderLine,OtpTemplate}` |
| Shahkar / e-KYC / شبا | `Seams:{Shahkar,IdentityKyc,BankOwnership}:Provider` | `finnotech` | `Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` |
| Geocoding | `Seams:Geocoding:Provider` | `neshan` | `Seams:Geocoding:{ApiKey}` |
| Object storage | `Seams:ObjectStorage:Provider` | `s3` | `Seams:ObjectStorage:{ServiceUrl,Bucket,Region,AccessKey,SecretKey}` |
| Card PSP (+ HMAC webhook + تسهیم) | `Seams:Payments:Provider` | `zarinpal` | `Seams:Payments:{MerchantId,CallbackUrl,WebhookSigningSecrets}` |
| BNPL | `Seams:Bnpl:Provider` | `real` | `Seams:Bnpl:Providers:{snapppay,digipay}:*` (creds via gateway config) |
| Payout rail | `Seams:BankTransfer:Provider` | `jibit` | `Seams:BankTransfer:{ApiKey,SourceSettlementAccount}` + webhook secret |
| Moadian | `Seams:Moadian:Provider` | `moadian` | `Seams:Moadian:{MemoryId,AccessToken}` + signing cert |
**Two hard rules baked in:**
- **SMS real ⇒ the OTP is never logged.** The Development OTP-in-logs bridge (`GET dev/last_otp`) runs **only while
the mock SMS sender is selected**. Once `Seams:Sms:Provider=kavenegar`, the code only leaves the process over the
SMS wire.
- **Money callbacks fail closed.** The payout reconciliation + PSP webhook verify a per-provider HMAC over the raw
body; an invalid signature mutates nothing. The confirm path still re-verifies the amount server-side.
## Behavioural notes the next phase should know
- **Payout rail is async now (when real).** A real `JibitBankTransferProvider` accepts a transfer as `submitted`;
the ledger posts only when the reconciliation callback confirms `paid`. The `ExecutePayoutBatch` handler already
handled this (`MarkSubmitted` first, ledger on `paid`) — it was unchanged.
- **`bookings/convert` is Dev/Testing only.** `IPaymentCaptureSimulator` is out of the production registration
(prod = fail-closed `DisabledPaymentCaptureSimulator`). Production converts via the b10 payment **webhook confirm**
calling `ConvertRequestToBooking` directly — do not build a client convert flow.
- **`balinyaar` BNPL = in-house.** In real BNPL mode `provider_code=balinyaar` resolves to the deterministic
net-of-fee model (no external API); `tara`/`torobpay` are unbuilt and rejected cleanly.
- **New cron:** `MoadianReconciliationJob` (6 h) walks `pending/submitted` invoices to `registered` — registered
the phase-7 way (`IRecurringJob` + one `AddSingleton`), no migration.
## Follow-ups carried forward
- Per-code BNPL revert (the b11 refund path uses the SnappPay default); SMS.ir/Ghasedak adapters; Finnotech/Moadian
token exchange + Moadian signing cert; the **refund-settlement poll** (BNPL `processing → succeeded`, pairs with
Moadian — the confirm command exists, the poll job is the remaining wiring); a dedicated **center-settlement
payout** (deferred per 6.6 — MoR centers settle via a تسهیم split leg, non-MoR have no separate money path).
- **Redis** stays the >1-instance gate; **Elasticsearch** is never MVP.
@@ -0,0 +1,43 @@
# After refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later
**For the frontend / next backend phase. Backend-owned; frontend reads.**
## What changed for a client
**Nothing user-facing.** No route, envelope, shape, or enum changed. Two things worth knowing:
- **`ApiResult.requestId` is now a real W3C trace id** (the request's OpenTelemetry trace). It's the id to quote in
a support ticket — it maps 1:1 to the server-side trace once an OTLP collector is wired.
- **Ticket message bodies are encrypted at rest** server-side. The thread read still returns **plaintext** (the wire
is unchanged); the change is purely storage-side (the refund/dispute paper trail is no longer plaintext in the DB).
## What the platform now does / exposes (ops)
- **One OpenTelemetry stack.** Metrics scrape at `/metrics`; distributed **tracing** (ASP.NET Core + EF Core) is
wired. **OTLP export is opt-in** — set `OpenTelemetry:Otlp:Endpoint` (Grafana Tempo / Jaeger / OTEL Collector) to
turn trace + metric export on. Prometheus-scrape-only is an acceptable MVP; prometheus-net was removed.
- **Health endpoints:** `/healthz/live` (process only — safe liveness), `/healthz/ready` (app DB + log DB [deployed]
+ an object-storage write probe — pull an instance out of rotation when a dependency is down), `/HealthCheck`
(aggregate, kept for compat). Point the orchestrator's liveness probe at `/healthz/live`, readiness at
`/healthz/ready`.
- **Prod logs are Information+ with no PII/secrets.** The OTP code is no longer logged in any environment. Log-table
retention on `Baya_Logs` is an ops/DBA task (or ship logs to the OTLP collector).
- **Audit-log retention** runs as a scheduled `IRecurringJob` — two-tier (financial/verification rows kept ~7 yr,
everyday rows ~2 yr) via the `audit_retention_*` config keys.
- **gRPC reflection is Development-only** (the plugin itself is unchanged; it shares the mixed-protocol listener).
## For the next backend phase / deploy
- **Turn tracing on in deployed envs** by provisioning an OTLP collector and setting `OpenTelemetry:Otlp:Endpoint`.
- **When Redis lands (>1 instance)**, add a `redis` readiness check (tagged `ready`) to `ConfigureHealthChecks`.
- **Register any new retention/cron via `IRecurringJob`** (unchanged from phase 7).
## Deferred — recorded, NOT gaps (each has a written pull-trigger; see the phase report)
- **Elasticsearch `INurseSearch` backend + outbox feeder** — pull when SQL search shows strain. SQL search is the
real MVP (`Search:Backend=sql`; any other value fails fast).
- **SMS/push channels of `INotificationDispatcher`** — pull when the notification UX demands out-of-app reach.
In-app notifications are real now.
- **Analytics warehouse/stream, holiday-calendar feed, 8 deferred product tables** (`organizations`,
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability
slots, customer national-ID KYC, geo bulk import) — each a pure additive step when product pulls it.