diff --git a/BACKEND-API-REFERENCE.md b/BACKEND-API-REFERENCE.md index 4d6e074..bd71f72 100644 --- a/BACKEND-API-REFERENCE.md +++ b/BACKEND-API-REFERENCE.md @@ -243,7 +243,7 @@ No cursor/keyset pagination exists anywhere. No server-side page-size cap is enf ## 5. Error model -**The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) derives its error code from **HTTP status only**, ignoring any body field, which is itself a known bug (see below). Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over. +**The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) now reads `error.error.code` from the body when present (`authErrorCodeFromBackendCode()`), falling back to HTTP status only when no body code is sent. Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over. ### The envelope @@ -281,8 +281,8 @@ No cursor/keyset pagination exists anywhere. No server-side page-size cap is enf | 503 (infra down) | `SERVICE_UNAVAILABLE` | Same "backend unavailable, retry" screen as 500, on the Ed25519 flow only. | | 503 (maintenance) | `MAINTENANCE_MODE` (+`maintenanceUntil`) | **No maintenance-mode concept exists in the frontend at all today.** Same HTTP status as infra-down 503 — `error.code` is the only way to distinguish them. | | 403 (tenant disabled) | `TENANT_DISABLED` | **No handling exists.** No code path today distinguishes "tenant exists but is disabled" from any other 403. | -| 401 (token expired) | `TOKEN_EXPIRED` | **Known bug, not just a gap:** the client has a dedicated "Session expired" screen wired and ready, but `toAuthErrorShape()` only reaches it via a no-refresh-token-present client-side branch — a *real* backend 401 on `/refresh` always renders the generic "Unauthorized" screen instead, because the mapping function ignores any body code and derives purely from HTTP status. Fix requires the backend to send `error.code: "TOKEN_EXPIRED"` **and** a small frontend change to prefer it. | -| 401 (bad signature) | `INVALID_SIGNATURE` | Same bug class as above — dedicated screen exists, unreachable from a real HTTP response for the identical reason. | +| 401 (token expired) | `TOKEN_EXPIRED` | **Fixed** — `toAuthErrorShape()` (`core/auth/services/auth.service.ts`) now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. A backend 401 on `/refresh` sending `error.code: "TOKEN_EXPIRED"` reaches the dedicated "Session expired" screen. | +| 401 (bad signature) | `INVALID_SIGNATURE` | **Fixed**, same mechanism — reaches the dedicated screen when the backend sends `error.code: "INVALID_SIGNATURE"`. | **Every admin backoffice list page** (Users/Orders/Monitoring/Moderation/Transactions/Products/Categories/Analytics/Customers/Dashboard) shares one generic pattern: a boolean `error` signal → "Something went wrong" + retry button. None of them branch on status or `code` today — every status above collapses into the same generic UI until facades are individually updated. diff --git a/docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md b/docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md new file mode 100644 index 0000000..85e384b --- /dev/null +++ b/docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md @@ -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: +``` + +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. diff --git a/src/app/models/category.model.ts b/src/app/models/category.model.ts index 43ede8e..a531a05 100644 --- a/src/app/models/category.model.ts +++ b/src/app/models/category.model.ts @@ -1,6 +1,11 @@ import { ItemName } from './item.model'; -export interface Category { +/** + * Storefront-side raw category shape (item.service.ts/api.service.ts responses). + * Unrelated to core/categories/models/category-domain.model.ts's Category, which is + * the normalized admin-backoffice domain shape produced by CategoryMapper. + */ +export interface CategoryApiModel { categoryID: number; name: string; parentID: number; diff --git a/src/app/services/api.service.ts b/src/app/services/api.service.ts index 48da298..4b5b77c 100644 --- a/src/app/services/api.service.ts +++ b/src/app/services/api.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Observable, timer } from 'rxjs'; import { map, retry } from 'rxjs/operators'; -import { Category, DeliveryOption, Item, Subcategory } from '../models'; +import { CategoryApiModel, DeliveryOption, Item, Subcategory } from '../models'; import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils'; import { environment } from '../../environments/environment'; import { ApiConfigService } from '../core/config/api-config.service'; @@ -199,7 +199,7 @@ export class ApiService { || (subcategory.subcategories?.length ?? 0) > 0; } - private isDisplayableCategory(category: Category): boolean { + private isDisplayableCategory(category: CategoryApiModel): boolean { return category.visible !== false; } @@ -462,8 +462,8 @@ export class ApiService { * Normalize a category from the API response — supports both * the flat legacy format and nested backOffice format. */ - private normalizeCategory(raw: any): Category { - const cat: Category = { ...raw }; + private normalizeCategory(raw: any): CategoryApiModel { + const cat: CategoryApiModel = { ...raw }; if (raw.id != null && raw.categoryID == null) { cat.id = String(raw.id); @@ -522,7 +522,7 @@ export class ApiService { return cat; } - private normalizeCategories(cats: any[] | null | undefined): Category[] { + private normalizeCategories(cats: any[] | null | undefined): CategoryApiModel[] { if (!cats || !Array.isArray(cats)) return []; return cats .map(c => this.normalizeCategory(c)) @@ -535,7 +535,7 @@ export class ApiService { return this.http.get<{ message: string }>(`${this.baseUrl}/ping`); } - getCategories(): Observable { + getCategories(): Observable { return this.http.get(`${this.baseUrl}/category`) .pipe(retry(this.retryConfig), map(cats => this.normalizeCategories(cats))); } diff --git a/src/app/utils/item.utils.ts b/src/app/utils/item.utils.ts index 256f91c..7715bfe 100644 --- a/src/app/utils/item.utils.ts +++ b/src/app/utils/item.utils.ts @@ -1,5 +1,4 @@ import { Item } from '../models'; -import { Category } from '../models/category.model'; export function getDiscountedPrice(item: Item): number { return item.price * (1 - (item.discount || 0) / 100);