backend phase 10
This commit is contained in:
@@ -0,0 +1 @@
|
||||
temp
|
||||
@@ -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`.
|
||||
@@ -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 b11–b13
|
||||
`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
|
||||
|
||||
@@ -52,6 +52,20 @@ DEBIT nurse_payable nurse_payout_refunded_irr
|
||||
<pre><code>DEBIT nurse_clawback_receivable amount_irr (nurse_id set; nurse now owes the platform)
|
||||
CREDIT refund_payable amount_irr</code></pre>
|
||||
<p>Recovered by <strong>netting against the nurse's next <code>nurse_payable</code></strong> at batch time, or marked <code>written_off</code> if uncollectable. A <code>nurse_clawbacks</code> row carries the lifecycle (<code>pending</code> / <code>recovered</code> / <code>written_off</code>). This is unavoidable because <strong>Iranian payouts are real bank transfers — hard/impossible to reverse</strong> — so the right defense is <em>gating payout on the dispute window</em>, with clawback as the fallback.</p>
|
||||
<h2 id="32a-implementation-rules-confirmed-in-build-backend-b10">3.2a Implementation rules confirmed in build (backend b10) <a class="anchor" href="#32a-implementation-rules-confirmed-in-build-backend-b10" aria-hidden="true">#</a></h2>
|
||||
<p>These are the exact orderings the card-capture rail was built to (see <code>dev/contracts/domains/payments.md</code>):</p>
|
||||
<ul>
|
||||
<li><strong>Upsert the webhook event first, then re-verify server-side, then confirm.</strong> Every PSP callback lands in</li>
|
||||
</ul>
|
||||
<p> <code>payment_webhook_events</code> keyed on <code>UNIQUE(provider_code, external_event_id)</code> <strong>before</strong> any money state changes; a duplicate replay is a <strong>no-op</strong>. Only a new, signature-valid <em>success</em> event proceeds — and it is <strong>re-verified against the gateway</strong> (amount + reference) before the capture posts. A callback with an invalid signature is stored <code>ignored</code> and mutates nothing (<strong>never trust a callback alone</strong>).</p>
|
||||
<ul>
|
||||
<li><strong>A unique-violation on confirm is an idempotent no-op success, not an error.</strong> The filtered</li>
|
||||
</ul>
|
||||
<p> <code>UNIQUE(booking_id) WHERE status='succeeded'</code> is the authoritative anti-double-capture backstop: if a concurrent confirm races past the Redis lock, the second one fails the constraint and is treated as "already captured → success".</p>
|
||||
<ul>
|
||||
<li>**Payment is initiated against the accepted <em>request</em>, not a booking.** Because a <code>bookings</code> row exists only</li>
|
||||
</ul>
|
||||
<p> on capture, <code>payment_transactions.booking_id</code> is <strong>nullable</strong> and bound at confirm (when the booking is created/loaded). The captured gross is the request's frozen amount (variant price × session count).</p>
|
||||
<h2 id="33-why-the-ledger-not-more-columns">3.3 Why the ledger, not more columns <a class="anchor" href="#33-why-the-ledger-not-more-columns" aria-hidden="true">#</a></h2>
|
||||
<p>A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is <strong>one table + posting discipline</strong>. The alternative (more money columns on bookings/payouts) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the <em>pricing</em> record; the ledger is the <em>financial-truth / reconciliation</em> layer posted alongside.</p>
|
||||
<a class="back-to-top" href="#">↑ Back to top</a>
|
||||
|
||||
@@ -58,6 +58,23 @@ DEBIT nurse_clawback_receivable amount_irr (nurse_id set; nurse now owes th
|
||||
```
|
||||
Recovered by **netting against the nurse's next `nurse_payable`** at batch time, or marked `written_off` if uncollectable. A `nurse_clawbacks` row carries the lifecycle (`pending` / `recovered` / `written_off`). This is unavoidable because **Iranian payouts are real bank transfers — hard/impossible to reverse** — so the right defense is *gating payout on the dispute window*, with clawback as the fallback.
|
||||
|
||||
## 3.2a Implementation rules confirmed in build (backend b10)
|
||||
|
||||
These are the exact orderings the card-capture rail was built to (see `dev/contracts/domains/payments.md`):
|
||||
|
||||
- **Upsert the webhook event first, then re-verify server-side, then confirm.** Every PSP callback lands in
|
||||
`payment_webhook_events` keyed on `UNIQUE(provider_code, external_event_id)` **before** any money state
|
||||
changes; a duplicate replay is a **no-op**. Only a new, signature-valid *success* event proceeds — and it is
|
||||
**re-verified against the gateway** (amount + reference) before the capture posts. A callback with an invalid
|
||||
signature is stored `ignored` and mutates nothing (**never trust a callback alone**).
|
||||
- **A unique-violation on confirm is an idempotent no-op success, not an error.** The filtered
|
||||
`UNIQUE(booking_id) WHERE status='succeeded'` is the authoritative anti-double-capture backstop: if a
|
||||
concurrent confirm races past the Redis lock, the second one fails the constraint and is treated as
|
||||
"already captured → success".
|
||||
- **Payment is initiated against the accepted *request*, not a booking.** Because a `bookings` row exists only
|
||||
on capture, `payment_transactions.booking_id` is **nullable** and bound at confirm (when the booking is
|
||||
created/loaded). The captured gross is the request's frozen amount (variant price × session count).
|
||||
|
||||
## 3.3 Why the ledger, not more columns
|
||||
|
||||
A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is **one table + posting discipline**. The alternative (more money columns on bookings/payouts) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the *pricing* record; the ledger is the *financial-truth / reconciliation* layer posted alongside.
|
||||
|
||||
@@ -287,6 +287,42 @@ Load-bearing rules:
|
||||
real card capture replaces it by calling `ConvertRequestToBooking` directly on a `succeeded` transaction. The no-show
|
||||
sweep (`DetectNoShowSessions`) is admin/test-triggered; its recurring cron is DEFERRED (like b8's expiry sweep).
|
||||
|
||||
**Payments core — ledger, transactions, webhooks & card capture (backend-phase-10).** A new **`payments`
|
||||
schema** holds the money core: `PaymentGateways` (config per PSP; **encrypted `config_json`**;
|
||||
selection by `type`+`priority`), `PaymentTransactions` (every attempt; the **two filtered uniques** —
|
||||
`UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded'` — are the
|
||||
anti-double-capture backstop), `PaymentWebhookEvents` (the idempotency store; **`UNIQUE(provider_code,
|
||||
external_event_id)`**), and the **append-only** `LedgerEntries` (double-entry source of truth). Entities in
|
||||
`Domain/Entities/Payments/` (+ `LedgerPosting` balanced-group builder, `LedgerAccountType`/`PaymentTransactionStatus`/
|
||||
`WebhookProcessingStatus`/`PaymentGatewayType` code sets); configs in `Persistence/Configuration/PaymentsConfig/`;
|
||||
one migration (`PaymentsCoreLedger`). Features under `Baya.Application/Features/Payments/{Commands|Queries}/`
|
||||
(`InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, `GetNursePayableBalance`);
|
||||
`IPaymentRepository` on `IUnitOfWork`; controllers `PaymentsController` (`POST bookings/{id}/payments`),
|
||||
`WebhooksController` (public `POST webhooks/payments/{provider}`), `NursePayableBalanceController`
|
||||
(`GET nurses/{id}/payable_balance`). Load-bearing rules:
|
||||
- **A `bookings` row exists only on capture (b9).** So a payment is initiated against the
|
||||
`accepted_awaiting_payment` **request**; `payment_transactions.booking_id` is **nullable**, bound only when
|
||||
the confirm creates/loads the booking. Confirm reuses b9 via the extracted **`BookingFactory`** (shared
|
||||
conversion/amount logic) rather than re-implementing it — the mock `IPaymentCaptureSimulator` Convert path
|
||||
stays for b9's own tests.
|
||||
- **Idempotency ordering:** `HandlePaymentWebhook` **upserts the webhook event first** on `(provider,
|
||||
external_event_id)` and **no-ops on a duplicate**; on a new success event it **re-verifies server-side**
|
||||
(`IPaymentProvider.VerifyAsync`) then dispatches `ConfirmPaymentAndPostLedger`, all under
|
||||
`IDistributedLock(booking-request:{id}:payment)`. A unique-violation on confirm is treated as an
|
||||
**idempotent no-op success**, not an error.
|
||||
- **The card-capture group is balanced:** `LedgerPosting.CardCapture` posts DEBIT `escrow_held` gross =
|
||||
CREDIT `platform_revenue` commission + `nurse_payable` payout under one `transaction_group_id`
|
||||
(Σdebit = Σcredit; throws if the three frozen amounts don't reconcile). `ledger_entries` is **append-only**
|
||||
(implements `IEntity` only — no `ITimeModification`, so the audit interceptor never stamps it; no soft-delete).
|
||||
- **Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
|
||||
stored column. The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs (the platform
|
||||
never moves money).
|
||||
- **Four money-path seams** in `Application/Contracts/Payments/` — `IPaymentProvider`,
|
||||
`ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` — with faithful mocks in
|
||||
`CrossCutting/Seams/` (`MockPaymentProvider`, `MockSettlementSplitProvider`, `MockWebhookVerifier`,
|
||||
`InProcessDistributedLock`), registered by `AddCrossCuttingSeams`. `payment_gateways.config_json` is
|
||||
encrypted through the b0 `IFieldEncryptor` (converter wired in `ApplicationDbContext`).
|
||||
|
||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Payments.Queries.GetNursePayableBalance;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// The derived per-nurse payable balance (IRR digit-string), summed from the append-only ledger — never a
|
||||
/// stored column. Authorized to the nurse themself or an admin/finance role. This is what b13 payouts read.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/nurses")]
|
||||
[Authorize]
|
||||
[Display(Description = "Derived nurse payable balance (ledger projection)")]
|
||||
public sealed class NursePayableBalanceController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("{nurseId}/payable_balance")]
|
||||
[ProducesOkApiResponseType<NursePayableBalanceDto>]
|
||||
public async Task<IActionResult> PayableBalance(long nurseId, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetNursePayableBalanceQuery(nurseId), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Baya.WebFramework.ServiceConfiguration;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// The customer-facing card-payment start. A booking exists only on capture, so a payment is initiated against
|
||||
/// the accepted <c>booking_requests</c> id; the money charged is the request's frozen gross. Rate-limited as a
|
||||
/// money endpoint and idempotency-keyed (the <c>Idempotency-Key</c> header) so a retried start reuses the same
|
||||
/// attempt. Internal account types are never exposed here — the response is just the redirect + the attempt id.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/bookings")]
|
||||
[Authorize]
|
||||
[Display(Description = "Card payment initiation against an accepted booking request")]
|
||||
public sealed class PaymentsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("{bookingRequestId}/payments")]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
|
||||
[ProducesOkApiResponseType<InitiatePaymentResult>]
|
||||
public async Task<IActionResult> Initiate(long bookingRequestId, CancellationToken cancellationToken)
|
||||
{
|
||||
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault();
|
||||
return OperationResult(await sender.Send(new InitiatePaymentCommand(bookingRequestId, idempotencyKey), cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// The inbound PSP/BNPL callback surface. Authenticated by <b>signature</b>, not a user session, so it is
|
||||
/// anonymous to the auth pipeline; at-least-once tolerant and idempotency-deduplicated on
|
||||
/// <c>(provider, external_event_id)</c> before any money moves. The raw body is read verbatim and stored in
|
||||
/// <c>payload_json</c>.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/webhooks")]
|
||||
[AllowAnonymous]
|
||||
[Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")]
|
||||
public sealed class WebhooksController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("payments/{provider}")]
|
||||
[ProducesOkApiResponseType<WebhookIngestResult>]
|
||||
public async Task<IActionResult> Payments(string provider, CancellationToken cancellationToken)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var rawBody = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return OperationResult(await sender.Send(new HandlePaymentWebhookCommand(provider, headers, rawBody), cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
await app.ApplyMigrationsAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
await app.SeedPaymentGatewaysAsync();
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// A distributed mutex around a money-path critical section (real impl: StackExchange.Redis with a lease/
|
||||
/// expiry, key convention <c>booking:{id}:payment</c>). It is the <b>fast first line</b> so a double callback
|
||||
/// and a user retry don't both start a money mutation — but <b>never the sole correctness guarantee</b>: if
|
||||
/// Redis is down or the lease expires, the DB uniques/state-machine remain the authoritative backstop.
|
||||
/// </summary>
|
||||
public interface IDistributedLock
|
||||
{
|
||||
/// <summary>Acquires the lock for <paramref name="key"/>; dispose the handle to release. The mock is an
|
||||
/// in-process semaphore per key, so the money-path code runs the same shape it will with real Redis.</summary>
|
||||
ValueTask<IAsyncDisposable> AcquireAsync(string key, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The swappable card-PSP acquirer seam (ZarinPal / Sadad / Vandar / Jibit …). Handlers depend only on this
|
||||
/// contract; the concrete provider is selected from <c>payment_gateways</c> config so a cut-off provider is
|
||||
/// swapped without a code change. <b>Every amount crossing this seam is IRR <c>long</c></b> — Toman conversion
|
||||
/// happens only inside a real adapter at its boundary, never here.
|
||||
/// </summary>
|
||||
public interface IPaymentProvider
|
||||
{
|
||||
/// <summary>Starts an IPG session and returns the redirect URL plus a deterministic gateway reference to
|
||||
/// persist on the pending transaction (honouring its filtered unique). <paramref name="idempotencyKey"/>
|
||||
/// makes a retried initiate return the same reference rather than opening a second session.</summary>
|
||||
ValueTask<PaymentInitResult> InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The mandatory server-side re-check (<b>never trust a callback alone</b>): re-verifies the
|
||||
/// amount + reference against the gateway before a success callback is allowed to confirm.</summary>
|
||||
ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reverses a captured payment (partial or full). Exposed here so b11 refunds can call it; this
|
||||
/// phase builds no refund flow.</summary>
|
||||
ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>The outcome verb of a provider verify/refund — mirrors the wire <c>payment</c> status codes.</summary>
|
||||
public enum PaymentProviderStatus
|
||||
{
|
||||
Pending,
|
||||
Succeeded,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <param name="RedirectUrl">Where the client is sent to complete the card payment.</param>
|
||||
/// <param name="GatewayReferenceCode">The deterministic reference persisted on the pending transaction.</param>
|
||||
public sealed record PaymentInitResult(string RedirectUrl, string GatewayReferenceCode);
|
||||
|
||||
/// <param name="Status">The re-verified outcome — only <see cref="PaymentProviderStatus.Succeeded"/> confirms.</param>
|
||||
/// <param name="AmountIrr">The amount the gateway reports, re-checked against the stored transaction.</param>
|
||||
public sealed record PaymentVerifyResult(PaymentProviderStatus Status, long AmountIrr);
|
||||
|
||||
/// <param name="Status">Whether the reversal was accepted by the gateway.</param>
|
||||
/// <param name="GatewayRefundReference">The provider's refund reference, when issued.</param>
|
||||
public sealed record PaymentRefundResult(PaymentProviderStatus Status, string? GatewayRefundReference);
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The تسهیم (settlement-sharing) seam — the <b>lawful split primitive</b>. A پرداختیار may not custody funds
|
||||
/// or run a wallet, so the platform never moves money between merchants: it registers a split-by-ratio to the
|
||||
/// beneficiaries' <b>registered IBANs</b> and the provider credits each IBAN directly. The ledger only mirrors
|
||||
/// money that legally sits at the provider/bank. Amounts are IRR <c>long</c>.
|
||||
/// </summary>
|
||||
public interface ISettlementSplitProvider
|
||||
{
|
||||
/// <summary>Registers the split for a captured booking. <paramref name="legs"/> credit the nurse's payout
|
||||
/// and the platform's commission to their registered IBANs; their sum equals the captured gross.</summary>
|
||||
ValueTask<SettlementResult> RegisterSplitAsync(long bookingId, IReadOnlyList<SettlementLeg> legs, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<SettlementResult> GetSplitStatusAsync(long bookingId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public enum SettlementStatus
|
||||
{
|
||||
Registered,
|
||||
Settled,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <param name="Sheba">The beneficiary's registered IBAN (SHEBA) the provider credits directly.</param>
|
||||
/// <param name="AmountIrr">The IRR amount for this beneficiary.</param>
|
||||
/// <param name="Beneficiary">A label — <c>nurse</c> / <c>platform</c>.</param>
|
||||
public sealed record SettlementLeg(string Sheba, long AmountIrr, string Beneficiary);
|
||||
|
||||
public sealed record SettlementResult(SettlementStatus Status);
|
||||
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an inbound PSP/BNPL callback's authenticity and extracts its idempotency key + payload. A callback
|
||||
/// is authenticated by <b>signature</b>, not a user session; an invalid signature must mutate <b>nothing</b>
|
||||
/// (stored with <c>signature_valid = 0</c>, <c>processing_status = ignored</c>). Where a provider offers no
|
||||
/// signature, the real adapter falls back to the mandatory server-side <c>verify</c> re-check.
|
||||
/// </summary>
|
||||
public interface IWebhookVerifier
|
||||
{
|
||||
WebhookVerification Verify(string provider, IReadOnlyDictionary<string, string> headers, string rawBody);
|
||||
}
|
||||
|
||||
/// <param name="SignatureValid">False ⇒ the callback is stored ignored and no money moves.</param>
|
||||
/// <param name="ExternalEventId">The provider's event id — half of the <c>(provider, external_event_id)</c> key.</param>
|
||||
/// <param name="EventType">The provider's event type; a success type triggers the confirm path.</param>
|
||||
/// <param name="GatewayReferenceCode">The reference tying the callback to a pending transaction.</param>
|
||||
/// <param name="IsSuccessEvent">Whether this event asserts a successful capture (gates the confirm path).</param>
|
||||
public sealed record WebhookVerification(
|
||||
bool SignatureValid,
|
||||
string ExternalEventId,
|
||||
string EventType,
|
||||
string? GatewayReferenceCode,
|
||||
bool IsSuccessEvent);
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
@@ -20,6 +21,10 @@ public interface IBookingRepository
|
||||
/// a replayed conversion return the existing booking instead of creating a second one.</summary>
|
||||
Task<long?> GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The three frozen amounts + nurse for a booking — what b10's card-capture ledger group posts
|
||||
/// against when the booking already exists (idempotent confirm). NULL when absent.</summary>
|
||||
Task<BookingLedgerAmounts?> GetLedgerAmountsAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
// ---- detail + lists ----
|
||||
Task<BookingDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken);
|
||||
Task<PagedResult<BookingListItemDto>> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
@@ -49,4 +50,8 @@ public interface IBookingRequestRepository
|
||||
/// one projected read: ids + participant user ids, the engagement schedule, and the source data for the
|
||||
/// two frozen snapshots (variant + decrypted address). NULL when absent.</summary>
|
||||
Task<BookingConversionSource?> GetConversionSourceAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The facts b10's <c>InitiatePayment</c> needs to validate a card attempt (owning customer,
|
||||
/// status, frozen payment window, gross to charge). NULL when absent.</summary>
|
||||
Task<BookingPaymentContext?> GetPaymentContextAsync(long id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Entities.Payments;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The payments-core aggregate (gateways, transactions, webhook events, ledger). Writes load tracked rows;
|
||||
/// balance reads project a signed aggregate over the append-only ledger. Money is IRR <c>long</c> throughout.
|
||||
/// The DB uniques (Shaparak ref, succeeded-per-booking, the webhook idempotency key) are the authoritative
|
||||
/// money-path backstops — the handlers rely on them, not just on a pre-check.
|
||||
/// </summary>
|
||||
public interface IPaymentRepository
|
||||
{
|
||||
// ---- gateway selection ----
|
||||
/// <summary>The active gateway of <paramref name="type"/> with the lowest priority — config-driven
|
||||
/// selection so a cut-off provider is swapped without a code change. Null when none is configured.</summary>
|
||||
Task<long?> GetActiveGatewayIdAsync(string type, CancellationToken cancellationToken);
|
||||
|
||||
Task AddGatewayAsync(PaymentGateway gateway, CancellationToken cancellationToken);
|
||||
|
||||
// ---- transactions ----
|
||||
Task AddTransactionAsync(PaymentTransaction transaction, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether a booking created from this request already has a captured (succeeded) transaction —
|
||||
/// the initiate idempotency pre-check (the filtered succeeded-unique is the backstop).</summary>
|
||||
Task<bool> HasSucceededTransactionForRequestAsync(long bookingRequestId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The tracked pending transaction carrying <paramref name="gatewayReferenceCode"/> — the webhook
|
||||
/// re-verify + confirm loads it to bind the capture. Null when absent.</summary>
|
||||
Task<PaymentTransaction?> GetTrackedTransactionByReferenceAsync(string gatewayReferenceCode, CancellationToken cancellationToken);
|
||||
|
||||
Task<PaymentTransaction?> GetTrackedTransactionByIdAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
// ---- webhook idempotency store ----
|
||||
/// <summary>The existing webhook event for this idempotency key, if any — a non-null result means a
|
||||
/// duplicate replay the handler no-ops on. Null on a brand-new event.</summary>
|
||||
Task<PaymentWebhookEvent?> GetWebhookEventByKeyAsync(string providerCode, string externalEventId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddWebhookEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken);
|
||||
|
||||
// ---- ledger ----
|
||||
/// <summary>Whether a posting group already exists for this payment transaction — makes the ledger post
|
||||
/// idempotent so a re-confirm never writes a second capture group.</summary>
|
||||
Task<bool> LedgerGroupExistsForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddLedgerEntriesAsync(IEnumerable<LedgerEntry> entries, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The IRR balance currently owed a nurse — the signed sum over <c>nurse_payable</c> legs
|
||||
/// (credit adds, debit subtracts). Pure ledger projection, never a stored column. This is what b13 reads.</summary>
|
||||
Task<long> GetNursePayableBalanceAsync(long nurseId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public interface IUnitOfWork
|
||||
public IBookingRequestRepository BookingRequestRepository { get; }
|
||||
public IBookingRepository BookingRepository { get; }
|
||||
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
|
||||
public IPaymentRepository PaymentRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#nullable enable
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Unicode;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Application.Features.Bookings;
|
||||
|
||||
/// <summary>
|
||||
/// The single place the confirmed <c>bookings</c> row (+ its reconciling sessions + the two frozen snapshots)
|
||||
/// is built from an <c>accepted_awaiting_payment</c> request. It holds the <b>conversion/amount logic</b> so
|
||||
/// both the b9 <c>ConvertRequestToBooking</c> path (mock-capture trigger) and the b10 real card-capture
|
||||
/// confirm path share it rather than duplicate it. Pure construction only — no DB, no commit, no capture, no
|
||||
/// current-user gate; the caller owns tenancy, capture, idempotency and persistence.
|
||||
/// </summary>
|
||||
internal static class BookingFactory
|
||||
{
|
||||
/// <summary>session_count comes from the variant (a single visit is 1).</summary>
|
||||
public static int SessionCount(BookingConversionSource source)
|
||||
=> source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1;
|
||||
|
||||
/// <summary>The charged gross (IRR) = variant price × session count. Frozen onto the booking at build time.</summary>
|
||||
public static long Gross(BookingConversionSource source)
|
||||
=> source.VariantSnapshot.Price * SessionCount(source);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the booking (status <c>confirmed</c>) with its ≥ 1 reconciling sessions and the two frozen
|
||||
/// snapshots. The three amounts derive from the snapshotted <paramref name="rate"/> via
|
||||
/// <see cref="BookingAmounts"/> so <c>gross = commission + payout</c> always holds.
|
||||
/// </summary>
|
||||
public static BookingEntity Create(
|
||||
BookingConversionSource source,
|
||||
decimal rate,
|
||||
DateTime now,
|
||||
long? pspFeeAmount,
|
||||
IVariantSnapshotSerializer variantSnapshotSerializer)
|
||||
{
|
||||
var sessionCount = SessionCount(source);
|
||||
var gross = source.VariantSnapshot.Price * sessionCount;
|
||||
var (commission, payout) = BookingAmounts.Split(gross, rate);
|
||||
|
||||
var booking = new BookingEntity
|
||||
{
|
||||
BookingRequestId = source.RequestId,
|
||||
CustomerId = source.CustomerId,
|
||||
NurseId = source.NurseId,
|
||||
PatientId = source.PatientId,
|
||||
VariantId = source.VariantId,
|
||||
CustomerAddressId = source.CustomerAddressId,
|
||||
VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot),
|
||||
AddressSnapshotJson = SerializeAddress(source.AddressSnapshot),
|
||||
GrossPriceIrr = gross,
|
||||
BalinyaarCommissionIrr = commission,
|
||||
PlatformFeeRate = rate,
|
||||
NursePayoutAmount = payout,
|
||||
PspFeeAmount = pspFeeAmount,
|
||||
SessionCount = (short)sessionCount,
|
||||
ScheduledDate = source.RequestedDate,
|
||||
ScheduledTimeStart = source.RequestedTimeStart,
|
||||
ScheduledTimeEnd = source.RequestedTimeEnd
|
||||
};
|
||||
|
||||
// Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount.
|
||||
var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount);
|
||||
for (var i = 0; i < sessionCount; i++)
|
||||
{
|
||||
booking.Sessions.Add(new BookingSession
|
||||
{
|
||||
SessionIndex = i + 1,
|
||||
ScheduledDate = source.RequestedDate,
|
||||
ScheduledTimeStart = source.RequestedTimeStart,
|
||||
ScheduledTimeEnd = source.RequestedTimeEnd,
|
||||
VisitPayoutAmount = visitPayouts[i]
|
||||
});
|
||||
}
|
||||
|
||||
booking.TransitionTo(BookingStatus.Confirmed, now);
|
||||
return booking;
|
||||
}
|
||||
|
||||
// Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant
|
||||
// snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe.
|
||||
private static readonly JsonSerializerOptions AddressJson = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
|
||||
};
|
||||
|
||||
public static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new
|
||||
{
|
||||
addressId = a.AddressId,
|
||||
title = a.Title,
|
||||
cityId = a.CityId,
|
||||
cityNameFa = a.CityNameFa,
|
||||
cityNameEn = a.CityNameEn,
|
||||
districtId = a.DistrictId,
|
||||
districtNameFa = a.DistrictNameFa,
|
||||
districtNameEn = a.DistrictNameEn,
|
||||
addressLine = a.AddressLine,
|
||||
postalCode = a.PostalCode,
|
||||
recipientName = a.RecipientName,
|
||||
recipientPhone = a.RecipientPhone,
|
||||
latitude = a.Latitude,
|
||||
longitude = a.Longitude
|
||||
}, AddressJson);
|
||||
}
|
||||
+2
-71
@@ -1,7 +1,5 @@
|
||||
#nullable enable
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Unicode;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
@@ -9,9 +7,6 @@ using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Mediator;
|
||||
// The singular b8 namespace Baya.Application.Features.Booking shadows the entity type name `Booking` when
|
||||
// referenced unqualified from this (plural) Features.Bookings area — alias it to disambiguate.
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking;
|
||||
|
||||
@@ -60,49 +55,10 @@ internal sealed class ConvertRequestToBookingCommandHandler(
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// session_count comes from the variant (a single visit is 1); gross = price × sessions.
|
||||
var sessionCount = source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1;
|
||||
var gross = source.VariantSnapshot.Price * sessionCount;
|
||||
|
||||
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
|
||||
var (commission, payout) = BookingAmounts.Split(gross, rate);
|
||||
|
||||
var booking = new BookingEntity
|
||||
{
|
||||
BookingRequestId = source.RequestId,
|
||||
CustomerId = source.CustomerId,
|
||||
NurseId = source.NurseId,
|
||||
PatientId = source.PatientId,
|
||||
VariantId = source.VariantId,
|
||||
CustomerAddressId = source.CustomerAddressId,
|
||||
VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot),
|
||||
AddressSnapshotJson = SerializeAddress(source.AddressSnapshot),
|
||||
GrossPriceIrr = gross,
|
||||
BalinyaarCommissionIrr = commission,
|
||||
PlatformFeeRate = rate,
|
||||
NursePayoutAmount = payout,
|
||||
PspFeeAmount = capture.PspFeeAmount,
|
||||
SessionCount = (short)sessionCount,
|
||||
ScheduledDate = source.RequestedDate,
|
||||
ScheduledTimeStart = source.RequestedTimeStart,
|
||||
ScheduledTimeEnd = source.RequestedTimeEnd
|
||||
};
|
||||
|
||||
// Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount.
|
||||
var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount);
|
||||
for (var i = 0; i < sessionCount; i++)
|
||||
{
|
||||
booking.Sessions.Add(new BookingSession
|
||||
{
|
||||
SessionIndex = i + 1,
|
||||
ScheduledDate = source.RequestedDate,
|
||||
ScheduledTimeStart = source.RequestedTimeStart,
|
||||
ScheduledTimeEnd = source.RequestedTimeEnd,
|
||||
VisitPayoutAmount = visitPayouts[i]
|
||||
});
|
||||
}
|
||||
|
||||
booking.TransitionTo(BookingStatus.Confirmed, now);
|
||||
// The conversion/amount logic is shared with b10's real card capture — build through BookingFactory.
|
||||
var booking = BookingFactory.Create(source, rate, now, capture.PspFeeAmount, variantSnapshotSerializer);
|
||||
|
||||
// Flip the request → converted in the same unit of work. Re-check the tracked state so a racing
|
||||
// cancel/expiry that already moved it is a clean conflict, not a double conversion.
|
||||
@@ -136,29 +92,4 @@ internal sealed class ConvertRequestToBookingCommandHandler(
|
||||
var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken);
|
||||
return OperationResult<BookingDetailDto>.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true));
|
||||
}
|
||||
|
||||
// Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant
|
||||
// snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe.
|
||||
private static readonly JsonSerializerOptions AddressJson = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
|
||||
};
|
||||
|
||||
private static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new
|
||||
{
|
||||
addressId = a.AddressId,
|
||||
title = a.Title,
|
||||
cityId = a.CityId,
|
||||
cityNameFa = a.CityNameFa,
|
||||
cityNameEn = a.CityNameEn,
|
||||
districtId = a.DistrictId,
|
||||
districtNameFa = a.DistrictNameFa,
|
||||
districtNameEn = a.DistrictNameEn,
|
||||
addressLine = a.AddressLine,
|
||||
postalCode = a.PostalCode,
|
||||
recipientName = a.RecipientName,
|
||||
recipientPhone = a.RecipientPhone,
|
||||
latitude = a.Latitude,
|
||||
longitude = a.Longitude
|
||||
}, AddressJson);
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Bookings;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
|
||||
internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
IPaymentProvider paymentProvider,
|
||||
ISettlementSplitProvider settlementSplitProvider,
|
||||
IVariantSnapshotSerializer variantSnapshotSerializer,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<ConfirmPaymentAndPostLedgerCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(ConfirmPaymentAndPostLedgerCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(request.PaymentTransactionId, cancellationToken);
|
||||
if (transaction is null)
|
||||
return OperationResult<bool>.NotFoundResult("Payment transaction not found.");
|
||||
|
||||
// Already captured — idempotent no-op success (a replayed confirm must not re-post).
|
||||
if (transaction.Status == PaymentTransactionStatus.Succeeded)
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// Never trust a callback alone — re-verify amount + reference with the gateway before confirming.
|
||||
var verify = await paymentProvider.VerifyAsync(transaction.GatewayReferenceCode ?? string.Empty, transaction.Amount, cancellationToken);
|
||||
if (verify.Status != PaymentProviderStatus.Succeeded || verify.AmountIrr != transaction.Amount)
|
||||
{
|
||||
transaction.MarkFailed(verify.Status.ToString(), null);
|
||||
await unitOfWork.CommitAsync();
|
||||
return OperationResult<bool>.FailureResult("The payment could not be verified with the gateway.");
|
||||
}
|
||||
|
||||
// Create/confirm the booking through the shared b9 conversion (idempotent on UNIQUE booking_request_id).
|
||||
var (bookingId, amounts, created, participants) = await EnsureBookingAsync(transaction.BookingRequestId, now, cancellationToken);
|
||||
if (bookingId is not { } booking)
|
||||
return OperationResult<bool>.ConflictResult("This request can no longer be converted to a booking.");
|
||||
|
||||
transaction.MarkSucceeded(booking, verify.Status.ToString(), transaction.GatewayResponseJson);
|
||||
|
||||
// Idempotent ledger: don't post a second capture group if one already exists for this transaction.
|
||||
if (!await unitOfWork.PaymentRepository.LedgerGroupExistsForTransactionAsync(transaction.Id, cancellationToken))
|
||||
{
|
||||
var legs = LedgerPosting.CardCapture(
|
||||
booking, amounts!.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr, transaction.Id, now);
|
||||
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent confirm for a different transaction of the same booking hit the filtered
|
||||
// UNIQUE(booking_id) WHERE status='succeeded' — the DB backstop. Treat as already-captured.
|
||||
await unitOfWork.RollBackAsync();
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
// The lawful تسهیم split to the registered IBANs (the provider credits each directly; the platform
|
||||
// never moves money). The mock records the intent; a real adapter resolves each beneficiary's SHEBA.
|
||||
await settlementSplitProvider.RegisterSplitAsync(
|
||||
booking,
|
||||
[
|
||||
new SettlementLeg("nurse-registered-sheba", amounts.PayoutIrr, "nurse"),
|
||||
new SettlementLeg("platform-registered-sheba", amounts.CommissionIrr, "platform")
|
||||
],
|
||||
cancellationToken);
|
||||
|
||||
if (created && participants is { } p)
|
||||
await NotifyConfirmedAsync(p, booking, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
private async Task<(long? BookingId, BookingLedgerAmountsLocal? Amounts, bool Created, BookingParticipantsLocal? Participants)>
|
||||
EnsureBookingAsync(long bookingRequestId, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(bookingRequestId, cancellationToken);
|
||||
if (existingId is { } eb)
|
||||
{
|
||||
var amounts = await unitOfWork.BookingRepository.GetLedgerAmountsAsync(eb, cancellationToken);
|
||||
return amounts is null
|
||||
? (null, null, false, null)
|
||||
: (eb, new BookingLedgerAmountsLocal(amounts.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr), false, null);
|
||||
}
|
||||
|
||||
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(bookingRequestId, cancellationToken);
|
||||
if (source is null || source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
return (null, null, false, null);
|
||||
|
||||
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
|
||||
var booking = BookingFactory.Create(source, rate, now, pspFeeAmount: null, variantSnapshotSerializer);
|
||||
|
||||
// Re-check the tracked request so a racing cancel/expiry is a clean conflict, not a double conversion.
|
||||
var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken);
|
||||
if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted))
|
||||
return (null, null, false, null);
|
||||
|
||||
trackedRequest.MarkConverted();
|
||||
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return (
|
||||
booking.Id,
|
||||
new BookingLedgerAmountsLocal(booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, booking.NursePayoutAmount),
|
||||
true,
|
||||
new BookingParticipantsLocal(source.CustomerUserId, source.NurseUserId));
|
||||
}
|
||||
|
||||
private async Task NotifyConfirmedAsync(BookingParticipantsLocal p, long bookingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { booking_id = bookingId });
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(p.CustomerUserId, "booking_confirmed", "Booking confirmed",
|
||||
"Your payment was captured and your booking is confirmed.", payload),
|
||||
cancellationToken);
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(p.NurseUserId, "booking_confirmed_nurse", "New confirmed booking",
|
||||
"A booking has been confirmed and paid. The care instructions and schedule are now available.", payload),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record BookingLedgerAmountsLocal(long NurseId, long GrossIrr, long CommissionIrr, long PayoutIrr);
|
||||
|
||||
private sealed record BookingParticipantsLocal(int CustomerUserId, int NurseUserId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
|
||||
/// <summary>
|
||||
/// Captures a verified payment: server-side re-verifies the attempt, creates/confirms the booking (through the
|
||||
/// shared b9 conversion), posts the <b>balanced card-capture ledger group</b> (DEBIT <c>escrow_held</c> gross =
|
||||
/// CREDIT <c>platform_revenue</c> commission + <c>nurse_payable</c> payout), and registers the تسهیم split.
|
||||
/// It is <b>never a public endpoint</b> — dispatched only by <c>HandlePaymentWebhook</c> (and directly in
|
||||
/// tests). Idempotent: an already-succeeded transaction, or a concurrent double-confirm caught by the filtered
|
||||
/// <c>UNIQUE(booking_id) WHERE status='succeeded'</c>, is a no-op success.
|
||||
/// </summary>
|
||||
public record ConfirmPaymentAndPostLedgerCommand(long PaymentTransactionId) : IRequest<OperationResult<bool>>;
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
||||
|
||||
internal sealed class HandlePaymentWebhookCommandHandler(
|
||||
ISender sender,
|
||||
IUnitOfWork unitOfWork,
|
||||
IWebhookVerifier webhookVerifier,
|
||||
IDistributedLock distributedLock,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<HandlePaymentWebhookCommand, OperationResult<WebhookIngestResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<WebhookIngestResult>> Handle(HandlePaymentWebhookCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var headers = request.Headers ?? new Dictionary<string, string>();
|
||||
var rawBody = request.RawBody ?? string.Empty;
|
||||
var verification = webhookVerifier.Verify(request.Provider, headers, rawBody);
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// Dedup FIRST on the idempotency key: a duplicate replay never mutates money state again.
|
||||
if (!string.IsNullOrEmpty(verification.ExternalEventId))
|
||||
{
|
||||
var duplicate = await unitOfWork.PaymentRepository.GetWebhookEventByKeyAsync(request.Provider, verification.ExternalEventId, cancellationToken);
|
||||
if (duplicate is not null)
|
||||
return Success(duplicate.ProcessingStatus, isDuplicate: true);
|
||||
}
|
||||
|
||||
var webhookEvent = new PaymentWebhookEvent
|
||||
{
|
||||
ProviderCode = request.Provider,
|
||||
ExternalEventId = verification.ExternalEventId,
|
||||
EventType = verification.EventType,
|
||||
SignatureValid = verification.SignatureValid,
|
||||
PayloadJson = rawBody,
|
||||
ReceivedAt = now
|
||||
};
|
||||
|
||||
// An unverified-signature callback mutates nothing — stored ignored and stopped.
|
||||
if (!verification.SignatureValid)
|
||||
{
|
||||
webhookEvent.MarkIgnored(now);
|
||||
return await PersistNewEventAsync(webhookEvent, cancellationToken);
|
||||
}
|
||||
|
||||
// A non-success event (or one with no reference) has nothing to confirm — acknowledged, no money moves.
|
||||
if (!verification.IsSuccessEvent || string.IsNullOrEmpty(verification.GatewayReferenceCode))
|
||||
{
|
||||
webhookEvent.MarkProcessed(null, now);
|
||||
return await PersistNewEventAsync(webhookEvent, cancellationToken);
|
||||
}
|
||||
|
||||
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByReferenceAsync(verification.GatewayReferenceCode!, cancellationToken);
|
||||
if (transaction is null)
|
||||
{
|
||||
// No matching pending attempt — retryable (failed), never a silent success.
|
||||
webhookEvent.MarkFailed(now);
|
||||
return await PersistNewEventAsync(webhookEvent, cancellationToken);
|
||||
}
|
||||
|
||||
// The whole money mutation runs under the lock; the DB uniques remain the authoritative backstop if
|
||||
// the lock is lost/expired. Keyed on the request (a b9 booking exists only after this confirm).
|
||||
await using var _ = await distributedLock.AcquireAsync($"booking-request:{transaction.BookingRequestId}:payment", cancellationToken);
|
||||
|
||||
// Claim the idempotency key first (inside the same context that mutates payment state). A racing
|
||||
// duplicate insert loses on the unique index and is treated as a no-op replay.
|
||||
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await unitOfWork.RollBackAsync();
|
||||
return Success(WebhookProcessingStatus.Processed, isDuplicate: true);
|
||||
}
|
||||
|
||||
// Re-verify server-side + capture + post the balanced ledger group + confirm the booking.
|
||||
var confirm = await sender.Send(new ConfirmPaymentAndPostLedgerCommand(transaction.Id), cancellationToken);
|
||||
|
||||
if (confirm.IsSuccess)
|
||||
webhookEvent.MarkProcessed(transaction.Id, now);
|
||||
else
|
||||
webhookEvent.MarkFailed(now);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
|
||||
}
|
||||
|
||||
private async Task<OperationResult<WebhookIngestResult>> PersistNewEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent insert of the same (provider, external_event_id) — the idempotency backstop.
|
||||
await unitOfWork.RollBackAsync();
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: true);
|
||||
}
|
||||
|
||||
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
|
||||
}
|
||||
|
||||
private static OperationResult<WebhookIngestResult> Success(string processingStatus, bool isDuplicate)
|
||||
=> OperationResult<WebhookIngestResult>.SuccessResult(new WebhookIngestResult(processingStatus, isDuplicate));
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
||||
|
||||
/// <summary>
|
||||
/// The verify-then-dedup-then-mutate ingest for every inbound PSP callback. It verifies the signature, upserts
|
||||
/// <c>payment_webhook_events</c> <b>first</b> on <c>(provider_code, external_event_id)</c> — no-op on a
|
||||
/// duplicate replay — and, on a new success event, re-verifies server-side and dispatches
|
||||
/// <c>ConfirmPaymentAndPostLedger</c>, all under a Redis <c>lock(booking:{id}:payment)</c> with the DB uniques
|
||||
/// as the authoritative backstop. Authenticated by signature, not a user session; at-least-once tolerant.
|
||||
/// </summary>
|
||||
/// <param name="Provider">The provider code from the route (<c>zarinpal</c>/<c>sadad</c>/…).</param>
|
||||
/// <param name="Headers">The raw callback headers (signature material for the verifier).</param>
|
||||
/// <param name="RawBody">The raw callback body — stored verbatim in <c>payload_json</c>.</param>
|
||||
public record HandlePaymentWebhookCommand(string Provider, IReadOnlyDictionary<string, string> Headers, string RawBody)
|
||||
: IRequest<OperationResult<WebhookIngestResult>>;
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
|
||||
internal sealed class InitiatePaymentCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPaymentProvider paymentProvider,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<InitiatePaymentCommand, OperationResult<InitiatePaymentResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<InitiatePaymentResult>> Handle(InitiatePaymentCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<InitiatePaymentResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is null)
|
||||
return OperationResult<InitiatePaymentResult>.NotFoundResult("Booking request not found.");
|
||||
|
||||
// Tenancy: only the owning customer may pay; any other caller must not learn the request exists.
|
||||
var ctx = await unitOfWork.BookingRequestRepository.GetPaymentContextAsync(request.BookingRequestId, cancellationToken);
|
||||
if (ctx is null || ctx.CustomerId != customerId)
|
||||
return OperationResult<InitiatePaymentResult>.NotFoundResult("Booking request not found.");
|
||||
|
||||
// Already paid — do not open a second attempt. The filtered succeeded-unique is the structural backstop.
|
||||
if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken))
|
||||
return OperationResult<InitiatePaymentResult>.ConflictResult("This booking has already been paid.");
|
||||
|
||||
if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
return OperationResult<InitiatePaymentResult>.ConflictResult("This request is not awaiting payment.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
if (ctx.PaymentDeadlineAt is { } deadline && deadline < now)
|
||||
return OperationResult<InitiatePaymentResult>.ConflictResult("The payment window for this request has lapsed.");
|
||||
|
||||
// Config-driven selection: the active standard gateway with the lowest priority (swap a cut-off
|
||||
// provider by config, not a code change).
|
||||
var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Standard, cancellationToken);
|
||||
if (gatewayId is null)
|
||||
return OperationResult<InitiatePaymentResult>.FailureResult("No active payment gateway is configured.");
|
||||
|
||||
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
|
||||
? $"br-{request.BookingRequestId}"
|
||||
: request.IdempotencyKey!;
|
||||
|
||||
// amount is the request's frozen gross — never recomputed from client input.
|
||||
var init = await paymentProvider.InitPaymentAsync(request.BookingRequestId, ctx.GrossIrr, idempotencyKey, cancellationToken);
|
||||
|
||||
// A retried initiate (same idempotency key ⇒ same reference) reuses the existing pending attempt
|
||||
// instead of colliding on the filtered UNIQUE(gateway_reference_code).
|
||||
var existing = await unitOfWork.PaymentRepository.GetTrackedTransactionByReferenceAsync(init.GatewayReferenceCode, cancellationToken);
|
||||
if (existing is not null)
|
||||
return OperationResult<InitiatePaymentResult>.SuccessResult(
|
||||
new InitiatePaymentResult(existing.Id, init.RedirectUrl, init.GatewayReferenceCode));
|
||||
|
||||
var transaction = new PaymentTransaction
|
||||
{
|
||||
BookingRequestId = request.BookingRequestId,
|
||||
CustomerId = customerId.Value,
|
||||
GatewayId = gatewayId.Value,
|
||||
Amount = ctx.GrossIrr,
|
||||
Currency = "IRR",
|
||||
GatewayReferenceCode = init.GatewayReferenceCode,
|
||||
IpAddress = currentUser.IpAddress
|
||||
};
|
||||
|
||||
await unitOfWork.PaymentRepository.AddTransactionAsync(transaction, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<InitiatePaymentResult>.SuccessResult(
|
||||
new InitiatePaymentResult(transaction.Id, init.RedirectUrl, init.GatewayReferenceCode));
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
|
||||
public sealed class InitiatePaymentCommandValidator : AbstractValidator<InitiatePaymentCommand>
|
||||
{
|
||||
public InitiatePaymentCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BookingRequestId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a card payment for an <c>accepted_awaiting_payment</c> booking request owned by the caller: selects
|
||||
/// the active <c>standard</c> gateway, asks the PSP to open an IPG session, and persists a <c>pending</c>
|
||||
/// <c>payment_transactions</c> row (with the gateway reference, honouring its filtered unique). The charged
|
||||
/// amount is the request's frozen gross (variant price × session count) — never client-supplied. A repeat for
|
||||
/// a request already captured is a <c>409</c>: the filtered <c>UNIQUE(booking_id) WHERE status='succeeded'</c>
|
||||
/// is the structural backstop.
|
||||
/// </summary>
|
||||
/// <param name="BookingRequestId">The accepted request to pay for (a b9 <c>bookings</c> row exists only on capture).</param>
|
||||
/// <param name="IdempotencyKey">The client's idempotency key, so a retried initiate reuses the same reference.</param>
|
||||
public record InitiatePaymentCommand(long BookingRequestId, string? IdempotencyKey)
|
||||
: IRequest<OperationResult<InitiatePaymentResult>>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Bookings;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Queries.GetNursePayableBalance;
|
||||
|
||||
internal sealed class GetNursePayableBalanceQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetNursePayableBalanceQuery, OperationResult<NursePayableBalanceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NursePayableBalanceDto>> Handle(GetNursePayableBalanceQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NursePayableBalanceDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
// The nurse themself, or an admin/finance role — no one else may read a nurse's payable balance.
|
||||
var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true;
|
||||
if (!isAdmin)
|
||||
{
|
||||
var callerNurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (callerNurseId is null || callerNurseId != request.NurseId)
|
||||
return OperationResult<NursePayableBalanceDto>.ForbiddenResult("You may only read your own payable balance.");
|
||||
}
|
||||
|
||||
var balance = await unitOfWork.PaymentRepository.GetNursePayableBalanceAsync(request.NurseId, cancellationToken);
|
||||
return OperationResult<NursePayableBalanceDto>.SuccessResult(
|
||||
new NursePayableBalanceDto(request.NurseId, balance.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payments.Queries.GetNursePayableBalance;
|
||||
|
||||
/// <summary>
|
||||
/// The IRR balance currently owed a nurse — the signed sum of <c>nurse_payable</c> ledger legs (credit adds,
|
||||
/// debit subtracts). A <b>pure projection over the append-only ledger</b> (no cached wallet column ever); this
|
||||
/// is what b13 payouts read to know what to pay. Authorized to the nurse themself or an admin/finance role.
|
||||
/// </summary>
|
||||
public record GetNursePayableBalanceQuery(long NurseId) : IRequest<OperationResult<NursePayableBalanceDto>>;
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Baya.Application.Models.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The minimal facts <c>InitiatePayment</c> needs to validate a card attempt against an
|
||||
/// <c>accepted_awaiting_payment</c> request: the owning customer (tenancy), the request status, the frozen
|
||||
/// payment window, and the gross to charge (variant price × session count — the same figure b9 freezes onto
|
||||
/// the booking on capture).
|
||||
/// </summary>
|
||||
public record BookingPaymentContext(long RequestId, long CustomerId, string Status, System.DateTime? PaymentDeadlineAt, long GrossIrr);
|
||||
|
||||
/// <summary>The three frozen amounts + nurse a card-capture ledger group posts against, read from an existing
|
||||
/// booking (the confirm path is idempotent — a booking created by a prior confirm reuses this).</summary>
|
||||
public record BookingLedgerAmounts(long NurseId, long GrossIrr, long CommissionIrr, long PayoutIrr);
|
||||
|
||||
/// <summary>
|
||||
/// The result of starting a card payment: the redirect the client sends the payer to, plus the ids that
|
||||
/// identify the attempt. Money never appears here — it is the booking's frozen gross, echoed nowhere the
|
||||
/// customer could tamper with it.
|
||||
/// </summary>
|
||||
/// <param name="TransactionId">The <c>payment_transactions</c> row id for this attempt.</param>
|
||||
/// <param name="RedirectUrl">Where to send the payer to complete the card payment.</param>
|
||||
/// <param name="GatewayReferenceCode">The deterministic gateway reference persisted on the attempt.</param>
|
||||
public record InitiatePaymentResult(long TransactionId, string RedirectUrl, string GatewayReferenceCode);
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a webhook ingest — the terminal <c>processing_status</c> and whether this call was a
|
||||
/// duplicate replay that was short-circuited. The endpoint always returns success (at-least-once tolerant).
|
||||
/// </summary>
|
||||
/// <param name="ProcessingStatus">A <c>payment_webhook_events.processing_status</c> code.</param>
|
||||
/// <param name="Duplicate">True when the idempotency key was already stored — no money state was touched.</param>
|
||||
public record WebhookIngestResult(string ProcessingStatus, bool Duplicate);
|
||||
|
||||
/// <summary>
|
||||
/// The IRR balance currently owed a nurse, <b>derived from the ledger</b> (signed by direction), never a
|
||||
/// stored column. Serialized as a string of digits per the money convention.
|
||||
/// </summary>
|
||||
/// <param name="NurseId">The nurse profile id.</param>
|
||||
/// <param name="BalanceIrr">The signed <c>nurse_payable</c> sum, as a digit string.</param>
|
||||
public record NursePayableBalanceDto(long NurseId, string BalanceIrr);
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The closed set of double-entry <c>ledger_entries.account_type</c> codes — the accounts every money event
|
||||
/// posts against. This phase only posts the first three (the card-capture group), but <b>all</b> are defined
|
||||
/// now because b11 (refunds/clawbacks) and b12 (BNPL) post against the rest. Balances are <b>derived by
|
||||
/// filtering</b> on these, never stored in a drifting column.
|
||||
/// </summary>
|
||||
public static class LedgerAccountType
|
||||
{
|
||||
/// <summary>Funds held in escrow state (legally at the PSP/bank, never platform cash). Debited on capture.</summary>
|
||||
public const string EscrowHeld = "escrow_held";
|
||||
|
||||
/// <summary>Balinyaar's own commission revenue. Credited on capture.</summary>
|
||||
public const string PlatformRevenue = "platform_revenue";
|
||||
|
||||
/// <summary>Amount owed to a nurse (carries <c>nurse_id</c>). Credited on capture; b13 pays it down.</summary>
|
||||
public const string NursePayable = "nurse_payable";
|
||||
|
||||
/// <summary>Amount owed back to a customer for a refund. Posted by b11.</summary>
|
||||
public const string RefundPayable = "refund_payable";
|
||||
|
||||
/// <summary>The BNPL provider's commission expense. Posted by b12's settle group.</summary>
|
||||
public const string BnplFeeExpense = "bnpl_fee_expense";
|
||||
|
||||
/// <summary>The PSP/gateway fee expense on a capture (true margin). Reserved.</summary>
|
||||
public const string PspFeeExpense = "psp_fee_expense";
|
||||
|
||||
/// <summary>A receivable from a nurse already paid when a booking is later refunded (carries <c>nurse_id</c>).
|
||||
/// Posted by b11.</summary>
|
||||
public const string NurseClawbackReceivable = "nurse_clawback_receivable";
|
||||
|
||||
/// <summary>Written-off uncollectable amount. Reserved.</summary>
|
||||
public const string BadDebt = "bad_debt";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The two <c>ledger_entries.direction</c> codes. <c>amount_irr</c> is <b>always positive</b>; the direction
|
||||
/// carries the sign. A balanced posting group has Σ(debit) = Σ(credit).
|
||||
/// </summary>
|
||||
public static class LedgerDirection
|
||||
{
|
||||
public const string Debit = "debit";
|
||||
public const string Credit = "credit";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>ledger_entries.source_ref_type</c> codes — what a posting group's <c>source_ref_id</c> points at.
|
||||
/// </summary>
|
||||
public static class LedgerSourceRefType
|
||||
{
|
||||
public const string PaymentTransaction = "payment_transaction";
|
||||
public const string Refund = "refund";
|
||||
public const string NursePayout = "nurse_payout";
|
||||
public const string BnplTransaction = "bnpl_transaction";
|
||||
public const string Clawback = "clawback";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// One leg of the append-only, double-entry financial <b>source of truth</b>. Every money event posts
|
||||
/// <b>balanced</b> legs sharing a <see cref="TransactionGroupId"/> (Σ debit = Σ credit per group);
|
||||
/// <see cref="AmountIrr"/> is always positive and <see cref="Direction"/> carries the sign. Per-nurse balances
|
||||
/// (b13) derive by filtering <c>account_type = 'nurse_payable' AND nurse_id = …</c> — never a stored column.
|
||||
/// <para>
|
||||
/// This entity is <b>insert-only</b>: it implements <see cref="IEntity"/> but <b>not</b>
|
||||
/// <see cref="ITimeModification"/>/<see cref="IAuditableEntity"/>, so the audit interceptor never stamps a
|
||||
/// modify on it and there is no soft-delete path. Corrections are <b>new balancing rows</b>, never edits.
|
||||
/// <see cref="CreatedAt"/> is set explicitly from <c>IDateTimeProvider</c> at post time.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class LedgerEntry : IEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Groups the balanced legs of one money event.</summary>
|
||||
public Guid TransactionGroupId { get; set; }
|
||||
|
||||
/// <summary>A <see cref="LedgerAccountType"/> code.</summary>
|
||||
public string AccountType { get; set; } = null!;
|
||||
|
||||
/// <summary>Set for <c>nurse_payable</c>/<c>nurse_clawback_receivable</c> legs; null otherwise.</summary>
|
||||
public long? NurseId { get; set; }
|
||||
|
||||
/// <summary>A <see cref="LedgerDirection"/> code — carries the sign of <see cref="AmountIrr"/>.</summary>
|
||||
public string Direction { get; set; } = null!;
|
||||
|
||||
/// <summary>Always positive IRR; the sign lives in <see cref="Direction"/>. No floats.</summary>
|
||||
public long AmountIrr { get; set; }
|
||||
|
||||
public long? BookingId { get; set; }
|
||||
|
||||
/// <summary>A <see cref="LedgerSourceRefType"/> code.</summary>
|
||||
public string SourceRefType { get; set; } = null!;
|
||||
|
||||
public long SourceRefId { get; set; }
|
||||
|
||||
public string? Memo { get; set; }
|
||||
|
||||
/// <summary>Append-only; never updated. Set from <c>IDateTimeProvider</c> at post time.</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#nullable enable
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical, <b>balanced</b> ledger posting groups so the posting discipline lives in one
|
||||
/// unit-testable place instead of a handler. Every group returned satisfies Σ(debit) = Σ(credit).
|
||||
/// </summary>
|
||||
public static class LedgerPosting
|
||||
{
|
||||
/// <summary>
|
||||
/// The <b>card-capture group</b>: <c>DEBIT escrow_held gross = CREDIT platform_revenue commission +
|
||||
/// nurse_payable payout</c>, all under one fresh <see cref="LedgerEntry.TransactionGroupId"/>. The three
|
||||
/// amounts come <b>frozen from the booking</b> (b9) — never recomputed here — and must reconcile
|
||||
/// (<c>gross = commission + payout</c>); this method throws if they do not, so an unbalanced group can
|
||||
/// never be persisted.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LedgerEntry> CardCapture(
|
||||
long bookingId,
|
||||
long nurseId,
|
||||
long grossIrr,
|
||||
long commissionIrr,
|
||||
long payoutIrr,
|
||||
long paymentTransactionId,
|
||||
DateTime createdAt)
|
||||
{
|
||||
if (grossIrr != commissionIrr + payoutIrr)
|
||||
throw new InvalidOperationException(
|
||||
$"Card-capture group would not balance: gross {grossIrr} != commission {commissionIrr} + payout {payoutIrr}.");
|
||||
|
||||
var group = Guid.NewGuid();
|
||||
|
||||
LedgerEntry Leg(string account, string direction, long amount, long? nurse) => new()
|
||||
{
|
||||
TransactionGroupId = group,
|
||||
AccountType = account,
|
||||
Direction = direction,
|
||||
AmountIrr = amount,
|
||||
NurseId = nurse,
|
||||
BookingId = bookingId,
|
||||
SourceRefType = LedgerSourceRefType.PaymentTransaction,
|
||||
SourceRefId = paymentTransactionId,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
|
||||
return
|
||||
[
|
||||
Leg(LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null),
|
||||
Leg(LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null),
|
||||
Leg(LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Config per connected PSP/BNPL provider — the unit of <b>selection and failover</b>. The active
|
||||
/// <c>standard</c> gateway with the lowest <see cref="Priority"/> is chosen for a card capture, so a
|
||||
/// cut-off provider (Toman/Jibit were abruptly suspended Nov 2024) is swapped <b>by config, not a code
|
||||
/// change</b>. <see cref="ConfigJson"/> holds provider-selection/failover config (merchant id, terminal/IBAN
|
||||
/// registration for the تسهیم split, base url, sandbox flag) — <b>never per-transaction credentials</b> — and
|
||||
/// is <b>encrypted at rest</b> through the field encryptor and never logged in plaintext.
|
||||
/// </summary>
|
||||
public class PaymentGateway : BaseEntity<long>
|
||||
{
|
||||
/// <summary>Provider code — <c>zarinpal</c> / <c>sadad</c> / <c>vandar</c> / <c>jibit</c> …</summary>
|
||||
public string ProviderCode { get; set; } = null!;
|
||||
|
||||
/// <summary>A <see cref="PaymentGatewayType"/> code — selects the card (<c>standard</c>) vs BNPL flow.</summary>
|
||||
public string Type { get; set; } = PaymentGatewayType.Standard;
|
||||
|
||||
public string DisplayName { get; set; } = null!;
|
||||
|
||||
/// <summary>Encrypted provider-selection/failover config (merchant id, terminal/IBAN registration, base
|
||||
/// url, sandbox flag). Encrypted at rest via <c>IFieldEncryptor</c>; never per-transaction credentials.</summary>
|
||||
public string ConfigJson { get; set; } = null!;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Failover order — the active gateway of a type with the lowest priority wins selection.</summary>
|
||||
public int Priority { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>payment_gateways.type</c> code set — it <b>selects the flow</b>. A <c>standard</c> gateway
|
||||
/// is a card IPG (Shaparak-routed); a <c>bnpl</c> gateway is a Buy-Now-Pay-Later provider (b12). Persisted
|
||||
/// as these stable snake_case codes, never a C# enum member name.
|
||||
/// </summary>
|
||||
public static class PaymentGatewayType
|
||||
{
|
||||
/// <summary>Card IPG — the rail this phase drives end-to-end.</summary>
|
||||
public const string Standard = "standard";
|
||||
|
||||
/// <summary>Buy-Now-Pay-Later provider — settle flow is DEFERRED to b12.</summary>
|
||||
public const string Bnpl = "bnpl";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// Every payment attempt against a booking request; the <see cref="PaymentTransactionStatus.Succeeded"/> row
|
||||
/// is what triggers confirmation. It stores the full <see cref="GatewayResponseJson"/> and the Shaparak
|
||||
/// <see cref="GatewayReferenceCode"/> — the definitive proof for reconciliation and chargebacks.
|
||||
/// <para>
|
||||
/// Two structural idempotency guards protect the money path (configured on the table, not just in a handler):
|
||||
/// a filtered <c>UNIQUE(gateway_reference_code) WHERE NOT NULL</c> (Shaparak ref dedupe) and a filtered
|
||||
/// <c>UNIQUE(booking_id) WHERE status='succeeded'</c> (<b>at most one capturing transaction per booking</b>).
|
||||
/// The latter is the authoritative anti-double-capture backstop even if the Redis lock is lost.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Because a b9 <c>bookings</c> row is created only <i>on capture</i>, the attempt is opened against the
|
||||
/// originating <see cref="BookingRequestId"/> and <see cref="BookingId"/> stays null until confirmation binds
|
||||
/// the two together (which is also when the succeeded-unique starts guarding the booking).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PaymentTransaction : BaseEntity<long>
|
||||
{
|
||||
/// <summary>The originating <c>accepted_awaiting_payment</c> request this attempt pays for.</summary>
|
||||
public long BookingRequestId { get; set; }
|
||||
|
||||
/// <summary>The confirmed booking, set only when this attempt succeeds and conversion creates it. Null
|
||||
/// while pending — so the filtered succeeded-unique only ever guards a captured booking.</summary>
|
||||
public long? BookingId { get; private set; }
|
||||
|
||||
public long CustomerId { get; set; }
|
||||
public long GatewayId { get; set; }
|
||||
|
||||
/// <summary>The charged amount (IRR) — the booking's frozen gross. No floats, ever.</summary>
|
||||
public long Amount { get; set; }
|
||||
|
||||
/// <summary>Always <c>IRR</c> internally; Toman is a display concern at the provider boundary only.</summary>
|
||||
public string Currency { get; set; } = "IRR";
|
||||
|
||||
/// <summary>Guarded — mutated only through <see cref="MarkSucceeded"/>/<see cref="MarkFailed"/>.</summary>
|
||||
public string Status { get; private set; } = PaymentTransactionStatus.Pending;
|
||||
|
||||
public string? GatewayTransactionId { get; set; }
|
||||
|
||||
/// <summary>The Shaparak reference code — definitive reconciliation/chargeback proof. Filtered-unique.</summary>
|
||||
public string? GatewayReferenceCode { get; set; }
|
||||
|
||||
public string? GatewayResponseCode { get; private set; }
|
||||
public string? GatewayResponseJson { get; private set; }
|
||||
|
||||
public bool IsInstallment { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>Binds the succeeded capture to its booking and records the gateway response. The filtered
|
||||
/// <c>UNIQUE(booking_id) WHERE status='succeeded'</c> makes a second succeeded row for the same booking
|
||||
/// impossible — a concurrent double-confirm fails on the constraint (treated as an idempotent no-op).</summary>
|
||||
public void MarkSucceeded(long bookingId, string? responseCode, string? responseJson)
|
||||
{
|
||||
BookingId = bookingId;
|
||||
Status = PaymentTransactionStatus.Succeeded;
|
||||
GatewayResponseCode = responseCode;
|
||||
GatewayResponseJson = responseJson;
|
||||
}
|
||||
|
||||
public void MarkFailed(string? responseCode, string? responseJson)
|
||||
{
|
||||
Status = PaymentTransactionStatus.Failed;
|
||||
GatewayResponseCode = responseCode;
|
||||
GatewayResponseJson = responseJson;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>payment_transactions.status</c> code set. Exactly one <see cref="Succeeded"/> row may exist
|
||||
/// per booking — enforced structurally by the filtered <c>UNIQUE(booking_id) WHERE status='succeeded'</c>,
|
||||
/// the authoritative anti-double-capture backstop. Persisted as these stable snake_case codes.
|
||||
/// </summary>
|
||||
public static class PaymentTransactionStatus
|
||||
{
|
||||
/// <summary>An IPG session was started; awaiting the PSP callback.</summary>
|
||||
public const string Pending = "pending";
|
||||
|
||||
/// <summary>Capture confirmed (server-side re-verified) — triggers the ledger posting + booking confirm.</summary>
|
||||
public const string Succeeded = "succeeded";
|
||||
|
||||
/// <summary>The attempt failed at the gateway; a fresh attempt is a new row.</summary>
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The raw, deduplicated store of every inbound PSP/BNPL callback — the <b>idempotency chokepoint</b> of the
|
||||
/// whole money path. PSP callbacks are at-least-once and retried, so the handler <b>upserts here first</b>,
|
||||
/// keyed on <c>UNIQUE(provider_code, external_event_id)</c>, and <b>no-ops on a duplicate</b> inside the same
|
||||
/// transaction that would mutate payment state: a replayed <c>succeeded</c> can never double-confirm and a
|
||||
/// replayed <c>settled</c> can never double-count.
|
||||
/// </summary>
|
||||
public class PaymentWebhookEvent : BaseEntity<long>
|
||||
{
|
||||
public string ProviderCode { get; set; } = null!;
|
||||
|
||||
/// <summary>The provider's own event id — the second half of the idempotency key.</summary>
|
||||
public string ExternalEventId { get; set; } = null!;
|
||||
|
||||
public string EventType { get; set; } = null!;
|
||||
|
||||
/// <summary>Whether the signature verified. A false here forces <see cref="WebhookProcessingStatus.Ignored"/>
|
||||
/// and stops the path before any money moves.</summary>
|
||||
public bool SignatureValid { get; set; }
|
||||
|
||||
public string PayloadJson { get; set; } = null!;
|
||||
|
||||
/// <summary>Guarded — mutated only through the mark-* methods.</summary>
|
||||
public string ProcessingStatus { get; private set; } = WebhookProcessingStatus.Received;
|
||||
|
||||
public long? RelatedPaymentTransactionId { get; private set; }
|
||||
|
||||
public DateTime ReceivedAt { get; set; }
|
||||
public DateTime? ProcessedAt { get; private set; }
|
||||
|
||||
public void MarkProcessed(long? relatedPaymentTransactionId, DateTime processedAt)
|
||||
{
|
||||
ProcessingStatus = WebhookProcessingStatus.Processed;
|
||||
RelatedPaymentTransactionId = relatedPaymentTransactionId;
|
||||
ProcessedAt = processedAt;
|
||||
}
|
||||
|
||||
public void MarkIgnored(DateTime processedAt)
|
||||
{
|
||||
ProcessingStatus = WebhookProcessingStatus.Ignored;
|
||||
ProcessedAt = processedAt;
|
||||
}
|
||||
|
||||
public void MarkFailed(DateTime processedAt)
|
||||
{
|
||||
ProcessingStatus = WebhookProcessingStatus.Failed;
|
||||
ProcessedAt = processedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Baya.Domain.Entities.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// The closed <c>payment_webhook_events.processing_status</c> code set. Persisted as these stable snake_case
|
||||
/// codes.
|
||||
/// </summary>
|
||||
public static class WebhookProcessingStatus
|
||||
{
|
||||
/// <summary>Stored, not yet acted on (the transient state a brand-new event is inserted in).</summary>
|
||||
public const string Received = "received";
|
||||
|
||||
/// <summary>Verified, deduplicated and applied — money state was mutated by this event.</summary>
|
||||
public const string Processed = "processed";
|
||||
|
||||
/// <summary>Verified but the downstream mutation failed; safe to retry.</summary>
|
||||
public const string Failed = "failed";
|
||||
|
||||
/// <summary>Rejected before any money moved — an invalid signature or a nothing-to-do event.</summary>
|
||||
public const string Ignored = "ignored";
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using System.Collections.Concurrent;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// In-process mock <see cref="IDistributedLock"/> — a per-key <see cref="SemaphoreSlim"/> so the money-path
|
||||
/// code runs the same acquire/release shape it will with real Redis, within a single process. It is
|
||||
/// deliberately <b>not</b> a correctness guarantee across instances: the DB uniques/state-machine are the
|
||||
/// authoritative backstop. A real StackExchange.Redis lock (lease/expiry, key <c>booking:{id}:payment</c>)
|
||||
/// replaces this registration only.
|
||||
/// </summary>
|
||||
public sealed class InProcessDistributedLock : IDistributedLock
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Gates = new();
|
||||
|
||||
public async ValueTask<IAsyncDisposable> AcquireAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var gate = Gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
return new Release(gate);
|
||||
}
|
||||
|
||||
private sealed class Release(SemaphoreSlim gate) : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
gate.Release();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Payments;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic mock <see cref="IPaymentProvider"/> — no external call. <see cref="InitPaymentAsync"/> returns
|
||||
/// a stable fake reference derived from the request + idempotency key and a fake redirect URL;
|
||||
/// <see cref="VerifyAsync"/> instantly succeeds and echoes the expected amount (the server-side re-check always
|
||||
/// passes in the mock); <see cref="RefundAsync"/> always succeeds (so b11 can call it). A real
|
||||
/// ZarinPal/Sadad/Vandar/Jibit adapter (acquirer-with-تسهیم, Shaparak reference, sandbox flag from the
|
||||
/// gateway's encrypted config) replaces this registration only — no mock behaviour is baked into a handler.
|
||||
/// </summary>
|
||||
public sealed class MockPaymentProvider : IPaymentProvider
|
||||
{
|
||||
public ValueTask<PaymentInitResult> InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reference = $"mock-ref-{bookingRequestId}-{idempotencyKey}";
|
||||
return ValueTask.FromResult(new PaymentInitResult(
|
||||
RedirectUrl: $"https://mock-psp.local/pay/{reference}",
|
||||
GatewayReferenceCode: reference));
|
||||
}
|
||||
|
||||
public ValueTask<PaymentVerifyResult> VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(new PaymentVerifyResult(PaymentProviderStatus.Succeeded, expectedAmountIrr));
|
||||
|
||||
public ValueTask<PaymentRefundResult> RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}"));
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Payments;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic mock <see cref="ISettlementSplitProvider"/> — records the split intent and reports it
|
||||
/// settled without moving a Rial (the platform never custodies funds). It accepts any legs whose sum is
|
||||
/// positive and returns <see cref="SettlementStatus.Settled"/>. A real تسهیم adapter (each beneficiary's
|
||||
/// registered SHEBA, split-by-ratio config, the ~100,000 IRR min-amount caveat; the provider credits IBANs
|
||||
/// directly) replaces this registration only.
|
||||
/// </summary>
|
||||
public sealed class MockSettlementSplitProvider : ISettlementSplitProvider
|
||||
{
|
||||
public ValueTask<SettlementResult> RegisterSplitAsync(long bookingId, IReadOnlyList<SettlementLeg> legs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = legs.Count > 0 && legs.Sum(l => l.AmountIrr) > 0
|
||||
? SettlementStatus.Settled
|
||||
: SettlementStatus.Failed;
|
||||
return ValueTask.FromResult(new SettlementResult(status));
|
||||
}
|
||||
|
||||
public ValueTask<SettlementResult> GetSplitStatusAsync(long bookingId, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(new SettlementResult(SettlementStatus.Settled));
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic mock <see cref="IWebhookVerifier"/>. It treats the signature as valid unless the raw body
|
||||
/// carries the configured invalid-signature marker (so the "unverified callback mutates nothing" path is
|
||||
/// testable), and extracts a test <c>external_event_id</c> / <c>event_type</c> / <c>gateway_reference_code</c>
|
||||
/// from a small JSON body — which lets tests replay a duplicate callback to prove idempotency. A real adapter
|
||||
/// implements the per-provider HMAC/signature scheme (or the mandatory server-side <c>verify</c> re-check).
|
||||
/// </summary>
|
||||
public sealed class MockWebhookVerifier(IOptions<SeamOptions> options) : IWebhookVerifier
|
||||
{
|
||||
private readonly PaymentsOptions _options = options.Value.Payments;
|
||||
|
||||
public WebhookVerification Verify(string provider, IReadOnlyDictionary<string, string> headers, string rawBody)
|
||||
{
|
||||
var signatureValid = !rawBody.Contains(_options.InvalidSignatureMarker, StringComparison.Ordinal);
|
||||
|
||||
string externalEventId = string.Empty;
|
||||
string eventType = string.Empty;
|
||||
string? gatewayReferenceCode = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(rawBody);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("external_event_id", out var id))
|
||||
externalEventId = id.GetString() ?? string.Empty;
|
||||
if (root.TryGetProperty("event_type", out var type))
|
||||
eventType = type.GetString() ?? string.Empty;
|
||||
if (root.TryGetProperty("gateway_reference_code", out var reference))
|
||||
gatewayReferenceCode = reference.GetString();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A body we can't parse yields an empty id/type; the handler stores it and no-ops (nothing to do).
|
||||
}
|
||||
|
||||
var isSuccessEvent = eventType.Contains("succeed", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new WebhookVerification(signatureValid, externalEventId, eventType, gatewayReferenceCode, isSuccessEvent);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,22 @@ public sealed class SeamOptions
|
||||
public ShahkarOptions Shahkar { get; set; } = new();
|
||||
public IdentityKycOptions IdentityKyc { get; set; } = new();
|
||||
public PaymentCaptureOptions PaymentCapture { get; set; } = new();
|
||||
public PaymentsOptions Payments { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunes the b10 money-path mocks (PSP acquirer, تسهیم split, webhook verifier). The real adapters ignore
|
||||
/// these — production merchant ids / signing keys come from <c>payment_gateways.config_json</c> and secrets,
|
||||
/// never from here.
|
||||
/// </summary>
|
||||
public sealed class PaymentsOptions
|
||||
{
|
||||
/// <summary>The platform's own registered IBAN (SHEBA) the mock split credits the commission leg to.</summary>
|
||||
public string PlatformSheba { get; set; } = "IR000000000000000000000001";
|
||||
|
||||
/// <summary>A callback whose raw body contains this marker is treated as an <b>invalid signature</b> by the
|
||||
/// mock verifier, so the "unverified callback mutates nothing" path is testable.</summary>
|
||||
public string InvalidSignatureMarker { get; set; } = "INVALID_SIGNATURE";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+10
@@ -1,4 +1,5 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -48,6 +49,15 @@ public static class ServiceCollectionExtension
|
||||
// and calls ConvertRequestToBooking directly on a real payment_transactions.succeeded.
|
||||
services.AddSingleton<IPaymentCaptureSimulator, MockPaymentCaptureSimulator>();
|
||||
|
||||
// Payments money-path seams (backend-phase-10). All four are deterministic mocks; a real card PSP /
|
||||
// تسهیم split adapter (config-selected per payment_gateways.config_json), per-provider signature
|
||||
// verifier, and StackExchange.Redis lock swap in by a registration change only — no mock behaviour is
|
||||
// baked into any handler. The DB uniques/state-machine remain the authoritative money-path backstop.
|
||||
services.AddSingleton<IPaymentProvider, MockPaymentProvider>();
|
||||
services.AddSingleton<ISettlementSplitProvider, MockSettlementSplitProvider>();
|
||||
services.AddSingleton<IWebhookVerifier, MockWebhookVerifier>();
|
||||
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Baya.Application.Contracts.Common;
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Baya.Infrastructure.Persistence.ValueConversion;
|
||||
@@ -157,5 +158,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
|
||||
builder.Property(c => c.EmergencyContactName).HasConversion(encrypted);
|
||||
builder.Property(c => c.EmergencyContactPhone).HasConversion(encrypted);
|
||||
});
|
||||
|
||||
// b10 gateway config: provider-selection/failover config (merchant id, terminal/IBAN registration,
|
||||
// base url, sandbox flag) is encrypted at rest through the same seam and never logged in plaintext.
|
||||
modelBuilder.Entity<PaymentGateway>(builder =>
|
||||
{
|
||||
builder.Property(g => g.ConfigJson).HasConversion(encrypted);
|
||||
});
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig;
|
||||
|
||||
/// <summary>
|
||||
/// <c>ledger_entries</c> is <b>append-only</b>: no soft-delete, no audit-modified columns, no query filter,
|
||||
/// and (because the entity implements <c>IEntity</c> only, not <c>ITimeModification</c>) the audit interceptor
|
||||
/// never stamps a modify on it. Corrections are new balancing rows, never edits.
|
||||
/// </summary>
|
||||
internal sealed class LedgerEntryConfig : IEntityTypeConfiguration<LedgerEntry>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LedgerEntry> builder)
|
||||
{
|
||||
builder.ToTable("LedgerEntries", "payments");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.AccountType).HasMaxLength(40).IsRequired();
|
||||
builder.Property(e => e.Direction).HasMaxLength(6).IsRequired();
|
||||
builder.Property(e => e.SourceRefType).HasMaxLength(40).IsRequired();
|
||||
builder.Property(e => e.Memo).HasMaxLength(300);
|
||||
|
||||
// Balance reads (b13 payouts): the nurse_payable balance for a nurse; a posting group; a source's legs.
|
||||
builder.HasIndex(e => new { e.AccountType, e.NurseId });
|
||||
builder.HasIndex(e => e.TransactionGroupId);
|
||||
builder.HasIndex(e => new { e.SourceRefType, e.SourceRefId });
|
||||
builder.HasIndex(e => e.BookingId);
|
||||
|
||||
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(e => e.NurseId).IsRequired(false);
|
||||
builder.HasOne<Booking>().WithMany().HasForeignKey(e => e.BookingId).IsRequired(false);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig;
|
||||
|
||||
internal sealed class PaymentGatewayConfig : IEntityTypeConfiguration<PaymentGateway>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PaymentGateway> builder)
|
||||
{
|
||||
builder.ToTable("PaymentGateways", "payments");
|
||||
|
||||
builder.Property(g => g.ProviderCode).HasMaxLength(50).IsRequired();
|
||||
builder.Property(g => g.Type).HasMaxLength(20).IsRequired();
|
||||
builder.Property(g => g.DisplayName).HasMaxLength(100).IsRequired();
|
||||
// config_json is encrypted at rest (converter wired in ApplicationDbContext); nvarchar(max), no cap.
|
||||
builder.Property(g => g.ConfigJson).IsRequired();
|
||||
|
||||
// Gateway selection reads the active gateway of a type by lowest priority — a covering index.
|
||||
builder.HasIndex(g => new { g.Type, g.IsActive, g.Priority });
|
||||
|
||||
builder.HasQueryFilter(g => g.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig;
|
||||
|
||||
internal sealed class PaymentTransactionConfig : IEntityTypeConfiguration<PaymentTransaction>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PaymentTransaction> builder)
|
||||
{
|
||||
builder.ToTable("PaymentTransactions", "payments");
|
||||
|
||||
builder.Property(t => t.Currency).HasMaxLength(3).IsRequired();
|
||||
builder.Property(t => t.Status).HasMaxLength(20).IsRequired();
|
||||
builder.Property(t => t.GatewayTransactionId).HasMaxLength(200);
|
||||
builder.Property(t => t.GatewayReferenceCode).HasMaxLength(200);
|
||||
builder.Property(t => t.GatewayResponseCode).HasMaxLength(50);
|
||||
builder.Property(t => t.IpAddress).HasMaxLength(64);
|
||||
builder.Property(t => t.UserAgent).HasMaxLength(400);
|
||||
|
||||
// The two structural idempotency guards (the whole point of the phase). SQL Server/SQLite both honour
|
||||
// filtered (partial) unique indexes; NULLs sit outside the filter so pending rows don't collide.
|
||||
builder.HasIndex(t => t.GatewayReferenceCode)
|
||||
.IsUnique()
|
||||
.HasFilter("[GatewayReferenceCode] IS NOT NULL");
|
||||
|
||||
// At most one capturing transaction per booking — the authoritative anti-double-capture backstop.
|
||||
builder.HasIndex(t => t.BookingId)
|
||||
.IsUnique()
|
||||
.HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL");
|
||||
|
||||
// The lookup used by initiate/confirm (attempts for a request/booking by status).
|
||||
builder.HasIndex(t => new { t.BookingId, t.Status });
|
||||
builder.HasIndex(t => new { t.BookingRequestId, t.Status });
|
||||
|
||||
builder.HasOne<BookingRequest>().WithMany().HasForeignKey(t => t.BookingRequestId).IsRequired();
|
||||
builder.HasOne<Booking>().WithMany().HasForeignKey(t => t.BookingId).IsRequired(false);
|
||||
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(t => t.CustomerId).IsRequired();
|
||||
builder.HasOne<PaymentGateway>().WithMany().HasForeignKey(t => t.GatewayId).IsRequired();
|
||||
|
||||
builder.HasQueryFilter(t => t.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig;
|
||||
|
||||
internal sealed class PaymentWebhookEventConfig : IEntityTypeConfiguration<PaymentWebhookEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PaymentWebhookEvent> builder)
|
||||
{
|
||||
builder.ToTable("PaymentWebhookEvents", "payments");
|
||||
|
||||
builder.Property(e => e.ProviderCode).HasMaxLength(50).IsRequired();
|
||||
builder.Property(e => e.ExternalEventId).HasMaxLength(200).IsRequired();
|
||||
builder.Property(e => e.EventType).HasMaxLength(80).IsRequired();
|
||||
builder.Property(e => e.ProcessingStatus).HasMaxLength(20).IsRequired();
|
||||
// payload_json is nvarchar(max) (raw callback); no length cap.
|
||||
builder.Property(e => e.PayloadJson).IsRequired();
|
||||
|
||||
// THE idempotency key — a duplicate provider event can never insert twice, so a replay can never
|
||||
// double-confirm/double-count. No soft-delete: this store is append/upsert-only.
|
||||
builder.HasIndex(e => new { e.ProviderCode, e.ExternalEventId }).IsUnique();
|
||||
}
|
||||
}
|
||||
+4502
File diff suppressed because it is too large
Load Diff
+265
@@ -0,0 +1,265 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PaymentsCoreLedger : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "payments");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LedgerEntries",
|
||||
schema: "payments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TransactionGroupId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
AccountType = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
|
||||
NurseId = table.Column<long>(type: "bigint", nullable: true),
|
||||
Direction = table.Column<string>(type: "nvarchar(6)", maxLength: 6, nullable: false),
|
||||
AmountIrr = table.Column<long>(type: "bigint", nullable: false),
|
||||
BookingId = table.Column<long>(type: "bigint", nullable: true),
|
||||
SourceRefType = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
|
||||
SourceRefId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Memo = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LedgerEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LedgerEntries_Bookings_BookingId",
|
||||
column: x => x.BookingId,
|
||||
principalSchema: "booking",
|
||||
principalTable: "Bookings",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_LedgerEntries_NurseProfiles_NurseId",
|
||||
column: x => x.NurseId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PaymentGateways",
|
||||
schema: "payments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ProviderCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Type = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
ConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
Priority = table.Column<int>(type: "int", nullable: false),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PaymentGateways", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PaymentWebhookEvents",
|
||||
schema: "payments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ProviderCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
ExternalEventId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EventType = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
|
||||
SignatureValid = table.Column<bool>(type: "bit", nullable: false),
|
||||
PayloadJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ProcessingStatus = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
RelatedPaymentTransactionId = table.Column<long>(type: "bigint", nullable: true),
|
||||
ReceivedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PaymentWebhookEvents", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PaymentTransactions",
|
||||
schema: "payments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
BookingRequestId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BookingId = table.Column<long>(type: "bigint", nullable: true),
|
||||
CustomerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
GatewayId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Amount = table.Column<long>(type: "bigint", nullable: false),
|
||||
Currency = table.Column<string>(type: "nvarchar(3)", maxLength: 3, nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
GatewayTransactionId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
GatewayReferenceCode = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
GatewayResponseCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
GatewayResponseJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsInstallment = table.Column<bool>(type: "bit", nullable: false),
|
||||
IpAddress = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
UserAgent = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PaymentTransactions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PaymentTransactions_BookingRequests_BookingRequestId",
|
||||
column: x => x.BookingRequestId,
|
||||
principalSchema: "booking",
|
||||
principalTable: "BookingRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PaymentTransactions_Bookings_BookingId",
|
||||
column: x => x.BookingId,
|
||||
principalSchema: "booking",
|
||||
principalTable: "Bookings",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_PaymentTransactions_CustomerProfiles_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "CustomerProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PaymentTransactions_PaymentGateways_GatewayId",
|
||||
column: x => x.GatewayId,
|
||||
principalSchema: "payments",
|
||||
principalTable: "PaymentGateways",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LedgerEntries_AccountType_NurseId",
|
||||
schema: "payments",
|
||||
table: "LedgerEntries",
|
||||
columns: new[] { "AccountType", "NurseId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LedgerEntries_BookingId",
|
||||
schema: "payments",
|
||||
table: "LedgerEntries",
|
||||
column: "BookingId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LedgerEntries_NurseId",
|
||||
schema: "payments",
|
||||
table: "LedgerEntries",
|
||||
column: "NurseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LedgerEntries_SourceRefType_SourceRefId",
|
||||
schema: "payments",
|
||||
table: "LedgerEntries",
|
||||
columns: new[] { "SourceRefType", "SourceRefId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LedgerEntries_TransactionGroupId",
|
||||
schema: "payments",
|
||||
table: "LedgerEntries",
|
||||
column: "TransactionGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaymentGateways_Type_IsActive_Priority",
|
||||
schema: "payments",
|
||||
table: "PaymentGateways",
|
||||
columns: new[] { "Type", "IsActive", "Priority" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaymentTransactions_BookingId",
|
||||
schema: "payments",
|
||||
table: "PaymentTransactions",
|
||||
column: "BookingId",
|
||||
unique: true,
|
||||
filter: "[Status] = 'succeeded' AND [BookingId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaymentTransactions_BookingId_Status",
|
||||
schema: "payments",
|
||||
table: "PaymentTransactions",
|
||||
columns: new[] { "BookingId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaymentTransactions_BookingRequestId_Status",
|
||||
schema: "payments",
|
||||
table: "PaymentTransactions",
|
||||
columns: new[] { "BookingRequestId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaymentTransactions_CustomerId",
|
||||
schema: "payments",
|
||||
table: "PaymentTransactions",
|
||||
column: "CustomerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaymentTransactions_GatewayId",
|
||||
schema: "payments",
|
||||
table: "PaymentTransactions",
|
||||
column: "GatewayId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaymentTransactions_GatewayReferenceCode",
|
||||
schema: "payments",
|
||||
table: "PaymentTransactions",
|
||||
column: "GatewayReferenceCode",
|
||||
unique: true,
|
||||
filter: "[GatewayReferenceCode] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaymentWebhookEvents_ProviderCode_ExternalEventId",
|
||||
schema: "payments",
|
||||
table: "PaymentWebhookEvents",
|
||||
columns: new[] { "ProviderCode", "ExternalEventId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "LedgerEntries",
|
||||
schema: "payments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PaymentTransactions",
|
||||
schema: "payments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PaymentWebhookEvents",
|
||||
schema: "payments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PaymentGateways",
|
||||
schema: "payments");
|
||||
}
|
||||
}
|
||||
}
|
||||
+310
@@ -2675,6 +2675,280 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Notifications", "ops");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AccountType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("nvarchar(40)");
|
||||
|
||||
b.Property<long>("AmountIrr")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("BookingId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Direction")
|
||||
.IsRequired()
|
||||
.HasMaxLength(6)
|
||||
.HasColumnType("nvarchar(6)");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<long?>("NurseId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("SourceRefId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SourceRefType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("nvarchar(40)");
|
||||
|
||||
b.Property<Guid>("TransactionGroupId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookingId");
|
||||
|
||||
b.HasIndex("NurseId");
|
||||
|
||||
b.HasIndex("TransactionGroupId");
|
||||
|
||||
b.HasIndex("AccountType", "NurseId");
|
||||
|
||||
b.HasIndex("SourceRefType", "SourceRefId");
|
||||
|
||||
b.ToTable("LedgerEntries", "payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentGateway", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ConfigJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ProviderCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Type", "IsActive", "Priority");
|
||||
|
||||
b.ToTable("PaymentGateways", "payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("Amount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("BookingId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("BookingRequestId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("nvarchar(3)");
|
||||
|
||||
b.Property<long>("CustomerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<long>("GatewayId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("GatewayReferenceCode")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("GatewayResponseCode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("GatewayResponseJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("GatewayTransactionId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<bool>("IsInstallment")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("nvarchar(400)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookingId")
|
||||
.IsUnique()
|
||||
.HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.HasIndex("GatewayId");
|
||||
|
||||
b.HasIndex("GatewayReferenceCode")
|
||||
.IsUnique()
|
||||
.HasFilter("[GatewayReferenceCode] IS NOT NULL");
|
||||
|
||||
b.HasIndex("BookingId", "Status");
|
||||
|
||||
b.HasIndex("BookingRequestId", "Status");
|
||||
|
||||
b.ToTable("PaymentTransactions", "payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentWebhookEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)");
|
||||
|
||||
b.Property<string>("ExternalEventId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("PayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ProcessingStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("ProviderCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime>("ReceivedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<long?>("RelatedPaymentTransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("SignatureValid")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderCode", "ExternalEventId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PaymentWebhookEvents", "payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -3921,6 +4195,42 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BookingId");
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("NurseId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BookingId");
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BookingRequestId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Payments.PaymentGateway", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GatewayId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
|
||||
|
||||
+7
@@ -2,6 +2,7 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
@@ -24,6 +25,12 @@ internal sealed class BookingRepository : BaseAsyncRepository<Booking>, IBooking
|
||||
.Select(b => (long?)b.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<BookingLedgerAmounts?> GetLedgerAmountsAsync(long id, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(b => b.Id == id)
|
||||
.Select(b => new BookingLedgerAmounts(b.NurseId, b.GrossPriceIrr, b.BalinyaarCommissionIrr, b.NursePayoutAmount))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public async Task<BookingDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var row = await (
|
||||
|
||||
+12
@@ -3,6 +3,7 @@ using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -222,6 +223,17 @@ internal sealed class BookingRequestRepository : BaseAsyncRepository<BookingRequ
|
||||
r.CustomerAddress.Longitude)))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<BookingPaymentContext?> GetPaymentContextAsync(long id, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(r => r.Id == id)
|
||||
.Select(r => new BookingPaymentContext(
|
||||
r.Id,
|
||||
r.CustomerId,
|
||||
r.Status,
|
||||
r.PaymentDeadlineAt,
|
||||
r.Variant.Price * (r.Variant.SessionCount != null ? r.Variant.SessionCount.Value : 1)))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
// Actionable (awaiting a party's action) rows float above terminal ones, then most-recent first. Recency
|
||||
// (id) rather than the deadline is the secondary key: for a given status the deadline tracks creation
|
||||
// time anyway, and DateTimeOffset is not sortable on the SQLite test provider.
|
||||
|
||||
+2
@@ -22,6 +22,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IBookingRequestRepository BookingRequestRepository { get; }
|
||||
public IBookingRepository BookingRepository { get; }
|
||||
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
|
||||
public IPaymentRepository PaymentRepository { get; }
|
||||
|
||||
public UnitOfWork(ApplicationDbContext db)
|
||||
{
|
||||
@@ -42,6 +43,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
BookingRequestRepository = new BookingRequestRepository(_db);
|
||||
BookingRepository = new BookingRepository(_db);
|
||||
CancellationPolicyRepository = new CancellationPolicyRepository(_db);
|
||||
PaymentRepository = new PaymentRepository(_db);
|
||||
}
|
||||
|
||||
public Task CommitAsync()
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class PaymentRepository : BaseAsyncRepository<PaymentTransaction>, IPaymentRepository
|
||||
{
|
||||
public PaymentRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<long?> GetActiveGatewayIdAsync(string type, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<PaymentGateway>().AsNoTracking()
|
||||
.Where(g => g.Type == type && g.IsActive)
|
||||
.OrderBy(g => g.Priority)
|
||||
.Select(g => (long?)g.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task AddGatewayAsync(PaymentGateway gateway, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<PaymentGateway>().AddAsync(gateway, cancellationToken).AsTask();
|
||||
|
||||
public Task AddTransactionAsync(PaymentTransaction transaction, CancellationToken cancellationToken)
|
||||
=> base.AddAsync(transaction);
|
||||
|
||||
public Task<bool> HasSucceededTransactionForRequestAsync(long bookingRequestId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking.AnyAsync(
|
||||
t => t.BookingRequestId == bookingRequestId && t.Status == PaymentTransactionStatus.Succeeded,
|
||||
cancellationToken);
|
||||
|
||||
public Task<PaymentTransaction?> GetTrackedTransactionByReferenceAsync(string gatewayReferenceCode, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(t => t.GatewayReferenceCode == gatewayReferenceCode, cancellationToken);
|
||||
|
||||
public Task<PaymentTransaction?> GetTrackedTransactionByIdAsync(long id, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
||||
|
||||
public Task<PaymentWebhookEvent?> GetWebhookEventByKeyAsync(string providerCode, string externalEventId, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<PaymentWebhookEvent>().AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.ProviderCode == providerCode && e.ExternalEventId == externalEventId, cancellationToken);
|
||||
|
||||
public Task AddWebhookEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<PaymentWebhookEvent>().AddAsync(webhookEvent, cancellationToken).AsTask();
|
||||
|
||||
public Task<bool> LedgerGroupExistsForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<LedgerEntry>().AsNoTracking().AnyAsync(
|
||||
e => e.SourceRefType == LedgerSourceRefType.PaymentTransaction && e.SourceRefId == paymentTransactionId,
|
||||
cancellationToken);
|
||||
|
||||
public Task AddLedgerEntriesAsync(IEnumerable<LedgerEntry> entries, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<LedgerEntry>().AddRangeAsync(entries, cancellationToken);
|
||||
|
||||
public async Task<long> GetNursePayableBalanceAsync(long nurseId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Signed sum over the append-only ledger — credit adds, debit subtracts. No cached wallet column.
|
||||
var query = DbContext.Set<LedgerEntry>().AsNoTracking()
|
||||
.Where(e => e.AccountType == LedgerAccountType.NursePayable && e.NurseId == nurseId);
|
||||
|
||||
var credits = await query.Where(e => e.Direction == LedgerDirection.Credit)
|
||||
.SumAsync(e => (long?)e.AmountIrr, cancellationToken) ?? 0;
|
||||
var debits = await query.Where(e => e.Direction == LedgerDirection.Debit)
|
||||
.SumAsync(e => (long?)e.AmountIrr, cancellationToken) ?? 0;
|
||||
|
||||
return credits - debits;
|
||||
}
|
||||
}
|
||||
+28
@@ -7,6 +7,7 @@ using Baya.Application.Contracts.Notifications;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Infrastructure.Persistence.Interceptors;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Infrastructure.Persistence.Services.Analytics;
|
||||
@@ -83,4 +84,31 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
await context.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently seeds one active <c>standard</c> payment gateway so the b10 card rail has a selectable
|
||||
/// provider out of the box. <c>config_json</c> is encrypted at rest by the EF converter on save (so it
|
||||
/// must go through the DbContext, not <c>HasData</c>). Real merchant credentials come from user-secrets /
|
||||
/// environment per deployment — this sandbox row is non-secret and only enables the local/dev flow.
|
||||
/// </summary>
|
||||
public static async Task SeedPaymentGatewaysAsync(this WebApplication app)
|
||||
{
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
if (await context.Set<PaymentGateway>().AnyAsync(g => g.Type == PaymentGatewayType.Standard))
|
||||
return;
|
||||
|
||||
context.Set<PaymentGateway>().Add(new PaymentGateway
|
||||
{
|
||||
ProviderCode = "zarinpal",
|
||||
Type = PaymentGatewayType.Standard,
|
||||
DisplayName = "ZarinPal (sandbox)",
|
||||
ConfigJson = "{\"merchantId\":\"00000000-0000-0000-0000-000000000000\",\"baseUrl\":\"https://sandbox.zarinpal.com\",\"sandbox\":true}",
|
||||
IsActive = true,
|
||||
Priority = 0
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class PaymentsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Initiate_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.PostAsync("/api/v1/bookings/1/payments", null);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initiate_InvalidId_Returns400()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09131900201", "customer");
|
||||
|
||||
var response = await client.PostAsync("/api/v1/bookings/0/payments", null);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Full_flow_initiate_then_webhook_confirms_booking_and_accrues_nurse_payable()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
const string phone = "09131900202";
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer");
|
||||
|
||||
var (requestId, nurseId) = await SeedAcceptedRequestAsync(phone, price: 23_300_000);
|
||||
|
||||
// 1. Initiate the card payment → pending transaction + a redirect + the gateway reference.
|
||||
var initiate = await client.PostAsync($"/api/v1/bookings/{requestId}/payments", null);
|
||||
Assert.Equal(HttpStatusCode.OK, initiate.StatusCode);
|
||||
var initData = await AuthTestClient.ReadDataAsync(initiate);
|
||||
var reference = initData.GetProperty("gatewayReferenceCode").GetString()!;
|
||||
Assert.False(string.IsNullOrEmpty(initData.GetProperty("redirectUrl").GetString()));
|
||||
|
||||
// 2. A signature-authenticated webhook confirms it (anonymous to the auth pipeline).
|
||||
var webhook = await PostWebhookAsync(client, "zarinpal", SuccessBody(reference, "evt-flow-1"));
|
||||
Assert.Equal(HttpStatusCode.OK, webhook.StatusCode);
|
||||
var webhookData = await AuthTestClient.ReadDataAsync(webhook);
|
||||
Assert.Equal(WebhookProcessingStatus.Processed, webhookData.GetProperty("processingStatus").GetString());
|
||||
|
||||
// 3. The booking converted/confirmed and the balanced capture group posted.
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var booking = db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId);
|
||||
Assert.Equal(BookingStatus.Confirmed, booking.Status);
|
||||
|
||||
var legs = db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == booking.Id).ToList();
|
||||
Assert.Equal(3, legs.Count);
|
||||
Assert.Equal(
|
||||
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
|
||||
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
|
||||
|
||||
var payable = legs.Single(l => l.AccountType == LedgerAccountType.NursePayable);
|
||||
Assert.Equal(nurseId, payable.NurseId);
|
||||
Assert.Equal(19_805_000, payable.AmountIrr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Webhook_duplicate_replay_is_idempotent()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
const string phone = "09131900203";
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer");
|
||||
|
||||
var (requestId, _) = await SeedAcceptedRequestAsync(phone, price: 5_000_000);
|
||||
var initiate = await client.PostAsync($"/api/v1/bookings/{requestId}/payments", null);
|
||||
var reference = (await AuthTestClient.ReadDataAsync(initiate)).GetProperty("gatewayReferenceCode").GetString()!;
|
||||
|
||||
var body = SuccessBody(reference, "evt-dup-1");
|
||||
var first = await PostWebhookAsync(client, "zarinpal", body);
|
||||
var replay = await PostWebhookAsync(client, "zarinpal", body);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, replay.StatusCode);
|
||||
Assert.True((await AuthTestClient.ReadDataAsync(replay)).GetProperty("duplicate").GetBoolean());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
// Exactly one capture group for this booking, and one webhook event for the replayed key (the DB is
|
||||
// shared across the class fixture, so scope both assertions to this test's own rows).
|
||||
var booking = db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId);
|
||||
Assert.Equal(3, db.Set<LedgerEntry>().AsNoTracking().Count(l => l.BookingId == booking.Id));
|
||||
Assert.Equal(1, db.Set<PaymentWebhookEvent>().AsNoTracking().Count(e => e.ExternalEventId == "evt-dup-1"));
|
||||
}
|
||||
|
||||
private static string SuccessBody(string reference, string eventId)
|
||||
=> $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";
|
||||
|
||||
private static Task<HttpResponseMessage> PostWebhookAsync(HttpClient client, string provider, string body)
|
||||
=> client.PostAsync($"/api/v1/webhooks/payments/{provider}", new StringContent(body, Encoding.UTF8, "application/json"));
|
||||
|
||||
/// <summary>Seeds the reference data + a bookable nurse + an accepted request owned by the authenticated
|
||||
/// customer, plus an active standard gateway. Returns the request id and nurse profile id.</summary>
|
||||
private async Task<(long RequestId, long NurseId)> SeedAcceptedRequestAsync(string customerPhone, long price)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
|
||||
|
||||
var customerUser = await userManager.GetUserByPhoneNumber(customerPhone);
|
||||
var customer = db.Set<CustomerProfile>().FirstOrDefault(c => c.UserId == customerUser!.Id);
|
||||
if (customer is null)
|
||||
{
|
||||
customer = new CustomerProfile { UserId = customerUser!.Id };
|
||||
db.Set<CustomerProfile>().Add(customer);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
if (!db.Set<PaymentGateway>().Any(g => g.Type == PaymentGatewayType.Standard && g.IsActive))
|
||||
{
|
||||
db.Set<PaymentGateway>().Add(new PaymentGateway
|
||||
{
|
||||
ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "ZarinPal",
|
||||
ConfigJson = "{\"merchantId\":\"test\",\"sandbox\":true}", IsActive = true, Priority = 0
|
||||
});
|
||||
}
|
||||
|
||||
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
db.Set<Province>().Add(province);
|
||||
db.SaveChanges();
|
||||
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
db.Set<City>().Add(city);
|
||||
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
|
||||
db.Set<ServiceCategory>().Add(category);
|
||||
db.SaveChanges();
|
||||
|
||||
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
|
||||
db.Set<Patient>().Add(patient);
|
||||
var address = new CustomerAddress
|
||||
{
|
||||
CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول",
|
||||
PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = customerPhone,
|
||||
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
|
||||
};
|
||||
db.Set<CustomerAddress>().Add(address);
|
||||
|
||||
var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
|
||||
db.Users.Add(nurseUser);
|
||||
db.SaveChanges();
|
||||
var nurse = new NurseProfile { UserId = nurseUser.Id };
|
||||
nurse.MarkVerified();
|
||||
nurse.SetAcceptingBookings(true);
|
||||
db.Set<NurseProfile>().Add(nurse);
|
||||
db.SaveChanges();
|
||||
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = price, PriceUnit = "per_day",
|
||||
SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
|
||||
};
|
||||
db.Set<NurseServiceVariant>().Add(variant);
|
||||
db.SaveChanges();
|
||||
|
||||
var request = new BookingRequest
|
||||
{
|
||||
CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, VariantId = variant.Id,
|
||||
CustomerAddressId = address.Id, RequiredCaregiverGender = CaregiverGender.Any,
|
||||
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
|
||||
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
db.Set<BookingRequest>().Add(request);
|
||||
db.SaveChanges();
|
||||
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
db.SaveChanges();
|
||||
|
||||
return (request.Id, nurse.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
using Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Test.Foundation.Payments;
|
||||
|
||||
public class InitiatePaymentTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static InitiatePaymentCommandHandler Initiate(PaymentsTestHost host)
|
||||
=> new(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now));
|
||||
|
||||
[Fact]
|
||||
public async Task Initiate_creates_a_pending_transaction_with_the_frozen_gross_and_a_reference()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var variantId = host.AddVariant(sessionCount: 2, price: 5_000_000); // gross = 10_000_000
|
||||
var requestId = host.AddAcceptedRequest(variantId);
|
||||
|
||||
var result = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(string.IsNullOrEmpty(result.Result.RedirectUrl));
|
||||
|
||||
var txn = host.Db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.Id == result.Result.TransactionId);
|
||||
Assert.Equal(PaymentTransactionStatus.Pending, txn.Status);
|
||||
Assert.Equal(10_000_000, txn.Amount);
|
||||
Assert.Null(txn.BookingId);
|
||||
Assert.Equal(result.Result.GatewayReferenceCode, txn.GatewayReferenceCode);
|
||||
|
||||
// No ledger rows and no booking yet.
|
||||
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initiate_after_capture_is_a_conflict()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var variantId = host.AddVariant(sessionCount: 1, price: 5_000_000);
|
||||
var requestId = host.AddAcceptedRequest(variantId);
|
||||
|
||||
var first = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
|
||||
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
||||
await confirm.Handle(new ConfirmPaymentAndPostLedgerCommand(first.Result.TransactionId), CancellationToken.None);
|
||||
|
||||
// A repeat initiate for an already-paid booking is a 409, not a second attempt.
|
||||
var again = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, "fresh-key"), CancellationToken.None);
|
||||
Assert.False(again.IsSuccess);
|
||||
Assert.True(again.IsConflict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Baya.Domain.Entities.Payments;
|
||||
|
||||
namespace Baya.Test.Foundation.Payments;
|
||||
|
||||
public class LedgerPostingTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 6, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void CardCapture_posts_three_legs_that_balance()
|
||||
{
|
||||
var legs = LedgerPosting.CardCapture(
|
||||
bookingId: 42, nurseId: 7, grossIrr: 23_300_000, commissionIrr: 3_495_000, payoutIrr: 19_805_000,
|
||||
paymentTransactionId: 100, createdAt: Now);
|
||||
|
||||
Assert.Equal(3, legs.Count);
|
||||
|
||||
var debits = legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr);
|
||||
var credits = legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr);
|
||||
Assert.Equal(debits, credits);
|
||||
Assert.Equal(23_300_000, debits);
|
||||
|
||||
var escrow = legs.Single(l => l.AccountType == LedgerAccountType.EscrowHeld);
|
||||
Assert.Equal(LedgerDirection.Debit, escrow.Direction);
|
||||
Assert.Equal(23_300_000, escrow.AmountIrr);
|
||||
|
||||
var revenue = legs.Single(l => l.AccountType == LedgerAccountType.PlatformRevenue);
|
||||
Assert.Equal(LedgerDirection.Credit, revenue.Direction);
|
||||
Assert.Equal(3_495_000, revenue.AmountIrr);
|
||||
|
||||
var payable = legs.Single(l => l.AccountType == LedgerAccountType.NursePayable);
|
||||
Assert.Equal(LedgerDirection.Credit, payable.Direction);
|
||||
Assert.Equal(19_805_000, payable.AmountIrr);
|
||||
Assert.Equal(7, payable.NurseId);
|
||||
|
||||
// One shared group; amounts positive; sign carried by direction.
|
||||
Assert.Single(legs.Select(l => l.TransactionGroupId).Distinct());
|
||||
Assert.All(legs, l => Assert.True(l.AmountIrr > 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CardCapture_throws_when_amounts_do_not_reconcile()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => LedgerPosting.CardCapture(
|
||||
bookingId: 1, nurseId: 1, grossIrr: 100, commissionIrr: 10, payoutIrr: 80, // 10 + 80 != 100
|
||||
paymentTransactionId: 1, createdAt: Now));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
using Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
using Baya.Application.Features.Payments.Queries.GetNursePayableBalance;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
|
||||
namespace Baya.Test.Foundation.Payments;
|
||||
|
||||
public class NursePayableBalanceTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task Payable_balance_equals_the_signed_nurse_payable_ledger_sum()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var variantId = host.AddVariant(sessionCount: 1, price: 23_300_000);
|
||||
var requestId = host.AddAcceptedRequest(variantId);
|
||||
|
||||
var initiate = new InitiatePaymentCommandHandler(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now));
|
||||
var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
|
||||
|
||||
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
||||
await confirm.Handle(new ConfirmPaymentAndPostLedgerCommand(init.Result.TransactionId), CancellationToken.None);
|
||||
|
||||
var query = new GetNursePayableBalanceQueryHandler(host.AsNurse(), host.UnitOfWork);
|
||||
var result = await query.Handle(new GetNursePayableBalanceQuery(host.NurseId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("19805000", result.Result.BalanceIrr);
|
||||
|
||||
// A debit (e.g. a later payout) reduces the derived balance — never a stored column.
|
||||
host.Db.Set<LedgerEntry>().Add(new LedgerEntry
|
||||
{
|
||||
TransactionGroupId = Guid.NewGuid(), AccountType = LedgerAccountType.NursePayable, NurseId = host.NurseId,
|
||||
Direction = LedgerDirection.Debit, AmountIrr = 5_000_000, SourceRefType = LedgerSourceRefType.NursePayout,
|
||||
SourceRefId = 1, CreatedAt = Now.UtcDateTime
|
||||
});
|
||||
host.Db.SaveChanges();
|
||||
|
||||
var after = await query.Handle(new GetNursePayableBalanceQuery(host.NurseId), CancellationToken.None);
|
||||
Assert.Equal((19_805_000 - 5_000_000).ToString(CultureInfo.InvariantCulture), after.Result.BalanceIrr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_owner_nurse_is_forbidden()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var query = new GetNursePayableBalanceQueryHandler(host.AsCustomer(), host.UnitOfWork); // a customer, not the nurse
|
||||
var result = await query.Handle(new GetNursePayableBalanceQuery(host.NurseId), CancellationToken.None);
|
||||
Assert.True(result.IsForbidden);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
using Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Test.Foundation.Payments;
|
||||
|
||||
public class PaymentConfirmTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// §7 worked example: gross 23_300_000, commission 3_495_000 (×0.15), payout 19_805_000.
|
||||
private const long Price = 23_300_000;
|
||||
|
||||
private static InitiatePaymentCommandHandler Initiate(PaymentsTestHost host)
|
||||
=> new(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now));
|
||||
|
||||
private static ConfirmPaymentAndPostLedgerCommandHandler Confirm(PaymentsTestHost host)
|
||||
=> new(host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
||||
|
||||
private static async Task<long> InitiatePendingAsync(PaymentsTestHost host, long requestId)
|
||||
{
|
||||
var result = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
|
||||
Assert.True(result.IsSuccess);
|
||||
return result.Result.TransactionId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_posts_balanced_card_capture_group_and_confirms_booking()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var variantId = host.AddVariant(sessionCount: 1, price: Price);
|
||||
var requestId = host.AddAcceptedRequest(variantId);
|
||||
var txnId = await InitiatePendingAsync(host, requestId);
|
||||
|
||||
var result = await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(txnId), CancellationToken.None);
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
// Transaction captured + bound to a booking.
|
||||
var txn = host.Db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.Id == txnId);
|
||||
Assert.Equal(PaymentTransactionStatus.Succeeded, txn.Status);
|
||||
Assert.NotNull(txn.BookingId);
|
||||
|
||||
// Booking created + confirmed.
|
||||
var booking = host.Db.Set<BookingEntity>().AsNoTracking().Single();
|
||||
Assert.Equal(BookingStatus.Confirmed, booking.Status);
|
||||
|
||||
// Exactly one balanced card-capture group with the three correct legs.
|
||||
var legs = host.Db.Set<LedgerEntry>().AsNoTracking()
|
||||
.Where(l => l.SourceRefType == LedgerSourceRefType.PaymentTransaction && l.SourceRefId == txnId)
|
||||
.ToList();
|
||||
Assert.Equal(3, legs.Count);
|
||||
Assert.Single(legs.Select(l => l.TransactionGroupId).Distinct());
|
||||
|
||||
var debits = legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr);
|
||||
var credits = legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr);
|
||||
Assert.Equal(debits, credits);
|
||||
Assert.Equal(Price, legs.Single(l => l.AccountType == LedgerAccountType.EscrowHeld).AmountIrr);
|
||||
Assert.Equal(3_495_000, legs.Single(l => l.AccountType == LedgerAccountType.PlatformRevenue).AmountIrr);
|
||||
|
||||
var payable = legs.Single(l => l.AccountType == LedgerAccountType.NursePayable);
|
||||
Assert.Equal(19_805_000, payable.AmountIrr);
|
||||
Assert.Equal(host.NurseId, payable.NurseId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_is_idempotent_no_second_ledger_group()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var variantId = host.AddVariant(sessionCount: 1, price: Price);
|
||||
var requestId = host.AddAcceptedRequest(variantId);
|
||||
var txnId = await InitiatePendingAsync(host, requestId);
|
||||
|
||||
await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(txnId), CancellationToken.None);
|
||||
var second = await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(txnId), CancellationToken.None);
|
||||
|
||||
Assert.True(second.IsSuccess);
|
||||
Assert.Equal(3, host.Db.Set<LedgerEntry>().AsNoTracking().Count());
|
||||
Assert.Equal(1, host.Db.Set<BookingEntity>().AsNoTracking().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_succeeded_transaction_for_a_booking_is_blocked_by_the_filtered_unique()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var variantId = host.AddVariant(sessionCount: 1, price: Price);
|
||||
var requestId = host.AddAcceptedRequest(variantId);
|
||||
|
||||
var firstTxnId = await InitiatePendingAsync(host, requestId);
|
||||
await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(firstTxnId), CancellationToken.None);
|
||||
|
||||
// A second, distinct pending attempt for the same (now-captured) booking.
|
||||
var secondTxn = new PaymentTransaction
|
||||
{
|
||||
BookingRequestId = requestId, CustomerId = host.CustomerId, GatewayId = host.GatewayId,
|
||||
Amount = Price, Currency = "IRR", GatewayReferenceCode = "second-attempt-ref"
|
||||
};
|
||||
host.Db.Set<PaymentTransaction>().Add(secondTxn);
|
||||
host.Db.SaveChanges();
|
||||
|
||||
// Confirming it must not create a second capture — the filtered UNIQUE(booking_id) WHERE succeeded
|
||||
// backstops it into an idempotent no-op success.
|
||||
var result = await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(secondTxn.Id), CancellationToken.None);
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
// Still exactly one capture group; the second attempt was rolled back (not succeeded).
|
||||
Assert.Equal(3, host.Db.Set<LedgerEntry>().AsNoTracking().Count());
|
||||
var reloaded = host.Db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.Id == secondTxn.Id);
|
||||
Assert.NotEqual(PaymentTransactionStatus.Succeeded, reloaded.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger;
|
||||
using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
||||
using Baya.Application.Features.Payments.Commands.InitiatePayment;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Test.Foundation.Payments;
|
||||
|
||||
public class PaymentWebhookTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
|
||||
private const long Price = 23_300_000;
|
||||
|
||||
private static ISender SenderRoutingConfirmTo(ConfirmPaymentAndPostLedgerCommandHandler confirm)
|
||||
{
|
||||
var sender = Substitute.For<ISender>();
|
||||
sender.Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => confirm.Handle(ci.Arg<ConfirmPaymentAndPostLedgerCommand>(), ci.Arg<CancellationToken>()));
|
||||
return sender;
|
||||
}
|
||||
|
||||
private static async Task<(long RequestId, string Reference)> SeedPendingAsync(PaymentsTestHost host)
|
||||
{
|
||||
var variantId = host.AddVariant(sessionCount: 1, price: Price);
|
||||
var requestId = host.AddAcceptedRequest(variantId);
|
||||
var initiate = new InitiatePaymentCommandHandler(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now));
|
||||
var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
|
||||
return (requestId, init.Result.GatewayReferenceCode);
|
||||
}
|
||||
|
||||
private static string SuccessBody(string reference, string eventId)
|
||||
=> $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";
|
||||
|
||||
[Fact]
|
||||
public async Task Webhook_success_confirms_the_booking_and_posts_one_balanced_group()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var (_, reference) = await SeedPendingAsync(host);
|
||||
|
||||
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
||||
var handler = new HandlePaymentWebhookCommandHandler(
|
||||
SenderRoutingConfirmTo(confirm), host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
|
||||
|
||||
var result = await handler.Handle(
|
||||
new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), SuccessBody(reference, "evt-1")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus);
|
||||
Assert.False(result.Result.Duplicate);
|
||||
|
||||
Assert.Equal(BookingStatus.Confirmed, host.Db.Set<BookingEntity>().AsNoTracking().Single().Status);
|
||||
Assert.Equal(3, host.Db.Set<LedgerEntry>().AsNoTracking().Count());
|
||||
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replayed_webhook_event_is_a_no_op()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var (_, reference) = await SeedPendingAsync(host);
|
||||
|
||||
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
||||
var sender = SenderRoutingConfirmTo(confirm);
|
||||
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
|
||||
|
||||
var body = SuccessBody(reference, "evt-1");
|
||||
await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
|
||||
var replay = await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
|
||||
|
||||
Assert.True(replay.IsSuccess);
|
||||
Assert.True(replay.Result.Duplicate);
|
||||
|
||||
// No second confirm, no second ledger group, one webhook event row.
|
||||
await sender.Received(1).Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
|
||||
Assert.Equal(3, host.Db.Set<LedgerEntry>().AsNoTracking().Count());
|
||||
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unverified_signature_callback_mutates_nothing()
|
||||
{
|
||||
using var host = new PaymentsTestHost();
|
||||
var (_, reference) = await SeedPendingAsync(host);
|
||||
|
||||
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
|
||||
var sender = SenderRoutingConfirmTo(confirm);
|
||||
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
|
||||
|
||||
// The default MockWebhookVerifier marks a body carrying the invalid-signature marker as invalid.
|
||||
var body = $"{{\"external_event_id\":\"evt-x\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\",\"note\":\"INVALID_SIGNATURE\"}}";
|
||||
var result = await handler.Handle(
|
||||
new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(WebhookProcessingStatus.Ignored, result.Result.ProcessingStatus);
|
||||
|
||||
await sender.DidNotReceive().Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
|
||||
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
|
||||
Assert.Empty(host.Db.Set<BookingEntity>().AsNoTracking());
|
||||
var txn = host.Db.Set<PaymentTransaction>().AsNoTracking().Single();
|
||||
Assert.Equal(PaymentTransactionStatus.Pending, txn.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Payments;
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained SQLite host exercising the real EF model (the two filtered uniques, the append-only
|
||||
/// ledger, the webhook idempotency unique) for the b10 money core. Seeds one bookable nurse + one customer
|
||||
/// with a geocoded address, an active standard gateway, and lets a test create an accepted request and drive
|
||||
/// the real payment handlers against the real <see cref="UnitOfWork"/> with faithful money-path seams.
|
||||
/// </summary>
|
||||
public sealed class PaymentsTestHost : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
public ApplicationDbContext Db { get; }
|
||||
public UnitOfWork UnitOfWork { get; }
|
||||
|
||||
public long CustomerId { get; }
|
||||
public int CustomerUserId { get; }
|
||||
public long NurseId { get; }
|
||||
public int NurseUserId { get; }
|
||||
public long PatientId { get; }
|
||||
public long AddressId { get; }
|
||||
public long CategoryId { get; }
|
||||
public long GatewayId { get; }
|
||||
|
||||
public PaymentsTestHost()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
|
||||
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
Db.Database.EnsureCreated();
|
||||
UnitOfWork = new UnitOfWork(Db);
|
||||
|
||||
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<Province>().Add(province);
|
||||
Db.SaveChanges();
|
||||
|
||||
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<City>().Add(city);
|
||||
Db.SaveChanges();
|
||||
|
||||
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
|
||||
Db.Set<ServiceCategory>().Add(category);
|
||||
Db.SaveChanges();
|
||||
CategoryId = category.Id;
|
||||
|
||||
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
|
||||
Db.Users.Add(customerUser);
|
||||
Db.SaveChanges();
|
||||
CustomerUserId = customerUser.Id;
|
||||
|
||||
var customer = new CustomerProfile { UserId = customerUser.Id };
|
||||
Db.Set<CustomerProfile>().Add(customer);
|
||||
Db.SaveChanges();
|
||||
CustomerId = customer.Id;
|
||||
|
||||
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
|
||||
Db.Set<Patient>().Add(patient);
|
||||
Db.SaveChanges();
|
||||
PatientId = patient.Id;
|
||||
|
||||
var address = new CustomerAddress
|
||||
{
|
||||
CustomerId = customer.Id, CityId = city.Id, Title = "خانه",
|
||||
AddressLine = "خیابان اول", PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001",
|
||||
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
|
||||
};
|
||||
Db.Set<CustomerAddress>().Add(address);
|
||||
Db.SaveChanges();
|
||||
AddressId = address.Id;
|
||||
|
||||
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
|
||||
Db.Users.Add(nurseUser);
|
||||
Db.SaveChanges();
|
||||
NurseUserId = nurseUser.Id;
|
||||
|
||||
var nurse = new NurseProfile { UserId = nurseUser.Id };
|
||||
nurse.MarkVerified();
|
||||
nurse.SetAcceptingBookings(true);
|
||||
Db.Set<NurseProfile>().Add(nurse);
|
||||
Db.SaveChanges();
|
||||
NurseId = nurse.Id;
|
||||
|
||||
var gateway = new PaymentGateway
|
||||
{
|
||||
ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "ZarinPal",
|
||||
ConfigJson = "{\"merchantId\":\"test\",\"sandbox\":true}", IsActive = true, Priority = 0
|
||||
};
|
||||
Db.Set<PaymentGateway>().Add(gateway);
|
||||
Db.SaveChanges();
|
||||
GatewayId = gateway.Id;
|
||||
}
|
||||
|
||||
public long AddVariant(int? sessionCount, long price)
|
||||
{
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = NurseId, ServiceCategoryId = CategoryId, Price = price, PriceUnit = "per_day",
|
||||
SessionCount = sessionCount, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
|
||||
};
|
||||
Db.Set<NurseServiceVariant>().Add(variant);
|
||||
Db.SaveChanges();
|
||||
return variant.Id;
|
||||
}
|
||||
|
||||
public long AddAcceptedRequest(long variantId)
|
||||
{
|
||||
var request = new BookingRequest
|
||||
{
|
||||
CustomerId = CustomerId, NurseId = NurseId, PatientId = PatientId, VariantId = variantId,
|
||||
CustomerAddressId = AddressId, RequiredCaregiverGender = CaregiverGender.Any,
|
||||
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
|
||||
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
Db.Set<BookingRequest>().Add(request);
|
||||
Db.SaveChanges();
|
||||
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Db.SaveChanges();
|
||||
return request.Id;
|
||||
}
|
||||
|
||||
// ---- seams ----
|
||||
public IVariantSnapshotSerializer Serializer { get; } = new VariantSnapshotSerializer();
|
||||
public IPaymentProvider PaymentProvider { get; } = new MockPaymentProvider();
|
||||
public ISettlementSplitProvider Settlement { get; } = new MockSettlementSplitProvider();
|
||||
public IDistributedLock Lock { get; } = new InProcessDistributedLock();
|
||||
public IWebhookVerifier Verifier { get; } = new MockWebhookVerifier(Options.Create(new SeamOptions()));
|
||||
|
||||
public ICurrentUser AsCustomer()
|
||||
{
|
||||
var u = Substitute.For<ICurrentUser>();
|
||||
u.UserId.Returns(CustomerUserId);
|
||||
u.Roles.Returns(new[] { RoleNames.Customer });
|
||||
return u;
|
||||
}
|
||||
|
||||
public ICurrentUser AsNurse()
|
||||
{
|
||||
var u = Substitute.For<ICurrentUser>();
|
||||
u.UserId.Returns(NurseUserId);
|
||||
u.Roles.Returns(new[] { RoleNames.Nurse });
|
||||
return u;
|
||||
}
|
||||
|
||||
public IDateTimeProvider Clock(DateTimeOffset now)
|
||||
{
|
||||
var c = Substitute.For<IDateTimeProvider>();
|
||||
c.UtcNow.Returns(now);
|
||||
return c;
|
||||
}
|
||||
|
||||
public IPlatformConfig Config(decimal feeRate = 0.15m)
|
||||
{
|
||||
var cfg = Substitute.For<IPlatformConfig>();
|
||||
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(feeRate);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
public INotificationDispatcher Notifications() => Substitute.For<INotificationDispatcher>();
|
||||
|
||||
public string StatusOfBooking(long bookingId)
|
||||
=> Db.Set<Baya.Domain.Entities.Booking.Booking>().AsNoTracking().Where(b => b.Id == bookingId).Select(b => b.Status).Single();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user