diff --git a/frontend/app/[locale]/shop/admin/products/page.tsx b/frontend/app/[locale]/shop/admin/products/page.tsx index 1491ebec..d8625acb 100644 --- a/frontend/app/[locale]/shop/admin/products/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/page.tsx @@ -12,10 +12,22 @@ function formatDate(value: Date | null, locale: string) { return value.toLocaleDateString(locale); } -function safeFromDbMoney(value: unknown): number | null { +function safeFromDbMoney( + value: unknown, + ctx: { productId: string; currency: string } +): number | null { + // expected case for leftJoin: missing price row + if (value == null) return null; + try { return fromDbMoney(value); - } catch { + } catch (err) { + console.warn('[admin products] fromDbMoney failed', { + ...ctx, + valueType: typeof value, + value, + error: err instanceof Error ? err.message : String(err), + }); return null; } } @@ -108,7 +120,10 @@ export default async function AdminProductsPage({ {rows.map(row => { - const priceMinor = safeFromDbMoney(row.price); + const priceMinor = safeFromDbMoney(row.price, { + productId: row.id, + currency: displayCurrency, + }); return ( diff --git a/frontend/app/api/shop/admin/products/[id]/route.ts b/frontend/app/api/shop/admin/products/[id]/route.ts index d782382a..055599b9 100644 --- a/frontend/app/api/shop/admin/products/[id]/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/route.ts @@ -32,7 +32,8 @@ type InvalidPricesJsonError = { function isInvalidPricesJsonError( value: SaleRuleViolation | InvalidPricesJsonError | null ): value is InvalidPricesJsonError { - return !!value && (value as any).code === 'INVALID_PRICES_JSON'; + if (!value || typeof value !== 'object') return false; + return (value as Record).code === 'INVALID_PRICES_JSON'; } function findSaleRuleViolation(input: any): SaleRuleViolation | null { diff --git a/frontend/app/api/shop/admin/products/route.ts b/frontend/app/api/shop/admin/products/route.ts index 595e3532..090e6a49 100644 --- a/frontend/app/api/shop/admin/products/route.ts +++ b/frontend/app/api/shop/admin/products/route.ts @@ -26,7 +26,8 @@ type InvalidPricesJsonError = { function isInvalidPricesJsonError( value: SaleRuleViolation | InvalidPricesJsonError | null ): value is InvalidPricesJsonError { - return !!value && (value as any).code === 'INVALID_PRICES_JSON'; + if (!value || typeof value !== 'object') return false; + return (value as Record).code === 'INVALID_PRICES_JSON'; } function findSaleRuleViolation(input: any): SaleRuleViolation | null { diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index e1f0d3e2..bb7f3139 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -112,6 +112,17 @@ function buildPspMetadata(params: { }; } +function shouldRestockFromWebhook(order: { + stockRestored: boolean | null; + inventoryStatus: string | null; +}) { + // Webhook-level gate: avoid unnecessary calls when we already restored stock + // or inventory has already been released. + if (order.stockRestored === true) return false; + if (order.inventoryStatus === 'released') return false; + return true; +} + export async function POST(request: NextRequest) { let rawBody: string; @@ -310,6 +321,8 @@ export async function POST(request: NextRequest) { currency: orders.currency, paymentStatus: orders.paymentStatus, status: orders.status, + stockRestored: orders.stockRestored, + inventoryStatus: orders.inventoryStatus, }) .from(orders) .where(eq(orders.id, resolvedOrderId)) @@ -504,7 +517,7 @@ export async function POST(request: NextRequest) { paymentIntent?.status ?? 'payment_failed'; - const updated = await db + await db .update(orders) .set({ paymentStatus: 'failed', @@ -521,10 +534,11 @@ export async function POST(request: NextRequest) { charge: chargeForIntent, }), }) - .where(and(eq(orders.id, order.id), ne(orders.paymentStatus, 'failed'))) - .returning({ id: orders.id }); + .where( + and(eq(orders.id, order.id), ne(orders.paymentStatus, 'failed')) + ); - if (updated.length > 0) { + if (shouldRestockFromWebhook(order)) { await restockOrder(order.id, { reason: 'failed' }); } @@ -554,7 +568,7 @@ export async function POST(request: NextRequest) { paymentIntent?.status ?? 'canceled'; - const updated = await db + await db .update(orders) .set({ paymentStatus: 'failed', @@ -571,10 +585,11 @@ export async function POST(request: NextRequest) { charge: chargeForIntent, }), }) - .where(and(eq(orders.id, order.id), ne(orders.paymentStatus, 'failed'))) - .returning({ id: orders.id }); + .where( + and(eq(orders.id, order.id), ne(orders.paymentStatus, 'failed')) + ); - if (updated.length > 0) { + if (shouldRestockFromWebhook(order)) { await restockOrder(order.id, { reason: 'canceled' }); } @@ -611,16 +626,44 @@ export async function POST(request: NextRequest) { if (eventType === 'charge.refunded') { const effectiveCharge = charge; + const amt = typeof (effectiveCharge as any)?.amount === 'number' ? (effectiveCharge as any).amount : null; - const refunded = + + let cumulativeRefunded: number | null = typeof (effectiveCharge as any)?.amount_refunded === 'number' ? (effectiveCharge as any).amount_refunded : null; - isFullRefund = amt != null && refunded != null && refunded === amt; + // Fallback: sum refunds list if amount_refunded is missing + if ( + cumulativeRefunded == null && + Array.isArray((effectiveCharge as any)?.refunds?.data) + ) { + const list = (effectiveCharge as any).refunds.data as any[]; + const sumFromList = list.reduce((sum, r) => { + const a = typeof r?.amount === 'number' ? r.amount : 0; + return sum + a; + }, 0); + + const currentAmt = + typeof (refund as any)?.amount === 'number' + ? (refund as any).amount + : 0; + + const hasCurrent = + refund?.id && list.some(r => r?.id && r.id === refund.id); + + cumulativeRefunded = sumFromList + (hasCurrent ? 0 : currentAmt); + } + + if (amt == null || cumulativeRefunded == null) { + throw new Error('REFUND_FULLNESS_UNDETERMINED'); + } + + isFullRefund = cumulativeRefunded === amt; } else if (eventType === 'charge.refund.updated' && refund) { // Ensure we have the Charge to compute cumulative refunded correctly. let effectiveCharge: Stripe.Charge | undefined; @@ -735,7 +778,9 @@ export async function POST(request: NextRequest) { and(eq(orders.id, order.id), ne(orders.paymentStatus, 'refunded')) ); - await restockOrder(order.id, { reason: 'refunded' }); + if (shouldRestockFromWebhook(order)) { + await restockOrder(order.id, { reason: 'refunded' }); + } logWebhookEvent({ orderId: order.id, diff --git a/frontend/lib/psp/stripe.ts b/frontend/lib/psp/stripe.ts index 7a6c5e37..6f3fb8d4 100644 --- a/frontend/lib/psp/stripe.ts +++ b/frontend/lib/psp/stripe.ts @@ -1,141 +1,146 @@ -import Stripe from 'stripe'; -import { getStripeEnv } from '@/lib/env/stripe'; -import { logError } from '@/lib/logging'; +import Stripe from "stripe"; +import { getStripeEnv } from "@/lib/env/stripe"; +import { logError } from "@/lib/logging"; type CreatePaymentIntentInput = { - amount: number; - currency: string; - orderId: string; - idempotencyKey?: string; + amount: number; + currency: string; + orderId: string; + idempotencyKey?: string; }; let _stripe: Stripe | null = null; let _stripeKey: string | null = null; function getStripeClient(): Stripe | null { - const { secretKey } = getStripeEnv(); - if (!secretKey) return null; + const { secretKey } = getStripeEnv(); + if (!secretKey) return null; - if (_stripe && _stripeKey === secretKey) return _stripe; - _stripeKey = secretKey; + if (_stripe && _stripeKey === secretKey) return _stripe; + _stripeKey = secretKey; - _stripe = new Stripe(secretKey, { - apiVersion: '2025-11-17.clover', - }); + _stripe = new Stripe(secretKey, { + apiVersion: "2025-11-17.clover", + }); - return _stripe; + return _stripe; } export async function createPaymentIntent({ - amount, - currency, - orderId, - idempotencyKey, + amount, + currency, + orderId, + idempotencyKey, }: CreatePaymentIntentInput): Promise<{ - clientSecret: string; - paymentIntentId: string; + clientSecret: string; + paymentIntentId: string; }> { - const { paymentsEnabled, mode } = getStripeEnv(); - const stripe = getStripeClient(); - - if (!paymentsEnabled || !stripe) { - throw new Error('STRIPE_DISABLED'); - } - - if (!Number.isFinite(amount) || amount <= 0) { - throw new Error('STRIPE_INVALID_AMOUNT'); - } - - try { - const intent = await stripe.paymentIntents.create( - { - amount, - currency: currency.toLowerCase(), - metadata: { orderId, mode: mode ?? 'test' }, - automatic_payment_methods: { enabled: true }, - }, - idempotencyKey ? { idempotencyKey } : undefined - ); - - if (!intent.client_secret) { - throw new Error('STRIPE_CLIENT_SECRET_MISSING'); - } - - return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; - } catch (error) { - logError('Stripe payment intent creation failed', error); - throw new Error('STRIPE_PAYMENT_INTENT_FAILED'); - } + const { paymentsEnabled, mode } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe) { + throw new Error("STRIPE_DISABLED"); + } + + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error("STRIPE_INVALID_AMOUNT"); + } + + try { + const intent = await stripe.paymentIntents.create( + { + amount, + currency: currency.toLowerCase(), + metadata: { orderId, mode: mode ?? "test" }, + automatic_payment_methods: { enabled: true }, + }, + idempotencyKey ? { idempotencyKey } : undefined, + ); + + if (!intent.client_secret) { + throw new Error("STRIPE_CLIENT_SECRET_MISSING"); + } + + return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; + } catch (error) { + logError("Stripe payment intent creation failed", error); + throw new Error("STRIPE_PAYMENT_INTENT_FAILED"); + } } export async function retrievePaymentIntent(paymentIntentId: string): Promise<{ - clientSecret: string; - paymentIntentId: string; + clientSecret: string; + paymentIntentId: string; }> { - const { paymentsEnabled } = getStripeEnv(); - const stripe = getStripeClient(); - - if (!paymentsEnabled || !stripe) { - throw new Error('STRIPE_DISABLED'); - } - - try { - const intent = await stripe.paymentIntents.retrieve(paymentIntentId); - if (!intent.client_secret) throw new Error('STRIPE_CLIENT_SECRET_MISSING'); - return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; - } catch (error) { - logError('Stripe payment intent retrieval failed', error); - throw new Error('STRIPE_PAYMENT_INTENT_FAILED'); - } + const { paymentsEnabled } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe) { + throw new Error("STRIPE_DISABLED"); + } + + if (!paymentIntentId || paymentIntentId.trim().length === 0) { + throw new Error("STRIPE_INVALID_PAYMENT_INTENT_ID"); + } + + try { + const intent = await stripe.paymentIntents.retrieve(paymentIntentId); + if (!intent.client_secret) throw new Error("STRIPE_CLIENT_SECRET_MISSING"); + return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; + } catch (error) { + logError("Stripe payment intent retrieval failed", error); + throw new Error("STRIPE_PAYMENT_INTENT_FAILED"); + } } + export async function retrieveCharge(chargeId: string): Promise { - const { paymentsEnabled } = getStripeEnv(); - const stripe = getStripeClient(); - - if (!paymentsEnabled || !stripe) { - throw new Error('STRIPE_DISABLED'); - } - - if (!chargeId || chargeId.trim().length === 0) { - throw new Error('STRIPE_INVALID_CHARGE_ID'); - } - - try { - return await stripe.charges.retrieve(chargeId); - } catch (error) { - logError('Stripe charge retrieval failed', error); - throw new Error('STRIPE_CHARGE_RETRIEVE_FAILED'); - } + const { paymentsEnabled } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe) { + throw new Error("STRIPE_DISABLED"); + } + + if (!chargeId || chargeId.trim().length === 0) { + throw new Error("STRIPE_INVALID_CHARGE_ID"); + } + + try { + return await stripe.charges.retrieve(chargeId); + } catch (error) { + logError("Stripe charge retrieval failed", error); + throw new Error("STRIPE_CHARGE_RETRIEVE_FAILED"); + } } type VerifyWebhookSignatureInput = { - rawBody: string; - signatureHeader: string | null; + rawBody: string; + signatureHeader: string | null; }; export function verifyWebhookSignature({ - rawBody, - signatureHeader, + rawBody, + signatureHeader, }: VerifyWebhookSignatureInput): Stripe.Event { - const { paymentsEnabled, webhookSecret } = getStripeEnv(); - const stripe = getStripeClient(); - - if (!paymentsEnabled || !stripe || !webhookSecret) { - throw new Error('STRIPE_WEBHOOK_DISABLED'); - } - - if (!signatureHeader) { - throw new Error('STRIPE_MISSING_SIGNATURE'); - } - - try { - return stripe.webhooks.constructEvent( - rawBody, - signatureHeader, - webhookSecret - ); - } catch (error) { - logError('Stripe webhook signature verification failed', error); - throw new Error('STRIPE_INVALID_SIGNATURE'); - } + const { paymentsEnabled, webhookSecret } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe || !webhookSecret) { + throw new Error("STRIPE_WEBHOOK_DISABLED"); + } + + if (!signatureHeader) { + throw new Error("STRIPE_MISSING_SIGNATURE"); + } + + try { + return stripe.webhooks.constructEvent( + rawBody, + signatureHeader, + webhookSecret, + ); + } catch (error) { + logError("Stripe webhook signature verification failed", error); + throw new Error("STRIPE_INVALID_SIGNATURE"); + } } diff --git a/frontend/lib/tests/checkout-concurrency-stock1.test.ts b/frontend/lib/tests/checkout-concurrency-stock1.test.ts index f6c03855..922e6848 100644 --- a/frontend/lib/tests/checkout-concurrency-stock1.test.ts +++ b/frontend/lib/tests/checkout-concurrency-stock1.test.ts @@ -1,5 +1,5 @@ // lib/tests/checkout-concurrency-stock1.test.ts -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import crypto from 'crypto'; import { NextRequest } from 'next/server'; import { eq, inArray } from 'drizzle-orm'; @@ -13,8 +13,7 @@ import { inventoryMoves, } from '@/db/schema/shop'; -import { POST as checkoutPOST } from '@/app/api/shop/checkout/route'; -import { vi } from 'vitest'; +// NOTE: checkout route will be imported dynamically after mocks are installed. vi.mock('@/lib/auth', async () => { const actual = await vi.importActual('@/lib/auth'); @@ -122,6 +121,9 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = // ---------- Helper: call checkout with given idempotency key ---------- const baseUrl = 'http://localhost:3000'; + const { POST: checkoutPOST } = await import( + '@/app/api/shop/checkout/route' + ); async function callCheckout(idemKey: string) { const body = JSON.stringify({ diff --git a/frontend/lib/tests/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/order-items-snapshot-immutable.test.ts index fcd4c0a2..4089e875 100644 --- a/frontend/lib/tests/order-items-snapshot-immutable.test.ts +++ b/frontend/lib/tests/order-items-snapshot-immutable.test.ts @@ -1,219 +1,229 @@ // lib/tests/order-items-snapshot-immutable.test.ts -import { describe, it, expect, vi } from 'vitest'; -import { NextRequest } from 'next/server'; -import { eq, and } from 'drizzle-orm'; -import { randomUUID } from 'crypto'; +import { randomUUID } from "node:crypto"; -import { db } from '@/db'; +import { and, eq } from "drizzle-orm"; +import { NextRequest } from "next/server"; +import { describe, expect, it, vi } from "vitest"; + +import { db } from "@/db"; import { - products, - productPrices, - orders, - orderItems, - inventoryMoves, -} from '@/db/schema'; + inventoryMoves, + orderItems, + orders, + productPrices, + products, +} from "@/db/schema"; // IMPORTANT: checkout route calls getCurrentUser(), which uses next/headers cookies() // In vitest there is no request scope, so we must mock it to avoid noisy error + flakiness. -vi.mock('@/lib/auth', async () => { - const actual = await vi.importActual>('@/lib/auth'); - return { - ...actual, - getCurrentUser: vi.fn(async () => null), - }; +vi.mock("@/lib/auth", async () => { + const actual = await vi.importActual>("@/lib/auth"); + return { + ...actual, + getCurrentUser: vi.fn(async () => null), + }; }); // Force "no-payments" path so checkout never touches Stripe network. // This test is only about snapshot immutability. -vi.mock('@/lib/env/stripe', async () => { - const actual = await vi.importActual>( - '@/lib/env/stripe' - ); - return { - ...actual, - isPaymentsEnabled: () => false, - }; +vi.mock("@/lib/env/stripe", async () => { + const actual = + await vi.importActual>("@/lib/env/stripe"); + return { + ...actual, + isPaymentsEnabled: () => false, + }; }); -import { POST as checkoutPOST } from '@/app/api/shop/checkout/route'; - type CheckoutResponse = { - success: boolean; - orderId?: string; - order?: { id?: string }; + success: boolean; + orderId?: string; + order?: { id?: string }; }; function makeJsonRequest( - url: string, - body: unknown, - headers: Record + url: string, + body: unknown, + headers: Record, ) { - return new NextRequest(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - } as any); + return new NextRequest(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); } async function cleanupByIds(params: { orderId?: string; productId: string }) { - const { orderId, productId } = params; + const { orderId, productId } = params; - if (orderId) { - // delete children first - await db.delete(inventoryMoves).where(eq(inventoryMoves.orderId, orderId)); - await db.delete(orderItems).where(eq(orderItems.orderId, orderId)); - await db.delete(orders).where(eq(orders.id, orderId)); - } + if (orderId) { + // delete children first + await db.delete(inventoryMoves).where(eq(inventoryMoves.orderId, orderId)); + await db.delete(orderItems).where(eq(orderItems.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); + } - await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(productPrices).where(eq(productPrices.productId, productId)); - await db.delete(products).where(eq(products.id, productId)); + await db.delete(products).where(eq(products.id, productId)); } -describe('P0-6 snapshots: order_items immutability', () => { - it('snapshot fields must not change after products/product_prices update', async () => { - const productId = randomUUID(); - const priceId = randomUUID(); - - const titleV1 = 'Snapshot Test Product'; - const slugV1 = `snapshot-test-${productId.slice(0, 8)}`; - const skuV1 = `SKU-${productId.slice(0, 8)}`; - - // Seed product (USD-only per your CHECK constraint) - await db.insert(products).values({ - id: productId, - slug: slugV1, - title: titleV1, - description: 'snapshot test', - imageUrl: 'https://res.cloudinary.com/devlovers/image/upload/v1/test.png', - imagePublicId: null, - price: '9.00', - originalPrice: null, - currency: 'USD', - category: null, - type: null, - colors: [], - sizes: [], - badge: 'NONE', - isActive: true, - isFeatured: false, - stock: 10, - sku: skuV1, - }); - - // Seed product_prices (USD) - await db.insert(productPrices).values({ - id: priceId, - productId, - currency: 'USD', - priceMinor: 900, - originalPriceMinor: null, - price: '9.00', - originalPrice: null, - }); - - const idem = randomUUID(); - const req = makeJsonRequest( - 'http://localhost:3000/api/shop/checkout', - { items: [{ productId, quantity: 1 }] }, - { - 'Accept-Language': 'en-US,en;q=0.9', - 'Content-Type': 'application/json', - 'Idempotency-Key': idem, - } - ); - - const res = await checkoutPOST(req); - - // Your checkout returns 201 Created on success. - expect(res.status).toBeGreaterThanOrEqual(200); - expect(res.status).toBeLessThan(300); - - const json = (await res.json()) as CheckoutResponse; - expect(json.success).toBe(true); - - const orderId = json.orderId ?? json.order?.id; - expect(typeof orderId).toBe('string'); - if (!orderId) throw new Error('Missing orderId from checkout response'); - - try { - // Baseline snapshot - const before = await db - .select({ - orderId: orderItems.orderId, - productId: orderItems.productId, - quantity: orderItems.quantity, - unitPriceMinor: orderItems.unitPriceMinor, - lineTotalMinor: orderItems.lineTotalMinor, - productTitle: orderItems.productTitle, - productSlug: orderItems.productSlug, - productSku: orderItems.productSku, - }) - .from(orderItems) - .where(eq(orderItems.orderId, orderId)); - - expect(before.length).toBe(1); - expect(before[0].productId).toBe(productId); - expect(before[0].productTitle).toBe(titleV1); - expect(before[0].productSlug).toBe(slugV1); - expect(before[0].productSku).toBe(skuV1); - expect(before[0].unitPriceMinor).toBe(900); - expect(before[0].lineTotalMinor).toBe(900); - - // Mutate product + product_prices aggressively (attempt to "break" snapshots) - const titleV2 = `${titleV1} UPDATED`; - const slugV2 = `${slugV1}-updated`; - const skuV2 = `${skuV1}-UPDATED`; - - await db - .update(products) - .set({ - title: titleV2, - slug: slugV2, - sku: skuV2, - updatedAt: new Date(), - }) - .where(eq(products.id, productId)); - - await db - .update(productPrices) - .set({ - priceMinor: 1000, - price: '10.00', - updatedAt: new Date(), - }) - .where( - and( - eq(productPrices.productId, productId), - eq(productPrices.currency, 'USD') - ) - ); - - const after = await db - .select({ - orderId: orderItems.orderId, - productId: orderItems.productId, - quantity: orderItems.quantity, - unitPriceMinor: orderItems.unitPriceMinor, - lineTotalMinor: orderItems.lineTotalMinor, - productTitle: orderItems.productTitle, - productSlug: orderItems.productSlug, - productSku: orderItems.productSku, - }) - .from(orderItems) - .where(eq(orderItems.orderId, orderId)); - - expect(after.length).toBe(1); - - // Snapshot MUST remain V1 even after product changes - expect(after[0]).toEqual(before[0]); - } finally { - try { - await cleanupByIds({ orderId, productId }); - } catch (e) { - console.error('[test cleanup failed]', { orderId, productId }, e); - throw e; - } - } - }, 30_000); +describe("P0-6 snapshots: order_items immutability", () => { + it("snapshot fields must not change after products/product_prices update", async () => { + const productId = randomUUID(); + const priceId = randomUUID(); + + const titleV1 = "Snapshot Test Product"; + const slugV1 = `snapshot-test-${productId.slice(0, 8)}`; + const skuV1 = `SKU-${productId.slice(0, 8)}`; + + // Seed product (USD-only per your CHECK constraint) + await db.insert(products).values({ + id: productId, + slug: slugV1, + title: titleV1, + description: "snapshot test", + imageUrl: "https://res.cloudinary.com/devlovers/image/upload/v1/test.png", + imagePublicId: null, + price: "9.00", + originalPrice: null, + currency: "USD", + category: null, + type: null, + colors: [], + sizes: [], + badge: "NONE", + isActive: true, + isFeatured: false, + stock: 10, + sku: skuV1, + }); + + // Seed product_prices (USD) + await db.insert(productPrices).values({ + id: priceId, + productId, + currency: "USD", + priceMinor: 900, + originalPriceMinor: null, + price: "9.00", + originalPrice: null, + }); + + const idem = randomUUID(); + const req = makeJsonRequest( + "http://localhost:3000/api/shop/checkout", + { items: [{ productId, quantity: 1 }] }, + { + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/json", + "Idempotency-Key": idem, + }, + ); + const { POST: checkoutPOST } = await import( + "@/app/api/shop/checkout/route" + ); + + const res = await checkoutPOST(req); + + // Your checkout returns 201 Created on success. + expect(res.status).toBeGreaterThanOrEqual(200); + expect(res.status).toBeLessThan(300); + + const json = (await res.json()) as CheckoutResponse; + expect(json.success).toBe(true); + + const orderId = json.orderId ?? json.order?.id; + expect(typeof orderId).toBe("string"); + if (!orderId) throw new Error("Missing orderId from checkout response"); + + let primaryError: unknown = null; + let cleanupError: unknown = null; + + try { + // Baseline snapshot + const before = await db + .select({ + orderId: orderItems.orderId, + productId: orderItems.productId, + quantity: orderItems.quantity, + unitPriceMinor: orderItems.unitPriceMinor, + lineTotalMinor: orderItems.lineTotalMinor, + productTitle: orderItems.productTitle, + productSlug: orderItems.productSlug, + productSku: orderItems.productSku, + }) + .from(orderItems) + .where(eq(orderItems.orderId, orderId)); + + expect(before.length).toBe(1); + expect(before[0].productId).toBe(productId); + expect(before[0].productTitle).toBe(titleV1); + expect(before[0].productSlug).toBe(slugV1); + expect(before[0].productSku).toBe(skuV1); + expect(before[0].unitPriceMinor).toBe(900); + expect(before[0].lineTotalMinor).toBe(900); + + // Mutate product + product_prices aggressively (attempt to "break" snapshots) + const titleV2 = `${titleV1} UPDATED`; + const slugV2 = `${slugV1}-updated`; + const skuV2 = `${skuV1}-UPDATED`; + + await db + .update(products) + .set({ + title: titleV2, + slug: slugV2, + sku: skuV2, + updatedAt: new Date(), + }) + .where(eq(products.id, productId)); + + await db + .update(productPrices) + .set({ + priceMinor: 1000, + price: "10.00", + updatedAt: new Date(), + }) + .where( + and( + eq(productPrices.productId, productId), + eq(productPrices.currency, "USD"), + ), + ); + + const after = await db + .select({ + orderId: orderItems.orderId, + productId: orderItems.productId, + quantity: orderItems.quantity, + unitPriceMinor: orderItems.unitPriceMinor, + lineTotalMinor: orderItems.lineTotalMinor, + productTitle: orderItems.productTitle, + productSlug: orderItems.productSlug, + productSku: orderItems.productSku, + }) + .from(orderItems) + .where(eq(orderItems.orderId, orderId)); + + expect(after.length).toBe(1); + + // Snapshot MUST remain V1 even after product changes + expect(after[0]).toEqual(before[0]); + } catch (e) { + primaryError = e; + throw e; + } finally { + try { + await cleanupByIds({ orderId, productId }); + } catch (e) { + cleanupError = e; + console.error("[test cleanup failed]", { orderId, productId }, e); + } + } + if (!primaryError && cleanupError) { + throw cleanupError; + } + }, 30_000); }); diff --git a/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts b/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts index bd88dbb5..b952cb46 100644 --- a/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts +++ b/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts @@ -1,3 +1,5 @@ +// C:\Users\milka\devlovers.net\frontend\lib\tests\stripe-webhook-paid-status-repair.test.ts + import crypto from 'crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { eq } from 'drizzle-orm'; @@ -10,7 +12,7 @@ async function seedOrder(params: { orderId: string; pi: string }) { const now = new Date(); await db.insert(orders).values({ id: params.orderId, - idempotencyKey: `test:${crypto.randomUUID()}`, // required in your schema + idempotencyKey: `test:${crypto.randomUUID()}`, totalAmountMinor: 2500, totalAmount: toDbMoney(2500), currency: 'USD', @@ -26,8 +28,14 @@ async function seedOrder(params: { orderId: string; pi: string }) { }); } -async function callWebhook(params: { eventId: string; pi: string; orderId: string }) { +async function callWebhook(params: { + eventId: string; + pi: string; + orderId: string; +}) { + // Keep this pattern: reset module cache so route picks up env + mocks. vi.resetModules(); + vi.doMock('@/lib/psp/stripe', async () => { const actual = await vi.importActual('@/lib/psp/stripe'); return { @@ -49,8 +57,9 @@ async function callWebhook(params: { eventId: string; pi: string; orderId: strin }; }); - process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; - process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_dummy'; + // Task #5: avoid process.env mutation; use stubEnv + restore in afterEach. + vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_dummy'); + vi.stubEnv('STRIPE_WEBHOOK_SECRET', 'whsec_test_dummy'); const { POST } = await import('@/app/api/shop/webhooks/stripe/route'); @@ -102,8 +111,13 @@ describe('stripe webhook: repair paid status mismatch', () => { let lastEventId: string | null = null; afterEach(async () => { - if (!lastOrderId || !lastEventId) return; - await cleanupByIds({ orderId: lastOrderId, eventId: lastEventId }); + // restore env stubs to avoid cross-test coupling + vi.unstubAllEnvs(); + + if (lastOrderId && lastEventId) { + await cleanupByIds({ orderId: lastOrderId, eventId: lastEventId }); + } + lastOrderId = null; lastEventId = null; }); diff --git a/frontend/lib/tests/stripe-webhook-refund-full.test.ts b/frontend/lib/tests/stripe-webhook-refund-full.test.ts index db869ca7..3854ecb4 100644 --- a/frontend/lib/tests/stripe-webhook-refund-full.test.ts +++ b/frontend/lib/tests/stripe-webhook-refund-full.test.ts @@ -1,22 +1,36 @@ // frontend/lib/tests/stripe-webhook-refund-full.test.ts -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import crypto from 'crypto'; -import { NextRequest } from 'next/server'; + +import crypto from 'node:crypto'; import { eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type Stripe from 'stripe'; -vi.mock('@/lib/psp/stripe', () => ({ - verifyWebhookSignature: vi.fn(), -})); +vi.mock('@/lib/psp/stripe', async () => { + const actual = await vi.importActual( + '@/lib/psp/stripe' + ); + + return { + ...actual, + verifyWebhookSignature: vi.fn(), + retrieveCharge: vi.fn(), + }; +}); vi.mock('@/lib/services/orders', () => ({ restockOrder: vi.fn(), })); +import { POST } from '@/app/api/shop/webhooks/stripe/route'; import { db } from '@/db'; import { orders, stripeEvents } from '@/db/schema'; -import { verifyWebhookSignature } from '@/lib/psp/stripe'; +import { retrieveCharge, verifyWebhookSignature } from '@/lib/psp/stripe'; import { restockOrder } from '@/lib/services/orders'; -import { POST } from '@/app/api/shop/webhooks/stripe/route'; + +const verifyWebhookSignatureMock = vi.mocked(verifyWebhookSignature); +const retrieveChargeMock = vi.mocked(retrieveCharge); +const restockOrderMock = vi.mocked(restockOrder); type Inserted = { orderId: string; paymentIntentId: string }; @@ -27,7 +41,7 @@ async function insertPaidOrder(): Promise { const totalAmountMinor = 2500; const totalAmount = (totalAmountMinor / 100).toFixed(2); - await db.insert(orders).values({ + const row: typeof orders.$inferInsert = { id: orderId, userId: null, totalAmountMinor, @@ -41,7 +55,9 @@ async function insertPaidOrder(): Promise { idempotencyKey: `idem_${crypto.randomUUID()}`, stockRestored: false, pspMetadata: {}, - } as any); + }; + + await db.insert(orders).values(row); return { orderId, paymentIntentId }; } @@ -67,6 +83,40 @@ async function cleanupInserted(ins: Inserted) { await db.delete(orders).where(eq(orders.id, ins.orderId)); } +function makeCharge(input: { + chargeId: string; + paymentIntentId: string; + amount: number; + amountRefunded: number; + refunds?: Array<{ + id: string; + amount: number; + status?: string; + reason?: null; + }>; +}): Stripe.Charge { + const refunds = input.refunds ?? []; + return { + id: input.chargeId, + object: 'charge', + payment_intent: input.paymentIntentId, + amount: input.amount, + amount_refunded: input.amountRefunded, + status: 'succeeded', + metadata: {}, + refunds: { + object: 'list', + data: refunds.map(r => ({ + id: r.id, + object: 'refund', + status: r.status ?? 'succeeded', + reason: r.reason ?? null, + amount: r.amount, + })), + }, + } as unknown as Stripe.Charge; +} + describe('stripe webhook refund (full only): PI fallback + terminal status + dedupe', () => { let inserted: Inserted | null = null; @@ -75,7 +125,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded // restockOrder mocked: we don't retest inventory ledger here (it is covered by restock tests), // we only assert webhook triggers it exactly-once and marks order as restocked. - (restockOrder as any).mockImplementation(async (orderId: string) => { + restockOrderMock.mockImplementation(async (orderId: string) => { await db .update(orders) .set({ @@ -98,33 +148,22 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded const eventId = `evt_${crypto.randomUUID()}`; - const charge = { - id: `ch_${crypto.randomUUID()}`, - object: 'charge', - payment_intent: inserted.paymentIntentId, + const chargeId = `ch_${crypto.randomUUID()}`; + const refundId = `re_${crypto.randomUUID()}`; + + const charge = makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, amount: 2500, - amount_refunded: 2500, - status: 'succeeded', - metadata: {}, // IMPORTANT: no orderId -> PI fallback must resolve - refunds: { - object: 'list', - data: [ - { - id: `re_${crypto.randomUUID()}`, - object: 'refund', - status: 'succeeded', - reason: null, - amount: 2500, - }, - ], - }, - }; + amountRefunded: 2500, + refunds: [{ id: refundId, amount: 2500 }], + }); - (verifyWebhookSignature as any).mockReturnValue({ + verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refunded', data: { object: charge }, - }); + } as unknown as Stripe.Event); // 1st call const res1 = await POST(makeRequest()); @@ -146,8 +185,8 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row1.status).toBe('CANCELED'); // terminal status per current enum expect(row1.stockRestored).toBe(true); - expect(restockOrder).toHaveBeenCalledTimes(1); - expect(restockOrder).toHaveBeenCalledWith(inserted.orderId, { + expect(restockOrderMock).toHaveBeenCalledTimes(1); + expect(restockOrderMock).toHaveBeenCalledWith(inserted.orderId, { reason: 'refunded', }); @@ -155,7 +194,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded const res2 = await POST(makeRequest()); expect(res2.status).toBe(200); - expect(restockOrder).toHaveBeenCalledTimes(1); + expect(restockOrderMock).toHaveBeenCalledTimes(1); const events = await db .select({ eventId: stripeEvents.eventId }) @@ -165,12 +204,11 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(events.length).toBe(1); }, 30_000); - it('full refund (charge.refund.updated) WITHOUT metadata.orderId resolves by paymentIntentId, sets terminal status, calls restock once', async () => { + it('full refund (charge.refund.updated) WITHOUT metadata.orderId resolves by paymentIntentId (via retrieveCharge), sets terminal status, calls restock once', async () => { inserted = await insertPaidOrder(); const eventId = `evt_${crypto.randomUUID()}`; const chargeId = `ch_${crypto.randomUUID()}`; - const refundId = `re_${crypto.randomUUID()}`; const refund = { @@ -179,33 +217,27 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded amount: 2500, status: 'succeeded', reason: null, - charge: { - id: chargeId, - object: 'charge', - amount: 2500, - amount_refunded: 2500, - refunds: { - object: 'list', - data: [ - { - id: refundId, - object: 'refund', - status: 'succeeded', - reason: null, - amount: 2500, - }, - ], - }, - }, + charge: chargeId, // IMPORTANT: real Stripe shape is usually string id payment_intent: inserted.paymentIntentId, metadata: {}, }; - (verifyWebhookSignature as any).mockReturnValue({ + // Webhook code should retrieve the charge by id to get cumulative refunded, etc. + retrieveChargeMock.mockResolvedValue( + makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, + amount: 2500, + amountRefunded: 2500, + refunds: [{ id: refundId, amount: 2500 }], + }) + ); + + verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refund.updated', data: { object: refund }, - }); + } as unknown as Stripe.Event); const res = await POST(makeRequest()); expect(res.status).toBe(200); @@ -224,10 +256,13 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.paymentStatus).toBe('refunded'); expect(row.status).toBe('CANCELED'); expect(row.stockRestored).toBe(true); - expect(row.pspChargeId).toBe(chargeId); // requires PATCH 1 + expect(row.pspChargeId).toBe(chargeId); // requires PATCH 1 in webhook + + expect(retrieveChargeMock).toHaveBeenCalledTimes(1); + expect(retrieveChargeMock).toHaveBeenCalledWith(chargeId); - expect(restockOrder).toHaveBeenCalledTimes(1); - expect(restockOrder).toHaveBeenCalledWith(inserted.orderId, { + expect(restockOrderMock).toHaveBeenCalledTimes(1); + expect(restockOrderMock).toHaveBeenCalledWith(inserted.orderId, { reason: 'refunded', }); }, 30_000); @@ -237,33 +272,22 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded const eventId = `evt_${crypto.randomUUID()}`; - const charge = { - id: `ch_${crypto.randomUUID()}`, - object: 'charge', - payment_intent: inserted.paymentIntentId, + const chargeId = `ch_${crypto.randomUUID()}`; + const refundId = `re_${crypto.randomUUID()}`; + + const charge = makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, amount: 2500, - amount_refunded: 1000, // partial - status: 'succeeded', - metadata: {}, // still PI fallback path - refunds: { - object: 'list', - data: [ - { - id: `re_${crypto.randomUUID()}`, - object: 'refund', - status: 'succeeded', - reason: null, - amount: 1000, - }, - ], - }, - }; + amountRefunded: 1000, // partial + refunds: [{ id: refundId, amount: 1000 }], + }); - (verifyWebhookSignature as any).mockReturnValue({ + verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refunded', data: { object: charge }, - }); + } as unknown as Stripe.Event); const res = await POST(makeRequest()); expect(res.status).toBe(200); @@ -282,32 +306,32 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.status).toBe('PAID'); expect(row.stockRestored).toBe(false); - expect(restockOrder).toHaveBeenCalledTimes(0); + expect(restockOrderMock).toHaveBeenCalledTimes(0); }, 30_000); + it('retry after 500 must reprocess same event.id until processedAt is set (restock not lost)', async () => { inserted = await insertPaidOrder(); const eventId = `evt_${crypto.randomUUID()}`; - const charge = { - id: `ch_${crypto.randomUUID()}`, - object: 'charge', - payment_intent: inserted.paymentIntentId, + const chargeId = `ch_${crypto.randomUUID()}`; + + const charge = makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, amount: 2500, - amount_refunded: 2500, - status: 'succeeded', - metadata: {}, // no orderId -> PI fallback - refunds: { object: 'list', data: [] }, - }; + amountRefunded: 2500, + refunds: [], + }); - (verifyWebhookSignature as any).mockReturnValue({ + verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refunded', data: { object: charge }, - }); + } as unknown as Stripe.Event); // first call: restock throws => webhook returns 500 - (restockOrder as any) + restockOrderMock .mockImplementationOnce(async () => { throw new Error('RESTOCK_FAILED'); }) @@ -352,26 +376,15 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(evt.processedAt).not.toBeNull(); }, 30_000); + it('full refund (charge.refund.updated) must use cumulative refunded (not refund.amount) when full consists of multiple partial refunds', async () => { inserted = await insertPaidOrder(); const eventId = `evt_${crypto.randomUUID()}`; const chargeId = `ch_${crypto.randomUUID()}`; - const refund1 = { - id: `re_${crypto.randomUUID()}`, - object: 'refund', - status: 'succeeded', - reason: null, - amount: 1000, - }; - const refund2 = { - id: `re_${crypto.randomUUID()}`, - object: 'refund', - status: 'succeeded', - reason: null, - amount: 1000, - }; + const refund1Id = `re_${crypto.randomUUID()}`; + const refund2Id = `re_${crypto.randomUUID()}`; const refund3Id = `re_${crypto.randomUUID()}`; // current event refund is only 500 (not full by itself) @@ -381,26 +394,82 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded amount: 500, status: 'succeeded', reason: null, - charge: { - id: chargeId, - object: 'charge', - amount: 2500, - amount_refunded: 2500, // cumulative full - refunds: { - object: 'list', - data: [refund1, refund2, { ...refund1, id: refund3Id, amount: 500 }], - }, - }, + charge: chargeId, // IMPORTANT: real Stripe shape is usually string id payment_intent: inserted.paymentIntentId, metadata: {}, }; - (verifyWebhookSignature as any).mockReturnValue({ + // Charge says cumulative refunded is FULL (2500), but refund.amount is only 500. + retrieveChargeMock.mockResolvedValue( + makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, + amount: 2500, + amountRefunded: 2500, + refunds: [ + { id: refund1Id, amount: 1000 }, + { id: refund2Id, amount: 1000 }, + { id: refund3Id, amount: 500 }, + ], + }) + ); + + verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refund.updated', data: { object: refund }, + } as unknown as Stripe.Event); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + + const [row] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, inserted.orderId)) + .limit(1); + + expect(row.paymentStatus).toBe('refunded'); + expect(row.status).toBe('CANCELED'); + expect(row.stockRestored).toBe(true); + + expect(retrieveChargeMock).toHaveBeenCalledTimes(1); + expect(retrieveChargeMock).toHaveBeenCalledWith(chargeId); + + expect(restockOrderMock).toHaveBeenCalledTimes(1); + }, 30_000); + it('charge.refunded: fallback to sum(refunds) when amount_refunded is missing (still detects full refund)', async () => { + inserted = await insertPaidOrder(); + + const eventId = `evt_${crypto.randomUUID()}`; + const chargeId = `ch_${crypto.randomUUID()}`; + const refund1Id = `re_${crypto.randomUUID()}`; + const refund2Id = `re_${crypto.randomUUID()}`; + + const charge = makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, + amount: 2500, + amountRefunded: 2500, // will be deleted to force fallback + refunds: [ + { id: refund1Id, amount: 1000 }, + { id: refund2Id, amount: 1500 }, + ], }); + // force edge-case: Stripe object without amount_refunded + delete (charge as any).amount_refunded; + + verifyWebhookSignatureMock.mockReturnValue({ + id: eventId, + type: 'charge.refunded', + data: { object: charge }, + } as unknown as Stripe.Event); + const res = await POST(makeRequest()); expect(res.status).toBe(200); @@ -418,6 +487,9 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.status).toBe('CANCELED'); expect(row.stockRestored).toBe(true); - expect(restockOrder).toHaveBeenCalledTimes(1); + expect(restockOrderMock).toHaveBeenCalledTimes(1); + expect(restockOrderMock).toHaveBeenCalledWith(inserted.orderId, { + reason: 'refunded', + }); }, 30_000); }); diff --git a/frontend/scripts/shop-janitor-restock-stale.mjs b/frontend/scripts/shop-janitor-restock-stale.mjs index 76be76b7..b4f8eb8a 100644 --- a/frontend/scripts/shop-janitor-restock-stale.mjs +++ b/frontend/scripts/shop-janitor-restock-stale.mjs @@ -18,13 +18,32 @@ const DEFAULT_TIMEOUT_MS = 25_000; const MIN_TIMEOUT_MS = 1_000; const rawTimeout = (process.env.JANITOR_TIMEOUT_MS ?? '').trim(); -const n = Number.parseInt(rawTimeout, 10); -// NaN / '' / abc / 0 / negative -> default -const timeoutMs = - Number.isFinite(n) && n > 0 - ? Math.max(MIN_TIMEOUT_MS, n) - : DEFAULT_TIMEOUT_MS; +let timeoutMs = DEFAULT_TIMEOUT_MS; + +if (rawTimeout) { + // strict: only digits, no "123abc", no floats, no underscores + if (/^\d+$/.test(rawTimeout)) { + const n = Number(rawTimeout); + if (Number.isSafeInteger(n) && n > 0) { + timeoutMs = Math.max(MIN_TIMEOUT_MS, n); + } else { + console.warn( + '[janitor] Invalid JANITOR_TIMEOUT_MS (non-positive/out of range). Using default.', + { + raw: rawTimeout, + } + ); + } + } else { + console.warn( + '[janitor] Invalid JANITOR_TIMEOUT_MS (must be digits). Using default.', + { + raw: rawTimeout, + } + ); + } +} console.log('[janitor] timeoutMs=', timeoutMs, 'raw=', rawTimeout || '(empty)');