backend phase 10

This commit is contained in:
hamid
2026-07-06 21:17:00 +03:30
parent 12c7e51c32
commit aae056b4e5
70 changed files with 8124 additions and 73 deletions
+71
View File
@@ -0,0 +1,71 @@
# Contract — Payments core: ledger, transactions, webhooks & card capture (backend phase b10)
> One-line: the inbound money rail — start a card payment against an accepted request, a PSP webhook confirms
> it, the balanced card-capture ledger group posts, and the booking confirms. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b10 · **Frontend consumer:** frontend-phase-f9-b10
All money is **IRR Rials, integer, on the wire as a string of digits** (`"23300000"`). The card-capture ledger
group is always **balanced** (Σ debit = Σ credit). **Internal `account_type`s are never exposed to the
customer** — the checkout UI shows gross + the commission/VAT breakdown only. Timestamps are UTC ISO-8601.
## Enums used
- `payment` status (`payment_transactions.status`): `pending` | `succeeded` | `failed`.
- `payment_gateways.type`: `standard` (card IPG) | `bnpl`.
- `payment_webhook_events.processing_status`: `received` | `processed` | `failed` | `ignored`.
- `account_type` (internal, never on the customer wire): `escrow_held` | `platform_revenue` | `nurse_payable`
| `refund_payable` | `bnpl_fee_expense` | `psp_fee_expense` | `nurse_clawback_receivable` | `bad_debt`.
b10 posts only the first three (card capture); the rest are reserved for b11/b12/b13.
## Endpoints
### `POST api/v1/bookings/{bookingRequestId}/payments`
- **Purpose:** start a card payment for an `accepted_awaiting_payment` booking request owned by the caller.
A `bookings` row exists only on capture (b9), so payment is initiated against the **request**; the amount
charged is the request's **frozen gross** (variant price × session count), never client-supplied.
- **Auth:** authenticated (the owning customer) · **Rate-limited:** yes (sensitive) · **Idempotency:** send an
**`Idempotency-Key`** header — a retried start reuses the same attempt/reference.
- **Request body:** none (the id is in the route; the key is a header).
- **Success `200` (`data`):** `{ "transactionId": 42, "redirectUrl": "https://…", "gatewayReferenceCode": "…" }`.
No ledger rows yet; the booking is not created yet.
- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` already paid /
not awaiting payment / payment window lapsed, `400` no active gateway configured.
### `POST api/v1/webhooks/payments/{provider}`
- **Purpose:** the inbound PSP/BNPL callback — verify-then-dedup-then-mutate.
- **Auth:** **none (signature-authenticated)**, anonymous to the auth pipeline · **Rate-limited:** yes (global
per-IP) · **Idempotent:** yes, at-least-once tolerant.
- **Request:** the raw provider callback body (stored verbatim in `payload_json`); signature material in headers.
- **Behaviour:** upserts `payment_webhook_events` **first** on `(provider, external_event_id)` and **no-ops on
a duplicate**; an **invalid signature** is stored `ignored` and mutates nothing; on a **new success event**
it re-verifies server-side (never trusts the callback alone), then captures — posts the balanced card-capture
group and creates/confirms the booking — all under a `lock(booking:{id}:payment)` with the DB uniques as the
authoritative backstop.
- **Success `200` (`data`):** `{ "processingStatus": "processed" | "ignored" | "failed", "duplicate": false }`
(`duplicate: true` on a replayed event).
### `GET api/v1/nurses/{nurseId}/payable_balance`
- **Purpose:** the IRR balance currently owed a nurse — the **signed sum** over `nurse_payable` ledger legs
(credit adds, debit subtracts), **derived, never a stored column**. This is what b13 payouts read.
- **Auth:** authenticated — the **nurse themself or an admin/finance role** (`403` otherwise).
- **Success `200` (`data`):** `{ "nurseId": 7, "balanceIrr": "19805000" }`.
## The card-capture ledger group (posted on webhook confirm)
One `transaction_group_id`, `amount_irr` positive with `direction` carrying the sign, Σdebit = Σcredit:
```
DEBIT escrow_held gross_price_irr (e.g. 23300000)
CREDIT platform_revenue balinyaar_commission_irr (e.g. 3495000)
CREDIT nurse_payable nurse_payout_amount (e.g. 19805000, nurse_id set)
```
## Load-bearing rules the client must honour
- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math.
- **A booking is created & confirmed on capture** (the webhook), not on initiate — after `initiate` the
redirect is shown; the booking appears once the PSP callback confirms.
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
+335 -2
View File
@@ -7,7 +7,7 @@
},
"servers": [
{
"url": "http://localhost"
"url": "https://localhost:5002"
}
],
"paths": {
@@ -5698,7 +5698,7 @@
"tags": [
"Me"
],
"description": "Role claims live inside the access token \u2014 after selecting a role the client should\n refresh its tokens to pick the new role up.",
"description": "Role claims live inside the access token after selecting a role the client should\n refresh its tokens to pick the new role up.",
"operationId": "Me_SelectRole",
"requestBody": {
"x-name": "command",
@@ -6360,6 +6360,83 @@
]
}
},
"/api/v1/nurses/{nurseId}/payable_balance": {
"get": {
"tags": [
"NursePayableBalance"
],
"operationId": "NursePayableBalance_PayableBalance",
"parameters": [
{
"name": "nurseId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
},
"x-position": 1
}
],
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfNursePayableBalanceDto"
}
}
}
}
},
"security": [
{
"Bearer": []
}
]
}
},
"/api/v1/nurse_profiles/upsert": {
"post": {
"tags": [
@@ -8237,6 +8314,83 @@
]
}
},
"/api/v1/bookings/{bookingRequestId}/payments": {
"post": {
"tags": [
"Payments"
],
"operationId": "Payments_Initiate",
"parameters": [
{
"name": "bookingRequestId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
},
"x-position": 1
}
],
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfInitiatePaymentResult"
}
}
}
}
},
"security": [
{
"Bearer": []
}
]
}
},
"/api/v1/ping/get_status": {
"get": {
"tags": [
@@ -9037,6 +9191,77 @@
}
]
}
},
"/api/v1/webhooks/payments/{provider}": {
"post": {
"tags": [
"Webhooks"
],
"operationId": "Webhooks_Payments",
"parameters": [
{
"name": "provider",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"x-position": 1
}
],
"responses": {
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
}
}
}
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"403": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"500": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResult"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResultOfWebhookIngestResult"
}
}
}
}
}
}
}
},
"components": {
@@ -12600,6 +12825,41 @@
}
]
},
"ApiResultOfNursePayableBalanceDto": {
"allOf": [
{
"$ref": "#/components/schemas/ApiResult"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"data": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/NursePayableBalanceDto"
}
]
}
}
}
]
},
"NursePayableBalanceDto": {
"type": "object",
"additionalProperties": false,
"properties": {
"nurseId": {
"type": "integer",
"format": "int64"
},
"balanceIrr": {
"type": "string",
"nullable": true
}
}
},
"ApiResultOfNurseProfileDto": {
"allOf": [
{
@@ -13519,6 +13779,45 @@
}
}
},
"ApiResultOfInitiatePaymentResult": {
"allOf": [
{
"$ref": "#/components/schemas/ApiResult"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"data": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/InitiatePaymentResult"
}
]
}
}
}
]
},
"InitiatePaymentResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"transactionId": {
"type": "integer",
"format": "int64"
},
"redirectUrl": {
"type": "string",
"nullable": true
},
"gatewayReferenceCode": {
"type": "string",
"nullable": true
}
}
},
"ApiResultOfPingQueryResult": {
"allOf": [
{
@@ -13927,6 +14226,40 @@
"nullable": true
}
}
},
"ApiResultOfWebhookIngestResult": {
"allOf": [
{
"$ref": "#/components/schemas/ApiResult"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"data": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/WebhookIngestResult"
}
]
}
}
}
]
},
"WebhookIngestResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"processingStatus": {
"type": "string",
"nullable": true
},
"duplicate": {
"type": "boolean"
}
}
}
},
"securitySchemes": {
@@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## backend-phase-10 — Payments core: ledger, transactions, webhooks & card capture — 2026-07-06
- **Shipped:** the money core via one migration in a new **`payments`** schema — **4 tables** `PaymentGateways`
(encrypted `config_json`) / `PaymentTransactions` (the **two filtered uniques**`gateway_reference_code` WHERE
NOT NULL, `booking_id` WHERE status='succeeded') / `PaymentWebhookEvents` (**UNIQUE(provider_code,
external_event_id)**) / **append-only** `LedgerEntries`. Features `InitiatePayment`, `HandlePaymentWebhook`,
`ConfirmPaymentAndPostLedger` (internal), `GetNursePayableBalance`; controllers
`POST bookings/{id}/payments`, public `POST webhooks/payments/{provider}`, `GET nurses/{id}/payable_balance`.
Extracted **`BookingFactory`** so b10's real capture reuses b9's conversion (b9 unchanged).
- **Contracts:** `dev/contracts/domains/payments.md` written; **openapi snapshot refreshed**
(`dev/contracts/openapi/swagger.v1.json` — the three b10 paths + DTOs present).
- **Mocked:** `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` → 🟡
(see reports/mocks-registry.md). `IPaymentCaptureSimulator` (b9) retained for b9's Convert path/tests.
- **Gate:** build clean (0 new warnings) / tests green (Foundation 198, Identity 4, Api 83; +12 Foundation +4 Api new).
- **Handoff:** backend/handoff/after-backend-phase-10.md
- **Notes for frontend:** money is an **IRR digit-string**; a booking exists only **on capture** (pay against the
accepted **request** id; the booking appears `confirmed` after the webhook); **never expose internal
`account_type`s** (checkout = gross + commission/VAT); payment idempotent end-to-end (`Idempotency-Key` header).
## backend-phase-9 — Bookings, sessions, care instructions & EVV — 2026-07-06
- **Shipped:** the post-payment engine via one additive migration in the **`booking`** schema — **5 tables**
`Bookings` / `BookingSessions` / `BookingCareInstructions` / `VisitVerifications` / `CancellationPolicies`
@@ -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,69 @@
# Backend Phase 10 report — Payments core: ledger, transactions, webhooks & card capture
## What was built
- **`payments` schema, four tables, one migration (`PaymentsCoreLedger`):**
- `PaymentGateways` — PSP config; **encrypted `config_json`** (`IFieldEncryptor`); selection index on
`(type, is_active, priority)`.
- `PaymentTransactions` — every attempt; the **two filtered uniques**:
`UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded' AND
booking_id IS NOT NULL`. `booking_id` is **nullable** (bound at confirm — a b9 booking exists only on capture).
- `PaymentWebhookEvents` — the idempotency store; **`UNIQUE(provider_code, external_event_id)`**.
- `LedgerEntries` — the append-only double-entry source of truth. Implements `IEntity` **only** (no
`ITimeModification`/audit-modify, no soft-delete) so the audit interceptor never stamps it; `created_at`
set explicitly from `IDateTimeProvider`.
- **Domain:** `LedgerPosting.CardCapture` (the balanced-group builder — throws if `gross ≠ commission + payout`);
`LedgerAccountType` (all 8 codes), `LedgerDirection`/`LedgerSourceRefType`, `PaymentTransactionStatus`,
`WebhookProcessingStatus`, `PaymentGatewayType`.
- **Features (`Baya.Application/Features/Payments/`):** `InitiatePayment`, `HandlePaymentWebhook`,
`ConfirmPaymentAndPostLedger` (internal — dispatched only from the webhook + tests), `GetNursePayableBalance`.
- **Seams (`Application/Contracts/Payments/`) + mocks (`CrossCutting/Seams/`):** `IPaymentProvider`,
`ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`; registered in `AddCrossCuttingSeams`.
- **Persistence:** `IPaymentRepository` + `PaymentRepository` on `IUnitOfWork`; `config_json` encryption wired
in `ApplicationDbContext`.
- **Controllers:** `PaymentsController` (`POST bookings/{bookingRequestId}/payments`), `WebhooksController`
(public `POST webhooks/payments/{provider}`), `NursePayableBalanceController` (`GET nurses/{id}/payable_balance`).
- **Shared conversion:** extracted **`BookingFactory`** from b9's `ConvertRequestToBooking` handler so b9 (mock
capture) and b10 (real webhook capture) share the conversion/amount logic — b9 behaviour unchanged.
## What is now testable and exactly how (mirrors phase §7)
1. **Initiate** `POST api/v1/bookings/{requestId}/payments` (as the customer) → `200` with a redirect URL + a
`pending` transaction carrying the deterministic `gatewayReferenceCode`; no ledger rows, no booking yet.
2. **Webhook confirms** `POST api/v1/webhooks/payments/{provider}` with a `succeeded` event for that reference →
the transaction flips `succeeded`; **one balanced ledger group** posts (DEBIT `escrow_held` 23300000 =
CREDIT `platform_revenue` 3495000 + `nurse_payable` 19805000); the booking is created & **confirmed**.
3. **Replay** the same `external_event_id``200` with `duplicate: true`; no second confirm, no second ledger
group; still one webhook-event row.
4. **`GET api/v1/nurses/{nurseId}/payable_balance`** → `19805000` (signed ledger sum; a debit reduces it).
5. **Second `succeeded` transaction for a booking is blocked** by the filtered `UNIQUE(booking_id) WHERE
status='succeeded'` — an idempotent no-op success, never a second capture.
6. **Unverified callback** (mock verifier marks it invalid) → stored `ignored`, no transaction flip, no ledger.
7. **Encrypted gateway config** — `payment_gateways.config_json` is ciphertext at rest.
Tests: **12 Foundation** (`Baya.Test.Foundation/Payments/`: `LedgerPostingTests`, `PaymentConfirmTests`,
`InitiatePaymentTests`, `NursePayableBalanceTests`, `PaymentWebhookTests`) + **4 Api integration**
(`PaymentsApiTests`: 401, validation 400, full initiate→webhook→ledger flow, duplicate-replay). Whole suite
green (Foundation 198, Identity 4, Api 83); `dotnet build Baya.sln` 0 new warnings. Filtered uniques + the
balanced ledger are exercised against the real EF model on SQLite (partial indexes work there).
## What is mocked + how to make it real
The four money-path seams — `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`,
`IDistributedLock` — see `reports/mocks-registry.md` (each row → 🟡 with step-by-step make-it-real). The mock
`IPaymentCaptureSimulator` (b9) is **retained** (b9's `bookings/convert` endpoint + tests use it); b10's real
capture uses `BookingFactory` directly.
## Account types reserved for b11b13
`refund_payable`, `nurse_clawback_receivable` (b11); `bnpl_fee_expense` (b12); `psp_fee_expense`, `bad_debt`
(reserved). All defined now so later phases only post against them.
## Contracts produced
- `dev/contracts/domains/payments.md` (human contract) + **`dev/contracts/openapi/swagger.v1.json` refreshed**
(the three b10 paths — `bookings/{bookingRequestId}/payments`, `webhooks/payments/{provider}`,
`nurses/{nurseId}/payable_balance` — and the `InitiatePaymentResult`/`WebhookIngestResult`/
`NursePayableBalanceDto` schemas are present).
## Follow-ups
- b11 removes the last coupling to `IPaymentCaptureSimulator` once the refund path exercises the real rail end
to end; consider retiring the `bookings/convert` mock endpoint then.
- The webhook confirm crosses two commits (booking creation, then txn+ledger) because the ledger legs need the
DB-generated `booking_id`; the webhook dedup + forward-only request guard + the succeeded-unique keep it
idempotent. A single explicit transaction is a future hardening if `IUnitOfWork` grows a transaction scope.
@@ -35,6 +35,10 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
| `IPaymentCaptureSimulator` | backend-phase-9 | The **temporary conversion trigger** standing in for b10's real card capture. `MockPaymentCaptureSimulator` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic *succeeded* capture (a fake `gateway_reference` + a configurable `psp_fee_amount`) so `ConvertRequestToBookingCommand` is exercisable now; a config switch forces a *failed* capture (→ no booking is created). **This is the trigger, not a parallel money path** — registered singleton in `AddCrossCuttingSeams` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | In b10: 1) build the real card capture (`payment_transactions`, PSP/IPG client, webhook verify); 2) on a real `payment_transactions.succeeded`, call `ConvertRequestToBooking` **directly** (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the `IPaymentCaptureSimulator` registration + `MockPaymentCaptureSimulator`; the conversion/idempotency logic is unchanged | 🟡 |
| `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 |
| `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync` → always `Succeeded` (for b11 to call). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 |
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 |
| `IWebhookVerifier` | backend-phase-10 | PSP callback signature verify — `MockWebhookVerifier` (`.../Seams/`) treats the signature as valid unless the body carries `Seams:Payments:InvalidSignatureMarker`, and extracts `external_event_id`/`event_type`/`gateway_reference_code` from a small JSON body (so tests can replay duplicates + exercise the invalid-signature path). Registered singleton in `AddCrossCuttingSeams` | `Seams:Payments:InvalidSignatureMarker` (default `INVALID_SIGNATURE`) | 1) implement the per-provider HMAC/signature scheme (verify the raw body against the provider's signing key from the gateway config); 2) where a provider offers no signature, fall back to the mandatory server-side `verify` re-check (amount + reference) via `IPaymentProvider.VerifyAsync`; 3) parse the real provider event shape into `WebhookVerification`; 4) swap the registration (config-selected) — the `HandlePaymentWebhook` upsert-first/no-op-on-duplicate ordering is unchanged | 🟡 |
| `IDistributedLock` | backend-phase-10 | Money-path mutex — `InProcessDistributedLock` (`.../Seams/`): a per-key `SemaphoreSlim` so the capture path runs the same acquire/release shape it will with real Redis, **within one process only**. **Not** a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs a Redis connection string | 1) add `StackExchange.Redis` to `Directory.Packages.props`; 2) implement `AcquireAsync(key)` with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention `booking:{id}:payment`; 3) bind `Seams:Payments:Redis` (or reuse the `ICacheService` Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired | 🟡 |
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL**`SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the