+
+
+ {sizeGuide.label}
+
+
+
+
{sizeGuide.title}
+
{sizeGuide.intro}
+
+ {sizeGuide.measurementNote}
+
+
+
+
+ {sizeGuide.fitNotes.map(note => (
+ - {note}
+ ))}
+
+
+
+
+ {sizeGuide.chart.caption}
+
+
+
+
+ {sizeGuide.chart.caption}
+
+
+ |
+ {sizeGuide.chart.columns.size}
+ |
+
+ {sizeGuide.chart.columns.chestWidth}
+ |
+
+ {sizeGuide.chart.columns.bodyLength}
+ |
+
+
+
+ {sizeGuide.chart.rows.map(row => (
+
+ |
+ {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": {