+
- {error ? {error} : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
- )
+ );
}
diff --git a/frontend/components/shop/header/nav-links.tsx b/frontend/components/shop/header/nav-links.tsx
index 267f34cc..c515a02e 100644
--- a/frontend/components/shop/header/nav-links.tsx
+++ b/frontend/components/shop/header/nav-links.tsx
@@ -29,7 +29,7 @@ interface NavLinksProps {
}
export function NavLinks({ className, onNavigate, showAdminLink = false }: NavLinksProps) {
- const pathname = usePathname(); // i18n-aware (без /{locale} префіксу)
+ const pathname = usePathname();
const searchParams = useSearchParams();
const currentCategory = searchParams.get('category');
@@ -40,9 +40,6 @@ export function NavLinks({ className, onNavigate, showAdminLink = false }: NavLi
const linkParams = new URLSearchParams(linkQuery ?? '');
const linkCategory = linkParams.get('category');
- // Правило:
- // - "All Products" активний тільки коли немає category в URL
- // - category-лінк активний тільки коли category збігається
const isActive =
pathname === linkPath &&
(linkCategory ? currentCategory === linkCategory : !currentCategory);
diff --git a/frontend/db/queries/shop/products.ts b/frontend/db/queries/shop/products.ts
index 460d6cae..d8ea8966 100644
--- a/frontend/db/queries/shop/products.ts
+++ b/frontend/db/queries/shop/products.ts
@@ -114,7 +114,7 @@ type PublicProductRow = Pick<
> & {
price: string;
originalPrice: string | null;
- currency: CurrencyCode; // фактично буде "USD" | "UAH" зараз
+ currency: CurrencyCode;
};
function mapRowToDbProduct(row: PublicProductRow): DbProduct {
@@ -197,7 +197,7 @@ export async function getActiveProducts(
}
export async function getActiveProductsPage(options: {
- currency: CurrencyCode; // ✅ ВАЖЛИВО
+ currency: CurrencyCode;
limit: number;
offset: number;
slugs?: string[];
@@ -230,7 +230,6 @@ export async function getActiveProductsPage(options: {
return { items: rows.map(mapRowToDbProduct), total: totalCount };
}
-// (може бути потрібний для адмінки/внутрішнього використання)
export async function getProductBySlug(
slug: string,
currency: CurrencyCode
diff --git a/frontend/lib/auth/internal-janitor.ts b/frontend/lib/auth/internal-janitor.ts
index 77459c0f..5c7ad629 100644
--- a/frontend/lib/auth/internal-janitor.ts
+++ b/frontend/lib/auth/internal-janitor.ts
@@ -3,10 +3,25 @@ import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
function timingSafeEqual(a: string, b: string) {
- const aBuf = Buffer.from(a);
- const bBuf = Buffer.from(b);
- if (aBuf.length !== bBuf.length) return false;
- return crypto.timingSafeEqual(aBuf, bBuf);
+ const aBuf = Buffer.from(a, 'utf8');
+ const bBuf = Buffer.from(b, 'utf8');
+
+ // Pad both buffers to the same length to avoid length-based early return timing leak.
+ // Ensure min length 1 because timingSafeEqual requires non-zero length buffers.
+ const maxLen = Math.max(aBuf.length, bBuf.length, 1);
+
+ const aPadded = Buffer.alloc(maxLen);
+ const bPadded = Buffer.alloc(maxLen);
+
+ aBuf.copy(aPadded);
+ bBuf.copy(bPadded);
+
+ const equalPadded = crypto.timingSafeEqual(aPadded, bPadded);
+
+ // Length check AFTER timingSafeEqual; no early return.
+ const lengthEqual = aBuf.length === bBuf.length;
+
+ return equalPadded && lengthEqual;
}
export function requireInternalJanitorAuth(
diff --git a/frontend/lib/logging.ts b/frontend/lib/logging.ts
index c7fdd341..a35f54b6 100644
--- a/frontend/lib/logging.ts
+++ b/frontend/lib/logging.ts
@@ -1,5 +1,5 @@
export function logError(context: string, error: unknown) {
- const isProd = process.env.NODE_ENV === "production";
+ const isProd = process.env.NODE_ENV === 'production';
if (isProd) {
if (error instanceof Error) {
@@ -13,9 +13,7 @@ export function logError(context: string, error: unknown) {
console.error(context, error);
}
-
export function logWarn(message: string, meta?: Record
) {
- // ВАЖЛИВО: console.warn -> stderr, нам треба stdout, щоб не спамити stderr у тестах/CI
if (meta) console.info(`WARN: ${message}`, meta);
else console.info(`WARN: ${message}`);
}
diff --git a/frontend/lib/psp/stripe.ts b/frontend/lib/psp/stripe.ts
index 94d9aecf..7a6c5e37 100644
--- a/frontend/lib/psp/stripe.ts
+++ b/frontend/lib/psp/stripe.ts
@@ -88,6 +88,25 @@ export async function retrievePaymentIntent(paymentIntentId: string): Promise<{
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');
+ }
+}
type VerifyWebhookSignatureInput = {
rawBody: string;
diff --git a/frontend/lib/services/orders.ts b/frontend/lib/services/orders.ts
index 2f58d529..13c30058 100644
--- a/frontend/lib/services/orders.ts
+++ b/frontend/lib/services/orders.ts
@@ -1108,7 +1108,7 @@ export async function restockOrder(
return;
}
- // Stripe (or any non-none provider): stale orphan must become terminal, иначе sweep будет подбирать снова.
+ // Stripe (or any non-none provider): stale orphan must become terminal
if (reason === 'stale') {
const now = new Date();
await db
diff --git a/frontend/lib/services/products.ts b/frontend/lib/services/products.ts
index 4275b4a5..1b693b04 100644
--- a/frontend/lib/services/products.ts
+++ b/frontend/lib/services/products.ts
@@ -574,7 +574,7 @@ export async function updateProduct(
}
try {
- // 1) upsert prices (якщо прийшли)
+ // 1) upsert prices
if (prices.length) {
const upsertRows = prices.map(p => {
const priceMinor = p.priceMinor;
diff --git a/frontend/lib/shop/data.ts b/frontend/lib/shop/data.ts
index 4cb279c1..ae992475 100644
--- a/frontend/lib/shop/data.ts
+++ b/frontend/lib/shop/data.ts
@@ -72,7 +72,6 @@ export async function getProductPageData(
if (dbProduct) {
const mapped = mapToShopProduct(dbProduct);
if (mapped) return { kind: "available", product: mapped };
- // якщо валідатор/маппер впав — не “видаємо” биті дані
return { kind: "not_found" };
}
diff --git a/frontend/lib/tests/admin-product-sale-contract.test.ts b/frontend/lib/tests/admin-product-sale-contract.test.ts
index 62ca05c2..1f52d99a 100644
--- a/frontend/lib/tests/admin-product-sale-contract.test.ts
+++ b/frontend/lib/tests/admin-product-sale-contract.test.ts
@@ -47,12 +47,15 @@ function makeFile(): File {
function makeFormData(payload?: {
badge?: string;
prices?: unknown;
+ pricesRaw?: string;
}): FormData {
const fd = new FormData();
fd.append('image', makeFile());
if (payload?.badge) fd.append('badge', payload.badge);
- if (payload?.prices) fd.append('prices', JSON.stringify(payload.prices));
+
+ if (payload?.pricesRaw != null) fd.append('prices', payload.pricesRaw);
+ else if (payload?.prices) fd.append('prices', JSON.stringify(payload.prices));
return fd;
}
@@ -155,4 +158,59 @@ describe('P1-3 SALE rule end-to-end contract: admin products API returns stable
rule: 'greater_than_price',
});
});
+ it('POST /api/shop/admin/products: invalid prices JSON -> 400 INVALID_PRICES_JSON', async () => {
+ parseAdminProductFormMock.mockImplementation(() => {
+ throw new Error('parseAdminProductForm must not be called');
+ });
+
+ const { POST } = await import('@/app/api/shop/admin/products/route');
+
+ const req = new NextRequest(
+ new Request('http://localhost/api/shop/admin/products', {
+ method: 'POST',
+ body: makeFormData({ badge: 'SALE', pricesRaw: '{' }),
+ })
+ );
+
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+
+ const json = await res.json();
+ expect(json.code).toBe('INVALID_PRICES_JSON');
+ expect(json.field).toBe('prices');
+
+ expect(productsServiceMock.createProduct).not.toHaveBeenCalled();
+ expect(parseAdminProductFormMock).not.toHaveBeenCalled();
+ });
+
+ it('PATCH /api/shop/admin/products/:id: invalid prices JSON -> 400 INVALID_PRICES_JSON', async () => {
+ parseAdminProductFormMock.mockImplementation(() => {
+ throw new Error('parseAdminProductForm must not be called');
+ });
+
+ const { PATCH } = await import('@/app/api/shop/admin/products/[id]/route');
+
+ const req = new NextRequest(
+ new Request(
+ 'http://localhost/api/shop/admin/products/11111111-1111-4111-8111-111111111111',
+ {
+ method: 'PATCH',
+ body: makeFormData({ badge: 'SALE', pricesRaw: '{' }),
+ }
+ )
+ );
+
+ const res = await PATCH(req, {
+ params: Promise.resolve({ id: '11111111-1111-4111-8111-111111111111' }),
+ });
+
+ expect(res.status).toBe(400);
+
+ const json = await res.json();
+ expect(json.code).toBe('INVALID_PRICES_JSON');
+ expect(json.field).toBe('prices');
+
+ expect(productsServiceMock.updateProduct).not.toHaveBeenCalled();
+ expect(parseAdminProductFormMock).not.toHaveBeenCalled();
+ });
});
diff --git a/frontend/lib/tests/checkout-concurrency-stock1.test.ts b/frontend/lib/tests/checkout-concurrency-stock1.test.ts
index 88cabaf4..f6c03855 100644
--- a/frontend/lib/tests/checkout-concurrency-stock1.test.ts
+++ b/frontend/lib/tests/checkout-concurrency-stock1.test.ts
@@ -14,6 +14,15 @@ import {
} from '@/db/schema/shop';
import { POST as checkoutPOST } from '@/app/api/shop/checkout/route';
+import { vi } from 'vitest';
+
+vi.mock('@/lib/auth', async () => {
+ const actual = await vi.importActual('@/lib/auth');
+ return {
+ ...actual,
+ getCurrentUser: async () => null, // avoid cookies() in vitest
+ };
+});
type JsonValue = any;
@@ -60,15 +69,11 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () =
const originalEnv: Record = {};
beforeAll(() => {
- // зберегти оригінальні env значення тільки для stripe-ключів
for (const k of stripeKeys) originalEnv[k] = process.env[k];
-
- // тест має бути незалежним від Stripe — гасимо stripe env
for (const k of stripeKeys) delete process.env[k];
});
afterAll(() => {
- // відновити env
for (const k of stripeKeys) {
const v = originalEnv[k];
if (v === undefined) delete process.env[k];
@@ -81,13 +86,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () =
const productId = crypto.randomUUID();
const slug = `__test_checkout_concurrency_${productId.slice(0, 8)}`;
const now = new Date();
-
- /**
- * ВАЖЛИВО:
- * products.image_url у твоїй БД NOT NULL -> треба передати imageUrl.
- * Якщо у твоїй Drizzle-схемі поле назване інакше (imageURL / image_url),
- * заміни ключ нижче.
- */
await db.insert(products).values({
id: productId,
slug,
@@ -110,7 +108,7 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () =
productId,
currency: 'USD',
- // minor-units (твоя поточна модель)
+ // minor-units
priceMinor: 1000,
originalPriceMinor: null,
@@ -175,11 +173,8 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () =
expect(success.length).toBe(1);
expect(fail.length).toBe(1);
- // Не допускаємо 5xx (має бути контрольований бізнес-фейл)
expect(fail[0].status).toBeGreaterThanOrEqual(400);
expect(fail[0].status).toBeLessThan(500);
-
- // Додатково (м’яко): якщо ваш контракт повертає error code — перевіримо, що він “стоковий”
const failJson = fail[0].json || {};
const failCode = String(
pick(failJson, ['code', 'errorCode', 'businessCode', 'reason']) ?? ''
diff --git a/frontend/lib/tests/checkout-currency-policy.test.ts b/frontend/lib/tests/checkout-currency-policy.test.ts
index a74317e2..a9e13160 100644
--- a/frontend/lib/tests/checkout-currency-policy.test.ts
+++ b/frontend/lib/tests/checkout-currency-policy.test.ts
@@ -245,5 +245,5 @@ describe('P0-CUR-3 checkout currency policy', () => {
expect(json.code).toBe('PRICE_CONFIG_ERROR');
expect(json.details?.productId).toBe(productId);
expect(json.details?.currency).toBe('UAH');
- });
+ }, 30_000);
});
diff --git a/frontend/lib/tests/checkout-no-payments.test.ts b/frontend/lib/tests/checkout-no-payments.test.ts
index 0fa8eaa5..31d455f3 100644
--- a/frontend/lib/tests/checkout-no-payments.test.ts
+++ b/frontend/lib/tests/checkout-no-payments.test.ts
@@ -33,6 +33,14 @@ vi.mock('@/lib/auth', async () => {
};
});
+function logTestCleanupFailed(meta: Record, error: unknown) {
+ console.error('[test cleanup failed]', {
+ file: 'checkout-no-payments.test.ts',
+ ...meta,
+ error,
+ });
+}
+
/**
* Creates an isolated product + product_prices row to avoid stock races
* with parallel test files that also reserve/release inventory.
@@ -89,10 +97,20 @@ async function createIsolatedProductForCurrency(opts: {
} as any);
} catch (e) {
// Cleanup orphaned product on price insert failure (best-effort)
- await db
- .delete(products)
- .where(eq(products.id, productId))
- .catch(() => {});
+ try {
+ await db.delete(products).where(eq(products.id, productId));
+ } catch (cleanupError) {
+ logTestCleanupFailed(
+ {
+ fn: 'createIsolatedProductForCurrency',
+ step: 'rollback delete product after productPrices insert failure',
+ productId,
+ currency: opts.currency,
+ stock: opts.stock,
+ },
+ cleanupError
+ );
+ }
throw e;
}
@@ -108,13 +126,10 @@ async function cleanupIsolatedProduct(productId: string) {
.where(eq(products.id, productId));
} catch (e) {
// Non-fatal: best-effort test teardown
- if (process.env.DEBUG) {
- console.warn(
- 'cleanupIsolatedProduct: deactivate failed',
- { productId },
- e
- );
- }
+ logTestCleanupFailed(
+ { fn: 'cleanupIsolatedProduct', step: 'deactivate product', productId },
+ e
+ );
}
try {
@@ -122,25 +137,23 @@ async function cleanupIsolatedProduct(productId: string) {
.delete(productPrices)
.where(eq(productPrices.productId, productId));
} catch (e) {
- if (process.env.DEBUG) {
- console.warn(
- 'cleanupIsolatedProduct: delete productPrices failed',
- { productId },
- e
- );
- }
+ logTestCleanupFailed(
+ {
+ fn: 'cleanupIsolatedProduct',
+ step: 'delete productPrices by productId',
+ productId,
+ },
+ e
+ );
}
try {
await db.delete(products).where(eq(products.id, productId));
} catch (e) {
- if (process.env.DEBUG) {
- console.warn(
- 'cleanupIsolatedProduct: delete product failed',
- { productId },
- e
- );
- }
+ logTestCleanupFailed(
+ { fn: 'cleanupIsolatedProduct', step: 'delete product by id', productId },
+ e
+ );
}
}
@@ -194,13 +207,14 @@ async function bestEffortHardDeleteOrder(orderId: string) {
sql`delete from inventory_moves where order_id = ${orderId}::uuid`
);
} catch (e) {
- if (process.env.DEBUG) {
- console.warn(
- 'bestEffortHardDeleteOrder: delete inventory_moves failed',
- { orderId },
- e
- );
- }
+ logTestCleanupFailed(
+ {
+ fn: 'bestEffortHardDeleteOrder',
+ step: 'delete inventory_moves by orderId',
+ orderId,
+ },
+ e
+ );
}
try {
@@ -208,25 +222,23 @@ async function bestEffortHardDeleteOrder(orderId: string) {
sql`delete from order_items where order_id = ${orderId}::uuid`
);
} catch (e) {
- if (process.env.DEBUG) {
- console.warn(
- 'bestEffortHardDeleteOrder: delete order_items failed',
- { orderId },
- e
- );
- }
+ logTestCleanupFailed(
+ {
+ fn: 'bestEffortHardDeleteOrder',
+ step: 'delete order_items by orderId',
+ orderId,
+ },
+ e
+ );
}
try {
await db.delete(orders).where(eq(orders.id, orderId));
} catch (e) {
- if (process.env.DEBUG) {
- console.warn(
- 'bestEffortHardDeleteOrder: delete order failed',
- { orderId },
- e
- );
- }
+ logTestCleanupFailed(
+ { fn: 'bestEffortHardDeleteOrder', step: 'delete order by id', orderId },
+ e
+ );
}
}
diff --git a/frontend/lib/tests/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/order-items-snapshot-immutable.test.ts
index cf8d7ab1..fcd4c0a2 100644
--- a/frontend/lib/tests/order-items-snapshot-immutable.test.ts
+++ b/frontend/lib/tests/order-items-snapshot-immutable.test.ts
@@ -208,7 +208,12 @@ describe('P0-6 snapshots: order_items immutability', () => {
// Snapshot MUST remain V1 even after product changes
expect(after[0]).toEqual(before[0]);
} finally {
- await cleanupByIds({ orderId, productId });
+ try {
+ await cleanupByIds({ orderId, productId });
+ } catch (e) {
+ console.error('[test cleanup failed]', { orderId, productId }, e);
+ throw e;
+ }
}
}, 30_000);
});
diff --git a/frontend/lib/tests/order-items-variants.test.ts b/frontend/lib/tests/order-items-variants.test.ts
index f193d9ec..843bb4d0 100644
--- a/frontend/lib/tests/order-items-variants.test.ts
+++ b/frontend/lib/tests/order-items-variants.test.ts
@@ -106,7 +106,17 @@ describe('order_items variants (selected_size/selected_color)', () => {
await db.execute(
sql`delete from product_prices where product_id = ${productId}::uuid`
);
- } catch {}
+ } catch (error) {
+ console.error('[test cleanup failed]', {
+ file: 'order-items-variants.test.ts',
+ test: 'order_items variants: distinct rows for different variants',
+ step: 'delete product_prices fallback by productId',
+ orderId,
+ productId,
+ priceId,
+ error,
+ });
+ }
}
}, 60_000);
});
diff --git a/frontend/lib/tests/product-sale-invariant.test.ts b/frontend/lib/tests/product-sale-invariant.test.ts
index 1d2d3efa..5bb9c894 100644
--- a/frontend/lib/tests/product-sale-invariant.test.ts
+++ b/frontend/lib/tests/product-sale-invariant.test.ts
@@ -47,7 +47,7 @@ describe('SALE invariant: originalPriceMinor is required', () => {
isActive: true,
} as any)
).rejects.toThrow(/SALE badge requires originalPrice/i);
- });
+ }, 30_000);
it('updateProduct: existing SALE + PATCH that removes originalPriceMinor must reject (final state invariant)', async () => {
const slug = uniqueSlug();
@@ -113,5 +113,5 @@ describe('SALE invariant: originalPriceMinor is required', () => {
expect(pp.priceMinor).toBe(1000);
expect(pp.originalPriceMinor).toBe(2000);
- });
+ }, 30_000);
});
diff --git a/frontend/lib/tests/public-product-visibility.test.ts b/frontend/lib/tests/public-product-visibility.test.ts
index 628d51b0..a05b25c7 100644
--- a/frontend/lib/tests/public-product-visibility.test.ts
+++ b/frontend/lib/tests/public-product-visibility.test.ts
@@ -1,18 +1,37 @@
-import { describe, it, expect } from "vitest";
-import { eq } from "drizzle-orm";
-import { randomUUID } from "node:crypto";
+import { describe, it, expect } from 'vitest';
+import { eq } from 'drizzle-orm';
+import { randomUUID } from 'node:crypto';
-import { db } from "@/db";
-import { products, productPrices } from "@/db/schema";
-import { getPublicProductBySlug } from "@/db/queries/shop/products";
+import { db } from '@/db';
+import { products, productPrices } from '@/db/schema';
+import { getPublicProductBySlug } from '@/db/queries/shop/products';
+
+function logTestCleanupFailed(meta: Record, error: unknown) {
+ console.error('[test cleanup failed]', {
+ file: 'public-product-visibility.test.ts',
+ ...meta,
+ error,
+ });
+}
async function cleanup(productId: string) {
- await db.delete(productPrices).where(eq(productPrices.productId, productId));
- await db.delete(products).where(eq(products.id, productId));
+ try {
+ await db
+ .delete(productPrices)
+ .where(eq(productPrices.productId, productId));
+ } catch (e) {
+ logTestCleanupFailed({ step: 'delete productPrices', productId }, e);
+ }
+
+ try {
+ await db.delete(products).where(eq(products.id, productId));
+ } catch (e) {
+ logTestCleanupFailed({ step: 'delete product', productId }, e);
+ }
}
-describe("P0-5 Public products: inactive not visible", () => {
- it("inactive slug -> 404 (selector returns null)", async () => {
+describe('P0-5 Public products: inactive not visible', () => {
+ it('inactive slug -> 404 (selector returns null)', async () => {
const productId = randomUUID();
const slug = `inactive-${randomUUID()}`;
@@ -20,46 +39,46 @@ describe("P0-5 Public products: inactive not visible", () => {
await db.insert(products).values({
id: productId,
slug,
- title: "Inactive product",
+ title: 'Inactive product',
description: null,
- imageUrl: "https://placehold.co/600x600",
+ imageUrl: 'https://placehold.co/600x600',
imagePublicId: null,
category: null,
type: null,
colors: [],
sizes: [],
- badge: "NONE",
+ badge: 'NONE',
isActive: false,
isFeatured: false,
stock: 5,
sku: null,
// legacy mirror required by schema + checks
- price: "10.00",
+ price: '10.00',
originalPrice: null,
- currency: "USD",
+ currency: 'USD',
});
await db.insert(productPrices).values({
id: randomUUID(),
productId,
- currency: "USD",
+ currency: 'USD',
// canonical + mirror must match checks
priceMinor: 1000,
originalPriceMinor: null,
- price: "10.00",
+ price: '10.00',
originalPrice: null,
});
- const result = await getPublicProductBySlug(slug, "USD");
+ const result = await getPublicProductBySlug(slug, 'USD');
expect(result).toBeNull();
} finally {
await cleanup(productId);
}
});
- it("active slug -> 200 (selector returns product)", async () => {
+ it('active slug -> 200 (selector returns product)', async () => {
const productId = randomUUID();
const slug = `active-${randomUUID()}`;
@@ -67,42 +86,42 @@ describe("P0-5 Public products: inactive not visible", () => {
await db.insert(products).values({
id: productId,
slug,
- title: "Active product",
+ title: 'Active product',
description: null,
- imageUrl: "https://placehold.co/600x600",
+ imageUrl: 'https://placehold.co/600x600',
imagePublicId: null,
category: null,
type: null,
colors: [],
sizes: [],
- badge: "NONE",
+ badge: 'NONE',
isActive: true,
isFeatured: false,
stock: 5,
sku: null,
// legacy mirror required by schema + checks
- price: "19.99",
+ price: '19.99',
originalPrice: null,
- currency: "USD",
+ currency: 'USD',
});
await db.insert(productPrices).values({
id: randomUUID(),
productId,
- currency: "USD",
+ currency: 'USD',
// canonical + mirror must match checks
priceMinor: 1999,
originalPriceMinor: null,
- price: "19.99",
+ price: '19.99',
originalPrice: null,
});
- const result = await getPublicProductBySlug(slug, "USD");
+ const result = await getPublicProductBySlug(slug, 'USD');
expect(result).not.toBeNull();
expect(result!.slug).toBe(slug);
- expect(result!.currency).toBe("USD");
+ expect(result!.currency).toBe('USD');
} finally {
await cleanup(productId);
}
diff --git a/frontend/lib/tests/restock-order-only-once.test.ts b/frontend/lib/tests/restock-order-only-once.test.ts
index 2181dfe7..a69993e4 100644
--- a/frontend/lib/tests/restock-order-only-once.test.ts
+++ b/frontend/lib/tests/restock-order-only-once.test.ts
@@ -22,6 +22,16 @@ async function countMoveKey(moveKey: string): Promise {
return Number(rows?.[0]?.n ?? 0);
}
+function logCleanupFailed(payload: {
+ test: string;
+ orderId: string;
+ productId: string;
+ step: string;
+ error: unknown;
+}) {
+ console.error('[test cleanup failed]', payload);
+}
+
describe('P0-8.4.2 restockOrder: order-level gate + idempotency', () => {
it('duplicate failed restock must not increment stock twice and must not change restocked_at', async () => {
const orderId = crypto.randomUUID();
@@ -146,10 +156,26 @@ describe('P0-8.4.2 restockOrder: order-level gate + idempotency', () => {
} finally {
try {
await db.delete(orders).where(eq(orders.id, orderId));
- } catch {}
+ } catch (error) {
+ logCleanupFailed({
+ test: 'restockOrder: duplicate failed restock',
+ orderId,
+ productId,
+ step: 'delete orders',
+ error,
+ });
+ }
try {
await db.delete(products).where(eq(products.id, productId));
- } catch {}
+ } catch (error) {
+ logCleanupFailed({
+ test: 'restockOrder: duplicate failed restock',
+ orderId,
+ productId,
+ step: 'delete products',
+ error,
+ });
+ }
}
}, 30_000);
@@ -248,10 +274,26 @@ describe('P0-8.4.2 restockOrder: order-level gate + idempotency', () => {
} finally {
try {
await db.delete(orders).where(eq(orders.id, orderId));
- } catch {}
+ } catch (error) {
+ logCleanupFailed({
+ test: 'restockOrder: concurrent restocks',
+ orderId,
+ productId,
+ step: 'delete orders',
+ error,
+ });
+ }
try {
await db.delete(products).where(eq(products.id, productId));
- } catch {}
+ } catch (error) {
+ logCleanupFailed({
+ test: 'restockOrder: concurrent restocks',
+ orderId,
+ productId,
+ step: 'delete products',
+ error,
+ });
+ }
}
}, 30_000);
@@ -386,10 +428,26 @@ describe('P0-8.4.2 restockOrder: order-level gate + idempotency', () => {
} finally {
try {
await db.delete(orders).where(eq(orders.id, orderId));
- } catch {}
+ } catch (error) {
+ logCleanupFailed({
+ test: 'restockOrder: duplicate refund restock',
+ orderId,
+ productId,
+ step: 'delete orders',
+ error,
+ });
+ }
try {
await db.delete(products).where(eq(products.id, productId));
- } catch {}
+ } catch (error) {
+ logCleanupFailed({
+ test: 'restockOrder: duplicate refund restock',
+ orderId,
+ productId,
+ step: 'delete products',
+ error,
+ });
+ }
}
- });
+ }, 30000);
}, 30000);
diff --git a/frontend/lib/tests/restock-stale-claim-gate.test.ts b/frontend/lib/tests/restock-stale-claim-gate.test.ts
index 525e4e74..5efa9ece 100644
--- a/frontend/lib/tests/restock-stale-claim-gate.test.ts
+++ b/frontend/lib/tests/restock-stale-claim-gate.test.ts
@@ -74,9 +74,18 @@ describe('restockStalePendingOrders claim gate', () => {
} finally {
try {
await db.delete(orders).where(eq(orders.id, orderId));
- } catch {}
+ } catch (error) {
+ console.error('[test cleanup failed]', {
+ file: 'restock-stale-claim-gate.test.ts',
+ test: 'skip active claim',
+ step: 'delete order by id',
+ orderId,
+ idem,
+ error,
+ });
+ }
}
- });
+ }, 30_000);
it('must process orders with an expired claim', async () => {
const orderId = crypto.randomUUID();
@@ -132,7 +141,16 @@ describe('restockStalePendingOrders claim gate', () => {
} finally {
try {
await db.delete(orders).where(eq(orders.id, orderId));
- } catch {}
+ } catch (error) {
+ console.error('[test cleanup failed]', {
+ file: 'restock-stale-claim-gate.test.ts',
+ test: 'process expired claim',
+ step: 'delete order by id',
+ orderId,
+ idem,
+ error,
+ });
+ }
}
- });
+ }, 30_000);
});
diff --git a/frontend/lib/tests/restock-stale-stripe-orphan.test.ts b/frontend/lib/tests/restock-stale-stripe-orphan.test.ts
index 79a677c0..ea06b49a 100644
--- a/frontend/lib/tests/restock-stale-stripe-orphan.test.ts
+++ b/frontend/lib/tests/restock-stale-stripe-orphan.test.ts
@@ -81,7 +81,16 @@ describe('P0-3.x Restock stale pending orders: stripe orphan cleanup', () => {
// cleanup
try {
await db.delete(orders).where(eq(orders.id, orderId));
- } catch {}
+ } catch (error) {
+ console.error('[test cleanup failed]', {
+ file: 'restock-stale-stripe-orphan.test.ts',
+ test: 'stale stripe orphan -> terminal failed + released',
+ step: 'delete order by id',
+ orderId,
+ idem,
+ error,
+ });
+ }
}
});
}, 20000);
diff --git a/frontend/lib/tests/restock-stuck-reserving-sweep.test.ts b/frontend/lib/tests/restock-stuck-reserving-sweep.test.ts
index e5ce1d63..4a4454de 100644
--- a/frontend/lib/tests/restock-stuck-reserving-sweep.test.ts
+++ b/frontend/lib/tests/restock-stuck-reserving-sweep.test.ts
@@ -48,6 +48,7 @@ describe('P0-7 stuckReserving sweep: restock exactly-once', () => {
const qty = 2;
const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); // old enough
const idem = `test-stuck-${crypto.randomUUID()}`;
+ let originalError: unknown = null;
try {
await db.insert(products).values({
@@ -164,18 +165,26 @@ describe('P0-7 stuckReserving sweep: restock exactly-once', () => {
.limit(1);
expect(after2.restockedAt?.getTime()).toBe(after1.restockedAt?.getTime());
+ } catch (e) {
+ originalError = e;
} finally {
try {
await cleanupTestRows({ orderId, productId });
- } catch (e) {
- // Don’t hide leaks: failing fast is better than contaminating subsequent tests.
- console.error('[test cleanup failed]', {
- orderId,
- productId,
- error: e,
- });
- throw e;
+ } catch (cleanupError) {
+ if (originalError) {
+ // cleanup failed, but do NOT hide the real test failure
+ console.error('[test cleanup failed]', {
+ orderId,
+ productId,
+ error: cleanupError,
+ });
+ } else {
+ // no original failure -> cleanup error is the failure
+ originalError = cleanupError;
+ }
}
}
+
+ if (originalError) throw originalError;
}, 30_000);
});
diff --git a/frontend/lib/tests/restock-sweep-claim.test.ts b/frontend/lib/tests/restock-sweep-claim.test.ts
index db9c8d51..8f53bd56 100644
--- a/frontend/lib/tests/restock-sweep-claim.test.ts
+++ b/frontend/lib/tests/restock-sweep-claim.test.ts
@@ -63,7 +63,16 @@ describe('restockStalePendingOrders claim', () => {
} finally {
try {
await db.delete(orders).where(eq(orders.id, orderId));
- } catch {}
+ } catch (error) {
+ console.error('[test cleanup failed]', {
+ file: 'restock-sweep-claim.test.ts',
+ test: 'two concurrent sweeps must not both process the same order',
+ step: 'delete order by id',
+ orderId,
+ idem,
+ error,
+ });
+ }
}
});
}, 20000);
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 35808b21..bd88dbb5 100644
--- a/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts
+++ b/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts
@@ -1,8 +1,9 @@
import crypto from 'crypto';
-import { describe, it, expect, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { eq } from 'drizzle-orm';
+
import { db } from '@/db';
import { orders, stripeEvents } from '@/db/schema';
-import { eq } from 'drizzle-orm';
import { toDbMoney } from '@/lib/shop/money';
async function seedOrder(params: { orderId: string; pi: string }) {
@@ -62,54 +63,112 @@ async function callWebhook(params: { eventId: string; pi: string; orderId: strin
);
}
+async function cleanupByIds(params: { orderId: string; eventId: string }) {
+ const { orderId, eventId } = params;
+
+ try {
+ await db.delete(stripeEvents).where(eq(stripeEvents.eventId, eventId));
+ } catch (e) {
+ console.error(
+ '[test cleanup failed]',
+ {
+ file: 'stripe-webhook-paid-status-repair.test.ts',
+ step: 'delete stripeEvents',
+ eventId,
+ orderId,
+ },
+ e
+ );
+ }
+
+ try {
+ await db.delete(orders).where(eq(orders.id, orderId));
+ } catch (e) {
+ console.error(
+ '[test cleanup failed]',
+ {
+ file: 'stripe-webhook-paid-status-repair.test.ts',
+ step: 'delete orders',
+ eventId,
+ orderId,
+ },
+ e
+ );
+ }
+}
+
describe('stripe webhook: repair paid status mismatch', () => {
- it('repairs status to PAID when paymentStatus=paid but status!=PAID', async () => {
- const orderId = crypto.randomUUID();
- const eventId = `evt_${crypto.randomUUID()}`;
- const pi = `pi_test_repair_${crypto.randomUUID()}`;
+ let lastOrderId: string | null = null;
+ let lastEventId: string | null = null;
+
+ afterEach(async () => {
+ if (!lastOrderId || !lastEventId) return;
+ await cleanupByIds({ orderId: lastOrderId, eventId: lastEventId });
+ lastOrderId = null;
+ lastEventId = null;
+ });
- await seedOrder({ orderId, pi });
+ it(
+ 'repairs status to PAID when paymentStatus=paid but status!=PAID',
+ async () => {
+ const orderId = crypto.randomUUID();
+ const eventId = `evt_${crypto.randomUUID()}`;
+ const pi = `pi_test_repair_${crypto.randomUUID()}`;
- const res = await callWebhook({ eventId, pi, orderId });
- expect(res.status).toBe(200);
+ lastOrderId = orderId;
+ lastEventId = eventId;
- const [row] = await db
- .select({ status: orders.status, paymentStatus: orders.paymentStatus })
- .from(orders)
- .where(eq(orders.id, orderId))
- .limit(1);
+ await seedOrder({ orderId, pi });
- expect(row!.paymentStatus).toBe('paid');
- expect(row!.status).toBe('PAID');
- });
+ const res = await callWebhook({ eventId, pi, orderId });
+ expect(res.status).toBe(200);
- it('dedupes the same eventId after processedAt is set (second call is no-op)', async () => {
- const orderId = crypto.randomUUID();
- const eventId = `evt_${crypto.randomUUID()}`;
- const pi = `pi_test_repair_${crypto.randomUUID()}`;
+ const [row] = await db
+ .select({ status: orders.status, paymentStatus: orders.paymentStatus })
+ .from(orders)
+ .where(eq(orders.id, orderId))
+ .limit(1);
- await seedOrder({ orderId, pi });
+ expect(row!.paymentStatus).toBe('paid');
+ expect(row!.status).toBe('PAID');
+ },
+ 30_000
+ );
- const res1 = await callWebhook({ eventId, pi, orderId });
- expect(res1.status).toBe(200);
+ it(
+ 'dedupes the same eventId after processedAt is set (second call is no-op)',
+ async () => {
+ const orderId = crypto.randomUUID();
+ const eventId = `evt_${crypto.randomUUID()}`;
+ const pi = `pi_test_repair_${crypto.randomUUID()}`;
- const res2 = await callWebhook({ eventId, pi, orderId });
- expect(res2.status).toBe(200);
+ lastOrderId = orderId;
+ lastEventId = eventId;
- const [evt] = await db
- .select({ processedAt: stripeEvents.processedAt })
- .from(stripeEvents)
- .where(eq(stripeEvents.eventId, eventId))
- .limit(1);
+ await seedOrder({ orderId, pi });
- expect(evt?.processedAt).toBeTruthy();
+ const res1 = await callWebhook({ eventId, pi, orderId });
+ expect(res1.status).toBe(200);
- const [row] = await db
- .select({ status: orders.status })
- .from(orders)
- .where(eq(orders.id, orderId))
- .limit(1);
+ const res2 = await callWebhook({ eventId, pi, orderId });
+ expect(res2.status).toBe(200);
- expect(row!.status).toBe('PAID');
- });
+ const [evt] = await db
+ .select({ processedAt: stripeEvents.processedAt })
+ .from(stripeEvents)
+ .where(eq(stripeEvents.eventId, eventId))
+ .limit(1);
+
+ expect(evt?.processedAt).toBeTruthy();
+
+ const [row] = await db
+ .select({ status: orders.status })
+ .from(orders)
+ .where(eq(orders.id, orderId))
+ .limit(1);
+
+ expect(row!.status).toBe('PAID');
+ },
+ 30_000
+ );
});
diff --git a/frontend/lib/tests/stripe-webhook-psp-fields.test.ts b/frontend/lib/tests/stripe-webhook-psp-fields.test.ts
index 2837d8b8..bb9c41fd 100644
--- a/frontend/lib/tests/stripe-webhook-psp-fields.test.ts
+++ b/frontend/lib/tests/stripe-webhook-psp-fields.test.ts
@@ -26,6 +26,14 @@ vi.mock('@/lib/psp/stripe', async () => {
import { verifyWebhookSignature } from '@/lib/psp/stripe';
import { POST as webhookPOST } from '@/app/api/shop/webhooks/stripe/route';
+function logTestCleanupFailed(meta: Record, error: unknown) {
+ console.error('[test cleanup failed]', {
+ file: 'stripe-webhook-psp-fields.test.ts',
+ ...meta,
+ error,
+ });
+}
+
function makeWebhookRequest(rawBody: string) {
return new NextRequest('http://localhost:3000/api/shop/webhooks/stripe', {
method: 'POST',
@@ -45,12 +53,58 @@ async function cleanup(params: {
}) {
const { orderId, productId, eventId } = params;
- await db.delete(stripeEvents).where(eq(stripeEvents.eventId, eventId));
- 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(products).where(eq(products.id, productId));
+ // teardown must not mask assertion failures, but must surface cleanup problems
+ try {
+ await db.delete(stripeEvents).where(eq(stripeEvents.eventId, eventId));
+ } catch (e) {
+ logTestCleanupFailed(
+ { step: 'delete stripeEvents by eventId', eventId, orderId, productId },
+ e
+ );
+ }
+
+ try {
+ await db.delete(orderItems).where(eq(orderItems.orderId, orderId));
+ } catch (e) {
+ logTestCleanupFailed(
+ { step: 'delete orderItems by orderId', orderId, eventId, productId },
+ e
+ );
+ }
+
+ try {
+ await db.delete(orders).where(eq(orders.id, orderId));
+ } catch (e) {
+ logTestCleanupFailed(
+ { step: 'delete order by id', orderId, eventId, productId },
+ e
+ );
+ }
+
+ try {
+ await db
+ .delete(productPrices)
+ .where(eq(productPrices.productId, productId));
+ } catch (e) {
+ logTestCleanupFailed(
+ {
+ step: 'delete productPrices by productId',
+ productId,
+ orderId,
+ eventId,
+ },
+ e
+ );
+ }
+
+ try {
+ await db.delete(products).where(eq(products.id, productId));
+ } catch (e) {
+ logTestCleanupFailed(
+ { step: 'delete product by id', productId, orderId, eventId },
+ e
+ );
+ }
}
describe('P0-6 webhook: writes PSP fields on succeeded', () => {
diff --git a/frontend/lib/tests/stripe-webhook-refund-full.test.ts b/frontend/lib/tests/stripe-webhook-refund-full.test.ts
index c6b2e907..db869ca7 100644
--- a/frontend/lib/tests/stripe-webhook-refund-full.test.ts
+++ b/frontend/lib/tests/stripe-webhook-refund-full.test.ts
@@ -61,8 +61,6 @@ function makeRequest() {
}
async function cleanupInserted(ins: Inserted) {
- // stripeEvents вставляються навіть коли orderId null (metadata missing),
- // тому чистимо по paymentIntentId
await db
.delete(stripeEvents)
.where(eq(stripeEvents.paymentIntentId, ins.paymentIntentId));
@@ -165,7 +163,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded
.where(eq(stripeEvents.eventId, eventId));
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 () => {
inserted = await insertPaidOrder();
@@ -173,15 +171,34 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded
const eventId = `evt_${crypto.randomUUID()}`;
const chargeId = `ch_${crypto.randomUUID()}`;
+ const refundId = `re_${crypto.randomUUID()}`;
+
const refund = {
- id: `re_${crypto.randomUUID()}`,
+ id: refundId,
object: 'refund',
- amount: 2500, // must equal order.totalAmountMinor for full-refund gate
+ amount: 2500,
status: 'succeeded',
reason: null,
- charge: chargeId,
+ charge: {
+ id: chargeId,
+ object: 'charge',
+ amount: 2500,
+ amount_refunded: 2500,
+ refunds: {
+ object: 'list',
+ data: [
+ {
+ id: refundId,
+ object: 'refund',
+ status: 'succeeded',
+ reason: null,
+ amount: 2500,
+ },
+ ],
+ },
+ },
payment_intent: inserted.paymentIntentId,
- metadata: {}, // IMPORTANT: no orderId -> PI fallback must resolve
+ metadata: {},
};
(verifyWebhookSignature as any).mockReturnValue({
@@ -213,7 +230,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded
expect(restockOrder).toHaveBeenCalledWith(inserted.orderId, {
reason: 'refunded',
});
- });
+ }, 30_000);
it('partial refund is ignored (no paymentStatus/status change, no restock)', async () => {
inserted = await insertPaidOrder();
@@ -266,8 +283,8 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded
expect(row.stockRestored).toBe(false);
expect(restockOrder).toHaveBeenCalledTimes(0);
- });
- it('retry after 500 must reprocess same event.id until processedAt is set (restock not lost)', async () => {
+ }, 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()}`;
@@ -295,12 +312,15 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded
throw new Error('RESTOCK_FAILED');
})
.mockImplementationOnce(async (orderId: string) => {
- await db.update(orders).set({
- stockRestored: true,
- restockedAt: new Date(),
- inventoryStatus: 'released',
- updatedAt: new Date(),
- }).where(eq(orders.id, orderId));
+ await db
+ .update(orders)
+ .set({
+ stockRestored: true,
+ restockedAt: new Date(),
+ inventoryStatus: 'released',
+ updatedAt: new Date(),
+ })
+ .where(eq(orders.id, orderId));
});
const res1 = await POST(makeRequest());
@@ -331,6 +351,73 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded
.limit(1);
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 refund3Id = `re_${crypto.randomUUID()}`;
+
+ // current event refund is only 500 (not full by itself)
+ const refund = {
+ id: refund3Id,
+ object: 'refund',
+ 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 }],
+ },
+ },
+ payment_intent: inserted.paymentIntentId,
+ metadata: {},
+ };
+
+ (verifyWebhookSignature as any).mockReturnValue({
+ id: eventId,
+ type: 'charge.refund.updated',
+ data: { object: refund },
+ });
+
+ 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(restockOrder).toHaveBeenCalledTimes(1);
+ }, 30_000);
});
diff --git a/frontend/scripts/shop-janitor-restock-stale.mjs b/frontend/scripts/shop-janitor-restock-stale.mjs
index 1ac1353c..76be76b7 100644
--- a/frontend/scripts/shop-janitor-restock-stale.mjs
+++ b/frontend/scripts/shop-janitor-restock-stale.mjs
@@ -14,7 +14,19 @@ if (!secret) {
process.exit(1);
}
-const timeoutMs = Number(process.env.JANITOR_TIMEOUT_MS ?? '25000');
+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;
+
+console.log('[janitor] timeoutMs=', timeoutMs, 'raw=', rawTimeout || '(empty)');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
diff --git a/frontend/tmp/replay-charge-refunded.json b/frontend/tmp/replay-charge-refunded.json
deleted file mode 100644
index 1c0aa6d5..00000000
--- a/frontend/tmp/replay-charge-refunded.json
+++ /dev/null
@@ -1 +0,0 @@
-{"data":{"object":{"id":"ch_3Sn5WRAzdq4b0hR02JIDJhDx","payment_intent":"pi_3Sn5WRAzdq4b0hR027sBGFiq","amount":5900,"refunds":{"data":[{"amount":5900,"status":"succeeded","id":"re_3Sn5WRAzdq4b0hR02gbWWFvP","reason":null}]},"metadata":{},"amount_refunded":5900,"status":"succeeded"}},"id":"\u003cPUT_EVT_ID_FROM_DB_HERE\u003e","type":"charge.refunded"}
diff --git a/frontend/tmp/replay-stripe-webhook.js b/frontend/tmp/replay-stripe-webhook.js
deleted file mode 100644
index bd546934..00000000
--- a/frontend/tmp/replay-stripe-webhook.js
+++ /dev/null
@@ -1,23 +0,0 @@
-const fs = require('fs');
-const crypto = require('crypto');
-
-const secret = process.env.STRIPE_WEBHOOK_SECRET;
-if (!secret) { console.error("Missing STRIPE_WEBHOOK_SECRET in env"); process.exit(1); }
-
-const payloadPath = process.env.PAYLOAD_PATH;
-if (!payloadPath) { console.error("Missing PAYLOAD_PATH in env"); process.exit(1); }
-
-const payload = fs.readFileSync(payloadPath, 'utf8');
-const ts = Math.floor(Date.now() / 1000);
-const signed = ${ts}.{"id":"evt_test"};
-const sig = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
-const header = =,v1=;
-
-fetch("http://localhost:3000/api/shop/webhooks/stripe", {
- method: "POST",
- headers: { "content-type":"application/json", "stripe-signature": header },
- body: payload
-}).then(async (r) => {
- const text = await r.text();
- console.log("HTTP", r.status, text);
-}).catch((e) => { console.error(e); process.exit(1); });