A partner integration request landed for programmatic merchant-hierarchy management (Company/Project/Store/PaymentPoint). Built the answer generically: partner-specific behaviour is a PartnerProfile config row, and no partner name appears in any entity, field, endpoint or status value. New: - docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md - hierarchy, idempotency, node-scoped public-key credentials, TEST/LIVE partition, routing context - docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md Amended, because the schema impact must land before Phase 1 is implemented: - Phase 1 gains RoutingContext on CheckoutSession/PaymentIntent/Payment, frozen at checkout-session creation and immutable after - Phase 7 gains routing on Refund/ReconciliationRecord, plus the rule that seller settlement splits happen after routing, never as a hierarchy level - Phase 9 gains Company/Project above Marketplace and PaymentPoint below it, with a backfill sequence for existing marketplaces - Track S gains partner credentials: public key only, node-scoped authority, rotation with overlap, immediate revoke, audit coverage Also: Track P (P1-P10) in the delivery plan, and backend ownership closed as answered across the contract set. Card payment was checked, not added - qr and card both already ship in cart.component.ts with separate create paths and status pollers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
Phase 1 Backend Contract — Money, FX, Price Snapshot, Payment State Machine
Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Phase 1 (Sprints 1.1–1.4) and PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md §3.3/§3.5/§3.6.
Status: unblocked (2026-08-17). BACKEND-API-REFERENCE.md §7 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.amounttoPOST /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 raised it first.
2. Money representation
All money fields in every new endpoint below use minor units, never float.
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
{
"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_UNAVAILABLEwitherror.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.
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)
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
POST /api/v2/storefront/checkout
{
"offers": [{ "offerId": "off_9a1", "qty": 2 }],
"currency": "USD",
"deliveryOptionId": "del_standard"
}
{
"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
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 — samePaymentEventrow, no second order, no second notification. - On success, emit
payment.confirmed/payment.failedonto 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.
6.5 Routing context
Added 2026-08-18. Full definition in PARTNER-PROVISIONING-API-CONTRACT.md §7.
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:
- Resolved and frozen at checkout-session creation. Immutable for the life of the payment. Later node status changes never rewrite it.
routingPathmust resolve to exactly one leaf. Ambiguous or unresolvable → reject at creation. Never accept a payment and resolve routing during reconciliation.- A payment whose leaf node is
suspendedordisabledis rejected at creation. merchantReferenceis stored verbatim, never parsed, never normalized.environmentmust match the credential's environment. Mismatch is403.
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'slocalStorage-persisted admin-typed rates and hardcodedDEFAULT_RATESfallback (USD: 0.011,AMD: 4.3). - Delete the Admin Settings currency-rate editor UI.
- Stop sending
amount/pricein any checkout-related request. - Replace client-side float conversion (
CurrencyRatesService.convert()) with server-suppliedMoneyvalues everywhere a price is displayed.
8. What the frontend will start doing
- Fetch
GET /api/v2/pricing/fx-quoteon 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_UNAVAILABLEanderror.code-driven stale-quote UI per the error envelope inBACKEND-API-REFERENCE.md §5.
9. Resolved / open questions (Sprint 0.1, 2026-08-17)
- Payment chain freeze — lifted. §5 can proceed.
- 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
sourcefield 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 ofsource. - 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
currencyand the backend returning the converted total. Confirm this is the intended flow before backend implementation starts. - Backend ownership — answered 2026-08-18. A separate backend developer implements against this contract. Note §6.5:
RoutingContextmust be carried from the first payment row, not retrofitted.