diff --git a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx index af75a718..1ff25652 100644 --- a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx @@ -10,7 +10,7 @@ import { type CurrencyCode, } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; -import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { ShopAdminTopbar } from '@/components/shop/admin/ShopAdminTopbar'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; import { Metadata } from 'next'; diff --git a/frontend/app/[locale]/shop/admin/orders/page.tsx b/frontend/app/[locale]/shop/admin/orders/page.tsx index 460f55a6..8d6f4e9c 100644 --- a/frontend/app/[locale]/shop/admin/orders/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/page.tsx @@ -8,8 +8,8 @@ import { type CurrencyCode, } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; -import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; -import { AdminPagination } from '@/components/shop/admin/admin-pagination'; +import { ShopAdminTopbar } from '@/components/shop/admin/ShopAdminTopbar'; +import { AdminPagination } from '@/components/shop/admin/AdminPagination'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; import { CSRF_FORM_FIELD, issueCsrfToken } from '@/lib/security/csrf'; import { parsePage } from '@/lib/pagination'; diff --git a/frontend/app/[locale]/shop/admin/page.tsx b/frontend/app/[locale]/shop/admin/page.tsx index 4c8a3fb9..31f1320c 100644 --- a/frontend/app/[locale]/shop/admin/page.tsx +++ b/frontend/app/[locale]/shop/admin/page.tsx @@ -1,7 +1,7 @@ import { Link } from '@/i18n/routing'; import { getTranslations } from 'next-intl/server'; -import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { ShopAdminTopbar } from '@/components/shop/admin/ShopAdminTopbar'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; import { Metadata } from 'next'; diff --git a/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx b/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx index 32c71ae8..12994503 100644 --- a/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx @@ -3,9 +3,9 @@ import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; -import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { ShopAdminTopbar } from '@/components/shop/admin/ShopAdminTopbar'; -import { ProductForm } from '../../_components/product-form'; +import { ProductForm } from '../../_components/ProductForm'; import { db } from '@/db'; import { products, productPrices } from '@/db/schema'; import type { CurrencyCode } from '@/lib/shop/currency'; diff --git a/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx b/frontend/app/[locale]/shop/admin/products/_components/ProductForm.tsx similarity index 100% rename from frontend/app/[locale]/shop/admin/products/_components/product-form.tsx rename to frontend/app/[locale]/shop/admin/products/_components/ProductForm.tsx diff --git a/frontend/app/[locale]/shop/admin/products/new/page.tsx b/frontend/app/[locale]/shop/admin/products/new/page.tsx index 89cc4de4..c3be929f 100644 --- a/frontend/app/[locale]/shop/admin/products/new/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/new/page.tsx @@ -1,8 +1,8 @@ -import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { ShopAdminTopbar } from '@/components/shop/admin/ShopAdminTopbar'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; import { issueCsrfToken } from '@/lib/security/csrf'; -import { ProductForm } from '../_components/product-form'; +import { ProductForm } from '../_components/ProductForm'; import { Metadata } from 'next'; export const metadata: Metadata = { diff --git a/frontend/app/[locale]/shop/admin/products/page.tsx b/frontend/app/[locale]/shop/admin/products/page.tsx index ed1dccf1..a9992cfe 100644 --- a/frontend/app/[locale]/shop/admin/products/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/page.tsx @@ -1,11 +1,11 @@ import { Link } from '@/i18n/routing'; import { and, desc, eq, sql } from 'drizzle-orm'; import { issueCsrfToken } from '@/lib/security/csrf'; -import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { ShopAdminTopbar } from '@/components/shop/admin/ShopAdminTopbar'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; -import { AdminProductDeleteButton } from '@/components/shop/admin/admin-product-delete-button'; -import { AdminProductStatusToggle } from '@/components/shop/admin/admin-product-status-toggle'; -import { AdminPagination } from '@/components/shop/admin/admin-pagination'; +import { AdminProductDeleteButton } from '@/components/shop/admin/AdminProductDeleteButton'; +import { AdminProductStatusToggle } from '@/components/shop/admin/AdminProductStatusToggle'; +import { AdminPagination } from '@/components/shop/admin/AdminPagination'; import { db } from '@/db'; import { inventoryMoves, diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 9fb12085..7c9f231c 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -8,7 +8,7 @@ import { useTranslations } from 'next-intl'; import { cn } from '@/lib/utils'; import { Minus, Plus, Trash2, ShoppingBag } from 'lucide-react'; -import { useCart } from '@/components/shop/cart-provider'; +import { useCart } from '@/components/shop/CartProvider'; import { generateIdempotencyKey } from '@/lib/shop/idempotency'; import { formatMoney } from '@/lib/shop/currency'; import { diff --git a/frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx b/frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx index 76f1139c..57883f91 100644 --- a/frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx +++ b/frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx @@ -1,5 +1,5 @@ import { Link } from '@/i18n/routing'; -import { ClearCartOnMount } from '@/components/shop/clear-cart-on-mount'; +import { ClearCartOnMount } from '@/components/shop/ClearCartOnMount'; import StripePaymentClient from '../StripePaymentClient'; import { formatMoney } from '@/lib/shop/currency'; diff --git a/frontend/app/[locale]/shop/checkout/success/page.tsx b/frontend/app/[locale]/shop/checkout/success/page.tsx index 91f93241..98ac6293 100644 --- a/frontend/app/[locale]/shop/checkout/success/page.tsx +++ b/frontend/app/[locale]/shop/checkout/success/page.tsx @@ -2,7 +2,7 @@ import { Link } from '@/i18n/routing'; import { getTranslations } from 'next-intl/server'; import OrderStatusAutoRefresh from './OrderStatusAutoRefresh'; -import { ClearCartOnMount } from '@/components/shop/clear-cart-on-mount'; +import { ClearCartOnMount } from '@/components/shop/ClearCartOnMount'; import { formatMoney } from '@/lib/shop/currency'; import { getOrderSummary } from '@/lib/services/orders'; import { OrderNotFoundError } from '@/lib/services/errors'; @@ -57,7 +57,6 @@ function shouldClearCart(params: SearchParams): boolean { return raw === 'true' || raw === '1'; } -/** Small hero CTA (Link) */ const SHOP_HERO_CTA_SM = cn( SHOP_CTA_BASE, SHOP_CTA_INTERACTIVE, diff --git a/frontend/app/[locale]/shop/page.tsx b/frontend/app/[locale]/shop/page.tsx index a86ddfb8..a8a78b66 100644 --- a/frontend/app/[locale]/shop/page.tsx +++ b/frontend/app/[locale]/shop/page.tsx @@ -1,7 +1,7 @@ import { Link } from '@/i18n/routing'; -import { ProductCard } from '@/components/shop/product-card'; -import { Hero } from '@/components/shop/shop-hero'; -import { CategoryTile } from '@/components/shop/category-tile'; +import { ProductCard } from '@/components/shop/ProductCard'; +import { Hero } from '@/components/shop/ShopHero'; +import { CategoryTile } from '@/components/shop/CategoryTile'; import { getHomepageContent } from '@/lib/shop/data'; import { getTranslations } from 'next-intl/server'; import { diff --git a/frontend/app/[locale]/shop/products/[slug]/page.tsx b/frontend/app/[locale]/shop/products/[slug]/page.tsx index f5ea2ed8..edb2eeff 100644 --- a/frontend/app/[locale]/shop/products/[slug]/page.tsx +++ b/frontend/app/[locale]/shop/products/[slug]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; import { cn } from '@/lib/utils'; -import { AddToCartButton } from '@/components/shop/add-to-cart-button'; +import { AddToCartButton } from '@/components/shop/AddToCartButton'; import { getProductPageData } from '@/lib/shop/data'; import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency'; import { getPublicProductBySlug } from '@/db/queries/shop/products'; diff --git a/frontend/app/[locale]/shop/products/page.tsx b/frontend/app/[locale]/shop/products/page.tsx index 8757db69..8d57d782 100644 --- a/frontend/app/[locale]/shop/products/page.tsx +++ b/frontend/app/[locale]/shop/products/page.tsx @@ -2,9 +2,9 @@ import { Suspense } from 'react'; import { redirect } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; -import { ProductFilters } from '@/components/shop/product-filters'; -import { CatalogProductsClient } from '@/components/shop/catalog-products-client'; -import { ProductsToolbar } from '@/components/shop/products-toolbar'; +import { ProductFilters } from '@/components/shop/ProductFilters'; +import { CatalogProductsClient } from '@/components/shop/CatalogProductsClient'; +import { ProductsToolbar } from '@/components/shop/ProductsToolbar'; import { getCatalogProducts } from '@/lib/shop/data'; import { catalogQuerySchema } from '@/lib/validation/shop'; import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog'; diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 73671d9a..f1ff4a6a 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -1518,9 +1518,14 @@ export async function POST(request: NextRequest) { isNull(stripeEvents.processedAt) ) ); - } catch { - // best-effort + } catch (err) { + logWarn('stripe_webhook_claim_release_failed', { + ...eventMeta(), + code: 'CLAIM_RELEASE_FAILED', + message: err instanceof Error ? err.message : String(err), + }); } + return noStoreJson({ error: 'internal_error' }, { status: 500 }); } } diff --git a/frontend/components/header/AppChrome.tsx b/frontend/components/header/AppChrome.tsx index 55a68e77..aca5f3b8 100644 --- a/frontend/components/header/AppChrome.tsx +++ b/frontend/components/header/AppChrome.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { useSelectedLayoutSegments } from 'next/navigation'; import { UnifiedHeader } from '@/components/header/UnifiedHeader'; -import { CartProvider } from '@/components/shop/cart-provider'; +import { CartProvider } from '@/components/shop/CartProvider'; type AppChromeProps = { userExists: boolean; diff --git a/frontend/components/header/DesktopActions.tsx b/frontend/components/header/DesktopActions.tsx index d0e627f2..4b59b2e2 100644 --- a/frontend/components/header/DesktopActions.tsx +++ b/frontend/components/header/DesktopActions.tsx @@ -7,7 +7,7 @@ import { HeaderButton } from '@/components/shared/HeaderButton'; import { GitHubStarButton } from '@/components/shared/GitHubStarButton'; import LanguageSwitcher from '@/components/shared/LanguageSwitcher'; import { LogoutButton } from '@/components/auth/logoutButton'; -import { CartButton } from '@/components/shop/header/cart-button'; +import { CartButton } from '@/components/shop/header/CartButton'; import { BlogHeaderSearch } from '@/components/blog/BlogHeaderSearch'; type DesktopActionsProps = { diff --git a/frontend/components/header/DesktopNav.tsx b/frontend/components/header/DesktopNav.tsx index 957fc6ef..2041273f 100644 --- a/frontend/components/header/DesktopNav.tsx +++ b/frontend/components/header/DesktopNav.tsx @@ -6,7 +6,7 @@ import { SITE_LINKS } from '@/lib/navigation'; import { NavLink } from '@/components/header/NavLink'; import { HeaderButton } from '@/components/shared/HeaderButton'; -import { NavLinks } from '@/components/shop/header/nav-links'; +import { NavLinks } from '@/components/shop/header/NavLinks'; import { BlogCategoryLinks } from '@/components/blog/BlogCategoryLinks'; type Category = { diff --git a/frontend/components/header/MobileActions.tsx b/frontend/components/header/MobileActions.tsx index 76cf49c9..263048ee 100644 --- a/frontend/components/header/MobileActions.tsx +++ b/frontend/components/header/MobileActions.tsx @@ -1,7 +1,7 @@ 'use client'; import LanguageSwitcher from '@/components/shared/LanguageSwitcher'; -import { CartButton } from '@/components/shop/header/cart-button'; +import { CartButton } from '@/components/shop/header/CartButton'; import { BlogHeaderSearch } from '@/components/blog/BlogHeaderSearch'; import { AppMobileMenu } from '@/components/header/AppMobileMenu'; diff --git a/frontend/components/shop/add-to-cart-button.tsx b/frontend/components/shop/AddToCartButton.tsx similarity index 99% rename from frontend/components/shop/add-to-cart-button.tsx rename to frontend/components/shop/AddToCartButton.tsx index 7cdfb619..56a53a73 100644 --- a/frontend/components/shop/add-to-cart-button.tsx +++ b/frontend/components/shop/AddToCartButton.tsx @@ -19,7 +19,7 @@ import { SHOP_STEPPER_BUTTON_BASE, } from '@/lib/shop/ui-classes'; -import { useCart } from './cart-provider'; +import { useCart } from './CartProvider'; interface AddToCartButtonProps { product: ShopProduct; diff --git a/frontend/components/shop/cart-provider.tsx b/frontend/components/shop/CartProvider.tsx similarity index 100% rename from frontend/components/shop/cart-provider.tsx rename to frontend/components/shop/CartProvider.tsx diff --git a/frontend/components/shop/catalog-load-more.tsx b/frontend/components/shop/CatalogLoadMore.tsx similarity index 100% rename from frontend/components/shop/catalog-load-more.tsx rename to frontend/components/shop/CatalogLoadMore.tsx diff --git a/frontend/components/shop/catalog-products-client.tsx b/frontend/components/shop/CatalogProductsClient.tsx similarity index 96% rename from frontend/components/shop/catalog-products-client.tsx rename to frontend/components/shop/CatalogProductsClient.tsx index 44c5c2a8..7748ae04 100644 --- a/frontend/components/shop/catalog-products-client.tsx +++ b/frontend/components/shop/CatalogProductsClient.tsx @@ -4,8 +4,8 @@ import React from 'react'; import { useSearchParams, type ReadonlyURLSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; -import { CatalogLoadMore } from '@/components/shop/catalog-load-more'; -import { ProductCard } from '@/components/shop/product-card'; +import { CatalogLoadMore } from '@/components/shop/CatalogLoadMore'; +import { ProductCard } from '@/components/shop/ProductCard'; import { logError } from '@/lib/logging'; type Product = React.ComponentProps['product'] & { diff --git a/frontend/components/shop/category-tile.tsx b/frontend/components/shop/CategoryTile.tsx similarity index 100% rename from frontend/components/shop/category-tile.tsx rename to frontend/components/shop/CategoryTile.tsx diff --git a/frontend/components/shop/clear-cart-on-mount.tsx b/frontend/components/shop/ClearCartOnMount.tsx similarity index 88% rename from frontend/components/shop/clear-cart-on-mount.tsx rename to frontend/components/shop/ClearCartOnMount.tsx index 603b3c37..f79ea7c5 100644 --- a/frontend/components/shop/clear-cart-on-mount.tsx +++ b/frontend/components/shop/ClearCartOnMount.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef } from 'react'; -import { useCart } from '@/components/shop/cart-provider'; +import { useCart } from '@/components/shop/CartProvider'; type ClearCartOnMountProps = { enabled?: boolean; diff --git a/frontend/components/shop/product-card.tsx b/frontend/components/shop/ProductCard.tsx similarity index 100% rename from frontend/components/shop/product-card.tsx rename to frontend/components/shop/ProductCard.tsx diff --git a/frontend/components/shop/product-filters.tsx b/frontend/components/shop/ProductFilters.tsx similarity index 98% rename from frontend/components/shop/product-filters.tsx rename to frontend/components/shop/ProductFilters.tsx index bb538ac3..67554934 100644 --- a/frontend/components/shop/product-filters.tsx +++ b/frontend/components/shop/ProductFilters.tsx @@ -68,7 +68,7 @@ export function ProductFilters() { SHOP_FOCUS, currentCategory === cat.slug ? 'text-accent' - : 'text-muted-foreground hover:text-accent' + : 'text-muted-foreground hover:text-accent active:text-accent' )} > {tCategories( @@ -109,7 +109,7 @@ export function ProductFilters() { SHOP_FOCUS, isSelected ? 'text-accent' - : 'text-muted-foreground hover:text-accent' + : 'text-muted-foreground hover:text-accent active:text-accent' )} > {tTypes(type.slug)} diff --git a/frontend/components/shop/product-sort.tsx b/frontend/components/shop/ProductSort.tsx similarity index 100% rename from frontend/components/shop/product-sort.tsx rename to frontend/components/shop/ProductSort.tsx diff --git a/frontend/components/shop/products-toolbar.tsx b/frontend/components/shop/ProductsToolbar.tsx similarity index 97% rename from frontend/components/shop/products-toolbar.tsx rename to frontend/components/shop/ProductsToolbar.tsx index e8f2a3d6..2353d802 100644 --- a/frontend/components/shop/products-toolbar.tsx +++ b/frontend/components/shop/ProductsToolbar.tsx @@ -4,8 +4,8 @@ import React from 'react'; import { Filter, X } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { ProductSort } from '@/components/shop/product-sort'; -import { ProductFilters } from '@/components/shop/product-filters'; +import { ProductSort } from '@/components/shop/ProductSort'; +import { ProductFilters } from '@/components/shop/ProductFilters'; export function ProductsToolbar() { const [open, setOpen] = React.useState(false); diff --git a/frontend/components/shop/shop-hero.tsx b/frontend/components/shop/ShopHero.tsx similarity index 100% rename from frontend/components/shop/shop-hero.tsx rename to frontend/components/shop/ShopHero.tsx diff --git a/frontend/components/shop/admin/admin-pagination.tsx b/frontend/components/shop/admin/AdminPagination.tsx similarity index 100% rename from frontend/components/shop/admin/admin-pagination.tsx rename to frontend/components/shop/admin/AdminPagination.tsx diff --git a/frontend/components/shop/admin/admin-product-delete-button.tsx b/frontend/components/shop/admin/AdminProductDeleteButton.tsx similarity index 100% rename from frontend/components/shop/admin/admin-product-delete-button.tsx rename to frontend/components/shop/admin/AdminProductDeleteButton.tsx diff --git a/frontend/components/shop/admin/admin-product-status-toggle.tsx b/frontend/components/shop/admin/AdminProductStatusToggle.tsx similarity index 100% rename from frontend/components/shop/admin/admin-product-status-toggle.tsx rename to frontend/components/shop/admin/AdminProductStatusToggle.tsx diff --git a/frontend/components/shop/admin/shop-admin-topbar.tsx b/frontend/components/shop/admin/ShopAdminTopbar.tsx similarity index 100% rename from frontend/components/shop/admin/shop-admin-topbar.tsx rename to frontend/components/shop/admin/ShopAdminTopbar.tsx diff --git a/frontend/components/shop/header/cart-button.tsx b/frontend/components/shop/header/CartButton.tsx similarity index 94% rename from frontend/components/shop/header/cart-button.tsx rename to frontend/components/shop/header/CartButton.tsx index 7e8ed8c8..5077846e 100644 --- a/frontend/components/shop/header/cart-button.tsx +++ b/frontend/components/shop/header/CartButton.tsx @@ -5,7 +5,7 @@ import { ShoppingBag } from 'lucide-react'; import { useMounted } from '@/hooks/use-mounted'; import { HeaderButton } from '@/components/shared/HeaderButton'; -import { useCart } from '../cart-provider'; +import { useCart } from '../CartProvider'; export function CartButton() { const { cart } = useCart(); diff --git a/frontend/components/shop/header/nav-links.tsx b/frontend/components/shop/header/NavLinks.tsx similarity index 100% rename from frontend/components/shop/header/nav-links.tsx rename to frontend/components/shop/header/NavLinks.tsx diff --git a/frontend/lib/admin/parseAdminProductForm.ts b/frontend/lib/admin/parseAdminProductForm.ts index 735a43a9..fa365899 100644 --- a/frontend/lib/admin/parseAdminProductForm.ts +++ b/frontend/lib/admin/parseAdminProductForm.ts @@ -83,8 +83,8 @@ function parseMajorToMinor( typeof value === 'string' ? value.trim() : typeof value === 'number' - ? String(value) - : ''; + ? String(value) + : ''; if (!raw) return null; @@ -104,12 +104,6 @@ function parseLegacyPriceMinorField( return toCents(v); } -/** - * Legacy optional field semantics: - * - if field missing => undefined (PATCH omit) - * - if present but empty => null (explicit clear) - * - if present and value => cents int - */ function parseLegacyOptionalOriginalMinorField( formData: FormData, name: string @@ -135,8 +129,8 @@ function parseMinorInt( typeof value === 'number' ? value : typeof value === 'string' - ? Number(value.trim()) - : NaN; + ? Number(value.trim()) + : NaN; if (!Number.isFinite(raw) || !Number.isInteger(raw) || raw < 0) { throw zodPricesJsonError(`Invalid ${opts.field} for ${opts.currency}`); @@ -149,7 +143,6 @@ function requirePositivePriceMinor( priceMinor: number | null, currency: string ) { - // DB check: priceMinor > 0 if (priceMinor == null || priceMinor <= 0) { throw zodPricesJsonError(`Missing price for ${currency}`); } @@ -206,13 +199,11 @@ function parsePricesJsonField(formData: FormData, mode: ParseMode) { throw zodPricesJsonError('Invalid currency in prices payload'); } - // Prefer canonical minor payload let priceMinor = parseMinorInt(row?.priceMinor, { field: 'priceMinor', currency: currency as string, }); - // Legacy major fallback if (priceMinor == null) { priceMinor = parseMajorToMinor(row?.price, { field: 'price', @@ -240,10 +231,8 @@ function parsePricesJsonField(formData: FormData, mode: ParseMode) { }); } - // Normalize: empty -> null if (originalPriceMinor == null) originalPriceMinor = null; - // DB invariant: originalPriceMinor is null OR > priceMinor if (originalPriceMinor !== null && priceMinor != null) { if (originalPriceMinor <= priceMinor) { throw zodPricesJsonError( @@ -285,13 +274,11 @@ export function parseAdminProductForm( > { const mode: ParseMode = options.mode ?? 'create'; - // 1) Prefer canonical "prices" JSON payload if present const pricesJson = parsePricesJsonField(formData, mode); if (pricesJson && 'ok' in pricesJson && pricesJson.ok === false) { return { ok: false, error: pricesJson.error }; } - // 2) Legacy fallback (priceUsd/priceUah) -> MINOR units const priceUsdMinor = parseLegacyPriceMinorField(formData, 'priceUsd'); const originalPriceUsdMinor = parseLegacyOptionalOriginalMinorField( formData, @@ -325,13 +312,12 @@ export function parseAdminProductForm( : []), ]; - // Resolve final prices with PATCH semantics const prices = pricesJson && 'value' in pricesJson ? pricesJson.value : mode === 'update' && legacyRawPrices.length === 0 - ? undefined - : legacyRawPrices; + ? undefined + : legacyRawPrices; const payload = { title: getStringField(formData, 'title'), diff --git a/frontend/lib/auth/admin.ts b/frontend/lib/auth/admin.ts index 4a4b356b..b5682545 100644 --- a/frontend/lib/auth/admin.ts +++ b/frontend/lib/auth/admin.ts @@ -1,70 +1,60 @@ -import "server-only" +import 'server-only'; -import type { NextRequest } from "next/server" +import type { NextRequest } from 'next/server'; -import { getCurrentUser } from "@/lib/auth" +import { getCurrentUser } from '@/lib/auth'; export class AdminApiDisabledError extends Error { - code = "ADMIN_API_DISABLED" as const - constructor(message = "Admin API is disabled by configuration") { - super(message) - this.name = "AdminApiDisabledError" + code = 'ADMIN_API_DISABLED' as const; + constructor(message = 'Admin API is disabled by configuration') { + super(message); + this.name = 'AdminApiDisabledError'; } } export class AdminUnauthorizedError extends Error { - code = "UNAUTHORIZED" as const - constructor(message = "Authentication required") { - super(message) - this.name = "AdminUnauthorizedError" + code = 'UNAUTHORIZED' as const; + constructor(message = 'Authentication required') { + super(message); + this.name = 'AdminUnauthorizedError'; } } export class AdminForbiddenError extends Error { - code = "FORBIDDEN" as const - constructor(message = "Admin role required") { - super(message) - this.name = "AdminForbiddenError" + code = 'FORBIDDEN' as const; + constructor(message = 'Admin role required') { + super(message); + this.name = 'AdminForbiddenError'; } } -/** - * Kill-switch for production. - * Keeps MVP safety: you can hard-disable admin mutating endpoints in prod instantly. - */ export function assertAdminApiEnabled(): void { - if (process.env.NODE_ENV === "production" && process.env.ENABLE_ADMIN_API !== "true") { - throw new AdminApiDisabledError() + if ( + process.env.NODE_ENV === 'production' && + process.env.ENABLE_ADMIN_API !== 'true' + ) { + throw new AdminApiDisabledError(); } } -/** - * API guard: must be enabled in prod + must be authenticated admin. - * Return value is useful if later you want audit logs (adminId, email). - */ export async function requireAdminApi(_request?: NextRequest) { - void _request - assertAdminApiEnabled() + void _request; + assertAdminApiEnabled(); - const user = await getCurrentUser() - if (!user) throw new AdminUnauthorizedError() + const user = await getCurrentUser(); + if (!user) throw new AdminUnauthorizedError(); - // Harden against unexpected role strings from DB - if (user.role !== "admin") throw new AdminForbiddenError() + if (user.role !== 'admin') throw new AdminForbiddenError(); - return user + return user; } -/** - * Page guard: same logic as API guard. - * Use from Server Components (layouts/pages) to protect /shop/admin/**. - */ export async function requireAdminPage() { - assertAdminApiEnabled() + assertAdminApiEnabled(); - const user = await getCurrentUser() - if (!user) throw new AdminUnauthorizedError() - if (user.role !== "admin") throw new AdminForbiddenError() + const user = await getCurrentUser(); + if (!user) throw new AdminUnauthorizedError(); + if (user.role !== 'admin') throw new AdminForbiddenError(); - return user + return user; } diff --git a/frontend/lib/auth/internal-janitor.ts b/frontend/lib/auth/internal-janitor.ts index 5c7ad629..a44163b4 100644 --- a/frontend/lib/auth/internal-janitor.ts +++ b/frontend/lib/auth/internal-janitor.ts @@ -1,13 +1,9 @@ -// lib/auth/internal-janitor.ts import { NextRequest, NextResponse } from 'next/server'; import crypto from 'crypto'; function timingSafeEqual(a: string, b: string) { 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); @@ -18,7 +14,6 @@ function timingSafeEqual(a: string, b: string) { const equalPadded = crypto.timingSafeEqual(aPadded, bPadded); - // Length check AFTER timingSafeEqual; no early return. const lengthEqual = aBuf.length === bBuf.length; return equalPadded && lengthEqual; diff --git a/frontend/lib/cart.ts b/frontend/lib/cart.ts index 14d8ff2c..b1285aa5 100644 --- a/frontend/lib/cart.ts +++ b/frontend/lib/cart.ts @@ -106,7 +106,9 @@ export function persistCartItems(items: CartClientItem[]): void { } } -function normalizeItemsForStorage(items: CartRehydrateItem[]): CartClientItem[] { +function normalizeItemsForStorage( + items: CartRehydrateItem[] +): CartClientItem[] { return items.map(item => ({ productId: item.productId, quantity: item.quantity, @@ -115,11 +117,9 @@ function normalizeItemsForStorage(items: CartRehydrateItem[]): CartClientItem[] })); } -/** - * IMPORTANT: - * Cart money fields are MINOR UNITS (integers). - */ -export function computeSummaryFromItems(items: CartRehydrateItem[]): CartSummary { +export function computeSummaryFromItems( + items: CartRehydrateItem[] +): CartSummary { if (!items.length) { return { totalAmountMinor: 0, @@ -135,7 +135,6 @@ export function computeSummaryFromItems(items: CartRehydrateItem[]): CartSummary let itemCount = 0; for (const item of items) { - // Production-safety: cart must not mix currencies (usually indicates locale switch + stale cart) if (item.currency !== currency) { throw new Error( `Cart contains mixed currencies (${currency} and ${item.currency}). Clear cart and try again.` @@ -163,12 +162,9 @@ function extractApiError(data: unknown): { message: string; details?: unknown; } { - // legacy: { error: "..." } if (isRecord(data) && typeof data.error === 'string') { return { code: 'UNKNOWN_ERROR', message: data.error }; } - - // preferred: { error: { code, message, details } } if (isRecord(data) && isRecord(data.error)) { const errObj = data.error; const code = @@ -181,8 +177,6 @@ function extractApiError(data: unknown): { : 'Request failed'; return { code, message, details: errObj.details }; } - - // fallback: { code, message, details } if (isRecord(data)) { const code = typeof data.code === 'string' && data.code.trim().length > 0 @@ -245,7 +239,8 @@ export async function rehydrateCart(items: CartClientItem[]): Promise { const apiErr = extractApiError(data); throw new CartRehydrateError({ code: apiErr.code, - message: apiErr.message || `Unable to rehydrate cart (HTTP ${response.status})`, + message: + apiErr.message || `Unable to rehydrate cart (HTTP ${response.status})`, status: response.status, details: apiErr.details, }); diff --git a/frontend/lib/env/cloudinary.ts b/frontend/lib/env/cloudinary.ts index 6576599c..80e42bba 100644 --- a/frontend/lib/env/cloudinary.ts +++ b/frontend/lib/env/cloudinary.ts @@ -23,10 +23,6 @@ export type CloudinaryEnv = { uploadFolder: string; }; -/** - * Returns null if Cloudinary is not configured. - * Never throws — safe to call anywhere (including during build). - */ export function getCloudinaryEnvOptional(): CloudinaryEnv | null { const cloudName = process.env.CLOUDINARY_CLOUD_NAME; const apiKey = process.env.CLOUDINARY_API_KEY; @@ -46,10 +42,6 @@ export function getCloudinaryEnvOptional(): CloudinaryEnv | null { }; } -/** - * Throws a typed error if Cloudinary is not configured. - * Use this ONLY in code paths that actually upload/delete images. - */ export function getCloudinaryEnvRequired(): CloudinaryEnv { const missing: string[] = []; if (!process.env.CLOUDINARY_CLOUD_NAME) missing.push('CLOUDINARY_CLOUD_NAME'); diff --git a/frontend/lib/errors/products.ts b/frontend/lib/errors/products.ts index 822e24d5..a652c59f 100644 --- a/frontend/lib/errors/products.ts +++ b/frontend/lib/errors/products.ts @@ -1,5 +1,3 @@ -// frontend/lib/errors/products.ts - export class ProductNotFoundError extends Error { readonly code = 'PRODUCT_NOT_FOUND' as const; diff --git a/frontend/lib/logging.ts b/frontend/lib/logging.ts index 5fb57ae9..b0af111e 100644 --- a/frontend/lib/logging.ts +++ b/frontend/lib/logging.ts @@ -45,7 +45,6 @@ function toErrorShape( } } -// Simple redaction: prevents accidental leaking of secrets in logs. function redactJsonStringify(value: unknown): string { const SENSITIVE_KEY_RE = /(secret|token|password|authorization|cookie|api[_-]?key)/i; @@ -75,15 +74,11 @@ function emit( const line = redactJsonStringify(payload); - // stderr for warn/error, stdout for debug/info if (level === 'error') console.error(line); else if (level === 'warn') console.warn(line); else console.log(line); } -/** - * Backward-compatible API (keep existing call sites working). - */ export function logError( context: string, error: unknown, diff --git a/frontend/lib/pagination.ts b/frontend/lib/pagination.ts index 2d07673b..864e8fbc 100644 --- a/frontend/lib/pagination.ts +++ b/frontend/lib/pagination.ts @@ -1,4 +1,3 @@ -// frontend/lib/pagination.ts export function parsePage(input?: string): number { const n = Number.parseInt(input ?? '1', 10); return Number.isFinite(n) && n > 0 ? n : 1; diff --git a/frontend/lib/psp/stripe.ts b/frontend/lib/psp/stripe.ts index 72e8185d..c37d92c7 100644 --- a/frontend/lib/psp/stripe.ts +++ b/frontend/lib/psp/stripe.ts @@ -13,7 +13,7 @@ type CreateRefundInput = { orderId: string; paymentIntentId?: string | null; chargeId?: string | null; - amountMinor?: number; // full refund: pass totalAmountMinor (recommended) + amountMinor?: number; idempotencyKey?: string; }; @@ -113,7 +113,6 @@ export async function createPaymentIntent({ throw new Error('STRIPE_DISABLED'); } - // Stripe amount must be an integer in minor units. Fail-closed on floats/NaN/huge values. if (!Number.isSafeInteger(amount) || amount <= 0) { throw new Error('STRIPE_INVALID_AMOUNT'); } diff --git a/frontend/lib/security/admin-csrf.ts b/frontend/lib/security/admin-csrf.ts index bc445d16..e40c87c1 100644 --- a/frontend/lib/security/admin-csrf.ts +++ b/frontend/lib/security/admin-csrf.ts @@ -20,16 +20,6 @@ function readTokenFromHeader(request: unknown): string | null { return typeof raw === 'string' ? raw.trim() : null; } -/** - * Admin CSRF gate for cookie-based auth. - * Token source: - * - multipart/form-data: field CSRF_FORM_FIELD - * - otherwise: header x-csrf-token - * - * Fail-closed: - * - missing/invalid token => 403 - * - CSRF secret misconfigured => 503 (not 500) - */ export function requireAdminCsrf( request: NextRequest, purpose: string, @@ -48,7 +38,6 @@ export function requireAdminCsrf( } return null; } catch (err) { - // e.g. CSRF_SECRET missing -> csrf.ts throws logError('CSRF verification failed (misconfigured)', err); return NextResponse.json({ code: 'CSRF_DISABLED' }, { status: 503 }); } diff --git a/frontend/lib/security/csrf.ts b/frontend/lib/security/csrf.ts index a1f7589d..cb23be93 100644 --- a/frontend/lib/security/csrf.ts +++ b/frontend/lib/security/csrf.ts @@ -5,8 +5,7 @@ import type { NextRequest } from 'next/server'; export const CSRF_FORM_FIELD = 'csrfToken' as const; -const DEFAULT_TTL_SECONDS = 60 * 60; // 1h - +const DEFAULT_TTL_SECONDS = 60 * 60; function getSecret(): string { const secret = process.env.CSRF_SECRET; if (!secret) throw new Error('Missing env var: CSRF_SECRET'); @@ -35,10 +34,6 @@ function b64urlDecodeUtf8(input: string): string { return Buffer.from(input, 'base64url').toString('utf8'); } -/** - * Stateless CSRF token: payload(JSON)->base64url + HMAC signature. - * TTL enforced via exp (unix seconds). - */ export function issueCsrfToken( purpose: string, ttlSeconds = DEFAULT_TTL_SECONDS @@ -80,17 +75,12 @@ export function verifyCsrfToken(token: string, purpose: string): boolean { if (typeof iat !== 'number' || !Number.isFinite(iat)) return false; if (typeof exp !== 'number' || !Number.isFinite(exp)) return false; - // TTL + basic sanity: exp must be >= now and not before iat if (exp < now) return false; if (exp < iat) return false; return true; } -/** - * Secondary defense: same-origin check. - * Use Origin when present, otherwise fall back to Referer. - */ export function isSameOrigin(req: NextRequest): boolean { const expected = req.nextUrl.origin; diff --git a/frontend/lib/security/origin.ts b/frontend/lib/security/origin.ts index 00f66581..9fecea22 100644 --- a/frontend/lib/security/origin.ts +++ b/frontend/lib/security/origin.ts @@ -13,7 +13,6 @@ function buildErrorResponse(code: string, message: string) { { status: 403 } ); - // Ensure security errors are never cached by intermediaries. res.headers.set('Cache-Control', 'no-store'); return res; @@ -23,10 +22,8 @@ export function normalizeOrigin(input: string): string { const trimmed = input.trim().replace(/\/+$/, ''); try { - // If the input is a valid URL (incl. scheme), normalize to canonical origin. return new URL(trimmed).origin; } catch { - // Backward-compatible fallback for values like "example.com" (no scheme). return trimmed; } } diff --git a/frontend/lib/security/rate-limit.ts b/frontend/lib/security/rate-limit.ts index cc5e5ab2..08b53f22 100644 --- a/frontend/lib/security/rate-limit.ts +++ b/frontend/lib/security/rate-limit.ts @@ -114,14 +114,11 @@ export function getClientIpFromHeaders(headers: Headers): string | null { ); const trustCf = envBool('TRUST_CF_CONNECTING_IP', false); - // Allow Cloudflare canonical header (highest priority) only when explicitly trusted. if (trustCf) { const cf = (headers.get('cf-connecting-ip') ?? '').trim(); if (cf && isIP(cf)) return cf; } - // Trusted boundary: if we don't trust forwarded headers, - // do NOT fall back to spoofable headers. if (!trustForwarded) return null; const xr = (headers.get('x-real-ip') ?? '').trim(); @@ -144,7 +141,6 @@ export function getClientIp(request: NextRequest): string | null { export function getRateLimitSubject(request: NextRequest): string { const ip = getClientIp(request); - // Keep subject clean/stable for IPv6 (no ":"), consistent with key normalization. if (ip) return normalizeRateLimitSubject(ip); const ua = (request.headers.get('user-agent') ?? '').trim(); @@ -158,11 +154,6 @@ export type RateLimitOk = { ok: true; remaining: number }; export type RateLimitNo = { ok: false; retryAfterSeconds: number }; export type RateLimitDecision = RateLimitOk | RateLimitNo; -/** - * DB-backed fixed-window limiter (cross-instance). - * - Atomic insert/update with conditional WHERE to avoid going above limit. - * - If limited: computes Retry-After from stored window_started_at. - */ export async function enforceRateLimit(params: { key: string; limit: number; @@ -173,7 +164,6 @@ export async function enforceRateLimit(params: { const { legacyKey, normalizedKey } = normalizeRateLimitKey(params.key); const key = normalizedKey; - // Allow disabling via env (for emergency): RATE_LIMIT_DISABLED=1 if (envInt('RATE_LIMIT_DISABLED', 0) === 1) { return { ok: true, remaining: Number.MAX_SAFE_INTEGER }; } @@ -192,8 +182,12 @@ export async function enforceRateLimit(params: { SELECT 1 FROM api_rate_limits WHERE key = ${normalizedKey} ) `); - } catch { - // Ignore conflicts; fall through to use normalizedKey for enforcement. + } catch (err) { + console.warn( + '[rate-limit] Failed to migrate legacy key:', + legacyKey, + err + ); } } diff --git a/frontend/lib/security/stripe-webhook-rate-limit.ts b/frontend/lib/security/stripe-webhook-rate-limit.ts index 38dbbac2..22875b43 100644 --- a/frontend/lib/security/stripe-webhook-rate-limit.ts +++ b/frontend/lib/security/stripe-webhook-rate-limit.ts @@ -5,7 +5,6 @@ type StripeWebhookRateLimitConfig = { windowSeconds: number; }; -// Must match previous inline defaults in the webhook route. const DEFAULT_STRIPE_WEBHOOK_RL_MAX = 30; const DEFAULT_STRIPE_WEBHOOK_RL_WINDOW_SECONDS = 60; @@ -15,7 +14,6 @@ function parsePositiveIntStrict(raw: string | undefined): number | null { const trimmed = raw.trim(); if (!trimmed) return null; - // Strict: digits only (no "+", "-", decimals, exponent, or "10abc"). if (!/^\d+$/.test(trimmed)) return null; const parsed = Number.parseInt(trimmed, 10); @@ -44,7 +42,7 @@ export function resolveStripeWebhookRateLimit( [ process.env.STRIPE_WEBHOOK_MISSING_SIG_RL_MAX, process.env.STRIPE_WEBHOOK_RL_MAX, - // legacy fallback to preserve current behavior: + process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX, ], DEFAULT_STRIPE_WEBHOOK_RL_MAX @@ -53,7 +51,7 @@ export function resolveStripeWebhookRateLimit( [ process.env.STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS, process.env.STRIPE_WEBHOOK_RL_WINDOW_SECONDS, - // legacy fallback to preserve current behavior: + process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS, ], DEFAULT_STRIPE_WEBHOOK_RL_WINDOW_SECONDS diff --git a/frontend/lib/services/errors.ts b/frontend/lib/services/errors.ts index 529eca16..42be4771 100644 --- a/frontend/lib/services/errors.ts +++ b/frontend/lib/services/errors.ts @@ -1,7 +1,3 @@ -// export class InvalidPayloadError extends Error { -// code = "INVALID_PAYLOAD" as const -// } - export class IdempotencyConflictError extends Error { code = 'IDEMPOTENCY_CONFLICT' as const; details?: Record; @@ -31,9 +27,6 @@ export class OrderNotFoundError extends Error { } } -// export class SlugConflictError extends Error { -// code = "SLUG_CONFLICT" as const -// } export class InvalidPayloadError extends Error { code: string; diff --git a/frontend/lib/services/inventory.ts b/frontend/lib/services/inventory.ts index 9d34b8c4..e19947d4 100644 --- a/frontend/lib/services/inventory.ts +++ b/frontend/lib/services/inventory.ts @@ -12,26 +12,19 @@ function releaseKey(orderId: string, productId: string) { return `release:${orderId}:${productId}`; } -// Robust status extractor for Drizzle/Neon variations. -// We do NOT treat "unknown shape" as "insufficient" because that breaks invariants. function readStatus(res: unknown): string { const r: any = res as any; - // Common shapes: - // 1) { rows: [ { status: '...' } ] } - // 2) [ { status: '...' } ] - // 3) { rowCount, rows } but keys can be uppercase depending on driver const rows = Array.isArray(r) ? r : Array.isArray(r?.rows) - ? r.rows - : undefined; + ? r.rows + : undefined; const row = rows?.[0]; const status = row?.status ?? row?.STATUS; if (typeof status === 'string' && status.length > 0) return status; - // Fail hard: better than silently turning into OUT_OF_STOCK and doing release. throw new Error( `inventory: unexpected db.execute result shape (missing status). ` + `Got: ${JSON.stringify( diff --git a/frontend/lib/services/orders/_shared.ts b/frontend/lib/services/orders/_shared.ts index b1495f94..a82055f5 100644 --- a/frontend/lib/services/orders/_shared.ts +++ b/frontend/lib/services/orders/_shared.ts @@ -4,7 +4,10 @@ import { createCartItemKey } from '@/lib/shop/cart-item-key'; import { type PaymentProvider } from '@/lib/shop/payments'; import { type CurrencyCode } from '@/lib/shop/currency'; import { MAX_QUANTITY_PER_LINE } from '@/lib/validation/shop'; -import { type CheckoutItem, type OrderSummaryWithMinor } from '@/lib/types/shop'; +import { + type CheckoutItem, + type OrderSummaryWithMinor, +} from '@/lib/types/shop'; import { orders } from '@/db/schema/shop'; import { db } from '@/db'; @@ -33,11 +36,9 @@ export function resolvePaymentProvider( if (provider === 'stripe' || provider === 'none') return provider; - // legacy / corrupted data fallback: if (order.paymentIntentId) return 'stripe'; if (order.paymentStatus === 'paid') return 'none'; - // safest default: treat as stripe to avoid skipping payment flows return 'stripe'; } @@ -92,7 +93,6 @@ export function hashIdempotencyRequest(params: { currency: string; userId: string | null; }) { - // Stable canonical form: const normalized = [...params.items] .map(i => ({ productId: i.productId, diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index a767b7e8..acecc5a2 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -46,9 +46,6 @@ import { getOrderById, getOrderByIdempotencyKey } from './summary'; import { restockOrder } from './restock'; import { guardedPaymentStatusUpdate } from './payment-state'; -// NOTE: PaymentStatus semantics for Stripe: -// pending (no PI yet) -> requires_payment (PI attached) -> paid/failed/refunded via provider events. - async function reconcileNoPaymentOrder( orderId: string ): Promise { @@ -76,7 +73,6 @@ async function reconcileNoPaymentOrder( paymentStatus: row.paymentStatus as PaymentStatus, }); - // Only reconcile "no payments" workflow. if (provider !== 'none') return getOrderById(orderId); if (row.paymentIntentId) { @@ -86,16 +82,11 @@ async function reconcileNoPaymentOrder( ); } - // IMPORTANT: - // With DB CHECK, provider='none' cannot use pending/requires_payment. - // Therefore paymentStatus is not a reliable "finality" signal here. - // Finality is inventory-driven: if inventory is reserved, the order is complete. if (row.inventoryStatus === 'reserved') { return getOrderById(orderId); } if (row.inventoryStatus === 'release_pending') { - // Do not attempt to reserve again while release is pending. try { await restockOrder(orderId, { reason: 'failed', @@ -112,8 +103,6 @@ async function reconcileNoPaymentOrder( row.failureMessage ?? 'Order cannot be completed (release pending).' ); } - - // If it was already released/restocked - treat as failed. if ( row.inventoryStatus === 'released' || row.stockRestored || @@ -192,8 +181,6 @@ async function reconcileNoPaymentOrder( return getOrderById(orderId); } catch (e) { const failAt = new Date(); - - // Mark as "release pending" only. Finalization must happen via restockOrder(). const isOos = e instanceof InsufficientStockError; await db @@ -205,7 +192,6 @@ async function reconcileNoPaymentOrder( failureMessage: isOos ? e.message : 'Checkout failed after reservation attempt.', - // IMPORTANT: do NOT set stockRestored/restockedAt here. updatedAt: failAt, }) .where(eq(orders.id, orderId)); @@ -216,7 +202,6 @@ async function reconcileNoPaymentOrder( workerId: 'reconcileNoPaymentOrder', }); } catch (restockErr) { - // If release fails, we must not lie in order state; leave it for sweeps/janitor. logError( `[reconcileNoPaymentOrder] restock failed orderId=${orderId}`, restockErr @@ -241,14 +226,11 @@ async function getProductsForCheckout( stock: products.stock, sku: products.sku, - // variant option sets (text/CSV/JSON) colors: products.colors, sizes: products.sizes, - // canonical price (minor) priceMinor: productPrices.priceMinor, - // legacy fallback (keep for safety during rollout) price: productPrices.price, originalPrice: productPrices.originalPrice, @@ -274,21 +256,18 @@ type CheckoutProductRow = Awaited< function parseVariantList(raw: unknown): string[] { if (raw == null) return []; - // If DB returns array (e.g. text[] / jsonb) if (Array.isArray(raw)) { const out = raw.map(x => normVariant(String(x))).filter(x => x.length > 0); return Array.from(new Set(out)); } if (typeof raw !== 'string') { - // Unknown shape -> treat as "no configured variants" return []; } const v0 = raw.trim(); if (!v0) return []; - // JSON array: '["S","M"]' if (v0.startsWith('[')) { try { const parsed = JSON.parse(v0); @@ -299,17 +278,15 @@ function parseVariantList(raw: unknown): string[] { return Array.from(new Set(out)); } } catch { - // fallthrough to CSV parsing + // Intentionally empty: fall through to delimiter-based parsing } } - // Postgres array literal string: '{S,M}' const v = v0.startsWith('{') && v0.endsWith('}') ? v0.slice(1, -1).replace(/"/g, '') : v0; - // CSV / ';' / newline const out = v .split(/[,;\n\r]+/g) .map(x => normVariant(x)) @@ -329,8 +306,6 @@ function priceItems( if (!product) { throw new InvalidPayloadError('Some products are unavailable.'); } - //Price must exist for requested currency. - // With leftJoin, missing row => priceCurrency null and both price fields null. if ( !product.priceCurrency || (product.priceMinor == null && product.price == null) @@ -341,7 +316,6 @@ function priceItems( }); } - // canonical: int minor let unitPriceCents: number | null = null; if (product.priceMinor !== null && product.priceMinor !== undefined) { if ( @@ -352,8 +326,6 @@ function priceItems( } unitPriceCents = product.priceMinor; } - - // safety fallback (should become dead code after migration + dual-write stabilizes) if (unitPriceCents == null) { const unitPrice = coercePriceFromDb(product.price, { field: 'price', @@ -405,10 +377,6 @@ export async function createOrderWithItems({ const paymentsEnabled = isPaymentsEnabled(); const paymentProvider: PaymentProvider = paymentsEnabled ? 'stripe' : 'none'; - // paymentStatus is initialized here only; ALL transitions must go via guardedPaymentStatusUpdate. - // IMPORTANT: DB CHECK requires provider='none' => payment_status in ('paid','failed') - // Avoid the cycle: requires_payment -> pending -> requires_payment. - // For Stripe, start at pending and switch to requires_payment only after PI is attached. const initialPaymentStatus: PaymentStatus = paymentProvider === 'none' ? 'paid' : 'pending'; @@ -438,7 +406,6 @@ export async function createOrderWithItems({ if (!row) throw new OrderNotFoundError('Order not found'); - // currency mismatch => hard conflict if (row.currency !== currency) { throw new IdempotencyConflictError( 'Idempotency key already used with different currency.', @@ -446,7 +413,6 @@ export async function createOrderWithItems({ ); } - // If DB hash is missing (legacy), derive from persisted state (order_items + order currency + userId) const derivedExistingHash = row.idempotencyRequestHash ?? hashIdempotencyRequest({ @@ -461,7 +427,6 @@ export async function createOrderWithItems({ }); if (!row.idempotencyRequestHash) { - // best-effort: backfill for future strict checks try { await db .update(orders) @@ -489,7 +454,6 @@ export async function createOrderWithItems({ } if (row.paymentStatus === 'failed') { - // Best-effort cleanup if inventory was left reserved due to crash. try { await restockOrder(existing.id, { reason: 'failed' }); } catch (restockErr) { @@ -505,12 +469,9 @@ export async function createOrderWithItems({ } } - // 1) idempotency read const existing = await getOrderByIdempotencyKey(db, idempotencyKey); if (existing) { await assertIdempotencyCompatible(existing); - // If payments are disabled, we must guarantee a final consistent state - // (previous run could have crashed after order insert). if (!paymentsEnabled) { const reconciled = await reconcileNoPaymentOrder(existing.id); return { @@ -525,8 +486,6 @@ export async function createOrderWithItems({ totalCents: requireTotalCents(existing), }; } - - // 3) pricing (read-only) const uniqueProductIds = Array.from( new Set(normalizedItems.map(i => i.productId)) ); @@ -537,7 +496,6 @@ export async function createOrderWithItems({ } const productMap = new Map(dbProducts.map(p => [p.id, p])); - // 3.1) Variant validation (fail-fast; no side effects) const variantMap = new Map( dbProducts.map(p => [ p.id, @@ -550,7 +508,11 @@ export async function createOrderWithItems({ for (const item of normalizedItems) { const cfg = variantMap.get(item.productId); - if (!cfg) continue; // product existence handled elsewhere + if (!cfg) { + throw new InvalidPayloadError( + `Invariant violation: missing variant config for product ${item.productId} (normalizedItems=${normalizedItems.length}, uniqueProductIds=${uniqueProductIds.length}, dbProducts=${dbProducts.length}).` + ); + } const selectedSize = normVariant(item.selectedSize ?? ''); const selectedColor = normVariant(item.selectedColor ?? ''); @@ -587,7 +549,6 @@ export async function createOrderWithItems({ const pricedItems = priceItems(normalizedItems, productMap, currency); const orderTotalCents = sumLineTotals(pricedItems.map(i => i.lineTotalCents)); - // 4) create order skeleton (CREATED/none) let orderId: string; try { const [created] = await db @@ -601,16 +562,13 @@ export async function createOrderWithItems({ paymentProvider, paymentIntentId: null, - // new workflow fields: status: 'CREATED', - // IMPORTANT (no-payments): payment_status must be 'paid' due to DB CHECK, - // so we must track in-progress via inventory_status. + inventoryStatus: paymentsEnabled ? 'none' : 'reserving', failureCode: null, failureMessage: null, idempotencyRequestHash: requestHash, - // legacy/idempotency: stockRestored: false, restockedAt: null, idempotencyKey, @@ -624,7 +582,6 @@ export async function createOrderWithItems({ if ((error as { code?: string }).code === '23505') { const existingOrder = await getOrderByIdempotencyKey(db, idempotencyKey); if (existingOrder) { - // IMPORTANT: in race conditions, we MUST still enforce hash/currency compatibility await assertIdempotencyCompatible(existingOrder); if (!paymentsEnabled) { const reconciled = await reconcileNoPaymentOrder(existingOrder.id); @@ -644,7 +601,6 @@ export async function createOrderWithItems({ throw error; } - // 5) upsert order_items (requires UNIQUE(order_id, product_id, selected_size, selected_color)) if (pricedItems.length) { await db .insert(orderItems) @@ -693,13 +649,11 @@ export async function createOrderWithItems({ .set({ inventoryStatus: 'reserving', updatedAt: now }) .where(eq(orders.id, orderId)); - // stock is per-product => reserve aggregated by productId across variants const itemsToReserve = aggregateReserveByProductId( pricedItems.map(i => ({ productId: i.productId, quantity: i.quantity })) ); try { - // 7) reserve inventory (idempotent + atomic in inventory.ts) for (const item of itemsToReserve) { const res = await applyReserveMove( orderId, @@ -713,7 +667,6 @@ export async function createOrderWithItems({ } } - // 8) success await db .update(orders) .set({ @@ -768,7 +721,6 @@ export async function createOrderWithItems({ failureMessage: isOos ? e.message : 'Checkout failed after reservation attempt.', - // IMPORTANT: do NOT set stockRestored/restockedAt here. updatedAt: failAt, }) .where(eq(orders.id, orderId)); diff --git a/frontend/lib/services/orders/payment-attempts.ts b/frontend/lib/services/orders/payment-attempts.ts index 8bcbfa83..f0f81bde 100644 --- a/frontend/lib/services/orders/payment-attempts.ts +++ b/frontend/lib/services/orders/payment-attempts.ts @@ -99,7 +99,6 @@ async function createActiveAttempt( if (!row) throw new Error('Failed to insert payment_attempts row'); return row; } catch (e) { - // race: another request may have created active attempt, return it const existing = await getActiveAttempt(orderId, provider); if (existing) return existing; throw e; @@ -114,7 +113,6 @@ async function upsertBackfillAttemptForExistingPI(args: { }): Promise { const { orderId, provider, paymentIntentId, maxAttempts } = args; - // If an attempt already references this PI, reuse it. const found = await db .select() .from(paymentAttempts) @@ -128,11 +126,8 @@ async function upsertBackfillAttemptForExistingPI(args: { const existingAttempt = found[0] ?? null; if (existingAttempt) { - // Guard: never reuse attempt that belongs to a different order (cross-order PI reuse). if (existingAttempt.orderId === orderId) return existingAttempt; - // Fail-closed: PI is already linked to another order. Do NOT create a new attempt - // that would bind this PI to a second order. throw new OrderStateInvalidError( 'PaymentIntent is already associated with a different order.', { @@ -173,21 +168,12 @@ async function upsertBackfillAttemptForExistingPI(args: { return inserted[0]!; } catch (e) { - // On conflict (active unique), return active attempt. const active = await getActiveAttempt(orderId, provider); if (active) return active; throw e; } } -/** - * P0.7 entrypoint: - * - no infinite PaymentIntents per order (durable attempt) - * - controlled init (one active attempt) - * - audit trail (payment_attempts) - * - * Returns clientSecret to initialize Stripe Elements. - */ export async function ensureStripePaymentIntentForOrder(args: { orderId: string; existingPaymentIntentId?: string | null; @@ -202,10 +188,8 @@ export async function ensureStripePaymentIntentForOrder(args: { const provider: PaymentProvider = 'stripe'; const maxAttempts = args.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; - // 1) If we already have an active attempt, prefer it. let attempt = await getActiveAttempt(orderId, provider); - // 2) If order already has a PI, ensure there is an attempt row for it (audit/backfill). if (!attempt && existingPaymentIntentId && existingPaymentIntentId.trim()) { attempt = await upsertBackfillAttemptForExistingPI({ orderId, @@ -214,12 +198,10 @@ export async function ensureStripePaymentIntentForOrder(args: { maxAttempts, }); } - // 3) If still none — create new attempt (with limit). if (!attempt) { attempt = await createActiveAttempt(orderId, provider, maxAttempts); } - // 4) If attempt already has PI — retrieve (and handle canceled). if ( attempt.providerPaymentIntentId && attempt.providerPaymentIntentId.trim() @@ -228,7 +210,6 @@ export async function ensureStripePaymentIntentForOrder(args: { attempt.providerPaymentIntentId.trim() ); - // If PI was canceled, mark attempt canceled and create a new attempt (bounded by maxAttempts). if (pi.status === 'canceled') { await db .update(paymentAttempts) @@ -284,7 +265,6 @@ export async function ensureStripePaymentIntentForOrder(args: { }; } - // 5) No PI yet -> create PI using DB readback (P0.6 invariant), idempotent by attempt. try { const snapshot = await readStripePaymentIntentParams(orderId); @@ -336,10 +316,6 @@ export async function ensureStripePaymentIntentForOrder(args: { } } -/** - * Webhook helper: mark attempt final by Stripe PI id. - * Call this from stripe webhook route for succeeded/failed/canceled. - */ export async function markStripeAttemptFinal(args: { paymentIntentId: string; status: 'succeeded' | 'failed' | 'canceled'; @@ -353,8 +329,8 @@ export async function markStripeAttemptFinal(args: { status === 'succeeded' ? 'succeeded' : status === 'canceled' - ? 'canceled' - : 'failed'; + ? 'canceled' + : 'failed'; await db .update(paymentAttempts) @@ -368,7 +344,6 @@ export async function markStripeAttemptFinal(args: { }) .where(eq(paymentAttempts.providerPaymentIntentId, paymentIntentId)); } catch (error) { - // IMPORTANT: fail-open — не ламаємо webhook через audit layer logError('payment_attempt_finalize_failed', error, { paymentIntentId: args.paymentIntentId, status: args.status, diff --git a/frontend/lib/services/orders/payment-intent.ts b/frontend/lib/services/orders/payment-intent.ts index 45019e5d..b82f8120 100644 --- a/frontend/lib/services/orders/payment-intent.ts +++ b/frontend/lib/services/orders/payment-intent.ts @@ -35,8 +35,6 @@ export async function setOrderPaymentIntent({ ); } - // New flow: pending -> requires_payment when attaching PI. - // Keep requires_payment only for backward-compat (old orders created before this change). const allowed: PaymentStatus[] = ['pending', 'requires_payment']; if (!allowed.includes(existing.paymentStatus as PaymentStatus)) { throw new InvalidPayloadError( @@ -71,8 +69,6 @@ export async function setOrderPaymentIntent({ }); if (!res.applied) { - // Keep error semantics consistent with previous validation rules. - // This also guarantees we won't ever do failed/refunded -> requires_payment. throw new InvalidPayloadError( `Order payment intent update blocked (${res.reason}).` ); @@ -110,7 +106,6 @@ export async function readStripePaymentIntentParams(orderId: string): Promise<{ ); } - // Payable-state gate: fail-closed for paid/failed/refunded/canceled/etc. const allowed: PaymentStatus[] = ['pending', 'requires_payment']; if (!allowed.includes(existing.paymentStatus as PaymentStatus)) { throw new OrderStateInvalidError( @@ -130,7 +125,6 @@ export async function readStripePaymentIntentParams(orderId: string): Promise<{ const amountMinor = existing.totalAmountMinor; - // Canonical money source = DB minor units. Fail-closed on invalid totals. if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) { throw new OrderStateInvalidError( 'Invalid order total for Stripe payment intent creation.', diff --git a/frontend/lib/services/orders/payment-state.ts b/frontend/lib/services/orders/payment-state.ts index a2838d8c..703a4b34 100644 --- a/frontend/lib/services/orders/payment-state.ts +++ b/frontend/lib/services/orders/payment-state.ts @@ -12,19 +12,14 @@ export type PaymentTransitionSource = | 'janitor' | 'system'; -// Stripe flow transitions const ALLOWED_FROM_STRIPE: Record = { pending: ['requires_payment'], requires_payment: ['pending'], paid: ['pending', 'requires_payment'], failed: ['pending', 'requires_payment'], - // allow refunds even if we missed/never persisted "paid" (webhook ordering / retries) refunded: ['paid', 'pending', 'requires_payment'], }; -// payment_provider='none' (no-payments) rules: -// DB CHECK already enforces only ('paid','failed'), and in this workflow 'paid' is not finality. -// We allow paid -> failed (e.g. inventory failed / stale orphan), but NOT failed -> paid. const ALLOWED_FROM_NONE: Record = { pending: [], requires_payment: [], @@ -57,29 +52,14 @@ function hasSetFields(set: unknown): boolean { export type GuardedPaymentUpdateArgs = { orderId: string; - /** - * IMPORTANT: caller must pass the provider policy explicitly. - * This prevents accidental use of Stripe rules for no-payments (and vice versa). - */ paymentProvider: PaymentProvider; to: PaymentStatus; - /** - * Extra fields updated together with paymentStatus (psp fields, status, timestamps, etc). - * paymentStatus MUST NOT be provided here. - */ set?: Partial>; - /** - * Extra WHERE conditions (stock/inventory safety gates, repair gates, etc) - */ extraWhere?: SQL; - /** - * Allow "same-state update" to apply set (needed for repair updates). - * Default: true if set has any fields. - */ allowSameStateUpdate?: boolean; source: PaymentTransitionSource; @@ -101,9 +81,7 @@ export type GuardedPaymentUpdateResult = currentProvider?: PaymentProvider; }; -async function getCurrentState( - orderId: string -): Promise<{ +async function getCurrentState(orderId: string): Promise<{ paymentStatus: PaymentStatus; paymentProvider: PaymentProvider; } | null> { @@ -124,7 +102,6 @@ export async function guardedPaymentStatusUpdate( ): Promise { const { orderId, paymentProvider, to, source, eventId, note } = args; - // Hard reject invalid targets for no-payments before we even touch DB. if ( paymentProvider === 'none' && (to === 'pending' || to === 'requires_payment' || to === 'refunded') @@ -158,7 +135,6 @@ export async function guardedPaymentStatusUpdate( ? Array.from(new Set([...baseAllowed, to])) : baseAllowed; - // If nothing is eligible (e.g. provider none + pending), block early if (!eligibleFrom.length) { const current = await getCurrentState(orderId); if (!current) return { applied: false, reason: 'NOT_FOUND' }; @@ -201,7 +177,6 @@ export async function guardedPaymentStatusUpdate( if (updated.length > 0) return { applied: true }; - // Diagnose why not applied const current = await getCurrentState(orderId); if (!current) return { applied: false, reason: 'NOT_FOUND' }; @@ -253,7 +228,6 @@ export async function guardedPaymentStatusUpdate( }; } - // Transition is allowed by matrix, but blocked by extraWhere (stockRestored/inventoryStatus gates etc) return { applied: false, reason: 'BLOCKED', @@ -262,5 +236,4 @@ export async function guardedPaymentStatusUpdate( }; } -// Export for unit testing (transition matrix) export const __paymentTransitions = { isAllowed, allowedFrom }; diff --git a/frontend/lib/services/orders/psp-metadata/refunds.ts b/frontend/lib/services/orders/psp-metadata/refunds.ts index 80e85d5a..14d3089e 100644 --- a/frontend/lib/services/orders/psp-metadata/refunds.ts +++ b/frontend/lib/services/orders/psp-metadata/refunds.ts @@ -23,7 +23,6 @@ function toNonEmptyString(v: unknown): string | null { function toCurrency(v: unknown, fallback: string): string { const s = toNonEmptyString(v); if (!s) return fallback; - // keep tolerant but normalize common cases (usd -> USD) return s.toUpperCase(); } @@ -32,12 +31,11 @@ function toAmountMinor(v: unknown): number | null { typeof v === 'number' ? v : typeof v === 'string' && v.trim().length - ? Number(v) - : NaN; + ? Number(v) + : NaN; if (!Number.isFinite(n)) return null; - // money minor must be integer >= 0 const i = Math.trunc(n); if (i < 0) return null; if (!Number.isSafeInteger(i)) return null; @@ -58,8 +56,8 @@ function normalizeRefundRecord( (typeof (r as any).refundId === 'number' ? String((r as any).refundId) : typeof (r as any).id === 'number' - ? String((r as any).id) - : null); + ? String((r as any).id) + : null); if (!refundId) return null; @@ -98,7 +96,6 @@ export function normalizeRefundsFromMeta( const m = ensureMetaObject(meta) as any; if (Array.isArray(m.refunds)) { - // hardening: only return validated records return (m.refunds as unknown[]) .map(r => normalizeRefundRecord(r, fallback)) .filter((r): r is RefundMetaRecord => r !== null); diff --git a/frontend/lib/services/orders/refund.ts b/frontend/lib/services/orders/refund.ts index 6cfc37af..f4dcbf6d 100644 --- a/frontend/lib/services/orders/refund.ts +++ b/frontend/lib/services/orders/refund.ts @@ -36,7 +36,6 @@ export async function refundOrder( if (!order) throw new OrderNotFoundError(orderId); - // Preconditions (fail-closed) if (order.paymentProvider !== 'stripe') { throw invalid( 'REFUND_PROVIDER_NOT_STRIPE', @@ -79,7 +78,6 @@ export async function refundOrder( currency ); - // Domain idempotency: if already recorded in metadata — return summary const existingRefunds = normalizeRefundsFromMeta(order.pspMetadata, { currency, createdAt: order.createdAt.toISOString(), @@ -92,7 +90,6 @@ export async function refundOrder( return await getOrderById(orderId); } - // Real Stripe call (Stripe-idempotent) const { refundId, status } = await createRefund({ orderId, paymentIntentId, @@ -117,7 +114,6 @@ export async function refundOrder( }, }); - // Persist ONLY metadata. payment_status not touched (source of truth = webhook) await db .update(orders) .set({ diff --git a/frontend/lib/services/orders/restock.ts b/frontend/lib/services/orders/restock.ts index b842c071..a92f7d1a 100644 --- a/frontend/lib/services/orders/restock.ts +++ b/frontend/lib/services/orders/restock.ts @@ -10,14 +10,13 @@ import { OrderNotFoundError, OrderStateInvalidError } from '../errors'; import { resolvePaymentProvider } from './_shared'; import { logWarn } from '@/lib/logging'; +const PAYMENT_STATUS_KEY = 'paymentStatus' as const; + export type RestockReason = 'failed' | 'refunded' | 'canceled' | 'stale'; export type RestockOptions = { reason?: RestockReason; - /** If caller already claimed the order (e.g. sweep), skip local claim. */ alreadyClaimed?: boolean; - /** Lease TTL for restock claim */ claimTtlMinutes?: number; - /** Who is claiming (trace/debug) */ workerId?: string; }; @@ -42,7 +41,6 @@ async function tryClaimRestockLease(params: { and( eq(orders.id, params.orderId), eq(orders.stockRestored, false), - // claim gate: only unclaimed or expired claims can be claimed or( isNull(orders.sweepClaimExpiresAt), lt(orders.sweepClaimExpiresAt, now) @@ -64,7 +62,7 @@ export async function restockOrder( .select({ id: orders.id, paymentProvider: orders.paymentProvider, - paymentStatus: orders.paymentStatus, + [PAYMENT_STATUS_KEY]: orders.paymentStatus, paymentIntentId: orders.paymentIntentId, inventoryStatus: orders.inventoryStatus, stockRestored: orders.stockRestored, @@ -82,7 +80,6 @@ export async function restockOrder( const provider = resolvePaymentProvider(order); const transitionSource = options?.alreadyClaimed ? 'janitor' : 'system'; - // already released / legacy idempotency if ( order.inventoryStatus === 'released' || order.stockRestored || @@ -90,7 +87,6 @@ export async function restockOrder( ) return; - // If state says "none" we still may have reserve moves (crash between reserve and status update). const reservedMoves = await db .select({ productId: inventoryMoves.productId, @@ -105,7 +101,6 @@ export async function restockOrder( ); if (reservedMoves.length === 0) { - // safety: paid can only be terminalized via refund if ( !isNoPayment && order.paymentStatus === 'paid' && @@ -113,11 +108,13 @@ export async function restockOrder( ) { throw new OrderStateInvalidError( `Cannot terminalize an orphan paid order without refund reason.`, - { orderId, details: { reason, paymentStatus: order.paymentStatus } } + { + orderId, + details: { reason, [PAYMENT_STATUS_KEY]: order.paymentStatus }, + } ); } - // No inventory was reserved. If caller gave no reason, do nothing (fail-closed). if (!reason) return; const now = new Date(); @@ -129,8 +126,8 @@ export async function restockOrder( (reason === 'failed' ? 'FAILED_ORPHAN' : reason === 'canceled' - ? 'CANCELED_ORPHAN' - : 'STALE_ORPHAN'); + ? 'CANCELED_ORPHAN' + : 'STALE_ORPHAN'); const [touched] = await db .update(orders) @@ -166,7 +163,6 @@ export async function restockOrder( paymentProvider: provider, to: normalizedStatus, source: transitionSource, - // bind to this exact finalize marker (prevents races) extraWhere: eq(orders.restockedAt, now), }); } @@ -174,18 +170,15 @@ export async function restockOrder( return; } - // safety: paid can only be released via refund - // IMPORTANT: for payment_provider='none', payment_status='paid' is not a finality signal - // (forced by DB CHECK). Finality is inventory_status='reserved'. if (!isNoPayment && order.paymentStatus === 'paid' && reason !== 'refunded') { throw new OrderStateInvalidError( `Cannot restock a paid order without refund reason.`, - { orderId, details: { reason, paymentStatus: order.paymentStatus } } + { + orderId, + details: { reason, [PAYMENT_STATUS_KEY]: order.paymentStatus }, + } ); } - // If we have reserved moves, we must claim a lease to avoid concurrent double-processing. - // (Actual stock safety is guaranteed by inventory_moves move_key, but lease prevents wasted work - // and prevents "restocked_at" churn under concurrency.) const claimTtlMinutes = options?.claimTtlMinutes ?? 5; const workerId = options?.workerId ?? 'restock'; if (!options?.alreadyClaimed) { @@ -194,7 +187,7 @@ export async function restockOrder( workerId, claimTtlMinutes, }); - if (!claimed) return; // someone else is processing + if (!claimed) return; } const now = new Date(); @@ -203,9 +196,6 @@ export async function restockOrder( .set({ inventoryStatus: 'release_pending', updatedAt: now }) .where(and(eq(orders.id, orderId), ne(orders.inventoryStatus, 'released'))); - // Apply release moves. IMPORTANT invariant: - // do NOT mark released/stockRestored/restockedAt unless all releases are CONFIRMED ok. - const releaseFailures: Array<{ productId: string; reason: string }> = []; for (const item of reservedMoves) { @@ -220,8 +210,8 @@ export async function restockOrder( typeof res === 'boolean' ? res === false : res && typeof res === 'object' && 'applied' in (res as any) - ? (res as any).applied === false - : false; + ? (res as any).applied === false + : false; if (appliedFalse) { const detail = @@ -258,8 +248,6 @@ export async function restockOrder( details + (releaseFailures.length > 3 ? ', ...' : ''); - // FAIL-SAFE: leave order in a state janitor can safely retry. - // Do NOT set: inventoryStatus='released' OR stockRestored=true OR restockedAt!=null. const shouldSetFailureCode = reason === 'failed' || reason === 'stale'; await db .update(orders) @@ -279,8 +267,7 @@ export async function restockOrder( return; } - // FINALIZE ONCE: only one caller may flip stock_restored/restocked_at - // If RETURNING is empty => already finalized by another worker (or previous attempt). + const finalizedAt = new Date(); const [finalized] = await db .update(orders) @@ -317,7 +304,7 @@ export async function restockOrder( paymentProvider: provider, to: normalizedStatus, source: transitionSource, - // bind to finalize-once marker + extraWhere: eq(orders.restockedAt, finalizedAt), }); } diff --git a/frontend/lib/services/orders/summary.ts b/frontend/lib/services/orders/summary.ts index 58803ff6..1327b220 100644 --- a/frontend/lib/services/orders/summary.ts +++ b/frontend/lib/services/orders/summary.ts @@ -5,10 +5,7 @@ import { orderItems, orders, products } from '@/db/schema/shop'; import { fromCents, fromDbMoney } from '@/lib/shop/money'; import { type OrderDetail, type OrderSummaryWithMinor } from '@/lib/types/shop'; -import { - OrderNotFoundError, - OrderStateInvalidError, -} from '../errors'; +import { OrderNotFoundError, OrderStateInvalidError } from '../errors'; import { type DbClient, @@ -97,12 +94,8 @@ export function parseOrderSummary( selectedSize: item.selectedSize ?? '', selectedColor: item.selectedColor ?? '', quantity: item.quantity, - - // canonical: unitPriceMinor, lineTotalMinor, - - // display/legacy: unitPrice: fromCents(unitPriceMinor), lineTotal: fromCents(lineTotalMinor), }; @@ -130,9 +123,7 @@ export function parseOrderSummary( return { id: order.id, - // canonical: totalAmountMinor, - // display/legacy: totalAmount: fromCents(totalAmountMinor), currency: order.currency, paymentStatus: order.paymentStatus, @@ -169,7 +160,6 @@ export async function getOrderSummary( return getOrderById(id); } -// Internal helper (used by checkout idempotency) export async function getOrderByIdempotencyKey( dbClient: DbClient, key: string diff --git a/frontend/lib/services/orders/sweeps.ts b/frontend/lib/services/orders/sweeps.ts index f2e82a91..d07ba19e 100644 --- a/frontend/lib/services/orders/sweeps.ts +++ b/frontend/lib/services/orders/sweeps.ts @@ -11,9 +11,9 @@ export async function restockStalePendingOrders(options?: { olderThanMinutes?: number; batchSize?: number; orderIds?: string[]; - claimTtlMinutes?: number; // claim TTL window - workerId?: string; // identify who claimed - timeBudgetMs?: number; // max runtime budget for this sweep + claimTtlMinutes?: number; + workerId?: string; + timeBudgetMs?: number; }): Promise { const MIN_OLDER_MIN = 10; const MAX_OLDER_MIN = 60 * 24 * 7; @@ -57,7 +57,6 @@ export async function restockStalePendingOrders(options?: { ); const deadlineMs = Date.now() + timeBudgetMs; - // If explicitly provided empty list => nothing to do (test helper). if (options?.orderIds && options.orderIds.length === 0) return 0; const hasExplicitIds = Boolean(options?.orderIds?.length); @@ -81,14 +80,11 @@ export async function restockStalePendingOrders(options?: { eq(orders.stockRestored, false), isNull(orders.restockedAt), ne(orders.inventoryStatus, 'released'), - // claim gate: only unclaimed or expired claims are eligible or( isNull(orders.sweepClaimExpiresAt), lt(orders.sweepClaimExpiresAt, now) ), ]; - - // If not targeting specific orders, apply age cutoff. if (!hasExplicitIds) { baseConditions.push(lt(orders.createdAt, cutoff)); } @@ -140,7 +136,6 @@ export async function restockStalePendingOrders(options?: { return processed; } -// Cleanup for orders stuck in "reserving" phase (inventory reservation started but never completed). export async function restockStuckReservingOrders(options?: { olderThanMinutes?: number; batchSize?: number; @@ -199,29 +194,23 @@ export async function restockStuckReservingOrders(options?: { const claimExpiresAt = new Date(Date.now() + claimTtlMinutes * 60 * 1000); const baseConditions = [ - // Only Stripe flow here; no-payments has its own sweep. eq(orders.paymentProvider, 'stripe'), - // "still in progress" payment states inArray(orders.paymentStatus, [ 'pending', 'requires_payment', ] as PaymentStatus[]), - // stuck in reserving/releasing phase (not final) inArray(orders.inventoryStatus, [ 'reserving', 'release_pending', ] as const), - // not already restocked/finalized eq(orders.stockRestored, false), isNull(orders.restockedAt), - // age cutoff lt(orders.createdAt, cutoff), - // claim gate or( isNull(orders.sweepClaimExpiresAt), lt(orders.sweepClaimExpiresAt, now) @@ -242,7 +231,6 @@ export async function restockStuckReservingOrders(options?: { sweepClaimExpiresAt: claimExpiresAt, sweepRunId: runId, sweepClaimedBy: workerId, - // set failure details only if absent (keeps real error if it already exists) failureCode: sql`coalesce(${orders.failureCode}, 'STUCK_RESERVING_TIMEOUT')`, failureMessage: sql`coalesce(${orders.failureMessage}, 'Order timed out while reserving inventory.')`, updatedAt: now, @@ -263,7 +251,6 @@ export async function restockStuckReservingOrders(options?: { for (const { id } of claimed) { if (Date.now() >= deadlineMs) break; - // IMPORTANT: reuse hardened exactly-once restock await restockOrder(id, { reason: 'stale', alreadyClaimed: true, @@ -277,7 +264,6 @@ export async function restockStuckReservingOrders(options?: { return processed; } -// Cleanup for payment_provider='none' flow where payment_status may be 'paid' before inventory reservation completes. export async function restockStaleNoPaymentOrders(options?: { olderThanMinutes?: number; batchSize?: number; @@ -347,7 +333,6 @@ export async function restockStaleNoPaymentOrders(options?: { 'release_pending', ] as const), - // claim gate or( isNull(orders.sweepClaimExpiresAt), lt(orders.sweepClaimExpiresAt, now) @@ -387,7 +372,7 @@ export async function restockStaleNoPaymentOrders(options?: { if (Date.now() >= deadlineMs) break; await restockOrder(id, { - reason: 'stale', // reuse existing terminalization semantics + reason: 'stale', alreadyClaimed: true, workerId, }); @@ -398,4 +383,3 @@ export async function restockStaleNoPaymentOrders(options?: { return processed; } - diff --git a/frontend/lib/services/products.ts b/frontend/lib/services/products.ts index 17fa2c8d..0c4528a0 100644 --- a/frontend/lib/services/products.ts +++ b/frontend/lib/services/products.ts @@ -1,6 +1,3 @@ -// frontend/lib/services/products.ts -// Facade: keep legacy import path stable (no index.ts required) - export { createProduct } from './products/mutations/create'; export { updateProduct } from './products/mutations/update'; export { deleteProduct } from './products/mutations/delete'; diff --git a/frontend/lib/services/products/admin/queries.ts b/frontend/lib/services/products/admin/queries.ts index a16d4ed7..ba1d219d 100644 --- a/frontend/lib/services/products/admin/queries.ts +++ b/frontend/lib/services/products/admin/queries.ts @@ -24,26 +24,25 @@ export async function getAdminProductById(id: string): Promise { return mapRowToProduct(row); } - export async function getAdminProductPrices( productId: string ): Promise { const rows = await db .select({ currency: productPrices.currency, - // canonical: + priceMinor: productPrices.priceMinor, originalPriceMinor: productPrices.originalPriceMinor, - // legacy (keep during rollout): + price: productPrices.price, originalPrice: productPrices.originalPrice, }) .from(productPrices) .where(eq(productPrices.productId, productId)); - // Guard: drizzle int columns should come as number, but never trust the driver implicitly. return rows.map(r => ({ currency: r.currency as CurrencyCode, + // Defensive: some DB drivers return NUMERIC/DECIMAL as string/unknown at runtime; enforce safe integer minor-units here. priceMinor: assertMoneyMinorInt( r.priceMinor, `${String(r.currency)} priceMinor` diff --git a/frontend/lib/services/products/cart/rehydrate.ts b/frontend/lib/services/products/cart/rehydrate.ts index f0646ea0..61830b30 100644 --- a/frontend/lib/services/products/cart/rehydrate.ts +++ b/frontend/lib/services/products/cart/rehydrate.ts @@ -21,17 +21,13 @@ import { logWarn } from '@/lib/logging'; import { PriceConfigError } from '../../errors'; const fromMinorUnits = fromCents; -// 2-decimal currencies (money helpers fromCents/toCents assume exponent=2) function assertTwoDecimalCurrency(currency: CurrencyCode): void { - // fromCents/toCents assume exponent=2. - // Guard against 0-decimal (JPY) / 3-decimal (BHD) and any future non-2-decimal currency. if (isTwoDecimalCurrency(currency)) return; throw new PriceConfigError( 'Unsupported currency minor units exponent in cart rehydrate (expected 2-decimal currency).', { - // Keep productId to avoid breaking error-contract shape if it's required. productId: '__cart__', currency, } @@ -140,8 +136,6 @@ export async function rehydrateCartItems( const productMap = new Map(rows.map(r => [r.id, r])); const removed: CartRemovedItem[] = []; - - // Merge by canonical key AFTER sanitization (prevents duplicate lines). const merged = new Map< string, Omit< diff --git a/frontend/lib/services/products/mutations/create.ts b/frontend/lib/services/products/mutations/create.ts index c2b7d0da..5e570a71 100644 --- a/frontend/lib/services/products/mutations/create.ts +++ b/frontend/lib/services/products/mutations/create.ts @@ -1,8 +1,6 @@ import { eq } from 'drizzle-orm'; -import { - uploadProductImageFromFile, -} from '@/lib/cloudinary'; +import { uploadProductImageFromFile } from '@/lib/cloudinary'; import { db } from '@/db'; import { products, productPrices } from '@/db/schema'; import { logError } from '@/lib/logging'; @@ -36,7 +34,6 @@ export async function createProduct(input: ProductInput): Promise { const prices = normalizePricesFromInput(input); if (!prices.length) { - // Hard fail: admin flow must provide prices throw new InvalidPayloadError('Product pricing is required.'); } @@ -58,8 +55,6 @@ export async function createProduct(input: ProductInput): Promise { description: (input as any).description ?? null, imageUrl: uploaded?.secureUrl ?? '', imagePublicId: uploaded?.publicId, - - // legacy mirror (USD) — required by products.price NOT NULL price: toDbMoney(usd.priceMinor), originalPrice: usd.originalPriceMinor == null @@ -94,12 +89,8 @@ export async function createProduct(input: ProductInput): Promise { return { productId: row.id, currency: p.currency, - - // canonical priceMinor, originalPriceMinor: originalMinor, - - // legacy mirror price: toDbMoney(priceMinor), originalPrice: originalMinor == null ? null : toDbMoney(originalMinor), diff --git a/frontend/lib/services/products/mutations/delete.ts b/frontend/lib/services/products/mutations/delete.ts index 928fe85a..e1590715 100644 --- a/frontend/lib/services/products/mutations/delete.ts +++ b/frontend/lib/services/products/mutations/delete.ts @@ -7,8 +7,6 @@ import { logError } from '@/lib/logging'; import { ProductNotFoundError } from '@/lib/errors/products'; export async function deleteProduct(id: string): Promise { - // Atomic delete: prices first, then product, all-or-nothing. - // Return imagePublicId from the deleted row to avoid stale pre-reads. const result = await db.execute(sql` WITH del_prices AS ( DELETE FROM ${productPrices} @@ -32,7 +30,6 @@ export async function deleteProduct(id: string): Promise { const [deleted] = rows; if (!deleted) { - // not found or concurrent delete edge-case throw new ProductNotFoundError(id); } diff --git a/frontend/lib/services/products/mutations/toggle.ts b/frontend/lib/services/products/mutations/toggle.ts index 84bc3a7b..7a430c3a 100644 --- a/frontend/lib/services/products/mutations/toggle.ts +++ b/frontend/lib/services/products/mutations/toggle.ts @@ -1,4 +1,3 @@ -// frontend/lib/services/products/mutations/toggle.ts import { eq } from 'drizzle-orm'; import { db } from '@/db'; @@ -26,7 +25,6 @@ export async function toggleProductStatus(id: string): Promise { .returning(); if (!updated) { - // concurrent delete between SELECT and UPDATE throw new ProductNotFoundError(id); } diff --git a/frontend/lib/services/products/mutations/update.ts b/frontend/lib/services/products/mutations/update.ts index b3c16e99..4c9f444e 100644 --- a/frontend/lib/services/products/mutations/update.ts +++ b/frontend/lib/services/products/mutations/update.ts @@ -59,10 +59,6 @@ export async function updateProduct( const finalBadge = (input as any).badge ?? existing.badge; - // Enforce merged-state invariants (DB rows + incoming upserts) - // - If prices are patched, validate merged currency policy (e.g. USD must exist) - // - If final badge is SALE, enforce originalPrice for ALL currencies in merged state - if (prices.length || finalBadge === 'SALE') { const existingPriceRows = await db .select({ @@ -98,19 +94,15 @@ export async function updateProduct( const mergedRows = Array.from(merged.values()); - // Currency-set policy should be enforced on merged state ONLY when prices are patched. - // This keeps PATCH semantics: partial prices payload is allowed, and policy is checked post-merge. if (prices.length) { assertMergedPricesPolicy(mergedRows, { productId: id, requireUsd: true }); } - // SALE invariant must be enforced on merged state when badge is SALE (even if prices are not patched). if (finalBadge === 'SALE') { enforceSaleBadgeRequiresOriginal('SALE', mergedRows); } } - // Base fields update const updateData: Partial = { slug, title: (input as any).title ?? existing.title, @@ -133,13 +125,11 @@ export async function updateProduct( : null : existing.sku, - // legacy invariants: keep stable as USD mirror currency: 'USD', price: existing.price, originalPrice: existing.originalPrice, }; - // If USD provided in prices, update legacy mirror if (prices.length) { const usd = prices.find(p => p.currency === 'USD'); if (usd?.priceMinor) { @@ -153,7 +143,6 @@ export async function updateProduct( } try { - // 1) upsert prices if (prices.length) { const upsertRows = prices.map(p => { const priceMinor = p.priceMinor; @@ -185,7 +174,6 @@ export async function updateProduct( }); } - // 2) update products const [row] = await db .update(products) .set(updateData) @@ -206,7 +194,6 @@ export async function updateProduct( return mapRowToProduct(row); } catch (error) { - // IMPORTANT: cleanup new image on failure (price upsert or product update) if (uploaded?.publicId) { try { await destroyProductImage(uploaded.publicId); diff --git a/frontend/lib/services/products/prices.ts b/frontend/lib/services/products/prices.ts index 242301a4..260d0914 100644 --- a/frontend/lib/services/products/prices.ts +++ b/frontend/lib/services/products/prices.ts @@ -21,7 +21,6 @@ export function assertMoneyMinorInt(value: unknown, field: string): number { throw new InvalidPayloadError(`${field} must be a number.`); } - // Critical: reject fractional minor units (no truncation) if (!Number.isInteger(n)) { throw new InvalidPayloadError(`${field} must be an integer (minor units).`); } @@ -92,10 +91,6 @@ function toMoneyMinorNullable( } export function normalizePricesFromInput(input: unknown): NormalizedPriceRow[] { - // Transitional-safe: - // - NEW: input.prices[] uses MINOR units: { currency, priceMinor, originalPriceMinor } - // - LEGACY: input.prices[] uses MAJOR strings: { currency, price, originalPrice } - // - VERY LEGACY: top-level price/originalPrice/currency const anyInput = input as any; const prices = anyInput?.prices; @@ -108,7 +103,6 @@ export function normalizePricesFromInput(input: unknown): NormalizedPriceRow[] { ); } - // NEW path: minor units if (p?.priceMinor != null) { const priceMinor = assertMoneyMinorInt( p.priceMinor, @@ -133,7 +127,6 @@ export function normalizePricesFromInput(input: unknown): NormalizedPriceRow[] { return { currency, priceMinor, originalPriceMinor }; } - // LEGACY path: major strings const price = String(p?.price ?? '').trim(); const originalPrice = p?.originalPrice == null ? null : String(p.originalPrice).trim(); @@ -152,7 +145,6 @@ export function normalizePricesFromInput(input: unknown): NormalizedPriceRow[] { }); } - // Legacy fallback (only if present) if (anyInput?.price != null) { const currency = (anyInput?.currency as CurrencyCode) ?? 'USD'; if (!currencyValues.includes(currency as any)) { @@ -188,7 +180,6 @@ export function requireUsd(prices: NormalizedPriceRow[]): NormalizedPriceRow { } export function validatePriceRows(prices: NormalizedPriceRow[]) { - // Safety: no duplicates even if upstream schema is bypassed const seen = new Set(); for (const p of prices) { if (seen.has(p.currency)) { @@ -196,19 +187,16 @@ export function validatePriceRows(prices: NormalizedPriceRow[]) { } seen.add(p.currency); - // Runtime guard (transitional input can bypass TS/Zod) if (!currencyValues.includes(p.currency as any)) { throw new InvalidPayloadError( `Unsupported currency: ${String(p.currency)}.` ); } - // priceMinor must be positive integer (minor units) if (!Number.isSafeInteger(p.priceMinor) || p.priceMinor < 1) { throw new InvalidPayloadError(`${p.currency}: price is required.`); } - // originalPriceMinor must be > priceMinor when present if (p.originalPriceMinor != null) { if (!Number.isSafeInteger(p.originalPriceMinor)) { throw new InvalidPayloadError( diff --git a/frontend/lib/services/products/types.ts b/frontend/lib/services/products/types.ts index 9a90cb86..c9193f06 100644 --- a/frontend/lib/services/products/types.ts +++ b/frontend/lib/services/products/types.ts @@ -2,10 +2,10 @@ import type { CurrencyCode } from '@/lib/shop/currency'; export type AdminProductPriceRow = { currency: CurrencyCode; - // canonical (minor units) + priceMinor: number; originalPriceMinor: number | null; - // legacy mirror (keep during rollout) + price: string; originalPrice: string | null; }; @@ -16,7 +16,6 @@ export type AdminProductsFilter = { type?: string; }; -// Internal typing helpers (used across products/* modules) export type ProductsTable = typeof import('@/db/schema').products; export type ProductRow = ProductsTable['$inferSelect']; export type DbClient = typeof import('@/db').db; diff --git a/frontend/lib/shop/currency.ts b/frontend/lib/shop/currency.ts index deba4b1f..fe0a2a21 100644 --- a/frontend/lib/shop/currency.ts +++ b/frontend/lib/shop/currency.ts @@ -11,7 +11,12 @@ export function isTwoDecimalCurrency(currency: CurrencyCode): boolean { } function assertMinorUnitsStrict(minor: number): number { - if (!Number.isFinite(minor) || !Number.isInteger(minor) || minor < 0) { + if ( + !Number.isFinite(minor) || + !Number.isInteger(minor) || + !Number.isSafeInteger(minor) || + minor < 0 + ) { throw new Error('Invalid money minor-units value'); } return minor; @@ -20,15 +25,9 @@ function assertMinorUnitsStrict(minor: number): number { function normalizeLocaleTag(locale: string | null | undefined): string { const raw = (locale ?? '').trim().toLowerCase(); if (!raw) return ''; - // "uk-UA" -> "uk", "uk_UA" -> "uk" return raw.split(/[-_]/)[0] ?? raw; } -/** - * D1 policy: - * - uk -> UAH - * - otherwise -> USD - */ export function resolveCurrencyFromLocale( locale: string | null | undefined ): CurrencyCode { @@ -36,9 +35,6 @@ export function resolveCurrencyFromLocale( return primary === 'uk' ? 'UAH' : 'USD'; } -/** - * "uk-UA,uk;q=0.9,en-US;q=0.8" -> "uk-UA" - */ export function parsePrimaryLocaleFromAcceptLanguage( acceptLanguage: string | null | undefined ): string | null { @@ -51,10 +47,6 @@ export function parsePrimaryLocaleFromAcceptLanguage( return token && token.length ? token : null; } -/** - * Server-only resolution at API boundaries: - * currency is derived ONLY from locale (Accept-Language). - */ export function resolveCurrencyFromHeaders(headers: Headers): CurrencyCode { const locale = parsePrimaryLocaleFromAcceptLanguage( headers.get('accept-language') @@ -62,10 +54,6 @@ export function resolveCurrencyFromHeaders(headers: Headers): CurrencyCode { return resolveCurrencyFromLocale(locale); } -/** - * UI locale normalization. - * Route param locale is usually "uk" | "en", but Intl wants a BCP-47 tag. - */ function normalizeLocaleForIntl( locale: string | null | undefined, currency: CurrencyCode @@ -78,7 +66,6 @@ function normalizeLocaleForIntl( if (raw) return raw.replaceAll('_', '-'); - // Safe fallback (still yields correct narrow symbol for currency) return currency === 'UAH' ? 'uk-UA' : 'en-US'; } @@ -91,7 +78,7 @@ function getFormatter(locale: string, currency: CurrencyCode) { const created = new Intl.NumberFormat(locale, { style: 'currency', currency, - currencyDisplay: 'narrowSymbol', // ₴ for UAH when available + currencyDisplay: 'narrowSymbol', }); formatterCache.set(key, created); @@ -99,10 +86,8 @@ function getFormatter(locale: string, currency: CurrencyCode) { } function getCurrencyFractionDigits(currency: CurrencyCode): number { - // Single source of truth: we only support 2-decimal currencies for now. if (isTwoDecimalCurrency(currency)) return 2; - // Future-proof: if CurrencyCode expands (e.g., JPY/BHD), fail closed. throw new Error(`Unsupported currency fraction digits: ${currency}`); } @@ -112,10 +97,6 @@ function minorToMajor(amountMinor: number, currency: CurrencyCode): number { return assertMinorUnitsStrict(amountMinor) / factor; } -/** - * Canonical UI money formatter. - * amountMinor is in minor units (cents/kopeks), integer. - */ export function formatMoney( amountMinor: number, currency: CurrencyCode, @@ -131,10 +112,6 @@ export function formatMoney( } } -/** - * @deprecated Prefer formatMoney(minor, currency, locale). - * Legacy formatter for MAJOR units (e.g. 10.50). - */ export function formatPrice( amountMajor: number, currencyOrOptions?: diff --git a/frontend/lib/shop/data.ts b/frontend/lib/shop/data.ts index 284307eb..855b54b8 100644 --- a/frontend/lib/shop/data.ts +++ b/frontend/lib/shop/data.ts @@ -193,11 +193,6 @@ function mapToShopProduct(product: DbProduct): ShopProduct | null { return parsed.data; } -/** - * IMPORTANT: - * Pass `locale` from the route segment when possible (app/[locale]/...), - * because currency policy is locale-based: uk -> UAH, otherwise USD. - */ export async function getCatalogProducts( filters: unknown, locale: string = 'en' @@ -250,14 +245,12 @@ export async function getHomepageContent( ): Promise { const currency = resolveCurrencyFromLocale(locale); - // 1) primary: featured (може бути < 4) const featured: DbProduct[] = await getFeaturedProducts(currency, 4); const featuredProducts = featured .map(mapToShopProduct) .filter((product): product is ShopProduct => product !== null); - // helper: докомплектувати до N без дублікатів const fillTo = ( primary: ShopProduct[], fallback: ShopProduct[], @@ -276,8 +269,6 @@ export async function getHomepageContent( return merged.slice(0, count); }; - // 2) fallback source: newest active products (щоб завжди було чим добрати до 4) - // беремо більше ніж 4, щоб було що фільтрувати після дедупу const newestCatalog = await getCatalogProducts( { category: 'all', diff --git a/frontend/lib/shop/money.ts b/frontend/lib/shop/money.ts index 20e23226..fd2bad9b 100644 --- a/frontend/lib/shop/money.ts +++ b/frontend/lib/shop/money.ts @@ -1,13 +1,6 @@ -export type Money = number; // legacy/display only -export type MoneyCents = number; // canonical minor units (safe int >= 0) - -/** - * Strict invariant for canonical minor-units: - * - finite - * - integer (no trunc/round normalization here) - * - >= 0 - * - safe integer - */ +export type Money = number; +export type MoneyCents = number; + export function assertIntegerCentsStrict(cents: number): MoneyCents { if (!Number.isFinite(cents) || !Number.isInteger(cents) || cents < 0) { throw new Error('Invalid money cents value'); @@ -32,53 +25,36 @@ function isScientificNotation(s: string): boolean { return /e[+-]?\d+/i.test(s); } -/** - * Parse decimal "major units" string/number into minor units (cents) WITHOUT floats. - * Rules: - * - accepts: "12", "12.3", "12.34", ".5", "0.5" - * - rejects: negatives, NaN/Infinity, scientific notation ("1e-3"), non-numeric - * - rounds HALF_UP to 2 decimals if more than 2 fractional digits - */ function parseMajorToMinor(input: string | number): MoneyCents { - const raw = - typeof input === 'string' - ? input.trim() - : Number.isFinite(input) - ? String(input) - : String(input); + const raw = typeof input === 'string' ? input.trim() : String(input); if (!raw.length) throw new Error('Invalid money value'); if (typeof input === 'number') { - if (!Number.isFinite(input) || input < 0) throw new Error('Invalid money value'); + if (!Number.isFinite(input) || input < 0) + throw new Error('Invalid money value'); } - const s = raw; if (s.startsWith('-')) throw new Error('Invalid money value'); if (isScientificNotation(s)) { - // JS numbers can stringify to "1e-7" – refuse to avoid ambiguous rounding throw new Error('Invalid money value'); } - // Normalize leading-dot ".5" -> "0.5" const normalized = s.startsWith('.') ? `0${s}` : s; - // Accept only digits with optional single dot const m = normalized.match(/^(\d+)(?:\.(\d+))?$/); if (!m) throw new Error('Invalid money value'); const intStr = m[1] ?? '0'; const fracStrRaw = m[2] ?? ''; - // int part safe range pre-check const maxIntPart = Math.floor(Number.MAX_SAFE_INTEGER / 100); const intPart = Number(intStr); if (!Number.isSafeInteger(intPart) || intPart < 0 || intPart > maxIntPart) { throw new Error('Invalid money value'); } - // Fractional rounding to 2 digits, HALF_UP let frac2 = fracStrRaw.slice(0, 2); while (frac2.length < 2) frac2 += '0'; @@ -87,13 +63,11 @@ function parseMajorToMinor(input: string | number): MoneyCents { throw new Error('Invalid money value'); } - // Round if there are extra digits beyond 2 if (fracStrRaw.length > 2) { - const third = fracStrRaw.charCodeAt(2) - 48; // '0' => 0 + const third = fracStrRaw.charCodeAt(2) - 48; if (third >= 5) { cents += 1; if (cents === 100) { - // carry cents = 0; if (intPart + 1 > maxIntPart) throw new Error('Invalid money value'); const minor = (intPart + 1) * 100 + cents; @@ -106,11 +80,6 @@ function parseMajorToMinor(input: string | number): MoneyCents { return assertIntegerCentsStrict(minor); } -/** - * Public API: major -> cents (minor). - * NOTE: prefer passing strings from DB/inputs; numbers are accepted but may be rejected - * if they stringify to scientific notation. - */ export function toCents(value: number | string): MoneyCents { if (typeof value !== 'string' && typeof value !== 'number') { throw new Error('Invalid money value'); @@ -118,22 +87,14 @@ export function toCents(value: number | string): MoneyCents { return parseMajorToMinor(value); } -/** - * Minor -> legacy major number (display only). - * Still returns a JS number; do NOT use for money comparisons. - */ export function fromCents(cents: MoneyCents): Money { const v = assertIntegerCentsStrict(cents); const intPart = Math.floor(v / 100); const frac = v % 100; - // Controlled string -> number (display only) + return Number(`${intPart}.${String(frac).padStart(2, '0')}`); } -/** - * Legacy DB numeric money (string/number like "12.34") -> canonical cents (int >= 0). - * No floats. - */ export function fromDbMoney(value: unknown): MoneyCents { if (typeof value !== 'string' && typeof value !== 'number') { throw new Error('Invalid money value'); @@ -141,9 +102,6 @@ export function fromDbMoney(value: unknown): MoneyCents { return toCents(value); } -/** - * Canonical cents -> DB decimal string "12.34" WITHOUT floats/toFixed. - */ export function toDbMoney(cents: MoneyCents): string { const v = assertIntegerCentsStrict(cents); const intPart = Math.floor(v / 100); diff --git a/frontend/lib/shop/request-locale.ts b/frontend/lib/shop/request-locale.ts index e761dc17..fd37b1a7 100644 --- a/frontend/lib/shop/request-locale.ts +++ b/frontend/lib/shop/request-locale.ts @@ -8,12 +8,6 @@ import { type CurrencyCode, } from "@/lib/shop/currency"; -/** - * Canonical locale resolution at API boundaries: - * 1) next-intl / custom headers (from middleware) - * 2) locale cookies - * 3) Accept-Language - */ export function resolveRequestLocale(request: NextRequest): string | null { const headerLocale = request.headers.get("x-next-intl-locale") ?? diff --git a/frontend/lib/shop/ui-classes.ts b/frontend/lib/shop/ui-classes.ts index 22c6c737..155e66b4 100644 --- a/frontend/lib/shop/ui-classes.ts +++ b/frontend/lib/shop/ui-classes.ts @@ -25,11 +25,9 @@ export const SHOP_CTA_INSET = ` opacity-40 supports-[hover:hover]:group-hover:opacity-60 transition-opacity `; -// Shared interaction for “chips” (swatches, size pills, +/- stepper) export const SHOP_CHIP_INTERACTIVE = 'transition-[box-shadow,border-color,color,background-color,filter] duration-500 ease-out hover:brightness-110'; -// Optional “lift” (НЕ використовуй для size/+/- якщо хочеш без тремтіння) export const SHOP_CHIP_LIFT = 'transition-transform duration-500 ease-out hover:-translate-y-0.5'; @@ -39,7 +37,6 @@ export const SHOP_CHIP_HOVER = export const SHOP_CHIP_SELECTED = 'border-accent ring-2 ring-accent ring-offset-2 ring-offset-background shadow-[var(--shop-chip-shadow-selected)]'; -// Optional: base shapes (so you don’t repeat layout primitives) export const SHOP_SWATCH_BASE = 'group relative h-9 w-9 rounded-full border border-border shadow-none'; @@ -49,16 +46,9 @@ export const SHOP_SIZE_CHIP_BASE = export const SHOP_STEPPER_BUTTON_BASE = 'flex h-10 w-10 items-center justify-center rounded-md border border-border text-foreground bg-transparent'; -/** - * Builds a horizontal gradient background from two CSS vars. - * Example: shopCtaGradient('--shop-cta-bg', '--shop-cta-bg-hover') - */ - -// Text-link-ish filter items (category/type lists) export const SHOP_FILTER_ITEM_BASE = 'inline-flex text-sm font-medium transition-[color,transform] duration-300 ease-out hover:-translate-y-[1px]'; -// If you want the “chip hover border” to be consistent (used in filters + size) export const SHOP_CHIP_BORDER_HOVER = 'hover:border-foreground/60'; export const SHOP_DISABLED = 'disabled:pointer-events-none disabled:opacity-60'; @@ -66,10 +56,6 @@ export const SHOP_DISABLED = 'disabled:pointer-events-none disabled:opacity-60'; export const SHOP_CHIP_SHADOW_HOVER = 'hover:shadow-[var(--shop-chip-shadow-hover)]'; -/** - * Reusable “text link” for product names / order links / “go to order”, etc. - * Size додаєш окремо (text-xs, text-[15px], …). - */ export const SHOP_LINK_BASE = 'inline-flex font-medium text-foreground underline underline-offset-4 decoration-2 decoration-foreground/30 ' + 'transition-[color,transform,text-decoration-color] duration-300 ease-out ' + @@ -78,14 +64,9 @@ export const SHOP_LINK_BASE = export const SHOP_LINK_MD = 'text-[15px]'; export const SHOP_LINK_XS = 'text-xs'; -/** - * CTA interaction (додатково до SHOP_CTA_BASE). - * SHOP_CTA_BASE залишаємо як layout+typography+active. - */ export const SHOP_CTA_INTERACTIVE = 'transition-[transform,filter,box-shadow] duration-700 ease-out'; -// Outline button (inverted to CTA; used in error pages, secondary actions) export const SHOP_OUTLINE_BTN_BASE = 'inline-flex items-center justify-center rounded-xl border px-4 py-2 ' + 'text-sm font-semibold uppercase tracking-[0.25em] ' + @@ -96,14 +77,12 @@ export const SHOP_OUTLINE_BTN_INTERACTIVE = 'hover:-translate-y-[1px] hover:shadow-[var(--shop-chip-shadow-hover)] hover:brightness-110 ' + 'hover:border-[color:var(--accent-primary)] hover:text-[color:var(--accent-primary)]'; -// Nav/breadcrumb-ish links (e.g. "My orders", "Shop", "Back to ...") export const SHOP_NAV_LINK_BASE = 'inline-flex font-medium underline underline-offset-4 decoration-2 ' + 'text-muted-foreground decoration-foreground/30 ' + 'transition-[color,transform,text-decoration-color] duration-300 ease-out ' + 'hover:-translate-y-[1px] hover:text-accent hover:decoration-[color:var(--accent-primary)]'; -// Select / dropdown (e.g. sort) export const SHOP_SELECT_BASE = 'peer h-10 w-full appearance-none rounded-xl border border-border bg-background pl-3 pr-11 text-sm font-medium'; diff --git a/frontend/lib/tests/helpers/makeCheckoutReq.ts b/frontend/lib/tests/helpers/makeCheckoutReq.ts index 95de7b9e..72abb811 100644 --- a/frontend/lib/tests/helpers/makeCheckoutReq.ts +++ b/frontend/lib/tests/helpers/makeCheckoutReq.ts @@ -10,7 +10,7 @@ export type CheckoutItemInput = { export function makeCheckoutReq(params: { idempotencyKey: string; - locale?: string; // mapped to Accept-Language + locale?: string; items?: CheckoutItemInput[]; userId?: string; origin?: string | null; @@ -24,7 +24,6 @@ export function makeCheckoutReq(params: { { productId: '11111111-1111-4111-8111-111111111111', quantity: 1, - // IMPORTANT: не форсимо selectedSize/selectedColor }, ]; diff --git a/frontend/lib/tests/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/order-items-snapshot-immutable.test.ts deleted file mode 100644 index 744496dc..00000000 --- a/frontend/lib/tests/order-items-snapshot-immutable.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -// lib/tests/order-items-snapshot-immutable.test.ts -import { randomUUID } from "node:crypto"; - -import { and, eq } from "drizzle-orm"; -import { NextRequest } from "next/server"; -import { describe, expect, it, vi } from "vitest"; - -import { db } from "@/db"; -import { - inventoryMoves, - orderItems, - orders, - productPrices, - products, -} from "@/db/schema"; - -// IMPORTANT: checkout route calls getCurrentUser(), which uses next/headers cookies() -// In vitest there is no request scope, so we must mock it to avoid noisy error + flakiness. -vi.mock("@/lib/auth", async () => { - const actual = await vi.importActual>("@/lib/auth"); - return { - ...actual, - getCurrentUser: vi.fn(async () => null), - }; -}); - -// Force "no-payments" path so checkout never touches Stripe network. -// This test is only about snapshot immutability. -vi.mock("@/lib/env/stripe", async () => { - const actual = - await vi.importActual>("@/lib/env/stripe"); - return { - ...actual, - isPaymentsEnabled: () => false, - }; -}); - -type CheckoutResponse = { - success: boolean; - orderId?: string; - order?: { id?: string }; -}; - -function makeJsonRequest( - url: string, - body: unknown, - headers: Record, -) { - return new NextRequest(url, { - method: "POST", - headers, - body: JSON.stringify(body), - }); -} - -async function cleanupByIds(params: { orderId?: string; productId: string }) { - const { orderId, productId } = params; - - if (orderId) { - // delete children first - await db.delete(inventoryMoves).where(eq(inventoryMoves.orderId, orderId)); - await db.delete(orderItems).where(eq(orderItems.orderId, orderId)); - await db.delete(orders).where(eq(orders.id, orderId)); - } - - await db.delete(productPrices).where(eq(productPrices.productId, productId)); - - await db.delete(products).where(eq(products.id, productId)); -} - -describe("P0-6 snapshots: order_items immutability", () => { - it("snapshot fields must not change after products/product_prices update", async () => { - const productId = randomUUID(); - const priceId = randomUUID(); - - const titleV1 = "Snapshot Test Product"; - const slugV1 = `snapshot-test-${productId.slice(0, 8)}`; - const skuV1 = `SKU-${productId.slice(0, 8)}`; - - // Seed product (USD-only per your CHECK constraint) - await db.insert(products).values({ - id: productId, - slug: slugV1, - title: titleV1, - description: "snapshot test", - imageUrl: "https://res.cloudinary.com/devlovers/image/upload/v1/test.png", - imagePublicId: null, - price: "9.00", - originalPrice: null, - currency: "USD", - category: null, - type: null, - colors: [], - sizes: [], - badge: "NONE", - isActive: true, - isFeatured: false, - stock: 10, - sku: skuV1, - }); - - // Seed product_prices (USD) - await db.insert(productPrices).values({ - id: priceId, - productId, - currency: "USD", - priceMinor: 900, - originalPriceMinor: null, - price: "9.00", - originalPrice: null, - }); - - const idem = randomUUID(); - const req = makeJsonRequest( - "http://localhost:3000/api/shop/checkout", - { items: [{ productId, quantity: 1 }] }, - { - "Accept-Language": "en-US,en;q=0.9", - "Content-Type": "application/json", - "Idempotency-Key": idem, - Origin: "http://localhost:3000", - }, - ); - const { POST: checkoutPOST } = await import( - "@/app/api/shop/checkout/route" - ); - - const res = await checkoutPOST(req); - - // Your checkout returns 201 Created on success. - expect(res.status).toBeGreaterThanOrEqual(200); - expect(res.status).toBeLessThan(300); - - const json = (await res.json()) as CheckoutResponse; - expect(json.success).toBe(true); - - const orderId = json.orderId ?? json.order?.id; - expect(typeof orderId).toBe("string"); - if (!orderId) throw new Error("Missing orderId from checkout response"); - - let primaryError: unknown = null; - let cleanupError: unknown = null; - - try { - // Baseline snapshot - const before = await db - .select({ - orderId: orderItems.orderId, - productId: orderItems.productId, - quantity: orderItems.quantity, - unitPriceMinor: orderItems.unitPriceMinor, - lineTotalMinor: orderItems.lineTotalMinor, - productTitle: orderItems.productTitle, - productSlug: orderItems.productSlug, - productSku: orderItems.productSku, - }) - .from(orderItems) - .where(eq(orderItems.orderId, orderId)); - - expect(before.length).toBe(1); - expect(before[0].productId).toBe(productId); - expect(before[0].productTitle).toBe(titleV1); - expect(before[0].productSlug).toBe(slugV1); - expect(before[0].productSku).toBe(skuV1); - expect(before[0].unitPriceMinor).toBe(900); - expect(before[0].lineTotalMinor).toBe(900); - - // Mutate product + product_prices aggressively (attempt to "break" snapshots) - const titleV2 = `${titleV1} UPDATED`; - const slugV2 = `${slugV1}-updated`; - const skuV2 = `${skuV1}-UPDATED`; - - await db - .update(products) - .set({ - title: titleV2, - slug: slugV2, - sku: skuV2, - updatedAt: new Date(), - }) - .where(eq(products.id, productId)); - - await db - .update(productPrices) - .set({ - priceMinor: 1000, - price: "10.00", - updatedAt: new Date(), - }) - .where( - and( - eq(productPrices.productId, productId), - eq(productPrices.currency, "USD"), - ), - ); - - const after = await db - .select({ - orderId: orderItems.orderId, - productId: orderItems.productId, - quantity: orderItems.quantity, - unitPriceMinor: orderItems.unitPriceMinor, - lineTotalMinor: orderItems.lineTotalMinor, - productTitle: orderItems.productTitle, - productSlug: orderItems.productSlug, - productSku: orderItems.productSku, - }) - .from(orderItems) - .where(eq(orderItems.orderId, orderId)); - - expect(after.length).toBe(1); - - // Snapshot MUST remain V1 even after product changes - expect(after[0]).toEqual(before[0]); - } catch (e) { - primaryError = e; - throw e; - } finally { - try { - await cleanupByIds({ orderId, productId }); - } catch (e) { - cleanupError = e; - console.error("[test cleanup failed]", { orderId, productId }, e); - } - } - if (!primaryError && cleanupError) { - throw cleanupError; - } - }, 30_000); -}); diff --git a/frontend/lib/tests/orders-access.test.ts b/frontend/lib/tests/orders-access.test.ts deleted file mode 100644 index 028182fe..00000000 --- a/frontend/lib/tests/orders-access.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { NextRequest } from "next/server" - -vi.mock("@/lib/auth", () => ({ - getCurrentUser: vi.fn(), -})) - -vi.mock("@/db", () => ({ - db: { - select: vi.fn(), - }, -})) - -import { getCurrentUser } from "@/lib/auth" -import { db } from "@/db" - -type MockUser = { id: string; role: "user" | "admin" } - -describe("P0-SEC-1.1: GET /api/shop/orders/[id] access control", () => { - const orderId = "00000000-0000-0000-0000-000000000000" - const ownerId = "11111111-1111-1111-1111-111111111111" - const otherUserId = "22222222-2222-2222-2222-222222222222" - const adminId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - - function mockDbRows(rows: any[]) { - const builder = { - from: vi.fn().mockReturnThis(), - leftJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue(rows), - } - ;(db.select as any).mockReturnValue(builder) - return builder - } - - async function callGet(id: string) { - const { GET } = await import("@/app/api/shop/orders/[id]/route") - const req = new NextRequest(`http://localhost/api/shop/orders/${id}`, { - method: "GET", - }) - - // route expects: context: { params: Promise<{ id: string }> } - const res = await (GET as any)(req, { params: Promise.resolve({ id }) }) - return res as Response - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it("no session -> 401", async () => { - ;(getCurrentUser as any).mockResolvedValue(null) - - const res = await callGet(orderId) - expect(res.status).toBe(401) - expect(db.select).not.toHaveBeenCalled() - }) - - it("not owner and not admin -> 404 (hide existence)", async () => { - const user: MockUser = { id: otherUserId, role: "user" } - ;(getCurrentUser as any).mockResolvedValue(user) - - mockDbRows([]) // whereClause should filter out чужий order => 0 rows - - const res = await callGet(orderId) - expect(res.status).toBe(404) - }) - - it("owner -> 200", async () => { - const user: MockUser = { id: ownerId, role: "user" } - ;(getCurrentUser as any).mockResolvedValue(user) - - const now = new Date() - mockDbRows([ - { - order: { - id: orderId, - userId: ownerId, - totalAmount: "10.00", - currency: "USD", - paymentStatus: "pending", - paymentProvider: "stripe", - paymentIntentId: null, - stockRestored: false, - restockedAt: null, - idempotencyKey: "idem_key", - createdAt: now, - updatedAt: now, - }, - item: null, - }, - ]) - - const res = await callGet(orderId) - expect(res.status).toBe(200) - - const json = await res.json() - expect(json?.success).toBe(true) - expect(json?.order?.id).toBe(orderId) - expect(json?.order?.userId).toBe(ownerId) - }) - - it("admin -> 200", async () => { - const user: MockUser = { id: adminId, role: "admin" } - ;(getCurrentUser as any).mockResolvedValue(user) - - const now = new Date() - mockDbRows([ - { - order: { - id: orderId, - userId: ownerId, - totalAmount: "10.00", - currency: "USD", - paymentStatus: "pending", - paymentProvider: "stripe", - paymentIntentId: null, - stockRestored: false, - restockedAt: null, - idempotencyKey: "idem_key", - createdAt: now, - updatedAt: now, - }, - item: null, - }, - ]) - - const res = await callGet(orderId) - expect(res.status).toBe(200) - }) -}) diff --git a/frontend/lib/tests/admin-api-killswitch.test.ts b/frontend/lib/tests/shop/admin-api-killswitch.test.ts similarity index 87% rename from frontend/lib/tests/admin-api-killswitch.test.ts rename to frontend/lib/tests/shop/admin-api-killswitch.test.ts index 2dd39ea0..63afdaad 100644 --- a/frontend/lib/tests/admin-api-killswitch.test.ts +++ b/frontend/lib/tests/shop/admin-api-killswitch.test.ts @@ -4,7 +4,6 @@ import { NextRequest } from 'next/server'; const BASE_URL = 'http://localhost'; -// Valid UUIDs for dynamic routes; format matters more than real existence. const TEST_PRODUCT_ID = '00000000-0000-4000-8000-000000000001'; const TEST_ORDER_ID = '00000000-0000-4000-8000-000000000002'; @@ -78,15 +77,12 @@ function makeReq(path: string, method: string) { const url = `${BASE_URL}${path}`; const origin = process.env.APP_ORIGIN ?? 'http://localhost:3000'; - // Intentionally invalid JSON payload to ensure kill-switch guard runs - // BEFORE any req.json()/formData() parsing. const init: RequestInit = { method, headers: { 'content-type': 'application/json', origin }, }; if (method !== 'GET' && method !== 'HEAD') { - // Using invalid JSON lets us detect incorrect handler ordering. (init as any).body = '{'; } @@ -98,7 +94,6 @@ async function readCodeFromResponse( ): Promise<{ status: number; code?: string; raw: string }> { const status = res.status; - // Read body ONCE to avoid "body used already" const raw = await res.text(); if (!raw) return { status, raw: '' }; @@ -120,8 +115,6 @@ async function expectAdminDisabled(res: Response) { expect(body.status).toBe(403); - // Contract: we must surface ADMIN_API_DISABLED. - // Allow either {code} or {error:{code}}; also allow plain text containing the code. if (body.code) { expect(body.code).toBe('ADMIN_API_DISABLED'); } else { @@ -145,9 +138,7 @@ describe('P0-7.1 Admin API kill-switch coverage (production)', () => { beforeEach(() => { vi.resetModules(); - // Emulate production and disabled admin API. vi.stubEnv('NODE_ENV', 'production'); - // Treat anything except 'true' as disabled; empty string is explicitly disabled. vi.stubEnv('ENABLE_ADMIN_API', ''); vi.stubEnv('APP_ORIGIN', 'https://admin.example.test'); }); diff --git a/frontend/lib/tests/admin-csrf-contract.test.ts b/frontend/lib/tests/shop/admin-csrf-contract.test.ts similarity index 96% rename from frontend/lib/tests/admin-csrf-contract.test.ts rename to frontend/lib/tests/shop/admin-csrf-contract.test.ts index 99965180..fd920796 100644 --- a/frontend/lib/tests/admin-csrf-contract.test.ts +++ b/frontend/lib/tests/shop/admin-csrf-contract.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; -// Mock admin auth to "pass" vi.mock('@/lib/auth/admin', () => { class AdminApiDisabledError extends Error { code = 'ADMIN_API_DISABLED'; @@ -20,7 +19,6 @@ vi.mock('@/lib/auth/admin', () => { }; }); -// Import AFTER mocking import { PATCH as patchStatus } from '@/app/api/shop/admin/products/[id]/status/route'; describe('P0-SEC: admin CSRF required for mutating endpoints', () => { diff --git a/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts b/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts similarity index 94% rename from frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts rename to frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts index 95c4105e..edd6bffe 100644 --- a/frontend/lib/tests/admin-product-patch-price-config-error-contract.test.ts +++ b/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts @@ -45,14 +45,12 @@ beforeEach(() => { function makeReq(): NextRequest { const fd = new FormData(); - // Make payload realistic enough to pass parseAdminProductForm in PATCH fd.set('title', 'Test product'); fd.set('badge', 'NONE'); fd.set('isActive', 'true'); fd.set('isFeatured', 'false'); fd.set('stock', '0'); - // Include USD to avoid parser-level USD requirements if any still exist fd.set( 'prices', JSON.stringify([ diff --git a/frontend/lib/tests/admin-product-sale-contract.test.ts b/frontend/lib/tests/shop/admin-product-sale-contract.test.ts similarity index 96% rename from frontend/lib/tests/admin-product-sale-contract.test.ts rename to frontend/lib/tests/shop/admin-product-sale-contract.test.ts index 4264de3a..4887bc1b 100644 --- a/frontend/lib/tests/admin-product-sale-contract.test.ts +++ b/frontend/lib/tests/shop/admin-product-sale-contract.test.ts @@ -1,15 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { NextRequest } from 'next/server'; -/** - * We intentionally mock: - * - getCurrentUser() -> always admin - * - parseAdminProductForm() -> returns controlled payload - * - * This isolates contract behavior of the API route: - * returns stable code + details for SALE rule violations. - */ - const { getCurrentUserMock, parseAdminProductFormMock } = vi.hoisted(() => ({ getCurrentUserMock: vi.fn(async () => ({ id: 'u_test_admin', diff --git a/frontend/lib/tests/cart-rehydrate-variant-sanitize.test.ts b/frontend/lib/tests/shop/cart-rehydrate-variant-sanitize.test.ts similarity index 100% rename from frontend/lib/tests/cart-rehydrate-variant-sanitize.test.ts rename to frontend/lib/tests/shop/cart-rehydrate-variant-sanitize.test.ts diff --git a/frontend/lib/tests/checkout-concurrency-stock1.test.ts b/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts similarity index 86% rename from frontend/lib/tests/checkout-concurrency-stock1.test.ts rename to frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts index e459e36b..8bfdb1a4 100644 --- a/frontend/lib/tests/checkout-concurrency-stock1.test.ts +++ b/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts @@ -1,4 +1,3 @@ -// lib/tests/checkout-concurrency-stock1.test.ts import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import crypto from 'crypto'; import { NextRequest } from 'next/server'; @@ -13,13 +12,11 @@ import { inventoryMoves, } from '@/db/schema/shop'; -// NOTE: checkout route will be imported dynamically after mocks are installed. - vi.mock('@/lib/auth', async () => { const actual = await vi.importActual('@/lib/auth'); return { ...actual, - getCurrentUser: async () => null, // avoid cookies() in vitest + getCurrentUser: async () => null, }; }); @@ -81,7 +78,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = }); it('must allow only one success and must not double-reserve (stock must not go below 0)', async () => { - // ---------- Arrange: create isolated product + USD price, stock=1 ---------- const productId = crypto.randomUUID(); const slug = `__test_checkout_concurrency_${productId.slice(0, 8)}`; const now = new Date(); @@ -91,7 +87,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = title: `TEST concurrency stock=1 (${slug})`, imageUrl: '/placeholder.svg', - // FIX: products.price is NOT NULL in DB (legacy column) price: 1000, originalPrice: null, currency: 'USD', @@ -107,23 +102,19 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = productId, currency: 'USD', - // minor-units priceMinor: 1000, originalPriceMinor: null, - // FIX: legacy major-unit columns are NOT NULL - price: 10, // 1000 minor -> 10.00 + price: 10, originalPrice: null, createdAt: now, updatedAt: now, } as any); - // ---------- Helper: call checkout with given idempotency key ---------- const baseUrl = 'http://localhost:3000'; - const { POST: checkoutPOST } = await import( - '@/app/api/shop/checkout/route' - ); + const { POST: checkoutPOST } = + await import('@/app/api/shop/checkout/route'); async function callCheckout(idemKey: string) { const body = JSON.stringify({ @@ -147,7 +138,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = return { status: res.status, json }; } - // ---------- Act: run two requests in parallel (start-gated) ---------- const idemA = crypto.randomUUID(); const idemB = crypto.randomUUID(); @@ -168,7 +158,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = const [r1, r2] = await Promise.all([p1, p2]); - // ---------- Assert: exactly one success, the other controlled failure ---------- const results = [r1, r2]; const success = results.filter(r => r.status === 201); const fail = results.filter(r => r.status !== 201); @@ -194,7 +183,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = ).toBe(true); } - // ---------- DB asserts: stock must be 0 ---------- const prodRows = await db .select() .from(products) @@ -209,7 +197,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = expect(toNum(stock)).toBe(0); expect(toNum(stock)).toBeGreaterThanOrEqual(0); - // ---------- Ledger asserts: no double reserve for this product ---------- const moves = await db .select() .from(inventoryMoves) @@ -237,7 +224,6 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = expect(reservedUnits).toBe(1); expect(reserveMoves.length).toBe(1); - // ---------- Cleanup: best-effort ---------- try { const oi = await db .select({ orderId: (orderItems as any).orderId }) @@ -261,8 +247,13 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = } await db.delete(products).where(eq((products as any).id, productId)); - } catch { - // best-effort cleanup + } catch (err) { + // Do not swallow cleanup failures: they can leave residual rows and cause flaky follow-up tests. + // In CI we fail fast so flakes are visible. + if (process.env.CI) throw err; + + // eslint-disable-next-line no-console + console.warn('checkout concurrency cleanup failed', err); } }, 30000); }); diff --git a/frontend/lib/tests/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts similarity index 94% rename from frontend/lib/tests/checkout-currency-policy.test.ts rename to frontend/lib/tests/shop/checkout-currency-policy.test.ts index 0d34c8c6..0af8357c 100644 --- a/frontend/lib/tests/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -8,9 +8,8 @@ process.env.STRIPE_SECRET_KEY = ''; process.env.STRIPE_WEBHOOK_SECRET = ''; vi.mock('@/lib/auth', async () => { - const actual = await vi.importActual( - '@/lib/auth' - ); + const actual = + await vi.importActual('@/lib/auth'); return { ...actual, getCurrentUser: async () => null, // guest @@ -42,9 +41,8 @@ const logErrorMock = vi.fn((..._args: any[]) => undefined); const logWarnMock = vi.fn((..._args: any[]) => undefined); vi.mock('@/lib/logging', async () => { - const actual = await vi.importActual( - '@/lib/logging' - ); + const actual = + await vi.importActual('@/lib/logging'); return { ...actual, logError: (...args: any[]) => logErrorMock(...args), @@ -69,13 +67,11 @@ beforeAll(() => { }); beforeAll(async () => { - // Import route after env + mocks are set const mod = await import('@/app/api/shop/checkout/route'); POST = mod.POST; }); afterAll(async () => { - // delete orders first (cascade order_items) if (createdOrderIds.length) { await db.delete(orders).where(inArray(orders.id, createdOrderIds)); } @@ -88,7 +84,6 @@ afterAll(async () => { }); function makeIdempotencyKey(): string { - // 36 chars, allowed by your schema return crypto.randomUUID(); } @@ -127,7 +122,6 @@ async function seedProduct(options: { imageUrl: 'https://example.com/img.png', imagePublicId: null, - // legacy mirror required by schema price: '10.00', originalPrice: null, currency: 'USD', @@ -165,7 +159,7 @@ async function debugIfNotExpected(res: Response, expectedStatus: number) { if (res.status === expectedStatus) return; const text = await res.text().catch(() => ''); - // Keep output minimal but decisive + console.log('checkout failed', { status: res.status, body: text }); console.log('logError calls', logErrorMock.mock.calls); console.log( diff --git a/frontend/lib/tests/checkout-no-payments.test.ts b/frontend/lib/tests/shop/checkout-no-payments.test.ts similarity index 85% rename from frontend/lib/tests/checkout-no-payments.test.ts rename to frontend/lib/tests/shop/checkout-no-payments.test.ts index 4b119870..a0648620 100644 --- a/frontend/lib/tests/checkout-no-payments.test.ts +++ b/frontend/lib/tests/shop/checkout-no-payments.test.ts @@ -24,11 +24,10 @@ vi.mock('@/lib/logging', async () => { const actual = await vi.importActual('@/lib/logging'); return { ...actual, - logWarn: () => {}, // silence expected business warnings in this test file + logWarn: () => {}, }; }); -// Force "no payments" for this whole test file. vi.mock('@/lib/env/stripe', async () => { const actual = await vi.importActual('@/lib/env/stripe'); return { @@ -41,7 +40,7 @@ vi.mock('@/lib/auth', async () => { const actual = await vi.importActual('@/lib/auth'); return { ...actual, - getCurrentUser: async () => null, // critical: avoid cookies() in tests + getCurrentUser: async () => null, }; }); @@ -53,19 +52,12 @@ function logTestCleanupFailed(meta: Record, error: unknown) { }); } -/** - * Creates an isolated product + product_prices row to avoid stock races - * with parallel test files that also reserve/release inventory. - * - * Product is created as inactive by default; tests activate it only for the minimal window needed. - */ async function createIsolatedProductForCurrency(opts: { currency: 'USD' | 'UAH'; stock: number; }): Promise<{ productId: string }> { const now = new Date(); - // Clone a real product row to satisfy NOT NULL columns (schema varies). const [tpl] = await db .select() .from(products) @@ -82,7 +74,6 @@ async function createIsolatedProductForCurrency(opts: { const slug = `t-iso-nopay-${crypto.randomUUID()}`; const sku = `t-iso-nopay-${crypto.randomUUID()}`; - // Keep inactive by default to avoid being picked by other tests. await db.insert(products).values({ ...(tpl as any), id: productId, @@ -95,7 +86,6 @@ async function createIsolatedProductForCurrency(opts: { updatedAt: now, } as any); - // Ensure price exists for requested currency (minor + legacy). try { await db.insert(productPrices).values({ productId, @@ -108,7 +98,6 @@ async function createIsolatedProductForCurrency(opts: { updatedAt: now, } as any); } catch (e) { - // Cleanup orphaned product on price insert failure (best-effort) try { await db.delete(products).where(eq(products.id, productId)); } catch (cleanupError) { @@ -130,14 +119,12 @@ async function createIsolatedProductForCurrency(opts: { } async function cleanupIsolatedProduct(productId: string) { - // Make sure it won't be visible for any selector. try { await db .update(products) .set({ isActive: false, updatedAt: new Date() } as any) .where(eq(products.id, productId)); } catch (e) { - // Non-fatal: best-effort test teardown logTestCleanupFailed( { fn: 'cleanupIsolatedProduct', step: 'deactivate product', productId }, e @@ -231,8 +218,6 @@ async function countMovesForProduct(productId: string): Promise { } async function bestEffortHardDeleteOrder(orderId: string) { - // Keep DB reasonably clean in dev. - // Use raw SQL because inventory_moves/order_items may not be exported as Drizzle tables. try { await db.execute( sql`delete from inventory_moves where order_id = ${orderId}::uuid` @@ -283,7 +268,6 @@ describe.sequential('Checkout (no payments) invariants', () => { let orderId: string | null = null; try { - // Activate only for the minimal window needed by checkout. await db .update(products) .set({ isActive: true, updatedAt: new Date() } as any) @@ -314,18 +298,15 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(typeof orderId).toBe('string'); expect(orderId.length).toBeGreaterThan(10); - // Deactivate immediately to minimize chance other parallel tests pick it. await db .update(products) .set({ isActive: false, updatedAt: new Date() } as any) .where(eq(products.id, productId)); - // response-level contract expect(json.order.paymentProvider).toBe('none'); expect(json.order.paymentStatus).toBe('paid'); expect(json.order.currency).toBe('USD'); - // DB contract (source of truth) const [row] = await db .select({ id: orders.id, @@ -342,13 +323,12 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(row).toBeTruthy(); expect(row!.paymentProvider).toBe('none'); - expect(row!.paymentStatus).toBe('paid'); // forced by DB CHECK for provider=none - expect(row!.inventoryStatus).toBe('reserved'); // TRUE finality for no-payments + expect(row!.paymentStatus).toBe('paid'); + expect(row!.inventoryStatus).toBe('reserved'); expect(row!.status).toBe('PAID'); expect(row!.currency).toBe('USD'); expect(row!.totalAmountMinor).toBeGreaterThan(0); - // Ledger: exactly one reserve (no duplicates on single request) const moves = await readMoves(orderId); const reserves = moves.filter(m => m.type === 'reserve'); const releases = moves.filter(m => m.type === 'release'); @@ -358,7 +338,6 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(reserves[0]!.quantity).toBe(1); expect(releases.length).toBe(0); - // Stock decreased const [p1] = await db .select({ stock: products.stock }) .from(products) @@ -368,7 +347,6 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p1).toBeTruthy(); expect(p1!.stock).toBe(stockBefore - 1); - // cleanup: restore stock via release const { restockOrder } = await import('@/lib/services/orders'); await restockOrder(orderId, { reason: 'stale' }); @@ -384,7 +362,6 @@ describe.sequential('Checkout (no payments) invariants', () => { await bestEffortHardDeleteOrder(orderId); orderId = null; } finally { - // If test failed after creating an order, try to delete it. if (orderId) { await bestEffortHardDeleteOrder(orderId); } @@ -429,13 +406,11 @@ describe.sequential('Checkout (no payments) invariants', () => { orderId1 = (j1?.order?.id ?? j1?.orderId) as string; expect(orderId1).toBeTruthy(); - // Deactivate immediately (same reason as in success-path test) await db .update(products) .set({ isActive: false, updatedAt: new Date() } as any) .where(eq(products.id, productId)); - // same IdemKey + same payload => same order id (no extra reserve) const r2 = await postCheckout({ idemKey, acceptLanguage: 'en', @@ -450,9 +425,8 @@ describe.sequential('Checkout (no payments) invariants', () => { const movesAfter2 = await readMoves(orderId1); const reservesAfter2 = movesAfter2.filter(m => m.type === 'reserve'); - expect(reservesAfter2.length).toBe(1); // critical: no double-reserve + expect(reservesAfter2.length).toBe(1); - // same IdemKey but different payload => 409 conflict const r3 = await postCheckout({ idemKey, acceptLanguage: 'en', @@ -460,7 +434,6 @@ describe.sequential('Checkout (no payments) invariants', () => { }); expect(r3.status).toBe(409); - // cleanup: restore stock via release const { restockOrder } = await import('@/lib/services/orders'); await restockOrder(orderId1, { reason: 'stale' }); @@ -492,13 +465,11 @@ describe.sequential('Checkout (no payments) invariants', () => { let unexpectedOrderId: string | null = null; try { - // Activate only for the minimal window needed by checkout. await db .update(products) .set({ isActive: true, updatedAt: new Date() } as any) .where(eq(products.id, productId)); - // Make variants deterministic for this test. await db .update(products) .set({ @@ -518,7 +489,7 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p0).toBeTruthy(); const stockBefore = p0!.stock; - // ДО checkout: порахували moves + const countBefore = await countMovesForProduct(productId); const res = await postCheckout({ @@ -537,7 +508,7 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(res.status).toBe(400); const json: any = await res.json(); - // Deactivate immediately to minimize chance other parallel tests pick it. + await db .update(products) .set({ isActive: false, updatedAt: new Date() } as any) @@ -547,7 +518,6 @@ describe.sequential('Checkout (no payments) invariants', () => { const countAfter = await countMovesForProduct(productId); expect(countAfter).toBe(countBefore); - // No order should be created for invalid variant const [maybeOrder] = await db .select({ id: orders.id }) .from(orders) @@ -557,7 +527,6 @@ describe.sequential('Checkout (no payments) invariants', () => { if (maybeOrder?.id) unexpectedOrderId = maybeOrder.id; expect(maybeOrder).toBeFalsy(); - // Stock must not change const [p1] = await db .select({ stock: products.stock }) .from(products) @@ -567,7 +536,6 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p1).toBeTruthy(); expect(p1!.stock).toBe(stockBefore); } finally { - // Deactivate to reduce cross-test interference. try { await db .update(products) @@ -575,7 +543,6 @@ describe.sequential('Checkout (no payments) invariants', () => { .where(eq(products.id, productId)); } catch {} - // If an order was unexpectedly created, remove it (keep DB clean). if (unexpectedOrderId) { await bestEffortHardDeleteOrder(unexpectedOrderId); } @@ -594,13 +561,11 @@ describe.sequential('Checkout (no payments) invariants', () => { let unexpectedOrderId: string | null = null; try { - // Activate only for the minimal window needed by checkout. await db .update(products) .set({ isActive: true, updatedAt: new Date() } as any) .where(eq(products.id, productId)); - // Simulate "no variants configured" (empty lists). await db .update(products) .set({ @@ -621,7 +586,6 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p0).toBeTruthy(); const stockBefore = p0!.stock; - // ДО checkout: порахували moves const countBefore = await countMovesForProduct(productId); const res = await postCheckout({ @@ -631,7 +595,7 @@ describe.sequential('Checkout (no payments) invariants', () => { { productId, quantity: 1, - // Client sends options, but product has none => must be rejected. + selectedSize: 'S', selectedColor: 'Red', }, @@ -642,7 +606,6 @@ describe.sequential('Checkout (no payments) invariants', () => { const json: any = await res.json(); - // Deactivate immediately to minimize chance other parallel tests pick it. await db .update(products) .set({ isActive: false, updatedAt: new Date() } as any) @@ -650,11 +613,9 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(json?.code).toBe('INVALID_VARIANT'); - // Після checkout: moves count must be unchanged const countAfter = await countMovesForProduct(productId); expect(countAfter).toBe(countBefore); - // No order should be created for this idemKey const [maybeOrder] = await db .select({ id: orders.id }) .from(orders) @@ -664,7 +625,6 @@ describe.sequential('Checkout (no payments) invariants', () => { if (maybeOrder?.id) unexpectedOrderId = maybeOrder.id; expect(maybeOrder).toBeFalsy(); - // Stock must not change const [p1] = await db .select({ stock: products.stock }) .from(products) @@ -674,7 +634,6 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p1).toBeTruthy(); expect(p1!.stock).toBe(stockBefore); } finally { - // Deactivate to reduce cross-test interference. try { await db .update(products) @@ -682,7 +641,6 @@ describe.sequential('Checkout (no payments) invariants', () => { .where(eq(products.id, productId)); } catch {} - // If an order was unexpectedly created, remove it (keep DB clean). if (unexpectedOrderId) { await bestEffortHardDeleteOrder(unexpectedOrderId); } @@ -696,7 +654,6 @@ describe.sequential('Checkout (no payments) invariants', () => { const idemKey = crypto.randomUUID(); const createdAt = new Date(Date.now() - 11 * 60_000); - // Insert orphan order (no inventory_moves) await db.insert(orders).values({ id: orphanId, currency: 'USD', @@ -721,9 +678,8 @@ describe.sequential('Checkout (no payments) invariants', () => { const moves0 = await readMoves(orphanId); expect(moves0.length).toBe(0); - const { restockStaleNoPaymentOrders } = await import( - '@/lib/services/orders' - ); + const { restockStaleNoPaymentOrders } = + await import('@/lib/services/orders'); const processed = await restockStaleNoPaymentOrders({ olderThanMinutes: 10, batchSize: 50, diff --git a/frontend/lib/tests/checkout-origin-posture-contract.test.ts b/frontend/lib/tests/shop/checkout-origin-posture-contract.test.ts similarity index 100% rename from frontend/lib/tests/checkout-origin-posture-contract.test.ts rename to frontend/lib/tests/shop/checkout-origin-posture-contract.test.ts diff --git a/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts b/frontend/lib/tests/shop/checkout-set-payment-intent-reject-contract.test.ts similarity index 92% rename from frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts rename to frontend/lib/tests/shop/checkout-set-payment-intent-reject-contract.test.ts index a2068474..a77bbaf5 100644 --- a/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-set-payment-intent-reject-contract.test.ts @@ -11,8 +11,6 @@ import { makeCheckoutReq } from '@/lib/tests/helpers/makeCheckoutReq'; import { InvalidPayloadError } from '@/lib/services/errors'; import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts'; -// Force payments enabled so route goes into Stripe flow -// gitleaks:allow vi.mock('@/lib/env/stripe', () => ({ getStripeEnv: () => ({ paymentsEnabled: true, @@ -20,15 +18,13 @@ vi.mock('@/lib/env/stripe', () => ({ secretKey: 'sk_test_dummy', webhookSecret: 'whsec_test_dummy', }), - isPaymentsEnabled: () => true, // kept for backward compatibility + isPaymentsEnabled: () => true, })); -// Avoid auth coupling vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), })); -// Stripe: PI creation succeeds vi.mock('@/lib/psp/stripe', () => ({ createPaymentIntent: vi.fn(async () => ({ paymentIntentId: 'pi_test_attach_reject', @@ -37,7 +33,6 @@ vi.mock('@/lib/psp/stripe', () => ({ retrievePaymentIntent: vi.fn(), })); -// Avoid DB coupling introduced by #6 (DB-canonical PI amount/currency) vi.mock('@/lib/services/orders/payment-intent', () => ({ readStripePaymentIntentParams: vi.fn(async () => ({ amountMinor: 1000, @@ -45,7 +40,6 @@ vi.mock('@/lib/services/orders/payment-intent', () => ({ })), })); -// Mock order services vi.mock('@/lib/services/orders', async () => { const actual = await vi.importActual('@/lib/services/orders'); return { @@ -134,7 +128,6 @@ describe('checkout: setOrderPaymentIntent rejection after order creation must no const json = await res.json(); expect(json.code).toBe('CHECKOUT_CONFLICT'); - // Policy: conflict should not trigger immediate restock here. expect(restock).not.toHaveBeenCalled(); }); diff --git a/frontend/lib/tests/checkout-stripe-error-contract.test.ts b/frontend/lib/tests/shop/checkout-stripe-error-contract.test.ts similarity index 88% rename from frontend/lib/tests/checkout-stripe-error-contract.test.ts rename to frontend/lib/tests/shop/checkout-stripe-error-contract.test.ts index 208aa6c6..5f6bb582 100644 --- a/frontend/lib/tests/checkout-stripe-error-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-stripe-error-contract.test.ts @@ -9,7 +9,6 @@ import { } from 'vitest'; import { makeCheckoutReq } from '@/lib/tests/helpers/makeCheckoutReq'; -// 1) force payments enabled so route goes into Stripe flow vi.mock('@/lib/env/stripe', () => ({ getStripeEnv: () => ({ paymentsEnabled: true, @@ -17,15 +16,13 @@ vi.mock('@/lib/env/stripe', () => ({ secretKey: 'sk_test_dummy', webhookSecret: 'whsec_test_dummy', }), - isPaymentsEnabled: () => true, // kept for backward compatibility + isPaymentsEnabled: () => true, })); -// 2) avoid auth coupling vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), })); -// 3) force Stripe PI creation to fail AFTER "DB writes" (simulated by createOrderWithItems resolving) vi.mock('@/lib/psp/stripe', () => ({ createPaymentIntent: vi.fn(async () => { throw new Error('STRIPE_TEST_DOWN'); @@ -33,7 +30,6 @@ vi.mock('@/lib/psp/stripe', () => ({ retrievePaymentIntent: vi.fn(), })); -// Avoid DB coupling introduced by #6 (DB-canonical PI amount/currency) vi.mock('@/lib/services/orders/payment-intent', () => ({ readStripePaymentIntentParams: vi.fn(async () => ({ amountMinor: 1000, @@ -41,7 +37,6 @@ vi.mock('@/lib/services/orders/payment-intent', () => ({ })), })); -// 4) mock orders services so we don't depend on DB schema/seed here vi.mock('@/lib/services/orders', async () => { const actual = await vi.importActual('@/lib/services/orders'); return { diff --git a/frontend/lib/tests/currency.test.ts b/frontend/lib/tests/shop/currency.test.ts similarity index 98% rename from frontend/lib/tests/currency.test.ts rename to frontend/lib/tests/shop/currency.test.ts index 848d1f71..990df2e8 100644 --- a/frontend/lib/tests/currency.test.ts +++ b/frontend/lib/tests/shop/currency.test.ts @@ -3,7 +3,7 @@ import { parsePrimaryLocaleFromAcceptLanguage, resolveCurrencyFromHeaders, resolveCurrencyFromLocale, -} from "../shop/currency"; +} from "../../shop/currency"; describe("currency policy (CUR-0 / D1)", () => { it("uk -> UAH", () => { diff --git a/frontend/lib/tests/format-money.test.ts b/frontend/lib/tests/shop/format-money.test.ts similarity index 100% rename from frontend/lib/tests/format-money.test.ts rename to frontend/lib/tests/shop/format-money.test.ts diff --git a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts new file mode 100644 index 00000000..5606444c --- /dev/null +++ b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts @@ -0,0 +1,218 @@ +import { randomUUID } from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { + inventoryMoves, + orderItems, + orders, + productPrices, + products, +} from '@/db/schema'; + +vi.mock('@/lib/auth', async () => { + const actual = await vi.importActual>('@/lib/auth'); + return { + ...actual, + getCurrentUser: vi.fn(async () => null), + }; +}); + +vi.mock('@/lib/env/stripe', async () => { + const actual = + await vi.importActual>('@/lib/env/stripe'); + return { + ...actual, + isPaymentsEnabled: () => false, + }; +}); + +type CheckoutResponse = { + success: boolean; + orderId?: string; + order?: { id?: string }; +}; + +function makeJsonRequest( + url: string, + body: unknown, + headers: Record +) { + return new NextRequest(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +} + +async function cleanupByIds(params: { orderId?: string; productId: string }) { + const { orderId, productId } = params; + + if (orderId) { + // delete children first + await db.delete(inventoryMoves).where(eq(inventoryMoves.orderId, orderId)); + await db.delete(orderItems).where(eq(orderItems.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); + } + + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + + await db.delete(products).where(eq(products.id, productId)); +} + +describe('P0-6 snapshots: order_items immutability', () => { + it('snapshot fields must not change after products/product_prices update', async () => { + const productId = randomUUID(); + const priceId = randomUUID(); + + const titleV1 = 'Snapshot Test Product'; + const slugV1 = `snapshot-test-${productId.slice(0, 8)}`; + const skuV1 = `SKU-${productId.slice(0, 8)}`; + + await db.insert(products).values({ + id: productId, + slug: slugV1, + title: titleV1, + description: 'snapshot test', + imageUrl: 'https://res.cloudinary.com/devlovers/image/upload/v1/test.png', + imagePublicId: null, + price: '9.00', + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku: skuV1, + }); + + await db.insert(productPrices).values({ + id: priceId, + productId, + currency: 'USD', + priceMinor: 900, + originalPriceMinor: null, + price: '9.00', + originalPrice: null, + }); + + const idem = randomUUID(); + const req = makeJsonRequest( + 'http://localhost:3000/api/shop/checkout', + { items: [{ productId, quantity: 1 }] }, + { + 'Accept-Language': 'en-US,en;q=0.9', + 'Content-Type': 'application/json', + 'Idempotency-Key': idem, + Origin: 'http://localhost:3000', + } + ); + const { POST: checkoutPOST } = + await import('@/app/api/shop/checkout/route'); + + const res = await checkoutPOST(req); + + expect(res.status).toBeGreaterThanOrEqual(200); + expect(res.status).toBeLessThan(300); + + const json = (await res.json()) as CheckoutResponse; + expect(json.success).toBe(true); + + const orderId = json.orderId ?? json.order?.id; + expect(typeof orderId).toBe('string'); + if (!orderId) throw new Error('Missing orderId from checkout response'); + + let primaryError: unknown = null; + let cleanupError: unknown = null; + + try { + const before = await db + .select({ + orderId: orderItems.orderId, + productId: orderItems.productId, + quantity: orderItems.quantity, + unitPriceMinor: orderItems.unitPriceMinor, + lineTotalMinor: orderItems.lineTotalMinor, + productTitle: orderItems.productTitle, + productSlug: orderItems.productSlug, + productSku: orderItems.productSku, + }) + .from(orderItems) + .where(eq(orderItems.orderId, orderId)); + + expect(before.length).toBe(1); + expect(before[0].productId).toBe(productId); + expect(before[0].productTitle).toBe(titleV1); + expect(before[0].productSlug).toBe(slugV1); + expect(before[0].productSku).toBe(skuV1); + expect(before[0].unitPriceMinor).toBe(900); + expect(before[0].lineTotalMinor).toBe(900); + + const titleV2 = `${titleV1} UPDATED`; + const slugV2 = `${slugV1}-updated`; + const skuV2 = `${skuV1}-UPDATED`; + + await db + .update(products) + .set({ + title: titleV2, + slug: slugV2, + sku: skuV2, + updatedAt: new Date(), + }) + .where(eq(products.id, productId)); + + await db + .update(productPrices) + .set({ + priceMinor: 1000, + price: '10.00', + updatedAt: new Date(), + }) + .where( + and( + eq(productPrices.productId, productId), + eq(productPrices.currency, 'USD') + ) + ); + + const after = await db + .select({ + orderId: orderItems.orderId, + productId: orderItems.productId, + quantity: orderItems.quantity, + unitPriceMinor: orderItems.unitPriceMinor, + lineTotalMinor: orderItems.lineTotalMinor, + productTitle: orderItems.productTitle, + productSlug: orderItems.productSlug, + productSku: orderItems.productSku, + }) + .from(orderItems) + .where(eq(orderItems.orderId, orderId)); + + expect(after.length).toBe(1); + + expect(after[0]).toEqual(before[0]); + } catch (e) { + primaryError = e; + throw e; + } finally { + try { + await cleanupByIds({ orderId, productId }); + } catch (e) { + cleanupError = e; + console.error('[test cleanup failed]', { orderId, productId }, e); + } + } + if (!primaryError && cleanupError) { + throw cleanupError; + } + }, 30_000); +}); diff --git a/frontend/lib/tests/order-items-variants.test.ts b/frontend/lib/tests/shop/order-items-variants.test.ts similarity index 86% rename from frontend/lib/tests/order-items-variants.test.ts rename to frontend/lib/tests/shop/order-items-variants.test.ts index f23d72dd..be5f5d4b 100644 --- a/frontend/lib/tests/order-items-variants.test.ts +++ b/frontend/lib/tests/shop/order-items-variants.test.ts @@ -16,7 +16,6 @@ describe('order_items variants (selected_size/selected_color)', () => { let orderId: string | null = null; - // Arrange: create product + price row (USD) await db.insert(products).values({ id: productId, slug, @@ -27,7 +26,6 @@ describe('order_items variants (selected_size/selected_color)', () => { isActive: true, stock: 50, - // NEW: allow selected variants used below ...({ sizes: ['S', 'M'], colors: ['Red'], @@ -45,7 +43,6 @@ describe('order_items variants (selected_size/selected_color)', () => { }); try { - // Act: checkout with two variants for same productId const idem = crypto.randomUUID(); const result = await createOrderWithItems({ idempotencyKey: idem, @@ -55,14 +52,14 @@ describe('order_items variants (selected_size/selected_color)', () => { { productId, quantity: 1, - // variants: + selectedSize: 'S', selectedColor: 'Red', } as any, { productId, quantity: 1, - // variants: + selectedSize: 'M', selectedColor: 'Red', } as any, @@ -71,7 +68,6 @@ describe('order_items variants (selected_size/selected_color)', () => { orderId = result.order.id; - // Assert (API-level): should keep two lines expect(result.order.items.length).toBe(2); const norm = (v: unknown) => String(v ?? '') @@ -86,8 +82,6 @@ describe('order_items variants (selected_size/selected_color)', () => { expect(sizes.sort()).toEqual(['m', 's']); expect(colors.sort()).toEqual(['red', 'red']); - // DB-level - const rows = await db .select({ productId: orderItems.productId, @@ -108,15 +102,12 @@ describe('order_items variants (selected_size/selected_color)', () => { expect(rowKeys).toEqual([`${productId}|m|red`, `${productId}|s|red`]); } finally { - // Cleanup: delete order first (cascade deletes order_items + inventory_moves) if (orderId) { await db.delete(orders).where(eq(orders.id, orderId)); } - // Then delete product (cascade deletes product_prices) + await db.delete(products).where(eq(products.id, productId)); - // Safety: if something was left behind (shouldn't), try to clear by ids - // (No-throw best-effort) try { await db.execute( sql`delete from product_prices where product_id = ${productId}::uuid` diff --git a/frontend/lib/tests/shop/orders-access.test.ts b/frontend/lib/tests/shop/orders-access.test.ts new file mode 100644 index 00000000..ba33e0e2 --- /dev/null +++ b/frontend/lib/tests/shop/orders-access.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn(), +})); + +vi.mock('@/db', () => ({ + db: { + select: vi.fn(), + }, +})); + +import { getCurrentUser } from '@/lib/auth'; +import { db } from '@/db'; + +type MockUser = { id: string; role: 'user' | 'admin' }; + +describe('P0-SEC-1.1: GET /api/shop/orders/[id] access control', () => { + const orderId = '00000000-0000-0000-0000-000000000000'; + const ownerId = '11111111-1111-1111-1111-111111111111'; + const otherUserId = '22222222-2222-2222-2222-222222222222'; + const adminId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + + function mockDbRows(rows: any[]) { + const builder = { + from: vi.fn().mockReturnThis(), + leftJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(rows), + }; + (db.select as any).mockReturnValue(builder); + return builder; + } + + async function callGet(id: string) { + const { GET } = await import('@/app/api/shop/orders/[id]/route'); + const req = new NextRequest(`http://localhost/api/shop/orders/${id}`, { + method: 'GET', + }); + + const res = await (GET as any)(req, { params: Promise.resolve({ id }) }); + return res as Response; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('no session -> 401', async () => { + (getCurrentUser as any).mockResolvedValue(null); + + const res = await callGet(orderId); + expect(res.status).toBe(401); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('not owner and not admin -> 404 (hide existence)', async () => { + const user: MockUser = { id: otherUserId, role: 'user' }; + (getCurrentUser as any).mockResolvedValue(user); + + mockDbRows([]); + + const res = await callGet(orderId); + expect(res.status).toBe(404); + }); + + it('owner -> 200', async () => { + const user: MockUser = { id: ownerId, role: 'user' }; + (getCurrentUser as any).mockResolvedValue(user); + + const now = new Date(); + mockDbRows([ + { + order: { + id: orderId, + userId: ownerId, + totalAmount: '10.00', + currency: 'USD', + paymentStatus: 'pending', + paymentProvider: 'stripe', + paymentIntentId: null, + stockRestored: false, + restockedAt: null, + idempotencyKey: 'idem_key', + createdAt: now, + updatedAt: now, + }, + item: null, + }, + ]); + + const res = await callGet(orderId); + expect(res.status).toBe(200); + + const json = await res.json(); + expect(json?.success).toBe(true); + expect(json?.order?.id).toBe(orderId); + expect(json?.order?.userId).toBe(ownerId); + }); + + it('admin -> 200', async () => { + const user: MockUser = { id: adminId, role: 'admin' }; + (getCurrentUser as any).mockResolvedValue(user); + + const now = new Date(); + mockDbRows([ + { + order: { + id: orderId, + userId: ownerId, + totalAmount: '10.00', + currency: 'USD', + paymentStatus: 'pending', + paymentProvider: 'stripe', + paymentIntentId: null, + stockRestored: false, + restockedAt: null, + idempotencyKey: 'idem_key', + createdAt: now, + updatedAt: now, + }, + item: null, + }, + ]); + + const res = await callGet(orderId); + expect(res.status).toBe(200); + }); +}); diff --git a/frontend/lib/tests/origin-posture.test.ts b/frontend/lib/tests/shop/origin-posture.test.ts similarity index 100% rename from frontend/lib/tests/origin-posture.test.ts rename to frontend/lib/tests/shop/origin-posture.test.ts diff --git a/frontend/lib/tests/payment-state-legacy-writers.test.ts b/frontend/lib/tests/shop/payment-state-legacy-writers.test.ts similarity index 98% rename from frontend/lib/tests/payment-state-legacy-writers.test.ts rename to frontend/lib/tests/shop/payment-state-legacy-writers.test.ts index 337d9d9d..f6e109ad 100644 --- a/frontend/lib/tests/payment-state-legacy-writers.test.ts +++ b/frontend/lib/tests/shop/payment-state-legacy-writers.test.ts @@ -18,7 +18,7 @@ async function seedOrder(args: SeedArgs): Promise { const orderId = crypto.randomUUID(); const now = new Date(); - // NOTE: if your orders schema has extra NOT NULL columns, add them here once. + const idempotencyKey = `test:${orderId}`; await db.insert(orders).values({ diff --git a/frontend/lib/tests/payment-state-machine.helper.test.ts b/frontend/lib/tests/shop/payment-state-machine.helper.test.ts similarity index 91% rename from frontend/lib/tests/payment-state-machine.helper.test.ts rename to frontend/lib/tests/shop/payment-state-machine.helper.test.ts index 3cec96da..5ed27222 100644 --- a/frontend/lib/tests/payment-state-machine.helper.test.ts +++ b/frontend/lib/tests/shop/payment-state-machine.helper.test.ts @@ -35,7 +35,6 @@ vi.mock('@/db', () => ({ })); vi.mock('@/db/schema/shop', () => ({ - // minimal runtime shape needed by payment-state.ts orders: { id: 'orders.id', paymentStatus: 'orders.payment_status', @@ -86,21 +85,20 @@ describe('P1-6 payment state machine helper', () => { }); it('forbidden transition does not change state and logs warn', async () => { - // simulate "no row updated" updateReturningRows = []; - // current state returned by getCurrentState() after failed update + selectQueue = [[{ paymentStatus: 'failed', paymentProvider: 'stripe' }]]; const res = await mod.guardedPaymentStatusUpdate({ orderId: 'o2', paymentProvider: 'stripe', - to: 'paid', // failed -> paid is forbidden by matrix + to: 'paid', source: 'system', eventId: 'evt_2', note: 'test-forbidden', set: { updatedAt: new Date() } as any, }); - + expect(res.applied).toBe(false); if (res.applied) throw new Error('expected not applied'); expect(res.reason).toBe('INVALID_TRANSITION'); @@ -116,13 +114,12 @@ describe('P1-6 payment state machine helper', () => { }); it('provider=none hard-rejects invalid targets before UPDATE and logs warn', async () => { - // helper reads current state to log context selectQueue = [[{ paymentStatus: 'paid', paymentProvider: 'none' }]]; const res = await mod.guardedPaymentStatusUpdate({ orderId: 'o3', paymentProvider: 'none', - to: 'refunded', // explicitly forbidden for provider none + to: 'refunded', source: 'system', eventId: 'evt_3', note: 'test-none', diff --git a/frontend/lib/tests/payment-status-tripwire.test.ts b/frontend/lib/tests/shop/payment-status-tripwire.test.ts similarity index 80% rename from frontend/lib/tests/payment-status-tripwire.test.ts rename to frontend/lib/tests/shop/payment-status-tripwire.test.ts index 96bc0a1d..d9f76637 100644 --- a/frontend/lib/tests/payment-status-tripwire.test.ts +++ b/frontend/lib/tests/shop/payment-status-tripwire.test.ts @@ -2,19 +2,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { describe, it, expect } from 'vitest'; -/** - * Tripwire: fail if any code writes orders.paymentStatus via direct: - * - db.update(orders).set({ paymentStatus: ... }) - * - db.insert(orders).values({ paymentStatus: ... }) - * outside an explicit allowlist. - * - * Scope: repo TS/TSX files (excluding node_modules/.next/dist/etc). - * NOTE: This is NOT an AST parser. It is a scoped scan: - * - Finds `.set({ ... })` / `.values({ ... })` - * - Extracts ONLY the balanced object literal argument `{ ... }` - * - Detects `paymentStatus` only as a TOP-LEVEL key inside that object literal - */ - const REPO_ROOT = process.cwd(); const EXCLUDED_DIRS = new Set([ @@ -33,19 +20,12 @@ function norm(p: string): string { return p.replaceAll('\\', '/'); } -// Keep this allowlist very small on purpose. -// Rules are per-file and per-operation (insert(values) vs update(set)). type AllowedWriters = { set: boolean; values: boolean }; const WRITER_RULES = new Map([ - // Guarded writer (allowed): internal state machine transitions [norm('lib/services/orders/payment-state.ts'), { set: true, values: true }], - // Allowed ONLY for initial order creation (insert). Updates must stay guarded. [norm('lib/services/orders/checkout.ts'), { set: false, values: true }], - - // If you ever intentionally allow route-level inserts, add explicitly: - // [norm('app/api/shop/checkout/route.ts'), { set: false, values: true }], ]); function getAllowed(rel: string): AllowedWriters { @@ -72,10 +52,6 @@ function walk(dirAbs: string, relBase: string, out: string[]) { type ExtractedObject = { text: string; endIndex: number }; -/** - * Extracts a balanced object literal starting at `braceStart` (which MUST point to '{'). - * Handles strings and comments to avoid premature brace matching. - */ function extractBalancedObjectLiteral( src: string, braceStart: number @@ -89,14 +65,12 @@ function extractBalancedObjectLiteral( let depth = 0; - // For `${ ... }` inside template strings const templateExprDepth: number[] = []; for (let i = braceStart; i < src.length; i++) { const ch = src[i]; const next = i + 1 < src.length ? src[i + 1] : ''; - // --- comment/string handling --- if (mode() === 'line') { if (ch === '\n') stack.pop(); continue; @@ -133,18 +107,17 @@ function extractBalancedObjectLiteral( stack.pop(); continue; } - // Enter template expr `${` + if (ch === '$' && next === '{') { depth++; templateExprDepth.push(1); stack.push('code'); - i++; // skip '{' + i++; continue; } continue; } - // --- code mode --- if (ch === '/' && next === '/') { stack.push('line'); i++; @@ -168,7 +141,6 @@ function extractBalancedObjectLiteral( continue; } - // --- braces --- if (ch === '{') { depth++; if (templateExprDepth.length > 0) { @@ -182,9 +154,8 @@ function extractBalancedObjectLiteral( templateExprDepth[templateExprDepth.length - 1]--; if (templateExprDepth[templateExprDepth.length - 1] === 0) { templateExprDepth.pop(); - // return to template mode (we entered code because of `${ ... }`) if (stack.length >= 2 && stack[stack.length - 2] === 'template') { - stack.pop(); // pop 'code' + stack.pop(); } } } @@ -198,10 +169,6 @@ function extractBalancedObjectLiteral( return null; } -/** - * Returns true if the extracted `{ ... }` contains a TOP-LEVEL key `paymentStatus` - * (identifier, quoted, shorthand, or computed string literal). - */ function hasTopLevelPaymentStatusKey(objectLiteralWithBraces: string): boolean { if ( objectLiteralWithBraces.length < 2 || @@ -217,7 +184,6 @@ function hasTopLevelPaymentStatusKey(objectLiteralWithBraces: string): boolean { const stack: Mode[] = ['code']; const mode = () => stack[stack.length - 1]; - // nesting inside the object literal body (nested { [ ( ) let nest = 0; const isIdentStart = (c: string) => /[A-Za-z_$]/.test(c); @@ -250,7 +216,6 @@ function hasTopLevelPaymentStatusKey(objectLiteralWithBraces: string): boolean { const ch = body[i]; const next = i + 1 < body.length ? body[i + 1] : ''; - // --- comment/string handling --- if (mode() === 'line') { if (ch === '\n') stack.pop(); continue; @@ -290,7 +255,6 @@ function hasTopLevelPaymentStatusKey(objectLiteralWithBraces: string): boolean { continue; } - // --- code mode --- if (ch === '/' && next === '/') { stack.push('line'); i++; @@ -314,7 +278,6 @@ function hasTopLevelPaymentStatusKey(objectLiteralWithBraces: string): boolean { continue; } - // nesting if (ch === '{' || ch === '[' || ch === '(') { nest++; continue; @@ -324,12 +287,10 @@ function hasTopLevelPaymentStatusKey(objectLiteralWithBraces: string): boolean { continue; } - // only top-level keys if (nest !== 0) continue; if (ch === ',' || /\s/.test(ch)) continue; - // identifier key: paymentStatus: ... OR shorthand: paymentStatus, if (isIdentStart(ch)) { let j = i + 1; while (j < body.length && isIdentPart(body[j])) j++; @@ -345,7 +306,6 @@ function hasTopLevelPaymentStatusKey(objectLiteralWithBraces: string): boolean { continue; } - // quoted key: 'paymentStatus': or "paymentStatus": if (ch === "'" || ch === '"') { const q = readQuoted(body, i); if (q) { @@ -359,7 +319,6 @@ function hasTopLevelPaymentStatusKey(objectLiteralWithBraces: string): boolean { continue; } - // computed key: ['paymentStatus']: if (ch === '[') { let j = skipWs(body, i + 1); const qch = body[j]; @@ -402,26 +361,20 @@ function hasDirectOrdersWriter( return /\binsert\s*\(\s*orders\s*\)/m.test(chunk); }; - // Scan `.set({ ... })` / `.values({ ... })` call sites, then inspect ONLY their object literal argument. const CALL_RE = /\.(set|values)\s*\(\s*{/g; let m: RegExpExecArray | null; while ((m = CALL_RE.exec(source)) !== null) { const callKind = m[1] as 'set' | 'values'; - // Only enforce for orders table chains: - // - db.update(orders).set({ ... }) - // - db.insert(orders).values({ ... }) if (callKind === 'set' && !isOrdersUpdateContext(source, m.index)) continue; if (callKind === 'values' && !isOrdersInsertContext(source, m.index)) continue; - // `m[0]` ends with `{` due to the regex const braceStart = m.index + (m[0].length - 1); const extracted = extractBalancedObjectLiteral(source, braceStart); if (!extracted) continue; - // If this operation is explicitly allowed for this file, ignore it if (callKind === 'set' && allowed.set) { CALL_RE.lastIndex = Math.max(CALL_RE.lastIndex, extracted.endIndex); continue; @@ -433,7 +386,6 @@ function hasDirectOrdersWriter( if (hasTopLevelPaymentStatusKey(extracted.text)) return true; - // jump forward to end of this object to avoid rescanning inside it CALL_RE.lastIndex = Math.max(CALL_RE.lastIndex, extracted.endIndex); } @@ -448,7 +400,6 @@ describe('Task 6: Tripwire — no direct orders.paymentStatus writers outside al const offenders: string[] = []; for (const rel of files) { - // Skip tests entirely (they contain regex/fixtures that look like writers) if (rel.startsWith('lib/tests/')) continue; if (rel.endsWith('.test.ts') || rel.endsWith('.test.tsx')) continue; if (rel.endsWith('.spec.ts') || rel.endsWith('.spec.tsx')) continue; diff --git a/frontend/lib/tests/prices.test.ts b/frontend/lib/tests/shop/prices.test.ts similarity index 90% rename from frontend/lib/tests/prices.test.ts rename to frontend/lib/tests/shop/prices.test.ts index eed21c86..16db179f 100644 --- a/frontend/lib/tests/prices.test.ts +++ b/frontend/lib/tests/shop/prices.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { adminPriceRowSchema } from "../validation/shop"; +import { adminPriceRowSchema } from "../../validation/shop"; describe("pricing validation", () => { it("rejects originalPriceMinor == priceMinor (SALE must be strict)", () => { diff --git a/frontend/lib/tests/product-admin-merged-prices-policy.test.ts b/frontend/lib/tests/shop/product-admin-merged-prices-policy.test.ts similarity index 100% rename from frontend/lib/tests/product-admin-merged-prices-policy.test.ts rename to frontend/lib/tests/shop/product-admin-merged-prices-policy.test.ts diff --git a/frontend/lib/tests/product-admin-update-prices-patch-semantics.test.ts b/frontend/lib/tests/shop/product-admin-update-prices-patch-semantics.test.ts similarity index 100% rename from frontend/lib/tests/product-admin-update-prices-patch-semantics.test.ts rename to frontend/lib/tests/shop/product-admin-update-prices-patch-semantics.test.ts diff --git a/frontend/lib/tests/product-sale-invariant.test.ts b/frontend/lib/tests/shop/product-sale-invariant.test.ts similarity index 86% rename from frontend/lib/tests/product-sale-invariant.test.ts rename to frontend/lib/tests/shop/product-sale-invariant.test.ts index 5bb9c894..6c78cd0d 100644 --- a/frontend/lib/tests/product-sale-invariant.test.ts +++ b/frontend/lib/tests/shop/product-sale-invariant.test.ts @@ -24,7 +24,6 @@ describe('SALE invariant: originalPriceMinor is required', () => { const createdProductIds: string[] = []; afterEach(async () => { - // cleanup (cascade deletes product_prices) for (const id of createdProductIds.splice(0)) { await db.delete(products).where(eq(products.id, id)); } @@ -52,7 +51,6 @@ describe('SALE invariant: originalPriceMinor is required', () => { it('updateProduct: existing SALE + PATCH that removes originalPriceMinor must reject (final state invariant)', async () => { const slug = uniqueSlug(); - // Seed DB: product is SALE and has valid originalPriceMinor in DB const [p] = await db .insert(products) .values({ @@ -61,8 +59,8 @@ describe('SALE invariant: originalPriceMinor is required', () => { description: null, imageUrl: 'https://example.com/seed.png', imagePublicId: null, - price: toDbMoney(1000), // 10.00 - originalPrice: toDbMoney(2000), // 20.00 + price: toDbMoney(1000), + originalPrice: toDbMoney(2000), currency: 'USD', category: null, type: null, @@ -87,7 +85,6 @@ describe('SALE invariant: originalPriceMinor is required', () => { originalPrice: toDbMoney(2000), }); - // Attempt to "remove" originalPrice via PATCH (prices upsert with null originalPriceMinor) await expect( updateProduct(p.id, { prices: [ @@ -97,11 +94,9 @@ describe('SALE invariant: originalPriceMinor is required', () => { originalPriceMinor: null, }, ], - // badge omitted intentionally: finalBadge derives from existing.badge (SALE) } as any) ).rejects.toThrow(/SALE badge requires originalPrice/i); - // Ensure DB was not modified by the failed update const [pp] = await db .select({ priceMinor: productPrices.priceMinor, diff --git a/frontend/lib/tests/public-product-visibility.test.ts b/frontend/lib/tests/shop/public-product-visibility.test.ts similarity index 93% rename from frontend/lib/tests/public-product-visibility.test.ts rename to frontend/lib/tests/shop/public-product-visibility.test.ts index a05b25c7..c1960ea0 100644 --- a/frontend/lib/tests/public-product-visibility.test.ts +++ b/frontend/lib/tests/shop/public-product-visibility.test.ts @@ -53,7 +53,6 @@ describe('P0-5 Public products: inactive not visible', () => { stock: 5, sku: null, - // legacy mirror required by schema + checks price: '10.00', originalPrice: null, currency: 'USD', @@ -64,7 +63,6 @@ describe('P0-5 Public products: inactive not visible', () => { productId, currency: 'USD', - // canonical + mirror must match checks priceMinor: 1000, originalPriceMinor: null, price: '10.00', @@ -100,7 +98,6 @@ describe('P0-5 Public products: inactive not visible', () => { stock: 5, sku: null, - // legacy mirror required by schema + checks price: '19.99', originalPrice: null, currency: 'USD', @@ -111,7 +108,6 @@ describe('P0-5 Public products: inactive not visible', () => { productId, currency: 'USD', - // canonical + mirror must match checks priceMinor: 1999, originalPriceMinor: null, price: '19.99', diff --git a/frontend/lib/tests/rate-limit-subject-normalization.test.ts b/frontend/lib/tests/shop/rate-limit-subject-normalization.test.ts similarity index 100% rename from frontend/lib/tests/rate-limit-subject-normalization.test.ts rename to frontend/lib/tests/shop/rate-limit-subject-normalization.test.ts diff --git a/frontend/lib/tests/rate-limit-subject.test.ts b/frontend/lib/tests/shop/rate-limit-subject.test.ts similarity index 98% rename from frontend/lib/tests/rate-limit-subject.test.ts rename to frontend/lib/tests/shop/rate-limit-subject.test.ts index 749a8a4b..8717b7c0 100644 --- a/frontend/lib/tests/rate-limit-subject.test.ts +++ b/frontend/lib/tests/shop/rate-limit-subject.test.ts @@ -67,7 +67,6 @@ describe('rate limit subject', () => { 'x-real-ip': '198.51.100.4', }); - // cf invalid; trust disabled => must NOT accept x-real-ip expect(getClientIpFromHeaders(headers)).toBeNull(); }); diff --git a/frontend/lib/tests/restock-order-only-once.test.ts b/frontend/lib/tests/shop/restock-order-only-once.test.ts similarity index 100% rename from frontend/lib/tests/restock-order-only-once.test.ts rename to frontend/lib/tests/shop/restock-order-only-once.test.ts diff --git a/frontend/lib/tests/restock-release-failure-invariant.test.ts b/frontend/lib/tests/shop/restock-release-failure-invariant.test.ts similarity index 76% rename from frontend/lib/tests/restock-release-failure-invariant.test.ts rename to frontend/lib/tests/shop/restock-release-failure-invariant.test.ts index 3dd1b23e..865c86de 100644 --- a/frontend/lib/tests/restock-release-failure-invariant.test.ts +++ b/frontend/lib/tests/shop/restock-release-failure-invariant.test.ts @@ -24,29 +24,24 @@ describe('P0 Inventory release invariants', () => { const slug = `t-${productId.slice(0, 8)}`; const sku = `SKU-${productId.slice(0, 8)}`; - // Keep references for cleanup const now = new Date(); - // Spy/mocking: force release to fail - // NOTE: if applyReleaseMove throws in your impl, mockRejectedValueOnce is also OK. const releaseSpy = vi .spyOn(inventory, 'applyReleaseMove') .mockRejectedValue(new Error('SIMULATED_RELEASE_FAIL')); try { - // 1) Seed product + price await db.insert(products).values({ id: productId, slug, title: 'Test product', description: 'Test description', imageUrl: 'https://example.com/test.png', - imagePublicId: 'test-public-id', // додай одразу, щоб не впертись у NOT NULL, якщо він є + imagePublicId: 'test-public-id', sku, - // IMPORTANT: products.price is NOT NULL (legacy) currency: 'USD', - price: toDbMoney(1000), // 10.00 + price: toDbMoney(1000), originalPrice: null, stock: 1, @@ -59,14 +54,13 @@ describe('P0 Inventory release invariants', () => { await db.insert(productPrices).values({ productId, currency: 'USD', - // canonical minor (int) + priceMinor: 1000, - // legacy fallback (numeric) + price: 10, originalPrice: null, } as any); - // 2) Seed order + order item (minimal required fields) await db.insert(orders).values({ id: orderId, totalAmountMinor: 1000, @@ -75,10 +69,10 @@ describe('P0 Inventory release invariants', () => { paymentProvider: 'stripe', paymentIntentId: null, - paymentStatus: 'failed', // we are testing restock on "failed" path + paymentStatus: 'failed', status: 'INVENTORY_FAILED', - inventoryStatus: 'reserving', // will reserve first, then switch to release_pending + inventoryStatus: 'reserving', failureCode: 'INTERNAL_ERROR', failureMessage: 'fail before release', @@ -111,8 +105,6 @@ describe('P0 Inventory release invariants', () => { productSku: sku, } as any); - // 3) Create an ACTUAL reservation move (so “release” is реально потрібен) - // This should decrement product stock from 1 -> 0. const reserveRes = await inventory.applyReserveMove( orderId, productId, @@ -128,7 +120,6 @@ describe('P0 Inventory release invariants', () => { expect(pAfterReserve?.stock).toBe(0); - // 4) Put order into release_pending so restockOrder will attempt release await db .update(orders) .set({ @@ -138,17 +129,30 @@ describe('P0 Inventory release invariants', () => { } as any) .where(eq(orders.id, orderId)); - // 5) Call restockOrder — release fails; function may throw OR no-op, but must NOT finalize release fields + let restockErr: unknown; + try { await restockOrder(orderId, { reason: 'failed', workerId: 'test', } as any); - } catch { - // acceptable: some implementations throw for manual/admin path + } catch (err) { + restockErr = err; + } + + if (restockErr) { + // Accept ONLY the simulated release failure; rethrow anything else. + if ( + restockErr instanceof Error && + restockErr.message.includes('SIMULATED_RELEASE_FAIL') + ) { + expect(restockErr).toBeInstanceOf(Error); + expect(restockErr.message).toContain('SIMULATED_RELEASE_FAIL'); + } else { + throw restockErr; + } } - // 6) Assert invariants: order NOT finalized to released/stockRestored/restockedAt const [o] = await db .select({ inventoryStatus: orders.inventoryStatus, @@ -161,12 +165,10 @@ describe('P0 Inventory release invariants', () => { expect(o).toBeTruthy(); - // Key invariants: expect(o!.inventoryStatus).not.toBe('released'); expect(o!.stockRestored).toBe(false); expect(o!.restockedAt).toBeNull(); - // 7) Assert product stock DID NOT increment (release not confirmed) const [pAfterFail] = await db .select({ stock: products.stock }) .from(products) @@ -177,9 +179,7 @@ describe('P0 Inventory release invariants', () => { } finally { releaseSpy.mockRestore(); - // cleanup (best-effort) try { - // delete ledger rows for this order if table exists in your schema if (inventoryMoves) { await db .delete(inventoryMoves as any) diff --git a/frontend/lib/tests/restock-stale-claim-gate.test.ts b/frontend/lib/tests/shop/restock-stale-claim-gate.test.ts similarity index 89% rename from frontend/lib/tests/restock-stale-claim-gate.test.ts rename to frontend/lib/tests/shop/restock-stale-claim-gate.test.ts index 5efa9ece..389c4b86 100644 --- a/frontend/lib/tests/restock-stale-claim-gate.test.ts +++ b/frontend/lib/tests/shop/restock-stale-claim-gate.test.ts @@ -14,7 +14,7 @@ describe('restockStalePendingOrders claim gate', () => { const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); const claimNow = new Date(); - const activeExpires = new Date(Date.now() + 5 * 60 * 1000); // not expired + const activeExpires = new Date(Date.now() + 5 * 60 * 1000); try { await db.insert(orders).values({ @@ -40,7 +40,6 @@ describe('restockStalePendingOrders claim gate', () => { restockedAt: null, idempotencyKey: idem, - // claim fields: sweepClaimedAt: claimNow, sweepClaimExpiresAt: activeExpires, sweepRunId: crypto.randomUUID(), @@ -93,7 +92,7 @@ describe('restockStalePendingOrders claim gate', () => { const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); const claimNow = new Date(Date.now() - 10 * 60 * 1000); - const expiredAt = new Date(Date.now() - 5 * 60 * 1000); // expired + const expiredAt = new Date(Date.now() - 5 * 60 * 1000); try { await db.insert(orders).values({ @@ -119,7 +118,6 @@ describe('restockStalePendingOrders claim gate', () => { restockedAt: null, idempotencyKey: idem, - // expired claim fields: sweepClaimedAt: claimNow, sweepClaimExpiresAt: expiredAt, sweepRunId: crypto.randomUUID(), @@ -138,6 +136,19 @@ describe('restockStalePendingOrders claim gate', () => { }); expect(processed).toBe(1); + + const [row] = await db + .select({ + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(row).toBeTruthy(); + expect(row!.stockRestored).toBe(true); + expect(row!.restockedAt).not.toBeNull(); } finally { try { await db.delete(orders).where(eq(orders.id, orderId)); diff --git a/frontend/lib/tests/restock-stale-stripe-orphan.test.ts b/frontend/lib/tests/shop/restock-stale-stripe-orphan.test.ts similarity index 95% rename from frontend/lib/tests/restock-stale-stripe-orphan.test.ts rename to frontend/lib/tests/shop/restock-stale-stripe-orphan.test.ts index ea06b49a..36d3426c 100644 --- a/frontend/lib/tests/restock-stale-stripe-orphan.test.ts +++ b/frontend/lib/tests/shop/restock-stale-stripe-orphan.test.ts @@ -12,7 +12,6 @@ describe('P0-3.x Restock stale pending orders: stripe orphan cleanup', () => { const orderId = crypto.randomUUID(); const idem = `test-stale-orphan-stripe-${crypto.randomUUID()}`; - // Make it old enough to be considered stale. const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2h ago const totalAmountMinor = 1234; @@ -44,11 +43,10 @@ describe('P0-3.x Restock stale pending orders: stripe orphan cleanup', () => { updatedAt: createdAt, }); - // Run sweep const processed = await restockStalePendingOrders({ olderThanMinutes: 60, batchSize: 50, - orderIds: [orderId], // <-- add + orderIds: [orderId], }); expect(processed).toBeGreaterThan(0); @@ -78,7 +76,6 @@ describe('P0-3.x Restock stale pending orders: stripe orphan cleanup', () => { expect(row!.stockRestored).toBe(true); expect(row!.restockedAt).not.toBeNull(); } finally { - // cleanup try { await db.delete(orders).where(eq(orders.id, orderId)); } catch (error) { diff --git a/frontend/lib/tests/restock-stuck-reserving-sweep.test.ts b/frontend/lib/tests/shop/restock-stuck-reserving-sweep.test.ts similarity index 92% rename from frontend/lib/tests/restock-stuck-reserving-sweep.test.ts rename to frontend/lib/tests/shop/restock-stuck-reserving-sweep.test.ts index 4a4454de..5c368fc9 100644 --- a/frontend/lib/tests/restock-stuck-reserving-sweep.test.ts +++ b/frontend/lib/tests/shop/restock-stuck-reserving-sweep.test.ts @@ -8,6 +8,8 @@ import { toDbMoney } from '@/lib/shop/money'; import { applyReserveMove } from '@/lib/services/inventory'; import { restockStuckReservingOrders } from '@/lib/services/orders'; +const TWO_HOURS_MS = 2 * 60 * 60 * 1000; + function readRows(res: any): any[] { if (Array.isArray(res)) return res; if (Array.isArray(res?.rows)) return res.rows; @@ -25,7 +27,6 @@ async function countMoveKey(moveKey: string): Promise { async function cleanupTestRows(params: { orderId: string; productId: string }) { const { orderId, productId } = params; - // IMPORTANT: delete children first to avoid FK failures and silent leftovers. await db.execute( sql`delete from inventory_moves where order_id = ${orderId}::uuid` ); @@ -46,12 +47,12 @@ describe('P0-7 stuckReserving sweep: restock exactly-once', () => { const initialStock = 5; const qty = 2; - const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); // old enough + const createdAt = new Date(Date.now() - TWO_HOURS_MS); const idem = `test-stuck-${crypto.randomUUID()}`; let originalError: unknown = null; try { - await db.insert(products).values({ + const productInsert = { id: productId, title: 'Test Product', slug, @@ -64,9 +65,11 @@ describe('P0-7 stuckReserving sweep: restock exactly-once', () => { currency: 'USD', createdAt, updatedAt: createdAt, - } as any); + } satisfies typeof products.$inferInsert; + + await db.insert(products).values(productInsert); - await db.insert(orders).values({ + const orderInsert = { id: orderId, userId: null, @@ -91,7 +94,9 @@ describe('P0-7 stuckReserving sweep: restock exactly-once', () => { createdAt, updatedAt: createdAt, - } as any); + } satisfies typeof orders.$inferInsert; + + await db.insert(orders).values(orderInsert); const r = await applyReserveMove(orderId, productId, qty); expect(r.ok).toBe(true); @@ -172,14 +177,12 @@ describe('P0-7 stuckReserving sweep: restock exactly-once', () => { await cleanupTestRows({ orderId, productId }); } 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; } } diff --git a/frontend/lib/tests/restock-sweep-claim.test.ts b/frontend/lib/tests/shop/restock-sweep-claim.test.ts similarity index 100% rename from frontend/lib/tests/restock-sweep-claim.test.ts rename to frontend/lib/tests/shop/restock-sweep-claim.test.ts diff --git a/frontend/lib/tests/stripe-webhook-contract.test.ts b/frontend/lib/tests/shop/stripe-webhook-contract.test.ts similarity index 85% rename from frontend/lib/tests/stripe-webhook-contract.test.ts rename to frontend/lib/tests/shop/stripe-webhook-contract.test.ts index 4071b0c5..2be8c55e 100644 --- a/frontend/lib/tests/stripe-webhook-contract.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-contract.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { NextRequest } from 'next/server'; -/** - * Silence expected stderr noise for this contract test file only. - * We keep assertions intact; we only suppress logging side effects. - */ + vi.mock('@/lib/logging', async () => { const actual = await vi.importActual('@/lib/logging'); return { @@ -33,7 +30,6 @@ describe('P0-3.3 Stripe webhook contract: disabled vs invalid signature', () => vi.clearAllMocks(); process.env = { ...ORIG_ENV }; - // stderr (console.error) spam is expected in these negative scenarios consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -48,7 +44,7 @@ describe('P0-3.3 Stripe webhook contract: disabled vs invalid signature', () => process.env.PAYMENTS_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; - process.env.STRIPE_WEBHOOK_SECRET = ''; // ключове: після імпорту і саме порожній рядок + process.env.STRIPE_WEBHOOK_SECRET = ''; const res = await POST( makeReq('{"id":"evt_test"}', { 'stripe-signature': 't=0,v1=deadbeef' }) diff --git a/frontend/lib/tests/stripe-webhook-mismatch.test.ts b/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts similarity index 83% rename from frontend/lib/tests/stripe-webhook-mismatch.test.ts rename to frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts index 167184dc..6e76b483 100644 --- a/frontend/lib/tests/stripe-webhook-mismatch.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts @@ -55,9 +55,6 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p }); it('mismatch: does NOT set paid and stores pspStatusReason + pspMetadata(expected/actual + event id)', async () => { - /** - * 1) Create an order with payments disabled (so no Stripe network calls). - */ process.env.PAYMENTS_ENABLED = 'false'; vi.doMock('@/lib/auth', async () => { @@ -69,14 +66,12 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p }; }); - // IMPORTANT: mock stripe module BEFORE importing ANY route modules - // so webhook route doesn't cache the real module. vi.doMock('@/lib/psp/stripe', async () => { const actual = await vi.importActual('@/lib/psp/stripe'); return { __esModule: true, ...actual, - // IMPORTANT: route.ts calls this SYNC (no await). So mock MUST be sync. + verifyWebhookSignature: vi.fn((params: any) => { const rawBody = params?.rawBody; if (typeof rawBody !== 'string' || !rawBody.trim()) { @@ -86,9 +81,8 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p }), }; }); - const { POST: checkoutPOST } = await import( - '@/app/api/shop/checkout/route' - ); + const { POST: checkoutPOST } = + await import('@/app/api/shop/checkout/route'); const productId = await pickActiveProductIdForCurrency('UAH'); const idemKey = @@ -119,9 +113,6 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i ); - /** - * 2) Read expected total_amount_minor + currency from DB. - */ const row0 = (await db.execute(sql` select total_amount_minor, currency from orders @@ -136,9 +127,6 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p expect(expectedMinor).toBeGreaterThan(0); expect(expectedCurrency).toBe('UAH'); - /** - * 3) Turn this order into a "Stripe" order in DB (no real Stripe PI needed). - */ const piId = `pi_test_mismatch_${orderId.slice(0, 8)}`; await db.execute(sql` update orders @@ -148,10 +136,6 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p where id = ${orderId} `); - /** - * 4) Prepare mocked Stripe event with mismatched amount (expectedMinor + 1). - * We mock verifyWebhookSignature so we don't need real Stripe signature. - */ process.env.PAYMENTS_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = 'stripe_secret_key_placeholder'; process.env.STRIPE_WEBHOOK_SECRET = 'stripe_webhook_secret_placeholder'; @@ -175,9 +159,8 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p }, }; - const { POST: webhookPOST } = await import( - '@/app/api/shop/webhooks/stripe/route' - ); + const { POST: webhookPOST } = + await import('@/app/api/shop/webhooks/stripe/route'); const webhookRes = await webhookPOST( makeReq( @@ -189,12 +172,8 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p ) ); - // webhook handler should accept the event and record mismatch (usually 200) expect([200, 202]).toContain(webhookRes.status); - /** - * 5) Assert DB: not paid + pspStatusReason set + pspMetadata contains expected/actual + event id. - */ const row1 = (await db.execute(sql` select payment_status, psp_status_reason, psp_metadata from orders @@ -214,7 +193,7 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p expect(reason && reason.length > 0).toBe(true); const metaObj = - typeof metaRaw === 'string' ? JSON.parse(metaRaw) : metaRaw ?? {}; + typeof metaRaw === 'string' ? JSON.parse(metaRaw) : (metaRaw ?? {}); expect(metaObj?.mismatch?.eventId).toBe(evtId); expect(metaObj?.mismatch?.expected?.amountMinor).toBe(expectedMinor); diff --git a/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts b/frontend/lib/tests/shop/stripe-webhook-paid-status-repair.test.ts similarity index 91% rename from frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts rename to frontend/lib/tests/shop/stripe-webhook-paid-status-repair.test.ts index 6ed80c87..188a6704 100644 --- a/frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-paid-status-repair.test.ts @@ -15,9 +15,9 @@ async function seedOrder(params: { orderId: string; pi: string }) { totalAmount: toDbMoney(2500), currency: 'USD', paymentProvider: 'stripe', - paymentStatus: 'paid', // mismatch: already "paid" + paymentStatus: 'paid', paymentIntentId: params.pi, - status: 'INVENTORY_RESERVED', // mismatch: not "PAID" + status: 'INVENTORY_RESERVED', inventoryStatus: 'reserved', stockRestored: false, restockedAt: null, @@ -31,7 +31,6 @@ async function callWebhook(params: { pi: string; orderId: string; }) { - // Keep this pattern: reset module cache so route picks up env + mocks. vi.resetModules(); vi.doMock('@/lib/psp/stripe', async () => { @@ -55,7 +54,6 @@ async function callWebhook(params: { }; }); - // Task #5: avoid process.env mutation; use stubEnv + restore in afterEach. vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_dummy'); vi.stubEnv('STRIPE_WEBHOOK_SECRET', 'whsec_test_dummy'); @@ -68,7 +66,7 @@ async function callWebhook(params: { 'content-type': 'application/json', }, body: JSON.stringify({ id: params.eventId }), - // Node-fetch/undici інколи вимагає duplex при body; безпечно лишити як any. + duplex: 'half', } as any); @@ -121,7 +119,6 @@ describe('stripe webhook: repair paid status mismatch', () => { let lastEventId: string | null = null; afterEach(async () => { - // restore env stubs to avoid cross-test coupling vi.unstubAllEnvs(); if (lastOrderId || lastEventId) { diff --git a/frontend/lib/tests/stripe-webhook-psp-fields.test.ts b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts similarity index 91% rename from frontend/lib/tests/stripe-webhook-psp-fields.test.ts rename to frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts index bb9c41fd..f5f61ab7 100644 --- a/frontend/lib/tests/stripe-webhook-psp-fields.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts @@ -1,4 +1,3 @@ -// lib/tests/stripe-webhook-psp-fields.test.ts import { describe, it, expect, vi } from 'vitest'; import { NextRequest } from 'next/server'; import { eq } from 'drizzle-orm'; @@ -14,9 +13,8 @@ import { } from '@/db/schema'; vi.mock('@/lib/psp/stripe', async () => { - const actual = await vi.importActual>( - '@/lib/psp/stripe' - ); + const actual = + await vi.importActual>('@/lib/psp/stripe'); return { ...actual, verifyWebhookSignature: vi.fn(), @@ -39,7 +37,6 @@ function makeWebhookRequest(rawBody: string) { method: 'POST', headers: { 'Content-Type': 'application/json', - // route calls verifyWebhookSignature(rawBody, signatureHeader) 'Stripe-Signature': 't=1,v1=test', }, body: rawBody, @@ -53,7 +50,6 @@ async function cleanup(params: { }) { const { orderId, productId, eventId } = params; - // teardown must not mask assertion failures, but must surface cleanup problems try { await db.delete(stripeEvents).where(eq(stripeEvents.eventId, eventId)); } catch (e) { @@ -125,7 +121,6 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { const slug = `webhook-psp-${productId.slice(0, 8)}`; const sku = `SKU-${productId.slice(0, 8)}`; - // Seed product + price (needed because order_items FK -> products) await db.insert(products).values({ id: productId, slug, @@ -157,7 +152,6 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { originalPrice: null, }); - // Seed order in the pre-payment state that webhook expects await db.insert(orders).values({ id: orderId, totalAmountMinor: 900, @@ -166,7 +160,7 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { paymentStatus: 'requires_payment', paymentProvider: 'stripe', paymentIntentId, - // keep defaults for PSP fields; metadata default is {} + idempotencyKey: idemKey, status: 'INVENTORY_RESERVED', inventoryStatus: 'reserved', @@ -253,10 +247,8 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { expect(updated1.length).toBe(1); expect(updated1[0].paymentIntentId).toBe(paymentIntentId); - // Must be marked paid (or at minimum be terminal + PSP fields set; but your flow expects paid) expect(updated1[0].paymentStatus).toBe('paid'); - // PSP fields must be written expect(updated1[0].pspChargeId).toBe(chargeId); expect(typeof updated1[0].pspPaymentMethod).toBe('string'); expect((updated1[0].pspPaymentMethod ?? '').length).toBeGreaterThan(0); @@ -266,14 +258,12 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { 'succeeded' ); - // pspMetadata must not stay "{}" expect(updated1[0].pspMetadata).toBeTruthy(); expect( Object.keys((updated1[0].pspMetadata ?? {}) as Record) .length ).toBeGreaterThan(0); - // stripe_events must record the eventId once const ev1 = await db .select({ eventId: stripeEvents.eventId }) .from(stripeEvents) @@ -281,7 +271,6 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { expect(ev1.length).toBe(1); - // Duplicate delivery must not break (idempotency / dedupe) const req2 = makeWebhookRequest(rawBody); const res2 = await webhookPOST(req2); expect(res2.status).toBeGreaterThanOrEqual(200); diff --git a/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts b/frontend/lib/tests/shop/stripe-webhook-rate-limit-env.test.ts similarity index 97% rename from frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts rename to frontend/lib/tests/shop/stripe-webhook-rate-limit-env.test.ts index fa14b0b4..0c8251fa 100644 --- a/frontend/lib/tests/stripe-webhook-rate-limit-env.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-rate-limit-env.test.ts @@ -65,7 +65,7 @@ describe('stripe webhook rate limit env precedence', () => { it('supports partial config (field-by-field): missing_sig MAX can override while WINDOW falls back', () => { vi.stubEnv('STRIPE_WEBHOOK_MISSING_SIG_RL_MAX', '66'); - vi.stubEnv('STRIPE_WEBHOOK_RL_WINDOW_SECONDS', '555'); // fallback source for window + vi.stubEnv('STRIPE_WEBHOOK_RL_WINDOW_SECONDS', '555'); expect(resolveStripeWebhookRateLimit('missing_sig')).toEqual({ max: 66, diff --git a/frontend/lib/tests/stripe-webhook-refund-full.test.ts b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts similarity index 89% rename from frontend/lib/tests/stripe-webhook-refund-full.test.ts rename to frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts index 8b81bc82..48fa6789 100644 --- a/frontend/lib/tests/stripe-webhook-refund-full.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts @@ -1,5 +1,3 @@ -// frontend/lib/tests/stripe-webhook-refund-full.test.ts - import crypto from 'node:crypto'; import { eq } from 'drizzle-orm'; import { NextRequest } from 'next/server'; @@ -7,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type Stripe from 'stripe'; vi.mock('@/lib/psp/stripe', async () => { - const actual = await vi.importActual( - '@/lib/psp/stripe' - ); + const actual = + await vi.importActual( + '@/lib/psp/stripe' + ); return { ...actual, @@ -123,8 +122,6 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded beforeEach(async () => { vi.clearAllMocks(); - // restockOrder mocked: we don't retest inventory ledger here (it is covered by restock tests), - // we only assert webhook triggers it exactly-once and marks order as restocked. restockOrderMock.mockImplementation(async (orderId: string) => { await db .update(orders) @@ -182,7 +179,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded .limit(1); expect(row1.paymentStatus).toBe('refunded'); - expect(row1.status).toBe('CANCELED'); // terminal status per current enum + expect(row1.status).toBe('CANCELED'); expect(row1.stockRestored).toBe(true); expect(restockOrderMock).toHaveBeenCalledTimes(1); @@ -190,7 +187,6 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded reason: 'refunded', }); - // 2nd call with SAME event.id -> dedupe => no side effects const res2 = await POST(makeRequest()); expect(res2.status).toBe(200); @@ -217,12 +213,11 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded amount: 2500, status: 'succeeded', reason: null, - charge: chargeId, // IMPORTANT: real Stripe shape is usually string id + charge: chargeId, payment_intent: inserted.paymentIntentId, metadata: {}, }; - // Webhook code should retrieve the charge by id to get cumulative refunded, etc. retrieveChargeMock.mockResolvedValue( makeCharge({ chargeId, @@ -256,7 +251,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.paymentStatus).toBe('refunded'); expect(row.status).toBe('CANCELED'); expect(row.stockRestored).toBe(true); - expect(row.pspChargeId).toBe(chargeId); // requires PATCH 1 in webhook + expect(row.pspChargeId).toBe(chargeId); expect(retrieveChargeMock).toHaveBeenCalledTimes(1); expect(retrieveChargeMock).toHaveBeenCalledWith(chargeId); @@ -279,7 +274,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded chargeId, paymentIntentId: inserted.paymentIntentId, amount: 2500, - amountRefunded: 1000, // partial + amountRefunded: 1000, refunds: [{ id: refundId, amount: 1000 }], }); @@ -330,7 +325,6 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded data: { object: charge }, } as unknown as Stripe.Event); - // first call: restock throws => webhook returns 500 restockOrderMock .mockImplementationOnce(async () => { throw new Error('RESTOCK_FAILED'); @@ -350,7 +344,6 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded const res1 = await POST(makeRequest()); expect(res1.status).toBe(500); - // same eventId retry: MUST reprocess (processedAt is still NULL) and restock succeeds const res2 = await POST(makeRequest()); expect(res2.status).toBe(200); @@ -387,19 +380,17 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded const refund2Id = `re_${crypto.randomUUID()}`; 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: chargeId, // IMPORTANT: real Stripe shape is usually string id + charge: chargeId, payment_intent: inserted.paymentIntentId, metadata: {}, }; - // Charge says cumulative refunded is FULL (2500), but refund.amount is only 500. retrieveChargeMock.mockResolvedValue( makeCharge({ chargeId, @@ -454,14 +445,13 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded chargeId, paymentIntentId: inserted.paymentIntentId, amount: 2500, - amountRefunded: 2500, // will be deleted to force fallback + amountRefunded: 2500, refunds: [ { id: refund1Id, amount: 1000 }, { id: refund2Id, amount: 1500 }, ], }); - // force edge-case: Stripe object without amount_refunded delete (charge as any).amount_refunded; verifyWebhookSignatureMock.mockReturnValue({ @@ -502,14 +492,12 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded chargeId, paymentIntentId: inserted.paymentIntentId, amount: 2500, - amountRefunded: 0, // will be deleted to force "missing" - refunds: [], // empty list => refund object unavailable (refund == null) + amountRefunded: 0, + refunds: [], }); - // Edge-case: Stripe object WITHOUT amount_refunded delete (charge as any).amount_refunded; - // Ensure refunds list is present but empty (covers "refunds.data = []") (charge as any).refunds = { object: 'list', data: [] }; verifyWebhookSignatureMock.mockReturnValue({ @@ -523,11 +511,9 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded const res = await POST(makeRequest()); expect(res.status).toBe(500); - // If you kept the explicit diagnostic mapping: const body = await res.json(); expect(body).toEqual({ code: 'REFUND_FULLNESS_UNDETERMINED' }); - // stripe_events.processed_at must remain NULL (no ack -> Stripe retries) const [evt] = await db .select({ processedAt: stripeEvents.processedAt, @@ -542,7 +528,6 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(evt.processedAt).toBeNull(); expect(evt.paymentIntentId).toBe(inserted.paymentIntentId); - // Order must NOT change (safe no-op) const [row] = await db .select({ paymentStatus: orders.paymentStatus, @@ -561,7 +546,6 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(restockOrderMock).toHaveBeenCalledTimes(0); - // Optional: assert warning fired (locks observability behavior) expect(warnSpy).toHaveBeenCalled(); const firstArg = warnSpy.mock.calls[0]?.[0]; @@ -577,7 +561,6 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(parsed.level).toBe('warn'); expect(parsed.msg).toBe('stripe_webhook_refund_fullness_undetermined'); - // (опційно, але корисно: зафіксувати reason) expect(parsed.meta?.reason).toBe( 'missing_amount_refunded_and_empty_refunds_list' ); diff --git a/frontend/lib/types/shop.ts b/frontend/lib/types/shop.ts index 25b3773c..5f387c18 100644 --- a/frontend/lib/types/shop.ts +++ b/frontend/lib/types/shop.ts @@ -30,14 +30,12 @@ export type OrderSummary = z.infer & { export type OrderDetail = OrderSummary; export type OrderSummaryWithMinor = OrderSummary & { - // canonical money model (minor units) totalAmountMinor: number; }; export type CheckoutResult = { order: OrderSummaryWithMinor; isNew: boolean; - // legacy name (keep for back-compat in client/tests) totalCents: number; }; diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 9ba5c181..9fd07e20 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -99,8 +99,6 @@ export const dbProductSchema = z.object({ .string() .nullish() .transform(value => value ?? undefined), - // NOTE: DB shape is still "price/originalPrice" here (whatever your query returns). - // You convert to minor in the mapper (fromDbMoney), so we keep these permissive. price: z.coerce.number(), originalPrice: z.coerce .number() @@ -137,7 +135,6 @@ export const dbProductSchema = z.object({ updatedAt: z.coerce.date(), }); -// IMPORTANT: shopProduct.price/originalPrice are MINOR units (integers). export const shopProductSchema = z.object({ id: z.string(), slug: z.string(), @@ -166,7 +163,6 @@ const booleanFromString = z.preprocess(value => { return undefined; }, z.boolean().optional()); -// Money in MINOR units (integers) const moneyMinor = z.number().int().min(0); const moneyMinorPositive = z.number().int().min(1); @@ -177,7 +173,6 @@ export const adminPriceRowSchema = z originalPriceMinor: moneyMinor.optional().nullable(), }) .superRefine((v, ctx) => { - // priceMinor already validated > 0 if (v.originalPriceMinor != null) { if (!Number.isFinite(v.originalPriceMinor)) { ctx.addIssue({ @@ -239,10 +234,8 @@ export const productAdminSchema = z isFeatured: booleanFromString.default(false), }) .superRefine((data, ctx) => { - // 1) no duplicate currencies refineNoDuplicateCurrencies(data.prices, ctx); - // 2) USD is required const usd = data.prices.find(p => p.currency === 'USD'); if (!usd) { ctx.addIssue({ @@ -251,7 +244,7 @@ export const productAdminSchema = z message: 'USD price is required', }); } - // 3) SALE badge requires compare-at/original price for every provided currency + if (data.badge === 'SALE') { data.prices.forEach((p, idx) => { if (p.originalPriceMinor == null) { @@ -358,10 +351,10 @@ export const cartRehydratedItemSchema = z.object({ slug: z.string(), title: z.string(), quantity: z.number().int().min(1).max(MAX_QUANTITY_PER_LINE), - // canonical: + unitPriceMinor: moneyMinorPositive, lineTotalMinor: moneyMinor, - // display/legacy: + unitPrice: z.number().min(0), lineTotal: z.number().min(0), @@ -381,9 +374,8 @@ export const cartRehydrateResultSchema = z.object({ items: z.array(cartRehydratedItemSchema), removed: z.array(cartRemovedItemSchema), summary: z.object({ - // canonical: totalAmountMinor: moneyMinor, - // display/legacy: + totalAmount: z.number().min(0), itemCount: z.number().int().min(0), currency: currencySchema, @@ -396,9 +388,9 @@ export const orderIdParamSchema = z.object({ export const orderSummarySchema = z.object({ id: z.string().uuid(), - // canonical: + totalAmountMinor: moneyMinor, - // display/legacy: + totalAmount: z.number().min(0), currency: currencySchema, paymentStatus: paymentStatusSchema, @@ -414,10 +406,10 @@ export const orderSummarySchema = z.object({ productTitle: z.string(), productSlug: z.string(), quantity: z.number().int().min(1), - // canonical: + unitPriceMinor: moneyMinorPositive, lineTotalMinor: moneyMinor, - // display/legacy: + unitPrice: z.number().min(0), lineTotal: z.number().min(0), }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 03972cb6..d372edd7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1210,9 +1210,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.10.0.tgz", - "integrity": "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", + "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", "dev": true, "license": "MIT", "engines": { @@ -9660,8 +9660,9 @@ "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", - "extraneous": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.8.0" }