# 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.1–1.4) and [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](../PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md) §3.3/§3.5/§3.6. **Status: unblocked (2026-08-17).** [BACKEND-API-REFERENCE.md §7](../../BACKEND-API-REFERENCE.md) previously marked the cart/payment call chain frozen. Per the delivery plan's Sprint 0.1 decision, the freeze is lifted — this contract can move to implementation. Backend ownership was answered 2026-08-18 — a separate backend developer builds against it. 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"e=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: ``` 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. ### 6.5 Routing context Added 2026-08-18. Full definition in [PARTNER-PROVISIONING-API-CONTRACT.md §7](PARTNER-PROVISIONING-API-CONTRACT.md). ```ts interface RoutingContext { companyId: string; routingPath: string[]; // ordered node ids, root -> leaf leafNodeId: string; // the payment point money is accepted at environment: 'TEST' | 'LIVE'; merchantReference: string; // partner-supplied, opaque, echoed on every related event providerPaymentId: string; // our payment id, stable, unique } ``` `RoutingContext` is a **required** field on `CheckoutSession`, `PaymentIntent`, and `Payment`. `PaymentEvent` does not carry its own copy — it inherits via `paymentIntentId` — but every event **emitted** to the bus or to a partner must include the resolved context so consumers never need a second lookup. Invariants: 1. Resolved and frozen at checkout-session creation. Immutable for the life of the payment. Later node status changes never rewrite it. 2. `routingPath` must resolve to exactly one leaf. Ambiguous or unresolvable → reject at creation. Never accept a payment and resolve routing during reconciliation. 3. A payment whose leaf node is `suspended` or `disabled` is rejected at creation. 4. `merchantReference` is stored verbatim, never parsed, never normalized. 5. `environment` must match the credential's environment. Mismatch is `403`. **This is why it lands now, not later.** Without it, a payment cannot be attributed to a store, and §5's reconciliation goal — reconstructing why a given amount was charged — stops one level short of who it was charged for. Adding a routing dimension to a populated payments table after launch is materially more expensive than carrying it from the first row. --- ## 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. Resolved / open questions (Sprint 0.1, 2026-08-17) 1. **Payment chain freeze — lifted.** §5 can proceed. 2. **FX rate source/provider — ours, in-house, as the default (not just a fallback).** No external provider committed. Backend computes and serves the quote itself; the `source` field in §3.1 can legitimately read `"internal"` as the normal case. Revisit if an external provider is chosen later — the contract shape doesn't need to change, only the value of `source`. 3. **Backend-converted prices vs. frontend-requested display currency — still open, needs confirmation before implementation.** This doc's §5.2 models the frontend sending a target `currency` and the backend returning the converted total. Confirm this is the intended flow before backend implementation starts. 4. **Backend ownership — answered 2026-08-18.** A separate backend developer implements against this contract. Note §6.5: `RoutingContext` must be carried from the first payment row, not retrofitted.