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)');