diff --git a/.gitignore b/.gitignore index 526f852b..e7d1e0e3 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,11 @@ next-env.d.ts # Documentation (development only) .claude/ CLAUDE.md -frontend/docs/ \ No newline at end of file +frontend/docs/ +frontend/.env.bak + + +# local env backups +frontend/.env*.bak +frontend/.env.bak + diff --git a/frontend/app/[locale]/shop/admin/layout.tsx b/frontend/app/[locale]/shop/admin/layout.tsx index 76df6028..b1748c79 100644 --- a/frontend/app/[locale]/shop/admin/layout.tsx +++ b/frontend/app/[locale]/shop/admin/layout.tsx @@ -28,7 +28,7 @@ export default async function ShopAdminLayout({ return ( <>
-
+
{isPending ? 'Refunding…' : 'Refund'} - {error ? ( - {error} - ) : null} + {error ? {error} : null}
); } diff --git a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx index 5287dd6c..23af1bd1 100644 --- a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx @@ -23,7 +23,7 @@ function orderCurrency( locale: string ): CurrencyCode { const c = order?.currency ?? resolveCurrencyFromLocale(locale); - return (c === 'UAH' ? 'UAH' : 'USD') as CurrencyCode; + return c === 'UAH' ? 'UAH' : 'USD'; } function formatDateTime(value: Date | null | undefined) { @@ -41,9 +41,9 @@ export default async function AdminOrderDetailPage({ if (!order) notFound(); const canRefund = - order.paymentProvider === 'stripe' && - order.paymentStatus === 'paid' && - !!order.paymentIntentId; + order.paymentProvider === 'stripe' && + order.paymentStatus === 'paid' && + !!order.paymentIntentId; return (
diff --git a/frontend/app/[locale]/shop/admin/page.tsx b/frontend/app/[locale]/shop/admin/page.tsx index 8d1c5461..b9d3000d 100644 --- a/frontend/app/[locale]/shop/admin/page.tsx +++ b/frontend/app/[locale]/shop/admin/page.tsx @@ -3,7 +3,8 @@ import { Link } from '@/i18n/routing'; export default function ShopAdminHomePage() { return ( -
+
+

Shop Admin

Administrative tools for the merch shop. diff --git a/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx b/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx index ed993b1b..36c665a3 100644 --- a/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx +++ b/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx @@ -248,7 +248,6 @@ export function ProductForm({ setError( `${p.currency}: price is required when original price is set.` ); - setIsSubmitting(false); return; } } @@ -256,7 +255,6 @@ export function ProductForm({ const usd = effectivePrices.find(p => p.currency === 'USD'); if (!usd || !usd.price.length) { setError('USD price is required.'); - setIsSubmitting(false); return; } @@ -275,7 +273,6 @@ export function ProductForm({ })); } catch (e) { setError(e instanceof Error ? e.message : 'Invalid price value.'); - setIsSubmitting(false); return; } diff --git a/frontend/app/[locale]/shop/admin/products/page.tsx b/frontend/app/[locale]/shop/admin/products/page.tsx index 82252520..1491ebec 100644 --- a/frontend/app/[locale]/shop/admin/products/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/page.tsx @@ -1,41 +1,61 @@ import { Link } from '@/i18n/routing'; -import { desc } from 'drizzle-orm'; +import { and, desc, eq } from 'drizzle-orm'; import { AdminProductStatusToggle } from '@/components/shop/admin/admin-product-status-toggle'; import { db } from '@/db'; -import { products } from '@/db/schema'; -import { - currencyValues, - formatMoney, - type CurrencyCode, -} from '@/lib/shop/currency'; +import { products, productPrices } from '@/db/schema'; +import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; -function toCurrencyCode(value: string | null | undefined): CurrencyCode { - const normalized = (value ?? '').trim().toUpperCase(); - return currencyValues.includes(normalized as CurrencyCode) - ? (normalized as CurrencyCode) - : 'USD'; -} - function formatDate(value: Date | null, locale: string) { if (!value) return '-'; return value.toLocaleDateString(locale); } +function safeFromDbMoney(value: unknown): number | null { + try { + return fromDbMoney(value); + } catch { + return null; + } +} + export default async function AdminProductsPage({ params, }: { params: Promise<{ locale: string }>; }) { const { locale } = await params; - const allProducts = await db - .select() + + // currency policy: derived from locale + const displayCurrency = resolveCurrencyFromLocale(locale); + + const rows = await db + .select({ + id: products.id, + title: products.title, + slug: products.slug, + category: products.category, + type: products.type, + stock: products.stock, + badge: products.badge, + isActive: products.isActive, + isFeatured: products.isFeatured, + createdAt: products.createdAt, + price: productPrices.price, // numeric (major) from product_prices + }) .from(products) + .leftJoin( + productPrices, + and( + eq(productPrices.productId, products.id), + eq(productPrices.currency, displayCurrency) + ) + ) .orderBy(desc(products.createdAt)); return ( -

+

Admin · Products

- +
- - - - - - - - - - - + - {allProducts.map(product => ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - ))} + {row.isFeatured ? 'Yes' : 'No'} + + + + + + + + ); + })}
+ Title + Slug + Price + Category + Type + Stock + Badge + Active + Featured + Created + Actions
- {product.title} - - {product.slug} - - {formatMoney( - fromDbMoney(product.price), - toCurrencyCode(product.currency ?? 'USD'), - locale - )} - - {product.category ?? '-'} - - {product.type ?? '-'} - - {product.stock} - - {product.badge === 'NONE' ? '-' : product.badge} - - - {product.isActive ? 'Yes' : 'No'} - - - - {product.isFeatured ? 'Yes' : 'No'} - - - {formatDate(product.createdAt, locale)} - -
- { + const priceMinor = safeFromDbMoney(row.price); + + return ( +
+
+ {row.title} +
+
+
+ {row.slug} +
+
+ {priceMinor === null + ? '-' + : formatMoney(priceMinor, displayCurrency, locale)} + +
+ {row.category ?? '-'} +
+
+
+ {row.type ?? '-'} +
+
+ {row.stock} + + {row.badge === 'NONE' ? '-' : row.badge} + + - View - - + + - Edit - - - -
+ {formatDate(row.createdAt, locale)} + +
+ + View + + + Edit + + +
+
diff --git a/frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx b/frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx index 28c40336..ee4b151c 100644 --- a/frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx +++ b/frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx @@ -54,9 +54,6 @@ function nextRouteForPaymentResult(params: { status?: string | null; }) { const { orderId, status } = params; - - // ✅ Stripe може повернути "processing" або інший non-terminal статус. - // Джерело істини = webhook, тому error-page показуємо тільки для явних фейлів. const success = `/shop/checkout/success?orderId=${orderId}`; const failure = `/shop/checkout/error?orderId=${orderId}`; diff --git a/frontend/app/api/shop/admin/products/[id]/route.ts b/frontend/app/api/shop/admin/products/[id]/route.ts index e9144813..d782382a 100644 --- a/frontend/app/api/shop/admin/products/[id]/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/route.ts @@ -24,6 +24,17 @@ type SaleRuleViolation = { rule: 'required' | 'greater_than_price'; }; +type InvalidPricesJsonError = { + code: 'INVALID_PRICES_JSON'; + field: 'prices'; +}; + +function isInvalidPricesJsonError( + value: SaleRuleViolation | InvalidPricesJsonError | null +): value is InvalidPricesJsonError { + return !!value && (value as any).code === 'INVALID_PRICES_JSON'; +} + function findSaleRuleViolation(input: any): SaleRuleViolation | null { const badge = input?.badge; if (badge !== 'SALE') return null; @@ -54,9 +65,10 @@ function findSaleRuleViolation(input: any): SaleRuleViolation | null { return null; } + function getSaleViolationFromFormData( formData: FormData -): SaleRuleViolation | null { +): SaleRuleViolation | InvalidPricesJsonError | null { const badge = String(formData.get('badge') ?? ''); if (badge !== 'SALE') return null; @@ -67,9 +79,10 @@ function getSaleViolationFromFormData( const prices = JSON.parse(pricesRaw); return findSaleRuleViolation({ badge, prices }); } catch { - return null; + return { code: 'INVALID_PRICES_JSON', field: 'prices' }; } } + export async function GET( request: NextRequest, context: { params: Promise<{ id: string }> } @@ -132,6 +145,18 @@ export async function PATCH( const formData = await request.formData(); // PATCH inside PATCH() right after: const formData = await request.formData(); const saleViolationFromForm = getSaleViolationFromFormData(formData); + + if (isInvalidPricesJsonError(saleViolationFromForm)) { + return NextResponse.json( + { + error: 'Invalid prices JSON', + code: 'INVALID_PRICES_JSON', + field: 'prices', + }, + { status: 400 } + ); + } + if (saleViolationFromForm) { const message = saleViolationFromForm.rule === 'required' diff --git a/frontend/app/api/shop/admin/products/route.ts b/frontend/app/api/shop/admin/products/route.ts index 54812d59..595e3532 100644 --- a/frontend/app/api/shop/admin/products/route.ts +++ b/frontend/app/api/shop/admin/products/route.ts @@ -18,6 +18,17 @@ type SaleRuleViolation = { rule: 'required' | 'greater_than_price'; }; +type InvalidPricesJsonError = { + code: 'INVALID_PRICES_JSON'; + field: 'prices'; +}; + +function isInvalidPricesJsonError( + value: SaleRuleViolation | InvalidPricesJsonError | null +): value is InvalidPricesJsonError { + return !!value && (value as any).code === 'INVALID_PRICES_JSON'; +} + function findSaleRuleViolation(input: any): SaleRuleViolation | null { const badge = input?.badge; if (badge !== 'SALE') return null; @@ -50,7 +61,7 @@ function findSaleRuleViolation(input: any): SaleRuleViolation | null { function getSaleViolationFromFormData( formData: FormData -): SaleRuleViolation | null { +): SaleRuleViolation | InvalidPricesJsonError | null { const badge = String(formData.get('badge') ?? ''); if (badge !== 'SALE') return null; @@ -61,7 +72,7 @@ function getSaleViolationFromFormData( const prices = JSON.parse(pricesRaw); return findSaleRuleViolation({ badge, prices }); } catch { - return null; + return { code: 'INVALID_PRICES_JSON', field: 'prices' }; } } @@ -82,6 +93,17 @@ export async function POST(request: NextRequest) { ); } const saleViolationFromForm = getSaleViolationFromFormData(formData); + if (isInvalidPricesJsonError(saleViolationFromForm)) { + return NextResponse.json( + { + error: 'Invalid prices JSON', + code: 'INVALID_PRICES_JSON', + field: 'prices', + }, + { status: 400 } + ); + } + if (saleViolationFromForm) { const message = saleViolationFromForm.rule === 'required' diff --git a/frontend/app/api/shop/cart/rehydrate/route.ts b/frontend/app/api/shop/cart/rehydrate/route.ts index 586be77c..6fbc50ee 100644 --- a/frontend/app/api/shop/cart/rehydrate/route.ts +++ b/frontend/app/api/shop/cart/rehydrate/route.ts @@ -70,8 +70,6 @@ export async function POST(request: NextRequest) { { status: 422 } ); } - - // якщо ти десь все ще кидаєш MoneyValueError (coercePriceFromDb) if (error instanceof MoneyValueError) { return NextResponse.json( { diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index f831ad12..d1e40127 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -43,7 +43,6 @@ function isExpectedBusinessError(err: unknown): boolean { const code = getErrorCode(err); if (code && EXPECTED_BUSINESS_ERROR_CODES.has(code)) return true; - // fallback на типи (на випадок якщо десь нема .code) if (err instanceof IdempotencyConflictError) return true; if (err instanceof InvalidPayloadError) return true; if (err instanceof InsufficientStockError) return true; diff --git a/frontend/app/api/shop/internal/orders/restock-stale/route.ts b/frontend/app/api/shop/internal/orders/restock-stale/route.ts index 881c2d24..a3a0aaf4 100644 --- a/frontend/app/api/shop/internal/orders/restock-stale/route.ts +++ b/frontend/app/api/shop/internal/orders/restock-stale/route.ts @@ -422,7 +422,6 @@ export async function POST(request: NextRequest) { minIntervalSeconds, }); } catch (e) { - // не ковтай: але без твого логера — мінімально console.error('restock-stale failed', { runId, error: e }); return NextResponse.json( { success: false, code: 'INTERNAL_ERROR' }, diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 2d5deb60..e1f0d3e2 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -3,7 +3,7 @@ import Stripe from 'stripe'; import { NextRequest, NextResponse } from 'next/server'; import { and, eq, ne, or } from 'drizzle-orm'; import { db } from '@/db'; -import { verifyWebhookSignature } from '@/lib/psp/stripe'; +import { verifyWebhookSignature, retrieveCharge } from '@/lib/psp/stripe'; import { orders, stripeEvents } from '@/db/schema'; import { restockOrder } from '@/lib/services/orders'; import { logError } from '@/lib/logging'; @@ -233,7 +233,7 @@ export async function POST(request: NextRequest) { .where(eq(stripeEvents.eventId, event.id)); return NextResponse.json({ received: true }, { status: 200 }); }; - // 1) Записуємо event ідемпотентно (без транзакцій) + // 1) Insert event idempotently (no transactions) const inserted = await db .insert(stripeEvents) .values({ @@ -301,7 +301,7 @@ export async function POST(request: NextRequest) { .where(eq(stripeEvents.eventId, event.id)); } - // 3) Завантажуємо order + // 3) Load order const [order] = await db .select({ id: orders.id, @@ -331,7 +331,7 @@ export async function POST(request: NextRequest) { return ack(); } - // 4) Бізнес-обробка + // 4) Business logic per event type if (eventType === 'payment_intent.succeeded') { const stripeAmount = paymentIntent?.amount_received ?? paymentIntent?.amount ?? null; @@ -405,7 +405,7 @@ export async function POST(request: NextRequest) { const updated = await db .update(orders) .set({ - status: 'PAID', // ✅ додати + status: 'PAID', paymentStatus: 'paid', updatedAt: new Date(), pspChargeId: latestChargeId ?? chargeForIntent?.id ?? null, @@ -592,35 +592,89 @@ export async function POST(request: NextRequest) { eventType === 'charge.refund.updated' ) { const refund = refundObject ?? charge?.refunds?.data?.[0] ?? null; + const refundChargeId = - refundObject && - typeof (refundObject as any).charge === 'string' && - (refundObject as any).charge.trim().length > 0 - ? (refundObject as any).charge + refund && typeof refund.charge === 'string' + ? refund.charge.trim().length > 0 + ? refund.charge + : null + : refund && typeof refund.charge === 'object' && refund.charge + ? typeof (refund.charge as any).id === 'string' + ? (refund.charge as any).id + : null : null; // MVP: only FULL refund. // - charge.refunded: amount_refunded === amount - // - charge.refund.updated: refund.amount === order.totalAmountMinor (no partial support) + // - charge.refund.updated: compare cumulative refunded for the charge vs charge.amount let isFullRefund = false; - if (eventType === 'charge.refunded' && charge) { + if (eventType === 'charge.refunded') { + const effectiveCharge = charge; const amt = - typeof (charge as any).amount === 'number' - ? (charge as any).amount + typeof (effectiveCharge as any)?.amount === 'number' + ? (effectiveCharge as any).amount : null; const refunded = - typeof (charge as any).amount_refunded === 'number' - ? (charge as any).amount_refunded + typeof (effectiveCharge as any)?.amount_refunded === 'number' + ? (effectiveCharge as any).amount_refunded : null; + isFullRefund = amt != null && refunded != null && refunded === amt; } else if (eventType === 'charge.refund.updated' && refund) { - const refundAmt = - typeof (refund as any).amount === 'number' - ? (refund as any).amount + // Ensure we have the Charge to compute cumulative refunded correctly. + let effectiveCharge: Stripe.Charge | undefined; + + if (typeof refund.charge === 'object' && refund.charge) { + effectiveCharge = refund.charge as Stripe.Charge; + } else if (typeof refund.charge === 'string' && refund.charge.trim()) { + // Critical: fetch charge to get full refunds list + effectiveCharge = await retrieveCharge(refund.charge.trim()); + } + + const amt = + typeof (effectiveCharge as any)?.amount === 'number' + ? (effectiveCharge as any).amount + : null; + + let cumulativeRefunded: number | null = + typeof (effectiveCharge as any)?.amount_refunded === 'number' + ? (effectiveCharge as any).amount_refunded : null; - isFullRefund = - refundAmt != null && refundAmt === order.totalAmountMinor; + + // Fallback: sum refunds list if present; include current refund if not in list yet + 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 = list.some(r => r?.id && r.id === refund.id); + + cumulativeRefunded = sumFromList + (hasCurrent ? 0 : currentAmt); + } + + // If still unknown -> fail to force retry (better than silently ignoring full refund) + if (amt == null || cumulativeRefunded == null) { + throw new Error('REFUND_FULLNESS_UNDETERMINED'); + } + + isFullRefund = cumulativeRefunded === amt; + + // Prefer charge id from effectiveCharge for PSP fields + if (effectiveCharge?.id) { + // override local charge variable for downstream pspChargeId/metadata usage + charge = effectiveCharge; + } } if (!isFullRefund) { @@ -629,7 +683,7 @@ export async function POST(request: NextRequest) { .set({ updatedAt: new Date(), // do NOT change paymentStatus/status for partial refund - pspChargeId: charge?.id ?? null, + pspChargeId: charge?.id ?? refundChargeId ?? null, pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), pspStatusReason: 'PARTIAL_REFUND_IGNORED', pspMetadata: buildPspMetadata({ diff --git a/frontend/components/shop/admin/admin-product-status-toggle.tsx b/frontend/components/shop/admin/admin-product-status-toggle.tsx index 3c49e985..ba07db8b 100644 --- a/frontend/components/shop/admin/admin-product-status-toggle.tsx +++ b/frontend/components/shop/admin/admin-product-status-toggle.tsx @@ -1,55 +1,67 @@ -"use client" +"use client"; -import { useState } from "react" +import { useState } from "react"; interface AdminProductStatusToggleProps { - id: string - initialIsActive: boolean + id: string; + initialIsActive: boolean; } -export function AdminProductStatusToggle({ id, initialIsActive }: AdminProductStatusToggleProps) { - const [isActive, setIsActive] = useState(initialIsActive) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) +export function AdminProductStatusToggle({ + id, + initialIsActive, +}: AdminProductStatusToggleProps) { + const [isActive, setIsActive] = useState(initialIsActive); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); const toggleStatus = async () => { - setIsLoading(true) - setError(null) + setIsLoading(true); + setError(null); try { const response = await fetch(`/api/shop/admin/products/${id}/status`, { method: "PATCH", - }) + }); if (!response.ok) { - setError("Failed to update status") - return + setError("Failed to update status"); + return; } - const data: { product?: { isActive?: boolean } } = await response.json() + const data: { product?: { isActive?: boolean } } = await response.json(); if (typeof data.product?.isActive === "boolean") { - setIsActive(data.product.isActive) + setIsActive(data.product.isActive); } } catch (err) { - console.error("Failed to toggle product status", err) - setError("Failed to update status") + console.error("Failed to toggle product status", err); + setError("Failed to update status"); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; return ( -
+
- {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); });