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>
This commit is contained in:
228
docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md
Normal file
228
docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 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: 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"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: <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.
|
||||
Reference in New Issue
Block a user