diff --git a/frontend/lib/env/server-env.ts b/frontend/lib/env/server-env.ts index ec92b94d..eb5d779e 100644 --- a/frontend/lib/env/server-env.ts +++ b/frontend/lib/env/server-env.ts @@ -44,6 +44,7 @@ const GENERATED_FALLBACK_KEYS = new Set([ 'ENABLE_ADMIN_API', 'NEXT_PUBLIC_ENABLE_ADMIN', 'SHOP_STATUS_TOKEN_SECRET', + 'SHOP_MONOBANK_GPAY_ENABLED', 'APP_ORIGIN', 'APP_ADDITIONAL_ORIGINS', 'GMAIL_USER', @@ -51,7 +52,6 @@ const GENERATED_FALLBACK_KEYS = new Set([ 'EMAIL_FROM', ]); - function canUseGeneratedFallback(key: string): boolean { return GENERATED_FALLBACK_KEYS.has(key); } @@ -63,12 +63,11 @@ export function readServerEnv(key: string): string | undefined { const fromNetlify = readFromNetlifyEnv(key); if (fromNetlify) return fromNetlify; - if (!canUseGeneratedFallback(key)) return undefined; - return readFromGeneratedRuntimeEnv(key); - + if (!canUseGeneratedFallback(key)) return undefined; + return readFromGeneratedRuntimeEnv(key); } function readFromGeneratedRuntimeEnv(key: string): string | undefined { const value = RUNTIME_ENV[key]; return typeof value === 'string' && value.trim() ? value.trim() : undefined; -} \ No newline at end of file +} diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index b9eae622..54fbc058 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -17,7 +17,9 @@ import { getShopShippingFlags, NovaPoshtaConfigError, } from '@/lib/env/nova-poshta'; +import { readServerEnv } from '@/lib/env/server-env'; import { logError, logWarn } from '@/lib/logging'; +import { writePaymentEvent } from '@/lib/services/shop/events/write-payment-event'; import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability'; import { type CheckoutShippingQuote, @@ -80,6 +82,42 @@ export async function findExistingCheckoutOrderByIdempotencyKey( return getOrderByIdempotencyKey(db, idempotencyKey); } +async function writeOrderCreatedCanonicalEvent( + order: OrderSummaryWithMinor +): Promise { + await writePaymentEvent({ + orderId: order.id, + provider: order.paymentProvider, + eventName: 'order_created', + eventSource: 'checkout', + amountMinor: order.totalAmountMinor, + currency: order.currency, + payload: { + orderId: order.id, + totalAmountMinor: order.totalAmountMinor, + currency: order.currency, + paymentProvider: order.paymentProvider, + paymentStatus: order.paymentStatus, + fulfillmentStage: order.fulfillmentStage, + createdAt: order.createdAt.toISOString(), + }, + }); +} + +async function ensureOrderCreatedCanonicalEvent( + order: OrderSummaryWithMinor +): Promise { + try { + await writeOrderCreatedCanonicalEvent(order); + } catch (error) { + logWarn('checkout_order_created_event_write_failed', { + orderId: order.id, + code: 'ORDER_CREATED_EVENT_WRITE_FAILED', + message: error instanceof Error ? error.message : String(error), + }); + } +} + async function getProductsForCheckout( productIds: string[], currency: Currency @@ -697,9 +735,7 @@ function priceItems( } function isMonobankGooglePayEnabled(): boolean { - const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '') - .trim() - .toLowerCase(); + const raw = readServerEnv('SHOP_MONOBANK_GPAY_ENABLED')?.toLowerCase() ?? ''; return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on'; } @@ -1135,6 +1171,7 @@ export async function createOrderWithItems({ snapshot: preparedShipping.snapshot, }); } + await ensureOrderCreatedCanonicalEvent(existing); return { order: existing, isNew: false, @@ -1345,6 +1382,7 @@ export async function createOrderWithItems({ snapshot: preparedShipping.snapshot, }); } + await ensureOrderCreatedCanonicalEvent(existingOrder); return { order: existingOrder, isNew: false, @@ -1494,5 +1532,6 @@ export async function createOrderWithItems({ } const order = await getOrderById(orderId); + await ensureOrderCreatedCanonicalEvent(order); return { order, isNew: true, totalCents: orderTotalCents }; } diff --git a/frontend/lib/services/orders/restock.ts b/frontend/lib/services/orders/restock.ts index 092d6280..25526b92 100644 --- a/frontend/lib/services/orders/restock.ts +++ b/frontend/lib/services/orders/restock.ts @@ -5,13 +5,15 @@ import { and, eq, isNull, lt, ne, or } from 'drizzle-orm'; import { db } from '@/db'; import { inventoryMoves, orders } from '@/db/schema/shop'; import { logWarn } from '@/lib/logging'; +import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { writePaymentEvent } from '@/lib/services/shop/events/write-payment-event'; import { closeShippingPipelineForOrder } from '@/lib/services/shop/shipping/pipeline-shutdown'; import { isOrderNonPaymentStatusTransitionAllowed } from '@/lib/services/shop/transitions/order-state'; import { type PaymentStatus } from '@/lib/shop/payments'; import { OrderNotFoundError, OrderStateInvalidError } from '../errors'; import { applyReleaseMove } from '../inventory'; -import { resolvePaymentProvider } from './_shared'; +import { type OrderRow, resolvePaymentProvider } from './_shared'; import { guardedPaymentStatusUpdate } from './payment-state'; const PAYMENT_STATUS_KEY = 'paymentStatus' as const; @@ -94,6 +96,99 @@ function validateRestockTransition( } } +type OrderCanceledNotificationState = Pick< + OrderRow, + | 'id' + | 'totalAmountMinor' + | 'currency' + | 'paymentProvider' + | 'paymentIntentId' + | 'paymentStatus' + | 'status' + | 'inventoryStatus' + | 'stockRestored' + | 'restockedAt' + | 'shippingStatus' +>; + +async function loadOrderCanceledNotificationState( + orderId: string +): Promise { + const [row] = await db + .select({ + id: orders.id, + totalAmountMinor: orders.totalAmountMinor, + currency: orders.currency, + paymentProvider: orders.paymentProvider, + paymentIntentId: orders.paymentIntentId, + paymentStatus: orders.paymentStatus, + status: orders.status, + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + shippingStatus: orders.shippingStatus, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + return (row as OrderCanceledNotificationState | undefined) ?? null; +} + +function buildOrderCanceledEventDedupeKey(orderId: string): string { + return buildPaymentEventDedupeKey({ + orderId, + eventName: 'order_canceled', + status: 'CANCELED', + }); +} + +async function ensureOrderCanceledCanonicalEvent(args: { + orderId: string; + ensuredBy: string; +}): Promise { + const state = await loadOrderCanceledNotificationState(args.orderId); + if ( + !state || + state.status !== 'CANCELED' || + state.inventoryStatus !== 'released' || + !state.stockRestored + ) { + return; + } + + try { + await writePaymentEvent({ + orderId: state.id, + provider: resolvePaymentProvider(state), + eventName: 'order_canceled', + eventSource: 'order_restock', + eventRef: null, + amountMinor: state.totalAmountMinor, + currency: state.currency, + payload: { + orderId: state.id, + totalAmountMinor: state.totalAmountMinor, + currency: state.currency, + paymentProvider: state.paymentProvider, + paymentStatus: state.paymentStatus, + orderStatus: state.status, + inventoryStatus: state.inventoryStatus, + shippingStatus: state.shippingStatus, + restockedAt: state.restockedAt?.toISOString() ?? null, + ensuredBy: args.ensuredBy, + }, + dedupeKey: buildOrderCanceledEventDedupeKey(state.id), + }); + } catch (error) { + logWarn('order_canceled_event_write_failed', { + orderId: args.orderId, + ensuredBy: args.ensuredBy, + error: error instanceof Error ? error.message : String(error), + }); + } +} + export async function restockOrder( orderId: string, options?: RestockOptions @@ -127,8 +222,15 @@ export async function restockOrder( order.inventoryStatus === 'released' || order.stockRestored || order.restockedAt !== null - ) + ) { + if (reason === 'canceled' && order.status === 'CANCELED') { + await ensureOrderCanceledCanonicalEvent({ + orderId, + ensuredBy: 'restock_replay', + }); + } return; + } if (reason) { await closeShippingPipelineForOrder({ @@ -236,6 +338,13 @@ export async function restockOrder( }); } + if (reason === 'canceled') { + await ensureOrderCanceledCanonicalEvent({ + orderId, + ensuredBy: 'restock_finalize_orphan', + }); + } + return; } @@ -390,4 +499,11 @@ export async function restockOrder( extraWhere: eq(orders.restockedAt, finalizedAt), }); } + + if (reason === 'canceled') { + await ensureOrderCanceledCanonicalEvent({ + orderId, + ensuredBy: 'restock_finalize', + }); + } } diff --git a/frontend/lib/services/shop/notifications/outbox-worker.ts b/frontend/lib/services/shop/notifications/outbox-worker.ts index 27d9989c..264e5f8a 100644 --- a/frontend/lib/services/shop/notifications/outbox-worker.ts +++ b/frontend/lib/services/shop/notifications/outbox-worker.ts @@ -30,13 +30,22 @@ type OutboxClaimedRow = { type PreviewCountRow = { total: number }; type NotificationRecipientLookupRow = { + order_user_id: string | null; shipping_email: string | null; user_email: string | null; }; -type NotificationRecipient = { - email: string; -}; +type NotificationRecipient = + | { + kind: 'resolved'; + email: string; + } + | { + kind: 'missing'; + missingCode: + | 'NOTIFICATION_GUEST_RECIPIENT_MISSING' + | 'NOTIFICATION_RECIPIENT_MISSING'; + }; export type NotificationWorkerRunArgs = { runId: string; @@ -135,6 +144,7 @@ async function loadNotificationRecipient( ): Promise { const res = await db.execute(sql` select + o.user_id::text as order_user_id, nullif(trim(os.shipping_address #>> '{recipient,email}'), '') as shipping_email, nullif(trim(u.email), '') as user_email from orders o @@ -149,15 +159,25 @@ async function loadNotificationRecipient( const shippingEmail = normalizeEmailOrNull(row.shipping_email); if (shippingEmail) { - return { email: shippingEmail }; + return { kind: 'resolved', email: shippingEmail }; } const userEmail = normalizeEmailOrNull(row.user_email); if (userEmail) { - return { email: userEmail }; + return { kind: 'resolved', email: userEmail }; + } + + if (!row.order_user_id) { + return { + kind: 'missing', + missingCode: 'NOTIFICATION_GUEST_RECIPIENT_MISSING', + }; } - return null; + return { + kind: 'missing', + missingCode: 'NOTIFICATION_RECIPIENT_MISSING', + }; } function toNotificationSendError(error: unknown): NotificationSendError { @@ -205,6 +225,16 @@ async function sendNotification(row: OutboxClaimedRow): Promise { ); } + if (recipient.kind === 'missing') { + throw new NotificationSendError( + recipient.missingCode, + recipient.missingCode === 'NOTIFICATION_GUEST_RECIPIENT_MISSING' + ? 'Guest notification recipient email is missing from persisted shipping data.' + : 'Notification recipient email is missing for order.', + false + ); + } + const template = renderShopNotificationTemplate({ templateKey: row.template_key as ShopNotificationTemplateKey, orderId: row.order_id, diff --git a/frontend/lib/services/shop/notifications/templates.ts b/frontend/lib/services/shop/notifications/templates.ts index 78c232a6..fe05297e 100644 --- a/frontend/lib/services/shop/notifications/templates.ts +++ b/frontend/lib/services/shop/notifications/templates.ts @@ -8,6 +8,10 @@ export const shopNotificationTemplateKeys = [ 'intl_quote_accepted', 'intl_quote_declined', 'intl_quote_expired', + 'order_created', + 'order_shipped', + 'order_canceled', + 'order_returned', 'payment_confirmed', 'shipment_created', 'refund_processed', @@ -52,11 +56,65 @@ function readCanonicalEventName( return trimmed.length > 0 ? trimmed : null; } +function asObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function readCanonicalPayload( + payload: Record +): Record { + return asObject(payload.canonicalPayload); +} + +function readCanonicalAmountMinor( + payload: Record +): number | null { + const raw = payload.totalAmountMinor; + if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 0) { + return null; + } + return raw; +} + +function readCanonicalCurrency( + payload: Record +): string | null { + const raw = payload.currency; + if (typeof raw !== 'string') return null; + const trimmed = raw.trim().toUpperCase(); + return trimmed.length > 0 ? trimmed : null; +} + +function readCanonicalPaymentStatus( + payload: Record +): string | null { + const raw = payload.paymentStatus; + if (typeof raw !== 'string') return null; + const trimmed = raw.trim().toLowerCase(); + return trimmed.length > 0 ? trimmed : null; +} + +function formatCurrencyAmount( + amountMinor: number, + currency: string +): string | null { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + }).format(amountMinor / 100); + } catch { + return null; + } +} + export function renderShopNotificationTemplate( args: RenderShopNotificationTemplateArgs ): RenderedShopNotificationTemplate | null { const orderTag = toDisplayOrderId(args.orderId); const canonicalEvent = readCanonicalEventName(args.payload); + const canonicalPayload = readCanonicalPayload(args.payload); let subject: string; let leadLine: string; @@ -82,6 +140,22 @@ export function renderShopNotificationTemplate( subject = `[DevLovers] Quote expired for order ${orderTag}`; leadLine = 'Your international shipping quote has expired.'; break; + case 'order_created': + subject = `[DevLovers] Order received for order ${orderTag}`; + leadLine = 'Your order has been created.'; + break; + case 'order_shipped': + subject = `[DevLovers] Order shipped for order ${orderTag}`; + leadLine = 'Your order has been shipped.'; + break; + case 'order_canceled': + subject = `[DevLovers] Order canceled for order ${orderTag}`; + leadLine = 'Your order has been canceled.'; + break; + case 'order_returned': + subject = `[DevLovers] Return received for order ${orderTag}`; + leadLine = 'Your return has been received.'; + break; case 'payment_confirmed': subject = `[DevLovers] Payment confirmed for order ${orderTag}`; leadLine = 'Your payment has been confirmed.'; @@ -98,16 +172,39 @@ export function renderShopNotificationTemplate( return null; } + const totalLine = (() => { + const amountMinor = readCanonicalAmountMinor(canonicalPayload); + const currency = readCanonicalCurrency(canonicalPayload); + if (amountMinor === null || !currency) return null; + const formatted = formatCurrencyAmount(amountMinor, currency); + return formatted + ? `Total: ${formatted}` + : `Total: ${amountMinor} ${currency}`; + })(); + const paymentStatusLine = (() => { + const paymentStatus = readCanonicalPaymentStatus(canonicalPayload); + return paymentStatus ? `Payment status: ${paymentStatus}` : null; + })(); const eventLine = canonicalEvent ? `Canonical event: ${canonicalEvent}` : null; - const text = [leadLine, `Order: ${orderTag}`, eventLine] + const text = [ + leadLine, + `Order: ${orderTag}`, + totalLine, + paymentStatusLine, + eventLine, + ] .filter(Boolean) .join('\n'); const html = [ `

${escapeHtml(leadLine)}

`, `

Order: ${escapeHtml(orderTag)}

`, + totalLine ? `

${escapeHtml(totalLine)}

` : '', + paymentStatusLine + ? `

${escapeHtml(paymentStatusLine)}

` + : '', eventLine ? `

Canonical event: ${escapeHtml(canonicalEvent!)}

` : '', @@ -136,6 +233,10 @@ export function mapShippingEventToTemplate( case 'shipment_created': case 'label_created': return 'shipment_created'; + case 'shipped': + return 'order_shipped'; + case 'return_received': + return 'order_returned'; default: return null; } @@ -145,6 +246,10 @@ export function mapPaymentEventToTemplate( eventName: string ): ShopNotificationTemplateKey | null { switch (eventName) { + case 'order_created': + return 'order_created'; + case 'order_canceled': + return 'order_canceled'; case 'paid_applied': return 'payment_confirmed'; case 'refund_applied': diff --git a/frontend/lib/services/shop/shipping/admin-actions.ts b/frontend/lib/services/shop/shipping/admin-actions.ts index 48899b95..1ccd0891 100644 --- a/frontend/lib/services/shop/shipping/admin-actions.ts +++ b/frontend/lib/services/shop/shipping/admin-actions.ts @@ -4,7 +4,12 @@ import { sql } from 'drizzle-orm'; import { db } from '@/db'; import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; -import { buildAdminAuditDedupeKey } from '@/lib/services/shop/events/dedupe-key'; +import { logWarn } from '@/lib/logging'; +import { + buildAdminAuditDedupeKey, + buildShippingEventDedupeKey, +} from '@/lib/services/shop/events/dedupe-key'; +import { writeShippingEvent } from '@/lib/services/shop/events/write-shipping-event'; import { evaluateOrderShippingEligibility } from '@/lib/services/shop/shipping/eligibility'; import { ensureQueuedInitialShipment } from '@/lib/services/shop/shipping/ensure-queued-initial-shipment'; import { recordShippingMetric } from '@/lib/services/shop/shipping/metrics'; @@ -21,7 +26,10 @@ export type ShippingAdminAction = type ShippingStateRow = { order_id: string; + total_amount_minor: number; + currency: string | null; payment_status: string | null; + payment_provider: string | null; order_status: string | null; inventory_status: string | null; psp_status_reason: string | null; @@ -173,7 +181,10 @@ async function loadShippingState( const res = await db.execute(sql` select o.id as order_id, + o.total_amount_minor, + o.currency, o.payment_status, + o.payment_provider, o.status as order_status, o.inventory_status, o.psp_status_reason, @@ -488,6 +499,66 @@ function buildShippingAdminAuditDedupe(args: { }); } +function buildOrderShippedEventDedupe(args: { + orderId: string; + shipmentId: string | null; +}) { + return buildShippingEventDedupeKey({ + domain: 'shipping_admin_action', + orderId: args.orderId, + shipmentId: args.shipmentId, + eventName: 'shipped', + statusTo: 'shipped', + }); +} + +async function ensureOrderShippedCanonicalEvent(args: { + state: ShippingStateRow; + trackingNumber: string | null; + requestId: string; + ensuredBy: string; +}) { + if (args.state.shipping_status !== 'shipped') { + return; + } + + try { + await writeShippingEvent({ + orderId: args.state.order_id, + shipmentId: args.state.shipment_id ?? null, + provider: args.state.shipping_provider ?? 'nova_poshta', + eventName: 'shipped', + eventSource: 'shipping_admin_action', + eventRef: args.requestId, + statusFrom: null, + statusTo: 'shipped', + trackingNumber: args.trackingNumber ?? args.state.tracking_number ?? null, + payload: { + orderId: args.state.order_id, + totalAmountMinor: args.state.total_amount_minor, + currency: args.state.currency, + paymentProvider: args.state.payment_provider, + paymentStatus: args.state.payment_status, + shippingStatus: args.state.shipping_status, + trackingNumber: + args.trackingNumber ?? args.state.tracking_number ?? null, + ensuredBy: args.ensuredBy, + }, + dedupeKey: buildOrderShippedEventDedupe({ + orderId: args.state.order_id, + shipmentId: args.state.shipment_id ?? null, + }), + }); + } catch (error) { + logWarn('order_shipped_event_write_failed', { + orderId: args.state.order_id, + requestId: args.requestId, + ensuredBy: args.ensuredBy, + error: error instanceof Error ? error.message : String(error), + }); + } +} + export async function applyShippingAdminAction(args: { orderId: string; action: ShippingAdminAction; @@ -694,6 +765,13 @@ export async function applyShippingAdminAction(args: { }, }); + await ensureOrderShippedCanonicalEvent({ + state, + trackingNumber: state.tracking_number, + requestId: args.requestId, + ensuredBy: 'mark_shipped_replay', + }); + return { orderId: state.order_id, shippingStatus: state.shipping_status, @@ -749,6 +827,17 @@ export async function applyShippingAdminAction(args: { ); } + await ensureOrderShippedCanonicalEvent({ + state: { + ...state, + shipping_status: 'shipped', + tracking_number: updated.tracking_number, + }, + trackingNumber: updated.tracking_number, + requestId: args.requestId, + ensuredBy: 'mark_shipped', + }); + return { orderId: updated.id, shippingStatus: updated.shipping_status, diff --git a/frontend/lib/tests/shop/checkout-order-created-notification-phase5.test.ts b/frontend/lib/tests/shop/checkout-order-created-notification-phase5.test.ts new file mode 100644 index 00000000..48c3cdc0 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-order-created-notification-phase5.test.ts @@ -0,0 +1,380 @@ +import crypto from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendShopNotificationEmailMock = vi.hoisted(() => vi.fn()); +const writePaymentEventState = vi.hoisted(() => ({ + failNext: false, +})); + +vi.mock('@/lib/services/shop/notifications/transport', () => ({ + sendShopNotificationEmail: (...args: any[]) => + sendShopNotificationEmailMock(...args), + ShopNotificationTransportError: class ShopNotificationTransportError extends Error { + code: string; + transient: boolean; + + constructor(code: string, message: string, transient: boolean) { + super(message); + this.name = 'ShopNotificationTransportError'; + this.code = code; + this.transient = transient; + } + }, +})); + +vi.mock('@/lib/services/shop/events/write-payment-event', async () => { + const actual = await vi.importActual( + '@/lib/services/shop/events/write-payment-event' + ); + + return { + ...actual, + writePaymentEvent: vi.fn(async (...args: any[]) => { + if (writePaymentEventState.failNext) { + writePaymentEventState.failNext = false; + throw new Error('write_payment_event_forced_failure'); + } + + return actual.writePaymentEvent(...args); + }), + }; +}); + +import { db } from '@/db'; +import { + notificationOutbox, + orders, + orderShipping, + paymentEvents, + productPrices, + products, +} from '@/db/schema'; +import { createOrderWithItems } from '@/lib/services/orders'; +import { runNotificationOutboxWorker } from '@/lib/services/shop/notifications/outbox-worker'; +import { runNotificationOutboxProjector } from '@/lib/services/shop/notifications/projector'; +import { toDbMoney } from '@/lib/shop/money'; + +import { TEST_LEGAL_CONSENT } from './test-legal-consent'; + +type SeedProduct = { + productId: string; +}; + +async function seedProduct(): Promise { + const productId = crypto.randomUUID(); + const now = new Date(); + + await db.insert(products).values({ + id: productId, + slug: `checkout-order-created-${productId.slice(0, 8)}`, + title: 'Checkout Order Created Notification Product', + imageUrl: 'https://example.com/order-created-notification.png', + price: '10.00', + currency: 'USD', + isActive: true, + stock: 10, + sizes: [], + colors: [], + createdAt: now, + updatedAt: now, + } as any); + + await db.insert(productPrices).values([ + { + id: crypto.randomUUID(), + productId, + currency: 'USD', + priceMinor: 1000, + originalPriceMinor: null, + price: toDbMoney(1000), + originalPrice: null, + createdAt: now, + updatedAt: now, + }, + { + id: crypto.randomUUID(), + productId, + currency: 'UAH', + priceMinor: 4200, + originalPriceMinor: null, + price: toDbMoney(4200), + originalPrice: null, + createdAt: now, + updatedAt: now, + }, + ] as any); + + return { productId }; +} + +async function cleanupProduct(productId: string) { + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(products).where(eq(products.id, productId)); +} + +async function cleanupOrder(orderId: string) { + await db + .delete(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)); + await db.delete(paymentEvents).where(eq(paymentEvents.orderId, orderId)); + await db.delete(orderShipping).where(eq(orderShipping.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function attachRecipientEmail(orderId: string, email: string) { + await db.insert(orderShipping).values({ + orderId, + shippingAddress: { + recipient: { + fullName: 'Test Buyer', + email, + }, + }, + } as any); +} + +describe.sequential('checkout order-created notification phase 5', () => { + beforeEach(() => { + vi.clearAllMocks(); + writePaymentEventState.failNext = false; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('emits one order_created canonical event for the successful order-created path and idempotent replay', async () => { + const { productId } = await seedProduct(); + let orderId: string | null = null; + const idempotencyKey = crypto.randomUUID(); + + try { + const first = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: TEST_LEGAL_CONSENT, + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }); + orderId = first.order.id; + + const replay = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: TEST_LEGAL_CONSENT, + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }); + + expect(replay.isNew).toBe(false); + expect(replay.order.id).toBe(orderId); + + const events = await db + .select({ + id: paymentEvents.id, + provider: paymentEvents.provider, + eventName: paymentEvents.eventName, + eventSource: paymentEvents.eventSource, + amountMinor: paymentEvents.amountMinor, + currency: paymentEvents.currency, + payload: paymentEvents.payload, + }) + .from(paymentEvents) + .where( + and( + eq(paymentEvents.orderId, orderId), + eq(paymentEvents.eventName, 'order_created') + ) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + provider: 'stripe', + eventName: 'order_created', + eventSource: 'checkout', + amountMinor: 1000, + currency: 'USD', + }); + expect(events[0]?.payload).toMatchObject({ + orderId, + totalAmountMinor: 1000, + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'pending', + }); + } finally { + if (orderId) await cleanupOrder(orderId); + await cleanupProduct(productId); + } + }, 30_000); + + it('projects order_created into outbox and delivers one transactional confirmation email', async () => { + sendShopNotificationEmailMock.mockResolvedValue({ + messageId: 'msg-order-created-1', + }); + + const { productId } = await seedProduct(); + let orderId: string | null = null; + + try { + const created = await createOrderWithItems({ + idempotencyKey: crypto.randomUUID(), + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: TEST_LEGAL_CONSENT, + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }); + orderId = created.order.id; + + await attachRecipientEmail(orderId, 'buyer@example.test'); + + const firstProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + const secondProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + + expect(firstProjectorRun.inserted).toBeGreaterThanOrEqual(1); + expect(secondProjectorRun.inserted).toBe(0); + + const rows = await db + .select({ + id: notificationOutbox.id, + templateKey: notificationOutbox.templateKey, + sourceDomain: notificationOutbox.sourceDomain, + payload: notificationOutbox.payload, + status: notificationOutbox.status, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + templateKey: 'order_created', + sourceDomain: 'payment_event', + status: 'pending', + }); + expect(rows[0]?.payload).toMatchObject({ + canonicalEventName: 'order_created', + canonicalEventSource: 'checkout', + canonicalPayload: { + orderId, + totalAmountMinor: 1000, + currency: 'USD', + paymentStatus: 'pending', + }, + }); + + const workerResult = await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + + expect(workerResult.claimed).toBe(1); + expect(workerResult.sent).toBe(1); + expect(workerResult.retried).toBe(0); + expect(workerResult.deadLettered).toBe(0); + + expect(sendShopNotificationEmailMock).toHaveBeenCalledTimes(1); + expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'buyer@example.test', + subject: `[DevLovers] Order received for order ${orderId.slice(0, 12)}`, + text: expect.stringContaining('Total: $10.00'), + html: expect.stringContaining('Payment status: pending'), + }) + ); + } finally { + if (orderId) await cleanupOrder(orderId); + await cleanupProduct(productId); + } + }, 30_000); + + it('does not false-fail checkout when order_created persistence fails and replay backfills it', async () => { + const { productId } = await seedProduct(); + let orderId: string | null = null; + const idempotencyKey = crypto.randomUUID(); + + try { + writePaymentEventState.failNext = true; + + const first = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: TEST_LEGAL_CONSENT, + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }); + + orderId = first.order.id; + expect(first.isNew).toBe(true); + + const firstEvents = await db + .select({ id: paymentEvents.id }) + .from(paymentEvents) + .where( + and( + eq(paymentEvents.orderId, orderId), + eq(paymentEvents.eventName, 'order_created') + ) + ); + + expect(firstEvents).toHaveLength(0); + + const replay = await createOrderWithItems({ + idempotencyKey, + userId: null, + locale: 'en-US', + country: 'US', + items: [{ productId, quantity: 1 }], + legalConsent: TEST_LEGAL_CONSENT, + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + }); + + expect(replay.isNew).toBe(false); + expect(replay.order.id).toBe(orderId); + + const replayEvents = await db + .select({ + id: paymentEvents.id, + eventName: paymentEvents.eventName, + eventSource: paymentEvents.eventSource, + }) + .from(paymentEvents) + .where( + and( + eq(paymentEvents.orderId, orderId), + eq(paymentEvents.eventName, 'order_created') + ) + ); + + expect(replayEvents).toHaveLength(1); + expect(replayEvents[0]).toMatchObject({ + eventName: 'order_created', + eventSource: 'checkout', + }); + } finally { + if (orderId) await cleanupOrder(orderId); + await cleanupProduct(productId); + } + }, 30_000); +}); diff --git a/frontend/lib/tests/shop/notifications-projector-phase3.test.ts b/frontend/lib/tests/shop/notifications-projector-phase3.test.ts index fde37297..c13f53c9 100644 --- a/frontend/lib/tests/shop/notifications-projector-phase3.test.ts +++ b/frontend/lib/tests/shop/notifications-projector-phase3.test.ts @@ -94,6 +94,8 @@ describe.sequential('notifications projector phase 3', () => { 'quote_declined', 'quote_expired', 'shipment_created', + 'shipped', + 'return_received', ]; for (const eventName of shippingEventNames) { await db.insert(shippingEvents).values({ @@ -115,6 +117,40 @@ describe.sequential('notifications projector phase 3', () => { } await db.insert(paymentEvents).values([ + { + id: crypto.randomUUID(), + orderId, + provider: 'stripe', + eventName: 'order_created', + eventSource: 'test_mapping', + eventRef: `evt_${crypto.randomUUID()}`, + amountMinor: 2000, + currency: 'USD', + payload: { + totalAmountMinor: 2000, + currency: 'USD', + paymentStatus: 'pending', + }, + dedupeKey: makeDedupe('payment'), + occurredAt: new Date(), + } as any, + { + id: crypto.randomUUID(), + orderId, + provider: 'stripe', + eventName: 'order_canceled', + eventSource: 'test_mapping', + eventRef: `evt_${crypto.randomUUID()}`, + amountMinor: 2000, + currency: 'USD', + payload: { + totalAmountMinor: 2000, + currency: 'USD', + paymentStatus: 'failed', + }, + dedupeKey: makeDedupe('payment'), + occurredAt: new Date(), + } as any, { id: crypto.randomUUID(), orderId, @@ -144,7 +180,7 @@ describe.sequential('notifications projector phase 3', () => { ]); const projected = await runNotificationOutboxProjector({ limit: 100 }); - expect(projected.inserted).toBeGreaterThanOrEqual(8); + expect(projected.inserted).toBeGreaterThanOrEqual(12); const rows = await db .select({ @@ -161,6 +197,10 @@ describe.sequential('notifications projector phase 3', () => { 'intl_quote_accepted', 'intl_quote_declined', 'intl_quote_expired', + 'order_created', + 'order_shipped', + 'order_canceled', + 'order_returned', 'payment_confirmed', 'shipment_created', 'refund_processed', diff --git a/frontend/lib/tests/shop/notifications-worker-transport-phase3.test.ts b/frontend/lib/tests/shop/notifications-worker-transport-phase3.test.ts index 9800277e..18fc9839 100644 --- a/frontend/lib/tests/shop/notifications-worker-transport-phase3.test.ts +++ b/frontend/lib/tests/shop/notifications-worker-transport-phase3.test.ts @@ -22,14 +22,15 @@ vi.mock('@/lib/services/shop/notifications/transport', () => ({ })); import { db } from '@/db'; -import { notificationOutbox, orders, orderShipping } from '@/db/schema'; +import { notificationOutbox, orders, orderShipping, users } from '@/db/schema'; import { runNotificationOutboxWorker } from '@/lib/services/shop/notifications/outbox-worker'; import { toDbMoney } from '@/lib/shop/money'; -async function seedOrder() { +async function seedOrder(userId: string | null = null) { const orderId = crypto.randomUUID(); await db.insert(orders).values({ id: orderId, + userId, totalAmountMinor: 1500, totalAmount: toDbMoney(1500), currency: 'USD', @@ -42,6 +43,18 @@ async function seedOrder() { return orderId; } +async function ensureUser(userId: string, email: string) { + await db + .insert(users) + .values({ + id: userId, + email, + role: 'user', + name: 'Notification Test User', + } as any) + .onConflictDoNothing(); +} + async function attachRecipientEmail(orderId: string, email: string) { await db.insert(orderShipping).values({ orderId, @@ -58,6 +71,10 @@ async function cleanupOrder(orderId: string) { await db.delete(orders).where(eq(orders.id, orderId)); } +async function cleanupUser(userId: string) { + await db.delete(users).where(eq(users.id, userId)); +} + async function insertOutboxRow(orderId: string) { const id = crypto.randomUUID(); await db.insert(notificationOutbox).values({ @@ -171,10 +188,58 @@ describe.sequential('notifications worker transport phase 3', () => { expect(row?.status).toBe('dead_letter'); expect(row?.attemptCount).toBe(1); expect(row?.deadLetteredAt).toBeTruthy(); - expect(row?.lastErrorCode).toBe('NOTIFICATION_RECIPIENT_MISSING'); + expect(row?.lastErrorCode).toBe('NOTIFICATION_GUEST_RECIPIENT_MISSING'); expect(sendShopNotificationEmailMock).not.toHaveBeenCalled(); } finally { await cleanupOrder(orderId); } }); + + it('falls back to users.email for signed-in orders when shipping recipient email is absent', async () => { + sendShopNotificationEmailMock.mockResolvedValue({ + messageId: 'msg-test-3', + }); + + const userId = `user-${crypto.randomUUID()}`; + await ensureUser(userId, 'account@example.test'); + + const orderId = await seedOrder(userId); + try { + const outboxId = await insertOutboxRow(orderId); + + const result = await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + + expect(result.claimed).toBe(1); + expect(result.sent).toBe(1); + expect(result.deadLettered).toBe(0); + + const [row] = await db + .select({ + status: notificationOutbox.status, + lastErrorCode: notificationOutbox.lastErrorCode, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.id, outboxId)) + .limit(1); + + expect(row?.status).toBe('sent'); + expect(row?.lastErrorCode).toBeNull(); + + expect(sendShopNotificationEmailMock).toHaveBeenCalledTimes(1); + expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'account@example.test', + }) + ); + } finally { + await cleanupOrder(orderId); + await cleanupUser(userId); + } + }); }); diff --git a/frontend/lib/tests/shop/status-notifications-phase5.test.ts b/frontend/lib/tests/shop/status-notifications-phase5.test.ts new file mode 100644 index 00000000..980fe56a --- /dev/null +++ b/frontend/lib/tests/shop/status-notifications-phase5.test.ts @@ -0,0 +1,628 @@ +import crypto from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendShopNotificationEmailMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/services/shop/notifications/transport', () => ({ + sendShopNotificationEmail: (...args: any[]) => + sendShopNotificationEmailMock(...args), + ShopNotificationTransportError: class ShopNotificationTransportError extends Error { + code: string; + transient: boolean; + + constructor(code: string, message: string, transient: boolean) { + super(message); + this.name = 'ShopNotificationTransportError'; + this.code = code; + this.transient = transient; + } + }, +})); + +import { db } from '@/db'; +import { + adminAuditLog, + inventoryMoves, + notificationOutbox, + orderItems, + orders, + orderShipping, + paymentEvents, + products, + returnRequests, + shippingEvents, + shippingShipments, + users, +} from '@/db/schema'; +import { restockOrder } from '@/lib/services/orders/restock'; +import { applyAdminOrderLifecycleAction } from '@/lib/services/shop/admin-order-lifecycle'; +import { runNotificationOutboxWorker } from '@/lib/services/shop/notifications/outbox-worker'; +import { runNotificationOutboxProjector } from '@/lib/services/shop/notifications/projector'; +import { + approveReturnRequest, + createReturnRequest, + receiveReturnRequest, +} from '@/lib/services/shop/returns'; +import { applyShippingAdminAction } from '@/lib/services/shop/shipping/admin-actions'; +import { toDbMoney } from '@/lib/shop/money'; + +async function ensureUser(args: { + id: string; + email: string; + role?: 'user' | 'admin'; +}) { + await db + .insert(users) + .values({ + id: args.id, + email: args.email, + role: args.role ?? 'user', + name: args.email, + } as any) + .onConflictDoNothing(); +} + +async function cleanupUser(userId: string | null) { + if (!userId) return; + await db.delete(users).where(eq(users.id, userId)); +} + +async function cleanupOrder(orderId: string) { + await db + .delete(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)); + await db.delete(paymentEvents).where(eq(paymentEvents.orderId, orderId)); + await db.delete(shippingEvents).where(eq(shippingEvents.orderId, orderId)); + await db.delete(adminAuditLog).where(eq(adminAuditLog.orderId, orderId)); + await db.delete(returnRequests).where(eq(returnRequests.orderId, orderId)); + await db.delete(inventoryMoves).where(eq(inventoryMoves.orderId, orderId)); + await db.delete(orderItems).where(eq(orderItems.orderId, orderId)); + await db + .delete(shippingShipments) + .where(eq(shippingShipments.orderId, orderId)); + await db.delete(orderShipping).where(eq(orderShipping.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function seedShippableOrder(args: { + orderId: string; + userId: string | null; + paymentProvider?: 'stripe' | 'monobank'; + paymentStatus?: + | 'pending' + | 'requires_payment' + | 'paid' + | 'failed' + | 'refunded'; + status?: 'CREATED' | 'INVENTORY_RESERVED' | 'PAID' | 'CANCELED'; + inventoryStatus?: + | 'none' + | 'reserving' + | 'reserved' + | 'release_pending' + | 'released'; + shippingStatus?: + | 'pending' + | 'label_created' + | 'shipped' + | 'cancelled' + | 'delivered'; + recipientEmail?: string | null; +}) { + await db.insert(orders).values({ + id: args.orderId, + userId: args.userId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + paymentProvider: args.paymentProvider ?? 'stripe', + paymentStatus: args.paymentStatus ?? 'paid', + status: args.status ?? 'PAID', + inventoryStatus: args.inventoryStatus ?? 'reserved', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingAmountMinor: null, + shippingStatus: args.shippingStatus ?? 'label_created', + trackingNumber: '20499900000001', + idempotencyKey: `status-notify-${args.orderId}`, + } as any); + + if (args.recipientEmail !== undefined) { + await db.insert(orderShipping).values({ + orderId: args.orderId, + shippingAddress: { + recipient: { + fullName: 'Status Buyer', + email: args.recipientEmail, + }, + }, + } as any); + } +} + +async function seedShipment(orderId: string) { + await db.insert(shippingShipments).values({ + id: crypto.randomUUID(), + orderId, + provider: 'nova_poshta', + status: 'succeeded', + attemptCount: 1, + leaseOwner: null, + leaseExpiresAt: null, + nextAttemptAt: null, + } as any); +} + +async function seedReturnOrder(): Promise<{ + orderId: string; + productId: string; + userId: string; +}> { + const orderId = crypto.randomUUID(); + const productId = crypto.randomUUID(); + const userId = `user_${crypto.randomUUID()}`; + + await ensureUser({ + id: userId, + email: `${userId}@example.test`, + }); + + await db.insert(products).values({ + id: productId, + slug: `status-notifications-${productId.slice(0, 8)}`, + title: 'Status Notifications Product', + imageUrl: 'https://example.com/status-notifications.png', + price: toDbMoney(1000), + currency: 'USD', + stock: 3, + isActive: true, + isFeatured: false, + } as any); + + await db.insert(orders).values({ + id: orderId, + userId, + totalAmountMinor: 2000, + totalAmount: toDbMoney(2000), + currency: 'USD', + paymentProvider: 'stripe', + paymentStatus: 'paid', + paymentIntentId: `pi_${crypto.randomUUID()}`, + pspChargeId: `ch_${crypto.randomUUID()}`, + status: 'PAID', + inventoryStatus: 'reserved', + idempotencyKey: `status-return-${orderId}`, + } as any); + + await db.insert(orderItems).values({ + id: crypto.randomUUID(), + orderId, + productId, + selectedSize: '', + selectedColor: '', + quantity: 2, + unitPriceMinor: 1000, + lineTotalMinor: 2000, + unitPrice: toDbMoney(1000), + lineTotal: toDbMoney(2000), + productTitle: 'Status Notifications Product', + productSlug: 'status-notifications-product', + } as any); + + await db.insert(inventoryMoves).values({ + moveKey: `reserve:${orderId}:${productId}`, + orderId, + productId, + type: 'reserve', + quantity: 2, + } as any); + + return { orderId, productId, userId }; +} + +async function cleanupReturnSeed(seed: { + orderId: string; + productId: string; + userId: string; +}) { + await cleanupOrder(seed.orderId); + await db.delete(products).where(eq(products.id, seed.productId)); + await cleanupUser(seed.userId); +} + +describe.sequential('status notifications phase 5', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('mark_shipped emits one shipped canonical event and delivers via signed-in account email fallback', async () => { + sendShopNotificationEmailMock.mockResolvedValue({ + messageId: 'msg-status-shipped-1', + }); + + const orderId = crypto.randomUUID(); + const userId = `user-${crypto.randomUUID()}`; + await ensureUser({ + id: userId, + email: 'signed-in@example.test', + }); + await seedShippableOrder({ + orderId, + userId, + shippingStatus: 'label_created', + }); + await seedShipment(orderId); + + try { + const first = await applyShippingAdminAction({ + orderId, + action: 'mark_shipped', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + const replay = await applyShippingAdminAction({ + orderId, + action: 'mark_shipped', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + + expect(first.changed).toBe(true); + expect(replay.changed).toBe(false); + + const events = await db + .select({ + id: shippingEvents.id, + eventName: shippingEvents.eventName, + eventSource: shippingEvents.eventSource, + }) + .from(shippingEvents) + .where( + and( + eq(shippingEvents.orderId, orderId), + eq(shippingEvents.eventName, 'shipped') + ) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventName: 'shipped', + eventSource: 'shipping_admin_action', + }); + + const firstProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + const secondProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + + expect(firstProjectorRun.inserted).toBeGreaterThanOrEqual(1); + expect(secondProjectorRun.inserted).toBe(0); + + const rows = await db + .select({ + templateKey: notificationOutbox.templateKey, + sourceDomain: notificationOutbox.sourceDomain, + payload: notificationOutbox.payload, + status: notificationOutbox.status, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + templateKey: 'order_shipped', + sourceDomain: 'shipping_event', + status: 'pending', + }); + expect(rows[0]?.payload).toMatchObject({ + canonicalEventName: 'shipped', + canonicalPayload: { + paymentStatus: 'paid', + trackingNumber: '20499900000001', + }, + }); + + const worker = await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + + expect(worker.sent).toBe(1); + expect(worker.deadLettered).toBe(0); + expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'signed-in@example.test', + subject: `[DevLovers] Order shipped for order ${orderId.slice(0, 12)}`, + text: expect.stringContaining('Canonical event: shipped'), + }) + ); + } finally { + await cleanupOrder(orderId); + await cleanupUser(userId); + } + }, 30_000); + + it('cancel emits one order_canceled canonical event and delivers for guest orders through persisted shipping recipient email', async () => { + sendShopNotificationEmailMock.mockResolvedValue({ + messageId: 'msg-status-canceled-1', + }); + + const orderId = crypto.randomUUID(); + await seedShippableOrder({ + orderId, + userId: null, + paymentStatus: 'pending', + status: 'CREATED', + inventoryStatus: 'none', + shippingStatus: 'pending', + recipientEmail: 'guest-status@example.test', + }); + + try { + const first = await applyAdminOrderLifecycleAction({ + orderId, + action: 'cancel', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + const replay = await applyAdminOrderLifecycleAction({ + orderId, + action: 'cancel', + actorUserId: null, + requestId: `req_${crypto.randomUUID()}`, + }); + + expect(first.changed).toBe(true); + expect(replay.changed).toBe(false); + + const events = await db + .select({ + id: paymentEvents.id, + eventName: paymentEvents.eventName, + eventSource: paymentEvents.eventSource, + payload: paymentEvents.payload, + }) + .from(paymentEvents) + .where( + and( + eq(paymentEvents.orderId, orderId), + eq(paymentEvents.eventName, 'order_canceled') + ) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventName: 'order_canceled', + eventSource: 'order_restock', + }); + expect(events[0]?.payload).toMatchObject({ + orderStatus: 'CANCELED', + paymentStatus: 'failed', + shippingStatus: 'cancelled', + }); + + const firstProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + const secondProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + + expect(firstProjectorRun.inserted).toBeGreaterThanOrEqual(1); + expect(secondProjectorRun.inserted).toBe(0); + + const rows = await db + .select({ + templateKey: notificationOutbox.templateKey, + sourceDomain: notificationOutbox.sourceDomain, + payload: notificationOutbox.payload, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, orderId)); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + templateKey: 'order_canceled', + sourceDomain: 'payment_event', + }); + expect(rows[0]?.payload).toMatchObject({ + canonicalEventName: 'order_canceled', + canonicalPayload: { + orderStatus: 'CANCELED', + paymentStatus: 'failed', + }, + }); + + const worker = await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + + expect(worker.sent).toBe(1); + expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'guest-status@example.test', + subject: `[DevLovers] Order canceled for order ${orderId.slice(0, 12)}`, + text: expect.stringContaining('Payment status: failed'), + }) + ); + } finally { + await cleanupOrder(orderId); + } + }, 30_000); + + it('return_received maps to order_returned notification and renders from persisted canonical payload data', async () => { + sendShopNotificationEmailMock.mockResolvedValue({ + messageId: 'msg-status-returned-1', + }); + + const seed = await seedReturnOrder(); + + try { + const created = await createReturnRequest({ + orderId: seed.orderId, + actorUserId: seed.userId, + idempotencyKey: `ret_${crypto.randomUUID()}`, + reason: 'size mismatch', + policyRestock: true, + requestId: `req_${crypto.randomUUID()}`, + }); + + await ensureUser({ + id: 'admin-status-1', + email: 'admin-status-1@example.test', + role: 'admin', + }); + + await approveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin-status-1', + requestId: `req_${crypto.randomUUID()}`, + }); + + const received = await receiveReturnRequest({ + returnRequestId: created.request.id, + actorUserId: 'admin-status-1', + requestId: `req_${crypto.randomUUID()}`, + }); + + expect(received.changed).toBe(true); + + const events = await db + .select({ + id: shippingEvents.id, + eventName: shippingEvents.eventName, + eventSource: shippingEvents.eventSource, + payload: shippingEvents.payload, + }) + .from(shippingEvents) + .where( + and( + eq(shippingEvents.orderId, seed.orderId), + eq(shippingEvents.eventName, 'return_received') + ) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventName: 'return_received', + eventSource: 'returns_admin_route', + }); + expect(events[0]?.payload).toMatchObject({ + returnRequestId: created.request.id, + restocked: true, + }); + + const firstProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + const secondProjectorRun = await runNotificationOutboxProjector({ + limit: 50, + }); + + expect(firstProjectorRun.inserted).toBeGreaterThanOrEqual(1); + expect(secondProjectorRun.inserted).toBe(0); + + const rows = await db + .select({ + templateKey: notificationOutbox.templateKey, + sourceDomain: notificationOutbox.sourceDomain, + payload: notificationOutbox.payload, + }) + .from(notificationOutbox) + .where(eq(notificationOutbox.orderId, seed.orderId)); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + templateKey: 'order_returned', + sourceDomain: 'shipping_event', + }); + expect(rows[0]?.payload).toMatchObject({ + canonicalEventName: 'return_received', + canonicalPayload: { + returnRequestId: created.request.id, + restocked: true, + }, + }); + + const worker = await runNotificationOutboxWorker({ + runId: `notify-worker-${crypto.randomUUID()}`, + limit: 10, + leaseSeconds: 120, + maxAttempts: 5, + baseBackoffSeconds: 5, + }); + + expect(worker.sent).toBe(1); + expect(sendShopNotificationEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: `${seed.userId}@example.test`, + subject: `[DevLovers] Return received for order ${seed.orderId.slice(0, 12)}`, + text: expect.stringContaining('Canonical event: return_received'), + }) + ); + } finally { + await cleanupReturnSeed(seed); + await cleanupUser('admin-status-1'); + } + }, 30_000); + + it('restock replay backfills a missing order_canceled canonical event without creating duplicates', async () => { + const orderId = crypto.randomUUID(); + await seedShippableOrder({ + orderId, + userId: null, + paymentStatus: 'failed', + status: 'CANCELED', + inventoryStatus: 'released', + shippingStatus: 'cancelled', + recipientEmail: 'guest-replay@example.test', + }); + + await db + .update(orders) + .set({ + stockRestored: true, + restockedAt: new Date(), + }) + .where(eq(orders.id, orderId)); + + try { + await restockOrder(orderId, { reason: 'canceled' }); + await restockOrder(orderId, { reason: 'canceled' }); + + const events = await db + .select({ + id: paymentEvents.id, + eventName: paymentEvents.eventName, + }) + .from(paymentEvents) + .where( + and( + eq(paymentEvents.orderId, orderId), + eq(paymentEvents.eventName, 'order_canceled') + ) + ); + + expect(events).toHaveLength(1); + } finally { + await cleanupOrder(orderId); + } + }, 30_000); +});