Files
marketplaces/docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md
sdarbinyan ffaa6d2a1c refactor: rename storefront CategoryApiModel; correct stale auth-error doc; add Phase 1 backend contract
- models/category.model.ts: Category -> CategoryApiModel, disambiguated
  from core/categories/models/category-domain.model.ts's Category (admin
  domain shape). Removes a dead unused import in item.utils.ts along the
  way. Only live consumer was services/api.service.ts, updated in place.
- BACKEND-API-REFERENCE.md §5: corrected two rows documenting the
  TOKEN_EXPIRED/INVALID_SIGNATURE auth-error bug as still open - the fix
  (reading error.error.code before falling back to HTTP status) is
  already in auth.service.ts. Doc was stale, not the code.
- Sprint 0.2 audit: AdminRole duplication and the
  PRODUCT_DATA_PROVIDER/CATEGORY_REPOSITORY dead mock branches were
  already resolved in a prior pass - verified, no code change needed.
- docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md: new wire contract
  for Money/FxQuote/PriceSnapshot/payment state machine, so backend can
  start Phase 1 the moment the frozen payment chain is unblocked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:31:11 +04:00

229 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 1 Backend Contract — Money, FX, Price Snapshot, Payment State Machine
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 1 (Sprints 1.11.4) and [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](../PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md) §3.3/§3.5/§3.6.
**Status: blocked.** [BACKEND-API-REFERENCE.md §7](../../BACKEND-API-REFERENCE.md) marks the cart/payment call chain "frozen — explicitly out of scope for changes." This document specifies the target contract so backend work can start the moment that freeze lifts or is scoped around; it does not imply the freeze has been lifted. See open decision in the delivery plan's Sprint 0.1.
This doc is the frontend's ask, in the same style as `BACKEND-API-REFERENCE.md`. It does not prescribe backend implementation (DB schema, service boundaries) — only the wire contract and the invariants the frontend needs to hold.
---
## 1. Why this exists
Current behaviour (`services/currency-rates.service.ts`, `pages/cart/cart.component.ts`):
- Currency conversion rates are typed by an admin into Admin Settings and persisted to browser `localStorage`. They never update and drift from market.
- The amount charged is computed **client-side** and sent as `CartPaymentRequest.amount` to `POST /cart`. The backend currently trusts this number.
- No record exists anywhere of which FX rate produced a given displayed price, or when it was captured.
Result: bank/NSPK settlement totals don't reconcile against order counts, because nothing on the backend can reconstruct *why* a given amount was charged. This document's contract exists to close that gap — it is the same complaint as Product Plan v3.1 §3.3/§3.8, and our own [§12.7](../../BACKEND-API-REFERENCE.md) raised it first.
---
## 2. Money representation
All money fields in every new endpoint below use minor units, never float.
```ts
interface Money {
amountMinor: number; // integer, no float. 4990 = 49.90 for a 2-decimal currency.
currency: string; // ISO 4217, e.g. "RUB" | "USD" | "EUR" | "AMD"
}
```
| Currency | Minor unit | Decimals |
|---|---|---|
| RUB | kopeck | 2 |
| USD | cent | 2 |
| EUR | cent | 2 |
| AMD | luma | 2 |
Rounding rule for any conversion: round half up to the currency's minor-unit precision, applied once, at the point of conversion — never re-rounded on redisplay.
---
## 3. FX Quote
### 3.1 Endpoint
```
GET /api/v2/pricing/fx-quote?base=RUB&quote=USD
```
```json
{
"quoteId": "fxq_8a3f1c2a",
"base": "RUB",
"quote": "USD",
"rate": 0.0108,
"source": "rapira",
"observedAt": "2026-08-20T09:14:00Z",
"expiresAt": "2026-08-20T09:19:00Z"
}
```
| Field | Notes |
|---|---|
| `quoteId` | Opaque, referenced by every `PriceSnapshot` that used this quote. |
| `rate` | `1 base = rate * quote`. Float is acceptable here — it's a market rate, not a money amount. |
| `source` | Adapter name. Frontend never hardcodes a provider; treat as an opaque label for display in the backoffice reconciliation panel. |
| `expiresAt` | TTL, provider-configurable. Frontend must not use an expired quote to display or charge. |
### 3.2 Stale-quote policy
- If the frontend holds a quote past `expiresAt`, it must re-fetch before checkout can proceed.
- If the rate source is unavailable, the backend decides: **block** (`503 SERVICE_UNAVAILABLE` with `error.code: "FX_SOURCE_UNAVAILABLE"`) or serve a configured fallback quote explicitly marked `"source": "fallback"`. Which policy applies is a tenant setting, not a frontend choice — see delivery-plan Sprint 0.1 decision on FX source.
- Outlier detection (e.g. a quote >X% off the previous one) is a backend concern; the frontend has no opinion on the threshold, only on obeying `expiresAt`.
---
## 4. PriceSnapshot
Created once, at checkout, immutable afterward. This is what makes a total explainable months later.
```ts
interface PriceSnapshot {
id: string;
offerId: string;
amount: Money; // price in the offer's base currency
displayAmount: Money; // price in the currency the customer checked out in
fxQuoteId: string | null; // null when displayAmount.currency === amount.currency
capturedAt: string; // ISO 8601
}
```
Rule: once a `PriceSnapshot` exists on an order line, it is never recalculated — not on rate update, not on currency-setting change, not on replay. An old order shows the price it was actually charged at.
---
## 5. Server-authoritative checkout amount
This is the contract change with the highest priority in Phase 1 — it removes the client-trusted `amount` field entirely.
### 5.1 Current (to be replaced)
```http
POST /cart
{ "amount": 4990, "currency": "RUB", "items": [{ "itemID": 101, "price": 4990, ... }], ... }
```
The backend trusts `amount` and each line's `price` as sent by the browser.
### 5.2 Target
```http
POST /api/v2/storefront/checkout
{
"offers": [{ "offerId": "off_9a1", "qty": 2 }],
"currency": "USD",
"deliveryOptionId": "del_standard"
}
```
```json
{
"checkoutSessionId": "chk_7f2e",
"lines": [
{
"offerId": "off_9a1",
"qty": 2,
"unitPrice": { "amountMinor": 5390, "currency": "USD" },
"lineTotal": { "amountMinor": 10780, "currency": "USD" },
"priceSnapshotId": "snap_3b1c"
}
],
"subtotal": { "amountMinor": 10780, "currency": "USD" },
"discount": { "amountMinor": 0, "currency": "USD" },
"delivery": { "amountMinor": 500, "currency": "USD" },
"total": { "amountMinor": 11280, "currency": "USD" },
"fxQuoteId": "fxq_8a3f1c2a",
"expiresAt": "2026-08-20T09:19:00Z"
}
```
**The frontend sends offer IDs and quantities. The backend computes every price, using the offer's live price and the current FX quote. No `amount` or `price` field is ever accepted from the client for anything that affects the charge.**
`POST /api/v2/storefront/payments/intents` then references `checkoutSessionId` only — the amount charged is read server-side from the checkout session, never re-sent by the client.
### 5.3 Total formula (must be reconstructable, per line)
```
order.total = sum(line.unitPrice * line.qty)
- discounts
+ delivery
+ taxes/fees (if applicable)
```
Backoffice must be able to render this formula, with the FX quote used, for any order — this is what Product Plan §7.2 asks for and what a bank reconciliation needs.
---
## 6. Payment state machine
### 6.1 States
```
PaymentIntent: created -> pending -> authorized/paid -> failed/cancelled
Payment: received -> confirmed -> captured/settled -> refunded/partially_refunded
Order: pending_payment -> paid -> processing -> fulfilled/completed
```
### 6.2 Required fields per transition
```ts
interface PaymentEvent {
id: string;
paymentIntentId: string;
fromState: string;
toState: string;
providerEventId: string; // idempotency key from the provider
providerTimestamp: string; // when the provider says it happened
receivedAt: string; // when our webhook received it
processedAt: string; // when our system finished processing it
}
```
No fixed delays anywhere in this chain. The frontend already complies with this (polls real provider status via `/qr/dynamic/{partnerId}/{qrId}` and `/card/{partnerId}/{orderId}` on an interval bounded by QR TTL) — this section documents the backend side of the same principle.
### 6.3 Webhook contract
```
POST /api/providers/v1/payments/{provider}/webhook
```
- Signature verification is mandatory; reject unsigned/invalid-signature payloads with `401`, do not silently accept.
- Idempotency key = `provider + providerEventId`. A repeated delivery of the same event must be a no-op — same `PaymentEvent` row, no second order, no second notification.
- On success, emit `payment.confirmed` / `payment.failed` onto the platform event bus (Phase 2) so Order creation is driven by the event, not by the webhook handler doing double duty.
### 6.4 Idempotent order creation
```
POST /api/admin/v2/orders (internal, from the payment-confirmation handler)
Idempotency-Key: <checkoutSessionId>
```
A retried call with the same `checkoutSessionId` must return the existing order, not create a second one. This is the mechanism that makes "double-click doesn't create two orders" true regardless of frontend debouncing.
---
## 7. What the frontend will stop doing once this ships
- Delete `CurrencyRatesService`'s `localStorage`-persisted admin-typed rates and hardcoded `DEFAULT_RATES` fallback (`USD: 0.011`, `AMD: 4.3`).
- Delete the Admin Settings currency-rate editor UI.
- Stop sending `amount` / `price` in any checkout-related request.
- Replace client-side float conversion (`CurrencyRatesService.convert()`) with server-supplied `Money` values everywhere a price is displayed.
## 8. What the frontend will start doing
- Fetch `GET /api/v2/pricing/fx-quote` on currency switch; block checkout if the held quote has expired.
- Render the backoffice "total formula" panel (lines × qty discounts + delivery + fees, FX quote used) once §5.2 and the admin Orders API exist (Phase 2).
- Surface `FX_SOURCE_UNAVAILABLE` and `error.code`-driven stale-quote UI per the error envelope in `BACKEND-API-REFERENCE.md §5`.
---
## 9. Open questions (mirrors delivery-plan Sprint 0.1)
1. Payment chain freeze — must be lifted or explicitly scoped around before §5 can ship.
2. FX rate source/provider — not named yet; `source` field above is provider-agnostic pending that answer.
3. Does the backend return already-converted prices, or does the frontend request a specific display currency at checkout time (as modeled in §5.2)? This doc assumes the latter; confirm before implementation.