backend phase 10
This commit is contained in:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user