feat: server-authoritative checkout, no client-computed amount
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

F14-F16 of the frontend backlog. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2.

The highest-priority change in Phase 1: `POST /cart` sent `amount` computed
client-side (this.convertTotal(this.totalWithDelivery())) and the backend was
asked to trust it. Replaced with two calls:

1. POST /api/v2/storefront/checkout - offer ids + qty only. Returns
   checkoutSessionId and the server-computed total.
2. POST /api/v2/storefront/payments/intents - references checkoutSessionId
   only. Same response shape as before (qrId/qrUrl/bankUrl/qrTTL via the
   existing resolvePaymentQrId/resolvePaymentLink/resolveBankPaymentUrl
   helpers) - this replaces how the charged amount is determined, not the
   QR/card provider polling flow, which Phase 1 does not redesign.

merchantReference (PARTNER-PROVISIONING-API-CONTRACT.md's RoutingContext
field) is sent on the payment intent, generated the same way the old orderId
was - our own correlation id, now with a name that matches what it is.

api.service.ts: CheckoutSessionRequest/Response and PaymentIntentRequest
types added, old CartPaymentRequest/createCartPayment left in place (Phase 7
reconciliation and any other caller may still reference the shape) but no
longer called from checkout.

offerId uses item.itemID: this codebase has no distinct Offer entity yet
(Phase 3, Product/Offer split, not shipped in this model) - itemID is the
same catalog identifier every other endpoint already keys off. Flagged in a
code comment for whoever ships Phase 3 to revisit.

Dead code removed as a consequence, not a separate pass: buildPaymentItems,
getPaymentUserId, getPaymentDescription (no other caller once the old
payload was gone), the ConfigService/TenantResolverService injects that
existed only for getPaymentDescription, and the now-orphaned
cart.paymentDescriptionFallback i18n key in all three locales.

Verification: cart.component.ts has no unit spec (no src/app/pages/cart/
*.spec.ts exists) - this session's E2E suite is the only coverage the
checkout request shape has. Added checkout-request-shape.spec.ts, scoped
narrowly to the request/response contract rather than a full add-to-cart
UI journey: seeds cart state directly into localStorage, fakes the customer
session via cookie + intercepted session-check, intercepts both new
endpoints and asserts on the captured request bodies. Confirms concretely:
no `amount` or `price` field ever leaves the client, offers carry the right
offerId/qty, and the payment intent correctly threads checkoutSessionId
through.

Verified: 5/5 E2E green, 115/115 unit tests green, arch:check clean,
production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 14:14:19 +04:00
parent 14467cc6fb
commit fc53a3b7f5
7 changed files with 312 additions and 83 deletions

View File

@@ -53,6 +53,53 @@ export interface CartPaymentRequest {
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
}
/**
* Server-authoritative checkout. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2.
* No `amount` or `price` field anywhere in this pair - the backend prices
* every offer itself from its own catalog and the current FX quote.
*/
export interface CheckoutSessionRequest {
offers: Array<{ offerId: string; qty: number }>;
currency: string;
deliveryOptionId?: string;
}
interface MoneyAmount {
amountMinor: number;
currency: string;
}
export interface CheckoutSessionLine {
offerId: string;
qty: number;
unitPrice: MoneyAmount;
lineTotal: MoneyAmount;
priceSnapshotId: string;
}
export interface CheckoutSessionResponse {
checkoutSessionId: string;
lines: CheckoutSessionLine[];
subtotal: MoneyAmount;
discount: MoneyAmount;
delivery: MoneyAmount;
total: MoneyAmount;
fxQuoteId: string;
expiresAt: string;
}
/**
* References checkoutSessionId only - the amount charged is read
* server-side from the session, never re-sent by the client (contract §5.2).
* merchantReference is PARTNER-PROVISIONING-API-CONTRACT.md's RoutingContext
* field: our own correlation id, echoed back on every related event.
*/
export interface PaymentIntentRequest {
checkoutSessionId: string;
paymentMethod: 'qr' | 'card';
merchantReference: string;
}
export interface CreateOrderRequest {
/**
* No `price` field: the backend must price each line item from its own
@@ -637,6 +684,26 @@ export class ApiService {
return this.http.post<QrCreateResponse>(`${this.baseUrl}/cart`, payload);
}
/**
* Creates a server-priced checkout session. Contract §5.2 - the frontend
* sends offer ids and quantities only; the response carries the total that
* actually gets charged, computed server-side from the live offer price
* and current FX quote.
*/
createCheckoutSession(payload: CheckoutSessionRequest): Observable<CheckoutSessionResponse> {
return this.http.post<CheckoutSessionResponse>('/api/v2/storefront/checkout', payload);
}
/**
* Creates a payment intent against an existing checkout session. Same
* response shape as createCartPayment (QrCreateResponse) - this replaces
* how the amount is determined, not the QR/card provider integration
* itself, which Phase 1 does not redesign.
*/
createPaymentIntent(payload: PaymentIntentRequest): Observable<QrCreateResponse> {
return this.http.post<QrCreateResponse>('/api/v2/storefront/payments/intents', payload);
}
/**
* Records the just-paid cart as a backoffice order (POST /orders). Fire-and-forget
* from the caller's perspective - a failure here must never block the existing