From 4258a1367e1c180bc42076b499099f65087906a0 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Fri, 16 Jan 2026 12:23:05 -0800 Subject: [PATCH 1/5] (SP: 1) [Shop] Sanitize cart variant values on rehydrate boundary + merge canonical cart lines --- frontend/components/shop/cart-provider.tsx | 2 + .../lib/services/products/cart/rehydrate.ts | 169 +++++++++++++++--- .../cart-rehydrate-variant-sanitize.test.ts | 101 +++++++++++ frontend/project-structure.txt | 8 + 4 files changed, 256 insertions(+), 24 deletions(-) create mode 100644 frontend/lib/tests/cart-rehydrate-variant-sanitize.test.ts diff --git a/frontend/components/shop/cart-provider.tsx b/frontend/components/shop/cart-provider.tsx index 7c04a429..cf3802db 100644 --- a/frontend/components/shop/cart-provider.tsx +++ b/frontend/components/shop/cart-provider.tsx @@ -86,6 +86,7 @@ export function CartProvider({ children }: CartProviderProps) { try { const nextCart = await rehydrateCart(items); + setCart(nextCart); return; } catch (error) { @@ -107,6 +108,7 @@ export function CartProvider({ children }: CartProviderProps) { try { const retriedCart = await rehydrateCart(filtered); + setCart(retriedCart); logWarn('cart_rehydrate_recovered_by_removing_item', { diff --git a/frontend/lib/services/products/cart/rehydrate.ts b/frontend/lib/services/products/cart/rehydrate.ts index 8b6a7a43..f0646ea0 100644 --- a/frontend/lib/services/products/cart/rehydrate.ts +++ b/frontend/lib/services/products/cart/rehydrate.ts @@ -15,6 +15,8 @@ import type { CartRemovedItem, } from '@/lib/validation/shop'; import { isTwoDecimalCurrency, type CurrencyCode } from '@/lib/shop/currency'; +import { createCartItemKey } from '@/lib/shop/cart-item-key'; +import { logWarn } from '@/lib/logging'; import { PriceConfigError } from '../../errors'; @@ -36,14 +38,72 @@ function assertTwoDecimalCurrency(currency: CurrencyCode): void { ); } +const MAX_VARIANT_LENGTH = 64; + +function normalizeVariant(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (trimmed.length > MAX_VARIANT_LENGTH) return undefined; + return trimmed; +} + +function sanitizeVariant( + value: string | undefined, + allowed: string[] | null | undefined +): string | undefined { + if (!value) return undefined; + + const list = Array.isArray(allowed) + ? allowed.filter(v => typeof v === 'string' && v.trim().length > 0) + : []; + + if (list.length === 0) return undefined; + return list.includes(value) ? value : undefined; +} + +type NormalizedCartLine = { + productId: string; + quantity: number; + selectedSize?: string; + selectedColor?: string; +}; + +function aggregateClientLines(items: CartClientItem[]): NormalizedCartLine[] { + const map = new Map(); + + for (const item of items) { + const selectedSize = normalizeVariant(item.selectedSize); + const selectedColor = normalizeVariant(item.selectedColor); + + const key = createCartItemKey(item.productId, selectedSize, selectedColor); + const prev = map.get(key); + + if (prev) { + prev.quantity += item.quantity; + } else { + map.set(key, { + productId: item.productId, + quantity: item.quantity, + selectedSize, + selectedColor, + }); + } + } + + return Array.from(map.values()); +} + export async function rehydrateCartItems( items: CartClientItem[], currency: CurrencyCode ): Promise { assertTwoDecimalCurrency(currency); + const aggregated = aggregateClientLines(items); + const uniqueProductIds = Array.from( - new Set(items.map(item => item.productId)) + new Set(aggregated.map(item => item.productId)) ); if (uniqueProductIds.length === 0) { return cartRehydrateResultSchema.parse({ @@ -62,6 +122,8 @@ export async function rehydrateCartItems( isActive: products.isActive, badge: products.badge, imageUrl: products.imageUrl, + colors: products.colors, + sizes: products.sizes, priceMinor: productPrices.priceMinor, price: productPrices.price, priceCurrency: productPrices.currency, @@ -77,12 +139,21 @@ export async function rehydrateCartItems( .where(inArray(products.id, uniqueProductIds)); const productMap = new Map(rows.map(r => [r.id, r])); - - const rehydratedItems: CartRehydrateItem[] = []; const removed: CartRemovedItem[] = []; - let totalMinor = 0; - for (const item of items) { + // Merge by canonical key AFTER sanitization (prevents duplicate lines). + const merged = new Map< + string, + Omit< + CartRehydrateItem, + 'quantity' | 'lineTotalMinor' | 'lineTotal' | 'unitPrice' | 'lineTotal' + > & { + desiredQuantity: number; + unitPriceMinor: number; + } + >(); + + for (const item of aggregated) { const product = productMap.get(item.productId); if (!product) { @@ -110,12 +181,6 @@ export async function rehydrateCartItems( }); } - const effectiveQuantity = Math.min( - item.quantity, - product.stock, - MAX_QUANTITY_PER_LINE - ); - let unitPriceMinor: number; if ( @@ -125,7 +190,10 @@ export async function rehydrateCartItems( if (!Number.isInteger(product.priceMinor)) { throw new PriceConfigError( 'Invalid priceMinor in DB (must be integer).', - { productId: product.id, currency } + { + productId: product.id, + currency, + } ); } if (!Number.isSafeInteger(product.priceMinor) || product.priceMinor < 1) { @@ -152,31 +220,84 @@ export async function rehydrateCartItems( }); } + const sanitizedSize = sanitizeVariant(item.selectedSize, product.sizes); + const sanitizedColor = sanitizeVariant(item.selectedColor, product.colors); + + if ( + sanitizedSize !== item.selectedSize || + sanitizedColor !== item.selectedColor + ) { + logWarn('cart_rehydrate_variant_sanitized', { + productId: product.id, + currency, + droppedSize: + item.selectedSize && sanitizedSize !== item.selectedSize + ? item.selectedSize + : undefined, + droppedColor: + item.selectedColor && sanitizedColor !== item.selectedColor + ? item.selectedColor + : undefined, + }); + } + + const key = createCartItemKey(product.id, sanitizedSize, sanitizedColor); + + const prev = merged.get(key); + if (prev) { + prev.desiredQuantity += item.quantity; + } else { + merged.set(key, { + productId: product.id, + slug: product.slug, + title: product.title, + currency, + stock: product.stock, + badge: product.badge ?? 'NONE', + imageUrl: product.imageUrl, + selectedSize: sanitizedSize, + selectedColor: sanitizedColor, + desiredQuantity: item.quantity, + unitPriceMinor, + }); + } + } + + const rehydratedItems: CartRehydrateItem[] = []; + let totalMinor = 0; + + for (const line of merged.values()) { + const effectiveQuantity = Math.min( + line.desiredQuantity, + line.stock, + MAX_QUANTITY_PER_LINE + ); + const lineTotalMinor = calculateLineTotal( - unitPriceMinor, + line.unitPriceMinor, effectiveQuantity ); totalMinor += lineTotalMinor; rehydratedItems.push({ - productId: product.id, - slug: product.slug, - title: product.title, + productId: line.productId, + slug: line.slug, + title: line.title, quantity: effectiveQuantity, - unitPriceMinor, + unitPriceMinor: line.unitPriceMinor, lineTotalMinor, - unitPrice: fromMinorUnits(unitPriceMinor), + unitPrice: fromMinorUnits(line.unitPriceMinor), lineTotal: fromMinorUnits(lineTotalMinor), - currency, + currency: line.currency, - stock: product.stock, - badge: product.badge ?? 'NONE', - imageUrl: product.imageUrl, - selectedSize: item.selectedSize, - selectedColor: item.selectedColor, + stock: line.stock, + badge: line.badge, + imageUrl: line.imageUrl, + selectedSize: line.selectedSize, + selectedColor: line.selectedColor, }); } diff --git a/frontend/lib/tests/cart-rehydrate-variant-sanitize.test.ts b/frontend/lib/tests/cart-rehydrate-variant-sanitize.test.ts new file mode 100644 index 00000000..112134d0 --- /dev/null +++ b/frontend/lib/tests/cart-rehydrate-variant-sanitize.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import crypto from 'node:crypto'; +import { eq } from 'drizzle-orm'; + +import { db } from '@/db'; +import { products, productPrices } from '@/db/schema'; +import { rehydrateCartItems } from '@/lib/services/products'; + +let productId: string; + +beforeAll(async () => { + productId = crypto.randomUUID(); + + await db.insert(products).values({ + id: productId, + slug: `test-cart-${productId}`, + title: 'Test Product', + description: null, + imageUrl: 'https://example.com/test.jpg', + imagePublicId: null, + price: '10.00', + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: ['black'], + sizes: ['S', 'M'], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku: null, + }); + + await db.insert(productPrices).values({ + id: crypto.randomUUID(), + productId, + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: null, + price: '10.00', + originalPrice: null, + }); +}); + +afterAll(async () => { + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(products).where(eq(products.id, productId)); +}); + +describe('cart rehydrate: variant sanitization', () => { + it('drops invalid selectedSize and merges lines after sanitization', async () => { + const result = await rehydrateCartItems( + [ + { + productId, + quantity: 1, + selectedSize: 'INVALID', + selectedColor: 'black', + }, + { + productId, + quantity: 2, + selectedColor: 'black', + }, + ], + 'USD' + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0]!.quantity).toBe(3); + expect(result.items[0]!.selectedSize).toBeUndefined(); + expect(result.items[0]!.selectedColor).toBe('black'); + expect(result.summary.totalAmountMinor).toBe(3000); + }); + + it('drops invalid selectedColor and merges lines after sanitization', async () => { + const result = await rehydrateCartItems( + [ + { + productId, + quantity: 1, + selectedSize: 'S', + selectedColor: 'INVALID', + }, + { + productId, + quantity: 2, + selectedSize: 'S', + }, + ], + 'USD' + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0]!.quantity).toBe(3); + expect(result.items[0]!.selectedSize).toBe('S'); + expect(result.items[0]!.selectedColor).toBeUndefined(); + expect(result.summary.totalAmountMinor).toBe(3000); + }); +}); diff --git a/frontend/project-structure.txt b/frontend/project-structure.txt index 8bbcda9a..69150a9f 100644 --- a/frontend/project-structure.txt +++ b/frontend/project-structure.txt @@ -224,6 +224,7 @@ πŸ“„ catalog-load-more.tsx πŸ“„ catalog-products-client.tsx πŸ“„ category-tile.tsx + πŸ“„ clear-cart-on-mount.tsx πŸ“ header πŸ“„ cart-button.tsx πŸ“„ nav-links.tsx @@ -386,6 +387,7 @@ πŸ“„ logging.ts πŸ“„ logout.ts πŸ“„ navigation.ts + πŸ“„ pagination.ts πŸ“ psp πŸ“„ stripe.ts πŸ“ quiz @@ -393,6 +395,8 @@ πŸ“„ quiz-crypto.ts πŸ“„ quiz-session.ts πŸ“„ quiz-storage-keys.ts + πŸ“ security + πŸ“„ csrf.ts πŸ“ services πŸ“„ errors.ts πŸ“„ inventory.ts @@ -400,6 +404,8 @@ πŸ“„ checkout.ts πŸ“„ payment-intent.ts πŸ“„ payment-state.ts + πŸ“ psp-metadata + πŸ“„ refunds.ts πŸ“„ refund.ts πŸ“„ restock.ts πŸ“„ summary.ts @@ -440,6 +446,8 @@ πŸ“„ checkout-stripe-error-contract.test.ts πŸ“„ currency.test.ts πŸ“„ format-money.test.ts + πŸ“ helpers + πŸ“„ makeCheckoutReq.ts πŸ“„ order-items-snapshot-immutable.test.ts πŸ“„ order-items-variants.test.ts πŸ“„ orders-access.test.ts From 56ed8d537ccff96bf368ec8300a2c0bcb1b8249f Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Fri, 16 Jan 2026 13:12:06 -0800 Subject: [PATCH 2/5] =?UTF-8?q?(SP:=201)=20[Shop]=20Eliminate=20float=20mo?= =?UTF-8?q?ney=20conversions:=20string-safe=20DECIMAL=E2=86=92minor=20pars?= =?UTF-8?q?er=20+=20strict=20minor-unit=20invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/shop/money.ts | 181 +++++++++++++++++++++++++------- frontend/lib/validation/shop.ts | 12 ++- 2 files changed, 154 insertions(+), 39 deletions(-) diff --git a/frontend/lib/shop/money.ts b/frontend/lib/shop/money.ts index 06321733..20e23226 100644 --- a/frontend/lib/shop/money.ts +++ b/frontend/lib/shop/money.ts @@ -1,76 +1,181 @@ -export type Money = number; -export type MoneyCents = number; - -function parseAmount(value: unknown): number { - const parsed = typeof value === "string" ? value.trim() : value; - - if (typeof parsed !== "string" && typeof parsed !== "number") { - throw new Error("Invalid money value"); - } - - const numeric = - typeof parsed === "string" && parsed.length > 0 ? Number(parsed) : Number(parsed); - - if (!Number.isFinite(numeric)) { - throw new Error("Invalid money value"); - } - - if (numeric < 0) { - throw new Error("Invalid money value"); - } - - return numeric; -} +export type Money = number; // legacy/display only +export type MoneyCents = number; // canonical minor units (safe int >= 0) /** * Strict invariant for canonical minor-units: * - finite * - integer (no trunc/round normalization here) * - >= 0 + * - safe integer */ export function assertIntegerCentsStrict(cents: number): MoneyCents { if (!Number.isFinite(cents) || !Number.isInteger(cents) || cents < 0) { - throw new Error("Invalid money cents value"); + throw new Error('Invalid money cents value'); + } + if (!Number.isSafeInteger(cents)) { + throw new Error('Money cents exceeds JS safe integer range'); } return cents; } +function assertPositiveInteger(name: string, v: number): number { + if (!Number.isFinite(v) || !Number.isInteger(v) || v <= 0) { + throw new Error(`Invalid ${name}`); + } + if (!Number.isSafeInteger(v)) { + throw new Error(`${name} exceeds JS safe integer range`); + } + return v; +} + +function isScientificNotation(s: string): boolean { + return /e[+-]?\d+/i.test(s); +} + +/** + * Parse decimal "major units" string/number into minor units (cents) WITHOUT floats. + * Rules: + * - accepts: "12", "12.3", "12.34", ".5", "0.5" + * - rejects: negatives, NaN/Infinity, scientific notation ("1e-3"), non-numeric + * - rounds HALF_UP to 2 decimals if more than 2 fractional digits + */ +function parseMajorToMinor(input: string | number): MoneyCents { + const raw = + typeof input === 'string' + ? input.trim() + : Number.isFinite(input) + ? String(input) + : String(input); + + if (!raw.length) throw new Error('Invalid money value'); + + if (typeof input === 'number') { + if (!Number.isFinite(input) || input < 0) throw new Error('Invalid money value'); + } + + const s = raw; + + if (s.startsWith('-')) throw new Error('Invalid money value'); + if (isScientificNotation(s)) { + // JS numbers can stringify to "1e-7" – refuse to avoid ambiguous rounding + throw new Error('Invalid money value'); + } + + // Normalize leading-dot ".5" -> "0.5" + const normalized = s.startsWith('.') ? `0${s}` : s; + + // Accept only digits with optional single dot + const m = normalized.match(/^(\d+)(?:\.(\d+))?$/); + if (!m) throw new Error('Invalid money value'); + + const intStr = m[1] ?? '0'; + const fracStrRaw = m[2] ?? ''; + + // int part safe range pre-check + const maxIntPart = Math.floor(Number.MAX_SAFE_INTEGER / 100); + const intPart = Number(intStr); + if (!Number.isSafeInteger(intPart) || intPart < 0 || intPart > maxIntPart) { + throw new Error('Invalid money value'); + } + + // Fractional rounding to 2 digits, HALF_UP + let frac2 = fracStrRaw.slice(0, 2); + while (frac2.length < 2) frac2 += '0'; + + let cents = Number(frac2); + if (!Number.isInteger(cents) || cents < 0 || cents > 99) { + throw new Error('Invalid money value'); + } + + // Round if there are extra digits beyond 2 + if (fracStrRaw.length > 2) { + const third = fracStrRaw.charCodeAt(2) - 48; // '0' => 0 + if (third >= 5) { + cents += 1; + if (cents === 100) { + // carry + cents = 0; + if (intPart + 1 > maxIntPart) throw new Error('Invalid money value'); + const minor = (intPart + 1) * 100 + cents; + return assertIntegerCentsStrict(minor); + } + } + } + + const minor = intPart * 100 + cents; + return assertIntegerCentsStrict(minor); +} + +/** + * Public API: major -> cents (minor). + * NOTE: prefer passing strings from DB/inputs; numbers are accepted but may be rejected + * if they stringify to scientific notation. + */ export function toCents(value: number | string): MoneyCents { - const parsed = parseAmount(value); - const cents = Math.round(parsed * 100); - return assertIntegerCentsStrict(cents); + if (typeof value !== 'string' && typeof value !== 'number') { + throw new Error('Invalid money value'); + } + return parseMajorToMinor(value); } +/** + * Minor -> legacy major number (display only). + * Still returns a JS number; do NOT use for money comparisons. + */ export function fromCents(cents: MoneyCents): Money { - return Number((assertIntegerCentsStrict(cents) / 100).toFixed(2)); + const v = assertIntegerCentsStrict(cents); + const intPart = Math.floor(v / 100); + const frac = v % 100; + // Controlled string -> number (display only) + return Number(`${intPart}.${String(frac).padStart(2, '0')}`); } /** * Legacy DB numeric money (string/number like "12.34") -> canonical cents (int >= 0). + * No floats. */ export function fromDbMoney(value: unknown): MoneyCents { - if (typeof value !== "string" && typeof value !== "number") { - throw new Error("Invalid money value"); + if (typeof value !== 'string' && typeof value !== 'number') { + throw new Error('Invalid money value'); } return toCents(value); } +/** + * Canonical cents -> DB decimal string "12.34" WITHOUT floats/toFixed. + */ export function toDbMoney(cents: MoneyCents): string { - return (assertIntegerCentsStrict(cents) / 100).toFixed(2); + const v = assertIntegerCentsStrict(cents); + const intPart = Math.floor(v / 100); + const frac = v % 100; + return `${intPart}.${String(frac).padStart(2, '0')}`; } -export function calculateLineTotal(unitPriceCents: MoneyCents, quantity: number): MoneyCents { - if (!Number.isFinite(quantity) || !Number.isInteger(quantity) || quantity <= 0) { - throw new Error("Invalid quantity"); +export function calculateLineTotal( + unitPriceCents: MoneyCents, + quantity: number +): MoneyCents { + const price = assertIntegerCentsStrict(unitPriceCents); + const qty = assertPositiveInteger('quantity', quantity); + + const total = price * qty; + + if (!Number.isSafeInteger(total)) { + throw new Error('Line total exceeds JS safe integer range'); } - return assertIntegerCentsStrict(assertIntegerCentsStrict(unitPriceCents) * quantity); -} + return assertIntegerCentsStrict(total); +} export function sumLineTotals(lineTotals: MoneyCents[]): MoneyCents { let total = 0; for (const cents of lineTotals) { - total = assertIntegerCentsStrict(total + assertIntegerCentsStrict(cents)); + const v = assertIntegerCentsStrict(cents); + const next = total + v; + if (!Number.isSafeInteger(next)) { + throw new Error('Sum exceeds JS safe integer range'); + } + total = next; } - return total; + return assertIntegerCentsStrict(total); } diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 4043dc65..1c03e86e 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -267,7 +267,8 @@ export const productAdminUpdateSchema = z const trimmed = value?.trim() ?? ''; return trimmed.length ? trimmed : undefined; }), - prices: z.array(adminPriceRowSchema).optional(), + prices: z.array(adminPriceRowSchema).min(1).optional(), + description: z.string().optional(), category: z.enum(productCategoryValues as [string, ...string[]]).optional(), type: z.enum(typeValues as [string, ...string[]]).optional(), @@ -293,6 +294,15 @@ export const productAdminUpdateSchema = z seen.add(p.currency); } }); + // USD is required when prices are provided (avoid wiping USD on PATCH replace-all) + const usd = data.prices.find(p => p.currency === 'USD'); + if (!usd) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['prices'], + message: 'USD price is required', + }); + } } if (data.badge === 'SALE' && data.prices) { data.prices.forEach((p, idx) => { From 2fe1dc85f0979cebd9c221c3caf1e9af0604d831 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Fri, 16 Jan 2026 13:44:01 -0800 Subject: [PATCH 3/5] (SP: 1) [Checkout] Use DB-canonical totalAmountMinor/currency for Stripe PaymentIntent creation (remove in-memory totals) --- frontend/app/api/shop/checkout/route.ts | 102 ++++++++++++++---- .../lib/services/orders/payment-intent.ts | 51 ++++++++- ...set-payment-intent-reject-contract.test.ts | 8 ++ .../checkout-stripe-error-contract.test.ts | 8 ++ 4 files changed, 143 insertions(+), 26 deletions(-) diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index b5539c43..b0751850 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -14,6 +14,7 @@ import { PriceConfigError, OrderStateInvalidError, } from '@/lib/services/errors'; +import { readStripePaymentIntentParams } from '@/lib/services/orders/payment-intent'; import { createOrderWithItems, @@ -157,7 +158,11 @@ export async function POST(request: NextRequest) { logWarn('Failed to parse cart payload', { reason: error instanceof Error ? error.message : String(error), }); - return errorResponse('INVALID_PAYLOAD', 'Unable to process cart data.', 400); + return errorResponse( + 'INVALID_PAYLOAD', + 'Unable to process cart data.', + 400 + ); } const idempotencyKey = getIdempotencyKey(request); @@ -232,18 +237,29 @@ export async function POST(request: NextRequest) { locale, }); - const { order, totalCents } = result; + const { order } = result; const paymentsEnabled = isPaymentsEnabled(); if (!paymentsEnabled) { - if (order.paymentProvider === 'none' && order.paymentStatus === 'failed') { - return errorResponse('CHECKOUT_FAILED', 'Order could not be completed.', 409, { - orderId: order.id, - }); + if ( + order.paymentProvider === 'none' && + order.paymentStatus === 'failed' + ) { + return errorResponse( + 'CHECKOUT_FAILED', + 'Order could not be completed.', + 409, + { + orderId: order.id, + } + ); } - if (order.paymentProvider === 'stripe' && order.paymentStatus !== 'paid') { + if ( + order.paymentProvider === 'stripe' && + order.paymentStatus !== 'paid' + ) { return errorResponse( 'PAYMENTS_DISABLED', 'Payments are disabled. This order requires payment and cannot be processed.', @@ -253,9 +269,16 @@ export async function POST(request: NextRequest) { } if (order.paymentProvider === 'none') { - if (!['paid', 'failed'].includes(order.paymentStatus) || order.paymentIntentId) { + if ( + !['paid', 'failed'].includes(order.paymentStatus) || + order.paymentIntentId + ) { logError( - `Payments disabled but order is not paid/none. orderId=${order.id} provider=${order.paymentProvider} status=${order.paymentStatus} intent=${order.paymentIntentId ?? 'null'}`, + `Payments disabled but order is not paid/none. orderId=${ + order.id + } provider=${order.paymentProvider} status=${ + order.paymentStatus + } intent=${order.paymentIntentId ?? 'null'}`, new Error('ORDER_STATE_INVALID') ); return errorResponse( @@ -267,7 +290,8 @@ export async function POST(request: NextRequest) { } } - const stripePaymentFlow = paymentsEnabled && order.paymentProvider === 'stripe'; + const stripePaymentFlow = + paymentsEnabled && order.paymentProvider === 'stripe'; // ========================= // Existing order path @@ -276,7 +300,9 @@ export async function POST(request: NextRequest) { // Existing order already has PI: retrieve client_secret if (stripePaymentFlow && order.paymentIntentId) { try { - const paymentIntent = await retrievePaymentIntent(order.paymentIntentId); + const paymentIntent = await retrievePaymentIntent( + order.paymentIntentId + ); return buildCheckoutResponse({ order: { id: order.id, @@ -292,7 +318,11 @@ export async function POST(request: NextRequest) { }); } catch (error) { logError('Checkout payment intent retrieval failed', error); - return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); + return errorResponse( + 'STRIPE_ERROR', + 'Unable to initiate payment.', + 502 + ); } } @@ -301,15 +331,21 @@ export async function POST(request: NextRequest) { let paymentIntent: { paymentIntentId: string; clientSecret: string }; try { + const snapshot = await readStripePaymentIntentParams(order.id); + paymentIntent = await createPaymentIntent({ - amount: totalCents, - currency: order.currency, + amount: snapshot.amountMinor, + currency: snapshot.currency, orderId: order.id, idempotencyKey, }); } catch (error) { logError('Checkout payment intent creation failed', error); - return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); + return errorResponse( + 'STRIPE_ERROR', + 'Unable to initiate payment.', + 502 + ); } try { @@ -351,7 +387,11 @@ export async function POST(request: NextRequest) { }); } - return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); + return errorResponse( + 'INTERNAL_ERROR', + 'Unable to process checkout.', + 500 + ); } } @@ -394,9 +434,11 @@ export async function POST(request: NextRequest) { let paymentIntent: { paymentIntentId: string; clientSecret: string }; try { + const snapshot = await readStripePaymentIntentParams(order.id); + paymentIntent = await createPaymentIntent({ - amount: totalCents, - currency: order.currency, + amount: snapshot.amountMinor, + currency: snapshot.currency, orderId: order.id, idempotencyKey, }); @@ -406,7 +448,10 @@ export async function POST(request: NextRequest) { try { await restockOrder(order.id, { reason: 'failed' }); } catch (restockError) { - logError('Restoring stock after payment intent failure failed', restockError); + logError( + 'Restoring stock after payment intent failure failed', + restockError + ); } return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); @@ -450,7 +495,10 @@ export async function POST(request: NextRequest) { try { await restockOrder(order.id, { reason: 'failed' }); } catch (restockError) { - logError('Restoring stock after payment intent attach failure failed', restockError); + logError( + 'Restoring stock after payment intent attach failure failed', + restockError + ); } if (error instanceof OrderStateInvalidError) { @@ -460,7 +508,11 @@ export async function POST(request: NextRequest) { }); } - return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); + return errorResponse( + 'INTERNAL_ERROR', + 'Unable to process checkout.', + 500 + ); } } catch (error) { if (isExpectedBusinessError(error)) { @@ -473,7 +525,11 @@ export async function POST(request: NextRequest) { } if (error instanceof InvalidPayloadError) { - return errorResponse(error.code, error.message || 'Invalid checkout payload', 400); + return errorResponse( + error.code, + error.message || 'Invalid checkout payload', + 400 + ); } if (error instanceof InvalidVariantError) { @@ -524,4 +580,4 @@ export async function POST(request: NextRequest) { return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); } -} \ No newline at end of file +} diff --git a/frontend/lib/services/orders/payment-intent.ts b/frontend/lib/services/orders/payment-intent.ts index 0041e84f..f8a04ba1 100644 --- a/frontend/lib/services/orders/payment-intent.ts +++ b/frontend/lib/services/orders/payment-intent.ts @@ -2,10 +2,13 @@ import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { orders } from '@/db/schema/shop'; -import { type PaymentStatus } from '@/lib/shop/payments'; +import { type PaymentProvider, type PaymentStatus } from '@/lib/shop/payments'; import { type OrderSummaryWithMinor } from '@/lib/types/shop'; - -import { InvalidPayloadError, OrderNotFoundError } from '../errors'; +import { + InvalidPayloadError, + OrderNotFoundError, + OrderStateInvalidError, +} from '../errors'; import { resolvePaymentProvider } from './_shared'; import { getOrderItems, parseOrderSummary } from './summary'; import { guardedPaymentStatusUpdate } from './payment-state'; @@ -86,3 +89,45 @@ export async function setOrderPaymentIntent({ const items = await getOrderItems(orderId); return parseOrderSummary(updated, items); } + +export async function readStripePaymentIntentParams(orderId: string): Promise<{ + amountMinor: number; + currency: (typeof orders.$inferSelect)['currency']; +}> { + const [existing] = await db + .select() + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + if (!existing) throw new OrderNotFoundError('Order not found'); + + const provider: PaymentProvider = resolvePaymentProvider(existing); + + if (provider !== 'stripe') { + throw new InvalidPayloadError( + 'Payment intent can only be created for stripe orders.' + ); + } + + const amountMinor = existing.totalAmountMinor; + + // Canonical money source = DB minor units. Fail-closed on invalid totals. + if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) { + const err = new OrderStateInvalidError( + 'Invalid order total for Stripe payment intent creation.' + ); + + // attach diagnostics for API handler (keeps existing errorResponse shape) + (err as any).orderId = orderId; + (err as any).field = 'totalAmountMinor'; + (err as any).rawValue = amountMinor; + (err as any).details = { + reason: 'Invalid order total for Stripe payment intent creation.', + }; + + throw err; + } + + return { amountMinor, currency: existing.currency }; +} diff --git a/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts b/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts index de3d39e1..f8205c9c 100644 --- a/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts +++ b/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts @@ -28,6 +28,14 @@ vi.mock('@/lib/psp/stripe', () => ({ retrievePaymentIntent: vi.fn(), })); +// Avoid DB coupling introduced by #6 (DB-canonical PI amount/currency) +vi.mock('@/lib/services/orders/payment-intent', () => ({ + readStripePaymentIntentParams: vi.fn(async () => ({ + amountMinor: 1000, + currency: 'USD', + })), +})); + // Mock order services vi.mock('@/lib/services/orders', async () => { const actual = await vi.importActual('@/lib/services/orders'); diff --git a/frontend/lib/tests/checkout-stripe-error-contract.test.ts b/frontend/lib/tests/checkout-stripe-error-contract.test.ts index 4ae13f34..4734fc31 100644 --- a/frontend/lib/tests/checkout-stripe-error-contract.test.ts +++ b/frontend/lib/tests/checkout-stripe-error-contract.test.ts @@ -25,6 +25,14 @@ vi.mock('@/lib/psp/stripe', () => ({ retrievePaymentIntent: vi.fn(), })); +// Avoid DB coupling introduced by #6 (DB-canonical PI amount/currency) +vi.mock('@/lib/services/orders/payment-intent', () => ({ + readStripePaymentIntentParams: vi.fn(async () => ({ + amountMinor: 1000, + currency: 'USD', + })), +})); + // 4) mock orders services so we don't depend on DB schema/seed here vi.mock('@/lib/services/orders', async () => { const actual = await vi.importActual('@/lib/services/orders'); From a43ec61f6dfa28bb4e757365e254988a4009ba1d Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Fri, 16 Jan 2026 16:55:28 -0800 Subject: [PATCH 4/5] (SP: 1) [Checkout] Add durable payment_attempts layer (unique/limits, bounded retries, audit trail) --- .../checkout/payment/StripePaymentClient.tsx | 42 +- .../shop/checkout/payment/[orderId]/page.tsx | 98 +- frontend/app/api/shop/checkout/route.ts | 206 +- .../app/api/shop/webhooks/stripe/route.ts | 29 +- frontend/db/schema/shop.ts | 68 + .../drizzle/0001_add_payment_attempts.sql | 24 + frontend/drizzle/meta/0001_snapshot.json | 2667 +++++++++++++++++ frontend/drizzle/meta/_journal.json | 7 + frontend/lib/psp/stripe.ts | 7 +- .../lib/services/orders/payment-attempts.ts | 357 +++ 10 files changed, 3331 insertions(+), 174 deletions(-) create mode 100644 frontend/drizzle/0001_add_payment_attempts.sql create mode 100644 frontend/drizzle/meta/0001_snapshot.json create mode 100644 frontend/lib/services/orders/payment-attempts.ts diff --git a/frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx b/frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx index 3d33a743..51fcb73b 100644 --- a/frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx +++ b/frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx @@ -1,10 +1,8 @@ -// frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx 'use client'; import { useMemo, useState } from 'react'; -import { Link } from '@/i18n/routing'; +import { Link, useRouter } from '@/i18n/routing'; -import { useRouter } from 'next/navigation'; import { Elements, PaymentElement, @@ -50,14 +48,21 @@ function toCurrencyCode( : resolveCurrencyFromLocale(locale); } +function buildShopBase(locale: string) { + return `/${locale}/shop`; +} + function nextRouteForPaymentResult(params: { locale: string; orderId: string; status?: string | null; }) { - const { orderId, status } = params; - const success = `/shop/checkout/success?orderId=${orderId}`; - const failure = `/shop/checkout/error?orderId=${orderId}`; + const { locale, orderId, status } = params; + const shopBase = buildShopBase(locale); + const id = encodeURIComponent(orderId); + + const success = `${shopBase}/checkout/success?orderId=${id}`; + const failure = `${shopBase}/checkout/error?orderId=${id}`; if (!status) return success; if ( @@ -75,6 +80,9 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) { const stripe = useStripe(); const elements = useElements(); const router = useRouter(); + + const shopBase = useMemo(() => buildShopBase(locale), [locale]); + const [submitting, setSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -92,17 +100,20 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) { setSubmitting(true); try { + const id = encodeURIComponent(orderId); + const { error, paymentIntent } = await stripe.confirmPayment({ elements, redirect: 'if_required', confirmParams: { - return_url: `${window.location.origin}/shop/checkout/success?orderId=${orderId}`, + // Stripe redirect comes from outside Next.js routing β€” must include locale. + return_url: `${window.location.origin}${shopBase}/checkout/success?orderId=${id}`, }, }); if (error) { setErrorMessage(error.message ?? 'Unable to confirm payment.'); - router.push(`/shop/checkout/error?orderId=${orderId}`); + router.push(`${shopBase}/checkout/error?orderId=${id}`); return; } @@ -111,11 +122,14 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) { orderId, status: paymentIntent?.status ?? null, }); + router.push(next); } catch (error) { logError('stripe_payment_confirm_failed', error, { orderId }); setErrorMessage('We couldn’t confirm your payment. Please try again.'); - router.push(`/shop/checkout/error?orderId=${orderId}`); + router.push( + `${shopBase}/checkout/error?orderId=${encodeURIComponent(orderId)}` + ); } finally { setSubmitting(false); } @@ -161,6 +175,8 @@ export default function StripePaymentClient({ [currency, locale] ); + const shopBase = useMemo(() => buildShopBase(locale), [locale]); + const stripePromise = useMemo(() => { if (!paymentsEnabled || !publishableKey) return null; return loadStripe(publishableKey); @@ -183,13 +199,15 @@ export default function StripePaymentClient({

Payments are disabled in this environment.