From fc53a3b7f5db84ea3b9532cb7ca6955a87d94c82 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 18 Aug 2026 14:14:19 +0400 Subject: [PATCH] feat: server-authoritative checkout, no client-computed amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/checkout-request-shape.spec.ts | 186 +++++++++++++++++++++++++++ src/app/i18n/en.ts | 1 - src/app/i18n/hy.ts | 1 - src/app/i18n/ru.ts | 1 - src/app/i18n/translations.ts | 1 - src/app/pages/cart/cart.component.ts | 138 +++++++++----------- src/app/services/api.service.ts | 67 ++++++++++ 7 files changed, 312 insertions(+), 83 deletions(-) create mode 100644 e2e/checkout-request-shape.spec.ts diff --git a/e2e/checkout-request-shape.spec.ts b/e2e/checkout-request-shape.spec.ts new file mode 100644 index 0000000..d6916ca --- /dev/null +++ b/e2e/checkout-request-shape.spec.ts @@ -0,0 +1,186 @@ +import { Page, Route, expect, test } from '@playwright/test'; + +/** + * Guards the specific contract this rewrite exists to enforce + * (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2): the amount actually charged + * must be computed server-side, never sent by the client. Before this + * rewrite, POST /cart carried a client-computed `amount` the backend was + * asked to trust. + * + * cart.component.ts has no unit spec (no src/app/pages/cart/*.spec.ts + * exists), so this E2E test is the only coverage the checkout request shape + * has. Scoped narrowly on purpose: cart state is seeded directly into + * localStorage and the customer session is faked via cookie + intercepted + * session-check, rather than driving a full add-to-cart-then-login UI + * journey - that journey is real product surface worth its own test, but + * would make this test about navigation, not about what it exists to prove. + */ + +const FAKE_SESSION_ID = 'e2e-fake-session'; +const FAKE_ITEM = { + categoryID: 1, + itemID: 4242, + name: 'E2E Test Item', + photos: null, + description: '', + currency: 'RUB', + price: 1000, + discount: 0, + rating: 0, + callbacks: null, + questions: null, + quantity: 2, +}; + +test.describe('checkout request shape', () => { + test.beforeEach(async ({ page, context }) => { + await seedCart(page); + await fakeCustomerSession(page, context); + await mockFxQuoteEndpoint(page); + }); + + test('checkout session request carries offers and qty, never amount or price', async ({ page }) => { + const checkoutRequest = interceptCheckoutSession(page); + + await page.goto('/cart'); + await acceptTermsAndCheckout(page); + + const body = await checkoutRequest; + + expect(body, 'must never send a client-computed amount').not.toHaveProperty('amount'); + expect(body, 'must never send a client-computed price').not.toHaveProperty('price'); + expect(Array.isArray(body.offers), 'must send an offers array').toBe(true); + expect(body.offers[0]).toMatchObject({ offerId: String(FAKE_ITEM.itemID), qty: FAKE_ITEM.quantity }); + }); + + test('payment intent request references the checkout session id, not a raw amount', async ({ page }) => { + interceptCheckoutSession(page); // must resolve for the intent call to fire at all + const intentRequest = interceptPaymentIntent(page); + + await page.goto('/cart'); + await acceptTermsAndCheckout(page); + + const body = await intentRequest; + + expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture'); + expect(body).not.toHaveProperty('amount'); + expect(typeof body.merchantReference).toBe('string'); + expect(body.merchantReference.length).toBeGreaterThan(0); + }); +}); + +async function seedCart(page: Page): Promise { + await page.addInitScript(item => { + window.localStorage.setItem('marketplace_cart', JSON.stringify([item])); + }, FAKE_ITEM); +} + +async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise { + await context.addCookies([ + { + name: 'webSessionID', + value: FAKE_SESSION_ID, + domain: 'localhost', + path: '/', + }, + ]); + + // Matches TelegramSessionApiService.normalizeWebSession's expected shape. + await page.route('**/users/sessions/**', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sessionId: FAKE_SESSION_ID, + status: 'active', + username: 'e2e_user', + userId: 1, + }), + }); + }); +} + +async function mockFxQuoteEndpoint(page: Page): Promise { + await page.route('**/api/v2/pricing/fx-quote**', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + quoteId: 'fxq_e2e', + base: 'RUB', + quote: 'RUB', + rate: 1, + source: 'e2e-fixture', + observedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }), + }); + }); +} + +function interceptCheckoutSession(page: Page): Promise> { + return new Promise(resolve => { + page.route('**/api/v2/storefront/checkout', (route: Route) => { + const body = route.request().postDataJSON(); + resolve(body); + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + checkoutSessionId: 'chk_e2e_fixture', + lines: [{ + offerId: String(FAKE_ITEM.itemID), + qty: FAKE_ITEM.quantity, + unitPrice: { amountMinor: FAKE_ITEM.price * 100, currency: 'RUB' }, + lineTotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' }, + priceSnapshotId: 'snap_e2e', + }], + subtotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' }, + discount: { amountMinor: 0, currency: 'RUB' }, + delivery: { amountMinor: 0, currency: 'RUB' }, + total: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' }, + fxQuoteId: 'fxq_e2e', + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }), + }); + }); + }); +} + +function interceptPaymentIntent(page: Page): Promise> { + return new Promise(resolve => { + page.route('**/api/v2/storefront/payments/intents', (route: Route) => { + const body = route.request().postDataJSON(); + resolve(body); + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + qrId: 'qr_e2e_fixture', + nspkurl: 'https://example.com/pay/e2e', + qrTTL: 5, + }), + }); + }); + }); +} + +async function acceptTermsAndCheckout(page: Page): Promise { + await page.waitForLoadState('networkidle'); + + // #terms-checkbox is a custom-styled input (zero-size native element, a + //