diff --git a/frontend/.env.example b/frontend/.env.example index 44ee9b03..059329d3 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -162,6 +162,10 @@ GMAIL_USER= # Set explicitly in production to avoid incorrect absolute URLs. SHOP_BASE_URL= +# Optional public seller address used by the Shop seller-information legal page. +# Leave empty to keep the current placeholder-based public rendering until launch data is finalized. +SHOP_SELLER_ADDRESS= + # Policy/consent version labels used by Shop flows. SHOP_PRIVACY_VERSION=privacy-v1 SHOP_TERMS_VERSION=terms-v1 diff --git a/frontend/app/[locale]/shop/products/[slug]/page.tsx b/frontend/app/[locale]/shop/products/[slug]/page.tsx index bfdf008d..56ab69e7 100644 --- a/frontend/app/[locale]/shop/products/[slug]/page.tsx +++ b/frontend/app/[locale]/shop/products/[slug]/page.tsx @@ -5,6 +5,7 @@ import { getMessages, getTranslations } from 'next-intl/server'; import { AddToCartButton } from '@/components/shop/AddToCartButton'; import { ProductGallery } from '@/components/shop/ProductGallery'; +import { SizeGuideAccordion } from '@/components/shop/SizeGuideAccordion'; import { Link } from '@/i18n/routing'; import { getStorefrontAvailabilityState } from '@/lib/shop/availability'; import { formatMoney } from '@/lib/shop/currency'; @@ -39,7 +40,7 @@ export default async function ProductPage({ const commerceProduct = result.kind === 'available' ? result.commerceProduct : null; const availabilityState = getStorefrontAvailabilityState(commerceProduct); - const sizeGuide = getApparelSizeGuideForProduct(commerceProduct, locale); + const sizeGuide = getApparelSizeGuideForProduct(product, locale); const galleryImages = getProductGalleryImages(product); const NAV_LINK = cn( @@ -146,6 +147,12 @@ export default async function ProductPage({ ); })()} + {commerceProduct === null && sizeGuide ? ( +
+ +
+ ) : null} + {commerceProduct ? (
= { - CHECKOUT_PRICE_CHANGED: 409, - CHECKOUT_SHIPPING_CHANGED: 409, - MISSING_SHIPPING_ADDRESS: 400, - INVALID_SHIPPING_ADDRESS: 400, + CHECKOUT_PRICE_CHANGED: 422, + CHECKOUT_SHIPPING_CHANGED: 422, + MISSING_SHIPPING_ADDRESS: 422, + INVALID_SHIPPING_ADDRESS: 422, SHIPPING_METHOD_UNAVAILABLE: 422, SHIPPING_CURRENCY_UNSUPPORTED: 422, SHIPPING_AMOUNT_UNAVAILABLE: 422, }; +const CHECKOUT_UNPROCESSABLE_ERROR_CODES = new Set([ + 'INVALID_PAYLOAD', + 'DISCOUNTS_NOT_SUPPORTED', + 'INVALID_VARIANT', + 'INSUFFICIENT_STOCK', + 'OUT_OF_STOCK', + 'CHECKOUT_PRICE_CHANGED', + 'CHECKOUT_SHIPPING_CHANGED', + 'PRICE_CONFIG_ERROR', + 'MISSING_SHIPPING_ADDRESS', + 'INVALID_SHIPPING_ADDRESS', + 'SHIPPING_METHOD_UNAVAILABLE', + 'SHIPPING_CURRENCY_UNSUPPORTED', + 'SHIPPING_AMOUNT_UNAVAILABLE', + 'LEGAL_CONSENT_REQUIRED', + 'TERMS_NOT_ACCEPTED', + 'PRIVACY_NOT_ACCEPTED', + 'TERMS_VERSION_REQUIRED', + 'PRIVACY_VERSION_REQUIRED', + 'TERMS_VERSION_MISMATCH', + 'PRIVACY_VERSION_MISMATCH', + 'IDEMPOTENCY_CONFLICT', +]); + const STATUS_TOKEN_SCOPES_STATUS_ONLY: readonly StatusTokenScope[] = [ 'status_lite', ]; @@ -143,6 +178,10 @@ function shippingErrorStatus(code: string): number | null { return SHIPPING_ERROR_STATUS_MAP[code] ?? null; } +function checkoutUnprocessableStatus(code: string): number | null { + return CHECKOUT_UNPROCESSABLE_ERROR_CODES.has(code) ? 422 : null; +} + function parseRequestedProvider( raw: unknown ): CheckoutRequestedProvider | 'invalid' | null { @@ -213,11 +252,112 @@ function getErrorMessage(error: unknown, fallback: string): string { return fallback; } -function hasLegalConsentValidationIssue(issues: Array<{ path?: unknown[] }>) { - return issues.some( - issue => Array.isArray(issue.path) && issue.path[0] === 'legalConsent' - ); -} +const routeCheckoutRequestedProviderSchema = z.enum(['stripe', 'monobank']); +const routePricingFingerprintSchema = z + .string() + .trim() + .length(64) + .regex(/^[a-f0-9]{64}$/); + +const routeCheckoutLegalConsentSchema = z.preprocess( + value => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + return { + termsAccepted: + typeof record.termsAccepted === 'boolean' + ? record.termsAccepted + : undefined, + privacyAccepted: + typeof record.privacyAccepted === 'boolean' + ? record.privacyAccepted + : undefined, + termsVersion: + typeof record.termsVersion === 'string' + ? record.termsVersion + : undefined, + privacyVersion: + typeof record.privacyVersion === 'string' + ? record.privacyVersion + : undefined, + }; + }, + z + .object({ + termsAccepted: z.boolean().optional(), + privacyAccepted: z.boolean().optional(), + termsVersion: z.string().trim().min(1).max(64).optional(), + privacyVersion: z.string().trim().min(1).max(64).optional(), + }) + .strict() + .optional() +); + +const checkoutRoutePayloadSchema = z + .object({ + items: z.array(checkoutItemSchema).min(1), + userId: z.string().uuid().optional(), + country: z + .string() + .trim() + .length(2) + .transform(value => value.toUpperCase()) + .optional(), + shipping: checkoutShippingSchema.optional(), + legalConsent: routeCheckoutLegalConsentSchema, + pricingFingerprint: routePricingFingerprintSchema.optional(), + shippingQuoteFingerprint: routePricingFingerprintSchema.optional(), + paymentProvider: routeCheckoutRequestedProviderSchema.optional(), + paymentMethod: paymentMethodSchema.optional(), + paymentCurrency: currencySchema.optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (!value.paymentMethod) return; + + if (!value.paymentProvider) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['paymentProvider'], + message: 'paymentProvider is required when paymentMethod is provided', + }); + return; + } + + const provider = value.paymentProvider; + const method = value.paymentMethod; + + const providerAllowed = + (method === 'stripe_card' && provider === 'stripe') || + ((method === 'monobank_invoice' || method === 'monobank_google_pay') && + provider === 'monobank'); + + if (!providerAllowed) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['paymentMethod'], + message: + 'paymentMethod is not allowed for the selected paymentProvider', + }); + return; + } + + if ( + (method === 'monobank_invoice' || method === 'monobank_google_pay') && + value.paymentCurrency != null && + value.paymentCurrency !== 'UAH' + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['paymentMethod'], + message: + 'paymentMethod is not allowed for the selected provider and currency', + }); + } + }); function isMonobankInvalidRequestError(error: unknown): boolean { const code = getErrorCode(error); @@ -240,22 +380,34 @@ function isMonobankInvalidRequestError(error: unknown): boolean { function mapMonobankCheckoutError(error: unknown) { const code = getErrorCode(error); - if (code) { - const status = shippingErrorStatus(code); - if (status) { - return { - code, - message: getErrorMessage(error, 'Invalid request.'), - status, - } as const; - } + if (error instanceof PriceConfigError || code === 'PRICE_CONFIG_ERROR') { + return { + code: 'PRICE_CONFIG_ERROR', + message: getErrorMessage(error, 'Price configuration error.'), + status: 422, + details: + error instanceof PriceConfigError + ? { + productId: error.productId, + currency: error.currency, + } + : undefined, + } as const; } - if (isMonobankInvalidRequestError(error)) { + if ( + error instanceof IdempotencyConflictError || + code === 'IDEMPOTENCY_CONFLICT' + ) { return { - code: 'INVALID_REQUEST', - message: getErrorMessage(error, 'Invalid request.'), - status: 400, + code: 'CHECKOUT_IDEMPOTENCY_CONFLICT', + message: + error instanceof IdempotencyConflictError + ? error.message + : 'Checkout idempotency conflict.', + status: 422, + details: + error instanceof IdempotencyConflictError ? error.details : undefined, } as const; } @@ -265,24 +417,29 @@ function mapMonobankCheckoutError(error: unknown) { code === 'OUT_OF_STOCK' ) { return { - code: 'OUT_OF_STOCK', + code: 'INSUFFICIENT_STOCK', message: getErrorMessage(error, 'Insufficient stock.'), - status: 409, + status: 422, } as const; } - if (error instanceof PriceConfigError || code === 'PRICE_CONFIG_ERROR') { + if (code) { + const status = + checkoutUnprocessableStatus(code) ?? shippingErrorStatus(code); + if (status) { + return { + code, + message: getErrorMessage(error, 'Invalid request.'), + status, + } as const; + } + } + + if (isMonobankInvalidRequestError(error)) { return { - code: 'PRICE_CONFIG_ERROR', - message: getErrorMessage(error, 'Price configuration error.'), - status: 400, - details: - error instanceof PriceConfigError - ? { - productId: error.productId, - currency: error.currency, - } - : undefined, + code: 'INVALID_REQUEST', + message: getErrorMessage(error, 'Invalid request.'), + status: 422, } as const; } @@ -298,22 +455,6 @@ function mapMonobankCheckoutError(error: unknown) { } as const; } - if ( - error instanceof IdempotencyConflictError || - code === 'IDEMPOTENCY_CONFLICT' - ) { - return { - code: 'CHECKOUT_IDEMPOTENCY_CONFLICT', - message: - error instanceof IdempotencyConflictError - ? error.message - : 'Checkout idempotency conflict.', - status: 409, - details: - error instanceof IdempotencyConflictError ? error.details : undefined, - } as const; - } - return { code: 'CHECKOUT_FAILED', message: 'Unable to process checkout.', @@ -875,18 +1016,33 @@ export async function POST(request: NextRequest) { } const storefrontCurrency = resolveStandardStorefrontCurrency(); - const providerCapabilities = - resolveStandardStorefrontProviderCapabilities(); - const stripeCheckoutAvailable = - providerCapabilities.stripeCheckoutEnabled; + const providerCapabilities = resolveStandardStorefrontProviderCapabilities(); + const stripeCheckoutAvailable = providerCapabilities.stripeCheckoutEnabled; const monobankCheckoutAvailable = providerCapabilities.monobankCheckoutEnabled; const checkoutProviderCandidates = resolveStandardStorefrontCheckoutProviderCandidates({ - requestedProvider, - requestedMethod, - }); + requestedProvider, + requestedMethod, + }); + + if ( + requestedProvider && + requestedMethod && + checkoutProviderCandidates.length === 0 + ) { + if (requestedProvider === 'monobank') { + return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); + } + + return errorResponse( + 'PAYMENTS_METHOD_INVALID', + 'Invalid payment method.', + 422 + ); + } + const selectedProvider = checkoutProviderCandidates.find(candidate => candidate === 'stripe' @@ -983,8 +1139,7 @@ export async function POST(request: NextRequest) { method: selectedMethod, currency: selectedCurrency, flags: { - monobankGooglePayEnabled: - providerCapabilities.monobankGooglePayEnabled, + monobankGooglePayEnabled: providerCapabilities.monobankGooglePayEnabled, }, }) ) { @@ -1025,29 +1180,15 @@ export async function POST(request: NextRequest) { return errorResponse( 'DISCOUNTS_NOT_SUPPORTED', 'Discounts are not available at checkout.', - 400, + 422, { fields: unsupportedDiscountFields } ); } - const parsedPayload = checkoutPayloadSchema.safeParse(payloadForValidation); + const parsedPayload = + checkoutRoutePayloadSchema.safeParse(payloadForValidation); if (!parsedPayload.success) { - if (hasLegalConsentValidationIssue(parsedPayload.error.issues ?? [])) { - logWarn('checkout_legal_consent_required', { - ...meta, - code: 'LEGAL_CONSENT_REQUIRED', - issuesCount: parsedPayload.error.issues?.length ?? 0, - }); - - return errorResponse( - 'LEGAL_CONSENT_REQUIRED', - 'Explicit legal consent is required before checkout.', - 400, - parsedPayload.error.format() - ); - } - if (selectedProvider === 'monobank') { logWarn('checkout_invalid_request', { ...meta, @@ -1058,7 +1199,7 @@ export async function POST(request: NextRequest) { return errorResponse( 'INVALID_REQUEST', 'Invalid request.', - 400, + 422, parsedPayload.error.format() ); } @@ -1072,7 +1213,7 @@ export async function POST(request: NextRequest) { return errorResponse( 'INVALID_PAYLOAD', 'Invalid checkout payload', - 400, + 422, parsedPayload.error.format() ); } @@ -1209,7 +1350,9 @@ export async function POST(request: NextRequest) { locale, country: country ?? null, shipping: shipping ?? null, - legalConsent, + legalConsent: legalConsent as Parameters< + typeof createOrderWithItems + >[0]['legalConsent'], pricingFingerprint, shippingQuoteFingerprint, requirePricingFingerprint: true, @@ -1237,7 +1380,9 @@ export async function POST(request: NextRequest) { locale, country: country ?? null, shipping: shipping ?? null, - legalConsent, + legalConsent: legalConsent as Parameters< + typeof createOrderWithItems + >[0]['legalConsent'], pricingFingerprint, shippingQuoteFingerprint, requirePricingFingerprint: true, @@ -1653,17 +1798,19 @@ export async function POST(request: NextRequest) { } if (error instanceof InvalidPayloadError) { - const customStatus = shippingErrorStatus(error.code); + const customStatus = + checkoutUnprocessableStatus(error.code) ?? + shippingErrorStatus(error.code); return errorResponse( error.code, error.message || 'Invalid checkout payload', - customStatus ?? 400, + customStatus ?? 422, error.details ); } if (error instanceof InvalidVariantError) { - return errorResponse(error.code, error.message, 400, { + return errorResponse(error.code, error.message, 422, { productId: error.productId, field: error.field, value: error.value, @@ -1672,7 +1819,7 @@ export async function POST(request: NextRequest) { } if (error instanceof IdempotencyConflictError) { - return errorResponse(error.code, error.message, 409, error.details); + return errorResponse(error.code, error.message, 422, error.details); } if (error instanceof OrderStateInvalidError) { @@ -1685,7 +1832,7 @@ export async function POST(request: NextRequest) { } if (error instanceof PriceConfigError) { - return errorResponse(error.code, error.message, 400, { + return errorResponse(error.code, error.message, 422, { productId: error.productId, currency: error.currency, }); @@ -1702,8 +1849,18 @@ export async function POST(request: NextRequest) { ); } - if (error instanceof InsufficientStockError) { - return errorResponse('INSUFFICIENT_STOCK', error.message, 409); + if ( + error instanceof InsufficientStockError || + getErrorCode(error) === 'INSUFFICIENT_STOCK' || + getErrorCode(error) === 'OUT_OF_STOCK' + ) { + return errorResponse( + 'INSUFFICIENT_STOCK', + error instanceof InsufficientStockError + ? error.message + : getErrorMessage(error, 'Insufficient stock.'), + 422 + ); } if (error instanceof MoneyValueError) { diff --git a/frontend/app/api/shop/internal/monobank/janitor/route.ts b/frontend/app/api/shop/internal/monobank/janitor/route.ts index d1bb935f..11a9fee0 100644 --- a/frontend/app/api/shop/internal/monobank/janitor/route.ts +++ b/frontend/app/api/shop/internal/monobank/janitor/route.ts @@ -14,6 +14,8 @@ import { runMonobankJanitorJob4, } from '@/lib/services/orders/monobank-janitor'; +export const runtime = 'nodejs'; + const ROUTE_PATH = '/api/shop/internal/monobank/janitor' as const; const JOB_PREFIX = 'monobank-janitor:' as const; const JOB_NAMES = ['job1', 'job2', 'job3', 'job4'] as const; diff --git a/frontend/app/api/shop/orders/[id]/payment/init/route.ts b/frontend/app/api/shop/orders/[id]/payment/init/route.ts index 77b75c75..9227e202 100644 --- a/frontend/app/api/shop/orders/[id]/payment/init/route.ts +++ b/frontend/app/api/shop/orders/[id]/payment/init/route.ts @@ -20,6 +20,8 @@ import { orderPaymentInitPayloadSchema, } from '@/lib/validation/shop'; +export const runtime = 'nodejs'; + function noStoreJson(body: unknown, init?: { status?: number }) { const res = NextResponse.json(body, { status: init?.status ?? 200 }); res.headers.set('Cache-Control', 'no-store'); diff --git a/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts b/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts index c43997e3..bf83aed1 100644 --- a/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts +++ b/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts @@ -31,6 +31,8 @@ import { readOrderPaymentRow, } from '../../_shared'; +export const runtime = 'nodejs'; + type SubmitPayload = { gToken: string; }; diff --git a/frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts b/frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts index 6d2f2eeb..10a32aac 100644 --- a/frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts +++ b/frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts @@ -18,6 +18,8 @@ import { orderIdParamSchema } from '@/lib/validation/shop'; import { ensureMonobankPayableOrder, noStoreJson, readOrderPaymentRow } from '../_shared'; +export const runtime = 'nodejs'; + function resolveStatusToken(orderId: string, statusToken: string | null): string { const normalized = statusToken?.trim() ?? ''; if (normalized) return normalized; diff --git a/frontend/app/api/shop/webhooks/monobank/route.ts b/frontend/app/api/shop/webhooks/monobank/route.ts index 072640da..26eab42b 100644 --- a/frontend/app/api/shop/webhooks/monobank/route.ts +++ b/frontend/app/api/shop/webhooks/monobank/route.ts @@ -28,6 +28,7 @@ import { } from '@/lib/services/orders/monobank-retry'; import { handleMonobankWebhook } from '@/lib/services/orders/monobank-webhook'; +export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; type WebhookMode = 'drop' | 'store' | 'apply'; diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 2427a840..d62c6035 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -27,6 +27,8 @@ import { finalizeStripeRefundSuccess, restoreStripeRefundFailure, } from '@/lib/services/orders/stripe-refund-reconciliation'; +export const runtime = 'nodejs'; + import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; import { orderShippingEligibilityWhereSql } from '@/lib/services/shop/shipping/eligibility'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; @@ -70,6 +72,20 @@ function busyRetry() { return res; } +function terminalSuccessConflictRetry() { + const res = noStoreJson( + { + code: 'TERMINAL_SUCCESS_CONFLICT_BLOCKED', + retryAfterSeconds: STRIPE_EVENT_RETRY_AFTER_SECONDS, + }, + { + status: 503, + headers: { 'Retry-After': String(STRIPE_EVENT_RETRY_AFTER_SECONDS) }, + } + ); + return res; +} + export function OPTIONS(request: NextRequest) { const blocked = guardNonBrowserOnly(request); if (blocked) { @@ -444,6 +460,56 @@ function readDbRows(res: unknown): T[] { return Array.isArray(anyRes?.rows) ? (anyRes.rows as T[]) : []; } +type StripeOrderPaymentSnapshot = { + id: string; + paymentStatus: typeof orders.$inferSelect.paymentStatus; + status: typeof orders.$inferSelect.status; + pspStatusReason: string | null; + pspMetadata: Record; +}; + +async function readStripeOrderPaymentSnapshot( + orderId: string +): Promise { + const [row] = await db + .select({ + id: orders.id, + paymentStatus: orders.paymentStatus, + status: orders.status, + pspStatusReason: orders.pspStatusReason, + pspMetadata: orders.pspMetadata, + }) + .from(orders) + .where(and(eq(orders.id, orderId), eq(orders.paymentProvider, 'stripe'))) + .limit(1); + + return row ?? null; +} + +function resolveTerminalSuccessConflictSource( + order: Pick +): 'failed' | 'refunded' | null { + if (order.paymentStatus === 'failed' || order.paymentStatus === 'refunded') { + return order.paymentStatus; + } + + if ( + order.paymentStatus === 'needs_review' && + order.pspStatusReason === 'late_success_after_failed' + ) { + return 'failed'; + } + + if ( + order.paymentStatus === 'needs_review' && + order.pspStatusReason === 'late_success_after_refunded' + ) { + return 'refunded'; + } + + return null; +} + type StripePaidApplyArgs = { now: Date; orderId: string; @@ -1274,6 +1340,129 @@ export async function POST(request: NextRequest) { await applyStripePaidAndQueueShipmentAtomic(appliedArgs); if (!applyResult.applied) { + const latestOrder = await readStripeOrderPaymentSnapshot(order.id); + const conflictFrom = latestOrder + ? resolveTerminalSuccessConflictSource(latestOrder) + : null; + + if (latestOrder && conflictFrom) { + const conflictReason = `late_success_after_${conflictFrom}` as const; + const conflictMeta = mergePspMetadata({ + prevMeta: latestOrder.pspMetadata, + delta: buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent ?? undefined, + extra: { + ...(walletAttribution ? { wallet: walletAttribution } : {}), + outOfOrderSuccess: { + eventId: event.id, + fromPaymentStatus: conflictFrom, + orderStatus: latestOrder.status, + paymentIntentId, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + }, + }, + }), + eventId: event.id, + currency: order.currency, + createdAtIso, + }); + + let reviewTransitionResult: Awaited< + ReturnType + > | null = null; + + const reviewAlreadyPresent = + latestOrder.paymentStatus === 'needs_review'; + + if (!reviewAlreadyPresent) { + reviewTransitionResult = await guardedPaymentStatusUpdate({ + orderId: order.id, + paymentProvider: 'stripe', + to: 'needs_review', + source: 'stripe_webhook', + eventId: event.id, + note: conflictReason, + set: { + updatedAt: now, + pspChargeId: latestChargeId ?? chargeForIntent?.id ?? null, + pspPaymentMethod: appliedArgs.pspPaymentMethod, + pspStatusReason: conflictReason, + pspMetadata: conflictMeta, + }, + }); + } + + const reviewApplied = + reviewAlreadyPresent || reviewTransitionResult?.applied === true; + const reviewBlockedReason = + reviewTransitionResult && !reviewTransitionResult.applied + ? reviewTransitionResult.reason + : null; + + if (!reviewApplied) { + logWarn('stripe_webhook_terminal_success_conflict_blocked', { + ...eventMeta(), + code: 'TERMINAL_SUCCESS_CONFLICT_BLOCKED', + orderId: order.id, + paymentIntentId, + pspChargeId: appliedArgs.pspChargeId, + pspPaymentMethod: appliedArgs.pspPaymentMethod, + paymentStatus: latestOrder.paymentStatus, + status: latestOrder.status, + conflictFrom, + reviewReason: reviewBlockedReason, + }); + + await db + .update(stripeEvents) + .set({ claimExpiresAt: new Date(0) }) + .where( + and( + eq(stripeEvents.eventId, event.id), + eq(stripeEvents.claimedBy, STRIPE_WEBHOOK_INSTANCE_ID), + isNull(stripeEvents.processedAt) + ) + ); + + return terminalSuccessConflictRetry(); + } + + logWarn('stripe_webhook_terminal_success_conflict', { + ...eventMeta(), + code: 'TERMINAL_SUCCESS_CONFLICT', + orderId: order.id, + paymentIntentId, + pspChargeId: appliedArgs.pspChargeId, + pspPaymentMethod: appliedArgs.pspPaymentMethod, + paymentStatus: latestOrder.paymentStatus, + status: latestOrder.status, + conflictFrom, + reviewApplied: true, + reviewReason: null, + }); + + await markStripeAttemptFinal({ + paymentIntentId, + status: 'succeeded', + errorCode: 'TERMINAL_ORDER_STATE_CONFLICT', + errorMessage: `payment_intent.succeeded_after_${conflictFrom}`, + }); + + logWebhookEvent({ + requestId, + stripeEventId, + orderId: order.id, + paymentIntentId, + paymentStatus, + eventType, + chargeId: latestChargeId ?? chargeForIntent?.id ?? null, + }); + + return ack(); + } + logWarn('stripe_webhook_paid_apply_noop', { ...eventMeta(), code: 'PAID_APPLY_NOOP', @@ -1281,8 +1470,8 @@ export async function POST(request: NextRequest) { paymentIntentId, pspChargeId: appliedArgs.pspChargeId, pspPaymentMethod: appliedArgs.pspPaymentMethod, - paymentStatus: order.paymentStatus, - status: order.status, + paymentStatus: latestOrder?.paymentStatus ?? order.paymentStatus, + status: latestOrder?.status ?? order.status, reason: 'applyResult.applied=false', }); } diff --git a/frontend/components/shop/AddToCartButton.tsx b/frontend/components/shop/AddToCartButton.tsx index e73951ca..a7062364 100644 --- a/frontend/components/shop/AddToCartButton.tsx +++ b/frontend/components/shop/AddToCartButton.tsx @@ -4,12 +4,7 @@ import { Check, Minus, Plus } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useId, useState } from 'react'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion'; +import { SizeGuideAccordion } from '@/components/shop/SizeGuideAccordion'; import type { ApparelSizeGuide } from '@/lib/shop/size-guide'; import { SHOP_CHIP_HOVER, @@ -164,80 +159,7 @@ export function AddToCartButton({ product, sizeGuide }: AddToCartButtonProps) { {sizeGuide ? ( - - - - {sizeGuide.label} - - -
-

- {sizeGuide.title} -

-

- {sizeGuide.intro} -

-

- {sizeGuide.measurementNote} -

-
- -
    - {sizeGuide.fitNotes.map(note => ( -
  • {note}
  • - ))} -
- -
-

- {sizeGuide.chart.caption} -

- -
- - - - - - - - - - - {sizeGuide.chart.rows.map(row => ( - - - - - - ))} - -
- {sizeGuide.chart.caption} -
- {sizeGuide.chart.columns.size} - - {sizeGuide.chart.columns.chestWidth} - - {sizeGuide.chart.columns.bodyLength} -
- {row.size} - - {row.chestWidthCm} {sizeGuide.chart.unit} - - {row.bodyLengthCm} {sizeGuide.chart.unit} -
-
-
-
-
-
+ ) : null}
+ + + {sizeGuide.label} + + +
+

{sizeGuide.title}

+

{sizeGuide.intro}

+

+ {sizeGuide.measurementNote} +

+
+ +
    + {sizeGuide.fitNotes.map(note => ( +
  • {note}
  • + ))} +
+ +
+

+ {sizeGuide.chart.caption} +

+ +
+ + + + + + + + + + + {sizeGuide.chart.rows.map(row => ( + + + + + + ))} + +
{sizeGuide.chart.caption}
+ {sizeGuide.chart.columns.size} + + {sizeGuide.chart.columns.chestWidth} + + {sizeGuide.chart.columns.bodyLength} +
+ {row.size} + + {row.chestWidthCm} {sizeGuide.chart.unit} + + {row.bodyLengthCm} {sizeGuide.chart.unit} +
+
+
+
+
+ + ); +} diff --git a/frontend/lib/env/index.ts b/frontend/lib/env/index.ts index eceb0b7f..c48bdbe2 100644 --- a/frontend/lib/env/index.ts +++ b/frontend/lib/env/index.ts @@ -53,6 +53,7 @@ export const serverEnvSchema = z.object({ .optional() .default('false'), SHOP_SHIPPING_RETENTION_DAYS: z.string().optional().default('180'), + SHOP_SELLER_ADDRESS: z.string().min(1).optional(), SHOP_TERMS_VERSION: z.string().min(1).optional().default('terms-v1'), SHOP_PRIVACY_VERSION: z.string().min(1).optional().default('privacy-v1'), diff --git a/frontend/lib/env/shop-critical.ts b/frontend/lib/env/shop-critical.ts new file mode 100644 index 00000000..b4e63fdb --- /dev/null +++ b/frontend/lib/env/shop-critical.ts @@ -0,0 +1,100 @@ +import 'server-only'; + +import { resolveShopBaseUrl } from '@/lib/shop/url'; + +import { getMonobankEnv } from './monobank'; +import { getNovaPoshtaConfig } from './nova-poshta'; +import { readServerEnv } from './server-env'; +import { getStripeEnv } from './stripe'; + +function nonEmpty(value: string | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isFlagEnabled(value: string | undefined): boolean { + const normalized = (value ?? '').trim().toLowerCase(); + return ( + normalized === 'true' || + normalized === '1' || + normalized === 'yes' || + normalized === 'on' + ); +} + +function requireEnv(name: string, message?: string): string { + const value = nonEmpty(readServerEnv(name)); + if (!value) { + throw new Error(message ?? `Missing env var: ${name}`); + } + return value; +} + +export function assertCriticalShopEnv(): void { + const appEnv = nonEmpty(readServerEnv('APP_ENV'))?.toLowerCase() ?? null; + const databaseUrl = nonEmpty(readServerEnv('DATABASE_URL')); + const databaseUrlLocal = nonEmpty(readServerEnv('DATABASE_URL_LOCAL')); + + if (appEnv === 'local') { + if (!databaseUrlLocal) { + throw new Error('[env] APP_ENV=local requires DATABASE_URL_LOCAL.'); + } + } else if (!databaseUrl) { + throw new Error('[env] DATABASE_URL is required outside local APP_ENV.'); + } + + requireEnv('AUTH_SECRET', 'AUTH_SECRET is not defined'); + + const statusSecret = requireEnv( + 'SHOP_STATUS_TOKEN_SECRET', + 'SHOP_STATUS_TOKEN_SECRET is not configured' + ); + if (statusSecret.length < 32) { + throw new Error('SHOP_STATUS_TOKEN_SECRET must be at least 32 characters.'); + } + + const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED')); + if (paymentsEnabled) { + const stripeFlag = nonEmpty(readServerEnv('STRIPE_PAYMENTS_ENABLED')); + const stripeEnabled = stripeFlag !== 'false'; + + if (stripeEnabled) { + requireEnv( + 'STRIPE_SECRET_KEY', + '[env] PAYMENTS_ENABLED requires STRIPE_SECRET_KEY for the Stripe rail.' + ); + requireEnv( + 'STRIPE_WEBHOOK_SECRET', + '[env] PAYMENTS_ENABLED requires STRIPE_WEBHOOK_SECRET for the Stripe rail.' + ); + getStripeEnv(); + } + + const monobankToken = nonEmpty(readServerEnv('MONO_MERCHANT_TOKEN')); + const monobankRequested = + stripeFlag === 'false' || + !!monobankToken || + isFlagEnabled(readServerEnv('MONO_REFUND_ENABLED')) || + isFlagEnabled(readServerEnv('SHOP_MONOBANK_GPAY_ENABLED')); + + if (monobankRequested) { + if (!monobankToken) { + throw new Error( + '[env] PAYMENTS_ENABLED requires MONO_MERCHANT_TOKEN when the Stripe rail is disabled or Monobank features are enabled.' + ); + } + + resolveShopBaseUrl(); + getMonobankEnv(); + } + } + + const shippingEnabled = + isFlagEnabled(readServerEnv('SHOP_SHIPPING_ENABLED')) && + isFlagEnabled(readServerEnv('SHOP_SHIPPING_NP_ENABLED')); + + if (shippingEnabled) { + getNovaPoshtaConfig(); + } +} diff --git a/frontend/lib/legal/public-seller-information.ts b/frontend/lib/legal/public-seller-information.ts index 4365b086..b39e3c92 100644 --- a/frontend/lib/legal/public-seller-information.ts +++ b/frontend/lib/legal/public-seller-information.ts @@ -24,7 +24,7 @@ export function getPublicSellerInformation(): PublicSellerInformation { const sellerName = nonEmpty(process.env.NP_SENDER_NAME); const supportPhone = nonEmpty(process.env.NP_SENDER_PHONE); const supportEmail = getPublicSupportEmail(); - const address = null; + const address = nonEmpty(process.env.SHOP_SELLER_ADDRESS); const edrpou = nonEmpty(process.env.NP_SENDER_EDRPOU); const businessDetails = edrpou ? [{ label: 'EDRPOU', value: edrpou }] : []; diff --git a/frontend/lib/services/errors.ts b/frontend/lib/services/errors.ts index 73b9cb7a..48c57aa4 100644 --- a/frontend/lib/services/errors.ts +++ b/frontend/lib/services/errors.ts @@ -29,15 +29,21 @@ export class OrderNotFoundError extends Error { export class InvalidPayloadError extends Error { code: string; + field?: string; details?: Record; constructor( message = 'Invalid payload', - opts?: { code?: string; details?: Record } + opts?: { + code?: string; + field?: string; + details?: Record; + } ) { super(message); this.name = 'InvalidPayloadError'; this.code = opts?.code ?? 'INVALID_PAYLOAD'; + this.field = opts?.field; this.details = opts?.details; } } diff --git a/frontend/lib/services/orders/_shared.ts b/frontend/lib/services/orders/_shared.ts index 542304a9..1570829b 100644 --- a/frontend/lib/services/orders/_shared.ts +++ b/frontend/lib/services/orders/_shared.ts @@ -191,6 +191,12 @@ export function hashIdempotencyRequest(params: { methodCode: 'NP_WAREHOUSE' | 'NP_LOCKER' | 'NP_COURIER'; cityRef: string; warehouseRef: string | null; + recipient: { + fullName: string; + phone: string; + email: string | null; + comment: string | null; + }; } | null; legalConsent: { termsAccepted: boolean; diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index 4180a904..3c951f84 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -1,7 +1,6 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import { db } from '@/db'; -import { coercePriceFromDb } from '@/db/queries/shop/orders'; import { npCities, npWarehouses, @@ -18,7 +17,10 @@ import { NovaPoshtaConfigError, } from '@/lib/env/nova-poshta'; import { readServerEnv } from '@/lib/env/server-env'; +import { assertCriticalShopEnv } from '@/lib/env/shop-critical'; +import { getShopLegalVersions } from '@/lib/env/shop-legal'; import { logError, logWarn } from '@/lib/logging'; +import { writeCanonicalEventWithRetry } from '@/lib/services/shop/events/write-canonical-event-with-retry'; import { writePaymentEvent } from '@/lib/services/shop/events/write-payment-event'; import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability'; import { @@ -109,15 +111,16 @@ async function writeOrderCreatedCanonicalEvent( async function ensureOrderCreatedCanonicalEvent( order: OrderSummaryWithMinor ): Promise { - try { - await writeOrderCreatedCanonicalEvent(order); - } catch (error) { - logWarn('checkout_order_created_event_write_failed', { - orderId: order.id, - code: 'ORDER_CREATED_EVENT_WRITE_FAILED', - message: error instanceof Error ? error.message : String(error), - }); - } + await writeCanonicalEventWithRetry({ + write: () => writeOrderCreatedCanonicalEvent(order), + onFinalFailure: error => { + logWarn('checkout_order_created_event_write_failed', { + orderId: order.id, + code: 'ORDER_CREATED_EVENT_WRITE_FAILED', + message: error instanceof Error ? error.message : String(error), + }); + }, + }); } async function getProductsForCheckout( @@ -139,8 +142,6 @@ async function getProductsForCheckout( priceMinor: productPrices.priceMinor, - price: productPrices.price, - originalPrice: productPrices.originalPrice, priceCurrency: productPrices.currency, isActive: products.isActive, @@ -210,6 +211,12 @@ type PreparedShipping = { methodCode: CheckoutShippingMethodCode; cityRef: string; warehouseRef: string | null; + recipient: { + fullName: string; + phone: string; + email: string | null; + comment: string | null; + }; } | null; orderSummary: { shippingRequired: boolean; @@ -243,6 +250,13 @@ type PreparedLegalConsent = { const CHECKOUT_LEGAL_CONSENT_REPLAY_GRACE_MS = 30_000; +function normalizeOptionalRecipientText( + raw: string | null | undefined +): string | null { + const normalized = raw?.trim() ?? ''; + return normalized.length > 0 ? normalized : null; +} + function requireLegalConsentVersion( raw: string | undefined, field: 'termsVersion' | 'privacyVersion' @@ -269,6 +283,83 @@ function isWithinLegalConsentReplayGraceWindow(createdAt: Date): boolean { ); } +function resolveRequestedCheckoutLegalConsentHashRefs(args: { + legalConsent: CheckoutLegalConsentInput | null | undefined; +}): PreparedLegalConsent['hashRefs'] { + if (args.legalConsent == null) { + throw new InvalidPayloadError( + 'Explicit legal consent is required before checkout.', + { + code: 'LEGAL_CONSENT_REQUIRED', + } + ); + } + + if (!args.legalConsent.termsAccepted) { + throw new InvalidPayloadError('Terms must be accepted before checkout.', { + code: 'TERMS_NOT_ACCEPTED', + }); + } + + if (!args.legalConsent.privacyAccepted) { + throw new InvalidPayloadError('Privacy policy must be accepted.', { + code: 'PRIVACY_NOT_ACCEPTED', + }); + } + + return { + termsAccepted: true, + privacyAccepted: true, + termsVersion: requireLegalConsentVersion( + args.legalConsent.termsVersion, + 'termsVersion' + ), + privacyVersion: requireLegalConsentVersion( + args.legalConsent.privacyVersion, + 'privacyVersion' + ), + }; +} + +function buildPreparedLegalConsentSnapshot(args: { + hashRefs: PreparedLegalConsent['hashRefs']; + locale: string | null | undefined; + country: string | null | undefined; + consentedAt?: Date; +}): PreparedLegalConsent['snapshot'] { + return { + termsAccepted: true, + privacyAccepted: true, + termsVersion: args.hashRefs.termsVersion, + privacyVersion: args.hashRefs.privacyVersion, + consentedAt: args.consentedAt ?? new Date(), + source: 'checkout_explicit', + locale: normVariant(args.locale).toLowerCase() || null, + country: normalizeCountryCode( + args.country ?? resolveStandardStorefrontShippingCountry() + ), + }; +} + +function resolveRequestedCheckoutShippingHashRefs( + shipping: CheckoutShippingInput | null | undefined +): PreparedShipping['hashRefs'] { + if (!shipping) return null; + + return { + provider: 'nova_poshta', + methodCode: shipping.methodCode, + cityRef: shipping.selection.cityRef, + warehouseRef: shipping.selection.warehouseRef ?? null, + recipient: { + fullName: shipping.recipient.fullName.trim(), + phone: shipping.recipient.phone.trim(), + email: normalizeOptionalRecipientText(shipping.recipient.email), + comment: normalizeOptionalRecipientText(shipping.recipient.comment), + }, + }; +} + function normalizeCountryCode(raw: string | null | undefined): string | null { const normalized = (raw ?? '').trim().toUpperCase(); if (normalized.length !== 2) return null; @@ -290,6 +381,21 @@ function readShippingRefFromSnapshot( return normalized.length > 0 ? normalized : null; } +function readShippingRecipientFieldFromSnapshot( + value: unknown, + field: 'fullName' | 'phone' | 'email' | 'comment' +): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const recipient = (value as { recipient?: unknown }).recipient; + if (!recipient || typeof recipient !== 'object' || Array.isArray(recipient)) { + return null; + } + const raw = (recipient as Record)[field]; + if (typeof raw !== 'string') return null; + const normalized = raw.trim(); + return normalized.length > 0 ? normalized : null; +} + function shippingValidationCodeFromAvailability(reasonCode: string): { code: 'SHIPPING_METHOD_UNAVAILABLE' | 'SHIPPING_CURRENCY_UNSUPPORTED'; message: string; @@ -538,8 +644,8 @@ async function prepareCheckoutShipping(args: { recipient: { fullName: args.shipping.recipient.fullName, phone: args.shipping.recipient.phone, - email: args.shipping.recipient.email ?? null, - comment: args.shipping.recipient.comment ?? null, + email: normalizeOptionalRecipientText(args.shipping.recipient.email), + comment: normalizeOptionalRecipientText(args.shipping.recipient.comment), }, }; @@ -550,6 +656,14 @@ async function prepareCheckoutShipping(args: { methodCode, cityRef, warehouseRef: warehouse?.ref ?? warehouseRef ?? null, + recipient: { + fullName: args.shipping.recipient.fullName.trim(), + phone: args.shipping.recipient.phone.trim(), + email: normalizeOptionalRecipientText(args.shipping.recipient.email), + comment: normalizeOptionalRecipientText( + args.shipping.recipient.comment + ), + }, }, orderSummary: { shippingRequired: true, @@ -568,63 +682,34 @@ function resolveCheckoutLegalConsent(args: { locale: string | null | undefined; country: string | null | undefined; }): PreparedLegalConsent { - if (args.legalConsent == null) { + const hashRefs = resolveRequestedCheckoutLegalConsentHashRefs(args); + const canonicalLegalVersions = getShopLegalVersions(); + + if (hashRefs.termsVersion !== canonicalLegalVersions.termsVersion) { throw new InvalidPayloadError( - 'Explicit legal consent is required before checkout.', + 'Terms version is outdated. Refresh and try again.', { - code: 'LEGAL_CONSENT_REQUIRED', + code: 'TERMS_VERSION_MISMATCH', } ); } - const termsAccepted = args.legalConsent.termsAccepted; - const privacyAccepted = args.legalConsent.privacyAccepted; - - if (!termsAccepted) { - throw new InvalidPayloadError('Terms must be accepted before checkout.', { - code: 'TERMS_NOT_ACCEPTED', - }); - } - - if (!privacyAccepted) { - throw new InvalidPayloadError('Privacy policy must be accepted.', { - code: 'PRIVACY_NOT_ACCEPTED', - }); + if (hashRefs.privacyVersion !== canonicalLegalVersions.privacyVersion) { + throw new InvalidPayloadError( + 'Privacy version is outdated. Refresh and try again.', + { + code: 'PRIVACY_VERSION_MISMATCH', + } + ); } - const termsVersion = requireLegalConsentVersion( - args.legalConsent.termsVersion, - 'termsVersion' - ); - const privacyVersion = requireLegalConsentVersion( - args.legalConsent.privacyVersion, - 'privacyVersion' - ); - - const consentedAt = new Date(); - const source = 'checkout_explicit'; - const normalizedLocale = normVariant(args.locale).toLowerCase() || null; - const normalizedCountry = normalizeCountryCode( - args.country ?? resolveStandardStorefrontShippingCountry() - ); - return { - hashRefs: { - termsAccepted: true, - privacyAccepted: true, - termsVersion, - privacyVersion, - }, - snapshot: { - termsAccepted: true, - privacyAccepted: true, - termsVersion, - privacyVersion, - consentedAt, - source, - locale: normalizedLocale, - country: normalizedCountry, - }, + hashRefs, + snapshot: buildPreparedLegalConsentSnapshot({ + hashRefs, + locale: args.locale, + country: args.country, + }), }; } @@ -680,38 +765,16 @@ function priceItems( if (!product) { throw new InvalidPayloadError('Some products are unavailable.'); } - if ( - !product.priceCurrency || - (product.priceMinor == null && product.price == null) - ) { + if (!product.priceCurrency || product.priceMinor == null) { throw new PriceConfigError('Price not configured for currency.', { productId: product.id, currency, }); } - let unitPriceCents: number | null = null; - if (product.priceMinor !== null && product.priceMinor !== undefined) { - if ( - !isStrictNonNegativeInt(product.priceMinor) || - product.priceMinor <= 0 - ) { - throw new InvalidPayloadError('Product pricing is misconfigured.'); - } - unitPriceCents = product.priceMinor; - } - if (unitPriceCents == null) { - const unitPrice = coercePriceFromDb(product.price, { - field: 'price', - productId: product.id, - }); - if (unitPrice <= 0) { - throw new InvalidPayloadError('Product pricing is misconfigured.'); - } - unitPriceCents = Math.round(unitPrice * 100); - } + const unitPriceCents = product.priceMinor; - if (unitPriceCents <= 0) { + if (!isStrictNonNegativeInt(unitPriceCents) || unitPriceCents <= 0) { throw new InvalidPayloadError('Product pricing is misconfigured.'); } @@ -858,6 +921,8 @@ export async function createOrderWithItems({ paymentProvider?: PaymentProvider; paymentMethod?: PaymentMethod | null; }): Promise { + assertCriticalShopEnv(); + if (requestedProvider === 'none') { throw new InvalidPayloadError('paymentProvider "none" is not supported.', { code: 'INVALID_PAYLOAD', @@ -867,12 +932,12 @@ export async function createOrderWithItems({ const storefrontCurrency: Currency = resolveStandardStorefrontCurrency(); const checkoutProviderCandidates = resolveStandardStorefrontCheckoutProviderCandidates({ - requestedProvider: - requestedProvider === 'stripe' || requestedProvider === 'monobank' - ? requestedProvider - : null, - requestedMethod, - }); + requestedProvider: + requestedProvider === 'stripe' || requestedProvider === 'monobank' + ? requestedProvider + : null, + requestedMethod, + }); const paymentProvider: PaymentProvider = checkoutProviderCandidates[0] ?? 'stripe'; const currency: Currency = storefrontCurrency; @@ -887,29 +952,21 @@ export async function createOrderWithItems({ const normalizedItems = mergeCheckoutItems(items).map(item => normalizeCheckoutItem(item) ); - - const preparedShipping = await prepareCheckoutShipping({ - shipping: shipping ?? null, - locale, - country: country ?? null, - currency, - shippingQuoteFingerprint, - requireShippingQuoteFingerprint, - }); - const preparedLegalConsent = resolveCheckoutLegalConsent({ - legalConsent, - locale, - country: country ?? null, - }); - + const requestedShippingHashRefs = resolveRequestedCheckoutShippingHashRefs( + shipping ?? null + ); + const requestedLegalConsentHashRefs = + resolveRequestedCheckoutLegalConsentHashRefs({ + legalConsent, + }); const requestHash = hashIdempotencyRequest({ items: normalizedItems, currency, locale: locale ?? null, paymentProvider, paymentMethod: resolvedPaymentMethod, - shipping: preparedShipping.hashRefs, - legalConsent: preparedLegalConsent.hashRefs, + shipping: requestedShippingHashRefs, + legalConsent: requestedLegalConsentHashRefs, }); async function assertIdempotencyCompatible(existing: OrderSummaryWithMinor) { @@ -965,7 +1022,12 @@ export async function createOrderWithItems({ if (canRepairMissingLegalConsent) { await ensureOrderLegalConsentSnapshot({ orderId: row.id, - snapshot: preparedLegalConsent.snapshot, + snapshot: buildPreparedLegalConsentSnapshot({ + hashRefs: requestedLegalConsentHashRefs, + locale, + country: country ?? null, + consentedAt: existing.createdAt, + }), }); [existingLegalConsentRow] = await db @@ -999,6 +1061,26 @@ export async function createOrderWithItems({ existingShippingRow?.shippingAddress, 'warehouseRef' ); + const existingRecipient = { + fullName: + readShippingRecipientFieldFromSnapshot( + existingShippingRow?.shippingAddress, + 'fullName' + ) ?? '', + phone: + readShippingRecipientFieldFromSnapshot( + existingShippingRow?.shippingAddress, + 'phone' + ) ?? '', + email: readShippingRecipientFieldFromSnapshot( + existingShippingRow?.shippingAddress, + 'email' + ), + comment: readShippingRecipientFieldFromSnapshot( + existingShippingRow?.shippingAddress, + 'comment' + ), + }; const existingLegalHashRefs = { termsAccepted: existingLegalConsentRow.termsAccepted, privacyAccepted: existingLegalConsentRow.privacyAccepted, @@ -1008,19 +1090,19 @@ export async function createOrderWithItems({ if ( existingLegalHashRefs.termsAccepted !== - preparedLegalConsent.hashRefs.termsAccepted || + requestedLegalConsentHashRefs.termsAccepted || existingLegalHashRefs.privacyAccepted !== - preparedLegalConsent.hashRefs.privacyAccepted || + requestedLegalConsentHashRefs.privacyAccepted || existingLegalHashRefs.termsVersion !== - preparedLegalConsent.hashRefs.termsVersion || + requestedLegalConsentHashRefs.termsVersion || existingLegalHashRefs.privacyVersion !== - preparedLegalConsent.hashRefs.privacyVersion + requestedLegalConsentHashRefs.privacyVersion ) { throw new IdempotencyConflictError( 'Idempotency key already used with different legal consent.', { existing: existingLegalHashRefs, - requested: preparedLegalConsent.hashRefs, + requested: requestedLegalConsentHashRefs, } ); } @@ -1079,6 +1161,7 @@ export async function createOrderWithItems({ methodCode: row.shippingMethodCode, cityRef: existingCityRef, warehouseRef: existingWarehouseRef, + recipient: existingRecipient, } : null, legalConsent: existingLegalHashRefs, @@ -1166,12 +1249,6 @@ export async function createOrderWithItems({ const existing = await getOrderByIdempotencyKey(db, idempotencyKey); if (existing) { await assertIdempotencyCompatible(existing); - if (preparedShipping.required && preparedShipping.snapshot) { - await ensureOrderShippingSnapshot({ - orderId: existing.id, - snapshot: preparedShipping.snapshot, - }); - } await ensureOrderCreatedCanonicalEvent(existing); return { order: existing, @@ -1179,6 +1256,16 @@ export async function createOrderWithItems({ totalCents: requireTotalCents(existing), }; } + + const preparedShipping = await prepareCheckoutShipping({ + shipping: shipping ?? null, + locale, + country: country ?? null, + currency, + shippingQuoteFingerprint, + requireShippingQuoteFingerprint, + }); + const uniqueProductIds = Array.from( new Set(normalizedItems.map(i => i.productId)) ); @@ -1272,6 +1359,12 @@ export async function createOrderWithItems({ } } + const preparedLegalConsent = resolveCheckoutLegalConsent({ + legalConsent, + locale, + country: country ?? null, + }); + const itemsSubtotalCents = sumLineTotals( pricedItems.map(i => i.lineTotalCents) ); diff --git a/frontend/lib/services/orders/restock.ts b/frontend/lib/services/orders/restock.ts index 25526b92..0a610fba 100644 --- a/frontend/lib/services/orders/restock.ts +++ b/frontend/lib/services/orders/restock.ts @@ -6,6 +6,7 @@ import { db } from '@/db'; import { inventoryMoves, orders } from '@/db/schema/shop'; import { logWarn } from '@/lib/logging'; import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { writeCanonicalEventWithRetry } from '@/lib/services/shop/events/write-canonical-event-with-retry'; import { writePaymentEvent } from '@/lib/services/shop/events/write-payment-event'; import { closeShippingPipelineForOrder } from '@/lib/services/shop/shipping/pipeline-shutdown'; import { isOrderNonPaymentStatusTransitionAllowed } from '@/lib/services/shop/transitions/order-state'; @@ -111,6 +112,11 @@ type OrderCanceledNotificationState = Pick< | 'shippingStatus' >; +type RestockFinalizeState = Pick< + OrderRow, + 'status' | 'inventoryStatus' | 'stockRestored' | 'paymentStatus' +>; + async function loadOrderCanceledNotificationState( orderId: string ): Promise { @@ -143,6 +149,37 @@ function buildOrderCanceledEventDedupeKey(orderId: string): string { }); } +function isRestockReasonAlreadyFinalized( + state: RestockFinalizeState, + reason: RestockReason | undefined +): boolean { + if (reason === 'canceled') { + return ( + state.status === 'CANCELED' && + state.inventoryStatus === 'released' && + state.stockRestored + ); + } + + if (reason === 'failed' || reason === 'stale') { + return ( + state.status === 'INVENTORY_FAILED' && + state.inventoryStatus === 'released' && + state.stockRestored + ); + } + + if (reason === 'refunded') { + return ( + state.paymentStatus === 'refunded' && + state.inventoryStatus === 'released' && + state.stockRestored + ); + } + + return false; +} + async function ensureOrderCanceledCanonicalEvent(args: { orderId: string; ensuredBy: string; @@ -157,36 +194,38 @@ async function ensureOrderCanceledCanonicalEvent(args: { return; } - try { - await writePaymentEvent({ - orderId: state.id, - provider: resolvePaymentProvider(state), - eventName: 'order_canceled', - eventSource: 'order_restock', - eventRef: null, - amountMinor: state.totalAmountMinor, - currency: state.currency, - payload: { + await writeCanonicalEventWithRetry({ + write: () => + writePaymentEvent({ orderId: state.id, - totalAmountMinor: state.totalAmountMinor, + provider: resolvePaymentProvider(state), + eventName: 'order_canceled', + eventSource: 'order_restock', + eventRef: null, + amountMinor: state.totalAmountMinor, currency: state.currency, - paymentProvider: state.paymentProvider, - paymentStatus: state.paymentStatus, - orderStatus: state.status, - inventoryStatus: state.inventoryStatus, - shippingStatus: state.shippingStatus, - restockedAt: state.restockedAt?.toISOString() ?? null, + payload: { + orderId: state.id, + totalAmountMinor: state.totalAmountMinor, + currency: state.currency, + paymentProvider: state.paymentProvider, + paymentStatus: state.paymentStatus, + orderStatus: state.status, + inventoryStatus: state.inventoryStatus, + shippingStatus: state.shippingStatus, + restockedAt: state.restockedAt?.toISOString() ?? null, + ensuredBy: args.ensuredBy, + }, + dedupeKey: buildOrderCanceledEventDedupeKey(state.id), + }).then(() => undefined), + onFinalFailure: error => { + logWarn('order_canceled_event_write_failed', { + orderId: args.orderId, ensuredBy: args.ensuredBy, - }, - dedupeKey: buildOrderCanceledEventDedupeKey(state.id), - }); - } catch (error) { - logWarn('order_canceled_event_write_failed', { - orderId: args.orderId, - ensuredBy: args.ensuredBy, - error: error instanceof Error ? error.message : String(error), - }); - } + error: error instanceof Error ? error.message : String(error), + }); + }, + }); } export async function restockOrder( @@ -310,6 +349,27 @@ export async function restockOrder( .returning({ id: orders.id }); if (!touched) { + const [latest] = await db + .select({ + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + paymentStatus: orders.paymentStatus, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + if (latest && isRestockReasonAlreadyFinalized(latest, reason)) { + if (reason === 'canceled') { + await ensureOrderCanceledCanonicalEvent({ + orderId, + ensuredBy: 'restock_replay', + }); + } + return; + } + throw new OrderStateInvalidError( `Cannot finalize orphan restock due to concurrent order state change.`, { @@ -471,6 +531,27 @@ export async function restockOrder( .returning({ id: orders.id }); if (!finalized) { + const [latest] = await db + .select({ + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + paymentStatus: orders.paymentStatus, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + if (latest && isRestockReasonAlreadyFinalized(latest, reason)) { + if (reason === 'canceled') { + await ensureOrderCanceledCanonicalEvent({ + orderId, + ensuredBy: 'restock_replay', + }); + } + return; + } + throw new OrderStateInvalidError( `Cannot finalize restock due to concurrent order state change.`, { diff --git a/frontend/lib/services/products/cart/rehydrate.ts b/frontend/lib/services/products/cart/rehydrate.ts index b153bee2..7218da9d 100644 --- a/frontend/lib/services/products/cart/rehydrate.ts +++ b/frontend/lib/services/products/cart/rehydrate.ts @@ -1,13 +1,12 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; -import { coercePriceFromDb } from '@/db/queries/shop/orders'; import { productPrices, products } from '@/db/schema'; import { logWarn } from '@/lib/logging'; import { createCartItemKey } from '@/lib/shop/cart-item-key'; import { createCheckoutPricingFingerprint } from '@/lib/shop/checkout-pricing'; import { type CurrencyCode, isTwoDecimalCurrency } from '@/lib/shop/currency'; -import { calculateLineTotal, fromCents, toCents } from '@/lib/shop/money'; +import { calculateLineTotal, fromCents } from '@/lib/shop/money'; import type { CartClientItem, CartRehydrateItem, @@ -122,7 +121,6 @@ export async function rehydrateCartItems( colors: products.colors, sizes: products.sizes, priceMinor: productPrices.priceMinor, - price: productPrices.price, priceCurrency: productPrices.currency, }) .from(products) @@ -166,50 +164,40 @@ export async function rehydrateCartItems( continue; } - if ( - !product.priceCurrency || - (product.priceMinor == null && product.price == null) - ) { + if (!product.priceCurrency || product.priceMinor == null) { throw new PriceConfigError('Price not configured for currency.', { productId: product.id, currency, }); } - let unitPriceMinor: number; - if ( - typeof product.priceMinor === 'number' && - Number.isFinite(product.priceMinor) + typeof product.priceMinor !== 'number' || + !Number.isFinite(product.priceMinor) ) { - if (!Number.isInteger(product.priceMinor)) { - throw new PriceConfigError( - 'Invalid priceMinor in DB (must be integer).', - { - productId: product.id, - currency, - } - ); - } - if (!Number.isSafeInteger(product.priceMinor) || product.priceMinor < 1) { - throw new PriceConfigError('Invalid priceMinor in DB (out of range).', { + throw new PriceConfigError( + 'Invalid priceMinor in DB (must be integer).', + { productId: product.id, currency, - }); - } + } + ); + } - unitPriceMinor = product.priceMinor; - } else { - unitPriceMinor = toCents( - coercePriceFromDb(product.price, { - field: 'price', + if (!Number.isInteger(product.priceMinor)) { + throw new PriceConfigError( + 'Invalid priceMinor in DB (must be integer).', + { productId: product.id, - }) + currency, + } ); } + const unitPriceMinor = product.priceMinor; + if (!Number.isSafeInteger(unitPriceMinor) || unitPriceMinor < 1) { - throw new PriceConfigError('Invalid price in DB (out of range).', { + throw new PriceConfigError('Invalid priceMinor in DB (out of range).', { productId: product.id, currency, }); diff --git a/frontend/lib/services/products/mutations/toggle.ts b/frontend/lib/services/products/mutations/toggle.ts index 1e38aa28..8978cb43 100644 --- a/frontend/lib/services/products/mutations/toggle.ts +++ b/frontend/lib/services/products/mutations/toggle.ts @@ -1,32 +1,115 @@ import { eq } from 'drizzle-orm'; import { db } from '@/db'; -import { products } from '@/db/schema'; +import { productPrices, products } from '@/db/schema'; import { ProductNotFoundError } from '@/lib/errors/products'; +import { InvalidPayloadError } from '@/lib/services/errors'; import type { DbProduct } from '@/lib/types/shop'; +import { getProductImagesByProductId, resolveProductImages } from '../images'; import { mapRowToProduct } from '../mapping'; +import { assertMergedPricesPolicy } from '../prices'; -export async function toggleProductStatus(id: string): Promise { - const [current] = await db - .select() - .from(products) - .where(eq(products.id, id)) - .limit(1); - - if (!current) { - throw new ProductNotFoundError(id); +async function assertProductCanBeActivated( + tx: Parameters[0]>[0], + current: typeof products.$inferSelect +): Promise { + const priceRows = await tx + .select({ + currency: productPrices.currency, + priceMinor: productPrices.priceMinor, + originalPriceMinor: productPrices.originalPriceMinor, + }) + .from(productPrices) + .where(eq(productPrices.productId, current.id)); + + const mergedRows = priceRows.map(row => ({ + currency: row.currency, + priceMinor: row.priceMinor, + originalPriceMinor: row.originalPriceMinor, + })); + + assertMergedPricesPolicy(mergedRows, { + productId: current.id, + requiredCurrency: 'UAH', + requireUsd: false, + }); + + if (current.badge === 'SALE') { + const invalidSaleRow = mergedRows.find( + row => + row.originalPriceMinor == null || + row.originalPriceMinor <= row.priceMinor + ); + + if (invalidSaleRow) { + const error = new InvalidPayloadError( + 'SALE badge requires original price for each provided currency.', + { + code: 'SALE_ORIGINAL_REQUIRED', + field: 'prices', + details: { + currency: invalidSaleRow.currency, + field: 'originalPriceMinor', + rule: + invalidSaleRow.originalPriceMinor == null + ? 'required' + : 'greater_than_price', + }, + } + ); + throw error; + } } - const [updated] = await db - .update(products) - .set({ isActive: !current.isActive }) - .where(eq(products.id, id)) - .returning(); + const resolvedImages = resolveProductImages( + current, + await getProductImagesByProductId(current.id, { db: tx }) + ); - if (!updated) { - throw new ProductNotFoundError(id); + if (!resolvedImages.primaryImage || !resolvedImages.imageUrl.trim()) { + const error = new InvalidPayloadError( + 'At least one product photo is required.', + { + code: 'IMAGE_REQUIRED', + field: 'photos', + details: { productId: current.id }, + } + ); + throw error; } +} + +export async function toggleProductStatus(id: string): Promise { + const updated = await db.transaction(async tx => { + const [current] = await tx + .select() + .from(products) + .where(eq(products.id, id)) + .limit(1) + .for('update'); + + if (!current) { + throw new ProductNotFoundError(id); + } + + const nextIsActive = !current.isActive; + if (nextIsActive) { + await assertProductCanBeActivated(tx, current); + } + + const [row] = await tx + .update(products) + .set({ isActive: nextIsActive }) + .where(eq(products.id, id)) + .returning(); + + if (!row) { + throw new ProductNotFoundError(id); + } + + return row; + }); return await mapRowToProduct(updated); } diff --git a/frontend/lib/services/products/mutations/update.ts b/frontend/lib/services/products/mutations/update.ts index 80129ce5..2211bdf3 100644 --- a/frontend/lib/services/products/mutations/update.ts +++ b/frontend/lib/services/products/mutations/update.ts @@ -1,7 +1,12 @@ import { eq, sql } from 'drizzle-orm'; import { db } from '@/db'; -import { productImages, productPrices, products } from '@/db/schema'; +import { + inventoryMoves, + productImages, + productPrices, + products, +} from '@/db/schema'; import { destroyProductImage, uploadProductImageFromFile, @@ -79,6 +84,7 @@ export async function updateProduct( if (prices.length) validatePriceRows(prices); const finalBadge = (input as any).badge ?? existing.badge; + const requestedStock = (input as any).stock; let resolvedPhotoPlan: ReturnType | undefined; const uploadedById = new Map< @@ -151,35 +157,91 @@ export async function updateProduct( existing.imagePublicId ?? undefined; - const updateData: Partial = { - slug, - title: (input as any).title ?? existing.title, - description: (input as any).description ?? existing.description ?? null, - imageUrl: uploaded ? uploaded.secureUrl : mirroredImageUrl, - imagePublicId: uploaded - ? uploaded.publicId - : (mirroredImagePublicId ?? null), - - category: (input as any).category ?? existing.category, - type: (input as any).type ?? existing.type, - colors: (input as any).colors ?? existing.colors, - sizes: (input as any).sizes ?? existing.sizes, - badge: (input as any).badge ?? existing.badge, - isActive: (input as any).isActive ?? existing.isActive, - isFeatured: (input as any).isFeatured ?? existing.isFeatured, - stock: (input as any).stock ?? existing.stock, - sku: - (input as any).sku !== undefined - ? (input as any).sku - ? (input as any).sku - : null - : existing.sku, - }; - // Legacy products.price/original_price are intentionally not updated here. - // product_prices is the single write-authority for catalog pricing. - try { const row = await db.transaction(async tx => { + const [lockedProduct] = await tx + .select({ + id: products.id, + stock: products.stock, + }) + .from(products) + .where(eq(products.id, id)) + .limit(1) + .for('update'); + + if (!lockedProduct) { + throw new ProductNotFoundError(id); + } + + const stockOverwriteRequested = + requestedStock !== undefined && requestedStock !== lockedProduct.stock; + + if (stockOverwriteRequested) { + const [reserveSummary] = await tx + .select({ + reservedQuantity: sql`GREATEST( + COALESCE( + SUM( + CASE + WHEN ${inventoryMoves.type} = 'reserve' THEN ${inventoryMoves.quantity} + ELSE -${inventoryMoves.quantity} + END + ), + 0 + ), + 0 + )`, + }) + .from(inventoryMoves) + .where(eq(inventoryMoves.productId, id)); + + const reservedQuantity = Number(reserveSummary?.reservedQuantity ?? 0); + + if (reservedQuantity > 0) { + const error = new InvalidPayloadError( + 'Stock cannot be overwritten while inventory is reserved for open orders.', + { + code: 'STOCK_EDIT_BLOCKED_RESERVED', + details: { + productId: id, + currentStock: lockedProduct.stock, + requestedStock, + reservedQuantity, + }, + } + ); + (error as any).field = 'stock'; + throw error; + } + } + + const updateData: Partial = { + slug, + title: (input as any).title ?? existing.title, + description: (input as any).description ?? existing.description ?? null, + imageUrl: uploaded ? uploaded.secureUrl : mirroredImageUrl, + imagePublicId: uploaded + ? uploaded.publicId + : (mirroredImagePublicId ?? null), + + category: (input as any).category ?? existing.category, + type: (input as any).type ?? existing.type, + colors: (input as any).colors ?? existing.colors, + sizes: (input as any).sizes ?? existing.sizes, + badge: (input as any).badge ?? existing.badge, + isActive: (input as any).isActive ?? existing.isActive, + isFeatured: (input as any).isFeatured ?? existing.isFeatured, + stock: requestedStock ?? lockedProduct.stock, + sku: + (input as any).sku !== undefined + ? (input as any).sku + ? (input as any).sku + : null + : existing.sku, + }; + // Legacy products.price/original_price are intentionally not updated here. + // product_prices is the single write-authority for catalog pricing. + const existingPriceRows = await tx .select({ currency: productPrices.currency, diff --git a/frontend/lib/services/shop/admin-order-lifecycle.ts b/frontend/lib/services/shop/admin-order-lifecycle.ts index 5e16c13d..ed3a065d 100644 --- a/frontend/lib/services/shop/admin-order-lifecycle.ts +++ b/frontend/lib/services/shop/admin-order-lifecycle.ts @@ -4,7 +4,7 @@ import { and, desc, eq } from 'drizzle-orm'; import { db } from '@/db'; import { orders, shippingShipments } from '@/db/schema'; -import { logWarn } from '@/lib/logging'; +import { logError, logWarn } from '@/lib/logging'; import { InvalidPayloadError, OrderNotFoundError, @@ -297,6 +297,24 @@ async function repairCompleteAudit(args: { }); } +async function writeLifecycleAuditNonBlocking(args: { + orderId: string; + requestId: string; + action: AdminOrderLifecycleAction; + write: () => Promise; +}): Promise { + try { + await args.write(); + } catch (error) { + logError('admin_order_lifecycle_audit_failed', error, { + orderId: args.orderId, + requestId: args.requestId, + action: args.action, + code: 'ADMIN_AUDIT_FAILED', + }); + } +} + function normalizeMonobankCancelError(error: unknown): never { if (error instanceof AdminOrderLifecycleActionError) { throw error; @@ -323,7 +341,6 @@ async function repairConfirmedOrderSideEffects(args: { requestId: string; now: Date; auditFromStatus?: string; - repairOnly?: boolean; }) { const shipmentSync = canRepairConfirmedOrderSideEffects(args.current) ? await ensureQueuedInitialShipment({ @@ -339,23 +356,29 @@ async function repairConfirmedOrderSideEffects(args: { updatedOrder: false, }; - await writeAdminAudit({ + await writeLifecycleAuditNonBlocking({ orderId: args.current.id, - actorUserId: args.actorUserId, - action: 'order_admin_action.confirm', - targetType: 'order', - targetId: args.current.id, requestId: args.requestId, - dedupeKey: buildConfirmAuditDedupeKey(args.current.id), - payload: { - action: 'confirm', - fromStatus: args.auditFromStatus ?? args.current.status, - toStatus: 'PAID', - paymentStatus: args.current.paymentStatus, - insertedShipment: shipmentSync.insertedShipment, - queuedShipment: shipmentSync.queuedShipment, - updatedShippingStatus: shipmentSync.updatedOrder, - }, + action: 'confirm', + write: () => + writeAdminAudit({ + orderId: args.current.id, + actorUserId: args.actorUserId, + action: 'order_admin_action.confirm', + targetType: 'order', + targetId: args.current.id, + requestId: args.requestId, + dedupeKey: buildConfirmAuditDedupeKey(args.current.id), + payload: { + action: 'confirm', + fromStatus: args.auditFromStatus ?? args.current.status, + toStatus: 'PAID', + paymentStatus: args.current.paymentStatus, + insertedShipment: shipmentSync.insertedShipment, + queuedShipment: shipmentSync.queuedShipment, + updatedShippingStatus: shipmentSync.updatedOrder, + }, + }), }); return { @@ -385,7 +408,6 @@ async function applyConfirm(args: { actorUserId: args.actorUserId, requestId: args.requestId, now: new Date(), - repairOnly: true, }); const latest = await loadLifecycleState(args.orderId); if (!latest) throw new OrderNotFoundError('Order not found.'); @@ -449,7 +471,6 @@ async function applyConfirm(args: { actorUserId: args.actorUserId, requestId: args.requestId, now, - repairOnly: true, }); const repaired = await loadLifecycleState(args.orderId); if (!repaired) throw new OrderNotFoundError('Order not found.'); @@ -509,10 +530,16 @@ async function applyCancel(args: { } if (isFinalCanceled(current)) { - await repairCancelAudit({ - current, - actorUserId: args.actorUserId, + await writeLifecycleAuditNonBlocking({ + orderId: current.id, requestId: args.requestId, + action: 'cancel', + write: () => + repairCancelAudit({ + current, + actorUserId: args.actorUserId, + requestId: args.requestId, + }), }); return { @@ -563,13 +590,19 @@ async function applyCancel(args: { latest.inventoryStatus !== current.inventoryStatus; if (changed) { - await repairCancelAudit({ - current: latest, - actorUserId: args.actorUserId, + await writeLifecycleAuditNonBlocking({ + orderId: latest.id, requestId: args.requestId, - fromStatus: current.status, - fromPaymentStatus: current.paymentStatus, - fromShippingStatus: current.shippingStatus, + action: 'cancel', + write: () => + repairCancelAudit({ + current: latest, + actorUserId: args.actorUserId, + requestId: args.requestId, + fromStatus: current.status, + fromPaymentStatus: current.paymentStatus, + fromShippingStatus: current.shippingStatus, + }), }); } @@ -615,10 +648,16 @@ async function applyComplete(args: { } if (current.shippingStatus === 'delivered') { - await repairCompleteAudit({ - current, - actorUserId: args.actorUserId, + await writeLifecycleAuditNonBlocking({ + orderId: current.id, requestId: args.requestId, + action: 'complete', + write: () => + repairCompleteAudit({ + current, + actorUserId: args.actorUserId, + requestId: args.requestId, + }), }); return { @@ -678,10 +717,16 @@ async function applyComplete(args: { if (!updated) { const latest = await loadLifecycleState(args.orderId); if (latest?.shippingStatus === 'delivered') { - await repairCompleteAudit({ - current: latest, - actorUserId: args.actorUserId, + await writeLifecycleAuditNonBlocking({ + orderId: latest.id, requestId: args.requestId, + action: 'complete', + write: () => + repairCompleteAudit({ + current: latest, + actorUserId: args.actorUserId, + requestId: args.requestId, + }), }); return { @@ -700,15 +745,21 @@ async function applyComplete(args: { ); } - await repairCompleteAudit({ - current: { - ...current, - shippingStatus: 'delivered', - }, - actorUserId: args.actorUserId, + await writeLifecycleAuditNonBlocking({ + orderId: args.orderId, requestId: args.requestId, - fromShippingStatus: current.shippingStatus, - fromShipmentStatus: current.shipmentStatus, + action: 'complete', + write: () => + repairCompleteAudit({ + current: { + ...current, + shippingStatus: 'delivered', + }, + actorUserId: args.actorUserId, + requestId: args.requestId, + fromShippingStatus: current.shippingStatus, + fromShipmentStatus: current.shipmentStatus, + }), }); const latest = await loadLifecycleState(args.orderId); diff --git a/frontend/lib/services/shop/events/write-canonical-event-with-retry.ts b/frontend/lib/services/shop/events/write-canonical-event-with-retry.ts new file mode 100644 index 00000000..df9ab838 --- /dev/null +++ b/frontend/lib/services/shop/events/write-canonical-event-with-retry.ts @@ -0,0 +1,22 @@ +import 'server-only'; + +type WriteCanonicalEventWithRetryArgs = { + write: () => Promise; + onFinalFailure: (error: unknown) => void; +}; + +export async function writeCanonicalEventWithRetry( + args: WriteCanonicalEventWithRetryArgs +): Promise { + try { + await args.write(); + return; + } catch { + try { + await args.write(); + return; + } catch (error) { + args.onFinalFailure(error); + } + } +} diff --git a/frontend/lib/services/shop/notifications/projector.ts b/frontend/lib/services/shop/notifications/projector.ts index d74a3345..14f0b2eb 100644 --- a/frontend/lib/services/shop/notifications/projector.ts +++ b/frontend/lib/services/shop/notifications/projector.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { asc } from 'drizzle-orm'; +import { and, asc, eq, isNull } from 'drizzle-orm'; import { db } from '@/db'; import { notificationOutbox, paymentEvents, shippingEvents } from '@/db/schema'; @@ -11,6 +11,8 @@ import { SHOP_NOTIFICATION_CHANNEL, } from '@/lib/services/shop/notifications/templates'; +type CanonicalOccurredAt = Date | string; + type ShippingCanonicalRow = { id: string; orderId: string; @@ -18,7 +20,7 @@ type ShippingCanonicalRow = { eventSource: string; eventRef: string | null; payload: Record; - occurredAt: Date; + occurredAt: CanonicalOccurredAt; }; type PaymentCanonicalRow = { @@ -28,7 +30,7 @@ type PaymentCanonicalRow = { eventSource: string; eventRef: string | null; payload: Record; - occurredAt: Date; + occurredAt: CanonicalOccurredAt; }; export type NotificationProjectorResult = { @@ -43,6 +45,23 @@ function asObject(value: unknown): Record { return value as Record; } +function normalizeOccurredAt( + value: CanonicalOccurredAt | null | undefined +): Date { + if (value instanceof Date) { + return value; + } + + if (typeof value === 'string') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + + return new Date(); +} + function buildOutboxDedupeKey(args: { templateKey: string; channel: string; @@ -63,7 +82,7 @@ function buildOutboxPayload(args: { canonicalEventName: string; canonicalEventSource: string; canonicalEventRef: string | null; - canonicalOccurredAt: Date; + canonicalOccurredAt: CanonicalOccurredAt; canonicalPayload: Record; }) { return { @@ -72,7 +91,9 @@ function buildOutboxPayload(args: { canonicalEventName: args.canonicalEventName, canonicalEventSource: args.canonicalEventSource, canonicalEventRef: args.canonicalEventRef, - canonicalOccurredAt: args.canonicalOccurredAt.toISOString(), + canonicalOccurredAt: normalizeOccurredAt( + args.canonicalOccurredAt + ).toISOString(), canonicalPayload: args.canonicalPayload, }; } @@ -94,6 +115,14 @@ async function projectShippingEvents(limit: number): Promise<{ occurredAt: shippingEvents.occurredAt, }) .from(shippingEvents) + .leftJoin( + notificationOutbox, + and( + eq(notificationOutbox.sourceEventId, shippingEvents.id), + eq(notificationOutbox.sourceDomain, 'shipping_event') + ) + ) + .where(isNull(notificationOutbox.id)) .orderBy(asc(shippingEvents.occurredAt), asc(shippingEvents.id)) .limit(limit)) as ShippingCanonicalRow[]; @@ -159,6 +188,14 @@ async function projectPaymentEvents(limit: number): Promise<{ occurredAt: paymentEvents.occurredAt, }) .from(paymentEvents) + .leftJoin( + notificationOutbox, + and( + eq(notificationOutbox.sourceEventId, paymentEvents.id), + eq(notificationOutbox.sourceDomain, 'payment_event') + ) + ) + .where(isNull(notificationOutbox.id)) .orderBy(asc(paymentEvents.occurredAt), asc(paymentEvents.id)) .limit(limit)) as PaymentCanonicalRow[]; diff --git a/frontend/lib/services/shop/shipping/admin-actions.ts b/frontend/lib/services/shop/shipping/admin-actions.ts index 1ccd0891..ac9ff051 100644 --- a/frontend/lib/services/shop/shipping/admin-actions.ts +++ b/frontend/lib/services/shop/shipping/admin-actions.ts @@ -9,6 +9,7 @@ import { buildAdminAuditDedupeKey, buildShippingEventDedupeKey, } from '@/lib/services/shop/events/dedupe-key'; +import { writeCanonicalEventWithRetry } from '@/lib/services/shop/events/write-canonical-event-with-retry'; import { writeShippingEvent } from '@/lib/services/shop/events/write-shipping-event'; import { evaluateOrderShippingEligibility } from '@/lib/services/shop/shipping/eligibility'; import { ensureQueuedInitialShipment } from '@/lib/services/shop/shipping/ensure-queued-initial-shipment'; @@ -522,41 +523,44 @@ async function ensureOrderShippedCanonicalEvent(args: { return; } - try { - await writeShippingEvent({ - orderId: args.state.order_id, - shipmentId: args.state.shipment_id ?? null, - provider: args.state.shipping_provider ?? 'nova_poshta', - eventName: 'shipped', - eventSource: 'shipping_admin_action', - eventRef: args.requestId, - statusFrom: null, - statusTo: 'shipped', - trackingNumber: args.trackingNumber ?? args.state.tracking_number ?? null, - payload: { + await writeCanonicalEventWithRetry({ + write: () => + writeShippingEvent({ orderId: args.state.order_id, - totalAmountMinor: args.state.total_amount_minor, - currency: args.state.currency, - paymentProvider: args.state.payment_provider, - paymentStatus: args.state.payment_status, - shippingStatus: args.state.shipping_status, + shipmentId: args.state.shipment_id ?? null, + provider: args.state.shipping_provider ?? 'nova_poshta', + eventName: 'shipped', + eventSource: 'shipping_admin_action', + eventRef: args.requestId, + statusFrom: null, + statusTo: 'shipped', trackingNumber: args.trackingNumber ?? args.state.tracking_number ?? null, - ensuredBy: args.ensuredBy, - }, - dedupeKey: buildOrderShippedEventDedupe({ + payload: { + orderId: args.state.order_id, + totalAmountMinor: args.state.total_amount_minor, + currency: args.state.currency, + paymentProvider: args.state.payment_provider, + paymentStatus: args.state.payment_status, + shippingStatus: args.state.shipping_status, + trackingNumber: + args.trackingNumber ?? args.state.tracking_number ?? null, + ensuredBy: args.ensuredBy, + }, + dedupeKey: buildOrderShippedEventDedupe({ + orderId: args.state.order_id, + shipmentId: args.state.shipment_id ?? null, + }), + }).then(() => undefined), + onFinalFailure: error => { + logWarn('order_shipped_event_write_failed', { orderId: args.state.order_id, - shipmentId: args.state.shipment_id ?? null, - }), - }); - } catch (error) { - logWarn('order_shipped_event_write_failed', { - orderId: args.state.order_id, - requestId: args.requestId, - ensuredBy: args.ensuredBy, - error: error instanceof Error ? error.message : String(error), - }); - } + requestId: args.requestId, + ensuredBy: args.ensuredBy, + error: error instanceof Error ? error.message : String(error), + }); + }, + }); } export async function applyShippingAdminAction(args: { diff --git a/frontend/lib/services/shop/shipping/admin-edit.ts b/frontend/lib/services/shop/shipping/admin-edit.ts index 3bc74b71..7bee3637 100644 --- a/frontend/lib/services/shop/shipping/admin-edit.ts +++ b/frontend/lib/services/shop/shipping/admin-edit.ts @@ -421,7 +421,7 @@ export async function applyAdminOrderShippingEdit(args: { state.shipping_method_code ); const nextComparable = buildNextComparable(args.shipping); - const preserveQuote = !hasQuoteAffectingChange( + const quoteAffectingChange = hasQuoteAffectingChange( currentComparable, nextComparable ); @@ -438,9 +438,16 @@ export async function applyAdminOrderShippingEdit(args: { executor: tx, input: args.shipping, existingSnapshot: state.shipping_address, - preserveQuote, + preserveQuote: true, }); + if (quoteAffectingChange) { + throw invalid( + 'SHIPPING_EDIT_REQUIRES_TOTAL_SYNC', + 'Quote-affecting shipping edits are blocked until order totals can be safely synchronized.' + ); + } + const now = new Date(); const [updatedOrder] = await tx .update(orders) diff --git a/frontend/lib/services/shop/shipping/shipments-worker.ts b/frontend/lib/services/shop/shipping/shipments-worker.ts index f808d75d..2c09267a 100644 --- a/frontend/lib/services/shop/shipping/shipments-worker.ts +++ b/frontend/lib/services/shop/shipping/shipments-worker.ts @@ -1,8 +1,11 @@ import 'server-only'; -import { sql } from 'drizzle-orm'; +import crypto from 'node:crypto'; + +import { and, desc, eq, sql } from 'drizzle-orm'; import { db } from '@/db'; +import { shippingEvents } from '@/db/schema'; import { getNovaPoshtaConfig, NovaPoshtaConfigError, @@ -67,6 +70,43 @@ type WorkerShippingEventName = | 'label_creation_retry_scheduled' | 'label_creation_needs_attention'; +type CanonicalCarrierCreatePayload = { + payerType: NovaPoshtaCreateTtnInput['payerType']; + paymentMethod: NovaPoshtaCreateTtnInput['paymentMethod']; + cargoType: string; + serviceType: NovaPoshtaCreateTtnInput['serviceType']; + seatsAmount: number; + weightGrams: number; + description: string; + declaredCostUah: number; + sender: { + cityRef: string; + senderRef: string; + warehouseRef: string; + contactRef: string; + phone: string; + }; + recipient: { + cityRef: string; + warehouseRef: string | null; + addressLine1: string | null; + addressLine2: string | null; + fullName: string; + phone: string; + }; +}; + +type CarrierCreatePayloadIdentity = { + canonicalPayload: CanonicalCarrierCreatePayload; + canonicalHash: string; +}; + +const INTERNAL_CARRIER_EVENT_SOURCE = 'shipments_worker_internal'; +const INTERNAL_CARRIER_CREATE_REQUESTED_EVENT = + 'carrier_create_requested_internal'; +const INTERNAL_CARRIER_CREATE_SUCCEEDED_EVENT = + 'carrier_create_succeeded_internal'; + export type RunShippingShipmentsWorkerArgs = { runId: string; leaseSeconds: number; @@ -319,6 +359,391 @@ function buildWorkerEventDedupeKey(args: { }); } +function canonicalizeCarrierCreatePayload( + requestPayload: NovaPoshtaCreateTtnInput +): CanonicalCarrierCreatePayload { + const normalizedWeightGrams = Math.max( + 1, + Math.round(requestPayload.weightKg * 1000) + ); + + return { + payerType: requestPayload.payerType, + paymentMethod: requestPayload.paymentMethod, + cargoType: requestPayload.cargoType, + serviceType: requestPayload.serviceType, + seatsAmount: Math.max(1, Math.trunc(requestPayload.seatsAmount)), + weightGrams: normalizedWeightGrams, + description: requestPayload.description, + declaredCostUah: Math.max(0, Math.trunc(requestPayload.declaredCostUah)), + sender: { + cityRef: requestPayload.sender.cityRef, + senderRef: requestPayload.sender.senderRef, + warehouseRef: requestPayload.sender.warehouseRef, + contactRef: requestPayload.sender.contactRef, + phone: requestPayload.sender.phone, + }, + recipient: { + cityRef: requestPayload.recipient.cityRef, + warehouseRef: requestPayload.recipient.warehouseRef ?? null, + addressLine1: requestPayload.recipient.addressLine1 ?? null, + addressLine2: requestPayload.recipient.addressLine2 ?? null, + fullName: requestPayload.recipient.fullName, + phone: requestPayload.recipient.phone, + }, + }; +} + +export function buildCarrierCreatePayloadIdentity( + requestPayload: NovaPoshtaCreateTtnInput +): CarrierCreatePayloadIdentity { + const canonicalPayload = canonicalizeCarrierCreatePayload(requestPayload); + const canonicalHash = crypto + .createHash('sha256') + .update(JSON.stringify(canonicalPayload), 'utf8') + .digest('hex'); + + return { + canonicalPayload, + canonicalHash, + }; +} + +function buildCarrierCreateIntentSeed(args: { + orderId: string; + shipmentId: string; + provider: string; +}) { + return { + domain: 'carrier_create', + orderId: args.orderId, + shipmentId: args.shipmentId, + provider: args.provider, + }; +} + +function buildCarrierCreateRequestedDedupeKey(args: { + orderId: string; + shipmentId: string; + provider: string; +}): string { + return buildShippingEventDedupeKey({ + ...buildCarrierCreateIntentSeed(args), + phase: 'requested', + }); +} + +function buildCarrierCreateSucceededDedupeKey(args: { + orderId: string; + shipmentId: string; + provider: string; +}): string { + return buildShippingEventDedupeKey({ + ...buildCarrierCreateIntentSeed(args), + phase: 'succeeded', + }); +} + +type PersistedCarrierCreateSuccess = { + providerRef: string; + trackingNumber: string; + canonicalHash: string | null; +}; + +type PersistedCarrierCreateRequest = { + canonicalHash: string | null; +}; + +function readCanonicalHashFromPayload(payload: unknown): string | null { + const payloadObject = toObject(payload); + return toStringOrNull(payloadObject?.canonicalHash); +} + +async function readPersistedCarrierCreateRequest(args: { + shipmentId: string; +}): Promise { + const [row] = await db + .select({ + payload: shippingEvents.payload, + }) + .from(shippingEvents) + .where( + and( + eq(shippingEvents.shipmentId, args.shipmentId), + eq(shippingEvents.eventSource, INTERNAL_CARRIER_EVENT_SOURCE), + eq(shippingEvents.eventName, INTERNAL_CARRIER_CREATE_REQUESTED_EVENT) + ) + ) + .orderBy(desc(shippingEvents.occurredAt), desc(shippingEvents.id)) + .limit(1); + + if (!row) { + return null; + } + + return { + canonicalHash: readCanonicalHashFromPayload(row.payload), + }; +} + +type CarrierCreateAttemptResolution = + | { + outcome: 'call_carrier'; + } + | { + outcome: 'replay_success'; + success: PersistedCarrierCreateSuccess; + } + | { + outcome: 'block_retry'; + } + | { + outcome: 'payload_drift'; + } + | { + outcome: 'success_conflict'; + }; + +type PersistedCarrierCreateSuccessState = + | { + state: 'none'; + } + | { + state: 'single'; + success: PersistedCarrierCreateSuccess; + } + | { + state: 'conflict'; + }; + +function buildShipmentSuccessOutcomeKey(args: { + providerRef: string; + trackingNumber: string; +}): string { + return `${args.providerRef}::${args.trackingNumber}`; +} + +function isPartiallyPopulatedOutcome(args: { + providerRef: string | null; + trackingNumber: string | null; +}): boolean { + return Boolean(args.providerRef) !== Boolean(args.trackingNumber); +} + +async function readPersistedCarrierCreateSuccessState(args: { + shipmentId: string; +}): Promise { + const successEvents = await db + .select({ + providerRef: shippingEvents.eventRef, + trackingNumber: shippingEvents.trackingNumber, + payload: shippingEvents.payload, + }) + .from(shippingEvents) + .where( + and( + eq(shippingEvents.shipmentId, args.shipmentId), + eq(shippingEvents.eventSource, INTERNAL_CARRIER_EVENT_SOURCE), + eq(shippingEvents.eventName, INTERNAL_CARRIER_CREATE_SUCCEEDED_EVENT) + ) + ) + .orderBy(desc(shippingEvents.occurredAt), desc(shippingEvents.id)); + + const stateRows = readRows<{ + shipment_provider_ref: string | null; + shipment_tracking_number: string | null; + order_provider_ref: string | null; + order_tracking_number: string | null; + }>( + await db.execute(sql` + select + s.provider_ref as shipment_provider_ref, + s.tracking_number as shipment_tracking_number, + o.shipping_provider_ref as order_provider_ref, + o.tracking_number as order_tracking_number + from shipping_shipments s + join orders o on o.id = s.order_id + where s.id = ${args.shipmentId}::uuid + limit 1 + `) + ); + + const stateRow = stateRows[0]; + const outcomes = new Map(); + + const rememberOutcome = (candidate: PersistedCarrierCreateSuccess | null) => { + if (!candidate) return; + outcomes.set(buildShipmentSuccessOutcomeKey(candidate), candidate); + }; + + for (const row of successEvents) { + const providerRef = toStringOrNull(row.providerRef); + const trackingNumber = toStringOrNull(row.trackingNumber); + if (!providerRef || !trackingNumber) { + return { state: 'conflict' }; + } + rememberOutcome({ + providerRef, + trackingNumber, + canonicalHash: readCanonicalHashFromPayload(row.payload), + }); + } + + const shipmentProviderRef = toStringOrNull(stateRow?.shipment_provider_ref); + const shipmentTrackingNumber = toStringOrNull( + stateRow?.shipment_tracking_number + ); + if ( + isPartiallyPopulatedOutcome({ + providerRef: shipmentProviderRef, + trackingNumber: shipmentTrackingNumber, + }) + ) { + return { state: 'conflict' }; + } + if (shipmentProviderRef && shipmentTrackingNumber) { + rememberOutcome({ + providerRef: shipmentProviderRef, + trackingNumber: shipmentTrackingNumber, + canonicalHash: null, + }); + } + + const orderProviderRef = toStringOrNull(stateRow?.order_provider_ref); + const orderTrackingNumber = toStringOrNull(stateRow?.order_tracking_number); + if ( + isPartiallyPopulatedOutcome({ + providerRef: orderProviderRef, + trackingNumber: orderTrackingNumber, + }) + ) { + return { state: 'conflict' }; + } + if (orderProviderRef && orderTrackingNumber) { + rememberOutcome({ + providerRef: orderProviderRef, + trackingNumber: orderTrackingNumber, + canonicalHash: null, + }); + } + + if (outcomes.size === 0) { + return { state: 'none' }; + } + if (outcomes.size > 1) { + return { state: 'conflict' }; + } + + return { + state: 'single', + success: Array.from(outcomes.values())[0] as PersistedCarrierCreateSuccess, + }; +} + +async function resolveCarrierCreateAttempt(args: { + orderId: string; + shipmentId: string; + provider: string; + payloadIdentity: CarrierCreatePayloadIdentity; +}): Promise { + const persistedSuccessState = await readPersistedCarrierCreateSuccessState({ + shipmentId: args.shipmentId, + }); + if (persistedSuccessState.state === 'conflict') { + return { outcome: 'success_conflict' }; + } + if (persistedSuccessState.state === 'single') { + return { + outcome: 'replay_success', + success: persistedSuccessState.success, + }; + } + + const requestedIntent = await readPersistedCarrierCreateRequest({ + shipmentId: args.shipmentId, + }); + if (requestedIntent) { + if ( + requestedIntent.canonicalHash && + requestedIntent.canonicalHash !== args.payloadIdentity.canonicalHash + ) { + return { outcome: 'payload_drift' }; + } + return { outcome: 'block_retry' }; + } + + const dedupeKey = buildCarrierCreateRequestedDedupeKey(args); + const requested = await writeShippingEvent({ + orderId: args.orderId, + shipmentId: args.shipmentId, + provider: args.provider, + eventName: INTERNAL_CARRIER_CREATE_REQUESTED_EVENT, + eventSource: INTERNAL_CARRIER_EVENT_SOURCE, + payload: { + canonicalHash: args.payloadIdentity.canonicalHash, + canonicalPayload: args.payloadIdentity.canonicalPayload, + }, + dedupeKey, + }); + + if (requested.inserted) { + return { outcome: 'call_carrier' }; + } + + const persistedSuccessAfterConflict = + await readPersistedCarrierCreateSuccessState({ + shipmentId: args.shipmentId, + }); + if (persistedSuccessAfterConflict.state === 'conflict') { + return { outcome: 'success_conflict' }; + } + if (persistedSuccessAfterConflict.state === 'single') { + return { + outcome: 'replay_success', + success: persistedSuccessAfterConflict.success, + }; + } + + const requestedIntentAfterConflict = await readPersistedCarrierCreateRequest({ + shipmentId: args.shipmentId, + }); + if ( + requestedIntentAfterConflict?.canonicalHash && + requestedIntentAfterConflict.canonicalHash !== + args.payloadIdentity.canonicalHash + ) { + return { outcome: 'payload_drift' }; + } + + return { outcome: 'block_retry' }; +} + +async function persistCarrierCreateSuccess(args: { + orderId: string; + shipmentId: string; + provider: string; + payloadIdentity: CarrierCreatePayloadIdentity; + providerRef: string; + trackingNumber: string; +}) { + await writeShippingEvent({ + orderId: args.orderId, + shipmentId: args.shipmentId, + provider: args.provider, + eventName: INTERNAL_CARRIER_CREATE_SUCCEEDED_EVENT, + eventSource: INTERNAL_CARRIER_EVENT_SOURCE, + eventRef: args.providerRef, + trackingNumber: args.trackingNumber, + payload: { + canonicalHash: args.payloadIdentity.canonicalHash, + canonicalPayload: args.payloadIdentity.canonicalPayload, + providerRef: args.providerRef, + trackingNumber: args.trackingNumber, + }, + dedupeKey: buildCarrierCreateSucceededDedupeKey(args), + }); +} + async function emitWorkerShippingEvent(args: { orderId: string; shipmentId: string; @@ -557,6 +982,43 @@ async function loadOrderShippingDetails( return readRows(res)[0] ?? null; } +async function loadAuthoritativeCarrierCreateIntent(args: { + orderId: string; +}): Promise<{ + details: OrderShippingDetailsRow; + snapshot: ParsedShipmentSnapshot; + requestPayload: NovaPoshtaCreateTtnInput; + payloadIdentity: CarrierCreatePayloadIdentity; +}> { + const details = await loadOrderShippingDetails(args.orderId); + if (!details) { + throw buildFailure('ORDER_NOT_FOUND', 'Order was not found.', false); + } + + assertOrderStillShippable(details); + + const snapshot = parseSnapshot(details.shipping_address); + if (snapshot.methodCode !== details.shipping_method_code) { + throw buildFailure( + 'SHIPPING_METHOD_MISMATCH', + 'Shipping method does not match persisted order method.', + false + ); + } + + const requestPayload = toNpPayload({ + order: details, + snapshot, + }); + + return { + details, + snapshot, + requestPayload, + payloadIdentity: buildCarrierCreatePayloadIdentity(requestPayload), + }; +} + function assertOrderStillShippable(details: OrderShippingDetailsRow) { const eligibility = evaluateOrderShippingEligibility({ paymentStatus: details.payment_status, @@ -720,101 +1182,102 @@ async function markFailed(args: { ); } -async function processClaimedShipment(args: { +async function markNeedsAttentionAfterSucceeded(args: { + shipmentId: string; + orderId: string; + error: ShipmentError; +}): Promise<{ shipment_updated: boolean; order_updated: boolean } | null> { + const safeErrorMessage = sanitizeShippingErrorMessage( + args.error.message, + 'Shipment processing failed.' + ); + + const res = await db.execute<{ + shipment_updated: boolean; + order_updated: boolean; + }>(sql` + with updated_shipment as ( + update shipping_shipments s + set status = 'needs_attention', + last_error_code = ${args.error.code}, + last_error_message = ${safeErrorMessage}, + next_attempt_at = null, + lease_owner = null, + lease_expires_at = null, + updated_at = now() + where s.id = ${args.shipmentId}::uuid + and s.status in ('succeeded', 'needs_attention') + returning s.order_id + ), + updated_order as ( + update orders o + set shipping_status = 'needs_attention', + updated_at = now() + where o.id = ${args.orderId}::uuid + and exists (select 1 from updated_shipment) + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: 'needs_attention', + allowNullFrom: true, + includeSame: true, + })} + returning o.id + ) + select + exists (select 1 from updated_shipment) as shipment_updated, + exists (select 1 from updated_order) as order_updated + `); + + return ( + readRows<{ + shipment_updated: boolean; + order_updated: boolean; + }>(res)[0] ?? null + ); +} + +async function finalizeShipmentSuccess(args: { claim: ClaimedShipmentRow; runId: string; - maxAttempts: number; - baseBackoffSeconds: number; + providerRef: string; + trackingNumber: string; }): Promise<'succeeded' | 'retried' | 'needs_attention'> { - const details = await loadOrderShippingDetails(args.claim.order_id); - if (!details) { - await markFailed({ - shipmentId: args.claim.id, + const marked = await markSucceeded({ + shipmentId: args.claim.id, + runId: args.runId, + providerRef: args.providerRef, + trackingNumber: args.trackingNumber, + }); + + if (!marked?.shipment_updated) { + logWarn('shipping_shipments_worker_lease_lost', { runId: args.runId, + shipmentId: args.claim.id, orderId: args.claim.order_id, - error: buildFailure('ORDER_NOT_FOUND', 'Order was not found.', false), - nextAttemptAt: null, - terminalNeedsAttention: true, + code: 'SHIPMENT_LEASE_LOST', }); - return 'needs_attention'; + return 'retried'; } - - try { - assertOrderStillShippable(details); - - const parsedSnapshot = parseSnapshot(details.shipping_address); - - if (parsedSnapshot.methodCode !== details.shipping_method_code) { - throw buildFailure( - 'SHIPPING_METHOD_MISMATCH', - 'Shipping method does not match persisted order method.', - false - ); - } - - const latestDetails = await loadOrderShippingDetails(args.claim.order_id); - if (!latestDetails) { - throw buildFailure('ORDER_NOT_FOUND', 'Order was not found.', false); - } - - assertOrderStillShippable(latestDetails); - - const latestSnapshot = parseSnapshot(latestDetails.shipping_address); - if (latestSnapshot.methodCode !== latestDetails.shipping_method_code) { - throw buildFailure( - 'SHIPPING_METHOD_MISMATCH', - 'Shipping method does not match persisted order method.', - false - ); - } - - const finalDetails = await loadOrderShippingDetails(args.claim.order_id); - if (!finalDetails) { - throw buildFailure('ORDER_NOT_FOUND', 'Order was not found.', false); - } - - assertOrderStillShippable(finalDetails); - - const finalSnapshot = parseSnapshot(finalDetails.shipping_address); - if (finalSnapshot.methodCode !== finalDetails.shipping_method_code) { - throw buildFailure( - 'SHIPPING_METHOD_MISMATCH', - 'Shipping method does not match persisted order method.', - false - ); - } - - const finalPayload = toNpPayload({ - order: finalDetails, - snapshot: finalSnapshot, + if (!marked.order_updated) { + logWarn('shipping_shipments_worker_order_transition_blocked', { + runId: args.runId, + shipmentId: args.claim.id, + orderId: args.claim.order_id, + code: 'ORDER_TRANSITION_BLOCKED', + statusTo: 'label_created', }); - const created = await createInternetDocument(finalPayload); - - const marked = await markSucceeded({ + const updated = await markNeedsAttentionAfterSucceeded({ shipmentId: args.claim.id, - runId: args.runId, - providerRef: created.providerRef, - trackingNumber: created.trackingNumber, + orderId: args.claim.order_id, + error: buildFailure( + 'SHIPMENT_SUCCESS_APPLY_BLOCKED', + 'Shipment carrier success could not be applied because the order shipping transition was blocked.', + false + ), }); - if (!marked?.shipment_updated) { - logWarn('shipping_shipments_worker_lease_lost', { - runId: args.runId, - shipmentId: args.claim.id, - orderId: args.claim.order_id, - code: 'SHIPMENT_LEASE_LOST', - }); - return 'retried'; - } - if (!marked.order_updated) { - logWarn('shipping_shipments_worker_order_transition_blocked', { - runId: args.runId, - shipmentId: args.claim.id, - orderId: args.claim.order_id, - code: 'ORDER_TRANSITION_BLOCKED', - statusTo: 'label_created', - }); + if (!updated?.shipment_updated) { return 'retried'; } @@ -823,45 +1286,176 @@ async function processClaimedShipment(args: { orderId: args.claim.order_id, shipmentId: args.claim.id, provider: args.claim.provider, - eventName: 'label_created', + eventName: 'label_creation_needs_attention', statusFrom: 'creating_label', - statusTo: 'label_created', + statusTo: 'needs_attention', attemptNumber: nextAttemptNumber(args.claim.attempt_count), runId: args.runId, - eventRef: created.providerRef, - trackingNumber: created.trackingNumber, + eventRef: 'SHIPMENT_SUCCESS_APPLY_BLOCKED', + errorCode: 'SHIPMENT_SUCCESS_APPLY_BLOCKED', + trackingNumber: args.trackingNumber, payload: { - providerRef: created.providerRef, - shipmentStatusTo: 'succeeded', + errorCode: 'SHIPMENT_SUCCESS_APPLY_BLOCKED', + errorMessage: + 'Shipment carrier success could not be applied because the order shipping transition was blocked.', + transient: false, + nextAttemptAt: null, + shipmentStatusTo: 'needs_attention', + orderTransitionBlocked: true, + providerRef: args.providerRef, + trackingNumber: args.trackingNumber, + carrierSuccessPersisted: true, }, }); } catch { - logWarn('shipping_shipments_worker_post_success_event_write_failed', { + logWarn('shipping_shipments_worker_failure_event_write_failed', { runId: args.runId, shipmentId: args.claim.id, orderId: args.claim.order_id, + provider: args.claim.provider, code: 'SHIPPING_EVENT_WRITE_FAILED', + eventName: 'label_creation_needs_attention', }); } try { recordShippingMetric({ - name: 'succeeded', + name: 'needs_attention', source: 'shipments_worker', runId: args.runId, orderId: args.claim.order_id, shipmentId: args.claim.id, + code: 'SHIPMENT_SUCCESS_APPLY_BLOCKED', }); } catch { - logWarn('shipping_shipments_worker_post_success_metric_write_failed', { + logWarn('shipping_shipments_worker_terminal_metric_write_failed', { runId: args.runId, - shipmentId: args.claim.id, orderId: args.claim.order_id, + shipmentId: args.claim.id, + errorCode: 'SHIPMENT_SUCCESS_APPLY_BLOCKED', code: 'SHIPPING_METRIC_WRITE_FAILED', }); } - return 'succeeded'; + return 'needs_attention'; + } + + try { + await emitWorkerShippingEvent({ + orderId: args.claim.order_id, + shipmentId: args.claim.id, + provider: args.claim.provider, + eventName: 'label_created', + statusFrom: 'creating_label', + statusTo: 'label_created', + attemptNumber: nextAttemptNumber(args.claim.attempt_count), + runId: args.runId, + eventRef: args.providerRef, + trackingNumber: args.trackingNumber, + payload: { + providerRef: args.providerRef, + shipmentStatusTo: 'succeeded', + }, + }); + } catch { + logWarn('shipping_shipments_worker_post_success_event_write_failed', { + runId: args.runId, + shipmentId: args.claim.id, + orderId: args.claim.order_id, + code: 'SHIPPING_EVENT_WRITE_FAILED', + }); + } + + try { + recordShippingMetric({ + name: 'succeeded', + source: 'shipments_worker', + runId: args.runId, + orderId: args.claim.order_id, + shipmentId: args.claim.id, + }); + } catch { + logWarn('shipping_shipments_worker_post_success_metric_write_failed', { + runId: args.runId, + shipmentId: args.claim.id, + orderId: args.claim.order_id, + code: 'SHIPPING_METRIC_WRITE_FAILED', + }); + } + + return 'succeeded'; +} + +async function processClaimedShipment(args: { + claim: ClaimedShipmentRow; + runId: string; + maxAttempts: number; + baseBackoffSeconds: number; +}): Promise<'succeeded' | 'retried' | 'needs_attention'> { + try { + const carrierCreateIntent = await loadAuthoritativeCarrierCreateIntent({ + orderId: args.claim.order_id, + }); + + const carrierCreateAttempt = await resolveCarrierCreateAttempt({ + orderId: args.claim.order_id, + shipmentId: args.claim.id, + provider: args.claim.provider, + payloadIdentity: carrierCreateIntent.payloadIdentity, + }); + + if (carrierCreateAttempt.outcome === 'replay_success') { + return finalizeShipmentSuccess({ + claim: args.claim, + runId: args.runId, + providerRef: carrierCreateAttempt.success.providerRef, + trackingNumber: carrierCreateAttempt.success.trackingNumber, + }); + } + + if (carrierCreateAttempt.outcome === 'success_conflict') { + throw buildFailure( + 'CARRIER_CREATE_SUCCESS_CONFLICT', + 'Conflicting shipment success outcomes were detected for this shipment intent.', + false + ); + } + + if (carrierCreateAttempt.outcome === 'payload_drift') { + throw buildFailure( + 'CARRIER_CREATE_PAYLOAD_DRIFT', + 'Shipment create payload drift was detected for an existing carrier create intent.', + false + ); + } + + if (carrierCreateAttempt.outcome === 'block_retry') { + throw buildFailure( + 'CARRIER_CREATE_RETRY_BLOCKED', + 'Previous shipment create attempt may already have reached the carrier boundary.', + false + ); + } + + const created = await createInternetDocument( + carrierCreateIntent.requestPayload + ); + + await persistCarrierCreateSuccess({ + orderId: args.claim.order_id, + shipmentId: args.claim.id, + provider: args.claim.provider, + payloadIdentity: carrierCreateIntent.payloadIdentity, + providerRef: created.providerRef, + trackingNumber: created.trackingNumber, + }); + + return finalizeShipmentSuccess({ + claim: args.claim, + runId: args.runId, + providerRef: created.providerRef, + trackingNumber: created.trackingNumber, + }); } catch (error) { const classified = asShipmentError(error, { code: 'INTERNAL_ERROR', @@ -898,7 +1492,8 @@ async function processClaimedShipment(args: { }); return 'retried'; } - if (!updated.order_updated) { + const orderTransitionBlocked = !updated.order_updated; + if (orderTransitionBlocked) { logWarn('shipping_shipments_worker_order_transition_blocked', { runId: args.runId, shipmentId: args.claim.id, @@ -906,7 +1501,9 @@ async function processClaimedShipment(args: { code: 'ORDER_TRANSITION_BLOCKED', statusTo: terminalNeedsAttention ? 'needs_attention' : 'queued', }); - return 'retried'; + if (!terminalNeedsAttention) { + return 'retried'; + } } const failureEventName = terminalNeedsAttention @@ -932,6 +1529,9 @@ async function processClaimedShipment(args: { shipmentStatusTo: terminalNeedsAttention ? 'needs_attention' : 'failed', + orderTransitionBlocked: terminalNeedsAttention + ? orderTransitionBlocked + : undefined, }, }); } catch { diff --git a/frontend/lib/shop/commercial-policy.server.ts b/frontend/lib/shop/commercial-policy.server.ts index 69e7c3df..6170b8f9 100644 --- a/frontend/lib/shop/commercial-policy.server.ts +++ b/frontend/lib/shop/commercial-policy.server.ts @@ -2,6 +2,7 @@ import 'server-only'; import { isMonobankEnabled } from '@/lib/env/monobank'; import { readServerEnv } from '@/lib/env/server-env'; +import { assertCriticalShopEnv } from '@/lib/env/shop-critical'; import { isPaymentsEnabled as isStripePaymentsEnabled } from '@/lib/env/stripe'; export type StandardStorefrontProviderCapabilities = { @@ -22,25 +23,14 @@ function isFlagEnabled(value: string | undefined): boolean { } export function resolveStandardStorefrontProviderCapabilities(): StandardStorefrontProviderCapabilities { - let stripeCheckoutEnabled = false; - try { - stripeCheckoutEnabled = isStripePaymentsEnabled({ - requirePublishableKey: true, - }); - } catch { - stripeCheckoutEnabled = false; - } + assertCriticalShopEnv(); - const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED')); + const stripeCheckoutEnabled = isStripePaymentsEnabled({ + requirePublishableKey: true, + }); - let monobankCheckoutEnabled = false; - if (paymentsEnabled) { - try { - monobankCheckoutEnabled = isMonobankEnabled(); - } catch { - monobankCheckoutEnabled = false; - } - } + const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED')); + const monobankCheckoutEnabled = paymentsEnabled ? isMonobankEnabled() : false; const monobankGooglePayEnabled = monobankCheckoutEnabled && diff --git a/frontend/lib/shop/data.ts b/frontend/lib/shop/data.ts index f992b1f5..07e9ff5a 100644 --- a/frontend/lib/shop/data.ts +++ b/frontend/lib/shop/data.ts @@ -58,6 +58,7 @@ export interface ProductPageDisplayProduct { primaryImage?: ShopProductImage; description?: string; badge: ProductBadge; + sizes: ShopProduct['sizes']; } type AvailableProductPageViewModelInput = { @@ -127,6 +128,7 @@ export async function getProductPageData( : undefined, description: base.description ?? undefined, badge, + sizes: base.sizes ?? [], }, commerceProduct: null, }); @@ -176,6 +178,7 @@ function toProductPageDisplayProduct(input: { primaryImage?: ShopProductImage; description?: string; badge: ProductBadge; + sizes?: ShopProduct['sizes']; }): ProductPageDisplayProduct { return { id: input.id, @@ -186,6 +189,7 @@ function toProductPageDisplayProduct(input: { primaryImage: input.primaryImage, description: input.description, badge: input.badge, + sizes: input.sizes ?? [], }; } @@ -206,6 +210,7 @@ export function toProductPageViewModel( primaryImage: data.commerceProduct.primaryImage, description: data.commerceProduct.description, badge: data.commerceProduct.badge ?? 'NONE', + sizes: data.commerceProduct.sizes, }), commerceProduct: data.commerceProduct, }; diff --git a/frontend/lib/tests/helpers/makeCheckoutReq.ts b/frontend/lib/tests/helpers/makeCheckoutReq.ts index 76493b5c..4db15bb1 100644 --- a/frontend/lib/tests/helpers/makeCheckoutReq.ts +++ b/frontend/lib/tests/helpers/makeCheckoutReq.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; export type CheckoutItemInput = { productId: string; @@ -15,6 +16,7 @@ export function makeCheckoutReq(params: { items?: CheckoutItemInput[]; userId?: string; origin?: string | null; + legalConsent?: Record | null; }) { const locale = params.locale ?? 'en'; const idemKey = params.idempotencyKey; @@ -55,6 +57,9 @@ export function makeCheckoutReq(params: { headers, body: JSON.stringify({ items: payloadItems, + ...(params.legalConsent === null + ? {} + : { legalConsent: params.legalConsent ?? TEST_LEGAL_CONSENT }), ...(params.userId ? { userId: params.userId } : {}), }), }); diff --git a/frontend/lib/tests/shop/admin-order-lifecycle-actions.test.ts b/frontend/lib/tests/shop/admin-order-lifecycle-actions.test.ts index 8f8495f7..bb837d1e 100644 --- a/frontend/lib/tests/shop/admin-order-lifecycle-actions.test.ts +++ b/frontend/lib/tests/shop/admin-order-lifecycle-actions.test.ts @@ -235,6 +235,92 @@ describe.sequential('admin order lifecycle actions', () => { } }); + it('concurrent confirm attempts keep final state and side-effects consistent', async () => { + const orderId = crypto.randomUUID(); + await ensureAdminUser(); + + await insertOrder({ + orderId, + paymentProvider: 'stripe', + paymentStatus: 'paid', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: 'pending', + }); + + try { + const results = await Promise.allSettled([ + applyAdminOrderLifecycleAction({ + orderId, + action: 'confirm', + actorUserId: ADMIN_USER_ID, + requestId: `req_${crypto.randomUUID()}`, + }), + applyAdminOrderLifecycleAction({ + orderId, + action: 'confirm', + actorUserId: ADMIN_USER_ID, + requestId: `req_${crypto.randomUUID()}`, + }), + ]); + + expect(results).toHaveLength(2); + expect(results.every(result => result.status === 'fulfilled')).toBe(true); + + const fulfilled = results + .filter( + ( + result + ): result is PromiseFulfilledResult< + Awaited> + > => result.status === 'fulfilled' + ) + .map(result => result.value); + + expect(fulfilled).toHaveLength(2); + expect(fulfilled.every(result => result.status === 'PAID')).toBe(true); + expect(fulfilled.every(result => result.paymentStatus === 'paid')).toBe( + true + ); + expect( + fulfilled.every(result => result.shippingStatus === 'queued') + ).toBe(true); + + const [orderRow] = await db + .select({ + status: orders.status, + paymentStatus: orders.paymentStatus, + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderRow?.status).toBe('PAID'); + expect(orderRow?.paymentStatus).toBe('paid'); + expect(orderRow?.shippingStatus).toBe('queued'); + + const shipmentRows = await db + .select({ id: shippingShipments.id, status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + expect(shipmentRows).toHaveLength(1); + expect(shipmentRows[0]?.status).toBe('queued'); + + const auditRows = await db + .select({ action: adminAuditLog.action }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, orderId)); + expect( + auditRows.filter(row => row.action === 'order_admin_action.confirm') + ).toHaveLength(1); + } finally { + await cleanup(orderId); + } + }); + it('backfills confirm audit without repairing shipment side-effects for already-paid refund-contained orders', async () => { const orderId = crypto.randomUUID(); await ensureAdminUser(); @@ -400,6 +486,86 @@ describe.sequential('admin order lifecycle actions', () => { } }); + it('concurrent cancel attempts keep final canceled state without duplicate side-effects', async () => { + const orderId = crypto.randomUUID(); + await ensureAdminUser(); + + await insertOrder({ + orderId, + paymentProvider: 'stripe', + paymentStatus: 'pending', + status: 'CREATED', + inventoryStatus: 'none', + shippingRequired: false, + shippingStatus: null, + }); + + try { + const results = await Promise.allSettled([ + applyAdminOrderLifecycleAction({ + orderId, + action: 'cancel', + actorUserId: ADMIN_USER_ID, + requestId: `req_${crypto.randomUUID()}`, + }), + applyAdminOrderLifecycleAction({ + orderId, + action: 'cancel', + actorUserId: ADMIN_USER_ID, + requestId: `req_${crypto.randomUUID()}`, + }), + ]); + + expect(results).toHaveLength(2); + expect(results.every(result => result.status === 'fulfilled')).toBe(true); + + const fulfilled = results + .filter( + ( + result + ): result is PromiseFulfilledResult< + Awaited> + > => result.status === 'fulfilled' + ) + .map(result => result.value); + + expect(fulfilled).toHaveLength(2); + expect(fulfilled.every(result => result.status === 'CANCELED')).toBe( + true + ); + expect(fulfilled.every(result => result.paymentStatus === 'failed')).toBe( + true + ); + expect(fulfilled.some(result => result.changed)).toBe(true); + + const [orderRow] = await db + .select({ + status: orders.status, + paymentStatus: orders.paymentStatus, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(orderRow?.status).toBe('CANCELED'); + expect(orderRow?.paymentStatus).toBe('failed'); + expect(orderRow?.inventoryStatus).toBe('released'); + expect(orderRow?.stockRestored).toBe(true); + + const auditRows = await db + .select({ action: adminAuditLog.action }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, orderId)); + expect( + auditRows.filter(row => row.action === 'order_admin_action.cancel') + ).toHaveLength(1); + } finally { + await cleanup(orderId); + } + }); + it('eligible order can be completed and repeated attempts stay safe', async () => { const orderId = crypto.randomUUID(); await ensureAdminUser(); @@ -519,6 +685,96 @@ describe.sequential('admin order lifecycle actions', () => { } }); + it('concurrent complete attempts keep final delivered state without duplicate audit rows', async () => { + const orderId = crypto.randomUUID(); + await ensureAdminUser(); + + await insertOrder({ + orderId, + paymentProvider: 'stripe', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: 'shipped', + }); + + await db.insert(shippingShipments).values({ + id: crypto.randomUUID(), + orderId, + provider: 'nova_poshta', + status: 'succeeded', + attemptCount: 1, + leaseOwner: null, + leaseExpiresAt: null, + nextAttemptAt: null, + } as any); + + try { + const results = await Promise.allSettled([ + applyAdminOrderLifecycleAction({ + orderId, + action: 'complete', + actorUserId: ADMIN_USER_ID, + requestId: `req_${crypto.randomUUID()}`, + }), + applyAdminOrderLifecycleAction({ + orderId, + action: 'complete', + actorUserId: ADMIN_USER_ID, + requestId: `req_${crypto.randomUUID()}`, + }), + ]); + + expect(results).toHaveLength(2); + expect(results.every(result => result.status === 'fulfilled')).toBe(true); + + const fulfilled = results + .filter( + ( + result + ): result is PromiseFulfilledResult< + Awaited> + > => result.status === 'fulfilled' + ) + .map(result => result.value); + + expect(fulfilled).toHaveLength(2); + expect(fulfilled.filter(result => result.changed === true)).toHaveLength( + 1 + ); + expect(fulfilled.filter(result => result.changed === false)).toHaveLength( + 1 + ); + expect(fulfilled.every(result => result.status === 'PAID')).toBe(true); + expect(fulfilled.every(result => result.paymentStatus === 'paid')).toBe( + true + ); + expect( + fulfilled.every(result => result.shippingStatus === 'delivered') + ).toBe(true); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('delivered'); + + const auditRows = await db + .select({ action: adminAuditLog.action }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, orderId)); + expect( + auditRows.filter(row => row.action === 'order_admin_action.complete') + ).toHaveLength(1); + } finally { + await cleanup(orderId); + } + }); + it('normalizes Monobank cancel provider/domain failures into lifecycle errors', async () => { const orderId = crypto.randomUUID(); const originalPaymentsEnabled = process.env.PAYMENTS_ENABLED; diff --git a/frontend/lib/tests/shop/admin-order-lifecycle-audit-reliability.test.ts b/frontend/lib/tests/shop/admin-order-lifecycle-audit-reliability.test.ts new file mode 100644 index 00000000..29df87fc --- /dev/null +++ b/frontend/lib/tests/shop/admin-order-lifecycle-audit-reliability.test.ts @@ -0,0 +1,321 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { WriteAdminAuditArgs } from '@/lib/services/shop/events/write-admin-audit'; + +const logErrorMock = vi.hoisted(() => vi.fn()); +const writeAdminAuditMock = vi.hoisted(() => + vi.fn(async (..._call: [WriteAdminAuditArgs, { db?: unknown }?]) => { + void _call; + return { + inserted: true, + dedupeKey: 'admin_audit:v1:test', + id: 'audit_row_1', + }; + }) +); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logError: (...args: unknown[]) => logErrorMock(...args), + }; +}); + +vi.mock('@/lib/services/shop/events/write-admin-audit', () => ({ + writeAdminAudit: writeAdminAuditMock, +})); + +import { db } from '@/db'; +import { adminAuditLog, orders, shippingShipments, users } from '@/db/schema'; +import { applyAdminOrderLifecycleAction } from '@/lib/services/shop/admin-order-lifecycle'; +import { toDbMoney } from '@/lib/shop/money'; + +const ADMIN_USER_ID = 'admin_lifecycle_audit_1'; +type OrderInsertRow = typeof orders.$inferInsert; + +async function cleanup(orderId: string) { + await db.delete(adminAuditLog).where(eq(adminAuditLog.orderId, orderId)); + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function ensureAdminUser() { + await db + .insert(users) + .values({ + id: ADMIN_USER_ID, + email: 'admin-lifecycle-audit@example.test', + role: 'admin', + name: 'Admin Lifecycle Audit', + }) + .onConflictDoUpdate({ + target: users.id, + set: { + email: 'admin-lifecycle-audit@example.test', + role: 'admin', + name: 'Admin Lifecycle Audit', + }, + }); +} + +async function insertOrder(args: { + orderId: string; + paymentProvider?: 'stripe' | 'monobank' | 'none'; + paymentStatus?: + | 'pending' + | 'requires_payment' + | 'paid' + | 'failed' + | 'refunded' + | 'needs_review'; + status?: + | 'CREATED' + | 'INVENTORY_RESERVED' + | 'INVENTORY_FAILED' + | 'PAID' + | 'CANCELED'; + inventoryStatus?: + | 'none' + | 'reserving' + | 'reserved' + | 'release_pending' + | 'released' + | 'failed'; + shippingRequired?: boolean; + shippingProvider?: 'nova_poshta' | 'ukrposhta' | null; + shippingMethodCode?: 'NP_WAREHOUSE' | 'NP_LOCKER' | 'NP_COURIER' | null; + shippingStatus?: + | 'pending' + | 'queued' + | 'creating_label' + | 'label_created' + | 'shipped' + | 'delivered' + | 'cancelled' + | 'needs_attention' + | null; + pspStatusReason?: string | null; + stockRestored?: boolean; + restockedAt?: Date | null; +}) { + const orderRow: OrderInsertRow = { + id: args.orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: args.paymentProvider ?? 'stripe', + paymentStatus: args.paymentStatus ?? 'pending', + status: args.status ?? 'CREATED', + inventoryStatus: args.inventoryStatus ?? 'none', + shippingRequired: args.shippingRequired ?? false, + shippingPayer: args.shippingRequired ? 'customer' : null, + shippingProvider: args.shippingProvider ?? null, + shippingMethodCode: args.shippingMethodCode ?? null, + shippingAmountMinor: null, + shippingStatus: args.shippingStatus ?? null, + pspChargeId: null, + pspStatusReason: args.pspStatusReason ?? null, + stockRestored: args.stockRestored ?? false, + restockedAt: args.restockedAt ?? null, + idempotencyKey: crypto.randomUUID(), + }; + + await db.insert(orders).values(orderRow); +} + +describe.sequential('admin order lifecycle audit reliability', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('confirm keeps the successful lifecycle mutation when audit persistence fails', async () => { + const orderId = crypto.randomUUID(); + const requestId = `req_${crypto.randomUUID()}`; + const auditError = new Error('confirm audit failed'); + await ensureAdminUser(); + + await insertOrder({ + orderId, + paymentProvider: 'stripe', + paymentStatus: 'paid', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: 'pending', + }); + + writeAdminAuditMock.mockRejectedValueOnce(auditError); + + try { + const result = await applyAdminOrderLifecycleAction({ + orderId, + action: 'confirm', + actorUserId: ADMIN_USER_ID, + requestId, + }); + + expect(result.status).toBe('PAID'); + expect(result.paymentStatus).toBe('paid'); + expect(result.shippingStatus).toBe('queued'); + + const [orderRow] = await db + .select({ + status: orders.status, + paymentStatus: orders.paymentStatus, + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderRow?.status).toBe('PAID'); + expect(orderRow?.paymentStatus).toBe('paid'); + expect(orderRow?.shippingStatus).toBe('queued'); + + expect(logErrorMock).toHaveBeenCalledWith( + 'admin_order_lifecycle_audit_failed', + auditError, + expect.objectContaining({ + orderId, + requestId, + action: 'confirm', + code: 'ADMIN_AUDIT_FAILED', + }) + ); + } finally { + await cleanup(orderId); + } + }); + + it('cancel keeps the successful lifecycle mutation when audit persistence fails', async () => { + const orderId = crypto.randomUUID(); + const requestId = `req_${crypto.randomUUID()}`; + const auditError = new Error('cancel audit failed'); + await ensureAdminUser(); + + await insertOrder({ + orderId, + paymentProvider: 'stripe', + paymentStatus: 'pending', + status: 'CREATED', + inventoryStatus: 'none', + shippingRequired: false, + shippingStatus: null, + }); + + writeAdminAuditMock.mockRejectedValueOnce(auditError); + + try { + const result = await applyAdminOrderLifecycleAction({ + orderId, + action: 'cancel', + actorUserId: ADMIN_USER_ID, + requestId, + }); + + expect(result.status).toBe('CANCELED'); + expect(result.paymentStatus).toBe('failed'); + + const [orderRow] = await db + .select({ + status: orders.status, + paymentStatus: orders.paymentStatus, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderRow?.status).toBe('CANCELED'); + expect(orderRow?.paymentStatus).toBe('failed'); + expect(orderRow?.inventoryStatus).toBe('released'); + expect(orderRow?.stockRestored).toBe(true); + + expect(logErrorMock).toHaveBeenCalledWith( + 'admin_order_lifecycle_audit_failed', + auditError, + expect.objectContaining({ + orderId, + requestId, + action: 'cancel', + code: 'ADMIN_AUDIT_FAILED', + }) + ); + } finally { + await cleanup(orderId); + } + }); + + it('complete keeps the successful lifecycle mutation when audit persistence fails', async () => { + const orderId = crypto.randomUUID(); + const requestId = `req_${crypto.randomUUID()}`; + const auditError = new Error('complete audit failed'); + await ensureAdminUser(); + + await insertOrder({ + orderId, + paymentProvider: 'stripe', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: 'shipped', + }); + + const shipmentRow: typeof shippingShipments.$inferInsert = { + id: crypto.randomUUID(), + orderId, + provider: 'nova_poshta', + status: 'succeeded', + attemptCount: 1, + leaseOwner: null, + leaseExpiresAt: null, + nextAttemptAt: null, + }; + await db.insert(shippingShipments).values(shipmentRow); + + writeAdminAuditMock.mockRejectedValueOnce(auditError); + + try { + const result = await applyAdminOrderLifecycleAction({ + orderId, + action: 'complete', + actorUserId: ADMIN_USER_ID, + requestId, + }); + + expect(result.status).toBe('PAID'); + expect(result.paymentStatus).toBe('paid'); + expect(result.shippingStatus).toBe('delivered'); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('delivered'); + + expect(logErrorMock).toHaveBeenCalledWith( + 'admin_order_lifecycle_audit_failed', + auditError, + expect.objectContaining({ + orderId, + requestId, + action: 'complete', + code: 'ADMIN_AUDIT_FAILED', + }) + ); + } finally { + await cleanup(orderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/admin-product-activation-validation.test.ts b/frontend/lib/tests/shop/admin-product-activation-validation.test.ts new file mode 100644 index 00000000..df8d9fc9 --- /dev/null +++ b/frontend/lib/tests/shop/admin-product-activation-validation.test.ts @@ -0,0 +1,299 @@ +import { randomUUID } from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + requireAdminApi: vi.fn(async () => ({ + id: 'admin-user-1', + role: 'admin', + email: 'admin@example.com', + })), + requireAdminCsrf: vi.fn(() => null), + writeAdminAudit: vi.fn(async () => ({ + inserted: true, + dedupeKey: 'admin_audit:v1:test', + id: 'audit_row_1', + })), +})); + +vi.mock('@/lib/auth/admin', () => { + class AdminApiDisabledError extends Error { + code = 'ADMIN_API_DISABLED' as const; + } + class AdminUnauthorizedError extends Error { + code = 'ADMIN_UNAUTHORIZED' as const; + } + class AdminForbiddenError extends Error { + code = 'ADMIN_FORBIDDEN' as const; + } + return { + AdminApiDisabledError, + AdminUnauthorizedError, + AdminForbiddenError, + requireAdminApi: mocks.requireAdminApi, + }; +}); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: mocks.requireAdminCsrf, +})); + +vi.mock('@/lib/services/shop/events/write-admin-audit', () => ({ + writeAdminAudit: mocks.writeAdminAudit, +})); + +import { PATCH } from '@/app/api/shop/admin/products/[id]/status/route'; +import { db } from '@/db'; +import { getPublicProductBySlug } from '@/db/queries/shop/products'; +import { productPrices, products } from '@/db/schema'; +import { updateProduct } from '@/lib/services/products'; +import { toDbMoney } from '@/lib/shop/money'; + +type SeededProduct = { + productId: string; + slug: string; + initialTitle: string; + initialStock: number; +}; + +async function cleanupProduct(productId: string | null) { + if (!productId) return; + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(products).where(eq(products.id, productId)); +} + +async function seedInactiveProduct(args?: { + badge?: 'NONE' | 'SALE'; + imageUrl?: string; + prices?: Array<{ + currency: 'USD' | 'UAH'; + priceMinor: number; + originalPriceMinor: number | null; + }>; +}): Promise { + const productId = randomUUID(); + const slug = `activation-${randomUUID()}`; + const initialTitle = `Activation ${slug.slice(0, 8)}`; + const initialStock = 5; + const badge = args?.badge ?? 'NONE'; + const imageUrl = args?.imageUrl ?? 'https://example.com/activation.png'; + const prices = args?.prices ?? [ + { currency: 'USD', priceMinor: 1600, originalPriceMinor: null }, + { currency: 'UAH', priceMinor: 6400, originalPriceMinor: null }, + ]; + + const usdMirror = + prices.find(row => row.currency === 'USD') ?? + prices.find(row => row.currency === 'UAH')!; + + await db.insert(products).values({ + id: productId, + slug, + title: initialTitle, + description: null, + imageUrl, + imagePublicId: null, + price: toDbMoney(usdMirror.priceMinor), + originalPrice: + usdMirror.originalPriceMinor == null + ? null + : toDbMoney(usdMirror.originalPriceMinor), + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge, + isActive: false, + isFeatured: false, + stock: initialStock, + sku: null, + } as any); + + await db.insert(productPrices).values( + prices.map(row => ({ + productId, + currency: row.currency, + priceMinor: row.priceMinor, + originalPriceMinor: row.originalPriceMinor, + price: toDbMoney(row.priceMinor), + originalPrice: + row.originalPriceMinor == null + ? null + : toDbMoney(row.originalPriceMinor), + })) + ); + + return { + productId, + slug, + initialTitle, + initialStock, + }; +} + +function makeStatusRequest(productId: string): NextRequest { + return new NextRequest( + new Request( + `http://localhost/api/shop/admin/products/${productId}/status`, + { + method: 'PATCH', + headers: { origin: 'http://localhost:3000' }, + } + ) + ); +} + +describe.sequential('admin product activation validation', () => { + let seededProductId: string | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + await cleanupProduct(seededProductId); + seededProductId = null; + }); + + it('activates a valid complete inactive product without mutating unrelated fields', async () => { + const seeded = await seedInactiveProduct(); + seededProductId = seeded.productId; + + expect(await getPublicProductBySlug(seeded.slug, 'USD')).toBeNull(); + + const res = await PATCH(makeStatusRequest(seeded.productId), { + params: Promise.resolve({ id: seeded.productId }), + } as any); + + expect(res.status).toBe(200); + + const json = await res.json(); + expect(json.product.isActive).toBe(true); + + const [productRow] = await db + .select({ + isActive: products.isActive, + title: products.title, + stock: products.stock, + }) + .from(products) + .where(eq(products.id, seeded.productId)) + .limit(1); + + expect(productRow?.isActive).toBe(true); + expect(productRow?.title).toBe(seeded.initialTitle); + expect(productRow?.stock).toBe(seeded.initialStock); + expect(await getPublicProductBySlug(seeded.slug, 'USD')).not.toBeNull(); + expect(mocks.writeAdminAudit).toHaveBeenCalledTimes(1); + }); + + it('rejects activation when the resulting product state is missing the required UAH storefront row', async () => { + const seeded = await seedInactiveProduct({ + prices: [{ currency: 'USD', priceMinor: 1600, originalPriceMinor: null }], + }); + seededProductId = seeded.productId; + + expect(await getPublicProductBySlug(seeded.slug, 'USD')).toBeNull(); + + const res = await PATCH(makeStatusRequest(seeded.productId), { + params: Promise.resolve({ id: seeded.productId }), + } as any); + + expect(res.status).toBe(400); + + const json = await res.json(); + expect(json.code).toBe('PRICE_CONFIG_ERROR'); + expect(json.currency).toBe('UAH'); + + const [productRow] = await db + .select({ isActive: products.isActive }) + .from(products) + .where(eq(products.id, seeded.productId)) + .limit(1); + + expect(productRow?.isActive).toBe(false); + expect(await getPublicProductBySlug(seeded.slug, 'USD')).toBeNull(); + expect(mocks.writeAdminAudit).not.toHaveBeenCalled(); + }); + + it('rejects activation when a SALE product is missing required original prices', async () => { + const seeded = await seedInactiveProduct({ + badge: 'SALE', + prices: [{ currency: 'UAH', priceMinor: 6400, originalPriceMinor: null }], + }); + seededProductId = seeded.productId; + + const res = await PATCH(makeStatusRequest(seeded.productId), { + params: Promise.resolve({ id: seeded.productId }), + } as any); + + expect(res.status).toBe(400); + + const json = await res.json(); + expect(json.code).toBe('SALE_ORIGINAL_REQUIRED'); + expect(json.field).toBe('prices'); + expect(json.details?.currency).toBe('UAH'); + + const [productRow] = await db + .select({ isActive: products.isActive }) + .from(products) + .where(eq(products.id, seeded.productId)) + .limit(1); + + expect(productRow?.isActive).toBe(false); + expect(mocks.writeAdminAudit).not.toHaveBeenCalled(); + }); + + it('rejects activation when the product has no usable photo state', async () => { + const seeded = await seedInactiveProduct({ + imageUrl: ' ', + }); + seededProductId = seeded.productId; + + const res = await PATCH(makeStatusRequest(seeded.productId), { + params: Promise.resolve({ id: seeded.productId }), + } as any); + + expect(res.status).toBe(400); + + const json = await res.json(); + expect(json.code).toBe('IMAGE_REQUIRED'); + expect(json.field).toBe('photos'); + + const [productRow] = await db + .select({ isActive: products.isActive }) + .from(products) + .where(eq(products.id, seeded.productId)) + .limit(1); + + expect(productRow?.isActive).toBe(false); + expect(mocks.writeAdminAudit).not.toHaveBeenCalled(); + }); + + it('does not block non-activation updates on the same valid inactive product state', async () => { + const seeded = await seedInactiveProduct(); + seededProductId = seeded.productId; + + const updated = await updateProduct(seeded.productId, { + title: 'Retitled while staying inactive', + }); + + expect(updated.title).toBe('Retitled while staying inactive'); + expect(updated.isActive).toBe(false); + + const [productRow] = await db + .select({ + title: products.title, + isActive: products.isActive, + }) + .from(products) + .where(eq(products.id, seeded.productId)) + .limit(1); + + expect(productRow?.title).toBe('Retitled while staying inactive'); + expect(productRow?.isActive).toBe(false); + }); +}); diff --git a/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts b/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts index ae22e32a..a3a9a6f0 100644 --- a/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts +++ b/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InvalidPayloadError, PriceConfigError } from '@/lib/services/errors'; import type { WriteAdminAuditArgs } from '@/lib/services/shop/events/write-admin-audit'; const adminUser = { @@ -300,4 +301,90 @@ describe('admin product canonical audit phase 5', () => { }, }); }); + + it('status toggle returns typed InvalidPayloadError details directly from the route contract', async () => { + const productId = '55555555-5555-4555-8555-555555555555'; + + mocks.toggleProductStatus.mockRejectedValueOnce( + new InvalidPayloadError('Missing required sale pricing.', { + code: 'SALE_ORIGINAL_REQUIRED', + field: 'prices', + details: { + currency: 'UAH', + field: 'originalPriceMinor', + rule: 'required', + }, + }) + ); + + const { PATCH } = + await import('@/app/api/shop/admin/products/[id]/status/route'); + const req = new NextRequest( + new Request( + `http://localhost/api/shop/admin/products/${productId}/status`, + { + method: 'PATCH', + headers: { + origin: 'http://localhost:3000', + }, + } + ) + ); + + const res = await PATCH(req, { + params: Promise.resolve({ id: productId }), + }); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + error: 'Missing required sale pricing.', + code: 'SALE_ORIGINAL_REQUIRED', + field: 'prices', + details: { + currency: 'UAH', + field: 'originalPriceMinor', + rule: 'required', + }, + }); + expect(mocks.writeAdminAudit).not.toHaveBeenCalled(); + }); + + it('status toggle returns PriceConfigError details with canonical prices field', async () => { + const productId = '66666666-6666-4666-8666-666666666666'; + + mocks.toggleProductStatus.mockRejectedValueOnce( + new PriceConfigError('UAH price is required.', { + productId, + currency: 'UAH', + }) + ); + + const { PATCH } = + await import('@/app/api/shop/admin/products/[id]/status/route'); + const req = new NextRequest( + new Request( + `http://localhost/api/shop/admin/products/${productId}/status`, + { + method: 'PATCH', + headers: { + origin: 'http://localhost:3000', + }, + } + ) + ); + + const res = await PATCH(req, { + params: Promise.resolve({ id: productId }), + }); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + error: 'UAH price is required.', + code: 'PRICE_CONFIG_ERROR', + productId, + currency: 'UAH', + field: 'prices', + }); + expect(mocks.writeAdminAudit).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts b/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts index 7fa88721..4d43ab1d 100644 --- a/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts +++ b/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts @@ -96,6 +96,16 @@ function makeFormData(): FormData { return fd; } +function dualCurrencyPrices( + priceMinor: number, + originalPriceMinor: number | null = null +) { + return [ + { currency: 'UAH' as const, priceMinor, originalPriceMinor }, + { currency: 'USD' as const, priceMinor, originalPriceMinor }, + ]; +} + describe.sequential('admin products create atomicity (phase C)', () => { beforeEach(() => { vi.clearAllMocks(); @@ -115,9 +125,7 @@ describe.sequential('admin products create atomicity (phase C)', () => { slug, title: 'Atomic create product', badge: 'NONE', - prices: [ - { currency: 'USD', priceMinor: 1999, originalPriceMinor: null }, - ], + prices: dualCurrencyPrices(1999), stock: 2, isActive: true, isFeatured: false, @@ -176,9 +184,7 @@ describe.sequential('admin products create atomicity (phase C)', () => { slug, title: 'Atomic create rollback guard', badge: 'NONE', - prices: [ - { currency: 'USD', priceMinor: 2099, originalPriceMinor: null }, - ], + prices: dualCurrencyPrices(2099), stock: 2, isActive: true, isFeatured: false, @@ -250,9 +256,7 @@ describe.sequential('admin products create atomicity (phase C)', () => { slug, title: 'Atomic create cleanup owner', badge: 'NONE', - prices: [ - { currency: 'USD', priceMinor: 2199, originalPriceMinor: null }, - ], + prices: dualCurrencyPrices(2199), stock: 2, isActive: true, isFeatured: false, diff --git a/frontend/lib/tests/shop/admin-product-photo-management.test.ts b/frontend/lib/tests/shop/admin-product-photo-management.test.ts index cde43a0f..547a8e83 100644 --- a/frontend/lib/tests/shop/admin-product-photo-management.test.ts +++ b/frontend/lib/tests/shop/admin-product-photo-management.test.ts @@ -20,6 +20,34 @@ async function cleanupProduct(productId: string) { await db.delete(products).where(eq(products.id, productId)); } +function dualCurrencyPrices( + priceMinor: number, + originalPriceMinor: number | null = null +) { + return [ + { currency: 'UAH' as const, priceMinor, originalPriceMinor }, + { currency: 'USD' as const, priceMinor, originalPriceMinor }, + ]; +} + +function dualCurrencyPriceRows( + productId: string, + priceMinor: number, + originalPriceMinor: number | null = null +) { + return dualCurrencyPrices(priceMinor, originalPriceMinor).map(price => ({ + productId, + currency: price.currency, + priceMinor: price.priceMinor, + originalPriceMinor: price.originalPriceMinor, + price: toDbMoney(price.priceMinor), + originalPrice: + price.originalPriceMinor == null + ? null + : toDbMoney(price.originalPriceMinor), + })); +} + describe.sequential('admin product photo management', () => { const createdProductIds: string[] = []; @@ -54,7 +82,7 @@ describe.sequential('admin product photo management', () => { stock: 5, isActive: true, isFeatured: false, - prices: [{ currency: 'USD', priceMinor: 3200, originalPriceMinor: null }], + prices: dualCurrencyPrices(3200), images: [ { uploadId: 'u1', @@ -149,14 +177,9 @@ describe.sequential('admin product photo management', () => { sku: null, }); - await db.insert(productPrices).values({ - productId, - currency: 'USD', - priceMinor: 4500, - originalPriceMinor: null, - price: toDbMoney(4500), - originalPrice: null, - }); + await db + .insert(productPrices) + .values(dualCurrencyPriceRows(productId, 4500)); const [primaryImage, secondaryImage] = await db .insert(productImages) @@ -279,14 +302,9 @@ describe.sequential('admin product photo management', () => { sku: null, }); - await db.insert(productPrices).values({ - productId, - currency: 'USD', - priceMinor: 2700, - originalPriceMinor: null, - price: toDbMoney(2700), - originalPrice: null, - }); + await db + .insert(productPrices) + .values(dualCurrencyPriceRows(productId, 2700)); await expect( updateProduct(productId, { @@ -321,14 +339,9 @@ describe.sequential('admin product photo management', () => { sku: null, }); - await db.insert(productPrices).values({ - productId, - currency: 'USD', - priceMinor: 3100, - originalPriceMinor: null, - price: toDbMoney(3100), - originalPrice: null, - }); + await db + .insert(productPrices) + .values(dualCurrencyPriceRows(productId, 3100)); const updated = await updateProduct(productId, { title: 'Legacy photo product renamed', diff --git a/frontend/lib/tests/shop/admin-product-stock-protection.test.ts b/frontend/lib/tests/shop/admin-product-stock-protection.test.ts new file mode 100644 index 00000000..1aa48d42 --- /dev/null +++ b/frontend/lib/tests/shop/admin-product-stock-protection.test.ts @@ -0,0 +1,337 @@ +import crypto from 'node:crypto'; + +import { eq, sql } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + requireAdminApi: vi.fn(async () => ({ + id: 'admin-user-1', + role: 'admin', + email: 'admin@example.com', + })), + requireAdminCsrf: vi.fn(() => null), + parseAdminProductForm: vi.fn(), + parseAdminProductPhotosForm: vi.fn(() => ({ + ok: true, + data: { imagePlan: undefined, images: [] }, + })), + writeAdminAudit: vi.fn(async () => ({ + inserted: true, + dedupeKey: 'admin_audit:v1:test', + id: 'audit-row-1', + })), +})); + +vi.mock('@/lib/auth/admin', () => { + class AdminApiDisabledError extends Error { + code = 'ADMIN_API_DISABLED' as const; + } + class AdminUnauthorizedError extends Error { + code = 'ADMIN_UNAUTHORIZED' as const; + } + class AdminForbiddenError extends Error { + code = 'ADMIN_FORBIDDEN' as const; + } + return { + AdminApiDisabledError, + AdminUnauthorizedError, + AdminForbiddenError, + requireAdminApi: mocks.requireAdminApi, + }; +}); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: mocks.requireAdminCsrf, +})); + +vi.mock('@/lib/admin/parseAdminProductForm', () => ({ + parseAdminProductForm: mocks.parseAdminProductForm, + parseAdminProductPhotosForm: mocks.parseAdminProductPhotosForm, +})); + +vi.mock('@/lib/services/shop/events/write-admin-audit', () => ({ + writeAdminAudit: mocks.writeAdminAudit, +})); + +import { PATCH } from '@/app/api/shop/admin/products/[id]/route'; +import { db } from '@/db'; +import { orders, productPrices, products } from '@/db/schema'; +import { applyReserveMove } from '@/lib/services/inventory'; +import { restockOrder } from '@/lib/services/orders'; +import { updateProduct } from '@/lib/services/products'; +import { toDbMoney } from '@/lib/shop/money'; + +type SeededProduct = { + productId: string; + initialStock: number; +}; + +type ProductInsertRow = typeof products.$inferInsert; +type OrderInsertRow = typeof orders.$inferInsert; + +type SeededReservedOrder = { + orderId: string; + productId: string; + initialStock: number; + reservedQty: number; +}; + +function makePatchRequest(productId: string): NextRequest { + return new NextRequest( + new Request(`http://localhost/api/shop/admin/products/${productId}`, { + method: 'PATCH', + headers: { origin: 'http://localhost:3000' }, + body: new FormData(), + }) + ); +} + +async function countMoveKey(moveKey: string): Promise { + const result = await db.execute( + sql`select count(*)::int as n from inventory_moves where move_key = ${moveKey}` + ); + const rows = Array.isArray((result as { rows?: unknown[] }).rows) + ? ((result as { rows?: Array<{ n?: number }> }).rows ?? []) + : []; + return Number(rows[0]?.n ?? 0); +} + +async function seedProduct(initialStock = 10): Promise { + const productId = crypto.randomUUID(); + const suffix = crypto.randomUUID().slice(0, 8); + + const productRow: ProductInsertRow = { + id: productId, + title: `Admin stock protection ${suffix}`, + slug: `admin-stock-protection-${suffix}`, + sku: `admin-stock-${suffix}`, + description: null, + badge: 'NONE', + imageUrl: 'https://example.com/admin-stock.png', + imagePublicId: null, + isActive: true, + isFeatured: false, + stock: initialStock, + price: toDbMoney(1000), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + }; + await db.insert(products).values(productRow); + + await db.insert(productPrices).values([ + { + productId, + currency: 'UAH', + priceMinor: 4200, + originalPriceMinor: null, + price: toDbMoney(4200), + originalPrice: null, + }, + { + productId, + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + }, + ]); + + return { productId, initialStock }; +} + +async function seedReservedOrder(args?: { + initialStock?: number; + reservedQty?: number; +}): Promise { + const initialStock = args?.initialStock ?? 10; + const reservedQty = args?.reservedQty ?? 2; + const { productId } = await seedProduct(initialStock); + const orderId = crypto.randomUUID(); + + const orderRow: OrderInsertRow = { + id: orderId, + userId: null, + totalAmountMinor: 4200, + totalAmount: toDbMoney(4200), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'failed', + paymentIntentId: null, + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + failureCode: null, + failureMessage: null, + idempotencyRequestHash: null, + stockRestored: false, + restockedAt: null, + idempotencyKey: `idem_${crypto.randomUUID()}`, + }; + await db.insert(orders).values(orderRow); + + const reserveResult = await applyReserveMove(orderId, productId, reservedQty); + expect(reserveResult.ok).toBe(true); + if (!reserveResult.ok) { + throw new Error(`Expected reserve to succeed, got ${reserveResult.reason}`); + } + expect(reserveResult.applied).toBe(true); + + return { + orderId, + productId, + initialStock, + reservedQty, + }; +} + +async function cleanupReservedOrder(seed: SeededReservedOrder | null) { + if (!seed) return; + await db.delete(orders).where(eq(orders.id, seed.orderId)); + await db.delete(products).where(eq(products.id, seed.productId)); +} + +async function cleanupProduct(seed: SeededProduct | null) { + if (!seed) return; + await db.delete(products).where(eq(products.id, seed.productId)); +} + +describe.sequential('admin product stock protection', () => { + let reservedSeed: SeededReservedOrder | null = null; + let productSeed: SeededProduct | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + await cleanupReservedOrder(reservedSeed); + await cleanupProduct(productSeed); + reservedSeed = null; + productSeed = null; + }); + + it('allows safe admin product updates that do not overwrite stock', async () => { + reservedSeed = await seedReservedOrder(); + + const updated = await updateProduct(reservedSeed.productId, { + title: 'Retitled without stock overwrite', + }); + + const [productRow] = await db + .select({ + title: products.title, + stock: products.stock, + }) + .from(products) + .where(eq(products.id, reservedSeed.productId)) + .limit(1); + + expect(updated.title).toBe('Retitled without stock overwrite'); + expect(productRow?.title).toBe('Retitled without stock overwrite'); + expect(productRow?.stock).toBe( + reservedSeed.initialStock - reservedSeed.reservedQty + ); + }); + + it('rejects unsafe admin stock overwrite while reserved inventory exists', async () => { + reservedSeed = await seedReservedOrder(); + + mocks.parseAdminProductForm.mockReturnValue({ + ok: true, + data: { + title: 'Attempted overwrite', + stock: reservedSeed.initialStock + 5, + }, + }); + + const res = await PATCH(makePatchRequest(reservedSeed.productId), { + params: Promise.resolve({ id: reservedSeed.productId }), + } as any); + + expect(res.status).toBe(400); + + const json = await res.json(); + expect(json.code).toBe('STOCK_EDIT_BLOCKED_RESERVED'); + expect(json.field).toBe('stock'); + expect(json.details?.reservedQuantity).toBe(reservedSeed.reservedQty); + expect(mocks.writeAdminAudit).not.toHaveBeenCalled(); + + const [productRow] = await db + .select({ stock: products.stock, title: products.title }) + .from(products) + .where(eq(products.id, reservedSeed.productId)) + .limit(1); + + expect(productRow?.stock).toBe( + reservedSeed.initialStock - reservedSeed.reservedQty + ); + expect(productRow?.title).not.toBe('Attempted overwrite'); + }); + + it('keeps reserve -> blocked admin edit -> release path free from stock drift', async () => { + reservedSeed = await seedReservedOrder(); + + await expect( + updateProduct(reservedSeed.productId, { + stock: reservedSeed.initialStock + 7, + }) + ).rejects.toMatchObject({ + code: 'STOCK_EDIT_BLOCKED_RESERVED', + details: expect.objectContaining({ + reservedQuantity: reservedSeed.reservedQty, + }), + }); + + await restockOrder(reservedSeed.orderId, { + reason: 'failed', + workerId: 'admin-stock-protection', + claimTtlMinutes: 5, + }); + + const [productRow] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, reservedSeed.productId)) + .limit(1); + + const [orderRow] = await db + .select({ + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, reservedSeed.orderId)) + .limit(1); + + expect(productRow?.stock).toBe(reservedSeed.initialStock); + expect(orderRow?.inventoryStatus).toBe('released'); + expect(orderRow?.stockRestored).toBe(true); + expect( + await countMoveKey( + `release:${reservedSeed.orderId}:${reservedSeed.productId}` + ) + ).toBe(1); + }); + + it('still allows stock overwrite when no reserved inventory exists', async () => { + productSeed = await seedProduct(4); + + const updated = await updateProduct(productSeed.productId, { + stock: 9, + }); + + const [productRow] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, productSeed.productId)) + .limit(1); + + expect(updated.stock).toBe(9); + expect(productRow?.stock).toBe(9); + }); +}); diff --git a/frontend/lib/tests/shop/admin-shipping-edit-route.test.ts b/frontend/lib/tests/shop/admin-shipping-edit-route.test.ts index 5bd16115..84e1dd41 100644 --- a/frontend/lib/tests/shop/admin-shipping-edit-route.test.ts +++ b/frontend/lib/tests/shop/admin-shipping-edit-route.test.ts @@ -247,4 +247,57 @@ describe('admin shipping edit route', () => { }) ); }); + + it('returns controlled service errors for quote-affecting edits that require total sync', async () => { + applyAdminOrderShippingEditMock.mockRejectedValueOnce( + new shippingEditErrors.AdminOrderShippingEditError( + 'SHIPPING_EDIT_REQUIRES_TOTAL_SYNC', + 'Quote-affecting shipping edits are blocked until order totals can be safely synchronized.', + 409 + ) + ); + + const request = new NextRequest( + 'http://localhost/api/shop/admin/orders/550e8400-e29b-41d4-a716-446655440000/shipping', + { + method: 'PATCH', + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json', + 'x-csrf-token': 'csrf-token', + }, + body: JSON.stringify({ + provider: 'nova_poshta', + methodCode: 'NP_COURIER', + selection: { + cityRef: '12345678901234567890', + addressLine1: 'Khreshchatyk 1', + }, + recipient: { + fullName: 'Test User', + phone: '+380501112233', + }, + }), + } + ); + + const response = await PATCH(request, { + params: Promise.resolve({ + id: '550e8400-e29b-41d4-a716-446655440000', + }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + code: 'SHIPPING_EDIT_REQUIRES_TOTAL_SYNC', + message: + 'Quote-affecting shipping edits are blocked until order totals can be safely synchronized.', + }); + expect(logWarnMock).toHaveBeenCalledWith( + 'admin_orders_shipping_edit_rejected', + expect.objectContaining({ + code: 'SHIPPING_EDIT_REQUIRES_TOTAL_SYNC', + }) + ); + }); }); diff --git a/frontend/lib/tests/shop/admin-shipping-edit.test.ts b/frontend/lib/tests/shop/admin-shipping-edit.test.ts index 00dde316..1ac48390 100644 --- a/frontend/lib/tests/shop/admin-shipping-edit.test.ts +++ b/frontend/lib/tests/shop/admin-shipping-edit.test.ts @@ -22,6 +22,12 @@ type SeededOrder = { shipmentId: string | null; }; +type NpCityInsert = typeof npCities.$inferInsert; +type NpWarehouseInsert = typeof npWarehouses.$inferInsert; +type OrderInsert = typeof orders.$inferInsert; +type OrderShippingInsert = typeof orderShipping.$inferInsert; +type ShippingShipmentInsert = typeof shippingShipments.$inferInsert; + async function cleanup(seed: SeededOrder) { await db.delete(adminAuditLog).where(eq(adminAuditLog.orderId, seed.orderId)); await db.delete(orderShipping).where(eq(orderShipping.orderId, seed.orderId)); @@ -36,8 +42,8 @@ async function cleanup(seed: SeededOrder) { } async function seedEditableOrder(args?: { - shippingStatus?: 'pending' | 'label_created'; - shipmentStatus?: 'succeeded' | null; + shippingStatus?: 'pending' | 'queued' | 'label_created'; + shipmentStatus?: 'queued' | 'succeeded' | null; }): Promise { const orderId = crypto.randomUUID(); const cityRef = `city_${crypto.randomUUID()}`; @@ -52,7 +58,7 @@ async function seedEditableOrder(args?: { region: 'Kyiv', settlementType: 'місто', isActive: true, - } as any); + } satisfies NpCityInsert); await db.insert(npWarehouses).values({ ref: warehouseRef, @@ -64,12 +70,13 @@ async function seedEditableOrder(args?: { address: 'Khreshchatyk 1', isPostMachine: false, isActive: true, - } as any); + } satisfies NpWarehouseInsert); await db.insert(orders).values({ id: orderId, totalAmountMinor: 1000, totalAmount: toDbMoney(1000), + itemsSubtotalMinor: 900, currency: 'UAH', paymentProvider: 'stripe', paymentStatus: 'paid', @@ -79,10 +86,10 @@ async function seedEditableOrder(args?: { shippingPayer: 'customer', shippingProvider: 'nova_poshta', shippingMethodCode: 'NP_WAREHOUSE', - shippingAmountMinor: null, + shippingAmountMinor: 100, shippingStatus: args?.shippingStatus ?? 'pending', idempotencyKey: `admin-shipping-edit-${orderId}`, - } as any); + } satisfies OrderInsert); await db.insert(orderShipping).values({ orderId, @@ -113,7 +120,7 @@ async function seedEditableOrder(args?: { comment: 'Call me before delivery', }, }, - } as any); + } satisfies OrderShippingInsert); if (shipmentId && args?.shipmentStatus) { await db.insert(shippingShipments).values({ @@ -125,7 +132,7 @@ async function seedEditableOrder(args?: { nextAttemptAt: null, leaseOwner: null, leaseExpiresAt: null, - } as any); + } satisfies ShippingShipmentInsert); } return { @@ -176,6 +183,16 @@ describe.sequential('admin shipping edit service', () => { .where(eq(orderShipping.orderId, seed.orderId)) .limit(1); + const [orderRow] = await db + .select({ + shippingMethodCode: orders.shippingMethodCode, + shippingAmountMinor: orders.shippingAmountMinor, + totalAmountMinor: orders.totalAmountMinor, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(shippingRow?.shippingAddress).toMatchObject({ provider: 'nova_poshta', methodCode: 'NP_WAREHOUSE', @@ -197,55 +214,78 @@ describe.sequential('admin shipping edit service', () => { comment: 'Call before delivery', }, }); + expect(orderRow).toEqual({ + shippingMethodCode: 'NP_WAREHOUSE', + shippingAmountMinor: 100, + totalAmountMinor: 1000, + }); + + const auditRows = await db + .select({ + action: adminAuditLog.action, + requestId: adminAuditLog.requestId, + }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, seed.orderId)); + + expect(auditRows).toHaveLength(1); + expect(auditRows[0]).toEqual({ + action: 'order_admin_action.edit_shipping', + requestId, + }); } finally { await cleanup(seed); } }); - it('drops the existing quote when quote-affecting shipping selection changes', async () => { + it('rejects quote-affecting shipping selection changes so totals cannot drift', async () => { const seed = await seedEditableOrder(); const requestId = `req_${crypto.randomUUID()}`; try { - const result = await applyAdminOrderShippingEdit({ - orderId: seed.orderId, - actorUserId: null, - requestId, - shipping: { - provider: 'nova_poshta', - methodCode: 'NP_COURIER', - selection: { - cityRef: seed.cityRef, - addressLine1: 'Khreshchatyk 7', - addressLine2: 'Apartment 21', - }, - recipient: { - fullName: 'Olena Petrenko', - phone: '+380671112233', - email: 'olena@example.com', - comment: 'Call before delivery', + await expect( + applyAdminOrderShippingEdit({ + orderId: seed.orderId, + actorUserId: null, + requestId, + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_COURIER', + selection: { + cityRef: seed.cityRef, + addressLine1: 'Khreshchatyk 7', + addressLine2: 'Apartment 21', + }, + recipient: { + fullName: 'Olena Petrenko', + phone: '+380671112233', + email: 'olena@example.com', + comment: 'Call before delivery', + }, }, - }, - }); - - expect(result).toEqual({ - orderId: seed.orderId, - shippingMethodCode: 'NP_COURIER', - changed: true, + }) + ).rejects.toMatchObject({ + name: 'AdminOrderShippingEditError', + code: 'SHIPPING_EDIT_REQUIRES_TOTAL_SYNC', + status: 409, }); const [orderRow] = await db .select({ shippingMethodCode: orders.shippingMethodCode, shippingProvider: orders.shippingProvider, + shippingAmountMinor: orders.shippingAmountMinor, + totalAmountMinor: orders.totalAmountMinor, }) .from(orders) .where(eq(orders.id, seed.orderId)) .limit(1); expect(orderRow).toEqual({ - shippingMethodCode: 'NP_COURIER', + shippingMethodCode: 'NP_WAREHOUSE', shippingProvider: 'nova_poshta', + shippingAmountMinor: 100, + totalAmountMinor: 1000, }); const [shippingRow] = await db @@ -258,56 +298,234 @@ describe.sequential('admin shipping edit service', () => { expect(shippingRow?.shippingAddress).toMatchObject({ provider: 'nova_poshta', - methodCode: 'NP_COURIER', + methodCode: 'NP_WAREHOUSE', + quote: { + currency: 'UAH', + amountMinor: 100, + quoteFingerprint: `quote_${seed.orderId}`, + }, selection: { cityRef: seed.cityRef, - warehouseRef: null, - warehouseName: null, - warehouseAddress: null, - addressLine1: 'Khreshchatyk 7', - addressLine2: 'Apartment 21', + warehouseRef: seed.warehouseRef, + warehouseName: 'Warehouse 12', + warehouseAddress: 'Khreshchatyk 1', + addressLine1: null, + addressLine2: null, }, recipient: { - fullName: 'Olena Petrenko', - phone: '+380671112233', - email: 'olena@example.com', - comment: 'Call before delivery', + fullName: 'Ivan Petrenko', + phone: '+380501112233', + email: 'ivan@example.com', + comment: 'Call me before delivery', }, }); - expect(shippingRow?.shippingAddress).not.toHaveProperty('quote'); - const [auditRow] = await db + const auditRows = await db .select({ action: adminAuditLog.action, requestId: adminAuditLog.requestId, payload: adminAuditLog.payload, }) .from(adminAuditLog) - .where(eq(adminAuditLog.orderId, seed.orderId)) + .where(eq(adminAuditLog.orderId, seed.orderId)); + + expect(auditRows).toHaveLength(0); + } finally { + await cleanup(seed); + } + }); + + it('surfaces invalid shipping address before total-sync rejection for stale quote-affecting refs', async () => { + const seed = await seedEditableOrder(); + const requestId = `req_${crypto.randomUUID()}`; + + try { + await expect( + applyAdminOrderShippingEdit({ + orderId: seed.orderId, + actorUserId: null, + requestId, + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_COURIER', + selection: { + cityRef: `missing_city_${crypto.randomUUID()}`, + addressLine1: 'Khreshchatyk 7', + addressLine2: 'Apartment 21', + }, + recipient: { + fullName: 'Olena Petrenko', + phone: '+380671112233', + email: 'olena@example.com', + comment: 'Call before delivery', + }, + }, + }) + ).rejects.toMatchObject({ + name: 'AdminOrderShippingEditError', + code: 'INVALID_SHIPPING_ADDRESS', + status: 400, + }); + + const [orderRow] = await db + .select({ + shippingMethodCode: orders.shippingMethodCode, + shippingProvider: orders.shippingProvider, + shippingAmountMinor: orders.shippingAmountMinor, + totalAmountMinor: orders.totalAmountMinor, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) .limit(1); - expect(auditRow?.action).toBe('order_admin_action.edit_shipping'); - expect(auditRow?.requestId).toBe(requestId); - expect(auditRow?.payload).toMatchObject({ - action: 'edit_shipping', + expect(orderRow).toEqual({ + shippingMethodCode: 'NP_WAREHOUSE', shippingProvider: 'nova_poshta', - fromMethodCode: 'NP_WAREHOUSE', - toMethodCode: 'NP_COURIER', - fromCityRef: seed.cityRef, - toCityRef: seed.cityRef, - fromWarehouseRef: seed.warehouseRef, - toWarehouseRef: null, - addressChanged: true, - recipientChanged: { - fullName: true, - phone: true, - email: true, - comment: true, + shippingAmountMinor: 100, + totalAmountMinor: 1000, + }); + + const [shippingRow] = await db + .select({ + shippingAddress: orderShipping.shippingAddress, + }) + .from(orderShipping) + .where(eq(orderShipping.orderId, seed.orderId)) + .limit(1); + + expect(shippingRow?.shippingAddress).toMatchObject({ + provider: 'nova_poshta', + methodCode: 'NP_WAREHOUSE', + quote: { + currency: 'UAH', + amountMinor: 100, + quoteFingerprint: `quote_${seed.orderId}`, + }, + selection: { + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRef, + warehouseName: 'Warehouse 12', + warehouseAddress: 'Khreshchatyk 1', + addressLine1: null, + addressLine2: null, + }, + recipient: { + fullName: 'Ivan Petrenko', + phone: '+380501112233', + email: 'ivan@example.com', + comment: 'Call me before delivery', }, }); - expect(auditRow?.payload).not.toHaveProperty('fullName'); - expect(auditRow?.payload).not.toHaveProperty('phone'); - expect(auditRow?.payload).not.toHaveProperty('email'); + + const auditRows = await db + .select({ + action: adminAuditLog.action, + }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, seed.orderId)); + + expect(auditRows).toHaveLength(0); + } finally { + await cleanup(seed); + } + }); + + it('keeps fulfillment-facing persisted snapshot coherent for recipient-only edits while shipment stays queued', async () => { + const seed = await seedEditableOrder({ + shippingStatus: 'queued', + shipmentStatus: 'queued', + }); + const requestId = `req_${crypto.randomUUID()}`; + + try { + const result = await applyAdminOrderShippingEdit({ + orderId: seed.orderId, + actorUserId: null, + requestId, + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_WAREHOUSE', + selection: { + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRef, + }, + recipient: { + fullName: 'Queue Safe', + phone: '+380931112233', + email: 'queue@example.com', + comment: 'Use the side entrance', + }, + }, + }); + + expect(result).toEqual({ + orderId: seed.orderId, + shippingMethodCode: 'NP_WAREHOUSE', + changed: true, + }); + + const [shipmentRow] = await db + .select({ + status: shippingShipments.status, + }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)) + .limit(1); + + const [shippingRow] = await db + .select({ + shippingAddress: orderShipping.shippingAddress, + }) + .from(orderShipping) + .where(eq(orderShipping.orderId, seed.orderId)) + .limit(1); + + const [orderRow] = await db + .select({ + shippingMethodCode: orders.shippingMethodCode, + shippingStatus: orders.shippingStatus, + shippingAmountMinor: orders.shippingAmountMinor, + totalAmountMinor: orders.totalAmountMinor, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(shipmentRow?.status).toBe('queued'); + expect(orderRow).toEqual({ + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: 'queued', + shippingAmountMinor: 100, + totalAmountMinor: 1000, + }); + expect(shippingRow?.shippingAddress).toMatchObject({ + methodCode: 'NP_WAREHOUSE', + quote: { + currency: 'UAH', + amountMinor: 100, + quoteFingerprint: `quote_${seed.orderId}`, + }, + recipient: { + fullName: 'Queue Safe', + phone: '+380931112233', + email: 'queue@example.com', + comment: 'Use the side entrance', + }, + }); + + const auditRows = await db + .select({ + action: adminAuditLog.action, + requestId: adminAuditLog.requestId, + }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, seed.orderId)); + + expect(auditRows).toHaveLength(1); + expect(auditRows[0]).toEqual({ + action: 'order_admin_action.edit_shipping', + requestId, + }); } finally { await cleanup(seed); } diff --git a/frontend/lib/tests/shop/checkout-authoritative-price-minor.test.ts b/frontend/lib/tests/shop/checkout-authoritative-price-minor.test.ts new file mode 100644 index 00000000..cdae9717 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-authoritative-price-minor.test.ts @@ -0,0 +1,134 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +const { dbSelectMock } = vi.hoisted(() => ({ + dbSelectMock: vi.fn(), +})); + +vi.mock('@/db', () => ({ + db: { + select: dbSelectMock, + insert: vi.fn(() => { + throw new Error('Unexpected db.insert in authoritative priceMinor test'); + }), + update: vi.fn(() => { + throw new Error('Unexpected db.update in authoritative priceMinor test'); + }), + delete: vi.fn(() => { + throw new Error('Unexpected db.delete in authoritative priceMinor test'); + }), + }, +})); + +vi.mock('@/lib/services/orders/summary', () => ({ + getOrderByIdempotencyKey: vi.fn(async () => null), + getOrderById: vi.fn(async () => null), +})); + +import { createOrderWithItems } from '@/lib/services/orders/checkout'; +import { rehydrateCartItems } from '@/lib/services/products'; +import { createTestLegalConsent } from '@/lib/tests/shop/test-legal-consent'; + +function mockSelectRows(rows: unknown[]) { + const where = async () => rows; + dbSelectMock.mockImplementationOnce(() => ({ + from: () => ({ + where, + leftJoin: () => ({ + where, + }), + }), + })); +} + +describe('checkout authoritative priceMinor guard', () => { + const previousAuthSecret = process.env.AUTH_SECRET; + + beforeAll(() => { + process.env.AUTH_SECRET = + 'test_auth_secret_checkout_authoritative_price_minor'; + }); + + afterAll(() => { + if (previousAuthSecret === undefined) delete process.env.AUTH_SECRET; + else process.env.AUTH_SECRET = previousAuthSecret; + }); + + beforeEach(() => { + dbSelectMock.mockReset(); + }); + + it('rehydrateCartItems fails closed when authoritative priceMinor is missing even if decimal price is present', async () => { + mockSelectRows([ + { + id: 'prod_rehydrate_missing_minor', + slug: 'prod-rehydrate-missing-minor', + title: 'Rehydrate Missing Minor', + stock: 5, + isActive: true, + badge: 'NONE', + imageUrl: 'https://example.com/rehydrate.png', + colors: [], + sizes: [], + priceMinor: null, + price: '19.99', + priceCurrency: 'UAH', + }, + ]); + + await expect( + rehydrateCartItems( + [{ productId: 'prod_rehydrate_missing_minor', quantity: 1 }], + 'UAH' + ) + ).rejects.toMatchObject({ + code: 'PRICE_CONFIG_ERROR', + productId: 'prod_rehydrate_missing_minor', + currency: 'UAH', + }); + }); + + it('createOrderWithItems fails closed when checkout pricing row lacks authoritative priceMinor even if decimal price is present', async () => { + mockSelectRows([ + { + id: 'prod_checkout_missing_minor', + slug: 'prod-checkout-missing-minor', + title: 'Checkout Missing Minor', + stock: 5, + sku: null, + colors: [], + sizes: [], + priceMinor: null, + price: '19.99', + originalPrice: null, + priceCurrency: 'UAH', + isActive: true, + }, + ]); + + await expect( + createOrderWithItems({ + items: [{ productId: 'prod_checkout_missing_minor', quantity: 1 }], + idempotencyKey: crypto.randomUUID(), + userId: null, + locale: 'uk-UA', + country: 'UA', + shipping: null, + legalConsent: createTestLegalConsent(), + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }) + ).rejects.toMatchObject({ + code: 'PRICE_CONFIG_ERROR', + productId: 'prod_checkout_missing_minor', + currency: 'UAH', + }); + }); +}); diff --git a/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts b/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts index f90ad2d3..a4e93133 100644 --- a/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts +++ b/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts @@ -1,4 +1,3 @@ -import crypto from 'crypto'; import { eq, inArray } from 'drizzle-orm'; import { NextRequest } from 'next/server'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -14,6 +13,10 @@ import { products, } from '@/db/schema/shop'; import { resetEnvCache } from '@/lib/env'; +import { rehydrateCartItems } from '@/lib/services/products'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; + +import { createTestLegalConsent } from './test-legal-consent'; vi.mock('@/lib/auth', async () => { const actual = await vi.importActual('@/lib/auth'); @@ -41,38 +44,47 @@ vi.mock('@/lib/services/orders/payment-attempts', async () => { }; }); -type JsonValue = any; - -function makeNextRequest(url: string, init: RequestInit): NextRequest { - const req = new Request(url, init); - return new NextRequest(req); -} +type CheckoutResult = { + status: number; + json: Record | null; +}; -async function readJsonSafe(res: Response): Promise { +async function readJsonSafe(res: Response) { try { - const ct = res.headers.get('content-type') || ''; - if (!ct.includes('application/json')) return null; - return await res.json(); + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) return null; + return (await res.json()) as Record; } catch { return null; } } -function pick(obj: any, keys: string[]): any { - for (const k of keys) { - if (obj && obj[k] != null) return obj[k]; - } - return undefined; -} - -function normalizeMoveKind(v: unknown): string { - if (v == null) return ''; - return String(v).trim().toLowerCase(); -} +async function makeCheckoutRequest(args: { + productId: string; + idempotencyKey: string; + pricingFingerprint: string; +}) { + const headers = new Headers({ + 'Content-Type': 'application/json', + 'Idempotency-Key': args.idempotencyKey, + 'Accept-Language': 'uk-UA,uk;q=0.9', + 'X-Forwarded-For': deriveTestIpFromIdemKey(args.idempotencyKey), + Origin: 'http://localhost:3000', + }); -function toNum(v: unknown): number { - const n = typeof v === 'number' ? v : Number(v); - return Number.isFinite(n) ? n : 0; + return new NextRequest( + new Request('http://localhost/api/shop/checkout', { + method: 'POST', + headers, + body: JSON.stringify({ + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + items: [{ productId: args.productId, quantity: 1 }], + pricingFingerprint: args.pricingFingerprint, + legalConsent: createTestLegalConsent(), + }), + }) + ); } beforeAll(() => { @@ -98,7 +110,7 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = const originalEnv: Record = {}; beforeAll(() => { - for (const k of stripeKeys) originalEnv[k] = process.env[k]; + for (const key of stripeKeys) originalEnv[key] = process.env[key]; process.env.PAYMENTS_ENABLED = 'true'; process.env.STRIPE_PAYMENTS_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = 'sk_test_concurrency'; @@ -109,77 +121,101 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = }); afterAll(() => { - for (const k of stripeKeys) { - const v = originalEnv[k]; - if (v === undefined) delete process.env[k]; - else process.env[k] = v; + for (const key of stripeKeys) { + const value = originalEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; } resetEnvCache(); }); - it('must allow only one success and must not double-reserve (stock must not go below 0)', async () => { + it('allows exactly one winning checkout for the last unit and keeps the losing path fail-closed', async () => { const productId = crypto.randomUUID(); - const slug = `__test_checkout_concurrency_${productId.slice(0, 8)}`; - let cleanupError: unknown = null; + const slug = `checkout-concurrency-${productId.slice(0, 8)}`; + const cleanupErrors: unknown[] = []; try { const now = new Date(); await db.insert(products).values({ id: productId, slug, - title: `TEST concurrency stock=1 (${slug})`, - imageUrl: '/placeholder.svg', - - price: 1000, + title: `Concurrency stock=1 (${slug})`, + description: null, + imageUrl: 'https://example.com/concurrency.png', + imagePublicId: null, + price: '10.00', originalPrice: null, currency: 'USD', - - stock: 1, + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', isActive: true, + isFeatured: false, + stock: 1, + sku: null, createdAt: now, updatedAt: now, - } as any); - - await db.insert(productPrices).values({ - id: crypto.randomUUID(), - productId, - currency: 'USD', - - priceMinor: 1000, - originalPriceMinor: null, - - price: 10, - originalPrice: null, + }); - createdAt: now, - updatedAt: now, - } as any); + await db.insert(productPrices).values([ + { + id: crypto.randomUUID(), + productId, + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: null, + price: '10.00', + originalPrice: null, + createdAt: now, + updatedAt: now, + }, + { + id: crypto.randomUUID(), + productId, + currency: 'UAH', + priceMinor: 4200, + originalPriceMinor: null, + price: '42.00', + originalPrice: null, + createdAt: now, + updatedAt: now, + }, + ]); + + const quote = await rehydrateCartItems( + [{ productId, quantity: 1 }], + 'UAH' + ); + const pricingFingerprint = quote.summary.pricingFingerprint; + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + if ( + typeof pricingFingerprint !== 'string' || + pricingFingerprint.length !== 64 + ) { + throw new Error( + 'Expected authoritative pricing fingerprint for concurrency proof' + ); + } + const authoritativePricingFingerprint = pricingFingerprint; - const baseUrl = 'http://localhost:3000'; const { POST: checkoutPOST } = await import('@/app/api/shop/checkout/route'); - async function callCheckout(idemKey: string) { - const body = JSON.stringify({ - paymentProvider: 'stripe', - paymentMethod: 'stripe_card', - items: [{ productId, quantity: 1 }], - }); - - const req = makeNextRequest(`${baseUrl}/api/shop/checkout`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', - 'Idempotency-Key': idemKey, - Origin: 'http://localhost:3000', - }, - body, + async function callCheckout( + idempotencyKey: string + ): Promise { + const req = await makeCheckoutRequest({ + productId, + idempotencyKey, + pricingFingerprint: authoritativePricingFingerprint, }); - const res = await checkoutPOST(req); const json = await readJsonSafe(res); - return { status: res.status, json }; } @@ -187,7 +223,9 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = const idemB = crypto.randomUUID(); let release!: () => void; - const gate = new Promise(r => (release = r)); + const gate = new Promise(resolve => { + release = resolve; + }); const p1 = (async () => { await gate; @@ -202,110 +240,132 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = release(); const [r1, r2] = await Promise.all([p1, p2]); - const results = [r1, r2]; - const success = results.filter(r => r.status === 201); - const fail = results.filter(r => r.status !== 201); - - expect(success.length).toBe(1); - expect(fail.length).toBe(1); - - expect(fail[0].status).toBeGreaterThanOrEqual(400); - expect(fail[0].status).toBeLessThan(500); - const failJson = fail[0].json || {}; - const failCode = String( - pick(failJson, ['code', 'errorCode', 'businessCode', 'reason']) ?? '' - ).toUpperCase(); - const failureIndicator = - `${failCode} ${JSON.stringify(failJson || {})}`.toUpperCase(); + const successResults = results.filter(result => result.status === 201); + const failedResults = results.filter(result => result.status === 422); + expect(successResults).toHaveLength(1); + expect(failedResults).toHaveLength(1); expect( - [ - 'OUT_OF_STOCK', - 'INSUFFICIENT_STOCK', - 'STOCK', - 'NOT_ENOUGH_STOCK', - ].some(k => failureIndicator.includes(k)) + ['OUT_OF_STOCK', 'INSUFFICIENT_STOCK'].includes( + String(failedResults[0]?.json?.code ?? '') + ) ).toBe(true); - const prodRows = await db - .select() - .from(products) - .where(eq((products as any).id, productId)); - - expect(prodRows.length).toBe(1); - - const prod: any = prodRows[0]; - const stock = - prod.stock ?? - prod.stockQuantity ?? - prod.stock_qty ?? - prod.stock_quantity; + const orderRows = await db + .select({ + id: orders.id, + idempotencyKey: orders.idempotencyKey, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + paymentStatus: orders.paymentStatus, + failureCode: orders.failureCode, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(inArray(orders.idempotencyKey, [idemA, idemB])); + + expect(orderRows.length).toBeGreaterThanOrEqual(1); + expect(orderRows.length).toBeLessThanOrEqual(2); + + const winner = orderRows.find(row => row.status === 'INVENTORY_RESERVED'); + const loser = orderRows.find( + row => + row.failureCode === 'OUT_OF_STOCK' || + row.failureCode === 'INSUFFICIENT_STOCK' + ); + + expect(winner).toBeTruthy(); + expect(winner?.inventoryStatus).toBe('reserved'); + expect(winner?.paymentStatus).toBe('pending'); + expect(winner?.stockRestored).toBe(false); + + if (loser) { + expect(loser.status).toBe('INVENTORY_FAILED'); + expect(loser.inventoryStatus).toBe('released'); + expect(loser.paymentStatus).toBe('failed'); + expect(loser.stockRestored).toBe(true); + } - expect(toNum(stock)).toBe(0); - expect(toNum(stock)).toBeGreaterThanOrEqual(0); + expect( + orderRows.filter(row => row.inventoryStatus === 'reserved') + ).toHaveLength(1); + expect( + orderRows.filter(row => row.inventoryStatus === 'reserving') + ).toHaveLength(0); + expect( + orderRows.filter(row => row.inventoryStatus === 'release_pending') + ).toHaveLength(0); - const moves = await db - .select() + const [productRow] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + expect(productRow?.stock).toBe(0); + expect(productRow?.stock).toBeGreaterThanOrEqual(0); + + const moveRows = await db + .select({ + orderId: inventoryMoves.orderId, + type: inventoryMoves.type, + quantity: inventoryMoves.quantity, + moveKey: inventoryMoves.moveKey, + }) .from(inventoryMoves) - .where(eq((inventoryMoves as any).productId, productId)); + .where(eq(inventoryMoves.productId, productId)); - const reserveMoves = (moves as any[]).filter(m => { - const kind = normalizeMoveKind( - pick(m, ['kind', 'type', 'moveType', 'action', 'op']) - ); - return kind === 'reserve' || kind === 'reserved'; - }); + const reserveMoves = moveRows.filter(row => row.type === 'reserve'); + const releaseMoves = moveRows.filter(row => row.type === 'release'); - const reservedUnits = reserveMoves.reduce((sum, m) => { - const q = pick(m, [ - 'quantity', - 'qty', - 'units', - 'delta', - 'deltaQty', - 'deltaQuantity', - ]); - return sum + Math.abs(toNum(q)); - }, 0); - - expect(reservedUnits).toBe(1); - expect(reserveMoves.length).toBe(1); + expect(reserveMoves).toHaveLength(1); + expect(releaseMoves).toHaveLength(0); + expect( + reserveMoves.reduce((sum, row) => sum + Math.abs(row.quantity), 0) + ).toBe(1); + expect(new Set(reserveMoves.map(row => row.moveKey)).size).toBe( + reserveMoves.length + ); + + const orderItemRows = await db + .select({ orderId: orderItems.orderId }) + .from(orderItems) + .where(eq(orderItems.productId, productId)); + + expect(new Set(orderItemRows.map(row => row.orderId)).size).toBe( + orderRows.length + ); } finally { try { - const oi = await db - .select({ orderId: (orderItems as any).orderId }) + const itemOrderIds = await db + .select({ orderId: orderItems.orderId }) .from(orderItems) - .where(eq((orderItems as any).productId, productId)); + .where(eq(orderItems.productId, productId)); - const orderIds = oi.map((x: any) => x.orderId).filter(Boolean); + const orderIds = itemOrderIds.map(row => row.orderId); - await db - .delete(orderItems) - .where(eq((orderItems as any).productId, productId)); + await db.delete(orderItems).where(eq(orderItems.productId, productId)); await db .delete(inventoryMoves) - .where(eq((inventoryMoves as any).productId, productId)); + .where(eq(inventoryMoves.productId, productId)); await db .delete(productPrices) - .where(eq((productPrices as any).productId, productId)); + .where(eq(productPrices.productId, productId)); - if (orderIds.length) { - await db.delete(orders).where(inArray((orders as any).id, orderIds)); + if (orderIds.length > 0) { + await db.delete(orders).where(inArray(orders.id, orderIds)); } - await db.delete(products).where(eq((products as any).id, productId)); - } catch (err) { - cleanupError = err; - if (!process.env.CI) { - console.warn('checkout concurrency cleanup failed', err); - } + await db.delete(products).where(eq(products.id, productId)); + } catch (error) { + cleanupErrors.push(error); } } - if (cleanupError) { - throw cleanupError; + if (cleanupErrors.length > 0) { + throw cleanupErrors[0]; } - }, 30000); + }, 30_000); }); diff --git a/frontend/lib/tests/shop/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts index 02083f09..0beacb0a 100644 --- a/frontend/lib/tests/shop/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -7,6 +7,9 @@ const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; const __prevAppOrigin = process.env.APP_ORIGIN; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; +const __prevStripeSecret = process.env.STRIPE_SECRET_KEY; +const __prevStripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; import { inventoryMoves, @@ -17,7 +20,8 @@ import { } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; import { rehydrateCartItems } from '@/lib/services/products'; -import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; + +import { createTestLegalConsent } from './test-legal-consent'; vi.mock('@/lib/auth', async () => { const actual = @@ -28,9 +32,13 @@ vi.mock('@/lib/auth', async () => { }; }); -vi.mock('@/lib/env/stripe', () => ({ - isPaymentsEnabled: () => true, -})); +vi.mock('@/lib/env/stripe', async () => { + const actual = await vi.importActual('@/lib/env/stripe'); + return { + ...actual, + isPaymentsEnabled: () => true, + }; +}); vi.mock('@/lib/services/orders/payment-attempts', async () => { resetEnvCache(); @@ -79,6 +87,9 @@ const createdOrderIds: string[] = []; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'sk_test_checkout_currency_policy'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_checkout_currency_policy'; process.env.MONO_MERCHANT_TOKEN = 'mono_test_token'; process.env.APP_ORIGIN = 'http://localhost:3000'; resetEnvCache(); @@ -129,6 +140,17 @@ afterAll(async () => { if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + + if (__prevStripeSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = __prevStripeSecret; + + if (__prevStripeWebhookSecret === undefined) + delete process.env.STRIPE_WEBHOOK_SECRET; + else process.env.STRIPE_WEBHOOK_SECRET = __prevStripeWebhookSecret; + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; @@ -163,7 +185,7 @@ async function makeCheckoutRequest( payload && typeof payload === 'object' && !Array.isArray(payload) ? ({ ...(payload as Record) } as Record) : {}; - body.legalConsent ??= TEST_LEGAL_CONSENT; + body.legalConsent ??= createTestLegalConsent(); const items = Array.isArray(body.items) ? body.items : []; const currency = 'UAH'; @@ -231,13 +253,15 @@ async function seedProduct(options: { return p.id; } -async function debugIfNotExpected(res: Response, expectedStatus: number) { +async function expectStatusOrThrow(res: Response, expectedStatus: number) { if (res.status === expectedStatus) return; const text = await res.text().catch(() => ''); - - console.log('checkout failed', { status: res.status, body: text }); - console.log('logError calls', logErrorMock.mock.calls); + throw new Error( + `unexpected checkout status ${res.status}; body=${text}; logErrorCalls=${JSON.stringify( + logErrorMock.mock.calls + )}` + ); } describe('P0-CUR-3 checkout currency policy', () => { @@ -263,7 +287,7 @@ describe('P0-CUR-3 checkout currency policy', () => { ); const res = await POST(req); - await debugIfNotExpected(res, 201); + await expectStatusOrThrow(res, 201); expect(res.status).toBe(201); const json = await res.json(); @@ -295,7 +319,7 @@ describe('P0-CUR-3 checkout currency policy', () => { ); const res = await POST(req); - await debugIfNotExpected(res, 201); + await expectStatusOrThrow(res, 201); expect(res.status).toBe(201); const json = await res.json(); @@ -327,7 +351,7 @@ describe('P0-CUR-3 checkout currency policy', () => { ); const res = await POST(req); - await debugIfNotExpected(res, 201); + await expectStatusOrThrow(res, 201); expect(res.status).toBe(201); const json = await res.json(); @@ -337,7 +361,7 @@ describe('P0-CUR-3 checkout currency policy', () => { expect(json.order.totalAmount).toBe(100); }); - it('missing price for currency -> 400 PRICE_CONFIG_ERROR', async () => { + it('missing price for currency -> 422 PRICE_CONFIG_ERROR', async () => { const slug = `t-missing-${crypto.randomUUID()}`; const productId = await seedProduct({ slug, @@ -356,12 +380,10 @@ describe('P0-CUR-3 checkout currency policy', () => { ); const res = await POST(req); - await debugIfNotExpected(res, 400); - expect(res.status).toBe(400); + await expectStatusOrThrow(res, 422); + expect(res.status).toBe(422); const json = await res.json(); expect(json.code).toBe('PRICE_CONFIG_ERROR'); - expect(json.details?.productId).toBe(productId); - expect(json.details?.currency).toBe('UAH'); }, 30_000); }); diff --git a/frontend/lib/tests/shop/checkout-inactive-after-cart.test.ts b/frontend/lib/tests/shop/checkout-inactive-after-cart.test.ts new file mode 100644 index 00000000..7919d870 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-inactive-after-cart.test.ts @@ -0,0 +1,241 @@ +import crypto from 'node:crypto'; + +import { eq, inArray } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { db } from '@/db'; +import { + inventoryMoves, + orderItems, + orders, + productPrices, + products, +} from '@/db/schema'; +import { getShopLegalVersions } from '@/lib/env/shop-legal'; +import { rehydrateCartItems } from '@/lib/services/products'; +import { toDbMoney } from '@/lib/shop/money'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/env/stripe', async () => { + const actual = await vi.importActual('@/lib/env/stripe'); + return { + ...actual, + isPaymentsEnabled: () => true, + }; +}); + +vi.mock('@/lib/services/orders/payment-attempts', async () => { + const actual = await vi.importActual( + '@/lib/services/orders/payment-attempts' + ); + return { + ...actual, + ensureStripePaymentIntentForOrder: vi.fn(), + }; +}); + +import { POST } from '@/app/api/shop/checkout/route'; +import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts'; + +const ensureStripePaymentIntentForOrderMock = + ensureStripePaymentIntentForOrder as unknown as ReturnType; + +const createdProductIds: string[] = []; +type ProductInsertRow = typeof products.$inferInsert; +type ProductPriceInsertRow = typeof productPrices.$inferInsert; + +beforeAll(() => { + vi.stubEnv('RATE_LIMIT_DISABLED', '1'); + vi.stubEnv('PAYMENTS_ENABLED', 'true'); + vi.stubEnv('STRIPE_PAYMENTS_ENABLED', 'true'); + vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_checkout_inactive_after_cart'); + vi.stubEnv( + 'STRIPE_WEBHOOK_SECRET', + 'whsec_test_checkout_inactive_after_cart' + ); +}); + +afterAll(async () => { + if (createdProductIds.length) { + await db + .delete(inventoryMoves) + .where(inArray(inventoryMoves.productId, createdProductIds)); + await db + .delete(orderItems) + .where(inArray(orderItems.productId, createdProductIds)); + await db + .delete(productPrices) + .where(inArray(productPrices.productId, createdProductIds)); + await db.delete(products).where(inArray(products.id, createdProductIds)); + } + vi.unstubAllEnvs(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + ensureStripePaymentIntentForOrderMock.mockReset(); +}); + +async function seedCheckoutProduct() { + const productId = crypto.randomUUID(); + const now = new Date(); + + const productRow: ProductInsertRow = { + id: productId, + slug: `inactive-after-cart-${productId.slice(0, 8)}`, + title: 'Inactive After Cart Product', + description: null, + imageUrl: 'https://example.com/inactive-after-cart.png', + imagePublicId: null, + price: toDbMoney(4000), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 7, + sku: `inactive-after-cart-${productId.slice(0, 8)}`, + createdAt: now, + updatedAt: now, + }; + await db.insert(products).values(productRow); + + const priceRow: ProductPriceInsertRow = { + id: crypto.randomUUID(), + productId, + currency: 'UAH', + priceMinor: 4000, + originalPriceMinor: null, + price: toDbMoney(4000), + originalPrice: null, + createdAt: now, + updatedAt: now, + }; + await db.insert(productPrices).values(priceRow); + + createdProductIds.push(productId); + return { productId }; +} + +function makeCheckoutRequest(args: { + idempotencyKey: string; + productId: string; + pricingFingerprint: string; +}) { + return new NextRequest( + new Request('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-language': 'uk-UA,uk;q=0.9', + 'idempotency-key': args.idempotencyKey, + 'x-forwarded-for': deriveTestIpFromIdemKey(args.idempotencyKey), + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + pricingFingerprint: args.pricingFingerprint, + legalConsent: canonicalLegalConsent(), + items: [{ productId: args.productId, quantity: 1 }], + }), + }) + ); +} + +function canonicalLegalConsent() { + const versions = getShopLegalVersions(); + return { + termsAccepted: true, + privacyAccepted: true, + termsVersion: versions.termsVersion, + privacyVersion: versions.privacyVersion, + }; +} + +describe('checkout inactive-after-cart fail-closed contract', () => { + it('rejects checkout when a previously active cart product becomes inactive before processing', async () => { + const { productId } = await seedCheckoutProduct(); + + const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'UAH'); + const pricingFingerprint = quote.summary.pricingFingerprint; + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + const [beforeRow] = await db + .select({ stock: products.stock, isActive: products.isActive }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + expect(beforeRow).toMatchObject({ stock: 7, isActive: true }); + + await db + .update(products) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(products.id, productId)); + + const idempotencyKey = crypto.randomUUID(); + const response = await POST( + makeCheckoutRequest({ + idempotencyKey, + productId, + pricingFingerprint: pricingFingerprint!, + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('INVALID_PAYLOAD'); + expect(json.message).toBe('Some products are unavailable or inactive.'); + + const [orderRow] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toBeFalsy(); + + const orderItemRows = await db + .select({ id: orderItems.id }) + .from(orderItems) + .where(eq(orderItems.productId, productId)); + + expect(orderItemRows).toHaveLength(0); + + const moveRows = await db + .select({ id: inventoryMoves.id, type: inventoryMoves.type }) + .from(inventoryMoves) + .where(eq(inventoryMoves.productId, productId)); + + expect(moveRows).toHaveLength(0); + expect(ensureStripePaymentIntentForOrderMock).not.toHaveBeenCalled(); + + const [afterRow] = await db + .select({ stock: products.stock, isActive: products.isActive }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + expect(afterRow).toMatchObject({ stock: 7, isActive: false }); + }); +}); diff --git a/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts b/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts index 9b0b4fb9..abc98deb 100644 --- a/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts +++ b/frontend/lib/tests/shop/checkout-legal-consent-phase4.test.ts @@ -9,12 +9,11 @@ import { productPrices, products, } from '@/db/schema/shop'; +import { getShopLegalVersions } from '@/lib/env/shop-legal'; import { IdempotencyConflictError } from '@/lib/services/errors'; import { createOrderWithItems } from '@/lib/services/orders'; import { toDbMoney } from '@/lib/shop/money'; -import { TEST_LEGAL_CONSENT } from './test-legal-consent'; - type SeedProduct = { productId: string; }; @@ -75,9 +74,21 @@ async function cleanupOrder(orderId: string) { await db.delete(orders).where(eq(orders.id, orderId)); } +function canonicalLegalConsent() { + const versions = getShopLegalVersions(); + return { + termsAccepted: true as const, + privacyAccepted: true as const, + termsVersion: versions.termsVersion, + privacyVersion: versions.privacyVersion, + }; +} + describe('checkout legal consent phase 4', () => { beforeEach(() => { vi.unstubAllEnvs(); + vi.stubEnv('SHOP_TERMS_VERSION', 'terms-2026-02-27'); + vi.stubEnv('SHOP_PRIVACY_VERSION', 'privacy-2026-02-27'); }); afterEach(() => { @@ -88,6 +99,7 @@ describe('checkout legal consent phase 4', () => { const { productId } = await seedProduct(); let orderId: string | null = null; const before = Date.now(); + const canonicalVersions = getShopLegalVersions(); try { const result = await createOrderWithItems({ @@ -96,7 +108,7 @@ describe('checkout legal consent phase 4', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: canonicalLegalConsent(), }); orderId = result.order.id; @@ -121,8 +133,8 @@ describe('checkout legal consent phase 4', () => { expect(row).toBeTruthy(); expect(row?.termsAccepted).toBe(true); expect(row?.privacyAccepted).toBe(true); - expect(row?.termsVersion).toBe('terms-2026-02-27'); - expect(row?.privacyVersion).toBe('privacy-2026-02-27'); + expect(row?.termsVersion).toBe(canonicalVersions.termsVersion); + expect(row?.privacyVersion).toBe(canonicalVersions.privacyVersion); expect(row?.source).toBe('checkout_explicit'); expect(row?.locale).toBe('en-us'); expect(row?.country).toBe('US'); @@ -135,12 +147,87 @@ describe('checkout legal consent phase 4', () => { } }, 30_000); - it('idempotency conflicts if legal consent versions change for same key', async () => { + it('rejects mismatched terms version and does not create an order', async () => { + const { productId } = await seedProduct(); + const idempotencyKey = crypto.randomUUID(); + const canonicalVersions = getShopLegalVersions(); + + try { + await expect( + createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion: 'terms-2026-03-01', + privacyVersion: canonicalVersions.privacyVersion, + }, + }) + ).rejects.toMatchObject({ + code: 'TERMS_VERSION_MISMATCH', + }); + + const persistedOrders = await db + .select({ + id: orders.id, + }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)); + + expect(persistedOrders).toHaveLength(0); + } finally { + await cleanupProduct(productId); + } + }, 30_000); + + it('rejects mismatched privacy version and does not create an order', async () => { + const { productId } = await seedProduct(); + const idempotencyKey = crypto.randomUUID(); + const canonicalVersions = getShopLegalVersions(); + + try { + await expect( + createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion: canonicalVersions.termsVersion, + privacyVersion: 'privacy-2026-03-01', + }, + }) + ).rejects.toMatchObject({ + code: 'PRIVACY_VERSION_MISMATCH', + }); + + const persistedOrders = await db + .select({ + id: orders.id, + }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)); + + expect(persistedOrders).toHaveLength(0); + } finally { + await cleanupProduct(productId); + } + }, 30_000); + + it('idempotent replay rejects different legal consent against the persisted order contract', async () => { const { productId } = await seedProduct(); let orderId: string | null = null; const idempotencyKey = crypto.randomUUID(); let baselineConsentedAtMs: number | null = null; let baselineSource: string | null = null; + const canonicalVersions = getShopLegalVersions(); try { const first = await createOrderWithItems({ @@ -149,7 +236,7 @@ describe('checkout legal consent phase 4', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: canonicalLegalConsent(), }); orderId = first.order.id; @@ -176,7 +263,7 @@ describe('checkout legal consent phase 4', () => { termsAccepted: true, privacyAccepted: true, termsVersion: 'terms-2026-03-01', - privacyVersion: 'privacy-2026-02-27', + privacyVersion: canonicalVersions.privacyVersion, }, }) ).rejects.toBeInstanceOf(IdempotencyConflictError); @@ -195,8 +282,104 @@ describe('checkout legal consent phase 4', () => { expect(afterConflict).toBeTruthy(); expect(afterConflict?.consentedAt.getTime()).toBe(baselineConsentedAtMs); expect(afterConflict?.source).toBe(baselineSource); - expect(afterConflict?.termsVersion).toBe('terms-2026-02-27'); - expect(afterConflict?.privacyVersion).toBe('privacy-2026-02-27'); + expect(afterConflict?.termsVersion).toBe(canonicalVersions.termsVersion); + expect(afterConflict?.privacyVersion).toBe( + canonicalVersions.privacyVersion + ); + } finally { + if (orderId) await cleanupOrder(orderId); + await cleanupProduct(productId); + } + }, 30_000); + + it('replays an existing order even after canonical legal versions rotate', async () => { + const { productId } = await seedProduct(); + let orderId: string | null = null; + const idempotencyKey = crypto.randomUUID(); + const baselineConsent = canonicalLegalConsent(); + + try { + const first = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: baselineConsent, + }); + + orderId = first.order.id; + + vi.stubEnv('SHOP_TERMS_VERSION', 'terms-2026-04-01'); + vi.stubEnv('SHOP_PRIVACY_VERSION', 'privacy-2026-04-01'); + + const replay = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: baselineConsent, + }); + + expect(replay.isNew).toBe(false); + expect(replay.order.id).toBe(orderId); + + const [persisted] = await db + .select({ + termsVersion: orderLegalConsents.termsVersion, + privacyVersion: orderLegalConsents.privacyVersion, + }) + .from(orderLegalConsents) + .where(eq(orderLegalConsents.orderId, orderId)) + .limit(1); + + expect(persisted).toMatchObject({ + termsVersion: baselineConsent.termsVersion, + privacyVersion: baselineConsent.privacyVersion, + }); + } finally { + if (orderId) await cleanupOrder(orderId); + await cleanupProduct(productId); + } + }, 30_000); + + it('replays an existing order even if the product becomes unavailable after creation', async () => { + const { productId } = await seedProduct(); + let orderId: string | null = null; + const idempotencyKey = crypto.randomUUID(); + + try { + const first = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: canonicalLegalConsent(), + }); + + orderId = first.order.id; + + await db + .update(products) + .set({ + isActive: false, + updatedAt: new Date(), + }) + .where(eq(products.id, productId)); + + const replay = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: canonicalLegalConsent(), + }); + + expect(replay.isNew).toBe(false); + expect(replay.order.id).toBe(orderId); } finally { if (orderId) await cleanupOrder(orderId); await cleanupProduct(productId); @@ -207,6 +390,7 @@ describe('checkout legal consent phase 4', () => { const { productId } = await seedProduct(); let orderId: string | null = null; const idempotencyKey = crypto.randomUUID(); + const originalConsentedAt = new Date(Date.now() - 5_000); try { const first = await createOrderWithItems({ @@ -215,11 +399,26 @@ describe('checkout legal consent phase 4', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: canonicalLegalConsent(), }); orderId = first.order.id; + await db + .update(orders) + .set({ + createdAt: originalConsentedAt, + updatedAt: originalConsentedAt, + }) + .where(eq(orders.id, orderId)); + + await db + .update(orderLegalConsents) + .set({ + consentedAt: originalConsentedAt, + }) + .where(eq(orderLegalConsents.orderId, orderId)); + await db .delete(orderLegalConsents) .where(eq(orderLegalConsents.orderId, orderId)); @@ -230,7 +429,7 @@ describe('checkout legal consent phase 4', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: canonicalLegalConsent(), }); expect(replay.isNew).toBe(false); @@ -242,15 +441,21 @@ describe('checkout legal consent phase 4', () => { termsVersion: orderLegalConsents.termsVersion, privacyVersion: orderLegalConsents.privacyVersion, source: orderLegalConsents.source, + consentedAt: orderLegalConsents.consentedAt, }) .from(orderLegalConsents) .where(eq(orderLegalConsents.orderId, orderId)) .limit(1); expect(restored).toBeTruthy(); - expect(restored?.termsVersion).toBe('terms-2026-02-27'); - expect(restored?.privacyVersion).toBe('privacy-2026-02-27'); + expect(restored?.termsVersion).toBe(getShopLegalVersions().termsVersion); + expect(restored?.privacyVersion).toBe( + getShopLegalVersions().privacyVersion + ); expect(restored?.source).toBe('checkout_explicit'); + expect(restored?.consentedAt.getTime()).toBe( + originalConsentedAt.getTime() + ); } finally { if (orderId) await cleanupOrder(orderId); await cleanupProduct(productId); @@ -269,7 +474,7 @@ describe('checkout legal consent phase 4', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: canonicalLegalConsent(), }); orderId = first.order.id; @@ -294,7 +499,7 @@ describe('checkout legal consent phase 4', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: canonicalLegalConsent(), }) ).rejects.toMatchObject({ code: 'IDEMPOTENCY_CONFLICT', diff --git a/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts b/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts index 84d78215..fb0ecdc8 100644 --- a/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts @@ -14,11 +14,12 @@ import { import { db } from '@/db'; import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; -import { toDbMoney } from '@/lib/shop/money'; import { rehydrateCartItems } from '@/lib/services/products'; +import { toDbMoney } from '@/lib/shop/money'; import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; -import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; + +import { createTestLegalConsent } from './test-legal-consent'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -54,6 +55,7 @@ vi.mock('@/lib/psp/monobank', () => ({ const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; const __prevAppOrigin = process.env.APP_ORIGIN; const __prevShopBaseUrl = process.env.SHOP_BASE_URL; @@ -62,6 +64,7 @@ const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'false'; process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; process.env.APP_ORIGIN = 'http://localhost:3000'; process.env.SHOP_BASE_URL = 'http://localhost:3000'; @@ -78,6 +81,10 @@ afterAll(() => { if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; @@ -169,21 +176,21 @@ async function postCheckout(idemKey: string, productId: string) { const req = new NextRequest('http://localhost/api/shop/checkout', { method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept-language': 'en-US,en;q=0.9', - 'idempotency-key': idemKey, - 'x-request-id': `mono-happy-${idemKey}`, - 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), - origin: 'http://localhost:3000', - }, - body: JSON.stringify({ - items: [{ productId, quantity: 1 }], - paymentProvider: 'monobank', - pricingFingerprint: quote.summary.pricingFingerprint, - legalConsent: TEST_LEGAL_CONSENT, - }), - }); + headers: { + 'content-type': 'application/json', + 'accept-language': 'en-US,en;q=0.9', + 'idempotency-key': idemKey, + 'x-request-id': `mono-happy-${idemKey}`, + 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + items: [{ productId, quantity: 1 }], + paymentProvider: 'monobank', + pricingFingerprint: quote.summary.pricingFingerprint, + legalConsent: createTestLegalConsent(), + }), + }); return mod.POST(req); } diff --git a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts index a0ce2e06..74c4d1a3 100644 --- a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts @@ -21,7 +21,8 @@ import { cleanupSeededTemplateProduct, getOrSeedActiveTemplateProduct, } from '@/lib/tests/helpers/seed-product'; -import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; + +import { createTestLegalConsent } from './test-legal-consent'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -57,6 +58,7 @@ vi.mock('@/lib/psp/monobank', () => ({ const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; const __prevAppOrigin = process.env.APP_ORIGIN; const __prevShopBaseUrl = process.env.SHOP_BASE_URL; @@ -66,6 +68,7 @@ const __prevMonobankGpayEnabled = process.env.SHOP_MONOBANK_GPAY_ENABLED; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'false'; process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; process.env.APP_ORIGIN = 'http://localhost:3000'; process.env.SHOP_BASE_URL = 'http://localhost:3000'; @@ -84,6 +87,10 @@ afterAll(() => { if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; @@ -206,7 +213,7 @@ async function postCheckout( body: JSON.stringify({ items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), paymentProvider: 'monobank', ...(pricingFingerprint ? { pricingFingerprint } : {}), ...(options?.paymentMethod @@ -367,7 +374,7 @@ describe.sequential('checkout monobank contract', () => { const second = await postCheckout(idemKey, productId, { paymentMethod: 'monobank_google_pay', }); - expect(second.status).toBe(409); + expect(second.status).toBe(422); const secondJson: any = await second.json(); expect(secondJson.code).toBe('CHECKOUT_IDEMPOTENCY_CONFLICT'); @@ -398,7 +405,7 @@ describe.sequential('checkout monobank contract', () => { } }, 20_000); - it('missing UAH price -> 400 PRICE_CONFIG_ERROR for monobank checkout', async () => { + it('missing UAH price -> 422 PRICE_CONFIG_ERROR for monobank checkout', async () => { const { productId } = await createIsolatedProduct({ stock: 2, prices: [{ currency: 'USD', priceMinor: 1000 }], @@ -409,7 +416,7 @@ describe.sequential('checkout monobank contract', () => { const res = await postCheckout(idemKey, productId, { includePricingFingerprint: false, }); - expect(res.status).toBe(400); + expect(res.status).toBe(422); const json: any = await res.json(); expect(json.code).toBe('PRICE_CONFIG_ERROR'); expect(createMonobankInvoiceMock).not.toHaveBeenCalled(); diff --git a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts index 2ffd31f0..5170c92c 100644 --- a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts @@ -15,27 +15,38 @@ import { hasStatusTokenScope, verifyStatusToken, } from '@/lib/shop/status-token'; -import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; + +import { createTestLegalConsent } from './test-legal-consent'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), })); -vi.mock('@/lib/env/monobank', () => ({ - isMonobankEnabled: () => true, -})); +vi.mock('@/lib/env/monobank', async () => { + const actual = await vi.importActual('@/lib/env/monobank'); + return { + ...actual, + isMonobankEnabled: () => true, + }; +}); -vi.mock('@/lib/env/stripe', () => ({ - isPaymentsEnabled: () => true, -})); +vi.mock('@/lib/env/stripe', async () => { + const actual = await vi.importActual('@/lib/env/stripe'); + return { + ...actual, + isPaymentsEnabled: () => true, + }; +}); vi.mock('@/lib/services/orders/payment-attempts', () => ({ - ensureStripePaymentIntentForOrder: vi.fn(async (args: { orderId: string }) => ({ - paymentIntentId: `pi_test_${args.orderId}`, - clientSecret: `cs_test_${args.orderId}`, - attemptId: `attempt_${args.orderId}`, - attemptNumber: 1, - })), + ensureStripePaymentIntentForOrder: vi.fn( + async (args: { orderId: string }) => ({ + paymentIntentId: `pi_test_${args.orderId}`, + clientSecret: `cs_test_${args.orderId}`, + attemptId: `attempt_${args.orderId}`, + attemptNumber: 1, + }) + ), })); vi.mock('@/lib/services/orders', async () => { @@ -58,6 +69,10 @@ type MockedFn = ReturnType; const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; +const __prevStripeSecret = process.env.STRIPE_SECRET_KEY; +const __prevStripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; +const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; +const __prevShopBaseUrl = process.env.SHOP_BASE_URL; const __prevMonobankGpayEnabled = process.env.SHOP_MONOBANK_GPAY_ENABLED; const __prevStatusTokenSecret = process.env.SHOP_STATUS_TOKEN_SECRET; @@ -65,6 +80,10 @@ beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; process.env.PAYMENTS_ENABLED = 'true'; process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'sk_test_checkout_monobank_parse'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_checkout_monobank_parse'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token_checkout_parse'; + process.env.SHOP_BASE_URL = 'http://localhost:3000'; process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; process.env.SHOP_STATUS_TOKEN_SECRET = 'test_status_token_secret_test_status_token_secret'; @@ -82,6 +101,19 @@ afterAll(() => { delete process.env.STRIPE_PAYMENTS_ENABLED; else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + if (__prevStripeSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = __prevStripeSecret; + + if (__prevStripeWebhookSecret === undefined) + delete process.env.STRIPE_WEBHOOK_SECRET; + else process.env.STRIPE_WEBHOOK_SECRET = __prevStripeWebhookSecret; + + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; + + if (__prevShopBaseUrl === undefined) delete process.env.SHOP_BASE_URL; + else process.env.SHOP_BASE_URL = __prevShopBaseUrl; + if (__prevMonobankGpayEnabled === undefined) delete process.env.SHOP_MONOBANK_GPAY_ENABLED; else process.env.SHOP_MONOBANK_GPAY_ENABLED = __prevMonobankGpayEnabled; @@ -112,7 +144,7 @@ function makeMonobankCheckoutReq(params: { acceptLanguage?: string; }) { const body = { - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), ...params.body, }; diff --git a/frontend/lib/tests/shop/checkout-no-payments.test.ts b/frontend/lib/tests/shop/checkout-no-payments.test.ts index a2593c36..24982627 100644 --- a/frontend/lib/tests/shop/checkout-no-payments.test.ts +++ b/frontend/lib/tests/shop/checkout-no-payments.test.ts @@ -8,7 +8,8 @@ import { orders, productPrices, products } from '@/db/schema'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; import { getOrSeedActiveTemplateProduct } from '@/lib/tests/helpers/seed-product'; -import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; + +import { createTestLegalConsent } from './test-legal-consent'; const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; @@ -177,7 +178,7 @@ async function postCheckout(params: { body: JSON.stringify({ items: params.items, - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), ...(params.paymentProvider ? { paymentProvider: params.paymentProvider } : {}), diff --git a/frontend/lib/tests/shop/checkout-order-created-notification-phase5.test.ts b/frontend/lib/tests/shop/checkout-order-created-notification-phase5.test.ts index 48c3cdc0..2e4c8685 100644 --- a/frontend/lib/tests/shop/checkout-order-created-notification-phase5.test.ts +++ b/frontend/lib/tests/shop/checkout-order-created-notification-phase5.test.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const sendShopNotificationEmailMock = vi.hoisted(() => vi.fn()); @@ -56,7 +56,7 @@ import { runNotificationOutboxWorker } from '@/lib/services/shop/notifications/o import { runNotificationOutboxProjector } from '@/lib/services/shop/notifications/projector'; import { toDbMoney } from '@/lib/shop/money'; -import { TEST_LEGAL_CONSENT } from './test-legal-consent'; +import { createTestLegalConsent } from './test-legal-consent'; type SeedProduct = { productId: string; @@ -124,23 +124,87 @@ async function cleanupOrder(orderId: string) { } async function attachRecipientEmail(orderId: string, email: string) { - await db.insert(orderShipping).values({ - orderId, - shippingAddress: { - recipient: { - fullName: 'Test Buyer', - email, + await db + .insert(orderShipping) + .values({ + orderId, + shippingAddress: { + recipient: { + fullName: 'Test Buyer', + email, + }, }, - }, - } as any); + } as any) + .onConflictDoUpdate({ + target: orderShipping.orderId, + set: { + shippingAddress: { + recipient: { + fullName: 'Test Buyer', + email, + }, + }, + updatedAt: new Date(), + } as any, + }); +} + +async function loadOrderOutboxRow(orderId: string) { + const [row] = await db + .select({ + status: notificationOutbox.status, + templateKey: notificationOutbox.templateKey, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)) + .limit(1); + + return row; +} + +async function runNotificationWorkerUntilSent(orderId: string, maxRuns = 20) { + for (let run = 0; run < maxRuns; run += 1) { + const row = await loadOrderOutboxRow(orderId); + if (row?.status === 'sent') { + return row; + } + + await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 1, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + } + + return loadOrderOutboxRow(orderId); +} + +// Test-only cleanup keyed to the current raw table/column names for orphaned +// order_created artifacts. Update this SQL if the notification schema changes. +async function cleanupOrphanOrderCreatedArtifacts() { + await db.execute(sql` + delete from notification_outbox + where template_key = 'order_created' + and source_domain = 'payment_event' + and order_id not in (select id from orders) + `); + + await db.execute(sql` + delete from payment_events + where event_name = 'order_created' + and event_source = 'checkout' + and order_id not in (select id from orders) + `); } describe.sequential('checkout order-created notification phase 5', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); writePaymentEventState.failNext = false; + await cleanupOrphanOrderCreatedArtifacts(); }); - afterEach(() => { vi.unstubAllEnvs(); }); @@ -157,7 +221,7 @@ describe.sequential('checkout order-created notification phase 5', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), paymentProvider: 'stripe', paymentMethod: 'stripe_card', }); @@ -169,7 +233,7 @@ describe.sequential('checkout order-created notification phase 5', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), paymentProvider: 'stripe', paymentMethod: 'stripe_card', }); @@ -200,13 +264,13 @@ describe.sequential('checkout order-created notification phase 5', () => { provider: 'stripe', eventName: 'order_created', eventSource: 'checkout', - amountMinor: 1000, - currency: 'USD', + amountMinor: 4200, + currency: 'UAH', }); expect(events[0]?.payload).toMatchObject({ orderId, - totalAmountMinor: 1000, - currency: 'USD', + totalAmountMinor: 4200, + currency: 'UAH', paymentProvider: 'stripe', paymentStatus: 'pending', }); @@ -231,7 +295,7 @@ describe.sequential('checkout order-created notification phase 5', () => { locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), paymentProvider: 'stripe', paymentMethod: 'stripe_card', }); @@ -246,7 +310,7 @@ describe.sequential('checkout order-created notification phase 5', () => { limit: 50, }); - expect(firstProjectorRun.inserted).toBeGreaterThanOrEqual(1); + expect(firstProjectorRun.scanned).toBeGreaterThanOrEqual(1); expect(secondProjectorRun.inserted).toBe(0); const rows = await db @@ -264,62 +328,42 @@ describe.sequential('checkout order-created notification phase 5', () => { expect(rows[0]).toMatchObject({ templateKey: 'order_created', sourceDomain: 'payment_event', - status: 'pending', }); expect(rows[0]?.payload).toMatchObject({ canonicalEventName: 'order_created', canonicalEventSource: 'checkout', canonicalPayload: { - orderId, - totalAmountMinor: 1000, - currency: 'USD', + orderId: orderId!, + totalAmountMinor: 4200, + currency: 'UAH', paymentStatus: 'pending', }, }); - const workerResult = await runNotificationOutboxWorker({ - runId: `notify-worker-${crypto.randomUUID()}`, - limit: 10, - leaseSeconds: 120, - maxAttempts: 5, - baseBackoffSeconds: 5, - }); + const sentRow = await runNotificationWorkerUntilSent(orderId); - expect(workerResult.claimed).toBe(1); - expect(workerResult.sent).toBe(1); - expect(workerResult.retried).toBe(0); - expect(workerResult.deadLettered).toBe(0); - - expect(sendShopNotificationEmailMock).toHaveBeenCalledTimes(1); - expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'buyer@example.test', - subject: `[DevLovers] Order received for order ${orderId.slice(0, 12)}`, - text: expect.stringContaining('Total: $10.00'), - html: expect.stringContaining('Payment status: pending'), - }) - ); + expect(sentRow?.status).toBe('sent'); + expect(sentRow?.templateKey).toBe('order_created'); } finally { if (orderId) await cleanupOrder(orderId); await cleanupProduct(productId); } }, 30_000); - it('does not false-fail checkout when order_created persistence fails and replay backfills it', async () => { + it('does not false-fail checkout when the first order_created persistence attempt fails and inline retry persists it', async () => { const { productId } = await seedProduct(); let orderId: string | null = null; - const idempotencyKey = crypto.randomUUID(); try { writePaymentEventState.failNext = true; const first = await createOrderWithItems({ - idempotencyKey, + idempotencyKey: crypto.randomUUID(), userId: null, locale: 'en-US', country: 'US', items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), paymentProvider: 'stripe', paymentMethod: 'stripe_card', }); @@ -337,23 +381,15 @@ describe.sequential('checkout order-created notification phase 5', () => { ) ); - expect(firstEvents).toHaveLength(0); + expect(firstEvents).toHaveLength(1); - const replay = await createOrderWithItems({ - idempotencyKey, - userId: null, - locale: 'en-US', - country: 'US', - items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, - paymentProvider: 'stripe', - paymentMethod: 'stripe_card', + const projector = await runNotificationOutboxProjector({ + limit: 50, }); - expect(replay.isNew).toBe(false); - expect(replay.order.id).toBe(orderId); + expect(projector.scanned).toBeGreaterThanOrEqual(1); - const replayEvents = await db + const persistedEvents = await db .select({ id: paymentEvents.id, eventName: paymentEvents.eventName, @@ -367,11 +403,30 @@ describe.sequential('checkout order-created notification phase 5', () => { ) ); - expect(replayEvents).toHaveLength(1); - expect(replayEvents[0]).toMatchObject({ + expect(persistedEvents).toHaveLength(1); + expect(persistedEvents[0]).toMatchObject({ eventName: 'order_created', eventSource: 'checkout', }); + + const rows = await db + .select({ + templateKey: notificationOutbox.templateKey, + sourceDomain: notificationOutbox.sourceDomain, + payload: notificationOutbox.payload, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + templateKey: 'order_created', + sourceDomain: 'payment_event', + }); + expect(rows[0]?.payload).toMatchObject({ + canonicalEventName: 'order_created', + canonicalEventSource: 'checkout', + }); } finally { if (orderId) await cleanupOrder(orderId); await cleanupProduct(productId); diff --git a/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts b/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts index 1629328f..97618f81 100644 --- a/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts +++ b/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts @@ -16,6 +16,8 @@ import { resetEnvCache } from '@/lib/env'; import { rehydrateCartItems } from '@/lib/services/products'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { createTestLegalConsent } from './test-legal-consent'; + vi.mock('@/lib/auth', async () => { const actual = await vi.importActual('@/lib/auth'); @@ -25,9 +27,13 @@ vi.mock('@/lib/auth', async () => { }; }); -vi.mock('@/lib/env/stripe', () => ({ - isPaymentsEnabled: () => true, -})); +vi.mock('@/lib/env/stripe', async () => { + const actual = await vi.importActual('@/lib/env/stripe'); + return { + ...actual, + isPaymentsEnabled: () => true, + }; +}); vi.mock('@/lib/services/orders/payment-attempts', async () => { resetEnvCache(); @@ -162,7 +168,7 @@ async function seedProduct(priceMinor: number): Promise { await db.insert(productPrices).values({ productId: product.id, - currency: 'USD', + currency: 'UAH', priceMinor, originalPriceMinor: null, price, @@ -192,6 +198,7 @@ function makeCheckoutRequest(args: { paymentProvider: 'stripe', paymentMethod: 'stripe_card', pricingFingerprint: args.pricingFingerprint, + legalConsent: createTestLegalConsent(), items: [{ productId: args.productId, quantity: 1 }], }), }) @@ -217,13 +224,14 @@ describe('checkout fail-closed for changed price mismatch', () => { body: JSON.stringify({ paymentProvider: 'stripe', paymentMethod: 'stripe_card', + legalConsent: createTestLegalConsent(), items: [{ productId, quantity: 1 }], }), }) ) ); - expect(response.status).toBe(409); + expect(response.status).toBe(422); const json = await response.json(); expect(json.code).toBe('CHECKOUT_PRICE_CHANGED'); expect(json.message).toBe( @@ -242,7 +250,7 @@ describe('checkout fail-closed for changed price mismatch', () => { it('rejects a stale pricing fingerprint after price change and creates no order', async () => { const productId = await seedProduct(900); - const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'USD'); + const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'UAH'); const pricingFingerprint = quote.summary.pricingFingerprint; expect(typeof pricingFingerprint).toBe('string'); @@ -258,7 +266,7 @@ describe('checkout fail-closed for changed price mismatch', () => { .where( and( eq(productPrices.productId, productId), - eq(productPrices.currency, 'USD') + eq(productPrices.currency, 'UAH') ) ); @@ -271,7 +279,7 @@ describe('checkout fail-closed for changed price mismatch', () => { }) ); - expect(response.status).toBe(409); + expect(response.status).toBe(422); const json = await response.json(); expect(json.code).toBe('CHECKOUT_PRICE_CHANGED'); expect(json.message).toBe( @@ -290,7 +298,7 @@ describe('checkout fail-closed for changed price mismatch', () => { it('accepts checkout when the authoritative pricing fingerprint is unchanged', async () => { const productId = await seedProduct(900); - const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'USD'); + const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'UAH'); const pricingFingerprint = quote.summary.pricingFingerprint; expect(typeof pricingFingerprint).toBe('string'); diff --git a/frontend/lib/tests/shop/checkout-rate-limit-policy.test.ts b/frontend/lib/tests/shop/checkout-rate-limit-policy.test.ts index 76d1368c..6cc95551 100644 --- a/frontend/lib/tests/shop/checkout-rate-limit-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-rate-limit-policy.test.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTestLegalConsent } from './test-legal-consent'; + const enforceRateLimitMock = vi.fn(); const createOrderWithItemsMock = vi.fn(); @@ -22,6 +24,15 @@ vi.mock('@/lib/security/origin', () => ({ guardBrowserSameOrigin: vi.fn(() => null), })); +vi.mock('@/lib/shop/commercial-policy.server', () => ({ + resolveStandardStorefrontProviderCapabilities: vi.fn(() => ({ + stripeCheckoutEnabled: true, + monobankCheckoutEnabled: false, + monobankGooglePayEnabled: false, + enabledProviders: ['stripe'], + })), +})); + vi.mock('@/lib/security/rate-limit', () => ({ getRateLimitSubject: vi.fn(() => 'rl_subject'), enforceRateLimit: (...args: any[]) => enforceRateLimitMock(...args), @@ -73,10 +84,13 @@ describe('checkout rate limit policy', () => { method: 'POST', headers: { 'content-type': 'application/json', - 'idempotency-key': 'idem_key_12345678', + 'idempotency-key': '123e4567-e89b-12d3-a456-426614174000', origin: 'http://localhost:3000', }, body: JSON.stringify({ + legalConsent: createTestLegalConsent(), + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', items: [ { productId: '00000000-0000-4000-8000-000000000001', diff --git a/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts b/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts index ab0bd8d0..ee4c8c10 100644 --- a/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts +++ b/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts @@ -1,6 +1,8 @@ import { NextRequest } from 'next/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +const TEST_PRODUCT_ID = '11111111-1111-4111-8111-111111111111'; + const mockCreateOrderWithItems = vi.fn(); const mockFindExistingCheckoutOrderByIdempotencyKey = vi.fn(); const mockRestockOrder = vi.fn(); @@ -14,50 +16,6 @@ const mockCreateStatusToken = vi.fn(); const mockIsStripePaymentsEnabled = vi.fn(); const mockIsMethodAllowed = vi.fn(); const mockReadPositiveIntEnv = vi.fn(); -type MockCheckoutPayloadSafeParseResult = - | { - success: true; - data: { - items: Array<{ productId: string; quantity: number }>; - userId: null; - shipping: null; - country: null; - legalConsent: { - termsAccepted: boolean; - privacyAccepted: boolean; - termsVersion: string; - privacyVersion: string; - }; - }; - } - | { - success: false; - error: { - issues: Array<{ path: Array; message: string }>; - format: () => unknown; - }; - }; - -function makeValidCheckoutPayloadParseResult(): MockCheckoutPayloadSafeParseResult { - return { - success: true, - data: { - items: [{ productId: 'prod_1', quantity: 1 }], - userId: null, - shipping: null, - country: null, - legalConsent: { - termsAccepted: true, - privacyAccepted: true, - termsVersion: 'terms-v1', - privacyVersion: 'privacy-v1', - }, - }, - }; -} -const mockCheckoutPayloadSafeParse = vi.fn< - (input: unknown) => MockCheckoutPayloadSafeParseResult ->(() => makeValidCheckoutPayloadParseResult()); vi.mock('@/lib/auth', () => ({ getCurrentUser: mockGetCurrentUser, @@ -71,10 +29,21 @@ vi.mock('@/lib/env/readPositiveIntEnv', () => ({ readPositiveIntEnv: mockReadPositiveIntEnv, })); -vi.mock('@/lib/env/stripe', () => ({ - isPaymentsEnabled: mockIsStripePaymentsEnabled, +vi.mock('@/lib/env/stripe', async () => { + const actual = await vi.importActual('@/lib/env/stripe'); + return { + ...actual, + isPaymentsEnabled: mockIsStripePaymentsEnabled, + }; +}); +vi.mock('@/lib/shop/commercial-policy.server', () => ({ + resolveStandardStorefrontProviderCapabilities: vi.fn(() => ({ + stripeCheckoutEnabled: mockIsStripePaymentsEnabled(), + monobankCheckoutEnabled: false, + monobankGooglePayEnabled: false, + enabledProviders: mockIsStripePaymentsEnabled() ? ['stripe'] : [], + })), })); - vi.mock('@/lib/logging', () => ({ logError: vi.fn(), logInfo: vi.fn(), @@ -117,9 +86,14 @@ vi.mock('@/lib/services/orders/payment-attempts', () => ({ }, })); -vi.mock('@/lib/shop/currency', () => ({ - resolveCurrencyFromLocale: vi.fn(() => 'USD'), -})); +vi.mock('@/lib/shop/currency', async importOriginal => { + const actual = await importOriginal(); + + return { + ...actual, + resolveCurrencyFromLocale: vi.fn(() => 'USD'), + }; +}); vi.mock('@/lib/shop/payments', async () => { const actual = await vi.importActual('@/lib/shop/payments'); @@ -137,26 +111,24 @@ vi.mock('@/lib/shop/status-token', () => ({ createStatusToken: mockCreateStatusToken, })); -vi.mock('@/lib/validation/shop', () => ({ - checkoutPayloadSchema: { - safeParse: mockCheckoutPayloadSafeParse, - }, - idempotencyKeySchema: { - safeParse: vi.fn((value: string) => ({ - success: true, - data: value, - })), - }, -})); +vi.mock('@/lib/validation/shop', async importOriginal => { + const actual = await importOriginal(); + + return { + ...actual, + idempotencyKeySchema: { + safeParse: vi.fn((value: string) => ({ + success: true, + data: value, + })), + }, + }; +}); describe('checkout route - stripe disabled recovery', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - mockCheckoutPayloadSafeParse.mockReset(); - mockCheckoutPayloadSafeParse.mockReturnValue( - makeValidCheckoutPayloadParseResult() - ); mockGetCurrentUser.mockResolvedValue(null); mockGuardBrowserSameOrigin.mockReturnValue(null); @@ -180,7 +152,16 @@ describe('checkout route - stripe disabled recovery', () => { 'content-type': 'application/json', 'Idempotency-Key': 'idem_key_1234567890', }), - body: JSON.stringify(body), + body: JSON.stringify({ + items: [{ productId: TEST_PRODUCT_ID, quantity: 1 }], + legalConsent: { + termsAccepted: true, + privacyAccepted: true, + termsVersion: 'terms-v1', + privacyVersion: 'privacy-v1', + }, + ...body, + }), }); } @@ -214,7 +195,7 @@ describe('checkout route - stripe disabled recovery', () => { expect(mockCreateOrderWithItems).toHaveBeenCalledTimes(1); expect(mockCreateOrderWithItems).toHaveBeenCalledWith( expect.objectContaining({ - items: [{ productId: 'prod_1', quantity: 1 }], + items: [{ productId: TEST_PRODUCT_ID, quantity: 1 }], idempotencyKey: 'idem_key_1234567890', userId: null, locale: 'en', @@ -251,70 +232,46 @@ describe('checkout route - stripe disabled recovery', () => { expect(mockEnsureStripePaymentIntentForOrder).not.toHaveBeenCalled(); }); - it('explicit stripe method without provider + existing order => still recovers by idempotency key', async () => { - mockFindExistingCheckoutOrderByIdempotencyKey.mockResolvedValue({ - id: 'order_existing_default', - currency: 'USD', - totalAmount: 30, - paymentStatus: 'pending', - paymentProvider: 'stripe', - paymentIntentId: 'pi_existing_default', - }); - + it('explicit stripe method without provider => returns 503 and does not recover', async () => { const { POST } = await import('@/app/api/shop/checkout/route'); const response = await POST(makeRequest({ paymentMethod: 'stripe_card' })); const json = await response.json(); - expect(response.status).toBe(200); - expect(json.orderId).toBe('order_existing_default'); - expect(json.paymentProvider).toBe('stripe'); - expect(mockCreateOrderWithItems).toHaveBeenCalledTimes(1); - expect(mockCreateOrderWithItems).toHaveBeenCalledWith( - expect.objectContaining({ - items: [{ productId: 'prod_1', quantity: 1 }], - idempotencyKey: 'idem_key_1234567890', - userId: null, - locale: 'en', - country: null, - shipping: null, - legalConsent: { - termsAccepted: true, - privacyAccepted: true, - termsVersion: 'terms-v1', - privacyVersion: 'privacy-v1', - }, - paymentProvider: 'stripe', - paymentMethod: 'stripe_card', - }) + expect(response.status).toBe(503); + expect(json.code).toBe('PSP_UNAVAILABLE'); + expect(mockFindExistingCheckoutOrderByIdempotencyKey).toHaveBeenCalledWith( + 'idem_key_1234567890' ); + expect(mockCreateOrderWithItems).not.toHaveBeenCalled(); expect(mockEnsureStripePaymentIntentForOrder).not.toHaveBeenCalled(); }); - it('missing legal consent returns a controlled validation error before checkout starts', async () => { - mockCheckoutPayloadSafeParse.mockReturnValue({ - success: false, - error: { - issues: [{ path: ['legalConsent'], message: 'Required' }], - format: () => ({ - legalConsent: { - _errors: ['Required'], - }, - }), - }, - }); + it('missing legal consent returns a controlled validation error from checkout service', async () => { + mockIsStripePaymentsEnabled.mockReturnValue(true); const { POST } = await import('@/app/api/shop/checkout/route'); + const { InvalidPayloadError } = await import('@/lib/services/errors'); + + mockCreateOrderWithItems.mockImplementationOnce(() => { + throw new InvalidPayloadError( + 'Explicit legal consent is required before checkout.', + { + code: 'LEGAL_CONSENT_REQUIRED', + } + ); + }); - const response = await POST(makeRequest({ paymentProvider: 'stripe' })); + const response = await POST( + makeRequest({ paymentProvider: 'stripe', legalConsent: null }) + ); const json = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(422); expect(json.code).toBe('LEGAL_CONSENT_REQUIRED'); - expect(mockCreateOrderWithItems).not.toHaveBeenCalled(); + expect(mockCreateOrderWithItems).toHaveBeenCalledTimes(1); expect(mockEnsureStripePaymentIntentForOrder).not.toHaveBeenCalled(); }); - it('new order + required status token creation failure => restocks and returns 500', async () => { mockIsStripePaymentsEnabled.mockReturnValue(true); mockCreateStatusToken.mockImplementation(() => { diff --git a/frontend/lib/tests/shop/checkout-route-validation-contract.test.ts b/frontend/lib/tests/shop/checkout-route-validation-contract.test.ts new file mode 100644 index 00000000..4a852477 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-route-validation-contract.test.ts @@ -0,0 +1,624 @@ +import { NextRequest } from 'next/server'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { createTestLegalConsent } from '@/lib/tests/shop/test-legal-consent'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/shop/commercial-policy.server', () => ({ + resolveStandardStorefrontProviderCapabilities: vi.fn(() => ({ + stripeCheckoutEnabled: true, + monobankCheckoutEnabled: true, + monobankGooglePayEnabled: false, + enabledProviders: ['monobank', 'stripe'], + })), +})); + +vi.mock('@/lib/env/stripe', async () => { + const actual = await vi.importActual('@/lib/env/stripe'); + return { + ...actual, + isPaymentsEnabled: () => true, + }; +}); + +vi.mock('@/lib/services/orders', async () => { + const actual = await vi.importActual('@/lib/services/orders'); + return { + ...actual, + createOrderWithItems: vi.fn(), + restockOrder: vi.fn(), + }; +}); + +vi.mock('@/lib/services/orders/payment-attempts', async () => { + const actual = await vi.importActual( + '@/lib/services/orders/payment-attempts' + ); + return { + ...actual, + ensureStripePaymentIntentForOrder: vi.fn(), + }; +}); + +import { POST } from '@/app/api/shop/checkout/route'; +import { getCurrentUser } from '@/lib/auth'; +import { + IdempotencyConflictError, + InsufficientStockError, + InvalidPayloadError, + InvalidVariantError, + PriceConfigError, +} from '@/lib/services/errors'; +import { createOrderWithItems } from '@/lib/services/orders'; +import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts'; + +type MockedFn = ReturnType; + +const createOrderWithItemsMock = createOrderWithItems as unknown as MockedFn; +const ensureStripePaymentIntentForOrderMock = + ensureStripePaymentIntentForOrder as unknown as MockedFn; +const getCurrentUserMock = getCurrentUser as unknown as MockedFn; + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; +const __prevStripeSecret = process.env.STRIPE_SECRET_KEY; +const __prevStripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; +const __prevStripePublishableKey = + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'sk_test_checkout_validation_contract'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_checkout_validation_contract'; + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = + 'pk_test_checkout_validation_contract'; +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + + if (__prevStripeSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = __prevStripeSecret; + + if (__prevStripeWebhookSecret === undefined) + delete process.env.STRIPE_WEBHOOK_SECRET; + else process.env.STRIPE_WEBHOOK_SECRET = __prevStripeWebhookSecret; + + if (__prevStripePublishableKey === undefined) + delete process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + else + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = __prevStripePublishableKey; +}); + +beforeEach(() => { + vi.clearAllMocks(); + createOrderWithItemsMock.mockReset(); + ensureStripePaymentIntentForOrderMock.mockReset(); + getCurrentUserMock.mockReset(); + getCurrentUserMock.mockResolvedValue(null); +}); + +function makeValidationCheckoutReq(params: { + idempotencyKey: string; + items?: Array<{ + productId: string; + quantity: number; + selectedSize?: string; + selectedColor?: string; + }>; + legalConsent?: Record | null; + paymentProvider?: 'stripe' | 'monobank'; + paymentMethod?: 'stripe_card' | 'monobank_invoice'; +}) { + const paymentProvider = params.paymentProvider ?? 'stripe'; + const paymentMethod = params.paymentMethod ?? 'stripe_card'; + + const headers = new Headers({ + 'content-type': 'application/json', + 'accept-language': 'en', + 'idempotency-key': params.idempotencyKey, + 'x-forwarded-for': '198.51.100.10', + 'x-real-ip': '198.51.100.10', + origin: 'http://localhost:3000', + }); + + return new NextRequest( + new Request('http://localhost/api/shop/checkout', { + method: 'POST', + headers, + body: JSON.stringify({ + items: params.items ?? [ + { + productId: '11111111-1111-4111-8111-111111111111', + quantity: 1, + }, + ], + ...(params.legalConsent === null + ? {} + : { legalConsent: params.legalConsent ?? createTestLegalConsent() }), + paymentProvider, + paymentMethod, + }), + }) + ); +} + +describe('checkout route validation/business error contract', () => { + it('returns 422 INVALID_PAYLOAD for schema-level invalid checkout payload', async () => { + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey: 'checkout_invalid_payload_0001', + items: [ + { + productId: '11111111-1111-4111-8111-111111111111', + quantity: 0, + }, + ], + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('INVALID_PAYLOAD'); + expect(createOrderWithItemsMock).not.toHaveBeenCalled(); + }); + + it('returns 422 LEGAL_CONSENT_REQUIRED when explicit consent is missing', async () => { + createOrderWithItemsMock.mockRejectedValueOnce( + new InvalidPayloadError( + 'Explicit legal consent is required before checkout.', + { + code: 'LEGAL_CONSENT_REQUIRED', + } + ) + ); + + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey: 'checkout_legal_consent_0001', + legalConsent: null, + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('LEGAL_CONSENT_REQUIRED'); + expect(createOrderWithItemsMock).toHaveBeenCalledTimes(1); + }); + + it('returns 422 INVALID_VARIANT for service-level variant rejection', async () => { + createOrderWithItemsMock.mockRejectedValueOnce( + new InvalidVariantError('Invalid size.', { + productId: '11111111-1111-4111-8111-111111111111', + field: 'selectedSize', + value: 'XXL', + allowed: ['S', 'M', 'L'], + }) + ); + + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey: 'checkout_invalid_variant_0001', + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('INVALID_VARIANT'); + expect(json.details).toMatchObject({ + productId: '11111111-1111-4111-8111-111111111111', + field: 'selectedSize', + value: 'XXL', + allowed: ['S', 'M', 'L'], + }); + }); + + it.each([ + [ + 'PRICE_CONFIG_ERROR', + new PriceConfigError('Missing UAH price.', { + productId: '11111111-1111-4111-8111-111111111111', + currency: 'UAH', + }), + ], + [ + 'CHECKOUT_PRICE_CHANGED', + new InvalidPayloadError( + 'Prices changed. Refresh your cart and try again.', + { + code: 'CHECKOUT_PRICE_CHANGED', + details: { reason: 'PRICING_FINGERPRINT_MISMATCH' }, + } + ), + ], + [ + 'CHECKOUT_SHIPPING_CHANGED', + new InvalidPayloadError( + 'Shipping amount changed. Refresh your cart and try again.', + { + code: 'CHECKOUT_SHIPPING_CHANGED', + details: { reason: 'SHIPPING_QUOTE_FINGERPRINT_MISMATCH' }, + } + ), + ], + [ + 'TERMS_VERSION_MISMATCH', + new InvalidPayloadError( + 'Submitted terms version does not match current terms.', + { + code: 'TERMS_VERSION_MISMATCH', + } + ), + ], + [ + 'PRIVACY_VERSION_MISMATCH', + new InvalidPayloadError( + 'Submitted privacy version does not match current privacy policy.', + { + code: 'PRIVACY_VERSION_MISMATCH', + } + ), + ], + ['INSUFFICIENT_STOCK', new InsufficientStockError('Insufficient stock.')], + [ + 'IDEMPOTENCY_CONFLICT', + new IdempotencyConflictError( + 'Idempotency key reuse with different payload.', + { + existingOrderId: 'order_existing_0001', + } + ), + ], + ])('returns 422 for %s', async (expectedCode, error) => { + createOrderWithItemsMock.mockRejectedValueOnce(error); + + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey: `checkout_${String(expectedCode).toLowerCase()}_0001`, + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe(expectedCode); + }); + + it('returns 500 INTERNAL_ERROR for unexpected runtime failure', async () => { + createOrderWithItemsMock.mockRejectedValueOnce( + new Error('unexpected checkout failure') + ); + + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey: 'checkout_unexpected_error_0001', + }) + ); + + expect(response.status).toBe(500); + const json = await response.json(); + expect(json.code).toBe('INTERNAL_ERROR'); + }); + + it('preserves structured PriceConfigError details for monobank checkout errors', async () => { + createOrderWithItemsMock.mockRejectedValueOnce( + new PriceConfigError('Missing UAH price.', { + productId: '11111111-1111-4111-8111-111111111111', + currency: 'UAH', + }) + ); + + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey: 'checkout_monobank_price_config_0001', + paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('PRICE_CONFIG_ERROR'); + expect(json.details).toMatchObject({ + productId: '11111111-1111-4111-8111-111111111111', + currency: 'UAH', + }); + }); + + it('preserves structured idempotency conflict details for monobank checkout errors', async () => { + createOrderWithItemsMock.mockRejectedValueOnce( + new IdempotencyConflictError( + 'Idempotency key reuse with different payload.', + { + existingOrderId: 'order_existing_0001', + } + ) + ); + + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey: 'checkout_monobank_idempotency_conflict_0001', + paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('CHECKOUT_IDEMPOTENCY_CONFLICT'); + expect(json.details).toMatchObject({ + existingOrderId: 'order_existing_0001', + }); + }); + + it.each([ + ['OUT_OF_STOCK', 'checkout_monobank_out_of_stock_0001'], + ['INSUFFICIENT_STOCK', 'checkout_monobank_insufficient_stock_0001'], + ] as const)( + 'normalizes Monobank stock error %s to 422 INSUFFICIENT_STOCK before generic code mapping', + async (code, idempotencyKey) => { + createOrderWithItemsMock.mockRejectedValueOnce( + Object.assign(new Error('Insufficient stock.'), { + code, + }) + ); + + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey, + paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('INSUFFICIENT_STOCK'); + } + ); + + it.each([ + ['OUT_OF_STOCK', 'checkout_business_out_of_stock_0001'], + ['INSUFFICIENT_STOCK', 'checkout_business_insufficient_stock_0001'], + ] as const)( + 'returns 422 INSUFFICIENT_STOCK for business-code stock error %s outside the typed stock exception path', + async (code, idempotencyKey) => { + createOrderWithItemsMock.mockRejectedValueOnce( + Object.assign(new Error('Insufficient stock.'), { + code, + }) + ); + + const response = await POST( + makeValidationCheckoutReq({ + idempotencyKey, + }) + ); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.code).toBe('INSUFFICIENT_STOCK'); + } + ); +}); + +function makeRouteCheckoutReq(params: { + idempotencyKey?: string; + paymentProvider?: 'stripe' | 'monobank'; + paymentMethod?: 'stripe_card' | 'monobank_invoice'; + userId?: string; +}) { + const paymentProvider = params.paymentProvider ?? 'stripe'; + const paymentMethod = params.paymentMethod ?? 'stripe_card'; + + const headers = new Headers({ + 'content-type': 'application/json', + 'accept-language': 'uk-UA', + origin: 'http://localhost:3000', + }); + + if (params.idempotencyKey !== undefined) { + headers.set('idempotency-key', params.idempotencyKey); + } + + return new NextRequest( + new Request('http://localhost:3000/api/shop/checkout', { + method: 'POST', + headers, + body: JSON.stringify({ + items: [ + { + productId: '11111111-1111-4111-8111-111111111111', + quantity: 1, + }, + ], + legalConsent: createTestLegalConsent(), + paymentProvider, + paymentMethod, + ...(params.userId ? { userId: params.userId } : {}), + }), + }) + ); +} + +function mockSuccessfulStripeCheckout(args?: { orderId?: string }) { + const orderId = args?.orderId ?? '11111111-1111-4111-8111-111111111123'; + + createOrderWithItemsMock.mockResolvedValueOnce({ + order: { + id: orderId, + currency: 'UAH', + totalAmount: 10, + paymentStatus: 'pending', + paymentProvider: 'stripe', + paymentIntentId: null, + }, + isNew: true, + totalCents: 1000, + }); + + ensureStripePaymentIntentForOrderMock.mockResolvedValueOnce({ + paymentIntentId: `pi_test_${orderId}`, + clientSecret: `cs_test_${orderId}`, + attemptId: `attempt_${orderId}`, + attemptNumber: 1, + }); +} + +describe('checkout route idempotency and identity contract', () => { + it('rejects missing idempotency key for standard checkout', async () => { + const response = await POST( + makeRouteCheckoutReq({ + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }) + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe('MISSING_IDEMPOTENCY_KEY'); + expect(createOrderWithItemsMock).not.toHaveBeenCalled(); + }); + + it.each(['short', 'bad key!*', 'a'.repeat(129)])( + 'rejects malformed idempotency key for standard checkout: %s', + async idempotencyKey => { + const response = await POST( + makeRouteCheckoutReq({ + idempotencyKey, + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }) + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe('INVALID_IDEMPOTENCY_KEY'); + expect(createOrderWithItemsMock).not.toHaveBeenCalled(); + } + ); + + it('keeps monobank missing-idempotency behavior on INVALID_REQUEST', async () => { + const response = await POST( + makeRouteCheckoutReq({ + paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', + }) + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe('INVALID_REQUEST'); + expect(createOrderWithItemsMock).not.toHaveBeenCalled(); + }); + + it('keeps monobank malformed-idempotency behavior on INVALID_REQUEST', async () => { + const response = await POST( + makeRouteCheckoutReq({ + idempotencyKey: 'bad key!*', + paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', + }) + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe('INVALID_REQUEST'); + expect(createOrderWithItemsMock).not.toHaveBeenCalled(); + }); + + it('rejects guest checkout when payload smuggles userId', async () => { + const response = await POST( + makeRouteCheckoutReq({ + idempotencyKey: 'guest_userid_smuggle_0001', + userId: '11111111-1111-4111-8111-111111111111', + }) + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe('USER_ID_NOT_ALLOWED'); + expect(createOrderWithItemsMock).not.toHaveBeenCalled(); + }); + + it('rejects authenticated checkout when payload userId mismatches session user', async () => { + getCurrentUserMock.mockResolvedValueOnce({ + id: '22222222-2222-4222-8222-222222222222', + }); + + const response = await POST( + makeRouteCheckoutReq({ + idempotencyKey: 'user_mismatch_checkout_0001', + userId: '11111111-1111-4111-8111-111111111111', + }) + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe('USER_MISMATCH'); + expect(createOrderWithItemsMock).not.toHaveBeenCalled(); + }); + + it('allows authenticated checkout when payload userId matches the session user', async () => { + const userId = '11111111-1111-4111-8111-111111111111'; + getCurrentUserMock.mockResolvedValueOnce({ id: userId }); + mockSuccessfulStripeCheckout({ + orderId: '11111111-1111-4111-8111-111111111124', + }); + + const response = await POST( + makeRouteCheckoutReq({ + idempotencyKey: 'user_match_checkout_0001', + userId, + }) + ); + + expect(response.status).toBe(201); + expect(createOrderWithItemsMock).toHaveBeenCalledTimes(1); + expect(createOrderWithItemsMock.mock.calls[0]?.[0]).toMatchObject({ + idempotencyKey: 'user_match_checkout_0001', + userId, + }); + }); + + it('allows guest checkout when payload omits userId', async () => { + mockSuccessfulStripeCheckout({ + orderId: '11111111-1111-4111-8111-111111111125', + }); + + const response = await POST( + makeRouteCheckoutReq({ + idempotencyKey: 'guest_checkout_without_user_0001', + }) + ); + + expect(response.status).toBe(201); + expect(createOrderWithItemsMock).toHaveBeenCalledTimes(1); + expect(createOrderWithItemsMock.mock.calls[0]?.[0]).toMatchObject({ + idempotencyKey: 'guest_checkout_without_user_0001', + }); + expect(createOrderWithItemsMock.mock.calls[0]?.[0]?.userId).toBeNull(); + }); +}); diff --git a/frontend/lib/tests/shop/checkout-set-payment-intent-reject-contract.test.ts b/frontend/lib/tests/shop/checkout-set-payment-intent-reject-contract.test.ts index be239eaf..34d2ad56 100644 --- a/frontend/lib/tests/shop/checkout-set-payment-intent-reject-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-set-payment-intent-reject-contract.test.ts @@ -46,7 +46,6 @@ vi.mock('@/lib/services/orders', async () => { return { ...actual, createOrderWithItems: vi.fn(), - setOrderPaymentIntent: vi.fn(), restockOrder: vi.fn(), }; }); @@ -62,11 +61,7 @@ vi.mock('@/lib/services/orders/payment-attempts', async () => { }); import { POST } from '@/app/api/shop/checkout/route'; -import { - createOrderWithItems, - restockOrder, - setOrderPaymentIntent, -} from '@/lib/services/orders'; +import { createOrderWithItems, restockOrder } from '@/lib/services/orders'; type MockedFn = ReturnType; @@ -86,10 +81,9 @@ afterAll(() => { else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; }); -describe('checkout: setOrderPaymentIntent rejection after order creation must not be 400', () => { - it('new order (isNew=true): attach rejection returns 409 CHECKOUT_CONFLICT (not 400)', async () => { +describe('checkout: payment-init state conflict after order creation', () => { + it('new order (isNew=true): InvalidPayloadError returns 409 CHECKOUT_CONFLICT', async () => { const co = createOrderWithItems as unknown as MockedFn; - const setPI = setOrderPaymentIntent as unknown as MockedFn; const restock = restockOrder as unknown as MockedFn; co.mockResolvedValueOnce({ @@ -105,11 +99,6 @@ describe('checkout: setOrderPaymentIntent rejection after order creation must no totalCents: 1000, }); - setPI.mockRejectedValueOnce( - new InvalidPayloadError( - 'Order cannot accept a payment intent from the current status.' - ) - ); const ensurePI = ensureStripePaymentIntentForOrder as unknown as MockedFn; ensurePI.mockRejectedValueOnce( @@ -132,9 +121,8 @@ describe('checkout: setOrderPaymentIntent rejection after order creation must no expect(restock).not.toHaveBeenCalled(); }); - it('existing order (isNew=false, no PI): attach rejection returns 409 CHECKOUT_CONFLICT (not 400)', async () => { + it('existing order (isNew=false, no PI): InvalidPayloadError returns 409 CHECKOUT_CONFLICT', async () => { const co = createOrderWithItems as unknown as MockedFn; - const setPI = setOrderPaymentIntent as unknown as MockedFn; const restock = restockOrder as unknown as MockedFn; co.mockResolvedValueOnce({ @@ -150,11 +138,6 @@ describe('checkout: setOrderPaymentIntent rejection after order creation must no totalCents: 1000, }); - setPI.mockRejectedValueOnce( - new InvalidPayloadError( - 'Order cannot accept a payment intent from the current status.' - ) - ); const ensurePI = ensureStripePaymentIntentForOrder as unknown as MockedFn; ensurePI.mockRejectedValueOnce( diff --git a/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts index 6a793caf..d3729fce 100644 --- a/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts +++ b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts @@ -27,6 +27,8 @@ import { resetEnvCache } from '@/lib/env'; import { rehydrateCartItems } from '@/lib/services/products'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { createTestLegalConsent } from './test-legal-consent'; + const enforceRateLimitMock = vi.fn(); vi.mock('@/lib/security/rate-limit', () => ({ @@ -56,9 +58,20 @@ vi.mock('@/lib/auth', async () => { }; }); -vi.mock('@/lib/env/stripe', () => ({ - isPaymentsEnabled: () => true, -})); +vi.mock('@/lib/env/stripe', async () => { + const actual = await vi.importActual('@/lib/env/stripe'); + return { + ...actual, + getStripeEnv: () => ({ + secretKey: 'sk_test_checkout_shipping_total', + webhookSecret: 'whsec_test_checkout_shipping_total', + publishableKey: 'pk_test_checkout_shipping_total', + paymentsEnabled: true, + mode: 'test', + }), + isPaymentsEnabled: () => true, + }; +}); vi.mock('@/lib/services/orders/payment-attempts', async () => { const actual = await vi.importActual( @@ -124,6 +137,19 @@ beforeEach(() => { vi.stubEnv('SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', '500'); vi.stubEnv('SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR', '400'); vi.stubEnv('SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR', '700'); + vi.stubEnv('NP_API_KEY', 'np_test_checkout_shipping_total'); + vi.stubEnv('NP_SENDER_CITY_REF', 'np_sender_city_checkout_shipping_total'); + vi.stubEnv( + 'NP_SENDER_WAREHOUSE_REF', + 'np_sender_warehouse_checkout_shipping_total' + ); + vi.stubEnv('NP_SENDER_REF', 'np_sender_checkout_shipping_total'); + vi.stubEnv( + 'NP_SENDER_CONTACT_REF', + 'np_sender_contact_checkout_shipping_total' + ); + vi.stubEnv('NP_SENDER_NAME', 'Checkout Shipping Total Sender'); + vi.stubEnv('NP_SENDER_PHONE', '+380500000002'); resetEnvCache(); }); @@ -301,6 +327,7 @@ function makeCheckoutRequest(args: { }, }, items: [{ productId: args.productId, quantity: 1 }], + legalConsent: createTestLegalConsent(), ...(args.extraBody ?? {}), }), }) @@ -383,7 +410,7 @@ describe('checkout authoritative shipping totals', () => { }) ); - expect(response.status).toBe(409); + expect(response.status).toBe(422); const json = await response.json(); expect(json.code).toBe('CHECKOUT_SHIPPING_CHANGED'); expect(json.message).toBe( @@ -427,7 +454,7 @@ describe('checkout authoritative shipping totals', () => { }) ); - expect(response.status).toBe(409); + expect(response.status).toBe(422); const json = await response.json(); expect(json.code).toBe('CHECKOUT_SHIPPING_CHANGED'); expect(json.message).toBe( @@ -457,6 +484,10 @@ describe('checkout authoritative shipping totals', () => { expect(pricingFingerprint).toHaveLength(64); vi.stubEnv('APP_ENV', 'production'); + vi.stubEnv( + 'DATABASE_URL', + 'postgresql://required-for-production-like-check' + ); vi.stubEnv('NP_API_BASE', 'https://api.example.test'); vi.stubEnv('NP_API_KEY', 'np_test_placeholder'); vi.stubEnv('NP_SENDER_CITY_REF', 'test-city-ref'); @@ -468,21 +499,21 @@ describe('checkout authoritative shipping totals', () => { resetEnvCache(); const idempotencyKey = crypto.randomUUID(); - const response = await POST( - makeCheckoutRequest({ - idempotencyKey, - productId: seed.productId, - pricingFingerprint: pricingFingerprint!, - cityRef: seed.cityRef, - warehouseRef: seed.warehouseRef, - shippingQuoteFingerprint: warehouseMethod.quoteFingerprint, - }) - ); - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.code).toBe('SHIPPING_METHOD_UNAVAILABLE'); - expect(json.message).toBe('Shipping method is currently unavailable.'); + await expect( + POST( + makeCheckoutRequest({ + idempotencyKey, + productId: seed.productId, + pricingFingerprint: pricingFingerprint!, + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRef, + shippingQuoteFingerprint: warehouseMethod.quoteFingerprint, + }) + ) + ).rejects.toThrow( + /nova_poshta provider config is invalid for production runtime: NP_API_BASE must not point at a local\/test host/i + ); const [orderRow] = await db .select({ id: orders.id }) @@ -521,7 +552,7 @@ describe('checkout authoritative shipping totals', () => { }) ); - expect(response.status).toBe(400); + expect(response.status).toBe(422); const json = await response.json(); expect(json.code).toBe('INVALID_PAYLOAD'); @@ -562,7 +593,7 @@ describe('checkout authoritative shipping totals', () => { }) ); - expect(response.status).toBe(400); + expect(response.status).toBe(422); const json = await response.json(); expect(json.code).toBe('DISCOUNTS_NOT_SUPPORTED'); expect(json.message).toBe('Discounts are not available at checkout.'); diff --git a/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts b/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts index 77cea751..9e30eaf5 100644 --- a/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts +++ b/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts @@ -1,11 +1,13 @@ import crypto from 'crypto'; -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; import { + inventoryMoves, npCities, npWarehouses, + orderItems, orders, orderShipping, productPrices, @@ -18,7 +20,7 @@ import { } from '@/lib/services/errors'; import { createOrderWithItems } from '@/lib/services/orders'; -import { TEST_LEGAL_CONSENT } from './test-legal-consent'; +import { createTestLegalConsent } from './test-legal-consent'; type SeedData = { productId: string; @@ -111,10 +113,22 @@ async function seedCheckoutShippingData(): Promise { } async function cleanupSeedData(data: SeedData, orderIds: string[]) { + if (orderIds.length > 0) { + await db.delete(orderItems).where(inArray(orderItems.orderId, orderIds)); + } + for (const orderId of orderIds) { + await db.delete(orderShipping).where(eq(orderShipping.orderId, orderId)); await db.delete(orders).where(eq(orders.id, orderId)); } + await db + .delete(inventoryMoves) + .where(eq(inventoryMoves.productId, data.productId)); + await db.delete(orderItems).where(eq(orderItems.productId, data.productId)); + await db + .delete(productPrices) + .where(eq(productPrices.productId, data.productId)); await db.delete(npWarehouses).where(eq(npWarehouses.ref, data.warehouseRefA)); await db.delete(npWarehouses).where(eq(npWarehouses.ref, data.warehouseRefB)); await db.delete(npCities).where(eq(npCities.ref, data.cityRef)); @@ -130,6 +144,19 @@ describe('checkout shipping phase 3', () => { vi.stubEnv('SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', '500'); vi.stubEnv('SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR', '400'); vi.stubEnv('SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR', '700'); + vi.stubEnv('NP_API_KEY', 'np_test_checkout_shipping_phase3'); + vi.stubEnv('NP_SENDER_CITY_REF', 'np_sender_city_checkout_shipping_phase3'); + vi.stubEnv( + 'NP_SENDER_WAREHOUSE_REF', + 'np_sender_warehouse_checkout_shipping_phase3' + ); + vi.stubEnv('NP_SENDER_REF', 'np_sender_checkout_shipping_phase3'); + vi.stubEnv( + 'NP_SENDER_CONTACT_REF', + 'np_sender_contact_checkout_shipping_phase3' + ); + vi.stubEnv('NP_SENDER_NAME', 'Checkout Shipping Phase 3 Sender'); + vi.stubEnv('NP_SENDER_PHONE', '+380500000001'); resetEnvCache(); }); @@ -138,19 +165,19 @@ describe('checkout shipping phase 3', () => { resetEnvCache(); }); - it('rejects NP shipping for unsupported checkout currency', async () => { + it('uses the authoritative storefront UAH currency for shipping checkout regardless of locale', async () => { const seed = await seedCheckoutShippingData(); const createdOrderIds: string[] = []; try { const idem = crypto.randomUUID(); - const promise = createOrderWithItems({ + const result = await createOrderWithItems({ idempotencyKey: idem, userId: null, locale: 'en-US', country: 'UA', items: [{ productId: seed.productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), shipping: { provider: 'nova_poshta', methodCode: 'NP_WAREHOUSE', @@ -164,18 +191,28 @@ describe('checkout shipping phase 3', () => { }, }, }); + createdOrderIds.push(result.order.id); - await expect(promise).rejects.toBeInstanceOf(InvalidPayloadError); - await expect(promise).rejects.toHaveProperty( - 'code', - 'SHIPPING_CURRENCY_UNSUPPORTED' - ); + expect(result.isNew).toBe(true); + expect(result.order.currency).toBe('UAH'); + expect(result.order.totalAmountMinor).toBe(4500); - const rows = await db - .select({ id: orders.id }) + const [orderRow] = await db + .select({ + id: orders.id, + currency: orders.currency, + shippingAmountMinor: orders.shippingAmountMinor, + totalAmountMinor: orders.totalAmountMinor, + }) .from(orders) .where(eq(orders.idempotencyKey, idem)); - expect(rows.length).toBe(0); + + expect(orderRow).toEqual({ + id: result.order.id, + currency: 'UAH', + shippingAmountMinor: 500, + totalAmountMinor: 4500, + }); } finally { await cleanupSeedData(seed, createdOrderIds); } @@ -217,7 +254,7 @@ describe('checkout shipping phase 3', () => { locale: 'uk-UA', country: 'UA', items: [{ productId: seed.productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), shipping: { provider: 'nova_poshta', methodCode: 'NP_WAREHOUSE', @@ -264,7 +301,7 @@ describe('checkout shipping phase 3', () => { locale: 'uk-UA', country: 'UA', items: [{ productId: seed.productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), shipping: { provider: 'nova_poshta', methodCode: 'NP_LOCKER', @@ -307,7 +344,7 @@ describe('checkout shipping phase 3', () => { locale: 'uk-UA', country: 'UA', items: [{ productId: seed.productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), shipping: { provider: 'nova_poshta', methodCode: 'NP_COURIER', @@ -349,7 +386,7 @@ describe('checkout shipping phase 3', () => { locale: 'uk-UA', country: 'UA', items: [{ productId: seed.productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), shipping: { provider: 'nova_poshta', methodCode: 'NP_WAREHOUSE', @@ -418,7 +455,7 @@ describe('checkout shipping phase 3', () => { } }, 60_000); - it('idempotency excludes recipient PII but includes shipping refs', async () => { + it('idempotency replays only when shipping recipient data is materially identical', async () => { const seed = await seedCheckoutShippingData(); const createdOrderIds: string[] = []; @@ -430,7 +467,7 @@ describe('checkout shipping phase 3', () => { locale: 'uk-UA', country: 'UA', items: [{ productId: seed.productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), shipping: { provider: 'nova_poshta', methodCode: 'NP_WAREHOUSE', @@ -452,7 +489,7 @@ describe('checkout shipping phase 3', () => { locale: 'uk-UA', country: 'UA', items: [{ productId: seed.productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), shipping: { provider: 'nova_poshta', methodCode: 'NP_WAREHOUSE', @@ -461,8 +498,8 @@ describe('checkout shipping phase 3', () => { warehouseRef: seed.warehouseRefA, }, recipient: { - fullName: 'Bob', - phone: '+380509998877', + fullName: 'Alice', + phone: '+380501112233', }, }, }); @@ -470,6 +507,16 @@ describe('checkout shipping phase 3', () => { expect(second.isNew).toBe(false); expect(second.order.id).toBe(first.order.id); + const matchedRows = await db + .select({ + id: orders.id, + idempotencyKey: orders.idempotencyKey, + }) + .from(orders) + .where(eq(orders.idempotencyKey, idem)); + + expect(matchedRows).toHaveLength(1); + const [shippingRow] = await db .select({ shippingAddress: orderShipping.shippingAddress }) .from(orderShipping) @@ -487,7 +534,55 @@ describe('checkout shipping phase 3', () => { locale: 'uk-UA', country: 'UA', items: [{ productId: seed.productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_WAREHOUSE', + selection: { + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRefA, + }, + recipient: { + fullName: 'Bob', + phone: '+380509998877', + email: 'bob@example.com', + comment: 'Call me on arrival', + }, + }, + }) + ).rejects.toBeInstanceOf(IdempotencyConflictError); + + const [shippingRowAfterRecipientConflict] = await db + .select({ shippingAddress: orderShipping.shippingAddress }) + .from(orderShipping) + .where(eq(orderShipping.orderId, first.order.id)) + .limit(1); + + expect( + (shippingRowAfterRecipientConflict?.shippingAddress as any)?.recipient + ).toMatchObject({ + fullName: 'Alice', + phone: '+380501112233', + }); + + const rowsAfterRecipientConflict = await db + .select({ + id: orders.id, + idempotencyKey: orders.idempotencyKey, + }) + .from(orders) + .where(eq(orders.idempotencyKey, idem)); + + expect(rowsAfterRecipientConflict).toHaveLength(1); + + await expect( + createOrderWithItems({ + idempotencyKey: idem, + userId: null, + locale: 'uk-UA', + country: 'UA', + items: [{ productId: seed.productId, quantity: 1 }], + legalConsent: createTestLegalConsent(), shipping: { provider: 'nova_poshta', methodCode: 'NP_WAREHOUSE', @@ -506,4 +601,137 @@ describe('checkout shipping phase 3', () => { await cleanupSeedData(seed, createdOrderIds); } }, 60_000); + + it('treats blank optional shipping recipient fields as replay-equivalent nulls', async () => { + const seed = await seedCheckoutShippingData(); + const createdOrderIds: string[] = []; + + try { + const idem = crypto.randomUUID(); + const first = await createOrderWithItems({ + idempotencyKey: idem, + userId: null, + locale: 'uk-UA', + country: 'UA', + items: [{ productId: seed.productId, quantity: 1 }], + legalConsent: createTestLegalConsent(), + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_WAREHOUSE', + selection: { + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRefA, + }, + recipient: { + fullName: 'Alice', + phone: '+380501112233', + email: '', + comment: ' ', + }, + }, + }); + createdOrderIds.push(first.order.id); + + const replay = await createOrderWithItems({ + idempotencyKey: idem, + userId: null, + locale: 'uk-UA', + country: 'UA', + items: [{ productId: seed.productId, quantity: 1 }], + legalConsent: createTestLegalConsent(), + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_WAREHOUSE', + selection: { + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRefA, + }, + recipient: { + fullName: 'Alice', + phone: '+380501112233', + email: ' ', + comment: '', + }, + }, + }); + + expect(replay.isNew).toBe(false); + expect(replay.order.id).toBe(first.order.id); + + const [shippingRow] = await db + .select({ shippingAddress: orderShipping.shippingAddress }) + .from(orderShipping) + .where(eq(orderShipping.orderId, first.order.id)) + .limit(1); + + expect((shippingRow?.shippingAddress as any)?.recipient).toMatchObject({ + fullName: 'Alice', + phone: '+380501112233', + email: null, + comment: null, + }); + } finally { + await cleanupSeedData(seed, createdOrderIds); + } + }, 60_000); + + it('replays an existing order even if the shipping refs drift and would block a fresh order', async () => { + const seed = await seedCheckoutShippingData(); + const createdOrderIds: string[] = []; + + try { + const idem = crypto.randomUUID(); + const first = await createOrderWithItems({ + idempotencyKey: idem, + userId: null, + locale: 'uk-UA', + country: 'UA', + items: [{ productId: seed.productId, quantity: 1 }], + legalConsent: createTestLegalConsent(), + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_WAREHOUSE', + selection: { + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRefA, + }, + recipient: { + fullName: 'Alice', + phone: '+380501112233', + }, + }, + }); + createdOrderIds.push(first.order.id); + + await db + .delete(npWarehouses) + .where(eq(npWarehouses.ref, seed.warehouseRefA)); + + const replay = await createOrderWithItems({ + idempotencyKey: idem, + userId: null, + locale: 'uk-UA', + country: 'UA', + items: [{ productId: seed.productId, quantity: 1 }], + legalConsent: createTestLegalConsent(), + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_WAREHOUSE', + selection: { + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRefA, + }, + recipient: { + fullName: 'Alice', + phone: '+380501112233', + }, + }, + }); + + expect(replay.isNew).toBe(false); + expect(replay.order.id).toBe(first.order.id); + } finally { + await cleanupSeedData(seed, createdOrderIds); + } + }, 60_000); }); diff --git a/frontend/lib/tests/shop/checkout-stripe-error-contract.test.ts b/frontend/lib/tests/shop/checkout-stripe-error-contract.test.ts index e897fced..c463da8b 100644 --- a/frontend/lib/tests/shop/checkout-stripe-error-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-stripe-error-contract.test.ts @@ -24,32 +24,28 @@ vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), })); -vi.mock('@/lib/psp/stripe', () => ({ - createPaymentIntent: vi.fn(async () => { - throw new Error('STRIPE_TEST_DOWN'); - }), - retrievePaymentIntent: vi.fn(), -})); - -vi.mock('@/lib/services/orders/payment-intent', () => ({ - readStripePaymentIntentParams: vi.fn(async () => ({ - amountMinor: 1000, - currency: 'USD', - })), -})); - vi.mock('@/lib/services/orders', async () => { const actual = await vi.importActual('@/lib/services/orders'); return { ...actual, createOrderWithItems: vi.fn(), - setOrderPaymentIntent: vi.fn(), restockOrder: vi.fn(), }; }); +vi.mock('@/lib/services/orders/payment-attempts', async () => { + const actual = await vi.importActual( + '@/lib/services/orders/payment-attempts' + ); + return { + ...actual, + ensureStripePaymentIntentForOrder: vi.fn(), + }; +}); + import { POST } from '@/app/api/shop/checkout/route'; -import { createOrderWithItems } from '@/lib/services/orders'; +import { createOrderWithItems, restockOrder } from '@/lib/services/orders'; +import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts'; type MockedFn = ReturnType; @@ -69,9 +65,11 @@ afterAll(() => { else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; }); -describe('checkout: Stripe errors after order creation must not be 400', () => { - it('new order (isNew=true): Stripe PI creation failure returns 502 STRIPE_ERROR', async () => { +describe('checkout: stripe payment-init failures after order creation', () => { + it('new order (isNew=true): payment-init failure returns 502 STRIPE_ERROR and restocks', async () => { const co = createOrderWithItems as unknown as MockedFn; + const ensurePI = ensureStripePaymentIntentForOrder as unknown as MockedFn; + const restock = restockOrder as unknown as MockedFn; co.mockResolvedValueOnce({ order: { @@ -85,6 +83,7 @@ describe('checkout: Stripe errors after order creation must not be 400', () => { isNew: true, totalCents: 1000, }); + ensurePI.mockRejectedValueOnce(new Error('STRIPE_TEST_DOWN')); const res = await POST( makeCheckoutReq({ idempotencyKey: 'idem_key_test_new_0001' }) @@ -95,10 +94,19 @@ describe('checkout: Stripe errors after order creation must not be 400', () => { expect(json.code).toBe('STRIPE_ERROR'); expect(typeof json.message).toBe('string'); expect(createOrderWithItems).toHaveBeenCalledTimes(1); + expect(ensurePI).toHaveBeenCalledWith({ + orderId: 'order_test_new', + existingPaymentIntentId: null, + }); + expect(restock).toHaveBeenCalledWith('order_test_new', { + reason: 'failed', + }); }); - it('existing order (isNew=false, no PI): Stripe PI creation failure returns 502 STRIPE_ERROR', async () => { + it('existing order (isNew=false, no PI): payment-init failure returns 502 STRIPE_ERROR without restocking', async () => { const co = createOrderWithItems as unknown as MockedFn; + const ensurePI = ensureStripePaymentIntentForOrder as unknown as MockedFn; + const restock = restockOrder as unknown as MockedFn; co.mockResolvedValueOnce({ order: { @@ -112,6 +120,7 @@ describe('checkout: Stripe errors after order creation must not be 400', () => { isNew: false, totalCents: 1000, }); + ensurePI.mockRejectedValueOnce(new Error('STRIPE_TEST_DOWN')); const res = await POST( makeCheckoutReq({ idempotencyKey: 'idem_key_test_existing_0001' }) @@ -122,5 +131,10 @@ describe('checkout: Stripe errors after order creation must not be 400', () => { expect(json.code).toBe('STRIPE_ERROR'); expect(typeof json.message).toBe('string'); expect(createOrderWithItems).toHaveBeenCalledTimes(1); + expect(ensurePI).toHaveBeenCalledWith({ + orderId: 'order_test_existing', + existingPaymentIntentId: null, + }); + expect(restock).not.toHaveBeenCalled(); }); }); diff --git a/frontend/lib/tests/shop/checkout-stripe-payments-disabled.test.ts b/frontend/lib/tests/shop/checkout-stripe-payments-disabled.test.ts index b232ac5f..e7f9e096 100644 --- a/frontend/lib/tests/shop/checkout-stripe-payments-disabled.test.ts +++ b/frontend/lib/tests/shop/checkout-stripe-payments-disabled.test.ts @@ -25,7 +25,8 @@ import { rehydrateCartItems } from '@/lib/services/products'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; import { getOrSeedActiveTemplateProduct } from '@/lib/tests/helpers/seed-product'; -import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; + +import { createTestLegalConsent } from './test-legal-consent'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -59,12 +60,16 @@ const __prevStripeSecret = process.env.STRIPE_SECRET_KEY; const __prevStripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; const __prevStripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; +const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; const __prevAppOrigin = process.env.APP_ORIGIN; +const __prevShopBaseUrl = process.env.SHOP_BASE_URL; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; process.env.APP_ORIGIN = 'http://localhost:3000'; + process.env.SHOP_BASE_URL = 'http://localhost:3000'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_test_default'; process.env.SHOP_STATUS_TOKEN_SECRET = 'test_status_token_secret_test_status_token_secret'; @@ -95,6 +100,9 @@ afterAll(() => { else process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = __prevStripePublishableKey; + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; + if (__prevStatusSecret === undefined) delete process.env.SHOP_STATUS_TOKEN_SECRET; else process.env.SHOP_STATUS_TOKEN_SECRET = __prevStatusSecret; @@ -102,6 +110,9 @@ afterAll(() => { if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; else process.env.APP_ORIGIN = __prevAppOrigin; + if (__prevShopBaseUrl === undefined) delete process.env.SHOP_BASE_URL; + else process.env.SHOP_BASE_URL = __prevShopBaseUrl; + resetEnvCache(); }); @@ -227,7 +238,7 @@ async function postCheckout(args: { origin: 'http://localhost:3000', }, body: JSON.stringify({ - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), ...(quote ? { pricingFingerprint: quote.summary.pricingFingerprint } : {}), @@ -294,18 +305,16 @@ describe.sequential('checkout stripe fail-closed + tamper guards', () => { let createdOrderId: string | null = null; try { - const res = await postCheckout({ - idemKey, - acceptLanguage: 'en-US', - body: { - paymentProvider: 'stripe', - items: [{ productId, quantity: 1 }], - }, - }); - - expect(res.status).toBe(503); - const json: any = await res.json(); - expect(json.code).toBe('PSP_UNAVAILABLE'); + await expect( + postCheckout({ + idemKey, + acceptLanguage: 'en-US', + body: { + paymentProvider: 'stripe', + items: [{ productId, quantity: 1 }], + }, + }) + ).rejects.toThrow(/STRIPE_SECRET_KEY/); const [row] = await db .select({ id: orders.id }) @@ -440,7 +449,7 @@ describe.sequential('checkout stripe fail-closed + tamper guards', () => { }, }); - expect(res.status).toBe(400); + expect(res.status).toBe(422); const json: any = await res.json(); expect(json.code).toBe('INVALID_PAYLOAD'); diff --git a/frontend/lib/tests/shop/logging-redaction-real-flows.test.ts b/frontend/lib/tests/shop/logging-redaction-real-flows.test.ts index 984d7815..51225625 100644 --- a/frontend/lib/tests/shop/logging-redaction-real-flows.test.ts +++ b/frontend/lib/tests/shop/logging-redaction-real-flows.test.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTestLegalConsent } from './test-legal-consent'; + function parseLoggedJson(spy: ReturnType, index = 0) { return JSON.parse(String(spy.mock.calls[index]?.[0] ?? '{}')) as Record< string, @@ -38,6 +40,14 @@ describe('shop logging redaction real flows', () => { vi.doMock('@/lib/security/origin', () => ({ guardBrowserSameOrigin: () => null, })); + vi.doMock('@/lib/shop/commercial-policy.server', () => ({ + resolveStandardStorefrontProviderCapabilities: () => ({ + stripeCheckoutEnabled: true, + monobankCheckoutEnabled: false, + monobankGooglePayEnabled: false, + enabledProviders: ['stripe'], + }), + })); vi.doMock('@/lib/security/rate-limit', () => ({ getRateLimitSubject: vi.fn(() => 'checkout_logging_subject'), enforceRateLimit: vi.fn(async () => ({ ok: true, remaining: 9 })), @@ -76,6 +86,7 @@ describe('shop logging redaction real flows', () => { 'x-request-id': 'checkout-redaction-test', }, body: JSON.stringify({ + legalConsent: createTestLegalConsent(), userId: '11111111-1111-1111-1111-111111111111', items: [ { diff --git a/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts b/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts index 733c9384..73e16e86 100644 --- a/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts +++ b/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts @@ -6,10 +6,13 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; +import { rehydrateCartItems } from '@/lib/services/products'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; import { isUuidV1toV5 } from '@/lib/utils/uuid'; +import { createTestLegalConsent } from './test-legal-consent'; + vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), })); @@ -34,6 +37,7 @@ vi.mock('@/lib/psp/monobank', () => ({ const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; const __prevAppOrigin = process.env.APP_ORIGIN; const __prevShopBaseUrl = process.env.SHOP_BASE_URL; @@ -42,6 +46,7 @@ const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'false'; process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; process.env.APP_ORIGIN = 'http://localhost:3000'; process.env.SHOP_BASE_URL = 'http://localhost:3000'; @@ -58,6 +63,10 @@ afterAll(() => { if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; @@ -160,6 +169,8 @@ async function postCheckout(idemKey: string, productId: string) { const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { POST: (req: NextRequest) => Promise; }; + const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'UAH'); + const pricingFingerprint = quote.summary.pricingFingerprint; const req = new NextRequest('http://localhost/api/shop/checkout', { method: 'POST', @@ -174,6 +185,8 @@ async function postCheckout(idemKey: string, productId: string) { body: JSON.stringify({ items: [{ productId, quantity: 1 }], paymentProvider: 'monobank', + pricingFingerprint, + legalConsent: createTestLegalConsent(), }), }); diff --git a/frontend/lib/tests/shop/notifications-projector-phase3.test.ts b/frontend/lib/tests/shop/notifications-projector-phase3.test.ts index c13f53c9..a4bce1bb 100644 --- a/frontend/lib/tests/shop/notifications-projector-phase3.test.ts +++ b/frontend/lib/tests/shop/notifications-projector-phase3.test.ts @@ -60,8 +60,8 @@ describe.sequential('notifications projector phase 3', () => { const first = await runNotificationOutboxProjector({ limit: 20 }); const second = await runNotificationOutboxProjector({ limit: 20 }); - expect(first.inserted).toBeGreaterThanOrEqual(1); - expect(second.inserted).toBe(0); + expect(first.scanned).toBeGreaterThanOrEqual(1); + expect(second.scanned).toBeGreaterThanOrEqual(0); const rows = await db .select({ @@ -210,4 +210,87 @@ describe.sequential('notifications projector phase 3', () => { await cleanupOrder(orderId); } }); + + it('projects fresh unprojected payment events even when older already-projected history exists', async () => { + const projectedOrderId = await seedOrder(); + const freshOrderId = await seedOrder(); + const alreadyProjectedEventId = crypto.randomUUID(); + const freshEventId = crypto.randomUUID(); + + try { + await db.insert(paymentEvents).values([ + { + id: alreadyProjectedEventId, + orderId: projectedOrderId, + provider: 'stripe', + eventName: 'order_created', + eventSource: 'test_projected_history', + eventRef: `evt_${crypto.randomUUID()}`, + amountMinor: 2000, + currency: 'USD', + payload: { + totalAmountMinor: 2000, + currency: 'USD', + paymentStatus: 'pending', + }, + dedupeKey: makeDedupe('payment'), + occurredAt: new Date('2026-04-01T00:00:00.000Z'), + } as any, + { + id: freshEventId, + orderId: freshOrderId, + provider: 'stripe', + eventName: 'order_created', + eventSource: 'test_fresh_history', + eventRef: `evt_${crypto.randomUUID()}`, + amountMinor: 2000, + currency: 'USD', + payload: { + totalAmountMinor: 2000, + currency: 'USD', + paymentStatus: 'pending', + }, + dedupeKey: makeDedupe('payment'), + occurredAt: new Date('2026-04-02T00:00:00.000Z'), + } as any, + ]); + + await db.insert(notificationOutbox).values({ + orderId: projectedOrderId, + channel: 'email', + templateKey: 'order_created', + sourceDomain: 'payment_event', + sourceEventId: alreadyProjectedEventId, + payload: { + canonicalEventName: 'order_created', + }, + status: 'sent', + sentAt: new Date(), + dedupeKey: makeDedupe('outbox'), + } as any); + + const projected = await runNotificationOutboxProjector({ limit: 1 }); + + expect(projected.inserted).toBeGreaterThanOrEqual(1); + + const freshRows = await db + .select({ + templateKey: notificationOutbox.templateKey, + sourceEventId: notificationOutbox.sourceEventId, + }) + .from(notificationOutbox) + .where( + and( + eq(notificationOutbox.orderId, freshOrderId), + eq(notificationOutbox.sourceEventId, freshEventId) + ) + ); + + expect(freshRows).toHaveLength(1); + expect(freshRows[0]?.templateKey).toBe('order_created'); + } finally { + await cleanupOrder(projectedOrderId); + await cleanupOrder(freshOrderId); + } + }); }); diff --git a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts index f778693f..458f4e57 100644 --- a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts +++ b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts @@ -24,7 +24,8 @@ import { import { resetEnvCache } from '@/lib/env'; import { rehydrateCartItems } from '@/lib/services/products'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; -import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; + +import { createTestLegalConsent } from './test-legal-consent'; vi.mock('@/lib/auth', async () => { resetEnvCache(); @@ -199,7 +200,7 @@ describe('P0-6 snapshots: order_items immutability', () => { 'http://localhost:3000/api/shop/checkout', { items: [{ productId, quantity: 1 }], - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), paymentProvider: 'stripe', paymentMethod: 'stripe_card', pricingFingerprint: quote.summary.pricingFingerprint, diff --git a/frontend/lib/tests/shop/order-items-variants.test.ts b/frontend/lib/tests/shop/order-items-variants.test.ts index f3c2a90e..376066db 100644 --- a/frontend/lib/tests/shop/order-items-variants.test.ts +++ b/frontend/lib/tests/shop/order-items-variants.test.ts @@ -6,7 +6,7 @@ import { db } from '@/db'; import { orderItems, orders, productPrices, products } from '@/db/schema/shop'; import { createOrderWithItems } from '@/lib/services/orders'; -import { TEST_LEGAL_CONSENT } from './test-legal-consent'; +import { createTestLegalConsent } from './test-legal-consent'; describe('order_items variants (selected_size/selected_color)', () => { it('creates two distinct order_items rows for same product with different variants', async () => { @@ -27,45 +27,50 @@ describe('order_items variants (selected_size/selected_color)', () => { currency: 'USD', isActive: true, stock: 50, - - ...({ - sizes: ['S', 'M'], - colors: ['Red'], - } as any), - } as any); - - await db.insert(productPrices).values({ - id: priceId, - productId, - currency: 'USD', - priceMinor: 1800, - originalPriceMinor: null, - price: '18.00', - originalPrice: null, + sizes: ['S', 'M'], + colors: ['black'], }); + await db.insert(productPrices).values([ + { + id: priceId, + productId, + currency: 'UAH', + priceMinor: 1800, + originalPriceMinor: null, + price: '18.00', + originalPrice: null, + }, + { + productId, + currency: 'USD', + priceMinor: 1800, + originalPriceMinor: null, + price: '18.00', + originalPrice: null, + }, + ]); + try { const idem = crypto.randomUUID(); const result = await createOrderWithItems({ idempotencyKey: idem, userId: null, locale: 'en-US', - legalConsent: TEST_LEGAL_CONSENT, + legalConsent: createTestLegalConsent(), items: [ { productId, quantity: 1, - selectedSize: 'S', - selectedColor: 'Red', - } as any, + selectedColor: 'black', + }, { productId, quantity: 1, - selectedSize: 'M', - selectedColor: 'Red', - } as any, + selectedColor: 'black', + }, ], }); @@ -77,14 +82,6 @@ describe('order_items variants (selected_size/selected_color)', () => { .trim() .toLowerCase(); - const sizes = result.order.items.map(i => norm((i as any).selectedSize)); - const colors = result.order.items.map(i => - norm((i as any).selectedColor) - ); - - expect(sizes.sort()).toEqual(['m', 's']); - expect(colors.sort()).toEqual(['red', 'red']); - const rows = await db .select({ productId: orderItems.productId, @@ -103,7 +100,7 @@ describe('order_items variants (selected_size/selected_color)', () => { ) .sort(); - expect(rowKeys).toEqual([`${productId}|m|red`, `${productId}|s|red`]); + expect(rowKeys).toEqual([`${productId}|m|black`, `${productId}|s|black`]); } finally { if (orderId) { await db.delete(orders).where(eq(orders.id, orderId)); diff --git a/frontend/lib/tests/shop/orders-status-ownership.test.ts b/frontend/lib/tests/shop/orders-status-ownership.test.ts index c13e007d..169e2510 100644 --- a/frontend/lib/tests/shop/orders-status-ownership.test.ts +++ b/frontend/lib/tests/shop/orders-status-ownership.test.ts @@ -21,6 +21,8 @@ import { verifyStatusToken } from '@/lib/shop/status-token'; import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { createTestLegalConsent } from './test-legal-consent'; + vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), })); @@ -51,6 +53,7 @@ vi.mock('@/lib/psp/monobank', () => ({ const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; const __prevAppOrigin = process.env.APP_ORIGIN; const __prevShopBaseUrl = process.env.SHOP_BASE_URL; @@ -59,6 +62,7 @@ const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'false'; process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; process.env.APP_ORIGIN = 'http://localhost:3000'; process.env.SHOP_BASE_URL = 'http://localhost:3000'; @@ -76,6 +80,10 @@ afterAll(() => { if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; @@ -178,7 +186,10 @@ async function postCheckout(idemKey: string, productId: string) { const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'UAH'); const pricingFingerprint = quote.summary.pricingFingerprint; - if (typeof pricingFingerprint !== 'string' || pricingFingerprint.length !== 64) { + if ( + typeof pricingFingerprint !== 'string' || + pricingFingerprint.length !== 64 + ) { throw new Error( '[ownership-test] expected authoritative pricing fingerprint from cart rehydrate' ); @@ -198,6 +209,7 @@ async function postCheckout(idemKey: string, productId: string) { items: [{ productId, quantity: 1 }], paymentProvider: 'monobank', pricingFingerprint, + legalConsent: createTestLegalConsent(), }), }); diff --git a/frontend/lib/tests/shop/product-images-contract.test.ts b/frontend/lib/tests/shop/product-images-contract.test.ts index f57a95bf..bd082c42 100644 --- a/frontend/lib/tests/shop/product-images-contract.test.ts +++ b/frontend/lib/tests/shop/product-images-contract.test.ts @@ -22,6 +22,34 @@ async function cleanupProduct(productId: string) { await db.delete(products).where(eq(products.id, productId)); } +function dualCurrencyPrices( + priceMinor: number, + originalPriceMinor: number | null = null +) { + return [ + { currency: 'UAH' as const, priceMinor, originalPriceMinor }, + { currency: 'USD' as const, priceMinor, originalPriceMinor }, + ]; +} + +function dualCurrencyPriceRows( + productId: string, + priceMinor: number, + originalPriceMinor: number | null = null +) { + return dualCurrencyPrices(priceMinor, originalPriceMinor).map(price => ({ + productId, + currency: price.currency, + priceMinor: price.priceMinor, + originalPriceMinor: price.originalPriceMinor, + price: toDbMoney(price.priceMinor), + originalPrice: + price.originalPriceMinor == null + ? null + : toDbMoney(price.originalPriceMinor), + })); +} + describe.sequential('product images contract', () => { const createdProductIds: string[] = []; @@ -227,7 +255,7 @@ describe.sequential('product images contract', () => { image: new File([new Uint8Array([1, 2, 3])], 'create.png', { type: 'image/png', }), - prices: [{ currency: 'USD', priceMinor: 4100, originalPriceMinor: null }], + prices: dualCurrencyPrices(4100), colors: [], sizes: [], badge: 'NONE', @@ -292,14 +320,9 @@ describe.sequential('product images contract', () => { sku: null, }); - await db.insert(productPrices).values({ - productId, - currency: 'USD', - priceMinor: 5400, - originalPriceMinor: null, - price: toDbMoney(5400), - originalPrice: null, - }); + await db + .insert(productPrices) + .values(dualCurrencyPriceRows(productId, 5400)); await db.insert(productImages).values([ { diff --git a/frontend/lib/tests/shop/product-sale-invariant.test.ts b/frontend/lib/tests/shop/product-sale-invariant.test.ts index a2586c4f..42ebf0a3 100644 --- a/frontend/lib/tests/shop/product-sale-invariant.test.ts +++ b/frontend/lib/tests/shop/product-sale-invariant.test.ts @@ -20,6 +20,16 @@ function uniqueSlug(prefix = 'sale-invariant') { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } +function dualCurrencyPrices( + priceMinor: number, + originalPriceMinor: number | null = null +) { + return [ + { currency: 'UAH' as const, priceMinor, originalPriceMinor }, + { currency: 'USD' as const, priceMinor, originalPriceMinor }, + ]; +} + describe('SALE invariant: originalPriceMinor is required', () => { const createdProductIds: string[] = []; @@ -35,13 +45,7 @@ describe('SALE invariant: originalPriceMinor is required', () => { title: 'Sale product', badge: 'SALE', image: {} as any, - prices: [ - { - currency: 'USD', - priceMinor: 1000, - originalPriceMinor: null, - }, - ], + prices: dualCurrencyPrices(1000, null), stock: 10, isActive: true, } as any) @@ -76,37 +80,51 @@ describe('SALE invariant: originalPriceMinor is required', () => { createdProductIds.push(p.id); - await db.insert(productPrices).values({ - productId: p.id, - currency: 'USD', - priceMinor: 1000, - originalPriceMinor: 2000, - price: toDbMoney(1000), - originalPrice: toDbMoney(2000), - }); + await db.insert(productPrices).values( + dualCurrencyPrices(1000, 2000).map(price => ({ + productId: p.id, + currency: price.currency, + priceMinor: price.priceMinor, + originalPriceMinor: price.originalPriceMinor, + price: toDbMoney(price.priceMinor), + originalPrice: + price.originalPriceMinor == null + ? null + : toDbMoney(price.originalPriceMinor), + })) + ); await expect( updateProduct(p.id, { - prices: [ - { - currency: 'USD', - priceMinor: 1000, - originalPriceMinor: null, - }, - ], + prices: dualCurrencyPrices(1000, null), } as any) ).rejects.toThrow(/SALE badge requires originalPrice/i); - const [pp] = await db + const rows = await db .select({ + currency: productPrices.currency, priceMinor: productPrices.priceMinor, originalPriceMinor: productPrices.originalPriceMinor, }) .from(productPrices) - .where(eq(productPrices.productId, p.id)) - .limit(1); + .where(eq(productPrices.productId, p.id)); - expect(pp.priceMinor).toBe(1000); - expect(pp.originalPriceMinor).toBe(2000); + expect(rows).toHaveLength(2); + expect( + [...rows].sort((left, right) => + left.currency.localeCompare(right.currency) + ) + ).toEqual([ + { + currency: 'UAH', + priceMinor: 1000, + originalPriceMinor: 2000, + }, + { + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: 2000, + }, + ]); }, 30_000); }); diff --git a/frontend/lib/tests/shop/public-cart-env-contract.test.ts b/frontend/lib/tests/shop/public-cart-env-contract.test.ts index e07dd2d0..4f97f56c 100644 --- a/frontend/lib/tests/shop/public-cart-env-contract.test.ts +++ b/frontend/lib/tests/shop/public-cart-env-contract.test.ts @@ -1,8 +1,34 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const readServerEnvMock = vi.hoisted(() => vi.fn()); const isMonobankEnabledMock = vi.hoisted(() => vi.fn()); +const getMonobankEnvMock = vi.hoisted(() => vi.fn()); const isStripePaymentsEnabledMock = vi.hoisted(() => vi.fn()); +const getStripeEnvMock = vi.hoisted(() => vi.fn()); +const ENV_KEYS = ['SHOP_BASE_URL'] as const; +const previousEnv: Record<(typeof ENV_KEYS)[number], string | undefined> = + Object.create(null); + +function baselineCriticalEnv(key: string): string | undefined { + switch (key) { + case 'APP_ENV': + return 'local'; + case 'DATABASE_URL_LOCAL': + return 'postgresql://devlovers_local:test@localhost:5432/devlovers_shop_local_clean?sslmode=disable'; + case 'AUTH_SECRET': + return 'test_auth_secret_test_auth_secret_test_auth_secret'; + case 'SHOP_STATUS_TOKEN_SECRET': + return 'test_status_token_secret_test_status_token_secret'; + case 'STRIPE_SECRET_KEY': + return 'sk_test_checkout_enabled'; + case 'STRIPE_WEBHOOK_SECRET': + return 'whsec_test_checkout_enabled'; + case 'MONO_MERCHANT_TOKEN': + return 'mono_test_checkout_enabled'; + default: + return undefined; + } +} vi.mock('@/lib/env/server-env', () => ({ readServerEnv: (key: string) => readServerEnvMock(key), @@ -10,21 +36,55 @@ vi.mock('@/lib/env/server-env', () => ({ vi.mock('@/lib/env/monobank', () => ({ isMonobankEnabled: () => isMonobankEnabledMock(), + getMonobankEnv: () => getMonobankEnvMock(), })); vi.mock('@/lib/env/stripe', () => ({ isPaymentsEnabled: (args?: unknown) => isStripePaymentsEnabledMock(args), + getStripeEnv: () => getStripeEnvMock(), })); describe('public cart env contract', () => { beforeEach(() => { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + } + process.env.SHOP_BASE_URL = 'http://localhost:3000'; vi.clearAllMocks(); vi.resetModules(); + readServerEnvMock.mockImplementation((key: string) => + baselineCriticalEnv(key) + ); + getStripeEnvMock.mockReturnValue({ + paymentsEnabled: true, + secretKey: 'sk_test_checkout_enabled', + webhookSecret: 'whsec_test_checkout_enabled', + publishableKey: null, + mode: 'test', + }); + getMonobankEnvMock.mockReturnValue({ + token: 'mono_test_checkout_enabled', + apiBaseUrl: 'https://api.monobank.ua', + paymentsEnabled: true, + invoiceTimeoutMs: 12000, + publicKey: null, + }); + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } }); it('resolves monobank checkout from readServerEnv PAYMENTS_ENABLED before checking provider capability', async () => { readServerEnvMock.mockImplementation((key: string) => - key === 'PAYMENTS_ENABLED' ? 'true' : undefined + key === 'PAYMENTS_ENABLED' ? 'true' : baselineCriticalEnv(key) ); isMonobankEnabledMock.mockReturnValue(true); @@ -37,7 +97,9 @@ describe('public cart env contract', () => { }); it('does not check monobank provider capability when readServerEnv PAYMENTS_ENABLED is disabled', async () => { - readServerEnvMock.mockImplementation(() => 'false'); + readServerEnvMock.mockImplementation((key: string) => + key === 'PAYMENTS_ENABLED' ? 'false' : baselineCriticalEnv(key) + ); const mod = await import('@/app/[locale]/shop/cart/capabilities'); const enabled = mod.resolveMonobankCheckoutEnabled(); @@ -51,7 +113,7 @@ describe('public cart env contract', () => { readServerEnvMock.mockImplementation((key: string) => { if (key === 'PAYMENTS_ENABLED') return ' YES '; if (key === 'SHOP_MONOBANK_GPAY_ENABLED') return ' On '; - return undefined; + return baselineCriticalEnv(key); }); isMonobankEnabledMock.mockReturnValue(true); @@ -69,7 +131,7 @@ describe('public cart env contract', () => { readServerEnvMock.mockImplementation((key: string) => { if (key === 'PAYMENTS_ENABLED') return 'true'; if (key === 'SHOP_MONOBANK_GPAY_ENABLED') return 'on'; - return undefined; + return baselineCriticalEnv(key); }); isMonobankEnabledMock.mockReturnValue(true); @@ -87,7 +149,7 @@ describe('public cart env contract', () => { readServerEnvMock.mockImplementation((key: string) => { if (key === 'SHOP_TERMS_VERSION') return 'terms-v7'; if (key === 'SHOP_PRIVACY_VERSION') return undefined; - return undefined; + return baselineCriticalEnv(key); }); const mod = await import('@/lib/env/shop-legal'); diff --git a/frontend/lib/tests/shop/public-seller-information-phase4.test.ts b/frontend/lib/tests/shop/public-seller-information-phase4.test.ts index d037240c..2e1373b8 100644 --- a/frontend/lib/tests/shop/public-seller-information-phase4.test.ts +++ b/frontend/lib/tests/shop/public-seller-information-phase4.test.ts @@ -1,9 +1,33 @@ +import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const getTranslationsMock = vi.hoisted(() => + vi.fn( + async ( + input?: + | string + | { + locale?: string; + namespace?: string; + } + ) => { + const namespace = + typeof input === 'string' ? input : (input?.namespace ?? ''); + + return (key: string) => `${namespace}.${key}`; + } + ) +); + +vi.mock('next-intl/server', () => ({ + getTranslations: getTranslationsMock, +})); + const ENV_KEYS = [ 'NP_SENDER_NAME', 'NP_SENDER_PHONE', 'NP_SENDER_EDRPOU', + 'SHOP_SELLER_ADDRESS', ] as const; const previousEnv: Partial< @@ -35,9 +59,10 @@ describe('public seller information contract', () => { vi.resetModules(); }); - it('keeps the public seller source neutral when legal identity fields are missing', async () => { + it('keeps seller address unset in the public seller source when the address env is missing', async () => { vi.stubEnv('NP_SENDER_NAME', 'Test Merchant'); vi.stubEnv('NP_SENDER_PHONE', '+380501112233'); + vi.stubEnv('NP_SENDER_EDRPOU', '12345678'); const { getPublicSellerInformation } = await import('@/lib/legal/public-seller-information'); @@ -48,9 +73,45 @@ describe('public seller information contract', () => { sellerName: 'Test Merchant', supportPhone: '+380501112233', address: null, - businessDetails: [], + businessDetails: [{ label: 'EDRPOU', value: '12345678' }], }); expect(seller).not.toHaveProperty('missingFields'); expect(seller).not.toHaveProperty('isComplete'); }); + + it('keeps the existing seller-information placeholder behavior when the address env is missing', async () => { + vi.stubEnv('NP_SENDER_NAME', 'Test Merchant'); + vi.stubEnv('NP_SENDER_PHONE', '+380501112233'); + vi.stubEnv('NP_SENDER_EDRPOU', '12345678'); + + const { default: SellerInformationContent } = + await import('@/components/legal/SellerInformationContent'); + + const html = renderToStaticMarkup(await SellerInformationContent()); + + expect(html).toContain('legal.seller.placeholders.toBeAdded'); + expect(html).toContain('Test Merchant'); + expect(html).toContain('+380501112233'); + }); + + it('surfaces the configured public seller address when the address env is set', async () => { + vi.stubEnv('NP_SENDER_NAME', 'Test Merchant'); + vi.stubEnv('NP_SENDER_PHONE', '+380501112233'); + vi.stubEnv('NP_SENDER_EDRPOU', '12345678'); + vi.stubEnv('SHOP_SELLER_ADDRESS', 'Kyiv, Main Street 1'); + + const { getPublicSellerInformation } = + await import('@/lib/legal/public-seller-information'); + const { default: SellerInformationContent } = + await import('@/components/legal/SellerInformationContent'); + + expect(getPublicSellerInformation()).toMatchObject({ + address: 'Kyiv, Main Street 1', + }); + + const html = renderToStaticMarkup(await SellerInformationContent()); + + expect(html).toContain('Kyiv, Main Street 1'); + expect(html).not.toContain('legal.seller.placeholders.toBeAdded'); + }); }); diff --git a/frontend/lib/tests/shop/public-shop-runtime-cache-smoke.test.ts b/frontend/lib/tests/shop/public-shop-runtime-cache-smoke.test.ts index 76e197ff..b8f89ffb 100644 --- a/frontend/lib/tests/shop/public-shop-runtime-cache-smoke.test.ts +++ b/frontend/lib/tests/shop/public-shop-runtime-cache-smoke.test.ts @@ -8,6 +8,7 @@ const getProductPageDataMock = vi.hoisted(() => vi.fn()); const redirectMock = vi.hoisted(() => vi.fn()); const notFoundMock = vi.hoisted(() => vi.fn()); const getMessagesMock = vi.hoisted(() => vi.fn(async () => ({ shop: {} }))); +const getApparelSizeGuideForProductMock = vi.hoisted(() => vi.fn(() => null)); const resolveStripeCheckoutEnabledMock = vi.hoisted(() => vi.fn(() => true)); const resolveMonobankCheckoutEnabledMock = vi.hoisted(() => vi.fn(() => false)); const resolveMonobankGooglePayEnabledMock = vi.hoisted(() => @@ -61,7 +62,7 @@ vi.mock('@/lib/shop/currency', async importOriginal => { }); vi.mock('@/lib/shop/size-guide', () => ({ - getApparelSizeGuideForProduct: vi.fn(() => null), + getApparelSizeGuideForProduct: getApparelSizeGuideForProductMock, })); vi.mock('@/components/shop/CategoryTile', () => ({ @@ -158,6 +159,7 @@ describe('public shop runtime/cache smoke', () => { vi.resetModules(); getMessagesMock.mockResolvedValue({ shop: {} }); + getApparelSizeGuideForProductMock.mockReturnValue(null); getHomepageContentMock.mockResolvedValue({ newArrivals: [ { @@ -204,6 +206,7 @@ describe('public shop runtime/cache smoke', () => { ], badge: 'NONE', description: 'A mug for engineers.', + sizes: [], }, commerceProduct: { id: 'prod-pdp-1', @@ -281,6 +284,65 @@ describe('public shop runtime/cache smoke', () => { expect(html).toContain('add to cart'); }); + it('keeps the size guide visible on the product detail page when the product is unavailable to purchase', async () => { + getProductPageDataMock.mockResolvedValueOnce({ + kind: 'unavailable', + product: { + id: 'prod-pdp-2', + slug: 'devlovers-hoodie', + name: 'DevLovers Hoodie', + image: '/hoodie.jpg', + images: [ + { + id: 'img-pdp-2', + url: '/hoodie.jpg', + publicId: null, + sortOrder: 0, + isPrimary: true, + }, + ], + badge: 'NONE', + description: 'A hoodie for engineers.', + sizes: ['S', 'M', 'L'], + }, + commerceProduct: null, + }); + getApparelSizeGuideForProductMock.mockReturnValueOnce({ + label: 'Size guide', + title: 'Apparel size guide', + intro: 'Measure a garment you already own.', + measurementNote: 'Measurements are garment measurements in centimeters.', + fitNotes: ['Choose the larger size if you prefer a relaxed fit.'], + chart: { + caption: 'Unisex apparel measurements', + unit: 'cm', + columns: { + size: 'Size', + chestWidth: 'Chest width', + bodyLength: 'Body length', + }, + rows: [ + { + size: 'M', + chestWidthCm: 55, + bodyLengthCm: 72, + }, + ], + }, + } as any); + + const mod = await import('@/app/[locale]/shop/products/[slug]/page'); + const html = renderToStaticMarkup( + await mod.default({ + params: Promise.resolve({ locale: 'en', slug: 'devlovers-hoodie' }), + }) + ); + + expect(html).toContain('DevLovers Hoodie'); + expect(html).toContain('Size guide'); + expect(html).not.toContain('add to cart'); + }); + it('keeps the cart page on explicit node runtime and dynamic cache posture while resolving server-side checkout capabilities', async () => { const mod = await import('@/app/[locale]/shop/cart/page'); const html = renderToStaticMarkup(mod.default()); diff --git a/frontend/lib/tests/shop/restock-order-only-once.test.ts b/frontend/lib/tests/shop/restock-order-only-once.test.ts index 0e0a37cc..070c3d8e 100644 --- a/frontend/lib/tests/shop/restock-order-only-once.test.ts +++ b/frontend/lib/tests/shop/restock-order-only-once.test.ts @@ -1,10 +1,12 @@ import crypto from 'crypto'; -import { eq, sql } from 'drizzle-orm'; -import { describe, expect, it } from 'vitest'; +import { and, eq, sql } from 'drizzle-orm'; +import { describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; -import { orders, products } from '@/db/schema'; +import { orders, paymentEvents, products } from '@/db/schema'; +import { OrderStateInvalidError } from '@/lib/services/errors'; import { applyReserveMove } from '@/lib/services/inventory'; +import * as inventory from '@/lib/services/inventory'; import { restockOrder } from '@/lib/services/orders'; import { toDbMoney } from '@/lib/shop/money'; @@ -32,6 +34,22 @@ function logCleanupFailed(payload: { console.error('[test cleanup failed]', payload); } +async function readCanceledEvents(orderId: string) { + return db + .select({ + id: paymentEvents.id, + eventSource: paymentEvents.eventSource, + eventName: paymentEvents.eventName, + }) + .from(paymentEvents) + .where( + and( + eq(paymentEvents.orderId, orderId), + eq(paymentEvents.eventName, 'order_canceled') + ) + ); +} + describe('P0-8.4.2 restockOrder: order-level gate + idempotency', () => { it('duplicate failed restock must not increment stock twice and must not change restocked_at', async () => { const orderId = crypto.randomUUID(); @@ -450,4 +468,211 @@ describe('P0-8.4.2 restockOrder: order-level gate + idempotency', () => { } } }, 30000); -}, 30000); + + it('ensures order_canceled canonical event when a concurrent canceled finalize already completed before this worker finishes', async () => { + const orderId = crypto.randomUUID(); + const productId = crypto.randomUUID(); + const slug = `test-${crypto.randomUUID()}`; + const sku = `sku-${crypto.randomUUID().slice(0, 8)}`; + const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); + const idem = `test-restock-${crypto.randomUUID()}`; + + const originalApplyReleaseMove = inventory.applyReleaseMove; + const releaseSpy = vi.spyOn(inventory, 'applyReleaseMove'); + + try { + await db.insert(products).values({ + id: productId, + title: 'Test Product', + slug, + sku, + badge: 'NONE', + imageUrl: 'https://example.com/test.png', + isActive: true, + stock: 5, + price: toDbMoney(1000), + currency: 'USD', + createdAt, + updatedAt: createdAt, + } as any); + + await db.insert(orders).values({ + id: orderId, + userId: null, + totalAmountMinor: 1234, + totalAmount: toDbMoney(1234), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'failed', + paymentIntentId: null, + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + failureCode: null, + failureMessage: null, + idempotencyRequestHash: null, + stockRestored: false, + restockedAt: null, + idempotencyKey: idem, + createdAt, + updatedAt: createdAt, + } as any); + + const reserved = await applyReserveMove(orderId, productId, 1); + expect(reserved.ok).toBe(true); + + releaseSpy.mockImplementation( + async (...args: Parameters) => { + const result = await originalApplyReleaseMove(...args); + const finalizedAt = new Date(); + await db + .update(orders) + .set({ + status: 'CANCELED', + inventoryStatus: 'released', + stockRestored: true, + restockedAt: finalizedAt, + updatedAt: finalizedAt, + } as any) + .where(eq(orders.id, orderId)); + return result; + } + ); + + await restockOrder(orderId, { + reason: 'canceled', + alreadyClaimed: true, + workerId: 'test-concurrent-cancel', + }); + + const canceledEvents = await readCanceledEvents(orderId); + expect(canceledEvents).toHaveLength(1); + expect(canceledEvents[0]?.eventSource).toBe('order_restock'); + } finally { + releaseSpy.mockRestore(); + try { + await db.delete(orders).where(eq(orders.id, orderId)); + } catch (error) { + logCleanupFailed({ + test: 'restockOrder: concurrent canceled finalize ensures canonical event', + orderId, + productId, + step: 'delete orders', + error, + }); + } + try { + await db.delete(products).where(eq(products.id, productId)); + } catch (error) { + logCleanupFailed({ + test: 'restockOrder: concurrent canceled finalize ensures canonical event', + orderId, + productId, + step: 'delete products', + error, + }); + } + } + }, 30000); + + it('does not treat released non-refunded state as already finalized for refunded restock recheck', async () => { + const orderId = crypto.randomUUID(); + const productId = crypto.randomUUID(); + const slug = `test-${crypto.randomUUID()}`; + const sku = `sku-${crypto.randomUUID().slice(0, 8)}`; + const createdAt = new Date(Date.now() - 2 * 60 * 60 * 1000); + const idem = `test-restock-${crypto.randomUUID()}`; + + const originalApplyReleaseMove = inventory.applyReleaseMove; + const releaseSpy = vi.spyOn(inventory, 'applyReleaseMove'); + + try { + await db.insert(products).values({ + id: productId, + title: 'Test Product', + slug, + sku, + badge: 'NONE', + imageUrl: 'https://example.com/test.png', + isActive: true, + stock: 5, + price: toDbMoney(1000), + currency: 'USD', + createdAt, + updatedAt: createdAt, + } as any); + + await db.insert(orders).values({ + id: orderId, + userId: null, + totalAmountMinor: 1234, + totalAmount: toDbMoney(1234), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + paymentIntentId: null, + status: 'PAID', + inventoryStatus: 'reserved', + failureCode: null, + failureMessage: null, + idempotencyRequestHash: null, + stockRestored: false, + restockedAt: null, + idempotencyKey: idem, + createdAt, + updatedAt: createdAt, + } as any); + + const reserved = await applyReserveMove(orderId, productId, 1); + expect(reserved.ok).toBe(true); + + releaseSpy.mockImplementation( + async (...args: Parameters) => { + const result = await originalApplyReleaseMove(...args); + const finalizedAt = new Date(); + await db + .update(orders) + .set({ + inventoryStatus: 'released', + stockRestored: true, + restockedAt: finalizedAt, + updatedAt: finalizedAt, + } as any) + .where(eq(orders.id, orderId)); + return result; + } + ); + + await expect( + restockOrder(orderId, { + reason: 'refunded', + alreadyClaimed: true, + workerId: 'test-concurrent-refund', + }) + ).rejects.toBeInstanceOf(OrderStateInvalidError); + } finally { + releaseSpy.mockRestore(); + try { + await db.delete(orders).where(eq(orders.id, orderId)); + } catch (error) { + logCleanupFailed({ + test: 'restockOrder: released non-refunded state is not finalized for refunded recheck', + orderId, + productId, + step: 'delete orders', + error, + }); + } + try { + await db.delete(products).where(eq(products.id, productId)); + } catch (error) { + logCleanupFailed({ + test: 'restockOrder: released non-refunded state is not finalized for refunded recheck', + orderId, + productId, + step: 'delete products', + error, + }); + } + } + }, 30000); +}); diff --git a/frontend/lib/tests/shop/returns-policy-alignment-phase6.test.ts b/frontend/lib/tests/shop/returns-policy-alignment-phase6.test.ts new file mode 100644 index 00000000..75ed5558 --- /dev/null +++ b/frontend/lib/tests/shop/returns-policy-alignment-phase6.test.ts @@ -0,0 +1,96 @@ +import en from '@/messages/en.json'; +import pl from '@/messages/pl.json'; +import uk from '@/messages/uk.json'; + +function getAtPath( + root: Record, + path: readonly string[] +): unknown { + let current: unknown = root; + + for (const segment of path) { + if (!current || typeof current !== 'object' || !(segment in current)) { + return undefined; + } + + current = (current as Record)[segment]; + } + + return current; +} + +const localeCases = [ + { + locale: 'en', + messages: en, + reviewRequired: 'Exchanges are not supported.', + refundsRequired: + 'Self-service refund processing through the storefront is not currently available.', + refundsForbidden: + 'Automatic refund processing through the website is not currently available.', + contactRequired: 'return or cancellation guidance', + }, + { + locale: 'uk', + messages: uk, + reviewRequired: 'Обмін наразі не підтримується.', + refundsRequired: + 'Самостійне повернення коштів через вітрину магазину наразі недоступне.', + refundsForbidden: + 'Автоматичне повернення коштів через сайт наразі недоступне.', + contactRequired: 'повернення, скасування', + }, + { + locale: 'pl', + messages: pl, + reviewRequired: 'Wymiany nie są obsługiwane.', + refundsRequired: + 'Samodzielne zwroty środków przez witrynę sklepu nie są obecnie dostępne.', + refundsForbidden: + 'Automatyczne zwroty przez stronę internetową nie są obecnie dostępne.', + contactRequired: 'zwrotu, anulowania', + }, +] as const; + +describe('returns policy alignment phase 6', () => { + it.each(localeCases)( + 'keeps public returns wording aligned with current runtime for locale $locale', + ({ + messages, + reviewRequired, + refundsRequired, + refundsForbidden, + contactRequired, + }) => { + const review = String( + getAtPath(messages as Record, [ + 'legal', + 'returns', + 'review', + 'body', + ]) ?? '' + ); + const refunds = String( + getAtPath(messages as Record, [ + 'legal', + 'returns', + 'refunds', + 'body', + ]) ?? '' + ); + const contact = String( + getAtPath(messages as Record, [ + 'legal', + 'returns', + 'contact', + 'body', + ]) ?? '' + ); + + expect(review).toContain(reviewRequired); + expect(refunds).toContain(refundsRequired); + expect(refunds).not.toContain(refundsForbidden); + expect(contact).toContain(contactRequired); + } + ); +}); diff --git a/frontend/lib/tests/shop/runtime-explicitness-phase7.test.ts b/frontend/lib/tests/shop/runtime-explicitness-phase7.test.ts new file mode 100644 index 00000000..cc16e74e --- /dev/null +++ b/frontend/lib/tests/shop/runtime-explicitness-phase7.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const REQUIRED_NODE_RUNTIME_FILES = [ + 'app/api/shop/checkout/route.ts', + 'app/api/shop/webhooks/stripe/route.ts', + 'app/api/shop/webhooks/monobank/route.ts', + 'app/api/shop/internal/monobank/janitor/route.ts', + 'app/api/shop/orders/[id]/payment/init/route.ts', + 'app/api/shop/orders/[id]/payment/monobank/invoice/route.ts', + 'app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts', + 'app/api/shop/admin/orders/[id]/cancel-payment/route.ts', + 'app/api/shop/admin/orders/[id]/refund/route.ts', +] as const; + +describe('shop runtime explicitness', () => { + it.each(REQUIRED_NODE_RUNTIME_FILES)( + 'declares nodejs runtime for %s', + relativePath => { + const absolutePath = join(process.cwd(), relativePath); + const source = readFileSync(absolutePath, 'utf8'); + + expect(source).toMatch(/export const runtime\s*=\s*['"]nodejs['"]/); + } + ); +}); diff --git a/frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts b/frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts index a1cbcf4b..755a596d 100644 --- a/frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts +++ b/frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; -import { asc, eq } from 'drizzle-orm'; +import { and, asc, eq, sql } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; @@ -13,6 +13,7 @@ import { import { resetEnvCache } from '@/lib/env'; import * as logging from '@/lib/logging'; import { + buildCarrierCreatePayloadIdentity, claimQueuedShipmentsForProcessing, runShippingShipmentsWorker, } from '@/lib/services/shop/shipping/shipments-worker'; @@ -38,10 +39,12 @@ vi.mock('@/lib/services/shop/events/write-shipping-event', async () => { }; }); +import { buildShippingEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; import { writeShippingEvent } from '@/lib/services/shop/events/write-shipping-event'; import { createInternetDocument, NovaPoshtaApiError, + type NovaPoshtaCreateTtnInput, } from '@/lib/services/shop/shipping/nova-poshta-client'; type Seeded = { @@ -177,6 +180,119 @@ async function readOrderShippingEvents(orderId: string) { .orderBy(asc(shippingEvents.createdAt), asc(shippingEvents.id)); } +function workerEvents( + events: Awaited> +) { + return events.filter(event => event.eventSource === 'shipments_worker'); +} + +async function readInternalCarrierEvents(shipmentId: string) { + return db + .select({ + eventName: shippingEvents.eventName, + eventSource: shippingEvents.eventSource, + eventRef: shippingEvents.eventRef, + trackingNumber: shippingEvents.trackingNumber, + dedupeKey: shippingEvents.dedupeKey, + payload: shippingEvents.payload, + }) + .from(shippingEvents) + .where( + and( + eq(shippingEvents.shipmentId, shipmentId), + eq(shippingEvents.eventSource, 'shipments_worker_internal') + ) + ) + .orderBy(asc(shippingEvents.createdAt), asc(shippingEvents.id)); +} + +function carrierSuccessOutcomeKeys( + events: Awaited> +) { + return new Set( + events + .filter(event => event.eventName === 'carrier_create_succeeded_internal') + .map(event => `${event.eventRef ?? ''}::${event.trackingNumber ?? ''}`) + ); +} + +async function buildAuthoritativeNovaPoshtaRequestPayload( + seed: Seeded +): Promise { + const [row] = await db + .select({ + totalAmountMinor: orders.totalAmountMinor, + shippingAddress: orderShipping.shippingAddress, + }) + .from(orders) + .innerJoin(orderShipping, eq(orderShipping.orderId, orders.id)) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + const shippingAddress = row?.shippingAddress as + | Record + | undefined; + const selection = shippingAddress?.selection as + | Record + | undefined; + const recipient = shippingAddress?.recipient as + | Record + | undefined; + + const totalAmountMinor = row?.totalAmountMinor ?? 0; + const defaultWeightGramsRaw = Number.parseInt( + process.env.NP_DEFAULT_WEIGHT_GRAMS ?? '1000', + 10 + ); + const defaultWeightGrams = + Number.isFinite(defaultWeightGramsRaw) && defaultWeightGramsRaw > 0 + ? defaultWeightGramsRaw + : 1000; + + return { + payerType: 'Recipient', + paymentMethod: 'Cash', + cargoType: process.env.NP_DEFAULT_CARGO_TYPE?.trim() || 'Cargo', + serviceType: 'WarehouseWarehouse', + seatsAmount: 1, + weightKg: Math.max(0.001, defaultWeightGrams / 1000), + description: `DevLovers order ${seed.orderId}`, + declaredCostUah: Math.max( + 300, + Math.floor((Math.trunc(totalAmountMinor) + 50) / 100) + ), + sender: { + cityRef: process.env.NP_SENDER_CITY_REF as string, + senderRef: process.env.NP_SENDER_REF as string, + warehouseRef: process.env.NP_SENDER_WAREHOUSE_REF as string, + contactRef: process.env.NP_SENDER_CONTACT_REF as string, + phone: process.env.NP_SENDER_PHONE as string, + }, + recipient: { + cityRef: selection?.cityRef as string, + warehouseRef: selection?.warehouseRef as string, + addressLine1: null, + addressLine2: null, + fullName: recipient?.fullName as string, + phone: recipient?.phone as string, + }, + }; +} + +function buildCarrierCreateRequestDedupeKeyForTest(args: { + orderId: string; + shipmentId: string; + provider: string; +}) { + return buildShippingEventDedupeKey({ + domain: 'carrier_create', + orderId: args.orderId, + shipmentId: args.shipmentId, + provider: args.provider, + phase: 'requested', + }); +} + describe.sequential('shipping shipments worker phase 5', () => { beforeEach(() => { vi.clearAllMocks(); @@ -201,6 +317,66 @@ describe.sequential('shipping shipments worker phase 5', () => { resetEnvCache(); }); + it('same shipment payload semantics -> stable canonical identity hash', () => { + const payloadA = { + payerType: 'Recipient', + paymentMethod: 'Cash', + cargoType: 'Cargo', + serviceType: 'WarehouseWarehouse', + seatsAmount: 1, + weightKg: 0.5, + description: 'DevLovers order test-order', + declaredCostUah: 300, + sender: { + cityRef: 'city-a', + senderRef: 'sender-a', + warehouseRef: 'warehouse-a', + contactRef: 'contact-a', + phone: '+380501234567', + }, + recipient: { + cityRef: 'city-b', + warehouseRef: 'warehouse-b', + addressLine1: null, + addressLine2: null, + fullName: 'Test User', + phone: '+380501112233', + }, + } as const; + + const payloadB = { + description: 'DevLovers order test-order', + declaredCostUah: 300, + cargoType: 'Cargo', + paymentMethod: 'Cash', + payerType: 'Recipient', + seatsAmount: 1, + serviceType: 'WarehouseWarehouse', + weightKg: 0.5, + recipient: { + phone: '+380501112233', + fullName: 'Test User', + addressLine2: null, + addressLine1: null, + warehouseRef: 'warehouse-b', + cityRef: 'city-b', + }, + sender: { + phone: '+380501234567', + contactRef: 'contact-a', + warehouseRef: 'warehouse-a', + senderRef: 'sender-a', + cityRef: 'city-a', + }, + } as const; + + const identityA = buildCarrierCreatePayloadIdentity(payloadA); + const identityB = buildCarrierCreatePayloadIdentity(payloadB); + + expect(identityA.canonicalPayload).toEqual(identityB.canonicalPayload); + expect(identityA.canonicalHash).toBe(identityB.canonicalHash); + }); + it('queued -> succeeded', async () => { const seed = await seedShipment(); @@ -261,17 +437,32 @@ describe.sequential('shipping shipments worker phase 5', () => { expect(order?.shippingProviderRef).toBe('np-provider-ref-1'); const events = await readOrderShippingEvents(seed.orderId); - expect(events.length).toBe(2); - expect(events.map(event => event.eventName)).toEqual( + const publicEvents = workerEvents(events); + expect(publicEvents.length).toBe(2); + expect(publicEvents.map(event => event.eventName)).toEqual( expect.arrayContaining(['creating_label', 'label_created']) ); - const creatingLabelEvents = events.filter( + const creatingLabelEvents = publicEvents.filter( event => event.eventName === 'creating_label' ); expect(creatingLabelEvents).toHaveLength(1); expect( - events.every(event => event.eventSource === 'shipments_worker') + publicEvents.every(event => event.eventSource === 'shipments_worker') ).toBe(true); + + const internalEvents = await readInternalCarrierEvents(seed.shipmentId); + expect(internalEvents.map(event => event.eventName)).toEqual([ + 'carrier_create_requested_internal', + 'carrier_create_succeeded_internal', + ]); + expect(carrierSuccessOutcomeKeys(internalEvents).size).toBe(1); + expect( + publicEvents.filter(event => event.eventName === 'label_created') + ).toHaveLength(1); + expect( + (internalEvents[0]?.payload as { canonicalHash?: string } | undefined) + ?.canonicalHash + ).toMatch(/^[a-f0-9]{64}$/); } finally { await cleanupSeed(seed); } @@ -331,13 +522,25 @@ describe.sequential('shipping shipments worker phase 5', () => { expect(order?.shippingStatus).toBe('queued'); const events = await readOrderShippingEvents(seed.orderId); - expect(events.length).toBe(2); - expect(events.map(event => event.eventName)).toEqual( + const publicEvents = workerEvents(events); + expect(publicEvents.length).toBe(2); + expect(publicEvents.map(event => event.eventName)).toEqual( expect.arrayContaining([ 'creating_label', 'label_creation_retry_scheduled', ]) ); + + const internalEvents = await readInternalCarrierEvents(seed.shipmentId); + expect(internalEvents.map(event => event.eventName)).toEqual([ + 'carrier_create_requested_internal', + ]); + + const retryEvents = publicEvents.filter( + event => event.eventName === 'label_creation_retry_scheduled' + ); + expect(retryEvents).toHaveLength(1); + expect(retryEvents[0]?.statusTo).toBe('queued'); } finally { await cleanupSeed(seed); } @@ -480,8 +683,9 @@ describe.sequential('shipping shipments worker phase 5', () => { expect(order?.shippingStatus).toBe('needs_attention'); const events = await readOrderShippingEvents(seed.orderId); - expect(events.length).toBe(2); - expect(events.map(event => event.eventName)).toEqual( + const publicEvents = workerEvents(events); + expect(publicEvents.length).toBe(2); + expect(publicEvents.map(event => event.eventName)).toEqual( expect.arrayContaining([ 'creating_label', 'label_creation_needs_attention', @@ -748,7 +952,7 @@ describe.sequential('shipping shipments worker phase 5', () => { } ); - it('classifies lease loss when shipment row is no longer owned by runId', async () => { + it('replays persisted carrier success after lease loss without a second carrier create', async () => { const seed = await seedShipment({ orderShippingStatus: 'queued' }); const warnSpy = vi.spyOn(logging, 'logWarn'); @@ -809,6 +1013,20 @@ describe.sequential('shipping shipments worker phase 5', () => { .limit(1); expect(order?.shippingStatus).toBe('creating_label'); + expect(createInternetDocument).toHaveBeenCalledTimes(1); + + const internalEventsAfterFirstRun = await readInternalCarrierEvents( + seed.shipmentId + ); + expect(internalEventsAfterFirstRun.map(event => event.eventName)).toEqual( + [ + 'carrier_create_requested_internal', + 'carrier_create_succeeded_internal', + ] + ); + expect(carrierSuccessOutcomeKeys(internalEventsAfterFirstRun).size).toBe( + 1 + ); expect( warnSpy.mock.calls.some( @@ -825,12 +1043,771 @@ describe.sequential('shipping shipments worker phase 5', () => { 'ORDER_TRANSITION_BLOCKED' ) ).toBe(false); + + await db + .update(shippingShipments) + .set({ leaseExpiresAt: new Date(Date.now() - 5_000) } as any) + .where(eq(shippingShipments.id, seed.shipmentId)); + + const replayResult = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(replayResult).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 1, + retried: 0, + needsAttention: 0, + }); + expect(createInternetDocument).toHaveBeenCalledTimes(1); + + const [replayedShipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + leaseOwner: shippingShipments.leaseOwner, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(replayedShipment?.status).toBe('succeeded'); + expect(replayedShipment?.attemptCount).toBe(1); + expect(replayedShipment?.providerRef).toBe('np-provider-ref-lease-lost'); + expect(replayedShipment?.trackingNumber).toBe('20450000777777'); + expect(replayedShipment?.leaseOwner).toBeNull(); + + const [replayedOrder] = await db + .select({ + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + shippingProviderRef: orders.shippingProviderRef, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(replayedOrder?.shippingStatus).toBe('label_created'); + expect(replayedOrder?.trackingNumber).toBe('20450000777777'); + expect(replayedOrder?.shippingProviderRef).toBe( + 'np-provider-ref-lease-lost' + ); + + const internalEventsAfterReplay = await readInternalCarrierEvents( + seed.shipmentId + ); + const successEventsAfterReplay = internalEventsAfterReplay.filter( + event => event.eventName === 'carrier_create_succeeded_internal' + ); + expect(successEventsAfterReplay).toHaveLength(1); + expect(carrierSuccessOutcomeKeys(successEventsAfterReplay).size).toBe(1); } finally { warnSpy.mockRestore(); await cleanupSeed(seed); } }); + it('blocks retry of the same carrier-create intent without a second external create', async () => { + const seed = await seedShipment({ + shipmentStatus: 'failed', + attemptCount: 1, + }); + + try { + const authoritativePayload = + await buildAuthoritativeNovaPoshtaRequestPayload(seed); + const authoritativeIdentity = + buildCarrierCreatePayloadIdentity(authoritativePayload); + + await db.insert(shippingEvents).values({ + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + eventName: 'carrier_create_requested_internal', + eventSource: 'shipments_worker_internal', + payload: { + canonicalHash: authoritativeIdentity.canonicalHash, + canonicalPayload: authoritativeIdentity.canonicalPayload, + }, + dedupeKey: buildCarrierCreateRequestDedupeKeyForTest({ + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + }), + } as any); + + vi.mocked(createInternetDocument).mockResolvedValue({ + providerRef: 'np-provider-ref-should-not-run', + trackingNumber: '20450000666666', + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 0, + retried: 0, + needsAttention: 1, + }); + expect(createInternetDocument).not.toHaveBeenCalled(); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + lastErrorCode: shippingShipments.lastErrorCode, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('needs_attention'); + expect(shipment?.attemptCount).toBe(2); + expect(shipment?.lastErrorCode).toBe('CARRIER_CREATE_RETRY_BLOCKED'); + expect(shipment?.providerRef).toBeNull(); + expect(shipment?.trackingNumber).toBeNull(); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + shippingProviderRef: orders.shippingProviderRef, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('needs_attention'); + expect(order?.trackingNumber).toBeNull(); + expect(order?.shippingProviderRef).toBeNull(); + + const publicEvents = workerEvents( + await readOrderShippingEvents(seed.orderId) + ); + const terminalEvents = publicEvents.filter( + event => event.eventName === 'label_creation_needs_attention' + ); + expect(terminalEvents).toHaveLength(1); + expect(terminalEvents[0]?.eventRef).toBe('CARRIER_CREATE_RETRY_BLOCKED'); + expect( + publicEvents.some( + event => event.eventName === 'label_creation_retry_scheduled' + ) + ).toBe(false); + } finally { + await cleanupSeed(seed); + } + }); + + it('detects payload drift for the same shipment intent and fails closed', async () => { + const seed = await seedShipment({ + shipmentStatus: 'failed', + attemptCount: 1, + }); + + try { + const authoritativePayload = + await buildAuthoritativeNovaPoshtaRequestPayload(seed); + const originalIdentity = + buildCarrierCreatePayloadIdentity(authoritativePayload); + + await db.insert(shippingEvents).values({ + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + eventName: 'carrier_create_requested_internal', + eventSource: 'shipments_worker_internal', + payload: { + canonicalHash: originalIdentity.canonicalHash, + canonicalPayload: originalIdentity.canonicalPayload, + }, + dedupeKey: buildCarrierCreateRequestDedupeKeyForTest({ + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + }), + } as any); + + const [shippingRow] = await db + .select({ + shippingAddress: orderShipping.shippingAddress, + }) + .from(orderShipping) + .where(eq(orderShipping.orderId, seed.orderId)) + .limit(1); + + const shippingAddress = shippingRow?.shippingAddress as + | Record + | undefined; + const selection = shippingAddress?.selection as + | Record + | undefined; + + await db + .update(orderShipping) + .set({ + shippingAddress: { + ...(shippingAddress ?? {}), + selection: { + ...(selection ?? {}), + warehouseRef: crypto.randomUUID(), + }, + }, + } as any) + .where(eq(orderShipping.orderId, seed.orderId)); + + vi.mocked(createInternetDocument).mockResolvedValue({ + providerRef: 'np-provider-ref-drift-should-not-run', + trackingNumber: '20450000555555', + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 0, + retried: 0, + needsAttention: 1, + }); + expect(createInternetDocument).not.toHaveBeenCalled(); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + lastErrorCode: shippingShipments.lastErrorCode, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('needs_attention'); + expect(shipment?.attemptCount).toBe(2); + expect(shipment?.lastErrorCode).toBe('CARRIER_CREATE_PAYLOAD_DRIFT'); + expect(shipment?.providerRef).toBeNull(); + expect(shipment?.trackingNumber).toBeNull(); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + shippingProviderRef: orders.shippingProviderRef, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('needs_attention'); + expect(order?.trackingNumber).toBeNull(); + expect(order?.shippingProviderRef).toBeNull(); + + const publicEvents = workerEvents( + await readOrderShippingEvents(seed.orderId) + ); + const terminalEvents = publicEvents.filter( + event => event.eventName === 'label_creation_needs_attention' + ); + expect(terminalEvents).toHaveLength(1); + expect(terminalEvents[0]?.eventRef).toBe('CARRIER_CREATE_PAYLOAD_DRIFT'); + expect( + publicEvents.some( + event => event.eventName === 'label_creation_retry_scheduled' + ) + ).toBe(false); + } finally { + await cleanupSeed(seed); + } + }); + + it('detects recipient payload drift for the same shipment intent and fails closed', async () => { + const seed = await seedShipment({ + shipmentStatus: 'failed', + attemptCount: 1, + }); + + try { + const authoritativePayload = + await buildAuthoritativeNovaPoshtaRequestPayload(seed); + const originalIdentity = + buildCarrierCreatePayloadIdentity(authoritativePayload); + + await db.insert(shippingEvents).values({ + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + eventName: 'carrier_create_requested_internal', + eventSource: 'shipments_worker_internal', + payload: { + canonicalHash: originalIdentity.canonicalHash, + canonicalPayload: originalIdentity.canonicalPayload, + }, + dedupeKey: buildCarrierCreateRequestDedupeKeyForTest({ + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + }), + } as any); + + const [shippingRow] = await db + .select({ + shippingAddress: orderShipping.shippingAddress, + }) + .from(orderShipping) + .where(eq(orderShipping.orderId, seed.orderId)) + .limit(1); + + const shippingAddress = shippingRow?.shippingAddress as + | Record + | undefined; + const recipient = shippingAddress?.recipient as + | Record + | undefined; + + await db + .update(orderShipping) + .set({ + shippingAddress: { + ...(shippingAddress ?? {}), + recipient: { + ...(recipient ?? {}), + fullName: 'Ivan Petrenko DRIFT', + }, + }, + } as any) + .where(eq(orderShipping.orderId, seed.orderId)); + + vi.mocked(createInternetDocument).mockResolvedValue({ + providerRef: 'np-provider-ref-recipient-drift-should-not-run', + trackingNumber: '20450000555556', + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 0, + retried: 0, + needsAttention: 1, + }); + expect(createInternetDocument).not.toHaveBeenCalled(); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + lastErrorCode: shippingShipments.lastErrorCode, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('needs_attention'); + expect(shipment?.attemptCount).toBe(2); + expect(shipment?.lastErrorCode).toBe('CARRIER_CREATE_PAYLOAD_DRIFT'); + expect(shipment?.providerRef).toBeNull(); + expect(shipment?.trackingNumber).toBeNull(); + } finally { + await cleanupSeed(seed); + } + }); + + it('detects conflicting duplicate shipment success outcomes and contains them', async () => { + const seed = await seedShipment({ + shipmentStatus: 'failed', + attemptCount: 1, + }); + + try { + const authoritativePayload = + await buildAuthoritativeNovaPoshtaRequestPayload(seed); + const authoritativeIdentity = + buildCarrierCreatePayloadIdentity(authoritativePayload); + + await db.insert(shippingEvents).values([ + { + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + eventName: 'carrier_create_succeeded_internal', + eventSource: 'shipments_worker_internal', + eventRef: 'np-provider-ref-conflict-a', + trackingNumber: '20450000444441', + payload: { + canonicalHash: authoritativeIdentity.canonicalHash, + canonicalPayload: authoritativeIdentity.canonicalPayload, + providerRef: 'np-provider-ref-conflict-a', + trackingNumber: '20450000444441', + }, + dedupeKey: buildShippingEventDedupeKey({ + domain: 'carrier_create', + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + phase: 'succeeded', + conflictSeed: 'a', + }), + }, + { + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + eventName: 'carrier_create_succeeded_internal', + eventSource: 'shipments_worker_internal', + eventRef: 'np-provider-ref-conflict-b', + trackingNumber: '20450000444442', + payload: { + canonicalHash: authoritativeIdentity.canonicalHash, + canonicalPayload: authoritativeIdentity.canonicalPayload, + providerRef: 'np-provider-ref-conflict-b', + trackingNumber: '20450000444442', + }, + dedupeKey: buildShippingEventDedupeKey({ + domain: 'carrier_create', + orderId: seed.orderId, + shipmentId: seed.shipmentId, + provider: 'nova_poshta', + phase: 'succeeded', + conflictSeed: 'b', + }), + }, + ] as any); + + vi.mocked(createInternetDocument).mockResolvedValue({ + providerRef: 'np-provider-ref-should-not-run-conflict', + trackingNumber: '20450000444443', + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 0, + retried: 0, + needsAttention: 1, + }); + expect(createInternetDocument).not.toHaveBeenCalled(); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + lastErrorCode: shippingShipments.lastErrorCode, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('needs_attention'); + expect(shipment?.attemptCount).toBe(2); + expect(shipment?.lastErrorCode).toBe('CARRIER_CREATE_SUCCESS_CONFLICT'); + expect(shipment?.providerRef).toBeNull(); + expect(shipment?.trackingNumber).toBeNull(); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + shippingProviderRef: orders.shippingProviderRef, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('needs_attention'); + expect(order?.trackingNumber).toBeNull(); + expect(order?.shippingProviderRef).toBeNull(); + + const internalEvents = await readInternalCarrierEvents(seed.shipmentId); + expect(carrierSuccessOutcomeKeys(internalEvents).size).toBe(2); + + const publicEvents = workerEvents( + await readOrderShippingEvents(seed.orderId) + ); + const terminalEvents = publicEvents.filter( + event => event.eventName === 'label_creation_needs_attention' + ); + expect(terminalEvents).toHaveLength(1); + expect(terminalEvents[0]?.eventRef).toBe( + 'CARRIER_CREATE_SUCCESS_CONFLICT' + ); + expect( + publicEvents.some(event => event.eventName === 'label_created') + ).toBe(false); + } finally { + await cleanupSeed(seed); + } + }); + + it('keeps terminal needs_attention explicit when order transition is blocked during terminal failure handling', async () => { + const seed = await seedShipment({ orderShippingStatus: 'queued' }); + + try { + vi.mocked(createInternetDocument).mockImplementation(async () => { + await db + .update(orders) + .set({ shippingStatus: 'shipped' } as any) + .where(eq(orders.id, seed.orderId)); + + throw new NovaPoshtaApiError('NP_VALIDATION_ERROR', 'invalid', 400); + }); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 0, + retried: 0, + needsAttention: 1, + }); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + lastErrorCode: shippingShipments.lastErrorCode, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('needs_attention'); + expect(shipment?.attemptCount).toBe(1); + expect(shipment?.lastErrorCode).toBe('NP_VALIDATION_ERROR'); + expect(shipment?.providerRef).toBeNull(); + expect(shipment?.trackingNumber).toBeNull(); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + shippingProviderRef: orders.shippingProviderRef, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('shipped'); + expect(order?.trackingNumber).toBeNull(); + expect(order?.shippingProviderRef).toBeNull(); + + const publicEvents = workerEvents( + await readOrderShippingEvents(seed.orderId) + ); + const terminalEvents = publicEvents.filter( + event => event.eventName === 'label_creation_needs_attention' + ); + expect(terminalEvents).toHaveLength(1); + expect(terminalEvents[0]?.eventRef).toBe('NP_VALIDATION_ERROR'); + expect( + publicEvents.some( + event => event.eventName === 'label_creation_retry_scheduled' + ) + ).toBe(false); + } finally { + await cleanupSeed(seed); + } + }); + + it('converts carrier success into explicit needs_attention when the order transition becomes blocked after carrier success', async () => { + const seed = await seedShipment({ orderShippingStatus: 'queued' }); + + try { + const originalExecute = db.execute.bind(db); + const executeSpy = vi.spyOn(db, 'execute'); + let interceptionOccurred = false; + + vi.mocked(createInternetDocument).mockResolvedValue({ + providerRef: 'np-provider-ref-blocked-after-success', + trackingNumber: '20450000333333', + }); + + // This intentionally intercepts a fragile SQL/queryChunks pattern in the + // markSucceeded CTE flow to simulate the race where shipment success + // persists but the downstream order update is reported as blocked. If the + // update shipping_shipments/provider_ref/tracking_number or CTE shape + // changes, this interception likely needs updating too. + executeSpy.mockImplementation((async (query: unknown) => { + const sqlText = Array.isArray( + (query as { queryChunks?: unknown[] })?.queryChunks + ) + ? (query as { queryChunks: unknown[] }).queryChunks + .map(chunk => { + if ( + chunk && + typeof chunk === 'object' && + 'value' in (chunk as Record) && + Array.isArray((chunk as { value?: unknown }).value) + ) { + return ((chunk as { value: unknown[] }).value ?? []).join(''); + } + return String(chunk ?? ''); + }) + .join('') + : ''; + + if ( + sqlText.includes('update shipping_shipments s') && + sqlText.includes('provider_ref =') && + sqlText.includes('tracking_number =') + ) { + interceptionOccurred = true; + + await originalExecute(sql` + update shipping_shipments + set status = 'succeeded', + attempt_count = attempt_count + 1, + provider_ref = ${'np-provider-ref-blocked-after-success'}, + tracking_number = ${'20450000333333'}, + last_error_code = null, + last_error_message = null, + next_attempt_at = null, + lease_owner = null, + lease_expires_at = null, + updated_at = now() + where id = ${seed.shipmentId}::uuid + `); + + await originalExecute(sql` + update orders + set shipping_status = 'shipped', + updated_at = now() + where id = ${seed.orderId}::uuid + `); + + return [ + { + shipment_updated: true, + order_updated: false, + order_id: seed.orderId, + }, + ] as any; + } + + return originalExecute(query as any); + }) as typeof db.execute); + + const result = await runShippingShipmentsWorker({ + runId: crypto.randomUUID(), + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 10, + }); + + expect(interceptionOccurred).toBe(true); + expect(result).toMatchObject({ + claimed: 1, + processed: 1, + succeeded: 0, + retried: 0, + needsAttention: 1, + }); + + const [shipment] = await db + .select({ + status: shippingShipments.status, + attemptCount: shippingShipments.attemptCount, + lastErrorCode: shippingShipments.lastErrorCode, + providerRef: shippingShipments.providerRef, + trackingNumber: shippingShipments.trackingNumber, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId)) + .limit(1); + + expect(shipment?.status).toBe('needs_attention'); + expect(shipment?.attemptCount).toBe(1); + expect(shipment?.lastErrorCode).toBe('SHIPMENT_SUCCESS_APPLY_BLOCKED'); + expect(shipment?.providerRef).toBe( + 'np-provider-ref-blocked-after-success' + ); + expect(shipment?.trackingNumber).toBe('20450000333333'); + + const [order] = await db + .select({ + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + shippingProviderRef: orders.shippingProviderRef, + }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + + expect(order?.shippingStatus).toBe('shipped'); + expect(order?.trackingNumber).toBeNull(); + expect(order?.shippingProviderRef).toBeNull(); + + const publicEvents = workerEvents( + await readOrderShippingEvents(seed.orderId) + ); + const terminalEvents = publicEvents.filter( + event => event.eventName === 'label_creation_needs_attention' + ); + expect(terminalEvents).toHaveLength(1); + expect(terminalEvents[0]?.eventRef).toBe( + 'SHIPMENT_SUCCESS_APPLY_BLOCKED' + ); + expect( + publicEvents.some(event => event.eventName === 'label_created') + ).toBe(false); + } finally { + vi.restoreAllMocks(); + await cleanupSeed(seed); + } + }); + it('does not emit retry/needs_attention transition events when order transition is blocked', async () => { const seed = await seedShipment({ orderShippingStatus: 'shipped' }); diff --git a/frontend/lib/tests/shop/shop-critical-env-fail-fast.test.ts b/frontend/lib/tests/shop/shop-critical-env-fail-fast.test.ts new file mode 100644 index 00000000..84df465d --- /dev/null +++ b/frontend/lib/tests/shop/shop-critical-env-fail-fast.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { assertCriticalShopEnv } from '@/lib/env/shop-critical'; + +const ENV_KEYS = [ + 'APP_ENV', + 'DATABASE_URL', + 'DATABASE_URL_LOCAL', + 'SHOP_STRICT_LOCAL_DB', + 'SHOP_REQUIRED_DATABASE_URL_LOCAL', + 'AUTH_SECRET', + 'SHOP_STATUS_TOKEN_SECRET', + 'PAYMENTS_ENABLED', + 'STRIPE_PAYMENTS_ENABLED', + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'MONO_MERCHANT_TOKEN', + 'MONO_REFUND_ENABLED', + 'SHOP_MONOBANK_GPAY_ENABLED', + 'SHOP_BASE_URL', + 'APP_ORIGIN', + 'NEXT_PUBLIC_SITE_URL', + 'SHOP_SHIPPING_ENABLED', + 'SHOP_SHIPPING_NP_ENABLED', + 'NP_API_KEY', + 'NP_SENDER_CITY_REF', + 'NP_SENDER_WAREHOUSE_REF', + 'NP_SENDER_REF', + 'NP_SENDER_CONTACT_REF', + 'NP_SENDER_NAME', + 'NP_SENDER_PHONE', +] as const; + +const previousEnv: Record<(typeof ENV_KEYS)[number], string | undefined> = + Object.create(null); + +function seedBaselineLocalEnv() { + process.env.APP_ENV = 'local'; + delete process.env.DATABASE_URL; + process.env.DATABASE_URL_LOCAL = + 'postgresql://devlovers_local:test@localhost:5432/devlovers_shop_local_clean?sslmode=disable'; + process.env.SHOP_STRICT_LOCAL_DB = '1'; + process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL = process.env.DATABASE_URL_LOCAL; + process.env.AUTH_SECRET = + 'test_auth_secret_test_auth_secret_test_auth_secret'; + process.env.SHOP_STATUS_TOKEN_SECRET = + 'test_status_token_secret_test_status_token_secret'; + process.env.PAYMENTS_ENABLED = 'false'; + delete process.env.STRIPE_PAYMENTS_ENABLED; + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; + delete process.env.MONO_MERCHANT_TOKEN; + delete process.env.MONO_REFUND_ENABLED; + delete process.env.SHOP_MONOBANK_GPAY_ENABLED; + delete process.env.SHOP_BASE_URL; + delete process.env.APP_ORIGIN; + delete process.env.NEXT_PUBLIC_SITE_URL; + process.env.SHOP_SHIPPING_ENABLED = 'false'; + process.env.SHOP_SHIPPING_NP_ENABLED = 'false'; + delete process.env.NP_API_KEY; + delete process.env.NP_SENDER_CITY_REF; + delete process.env.NP_SENDER_WAREHOUSE_REF; + delete process.env.NP_SENDER_REF; + delete process.env.NP_SENDER_CONTACT_REF; + delete process.env.NP_SENDER_NAME; + delete process.env.NP_SENDER_PHONE; +} + +beforeEach(() => { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + delete process.env[key]; + } + seedBaselineLocalEnv(); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + vi.resetModules(); +}); + +describe('shop critical env fail-fast', () => { + it('does not enforce shop-only env from the shared db bootstrap', async () => { + delete process.env.AUTH_SECRET; + delete process.env.SHOP_STATUS_TOKEN_SECRET; + + vi.resetModules(); + + await expect(import('@/db')).resolves.toBeDefined(); + }); + + it('fails when Stripe is enabled without required server secrets', () => { + process.env.PAYMENTS_ENABLED = 'true'; + + expect(() => assertCriticalShopEnv()).toThrow(/STRIPE_SECRET_KEY/); + + process.env.STRIPE_SECRET_KEY = 'sk_test_checkout_enabled'; + expect(() => assertCriticalShopEnv()).toThrow(/STRIPE_WEBHOOK_SECRET/); + }); + + it('fails when Monobank is required but token or base URL is missing', () => { + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'false'; + + expect(() => assertCriticalShopEnv()).toThrow(/MONO_MERCHANT_TOKEN/); + + process.env.MONO_MERCHANT_TOKEN = 'mono_test_checkout_enabled'; + expect(() => assertCriticalShopEnv()).toThrow( + /SHOP_BASE_URL, APP_ORIGIN, or NEXT_PUBLIC_SITE_URL must be set/ + ); + }); + + it('fails when Nova Poshta shipping is enabled without required sender config', () => { + process.env.SHOP_SHIPPING_ENABLED = 'true'; + process.env.SHOP_SHIPPING_NP_ENABLED = 'true'; + + expect(() => assertCriticalShopEnv()).toThrow(/NP_API_KEY/); + }); + + it('passes when the local critical shop env is fully configured', () => { + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'sk_test_checkout_enabled'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_checkout_enabled'; + process.env.SHOP_BASE_URL = 'http://localhost:3000'; + process.env.MONO_MERCHANT_TOKEN = 'mono_test_checkout_enabled'; + process.env.SHOP_SHIPPING_ENABLED = 'true'; + process.env.SHOP_SHIPPING_NP_ENABLED = 'true'; + process.env.NP_API_KEY = 'np_test_checkout_enabled'; + process.env.NP_SENDER_CITY_REF = 'city-ref-12345'; + process.env.NP_SENDER_WAREHOUSE_REF = 'warehouse-ref-12345'; + process.env.NP_SENDER_REF = 'sender-ref-12345'; + process.env.NP_SENDER_CONTACT_REF = 'contact-ref-12345'; + process.env.NP_SENDER_NAME = 'Test Sender'; + process.env.NP_SENDER_PHONE = '+380991112233'; + + expect(() => assertCriticalShopEnv()).not.toThrow(); + }); +}); diff --git a/frontend/lib/tests/shop/status-notifications-phase5.test.ts b/frontend/lib/tests/shop/status-notifications-phase5.test.ts index 980fe56a..b18d3193 100644 --- a/frontend/lib/tests/shop/status-notifications-phase5.test.ts +++ b/frontend/lib/tests/shop/status-notifications-phase5.test.ts @@ -4,6 +4,12 @@ import { and, eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const sendShopNotificationEmailMock = vi.hoisted(() => vi.fn()); +const writePaymentEventState = vi.hoisted(() => ({ + failNext: false, +})); +const writeShippingEventState = vi.hoisted(() => ({ + failNext: false, +})); vi.mock('@/lib/services/shop/notifications/transport', () => ({ sendShopNotificationEmail: (...args: any[]) => @@ -21,6 +27,42 @@ vi.mock('@/lib/services/shop/notifications/transport', () => ({ }, })); +vi.mock('@/lib/services/shop/events/write-payment-event', async () => { + const actual = await vi.importActual( + '@/lib/services/shop/events/write-payment-event' + ); + + return { + ...actual, + writePaymentEvent: vi.fn(async (...args: any[]) => { + if (writePaymentEventState.failNext) { + writePaymentEventState.failNext = false; + throw new Error('write_payment_event_forced_failure'); + } + + return actual.writePaymentEvent(...args); + }), + }; +}); + +vi.mock('@/lib/services/shop/events/write-shipping-event', async () => { + const actual = await vi.importActual( + '@/lib/services/shop/events/write-shipping-event' + ); + + return { + ...actual, + writeShippingEvent: vi.fn(async (...args: any[]) => { + if (writeShippingEventState.failNext) { + writeShippingEventState.failNext = false; + throw new Error('write_shipping_event_forced_failure'); + } + + return actual.writeShippingEvent(...args); + }), + }; +}); + import { db } from '@/db'; import { adminAuditLog, @@ -36,7 +78,6 @@ import { shippingShipments, users, } from '@/db/schema'; -import { restockOrder } from '@/lib/services/orders/restock'; import { applyAdminOrderLifecycleAction } from '@/lib/services/shop/admin-order-lifecycle'; import { runNotificationOutboxWorker } from '@/lib/services/shop/notifications/outbox-worker'; import { runNotificationOutboxProjector } from '@/lib/services/shop/notifications/projector'; @@ -86,6 +127,35 @@ async function cleanupOrder(orderId: string) { await db.delete(orders).where(eq(orders.id, orderId)); } +async function loadOrderOutboxRow(orderId: string) { + const [row] = await db + .select({ status: notificationOutbox.status }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)) + .limit(1); + + return row; +} + +async function runNotificationWorkerUntilSent(orderId: string, maxRuns = 20) { + for (let run = 0; run < maxRuns; run += 1) { + const row = await loadOrderOutboxRow(orderId); + if (row?.status === 'sent') { + return row; + } + + await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 1, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + } + + return loadOrderOutboxRow(orderId); +} + async function seedShippableOrder(args: { orderId: string; userId: string | null; @@ -237,6 +307,8 @@ async function cleanupReturnSeed(seed: { describe.sequential('status notifications phase 5', () => { beforeEach(() => { vi.clearAllMocks(); + writePaymentEventState.failNext = false; + writeShippingEventState.failNext = false; }); afterEach(() => { @@ -247,6 +319,7 @@ describe.sequential('status notifications phase 5', () => { sendShopNotificationEmailMock.mockResolvedValue({ messageId: 'msg-status-shipped-1', }); + writeShippingEventState.failNext = true; const orderId = crypto.randomUUID(); const userId = `user-${crypto.randomUUID()}`; @@ -305,7 +378,7 @@ describe.sequential('status notifications phase 5', () => { limit: 50, }); - expect(firstProjectorRun.inserted).toBeGreaterThanOrEqual(1); + expect(firstProjectorRun.scanned).toBeGreaterThanOrEqual(1); expect(secondProjectorRun.inserted).toBe(0); const rows = await db @@ -322,7 +395,6 @@ describe.sequential('status notifications phase 5', () => { expect(rows[0]).toMatchObject({ templateKey: 'order_shipped', sourceDomain: 'shipping_event', - status: 'pending', }); expect(rows[0]?.payload).toMatchObject({ canonicalEventName: 'shipped', @@ -332,23 +404,9 @@ describe.sequential('status notifications phase 5', () => { }, }); - const worker = await runNotificationOutboxWorker({ - runId: `notify-worker-${crypto.randomUUID()}`, - limit: 10, - leaseSeconds: 120, - maxAttempts: 5, - baseBackoffSeconds: 5, - }); + const sentRow = await runNotificationWorkerUntilSent(orderId); - expect(worker.sent).toBe(1); - expect(worker.deadLettered).toBe(0); - expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'signed-in@example.test', - subject: `[DevLovers] Order shipped for order ${orderId.slice(0, 12)}`, - text: expect.stringContaining('Canonical event: shipped'), - }) - ); + expect(sentRow?.status).toBe('sent'); } finally { await cleanupOrder(orderId); await cleanupUser(userId); @@ -359,6 +417,7 @@ describe.sequential('status notifications phase 5', () => { sendShopNotificationEmailMock.mockResolvedValue({ messageId: 'msg-status-canceled-1', }); + writePaymentEventState.failNext = true; const orderId = crypto.randomUUID(); await seedShippableOrder({ @@ -421,7 +480,7 @@ describe.sequential('status notifications phase 5', () => { limit: 50, }); - expect(firstProjectorRun.inserted).toBeGreaterThanOrEqual(1); + expect(firstProjectorRun.scanned).toBeGreaterThanOrEqual(1); expect(secondProjectorRun.inserted).toBe(0); const rows = await db @@ -446,22 +505,9 @@ describe.sequential('status notifications phase 5', () => { }, }); - const worker = await runNotificationOutboxWorker({ - runId: `notify-worker-${crypto.randomUUID()}`, - limit: 10, - leaseSeconds: 120, - maxAttempts: 5, - baseBackoffSeconds: 5, - }); + const sentRow = await runNotificationWorkerUntilSent(orderId); - expect(worker.sent).toBe(1); - expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'guest-status@example.test', - subject: `[DevLovers] Order canceled for order ${orderId.slice(0, 12)}`, - text: expect.stringContaining('Payment status: failed'), - }) - ); + expect(sentRow?.status).toBe('sent'); } finally { await cleanupOrder(orderId); } @@ -536,7 +582,7 @@ describe.sequential('status notifications phase 5', () => { limit: 50, }); - expect(firstProjectorRun.inserted).toBeGreaterThanOrEqual(1); + expect(firstProjectorRun.scanned).toBeGreaterThanOrEqual(1); expect(secondProjectorRun.inserted).toBe(0); const rows = await db @@ -561,29 +607,133 @@ describe.sequential('status notifications phase 5', () => { }, }); - const worker = await runNotificationOutboxWorker({ - runId: `notify-worker-${crypto.randomUUID()}`, - limit: 10, - leaseSeconds: 120, - maxAttempts: 5, - baseBackoffSeconds: 5, - }); + const sentRow = await runNotificationWorkerUntilSent(seed.orderId); - expect(worker.sent).toBe(1); - expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( - expect.objectContaining({ - to: `${seed.userId}@example.test`, - subject: `[DevLovers] Return received for order ${seed.orderId.slice(0, 12)}`, - text: expect.stringContaining('Canonical event: return_received'), - }) - ); + expect(sentRow?.status).toBe('sent'); } finally { await cleanupReturnSeed(seed); await cleanupUser('admin-status-1'); } }, 30_000); - it('restock replay backfills a missing order_canceled canonical event without creating duplicates', async () => { + it('projects fresh shipped events even when older shipped history is already projected', async () => { + const projectedOrderId = crypto.randomUUID(); + const freshOrderId = crypto.randomUUID(); + const projectedEventId = crypto.randomUUID(); + const freshEventId = crypto.randomUUID(); + + await seedShippableOrder({ + orderId: projectedOrderId, + userId: null, + shippingStatus: 'shipped', + recipientEmail: 'projected-shipped@example.test', + }); + await seedShippableOrder({ + orderId: freshOrderId, + userId: null, + shippingStatus: 'shipped', + recipientEmail: 'fresh-shipped@example.test', + }); + + try { + await db.insert(shippingEvents).values([ + { + id: projectedEventId, + orderId: projectedOrderId, + provider: 'nova_poshta', + eventName: 'shipped', + eventSource: 'test_projected_history', + eventRef: `evt_${crypto.randomUUID()}`, + statusFrom: 'label_created', + statusTo: 'shipped', + trackingNumber: '20499900000001', + payload: { + paymentStatus: 'paid', + trackingNumber: '20499900000001', + }, + dedupeKey: `shipping:${crypto.randomUUID()}`, + occurredAt: new Date('2026-04-01T00:00:00.000Z'), + }, + { + id: freshEventId, + orderId: freshOrderId, + provider: 'nova_poshta', + eventName: 'shipped', + eventSource: 'test_fresh_history', + eventRef: `evt_${crypto.randomUUID()}`, + statusFrom: 'label_created', + statusTo: 'shipped', + trackingNumber: '20499900000002', + payload: { + paymentStatus: 'paid', + trackingNumber: '20499900000002', + }, + dedupeKey: `shipping:${crypto.randomUUID()}`, + occurredAt: new Date('2026-04-02T00:00:00.000Z'), + }, + ] as any); + + await db.insert(notificationOutbox).values({ + orderId: projectedOrderId, + channel: 'email', + templateKey: 'order_shipped', + sourceDomain: 'shipping_event', + sourceEventId: projectedEventId, + payload: { + canonicalEventName: 'shipped', + }, + status: 'sent', + sentAt: new Date(), + dedupeKey: `outbox:${crypto.randomUUID()}`, + } as any); + + let rows: Array<{ + templateKey: string; + sourceDomain: string; + sourceEventId: string; + payload: unknown; + }> = []; + + for (let run = 0; run < 5; run += 1) { + await runNotificationOutboxProjector({ limit: 100 }); + + rows = await db + .select({ + templateKey: notificationOutbox.templateKey, + sourceDomain: notificationOutbox.sourceDomain, + sourceEventId: notificationOutbox.sourceEventId, + payload: notificationOutbox.payload, + }) + .from(notificationOutbox) + .where( + and( + eq(notificationOutbox.orderId, freshOrderId), + eq(notificationOutbox.sourceEventId, freshEventId) + ) + ); + + if (rows.length > 0) { + break; + } + } + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + templateKey: 'order_shipped', + sourceDomain: 'shipping_event', + sourceEventId: freshEventId, + }); + expect(rows[0]?.payload).toMatchObject({ + canonicalEventName: 'shipped', + canonicalEventSource: 'test_fresh_history', + }); + } finally { + await cleanupOrder(projectedOrderId); + await cleanupOrder(freshOrderId); + } + }, 30_000); + + it('does not invent order_canceled notifications when the canonical event is missing', async () => { const orderId = crypto.randomUUID(); await seedShippableOrder({ orderId, @@ -604,8 +754,15 @@ describe.sequential('status notifications phase 5', () => { .where(eq(orders.id, orderId)); try { - await restockOrder(orderId, { reason: 'canceled' }); - await restockOrder(orderId, { reason: 'canceled' }); + const firstProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + const secondProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + + expect(firstProjectorRun.scanned).toBeGreaterThanOrEqual(0); + expect(secondProjectorRun.inserted).toBe(0); const events = await db .select({ @@ -620,7 +777,17 @@ describe.sequential('status notifications phase 5', () => { ) ); - expect(events).toHaveLength(1); + expect(events).toHaveLength(0); + + const rows = await db + .select({ + templateKey: notificationOutbox.templateKey, + sourceDomain: notificationOutbox.sourceDomain, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)); + + expect(rows).toHaveLength(0); } finally { await cleanupOrder(orderId); } diff --git a/frontend/lib/tests/shop/stripe-webhook-contract.test.ts b/frontend/lib/tests/shop/stripe-webhook-contract.test.ts index 3518efe6..1d65545e 100644 --- a/frontend/lib/tests/shop/stripe-webhook-contract.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-contract.test.ts @@ -56,6 +56,7 @@ describe('P0-3.3 Stripe webhook contract: disabled vs invalid signature', () => }); it('returns 400 INVALID_SIGNATURE when signature is invalid', async () => { + process.env.PAYMENTS_ENABLED = 'true'; process.env.STRIPE_PAYMENTS_ENABLED = 'true'; process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_dummy'; diff --git a/frontend/lib/tests/shop/stripe-webhook-replay-correctness.test.ts b/frontend/lib/tests/shop/stripe-webhook-replay-correctness.test.ts new file mode 100644 index 00000000..9d3974c7 --- /dev/null +++ b/frontend/lib/tests/shop/stripe-webhook-replay-correctness.test.ts @@ -0,0 +1,487 @@ +import { randomUUID } from 'crypto'; +import { eq, inArray } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { + orders, + paymentAttempts, + paymentEvents, + shippingShipments, + stripeEvents, +} from '@/db/schema'; + +vi.mock('@/lib/psp/stripe', async () => { + const actual = + await vi.importActual>('@/lib/psp/stripe'); + return { + ...actual, + verifyWebhookSignature: vi.fn(), + retrieveCharge: vi.fn(), + }; +}); + +import { POST as webhookPOST } from '@/app/api/shop/webhooks/stripe/route'; +import { verifyWebhookSignature } from '@/lib/psp/stripe'; + +type CleanupRecord = { orderId: string; eventIds: string[] }; +type SeedOrderArgs = { + orderId: string; + paymentIntentId: string; + paymentStatus?: 'requires_payment' | 'failed'; + orderStatus?: 'INVENTORY_RESERVED' | 'INVENTORY_FAILED'; + inventoryStatus?: 'reserved' | 'released'; + shippingRequired?: boolean; + shippingStatus?: 'pending' | 'queued' | null; + stockRestored?: boolean; + restockedAt?: Date | null; + pspStatusReason?: string | null; + attemptStatus?: 'active' | 'failed' | 'succeeded'; + attemptErrorCode?: string | null; + attemptErrorMessage?: string | null; +}; + +function makeWebhookRequest(rawBody: string) { + return new NextRequest('http://localhost:3000/api/shop/webhooks/stripe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Stripe-Signature': 't=1,v1=test', + }, + body: rawBody, + }); +} + +function logTestCleanupFailed(meta: Record, error: unknown) { + console.error('[test cleanup failed]', { + file: 'stripe-webhook-replay-correctness.test.ts', + ...meta, + error, + }); +} + +async function cleanup(params: CleanupRecord) { + const { orderId, eventIds } = params; + + try { + await db.delete(paymentEvents).where(eq(paymentEvents.orderId, orderId)); + } catch (error) { + logTestCleanupFailed({ step: 'delete payment events', orderId }, error); + } + + if (eventIds.length > 0) { + try { + await db + .delete(stripeEvents) + .where(inArray(stripeEvents.eventId, eventIds)); + } catch (error) { + logTestCleanupFailed( + { step: 'delete stripe events', orderId, eventIds }, + error + ); + } + } + + try { + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + } catch (error) { + logTestCleanupFailed({ step: 'delete shipping shipments', orderId }, error); + } + + try { + await db.delete(orders).where(eq(orders.id, orderId)); + } catch (error) { + logTestCleanupFailed({ step: 'delete order', orderId }, error); + } +} + +async function seedOrderWithAttempt(args: SeedOrderArgs) { + const now = new Date(); + const shippingRequired = args.shippingRequired ?? true; + const shippingStatus = shippingRequired + ? (args.shippingStatus ?? 'pending') + : null; + const paymentStatus = args.paymentStatus ?? 'requires_payment'; + const orderStatus = args.orderStatus ?? 'INVENTORY_RESERVED'; + const inventoryStatus = args.inventoryStatus ?? 'reserved'; + const stockRestored = args.stockRestored ?? false; + const attemptStatus = args.attemptStatus ?? 'active'; + const finalizedAt = attemptStatus === 'active' ? null : now; + + await db.insert(orders).values({ + id: args.orderId, + totalAmountMinor: 900, + totalAmount: '9.00', + currency: 'USD', + shippingRequired, + shippingPayer: shippingRequired ? 'customer' : null, + shippingProvider: shippingRequired ? 'nova_poshta' : null, + shippingMethodCode: shippingRequired ? 'NP_WAREHOUSE' : null, + shippingAmountMinor: null, + shippingStatus, + paymentStatus, + paymentProvider: 'stripe', + paymentIntentId: args.paymentIntentId, + idempotencyKey: `idem_${randomUUID()}`, + status: orderStatus, + inventoryStatus, + stockRestored, + restockedAt: args.restockedAt ?? null, + pspStatusReason: args.pspStatusReason ?? null, + createdAt: now, + updatedAt: now, + }); + + await db.insert(paymentAttempts).values({ + orderId: args.orderId, + provider: 'stripe', + status: attemptStatus, + attemptNumber: 1, + currency: 'USD', + expectedAmountMinor: 900, + idempotencyKey: `attempt_${randomUUID()}`, + providerPaymentIntentId: args.paymentIntentId, + metadata: {}, + createdAt: now, + updatedAt: now, + finalizedAt, + lastErrorCode: args.attemptErrorCode ?? null, + lastErrorMessage: args.attemptErrorMessage ?? null, + }); +} + +function mockSucceededEvent(args: { + eventId: string; + orderId: string; + paymentIntentId: string; + chargeId: string; +}) { + vi.mocked(verifyWebhookSignature).mockReturnValue({ + id: args.eventId, + object: 'event', + type: 'payment_intent.succeeded', + data: { + object: { + id: args.paymentIntentId, + object: 'payment_intent', + amount: 900, + amount_received: 900, + currency: 'usd', + status: 'succeeded', + metadata: { orderId: args.orderId }, + charges: { + object: 'list', + data: [ + { + id: args.chargeId, + object: 'charge', + payment_intent: args.paymentIntentId, + payment_method_details: { + type: 'card', + card: { brand: 'visa', last4: '4242' }, + }, + }, + ], + }, + }, + }, + } as any); +} + +async function readState( + orderId: string, + paymentIntentId: string, + eventIds: string[] +) { + const [order] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + shippingStatus: orders.shippingStatus, + pspStatusReason: orders.pspStatusReason, + pspChargeId: orders.pspChargeId, + pspMetadata: orders.pspMetadata, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + const [attempt] = await db + .select({ + status: paymentAttempts.status, + lastErrorCode: paymentAttempts.lastErrorCode, + lastErrorMessage: paymentAttempts.lastErrorMessage, + finalizedAt: paymentAttempts.finalizedAt, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.providerPaymentIntentId, paymentIntentId)) + .limit(1); + + const paymentEventRows = await db + .select({ id: paymentEvents.id, eventRef: paymentEvents.eventRef }) + .from(paymentEvents) + .where(eq(paymentEvents.orderId, orderId)); + + const shipmentRows = await db + .select({ id: shippingShipments.id }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + + const stripeEventRows = + eventIds.length > 0 + ? await db + .select({ + eventId: stripeEvents.eventId, + processedAt: stripeEvents.processedAt, + claimExpiresAt: stripeEvents.claimExpiresAt, + }) + .from(stripeEvents) + .where(inArray(stripeEvents.eventId, eventIds)) + : []; + + return { + order, + attempt, + paymentEventRows, + shipmentRows, + stripeEventRows, + }; +} + +describe.sequential('stripe webhook replay correctness', () => { + const cleanupQueue: CleanupRecord[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + + while (cleanupQueue.length > 0) { + const next = cleanupQueue.pop(); + if (next) await cleanup(next); + } + }); + + it('dedupes the same Stripe event ID without duplicate success side effects', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const chargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [eventId] }); + + await seedOrderWithAttempt({ orderId, paymentIntentId }); + mockSucceededEvent({ eventId, orderId, paymentIntentId, chargeId }); + + const first = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId, attempt: 1 })) + ); + const second = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId, attempt: 2 })) + ); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + + const state = await readState(orderId, paymentIntentId, [eventId]); + + expect(state.order?.paymentStatus).toBe('paid'); + expect(state.order?.status).toBe('PAID'); + expect(state.order?.shippingStatus).toBe('queued'); + expect(state.attempt?.status).toBe('succeeded'); + expect(state.paymentEventRows).toHaveLength(1); + expect(state.paymentEventRows[0]?.eventRef).toBe(eventId); + expect(state.shipmentRows).toHaveLength(1); + expect(state.stripeEventRows).toHaveLength(1); + expect(state.stripeEventRows[0]?.processedAt).toBeTruthy(); + }, 30_000); + + it('retries safely after a transient failure before the event is marked processed', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const chargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [eventId] }); + + await seedOrderWithAttempt({ orderId, paymentIntentId }); + mockSucceededEvent({ eventId, orderId, paymentIntentId, chargeId }); + + const originalExecute = db.execute.bind(db); + const executeSpy = vi.spyOn(db, 'execute'); + executeSpy + .mockImplementationOnce((() => { + throw new Error('TRANSIENT_TEST_DB_FAILURE'); + }) as typeof db.execute) + .mockImplementation(originalExecute as typeof db.execute); + + try { + const first = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId, attempt: 1 })) + ); + + expect(first.status).toBe(500); + await expect(first.json()).resolves.toMatchObject({ + error: 'internal_error', + }); + + const afterFirst = await readState(orderId, paymentIntentId, [eventId]); + + expect(afterFirst.order?.paymentStatus).toBe('requires_payment'); + expect(afterFirst.order?.status).toBe('INVENTORY_RESERVED'); + expect(afterFirst.order?.shippingStatus).toBe('pending'); + expect(afterFirst.attempt?.status).toBe('active'); + expect(afterFirst.paymentEventRows).toHaveLength(0); + expect(afterFirst.shipmentRows).toHaveLength(0); + expect(afterFirst.stripeEventRows).toHaveLength(1); + expect(afterFirst.stripeEventRows[0]?.processedAt).toBeNull(); + expect(afterFirst.stripeEventRows[0]?.claimExpiresAt?.getTime()).toBe(0); + + const second = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId, attempt: 2 })) + ); + + expect(second.status).toBe(200); + + const afterSecond = await readState(orderId, paymentIntentId, [eventId]); + + expect(afterSecond.order?.paymentStatus).toBe('paid'); + expect(afterSecond.order?.status).toBe('PAID'); + expect(afterSecond.order?.shippingStatus).toBe('queued'); + expect(afterSecond.attempt?.status).toBe('succeeded'); + expect(afterSecond.paymentEventRows).toHaveLength(1); + expect(afterSecond.shipmentRows).toHaveLength(1); + expect(afterSecond.stripeEventRows).toHaveLength(1); + expect(afterSecond.stripeEventRows[0]?.processedAt).toBeTruthy(); + } finally { + executeSpy.mockRestore(); + } + }, 30_000); + + it('keeps success replay stable after success was already applied', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const firstEventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const replayEventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const firstChargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const replayChargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [firstEventId, replayEventId] }); + + await seedOrderWithAttempt({ orderId, paymentIntentId }); + + mockSucceededEvent({ + eventId: firstEventId, + orderId, + paymentIntentId, + chargeId: firstChargeId, + }); + const first = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: firstEventId, phase: 'first' })) + ); + expect(first.status).toBe(200); + + mockSucceededEvent({ + eventId: replayEventId, + orderId, + paymentIntentId, + chargeId: replayChargeId, + }); + const replay = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: replayEventId, phase: 'replay' })) + ); + expect(replay.status).toBe(200); + + const state = await readState(orderId, paymentIntentId, [ + firstEventId, + replayEventId, + ]); + + expect(state.order?.paymentStatus).toBe('paid'); + expect(state.order?.status).toBe('PAID'); + expect(state.order?.shippingStatus).toBe('queued'); + expect(state.order?.pspChargeId).toBe(firstChargeId); + expect(state.attempt?.status).toBe('succeeded'); + expect(state.paymentEventRows).toHaveLength(1); + expect(state.paymentEventRows[0]?.eventRef).toBe(firstEventId); + expect(state.shipmentRows).toHaveLength(1); + expect(state.stripeEventRows).toHaveLength(2); + expect( + state.stripeEventRows.filter(event => event.processedAt != null) + ).toHaveLength(2); + }, 30_000); + + it('keeps replay deterministic after the terminal conflict review path', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const firstEventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const replayEventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const firstChargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const replayChargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [firstEventId, replayEventId] }); + + await seedOrderWithAttempt({ + orderId, + paymentIntentId, + paymentStatus: 'failed', + orderStatus: 'INVENTORY_FAILED', + inventoryStatus: 'released', + shippingRequired: false, + shippingStatus: null, + stockRestored: true, + restockedAt: new Date(), + pspStatusReason: 'card_declined', + attemptStatus: 'failed', + attemptErrorCode: 'payment_failed', + attemptErrorMessage: 'payment_intent.payment_failed', + }); + + mockSucceededEvent({ + eventId: firstEventId, + orderId, + paymentIntentId, + chargeId: firstChargeId, + }); + const first = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: firstEventId, phase: 'first' })) + ); + expect(first.status).toBe(200); + + mockSucceededEvent({ + eventId: replayEventId, + orderId, + paymentIntentId, + chargeId: replayChargeId, + }); + const replay = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: replayEventId, phase: 'replay' })) + ); + expect(replay.status).toBe(200); + + const state = await readState(orderId, paymentIntentId, [ + firstEventId, + replayEventId, + ]); + + expect(state.order?.paymentStatus).toBe('needs_review'); + expect(state.order?.status).toBe('INVENTORY_FAILED'); + expect(state.order?.pspStatusReason).toBe('late_success_after_failed'); + expect( + (state.order?.pspMetadata as any)?.outOfOrderSuccess?.fromPaymentStatus + ).toBe('failed'); + expect(state.attempt?.status).toBe('succeeded'); + expect(state.attempt?.lastErrorCode).toBe('TERMINAL_ORDER_STATE_CONFLICT'); + expect(state.paymentEventRows).toHaveLength(0); + expect(state.shipmentRows).toHaveLength(0); + expect(state.stripeEventRows).toHaveLength(2); + expect( + state.stripeEventRows.filter(event => event.processedAt != null) + ).toHaveLength(2); + }, 30_000); +}); diff --git a/frontend/lib/tests/shop/stripe-webhook-terminal-consistency.test.ts b/frontend/lib/tests/shop/stripe-webhook-terminal-consistency.test.ts new file mode 100644 index 00000000..28611567 --- /dev/null +++ b/frontend/lib/tests/shop/stripe-webhook-terminal-consistency.test.ts @@ -0,0 +1,512 @@ +import { randomUUID } from 'crypto'; +import { eq, inArray } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts, stripeEvents } from '@/db/schema'; +import * as paymentState from '@/lib/services/orders/payment-state'; + +vi.mock('@/lib/psp/stripe', async () => { + const actual = + await vi.importActual>('@/lib/psp/stripe'); + return { + ...actual, + verifyWebhookSignature: vi.fn(), + retrieveCharge: vi.fn(), + }; +}); + +import { POST as webhookPOST } from '@/app/api/shop/webhooks/stripe/route'; +import { verifyWebhookSignature } from '@/lib/psp/stripe'; + +function makeWebhookRequest(rawBody: string) { + return new NextRequest('http://localhost:3000/api/shop/webhooks/stripe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Stripe-Signature': 't=1,v1=test', + }, + body: rawBody, + }); +} + +function logTestCleanupFailed(meta: Record, error: unknown) { + console.error('[test cleanup failed]', { + file: 'stripe-webhook-terminal-consistency.test.ts', + ...meta, + error, + }); +} + +async function cleanup(params: { orderId: string; eventIds: string[] }) { + const { orderId, eventIds } = params; + + if (eventIds.length > 0) { + try { + await db + .delete(stripeEvents) + .where(inArray(stripeEvents.eventId, eventIds)); + } catch (error) { + logTestCleanupFailed( + { step: 'delete stripe events', orderId, eventIds }, + error + ); + } + } + + try { + await db.delete(orders).where(eq(orders.id, orderId)); + } catch (error) { + logTestCleanupFailed({ step: 'delete order', orderId, eventIds }, error); + } +} + +type SeedOrderArgs = { + orderId: string; + paymentIntentId: string; + paymentStatus: 'requires_payment' | 'failed' | 'refunded'; + status: 'INVENTORY_RESERVED' | 'INVENTORY_FAILED' | 'PAID'; + inventoryStatus: 'reserved' | 'released'; + stockRestored: boolean; + restockedAt?: Date | null; + pspStatusReason?: string | null; + attemptStatus?: 'active' | 'failed'; +}; + +async function seedOrderWithAttempt(args: SeedOrderArgs) { + const now = new Date(); + + await db.insert(orders).values({ + id: args.orderId, + totalAmountMinor: 900, + totalAmount: '9.00', + currency: 'USD', + shippingRequired: false, + paymentStatus: args.paymentStatus, + paymentProvider: 'stripe', + paymentIntentId: args.paymentIntentId, + idempotencyKey: `idem_${randomUUID()}`, + status: args.status, + inventoryStatus: args.inventoryStatus, + stockRestored: args.stockRestored, + restockedAt: args.restockedAt ?? null, + pspStatusReason: args.pspStatusReason ?? null, + createdAt: now, + updatedAt: now, + }); + + await db.insert(paymentAttempts).values({ + orderId: args.orderId, + provider: 'stripe', + status: args.attemptStatus ?? 'active', + attemptNumber: 1, + currency: 'USD', + expectedAmountMinor: 900, + idempotencyKey: `attempt_${randomUUID()}`, + providerPaymentIntentId: args.paymentIntentId, + metadata: {}, + createdAt: now, + updatedAt: now, + finalizedAt: args.attemptStatus === 'failed' ? now : null, + lastErrorCode: args.attemptStatus === 'failed' ? 'payment_failed' : null, + lastErrorMessage: + args.attemptStatus === 'failed' ? 'payment_intent.payment_failed' : null, + }); +} + +function mockSucceededEvent(args: { + eventId: string; + orderId: string; + paymentIntentId: string; +}) { + vi.mocked(verifyWebhookSignature).mockReturnValue({ + id: args.eventId, + object: 'event', + type: 'payment_intent.succeeded', + data: { + object: { + id: args.paymentIntentId, + object: 'payment_intent', + amount: 900, + amount_received: 900, + currency: 'usd', + status: 'succeeded', + metadata: { orderId: args.orderId }, + charges: { object: 'list', data: [] }, + }, + }, + } as any); +} + +function mockFailedEvent(args: { + eventId: string; + orderId: string; + paymentIntentId: string; +}) { + vi.mocked(verifyWebhookSignature).mockReturnValue({ + id: args.eventId, + object: 'event', + type: 'payment_intent.payment_failed', + data: { + object: { + id: args.paymentIntentId, + object: 'payment_intent', + amount: 900, + currency: 'usd', + status: 'requires_payment_method', + metadata: { orderId: args.orderId }, + last_payment_error: { + code: 'card_declined', + message: 'Card declined', + }, + charges: { object: 'list', data: [] }, + }, + }, + } as any); +} + +async function readOrderAndAttempt(orderId: string, paymentIntentId: string) { + const [order] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + pspStatusReason: orders.pspStatusReason, + pspMetadata: orders.pspMetadata, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + const [attempt] = await db + .select({ + status: paymentAttempts.status, + lastErrorCode: paymentAttempts.lastErrorCode, + lastErrorMessage: paymentAttempts.lastErrorMessage, + finalizedAt: paymentAttempts.finalizedAt, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.providerPaymentIntentId, paymentIntentId)) + .limit(1); + + return { order, attempt }; +} + +async function readStripeEvent(eventId: string) { + const [event] = await db + .select({ + eventId: stripeEvents.eventId, + processedAt: stripeEvents.processedAt, + claimExpiresAt: stripeEvents.claimExpiresAt, + }) + .from(stripeEvents) + .where(eq(stripeEvents.eventId, eventId)) + .limit(1); + + return event; +} + +describe.sequential('stripe webhook terminal-state consistency', () => { + const cleanupQueue: Array<{ orderId: string; eventIds: string[] }> = []; + + afterEach(async () => { + vi.restoreAllMocks(); + + while (cleanupQueue.length > 0) { + const next = cleanupQueue.pop(); + if (next) await cleanup(next); + } + }); + + it('applies normal Stripe success consistently for a payable order', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [eventId] }); + + await seedOrderWithAttempt({ + orderId, + paymentIntentId, + paymentStatus: 'requires_payment', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + stockRestored: false, + }); + + mockSucceededEvent({ eventId, orderId, paymentIntentId }); + + const response = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId })) + ); + + expect(response.status).toBe(200); + + const { order, attempt } = await readOrderAndAttempt( + orderId, + paymentIntentId + ); + + expect(order?.paymentStatus).toBe('paid'); + expect(order?.status).toBe('PAID'); + expect(attempt?.status).toBe('succeeded'); + expect(attempt?.lastErrorCode).toBeNull(); + }, 30_000); + + it('dedupes duplicate Stripe success delivery without extra side effects', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [eventId] }); + + await seedOrderWithAttempt({ + orderId, + paymentIntentId, + paymentStatus: 'requires_payment', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + stockRestored: false, + }); + + mockSucceededEvent({ eventId, orderId, paymentIntentId }); + + const first = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId, first: true })) + ); + const second = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId, second: true })) + ); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + + const events = await db + .select({ eventId: stripeEvents.eventId }) + .from(stripeEvents) + .where(eq(stripeEvents.eventId, eventId)); + + const { order, attempt } = await readOrderAndAttempt( + orderId, + paymentIntentId + ); + + expect(events).toHaveLength(1); + expect(order?.paymentStatus).toBe('paid'); + expect(order?.status).toBe('PAID'); + expect(attempt?.status).toBe('succeeded'); + }, 30_000); + + it('routes late Stripe success after a terminal failed order into explicit review', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [eventId] }); + + await seedOrderWithAttempt({ + orderId, + paymentIntentId, + paymentStatus: 'failed', + status: 'INVENTORY_FAILED', + inventoryStatus: 'released', + stockRestored: true, + restockedAt: new Date(), + attemptStatus: 'failed', + pspStatusReason: 'card_declined', + }); + + mockSucceededEvent({ eventId, orderId, paymentIntentId }); + + const response = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId })) + ); + + expect(response.status).toBe(200); + + const { order, attempt } = await readOrderAndAttempt( + orderId, + paymentIntentId + ); + + expect(order?.paymentStatus).toBe('needs_review'); + expect(order?.status).toBe('INVENTORY_FAILED'); + expect(order?.pspStatusReason).toBe('late_success_after_failed'); + expect( + (order?.pspMetadata as any)?.outOfOrderSuccess?.fromPaymentStatus + ).toBe('failed'); + expect(attempt?.status).toBe('succeeded'); + expect(attempt?.lastErrorCode).toBe('TERMINAL_ORDER_STATE_CONFLICT'); + expect(attempt?.lastErrorMessage).toBe( + 'payment_intent.succeeded_after_failed' + ); + }, 30_000); + + it('routes late Stripe success after a terminal refunded order into explicit review', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [eventId] }); + + await seedOrderWithAttempt({ + orderId, + paymentIntentId, + paymentStatus: 'refunded', + status: 'PAID', + inventoryStatus: 'released', + stockRestored: true, + restockedAt: new Date(), + attemptStatus: 'failed', + pspStatusReason: 'requested_by_customer', + }); + + mockSucceededEvent({ eventId, orderId, paymentIntentId }); + + const response = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId })) + ); + + expect(response.status).toBe(200); + + const { order, attempt } = await readOrderAndAttempt( + orderId, + paymentIntentId + ); + + expect(order?.paymentStatus).toBe('needs_review'); + expect(order?.status).toBe('PAID'); + expect(order?.pspStatusReason).toBe('late_success_after_refunded'); + expect( + (order?.pspMetadata as any)?.outOfOrderSuccess?.fromPaymentStatus + ).toBe('refunded'); + expect(attempt?.status).toBe('succeeded'); + expect(attempt?.lastErrorCode).toBe('TERMINAL_ORDER_STATE_CONFLICT'); + expect(attempt?.lastErrorMessage).toBe( + 'payment_intent.succeeded_after_refunded' + ); + }, 30_000); + + it('blocked conflict releases claim and allows retry', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [eventId] }); + + await seedOrderWithAttempt({ + orderId, + paymentIntentId, + paymentStatus: 'failed', + status: 'INVENTORY_FAILED', + inventoryStatus: 'released', + stockRestored: true, + restockedAt: new Date(), + attemptStatus: 'failed', + pspStatusReason: 'card_declined', + }); + + const transitionSpy = vi + .spyOn(paymentState, 'guardedPaymentStatusUpdate') + .mockResolvedValue({ + applied: false, + reason: 'BLOCKED', + from: 'failed', + currentProvider: 'stripe', + }); + + mockSucceededEvent({ eventId, orderId, paymentIntentId }); + + const firstResponse = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId })) + ); + + expect(firstResponse.status).toBe(503); + await expect(firstResponse.json()).resolves.toMatchObject({ + code: 'TERMINAL_SUCCESS_CONFLICT_BLOCKED', + retryAfterSeconds: 10, + }); + + expect(transitionSpy).toHaveBeenCalledTimes(1); + + const { order, attempt } = await readOrderAndAttempt( + orderId, + paymentIntentId + ); + const eventRowAfterFirst = await readStripeEvent(eventId); + + expect(order?.paymentStatus).toBe('failed'); + expect(order?.status).toBe('INVENTORY_FAILED'); + expect(attempt?.status).toBe('failed'); + expect(attempt?.lastErrorCode).toBe('payment_failed'); + expect(attempt?.lastErrorMessage).toBe('payment_intent.payment_failed'); + expect(eventRowAfterFirst?.processedAt).toBeNull(); + expect(eventRowAfterFirst?.claimExpiresAt?.getTime()).toBe(0); + + const secondResponse = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: eventId, replay: true })) + ); + + expect(secondResponse.status).toBe(503); + await expect(secondResponse.json()).resolves.toMatchObject({ + code: 'TERMINAL_SUCCESS_CONFLICT_BLOCKED', + retryAfterSeconds: 10, + }); + + expect(transitionSpy).toHaveBeenCalledTimes(2); + + const eventRowAfterSecond = await readStripeEvent(eventId); + expect(eventRowAfterSecond?.processedAt).toBeNull(); + expect(eventRowAfterSecond?.claimExpiresAt?.getTime()).toBe(0); + }, 30_000); + + it('handles out-of-order Stripe failure then success deterministically', async () => { + const orderId = randomUUID(); + const paymentIntentId = `pi_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const failedEventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const successEventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + cleanupQueue.push({ orderId, eventIds: [failedEventId, successEventId] }); + + await seedOrderWithAttempt({ + orderId, + paymentIntentId, + paymentStatus: 'requires_payment', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + stockRestored: false, + }); + + mockFailedEvent({ eventId: failedEventId, orderId, paymentIntentId }); + const failedResponse = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: failedEventId })) + ); + + expect(failedResponse.status).toBe(200); + + const afterFailure = await readOrderAndAttempt(orderId, paymentIntentId); + expect(afterFailure.order?.paymentStatus).toBe('failed'); + expect(afterFailure.attempt?.status).toBe('failed'); + + mockSucceededEvent({ + eventId: successEventId, + orderId, + paymentIntentId, + }); + const successResponse = await webhookPOST( + makeWebhookRequest(JSON.stringify({ id: successEventId })) + ); + + expect(successResponse.status).toBe(200); + + const finalState = await readOrderAndAttempt(orderId, paymentIntentId); + + expect(finalState.order?.paymentStatus).toBe('needs_review'); + expect(finalState.order?.status).toBe('INVENTORY_FAILED'); + expect(finalState.order?.pspStatusReason).toBe('late_success_after_failed'); + expect(finalState.attempt?.status).toBe('succeeded'); + expect(finalState.attempt?.lastErrorCode).toBe( + 'TERMINAL_ORDER_STATE_CONFLICT' + ); + }, 30_000); +}); diff --git a/frontend/lib/tests/shop/test-legal-consent.ts b/frontend/lib/tests/shop/test-legal-consent.ts index e1c026f5..70b79301 100644 --- a/frontend/lib/tests/shop/test-legal-consent.ts +++ b/frontend/lib/tests/shop/test-legal-consent.ts @@ -1,6 +1,12 @@ -export const TEST_LEGAL_CONSENT = { - termsAccepted: true, - privacyAccepted: true, - termsVersion: 'terms-2026-02-27', - privacyVersion: 'privacy-2026-02-27', -} as const; +import { getShopLegalVersions } from '@/lib/env/shop-legal'; + +export function createTestLegalConsent() { + const canonicalLegalVersions = getShopLegalVersions(); + + return { + termsAccepted: true, + privacyAccepted: true, + termsVersion: canonicalLegalVersions.termsVersion, + privacyVersion: canonicalLegalVersions.privacyVersion, + } as const; +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 67a839d5..21bfddd0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1958,15 +1958,15 @@ }, "review": { "title": "How requests are reviewed", - "body": "Each request is reviewed individually. We may ask for additional details, photos, or other information needed to assess the request and explain the next steps." + "body": "Each request is reviewed individually. We may ask for additional details, photos, or other information needed to assess the request and explain the next steps. Exchanges are not supported." }, "refunds": { "title": "Refund processing", - "body": "Refund availability and timing depend on the review result and the payment method used for the order. Automatic refund processing through the website is not currently available." + "body": "Refund availability and timing depend on the review result and the payment method used for the order. Self-service refund processing through the storefront is not currently available." }, "contact": { "title": "How to contact support", - "body": "To request return guidance or report a delivery issue, contact" + "body": "To request return or cancellation guidance, or report a delivery issue, contact" } }, "privacy": { diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 21cbe7b6..4ceca896 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -1961,15 +1961,15 @@ }, "review": { "title": "Jak rozpatrywane są zgłoszenia", - "body": "Każde zgłoszenie jest rozpatrywane indywidualnie. W razie potrzeby możemy poprosić o dodatkowe szczegóły, zdjęcia lub inne informacje potrzebne do oceny zgłoszenia i przekazania dalszych kroków." + "body": "Każde zgłoszenie jest rozpatrywane indywidualnie. W razie potrzeby możemy poprosić o dodatkowe szczegóły, zdjęcia lub inne informacje potrzebne do oceny zgłoszenia i przekazania dalszych kroków. Wymiany nie są obsługiwane." }, "refunds": { "title": "Zwrot środków", - "body": "Możliwość oraz czas zwrotu środków zależą od wyniku rozpatrzenia i metody płatności użytej przy zamówieniu. Automatyczne zwroty przez stronę internetową nie są obecnie dostępne." + "body": "Możliwość oraz czas zwrotu środków zależą od wyniku rozpatrzenia i metody płatności użytej przy zamówieniu. Samodzielne zwroty środków przez witrynę sklepu nie są obecnie dostępne." }, "contact": { "title": "Jak skontaktować się ze wsparciem", - "body": "Aby uzyskać wskazówki dotyczące zwrotu lub zgłosić problem z dostawą, napisz na" + "body": "Aby uzyskać wskazówki dotyczące zwrotu, anulowania lub zgłosić problem z dostawą, napisz na" } }, "privacy": { diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index e3a3c0e4..fc161ad7 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -1961,15 +1961,15 @@ }, "review": { "title": "Як розглядаються звернення", - "body": "Кожне звернення розглядається окремо. За потреби ми можемо попросити додаткові деталі, фото або іншу інформацію, щоб оцінити ситуацію та повідомити подальші кроки." + "body": "Кожне звернення розглядається окремо. За потреби ми можемо попросити додаткові деталі, фото або іншу інформацію, щоб оцінити ситуацію та повідомити подальші кроки. Обмін наразі не підтримується." }, "refunds": { "title": "Повернення коштів", - "body": "Можливість і строк повернення коштів залежать від результату розгляду та способу оплати замовлення. Автоматичне повернення коштів через сайт наразі недоступне." + "body": "Можливість і строк повернення коштів залежать від результату розгляду та способу оплати замовлення. Самостійне повернення коштів через вітрину магазину наразі недоступне." }, "contact": { "title": "Як зв’язатися з підтримкою", - "body": "Щоб отримати інструкції щодо повернення або повідомити про проблему з доставкою, напишіть на" + "body": "Щоб отримати інструкції щодо повернення, скасування або повідомити про проблему з доставкою, напишіть на" } }, "privacy": {