diff --git a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts index ad745577..15c26ea5 100644 --- a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts @@ -11,7 +11,6 @@ import { AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; -import { getMonobankConfig } from '@/lib/env/monobank'; import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; import { logError, logWarn } from '@/lib/logging'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; @@ -143,44 +142,17 @@ export async function POST( .limit(1); if (targetOrder?.paymentProvider === 'monobank') { - const { refundEnabled } = getMonobankConfig(); - if (!refundEnabled) { - logWarn('admin_orders_refund_disabled', { - ...baseMeta, - code: 'REFUND_DISABLED', - orderId: orderIdForLog, - durationMs: Date.now() - startedAtMs, - }); - - return noStoreJson( - { code: 'REFUND_DISABLED', message: 'Refunds are disabled.' }, - { status: 409 } - ); - } - - const { requestMonobankFullRefund } = - await import('@/lib/services/orders/monobank-refund'); - const result = await requestMonobankFullRefund({ + logWarn('admin_orders_refund_disabled', { + ...baseMeta, + code: 'REFUND_DISABLED', orderId: orderIdForLog, - requestId, + durationMs: Date.now() - startedAtMs, }); - const orderSummary = orderSummarySchema.parse(result.order); - return noStoreJson({ - success: true, - order: { - ...orderSummary, - createdAt: - orderSummary.createdAt instanceof Date - ? orderSummary.createdAt.toISOString() - : String(orderSummary.createdAt), - }, - refund: { - ...result.refund, - deduped: result.deduped, - }, - }); + code: 'REFUND_DISABLED', + message: 'Refunds are disabled.', + }, { status: 409 }); } const order = await refundOrder(orderIdForLog, { requestedBy: 'admin' }); diff --git a/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts b/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts index bab91044..9ab34b38 100644 --- a/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts +++ b/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts @@ -15,7 +15,10 @@ import { verifyCsrfToken, } from '@/lib/security/csrf'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; -import { restockStalePendingOrders } from '@/lib/services/orders'; +import { + reconcileStaleStripeRefundOrders, + restockStalePendingOrders, +} from '@/lib/services/orders'; export const runtime = 'nodejs'; function noStoreJson(body: unknown, init?: { status?: number }) { @@ -93,18 +96,32 @@ export async function POST(request: NextRequest) { return noStoreJson({ code: 'CSRF_INVALID' }, { status: 403 }); } - const processed = await restockStalePendingOrders({ + const processedStalePending = await restockStalePendingOrders({ olderThanMinutes: DEFAULT_STALE_MINUTES, }); + const processedStripeRefundRecovery = + await reconcileStaleStripeRefundOrders({ + olderThanMinutes: DEFAULT_STALE_MINUTES, + }); + const processed = processedStalePending + processedStripeRefundRecovery; logInfo('admin_reconcile_stale_succeeded', { ...baseMeta, code: 'OK', processed, + processedStalePending, + processedStripeRefundRecovery, olderThanMinutes: DEFAULT_STALE_MINUTES, durationMs: Date.now() - startedAtMs, }); - return noStoreJson({ processed }, { status: 200 }); + return noStoreJson( + { + processed, + processedStalePending, + processedStripeRefundRecovery, + }, + { status: 200 } + ); } catch (error) { if (error instanceof AdminApiDisabledError) { logWarn('admin_reconcile_stale_admin_api_disabled', { diff --git a/frontend/app/api/shop/admin/returns/[id]/refund/route.ts b/frontend/app/api/shop/admin/returns/[id]/refund/route.ts index e5c35829..33306021 100644 --- a/frontend/app/api/shop/admin/returns/[id]/refund/route.ts +++ b/frontend/app/api/shop/admin/returns/[id]/refund/route.ts @@ -25,6 +25,7 @@ function mapInvalidPayloadStatus(code: string): number { if (code === 'RETURN_NOT_FOUND') return 404; if ( code === 'RETURN_TRANSITION_INVALID' || + code === 'RETURN_REFUND_DISABLED' || code === 'RETURN_REFUND_STATE_INVALID' || code === 'RETURN_REFUND_PROVIDER_UNSUPPORTED' || code === 'RETURN_REFUND_PAYMENT_STATUS_INVALID' @@ -70,25 +71,12 @@ export async function POST( } returnRequestIdForLog = parsed.data.id; - const result = await refundReturnRequest({ + await refundReturnRequest({ returnRequestId: returnRequestIdForLog, actorUserId: typeof admin.id === 'string' ? admin.id : null, requestId, }); - - return noStoreJson({ - success: true, - changed: result.changed, - returnRequest: { - ...result.row, - approvedAt: result.row.approvedAt?.toISOString() ?? null, - rejectedAt: result.row.rejectedAt?.toISOString() ?? null, - receivedAt: result.row.receivedAt?.toISOString() ?? null, - refundedAt: result.row.refundedAt?.toISOString() ?? null, - createdAt: result.row.createdAt.toISOString(), - updatedAt: result.row.updatedAt.toISOString(), - }, - }); + throw new Error('refundReturnRequest unexpectedly resolved'); } catch (error) { if (error instanceof AdminApiDisabledError) { return noStoreJson({ code: 'ADMIN_API_DISABLED' }, 403); diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index f99f150b..f3e5f44c 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -41,6 +41,8 @@ import { paymentProviderValues, type PaymentStatus, paymentStatusValues, + resolveCheckoutProviderCandidates, + resolveDefaultMethodForProvider, } from '@/lib/shop/payments'; import { resolveRequestLocale } from '@/lib/shop/request-locale'; import { @@ -256,7 +258,7 @@ function mapMonobankCheckoutError(error: unknown) { return { code: 'PRICE_CONFIG_ERROR', message: getErrorMessage(error, 'Price configuration error.'), - status: 422, + status: 400, details: error instanceof PriceConfigError ? { @@ -845,72 +847,110 @@ export async function POST(request: NextRequest) { payloadForValidation = rest; } - const selectedProvider: CheckoutRequestedProvider = - requestedProvider ?? 'stripe'; - // Explicit default table (locked): stripe -> stripe_card, monobank -> monobank_invoice. - const defaultMethod: PaymentMethod = - selectedProvider === 'monobank' ? 'monobank_invoice' : 'stripe_card'; - const selectedMethod: PaymentMethod = requestedMethod ?? defaultMethod; - const selectedCurrency = - selectedProvider === 'monobank' ? 'UAH' : resolveCurrencyFromLocale(locale); - - if (selectedProvider === 'monobank') { - payloadForValidation = stripMonobankClientMoneyFields(payloadForValidation); - } - + const localeCurrency = resolveCurrencyFromLocale(locale); const paymentsEnabled = (process.env.PAYMENTS_ENABLED ?? '').trim() === 'true'; const stripeCheckoutAvailable = isStripePaymentsEnabled({ requirePublishableKey: true, }); - const checkoutPaymentProvider: PaymentProvider = - selectedProvider === 'monobank' - ? 'monobank' - : stripeCheckoutAvailable - ? 'stripe' - : 'none'; + let monobankCheckoutAvailable = false; + try { + monobankCheckoutAvailable = paymentsEnabled && isMonobankEnabled(); + } catch (error) { + logError('monobank_env_invalid', error, { + ...baseMeta, + code: 'MONOBANK_ENV_INVALID', + }); + } - const stripeExplicitlyRequested = - requestedProvider === 'stripe' || requestedMethod === 'stripe_card'; + const checkoutProviderCandidates = resolveCheckoutProviderCandidates({ + requestedProvider, + requestedMethod, + currency: localeCurrency, + }); + const selectedProvider = + checkoutProviderCandidates.find(candidate => + candidate === 'stripe' + ? stripeCheckoutAvailable + : monobankCheckoutAvailable + ) ?? null; + + const fallbackProvider = + selectedProvider ?? checkoutProviderCandidates[0] ?? null; + const selectedCurrency = + fallbackProvider === 'monobank' ? 'UAH' : localeCurrency; + const selectedMethod = + requestedMethod ?? + (fallbackProvider + ? resolveDefaultMethodForProvider(fallbackProvider, selectedCurrency) + : null); + + if (fallbackProvider === 'monobank') { + payloadForValidation = stripMonobankClientMoneyFields(payloadForValidation); + } const stripeRequestedButUnavailable = - stripeExplicitlyRequested && !stripeCheckoutAvailable; + checkoutProviderCandidates.length === 1 && + checkoutProviderCandidates[0] === 'stripe' && + !stripeCheckoutAvailable; - if (selectedProvider === 'monobank') { - let enabled = false; + if (!selectedMethod) { + logWarn('checkout_provider_unavailable', { + ...baseMeta, + code: 'PAYMENTS_DISABLED', + requestedProvider, + requestedMethod, + localeCurrency, + candidates: checkoutProviderCandidates, + stripeCheckoutAvailable, + monobankCheckoutAvailable, + }); - try { - enabled = isMonobankEnabled(); - } catch (error) { - logError('monobank_env_invalid', error, { - ...baseMeta, - code: 'MONOBANK_ENV_INVALID', - }); - enabled = false; - } + return errorResponse( + 'PSP_UNAVAILABLE', + 'Payment provider unavailable.', + 503 + ); + } - if (!enabled) { - logWarn('provider_disabled', { - requestedProvider: 'monobank', - requestId, - }); + if (!selectedProvider && !stripeRequestedButUnavailable) { + logWarn('checkout_provider_unavailable', { + ...baseMeta, + code: 'PAYMENTS_DISABLED', + requestedProvider, + requestedMethod, + localeCurrency, + candidates: checkoutProviderCandidates, + stripeCheckoutAvailable, + monobankCheckoutAvailable, + }); - return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); - } + return errorResponse( + 'PSP_UNAVAILABLE', + 'Payment provider unavailable.', + 503 + ); + } - if (!paymentsEnabled) { - logWarn('monobank_payments_disabled', { - ...baseMeta, - code: 'PAYMENTS_DISABLED', - }); + const resolvedProvider = selectedProvider ?? fallbackProvider; + if (!resolvedProvider) { + logWarn('checkout_provider_unavailable', { + ...baseMeta, + code: 'PAYMENTS_DISABLED', + requestedProvider, + requestedMethod, + localeCurrency, + candidates: checkoutProviderCandidates, + stripeCheckoutAvailable, + monobankCheckoutAvailable, + }); - return errorResponse( - 'PSP_UNAVAILABLE', - 'Payment provider unavailable.', - 503 - ); - } + return errorResponse( + 'PSP_UNAVAILABLE', + 'Payment provider unavailable.', + 503 + ); } if ( @@ -922,13 +962,13 @@ export async function POST(request: NextRequest) { if ( !isMethodAllowed({ - provider: selectedProvider, + provider: resolvedProvider, method: selectedMethod, currency: selectedCurrency, flags: { monobankGooglePayEnabled: isMonobankGooglePayEnabled() }, }) ) { - if (selectedProvider === 'monobank') { + if (resolvedProvider === 'monobank') { return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); } @@ -946,7 +986,7 @@ export async function POST(request: NextRequest) { ) { payloadForValidation = { ...(payloadForValidation as Record), - paymentProvider: selectedProvider, + paymentProvider: fallbackProvider, paymentMethod: selectedMethod, paymentCurrency: selectedCurrency, }; @@ -1133,7 +1173,7 @@ export async function POST(request: NextRequest) { country: country ?? null, shipping: shipping ?? null, legalConsent: legalConsent ?? null, - paymentProvider: checkoutPaymentProvider, + paymentProvider: resolvedProvider, paymentMethod: selectedMethod, })); diff --git a/frontend/app/api/shop/internal/orders/restock-stale/route.ts b/frontend/app/api/shop/internal/orders/restock-stale/route.ts index e0a8d792..a576cf14 100644 --- a/frontend/app/api/shop/internal/orders/restock-stale/route.ts +++ b/frontend/app/api/shop/internal/orders/restock-stale/route.ts @@ -8,6 +8,7 @@ import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor'; import { logError, logInfo, logWarn } from '@/lib/logging'; import { guardNonBrowserFailClosed } from '@/lib/security/origin'; import { + reconcileStaleStripeRefundOrders, restockStaleNoPaymentOrders, restockStalePendingOrders, restockStuckReservingOrders, @@ -478,17 +479,28 @@ export async function POST(request: NextRequest) { }); const remaining3 = Math.max(0, deadlineMs - Date.now()); - const processedIntlQuoteExpired = + const processedStripeRefundRecovery = remaining3 > 0 + ? await reconcileStaleStripeRefundOrders({ + olderThanMinutes: policy.olderThanMinutes.stalePending, + batchSize: policy.batchSize, + workerId, + timeBudgetMs: remaining3, + }) + : 0; + + const remaining4 = Math.max(0, deadlineMs - Date.now()); + const processedIntlQuoteExpired = + remaining4 > 0 ? await sweepExpiredOfferedIntlQuotes({ batchSize: policy.batchSize, now: new Date(), }) : 0; - const remaining4 = Math.max(0, deadlineMs - Date.now()); + const remaining5 = Math.max(0, deadlineMs - Date.now()); const processedIntlQuotePaymentTimeouts = - remaining4 > 0 + remaining5 > 0 ? await sweepAcceptedIntlQuotePaymentTimeouts({ batchSize: policy.batchSize, now: new Date(), @@ -499,6 +511,7 @@ export async function POST(request: NextRequest) { processedStuckReserving + processedStalePending + processedOrphanNoPayment + + processedStripeRefundRecovery + processedIntlQuoteExpired + processedIntlQuotePaymentTimeouts; @@ -513,6 +526,7 @@ export async function POST(request: NextRequest) { stuckReserving: processedStuckReserving, stalePending: processedStalePending, orphanNoPayment: processedOrphanNoPayment, + stripeRefundRecovery: processedStripeRefundRecovery, intlQuoteExpired: processedIntlQuoteExpired, intlQuotePaymentTimeout: processedIntlQuotePaymentTimeouts, }, @@ -535,6 +549,7 @@ export async function POST(request: NextRequest) { stuckReserving: processedStuckReserving, stalePending: processedStalePending, orphanNoPayment: processedOrphanNoPayment, + stripeRefundRecovery: processedStripeRefundRecovery, intlQuoteExpired: processedIntlQuoteExpired, intlQuotePaymentTimeout: processedIntlQuotePaymentTimeouts, }, diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 52f5dd63..2427a840 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -23,8 +23,12 @@ import { appendRefundToMeta, type RefundMetaRecord, } from '@/lib/services/orders/psp-metadata/refunds'; +import { + finalizeStripeRefundSuccess, + restoreStripeRefundFailure, +} from '@/lib/services/orders/stripe-refund-reconciliation'; import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; -import { inventoryCommittedForShippingSql } from '@/lib/services/shop/shipping/inventory-eligibility'; +import { orderShippingEligibilityWhereSql } from '@/lib/services/shop/shipping/eligibility'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; import { shippingStatusTransitionWhereSql } from '@/lib/services/shop/transitions/shipping-state'; @@ -429,6 +433,11 @@ function mergePspMetadata(params: { ...safeDelta, }; } + +type RestoreStripeRefundOrder = Parameters< + typeof restoreStripeRefundFailure +>[0]['order']; + function readDbRows(res: unknown): T[] { if (Array.isArray(res)) return res as T[]; const anyRes = res as { rows?: unknown }; @@ -523,11 +532,15 @@ async function applyStripePaidAndQueueShipmentAtomic( from orders o where o.id = ${args.orderId}::uuid and o.payment_provider = 'stripe' - and o.payment_status = 'paid' and o.shipping_required = true and o.shipping_provider = 'nova_poshta' and o.shipping_method_code is not null - and ${inventoryCommittedForShippingSql(sql`o.inventory_status`)} + 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 ( @@ -611,11 +624,15 @@ async function applyStripePaidAndQueueShipmentAtomic( from orders o where o.id = ${args.orderId}::uuid and o.payment_provider = 'stripe' - and o.payment_status = 'paid' and o.shipping_required = true and o.shipping_provider = 'nova_poshta' and o.shipping_method_code is not null - and ${inventoryCommittedForShippingSql(sql`o.inventory_status`)} + 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 ( @@ -681,87 +698,6 @@ async function applyStripePaidAndQueueShipmentAtomic( }; } -type StripeRefundApplyArgs = { - now: Date; - orderId: string; - paymentIntentId: string | null; - stripeEventId: string; - pspChargeId: string | null; - pspPaymentMethod: string | null; - pspStatusReason: string; - pspMetadata: Record; - canonicalDualWriteEnabled: boolean; - canonicalEventDedupeKey: string; - canonicalEventPayload: Record; -}; - -async function applyStripeRefundedAtomic( - args: StripeRefundApplyArgs -): Promise<{ applied: boolean }> { - const res = await db.execute(sql` - with updated_order as ( - update orders - set payment_status = 'refunded', - status = 'CANCELED', - updated_at = ${args.now}, - psp_charge_id = ${args.pspChargeId}, - psp_payment_method = ${args.pspPaymentMethod}, - psp_status_reason = ${args.pspStatusReason}, - psp_metadata = ${JSON.stringify(args.pspMetadata)}::jsonb - where id = ${args.orderId}::uuid - and payment_provider = 'stripe' - and payment_status = 'paid' - returning - id, - total_amount_minor, - currency - ), - inserted_payment_event as ( - insert into payment_events ( - order_id, - provider, - event_name, - event_source, - event_ref, - attempt_id, - provider_payment_intent_id, - provider_charge_id, - amount_minor, - currency, - payload, - dedupe_key, - occurred_at, - created_at - ) - select - uo.id, - 'stripe', - 'refund_applied', - 'stripe_webhook', - ${args.stripeEventId}, - null, - ${args.paymentIntentId}, - ${args.pspChargeId}, - uo.total_amount_minor::bigint, - uo.currency, - ${JSON.stringify(args.canonicalEventPayload)}::jsonb, - ${args.canonicalEventDedupeKey}, - ${args.now}, - ${args.now} - from updated_order uo - where ${args.canonicalDualWriteEnabled} = true - on conflict (dedupe_key) do nothing - returning id - ) - select (select count(*)::int from updated_order) as updated_count - `); - - const row = readDbRows<{ updated_count?: number }>(res)[0]; - return { - applied: Number(row?.updated_count ?? 0) > 0, - }; -} - export async function POST(request: NextRequest) { const startedAtMs = Date.now(); @@ -1120,11 +1056,15 @@ export async function POST(request: NextRequest) { const [order] = await db .select({ id: orders.id, + paymentProvider: orders.paymentProvider, paymentIntentId: orders.paymentIntentId, totalAmountMinor: orders.totalAmountMinor, currency: orders.currency, paymentStatus: orders.paymentStatus, status: orders.status, + pspChargeId: orders.pspChargeId, + pspPaymentMethod: orders.pspPaymentMethod, + pspStatusReason: orders.pspStatusReason, stockRestored: orders.stockRestored, inventoryStatus: orders.inventoryStatus, shippingRequired: orders.shippingRequired, @@ -1132,6 +1072,8 @@ export async function POST(request: NextRequest) { shippingMethodCode: orders.shippingMethodCode, shippingStatus: orders.shippingStatus, pspMetadata: orders.pspMetadata, + restockedAt: orders.restockedAt, + createdAt: orders.createdAt, }) .from(orders) .where(eq(orders.id, resolvedOrderId)) @@ -1150,6 +1092,33 @@ export async function POST(request: NextRequest) { return ack(); } + if (order.paymentProvider !== 'stripe') { + logWarn('stripe_webhook_provider_mismatch', { + ...eventMeta(), + code: 'PROVIDER_MISMATCH', + orderId: order.id, + paymentIntentId, + orderPaymentProvider: order.paymentProvider, + expectedPaymentProvider: 'stripe', + chargeId: bestEffortChargeId, + refundId: bestEffortRefundId, + reason: 'non_stripe_order_resolved', + }); + + logWebhookEvent({ + requestId, + stripeEventId, + orderId: order.id, + paymentIntentId, + paymentStatus, + eventType, + chargeId: bestEffortChargeId, + refundId: bestEffortRefundId, + }); + + return ack(); + } + if (order.paymentIntentId && order.paymentIntentId !== paymentIntentId) { logInfo('stripe_webhook_payment_intent_mismatch', { ...eventMeta(), @@ -1226,7 +1195,9 @@ export async function POST(request: NextRequest) { pspStatusReason: mismatchReason, pspMetadata: nextMeta, }) - .where(eq(orders.id, order.id)); + .where( + and(eq(orders.id, order.id), eq(orders.paymentProvider, 'stripe')) + ); logWarn('stripe_webhook_mismatch', { ...eventMeta(), @@ -1333,7 +1304,18 @@ export async function POST(request: NextRequest) { order.inventoryStatus === 'reserved' ) { await db.execute(sql` - with ensured_shipment as ( + with eligible_order as ( + select o.id + from orders o + where o.id = ${order.id}::uuid + and ${orderShippingEligibilityWhereSql({ + paymentStatusColumn: sql`o.payment_status`, + orderStatusColumn: sql`o.status`, + inventoryStatusColumn: sql`o.inventory_status`, + pspStatusReasonColumn: sql`o.psp_status_reason`, + })} + ), + ensured_shipment as ( insert into shipping_shipments ( order_id, provider, @@ -1341,14 +1323,15 @@ export async function POST(request: NextRequest) { attempt_count, created_at, updated_at - ) values ( - ${order.id}::uuid, + ) + select + eo.id, 'nova_poshta', 'queued', 0, ${now}, ${now} - ) + from eligible_order eo on conflict (order_id) do update set status = 'queued', updated_at = ${now} @@ -1359,7 +1342,7 @@ export async function POST(request: NextRequest) { existing_shipment as ( select order_id from shipping_shipments - where order_id = ${order.id}::uuid + where order_id in (select id from eligible_order) and status = 'queued' ), shipment_order_ids as ( @@ -1370,7 +1353,17 @@ export async function POST(request: NextRequest) { update orders set shipping_status = 'queued'::shipping_status, updated_at = ${now} - where id in (select order_id from shipment_order_ids) + where id in ( + select soid.order_id + from shipment_order_ids soid + join orders o on o.id = soid.order_id + where ${orderShippingEligibilityWhereSql({ + paymentStatusColumn: sql`o.payment_status`, + orderStatusColumn: sql`o.status`, + inventoryStatusColumn: sql`o.inventory_status`, + pspStatusReasonColumn: sql`o.psp_status_reason`, + })} + ) and shipping_status is distinct from 'queued'::shipping_status and ${shippingStatusTransitionWhereSql({ column: sql`shipping_status`, @@ -1705,6 +1698,52 @@ export async function POST(request: NextRequest) { isFullRefund = cumulativeRefunded === amt; } else if (eventType === 'charge.refund.updated' && refund) { + if ( + (refund.status === 'failed' || refund.status === 'canceled') && + order.pspStatusReason === 'REFUND_REQUESTED' + ) { + const now = new Date(); + const createdAtIso = now.toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: charge ?? undefined, + refund, + }); + + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); + + await restoreStripeRefundFailure({ + order: order as RestoreStripeRefundOrder, + now, + refundId: refund.id, + refundStatus: refund.status ?? null, + refundReason: refund.reason ?? null, + chargeId: charge?.id ?? refundChargeId ?? null, + paymentIntentId, + nextMeta, + }); + + logWebhookEvent({ + requestId, + stripeEventId, + orderId: order.id, + paymentIntentId, + paymentStatus, + eventType, + refundId: refund.id, + chargeId: charge?.id ?? refundChargeId ?? null, + }); + + return ack(); + } + if (isRefundChargeIdOnly(refund)) { warnRefundFullnessUndetermined({ ...warnBase, @@ -1869,6 +1908,114 @@ export async function POST(request: NextRequest) { const now = new Date(); const createdAtIso = now.toISOString(); + if ( + eventType === 'charge.refund.updated' && + refund && + refund.status !== 'succeeded' && + refund.status !== 'failed' && + refund.status !== 'canceled' + ) { + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: charge ?? undefined, + refund, + extra: { + refundGate: { + decision: 'waiting_terminal_refund_status', + refundStatus: refund.status ?? null, + eventId: event.id, + }, + }, + }); + + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); + + await db + .update(orders) + .set({ + updatedAt: now, + pspMetadata: nextMeta, + pspChargeId: charge?.id ?? refundChargeId ?? null, + pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), + }) + .where( + and(eq(orders.id, order.id), eq(orders.paymentProvider, 'stripe')) + ); + + logWebhookEvent({ + requestId, + stripeEventId, + orderId: order.id, + paymentIntentId, + paymentStatus, + eventType, + refundId: refund.id, + chargeId: charge?.id ?? refundChargeId ?? null, + }); + + return ack(); + } + + if ( + eventType === 'charge.refund.updated' && + refund && + (refund.status === 'failed' || refund.status === 'canceled') + ) { + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: charge ?? undefined, + refund, + extra: { + refundGate: { + decision: 'terminal_refund_failure_ignored', + refundStatus: refund.status ?? null, + eventId: event.id, + }, + }, + }); + + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); + + await db + .update(orders) + .set({ + updatedAt: now, + pspMetadata: nextMeta, + pspChargeId: charge?.id ?? refundChargeId ?? null, + pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), + }) + .where( + and(eq(orders.id, order.id), eq(orders.paymentProvider, 'stripe')) + ); + + logWebhookEvent({ + requestId, + stripeEventId, + orderId: order.id, + paymentIntentId, + paymentStatus, + eventType, + refundId: refund.id, + chargeId: charge?.id ?? refundChargeId ?? null, + }); + + return ack(); + } + if (!isFullRefund) { const deltaMeta = buildPspMetadata({ eventType, @@ -1904,7 +2051,9 @@ export async function POST(request: NextRequest) { pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), pspStatusReason: 'PARTIAL_REFUND_IGNORED', }) - .where(eq(orders.id, order.id)); + .where( + and(eq(orders.id, order.id), eq(orders.paymentProvider, 'stripe')) + ); logWebhookEvent({ requestId, @@ -1935,63 +2084,26 @@ export async function POST(request: NextRequest) { createdAtIso, }); - let canRestock = false; - if (canonicalDualWriteEnabled) { - const refundApply = await applyStripeRefundedAtomic({ - now, - orderId: order.id, - paymentIntentId, - stripeEventId: event.id, - pspChargeId: charge?.id ?? refundChargeId ?? null, - pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), - pspStatusReason: refund?.reason ?? refund?.status ?? 'refunded', - pspMetadata: nextMeta, - canonicalDualWriteEnabled, - canonicalEventDedupeKey: buildPaymentEventDedupeKey({ - provider: 'stripe', - orderId: order.id, - eventName: 'refund_applied', - eventSource: 'stripe_webhook', - stripeEventId: event.id, - paymentIntentId, - chargeId: charge?.id ?? refundChargeId ?? null, - refundId: refund?.id ?? null, - }), - canonicalEventPayload: { - stripeEventId: event.id, - eventType, - paymentIntentId, - chargeId: charge?.id ?? refundChargeId ?? null, - refundId: refund?.id ?? null, - }, - }); - canRestock = refundApply.applied || order.paymentStatus === 'refunded'; - } else { - const refundRes = await guardedPaymentStatusUpdate({ - orderId: order.id, - paymentProvider: 'stripe', - to: 'refunded', - source: 'stripe_webhook', - eventId: event.id, - note: eventType, - set: { - updatedAt: now, - status: 'CANCELED', - pspChargeId: charge?.id ?? refundChargeId ?? null, - pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), - pspStatusReason: refund?.reason ?? refund?.status ?? 'refunded', - pspMetadata: nextMeta, - }, - }); - - canRestock = - refundRes.applied || - (!refundRes.applied && refundRes.reason === 'ALREADY_IN_STATE'); - } - - if (canRestock) { - await restockOrder(order.id, { reason: 'refunded' }); - } + await finalizeStripeRefundSuccess({ + order: { + ...(order as any), + pspChargeId: order.pspChargeId ?? null, + pspPaymentMethod: + order.pspPaymentMethod ?? + resolvePaymentMethod(paymentIntent, charge), + pspStatusReason: order.pspStatusReason ?? null, + }, + now, + refundId: refund?.id ?? null, + refundStatus: refund?.status ?? null, + refundReason: refund?.reason ?? null, + paymentIntentId, + chargeId: charge?.id ?? refundChargeId ?? null, + nextMeta, + requireContainment: false, + source: 'stripe_webhook', + eventRef: event.id, + }); logWebhookEvent({ requestId, diff --git a/frontend/lib/psp/stripe.ts b/frontend/lib/psp/stripe.ts index 1805a745..1ad9ad1b 100644 --- a/frontend/lib/psp/stripe.ts +++ b/frontend/lib/psp/stripe.ts @@ -18,6 +18,14 @@ type CreateRefundInput = { idempotencyKey?: string; }; +type RetrieveRefundResult = { + refundId: string; + status: Stripe.Refund['status']; + reason: Stripe.Refund['reason'] | null; + chargeId: string | null; + paymentIntentId: string | null; +}; + let _stripe: Stripe | null = null; let _stripeKey: string | null = null; @@ -84,6 +92,48 @@ export async function createRefund({ } } +export async function retrieveRefund( + refundId: string +): Promise { + const { paymentsEnabled } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe) { + throw new Error('STRIPE_DISABLED'); + } + + if (!refundId || refundId.trim().length === 0) { + throw new Error('STRIPE_INVALID_REFUND_ID'); + } + + try { + const refund = await stripe.refunds.retrieve(refundId.trim(), { + expand: ['payment_intent', 'charge'], + }); + + const chargeId = + typeof refund.charge === 'string' + ? refund.charge.trim() || null + : (refund.charge?.id ?? null); + + const paymentIntentId = + typeof refund.payment_intent === 'string' + ? refund.payment_intent.trim() || null + : (refund.payment_intent?.id ?? null); + + return { + refundId: refund.id, + status: refund.status, + reason: refund.reason ?? null, + chargeId, + paymentIntentId, + }; + } catch (error) { + logError('Stripe refund retrieval failed', error); + throw withCause('STRIPE_REFUND_RETRIEVE_FAILED', error); + } +} + function getStripeClient(): Stripe | null { const { secretKey } = getStripeEnv(); if (!secretKey) return null; diff --git a/frontend/lib/services/orders.ts b/frontend/lib/services/orders.ts index f5bc3e53..505435b7 100644 --- a/frontend/lib/services/orders.ts +++ b/frontend/lib/services/orders.ts @@ -5,6 +5,10 @@ export { export { setOrderPaymentIntent } from './orders/payment-intent'; export { refundOrder } from './orders/refund'; export { restockOrder as restock, restockOrder } from './orders/restock'; +export { + reconcileStaleStripeRefundOrders, + reconcileStripeRefundOrder, +} from './orders/stripe-refund-reconciliation'; export { getCheckoutPaymentPageOrderSummary, getCheckoutSuccessOrderSummary, diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index 6e0f8def..656b30f1 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -14,7 +14,6 @@ import { } from '@/db/schema/shop'; import { getShopShippingFlags } from '@/lib/env/nova-poshta'; import { getShopLegalVersions } from '@/lib/env/shop-legal'; -import { isPaymentsEnabled } from '@/lib/env/stripe'; import { logError, logWarn } from '@/lib/logging'; import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability'; import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; @@ -29,6 +28,7 @@ import { type PaymentMethod, type PaymentProvider, type PaymentStatus, + resolveCheckoutProviderCandidates, resolveDefaultMethodForProvider, } from '@/lib/shop/payments'; import { @@ -872,28 +872,27 @@ export async function createOrderWithItems({ paymentProvider?: PaymentProvider; paymentMethod?: PaymentMethod | null; }): Promise { - const isMonobankRequested = requestedProvider === 'monobank'; - const currency: Currency = isMonobankRequested - ? 'UAH' - : resolveCurrencyFromLocale(locale); - const stripePaymentsEnabled = isPaymentsEnabled(); + if (requestedProvider === 'none') { + throw new InvalidPayloadError('paymentProvider "none" is not supported.', { + code: 'INVALID_PAYLOAD', + }); + } + + const localeCurrency: Currency = resolveCurrencyFromLocale(locale); + const checkoutProviderCandidates = resolveCheckoutProviderCandidates({ + requestedProvider: + requestedProvider === 'stripe' || requestedProvider === 'monobank' + ? requestedProvider + : null, + requestedMethod, + currency: localeCurrency, + }); const paymentProvider: PaymentProvider = - requestedProvider === 'none' - ? 'none' - : requestedProvider === 'monobank' - ? 'monobank' - : requestedProvider === 'stripe' - ? 'stripe' - : stripePaymentsEnabled - ? 'stripe' - : 'none'; - - const paymentsEnabled = - paymentProvider !== 'none' && - (paymentProvider === 'monobank' || stripePaymentsEnabled); - - const initialPaymentStatus: PaymentStatus = - paymentProvider === 'none' ? 'paid' : 'pending'; + checkoutProviderCandidates[0] ?? 'stripe'; + const currency: Currency = + paymentProvider === 'monobank' ? 'UAH' : localeCurrency; + + const initialPaymentStatus: PaymentStatus = 'pending'; const resolvedPaymentMethod = resolveCheckoutPaymentMethod({ requestedMethod, paymentProvider, @@ -1162,14 +1161,6 @@ export async function createOrderWithItems({ snapshot: preparedShipping.snapshot, }); } - if (!paymentsEnabled) { - const reconciled = await reconcileNoPaymentOrder(existing.id); - return { - order: reconciled, - isNew: false, - totalCents: requireTotalCents(reconciled), - }; - } return { order: existing, isNew: false, @@ -1264,7 +1255,7 @@ export async function createOrderWithItems({ status: 'CREATED', - inventoryStatus: paymentsEnabled ? 'none' : 'reserving', + inventoryStatus: 'none', failureCode: null, failureMessage: null, idempotencyRequestHash: requestHash, @@ -1336,14 +1327,6 @@ export async function createOrderWithItems({ snapshot: preparedShipping.snapshot, }); } - if (!paymentsEnabled) { - const reconciled = await reconcileNoPaymentOrder(existingOrder.id); - return { - order: reconciled, - isNew: false, - totalCents: requireTotalCents(reconciled), - }; - } return { order: existingOrder, isNew: false, @@ -1423,7 +1406,7 @@ export async function createOrderWithItems({ await db .update(orders) .set({ - status: paymentsEnabled ? 'INVENTORY_RESERVED' : 'PAID', + status: 'INVENTORY_RESERVED', inventoryStatus: 'reserved', failureCode: null, failureMessage: null, @@ -1431,8 +1414,7 @@ export async function createOrderWithItems({ }) .where(eq(orders.id, orderId)); - const targetPaymentStatus: PaymentStatus = - paymentProvider === 'none' ? 'paid' : 'pending'; + const targetPaymentStatus: PaymentStatus = 'pending'; const payRes = await guardedPaymentStatusUpdate({ orderId, diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts index 9ad5f322..b04d2fea 100644 --- a/frontend/lib/services/orders/monobank-webhook.ts +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -24,8 +24,8 @@ import { InvalidPayloadError } from '@/lib/services/errors'; 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 { - inventoryCommittedForShippingSql, isInventoryCommittedForShipping, } from '@/lib/services/shop/shipping/inventory-eligibility'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; @@ -632,11 +632,14 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { select uo.id from updated_order uo where ${args.enqueueShipment} = true - and uo.payment_status = 'paid' and uo.shipping_required = true and uo.shipping_provider = 'nova_poshta' and uo.shipping_method_code is not null - and ${inventoryCommittedForShippingSql(sql`uo.inventory_status`)} + and ${orderShippingEligibilityWhereSql({ + paymentStatusColumn: sql`uo.payment_status`, + orderStatusColumn: sql`'PAID'`, + inventoryStatusColumn: sql`uo.inventory_status`, + })} ), inserted_shipment as ( insert into shipping_shipments ( @@ -736,11 +739,14 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { select uo.id from updated_order uo where ${args.enqueueShipment} = true - and uo.payment_status = 'paid' and uo.shipping_required = true and uo.shipping_provider = 'nova_poshta' and uo.shipping_method_code is not null - and ${inventoryCommittedForShippingSql(sql`uo.inventory_status`)} + and ${orderShippingEligibilityWhereSql({ + paymentStatusColumn: sql`uo.payment_status`, + orderStatusColumn: sql`'PAID'`, + inventoryStatusColumn: sql`uo.inventory_status`, + })} ), inserted_shipment as ( insert into shipping_shipments ( @@ -841,11 +847,15 @@ async function ensureQueuedShipmentAndOrderShippingStatus(args: { from orders o where o.id = ${args.orderId}::uuid and o.payment_provider = 'monobank' - and o.payment_status = 'paid' and o.shipping_required = true and o.shipping_provider = 'nova_poshta' and o.shipping_method_code is not null - and ${inventoryCommittedForShippingSql(sql`o.inventory_status`)} + 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} diff --git a/frontend/lib/services/orders/refund.ts b/frontend/lib/services/orders/refund.ts index f83f43d7..82588832 100644 --- a/frontend/lib/services/orders/refund.ts +++ b/frontend/lib/services/orders/refund.ts @@ -1,8 +1,9 @@ -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { orders } from '@/db/schema/shop'; import { createRefund } from '@/lib/psp/stripe'; +import { closeShippingPipelineForOrder } from '@/lib/services/shop/shipping/pipeline-shutdown'; import { InvalidPayloadError, OrderNotFoundError } from '../errors'; import { @@ -23,6 +24,13 @@ function makeRefundIdempotencyKey( return `refund:${orderId}:${amountMinor}:${currency}`.slice(0, 128); } +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 []; +} + export async function refundOrder( orderId: string, opts?: { requestedBy?: string } @@ -101,6 +109,25 @@ export async function refundOrder( const now = new Date(); const createdAtIso = now.toISOString(); + const shipmentSnapshotRes = await db.execute<{ + shipment_id: string | null; + shipment_status: string | null; + }>(sql` + select + s.id::text as shipment_id, + s.status as shipment_status + from shipping_shipments s + where s.order_id = ${orderId}::uuid + order by s.created_at desc nulls last + limit 1 + `); + const latestShipment = readRows<{ + shipment_id: string | null; + shipment_status: string | null; + }>(shipmentSnapshotRes)[0] ?? { + shipment_id: null, + shipment_status: null, + }; const nextMeta = appendRefundToMeta({ prevMeta: order.pspMetadata, @@ -114,15 +141,40 @@ export async function refundOrder( status: status ?? null, }, }); + const containedMeta = { + ...nextMeta, + refundContainment: { + requestedAt: createdAtIso, + refundId, + orderShippingStatusBefore: order.shippingStatus ?? null, + latestShipmentIdBefore: latestShipment.shipment_id ?? null, + latestShipmentStatusBefore: latestShipment.shipment_status ?? null, + hadShipmentRowBefore: Boolean(latestShipment.shipment_id), + shippingRequiredBefore: order.shippingRequired === true, + shippingProviderBefore: order.shippingProvider ?? null, + shippingMethodCodeBefore: order.shippingMethodCode ?? null, + trackingNumberBefore: order.trackingNumber ?? null, + shippingProviderRefBefore: order.shippingProviderRef ?? null, + }, + }; - await db + const updated = await db .update(orders) .set({ updatedAt: now, pspStatusReason: 'REFUND_REQUESTED', - pspMetadata: nextMeta, + pspMetadata: containedMeta, }) - .where(eq(orders.id, orderId)); + .where(eq(orders.id, orderId)) + .returning({ id: orders.id }); + + if (updated.length > 0) { + await closeShippingPipelineForOrder({ + orderId, + reason: 'refund_requested', + now, + }); + } return await getOrderById(orderId); } diff --git a/frontend/lib/services/orders/stripe-refund-reconciliation.ts b/frontend/lib/services/orders/stripe-refund-reconciliation.ts new file mode 100644 index 00000000..ff64be32 --- /dev/null +++ b/frontend/lib/services/orders/stripe-refund-reconciliation.ts @@ -0,0 +1,657 @@ +import crypto from 'node:crypto'; + +import { + and, + eq, + inArray, + isNull, + lt, + or, + sql, + type SQLWrapper, +} from 'drizzle-orm'; + +import { db } from '@/db'; +import { orders } from '@/db/schema/shop'; +import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; +import { logDebug, logError } from '@/lib/logging'; +import { retrieveRefund } from '@/lib/psp/stripe'; +import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; + +import { + normalizeRefundsFromMeta, + type RefundMetaRecord, +} from './psp-metadata/refunds'; +import { restockOrder } from './restock'; + +type StripeReconcileSource = 'stripe_webhook' | 'stripe_reconciliation'; + +type RefundContainmentSnapshot = { + requestedAt: string | null; + refundId: string | null; + orderShippingStatusBefore: string | null; + latestShipmentIdBefore: string | null; + latestShipmentStatusBefore: string | null; + hadShipmentRowBefore: boolean; + shippingRequiredBefore: boolean; + shippingProviderBefore: string | null; + shippingMethodCodeBefore: string | null; + trackingNumberBefore: string | null; + shippingProviderRefBefore: string | null; +}; + +type StripeRefundOrderRow = { + id: string; + paymentProvider: string; + paymentStatus: string; + status: string; + inventoryStatus: string | null; + currency: string; + totalAmountMinor: number; + paymentIntentId: string | null; + pspChargeId: string | null; + pspPaymentMethod: string | null; + pspStatusReason: string | null; + pspMetadata: unknown; + shippingRequired: boolean | null; + shippingProvider: string | null; + shippingMethodCode: string | null; + shippingStatus: string | null; + stockRestored: boolean | null; + restockedAt: Date | null; + createdAt: Date; +}; + +type ReconcileRefundOrderResult = + | 'finalized_success' + | 'restored_failure' + | 'pending' + | 'noop'; + +function compactConditions(conds: Array): SQLWrapper[] { + return conds.filter((c): c is SQLWrapper => Boolean(c)); +} + +function readRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const anyRes = res as { rows?: unknown }; + if (Array.isArray(anyRes?.rows)) return anyRes.rows as T[]; + return []; +} + +function readMetaObject(meta: unknown): Record { + return meta && typeof meta === 'object' && !Array.isArray(meta) + ? (meta as Record) + : {}; +} + +function asTrimmedString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function toBoolean(value: unknown): boolean { + return value === true; +} + +function parseContainmentSnapshot(meta: unknown): RefundContainmentSnapshot { + const root = readMetaObject(meta); + const raw = readMetaObject(root.refundContainment); + + return { + requestedAt: asTrimmedString(raw.requestedAt), + refundId: asTrimmedString(raw.refundId), + orderShippingStatusBefore: asTrimmedString(raw.orderShippingStatusBefore), + latestShipmentIdBefore: asTrimmedString(raw.latestShipmentIdBefore), + latestShipmentStatusBefore: asTrimmedString(raw.latestShipmentStatusBefore), + hadShipmentRowBefore: toBoolean(raw.hadShipmentRowBefore), + shippingRequiredBefore: toBoolean(raw.shippingRequiredBefore), + shippingProviderBefore: asTrimmedString(raw.shippingProviderBefore), + shippingMethodCodeBefore: asTrimmedString(raw.shippingMethodCodeBefore), + trackingNumberBefore: asTrimmedString(raw.trackingNumberBefore), + shippingProviderRefBefore: asTrimmedString(raw.shippingProviderRefBefore), + }; +} + +function chooseRefundRecord( + order: StripeRefundOrderRow +): RefundMetaRecord | null { + const snapshot = parseContainmentSnapshot(order.pspMetadata); + const refunds = normalizeRefundsFromMeta(order.pspMetadata, { + currency: order.currency, + createdAt: order.createdAt.toISOString(), + }); + + if (snapshot.refundId) { + const bySnapshot = refunds.find(r => r.refundId === snapshot.refundId); + if (bySnapshot) return bySnapshot; + } + + return refunds[refunds.length - 1] ?? null; +} + +function updateRefundMetaStatus(args: { + prevMeta: unknown; + refundId: string; + status: string | null; +}): Record { + const base = readMetaObject(args.prevMeta); + const existingRefunds = normalizeRefundsFromMeta(base, { + currency: 'USD', + createdAt: new Date(0).toISOString(), + }); + + const updatedRefunds = existingRefunds.map(record => + record.refundId === args.refundId + ? { ...record, status: args.status ?? null } + : record + ); + + return { + ...base, + refunds: updatedRefunds, + }; +} + +async function loadStripeRefundOrder( + orderId: string +): Promise { + const [order] = await db + .select({ + id: orders.id, + paymentProvider: orders.paymentProvider, + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + currency: orders.currency, + totalAmountMinor: orders.totalAmountMinor, + paymentIntentId: orders.paymentIntentId, + pspChargeId: orders.pspChargeId, + pspPaymentMethod: orders.pspPaymentMethod, + pspStatusReason: orders.pspStatusReason, + pspMetadata: orders.pspMetadata, + shippingRequired: orders.shippingRequired, + shippingProvider: orders.shippingProvider, + shippingMethodCode: orders.shippingMethodCode, + shippingStatus: orders.shippingStatus, + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + createdAt: orders.createdAt, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + if (!order) return null; + + return { + ...order, + totalAmountMinor: Number(order.totalAmountMinor ?? 0), + paymentIntentId: order.paymentIntentId ?? null, + pspChargeId: order.pspChargeId ?? null, + pspPaymentMethod: order.pspPaymentMethod ?? null, + pspStatusReason: order.pspStatusReason ?? null, + shippingProvider: order.shippingProvider ?? null, + shippingMethodCode: order.shippingMethodCode ?? null, + shippingStatus: order.shippingStatus ?? null, + inventoryStatus: order.inventoryStatus ?? null, + stockRestored: order.stockRestored ?? null, + restockedAt: order.restockedAt ?? null, + }; +} + +export async function finalizeStripeRefundSuccess(args: { + order: StripeRefundOrderRow; + now: Date; + refundId: string | null; + refundStatus: string | null; + refundReason: string | null; + paymentIntentId: string | null; + chargeId: string | null; + nextMeta: Record; + requireContainment: boolean; + source: StripeReconcileSource; + eventRef: string; + restockAlreadyClaimed?: boolean; + restockWorkerId?: string; +}) { + const canonicalDualWriteEnabled = isCanonicalEventsDualWriteEnabled(); + const canonicalEventDedupeKey = buildPaymentEventDedupeKey({ + provider: 'stripe', + orderId: args.order.id, + eventName: 'refund_applied', + eventSource: args.source, + stripeEventId: args.eventRef, + paymentIntentId: args.paymentIntentId, + chargeId: args.chargeId, + refundId: args.refundId, + }); + + const res = await db.execute(sql` + with updated_order as ( + update orders + set payment_status = 'refunded', + status = 'CANCELED', + updated_at = ${args.now}, + psp_charge_id = ${args.chargeId}, + psp_payment_method = ${args.order.pspPaymentMethod}, + psp_status_reason = ${args.refundReason ?? args.refundStatus ?? 'refunded'}, + psp_metadata = ${JSON.stringify(args.nextMeta)}::jsonb + where id = ${args.order.id}::uuid + and payment_provider = 'stripe' + and payment_status = 'paid' + and ${ + args.requireContainment + ? sql`psp_status_reason = 'REFUND_REQUESTED'` + : sql`true` + } + returning + id, + total_amount_minor, + currency + ), + inserted_payment_event as ( + insert into payment_events ( + order_id, + provider, + event_name, + event_source, + event_ref, + attempt_id, + provider_payment_intent_id, + provider_charge_id, + amount_minor, + currency, + payload, + dedupe_key, + occurred_at, + created_at + ) + select + uo.id, + 'stripe', + 'refund_applied', + ${args.source}, + ${args.eventRef}, + null, + ${args.paymentIntentId}, + ${args.chargeId}, + uo.total_amount_minor::bigint, + uo.currency, + ${JSON.stringify({ + eventRef: args.eventRef, + refundId: args.refundId, + paymentIntentId: args.paymentIntentId, + chargeId: args.chargeId, + })}::jsonb, + ${canonicalEventDedupeKey}, + ${args.now}, + ${args.now} + from updated_order uo + where ${canonicalDualWriteEnabled} = true + on conflict (dedupe_key) do nothing + returning id + ) + select (select count(*)::int from updated_order) as updated_count + `); + + const updatedCount = + Number(readRows<{ updated_count?: number }>(res)[0]?.updated_count ?? 0) > + 0; + + if (updatedCount || args.order.paymentStatus === 'refunded') { + await restockOrder(args.order.id, { + reason: 'refunded', + alreadyClaimed: args.restockAlreadyClaimed, + workerId: args.restockWorkerId, + }); + return true; + } + + const latest = await loadStripeRefundOrder(args.order.id); + if (latest?.paymentStatus === 'refunded') { + await restockOrder(args.order.id, { + reason: 'refunded', + alreadyClaimed: args.restockAlreadyClaimed, + workerId: args.restockWorkerId, + }); + return true; + } + + return false; +} + +async function restoreShippingAfterRefundFailure(args: { + orderId: string; + snapshot: RefundContainmentSnapshot; + now: Date; +}) { + const shippingEligible = + args.snapshot.shippingRequiredBefore && + args.snapshot.shippingProviderBefore === 'nova_poshta' && + args.snapshot.shippingMethodCodeBefore !== null; + + const targetOrderShippingStatus = + args.snapshot.orderShippingStatusBefore ?? + (shippingEligible ? 'queued' : null); + + const shouldRequeueExistingShipment = + args.snapshot.latestShipmentIdBefore !== null && + (args.snapshot.latestShipmentStatusBefore === 'queued' || + args.snapshot.latestShipmentStatusBefore === 'processing' || + args.snapshot.latestShipmentStatusBefore === 'failed'); + + if (shouldRequeueExistingShipment) { + await db.execute(sql` + update shipping_shipments + set status = 'queued', + next_attempt_at = ${args.now}, + last_error_code = null, + last_error_message = null, + lease_owner = null, + lease_expires_at = null, + updated_at = ${args.now} + where id = ${args.snapshot.latestShipmentIdBefore}::uuid + and order_id = ${args.orderId}::uuid + and status = 'needs_attention' + `); + } else if ( + shippingEligible && + !args.snapshot.hadShipmentRowBefore && + targetOrderShippingStatus === 'queued' + ) { + await db.execute(sql` + insert into shipping_shipments ( + order_id, + provider, + status, + attempt_count, + created_at, + updated_at + ) + values ( + ${args.orderId}::uuid, + 'nova_poshta', + 'queued', + 0, + ${args.now}, + ${args.now} + ) + on conflict (order_id) do update + set status = 'queued', + next_attempt_at = ${args.now}, + lease_owner = null, + lease_expires_at = null, + updated_at = ${args.now} + where shipping_shipments.provider = 'nova_poshta' + and shipping_shipments.status is distinct from 'queued' + `); + } + + if (targetOrderShippingStatus !== null) { + await db.execute(sql` + update orders + set shipping_status = ${targetOrderShippingStatus}::shipping_status, + updated_at = ${args.now} + where id = ${args.orderId}::uuid + and shipping_status = 'cancelled'::shipping_status + `); + } +} + +export async function restoreStripeRefundFailure(args: { + order: StripeRefundOrderRow; + now: Date; + refundId: string; + refundStatus: string | null; + refundReason: string | null; + chargeId: string | null; + paymentIntentId: string | null; + nextMeta: Record; +}) { + const containmentSnapshot = parseContainmentSnapshot(args.order.pspMetadata); + + const res = await db.execute(sql` + update orders + set payment_status = 'paid', + status = 'PAID', + updated_at = ${args.now}, + psp_charge_id = ${args.chargeId}, + psp_payment_method = ${args.order.pspPaymentMethod}, + psp_status_reason = ${args.refundReason ?? args.refundStatus ?? 'failed'}, + psp_metadata = ${JSON.stringify(args.nextMeta)}::jsonb + where id = ${args.order.id}::uuid + and payment_provider = 'stripe' + and payment_status = 'paid' + and psp_status_reason = 'REFUND_REQUESTED' + returning id + `); + + const restored = readRows<{ id: string }>(res).length > 0; + if (restored) { + await restoreShippingAfterRefundFailure({ + orderId: args.order.id, + snapshot: containmentSnapshot, + now: args.now, + }); + return true; + } + + const latest = await loadStripeRefundOrder(args.order.id); + return ( + latest?.paymentStatus === 'paid' && + latest.pspStatusReason !== 'REFUND_REQUESTED' + ); +} + +export async function reconcileStripeRefundOrder(args: { + orderId: string; + source?: StripeReconcileSource; + restockAlreadyClaimed?: boolean; + restockWorkerId?: string; +}): Promise { + const order = await loadStripeRefundOrder(args.orderId); + if (!order || order.paymentProvider !== 'stripe') return 'noop'; + if (order.pspStatusReason !== 'REFUND_REQUESTED') return 'noop'; + + const refundRecord = chooseRefundRecord(order); + if (!refundRecord?.refundId) return 'noop'; + + const refund = await retrieveRefund(refundRecord.refundId); + const now = new Date(); + const nextMeta = updateRefundMetaStatus({ + prevMeta: order.pspMetadata, + refundId: refund.refundId, + status: refund.status ?? null, + }); + + if (refund.status === 'succeeded') { + const finalized = await finalizeStripeRefundSuccess({ + order, + now, + refundId: refund.refundId, + refundStatus: refund.status ?? null, + refundReason: refund.reason ?? null, + paymentIntentId: refund.paymentIntentId ?? order.paymentIntentId, + chargeId: refund.chargeId ?? order.pspChargeId, + nextMeta, + requireContainment: true, + source: args.source ?? 'stripe_reconciliation', + eventRef: refund.refundId, + restockAlreadyClaimed: args.restockAlreadyClaimed, + restockWorkerId: args.restockWorkerId, + }); + + return finalized ? 'finalized_success' : 'noop'; + } + + if (refund.status === 'failed' || refund.status === 'canceled') { + const restored = await restoreStripeRefundFailure({ + order, + now, + refundId: refund.refundId, + refundStatus: refund.status ?? null, + refundReason: refund.reason ?? null, + chargeId: refund.chargeId ?? order.pspChargeId, + paymentIntentId: refund.paymentIntentId ?? order.paymentIntentId, + nextMeta, + }); + return restored ? 'restored_failure' : 'noop'; + } + + await db + .update(orders) + .set({ + updatedAt: now, + pspMetadata: nextMeta, + }) + .where( + and( + eq(orders.id, order.id), + eq(orders.paymentProvider, 'stripe'), + eq(orders.pspStatusReason, 'REFUND_REQUESTED') + ) + ); + + return 'pending'; +} + +async function claimRefundOrdersForSweepBatch(args: { + now: Date; + claimExpiresAt: Date; + runId: string; + workerId: string; + batchSize: number; + baseConditions: SQLWrapper[]; +}): Promise> { + const claimable = db + .select({ id: orders.id }) + .from(orders) + .where(and(...args.baseConditions)) + .orderBy(orders.updatedAt, orders.createdAt) + .limit(args.batchSize) + .for('update', { skipLocked: true }); + + return db + .update(orders) + .set({ + sweepClaimedAt: args.now, + sweepClaimExpiresAt: args.claimExpiresAt, + sweepRunId: args.runId, + sweepClaimedBy: args.workerId, + updatedAt: args.now, + }) + .where( + and( + inArray(orders.id, claimable), + or( + isNull(orders.sweepClaimExpiresAt), + lt(orders.sweepClaimExpiresAt, args.now) + ) + ) + ) + .returning({ id: orders.id }); +} + +export async function reconcileStaleStripeRefundOrders(options?: { + olderThanMinutes?: number; + batchSize?: number; + claimTtlMinutes?: number; + workerId?: string; + timeBudgetMs?: number; + orderIds?: string[]; +}): Promise { + const olderThanMinutes = Math.max( + 1, + Math.min(60 * 24 * 7, Math.floor(Number(options?.olderThanMinutes ?? 15))) + ); + const batchSize = Math.max( + 1, + Math.min(100, Math.floor(Number(options?.batchSize ?? 50))) + ); + const claimTtlMinutes = Math.max( + 1, + Math.min(60, Math.floor(Number(options?.claimTtlMinutes ?? 5))) + ); + const timeBudgetMs = Math.max( + 0, + Math.min(25_000, Math.floor(Number(options?.timeBudgetMs ?? 20_000))) + ); + const deadlineMs = Date.now() + timeBudgetMs; + const workerId = + (options?.workerId ?? 'stripe-refund-reconcile-sweep').trim() || + 'stripe-refund-reconcile-sweep'; + + const cutoff = new Date(Date.now() - olderThanMinutes * 60 * 1000); + const runId = crypto.randomUUID(); + let processed = 0; + let loopCount = 0; + + while (true) { + if (Date.now() >= deadlineMs) break; + loopCount += 1; + + const now = new Date(); + const claimExpiresAt = new Date( + now.getTime() + claimTtlMinutes * 60 * 1000 + ); + + const baseConditions = compactConditions([ + eq(orders.paymentProvider, 'stripe'), + eq(orders.paymentStatus, 'paid'), + eq(orders.pspStatusReason, 'REFUND_REQUESTED'), + or( + isNull(orders.sweepClaimExpiresAt), + lt(orders.sweepClaimExpiresAt, now) + ), + options?.orderIds?.length + ? inArray(orders.id, options.orderIds) + : undefined, + options?.orderIds?.length ? undefined : lt(orders.updatedAt, cutoff), + ]); + + const claimed = await claimRefundOrdersForSweepBatch({ + now, + claimExpiresAt, + runId, + workerId, + batchSize, + baseConditions, + }); + + logDebug('orders_sweep_claim_batch', { + sweep: 'stripe_refund_reconcile', + runId, + loopCount, + claimedCount: claimed.length, + }); + + if (!claimed.length) break; + + for (const { id } of claimed) { + if (Date.now() >= deadlineMs) break; + + try { + const result = await reconcileStripeRefundOrder({ + orderId: id, + source: 'stripe_reconciliation', + restockAlreadyClaimed: true, + restockWorkerId: workerId, + }); + if (result === 'finalized_success' || result === 'restored_failure') { + processed += 1; + } + } catch (error) { + logError('stripe_refund_reconcile_failed', error, { + orderId: id, + workerId, + runId, + code: 'STRIPE_REFUND_RECONCILE_FAILED', + }); + } + } + } + + return processed; +} diff --git a/frontend/lib/services/shop/returns.ts b/frontend/lib/services/shop/returns.ts index 98f05fc6..60d9f117 100644 --- a/frontend/lib/services/shop/returns.ts +++ b/frontend/lib/services/shop/returns.ts @@ -4,7 +4,6 @@ import { asc, eq, inArray, sql } from 'drizzle-orm'; import { db } from '@/db'; import { orderItems, orders, returnItems, returnRequests } from '@/db/schema'; -import { createRefund } from '@/lib/psp/stripe'; import { InvalidPayloadError } from '@/lib/services/errors'; import { buildAdminAuditDedupeKey } from '@/lib/services/shop/events/dedupe-key'; import { buildShippingEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; @@ -957,108 +956,11 @@ export async function refundReturnRequest(args: { returnRequestId: string; actorUserId: string | null; requestId: string; -}) { - const current = await loadReturnById(args.returnRequestId); - if (!current) { - throw returnError('RETURN_NOT_FOUND', 'Return request not found.'); - } - if (current.status === 'refunded') { - return { changed: false, row: current }; - } - if (!isReturnStatusTransitionAllowed(current.status, 'refunded')) { - throw returnError( - 'RETURN_REFUND_STATE_INVALID', - 'Refund is allowed only after return is received.', - { returnRequestId: current.id, status: current.status } - ); - } - if ( - !Number.isInteger(current.refundAmountMinor) || - current.refundAmountMinor <= 0 - ) { - throw returnError( - 'RETURN_REFUND_AMOUNT_INVALID', - 'Refund amount is invalid.', - { - returnRequestId: current.id, - refundAmountMinor: current.refundAmountMinor, - } - ); - } - - const order = await loadOrder(current.orderId); - if (!order) { - throw returnError( - 'RETURN_NOT_FOUND', - 'Order not found for return request.' - ); - } - - if (order.paymentProvider !== 'stripe') { - throw returnError( - 'RETURN_REFUND_PROVIDER_UNSUPPORTED', - 'Return refund is supported only for Stripe orders.' - ); - } - if (order.paymentStatus !== 'paid') { - throw returnError( - 'RETURN_REFUND_PAYMENT_STATUS_INVALID', - 'Order payment status is not refundable.', - { paymentStatus: order.paymentStatus } - ); - } - if (!order.paymentIntentId && !order.pspChargeId) { - throw returnError( - 'RETURN_REFUND_MISSING_PSP_TARGET', - 'Missing Stripe identifiers for refund.' - ); - } - - const refundIdempotencyKey = - `return_refund:${current.id}:${current.refundAmountMinor}:${current.currency}`.slice( - 0, - 128 - ); - - let refundResult: { refundId: string; status: string | null }; - try { - refundResult = await createRefund({ - orderId: order.id, - paymentIntentId: order.paymentIntentId, - chargeId: order.pspChargeId, - amountMinor: current.refundAmountMinor, - idempotencyKey: refundIdempotencyKey, - }); - } catch (error) { - throw new InvalidPayloadError('Payment provider unavailable.', { - code: 'PSP_UNAVAILABLE', - details: { - returnRequestId: current.id, - reason: error instanceof Error ? error.message : String(error), - }, - }); - } - - return applyTransition({ - returnRequestId: args.returnRequestId, - actorUserId: args.actorUserId, - requestId: args.requestId, - expectedFrom: 'received', - statusTo: 'refunded', - action: 'return.refund', - eventName: 'return_refunded', - setClause: sql` - refunded_at = ${new Date()}, - refunded_by = ${args.actorUserId}, - refund_provider_ref = ${refundResult.refundId} - `, - payload: { +}): Promise { + throw new InvalidPayloadError('Return refunds are disabled.', { + code: 'RETURN_REFUND_DISABLED', + details: { returnRequestId: args.returnRequestId, - actorUserId: args.actorUserId, - refundId: refundResult.refundId, - refundStatus: refundResult.status, - refundAmountMinor: current.refundAmountMinor, - currency: current.currency, }, }); } diff --git a/frontend/lib/services/shop/shipping/admin-actions.ts b/frontend/lib/services/shop/shipping/admin-actions.ts index e2791e63..dcf116b8 100644 --- a/frontend/lib/services/shop/shipping/admin-actions.ts +++ b/frontend/lib/services/shop/shipping/admin-actions.ts @@ -22,6 +22,7 @@ type ShippingStateRow = { payment_status: string | null; order_status: string | null; inventory_status: string | null; + psp_status_reason: string | null; shipping_required: boolean | null; shipping_provider: string | null; shipping_method_code: string | null; @@ -132,6 +133,7 @@ function assertOrderIsShippable( paymentStatus: state.payment_status, orderStatus: state.order_status, inventoryStatus: state.inventory_status, + pspStatusReason: state.psp_status_reason, }); if (!eligibility.ok) { throw new ShippingAdminActionError( @@ -142,6 +144,27 @@ function assertOrderIsShippable( } } +function assertShipmentSupportsManualFulfillment( + state: ShippingStateRow, + action: 'mark_shipped' | 'mark_delivered' +) { + if (!state.shipment_id) { + throw new ShippingAdminActionError( + 'SHIPMENT_NOT_FOUND', + 'Shipment record does not exist for this order.', + 409 + ); + } + + if (state.shipment_status !== 'succeeded') { + throw new ShippingAdminActionError( + 'SHIPMENT_STATE_INCOMPATIBLE', + `${action} requires a succeeded shipment record.`, + 409 + ); + } +} + async function loadShippingState( orderId: string ): Promise { @@ -151,6 +174,7 @@ async function loadShippingState( o.payment_status, o.status as order_status, o.inventory_status, + o.psp_status_reason, o.shipping_required, o.shipping_provider, o.shipping_method_code, @@ -594,6 +618,8 @@ export async function applyShippingAdminAction(args: { }; } + assertShipmentSupportsManualFulfillment(state, 'mark_shipped'); + if (!isShippingStatusTransitionAllowed(state.shipping_status, 'shipped')) { throw new ShippingAdminActionError( 'INVALID_SHIPPING_TRANSITION', @@ -691,6 +717,8 @@ export async function applyShippingAdminAction(args: { }; } + assertShipmentSupportsManualFulfillment(state, 'mark_delivered'); + if (!isShippingStatusTransitionAllowed(state.shipping_status, 'delivered')) { throw new ShippingAdminActionError( 'INVALID_SHIPPING_TRANSITION', diff --git a/frontend/lib/services/shop/shipping/eligibility.ts b/frontend/lib/services/shop/shipping/eligibility.ts index 5689f8d4..b65e1e37 100644 --- a/frontend/lib/services/shop/shipping/eligibility.ts +++ b/frontend/lib/services/shop/shipping/eligibility.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, type SQL, type SQLWrapper } from 'drizzle-orm'; +import { and, eq, inArray, type SQL, sql, type SQLWrapper } from 'drizzle-orm'; import { inventoryCommittedForShippingSql, @@ -7,14 +7,17 @@ import { const SHIPPABLE_PAYMENT_STATUS = 'paid'; const SHIPPABLE_ORDER_STATUSES = ['PAID'] as const; +const REFUND_CONTAINMENT_REASON = 'REFUND_REQUESTED'; export type OrderShippingEligibilityInput = { paymentStatus: string | null | undefined; orderStatus: string | null | undefined; inventoryStatus: string | null | undefined; + pspStatusReason?: string | null | undefined; }; export type OrderShippingEligibilityFailureCode = + | 'REFUND_CONTAINED' | 'PAYMENT_NOT_PAID' | 'ORDER_NOT_FULFILLABLE' | 'INVENTORY_NOT_COMMITTED'; @@ -36,6 +39,14 @@ export type OrderShippingEligibilityResult = export function evaluateOrderShippingEligibility( state: OrderShippingEligibilityInput ): OrderShippingEligibilityResult { + if (state.pspStatusReason === REFUND_CONTAINMENT_REASON) { + return { + ok: false, + code: 'REFUND_CONTAINED', + message: 'Order refund is pending finalization.', + }; + } + if (state.paymentStatus !== SHIPPABLE_PAYMENT_STATUS) { return { ok: false, @@ -67,8 +78,12 @@ export function orderShippingEligibilityWhereSql(args: { paymentStatusColumn: SQLWrapper; orderStatusColumn: SQLWrapper; inventoryStatusColumn: SQLWrapper; + pspStatusReasonColumn?: SQLWrapper; }): SQL { return and( + args.pspStatusReasonColumn + ? sql`${args.pspStatusReasonColumn} is distinct from ${REFUND_CONTAINMENT_REASON}` + : undefined, eq(args.paymentStatusColumn, SHIPPABLE_PAYMENT_STATUS), inArray(args.orderStatusColumn, SHIPPABLE_ORDER_STATUSES), inventoryCommittedForShippingSql(args.inventoryStatusColumn) diff --git a/frontend/lib/services/shop/shipping/shipments-worker.ts b/frontend/lib/services/shop/shipping/shipments-worker.ts index 78afe7ba..f808d75d 100644 --- a/frontend/lib/services/shop/shipping/shipments-worker.ts +++ b/frontend/lib/services/shop/shipping/shipments-worker.ts @@ -38,6 +38,7 @@ type OrderShippingDetailsRow = { payment_status: string | null; status: string | null; inventory_status: string | null; + psp_status_reason: string | null; shipping_required: boolean | null; shipping_provider: string | null; shipping_method_code: string | null; @@ -418,12 +419,26 @@ export async function claimQueuedShipmentsForProcessing(args: { with candidates as ( select s.id, - s.order_id + s.order_id, + s.status as candidate_status from shipping_shipments s + join orders o on o.id = s.order_id where ( ( s.status in ('queued', 'failed') and (s.next_attempt_at is null or s.next_attempt_at <= now()) + and ${orderShippingEligibilityWhereSql({ + paymentStatusColumn: sql`o.payment_status`, + orderStatusColumn: sql`o.status`, + inventoryStatusColumn: sql`o.inventory_status`, + pspStatusReasonColumn: sql`o.psp_status_reason`, + })} + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: 'creating_label', + allowNullFrom: true, + includeSame: true, + })} ) or s.status = 'processing' ) @@ -439,19 +454,7 @@ export async function claimQueuedShipmentsForProcessing(args: { lease_expires_at = now() + make_interval(secs => ${args.leaseSeconds}), updated_at = now() from candidates c - join orders o on o.id = c.order_id where s.id = c.id - and ${orderShippingEligibilityWhereSql({ - paymentStatusColumn: sql`o.payment_status`, - orderStatusColumn: sql`o.status`, - inventoryStatusColumn: sql`o.inventory_status`, - })} - and ${shippingStatusTransitionWhereSql({ - column: sql`o.shipping_status`, - to: 'creating_label', - allowNullFrom: true, - includeSame: true, - })} returning s.id, s.order_id, @@ -540,6 +543,7 @@ async function loadOrderShippingDetails( o.payment_status as payment_status, o.status as status, o.inventory_status as inventory_status, + o.psp_status_reason as psp_status_reason, o.shipping_required as shipping_required, o.shipping_provider as shipping_provider, o.shipping_method_code as shipping_method_code, @@ -553,6 +557,42 @@ async function loadOrderShippingDetails( return readRows(res)[0] ?? null; } +function assertOrderStillShippable(details: OrderShippingDetailsRow) { + const eligibility = evaluateOrderShippingEligibility({ + paymentStatus: details.payment_status, + orderStatus: details.status, + inventoryStatus: details.inventory_status, + pspStatusReason: details.psp_status_reason, + }); + if (!eligibility.ok) { + throw buildFailure('ORDER_NOT_SHIPPABLE', eligibility.message, false); + } + + if (details.shipping_required !== true) { + throw buildFailure( + 'SHIPPING_NOT_REQUIRED', + 'Order does not require shipping.', + false + ); + } + + if (details.shipping_provider !== 'nova_poshta') { + throw buildFailure( + 'SHIPPING_PROVIDER_UNSUPPORTED', + 'Shipping provider is unsupported.', + false + ); + } + + if (!details.shipping_method_code) { + throw buildFailure( + 'SHIPPING_METHOD_MISSING', + 'Shipping method is missing.', + false + ); + } +} + async function markSucceeded(args: { shipmentId: string; runId: string; @@ -576,8 +616,21 @@ async function markSucceeded(args: { lease_owner = null, lease_expires_at = null, updated_at = now() + from orders o where s.id = ${args.shipmentId}::uuid and s.lease_owner = ${args.runId} + and o.id = s.order_id + and ${orderShippingEligibilityWhereSql({ + paymentStatusColumn: sql`o.payment_status`, + orderStatusColumn: sql`o.status`, + inventoryStatusColumn: sql`o.inventory_status`, + pspStatusReasonColumn: sql`o.psp_status_reason`, + })} + and ${shippingStatusTransitionWhereSql({ + column: sql`o.shipping_status`, + to: 'label_created', + allowNullFrom: true, + })} returning s.order_id ), updated_order as ( @@ -687,42 +740,43 @@ async function processClaimedShipment(args: { } try { - const eligibility = evaluateOrderShippingEligibility({ - paymentStatus: details.payment_status, - orderStatus: details.status, - inventoryStatus: details.inventory_status, - }); - if (!eligibility.ok) { - throw buildFailure('ORDER_NOT_SHIPPABLE', eligibility.message, false); - } + assertOrderStillShippable(details); - if (details.shipping_required !== true) { + const parsedSnapshot = parseSnapshot(details.shipping_address); + + if (parsedSnapshot.methodCode !== details.shipping_method_code) { throw buildFailure( - 'SHIPPING_NOT_REQUIRED', - 'Order does not require shipping.', + 'SHIPPING_METHOD_MISMATCH', + 'Shipping method does not match persisted order method.', false ); } - if (details.shipping_provider !== 'nova_poshta') { - throw buildFailure( - 'SHIPPING_PROVIDER_UNSUPPORTED', - 'Shipping provider is unsupported.', - false - ); + const latestDetails = await loadOrderShippingDetails(args.claim.order_id); + if (!latestDetails) { + throw buildFailure('ORDER_NOT_FOUND', 'Order was not found.', false); } - if (!details.shipping_method_code) { + assertOrderStillShippable(latestDetails); + + const latestSnapshot = parseSnapshot(latestDetails.shipping_address); + if (latestSnapshot.methodCode !== latestDetails.shipping_method_code) { throw buildFailure( - 'SHIPPING_METHOD_MISSING', - 'Shipping method is missing.', + 'SHIPPING_METHOD_MISMATCH', + 'Shipping method does not match persisted order method.', false ); } - const parsedSnapshot = parseSnapshot(details.shipping_address); + const finalDetails = await loadOrderShippingDetails(args.claim.order_id); + if (!finalDetails) { + throw buildFailure('ORDER_NOT_FOUND', 'Order was not found.', false); + } - if (parsedSnapshot.methodCode !== details.shipping_method_code) { + assertOrderStillShippable(finalDetails); + + const finalSnapshot = parseSnapshot(finalDetails.shipping_address); + if (finalSnapshot.methodCode !== finalDetails.shipping_method_code) { throw buildFailure( 'SHIPPING_METHOD_MISMATCH', 'Shipping method does not match persisted order method.', @@ -730,12 +784,12 @@ async function processClaimedShipment(args: { ); } - const payload = toNpPayload({ - order: details, - snapshot: parsedSnapshot, + const finalPayload = toNpPayload({ + order: finalDetails, + snapshot: finalSnapshot, }); - const created = await createInternetDocument(payload); + const created = await createInternetDocument(finalPayload); const marked = await markSucceeded({ shipmentId: args.claim.id, diff --git a/frontend/lib/shop/payments.ts b/frontend/lib/shop/payments.ts index 789e14a5..42d82a81 100644 --- a/frontend/lib/shop/payments.ts +++ b/frontend/lib/shop/payments.ts @@ -15,6 +15,8 @@ export const paymentProviderValues = ['stripe', 'monobank', 'none'] as const; export type PaymentProvider = (typeof paymentProviderValues)[number]; +export type CheckoutPaymentProvider = Exclude; + export const paymentMethodValues = [ 'stripe_card', 'monobank_invoice', @@ -23,6 +25,37 @@ export const paymentMethodValues = [ export type PaymentMethod = (typeof paymentMethodValues)[number]; +export function inferCheckoutProviderFromMethod( + method: PaymentMethod | null | undefined +): CheckoutPaymentProvider | null { + if (method === 'stripe_card') return 'stripe'; + if (method === 'monobank_invoice' || method === 'monobank_google_pay') { + return 'monobank'; + } + + return null; +} + +export function resolveCheckoutProviderCandidates(args: { + requestedProvider?: CheckoutPaymentProvider | null; + requestedMethod?: PaymentMethod | null; + currency: CurrencyCode; +}): readonly CheckoutPaymentProvider[] { + const explicitProvider = + args.requestedProvider ?? + inferCheckoutProviderFromMethod(args.requestedMethod); + + if (explicitProvider) { + return [explicitProvider]; + } + + if (args.currency === 'UAH') { + return ['monobank', 'stripe']; + } + + return ['stripe']; +} + export function resolveDefaultMethodForProvider( provider: PaymentProvider, currency: CurrencyCode 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 8d262580..543e6516 100644 --- a/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts +++ b/frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts @@ -19,6 +19,7 @@ async function cleanup(orderId: string) { describe.sequential('admin shipping action canonical audit', () => { it('mark_shipped inserts admin_audit_log row by default', async () => { const orderId = crypto.randomUUID(); + const shipmentId = crypto.randomUUID(); const requestId = `req_${crypto.randomUUID()}`; await db.insert(orders).values({ @@ -39,6 +40,17 @@ describe.sequential('admin shipping action canonical audit', () => { idempotencyKey: crypto.randomUUID(), } as any); + await db.insert(shippingShipments).values({ + id: shipmentId, + orderId, + provider: 'nova_poshta', + status: 'succeeded', + attemptCount: 1, + leaseOwner: null, + leaseExpiresAt: null, + nextAttemptAt: null, + } as any); + try { const result = await applyShippingAdminAction({ orderId, @@ -71,6 +83,7 @@ describe.sequential('admin shipping action canonical audit', () => { it('mark_delivered works after valid mark_shipped transition', async () => { const orderId = crypto.randomUUID(); + const shipmentId = crypto.randomUUID(); const shippedRequestId = `req_${crypto.randomUUID()}`; const deliveredRequestId = `req_${crypto.randomUUID()}`; @@ -92,6 +105,17 @@ describe.sequential('admin shipping action canonical audit', () => { idempotencyKey: crypto.randomUUID(), } as any); + await db.insert(shippingShipments).values({ + id: shipmentId, + orderId, + provider: 'nova_poshta', + status: 'succeeded', + attemptCount: 1, + leaseOwner: null, + leaseExpiresAt: null, + nextAttemptAt: null, + } as any); + try { const shipped = await applyShippingAdminAction({ orderId, 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 86a315c5..b58c93fa 100644 --- a/frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts +++ b/frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts @@ -32,13 +32,14 @@ type SeedArgs = { | 'release_pending' | 'released' | 'failed'; + pspStatusReason?: string | null; }; type Seeded = { orderId: string; shipmentId: string | null; shippingStatus: 'needs_attention' | 'label_created' | 'shipped' | 'cancelled'; - shipmentStatus: 'failed' | 'needs_attention' | null; + shipmentStatus: 'failed' | 'needs_attention' | 'succeeded' | null; }; function defaultStateForAction( @@ -53,12 +54,12 @@ function defaultStateForAction( if (action === 'mark_shipped') { return { shippingStatus: 'label_created', - shipmentStatus: null, + shipmentStatus: 'succeeded', }; } return { shippingStatus: 'shipped', - shipmentStatus: null, + shipmentStatus: 'succeeded', }; } @@ -70,7 +71,8 @@ async function seedOrder(args: SeedArgs): Promise { const targetIsShippable = args.paymentStatus === 'paid' && args.orderStatus === 'PAID' && - args.inventoryStatus === 'reserved'; + args.inventoryStatus === 'reserved' && + (args.pspStatusReason ?? null) !== 'REFUND_REQUESTED'; const requiresPostInsertBlockedTransition = !targetIsShippable; @@ -95,6 +97,7 @@ async function seedOrder(args: SeedArgs): Promise { paymentStatus: seedPaymentStatus, status: seedOrderStatus, inventoryStatus: seedInventoryStatus, + pspStatusReason: args.pspStatusReason ?? null, shippingRequired: true, shippingPayer: 'customer', shippingProvider: 'nova_poshta', @@ -124,6 +127,7 @@ async function seedOrder(args: SeedArgs): Promise { paymentStatus: args.paymentStatus, status: args.orderStatus, inventoryStatus: args.inventoryStatus, + pspStatusReason: args.pspStatusReason ?? null, } as any) .where(eq(orders.id, orderId)); } @@ -188,6 +192,13 @@ describe.sequential('admin shipping action payment gate', () => { orderStatus: 'CANCELED' as const, inventoryStatus: 'reserved' as const, }, + { + title: 'refund containment is active', + paymentStatus: 'paid' as const, + orderStatus: 'PAID' as const, + inventoryStatus: 'reserved' as const, + pspStatusReason: 'REFUND_REQUESTED' as const, + }, ]; const actions: readonly Action[] = [ @@ -204,6 +215,7 @@ describe.sequential('admin shipping action payment gate', () => { paymentStatus: invalidCase.paymentStatus, orderStatus: invalidCase.orderStatus, inventoryStatus: invalidCase.inventoryStatus, + pspStatusReason: invalidCase.pspStatusReason ?? null, }); try { diff --git a/frontend/lib/tests/shop/admin-shipping-state-sync.test.ts b/frontend/lib/tests/shop/admin-shipping-state-sync.test.ts new file mode 100644 index 00000000..2269259f --- /dev/null +++ b/frontend/lib/tests/shop/admin-shipping-state-sync.test.ts @@ -0,0 +1,265 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +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 { toDbMoney } from '@/lib/shop/money'; + +type Action = 'mark_shipped' | 'mark_delivered'; + +type SeedArgs = { + action: Action; + shippingStatus: 'label_created' | 'shipped'; + shipmentStatus?: + | 'queued' + | 'processing' + | 'succeeded' + | 'failed' + | 'needs_attention'; +}; + +type Seeded = { + orderId: string; + shipmentId: string | null; +}; + +const seededOrderIds = new Set(); + +async function cleanup(orderId: string) { + await db.delete(adminAuditLog).where(eq(adminAuditLog.orderId, orderId)); + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +afterEach(async () => { + for (const orderId of seededOrderIds) { + await cleanup(orderId); + } + seededOrderIds.clear(); +}); + +async function seedOrder(args: SeedArgs): Promise { + const orderId = crypto.randomUUID(); + const shipmentId = args.shipmentStatus ? crypto.randomUUID() : null; + seededOrderIds.add(orderId); + + 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: args.shippingStatus, + idempotencyKey: `admin-shipping-sync-${orderId}`, + } as any); + + if (shipmentId) { + await db.insert(shippingShipments).values({ + id: shipmentId, + orderId, + provider: 'nova_poshta', + status: args.shipmentStatus, + attemptCount: 1, + leaseOwner: null, + leaseExpiresAt: null, + nextAttemptAt: null, + } as any); + } + + return { orderId, shipmentId }; +} + +describe.sequential('admin shipping action state sync', () => { + it('mark_shipped rejects when shipment row is missing', async () => { + const seed = await seedOrder({ + action: 'mark_shipped', + shippingStatus: 'label_created', + }); + + await expect( + applyShippingAdminAction({ + orderId: seed.orderId, + action: 'mark_shipped', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + name: 'ShippingAdminActionError', + code: 'SHIPMENT_NOT_FOUND', + status: 409, + }); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('label_created'); + }); + + it('mark_delivered rejects when shipment row is missing', async () => { + const seed = await seedOrder({ + action: 'mark_delivered', + shippingStatus: 'shipped', + }); + + await expect( + applyShippingAdminAction({ + orderId: seed.orderId, + action: 'mark_delivered', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + name: 'ShippingAdminActionError', + code: 'SHIPMENT_NOT_FOUND', + status: 409, + }); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('shipped'); + }); + + it('mark_shipped rejects when shipment row is not in succeeded state', async () => { + const seed = await seedOrder({ + action: 'mark_shipped', + shippingStatus: 'label_created', + shipmentStatus: 'processing', + }); + + await expect( + applyShippingAdminAction({ + orderId: seed.orderId, + action: 'mark_shipped', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + name: 'ShippingAdminActionError', + code: 'SHIPMENT_STATE_INCOMPATIBLE', + status: 409, + }); + + const [shipmentRow] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId!)) + .limit(1); + expect(shipmentRow?.status).toBe('processing'); + }); + + it('mark_delivered rejects when shipment row is not in succeeded state', async () => { + const seed = await seedOrder({ + action: 'mark_delivered', + shippingStatus: 'shipped', + shipmentStatus: 'needs_attention', + }); + + await expect( + applyShippingAdminAction({ + orderId: seed.orderId, + action: 'mark_delivered', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + name: 'ShippingAdminActionError', + code: 'SHIPMENT_STATE_INCOMPATIBLE', + status: 409, + }); + + const [shipmentRow] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seed.shipmentId!)) + .limit(1); + expect(shipmentRow?.status).toBe('needs_attention'); + }); + + it('repeated mark_shipped is idempotent when shipment row is valid', async () => { + const seed = await seedOrder({ + action: 'mark_shipped', + shippingStatus: 'label_created', + shipmentStatus: 'succeeded', + }); + + const first = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'mark_shipped', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + expect(first.changed).toBe(true); + expect(first.shippingStatus).toBe('shipped'); + expect(first.shipmentStatus).toBe('succeeded'); + + const second = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'mark_shipped', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + expect(second.changed).toBe(false); + expect(second.shippingStatus).toBe('shipped'); + expect(second.shipmentStatus).toBe('succeeded'); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('shipped'); + }); + + it('repeated mark_delivered is idempotent when shipment row is valid', async () => { + const seed = await seedOrder({ + action: 'mark_delivered', + shippingStatus: 'shipped', + shipmentStatus: 'succeeded', + }); + + const first = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'mark_delivered', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + expect(first.changed).toBe(true); + expect(first.shippingStatus).toBe('delivered'); + expect(first.shipmentStatus).toBe('succeeded'); + + const second = await applyShippingAdminAction({ + orderId: seed.orderId, + action: 'mark_delivered', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + expect(second.changed).toBe(false); + expect(second.shippingStatus).toBe('delivered'); + expect(second.shipmentStatus).toBe('succeeded'); + + const [orderRow] = await db + .select({ shippingStatus: orders.shippingStatus }) + .from(orders) + .where(eq(orders.id, seed.orderId)) + .limit(1); + expect(orderRow?.shippingStatus).toBe('delivered'); + }); +}); diff --git a/frontend/lib/tests/shop/admin-stripe-refund-containment.test.ts b/frontend/lib/tests/shop/admin-stripe-refund-containment.test.ts new file mode 100644 index 00000000..48163e50 --- /dev/null +++ b/frontend/lib/tests/shop/admin-stripe-refund-containment.test.ts @@ -0,0 +1,161 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/psp/stripe', () => ({ + createRefund: vi.fn(), +})); + +import { db } from '@/db'; +import { orders, shippingShipments } from '@/db/schema'; +import { createRefund } from '@/lib/psp/stripe'; +import { refundOrder } from '@/lib/services/orders/refund'; +import { toDbMoney } from '@/lib/shop/money'; + +const createRefundMock = vi.mocked(createRefund); + +type SeededOrder = { + orderId: string; + shipmentId: string; +}; + +async function seedStripeOrder(): Promise { + const orderId = crypto.randomUUID(); + const shipmentId = crypto.randomUUID(); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 2500, + totalAmount: toDbMoney(2500), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + paymentIntentId: `pi_${crypto.randomUUID()}`, + pspChargeId: `ch_${crypto.randomUUID()}`, + status: 'PAID', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: 'queued', + idempotencyKey: `idem_${crypto.randomUUID()}`, + stockRestored: false, + pspMetadata: {}, + } 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); + + return { orderId, shipmentId }; +} + +async function cleanup(seed: SeededOrder) { + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)); + await db.delete(orders).where(eq(orders.id, seed.orderId)); +} + +describe.sequential('admin stripe refund containment', () => { + let seeded: SeededOrder | null = null; + + beforeEach(() => { + createRefundMock.mockReset(); + }); + + afterEach(async () => { + if (seeded) { + await cleanup(seeded); + seeded = null; + } + }); + + it('sets REFUND_REQUESTED and closes queued shipment work only after PSP acceptance', async () => { + seeded = await seedStripeOrder(); + createRefundMock.mockResolvedValue({ + refundId: `re_${crypto.randomUUID()}`, + status: 'pending', + }); + + const order = await refundOrder(seeded.orderId, { + requestedBy: 'admin', + }); + + expect(order.id).toBe(seeded.orderId); + expect(order.paymentStatus).toBe('paid'); + + const [shipmentRow] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seeded.shipmentId)) + .limit(1); + expect(shipmentRow?.status).toBe('needs_attention'); + + const [orderRow] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + pspStatusReason: orders.pspStatusReason, + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + }) + .from(orders) + .where(eq(orders.id, seeded.orderId)) + .limit(1); + + expect(orderRow?.paymentStatus).toBe('paid'); + expect(orderRow?.status).toBe('PAID'); + expect(orderRow?.inventoryStatus).toBe('reserved'); + expect(orderRow?.pspStatusReason).toBe('REFUND_REQUESTED'); + expect(orderRow?.stockRestored).toBe(false); + expect(orderRow?.restockedAt).toBeNull(); + expect(createRefundMock).toHaveBeenCalledTimes(1); + }); + + it('does not apply containment when Stripe refund request is rejected', async () => { + seeded = await seedStripeOrder(); + createRefundMock.mockRejectedValue(new Error('stripe down')); + + await expect( + refundOrder(seeded.orderId, { requestedBy: 'admin' }) + ).rejects.toThrow('stripe down'); + + const [orderRow] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + pspStatusReason: orders.pspStatusReason, + shippingStatus: orders.shippingStatus, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, seeded.orderId)) + .limit(1); + + expect(orderRow?.paymentStatus).toBe('paid'); + expect(orderRow?.status).toBe('PAID'); + expect(orderRow?.inventoryStatus).toBe('reserved'); + expect(orderRow?.pspStatusReason).toBeNull(); + expect(orderRow?.shippingStatus).toBe('queued'); + expect(orderRow?.stockRestored).toBe(false); + + const [shipmentRow] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seeded.shipmentId)) + .limit(1); + expect(shipmentRow?.status).toBe('queued'); + }); +}); diff --git a/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts b/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts index 358d634d..f90ad2d3 100644 --- a/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts +++ b/frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts @@ -13,6 +13,7 @@ import { productPrices, products, } from '@/db/schema/shop'; +import { resetEnvCache } from '@/lib/env'; vi.mock('@/lib/auth', async () => { const actual = await vi.importActual('@/lib/auth'); @@ -22,6 +23,24 @@ vi.mock('@/lib/auth', async () => { }; }); +vi.mock('@/lib/services/orders/payment-attempts', async () => { + resetEnvCache(); + const actual = await vi.importActual( + '@/lib/services/orders/payment-attempts' + ); + return { + ...actual, + ensureStripePaymentIntentForOrder: vi.fn( + async (args: { orderId: string }) => ({ + paymentIntentId: `pi_test_${args.orderId.slice(0, 8)}`, + clientSecret: `cs_test_${args.orderId.slice(0, 8)}`, + attemptId: crypto.randomUUID(), + attemptNumber: 1, + }) + ), + }; +}); + type JsonValue = any; function makeNextRequest(url: string, init: RequestInit): NextRequest { @@ -68,17 +87,25 @@ afterAll(() => { describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () => { const stripeKeys = [ + 'PAYMENTS_ENABLED', + 'STRIPE_PAYMENTS_ENABLED', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', - 'STRIPE_PUBLISHABLE_KEY', 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', + 'APP_ORIGIN', ] as const; const originalEnv: Record = {}; beforeAll(() => { for (const k of stripeKeys) originalEnv[k] = process.env[k]; - for (const k of stripeKeys) delete process.env[k]; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'sk_test_concurrency'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_concurrency'; + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_test_concurrency'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + resetEnvCache(); }); afterAll(() => { @@ -87,182 +114,198 @@ describe('P0-8.10.1 checkout concurrency: stock=1, two parallel checkouts', () = if (v === undefined) delete process.env[k]; else process.env[k] = v; } + resetEnvCache(); }); it('must allow only one success and must not double-reserve (stock must not go below 0)', async () => { const productId = crypto.randomUUID(); const slug = `__test_checkout_concurrency_${productId.slice(0, 8)}`; - const now = new Date(); - await db.insert(products).values({ - id: productId, - slug, - title: `TEST concurrency stock=1 (${slug})`, - imageUrl: '/placeholder.svg', - - price: 1000, - originalPrice: null, - currency: 'USD', - - stock: 1, - isActive: true, - createdAt: now, - updatedAt: now, - } as any); - - await db.insert(productPrices).values({ - id: crypto.randomUUID(), - productId, - currency: 'USD', - - priceMinor: 1000, - originalPriceMinor: null, - - price: 10, - originalPrice: null, - - createdAt: now, - updatedAt: now, - } as any); - - const baseUrl = 'http://localhost:3000'; - const { POST: checkoutPOST } = - await import('@/app/api/shop/checkout/route'); - - async function callCheckout(idemKey: string) { - const body = JSON.stringify({ - items: [{ productId, quantity: 1 }], - }); - - const req = makeNextRequest(`${baseUrl}/api/shop/checkout`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', - 'Idempotency-Key': idemKey, - Origin: 'http://localhost:3000', - }, - body, - }); + let cleanupError: unknown = null; - const res = await checkoutPOST(req); - const json = await readJsonSafe(res); + try { + const now = new Date(); + await db.insert(products).values({ + id: productId, + slug, + title: `TEST concurrency stock=1 (${slug})`, + imageUrl: '/placeholder.svg', + + price: 1000, + originalPrice: null, + currency: 'USD', + + stock: 1, + isActive: true, + createdAt: now, + updatedAt: now, + } as any); + + await db.insert(productPrices).values({ + id: crypto.randomUUID(), + productId, + currency: 'USD', + + priceMinor: 1000, + originalPriceMinor: null, + + price: 10, + originalPrice: null, + + createdAt: now, + updatedAt: now, + } as any); + + const baseUrl = 'http://localhost:3000'; + const { POST: checkoutPOST } = + await import('@/app/api/shop/checkout/route'); + + async function callCheckout(idemKey: string) { + const body = JSON.stringify({ + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + items: [{ productId, quantity: 1 }], + }); + + const req = makeNextRequest(`${baseUrl}/api/shop/checkout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Idempotency-Key': idemKey, + Origin: 'http://localhost:3000', + }, + body, + }); + + const res = await checkoutPOST(req); + const json = await readJsonSafe(res); + + return { status: res.status, json }; + } - return { status: res.status, json }; - } + const idemA = crypto.randomUUID(); + const idemB = crypto.randomUUID(); - const idemA = crypto.randomUUID(); - const idemB = crypto.randomUUID(); + let release!: () => void; + const gate = new Promise(r => (release = r)); - let release!: () => void; - const gate = new Promise(r => (release = r)); + const p1 = (async () => { + await gate; + return callCheckout(idemA); + })(); - const p1 = (async () => { - await gate; - return callCheckout(idemA); - })(); + const p2 = (async () => { + await gate; + return callCheckout(idemB); + })(); - const p2 = (async () => { - await gate; - return callCheckout(idemB); - })(); + release(); - release(); + const [r1, r2] = await Promise.all([p1, p2]); - const [r1, r2] = await Promise.all([p1, p2]); + const results = [r1, r2]; + const success = results.filter(r => r.status === 201); + const fail = results.filter(r => r.status !== 201); - const results = [r1, r2]; - const success = results.filter(r => r.status === 201); - const fail = results.filter(r => r.status !== 201); + expect(success.length).toBe(1); + expect(fail.length).toBe(1); - expect(success.length).toBe(1); - expect(fail.length).toBe(1); + expect(fail[0].status).toBeGreaterThanOrEqual(400); + expect(fail[0].status).toBeLessThan(500); + const failJson = fail[0].json || {}; + const failCode = String( + pick(failJson, ['code', 'errorCode', 'businessCode', 'reason']) ?? '' + ).toUpperCase(); - expect(fail[0].status).toBeGreaterThanOrEqual(400); - expect(fail[0].status).toBeLessThan(500); - const failJson = fail[0].json || {}; - const failCode = String( - pick(failJson, ['code', 'errorCode', 'businessCode', 'reason']) ?? '' - ).toUpperCase(); + const failureIndicator = + `${failCode} ${JSON.stringify(failJson || {})}`.toUpperCase(); - if (failCode) { expect( [ 'OUT_OF_STOCK', 'INSUFFICIENT_STOCK', 'STOCK', 'NOT_ENOUGH_STOCK', - ].some(k => failCode.includes(k)) + ].some(k => failureIndicator.includes(k)) ).toBe(true); - } - const prodRows = await db - .select() - .from(products) - .where(eq((products as any).id, productId)); - - expect(prodRows.length).toBe(1); - - const prod: any = prodRows[0]; - const stock = - prod.stock ?? prod.stockQuantity ?? prod.stock_qty ?? prod.stock_quantity; - - expect(toNum(stock)).toBe(0); - expect(toNum(stock)).toBeGreaterThanOrEqual(0); - - const moves = await db - .select() - .from(inventoryMoves) - .where(eq((inventoryMoves as any).productId, productId)); - - const reserveMoves = (moves as any[]).filter(m => { - const kind = normalizeMoveKind( - pick(m, ['kind', 'type', 'moveType', 'action', 'op']) - ); - return kind === 'reserve' || kind === 'reserved'; - }); - - const reservedUnits = reserveMoves.reduce((sum, m) => { - const q = pick(m, [ - 'quantity', - 'qty', - 'units', - 'delta', - 'deltaQty', - 'deltaQuantity', - ]); - return sum + Math.abs(toNum(q)); - }, 0); - - expect(reservedUnits).toBe(1); - expect(reserveMoves.length).toBe(1); + const prodRows = await db + .select() + .from(products) + .where(eq((products as any).id, productId)); - try { - const oi = await db - .select({ orderId: (orderItems as any).orderId }) - .from(orderItems) - .where(eq((orderItems as any).productId, productId)); - - const orderIds = oi.map((x: any) => x.orderId).filter(Boolean); - - await db - .delete(orderItems) - .where(eq((orderItems as any).productId, productId)); - await db - .delete(inventoryMoves) + expect(prodRows.length).toBe(1); + + const prod: any = prodRows[0]; + const stock = + prod.stock ?? + prod.stockQuantity ?? + prod.stock_qty ?? + prod.stock_quantity; + + expect(toNum(stock)).toBe(0); + expect(toNum(stock)).toBeGreaterThanOrEqual(0); + + const moves = await db + .select() + .from(inventoryMoves) .where(eq((inventoryMoves as any).productId, productId)); - await db - .delete(productPrices) - .where(eq((productPrices as any).productId, productId)); - if (orderIds.length) { - await db.delete(orders).where(inArray((orders as any).id, orderIds)); - } + const reserveMoves = (moves as any[]).filter(m => { + const kind = normalizeMoveKind( + pick(m, ['kind', 'type', 'moveType', 'action', 'op']) + ); + return kind === 'reserve' || kind === 'reserved'; + }); - await db.delete(products).where(eq((products as any).id, productId)); - } catch (err) { - if (process.env.CI) throw err; + const reservedUnits = reserveMoves.reduce((sum, m) => { + const q = pick(m, [ + 'quantity', + 'qty', + 'units', + 'delta', + 'deltaQty', + 'deltaQuantity', + ]); + return sum + Math.abs(toNum(q)); + }, 0); + + expect(reservedUnits).toBe(1); + expect(reserveMoves.length).toBe(1); + } finally { + try { + const oi = await db + .select({ orderId: (orderItems as any).orderId }) + .from(orderItems) + .where(eq((orderItems as any).productId, productId)); + + const orderIds = oi.map((x: any) => x.orderId).filter(Boolean); + + await db + .delete(orderItems) + .where(eq((orderItems as any).productId, productId)); + await db + .delete(inventoryMoves) + .where(eq((inventoryMoves as any).productId, productId)); + await db + .delete(productPrices) + .where(eq((productPrices as any).productId, productId)); + + if (orderIds.length) { + await db.delete(orders).where(inArray((orders as any).id, orderIds)); + } + + await db.delete(products).where(eq((products as any).id, productId)); + } catch (err) { + cleanupError = err; + if (!process.env.CI) { + console.warn('checkout concurrency cleanup failed', err); + } + } + } - console.warn('checkout concurrency cleanup failed', err); + if (cleanupError) { + throw cleanupError; } }, 30000); }); diff --git a/frontend/lib/tests/shop/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts index 3d102ce8..06536548 100644 --- a/frontend/lib/tests/shop/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -4,6 +4,9 @@ import { NextRequest } from 'next/server'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; +const __prevAppOrigin = process.env.APP_ORIGIN; import { inventoryMoves, @@ -12,10 +15,7 @@ import { productPrices, products, } from '@/db/schema'; - -process.env.STRIPE_PAYMENTS_ENABLED = 'false'; -process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; -process.env.STRIPE_WEBHOOK_SECRET = 'whsec_dummy'; +import { resetEnvCache } from '@/lib/env'; vi.mock('@/lib/auth', async () => { const actual = @@ -27,26 +27,27 @@ vi.mock('@/lib/auth', async () => { }); vi.mock('@/lib/env/stripe', () => ({ - isPaymentsEnabled: () => false, + isPaymentsEnabled: () => true, })); -const createPaymentIntentMock = vi.fn((...args: any[]) => { - void args; - throw new Error( - 'Stripe should not be called in this test (payments disabled).' +vi.mock('@/lib/services/orders/payment-attempts', async () => { + resetEnvCache(); + const actual = await vi.importActual( + '@/lib/services/orders/payment-attempts' ); + return { + ...actual, + ensureStripePaymentIntentForOrder: vi.fn( + async (args: { orderId: string }) => ({ + paymentIntentId: `pi_test_${args.orderId.slice(0, 8)}`, + clientSecret: `cs_test_${args.orderId.slice(0, 8)}`, + attemptId: crypto.randomUUID(), + attemptNumber: 1, + }) + ), + }; }); -vi.mock('@/lib/psp/stripe', () => ({ - createPaymentIntent: (...args: any[]) => createPaymentIntentMock(...args), - retrievePaymentIntent: (...args: any[]) => { - void args; - throw new Error( - 'Stripe should not be called in this test (payments disabled).' - ); - }, -})); - const logErrorMock = vi.fn((...args: any[]) => { void args; return undefined; @@ -75,6 +76,10 @@ const createdOrderIds: string[] = []; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'mono_test_token'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + resetEnvCache(); if (process.env.NODE_ENV === 'production') { throw new Error( @@ -118,6 +123,17 @@ afterAll(async () => { if (__prevRateLimitDisabled === undefined) delete process.env.RATE_LIMIT_DISABLED; else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + resetEnvCache(); }); function makeIdempotencyKey(): string { @@ -205,10 +221,6 @@ async function debugIfNotExpected(res: Response, expectedStatus: number) { console.log('checkout failed', { status: res.status, body: text }); console.log('logError calls', logErrorMock.mock.calls); - console.log( - 'stripe createPaymentIntent calls', - createPaymentIntentMock.mock.calls.length - ); } describe('P0-CUR-3 checkout currency policy', () => { @@ -225,7 +237,11 @@ describe('P0-CUR-3 checkout currency policy', () => { }); const req = makeCheckoutRequest( - { items: [{ productId, quantity: 1 }] }, + { + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + items: [{ productId, quantity: 1 }], + }, { idempotencyKey: makeIdempotencyKey(), acceptLanguage: 'uk-UA,uk;q=0.9' } ); @@ -253,7 +269,11 @@ describe('P0-CUR-3 checkout currency policy', () => { }); const req = makeCheckoutRequest( - { items: [{ productId, quantity: 1 }] }, + { + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + items: [{ productId, quantity: 1 }], + }, { idempotencyKey: makeIdempotencyKey(), acceptLanguage: 'en-US,en;q=0.9' } ); @@ -278,7 +298,11 @@ describe('P0-CUR-3 checkout currency policy', () => { }); const req = makeCheckoutRequest( - { items: [{ productId, quantity: 1 }] }, + { + paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', + items: [{ productId, quantity: 1 }], + }, { idempotencyKey: makeIdempotencyKey(), acceptLanguage: 'uk-UA,uk;q=0.9' } ); diff --git a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts index 190bce58..89a57518 100644 --- a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts @@ -385,7 +385,7 @@ describe.sequential('checkout monobank contract', () => { } }, 20_000); - it('missing UAH price -> 422 PRICE_CONFIG_ERROR for monobank checkout', async () => { + it('missing UAH price -> 400 PRICE_CONFIG_ERROR for monobank checkout', async () => { const { productId } = await createIsolatedProduct({ stock: 2, prices: [{ currency: 'USD', priceMinor: 1000 }], @@ -394,7 +394,7 @@ describe.sequential('checkout monobank contract', () => { try { const res = await postCheckout(idemKey, productId); - expect(res.status).toBe(422); + expect(res.status).toBe(400); const json: any = await res.json(); expect(json.code).toBe('PRICE_CONFIG_ERROR'); expect(createMonobankInvoiceMock).not.toHaveBeenCalled(); diff --git a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts index eeccdb23..9563c242 100644 --- a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts @@ -113,15 +113,24 @@ function makeMonobankCheckoutReq(params: { ); } -function mockCreateOrderSuccess(mockFn: MockedFn, orderId: string) { +function mockCreateOrderSuccess( + mockFn: MockedFn, + orderId: string, + options?: { + currency?: 'USD' | 'UAH'; + paymentStatus?: 'pending' | 'paid'; + paymentProvider?: 'stripe' | 'monobank' | 'none'; + paymentIntentId?: string | null; + } +) { mockFn.mockResolvedValueOnce({ order: { id: orderId, - currency: 'UAH', + currency: options?.currency ?? 'UAH', totalAmount: 10, - paymentStatus: 'paid', - paymentProvider: 'none', - paymentIntentId: null, + paymentStatus: options?.paymentStatus ?? 'pending', + paymentProvider: options?.paymentProvider ?? 'monobank', + paymentIntentId: options?.paymentIntentId ?? null, }, isNew: true, totalCents: 1000, @@ -189,7 +198,7 @@ describe('checkout monobank parse/validation', () => { expect(Array.isArray(args?.items)).toBe(true); }); - it('defaults stripe method to stripe_card when paymentMethod is omitted', async () => { + it('omitted provider resolves to the server-preferred monobank rail for UAH checkout', async () => { const createOrderWithItemsMock = createOrderWithItems as unknown as MockedFn; mockCreateOrderSuccess(createOrderWithItemsMock, 'order_stripe_default_1'); @@ -207,8 +216,8 @@ describe('checkout monobank parse/validation', () => { expect(res.status).toBe(201); const args = createOrderWithItemsMock.mock.calls[0]?.[0]; - expect(args?.paymentProvider).toBe('stripe'); - expect(args?.paymentMethod).toBe('stripe_card'); + expect(args?.paymentProvider).toBe('monobank'); + expect(args?.paymentMethod).toBe('monobank_invoice'); }); it('rejects incompatible provider/method pair', async () => { @@ -402,11 +411,15 @@ describe('checkout monobank parse/validation', () => { expect(hasStatusTokenScope(payload, 'order_payment_init')).toBe(true); }); - it('keeps status-only token for no-payment checkout flow', async () => { + it('omitted provider still issues payment-init capable token for the resolved rail', async () => { const createOrderWithItemsMock = createOrderWithItems as unknown as MockedFn; const orderId = '11111111-1111-4111-8111-111111111120'; - mockCreateOrderSuccess(createOrderWithItemsMock, orderId); + mockCreateOrderSuccess(createOrderWithItemsMock, orderId, { + currency: 'UAH', + paymentStatus: 'pending', + paymentProvider: 'monobank', + }); const res = await POST( makeMonobankCheckoutReq({ @@ -427,6 +440,6 @@ describe('checkout monobank parse/validation', () => { expect(payload).not.toBeNull(); if (!payload) return; expect(hasStatusTokenScope(payload, 'status_lite')).toBe(true); - expect(hasStatusTokenScope(payload, 'order_payment_init')).toBe(false); + expect(hasStatusTokenScope(payload, 'order_payment_init')).toBe(true); }); }); diff --git a/frontend/lib/tests/shop/checkout-no-payments.test.ts b/frontend/lib/tests/shop/checkout-no-payments.test.ts index 53d86e2f..baad69b3 100644 --- a/frontend/lib/tests/shop/checkout-no-payments.test.ts +++ b/frontend/lib/tests/shop/checkout-no-payments.test.ts @@ -261,7 +261,7 @@ async function bestEffortHardDeleteOrder(orderId: string) { } } -describe.sequential('Checkout (no payments) invariants', () => { +describe.sequential('Checkout provider fail-closed invariants', () => { it('Explicit Stripe request fails closed when Stripe is unavailable', async () => { const { productId } = await createIsolatedProductForCurrency({ currency: 'USD', @@ -282,7 +282,6 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p0).toBeTruthy(); const stockBefore = p0!.stock; - const movesBefore = await countMovesForProduct(productId); const idemKey = crypto.randomUUID(); @@ -320,14 +319,12 @@ describe.sequential('Checkout (no payments) invariants', () => { } }, 20_000); - it('No-payments success path', async () => { + it('omitted provider fails closed when no checkout provider is available', async () => { const { productId } = await createIsolatedProductForCurrency({ currency: 'USD', stock: 2, }); - let orderId: string | null = null; - try { await db .update(products) @@ -342,6 +339,7 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p0).toBeTruthy(); const stockBefore = p0!.stock; + const movesBefore = await countMovesForProduct(productId); const idemKey = crypto.randomUUID(); const res = await postCheckout({ @@ -350,23 +348,10 @@ describe.sequential('Checkout (no payments) invariants', () => { items: [{ productId, quantity: 1 }], }); - expect([200, 201]).toContain(res.status); + expect(res.status).toBe(503); const json: any = await res.json(); - expect(json?.success).toBe(true); - - orderId = (json?.order?.id ?? json?.orderId) as string; - expect(typeof orderId).toBe('string'); - expect(orderId.length).toBeGreaterThan(10); - - await db - .update(products) - .set({ isActive: false, updatedAt: new Date() } as any) - .where(eq(products.id, productId)); - - expect(json.order.paymentProvider).toBe('none'); - expect(json.order.paymentStatus).toBe('paid'); - expect(json.order.currency).toBe('USD'); + expect(json?.code).toBe('PSP_UNAVAILABLE'); const [row] = await db .select({ @@ -379,25 +364,13 @@ describe.sequential('Checkout (no payments) invariants', () => { totalAmountMinor: orders.totalAmountMinor, }) .from(orders) - .where(eq(orders.id, orderId)) + .where(eq(orders.idempotencyKey, idemKey)) .limit(1); - expect(row).toBeTruthy(); - expect(row!.paymentProvider).toBe('none'); - expect(row!.paymentStatus).toBe('paid'); - expect(row!.inventoryStatus).toBe('reserved'); - expect(row!.status).toBe('PAID'); - expect(row!.currency).toBe('USD'); - expect(row!.totalAmountMinor).toBeGreaterThan(0); - - const moves = await readMoves(orderId); - const reserves = moves.filter(m => m.type === 'reserve'); - const releases = moves.filter(m => m.type === 'release'); + expect(row).toBeFalsy(); - expect(reserves.length).toBe(1); - expect(reserves[0]!.productId).toBe(productId); - expect(reserves[0]!.quantity).toBe(1); - expect(releases.length).toBe(0); + const movesAfter = await countMovesForProduct(productId); + expect(movesAfter).toBe(movesBefore); const [p1] = await db .select({ stock: products.stock }) @@ -406,38 +379,18 @@ describe.sequential('Checkout (no payments) invariants', () => { .limit(1); expect(p1).toBeTruthy(); - expect(p1!.stock).toBe(stockBefore - 1); - - const { restockOrder } = await import('@/lib/services/orders'); - await restockOrder(orderId, { reason: 'stale' }); - - const [p2] = await db - .select({ stock: products.stock }) - .from(products) - .where(eq(products.id, productId)) - .limit(1); - - expect(p2).toBeTruthy(); - expect(p2!.stock).toBe(stockBefore); - - await bestEffortHardDeleteOrder(orderId); - orderId = null; + expect(p1!.stock).toBe(stockBefore); } finally { - if (orderId) { - await bestEffortHardDeleteOrder(orderId); - } await cleanupIsolatedProduct(productId); } }, 20_000); - it('Idempotency for no-payments', async () => { + it('repeated omitted-provider retries fail closed without creating orders or stock moves', async () => { const { productId } = await createIsolatedProductForCurrency({ currency: 'USD', stock: 3, }); - let orderId1: string | null = null; - try { await db .update(products) @@ -452,6 +405,7 @@ describe.sequential('Checkout (no payments) invariants', () => { expect(p0).toBeTruthy(); const stockBefore = p0!.stock; + const movesBefore = await countMovesForProduct(productId); const idemKey = crypto.randomUUID(); const body1 = [{ productId, quantity: 1 }]; @@ -461,62 +415,54 @@ describe.sequential('Checkout (no payments) invariants', () => { acceptLanguage: 'en', items: body1, }); - expect([200, 201]).toContain(r1.status); + expect(r1.status).toBe(503); const j1: any = await r1.json(); - orderId1 = (j1?.order?.id ?? j1?.orderId) as string; - expect(orderId1).toBeTruthy(); - - await db - .update(products) - .set({ isActive: false, updatedAt: new Date() } as any) - .where(eq(products.id, productId)); + expect(j1?.code).toBe('PSP_UNAVAILABLE'); const r2 = await postCheckout({ idemKey, acceptLanguage: 'en', items: body1, }); - expect([200, 201]).toContain(r2.status); + expect(r2.status).toBe(503); const j2: any = await r2.json(); - const orderId2: string = (j2?.order?.id ?? j2?.orderId) as string; - - expect(orderId2).toBe(orderId1); - - const movesAfter2 = await readMoves(orderId1); - const reservesAfter2 = movesAfter2.filter(m => m.type === 'reserve'); - expect(reservesAfter2.length).toBe(1); + expect(j2?.code).toBe('PSP_UNAVAILABLE'); const r3 = await postCheckout({ idemKey, acceptLanguage: 'en', items: [{ productId, quantity: 2 }], }); - expect(r3.status).toBe(409); + expect(r3.status).toBe(503); - const { restockOrder } = await import('@/lib/services/orders'); - await restockOrder(orderId1, { reason: 'stale' }); + const j3: any = await r3.json(); + expect(j3?.code).toBe('PSP_UNAVAILABLE'); - const [p2] = await db + const [row] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idemKey)) + .limit(1); + expect(row).toBeFalsy(); + + const movesAfter = await countMovesForProduct(productId); + expect(movesAfter).toBe(movesBefore); + + const [p1] = await db .select({ stock: products.stock }) .from(products) .where(eq(products.id, productId)) .limit(1); - expect(p2).toBeTruthy(); - expect(p2!.stock).toBe(stockBefore); - - await bestEffortHardDeleteOrder(orderId1); - orderId1 = null; + expect(p1).toBeTruthy(); + expect(p1!.stock).toBe(stockBefore); } finally { - if (orderId1) { - await bestEffortHardDeleteOrder(orderId1); - } await cleanupIsolatedProduct(productId); } }, 20_000); - it('Invalid variant rejects without side effects (no payments)', async () => { + it('provider unavailability fails closed before variant processing', async () => { const { productId } = await createIsolatedProductForCurrency({ currency: 'USD', stock: 2, @@ -566,7 +512,7 @@ describe.sequential('Checkout (no payments) invariants', () => { ], }); - expect(res.status).toBe(400); + expect(res.status).toBe(503); const json: any = await res.json(); @@ -575,7 +521,7 @@ describe.sequential('Checkout (no payments) invariants', () => { .set({ isActive: false, updatedAt: new Date() } as any) .where(eq(products.id, productId)); - expect(json?.code).toBe('INVALID_VARIANT'); + expect(json?.code).toBe('PSP_UNAVAILABLE'); const countAfter = await countMovesForProduct(productId); expect(countAfter).toBe(countBefore); @@ -612,7 +558,7 @@ describe.sequential('Checkout (no payments) invariants', () => { } }, 20_000); - it('Missing variants reject when client provides options (no payments)', async () => { + it('provider unavailability fails closed before missing-variant checks', async () => { const { productId } = await createIsolatedProductForCurrency({ currency: 'USD', stock: 2, @@ -663,7 +609,7 @@ describe.sequential('Checkout (no payments) invariants', () => { ], }); - expect(res.status).toBe(400); + expect(res.status).toBe(503); const json: any = await res.json(); @@ -672,7 +618,7 @@ describe.sequential('Checkout (no payments) invariants', () => { .set({ isActive: false, updatedAt: new Date() } as any) .where(eq(products.id, productId)); - expect(json?.code).toBe('INVALID_VARIANT'); + expect(json?.code).toBe('PSP_UNAVAILABLE'); const countAfter = await countMovesForProduct(productId); expect(countAfter).toBe(countBefore); diff --git a/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts b/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts index cf4f050a..b2c5579e 100644 --- a/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts +++ b/frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts @@ -77,18 +77,13 @@ vi.mock('@/lib/shop/currency', () => ({ resolveCurrencyFromLocale: vi.fn(() => 'USD'), })); -vi.mock('@/lib/shop/payments', () => ({ - isMethodAllowed: mockIsMethodAllowed, - paymentProviderValues: ['stripe', 'monobank', 'none'], - paymentStatusValues: [ - 'pending', - 'requires_payment', - 'paid', - 'failed', - 'refunded', - 'needs_review', - ], -})); +vi.mock('@/lib/shop/payments', async () => { + const actual = await vi.importActual('@/lib/shop/payments'); + return { + ...actual, + isMethodAllowed: mockIsMethodAllowed, + }; +}); vi.mock('@/lib/shop/request-locale', () => ({ resolveRequestLocale: mockResolveRequestLocale, diff --git a/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts b/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts index 898122cd..0789e644 100644 --- a/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts +++ b/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { and, eq } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; import { afterAll, @@ -111,12 +111,9 @@ beforeEach(() => { cancelInvoicePaymentMock.mockReset(); }); -async function insertOrder(args: { - orderId: string; - orderPspChargeId?: string | null; -}) { +async function insertOrderAndAttempt(orderId: string) { await db.insert(orders).values({ - id: args.orderId, + id: orderId, totalAmountMinor: 1000, totalAmount: toDbMoney(1000), currency: 'UAH', @@ -124,104 +121,39 @@ async function insertOrder(args: { paymentStatus: 'paid', status: 'PAID', inventoryStatus: 'released', - pspChargeId: args.orderPspChargeId ?? null, idempotencyKey: crypto.randomUUID(), } as any); -} -async function insertAttempt(args: { - orderId: string; - providerPaymentIntentId?: string | null; - metadata?: Record; - status?: 'creating' | 'active' | 'succeeded' | 'failed' | 'canceled'; - attemptNumber?: number; - updatedAt?: Date; -}) { await db.insert(paymentAttempts).values({ id: crypto.randomUUID(), - orderId: args.orderId, + orderId, provider: 'monobank', - status: args.status ?? 'succeeded', - attemptNumber: args.attemptNumber ?? 1, + status: 'succeeded', + attemptNumber: 1, currency: 'UAH', expectedAmountMinor: 1000, idempotencyKey: crypto.randomUUID(), - providerPaymentIntentId: args.providerPaymentIntentId ?? null, - metadata: args.metadata ?? {}, - createdAt: args.updatedAt - ? new Date(args.updatedAt.getTime() - 1_000) - : undefined, - updatedAt: args.updatedAt ?? undefined, + providerPaymentIntentId: `inv_${crypto.randomUUID()}`, + metadata: {}, } as any); } -async function insertOrderAndAttempt(args: { - orderId: string; - providerPaymentIntentId?: string | null; - metadata?: Record; - orderPspChargeId?: string | null; - status?: 'creating' | 'active' | 'succeeded' | 'failed' | 'canceled'; - attemptNumber?: number; - updatedAt?: Date; -}) { - await insertOrder({ - orderId: args.orderId, - orderPspChargeId: args.orderPspChargeId, - }); - await insertAttempt({ - orderId: args.orderId, - providerPaymentIntentId: args.providerPaymentIntentId, - metadata: args.metadata, - status: args.status, - attemptNumber: args.attemptNumber, - updatedAt: args.updatedAt, - }); -} - async function cleanupOrder(orderId: string) { await db.delete(monobankRefunds).where(eq(monobankRefunds.orderId, orderId)); await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); await db.delete(orders).where(eq(orders.id, orderId)); } -describe.sequential('monobank admin refund route (F4)', () => { - it('creates processing refund once and dedupes extRef on retry', async () => { +describe.sequential('monobank admin refund route (F4 launch containment)', () => { + it('returns REFUND_DISABLED even when MONO_REFUND_ENABLED is true', async () => { const orderId = crypto.randomUUID(); - const invoiceId = `inv_${crypto.randomUUID()}`; - await insertOrderAndAttempt({ - orderId, - providerPaymentIntentId: invoiceId, - }); - cancelInvoicePaymentMock.mockResolvedValue({ - invoiceId, - status: 'processing', - }); + await insertOrderAndAttempt(orderId); try { const { POST } = await import('@/app/api/shop/admin/orders/[id]/refund/route'); - const req1 = new NextRequest( - `http://localhost/api/shop/admin/orders/${orderId}/refund`, - { - method: 'POST', - headers: { origin: 'http://localhost:3000' }, - } - ); - - const res1 = await POST(req1, { - params: Promise.resolve({ id: orderId }), - }); - expect(res1.status).toBe(200); - const json1: any = await res1.json(); - expect(json1.success).toBe(true); - expect(json1.order.id).toBe(orderId); - expect(json1.order.paymentStatus).toBe('paid'); - expect(json1.refund.status).toBe('processing'); - expect(json1.refund.extRef).toBe(`mono_refund:${orderId}:full`); - expect(json1.refund.deduped).toBe(false); - - const req2 = new NextRequest( + const req = new NextRequest( `http://localhost/api/shop/admin/orders/${orderId}/refund`, { method: 'POST', @@ -229,118 +161,29 @@ describe.sequential('monobank admin refund route (F4)', () => { } ); - const res2 = await POST(req2, { + const res = await POST(req, { params: Promise.resolve({ id: orderId }), }); - expect(res2.status).toBe(200); - const json2: any = await res2.json(); - expect(json2.success).toBe(true); - expect(json2.refund.extRef).toBe(`mono_refund:${orderId}:full`); - expect(json2.refund.status).toBe('processing'); - expect(json2.refund.deduped).toBe(true); + expect(res.status).toBe(409); - expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(1); - expect(cancelInvoicePaymentMock).toHaveBeenCalledWith({ - invoiceId, - extRef: `mono_refund:${orderId}:full`, - amountMinor: 1000, - }); + const json: any = await res.json(); + expect(json.code).toBe('REFUND_DISABLED'); + expect(json.message).toBe('Refunds are disabled.'); + expect(cancelInvoicePaymentMock).not.toHaveBeenCalled(); - const rows = await db - .select({ - id: monobankRefunds.id, - status: monobankRefunds.status, - extRef: monobankRefunds.extRef, - }) + const refundRows = await db + .select({ id: monobankRefunds.id }) .from(monobankRefunds) .where(eq(monobankRefunds.orderId, orderId)); - - expect(rows).toHaveLength(1); - expect(rows[0]?.status).toBe('processing'); - expect(rows[0]?.extRef).toBe(`mono_refund:${orderId}:full`); - } finally { - await cleanupOrder(orderId); - } - }, 15000); - - it('treats requested as retryable, then dedupes once processing', async () => { - const orderId = crypto.randomUUID(); - const invoiceId = `inv_${crypto.randomUUID()}`; - await insertOrderAndAttempt({ - orderId, - providerPaymentIntentId: invoiceId, - }); - - await db.insert(monobankRefunds).values({ - id: crypto.randomUUID(), - provider: 'monobank', - orderId, - extRef: `mono_refund:${orderId}:full`, - status: 'requested', - amountMinor: 1000, - currency: 'UAH', - providerCreatedAt: new Date(), - providerModifiedAt: new Date(), - } as any); - - cancelInvoicePaymentMock.mockResolvedValue({ - invoiceId, - status: 'processing', - }); - - try { - const { POST } = - await import('@/app/api/shop/admin/orders/[id]/refund/route'); - - const req1 = new NextRequest( - `http://localhost/api/shop/admin/orders/${orderId}/refund`, - { - method: 'POST', - headers: { origin: 'http://localhost:3000' }, - } - ); - const res1 = await POST(req1, { - params: Promise.resolve({ id: orderId }), - }); - expect(res1.status).toBe(200); - const json1: any = await res1.json(); - expect(json1.refund.status).toBe('processing'); - expect(json1.refund.deduped).toBe(false); - - const req2 = new NextRequest( - `http://localhost/api/shop/admin/orders/${orderId}/refund`, - { - method: 'POST', - headers: { origin: 'http://localhost:3000' }, - } - ); - const res2 = await POST(req2, { - params: Promise.resolve({ id: orderId }), - }); - expect(res2.status).toBe(200); - const json2: any = await res2.json(); - expect(json2.refund.status).toBe('processing'); - expect(json2.refund.deduped).toBe(true); - expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(1); + expect(refundRows).toHaveLength(0); } finally { await cleanupOrder(orderId); } }); - it('returns PSP_UNAVAILABLE and marks refund failure when PSP call fails', async () => { + it('leaves order state unchanged when refund rail is disabled', async () => { const orderId = crypto.randomUUID(); - const invoiceId = `inv_${crypto.randomUUID()}`; - await insertOrderAndAttempt({ - orderId, - providerPaymentIntentId: invoiceId, - }); - - cancelInvoicePaymentMock - .mockRejectedValueOnce(new Error('Monobank cancel failed')) - .mockResolvedValueOnce({ - invoiceId, - status: 'processing', - }); + await insertOrderAndAttempt(orderId); try { const { POST } = @@ -357,193 +200,23 @@ describe.sequential('monobank admin refund route (F4)', () => { const res = await POST(req, { params: Promise.resolve({ id: orderId }), }); - expect(res.status).toBe(503); - const json: any = await res.json(); - expect(json.code).toBe('PSP_UNAVAILABLE'); + expect(res.status).toBe(409); - const [refundRow] = await db + const [row] = await db .select({ - status: monobankRefunds.status, - extRef: monobankRefunds.extRef, + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + pspStatusReason: orders.pspStatusReason, }) - .from(monobankRefunds) - .where( - and( - eq(monobankRefunds.orderId, orderId), - eq(monobankRefunds.extRef, `mono_refund:${orderId}:full`) - ) - ) - .limit(1); - - expect(refundRow?.status).toBe('failure'); - - const retryReq = new NextRequest( - `http://localhost/api/shop/admin/orders/${orderId}/refund`, - { - method: 'POST', - headers: { origin: 'http://localhost:3000' }, - } - ); - - const retryRes = await POST(retryReq, { - params: Promise.resolve({ id: orderId }), - }); - - expect(retryRes.status).toBe(200); - const retryJson: any = await retryRes.json(); - expect(retryJson.success).toBe(true); - expect(retryJson.refund.extRef).toBe(`mono_refund:${orderId}:full`); - expect(retryJson.refund.status).toBe('processing'); - expect(retryJson.refund.deduped).toBe(false); - - const [orderRow] = await db - .select({ paymentStatus: orders.paymentStatus }) .from(orders) .where(eq(orders.id, orderId)) .limit(1); - expect(orderRow?.paymentStatus).toBe('paid'); - expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(2); - } finally { - await cleanupOrder(orderId); - } - }); - - it('returns 409 REFUND_DISABLED when gate is off and does not call PSP', async () => { - const orderId = crypto.randomUUID(); - const invoiceId = `inv_${crypto.randomUUID()}`; - await insertOrderAndAttempt({ - orderId, - providerPaymentIntentId: invoiceId, - }); - - const prev = process.env.MONO_REFUND_ENABLED; - process.env.MONO_REFUND_ENABLED = 'false'; - resetEnvCache(); - - try { - const { POST } = - await import('@/app/api/shop/admin/orders/[id]/refund/route'); - - const req = new NextRequest( - `http://localhost/api/shop/admin/orders/${orderId}/refund`, - { - method: 'POST', - headers: { origin: 'http://localhost:3000' }, - } - ); - - const res = await POST(req, { - params: Promise.resolve({ id: orderId }), - }); - expect(res.status).toBe(409); - const json: any = await res.json(); - expect(json.code).toBe('REFUND_DISABLED'); - expect(cancelInvoicePaymentMock).not.toHaveBeenCalled(); - } finally { - if (prev === undefined) delete process.env.MONO_REFUND_ENABLED; - else process.env.MONO_REFUND_ENABLED = prev; - resetEnvCache(); - await cleanupOrder(orderId); - } - }); - - it('falls back to metadata.invoiceId when providerPaymentIntentId is absent', async () => { - const orderId = crypto.randomUUID(); - const invoiceId = `inv_${crypto.randomUUID()}`; - await insertOrderAndAttempt({ - orderId, - providerPaymentIntentId: null, - metadata: { invoiceId }, - orderPspChargeId: null, - }); - cancelInvoicePaymentMock.mockResolvedValue({ - invoiceId, - status: 'processing', - }); - - try { - const { POST } = - await import('@/app/api/shop/admin/orders/[id]/refund/route'); - - const req = new NextRequest( - `http://localhost/api/shop/admin/orders/${orderId}/refund`, - { - method: 'POST', - headers: { origin: 'http://localhost:3000' }, - } - ); - - const res = await POST(req, { - params: Promise.resolve({ id: orderId }), - }); - expect(res.status).toBe(200); - const json: any = await res.json(); - expect(json.success).toBe(true); - expect(json.refund.status).toBe('processing'); - - expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(1); - expect(cancelInvoicePaymentMock).toHaveBeenCalledWith({ - invoiceId, - extRef: `mono_refund:${orderId}:full`, - amountMinor: 1000, - }); - } finally { - await cleanupOrder(orderId); - } - }); - - it('prefers succeeded attempt invoice id over newer failed attempt', async () => { - const orderId = crypto.randomUUID(); - const now = Date.now(); - const goodInvoiceId = `inv_good_${crypto.randomUUID()}`; - const badInvoiceId = `inv_bad_${crypto.randomUUID()}`; - - await insertOrder({ orderId }); - - await insertAttempt({ - orderId, - providerPaymentIntentId: goodInvoiceId, - status: 'succeeded', - attemptNumber: 1, - updatedAt: new Date(now - 60_000), - }); - await insertAttempt({ - orderId, - providerPaymentIntentId: badInvoiceId, - status: 'failed', - attemptNumber: 2, - updatedAt: new Date(now), - }); - - cancelInvoicePaymentMock.mockResolvedValue({ - invoiceId: goodInvoiceId, - status: 'processing', - }); - - try { - const { POST } = - await import('@/app/api/shop/admin/orders/[id]/refund/route'); - - const req = new NextRequest( - `http://localhost/api/shop/admin/orders/${orderId}/refund`, - { - method: 'POST', - headers: { origin: 'http://localhost:3000' }, - } - ); - - const res = await POST(req, { - params: Promise.resolve({ id: orderId }), - }); - expect(res.status).toBe(200); - - expect(cancelInvoicePaymentMock).toHaveBeenCalledTimes(1); - expect(cancelInvoicePaymentMock).toHaveBeenCalledWith({ - invoiceId: goodInvoiceId, - extRef: `mono_refund:${orderId}:full`, - amountMinor: 1000, - }); + expect(row?.paymentStatus).toBe('paid'); + expect(row?.status).toBe('PAID'); + expect(row?.inventoryStatus).toBe('released'); + expect(row?.pspStatusReason).toBeNull(); } finally { await cleanupOrder(orderId); } diff --git a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts index c6bbb5eb..914d3fa9 100644 --- a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts +++ b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts @@ -5,6 +5,13 @@ import { NextRequest } from 'next/server'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; +const __prevStripeSecret = process.env.STRIPE_SECRET_KEY; +const __prevStripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; +const __prevStripePublishableKey = + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; +const __prevAppOrigin = process.env.APP_ORIGIN; import { db } from '@/db'; import { @@ -14,9 +21,11 @@ import { productPrices, products, } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; vi.mock('@/lib/auth', async () => { + resetEnvCache(); const actual = await vi.importActual>('@/lib/auth'); return { ...actual, @@ -25,11 +34,30 @@ vi.mock('@/lib/auth', async () => { }); vi.mock('@/lib/env/stripe', async () => { + resetEnvCache(); const actual = await vi.importActual>('@/lib/env/stripe'); return { ...actual, - isPaymentsEnabled: () => false, + isPaymentsEnabled: () => true, + }; +}); + +vi.mock('@/lib/services/orders/payment-attempts', async () => { + resetEnvCache(); + const actual = await vi.importActual( + '@/lib/services/orders/payment-attempts' + ); + return { + ...actual, + ensureStripePaymentIntentForOrder: vi.fn( + async (args: { orderId: string }) => ({ + paymentIntentId: `pi_test_${args.orderId.slice(0, 8)}`, + clientSecret: `cs_test_${args.orderId.slice(0, 8)}`, + attemptId: randomUUID(), + attemptNumber: 1, + }) + ), }; }); @@ -51,8 +79,12 @@ function makeJsonRequest( }); } -async function cleanupByIds(params: { orderId?: string; productId: string }) { - const { orderId, productId } = params; +async function cleanupByIds(params: { + orderId?: string; + productId: string; + priceId?: string; +}) { + const { orderId, productId, priceId } = params; if (orderId) { await db.delete(inventoryMoves).where(eq(inventoryMoves.orderId, orderId)); @@ -60,92 +92,133 @@ async function cleanupByIds(params: { orderId?: string; productId: string }) { await db.delete(orders).where(eq(orders.id, orderId)); } - await db.delete(productPrices).where(eq(productPrices.productId, productId)); + if (priceId) { + await db.delete(productPrices).where(eq(productPrices.id, priceId)); + } else { + await db + .delete(productPrices) + .where(eq(productPrices.productId, productId)); + } await db.delete(products).where(eq(products.id, productId)); } beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'sk_test_snapshot'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_snapshot'; + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_test_snapshot'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + resetEnvCache(); }); afterAll(() => { if (__prevRateLimitDisabled === undefined) delete process.env.RATE_LIMIT_DISABLED; else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + + if (__prevStripeSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = __prevStripeSecret; + + if (__prevStripeWebhookSecret === undefined) + delete process.env.STRIPE_WEBHOOK_SECRET; + else process.env.STRIPE_WEBHOOK_SECRET = __prevStripeWebhookSecret; + + if (__prevStripePublishableKey === undefined) + delete process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + else + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = __prevStripePublishableKey; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + resetEnvCache(); }); describe('P0-6 snapshots: order_items immutability', () => { it('snapshot fields must not change after products/product_prices update', async () => { const productId = randomUUID(); const priceId = randomUUID(); + let orderId: string | undefined; + let primaryError: unknown = null; + let cleanupError: unknown = null; const titleV1 = 'Snapshot Test Product'; const slugV1 = `snapshot-test-${productId.slice(0, 8)}`; const skuV1 = `SKU-${productId.slice(0, 8)}`; - await db.insert(products).values({ - id: productId, - slug: slugV1, - title: titleV1, - description: 'snapshot test', - imageUrl: 'https://res.cloudinary.com/devlovers/image/upload/v1/test.png', - imagePublicId: null, - price: '9.00', - originalPrice: null, - currency: 'USD', - category: null, - type: null, - colors: [], - sizes: [], - badge: 'NONE', - isActive: true, - isFeatured: false, - stock: 10, - sku: skuV1, - }); - - await db.insert(productPrices).values({ - id: priceId, - productId, - currency: 'USD', - priceMinor: 900, - originalPriceMinor: null, - price: '9.00', - originalPrice: null, - }); - - const idem = randomUUID(); - const req = makeJsonRequest( - 'http://localhost:3000/api/shop/checkout', - { items: [{ productId, quantity: 1 }] }, - { - 'Accept-Language': 'en-US,en;q=0.9', - 'Content-Type': 'application/json', - 'Idempotency-Key': idem, - 'X-Forwarded-For': deriveTestIpFromIdemKey(idem), - Origin: 'http://localhost:3000', - } - ); - const { POST: checkoutPOST } = - await import('@/app/api/shop/checkout/route'); + try { + await db.insert(products).values({ + id: productId, + slug: slugV1, + title: titleV1, + description: 'snapshot test', + imageUrl: + 'https://res.cloudinary.com/devlovers/image/upload/v1/test.png', + imagePublicId: null, + price: '9.00', + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku: skuV1, + }); - const res = await checkoutPOST(req); + await db.insert(productPrices).values({ + id: priceId, + productId, + currency: 'USD', + priceMinor: 900, + originalPriceMinor: null, + price: '9.00', + originalPrice: null, + }); - expect(res.status).toBeGreaterThanOrEqual(200); - expect(res.status).toBeLessThan(300); + const idem = randomUUID(); + const req = makeJsonRequest( + 'http://localhost:3000/api/shop/checkout', + { + items: [{ productId, quantity: 1 }], + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }, + { + 'Accept-Language': 'en-US,en;q=0.9', + 'Content-Type': 'application/json', + 'Idempotency-Key': idem, + 'X-Forwarded-For': deriveTestIpFromIdemKey(idem), + Origin: 'http://localhost:3000', + } + ); + const { POST: checkoutPOST } = + await import('@/app/api/shop/checkout/route'); - const json = (await res.json()) as CheckoutResponse; - expect(json.success).toBe(true); + const res = await checkoutPOST(req); - const orderId = json.orderId ?? json.order?.id; - expect(typeof orderId).toBe('string'); - if (!orderId) throw new Error('Missing orderId from checkout response'); + expect(res.status).toBeGreaterThanOrEqual(200); + expect(res.status).toBeLessThan(300); - let primaryError: unknown = null; - let cleanupError: unknown = null; + const json = (await res.json()) as CheckoutResponse; + expect(json.success).toBe(true); + + orderId = json.orderId ?? json.order?.id; + expect(typeof orderId).toBe('string'); + if (!orderId) throw new Error('Missing orderId from checkout response'); - try { const before = await db .select({ orderId: orderItems.orderId, @@ -217,12 +290,13 @@ describe('P0-6 snapshots: order_items immutability', () => { throw e; } finally { try { - await cleanupByIds({ orderId, productId }); + await cleanupByIds({ orderId, productId, priceId }); } catch (e) { cleanupError = e; console.error('[test cleanup failed]', { orderId, productId }, e); } } + if (!primaryError && cleanupError) { throw cleanupError; } diff --git a/frontend/lib/tests/shop/returns-phase4.test.ts b/frontend/lib/tests/shop/returns-phase4.test.ts index 5f3a4171..c558a847 100644 --- a/frontend/lib/tests/shop/returns-phase4.test.ts +++ b/frontend/lib/tests/shop/returns-phase4.test.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import { and, eq, sql } from 'drizzle-orm'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; import { @@ -10,6 +10,7 @@ import { orderItems, orders, products, + returnRequests, shippingEvents, users, } from '@/db/schema'; @@ -29,6 +30,10 @@ vi.mock('@/lib/psp/stripe', () => ({ createRefund: createRefundMock, })); +beforeEach(() => { + createRefundMock.mockReset(); +}); + type Seed = { orderId: string; productId: string; @@ -128,6 +133,9 @@ async function cleanupSeed(seed: Seed) { await db .delete(shippingEvents) .where(eq(shippingEvents.orderId, seed.orderId)); + await db + .delete(returnRequests) + .where(eq(returnRequests.orderId, seed.orderId)); await db .delete(inventoryMoves) .where(eq(inventoryMoves.orderId, seed.orderId)); @@ -193,7 +201,7 @@ describe.sequential('returns phase 4', () => { } }); - it('refund is allowed only after receive state', async () => { + it('refund stays disabled for launch even after receive state', async () => { const seed = await seedPaidReservedOrder(); try { const created = await createReturnRequest({ @@ -219,7 +227,7 @@ describe.sequential('returns phase 4', () => { requestId: `req_${crypto.randomUUID()}`, }) ).rejects.toMatchObject({ - code: 'RETURN_REFUND_STATE_INVALID', + code: 'RETURN_REFUND_DISABLED', } satisfies Partial); await receiveReturnRequest({ @@ -228,25 +236,30 @@ describe.sequential('returns phase 4', () => { requestId: `req_${crypto.randomUUID()}`, }); - createRefundMock.mockResolvedValueOnce({ - refundId: `re_${crypto.randomUUID()}`, - status: 'succeeded', - }); + await expect( + refundReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_2', + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + code: 'RETURN_REFUND_DISABLED', + } satisfies Partial); - const refunded = await refundReturnRequest({ - returnRequestId: created.request.id, - actorUserId: 'admin_2', - requestId: `req_${crypto.randomUUID()}`, - }); - expect(refunded.changed).toBe(true); - expect(refunded.row.status).toBe('refunded'); - expect(createRefundMock).toHaveBeenCalledTimes(1); - expect(createRefundMock).toHaveBeenCalledWith( - expect.objectContaining({ - orderId: seed.orderId, - amountMinor: created.request.refundAmountMinor, + const [row] = await db + .select({ + status: returnRequests.status, + refundedAt: returnRequests.refundedAt, + refundProviderRef: returnRequests.refundProviderRef, }) - ); + .from(returnRequests) + .where(eq(returnRequests.id, created.request.id)) + .limit(1); + + expect(row?.status).toBe('received'); + expect(row?.refundedAt).toBeNull(); + expect(row?.refundProviderRef).toBeNull(); + expect(createRefundMock).not.toHaveBeenCalled(); } finally { await cleanupSeed(seed); } @@ -314,7 +327,7 @@ describe.sequential('returns phase 4', () => { } }); - it('emits canonical shipping events and admin audit entries for return transitions', async () => { + it('does not emit refund events or audit entries while return refunds are disabled', async () => { const seed = await seedPaidReservedOrder(); try { const created = await createReturnRequest({ @@ -338,15 +351,15 @@ describe.sequential('returns phase 4', () => { requestId: `req_${crypto.randomUUID()}`, }); - createRefundMock.mockResolvedValueOnce({ - refundId: `re_${crypto.randomUUID()}`, - status: 'succeeded', - }); - await refundReturnRequest({ - returnRequestId: created.request.id, - actorUserId: 'admin_4', - requestId: `req_${crypto.randomUUID()}`, - }); + await expect( + refundReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin_4', + requestId: `req_${crypto.randomUUID()}`, + }) + ).rejects.toMatchObject({ + code: 'RETURN_REFUND_DISABLED', + } satisfies Partial); const events = await db .select({ @@ -366,9 +379,9 @@ describe.sequential('returns phase 4', () => { 'return_requested', 'return_approved', 'return_received', - 'return_refunded', ]) ); + expect(events.map(e => e.eventName)).not.toContain('return_refunded'); const audits = await db .select({ @@ -387,9 +400,10 @@ describe.sequential('returns phase 4', () => { 'return.requested', 'return.approve', 'return.receive', - 'return.refund', ]) ); + expect(audits.map(a => a.action)).not.toContain('return.refund'); + expect(createRefundMock).not.toHaveBeenCalled(); } finally { await cleanupSeed(seed); await db.delete(users).where(eq(users.id, seed.userId)); diff --git a/frontend/lib/tests/shop/returns-refund-disabled-for-launch.test.ts b/frontend/lib/tests/shop/returns-refund-disabled-for-launch.test.ts new file mode 100644 index 00000000..46e7ee06 --- /dev/null +++ b/frontend/lib/tests/shop/returns-refund-disabled-for-launch.test.ts @@ -0,0 +1,220 @@ +import crypto from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { + adminAuditLog, + orderItems, + orders, + products, + returnRequests, + shippingEvents, + users, +} from '@/db/schema'; +import { + approveReturnRequest, + createReturnRequest, + receiveReturnRequest, +} from '@/lib/services/shop/returns'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/auth/admin', () => ({ + requireAdminApi: vi.fn(async () => ({ id: 'admin_return_refund', role: 'admin' })), + AdminApiDisabledError: class AdminApiDisabledError extends Error {}, + AdminUnauthorizedError: class AdminUnauthorizedError extends Error { + code = 'ADMIN_UNAUTHORIZED'; + }, + AdminForbiddenError: class AdminForbiddenError extends Error { + code = 'ADMIN_FORBIDDEN'; + }, +})); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: vi.fn(() => null), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + }; +}); + +const __prevAppOrigin = process.env.APP_ORIGIN; + +beforeAll(() => { + process.env.APP_ORIGIN = 'http://localhost:3000'; +}); + +afterAll(() => { + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; +}); + +type Seed = { + orderId: string; + productId: string; + userId: string; + returnRequestId: string; +}; + +async function ensureUser(id: string, role: 'user' | 'admin') { + await db + .insert(users) + .values({ + id, + email: `${id}@example.test`, + role, + name: id, + } as any) + .onConflictDoNothing(); +} + +async function seedReceivedReturn(): Promise { + const orderId = crypto.randomUUID(); + const productId = crypto.randomUUID(); + const userId = `user_${crypto.randomUUID()}`; + const adminId = 'admin_return_refund'; + + await ensureUser(userId, 'user'); + await ensureUser(adminId, 'admin'); + + await db.insert(products).values({ + id: productId, + slug: `returns-disabled-${crypto.randomUUID()}`, + title: 'Returns disabled product', + imageUrl: 'https://example.com/p.png', + price: toDbMoney(1000), + currency: 'USD', + stock: 3, + isActive: true, + isFeatured: false, + } as any); + + await db.insert(orders).values({ + id: orderId, + userId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + paymentIntentId: `pi_${crypto.randomUUID()}`, + pspChargeId: `ch_${crypto.randomUUID()}`, + status: 'PAID', + inventoryStatus: 'reserved', + idempotencyKey: `idem_${crypto.randomUUID()}`, + } as any); + + await db.insert(orderItems).values({ + id: crypto.randomUUID(), + orderId, + productId, + selectedSize: '', + selectedColor: '', + quantity: 1, + unitPriceMinor: 1000, + lineTotalMinor: 1000, + unitPrice: toDbMoney(1000), + lineTotal: toDbMoney(1000), + productTitle: 'Returns disabled product', + productSlug: 'returns-disabled-product', + } as any); + + const created = await createReturnRequest({ + orderId, + actorUserId: userId, + idempotencyKey: `ret_${crypto.randomUUID()}`, + reason: 'wrong size', + policyRestock: false, + requestId: `req_${crypto.randomUUID()}`, + }); + + await approveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: adminId, + requestId: `req_${crypto.randomUUID()}`, + }); + + await receiveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: adminId, + requestId: `req_${crypto.randomUUID()}`, + }); + + return { + orderId, + productId, + userId, + returnRequestId: created.request.id, + }; +} + +async function cleanup(seed: Seed) { + await db + .delete(adminAuditLog) + .where(eq(adminAuditLog.orderId, seed.orderId)); + await db + .delete(shippingEvents) + .where(eq(shippingEvents.orderId, seed.orderId)); + await db + .delete(returnRequests) + .where(eq(returnRequests.id, seed.returnRequestId)); + await db.delete(orderItems).where(eq(orderItems.orderId, seed.orderId)); + await db.delete(orders).where(eq(orders.id, seed.orderId)); + await db.delete(products).where(eq(products.id, seed.productId)); + await db + .delete(users) + .where(and(eq(users.id, seed.userId), eq(users.role, 'user'))); + await db + .delete(users) + .where(and(eq(users.id, 'admin_return_refund'), eq(users.role, 'admin'))); +} + +describe.sequential('return refunds disabled for launch', () => { + it('route fails closed and leaves return state unchanged', async () => { + const seed = await seedReceivedReturn(); + + try { + const { POST } = + await import('@/app/api/shop/admin/returns/[id]/refund/route'); + + const req = new NextRequest( + `http://localhost/api/shop/admin/returns/${seed.returnRequestId}/refund`, + { + method: 'POST', + headers: { origin: 'http://localhost:3000' }, + } + ); + + const res = await POST(req, { + params: Promise.resolve({ id: seed.returnRequestId }), + }); + + expect(res.status).toBe(409); + const json: any = await res.json(); + expect(json.code).toBe('RETURN_REFUND_DISABLED'); + + const [row] = await db + .select({ + status: returnRequests.status, + refundedAt: returnRequests.refundedAt, + refundProviderRef: returnRequests.refundProviderRef, + }) + .from(returnRequests) + .where(eq(returnRequests.id, seed.returnRequestId)) + .limit(1); + + expect(row?.status).toBe('received'); + expect(row?.refundedAt).toBeNull(); + expect(row?.refundProviderRef).toBeNull(); + } finally { + await cleanup(seed); + } + }); +}); diff --git a/frontend/lib/tests/shop/shipping-eligibility-refund-contained.test.ts b/frontend/lib/tests/shop/shipping-eligibility-refund-contained.test.ts new file mode 100644 index 00000000..99dc621b --- /dev/null +++ b/frontend/lib/tests/shop/shipping-eligibility-refund-contained.test.ts @@ -0,0 +1,105 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { db } from '@/db'; +import { orders, shippingShipments } from '@/db/schema'; +import { + evaluateOrderShippingEligibility, +} from '@/lib/services/shop/shipping/eligibility'; +import { claimQueuedShipmentsForProcessing } from '@/lib/services/shop/shipping/shipments-worker'; +import { toDbMoney } from '@/lib/shop/money'; + +type Seeded = { + orderId: string; + shipmentId: string; +}; + +async function seedContainedOrder(): Promise { + const orderId = crypto.randomUUID(); + const shipmentId = crypto.randomUUID(); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1500, + totalAmount: toDbMoney(1500), + currency: 'UAH', + paymentProvider: 'stripe', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'reserved', + pspStatusReason: 'REFUND_REQUESTED', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: 'queued', + idempotencyKey: `idem_${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); + + return { orderId, shipmentId }; +} + +async function cleanup(seed: Seeded) { + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)); + await db.delete(orders).where(eq(orders.id, seed.orderId)); +} + +describe.sequential('shipping eligibility refund containment', () => { + let seeded: Seeded | null = null; + + afterEach(async () => { + if (seeded) { + await cleanup(seeded); + seeded = null; + } + }); + + it('centrally rejects REFUND_REQUESTED orders as not shippable', async () => { + const eligibility = evaluateOrderShippingEligibility({ + paymentStatus: 'paid', + orderStatus: 'PAID', + inventoryStatus: 'reserved', + pspStatusReason: 'REFUND_REQUESTED', + }); + + expect(eligibility).toEqual({ + ok: false, + code: 'REFUND_CONTAINED', + message: 'Order refund is pending finalization.', + }); + }); + + it('worker claim SQL does not lease contained queued shipments', async () => { + seeded = await seedContainedOrder(); + + const claimed = await claimQueuedShipmentsForProcessing({ + runId: crypto.randomUUID(), + leaseSeconds: 120, + limit: 10, + }); + + expect(claimed).toHaveLength(0); + + const [shipmentRow] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seeded.shipmentId)) + .limit(1); + expect(shipmentRow?.status).toBe('queued'); + }); +}); diff --git a/frontend/lib/tests/shop/stripe-refund-convergence.test.ts b/frontend/lib/tests/shop/stripe-refund-convergence.test.ts new file mode 100644 index 00000000..32a41b1a --- /dev/null +++ b/frontend/lib/tests/shop/stripe-refund-convergence.test.ts @@ -0,0 +1,359 @@ +import crypto from 'node:crypto'; + +import { eq, sql } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/psp/stripe', async () => { + const actual = + await vi.importActual( + '@/lib/psp/stripe' + ); + + return { + ...actual, + createRefund: vi.fn(), + retrieveRefund: vi.fn(), + }; +}); + +import { db } from '@/db'; +import { orders, products, shippingShipments } from '@/db/schema'; +import { createRefund, retrieveRefund } from '@/lib/psp/stripe'; +import { applyReserveMove } from '@/lib/services/inventory'; +import { + reconcileStaleStripeRefundOrders, + reconcileStripeRefundOrder, +} from '@/lib/services/orders'; +import { refundOrder } from '@/lib/services/orders/refund'; +import { toDbMoney } from '@/lib/shop/money'; + +const createRefundMock = vi.mocked(createRefund); +const retrieveRefundMock = vi.mocked(retrieveRefund); + +type SeededOrder = { + orderId: string; + productId: string; + shipmentId: string; + paymentIntentId: string; + chargeId: string; + refundId: string; + initialStock: number; + reservedQty: number; +}; + +function readRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const maybe = res as { rows?: unknown }; + return Array.isArray(maybe?.rows) ? (maybe.rows as T[]) : []; +} + +async function countMoveKey(moveKey: string): Promise { + const res = await db.execute( + sql`select count(*)::int as n from inventory_moves where move_key = ${moveKey}` + ); + return Number(readRows<{ n?: number }>(res)[0]?.n ?? 0); +} + +async function seedContainedStripeOrder(): Promise { + const orderId = crypto.randomUUID(); + const productId = crypto.randomUUID(); + const shipmentId = crypto.randomUUID(); + const paymentIntentId = `pi_${crypto.randomUUID()}`; + const chargeId = `ch_${crypto.randomUUID()}`; + const refundId = `re_${crypto.randomUUID()}`; + const initialStock = 5; + const reservedQty = 2; + + await db.insert(products).values({ + id: productId, + title: 'Stripe Refund Product', + slug: `stripe-refund-${crypto.randomUUID()}`, + sku: `stripe-refund-${crypto.randomUUID().slice(0, 8)}`, + badge: 'NONE', + imageUrl: 'https://example.com/stripe-refund.png', + isActive: true, + stock: initialStock, + price: toDbMoney(2500), + currency: 'USD', + } as any); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 2500, + totalAmount: toDbMoney(2500), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + paymentIntentId, + pspChargeId: chargeId, + status: 'PAID', + inventoryStatus: 'reserved', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingStatus: 'queued', + idempotencyKey: `idem_${crypto.randomUUID()}`, + stockRestored: false, + pspMetadata: {}, + } 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); + + const reserve = await applyReserveMove(orderId, productId, reservedQty); + expect(reserve.ok).toBe(true); + + createRefundMock.mockResolvedValueOnce({ + refundId, + status: 'pending', + }); + + await refundOrder(orderId, { requestedBy: 'admin' }); + + const [containedOrder] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + pspStatusReason: orders.pspStatusReason, + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(containedOrder?.paymentStatus).toBe('paid'); + expect(containedOrder?.status).toBe('PAID'); + expect(containedOrder?.inventoryStatus).toBe('reserved'); + expect(containedOrder?.pspStatusReason).toBe('REFUND_REQUESTED'); + expect(containedOrder?.shippingStatus).toBe('cancelled'); + + const [containedShipment] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.id, shipmentId)) + .limit(1); + expect(containedShipment?.status).toBe('needs_attention'); + + return { + orderId, + productId, + shipmentId, + paymentIntentId, + chargeId, + refundId, + initialStock, + reservedQty, + }; +} + +async function cleanup(seed: SeededOrder | null) { + if (!seed) return; + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, seed.orderId)); + await db.delete(orders).where(eq(orders.id, seed.orderId)); + await db.delete(products).where(eq(products.id, seed.productId)); +} + +describe.sequential('stripe refund convergence and recovery', () => { + let seeded: SeededOrder | null = null; + + beforeEach(() => { + createRefundMock.mockReset(); + retrieveRefundMock.mockReset(); + }); + + afterEach(async () => { + await cleanup(seeded); + seeded = null; + }); + + it('reconciles a contained Stripe refund to terminal refunded state exactly once', async () => { + seeded = await seedContainedStripeOrder(); + + retrieveRefundMock.mockResolvedValue({ + refundId: seeded.refundId, + status: 'succeeded', + reason: 'requested_by_customer', + chargeId: seeded.chargeId, + paymentIntentId: seeded.paymentIntentId, + }); + + const first = await reconcileStripeRefundOrder({ + orderId: seeded.orderId, + }); + expect(first).toBe('finalized_success'); + + const [afterFirst] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + pspStatusReason: orders.pspStatusReason, + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, seeded.orderId)) + .limit(1); + + expect(afterFirst?.paymentStatus).toBe('refunded'); + expect(afterFirst?.status).toBe('CANCELED'); + expect(afterFirst?.inventoryStatus).toBe('released'); + expect(afterFirst?.stockRestored).toBe(true); + expect(afterFirst?.restockedAt).not.toBeNull(); + expect(afterFirst?.pspStatusReason).toBe('requested_by_customer'); + expect(afterFirst?.pspStatusReason).not.toBe('REFUND_REQUESTED'); + expect(afterFirst?.shippingStatus).toBe('cancelled'); + + const [productAfterFirst] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, seeded.productId)) + .limit(1); + expect(productAfterFirst?.stock).toBe(seeded.initialStock); + + const releaseKey = `release:${seeded.orderId}:${seeded.productId}`; + const firstRestockedAt = afterFirst?.restockedAt?.getTime(); + expect(await countMoveKey(releaseKey)).toBe(1); + + const second = await reconcileStripeRefundOrder({ + orderId: seeded.orderId, + }); + expect(second).toBe('noop'); + + const [afterSecond] = await db + .select({ + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + }) + .from(orders) + .where(eq(orders.id, seeded.orderId)) + .limit(1); + + expect(afterSecond?.stockRestored).toBe(true); + expect(afterSecond?.restockedAt?.getTime()).toBe(firstRestockedAt); + expect(await countMoveKey(releaseKey)).toBe(1); + }); + + it('restores a contained Stripe refund failure back to paid and shippable truth without restock', async () => { + seeded = await seedContainedStripeOrder(); + + retrieveRefundMock.mockResolvedValue({ + refundId: seeded.refundId, + status: 'canceled', + reason: 'expired_uncaptured_charge', + chargeId: seeded.chargeId, + paymentIntentId: seeded.paymentIntentId, + }); + + const result = await reconcileStripeRefundOrder({ + orderId: seeded.orderId, + }); + expect(result).toBe('restored_failure'); + + const [orderRow] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + pspStatusReason: orders.pspStatusReason, + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, seeded.orderId)) + .limit(1); + + expect(orderRow?.paymentStatus).toBe('paid'); + expect(orderRow?.status).toBe('PAID'); + expect(orderRow?.inventoryStatus).toBe('reserved'); + expect(orderRow?.stockRestored).toBe(false); + expect(orderRow?.restockedAt).toBeNull(); + expect(orderRow?.pspStatusReason).toBe('expired_uncaptured_charge'); + expect(orderRow?.pspStatusReason).not.toBe('REFUND_REQUESTED'); + expect(orderRow?.shippingStatus).toBe('queued'); + + const [shipmentRow] = await db + .select({ + status: shippingShipments.status, + leaseOwner: shippingShipments.leaseOwner, + leaseExpiresAt: shippingShipments.leaseExpiresAt, + }) + .from(shippingShipments) + .where(eq(shippingShipments.id, seeded.shipmentId)) + .limit(1); + + expect(shipmentRow?.status).toBe('queued'); + expect(shipmentRow?.leaseOwner).toBeNull(); + expect(shipmentRow?.leaseExpiresAt).toBeNull(); + + const [productRow] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, seeded.productId)) + .limit(1); + expect(productRow?.stock).toBe(seeded.initialStock - seeded.reservedQty); + + const releaseKey = `release:${seeded.orderId}:${seeded.productId}`; + expect(await countMoveKey(releaseKey)).toBe(0); + }); + + it('reconciles stale contained Stripe refunds via the sweep path', async () => { + seeded = await seedContainedStripeOrder(); + + await db + .update(orders) + .set({ + updatedAt: new Date(Date.now() - 20 * 60 * 1000), + }) + .where(eq(orders.id, seeded.orderId)); + + retrieveRefundMock.mockResolvedValue({ + refundId: seeded.refundId, + status: 'succeeded', + reason: 'requested_by_customer', + chargeId: seeded.chargeId, + paymentIntentId: seeded.paymentIntentId, + }); + + const processed = await reconcileStaleStripeRefundOrders({ + olderThanMinutes: 15, + batchSize: 10, + workerId: 'batch-2b-test', + timeBudgetMs: 5_000, + }); + + expect(processed).toBe(1); + + const [orderRow] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + pspStatusReason: orders.pspStatusReason, + }) + .from(orders) + .where(eq(orders.id, seeded.orderId)) + .limit(1); + + expect(orderRow?.paymentStatus).toBe('refunded'); + expect(orderRow?.status).toBe('CANCELED'); + expect(orderRow?.inventoryStatus).toBe('released'); + expect(orderRow?.stockRestored).toBe(true); + expect(orderRow?.pspStatusReason).toBe('requested_by_customer'); + }); +}); diff --git a/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts index 1a422df0..ee4613b7 100644 --- a/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts @@ -390,6 +390,151 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { } }, 30_000); + it('payment_intent.succeeded replay/manual fallback must not requeue shipping for refund-contained orders', async () => { + const productId = randomUUID(); + const priceId = randomUUID(); + + const orderId = randomUUID(); + const idemKey = `idem_${randomUUID()}`; + + const paymentIntentId = `pi_test_${randomUUID() + .replace(/-/g, '') + .slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const chargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + await db.insert(products).values({ + id: productId, + slug: `webhook-refund-contained-${productId.slice(0, 8)}`, + title: 'Webhook Refund Contained Product', + description: 'webhook test', + imageUrl: 'https://res.cloudinary.com/devlovers/image/upload/v1/test.png', + imagePublicId: null, + price: '9.00', + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku: `SKU-${productId.slice(0, 8)}`, + }); + + await db.insert(productPrices).values({ + id: priceId, + productId, + currency: 'USD', + priceMinor: 900, + originalPriceMinor: null, + price: '9.00', + originalPrice: null, + }); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 900, + totalAmount: '9.00', + currency: 'USD', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingAmountMinor: null, + shippingStatus: 'cancelled', + paymentStatus: 'paid', + paymentProvider: 'stripe', + paymentIntentId, + idempotencyKey: idemKey, + status: 'PAID', + inventoryStatus: 'reserved', + pspStatusReason: 'REFUND_REQUESTED', + }); + + await db.insert(orderItems).values({ + id: randomUUID(), + orderId, + productId, + quantity: 1, + unitPriceMinor: 900, + lineTotalMinor: 900, + unitPrice: '9.00', + lineTotal: '9.00', + productTitle: 'Webhook Refund Contained Product', + productSlug: `webhook-refund-contained-${productId.slice(0, 8)}`, + productSku: `SKU-${productId.slice(0, 8)}`, + }); + + const event = { + id: eventId, + object: 'event', + type: 'payment_intent.succeeded', + data: { + object: { + id: paymentIntentId, + object: 'payment_intent', + amount: 900, + currency: 'usd', + status: 'succeeded', + metadata: { orderId }, + charges: { + object: 'list', + data: [ + { + id: chargeId, + object: 'charge', + payment_intent: paymentIntentId, + payment_method_details: { + type: 'card', + card: { + brand: 'visa', + last4: '4242', + }, + }, + }, + ], + }, + }, + }, + }; + + vi.mocked(verifyWebhookSignature).mockReturnValue(event as any); + + try { + const res = await webhookPOST( + makeWebhookRequest(JSON.stringify({ any: 'payload' })) + ); + expect(res.status).toBeGreaterThanOrEqual(200); + expect(res.status).toBeLessThan(300); + + const [updated] = await db + .select({ + paymentStatus: orders.paymentStatus, + shippingStatus: orders.shippingStatus, + pspStatusReason: orders.pspStatusReason, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(updated?.paymentStatus).toBe('paid'); + expect(updated?.shippingStatus).toBe('cancelled'); + expect(updated?.pspStatusReason).toBe('REFUND_REQUESTED'); + + const queued = await db + .select({ id: shippingShipments.id }) + .from(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + + expect(queued).toHaveLength(0); + } finally { + await cleanup({ orderId, productId, eventId }); + } + }, 30_000); + it('payment_intent.succeeded extracts google_pay wallet attribution into order and canonical event payload', async () => { const productId = randomUUID(); const priceId = randomUUID(); diff --git a/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts index 64ede328..ae23b9ad 100644 --- a/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts @@ -22,17 +22,35 @@ vi.mock('@/lib/services/orders', () => ({ restockOrder: vi.fn(), })); +vi.mock('@/lib/services/orders/stripe-refund-reconciliation', async () => { + const actual = await vi.importActual< + typeof import('@/lib/services/orders/stripe-refund-reconciliation') + >('@/lib/services/orders/stripe-refund-reconciliation'); + + return { + ...actual, + finalizeStripeRefundSuccess: vi.fn(actual.finalizeStripeRefundSuccess), + restoreStripeRefundFailure: vi.fn(actual.restoreStripeRefundFailure), + }; +}); + import { POST } from '@/app/api/shop/webhooks/stripe/route'; import { db } from '@/db'; import { orders, shippingShipments, stripeEvents } from '@/db/schema'; import { retrieveCharge, verifyWebhookSignature } from '@/lib/psp/stripe'; import { restockOrder } from '@/lib/services/orders'; +import { + finalizeStripeRefundSuccess, + restoreStripeRefundFailure, +} from '@/lib/services/orders/stripe-refund-reconciliation'; import { closeShippingPipelineForOrder } from '@/lib/services/shop/shipping/pipeline-shutdown'; import { claimQueuedShipmentsForProcessing } from '@/lib/services/shop/shipping/shipments-worker'; const verifyWebhookSignatureMock = vi.mocked(verifyWebhookSignature); const retrieveChargeMock = vi.mocked(retrieveCharge); const restockOrderMock = vi.mocked(restockOrder); +const finalizeStripeRefundSuccessMock = vi.mocked(finalizeStripeRefundSuccess); +const restoreStripeRefundFailureMock = vi.mocked(restoreStripeRefundFailure); type Inserted = { orderId: string; @@ -125,6 +143,61 @@ async function insertPendingOrder(): Promise { return { orderId, paymentIntentId, shipmentId: null }; } +async function insertContainedRefundRequestedOrder(): Promise< + Inserted & { refundId: string; chargeId: string } +> { + const inserted = await insertPaidOrder({ withQueuedShipment: true }); + const refundId = `re_${crypto.randomUUID()}`; + const chargeId = `ch_${crypto.randomUUID()}`; + const nowIso = new Date().toISOString(); + + await db + .update(orders) + .set({ + pspChargeId: chargeId, + pspStatusReason: 'REFUND_REQUESTED', + shippingStatus: 'cancelled', + pspMetadata: { + refunds: [ + { + refundId, + idempotencyKey: `refund:${inserted.orderId}:2500:USD`, + amountMinor: 2500, + currency: 'USD', + createdAt: nowIso, + createdBy: 'admin', + status: 'pending', + }, + ], + refundContainment: { + requestedAt: nowIso, + refundId, + orderShippingStatusBefore: 'queued', + latestShipmentIdBefore: inserted.shipmentId, + latestShipmentStatusBefore: 'queued', + hadShipmentRowBefore: true, + shippingRequiredBefore: true, + shippingProviderBefore: 'nova_poshta', + shippingMethodCodeBefore: 'NP_WAREHOUSE', + trackingNumberBefore: null, + shippingProviderRefBefore: null, + }, + } as any, + }) + .where(eq(orders.id, inserted.orderId)); + + await db + .update(shippingShipments) + .set({ + status: 'needs_attention', + lastErrorCode: 'ORDER_NOT_FULFILLABLE', + lastErrorMessage: 'Shipping pipeline closed: refund_requested', + }) + .where(eq(shippingShipments.id, inserted.shipmentId!)); + + return { ...inserted, refundId, chargeId }; +} + function makeRequest() { const req = new NextRequest( new Request('http://localhost/api/shop/webhooks/stripe', { @@ -212,7 +285,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded inserted = null; }); - it('full refund (charge.refund.updated) WITHOUT metadata.orderId resolves by paymentIntentId, sets terminal status, calls restock once', async () => { + it('full refund (charge.refund.updated) WITHOUT metadata.orderId resolves by paymentIntentId and finalizes once', async () => { inserted = await insertPaidOrder(); const eventId = `evt_${crypto.randomUUID()}`; @@ -268,10 +341,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(retrieveChargeMock).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled(); - expect(restockOrderMock).toHaveBeenCalledTimes(1); - expect(restockOrderMock).toHaveBeenCalledWith(inserted.orderId, { - reason: 'refunded', - }); + expect(finalizeStripeRefundSuccessMock).toHaveBeenCalledTimes(1); } finally { fetchSpy.mockRestore(); } @@ -353,7 +423,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded .where(eq(shippingShipments.id, inserted.shipmentId!)) .limit(1); expect(shipment2?.status).toBe('needs_attention'); - expect(restockOrderMock).toHaveBeenCalledTimes(1); + expect(finalizeStripeRefundSuccessMock).toHaveBeenCalledTimes(1); }, 30_000); it('payment_intent.payment_failed closes shipping pipeline and does not leave processable shipments on replay', async () => { @@ -530,7 +600,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(retrieveChargeMock).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled(); - expect(restockOrderMock).toHaveBeenCalledTimes(1); + expect(finalizeStripeRefundSuccessMock).toHaveBeenCalledTimes(1); } finally { fetchSpy.mockRestore(); } @@ -601,6 +671,71 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded } }, 30_000); + it('charge.refund.updated with refund.charge as string id still restores contained canceled refunds', async () => { + const contained = await insertContainedRefundRequestedOrder(); + inserted = contained; + + const eventId = `evt_${crypto.randomUUID()}`; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const refund = { + id: contained.refundId, + object: 'refund', + amount: 2500, + status: 'canceled', + reason: 'expired_uncaptured_charge', + charge: contained.chargeId, + payment_intent: contained.paymentIntentId, + metadata: {}, + }; + + verifyWebhookSignatureMock.mockReturnValue({ + id: eventId, + type: 'charge.refund.updated', + data: { object: refund }, + } as unknown as Stripe.Event); + + try { + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + + const [orderRow] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + pspStatusReason: orders.pspStatusReason, + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, contained.orderId)) + .limit(1); + + expect(orderRow?.paymentStatus).toBe('paid'); + expect(orderRow?.status).toBe('PAID'); + expect(orderRow?.inventoryStatus).toBe('reserved'); + expect(orderRow?.stockRestored).toBe(false); + expect(orderRow?.pspStatusReason).toBe('expired_uncaptured_charge'); + expect(orderRow?.pspStatusReason).not.toBe('REFUND_REQUESTED'); + expect(orderRow?.shippingStatus).toBe('queued'); + + const [shipmentRow] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.id, contained.shipmentId!)) + .limit(1); + + expect(shipmentRow?.status).toBe('queued'); + expect(restoreStripeRefundFailureMock).toHaveBeenCalledTimes(1); + expect(restockOrderMock).not.toHaveBeenCalled(); + expect(retrieveChargeMock).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }, 30_000); + it('partial refund is ignored (no paymentStatus/status change, no restock)', async () => { inserted = await insertPaidOrder(); @@ -643,7 +778,7 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(restockOrderMock).toHaveBeenCalledTimes(0); }, 30_000); - it('retry after 500 must reprocess same event.id until processedAt is set (restock not lost)', async () => { + it('retry after 500 must reprocess same event.id until processedAt is set (refund finalization not lost)', async () => { inserted = await insertPaidOrder(); const eventId = `evt_${crypto.randomUUID()}`; @@ -664,21 +799,9 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded data: { object: charge }, } as unknown as Stripe.Event); - restockOrderMock - .mockImplementationOnce(async () => { - throw new Error('RESTOCK_FAILED'); - }) - .mockImplementationOnce(async (orderId: string) => { - await db - .update(orders) - .set({ - stockRestored: true, - restockedAt: new Date(), - inventoryStatus: 'released', - updatedAt: new Date(), - }) - .where(eq(orders.id, orderId)); - }); + finalizeStripeRefundSuccessMock.mockImplementationOnce(async () => { + throw new Error('RESTOCK_FAILED'); + }); const res1 = await POST(makeRequest()); expect(res1.status).toBe(500); @@ -753,10 +876,80 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.status).toBe('CANCELED'); expect(row.stockRestored).toBe(true); - expect(restockOrderMock).toHaveBeenCalledTimes(1); - expect(restockOrderMock).toHaveBeenCalledWith(inserted.orderId, { - reason: 'refunded', + expect(finalizeStripeRefundSuccessMock).toHaveBeenCalledTimes(1); + }, 30_000); + + it('contained refund failure restores the order to paid and re-queues shipment work', async () => { + const contained = await insertContainedRefundRequestedOrder(); + inserted = contained; + + const eventId = `evt_${crypto.randomUUID()}`; + const chargeId = `ch_${crypto.randomUUID()}`; + + const expandedCharge = makeCharge({ + chargeId, + paymentIntentId: contained.paymentIntentId, + amount: 2500, + amountRefunded: 2500, + refunds: [ + { + id: contained.refundId, + amount: 2500, + status: 'canceled', + }, + ], }); + + const refund = { + id: contained.refundId, + object: 'refund', + amount: 2500, + status: 'canceled', + reason: 'expired_uncaptured_charge', + charge: expandedCharge, + payment_intent: contained.paymentIntentId, + metadata: {}, + }; + + verifyWebhookSignatureMock.mockReturnValue({ + id: eventId, + type: 'charge.refund.updated', + data: { object: refund }, + } as unknown as Stripe.Event); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + + const [orderRow] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + pspStatusReason: orders.pspStatusReason, + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, contained.orderId)) + .limit(1); + + expect(orderRow?.paymentStatus).toBe('paid'); + expect(orderRow?.status).toBe('PAID'); + expect(orderRow?.inventoryStatus).toBe('reserved'); + expect(orderRow?.stockRestored).toBe(false); + expect(orderRow?.pspStatusReason).toBe('expired_uncaptured_charge'); + expect(orderRow?.pspStatusReason).not.toBe('REFUND_REQUESTED'); + expect(orderRow?.shippingStatus).toBe('queued'); + + const [shipmentRow] = await db + .select({ status: shippingShipments.status }) + .from(shippingShipments) + .where(eq(shippingShipments.id, contained.shipmentId!)) + .limit(1); + + expect(shipmentRow?.status).toBe('queued'); + expect(restoreStripeRefundFailureMock).toHaveBeenCalledTimes(1); + expect(restockOrderMock).not.toHaveBeenCalled(); }, 30_000); it('refund fullness undetermined: amount_refunded missing + refunds list empty (no refund object) -> 500, processedAt NULL, no order changes', async () => { inserted = await insertPaidOrder();