diff --git a/frontend/.env.example b/frontend/.env.example index fd5958df..24d111e8 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,12 +1,20 @@ # --- Core / Environment -APP_ADDITIONAL_ORIGINS=https://admin.example.test +# Main runtime environment label (for example: local, develop, production). APP_ENV= + +# Canonical application origin / base URL. APP_ORIGIN=https://example.test APP_URL= NEXT_PUBLIC_SITE_URL= +# Additional trusted browser origins, comma-separated if multiple. +APP_ADDITIONAL_ORIGINS=https://admin.example.test + # --- Database +# Primary database URL used by the app runtime. DATABASE_URL= + +# Local-only database URL used for strict local development/testing flows. DATABASE_URL_LOCAL= # --- Upstash Redis (REST) @@ -46,40 +54,63 @@ CLOUDINARY_UPLOAD_FOLDER= CLOUDINARY_URL= # --- Payments (Stripe) +# Public Stripe key for browser checkout flows. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= -# Options: test, live (defaults to test in development, live in production) + +# Allowed values: test, live. +# In local/development, test mode is expected. +# In production-like runtime, invalid or placeholder Stripe config must fail closed. STRIPE_MODE= + +# Toggle Stripe payments for Shop checkout flows. STRIPE_PAYMENTS_ENABLED= + +# Required when PAYMENTS_ENABLED enables Shop payments and Stripe is used as an active payment rail. +# In production-like runtime, invalid or placeholder config must fail closed. STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= # --- Payments (Monobank) -# Optional; set explicitly in production for clarity +# Optional API base override. Leave empty to use code defaults unless a custom base is required. MONO_API_BASE= + +# Optional invoice timeout override in milliseconds. +# Default fallback: 8000 in production, 12000 outside production. MONO_INVOICE_TIMEOUT_MS= -# Required for Monobank checkout/webhooks +# Required when PAYMENTS_ENABLED enables Shop payments and Monobank is used as an active payment rail. +# In production-like runtime, invalid or placeholder config must fail closed. MONO_MERCHANT_TOKEN= MONO_PUBLIC_KEY= -# Optional webhook/runtime tuning (defaults in code if omitted) +# Optional Monobank webhook/runtime tuning. MONO_REFUND_ENABLED=0 MONO_WEBHOOK_CLAIM_TTL_MS= MONO_WEBHOOK_MODE= +# Global payments toggle for Shop checkout flows. PAYMENTS_ENABLED= # --- Shipping (Nova Poshta) -# Toggles (optional; defaults are handled in code) +# Shipping feature toggles. SHOP_SHIPPING_ENABLED=0 SHOP_SHIPPING_NP_ENABLED=0 -# Retention (optional; days, used for cleanup/retention policies) +# Optional retention in days for cleanup / retention jobs. SHOP_SHIPPING_RETENTION_DAYS= -# Required when shipping is enabled (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1). -# If shipping is enabled without required NP config, app throws NovaPoshtaConfigError at runtime. -# Optional if code has a default; set explicitly in production for clarity +# Authoritative flat shipping prices used by Shop checkout and shipping-method resolution. +# Values are stored in minor units for UAH. +# Example: 500 = 5.00 UAH +# Required whenever Nova Poshta shipping methods are enabled. +# Missing or invalid values must fail closed. +SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR= +SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR= +SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR= + +# Required Nova Poshta provider config when shipping is enabled +# (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1). +# In production-like runtime, invalid or placeholder config must fail closed. NP_API_BASE= NP_API_KEY= NP_SENDER_WAREHOUSE_REF= @@ -89,7 +120,7 @@ NP_SENDER_NAME= NP_SENDER_PHONE= NP_SENDER_REF= -# Optional tuning (override only if needed; otherwise code defaults apply) +# Optional Nova Poshta runtime tuning. NP_MAX_RETRIES= NP_RETRY_DELAY_MS= NP_TIMEOUT_MS= @@ -100,18 +131,18 @@ INTERNAL_JANITOR_MIN_INTERVAL_SECONDS= INTERNAL_JANITOR_SECRET= JANITOR_URL= -# Optional internal/admin runtime secrets & tuning (used by internal endpoints/jobs) +# Optional internal/admin runtime secrets & tuning. INTERNAL_SECRET= JANITOR_TIMEOUT_MS= -# Optional instance IDs for webhook multi-instance diagnostics/claiming +# Optional instance IDs for webhook multi-instance diagnostics / claiming. STRIPE_WEBHOOK_INSTANCE_ID= WEBHOOK_INSTANCE_ID= # --- Quiz QUIZ_ENCRYPTION_KEY= -# --- Web3Forms (feedback form) +# --- Web3Forms / Sponsors / Feedback GITHUB_SPONSORS_TOKEN= NEXT_PUBLIC_WEB3FORMS_KEY= @@ -125,44 +156,49 @@ GMAIL_APP_PASSWORD= GMAIL_USER= # --- Shop / Internal -# Optional public/base URL used by shop services/links +# Optional absolute base URL used by Shop links/services. +# Set explicitly in production to avoid incorrect absolute URLs. SHOP_BASE_URL= + +# Policy/consent version labels used by Shop flows. SHOP_PRIVACY_VERSION=privacy-v1 SHOP_TERMS_VERSION=terms-v1 -# Required for signed shop status tokens (if status endpoint/token flow is enabled) +# Required for signed Shop status-token flows. SHOP_STATUS_TOKEN_SECRET= # --- Security CSRF_SECRET= +# Checkout route rate limiting. CHECKOUT_RATE_LIMIT_MAX=10 CHECKOUT_RATE_LIMIT_WINDOW_SECONDS=300 -# Stripe webhook rate limit envs (applied per reason; reason-specific overrides generic). +# Stripe webhook rate-limit envs. # Missing signature has its own envs with fallback to generic, then legacy invalid_sig. STRIPE_WEBHOOK_MISSING_SIG_RL_MAX=30 STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS=60 -# Generic Stripe webhook rate limit fallback (applies to missing_sig and invalid_sig). +# Generic Stripe webhook rate-limit fallback. STRIPE_WEBHOOK_RL_MAX=30 STRIPE_WEBHOOK_RL_WINDOW_SECONDS=60 -# Invalid signature envs (canonical for invalid_sig, legacy fallback for missing_sig). +# Invalid-signature envs. STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30 STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60 -# SECURITY: If true, trust Cloudflare's cf-connecting-ip header for rate limiting. -# Enable ONLY when traffic is fronted by Cloudflare (header is set by Cloudflare at the edge). -# Default: false (0). Keep 0 in untrusted environments to avoid IP spoofing. +# SECURITY: +# Trust Cloudflare's cf-connecting-ip header for rate limiting only when traffic is fronted by Cloudflare. +# Default: 0 TRUST_CF_CONNECTING_IP=0 -# SECURITY: If true, trust x-real-ip / x-forwarded-for headers for rate limiting. -# Enable ONLY behind Cloudflare or a trusted reverse proxy that overwrites these headers. -# Default: false (empty/0/false). +# SECURITY: +# Trust x-real-ip / x-forwarded-for only behind Cloudflare or another trusted reverse proxy. +# Default: 0 TRUST_FORWARDED_HEADERS=0 -# emergency switch +# Emergency switch for rate limiting. RATE_LIMIT_DISABLED=0 +# --- AI / External GROQ_API_KEY= diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx index a38f575f..1ed6a0d1 100644 --- a/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx +++ b/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx @@ -10,6 +10,12 @@ import { orderItems, orders } from '@/db/schema'; import { Link } from '@/i18n/routing'; import { getCurrentUser } from '@/lib/auth'; import { logError } from '@/lib/logging'; +import { + type CanonicalFulfillmentStage, + deriveCanonicalFulfillmentStage, + latestReturnStatusSql, + latestShipmentStatusSql, +} from '@/lib/services/shop/fulfillment-stage'; import { type CurrencyCode, formatMoney } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; import { @@ -39,6 +45,7 @@ type OrderDetail = { paymentStatus: OrderPaymentStatus; paymentProvider: OrderPaymentProvider; paymentIntentId: string | null; + fulfillmentStage: CanonicalFulfillmentStage; shippingStatus: string | null; trackingNumber: string | null; stockRestored: boolean; @@ -76,6 +83,10 @@ function safeFormatDateTime(iso: string, dtf: Intl.DateTimeFormat): string { return dtf.format(d); } +function fulfillmentStageLabelKey(stage: CanonicalFulfillmentStage): string { + return `fulfillmentStages.${stage}`; +} + function toOrderItem( item: { id: string | null; @@ -150,7 +161,10 @@ export default async function OrderDetailPage({ paymentStatus: orders.paymentStatus, paymentProvider: orders.paymentProvider, paymentIntentId: orders.paymentIntentId, + orderStatus: orders.status, shippingStatus: orders.shippingStatus, + shipmentStatus: latestShipmentStatusSql(orders.id), + returnStatus: latestReturnStatusSql(orders.id), trackingNumber: orders.trackingNumber, stockRestored: orders.stockRestored, restockedAt: orders.restockedAt, @@ -183,13 +197,32 @@ export default async function OrderDetailPage({ try { const base = rows[0]!.order; + const fulfillmentStage = deriveCanonicalFulfillmentStage({ + orderStatus: base.orderStatus, + shippingStatus: base.shippingStatus, + shipmentStatus: + typeof base.shipmentStatus === 'string' ? base.shipmentStatus : null, + returnStatus: + typeof base.returnStatus === 'string' ? base.returnStatus : null, + }); const items = rows .map(r => toOrderItem(r.item)) .filter((i): i is NonNullable => i !== null); order = { - ...base, + id: base.id, + userId: base.userId, + totalAmount: base.totalAmount, + currency: base.currency, + paymentStatus: base.paymentStatus, + paymentProvider: base.paymentProvider, + paymentIntentId: base.paymentIntentId, + fulfillmentStage, + shippingStatus: base.shippingStatus, + trackingNumber: base.trackingNumber, + stockRestored: base.stockRestored, + idempotencyKey: base.idempotencyKey, createdAt: base.createdAt.toISOString(), updatedAt: base.updatedAt.toISOString(), restockedAt: base.restockedAt ? base.restockedAt.toISOString() : null, @@ -276,6 +309,15 @@ export default async function OrderDetailPage({ +
+
+ {t('fulfillmentStage')} +
+
+ {t(fulfillmentStageLabelKey(order.fulfillmentStage))} +
+
+
{t('shippingStatus')} diff --git a/frontend/app/[locale]/shop/orders/[id]/page.tsx b/frontend/app/[locale]/shop/orders/[id]/page.tsx index 1db6848e..e16f83b6 100644 --- a/frontend/app/[locale]/shop/orders/[id]/page.tsx +++ b/frontend/app/[locale]/shop/orders/[id]/page.tsx @@ -11,6 +11,12 @@ import { orderItems, orders } from '@/db/schema'; import { Link } from '@/i18n/routing'; import { getCurrentUser } from '@/lib/auth'; import { logError } from '@/lib/logging'; +import { + type CanonicalFulfillmentStage, + deriveCanonicalFulfillmentStage, + latestReturnStatusSql, + latestShipmentStatusSql, +} from '@/lib/services/shop/fulfillment-stage'; import { type CurrencyCode, formatMoney } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; import { @@ -40,6 +46,7 @@ type OrderDetail = { paymentStatus: OrderPaymentStatus; paymentProvider: OrderPaymentProvider; paymentIntentId: string | null; + fulfillmentStage: CanonicalFulfillmentStage; shippingStatus: string | null; trackingNumber: string | null; stockRestored: boolean; @@ -77,6 +84,10 @@ function safeFormatDateTime(iso: string, dtf: Intl.DateTimeFormat): string { return dtf.format(d); } +function fulfillmentStageLabelKey(stage: CanonicalFulfillmentStage): string { + return `fulfillmentStages.${stage}`; +} + function toOrderItem( item: { id: string | null; @@ -153,7 +164,10 @@ export default async function OrderDetailPage({ paymentStatus: orders.paymentStatus, paymentProvider: orders.paymentProvider, paymentIntentId: orders.paymentIntentId, + orderStatus: orders.status, shippingStatus: orders.shippingStatus, + shipmentStatus: latestShipmentStatusSql(orders.id), + returnStatus: latestReturnStatusSql(orders.id), trackingNumber: orders.trackingNumber, stockRestored: orders.stockRestored, restockedAt: orders.restockedAt, @@ -189,13 +203,32 @@ export default async function OrderDetailPage({ try { const base = rows[0]!.order; + const fulfillmentStage = deriveCanonicalFulfillmentStage({ + orderStatus: base.orderStatus, + shippingStatus: base.shippingStatus, + shipmentStatus: + typeof base.shipmentStatus === 'string' ? base.shipmentStatus : null, + returnStatus: + typeof base.returnStatus === 'string' ? base.returnStatus : null, + }); const items = rows .map(r => toOrderItem(r.item)) .filter((i): i is NonNullable => i !== null); order = { - ...base, + id: base.id, + userId: base.userId, + totalAmount: base.totalAmount, + currency: base.currency, + paymentStatus: base.paymentStatus, + paymentProvider: base.paymentProvider, + paymentIntentId: base.paymentIntentId, + fulfillmentStage, + shippingStatus: base.shippingStatus, + trackingNumber: base.trackingNumber, + stockRestored: base.stockRestored, + idempotencyKey: base.idempotencyKey, createdAt: base.createdAt.toISOString(), updatedAt: base.updatedAt.toISOString(), restockedAt: base.restockedAt ? base.restockedAt.toISOString() : null, @@ -282,6 +315,15 @@ export default async function OrderDetailPage({
+
+
+ {t('fulfillmentStage')} +
+
+ {t(fulfillmentStageLabelKey(order.fulfillmentStage))} +
+
+
{t('shippingStatus')} diff --git a/frontend/app/api/shop/admin/orders/[id]/shipping/route.ts b/frontend/app/api/shop/admin/orders/[id]/shipping/route.ts index 169df721..f062f564 100644 --- a/frontend/app/api/shop/admin/orders/[id]/shipping/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/shipping/route.ts @@ -26,7 +26,12 @@ export const runtime = 'nodejs'; const payloadSchema = z .object({ - action: z.enum(['retry_label_creation', 'mark_shipped', 'mark_delivered']), + action: z.enum([ + 'recover_initial_shipment', + 'retry_label_creation', + 'mark_shipped', + 'mark_delivered', + ]), }) .strict(); diff --git a/frontend/app/api/shop/orders/[id]/route.ts b/frontend/app/api/shop/orders/[id]/route.ts index f316f542..c23c8c06 100644 --- a/frontend/app/api/shop/orders/[id]/route.ts +++ b/frontend/app/api/shop/orders/[id]/route.ts @@ -9,6 +9,12 @@ import { db } from '@/db'; import { orderItems, orders } from '@/db/schema'; import { getCurrentUser } from '@/lib/auth'; import { logError, logWarn } from '@/lib/logging'; +import { + type CanonicalFulfillmentStage, + deriveCanonicalFulfillmentStage, + latestReturnStatusSql, + latestShipmentStatusSql, +} from '@/lib/services/shop/fulfillment-stage'; import { orderIdParamSchema } from '@/lib/validation/shop'; export const dynamic = 'force-dynamic'; @@ -28,6 +34,7 @@ type OrderDetailResponse = { paymentStatus: OrderPaymentStatus; paymentProvider: string; paymentIntentId: string | null; + fulfillmentStage: CanonicalFulfillmentStage; shippingStatus: string | null; trackingNumber: string | null; stockRestored: boolean; @@ -148,7 +155,10 @@ export async function GET( paymentStatus: orders.paymentStatus, paymentProvider: orders.paymentProvider, paymentIntentId: orders.paymentIntentId, + orderStatus: orders.status, shippingStatus: orders.shippingStatus, + shipmentStatus: latestShipmentStatusSql(orders.id), + returnStatus: latestReturnStatusSql(orders.id), trackingNumber: orders.trackingNumber, stockRestored: orders.stockRestored, restockedAt: orders.restockedAt, @@ -184,13 +194,32 @@ export async function GET( } const base = rows[0]!.order; + const fulfillmentStage = deriveCanonicalFulfillmentStage({ + orderStatus: base.orderStatus, + shippingStatus: base.shippingStatus, + shipmentStatus: + typeof base.shipmentStatus === 'string' ? base.shipmentStatus : null, + returnStatus: + typeof base.returnStatus === 'string' ? base.returnStatus : null, + }); const items = rows .map(r => toOrderItem(r.item)) .filter((i): i is NonNullable => i !== null); const response: OrderDetailResponse = { - ...base, + id: base.id, + userId: base.userId, + totalAmount: base.totalAmount, + currency: base.currency, + paymentStatus: base.paymentStatus, + paymentProvider: base.paymentProvider, + paymentIntentId: base.paymentIntentId, + fulfillmentStage, + shippingStatus: base.shippingStatus, + trackingNumber: base.trackingNumber, + stockRestored: base.stockRestored, + idempotencyKey: base.idempotencyKey, createdAt: base.createdAt.toISOString(), updatedAt: base.updatedAt.toISOString(), restockedAt: base.restockedAt ? base.restockedAt.toISOString() : null, diff --git a/frontend/app/api/shop/shipping/methods/route.ts b/frontend/app/api/shop/shipping/methods/route.ts index 6f86dfa4..dcce7f0d 100644 --- a/frontend/app/api/shop/shipping/methods/route.ts +++ b/frontend/app/api/shop/shipping/methods/route.ts @@ -2,7 +2,11 @@ import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; -import { getShopShippingFlags } from '@/lib/env/nova-poshta'; +import { + assertNovaPoshtaProductionLikeReady, + getShopShippingFlags, + NovaPoshtaConfigError, +} from '@/lib/env/nova-poshta'; import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; import { logError, logWarn } from '@/lib/logging'; import { @@ -208,6 +212,23 @@ export async function GET(request: NextRequest) { ); } + try { + assertNovaPoshtaProductionLikeReady(); + } catch (error) { + if (error instanceof NovaPoshtaConfigError) { + return noStoreJson( + { + success: false, + code: 'NP_MISCONFIG', + message: 'Nova Poshta configuration is invalid', + }, + requestId, + 503 + ); + } + throw error; + } + return cachedJson( { success: true, diff --git a/frontend/lib/env/monobank.ts b/frontend/lib/env/monobank.ts index 1ad397fd..8ce8207f 100644 --- a/frontend/lib/env/monobank.ts +++ b/frontend/lib/env/monobank.ts @@ -1,6 +1,10 @@ import 'server-only'; import { getRuntimeEnv } from '@/lib/env'; +import { + assertProductionLikeProviderString, + assertProductionLikeProviderUrl, +} from '@/lib/env/provider-runtime'; export type MonobankEnv = { token: string | null; @@ -70,6 +74,33 @@ function resolveMonobankToken(): string | null { return nonEmpty(process.env.MONO_MERCHANT_TOKEN); } +function assertMonobankRuntimeConfig(args: { + token: string; + apiBaseUrl: string; + publicKey: string | null; +}) { + assertProductionLikeProviderString({ + provider: 'monobank', + envName: 'MONO_MERCHANT_TOKEN', + value: args.token, + minLength: 8, + }); + assertProductionLikeProviderUrl({ + provider: 'monobank', + envName: 'MONO_API_BASE', + value: args.apiBaseUrl, + }); + + if (args.publicKey) { + assertProductionLikeProviderString({ + provider: 'monobank', + envName: 'MONO_PUBLIC_KEY', + value: args.publicKey, + minLength: 8, + }); + } +} + function resolveBaseUrlSource(): MonobankConfig['baseUrlSource'] { if (nonEmpty(process.env.SHOP_BASE_URL)) return 'shop_base_url'; if (nonEmpty(process.env.APP_ORIGIN)) return 'app_origin'; @@ -82,6 +113,12 @@ export function requireMonobankToken(): string { if (!token) { throw new Error('MONO_MERCHANT_TOKEN is required for Monobank operations.'); } + assertMonobankRuntimeConfig({ + token, + apiBaseUrl: + nonEmpty(process.env.MONO_API_BASE) ?? 'https://api.monobank.ua', + publicKey: nonEmpty(process.env.MONO_PUBLIC_KEY), + }); return token; } @@ -113,6 +150,12 @@ export function getMonobankEnv(): MonobankEnv { }; } + assertMonobankRuntimeConfig({ + token, + apiBaseUrl, + publicKey, + }); + return { token, apiBaseUrl, @@ -123,5 +166,15 @@ export function getMonobankEnv(): MonobankEnv { } export function isMonobankEnabled(): boolean { - return !!resolveMonobankToken(); + const token = resolveMonobankToken(); + if (!token) return false; + + assertMonobankRuntimeConfig({ + token, + apiBaseUrl: + nonEmpty(process.env.MONO_API_BASE) ?? 'https://api.monobank.ua', + publicKey: nonEmpty(process.env.MONO_PUBLIC_KEY), + }); + + return true; } diff --git a/frontend/lib/env/nova-poshta.ts b/frontend/lib/env/nova-poshta.ts index 6eac5ef6..7da87486 100644 --- a/frontend/lib/env/nova-poshta.ts +++ b/frontend/lib/env/nova-poshta.ts @@ -1,5 +1,13 @@ import 'server-only'; +import { + assertProductionLikeProviderPhone, + assertProductionLikeProviderString, + assertProductionLikeProviderUrl, + isProductionLikeRuntime, + ShopProviderConfigError, +} from '@/lib/env/provider-runtime'; + const DEFAULT_NP_API_BASE = 'https://api.novaposhta.ua/v2.0/json/'; function nonEmpty(value: string | undefined): string | null { @@ -48,6 +56,73 @@ export class NovaPoshtaConfigError extends Error { } } +function assertNovaPoshtaRuntimeConfig(args: { + apiBaseUrl: string; + apiKey: string; + sender: { + cityRef: string; + warehouseRef: string; + senderRef: string; + contactRef: string; + name: string; + phone: string; + }; +}) { + try { + assertProductionLikeProviderUrl({ + provider: 'nova_poshta', + envName: 'NP_API_BASE', + value: args.apiBaseUrl, + }); + assertProductionLikeProviderString({ + provider: 'nova_poshta', + envName: 'NP_API_KEY', + value: args.apiKey, + minLength: 8, + }); + assertProductionLikeProviderString({ + provider: 'nova_poshta', + envName: 'NP_SENDER_CITY_REF', + value: args.sender.cityRef, + minLength: 8, + }); + assertProductionLikeProviderString({ + provider: 'nova_poshta', + envName: 'NP_SENDER_WAREHOUSE_REF', + value: args.sender.warehouseRef, + minLength: 8, + }); + assertProductionLikeProviderString({ + provider: 'nova_poshta', + envName: 'NP_SENDER_REF', + value: args.sender.senderRef, + minLength: 8, + }); + assertProductionLikeProviderString({ + provider: 'nova_poshta', + envName: 'NP_SENDER_CONTACT_REF', + value: args.sender.contactRef, + minLength: 8, + }); + assertProductionLikeProviderString({ + provider: 'nova_poshta', + envName: 'NP_SENDER_NAME', + value: args.sender.name, + minLength: 2, + }); + assertProductionLikeProviderPhone({ + provider: 'nova_poshta', + envName: 'NP_SENDER_PHONE', + value: args.sender.phone, + }); + } catch (error) { + if (error instanceof ShopProviderConfigError) { + throw new NovaPoshtaConfigError(error.message); + } + throw error; + } +} + export function getShopShippingFlags(): ShopShippingFlags { const retentionDays = Math.max( 1, @@ -114,6 +189,19 @@ export function getNovaPoshtaConfig(): NovaPoshtaConfig { ); } + assertNovaPoshtaRuntimeConfig({ + apiBaseUrl, + apiKey: apiKey!, + sender: { + cityRef: sender.cityRef!, + warehouseRef: sender.warehouseRef!, + senderRef: sender.senderRef!, + contactRef: sender.contactRef!, + name: sender.name!, + phone: sender.phone!, + }, + }); + return { enabled: true, apiBaseUrl, @@ -131,3 +219,10 @@ export function getNovaPoshtaConfig(): NovaPoshtaConfig { }, }; } + +export function assertNovaPoshtaProductionLikeReady(): void { + const flags = getShopShippingFlags(); + if (!flags.shippingEnabled || !flags.npEnabled) return; + if (!isProductionLikeRuntime()) return; + getNovaPoshtaConfig(); +} diff --git a/frontend/lib/env/payments.ts b/frontend/lib/env/payments.ts index 6e1ccc7e..cdbcf580 100644 --- a/frontend/lib/env/payments.ts +++ b/frontend/lib/env/payments.ts @@ -1,9 +1,16 @@ import { isMonobankEnabled } from '@/lib/env/monobank'; +import { ShopProviderConfigError } from '@/lib/env/provider-runtime'; import { isPaymentsEnabled as isStripeEnabled } from '@/lib/env/stripe'; import type { PaymentProvider } from '@/lib/shop/payments'; export function resolveShopPaymentProvider(): PaymentProvider { - if (isMonobankEnabled()) return 'monobank'; + try { + if (isMonobankEnabled()) return 'monobank'; + } catch (error) { + if (!(error instanceof ShopProviderConfigError)) { + throw error; + } + } if (isStripeEnabled()) return 'stripe'; return 'none'; } diff --git a/frontend/lib/env/provider-runtime.ts b/frontend/lib/env/provider-runtime.ts new file mode 100644 index 00000000..ebe5f798 --- /dev/null +++ b/frontend/lib/env/provider-runtime.ts @@ -0,0 +1,225 @@ +import 'server-only'; + +const PLACEHOLDER_SEGMENTS = new Set([ + 'test', + 'testing', + 'dummy', + 'placeholder', + 'example', + 'sample', + 'fake', + 'mock', + 'demo', + 'local', + 'localhost', + 'staging', + 'sandbox', + 'changeme', + 'replace', + 'todo', + 'invalid', +]); + +type ProviderName = 'stripe' | 'monobank' | 'nova_poshta'; + +type ProviderStringValidationArgs = { + provider: ProviderName; + envName: string; + value: string; + minLength?: number; + requiredPrefix?: string; +}; + +type ProviderUrlValidationArgs = { + provider: ProviderName; + envName: string; + value: string; +}; + +type ProviderPhoneValidationArgs = { + provider: ProviderName; + envName: string; + value: string; +}; + +export class ShopProviderConfigError extends Error { + readonly provider: ProviderName; + readonly envName: string; + + constructor(args: { + provider: ProviderName; + envName: string; + message: string; + }) { + super(args.message); + this.name = 'ShopProviderConfigError'; + this.provider = args.provider; + this.envName = args.envName; + } +} + +export function isProductionLikeRuntime(): boolean { + const appEnv = String(process.env.APP_ENV ?? '') + .trim() + .toLowerCase(); + const nodeEnv = String(process.env.NODE_ENV ?? '') + .trim() + .toLowerCase(); + return appEnv === 'production' || nodeEnv === 'production'; +} + +function splitSegments(value: string): string[] { + return value + .trim() + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +function hasPlaceholderLikeValue(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (!normalized) return true; + + if ( + /^(test|dummy|placeholder|example|sample|fake|mock|demo|changeme|replace|todo|invalid)$/.test( + normalized + ) + ) { + return true; + } + + if ( + /(?:^|[_-])(test|dummy|placeholder|example|sample|fake|mock|demo|local|staging|sandbox|changeme|replace|todo|invalid)(?:[_-]|$)/.test( + normalized + ) + ) { + return true; + } + + return splitSegments(normalized).some(segment => + PLACEHOLDER_SEGMENTS.has(segment) + ); +} + +function throwProviderConfigError( + provider: ProviderName, + envName: string, + reason: string +): never { + throw new ShopProviderConfigError({ + provider, + envName, + message: `${provider} provider config is invalid for production runtime: ${envName} ${reason}`, + }); +} + +export function assertProductionLikeProviderString( + args: ProviderStringValidationArgs +): void { + if (!isProductionLikeRuntime()) return; + + const trimmed = args.value.trim(); + if (!trimmed) { + throwProviderConfigError(args.provider, args.envName, 'must be non-empty.'); + } + + if ((args.minLength ?? 1) > trimmed.length) { + throwProviderConfigError( + args.provider, + args.envName, + `is too short to be a valid production value.` + ); + } + + if (args.requiredPrefix && !trimmed.startsWith(args.requiredPrefix)) { + throwProviderConfigError( + args.provider, + args.envName, + `must start with ${args.requiredPrefix}.` + ); + } + + if (hasPlaceholderLikeValue(trimmed)) { + throwProviderConfigError( + args.provider, + args.envName, + 'looks like a placeholder or test value.' + ); + } +} + +export function assertProductionLikeProviderUrl( + args: ProviderUrlValidationArgs +): void { + if (!isProductionLikeRuntime()) return; + + const trimmed = args.value.trim(); + let url: URL; + try { + url = new URL(trimmed); + } catch { + throwProviderConfigError( + args.provider, + args.envName, + 'must be a valid URL.' + ); + } + + if (url.protocol !== 'https:') { + throwProviderConfigError( + args.provider, + args.envName, + 'must use https in production runtime.' + ); + } + + const host = url.hostname.trim().toLowerCase(); + if ( + host === 'localhost' || + host === '127.0.0.1' || + host === '0.0.0.0' || + host === '::1' || + host.endsWith('.localhost') || + host.endsWith('.test') || + host.endsWith('.example') || + host.endsWith('.invalid') || + host.endsWith('.local') + ) { + throwProviderConfigError( + args.provider, + args.envName, + 'must not point at a local/test host.' + ); + } +} + +export function assertProductionLikeProviderPhone( + args: ProviderPhoneValidationArgs +): void { + if (!isProductionLikeRuntime()) return; + + const digits = args.value.replace(/\D/g, ''); + if (digits.length < 10 || digits.length > 15) { + throwProviderConfigError( + args.provider, + args.envName, + 'must contain a valid production phone number.' + ); + } + + if (/^(\d)\1+$/.test(digits)) { + throwProviderConfigError( + args.provider, + args.envName, + 'must not be a repeated placeholder phone number.' + ); + } + + if (hasPlaceholderLikeValue(args.value)) { + throwProviderConfigError( + args.provider, + args.envName, + 'looks like a placeholder or test value.' + ); + } +} diff --git a/frontend/lib/env/stripe.ts b/frontend/lib/env/stripe.ts index ac5320bf..eb7c772a 100644 --- a/frontend/lib/env/stripe.ts +++ b/frontend/lib/env/stripe.ts @@ -1,4 +1,9 @@ import { getClientEnv, getRuntimeEnv } from '@/lib/env'; +import { + assertProductionLikeProviderString, + isProductionLikeRuntime, + ShopProviderConfigError, +} from '@/lib/env/provider-runtime'; export type StripeEnv = { secretKey: string | null; @@ -47,6 +52,40 @@ export function getStripeEnv(): StripeEnv { }; } + if (isProductionLikeRuntime() && mode !== 'live') { + throw new ShopProviderConfigError({ + provider: 'stripe', + envName: 'STRIPE_MODE', + message: + 'stripe provider config is invalid for production runtime: STRIPE_MODE must be live.', + }); + } + + assertProductionLikeProviderString({ + provider: 'stripe', + envName: 'STRIPE_SECRET_KEY', + value: secretKey, + minLength: 16, + requiredPrefix: 'sk_live_', + }); + assertProductionLikeProviderString({ + provider: 'stripe', + envName: 'STRIPE_WEBHOOK_SECRET', + value: webhookSecret, + minLength: 16, + requiredPrefix: 'whsec_', + }); + + if (publishableKey) { + assertProductionLikeProviderString({ + provider: 'stripe', + envName: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', + value: publishableKey, + minLength: 16, + requiredPrefix: 'pk_live_', + }); + } + return { secretKey, webhookSecret, @@ -71,7 +110,13 @@ function isStripeRailEnabledByFlags(): boolean { export function isRawPaymentsEnabled( options: Pick = {} ): boolean { - const env = getStripeEnv(); + let env: StripeEnv; + try { + env = getStripeEnv(); + } catch (error) { + if (error instanceof ShopProviderConfigError) return false; + throw error; + } if (!env.paymentsEnabled) return false; if (options.requirePublishableKey && !env.publishableKey) { @@ -84,7 +129,13 @@ export function isRawPaymentsEnabled( export function isPaymentsEnabled( options: StripePaymentsEnabledOptions = {} ): boolean { - const env = getStripeEnv(); + let env: StripeEnv; + try { + env = getStripeEnv(); + } catch (error) { + if (error instanceof ShopProviderConfigError) return false; + throw error; + } if (!env.paymentsEnabled) return false; if (!options.ignoreStripePaymentsFlag && !isStripeRailEnabledByFlags()) { diff --git a/frontend/lib/logging.ts b/frontend/lib/logging.ts index b0af111e..5abb4bc8 100644 --- a/frontend/lib/logging.ts +++ b/frontend/lib/logging.ts @@ -1,5 +1,11 @@ type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +import { + sanitizeShopLogError, + sanitizeShopLogMeta, + sanitizeShopLogString, +} from '@/lib/services/shop/logging-redaction'; + const LEVEL_WEIGHT: Record = { debug: 10, info: 20, @@ -46,14 +52,7 @@ function toErrorShape( } function redactJsonStringify(value: unknown): string { - const SENSITIVE_KEY_RE = - /(secret|token|password|authorization|cookie|api[_-]?key)/i; - - return JSON.stringify(value, (key, val) => { - if (typeof key === 'string' && SENSITIVE_KEY_RE.test(key)) - return '[REDACTED]'; - return val; - }); + return JSON.stringify(value); } function emit( @@ -67,9 +66,9 @@ function emit( const payload: Record = { ts: new Date().toISOString(), level, - msg: message, - ...(meta ? { meta } : null), - ...(error ? { err: toErrorShape(error) } : null), + msg: sanitizeShopLogString(message), + ...(meta ? { meta: sanitizeShopLogMeta(meta) } : null), + ...(error ? { err: sanitizeShopLogError(toErrorShape(error)) } : null), }; const line = redactJsonStringify(payload); diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index e78f1912..75269428 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -12,7 +12,11 @@ import { productPrices, products, } from '@/db/schema/shop'; -import { getShopShippingFlags } from '@/lib/env/nova-poshta'; +import { + assertNovaPoshtaProductionLikeReady, + getShopShippingFlags, + NovaPoshtaConfigError, +} from '@/lib/env/nova-poshta'; import { getShopLegalVersions } from '@/lib/env/shop-legal'; import { logError, logWarn } from '@/lib/logging'; import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability'; @@ -398,6 +402,20 @@ async function prepareCheckoutShipping(args: { ); } + try { + assertNovaPoshtaProductionLikeReady(); + } catch (error) { + if (error instanceof NovaPoshtaConfigError) { + throw new InvalidPayloadError( + 'Shipping method is currently unavailable.', + { + code: 'SHIPPING_METHOD_UNAVAILABLE', + } + ); + } + throw error; + } + let authoritativeQuote: CheckoutShippingQuote; try { authoritativeQuote = resolveCheckoutShippingQuote({ diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts index b04d2fea..4ce64620 100644 --- a/frontend/lib/services/orders/monobank-webhook.ts +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -25,9 +25,8 @@ import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state' import { restockOrder } from '@/lib/services/orders/restock'; import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; import { orderShippingEligibilityWhereSql } from '@/lib/services/shop/shipping/eligibility'; -import { - isInventoryCommittedForShipping, -} from '@/lib/services/shop/shipping/inventory-eligibility'; +import { ensureQueuedInitialShipment } from '@/lib/services/shop/shipping/ensure-queued-initial-shipment'; +import { isInventoryCommittedForShipping } from '@/lib/services/shop/shipping/inventory-eligibility'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; import { shippingStatusTransitionWhereSql } from '@/lib/services/shop/transitions/shipping-state'; import { isUuidV1toV5 } from '@/lib/utils/uuid'; @@ -828,68 +827,16 @@ async function ensureQueuedShipmentAndOrderShippingStatus(args: { now: Date; orderId: string; }): Promise<{ insertedShipment: boolean; updatedOrder: boolean }> { - const insertRes = await db.execute(sql` - insert into shipping_shipments ( - order_id, - provider, - status, - attempt_count, - created_at, - updated_at - ) - select - o.id, - 'nova_poshta', - 'queued', - 0, - ${args.now}, - ${args.now} - from orders o - where o.id = ${args.orderId}::uuid - and o.payment_provider = 'monobank' - and o.shipping_required = true - and o.shipping_provider = 'nova_poshta' - and o.shipping_method_code is not null - and ${orderShippingEligibilityWhereSql({ - paymentStatusColumn: sql`o.payment_status`, - orderStatusColumn: sql`o.status`, - inventoryStatusColumn: sql`o.inventory_status`, - pspStatusReasonColumn: sql`o.psp_status_reason`, - })} - on conflict (order_id) do update - set status = 'queued', - updated_at = ${args.now} - where shipping_shipments.provider = 'nova_poshta' - and shipping_shipments.status is distinct from 'queued' -returning order_id - `); - - const insertedShipment = - readDbRows<{ order_id?: string }>(insertRes).length > 0; - - const updateRes = await db.execute(sql` - update orders - set shipping_status = 'queued'::shipping_status, - updated_at = ${args.now} - where id = ${args.orderId}::uuid - and shipping_status is distinct from 'queued'::shipping_status - and ${shippingStatusTransitionWhereSql({ - column: sql`shipping_status`, - to: 'queued', - allowNullFrom: true, - })} - and exists ( - select 1 - from shipping_shipments s - where s.order_id = ${args.orderId}::uuid - and s.status = 'queued' - ) - returning id - `); - - const updatedOrder = readDbRows<{ id?: string }>(updateRes).length > 0; + const ensured = await ensureQueuedInitialShipment({ + now: args.now, + orderId: args.orderId, + paymentProvider: 'monobank', + }); - return { insertedShipment, updatedOrder }; + return { + insertedShipment: ensured.insertedShipment, + updatedOrder: ensured.updatedOrder, + }; } async function atomicFinalizeOrderAndAttempt(args: { diff --git a/frontend/lib/services/orders/payment-intent.ts b/frontend/lib/services/orders/payment-intent.ts index 5ab0113e..7e6f2462 100644 --- a/frontend/lib/services/orders/payment-intent.ts +++ b/frontend/lib/services/orders/payment-intent.ts @@ -10,6 +10,10 @@ import { OrderNotFoundError, OrderStateInvalidError, } from '../errors'; +import { + deriveCanonicalFulfillmentStage, + readCanonicalFulfillmentSignals, +} from '../shop/fulfillment-stage'; import { resolvePaymentProvider } from './_shared'; import { guardedPaymentStatusUpdate } from './payment-state'; import { getOrderItems, parseOrderSummary } from './summary'; @@ -53,8 +57,21 @@ export async function setOrderPaymentIntent({ } if (existing.paymentIntentId === paymentIntentId) { + const fulfillmentSignals = await readCanonicalFulfillmentSignals( + db, + orderId + ); const items = await getOrderItems(orderId); - return parseOrderSummary(existing, items); + return parseOrderSummary( + existing, + items, + deriveCanonicalFulfillmentStage({ + orderStatus: existing.status, + shippingStatus: existing.shippingStatus, + shipmentStatus: fulfillmentSignals.shipmentStatus, + returnStatus: fulfillmentSignals.returnStatus, + }) + ); } const res = await guardedPaymentStatusUpdate({ @@ -83,8 +100,18 @@ export async function setOrderPaymentIntent({ if (!updated) throw new OrderNotFoundError('Order not found'); + const fulfillmentSignals = await readCanonicalFulfillmentSignals(db, orderId); const items = await getOrderItems(orderId); - return parseOrderSummary(updated, items); + return parseOrderSummary( + updated, + items, + deriveCanonicalFulfillmentStage({ + orderStatus: updated.status, + shippingStatus: updated.shippingStatus, + shipmentStatus: fulfillmentSignals.shipmentStatus, + returnStatus: fulfillmentSignals.returnStatus, + }) + ); } export async function readStripePaymentIntentParams(orderId: string): Promise<{ diff --git a/frontend/lib/services/orders/summary.ts b/frontend/lib/services/orders/summary.ts index 66876cb0..846fbeb8 100644 --- a/frontend/lib/services/orders/summary.ts +++ b/frontend/lib/services/orders/summary.ts @@ -7,6 +7,12 @@ import { paymentAttempts, products, } from '@/db/schema/shop'; +import { + deriveCanonicalFulfillmentStage, + latestReturnStatusSql, + latestShipmentStatusSql, + readCanonicalFulfillmentSignals, +} from '@/lib/services/shop/fulfillment-stage'; import { fromCents, fromDbMoney } from '@/lib/shop/money'; import { type OrderDetail, type OrderSummaryWithMinor } from '@/lib/types/shop'; @@ -49,9 +55,22 @@ export const orderItemSummarySelection = { >`coalesce(${orderItems.productSlug}, ${products.slug})`, }; +type OrderSummaryRow = Pick< + OrderRow, + | 'id' + | 'totalAmountMinor' + | 'totalAmount' + | 'currency' + | 'paymentStatus' + | 'paymentProvider' + | 'paymentIntentId' + | 'createdAt' +>; + export function parseOrderSummary( - order: OrderRow, - items: OrderItemForSummary[] + order: OrderSummaryRow, + items: OrderItemForSummary[], + fulfillmentStage: OrderSummaryWithMinor['fulfillmentStage'] ): OrderSummaryWithMinor { function readLegacyMoneyCentsOrThrow( value: unknown, @@ -132,6 +151,7 @@ export function parseOrderSummary( totalAmount: fromCents(totalAmountMinor), currency: order.currency, paymentStatus: order.paymentStatus, + fulfillmentStage, paymentProvider, paymentIntentId: order.paymentIntentId ?? undefined, createdAt: order.createdAt, @@ -149,14 +169,35 @@ export async function getOrderItems(orderId: string) { export async function getOrderById(id: string): Promise { const [order] = await db - .select() + .select({ + id: orders.id, + totalAmountMinor: orders.totalAmountMinor, + totalAmount: orders.totalAmount, + currency: orders.currency, + paymentStatus: orders.paymentStatus, + paymentProvider: orders.paymentProvider, + paymentIntentId: orders.paymentIntentId, + createdAt: orders.createdAt, + orderStatus: orders.status, + shippingStatus: orders.shippingStatus, + }) .from(orders) .where(eq(orders.id, id)) .limit(1); if (!order) throw new OrderNotFoundError('Order not found'); + const fulfillmentSignals = await readCanonicalFulfillmentSignals(db, id); const items = await getOrderItems(id); - return parseOrderSummary(order, items); + return parseOrderSummary( + order, + items, + deriveCanonicalFulfillmentStage({ + orderStatus: order.orderStatus, + shippingStatus: order.shippingStatus, + shipmentStatus: fulfillmentSignals.shipmentStatus, + returnStatus: fulfillmentSignals.returnStatus, + }) + ); } export async function getOrderSummary( @@ -255,6 +296,7 @@ export async function getCheckoutPaymentPageOrderSummary(args: { export type OrderStatusLiteSummary = { id: string; paymentStatus: string; + fulfillmentStage: OrderSummaryWithMinor['fulfillmentStage']; totalAmountMinor: number; currency: string; itemsCount: number; @@ -268,10 +310,14 @@ export async function getOrderStatusLiteSummary( .select({ id: orders.id, paymentStatus: orders.paymentStatus, + orderStatus: orders.status, + shippingStatus: orders.shippingStatus, totalAmountMinor: orders.totalAmountMinor, totalAmount: orders.totalAmount, currency: orders.currency, updatedAt: orders.updatedAt, + shipmentStatus: latestShipmentStatusSql(orders.id), + returnStatus: latestReturnStatusSql(orders.id), itemsCount: sql`count(${orderItems.id})::int`, }) .from(orders) @@ -280,6 +326,8 @@ export async function getOrderStatusLiteSummary( .groupBy( orders.id, orders.paymentStatus, + orders.status, + orders.shippingStatus, orders.totalAmountMinor, orders.totalAmount, orders.currency, @@ -301,6 +349,14 @@ export async function getOrderStatusLiteSummary( return { id: row.id, paymentStatus: row.paymentStatus, + fulfillmentStage: deriveCanonicalFulfillmentStage({ + orderStatus: row.orderStatus, + shippingStatus: row.shippingStatus, + shipmentStatus: + typeof row.shipmentStatus === 'string' ? row.shipmentStatus : null, + returnStatus: + typeof row.returnStatus === 'string' ? row.returnStatus : null, + }), totalAmountMinor, currency: row.currency, itemsCount: Number.isFinite(row.itemsCount) ? row.itemsCount : 0, @@ -383,5 +439,15 @@ export async function getOrderByIdempotencyKey( .leftJoin(products, eq(orderItems.productId, products.id)) .where(eq(orderItems.orderId, order.id)); - return parseOrderSummary(order, items); + // This checkout-time recovery path intentionally omits shipment/return signal + // lookups because it is used before shipments/returns exist; future reuse + // outside checkout should prefer getOrderById()/getOrderSummary(). + return parseOrderSummary( + order, + items, + deriveCanonicalFulfillmentStage({ + orderStatus: order.status, + shippingStatus: order.shippingStatus, + }) + ); } diff --git a/frontend/lib/services/shop/fulfillment-stage.ts b/frontend/lib/services/shop/fulfillment-stage.ts new file mode 100644 index 00000000..e22c472a --- /dev/null +++ b/frontend/lib/services/shop/fulfillment-stage.ts @@ -0,0 +1,137 @@ +import 'server-only'; + +import { desc, eq, type SQL, sql, type SQLWrapper } from 'drizzle-orm'; + +import { db } from '@/db'; +import { returnRequests, shippingShipments } from '@/db/schema/shop'; +import { + type CanonicalFulfillmentStage as ValidationCanonicalFulfillmentStage, + canonicalFulfillmentStageValues, +} from '@/lib/validation/shop'; + +export const CANONICAL_FULFILLMENT_STAGES = canonicalFulfillmentStageValues; + +export type CanonicalFulfillmentStage = ValidationCanonicalFulfillmentStage; + +export type CanonicalFulfillmentStageInput = { + orderStatus?: string | null | undefined; + shippingStatus?: string | null | undefined; + shipmentStatus?: string | null | undefined; + returnStatus?: string | null | undefined; +}; + +export type FulfillmentStageSignals = { + shipmentStatus: string | null; + returnStatus: string | null; +}; + +export function latestShipmentStatusSql(orderIdColumn: SQLWrapper): SQL { + return sql`( + select s.status::text + from shipping_shipments s + where s.order_id = ${orderIdColumn} + order by s.created_at desc nulls last + limit 1 + )`; +} + +export function latestReturnStatusSql(orderIdColumn: SQLWrapper): SQL { + return sql`( + select rr.status::text + from return_requests rr + where rr.order_id = ${orderIdColumn} + order by rr.created_at desc nulls last + limit 1 + )`; +} + +function normalizeStatus(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isPackedShippingStatus(value: string | null): boolean { + return ( + value === 'queued' || + value === 'creating_label' || + value === 'label_created' || + value === 'needs_attention' + ); +} + +function isPackedShipmentStatus(value: string | null): boolean { + return ( + value === 'queued' || + value === 'processing' || + value === 'succeeded' || + value === 'failed' || + value === 'needs_attention' + ); +} + +function isReturnedStatus(value: string | null): boolean { + return value === 'received' || value === 'refunded'; +} + +function isCanceledOrderStatus(value: string | null): boolean { + return value === 'CANCELED' || value === 'INVENTORY_FAILED'; +} + +export async function readCanonicalFulfillmentSignals( + dbClient: typeof db, + orderId: string +): Promise { + const [shipmentRow] = await dbClient + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)) + .orderBy(desc(shippingShipments.createdAt)) + .limit(1); + + const [returnRow] = await dbClient + .select({ status: returnRequests.status }) + .from(returnRequests) + .where(eq(returnRequests.orderId, orderId)) + .orderBy(desc(returnRequests.createdAt)) + .limit(1); + + return { + shipmentStatus: shipmentRow?.status ?? null, + returnStatus: returnRow?.status ?? null, + }; +} + +export function deriveCanonicalFulfillmentStage( + input: CanonicalFulfillmentStageInput +): CanonicalFulfillmentStage { + const orderStatus = normalizeStatus(input.orderStatus); + const shippingStatus = normalizeStatus(input.shippingStatus); + const shipmentStatus = normalizeStatus(input.shipmentStatus); + const returnStatus = normalizeStatus(input.returnStatus); + + if (isReturnedStatus(returnStatus)) { + return 'returned'; + } + + if (isCanceledOrderStatus(orderStatus) || shippingStatus === 'cancelled') { + return 'canceled'; + } + + if (shippingStatus === 'delivered') { + return 'delivered'; + } + + if (shippingStatus === 'shipped') { + return 'shipped'; + } + + if ( + isPackedShippingStatus(shippingStatus) || + isPackedShipmentStatus(shipmentStatus) + ) { + return 'packed'; + } + + return 'processing'; +} diff --git a/frontend/lib/services/shop/logging-redaction.ts b/frontend/lib/services/shop/logging-redaction.ts new file mode 100644 index 00000000..e9ffda15 --- /dev/null +++ b/frontend/lib/services/shop/logging-redaction.ts @@ -0,0 +1,129 @@ +const EMAIL_KEY_RE = /(^|[_-])(email|e-mail)([_-]|$)/i; +const PHONE_KEY_RE = + /(^|[_-])(phone|mobile|tel|telephone|recipientphone|sendersphone|recipientsphone)([_-]|$)/i; +const ADDRESS_KEY_RE = + /(^|[_-])(address|shippingaddress|billingaddress|addressline1|addressline2|line1|line2|street|streetaddress)([_-]|$)/i; +const SECRET_KEY_RE = + /(^|[_-])(secret|token|password|authorization|cookie|apikey|api_key|clientsecret|webhooksecret|statustoken|signature|xsign|bearer)([_-]|$)/i; + +const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi; +const BEARER_RE = /\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b/gi; +const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\b/g; +const STRIPE_SECRET_RE = /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]+\b/g; +const STRIPE_WEBHOOK_SECRET_RE = /\bwhsec_[A-Za-z0-9]+\b/g; +const TOKENISH_VALUE_RE = + /\b(?:tok(?:en)?|secret|status[_-]?token)[._-][A-Za-z0-9._-]+\b/gi; +const PHONE_CANDIDATE_RE = /(?= 10 && digits.length <= 15; +} + +function redactPhones(value: string): string { + return value.replace(PHONE_CANDIDATE_RE, match => + phoneLike(match) ? '[REDACTED_PHONE]' : match + ); +} + +export function sanitizeShopLogString(value: string): string { + return redactPhones( + value + .replace(EMAIL_RE, '[REDACTED_EMAIL]') + .replace(BEARER_RE, 'Bearer [REDACTED_SECRET]') + .replace(JWT_RE, '[REDACTED_SECRET]') + .replace(STRIPE_SECRET_RE, '[REDACTED_SECRET]') + .replace(STRIPE_WEBHOOK_SECRET_RE, '[REDACTED_SECRET]') + .replace(TOKENISH_VALUE_RE, '[REDACTED_SECRET]') + ); +} + +function classifyKey(key: string): RedactionKind | null { + if (SECRET_KEY_RE.test(key)) return 'secret'; + if (EMAIL_KEY_RE.test(key)) return 'email'; + if (PHONE_KEY_RE.test(key)) return 'phone'; + if (ADDRESS_KEY_RE.test(key)) return 'address'; + return null; +} + +function sanitizeByKind(kind: RedactionKind, value: unknown): unknown { + if (value === null || value === undefined) return value; + + if (kind === 'email') return '[REDACTED_EMAIL]'; + if (kind === 'phone') return '[REDACTED_PHONE]'; + if (kind === 'address') return '[REDACTED_ADDRESS]'; + return '[REDACTED_SECRET]'; +} + +export function sanitizeShopLogValue( + value: unknown, + depth = 0, + parentKey?: string +): unknown { + if (depth > MAX_DEPTH) return '[TRUNCATED]'; + + if (parentKey) { + const kind = classifyKey(parentKey); + if (kind) return sanitizeByKind(kind, value); + } + + if (typeof value === 'string') return sanitizeShopLogString(value); + if ( + typeof value === 'number' || + typeof value === 'boolean' || + value === null || + value === undefined + ) { + return value; + } + + if (Array.isArray(value)) { + return value + .slice(0, MAX_ARRAY_ITEMS) + .map(item => sanitizeShopLogValue(item, depth + 1)); + } + + if (value instanceof Date) return value.toISOString(); + + if (typeof value === 'object') { + const out: Record = {}; + for (const [key, nested] of Object.entries( + value as Record + )) { + out[key] = sanitizeShopLogValue(nested, depth + 1, key); + } + return out; + } + + return String(value); +} + +export function sanitizeShopLogMeta( + meta?: Record +): Record | undefined { + if (!meta) return undefined; + + const sanitized = sanitizeShopLogValue(meta, 0); + if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) { + return undefined; + } + + return sanitized as Record; +} + +export function sanitizeShopLogError( + error: { name?: string; message: string; stack?: string } | null +): { name?: string; message: string; stack?: string } | null { + if (!error) return null; + + return { + ...(error.name ? { name: error.name } : null), + message: sanitizeShopLogString(error.message), + ...(error.stack ? { stack: sanitizeShopLogString(error.stack) } : null), + }; +} diff --git a/frontend/lib/services/shop/shipping/admin-actions.ts b/frontend/lib/services/shop/shipping/admin-actions.ts index dcf116b8..48899b95 100644 --- a/frontend/lib/services/shop/shipping/admin-actions.ts +++ b/frontend/lib/services/shop/shipping/admin-actions.ts @@ -6,6 +6,7 @@ import { db } from '@/db'; import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; import { buildAdminAuditDedupeKey } from '@/lib/services/shop/events/dedupe-key'; import { evaluateOrderShippingEligibility } from '@/lib/services/shop/shipping/eligibility'; +import { ensureQueuedInitialShipment } from '@/lib/services/shop/shipping/ensure-queued-initial-shipment'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; import { isShippingStatusTransitionAllowed, @@ -13,6 +14,7 @@ import { } from '@/lib/services/shop/transitions/shipping-state'; export type ShippingAdminAction = + | 'recover_initial_shipment' | 'retry_label_creation' | 'mark_shipped' | 'mark_delivered'; @@ -499,6 +501,90 @@ export async function applyShippingAdminAction(args: { const now = new Date(); const nowIso = now.toISOString(); + if (args.action === 'recover_initial_shipment') { + if (state.shipment_id) { + if (state.shipment_status !== 'queued') { + throw new ShippingAdminActionError( + 'SHIPMENT_ALREADY_EXISTS', + 'Shipment record already exists for this order.', + 409 + ); + } + } + + if ( + !isShippingStatusTransitionAllowed(state.shipping_status, 'queued', { + allowNullFrom: true, + includeSame: true, + }) + ) { + throw new ShippingAdminActionError( + 'INVALID_SHIPPING_TRANSITION', + `recover_initial_shipment is not allowed from ${state.shipping_status ?? 'null'}.`, + 409 + ); + } + + const ensured = await ensureQueuedInitialShipment({ + now, + orderId: args.orderId, + }); + + if (!ensured.queuedShipment) { + throw new ShippingAdminActionError( + 'SHIPMENT_RECOVERY_NOT_ALLOWED', + 'Initial shipment cannot be recovered in current state.', + 409 + ); + } + + await appendAuditEntry({ + orderId: args.orderId, + entry: { + action: args.action, + actorUserId: args.actorUserId, + requestId: args.requestId, + fromShippingStatus: state.shipping_status, + toShippingStatus: 'queued', + fromShipmentStatus: state.shipment_status, + at: nowIso, + }, + canonical: { + enabled: canonicalDualWriteEnabled, + dedupeKey: buildShippingAdminAuditDedupe({ + orderId: args.orderId, + requestId: args.requestId, + action: args.action, + fromShippingStatus: state.shipping_status, + toShippingStatus: 'queued', + fromShipmentStatus: state.shipment_status, + }), + occurredAt: now, + }, + }); + + if (ensured.insertedShipment || ensured.updatedOrder) { + recordShippingMetric({ + name: 'queued', + source: 'admin_action', + orderId: args.orderId, + requestId: args.requestId, + }); + } + + return { + orderId: state.order_id, + shippingStatus: + ensured.updatedOrder || state.shipping_status === 'queued' + ? 'queued' + : state.shipping_status, + trackingNumber: state.tracking_number, + shipmentStatus: 'queued', + changed: ensured.insertedShipment || ensured.updatedOrder, + action: args.action, + }; + } + if (args.action === 'retry_label_creation') { if (!state.shipment_id) { throw new ShippingAdminActionError( diff --git a/frontend/lib/services/shop/shipping/ensure-queued-initial-shipment.ts b/frontend/lib/services/shop/shipping/ensure-queued-initial-shipment.ts new file mode 100644 index 00000000..b85188f8 --- /dev/null +++ b/frontend/lib/services/shop/shipping/ensure-queued-initial-shipment.ts @@ -0,0 +1,114 @@ +import 'server-only'; + +import { type SQL, sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { orderShippingEligibilityWhereSql } from '@/lib/services/shop/shipping/eligibility'; +import { shippingStatusTransitionWhereSql } from '@/lib/services/shop/transitions/shipping-state'; + +type SupportedPaymentProvider = 'monobank' | 'stripe'; + +type CountRow = { + inserted_shipment_count?: number; + queued_shipment_count?: number; + updated_order_count?: number; +}; + +export type EnsureQueuedInitialShipmentResult = { + insertedShipment: boolean; + queuedShipment: boolean; + updatedOrder: boolean; +}; + +function readRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const maybe = res as { rows?: unknown }; + if (Array.isArray(maybe.rows)) return maybe.rows as T[]; + return []; +} + +function paymentProviderFilterSql( + paymentProvider: SupportedPaymentProvider | null | undefined +): SQL { + return paymentProvider + ? sql`and o.payment_provider = ${paymentProvider}` + : sql``; +} + +export async function ensureQueuedInitialShipment(args: { + now: Date; + orderId: string; + paymentProvider?: SupportedPaymentProvider | null; +}): Promise { + const res = await db.execute(sql` + with eligible_order as ( + select o.id + from orders o + where o.id = ${args.orderId}::uuid + ${paymentProviderFilterSql(args.paymentProvider)} + and o.shipping_required = true + and o.shipping_provider = 'nova_poshta' + and o.shipping_method_code is not null + and ${orderShippingEligibilityWhereSql({ + paymentStatusColumn: sql`o.payment_status`, + orderStatusColumn: sql`o.status`, + inventoryStatusColumn: sql`o.inventory_status`, + pspStatusReasonColumn: sql`o.psp_status_reason`, + })} + ), + inserted_shipment as ( + insert into shipping_shipments ( + order_id, + provider, + status, + attempt_count, + created_at, + updated_at + ) + select + eo.id, + 'nova_poshta', + 'queued', + 0, + ${args.now}, + ${args.now} + from eligible_order eo + on conflict (order_id) do nothing + returning order_id + ), + queued_order_ids as ( + select order_id from inserted_shipment + union + select s.order_id + from shipping_shipments s + where s.order_id in (select id from eligible_order) + and s.provider = 'nova_poshta' + and s.status = 'queued' + ), + updated_order as ( + update orders + set shipping_status = 'queued'::shipping_status, + updated_at = ${args.now} + where id in (select order_id from queued_order_ids) + and shipping_status is distinct from 'queued'::shipping_status + and ${shippingStatusTransitionWhereSql({ + column: sql`shipping_status`, + to: 'queued', + allowNullFrom: true, + })} + returning id + ) + select + (select count(*)::int from inserted_shipment) as inserted_shipment_count, + (select count(*)::int from queued_order_ids) as queued_shipment_count, + (select count(*)::int from updated_order) as updated_order_count + `); + + const row = readRows(res)[0]; + + return { + insertedShipment: Number(row?.inserted_shipment_count ?? 0) > 0, + queuedShipment: Number(row?.queued_shipment_count ?? 0) > 0, + updatedOrder: Number(row?.updated_order_count ?? 0) > 0, + }; +} diff --git a/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts b/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts index 543e6516..de6db243 100644 --- a/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts +++ b/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts @@ -17,6 +17,158 @@ async function cleanup(orderId: string) { } describe.sequential('admin shipping action canonical audit', () => { + it('recover_initial_shipment inserts admin_audit_log row and queues missing shipment', async () => { + const orderId = crypto.randomUUID(); + const requestId = `req_${crypto.randomUUID()}`; + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingAmountMinor: null, + shippingStatus: 'pending', + idempotencyKey: crypto.randomUUID(), + } as any); + + try { + const result = await applyShippingAdminAction({ + orderId, + action: 'recover_initial_shipment', + actorUserId: null, + requestId, + }); + + expect(result.changed).toBe(true); + expect(result.shippingStatus).toBe('queued'); + expect(result.shipmentStatus).toBe('queued'); + + const shipments = await db + .select({ + id: shippingShipments.id, + status: shippingShipments.status, + }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + + expect(shipments).toHaveLength(1); + expect(shipments[0]?.status).toBe('queued'); + + const logs = await db + .select({ + id: adminAuditLog.id, + action: adminAuditLog.action, + requestId: adminAuditLog.requestId, + orderId: adminAuditLog.orderId, + }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, orderId)); + + expect(logs.length).toBe(1); + expect(logs[0]?.action).toBe( + 'shipping_admin_action.recover_initial_shipment' + ); + expect(logs[0]?.requestId).toBe(requestId); + expect(logs[0]?.orderId).toBe(orderId); + } finally { + await cleanup(orderId); + } + }); + + it('recover_initial_shipment audits queued-row drift repair and resynchronizes order shipping status', async () => { + const orderId = crypto.randomUUID(); + const shipmentId = crypto.randomUUID(); + const requestId = `req_${crypto.randomUUID()}`; + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingAmountMinor: null, + shippingStatus: 'pending', + idempotencyKey: crypto.randomUUID(), + } as any); + + await db.insert(shippingShipments).values({ + id: shipmentId, + orderId, + provider: 'nova_poshta', + status: 'queued', + attemptCount: 0, + leaseOwner: null, + leaseExpiresAt: null, + nextAttemptAt: null, + } as any); + + try { + const result = await applyShippingAdminAction({ + orderId, + action: 'recover_initial_shipment', + actorUserId: null, + requestId, + }); + + expect(result.changed).toBe(true); + expect(result.shippingStatus).toBe('queued'); + expect(result.shipmentStatus).toBe('queued'); + + const shipments = await db + .select({ + id: shippingShipments.id, + status: shippingShipments.status, + }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + + expect(shipments).toHaveLength(1); + expect(shipments[0]?.id).toBe(shipmentId); + expect(shipments[0]?.status).toBe('queued'); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('queued'); + + const logs = await db + .select({ + id: adminAuditLog.id, + action: adminAuditLog.action, + requestId: adminAuditLog.requestId, + orderId: adminAuditLog.orderId, + }) + .from(adminAuditLog) + .where(eq(adminAuditLog.orderId, orderId)); + + expect(logs.length).toBe(1); + expect(logs[0]?.action).toBe( + 'shipping_admin_action.recover_initial_shipment' + ); + expect(logs[0]?.requestId).toBe(requestId); + expect(logs[0]?.orderId).toBe(orderId); + } finally { + await cleanup(orderId); + } + }); + it('mark_shipped inserts admin_audit_log row by default', async () => { const orderId = crypto.randomUUID(); const shipmentId = crypto.randomUUID(); diff --git a/frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts b/frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts index b58c93fa..26a46f0f 100644 --- a/frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts +++ b/frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts @@ -8,7 +8,11 @@ import { adminAuditLog, orders, shippingShipments } from '@/db/schema'; import { applyShippingAdminAction } from '@/lib/services/shop/shipping/admin-actions'; import { toDbMoney } from '@/lib/shop/money'; -type Action = 'retry_label_creation' | 'mark_shipped' | 'mark_delivered'; +type Action = + | 'recover_initial_shipment' + | 'retry_label_creation' + | 'mark_shipped' + | 'mark_delivered'; type SeedArgs = { action: Action; @@ -38,13 +42,24 @@ type SeedArgs = { type Seeded = { orderId: string; shipmentId: string | null; - shippingStatus: 'needs_attention' | 'label_created' | 'shipped' | 'cancelled'; + shippingStatus: + | 'pending' + | 'needs_attention' + | 'label_created' + | 'shipped' + | 'cancelled'; shipmentStatus: 'failed' | 'needs_attention' | 'succeeded' | null; }; function defaultStateForAction( action: Action ): Pick { + if (action === 'recover_initial_shipment') { + return { + shippingStatus: 'pending', + shipmentStatus: null, + }; + } if (action === 'retry_label_creation') { return { shippingStatus: 'needs_attention', @@ -202,6 +217,7 @@ describe.sequential('admin shipping action payment gate', () => { ]; const actions: readonly Action[] = [ + 'recover_initial_shipment', 'mark_shipped', 'mark_delivered', 'retry_label_creation', diff --git a/frontend/lib/tests/shop/admin-shipping-state-sync.test.ts b/frontend/lib/tests/shop/admin-shipping-state-sync.test.ts index 2269259f..a014de75 100644 --- a/frontend/lib/tests/shop/admin-shipping-state-sync.test.ts +++ b/frontend/lib/tests/shop/admin-shipping-state-sync.test.ts @@ -6,13 +6,14 @@ import { afterEach, describe, expect, it } from 'vitest'; import { db } from '@/db'; import { adminAuditLog, orders, shippingShipments } from '@/db/schema'; import { applyShippingAdminAction } from '@/lib/services/shop/shipping/admin-actions'; +import { ensureQueuedInitialShipment } from '@/lib/services/shop/shipping/ensure-queued-initial-shipment'; import { toDbMoney } from '@/lib/shop/money'; -type Action = 'mark_shipped' | 'mark_delivered'; +type Action = 'recover_initial_shipment' | 'mark_shipped' | 'mark_delivered'; type SeedArgs = { action: Action; - shippingStatus: 'label_created' | 'shipped'; + shippingStatus: 'pending' | 'queued' | 'label_created' | 'shipped'; shipmentStatus?: | 'queued' | 'processing' @@ -83,6 +84,191 @@ async function seedOrder(args: SeedArgs): Promise { } describe.sequential('admin shipping action state sync', () => { + it('ensureQueuedInitialShipment does not regress an advanced shipment back to queued', async () => { + const seed = await seedOrder({ + action: 'recover_initial_shipment', + shippingStatus: 'pending', + shipmentStatus: 'processing', + }); + + const result = await ensureQueuedInitialShipment({ + now: new Date(), + orderId: seed.orderId, + }); + + expect(result).toEqual({ + insertedShipment: false, + queuedShipment: false, + updatedOrder: false, + }); + + const [shipmentRow] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)) + .limit(1); + expect(shipmentRow?.status).toBe('processing'); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('pending'); + }); + + it('recover_initial_shipment creates a queued shipment row when missing', async () => { + const seed = await seedOrder({ + action: 'recover_initial_shipment', + shippingStatus: 'pending', + }); + + const result = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'recover_initial_shipment', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + + expect(result.changed).toBe(true); + expect(result.shippingStatus).toBe('queued'); + expect(result.shipmentStatus).toBe('queued'); + + const shipmentRows = await db + .select({ + id: shippingShipments.id, + status: shippingShipments.status, + }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)); + + expect(shipmentRows).toHaveLength(1); + expect(shipmentRows[0]?.status).toBe('queued'); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('queued'); + }); + + it('recover_initial_shipment repairs queued shipment/order drift without creating duplicates', async () => { + const seed = await seedOrder({ + action: 'recover_initial_shipment', + shippingStatus: 'pending', + shipmentStatus: 'queued', + }); + + const first = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'recover_initial_shipment', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + + expect(first.changed).toBe(true); + expect(first.shippingStatus).toBe('queued'); + expect(first.shipmentStatus).toBe('queued'); + + const second = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'recover_initial_shipment', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + + expect(second.changed).toBe(false); + expect(second.shippingStatus).toBe('queued'); + expect(second.shipmentStatus).toBe('queued'); + + const shipmentRows = await db + .select({ + id: shippingShipments.id, + status: shippingShipments.status, + }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)); + + expect(shipmentRows).toHaveLength(1); + expect(shipmentRows[0]?.status).toBe('queued'); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('queued'); + }); + + it('repeated recover_initial_shipment is deduped once shipment row is queued', async () => { + const seed = await seedOrder({ + action: 'recover_initial_shipment', + shippingStatus: 'pending', + }); + + const first = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'recover_initial_shipment', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + expect(first.changed).toBe(true); + expect(first.shipmentStatus).toBe('queued'); + + const second = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'recover_initial_shipment', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + expect(second.changed).toBe(false); + expect(second.shipmentStatus).toBe('queued'); + + const shipmentRows = await db + .select({ + id: shippingShipments.id, + status: shippingShipments.status, + }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)); + + expect(shipmentRows).toHaveLength(1); + expect(shipmentRows[0]?.status).toBe('queued'); + }); + + it('recover_initial_shipment rejects when a shipment row already exists', async () => { + const seed = await seedOrder({ + action: 'recover_initial_shipment', + shippingStatus: 'queued', + shipmentStatus: 'processing', + }); + + await expect( + applyShippingAdminAction({ + orderId: seed.orderId, + action: 'recover_initial_shipment', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + name: 'ShippingAdminActionError', + code: 'SHIPMENT_ALREADY_EXISTS', + status: 409, + }); + + const shipmentRows = await db + .select({ + id: shippingShipments.id, + status: shippingShipments.status, + }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)); + + expect(shipmentRows).toHaveLength(1); + expect(shipmentRows[0]?.status).toBe('processing'); + }); + it('mark_shipped rejects when shipment row is missing', async () => { const seed = await seedOrder({ action: 'mark_shipped', 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 aec9a08a..6a793caf 100644 --- a/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts +++ b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts @@ -61,7 +61,6 @@ vi.mock('@/lib/env/stripe', () => ({ })); vi.mock('@/lib/services/orders/payment-attempts', async () => { - resetEnvCache(); const actual = await vi.importActual( '@/lib/services/orders/payment-attempts' ); @@ -445,6 +444,55 @@ describe('checkout authoritative shipping totals', () => { expect(orderRow).toBeFalsy(); }); + it('fails closed in production-like runtime when NP provider config is placeholder and creates no order', async () => { + const seed = await seedShippingCheckoutData(); + const quote = await rehydrateCartItems( + [{ productId: seed.productId, quantity: 1 }], + 'UAH' + ); + const warehouseMethod = await fetchWarehouseMethodQuote(); + const pricingFingerprint = quote.summary.pricingFingerprint; + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + vi.stubEnv('APP_ENV', 'production'); + 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'); + vi.stubEnv('NP_SENDER_WAREHOUSE_REF', 'test-warehouse-ref'); + vi.stubEnv('NP_SENDER_REF', 'test-sender-ref'); + vi.stubEnv('NP_SENDER_CONTACT_REF', 'test-contact-ref'); + vi.stubEnv('NP_SENDER_NAME', 'Test Sender'); + vi.stubEnv('NP_SENDER_PHONE', '0000000000'); + 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.'); + + const [orderRow] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toBeFalsy(); + }); + it('rejects client-supplied payable totals and creates no order', async () => { const seed = await seedShippingCheckoutData(); const quote = await rehydrateCartItems( diff --git a/frontend/lib/tests/shop/fulfillment-stage.test.ts b/frontend/lib/tests/shop/fulfillment-stage.test.ts new file mode 100644 index 00000000..cb7e3a3f --- /dev/null +++ b/frontend/lib/tests/shop/fulfillment-stage.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveCanonicalFulfillmentStage } from '@/lib/services/shop/fulfillment-stage'; +import { + canonicalFulfillmentStageValues, + fulfillmentStageSchema, +} from '@/lib/validation/shop'; + +describe('canonical fulfillment stage mapping', () => { + it('uses one shared canonical fulfillment stage value set for schema validation', () => { + expect(canonicalFulfillmentStageValues).toEqual([ + 'processing', + 'packed', + 'shipped', + 'delivered', + 'canceled', + 'returned', + ]); + + for (const value of canonicalFulfillmentStageValues) { + expect(fulfillmentStageSchema.parse(value)).toBe(value); + } + + expect(() => fulfillmentStageSchema.parse('mystery')).toThrow(); + }); + + it('maps pre-shipment orders to processing by default', () => { + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'CREATED', + shippingStatus: 'pending', + shipmentStatus: null, + returnStatus: null, + }) + ).toBe('processing'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: null, + shipmentStatus: null, + returnStatus: null, + }) + ).toBe('processing'); + }); + + it('maps queued and label states to the explicit packed stage', () => { + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'queued', + shipmentStatus: 'queued', + }) + ).toBe('packed'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'creating_label', + shipmentStatus: 'processing', + }) + ).toBe('packed'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'label_created', + shipmentStatus: 'succeeded', + }) + ).toBe('packed'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'needs_attention', + shipmentStatus: 'failed', + }) + ).toBe('packed'); + }); + + it('maps shipment-row edge cases to packed deterministically even if order shipping status lags', () => { + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'pending', + shipmentStatus: 'succeeded', + returnStatus: null, + }) + ).toBe('packed'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: null, + shipmentStatus: 'processing', + returnStatus: null, + }) + ).toBe('packed'); + }); + + it('maps shipped and delivered explicitly', () => { + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'shipped', + shipmentStatus: 'succeeded', + }) + ).toBe('shipped'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'delivered', + shipmentStatus: 'succeeded', + }) + ).toBe('delivered'); + }); + + it('maps canceled explicitly from order or shipping terminal states', () => { + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'CANCELED', + shippingStatus: 'pending', + shipmentStatus: null, + }) + ).toBe('canceled'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'cancelled', + shipmentStatus: 'queued', + }) + ).toBe('canceled'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'INVENTORY_FAILED', + shippingStatus: null, + shipmentStatus: null, + }) + ).toBe('canceled'); + }); + + it('maps returned explicitly from terminal return states', () => { + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'delivered', + shipmentStatus: 'succeeded', + returnStatus: 'received', + }) + ).toBe('returned'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'CANCELED', + shippingStatus: 'cancelled', + shipmentStatus: null, + returnStatus: 'refunded', + }) + ).toBe('returned'); + }); + + it('does not treat non-terminal return states as returned', () => { + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'delivered', + shipmentStatus: 'succeeded', + returnStatus: 'requested', + }) + ).toBe('delivered'); + + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'PAID', + shippingStatus: 'shipped', + shipmentStatus: 'succeeded', + returnStatus: 'approved', + }) + ).toBe('shipped'); + }); + + it('falls back deterministically for unexpected combinations', () => { + expect( + deriveCanonicalFulfillmentStage({ + orderStatus: 'UNKNOWN', + shippingStatus: 'mystery', + shipmentStatus: 'odd', + returnStatus: 'weird', + }) + ).toBe('processing'); + }); +}); diff --git a/frontend/lib/tests/shop/logging-redaction-generic.test.ts b/frontend/lib/tests/shop/logging-redaction-generic.test.ts new file mode 100644 index 00000000..bb86fc86 --- /dev/null +++ b/frontend/lib/tests/shop/logging-redaction-generic.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + sanitizeShopLogMeta, + sanitizeShopLogString, +} from '@/lib/services/shop/logging-redaction'; + +describe('shop logging redaction (generic)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubEnv('LOG_LEVEL', 'debug'); + vi.stubEnv('NODE_ENV', 'test'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('redacts representative nested PII and secret-like fields while keeping operational metadata', () => { + const safe = sanitizeShopLogMeta({ + requestId: 'req_123', + orderId: 'ord_123', + retryAfterSeconds: 30, + customer: { + email: 'buyer@example.com', + phone: '+380501112233', + }, + shipping: { + addressLine1: 'Khreschatyk 1', + addressLine2: 'Apt 4', + }, + auth: { + authorization: 'Bearer abc.def.ghi', + statusToken: 'tok_secret_123', + }, + note: 'Email buyer@example.com or call +380501112233', + }); + + expect(safe).toMatchObject({ + requestId: 'req_123', + orderId: 'ord_123', + retryAfterSeconds: 30, + note: 'Email [REDACTED_EMAIL] or call [REDACTED_PHONE]', + }); + expect((safe as Record).customer).toEqual({ + email: '[REDACTED_EMAIL]', + phone: '[REDACTED_PHONE]', + }); + expect((safe as Record).shipping).toEqual({ + addressLine1: '[REDACTED_ADDRESS]', + addressLine2: '[REDACTED_ADDRESS]', + }); + expect((safe as Record).auth).toEqual({ + authorization: '[REDACTED_SECRET]', + statusToken: '[REDACTED_SECRET]', + }); + }); + + it('redacts email, phone, bearer, jwt, and provider secret strings generically', () => { + const safe = sanitizeShopLogString( + [ + 'buyer@example.com', + '+380501112233', + 'Bearer abc.def.ghi', + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature', + 'sk_live_1234567890abcdef', + 'whsec_1234567890abcdef', + ].join(' | ') + ); + + expect(safe).not.toContain('buyer@example.com'); + expect(safe).not.toContain('+380501112233'); + expect(safe).not.toContain('abc.def.ghi'); + expect(safe).not.toContain('sk_live_1234567890abcdef'); + expect(safe).not.toContain('whsec_1234567890abcdef'); + expect(safe).toContain('[REDACTED_EMAIL]'); + expect(safe).toContain('[REDACTED_PHONE]'); + expect(safe).toContain('Bearer [REDACTED_SECRET]'); + expect(safe).toContain('[REDACTED_SECRET]'); + }); + + it('shared logger emits redacted meta and error payloads', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { logError, logWarn } = await import('@/lib/logging'); + + logWarn('shop_test_warn buyer@example.com +380501112233', { + requestId: 'req_warn', + email: 'warn@example.com', + shippingAddress: { + addressLine1: 'Khreschatyk 1', + }, + note: 'Call +380501112233', + }); + logError( + 'shop_test_error', + new Error('buyer@example.com +380501112233 Bearer abc.def.ghi'), + { + orderId: 'ord_1', + token: 'secret_token', + } + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + + const warnPayload = JSON.parse(String(warnSpy.mock.calls[0]?.[0] ?? '{}')); + expect(warnPayload.msg).toBe( + 'shop_test_warn [REDACTED_EMAIL] [REDACTED_PHONE]' + ); + expect(warnPayload.meta).toMatchObject({ + requestId: 'req_warn', + email: '[REDACTED_EMAIL]', + shippingAddress: '[REDACTED_ADDRESS]', + note: 'Call [REDACTED_PHONE]', + }); + + const errorPayload = JSON.parse( + String(errorSpy.mock.calls[0]?.[0] ?? '{}') + ); + expect(errorPayload.meta).toMatchObject({ + orderId: 'ord_1', + token: '[REDACTED_SECRET]', + }); + expect(errorPayload.err.message).toContain('[REDACTED_EMAIL]'); + expect(errorPayload.err.message).toContain('[REDACTED_PHONE]'); + expect(errorPayload.err.message).toContain('Bearer [REDACTED_SECRET]'); + }); +}); diff --git a/frontend/lib/tests/shop/logging-redaction-real-flows.test.ts b/frontend/lib/tests/shop/logging-redaction-real-flows.test.ts new file mode 100644 index 00000000..984d7815 --- /dev/null +++ b/frontend/lib/tests/shop/logging-redaction-real-flows.test.ts @@ -0,0 +1,291 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +function parseLoggedJson(spy: ReturnType, index = 0) { + return JSON.parse(String(spy.mock.calls[index]?.[0] ?? '{}')) as Record< + string, + unknown + >; +} + +function expectNoSensitiveText(raw: string) { + expect(raw).not.toContain('buyer@example.com'); + expect(raw).not.toContain('+380501112233'); + expect(raw).not.toContain('abc.def.ghi'); + expect(raw).not.toContain('tok_secret_123'); +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubEnv('LOG_LEVEL', 'debug'); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('APP_ENV', 'local'); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +describe('shop logging redaction real flows', () => { + it('checkout route logs sanitized auth-resolution errors with useful meta', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + vi.doMock('@/lib/security/origin', () => ({ + guardBrowserSameOrigin: () => null, + })); + vi.doMock('@/lib/security/rate-limit', () => ({ + getRateLimitSubject: vi.fn(() => 'checkout_logging_subject'), + enforceRateLimit: vi.fn(async () => ({ ok: true, remaining: 9 })), + rateLimitResponse: ({ + retryAfterSeconds, + }: { + retryAfterSeconds: number; + }) => + NextResponse.json( + { success: false, code: 'RATE_LIMITED', retryAfterSeconds }, + { status: 429 } + ), + })); + vi.doMock('@/lib/auth', () => ({ + getCurrentUser: vi.fn(async () => { + throw new Error( + 'buyer@example.com +380501112233 Bearer abc.def.ghi tok_secret_123' + ); + }), + })); + vi.doMock('@/lib/env/stripe', () => ({ + isPaymentsEnabled: () => true, + })); + vi.doMock('@/lib/env/monobank', () => ({ + isMonobankEnabled: () => false, + })); + + const { POST } = await import('@/app/api/shop/checkout/route'); + const res = await POST( + new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json', + 'idempotency-key': '123e4567-e89b-12d3-a456-426614174000', + 'x-request-id': 'checkout-redaction-test', + }, + body: JSON.stringify({ + userId: '11111111-1111-1111-1111-111111111111', + items: [ + { + productId: '22222222-2222-2222-2222-222222222222', + quantity: 1, + }, + ], + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + paymentCurrency: 'USD', + }), + }) + ); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.code).toBe('USER_ID_NOT_ALLOWED'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + + const errorRaw = String(errorSpy.mock.calls[0]?.[0] ?? ''); + expectNoSensitiveText(errorRaw); + const errorPayload = parseLoggedJson(errorSpy); + expect(errorPayload.msg).toBe('checkout_auth_user_resolve_failed'); + expect(errorPayload.meta).toMatchObject({ + requestId: 'checkout-redaction-test', + route: '/api/shop/checkout', + method: 'POST', + code: 'AUTH_USER_RESOLVE_FAILED', + }); + expect((errorPayload.err as Record).message).toContain( + '[REDACTED_EMAIL]' + ); + expect((errorPayload.err as Record).message).toContain( + '[REDACTED_PHONE]' + ); + expect((errorPayload.err as Record).message).toContain( + 'Bearer [REDACTED_SECRET]' + ); + + const warnPayload = parseLoggedJson(warnSpy); + expect(warnPayload.msg).toBe('checkout_user_id_not_allowed'); + expect(warnPayload.meta).toMatchObject({ + requestId: 'checkout-redaction-test', + code: 'USER_ID_NOT_ALLOWED', + sessionUserId: null, + }); + }); + + it('admin orders route logs sanitized failures with useful operational meta', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + class AdminApiDisabledError extends Error { + code = 'ADMIN_API_DISABLED'; + } + class AdminUnauthorizedError extends Error { + code = 'ADMIN_UNAUTHORIZED'; + } + class AdminForbiddenError extends Error { + code = 'ADMIN_FORBIDDEN'; + } + + vi.doMock('@/lib/security/origin', () => ({ + guardBrowserSameOrigin: () => null, + })); + vi.doMock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: () => null, + })); + vi.doMock('@/lib/auth/admin', () => ({ + AdminApiDisabledError, + AdminUnauthorizedError, + AdminForbiddenError, + requireAdminApi: vi.fn(async () => ({ id: 'admin_1' })), + })); + vi.doMock('@/db/queries/shop/admin-orders', () => ({ + getAdminOrdersPage: vi.fn(async () => { + throw new Error( + 'admin buyer@example.com +380501112233 Bearer abc.def.ghi tok_secret_123' + ); + }), + })); + + const { GET } = await import('@/app/api/shop/admin/orders/route'); + const res = await GET( + new NextRequest( + 'http://localhost/api/shop/admin/orders?limit=10&offset=0', + { + method: 'GET', + headers: { + origin: 'http://localhost:3000', + 'x-request-id': 'admin-redaction-test', + }, + } + ) + ); + + expect(res.status).toBe(500); + const json = await res.json(); + expect(json.code).toBe('INTERNAL_ERROR'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const raw = String(errorSpy.mock.calls[0]?.[0] ?? ''); + expectNoSensitiveText(raw); + + const payload = parseLoggedJson(errorSpy); + expect(payload.msg).toBe('admin_orders_list_failed'); + expect(payload.meta).toMatchObject({ + requestId: 'admin-redaction-test', + route: '/api/shop/admin/orders', + method: 'GET', + code: 'ADMIN_ORDERS_LIST_FAILED', + }); + expect(typeof (payload.meta as Record).durationMs).toBe( + 'number' + ); + expect((payload.err as Record).message).toContain( + '[REDACTED_EMAIL]' + ); + expect((payload.err as Record).message).toContain( + '[REDACTED_PHONE]' + ); + expect((payload.err as Record).message).toContain( + 'Bearer [REDACTED_SECRET]' + ); + }); + + it('internal notifications run route logs sanitized worker failures with useful meta', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + vi.doMock('@/lib/security/origin', () => ({ + guardNonBrowserFailClosed: () => null, + })); + vi.doMock('@/lib/auth/internal-janitor', () => ({ + requireInternalJanitorAuth: () => null, + })); + vi.doMock('@/lib/services/shop/notifications/projector', () => ({ + runNotificationOutboxProjector: vi.fn(async () => ({ + claimed: 0, + projected: 0, + inserted: 0, + skipped: 0, + })), + })); + vi.doMock('@/lib/services/shop/notifications/outbox-worker', () => ({ + countRunnableNotificationOutboxRows: vi.fn(async () => 0), + runNotificationOutboxWorker: vi.fn(async () => { + throw new Error( + 'notify buyer@example.com +380501112233 Bearer abc.def.ghi tok_secret_123' + ); + }), + })); + + const { POST } = + await import('@/app/api/shop/internal/notifications/run/route'); + const res = await POST( + new NextRequest('http://localhost/api/shop/internal/notifications/run', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-request-id': 'notifications-redaction-test', + }, + body: JSON.stringify({}), + }) + ); + + expect(res.status).toBe(500); + const json = await res.json(); + expect(json.code).toBe('INTERNAL_ERROR'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + + const warnRaw = String(warnSpy.mock.calls[0]?.[0] ?? ''); + const errorRaw = String(errorSpy.mock.calls[0]?.[0] ?? ''); + expectNoSensitiveText(warnRaw); + expectNoSensitiveText(errorRaw); + + const warnPayload = parseLoggedJson(warnSpy); + expect(warnPayload.msg).toBe('shop_notifications_worker_failed'); + expect(warnPayload.meta).toMatchObject({ + requestId: 'notifications-redaction-test', + route: '/api/shop/internal/notifications/run', + method: 'POST', + code: 'SHOP_NOTIFICATIONS_WORKER_FAILED', + }); + expect(typeof (warnPayload.meta as Record).runId).toBe( + 'string' + ); + + const errorPayload = parseLoggedJson(errorSpy); + expect(errorPayload.msg).toBe('shop_notifications_worker_failed_error'); + expect(errorPayload.meta).toMatchObject({ + requestId: 'notifications-redaction-test', + route: '/api/shop/internal/notifications/run', + method: 'POST', + code: 'SHOP_NOTIFICATIONS_WORKER_FAILED', + }); + expect(typeof (errorPayload.meta as Record).runId).toBe( + 'string' + ); + expect((errorPayload.err as Record).message).toContain( + '[REDACTED_EMAIL]' + ); + expect((errorPayload.err as Record).message).toContain( + '[REDACTED_PHONE]' + ); + expect((errorPayload.err as Record).message).toContain( + 'Bearer [REDACTED_SECRET]' + ); + }); +}); diff --git a/frontend/lib/tests/shop/order-fulfillment-stage-surfaces.test.ts b/frontend/lib/tests/shop/order-fulfillment-stage-surfaces.test.ts new file mode 100644 index 00000000..1996cdb9 --- /dev/null +++ b/frontend/lib/tests/shop/order-fulfillment-stage-surfaces.test.ts @@ -0,0 +1,238 @@ +import crypto from 'node:crypto'; + +import { eq, type InferInsertModel } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders, returnRequests, shippingShipments, users } from '@/db/schema'; +import { getCurrentUser } from '@/lib/auth'; +import type { CanonicalFulfillmentStage } from '@/lib/services/shop/fulfillment-stage'; +import { toDbMoney } from '@/lib/shop/money'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn(), +})); + +vi.mock('@/lib/logging', async () => { + const actual = + await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +type OrderInsert = InferInsertModel; +type ShipmentInsert = InferInsertModel; +type ReturnRequestInsert = InferInsertModel; +type UserInsert = InferInsertModel; + +type Scenario = { + stage: CanonicalFulfillmentStage; + orderStatus: OrderInsert['status']; + shippingStatus: OrderInsert['shippingStatus']; + shipmentStatus?: ShipmentInsert['status']; + returnStatus?: ReturnRequestInsert['status']; +}; + +const ownerId = 'user-fulfillment-owner'; +const adminId = 'admin-fulfillment-owner'; +const seededOrderIds = new Set(); + +const ownerUser = { + id: ownerId, + email: `${ownerId}@example.test`, + username: 'fulfillment-owner', + role: 'user' as const, +}; + +const adminUser = { + id: adminId, + email: `${adminId}@example.test`, + username: 'fulfillment-admin', + role: 'admin' as const, +}; + +async function cleanupOrder(orderId: string) { + await db.delete(returnRequests).where(eq(returnRequests.orderId, orderId)); + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +afterEach(async () => { + for (const orderId of seededOrderIds) { + await cleanupOrder(orderId); + } + seededOrderIds.clear(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + assertNotProductionDb(); +}); + +async function ensureUser(user: UserInsert) { + await db.insert(users).values(user).onConflictDoNothing(); +} + +async function seedScenario(scenario: Scenario): Promise { + const orderId = crypto.randomUUID(); + seededOrderIds.add(orderId); + + await ensureUser({ + id: ownerUser.id, + email: ownerUser.email, + role: 'user', + name: ownerUser.username, + }); + await ensureUser({ + id: adminUser.id, + email: adminUser.email, + role: 'admin', + name: adminUser.username, + }); + + await db.insert(orders).values({ + id: orderId, + userId: ownerId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'paid', + status: scenario.orderStatus, + inventoryStatus: 'reserved', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: scenario.shippingStatus, + idempotencyKey: `fulfillment-stage-${orderId}`, + }); + + if (scenario.shipmentStatus) { + await db.insert(shippingShipments).values({ + id: crypto.randomUUID(), + orderId, + provider: 'nova_poshta', + status: scenario.shipmentStatus, + attemptCount: 1, + }); + } + + if (scenario.returnStatus) { + await db.insert(returnRequests).values({ + id: crypto.randomUUID(), + orderId, + userId: ownerId, + status: scenario.returnStatus, + currency: 'UAH', + refundAmountMinor: 0, + idempotencyKey: `return-${orderId}`, + }); + } + + return orderId; +} + +async function callDetailRoute(orderId: string) { + const { GET } = await import('@/app/api/shop/orders/[id]/route'); + const req = new NextRequest(`http://localhost/api/shop/orders/${orderId}`, { + method: 'GET', + }); + + return GET(req, { params: Promise.resolve({ id: orderId }) }); +} + +async function callStatusRoute(orderId: string, view: 'lite' | 'full') { + const { GET } = await import('@/app/api/shop/orders/[id]/status/route'); + const req = new NextRequest( + `http://localhost/api/shop/orders/${orderId}/status?view=${view}`, + { + method: 'GET', + } + ); + + return GET(req, { params: Promise.resolve({ id: orderId }) }); +} + +describe.sequential('canonical fulfillment stage surfaces', () => { + const getCurrentUserMock = vi.mocked(getCurrentUser); + + it.each([ + { + stage: 'packed', + orderStatus: 'PAID', + shippingStatus: 'label_created', + shipmentStatus: 'succeeded', + }, + { + stage: 'shipped', + orderStatus: 'PAID', + shippingStatus: 'shipped', + }, + { + stage: 'delivered', + orderStatus: 'PAID', + shippingStatus: 'delivered', + }, + { + stage: 'canceled', + orderStatus: 'CANCELED', + shippingStatus: 'cancelled', + }, + { + stage: 'returned', + orderStatus: 'PAID', + shippingStatus: 'delivered', + returnStatus: 'refunded', + }, + ])( + 'surfaces $stage consistently across customer/admin detail and status APIs', + async scenario => { + const orderId = await seedScenario(scenario); + const { getOrderSummary, getOrderStatusLiteSummary } = + await import('@/lib/services/orders/summary'); + + const summary = await getOrderSummary(orderId); + expect(summary.fulfillmentStage).toBe(scenario.stage); + const liteSummary = await getOrderStatusLiteSummary(orderId); + expect(liteSummary.fulfillmentStage).toBe(scenario.stage); + + getCurrentUserMock.mockResolvedValue(ownerUser); + const ownerDetailRes = await callDetailRoute(orderId); + expect(ownerDetailRes.status).toBe(200); + const ownerDetailJson = await ownerDetailRes.json(); + expect(ownerDetailJson?.order?.fulfillmentStage).toBe(scenario.stage); + expect(ownerDetailJson?.order?.shippingStatus).toBe( + scenario.shippingStatus + ); + + getCurrentUserMock.mockResolvedValue(adminUser); + const adminDetailRes = await callDetailRoute(orderId); + expect(adminDetailRes.status).toBe(200); + const adminDetailJson = await adminDetailRes.json(); + expect(adminDetailJson?.order?.fulfillmentStage).toBe(scenario.stage); + + getCurrentUserMock.mockResolvedValue(ownerUser); + const statusLiteRes = await callStatusRoute(orderId, 'lite'); + expect(statusLiteRes.status).toBe(200); + const statusLiteJson = await statusLiteRes.json(); + expect(statusLiteJson?.fulfillmentStage).toBe(scenario.stage); + expect(statusLiteJson?.id).toBe(orderId); + + const statusFullRes = await callStatusRoute(orderId, 'full'); + expect(statusFullRes.status).toBe(200); + const statusFullJson = await statusFullRes.json(); + expect(statusFullJson?.success).toBe(true); + expect(statusFullJson?.order?.fulfillmentStage).toBe(scenario.stage); + expect(statusFullJson?.order?.id).toBe(orderId); + } + ); +}); diff --git a/frontend/lib/tests/shop/order-status-token.test.ts b/frontend/lib/tests/shop/order-status-token.test.ts index e5904538..c55ddf13 100644 --- a/frontend/lib/tests/shop/order-status-token.test.ts +++ b/frontend/lib/tests/shop/order-status-token.test.ts @@ -112,6 +112,7 @@ describe('order status token access control', () => { expect(json.currency).toBe('UAH'); expect(json.totalAmountMinor).toBe(1000); expect(json.paymentStatus).toBe('pending'); + expect(json.fulfillmentStage).toBe('processing'); expect(json.itemsCount).toBe(0); expect(typeof json.updatedAt).toBe('string'); expect(json.order).toBeUndefined(); @@ -146,6 +147,7 @@ describe('order status token access control', () => { const json: any = await res.json(); expect(json.id).toBe(orderId); expect(json.paymentStatus).toBe('pending'); + expect(json.fulfillmentStage).toBe('processing'); expect(json.order).toBeUndefined(); expect(json.attempt).toBeUndefined(); expect(json.success).toBeUndefined(); diff --git a/frontend/lib/tests/shop/orders-access.test.ts b/frontend/lib/tests/shop/orders-access.test.ts index 209d53d9..491284bd 100644 --- a/frontend/lib/tests/shop/orders-access.test.ts +++ b/frontend/lib/tests/shop/orders-access.test.ts @@ -79,6 +79,10 @@ describe('P0-SEC-1.1: GET /api/shop/orders/[id] access control', () => { paymentStatus: 'pending', paymentProvider: 'stripe', paymentIntentId: null, + orderStatus: 'INVENTORY_RESERVED', + shippingStatus: null, + shipmentStatus: null, + returnStatus: null, stockRestored: false, restockedAt: null, idempotencyKey: 'idem_key', @@ -96,6 +100,7 @@ describe('P0-SEC-1.1: GET /api/shop/orders/[id] access control', () => { expect(json?.success).toBe(true); expect(json?.order?.id).toBe(orderId); expect(json?.order?.userId).toBe(ownerId); + expect(json?.order?.fulfillmentStage).toBe('processing'); }); it('admin -> 200', async () => { @@ -113,6 +118,10 @@ describe('P0-SEC-1.1: GET /api/shop/orders/[id] access control', () => { paymentStatus: 'pending', paymentProvider: 'stripe', paymentIntentId: null, + orderStatus: 'INVENTORY_RESERVED', + shippingStatus: null, + shipmentStatus: null, + returnStatus: null, stockRestored: false, restockedAt: null, idempotencyKey: 'idem_key', @@ -125,5 +134,7 @@ describe('P0-SEC-1.1: GET /api/shop/orders/[id] access control', () => { const res = await callGet(orderId); expect(res.status).toBe(200); + const json = await res.json(); + expect(json?.order?.fulfillmentStage).toBe('processing'); }); }); diff --git a/frontend/lib/tests/shop/orders-status-ownership.test.ts b/frontend/lib/tests/shop/orders-status-ownership.test.ts index 30e6fcb3..c13e007d 100644 --- a/frontend/lib/tests/shop/orders-status-ownership.test.ts +++ b/frontend/lib/tests/shop/orders-status-ownership.test.ts @@ -15,6 +15,7 @@ import { 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 { verifyStatusToken } from '@/lib/shop/status-token'; import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; @@ -174,6 +175,14 @@ 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; + + if (typeof pricingFingerprint !== 'string' || pricingFingerprint.length !== 64) { + throw new Error( + '[ownership-test] expected authoritative pricing fingerprint from cart rehydrate' + ); + } const req = new NextRequest('http://localhost/api/shop/checkout', { method: 'POST', @@ -188,6 +197,7 @@ async function postCheckout(idemKey: string, productId: string) { body: JSON.stringify({ items: [{ productId, quantity: 1 }], paymentProvider: 'monobank', + pricingFingerprint, }), }); @@ -395,6 +405,7 @@ describe.sequential('orders/[id]/status ownership (J)', () => { expect((json as any).order).toBeUndefined(); expect((json as any).attempt).toBeUndefined(); expect((json as any).paymentStatus).toBeTruthy(); + expect((json as any).fulfillmentStage).toBe('processing'); expect((json as any).totalAmountMinor).toBeGreaterThan(0); const returnedId = (json as any).orderId ?? (json as any).id; diff --git a/frontend/lib/tests/shop/payment-intent-fulfillment-stage.test.ts b/frontend/lib/tests/shop/payment-intent-fulfillment-stage.test.ts new file mode 100644 index 00000000..881c3140 --- /dev/null +++ b/frontend/lib/tests/shop/payment-intent-fulfillment-stage.test.ts @@ -0,0 +1,127 @@ +import crypto from 'node:crypto'; + +import { eq, type InferInsertModel } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { db } from '@/db'; +import { orders, returnRequests, shippingShipments } from '@/db/schema'; +import { setOrderPaymentIntent } from '@/lib/services/orders/payment-intent'; +import { toDbMoney } from '@/lib/shop/money'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; + +type OrderInsert = InferInsertModel; +type ShipmentInsert = InferInsertModel; +type ReturnRequestInsert = InferInsertModel; + +const seededOrderIds = new Set(); + +async function cleanupOrder(orderId: string) { + await db.delete(returnRequests).where(eq(returnRequests.orderId, orderId)); + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +afterEach(async () => { + for (const orderId of seededOrderIds) { + await cleanupOrder(orderId); + } + seededOrderIds.clear(); +}); + +beforeEach(() => { + assertNotProductionDb(); +}); + +async function seedStripeOrder(args: { + paymentStatus: OrderInsert['paymentStatus']; + orderStatus: OrderInsert['status']; + shippingStatus?: OrderInsert['shippingStatus']; + paymentIntentId?: string | null; + shipmentStatus?: ShipmentInsert['status']; + returnStatus?: ReturnRequestInsert['status']; +}) { + const orderId = crypto.randomUUID(); + seededOrderIds.add(orderId); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: args.paymentStatus, + paymentIntentId: args.paymentIntentId ?? null, + status: args.orderStatus, + inventoryStatus: 'reserved', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: args.shippingStatus ?? null, + idempotencyKey: `payment-intent-stage-${orderId}`, + }); + + if (args.shipmentStatus) { + await db.insert(shippingShipments).values({ + id: crypto.randomUUID(), + orderId, + provider: 'nova_poshta', + status: args.shipmentStatus, + attemptCount: 1, + }); + } + + if (args.returnStatus) { + await db.insert(returnRequests).values({ + id: crypto.randomUUID(), + orderId, + userId: null, + status: args.returnStatus, + currency: 'USD', + refundAmountMinor: 0, + idempotencyKey: `payment-intent-return-${orderId}`, + }); + } + + return orderId; +} + +describe.sequential('payment-intent fulfillment stage summary', () => { + it('reflects return-only signals in the idempotent branch', async () => { + const orderId = await seedStripeOrder({ + paymentStatus: 'requires_payment', + orderStatus: 'CREATED', + shippingStatus: 'pending', + paymentIntentId: 'pi_existing_stage', + returnStatus: 'refunded', + }); + + const summary = await setOrderPaymentIntent({ + orderId, + paymentIntentId: 'pi_existing_stage', + }); + + expect(summary.paymentIntentId).toBe('pi_existing_stage'); + expect(summary.fulfillmentStage).toBe('returned'); + }); + + it('reflects return-only signals after the guarded update branch', async () => { + const orderId = await seedStripeOrder({ + paymentStatus: 'pending', + orderStatus: 'CREATED', + shippingStatus: 'pending', + returnStatus: 'refunded', + }); + + const summary = await setOrderPaymentIntent({ + orderId, + paymentIntentId: 'pi_new_stage', + }); + + expect(summary.paymentIntentId).toBe('pi_new_stage'); + expect(summary.paymentStatus).toBe('requires_payment'); + expect(summary.fulfillmentStage).toBe('returned'); + }); +}); diff --git a/frontend/lib/tests/shop/provider-env-runtime-safety.test.ts b/frontend/lib/tests/shop/provider-env-runtime-safety.test.ts new file mode 100644 index 00000000..e6dedd9f --- /dev/null +++ b/frontend/lib/tests/shop/provider-env-runtime-safety.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetEnvCache } from '@/lib/env'; +import { getMonobankEnv } from '@/lib/env/monobank'; +import { getNovaPoshtaConfig } from '@/lib/env/nova-poshta'; +import { resolveShopPaymentProvider } from '@/lib/env/payments'; +import { getStripeEnv } from '@/lib/env/stripe'; + +const ENV_KEYS = [ + 'APP_ENV', + 'NODE_ENV', + 'PAYMENTS_ENABLED', + 'STRIPE_MODE', + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', + 'MONO_MERCHANT_TOKEN', + 'MONO_PUBLIC_KEY', + 'MONO_API_BASE', + 'SHOP_SHIPPING_ENABLED', + 'SHOP_SHIPPING_NP_ENABLED', + 'NP_API_BASE', + 'NP_API_KEY', + 'NP_SENDER_CITY_REF', + 'NP_SENDER_WAREHOUSE_REF', + 'NP_SENDER_REF', + 'NP_SENDER_CONTACT_REF', + 'NP_SENDER_NAME', + 'NP_SENDER_PHONE', + 'NP_SENDER_EDRPOU', +]; + +const previousEnv: Record = {}; + +function restoreEnv() { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function seedPlaceholderNovaPoshtaEnv() { + process.env.SHOP_SHIPPING_ENABLED = 'true'; + process.env.SHOP_SHIPPING_NP_ENABLED = 'true'; + process.env.NP_API_BASE = 'https://api.example.test'; + process.env.NP_API_KEY = 'np_test_placeholder'; + process.env.NP_SENDER_CITY_REF = 'test-city-ref'; + process.env.NP_SENDER_WAREHOUSE_REF = 'test-warehouse-ref'; + process.env.NP_SENDER_REF = 'test-sender-ref'; + process.env.NP_SENDER_CONTACT_REF = 'test-contact-ref'; + process.env.NP_SENDER_NAME = 'Test Sender'; + process.env.NP_SENDER_PHONE = '0000000000'; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + delete process.env[key]; + } + resetEnvCache(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + restoreEnv(); + resetEnvCache(); +}); + +describe('shop provider runtime env safety', () => { + it('rejects placeholder stripe config in production-like runtime and allows valid live config', () => { + process.env.APP_ENV = 'production'; + vi.stubEnv('NODE_ENV', 'test'); + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_MODE = 'live'; + process.env.STRIPE_SECRET_KEY = 'sk_test_placeholder'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_placeholder'; + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_test_placeholder'; + resetEnvCache(); + + expect(() => getStripeEnv()).toThrow( + /STRIPE_SECRET_KEY|STRIPE_WEBHOOK_SECRET|NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY|STRIPE_MODE/ + ); + + process.env.STRIPE_SECRET_KEY = 'sk_live_1234567890abcdef'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_1234567890abcdef'; + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_live_1234567890abcdef'; + resetEnvCache(); + + expect(getStripeEnv()).toMatchObject({ + paymentsEnabled: true, + mode: 'live', + secretKey: 'sk_live_1234567890abcdef', + webhookSecret: 'whsec_1234567890abcdef', + publishableKey: 'pk_live_1234567890abcdef', + }); + }); + + it('rejects a production-like stripe live/test mismatch when webhook validation is otherwise present', () => { + process.env.APP_ENV = 'production'; + vi.stubEnv('NODE_ENV', 'test'); + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_MODE = 'live'; + process.env.STRIPE_SECRET_KEY = 'sk_test_1234567890abcdef'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_1234567890abcdef'; + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_live_1234567890abcdef'; + resetEnvCache(); + + expect(() => getStripeEnv()).toThrow(/STRIPE_SECRET_KEY/); + }); + + it('keeps local/test stripe workflow usable with test keys', () => { + process.env.APP_ENV = 'local'; + vi.stubEnv('NODE_ENV', 'test'); + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_MODE = 'test'; + process.env.STRIPE_SECRET_KEY = 'sk_test_local_checkout'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_local_checkout'; + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_test_local_checkout'; + resetEnvCache(); + + expect(getStripeEnv()).toMatchObject({ + paymentsEnabled: true, + mode: 'test', + secretKey: 'sk_test_local_checkout', + }); + }); + + it('rejects placeholder monobank config in production-like runtime and keeps local/test usable', () => { + process.env.APP_ENV = 'production'; + vi.stubEnv('NODE_ENV', 'test'); + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'mono_test_placeholder'; + process.env.MONO_PUBLIC_KEY = 'mono_test_public'; + process.env.MONO_API_BASE = 'https://api.example.test'; + resetEnvCache(); + + expect(() => getMonobankEnv()).toThrow( + /MONO_MERCHANT_TOKEN|MONO_PUBLIC_KEY|MONO_API_BASE/ + ); + + process.env.APP_ENV = 'local'; + resetEnvCache(); + + expect(getMonobankEnv()).toMatchObject({ + paymentsEnabled: true, + token: 'mono_test_placeholder', + apiBaseUrl: 'https://api.example.test', + }); + }); + + it('treats invalid monobank runtime config as disabled in generic provider resolution', () => { + process.env.APP_ENV = 'production'; + vi.stubEnv('NODE_ENV', 'test'); + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'mono_test_placeholder'; + process.env.MONO_PUBLIC_KEY = 'mono_test_public'; + process.env.MONO_API_BASE = 'https://api.example.test'; + resetEnvCache(); + + expect(() => resolveShopPaymentProvider()).not.toThrow(); + expect(resolveShopPaymentProvider()).toBe('none'); + }); + + it('rejects placeholder nova poshta config in production-like runtime and keeps local/test usable', () => { + process.env.APP_ENV = 'production'; + vi.stubEnv('NODE_ENV', 'test'); + seedPlaceholderNovaPoshtaEnv(); + resetEnvCache(); + + expect(() => getNovaPoshtaConfig()).toThrow(/NP_/); + + process.env.APP_ENV = 'local'; + resetEnvCache(); + + expect(getNovaPoshtaConfig()).toMatchObject({ + enabled: true, + apiBaseUrl: 'https://api.example.test', + apiKey: 'np_test_placeholder', + sender: { + cityRef: 'test-city-ref', + warehouseRef: 'test-warehouse-ref', + }, + }); + }); +}); diff --git a/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts b/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts index 590cce91..54342d9b 100644 --- a/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts +++ b/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts @@ -131,4 +131,35 @@ describe('shop shipping methods route (phase 2)', () => { expect(method.quoteFingerprint).toMatch(/^[a-f0-9]{64}$/); } }); + + it('fails closed with NP_MISCONFIG in production-like runtime when NP config is placeholder', async () => { + vi.stubEnv('APP_ENV', 'production'); + vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); + 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_BASE', 'https://api.example.test'); + vi.stubEnv('NP_API_KEY', 'np_test_placeholder'); + vi.stubEnv('NP_SENDER_CITY_REF', 'test-city-ref'); + vi.stubEnv('NP_SENDER_WAREHOUSE_REF', 'test-warehouse-ref'); + vi.stubEnv('NP_SENDER_REF', 'test-sender-ref'); + vi.stubEnv('NP_SENDER_CONTACT_REF', 'test-contact-ref'); + vi.stubEnv('NP_SENDER_NAME', 'Test Sender'); + vi.stubEnv('NP_SENDER_PHONE', '0000000000'); + resetEnvCache(); + + const req = new NextRequest( + 'http://localhost/api/shop/shipping/methods?locale=uk¤cy=UAH&country=UA' + ); + const res = await GET(req); + const json: any = await res.json(); + + expect(res.status).toBe(503); + expect(json).toMatchObject({ + success: false, + code: 'NP_MISCONFIG', + message: 'Nova Poshta configuration is invalid', + }); + }); }); diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index f8cb9735..0ce21386 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -37,6 +37,17 @@ export const paymentStatusSchema = z.enum(paymentStatusValues); export const paymentProviderSchema = z.enum(paymentProviderValues); export const paymentMethodSchema = z.enum(paymentMethodValues); export const currencySchema = z.enum(currencyValues); +export const canonicalFulfillmentStageValues = [ + 'processing', + 'packed', + 'shipped', + 'delivered', + 'canceled', + 'returned', +] as const; +export type CanonicalFulfillmentStage = + (typeof canonicalFulfillmentStageValues)[number]; +export const fulfillmentStageSchema = z.enum(canonicalFulfillmentStageValues); const searchParamString = z .union([z.string(), z.array(z.string())]) @@ -557,6 +568,7 @@ export const orderSummarySchema = z.object({ totalAmount: z.number().min(0), currency: currencySchema, paymentStatus: paymentStatusSchema, + fulfillmentStage: fulfillmentStageSchema, paymentProvider: paymentProviderSchema, paymentIntentId: z .string() diff --git a/frontend/messages/en.json b/frontend/messages/en.json index de7e2386..913a21ea 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -802,6 +802,7 @@ "orderSummary": "Order summary", "total": "Total", "paymentStatus": "Payment status", + "fulfillmentStage": "Fulfillment stage", "created": "Created", "provider": "Provider", "paymentReference": "Payment reference", @@ -818,6 +819,14 @@ "no": "No", "shippingStatus": "Shipping status", "trackingNumber": "Tracking number", + "fulfillmentStages": { + "processing": "Processing", + "packed": "Packed", + "shipped": "Shipped", + "delivered": "Delivered", + "canceled": "Canceled", + "returned": "Returned" + }, "qtyShort": "Qty", "unitShort": "Unit", "lineShort": "Line" diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 5bab4ce7..0cc97c58 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -802,6 +802,7 @@ "orderSummary": "Podsumowanie zamówienia", "total": "Razem", "paymentStatus": "Status płatności", + "fulfillmentStage": "Etap realizacji", "created": "Utworzono", "provider": "Dostawca", "paymentReference": "Referencja płatności", @@ -818,6 +819,14 @@ "no": "Nie", "shippingStatus": "Status dostawy", "trackingNumber": "Numer śledzenia", + "fulfillmentStages": { + "processing": "Przetwarzanie", + "packed": "Spakowane", + "shipped": "Wysłane", + "delivered": "Dostarczone", + "canceled": "Anulowane", + "returned": "Zwrócone" + }, "qtyShort": "Ilość", "unitShort": "Cena", "lineShort": "Suma" diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index 84f59e23..7e7ccb51 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -802,6 +802,7 @@ "orderSummary": "Підсумок замовлення", "total": "Всього", "paymentStatus": "Статус оплати", + "fulfillmentStage": "Етап виконання", "created": "Створено", "provider": "Провайдер", "paymentReference": "Посилання на оплату", @@ -818,6 +819,14 @@ "no": "Ні", "shippingStatus": "Статус доставки", "trackingNumber": "Номер відстеження", + "fulfillmentStages": { + "processing": "Опрацювання", + "packed": "Упаковано", + "shipped": "Відправлено", + "delivered": "Доставлено", + "canceled": "Скасовано", + "returned": "Повернено" + }, "qtyShort": "К-сть", "unitShort": "За од.", "lineShort": "Сума"