diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts index ad40f3b4..5aec1485 100644 --- a/frontend/lib/services/orders/monobank-webhook.ts +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -10,6 +10,7 @@ import { logError, logInfo } from '@/lib/logging'; import { InvalidPayloadError } from '@/lib/services/errors'; import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; import { restockOrder } from '@/lib/services/orders/restock'; +import { isUuidV1toV5 } from '@/lib/utils/uuid'; type WebhookMode = 'apply' | 'store' | 'drop'; @@ -38,8 +39,30 @@ type MonobankApplyOutcome = { orderId: string | null; }; -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +type AttemptRow = Pick< + typeof paymentAttempts.$inferSelect, + | 'id' + | 'orderId' + | 'status' + | 'expectedAmountMinor' + | 'providerPaymentIntentId' + | 'providerModifiedAt' +>; + +type OrderRow = Pick< + typeof orders.$inferSelect, + | 'id' + | 'paymentStatus' + | 'paymentProvider' + | 'status' + | 'currency' + | 'totalAmountMinor' + | 'pspMetadata' +>; + +type PaymentStatusTarget = Parameters< + typeof guardedPaymentStatusUpdate +>[0]['to']; const CLAIM_TTL_MS = (() => { const raw = process.env.MONO_WEBHOOK_CLAIM_TTL_MS; @@ -65,6 +88,13 @@ function toIssueMessage(error: unknown): string { return msg.length > 500 ? msg.slice(0, 500) : msg; } +function readDbRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const anyRes = res as any; + if (Array.isArray(anyRes?.rows)) return anyRes.rows as T[]; + return []; +} + function normalizeStatus(raw: unknown): string { if (typeof raw !== 'string') return ''; return raw.trim().toLowerCase(); @@ -258,6 +288,723 @@ function amountMismatch(args: { return { mismatch: false }; } +function buildApplyOutcome(args: { + appliedResult: ApplyResult; + restockOrderId?: string | null; + restockReason?: 'failed' | 'refunded' | null; + attemptId?: string | null; + orderId?: string | null; +}): MonobankApplyOutcome { + return { + appliedResult: args.appliedResult, + restockOrderId: args.restockOrderId ?? null, + restockReason: args.restockReason ?? null, + attemptId: args.attemptId ?? null, + orderId: args.orderId ?? null, + }; +} + +function getReferenceAttemptId(reference: string | null): string | null { + return reference && isUuidV1toV5(reference) ? reference : null; +} + +async function fetchAttemptForWebhook(args: { + invoiceId: string; + referenceAttemptId: string | null; +}): Promise { + const attemptRes = (await db.execute(sql` + select + id as "id", + order_id as "orderId", + status as "status", + expected_amount_minor as "expectedAmountMinor", + provider_payment_intent_id as "providerPaymentIntentId", + provider_modified_at as "providerModifiedAt" + from payment_attempts + where provider = 'monobank' + and ( + (${args.referenceAttemptId}::uuid is not null and id = ${args.referenceAttemptId}::uuid) + or provider_payment_intent_id = ${args.invoiceId} + ) + order by case + when (${args.referenceAttemptId}::uuid is not null and id = ${args.referenceAttemptId}::uuid) then 1 + else 0 + end desc + limit 1 + `)) as unknown as { rows?: AttemptRow[] }; + + return attemptRes.rows?.[0] ?? null; +} + +async function fetchOrderForAttempt(orderId: string): Promise { + const orderRes = (await db.execute(sql` + select + id as "id", + payment_status as "paymentStatus", + payment_provider as "paymentProvider", + status as "status", + currency as "currency", + total_amount_minor as "totalAmountMinor", + psp_metadata as "pspMetadata" + from orders + where id = ${orderId}::uuid + limit 1 + `)) as unknown as { rows?: OrderRow[] }; + + return orderRes.rows?.[0] ?? null; +} + +function computeNextProviderModifiedAt( + providerModifiedAt: Date | null, + attemptProviderModifiedAt: Date | null +): Date | null { + if ( + providerModifiedAt && + (!attemptProviderModifiedAt || + providerModifiedAt > attemptProviderModifiedAt) + ) { + return providerModifiedAt; + } + + return attemptProviderModifiedAt; +} + +async function transitionPaymentStatus(args: { + orderId: string; + status: string; + eventId: string; + to: PaymentStatusTarget; +}): Promise<{ ok: boolean; applied: boolean; reason?: string }> { + const res = await guardedPaymentStatusUpdate({ + orderId: args.orderId, + paymentProvider: 'monobank', + to: args.to, + source: 'monobank_webhook', + note: `event:${args.eventId}:${args.status}`, + }); + const ok = + res.applied || (res.currentProvider === 'monobank' && res.from === args.to); + + return { + ok, + applied: res.applied, + reason: ok ? undefined : res.reason, + }; +} + +async function persistEventOutcome(args: { + eventId: string; + now: Date; + appliedResult: ApplyResult; + appliedErrorCode?: string; + appliedErrorMessage?: string; + attemptId?: string; + orderId?: string; +}): Promise { + const patch: { + appliedAt: Date; + appliedResult: ApplyResult; + appliedErrorCode?: string; + appliedErrorMessage?: string; + attemptId?: string; + orderId?: string; + } = { + appliedAt: args.now, + appliedResult: args.appliedResult, + }; + + if (args.appliedErrorCode !== undefined) { + patch.appliedErrorCode = args.appliedErrorCode; + } + if (args.appliedErrorMessage !== undefined) { + patch.appliedErrorMessage = args.appliedErrorMessage; + } + if (args.attemptId !== undefined) { + patch.attemptId = args.attemptId; + } + if (args.orderId !== undefined) { + patch.orderId = args.orderId; + } + + await db + .update(monobankEvents) + .set(patch) + .where(eq(monobankEvents.id, args.eventId)); +} + +function buildMergedMetaSql(normalized: NormalizedWebhook) { + const metadataPatch = { + monobank: { + invoiceId: normalized.invoiceId, + status: normalized.status, + amount: normalized.amount ?? null, + ccy: normalized.ccy ?? null, + reference: normalized.reference ?? null, + }, + }; + + return sql`coalesce(${orders.pspMetadata}, '{}'::jsonb) || ${JSON.stringify( + metadataPatch + )}::jsonb`; +} + +async function atomicMarkPaidOrderAndSucceedAttempt(args: { + now: Date; + orderId: string; + attemptId: string; + invoiceId: string; + mergedMetaSql: ReturnType; + nextProviderModifiedAt: Date | null; +}): Promise { + const res = await db.execute(sql` + with updated_order as ( + update orders + set status = 'PAID', + psp_charge_id = ${args.invoiceId}, + psp_metadata = ${args.mergedMetaSql}, + updated_at = ${args.now} + where id = ${args.orderId}::uuid + and payment_provider = 'monobank' + and exists ( + select 1 + from payment_attempts + where id = ${args.attemptId}::uuid + ) + returning id + ), + updated_attempt as ( + update payment_attempts + set status = 'succeeded', + finalized_at = ${args.now}, + updated_at = ${args.now}, + last_error_code = null, + last_error_message = null, + provider_modified_at = ${args.nextProviderModifiedAt ?? null} + where id = ${args.attemptId}::uuid + and exists (select 1 from updated_order) + returning id + ) + select + (select id from updated_order) as order_id, + (select id from updated_attempt) as attempt_id + `); + + const row = readDbRows<{ order_id?: string; attempt_id?: string }>(res)[0]; + return Boolean(row?.order_id && row?.attempt_id); +} + +async function atomicFinalizeOrderAndAttempt(args: { + now: Date; + orderId: string; + attemptId: string; + pspStatusReason: string; + mergedMetaSql: ReturnType; + attemptStatus: 'failed' | 'canceled'; + lastErrorCode: string; + lastErrorMessage: string; + nextProviderModifiedAt: Date | null; +}): Promise { + const res = await db.execute(sql` + with updated_order as ( + update orders + set psp_status_reason = ${args.pspStatusReason}, + psp_metadata = ${args.mergedMetaSql}, + updated_at = ${args.now} + where id = ${args.orderId}::uuid + and payment_provider = 'monobank' + and exists ( + select 1 + from payment_attempts + where id = ${args.attemptId}::uuid + ) + returning id + ), + updated_attempt as ( + update payment_attempts + set status = ${args.attemptStatus}, + finalized_at = ${args.now}, + updated_at = ${args.now}, + last_error_code = ${args.lastErrorCode}, + last_error_message = ${args.lastErrorMessage}, + provider_modified_at = ${args.nextProviderModifiedAt ?? null} + where id = ${args.attemptId}::uuid + and exists (select 1 from updated_order) + returning id + ) + select + (select id from updated_order) as order_id, + (select id from updated_attempt) as attempt_id + `); + + const row = readDbRows<{ order_id?: string; attempt_id?: string }>(res)[0]; + return Boolean(row?.order_id && row?.attempt_id); +} + +async function applyWebhookToMatchedOrderAttemptEvent(args: { + eventId: string; + now: Date; + normalized: NormalizedWebhook; + providerModifiedAt: Date | null; + attemptRow: AttemptRow; + orderRow: OrderRow; +}): Promise { + const { eventId, now, normalized, providerModifiedAt, attemptRow, orderRow } = + args; + + const status = normalized.status; + const attemptProviderModifiedAt = attemptRow.providerModifiedAt + ? new Date(attemptRow.providerModifiedAt) + : null; + const nextProviderModifiedAt = computeNextProviderModifiedAt( + providerModifiedAt, + attemptProviderModifiedAt + ); + const mergedMetaSql = buildMergedMetaSql(normalized); + + if ( + providerModifiedAt && + attemptProviderModifiedAt && + providerModifiedAt <= attemptProviderModifiedAt + ) { + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'OUT_OF_ORDER', + appliedErrorMessage: 'provider_modified_at older than latest', + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const mismatch = amountMismatch({ + payloadAmount: normalized.amount, + payloadCcy: normalized.ccy, + orderCurrency: orderRow.currency, + orderTotal: Number(orderRow.totalAmountMinor ?? 0), + expectedAmount: + attemptRow.expectedAmountMinor != null + ? Number(attemptRow.expectedAmountMinor) + : null, + }); + + if (mismatch.mismatch) { + const appliedResult: ApplyResult = 'applied_with_issue'; + + if (orderRow.paymentStatus !== 'paid') { + await db + .update(paymentAttempts) + .set({ + status: 'failed', + finalizedAt: now, + updatedAt: now, + lastErrorCode: 'AMOUNT_MISMATCH', + lastErrorMessage: mismatch.reason ?? 'Mismatch', + providerModifiedAt: nextProviderModifiedAt ?? null, + }) + .where(eq(paymentAttempts.id, attemptRow.id)); + + const tr = await transitionPaymentStatus({ + orderId: orderRow.id, + status, + eventId, + to: 'needs_review', + }); + + if (tr.ok) { + await db + .update(orders) + .set({ + failureCode: 'MONO_AMOUNT_MISMATCH', + failureMessage: + mismatch.reason ?? 'Webhook amount/currency mismatch.', + updatedAt: now, + }) + .where( + and( + eq(orders.id, orderRow.id), + eq(orders.paymentProvider, 'monobank' as any) + ) + ); + } else { + // transition blocked, appliedResult already 'applied_with_issue' + } + } + + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'AMOUNT_MISMATCH', + appliedErrorMessage: mismatch.reason ?? 'Mismatch', + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if ( + orderRow.paymentStatus === 'paid' && + (status === 'success' || status === 'processing' || status === 'created') + ) { + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if (orderRow.paymentStatus === 'needs_review') { + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if ( + (orderRow.paymentStatus === 'failed' || + orderRow.paymentStatus === 'refunded') && + status === 'success' + ) { + const appliedResult: ApplyResult = 'applied_with_issue'; + + const tr = await transitionPaymentStatus({ + orderId: orderRow.id, + status, + eventId, + to: 'needs_review', + }); + + if (tr.ok) { + await db + .update(orders) + .set({ + failureCode: 'MONO_OUT_OF_ORDER', + failureMessage: `Out-of-order event: ${orderRow.paymentStatus} -> success`, + updatedAt: now, + }) + .where( + and( + eq(orders.id, orderRow.id), + eq(orders.paymentProvider, 'monobank' as any) + ) + ); + } else { + // transition blocked, appliedResult already 'applied_with_issue' + } + + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'OUT_OF_ORDER', + appliedErrorMessage: `Out-of-order: ${orderRow.paymentStatus} -> success`, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if (status === 'success') { + const tr = await transitionPaymentStatus({ + orderId: orderRow.id, + status, + eventId, + to: 'paid', + }); + + if (!tr.ok) { + const appliedResult: ApplyResult = 'applied_with_issue'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'PAYMENT_STATE_BLOCKED', + appliedErrorMessage: `blocked transition to paid (${tr.reason})`, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const ok = await atomicMarkPaidOrderAndSucceedAttempt({ + now, + orderId: orderRow.id, + attemptId: attemptRow.id, + invoiceId: normalized.invoiceId, + mergedMetaSql, + nextProviderModifiedAt: nextProviderModifiedAt ?? null, + }); + + if (!ok) { + logError('monobank_webhook_atomic_update_failed', undefined, { + eventId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + }); + + const appliedResult: ApplyResult = 'applied_with_issue'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'DB_WRITE_FAILED', + appliedErrorMessage: + 'atomic update (paid+succeeded) did not update both rows', + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const appliedResult: ApplyResult = 'applied'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if (status === 'processing' || status === 'created') { + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + if (status === 'failure' || status === 'expired' || status === 'reversed') { + const isRefunded = status === 'reversed'; + const nextPaymentStatus: PaymentStatusTarget = isRefunded + ? 'refunded' + : 'failed'; + + const tr = await transitionPaymentStatus({ + orderId: orderRow.id, + status, + eventId, + to: nextPaymentStatus, + }); + + if (!tr.ok) { + const appliedResult: ApplyResult = 'applied_with_issue'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'PAYMENT_STATE_BLOCKED', + appliedErrorMessage: `blocked transition to ${nextPaymentStatus} (${tr.reason})`, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const attemptStatus = isRefunded ? 'canceled' : 'failed'; + const lastErrorMessage = `Monobank status: ${status}`; + + const ok = await atomicFinalizeOrderAndAttempt({ + now, + orderId: orderRow.id, + attemptId: attemptRow.id, + pspStatusReason: status, + mergedMetaSql, + attemptStatus, + lastErrorCode: status, + lastErrorMessage, + nextProviderModifiedAt: nextProviderModifiedAt ?? null, + }); + + if (!ok) { + logError('monobank_webhook_atomic_update_failed', undefined, { + eventId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + }); + + const appliedResult: ApplyResult = 'applied_with_issue'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'DB_WRITE_FAILED', + appliedErrorMessage: + 'atomic update (finalize) did not update both rows', + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + const appliedResult: ApplyResult = 'applied'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + restockReason: isRefunded ? 'refunded' : 'failed', + restockOrderId: orderRow.id, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + } + + logError('MONO_WEBHOOK_UNKNOWN_STATUS', undefined, { + eventId, + status, + invoiceId: normalized.invoiceId, + orderId: orderRow.id, + attemptId: attemptRow.id, + }); + + const appliedResult: ApplyResult = 'applied_noop'; + await persistEventOutcome({ + eventId, + now, + appliedResult, + appliedErrorCode: 'UNKNOWN_STATUS', + appliedErrorMessage: `Unrecognized Monobank status: ${status}`, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + orderId: orderRow.id, + }); +} + +async function applyWebhookToOrderAttemptEvent(args: { + eventId: string; + normalized: NormalizedWebhook; + providerModifiedAt: Date | null; +}): Promise { + const now = new Date(); + const referenceAttemptId = getReferenceAttemptId(args.normalized.reference); + const attemptRow = await fetchAttemptForWebhook({ + invoiceId: args.normalized.invoiceId, + referenceAttemptId, + }); + + if (!attemptRow) { + const appliedResult: ApplyResult = 'unmatched'; + await persistEventOutcome({ + eventId: args.eventId, + now, + appliedResult, + appliedErrorCode: 'ATTEMPT_NOT_FOUND', + appliedErrorMessage: 'No matching payment attempt', + }); + + return buildApplyOutcome({ appliedResult }); + } + + const orderRow = await fetchOrderForAttempt(attemptRow.orderId); + if (!orderRow) { + const appliedResult: ApplyResult = 'unmatched'; + await persistEventOutcome({ + eventId: args.eventId, + now, + appliedResult, + appliedErrorCode: 'ORDER_NOT_FOUND', + appliedErrorMessage: 'Order not found for attempt', + attemptId: attemptRow.id, + }); + + return buildApplyOutcome({ + appliedResult, + attemptId: attemptRow.id, + }); + } + + return applyWebhookToMatchedOrderAttemptEvent({ + eventId: args.eventId, + now, + normalized: args.normalized, + providerModifiedAt: args.providerModifiedAt, + attemptRow, + orderRow, + }); +} + export async function applyMonoWebhookEvent(args: { rawBody: string; requestId: string; @@ -294,6 +1041,7 @@ export async function applyMonoWebhookEvent(args: { normalizedPayload: parsed.normalized, providerModifiedAt: parsed.providerModifiedAt, }); + if (!eventId) { logInfo('monobank_webhook_deduped', { requestId: args.requestId, @@ -344,525 +1092,11 @@ export async function applyMonoWebhookEvent(args: { }; } - const outcome = await (async (): Promise => { - const dbx = db; - let restockReason: 'failed' | 'refunded' | null = null; - let restockOrderId: string | null = null; - let appliedResult: ApplyResult = 'applied'; - let attemptId: string | null = null; - let orderId: string | null = null; - - const now = new Date(); - const referenceAttemptId = - parsed.normalized.reference && UUID_RE.test(parsed.normalized.reference) - ? parsed.normalized.reference - : null; - - const attemptRes = (await dbx.execute(sql` - select id, order_id, status, expected_amount_minor, provider_payment_intent_id, provider_modified_at - from payment_attempts - where provider = 'monobank' - and ( - (${referenceAttemptId}::uuid is not null and id = ${referenceAttemptId}::uuid) - or provider_payment_intent_id = ${parsed.normalized.invoiceId} - ) - order by case - when (${referenceAttemptId}::uuid is not null and id = ${referenceAttemptId}::uuid) then 1 - else 0 - end desc - limit 1 - `)) as unknown as { rows?: Array }; - - const attemptRow = attemptRes.rows?.[0] as - | { - id: string; - order_id: string; - status: string; - expected_amount_minor: number | null; - provider_payment_intent_id: string | null; - provider_modified_at: Date | string | null; - } - | undefined; - - if (!attemptRow) { - appliedResult = 'unmatched'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - appliedErrorCode: 'ATTEMPT_NOT_FOUND', - appliedErrorMessage: 'No matching payment attempt', - }) - .where(eq(monobankEvents.id, eventId)); - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - attemptId = attemptRow.id; - const orderRes = (await dbx.execute(sql` - select id, payment_status, payment_provider, status, currency, total_amount_minor, psp_metadata - from orders - where id = ${attemptRow.order_id}::uuid - limit 1 - `)) as unknown as { rows?: Array }; - - const orderRow = orderRes.rows?.[0] as - | { - id: string; - payment_status: string; - payment_provider: string; - status: string; - currency: string; - total_amount_minor: number; - psp_metadata: Record | null; - } - | undefined; - - if (!orderRow) { - appliedResult = 'unmatched'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - appliedErrorCode: 'ORDER_NOT_FOUND', - appliedErrorMessage: 'Order not found for attempt', - attemptId: attemptRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - orderId = orderRow.id; - - const status = parsed.normalized.status; - const providerModifiedAt = parsed.providerModifiedAt; - const attemptProviderModifiedAt = attemptRow.provider_modified_at - ? new Date(attemptRow.provider_modified_at) - : null; - const nextProviderModifiedAt = - providerModifiedAt && - (!attemptProviderModifiedAt || - providerModifiedAt > attemptProviderModifiedAt) - ? providerModifiedAt - : attemptProviderModifiedAt; - - if ( - providerModifiedAt && - attemptProviderModifiedAt && - providerModifiedAt <= attemptProviderModifiedAt - ) { - appliedResult = 'applied_noop'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - appliedErrorCode: 'OUT_OF_ORDER', - appliedErrorMessage: 'provider_modified_at older than latest', - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - const mismatch = amountMismatch({ - payloadAmount: parsed.normalized.amount, - payloadCcy: parsed.normalized.ccy, - orderCurrency: orderRow.currency, - orderTotal: Number(orderRow.total_amount_minor ?? 0), - expectedAmount: - attemptRow.expected_amount_minor != null - ? Number(attemptRow.expected_amount_minor) - : null, - }); - - const metadataPatch = { - monobank: { - invoiceId: parsed.normalized.invoiceId, - status, - amount: parsed.normalized.amount ?? null, - ccy: parsed.normalized.ccy ?? null, - reference: parsed.normalized.reference ?? null, - }, - }; - - const mergedMetaSql = sql`coalesce(${orders.pspMetadata}, '{}'::jsonb) || ${JSON.stringify( - metadataPatch - )}::jsonb`; - - const transitionPaymentStatus = async (to: any) => { - const res = await guardedPaymentStatusUpdate({ - orderId: orderRow.id, - paymentProvider: 'monobank', - to, - source: 'monobank_webhook', - note: `event:${eventId}:${status}`, - }); - const ok = - res.applied || (res.currentProvider === 'monobank' && res.from === to); - - return { - ok, - applied: res.applied, - reason: ok ? undefined : res.reason, - }; - }; - - if (mismatch.mismatch) { - appliedResult = 'applied_with_issue'; - - if (orderRow.payment_status !== 'paid') { - await dbx - .update(paymentAttempts) - .set({ - status: 'failed', - finalizedAt: now, - updatedAt: now, - lastErrorCode: 'AMOUNT_MISMATCH', - lastErrorMessage: mismatch.reason ?? 'Mismatch', - providerModifiedAt: nextProviderModifiedAt ?? null, - }) - .where(eq(paymentAttempts.id, attemptRow.id)); - - const tr = await transitionPaymentStatus('needs_review'); - if (tr.ok) { - await dbx - .update(orders) - .set({ - failureCode: 'MONO_AMOUNT_MISMATCH', - failureMessage: - mismatch.reason ?? 'Webhook amount/currency mismatch.', - updatedAt: now, - }) - .where( - and( - eq(orders.id, orderRow.id), - eq(orders.paymentProvider, 'monobank' as any) - ) - ); - } else { - appliedResult = 'applied_with_issue'; - } - } - - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - appliedErrorCode: 'AMOUNT_MISMATCH', - appliedErrorMessage: mismatch.reason ?? 'Mismatch', - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - if (orderRow.payment_status === 'paid' && status !== 'success') { - appliedResult = 'applied_noop'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - if (orderRow.payment_status === 'needs_review') { - appliedResult = 'applied_noop'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - if ( - (orderRow.payment_status === 'failed' || - orderRow.payment_status === 'refunded') && - status === 'success' - ) { - appliedResult = 'applied_with_issue'; - - const tr = await transitionPaymentStatus('needs_review'); - if (tr.ok) { - await dbx - .update(orders) - .set({ - failureCode: 'MONO_OUT_OF_ORDER', - failureMessage: `Out-of-order event: ${orderRow.payment_status} -> success`, - updatedAt: now, - }) - .where( - and( - eq(orders.id, orderRow.id), - eq(orders.paymentProvider, 'monobank' as any) - ) - ); - } else { - appliedResult = 'applied_with_issue'; - } - - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - appliedErrorCode: 'OUT_OF_ORDER', - appliedErrorMessage: `Out-of-order: ${orderRow.payment_status} -> success`, - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - if (status === 'success') { - const tr = await transitionPaymentStatus('paid'); - - if (!tr.ok) { - appliedResult = 'applied_with_issue'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - appliedErrorCode: 'PAYMENT_STATE_BLOCKED', - appliedErrorMessage: `blocked transition to paid (${tr.reason})`, - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - await dbx - .update(orders) - .set({ - status: 'PAID', - pspChargeId: parsed.normalized.invoiceId, - pspMetadata: mergedMetaSql as any, - updatedAt: now, - }) - .where( - and( - eq(orders.id, orderRow.id), - eq(orders.paymentProvider, 'monobank' as any) - ) - ); - - await dbx - .update(paymentAttempts) - .set({ - status: 'succeeded', - finalizedAt: now, - updatedAt: now, - lastErrorCode: null, - lastErrorMessage: null, - providerModifiedAt: nextProviderModifiedAt ?? null, - }) - .where(eq(paymentAttempts.id, attemptRow.id)); - - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult: 'applied', - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult: 'applied', - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - if (status === 'processing' || status === 'created') { - appliedResult = 'applied_noop'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - if (status === 'failure' || status === 'expired' || status === 'reversed') { - const isRefunded = status === 'reversed'; - const nextPaymentStatus = isRefunded ? 'refunded' : 'failed'; - - const tr = await transitionPaymentStatus(nextPaymentStatus); - - if (!tr.ok) { - appliedResult = 'applied_with_issue'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - appliedErrorCode: 'PAYMENT_STATE_BLOCKED', - appliedErrorMessage: `blocked transition to ${nextPaymentStatus} (${tr.reason})`, - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - await dbx - .update(orders) - .set({ - pspStatusReason: status, - pspMetadata: mergedMetaSql as any, - updatedAt: now, - }) - .where( - and( - eq(orders.id, orderRow.id), - eq(orders.paymentProvider, 'monobank' as any) - ) - ); - - await dbx - .update(paymentAttempts) - .set({ - status: isRefunded ? 'canceled' : 'failed', - finalizedAt: now, - updatedAt: now, - lastErrorCode: status, - lastErrorMessage: `Monobank status: ${status}`, - providerModifiedAt: nextProviderModifiedAt ?? null, - }) - .where(eq(paymentAttempts.id, attemptRow.id)); - - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult: 'applied', - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - restockReason = isRefunded ? 'refunded' : 'failed'; - restockOrderId = orderRow.id; - - return { - appliedResult: 'applied', - restockReason, - restockOrderId, - attemptId, - orderId, - }; - } - - appliedResult = 'applied_noop'; - await dbx - .update(monobankEvents) - .set({ - appliedAt: now, - appliedResult, - appliedErrorCode: 'UNKNOWN_STATUS', - appliedErrorMessage: `Unrecognized Monobank status: ${status}`, - attemptId: attemptRow.id, - orderId: orderRow.id, - }) - .where(eq(monobankEvents.id, eventId)); - - return { - appliedResult, - restockReason, - restockOrderId, - attemptId, - orderId, - }; - })(); + const outcome = await applyWebhookToOrderAttemptEvent({ + eventId, + normalized: parsed.normalized, + providerModifiedAt: parsed.providerModifiedAt, + }); const { appliedResult, restockOrderId, restockReason } = outcome; @@ -880,18 +1114,16 @@ export async function applyMonoWebhookEvent(args: { const now = new Date(); const issueMsg = toIssueMessage(error); - if (eventId) { - await db - .update(monobankEvents) - .set({ - appliedResult: 'applied_with_issue', - appliedErrorCode: - sql`coalesce(${monobankEvents.appliedErrorCode}, 'RESTOCK_FAILED')` as any, - appliedErrorMessage: - sql`coalesce(${monobankEvents.appliedErrorMessage}, ${issueMsg})` as any, - }) - .where(eq(monobankEvents.id, eventId)); - } + await db + .update(monobankEvents) + .set({ + appliedResult: 'applied_with_issue', + appliedErrorCode: + sql`coalesce(${monobankEvents.appliedErrorCode}, 'RESTOCK_FAILED')` as any, + appliedErrorMessage: + sql`coalesce(${monobankEvents.appliedErrorMessage}, ${issueMsg})` as any, + }) + .where(eq(monobankEvents.id, eventId)); if (outcome.attemptId) { await db diff --git a/frontend/lib/services/orders/monobank.ts b/frontend/lib/services/orders/monobank.ts index 5a1a2afa..3dc68d76 100644 --- a/frontend/lib/services/orders/monobank.ts +++ b/frontend/lib/services/orders/monobank.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { db } from '@/db'; import { orderItems, orders, paymentAttempts } from '@/db/schema'; @@ -49,7 +49,7 @@ async function getActiveAttempt( and( eq(paymentAttempts.orderId, orderId), eq(paymentAttempts.provider, 'monobank'), - sql`${paymentAttempts.status} in ('creating','active')` + inArray(paymentAttempts.status, ['creating', 'active']) ) ) .limit(1); @@ -230,7 +230,7 @@ async function cancelOrderAndRelease(orderId: string, reason: string) { and( eq(orders.id, orderId), eq(orders.paymentProvider, 'monobank'), - sql`${orders.paymentStatus} in ('pending','requires_payment')` + inArray(orders.paymentStatus, ['pending', 'requires_payment']) ) ) .returning({ id: orders.id }); @@ -458,6 +458,7 @@ async function createMonoAttemptAndInvoiceImpl( } ): Promise<{ attemptId: string; + attemptNumber: number; invoiceId: string; pageUrl: string; currency: 'UAH'; @@ -473,6 +474,7 @@ async function createMonoAttemptAndInvoiceImpl( invoiceId: existing.providerPaymentIntentId, pageUrl, attemptId: existing.id, + attemptNumber: existing.attemptNumber, currency: MONO_CURRENCY, totalAmountMinor: snapshot.amountMinor, }; @@ -535,6 +537,7 @@ async function createMonoAttemptAndInvoiceImpl( invoiceId: reused.providerPaymentIntentId, pageUrl, attemptId: reused.id, + attemptNumber: reused.attemptNumber, currency: MONO_CURRENCY, totalAmountMinor: snapshot.amountMinor, }; @@ -579,10 +582,18 @@ async function createMonoAttemptAndInvoiceImpl( }); } - await deps.cancelOrderAndRelease( - args.orderId, - 'Monobank snapshot validation failed.' - ); + try { + await deps.cancelOrderAndRelease( + args.orderId, + 'Monobank snapshot validation failed.' + ); + } catch (cancelErr) { + logError('monobank_cancel_order_failed', cancelErr, { + orderId: args.orderId, + attemptId: attempt.id, + requestId: args.requestId, + }); + } throw error; } @@ -673,6 +684,7 @@ async function createMonoAttemptAndInvoiceImpl( invoiceId: invoice.invoiceId, pageUrl: invoice.pageUrl, attemptId: attempt.id, + attemptNumber: attempt.attemptNumber, currency: MONO_CURRENCY, totalAmountMinor: snapshot.amountMinor, }; @@ -686,6 +698,7 @@ export async function createMonoAttemptAndInvoice(args: { maxAttempts?: number; }): Promise<{ attemptId: string; + attemptNumber: number; invoiceId: string; pageUrl: string; currency: 'UAH'; @@ -738,14 +751,5 @@ export async function createMonobankAttemptAndInvoice(args: { maxAttempts: args.maxAttempts, }); - const [row] = await db - .select({ attemptNumber: paymentAttempts.attemptNumber }) - .from(paymentAttempts) - .where(eq(paymentAttempts.id, result.attemptId)) - .limit(1); - - return { - ...result, - attemptNumber: row?.attemptNumber ?? 1, - }; + return result; } diff --git a/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts b/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts index 11a2b532..733c9384 100644 --- a/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts +++ b/frontend/lib/tests/shop/monobank-psp-unavailable.test.ts @@ -8,6 +8,7 @@ import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { isUuidV1toV5 } from '@/lib/utils/uuid'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -120,20 +121,41 @@ async function createIsolatedProduct(stock: number) { } async function cleanupOrder(orderId: string) { - await db.execute(sql`delete from inventory_moves where order_id = ${orderId}::uuid`); - await db.execute(sql`delete from order_items where order_id = ${orderId}::uuid`); - await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); - await db.delete(orders).where(eq(orders.id, orderId)); + if (!isUuidV1toV5(orderId)) + throw new Error(`cleanupOrder: invalid uuid: ${orderId}`); + + await db.execute( + sql`delete from inventory_moves where order_id = ${orderId}::uuid` + ); + await db.execute( + sql`delete from order_items where order_id = ${orderId}::uuid` + ); + await db.execute( + sql`delete from payment_attempts where order_id = ${orderId}::uuid` + ); + await db.execute(sql`delete from orders where id = ${orderId}::uuid`); } -async function cleanupProduct(productId: string) { - await db.execute(sql`delete from inventory_moves where product_id = ${productId}::uuid`); - await db.execute(sql`delete from order_items where product_id = ${productId}::uuid`); - await db.delete(productPrices).where(eq(productPrices.productId, productId)); - await db.delete(products).where(eq(products.id, productId)); +async function archiveProduct(productId: string) { + if (!isUuidV1toV5(productId)) + throw new Error(`archiveProduct: invalid uuid: ${productId}`); + const TEST_ARCHIVE_PREFIX = '[TEST-ARCHIVED] '; + await db + .update(products) + .set({ + isActive: false, + stock: 0, + title: sql` + case + when ${products.title} like ${TEST_ARCHIVE_PREFIX + '%'} then ${products.title} + else ${TEST_ARCHIVE_PREFIX} || ${products.title} + end + `, + updatedAt: new Date(), + } as any) + .where(eq(products.id, productId)); } - async function postCheckout(idemKey: string, productId: string) { const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { POST: (req: NextRequest) => Promise; @@ -237,12 +259,14 @@ describe.sequential('monobank PSP_UNAVAILABLE invariant', () => { if (orderId) { await cleanupOrder(orderId); } - } catch (e) { warnCleanup('cleanupOrder', e); } + } catch (e) { + warnCleanup('cleanupOrder', e); + } try { - await cleanupProduct(productId); + await archiveProduct(productId); } catch (e) { - warnCleanup('cleanupProduct', e); + warnCleanup('archiveProduct', e); } } }, 20_000); diff --git a/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts new file mode 100644 index 00000000..587802d9 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts @@ -0,0 +1,426 @@ +import crypto from 'node:crypto'; + +import { sql } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; + +function readRows(res: unknown): T[] { + if (Array.isArray(res)) return res as T[]; + const anyRes = res as any; + if (Array.isArray(anyRes?.rows)) return anyRes.rows as T[]; + return []; +} + +const enumLabelCache = new Map(); + +async function getEnumLabelsByColumn( + tableName: string, + columnName: string +): Promise { + const cacheKey = `${tableName}.${columnName}`; + const cached = enumLabelCache.get(cacheKey); + if (cached) return cached; + + const typeRes = await db.execute(sql` + select udt_name as type_name + from information_schema.columns + where table_schema = 'public' + and table_name = ${tableName} + and column_name = ${columnName} + limit 1 + `); + const typeRow = readRows<{ type_name?: string }>(typeRes)[0]; + const typeName = typeRow?.type_name; + if (!typeName) throw new Error(`Cannot resolve enum type for ${cacheKey}`); + + const labelsRes = await db.execute(sql` + select e.enumlabel as label + from pg_type t + join pg_enum e on e.enumtypid = t.oid + where t.typname = ${typeName} + order by e.enumsortorder + `); + const labels = readRows<{ label?: string }>(labelsRes) + .map(r => r.label) + .filter((x): x is string => typeof x === 'string' && x.length > 0); + + if (labels.length === 0) { + throw new Error(`Enum ${typeName} has no labels (for ${cacheKey})`); + } + + enumLabelCache.set(cacheKey, labels); + return labels; +} + +async function pickEnumLabelByColumn( + tableName: string, + columnName: string, + preferred?: string[] +): Promise { + const labels = await getEnumLabelsByColumn(tableName, columnName); + if (preferred?.length) { + const found = preferred.find(p => labels.includes(p)); + if (found) return found; + } + return labels[0]!; +} + +vi.mock('@/lib/services/orders/payment-state', () => { + return { + guardedPaymentStatusUpdate: vi.fn(), + }; +}); + +vi.mock('@/lib/logging', () => { + return { + logInfo: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), + }; +}); + +import { logError } from '@/lib/logging'; +import { applyMonoWebhookEvent } from '@/lib/services/orders/monobank-webhook'; +import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; + +function sha256Hex(buf: Buffer): string { + return crypto.createHash('sha256').update(buf).digest('hex'); +} + +function uuid(): string { + return crypto.randomUUID(); +} + +async function insertOrder(args: { + orderId: string; + currency: 'UAH' | 'USD'; + totalAmountMinor: number; + paymentProvider: 'monobank' | 'stripe'; + paymentStatus: string; + status?: string; +}) { + const idemKey = `test_${args.orderId}`; + const statusLabel = + args.status ?? + (await pickEnumLabelByColumn('orders', 'status', [ + 'RESERVING', + 'CREATED', + 'NEW', + 'PENDING', + ])); + await db.execute(sql` + insert into orders ( + id, + user_id, + idempotency_key, + currency, + total_amount, + total_amount_minor, + payment_provider, + payment_status, + status, + psp_metadata, + created_at, + updated_at + ) + values ( + ${args.orderId}::uuid, + null, + ${idemKey}, + ${args.currency}, + (${args.totalAmountMinor}::numeric / 100), + ${args.totalAmountMinor}, + ${args.paymentProvider}, + ${args.paymentStatus}, + ${statusLabel}, + '{}'::jsonb, + now(), + now() + ) + `); +} + +async function insertAttempt(args: { + attemptId: string; + orderId: string; + status?: string; + expectedAmountMinor: number; + invoiceId: string; + providerModifiedAt: Date | null; +}) { + const attemptStatus = + args.status ?? + (await pickEnumLabelByColumn('payment_attempts', 'status', [ + 'pending', + 'created', + 'requires_action', + ])); + + const attemptNumberRes = await db.execute(sql` + select coalesce(max(attempt_number), 0)::int + 1 as n + from payment_attempts + where order_id = ${args.orderId}::uuid + `); + const attemptNumber = readRows<{ n?: number }>(attemptNumberRes)[0]?.n ?? 1; + const idempotencyKey = `test:${args.attemptId}`; + await db.execute(sql` + insert into payment_attempts ( + id, + order_id, + provider, + attempt_number, + status, + idempotency_key, + expected_amount_minor, + provider_payment_intent_id, + provider_modified_at, + created_at, + updated_at + ) + values ( + ${args.attemptId}::uuid, + ${args.orderId}::uuid, + 'monobank', + ${attemptNumber}, + ${attemptStatus}, + ${idempotencyKey}, + ${args.expectedAmountMinor}, + ${args.invoiceId}, + ${args.providerModifiedAt ?? null}, + now(), + now() + ) + `); +} + +async function fetchEventByRawSha256(rawSha256: string) { + const res = (await db.execute(sql` + select + id, + invoice_id, + status, + applied_result, + applied_error_code, + applied_error_message, + attempt_id, + order_id, + raw_sha256 + from monobank_events + where raw_sha256 = ${rawSha256} + limit 1 + `)) as unknown as { rows?: any[] }; + + return res.rows?.[0] ?? null; +} + +async function cleanup(args: { + orderId: string; + attemptId: string; + rawSha256: string; +}) { + await db.execute( + sql`delete from monobank_events where raw_sha256 = ${args.rawSha256}` + ); + await db.execute( + sql`delete from payment_attempts where id = ${args.attemptId}::uuid` + ); + await db.execute(sql`delete from orders where id = ${args.orderId}::uuid`); +} + +describe('monobank-webhook apply outcomes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('P1#2: amount mismatch -> persistEventOutcome uses applied_with_issue even when transition is blocked', async () => { + ( + guardedPaymentStatusUpdate as unknown as ReturnType + ).mockResolvedValue({ + applied: false, + currentProvider: 'monobank', + from: 'pending', + reason: 'blocked_for_test', + }); + + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + + await insertAttempt({ + attemptId, + orderId, + status: 'pending', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: null, + }); + + const payload = { + invoiceId, + status: 'success', + amount: 101, + ccy: 980, + reference: attemptId, + }; + + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256Hex(Buffer.from(rawBody, 'utf8')); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_amount_mismatch', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + + expect(res.appliedResult).toBe('applied_with_issue'); + + const ev = await fetchEventByRawSha256(rawSha256); + expect(ev).not.toBeNull(); + expect(ev.applied_result).toBe('applied_with_issue'); + expect(ev.applied_error_code).toBe('AMOUNT_MISMATCH'); + expect(ev.attempt_id).toBe(attemptId); + expect(ev.order_id).toBe(orderId); + } finally { + await cleanup({ orderId, attemptId, rawSha256 }); + } + }); + + it('P1#2: provider_modified_at out-of-order -> applied_noop + OUT_OF_ORDER', async () => { + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + + const attemptModifiedAt = new Date(); + const payloadModifiedAt = new Date(attemptModifiedAt.getTime() - 60_000); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + + await insertAttempt({ + attemptId, + orderId, + status: 'pending', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: attemptModifiedAt, + }); + + const payload = { + invoiceId, + status: 'success', + amount: 100, + ccy: 980, + reference: attemptId, + modifiedAt: payloadModifiedAt.toISOString(), + }; + + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256Hex(Buffer.from(rawBody, 'utf8')); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_out_of_order', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + + expect(res.appliedResult).toBe('applied_noop'); + + const ev = await fetchEventByRawSha256(rawSha256); + expect(ev).not.toBeNull(); + expect(ev.applied_result).toBe('applied_noop'); + expect(ev.applied_error_code).toBe('OUT_OF_ORDER'); + expect(ev.attempt_id).toBe(attemptId); + expect(ev.order_id).toBe(orderId); + } finally { + await cleanup({ orderId, attemptId, rawSha256 }); + } + }); + + it('P1#3: unknown status -> applied_noop + UNKNOWN_STATUS + operational log', async () => { + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + + await insertAttempt({ + attemptId, + orderId, + status: 'pending', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: null, + }); + + const payload = { + invoiceId, + status: 'totally_new_status', + amount: 100, + ccy: 980, + reference: attemptId, + }; + + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256Hex(Buffer.from(rawBody, 'utf8')); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_unknown_status', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + + expect(res.appliedResult).toBe('applied_noop'); + + const ev = await fetchEventByRawSha256(rawSha256); + expect(ev).not.toBeNull(); + expect(ev.applied_result).toBe('applied_noop'); + expect(ev.applied_error_code).toBe('UNKNOWN_STATUS'); + + expect(logError).toHaveBeenCalled(); + const calls = (logError as any).mock.calls as any[][]; + const found = calls.find(c => c?.[0] === 'MONO_WEBHOOK_UNKNOWN_STATUS'); + expect(found).toBeTruthy(); + + const meta = found?.[2]; + expect(meta?.eventId).toBeTruthy(); + expect(meta?.status).toBe('totally_new_status'); + expect(meta?.invoiceId).toBe(invoiceId); + expect(meta?.orderId).toBe(orderId); + expect(meta?.attemptId).toBe(attemptId); + } finally { + await cleanup({ orderId, attemptId, rawSha256 }); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts index 1f46e02e..72417955 100644 --- a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts @@ -227,7 +227,7 @@ describe.sequential('monobank webhook apply (persist-first)', () => { } }); - it('paid is terminal: later failure event is applied_noop', async () => { + it('paid does not block: later expired event is applied_with_issue (transition blocked)', async () => { const invoiceId = `inv_${crypto.randomUUID()}`; const { orderId } = await insertOrderAndAttempt({ invoiceId, @@ -262,7 +262,7 @@ describe.sequential('monobank webhook apply (persist-first)', () => { mode: 'apply', }); - expect(second.appliedResult).toBe('applied_noop'); + expect(second.appliedResult).toBe('applied_with_issue'); const [order] = await db .select({ paymentStatus: orders.paymentStatus }) @@ -272,7 +272,10 @@ describe.sequential('monobank webhook apply (persist-first)', () => { expect(order?.paymentStatus).toBe('paid'); const [event] = await db - .select({ appliedResult: monobankEvents.appliedResult }) + .select({ + appliedResult: monobankEvents.appliedResult, + appliedErrorCode: monobankEvents.appliedErrorCode, + }) .from(monobankEvents) .where( and( @@ -281,7 +284,8 @@ describe.sequential('monobank webhook apply (persist-first)', () => { ) ) .limit(1); - expect(event?.appliedResult).toBe('applied_noop'); + expect(event?.appliedResult).toBe('applied_with_issue'); + expect(event?.appliedErrorCode).toBe('PAYMENT_STATE_BLOCKED'); } finally { await cleanup(orderId, invoiceId); } diff --git a/frontend/lib/tests/shop/monobank-webhook-paid-reversal.test.ts b/frontend/lib/tests/shop/monobank-webhook-paid-reversal.test.ts new file mode 100644 index 00000000..85db5f98 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-paid-reversal.test.ts @@ -0,0 +1,151 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/services/orders/restock', () => ({ + restockOrder: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/lib/services/orders/payment-state', () => ({ + guardedPaymentStatusUpdate: vi.fn().mockResolvedValue({ + applied: true, + currentProvider: 'monobank', + from: 'paid', + reason: null, + }), +})); + +vi.mock('@/lib/logging', () => ({ + logError: vi.fn(), + logInfo: vi.fn(), +})); + +async function cleanup( + orderId: string, + attemptId: string, + eventId: string | null +) { + if (eventId) { + await db.delete(monobankEvents).where(eq(monobankEvents.id, eventId)); + } + await db.delete(paymentAttempts).where(eq(paymentAttempts.id, attemptId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe.sequential( + 'monobank webhook: paid must not block reversed/failure/expired', + () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('paid + reversed reaches reversal handler (attempt canceled, restock called, status transition invoked)', async () => { + const { applyMonoWebhookEvent } = + await import('@/lib/services/orders/monobank-webhook'); + const { restockOrder } = await import('@/lib/services/orders/restock'); + const { guardedPaymentStatusUpdate } = + await import('@/lib/services/orders/payment-state'); + + const orderId = crypto.randomUUID(); + const attemptId = crypto.randomUUID(); + const invoiceId = `inv_${crypto.randomUUID()}`; + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'paid', + status: 'PAID', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + pspMetadata: {}, + pspChargeId: invoiceId, + } as any); + + await db.insert(paymentAttempts).values({ + id: attemptId, + orderId, + provider: 'monobank', + status: 'succeeded', + attemptNumber: 1, + currency: 'UAH', + expectedAmountMinor: 1000, + idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), + providerPaymentIntentId: invoiceId, + metadata: {}, + } as any); + + const payload = { + invoiceId, + status: 'reversed', + amount: 1000, + ccy: 980, + reference: attemptId, + modifiedAt: Date.now(), + }; + + const rawBody = JSON.stringify(payload); + const rawSha256 = crypto + .createHash('sha256') + .update(rawBody) + .digest('hex'); + + let eventId: string | null = null; + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + requestId: 'req_paid_reversed', + mode: 'apply', + rawSha256, + parsedPayload: payload, + eventKey: rawSha256, + }); + + eventId = res.eventId; + expect(eventId).toBeTruthy(); + const expectedNote = `event:${eventId!}:${payload.status}`; + expect(res.appliedResult).toBe('applied'); + + const [attempt] = await db + .select({ + status: paymentAttempts.status, + lastErrorCode: paymentAttempts.lastErrorCode, + }) + .from(paymentAttempts) + .where(eq(paymentAttempts.id, attemptId)) + .limit(1); + + expect(attempt?.status).toBe('canceled'); + expect(attempt?.lastErrorCode).toBe('reversed'); + + expect(guardedPaymentStatusUpdate).toHaveBeenCalledTimes(1); + expect(guardedPaymentStatusUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + orderId, + paymentProvider: 'monobank', + to: 'refunded', + source: 'monobank_webhook', + note: expectedNote, + }) + ); + + expect(restockOrder).toHaveBeenCalledTimes(1); + expect(restockOrder).toHaveBeenCalledWith(orderId, { + reason: 'refunded', + workerId: 'monobank_webhook', + }); + } finally { + await cleanup(orderId, attemptId, eventId); + } + }, 20000); + } +); diff --git a/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts b/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts index c57311e2..35c457c7 100644 --- a/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-mismatch.test.ts @@ -1,10 +1,10 @@ -import { and, desc, eq, gt, sql } from 'drizzle-orm'; +import crypto from 'node:crypto'; + +import { sql } from 'drizzle-orm'; import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; -import { productPrices, products } from '@/db/schema/shop'; -import { createOrderWithItems } from '@/lib/services/orders'; const ORIG_ENV = process.env; @@ -23,34 +23,60 @@ function makeReq(url: string, body: string, headers?: Record) { ); } -async function pickActiveProductIdForCurrency(currency: 'UAH' | 'USD') { - const [row] = await db - .select({ id: products.id }) - .from(products) - .innerJoin( - productPrices, - and( - eq(productPrices.productId, products.id), - eq(productPrices.currency, currency) - ) - ) - .where( - and( - eq(products.isActive, true), - gt(products.stock, 0), - gt(productPrices.priceMinor, 0) - ) +function safeCurrencyLiteral(currency: 'USD' | 'UAH'): 'USD' | 'UAH' { + return currency === 'UAH' ? 'UAH' : 'USD'; +} + +async function createStripeOrderFixture(args: { currency: 'USD' | 'UAH' }) { + const currency = safeCurrencyLiteral(args.currency); + + const orderId = crypto.randomUUID(); + const totalMinor = 12_345; + const piId = `pi_test_mismatch_${orderId.slice(0, 8)}`; + const idemKey = `test_${crypto.randomUUID()}`; + const now = new Date(); + + await db.execute(sql` + insert into orders ( + id, + user_id, + total_amount_minor, + total_amount, + currency, + payment_status, + payment_provider, + payment_intent_id, + status, + inventory_status, + failure_code, + failure_message, + idempotency_key, + idempotency_request_hash, + stock_restored, + restocked_at, + updated_at + ) values ( + ${orderId}::uuid, + null, + ${totalMinor}, + (${totalMinor}::numeric / 100), + ${sql.raw(`'${currency}'`)}, + 'requires_payment', + 'stripe', + ${piId}, + 'INVENTORY_RESERVED', + 'reserved', + null, + null, + ${idemKey}, + ${`hash_${idemKey}`}, + false, + null, + ${now} ) - .orderBy(desc(products.updatedAt)) - .limit(1); - - const id = row?.id; - if (!id) { - throw new Error( - `No active product found for currency=${currency}. Ensure DB has products.isActive=true, stock>0, and product_prices row.` - ); - } - return id; + `); + + return { orderId, piId, totalMinor, currency }; } describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set paid', () => { @@ -64,138 +90,127 @@ describe('P0-3.4 Stripe webhook: amount/currency mismatch (minor) must not set p vi.restoreAllMocks(); }); - it('mismatch: does NOT set paid and stores pspStatusReason + pspMetadata(expected/actual + event id)', async () => { - process.env.STRIPE_PAYMENTS_ENABLED = 'false'; - - vi.doMock('@/lib/auth', async () => { - const actual = await vi.importActual('@/lib/auth'); - return { - __esModule: true, - ...actual, - getCurrentUser: vi.fn(async () => null), - }; - }); - - vi.doMock('@/lib/psp/stripe', async () => { - const actual = await vi.importActual('@/lib/psp/stripe'); - return { - __esModule: true, - ...actual, - - verifyWebhookSignature: vi.fn((params: any) => { - const rawBody = params?.rawBody; - if (typeof rawBody !== 'string' || !rawBody.trim()) { - throw new Error('TEST_INVALID_RAW_BODY'); - } - return JSON.parse(rawBody); - }), - }; - }); - const productId = await pickActiveProductIdForCurrency('UAH'); - const idemKey = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `idem_${Date.now()}_${Math.random().toString(16).slice(2)}`; - - const { order } = await createOrderWithItems({ - items: [{ productId, quantity: 1 }], - idempotencyKey: idemKey, - userId: null, - locale: 'uk', - }); - - const orderId: string = order.id; - - expect(orderId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - ); - - const row0 = (await db.execute(sql` - select total_amount_minor, currency - from orders - where id = ${orderId} - limit 1 - `)) as DbRows<{ total_amount_minor: number | string; currency: string }>; - - const expectedMinor = Number(row0.rows?.[0]?.total_amount_minor); - const expectedCurrency = String(row0.rows?.[0]?.currency); - - expect(Number.isFinite(expectedMinor)).toBe(true); - expect(expectedMinor).toBeGreaterThan(0); - expect(expectedCurrency).toBe('UAH'); - - const piId = `pi_test_mismatch_${orderId.slice(0, 8)}`; - await db.execute(sql` - update orders - set payment_provider = 'stripe', - payment_status = 'requires_payment', - payment_intent_id = ${piId} - where id = ${orderId} - `); - - process.env.STRIPE_PAYMENTS_ENABLED = 'true'; - process.env.STRIPE_SECRET_KEY = 'stripe_secret_key_placeholder'; - process.env.STRIPE_WEBHOOK_SECRET = 'stripe_webhook_secret_placeholder'; - - const evtId = `evt_mismatch_${orderId.slice(0, 8)}`; - const actualMinor = expectedMinor + 1; - - const mockedEvent = { - id: evtId, - type: 'payment_intent.succeeded', - data: { - object: { - id: piId, - object: 'payment_intent', - status: 'succeeded', - currency: 'uah', - amount: actualMinor, - amount_received: actualMinor, - metadata: { orderId }, + it( + 'mismatch: does NOT set paid and stores pspStatusReason + pspMetadata(expected/actual + event id)', + async () => { + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'false'; + + vi.doMock('@/lib/auth', async () => { + const actual = await vi.importActual('@/lib/auth'); + return { + __esModule: true, + ...actual, + getCurrentUser: vi.fn(async () => null), + }; + }); + + vi.doMock('@/lib/psp/stripe', async () => { + const actual = await vi.importActual('@/lib/psp/stripe'); + return { + __esModule: true, + ...actual, + verifyWebhookSignature: vi.fn((params: any) => { + const rawBody = params?.rawBody; + if (typeof rawBody !== 'string' || !rawBody.trim()) { + throw new Error('TEST_INVALID_RAW_BODY'); + } + return JSON.parse(rawBody); + }), + }; + }); + + const { orderId, piId, totalMinor, currency } = + await createStripeOrderFixture({ currency: 'USD' }); + + expect(orderId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + + const row0 = (await db.execute(sql` + select total_amount_minor, currency + from orders + where id = ${orderId}::uuid + limit 1 + `)) as DbRows<{ total_amount_minor: number | string; currency: string }>; + + const expectedMinor = Number(row0.rows?.[0]?.total_amount_minor); + const expectedCurrency = String(row0.rows?.[0]?.currency); + + expect(Number.isFinite(expectedMinor)).toBe(true); + expect(expectedMinor).toBe(totalMinor); + expect(expectedMinor).toBeGreaterThan(0); + expect(expectedCurrency).toBe(currency); + + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'stripe_secret_key_placeholder'; + process.env.STRIPE_WEBHOOK_SECRET = 'stripe_webhook_secret_placeholder'; + + const evtId = `evt_mismatch_${orderId.slice(0, 8)}`; + const actualMinor = expectedMinor + 1; + + const mockedEvent = { + id: evtId, + type: 'payment_intent.succeeded', + data: { + object: { + id: piId, + object: 'payment_intent', + status: 'succeeded', + currency: 'usd', + amount: actualMinor, + amount_received: actualMinor, + metadata: { orderId }, + }, }, - }, - }; - - const { POST: webhookPOST } = - await import('@/app/api/shop/webhooks/stripe/route'); - - const webhookRes = await webhookPOST( - makeReq( - 'http://localhost/api/shop/webhooks/stripe', - JSON.stringify(mockedEvent), - { - 'stripe-signature': 't=0,v1=deadbeef', - } - ) - ); - - expect([200, 202]).toContain(webhookRes.status); - - const row1 = (await db.execute(sql` - select payment_status, psp_status_reason, psp_metadata - from orders - where id = ${orderId} - limit 1 - `)) as DbRows<{ - payment_status: string; - psp_status_reason: string | null; - psp_metadata: unknown; - }>; - - const paymentStatus = String(row1.rows?.[0]?.payment_status ?? ''); - const reason = row1.rows?.[0]?.psp_status_reason ?? null; - const metaRaw = row1.rows?.[0]?.psp_metadata; - - expect(paymentStatus).not.toBe('paid'); - expect(reason && reason.length > 0).toBe(true); - - const metaObj = - typeof metaRaw === 'string' ? JSON.parse(metaRaw) : (metaRaw ?? {}); - - expect(metaObj?.mismatch?.eventId).toBe(evtId); - expect(metaObj?.mismatch?.expected?.amountMinor).toBe(expectedMinor); - expect(metaObj?.mismatch?.actual?.amountMinor).toBe(actualMinor); - expect(String(metaObj?.mismatch?.expected?.currency)).toBe('UAH'); - expect(String(metaObj?.mismatch?.actual?.currency)).toBe('uah'); - }, 30_000); + }; + + const { POST: webhookPOST } = await import( + '@/app/api/shop/webhooks/stripe/route' + ); + + const webhookRes = await webhookPOST( + makeReq( + 'http://localhost/api/shop/webhooks/stripe', + JSON.stringify(mockedEvent), + { 'stripe-signature': 't=0,v1=deadbeef' } + ) + ); + + expect([200, 202]).toContain(webhookRes.status); + + const row1 = (await db.execute(sql` + select payment_status, psp_status_reason, psp_metadata + from orders + where id = ${orderId}::uuid + limit 1 + `)) as DbRows<{ + payment_status: string; + psp_status_reason: string | null; + psp_metadata: unknown; + }>; + + const paymentStatus = String(row1.rows?.[0]?.payment_status ?? ''); + const reason = row1.rows?.[0]?.psp_status_reason ?? null; + const metaRaw = row1.rows?.[0]?.psp_metadata; + + expect(paymentStatus).not.toBe('paid'); + expect(reason && reason.length > 0).toBe(true); + + const metaObj = + typeof metaRaw === 'string' ? JSON.parse(metaRaw) : (metaRaw ?? {}); + + expect(metaObj?.mismatch?.eventId).toBe(evtId); + expect(metaObj?.mismatch?.expected?.amountMinor).toBe(expectedMinor); + expect(metaObj?.mismatch?.actual?.amountMinor).toBe(actualMinor); + expect(String(metaObj?.mismatch?.expected?.currency)).toBe(currency); + expect(String(metaObj?.mismatch?.actual?.currency)).toBe('usd'); + + await db.execute(sql` + delete from orders + where id = ${orderId}::uuid + `); + }, + 30_000 + ); }); diff --git a/frontend/lib/utils/uuid.ts b/frontend/lib/utils/uuid.ts new file mode 100644 index 00000000..ac62d292 --- /dev/null +++ b/frontend/lib/utils/uuid.ts @@ -0,0 +1,6 @@ +export const UUID_V1_V5_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isUuidV1toV5(value: unknown): value is string { + return typeof value === 'string' && UUID_V1_V5_RE.test(value); +}