From b482b575f9218da2d514712cc9cf10bdabf40fde Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 17:04:41 -0800 Subject: [PATCH 01/14] (SP: 2)[Wallets] add paymentMethod selection with method-aware checkout idempotency --- frontend/app/api/shop/checkout/route.ts | 106 ++++++++++- frontend/lib/services/orders/_shared.ts | 6 +- frontend/lib/services/orders/checkout.ts | 166 +++++++++++++++++- frontend/lib/shop/payments.ts | 51 ++++++ ...kout-monobank-idempotency-contract.test.ts | 82 ++++++++- ...checkout-monobank-parse-validation.test.ts | 123 +++++++++++-- frontend/lib/validation/shop.ts | 61 ++++++- 7 files changed, 565 insertions(+), 30 deletions(-) diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 5e24160d..f0a7a047 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -28,7 +28,13 @@ import { ensureStripePaymentIntentForOrder, PaymentAttemptsExhaustedError, } from '@/lib/services/orders/payment-attempts'; -import { type PaymentProvider, type PaymentStatus } from '@/lib/shop/payments'; +import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; +import { + isMethodAllowed, + type PaymentMethod, + type PaymentProvider, + type PaymentStatus, +} from '@/lib/shop/payments'; import { resolveRequestLocale } from '@/lib/shop/request-locale'; import { createStatusToken } from '@/lib/shop/status-token'; import { @@ -83,11 +89,32 @@ function parseRequestedProvider( return 'invalid'; } +function parseRequestedMethod(raw: unknown): PaymentMethod | 'invalid' | null { + if (raw === null || raw === undefined) return null; + if (typeof raw !== 'string') return 'invalid'; + + const normalized = raw.trim().toLowerCase(); + if (!normalized) return 'invalid'; + + if (normalized === 'stripe_card') return 'stripe_card'; + if (normalized === 'monobank_invoice') return 'monobank_invoice'; + if (normalized === 'monobank_google_pay') return 'monobank_google_pay'; + + return 'invalid'; +} + function isMonoAlias(raw: unknown): boolean { if (typeof raw !== 'string') return false; return raw.trim().toLowerCase() === 'mono'; } +function isMonobankGooglePayEnabled(): boolean { + const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '') + .trim() + .toLowerCase(); + return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on'; +} + function stripMonobankClientMoneyFields(payload: unknown): unknown { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return payload; @@ -508,14 +535,22 @@ export async function POST(request: NextRequest) { ); } + const locale = resolveRequestLocale(request); + let monobankRequestHint = false; if (body && typeof body === 'object' && !Array.isArray(body)) { - const { paymentProvider, provider } = body as Record; + const { paymentProvider, provider, paymentMethod } = body as Record< + string, + unknown + >; const rawProvider = paymentProvider ?? provider; const parsedProvider = parseRequestedProvider(rawProvider); + const parsedMethod = parseRequestedMethod(paymentMethod); monobankRequestHint = parsedProvider === 'monobank' || - (parsedProvider === 'invalid' && isMonoAlias(rawProvider)); + (parsedProvider === 'invalid' && isMonoAlias(rawProvider)) || + parsedMethod === 'monobank_invoice' || + parsedMethod === 'monobank_google_pay'; } const idempotencyKey = getIdempotencyKey(request); @@ -572,10 +607,11 @@ export async function POST(request: NextRequest) { }; let requestedProvider: CheckoutRequestedProvider | null = null; + let requestedMethod: PaymentMethod | null = null; let payloadForValidation: unknown = body; if (body && typeof body === 'object' && !Array.isArray(body)) { - const { paymentProvider, provider, ...rest } = body as Record< + const { paymentProvider, provider, paymentMethod, ...rest } = body as Record< string, unknown >; @@ -594,12 +630,33 @@ export async function POST(request: NextRequest) { ); } + const parsedMethod = parseRequestedMethod(paymentMethod); + if (parsedMethod === 'invalid') { + if (parsedProvider === 'monobank' || isMonoAlias(rawProvider)) { + return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); + } + + return errorResponse( + 'PAYMENTS_METHOD_INVALID', + 'Invalid payment method.', + 422 + ); + } + requestedProvider = parsedProvider; + requestedMethod = parsedMethod; payloadForValidation = rest; } const selectedProvider: CheckoutRequestedProvider = requestedProvider ?? 'stripe'; + // Explicit default table (locked): stripe -> stripe_card, monobank -> monobank_invoice. + const defaultMethod: PaymentMethod = + selectedProvider === 'monobank' ? 'monobank_invoice' : 'stripe_card'; + const selectedMethod: PaymentMethod = requestedMethod ?? defaultMethod; + const selectedCurrency = + selectedProvider === 'monobank' ? 'UAH' : resolveCurrencyFromLocale(locale); + if (selectedProvider === 'monobank') { payloadForValidation = stripMonobankClientMoneyFields(payloadForValidation); } @@ -651,6 +708,45 @@ export async function POST(request: NextRequest) { } } + if ( + selectedMethod === 'monobank_google_pay' && + !isMonobankGooglePayEnabled() + ) { + return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); + } + + if ( + !isMethodAllowed({ + provider: selectedProvider, + method: selectedMethod, + currency: selectedCurrency, + flags: { monobankGooglePayEnabled: isMonobankGooglePayEnabled() }, + }) + ) { + if (selectedProvider === 'monobank') { + return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); + } + + return errorResponse( + 'PAYMENTS_METHOD_INVALID', + 'Invalid payment method.', + 422 + ); + } + + if ( + payloadForValidation && + typeof payloadForValidation === 'object' && + !Array.isArray(payloadForValidation) + ) { + payloadForValidation = { + ...(payloadForValidation as Record), + paymentProvider: selectedProvider, + paymentMethod: selectedMethod, + paymentCurrency: selectedCurrency, + }; + } + const parsedPayload = checkoutPayloadSchema.safeParse(payloadForValidation); if (!parsedPayload.success) { @@ -685,7 +781,6 @@ export async function POST(request: NextRequest) { const { items, userId, shipping, country, legalConsent } = parsedPayload.data; const itemCount = items.reduce((total, item) => total + item.quantity, 0); - const locale = resolveRequestLocale(request); let currentUser: unknown = null; try { @@ -789,6 +884,7 @@ export async function POST(request: NextRequest) { shipping: shipping ?? null, legalConsent: legalConsent ?? null, paymentProvider: selectedProvider === 'monobank' ? 'monobank' : undefined, + paymentMethod: selectedMethod, }); const { order } = result; diff --git a/frontend/lib/services/orders/_shared.ts b/frontend/lib/services/orders/_shared.ts index c1d739d9..542304a9 100644 --- a/frontend/lib/services/orders/_shared.ts +++ b/frontend/lib/services/orders/_shared.ts @@ -3,7 +3,7 @@ import crypto from 'crypto'; import { db } from '@/db'; import { orders } from '@/db/schema/shop'; import { type CurrencyCode } from '@/lib/shop/currency'; -import { type PaymentProvider } from '@/lib/shop/payments'; +import { type PaymentMethod, type PaymentProvider } from '@/lib/shop/payments'; import { type CheckoutItem, type OrderSummaryWithMinor, @@ -185,6 +185,7 @@ export function hashIdempotencyRequest(params: { currency: string; locale: string | null; paymentProvider: PaymentProvider; + paymentMethod: PaymentMethod | null; shipping: { provider: 'nova_poshta'; methodCode: 'NP_WAREHOUSE' | 'NP_LOCKER' | 'NP_COURIER'; @@ -215,10 +216,11 @@ export function hashIdempotencyRequest(params: { }); const payload = JSON.stringify({ - v: 3, + v: 4, currency: params.currency, locale: normVariant(params.locale).toLowerCase(), paymentProvider: params.paymentProvider, + paymentMethod: params.paymentMethod, shipping: params.shipping, legalConsent: params.legalConsent, items: normalized, diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index 28a0e791..1169423b 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -25,7 +25,12 @@ import { sumLineTotals, toDbMoney, } from '@/lib/shop/money'; -import { type PaymentProvider, type PaymentStatus } from '@/lib/shop/payments'; +import { + resolveDefaultMethodForProvider, + type PaymentMethod, + type PaymentProvider, + type PaymentStatus, +} from '@/lib/shop/payments'; import { type CheckoutItem, type CheckoutLegalConsentInput, @@ -733,6 +738,97 @@ function priceItems( }); } +function isMonobankGooglePayEnabled(): boolean { + const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '') + .trim() + .toLowerCase(); + return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on'; +} + +function normalizeStoredPaymentMethod(value: unknown): PaymentMethod | null { + const normalized = normVariant(typeof value === 'string' ? value : ''); + if (normalized === 'stripe_card') return 'stripe_card'; + if (normalized === 'monobank_invoice') return 'monobank_invoice'; + if (normalized === 'monobank_google_pay') return 'monobank_google_pay'; + return null; +} + +function resolveCheckoutPaymentMethod(args: { + requestedMethod?: PaymentMethod | null; + paymentProvider: PaymentProvider; + currency: Currency; +}): PaymentMethod | null { + if (args.paymentProvider === 'none') return null; + + if (!args.requestedMethod) { + return resolveDefaultMethodForProvider(args.paymentProvider, args.currency); + } + + if (args.requestedMethod === 'stripe_card') { + if (args.paymentProvider !== 'stripe') { + throw new InvalidPayloadError( + 'paymentMethod is not allowed for selected provider.', + { + code: 'INVALID_PAYLOAD', + } + ); + } + return args.requestedMethod; + } + + if ( + args.requestedMethod === 'monobank_invoice' || + args.requestedMethod === 'monobank_google_pay' + ) { + if (args.paymentProvider !== 'monobank' || args.currency !== 'UAH') { + throw new InvalidPayloadError( + 'paymentMethod is not allowed for selected provider/currency.', + { + code: 'INVALID_PAYLOAD', + } + ); + } + + if ( + args.requestedMethod === 'monobank_google_pay' && + !isMonobankGooglePayEnabled() + ) { + throw new InvalidPayloadError('Monobank Google Pay is disabled.', { + code: 'INVALID_PAYLOAD', + }); + } + + return args.requestedMethod; + } + + throw new InvalidPayloadError('Invalid payment method.', { + code: 'INVALID_PAYLOAD', + }); +} + +function buildCheckoutMetadataPatch( + existingMeta: unknown, + paymentMethod: PaymentMethod | null +): Record { + const base = + existingMeta && typeof existingMeta === 'object' && !Array.isArray(existingMeta) + ? (existingMeta as Record) + : {}; + + const checkoutMeta = + base.checkout && typeof base.checkout === 'object' && !Array.isArray(base.checkout) + ? (base.checkout as Record) + : {}; + + return { + ...base, + checkout: { + ...checkoutMeta, + requestedMethod: paymentMethod, + }, + }; +} + export async function createOrderWithItems({ items, idempotencyKey, @@ -742,6 +838,7 @@ export async function createOrderWithItems({ shipping, legalConsent, paymentProvider: requestedProvider, + paymentMethod: requestedMethod, }: { items: CheckoutItem[]; idempotencyKey: string; @@ -751,6 +848,7 @@ export async function createOrderWithItems({ shipping?: CheckoutShippingInput | null; legalConsent?: CheckoutLegalConsentInput | null; paymentProvider?: PaymentProvider; + paymentMethod?: PaymentMethod | null; }): Promise { const isMonobankRequested = requestedProvider === 'monobank'; const currency: Currency = isMonobankRequested @@ -768,6 +866,11 @@ export async function createOrderWithItems({ const initialPaymentStatus: PaymentStatus = paymentProvider === 'none' ? 'paid' : 'pending'; + const resolvedPaymentMethod = resolveCheckoutPaymentMethod({ + requestedMethod, + paymentProvider, + currency, + }); const normalizedItems = mergeCheckoutItems(items).map(item => normalizeCheckoutItem(item) @@ -790,6 +893,7 @@ export async function createOrderWithItems({ currency, locale: locale ?? null, paymentProvider, + paymentMethod: resolvedPaymentMethod, shipping: preparedShipping.hashRefs, legalConsent: preparedLegalConsent.hashRefs, }); @@ -801,6 +905,8 @@ export async function createOrderWithItems({ currency: orders.currency, paymentStatus: orders.paymentStatus, paymentProvider: orders.paymentProvider, + pspPaymentMethod: orders.pspPaymentMethod, + pspMetadata: orders.pspMetadata, idempotencyRequestHash: orders.idempotencyRequestHash, failureMessage: orders.failureMessage, shippingProvider: orders.shippingProvider, @@ -881,6 +987,15 @@ export async function createOrderWithItems({ ); } + const existingProvider = resolvePaymentProvider({ + paymentProvider: row.paymentProvider, + paymentIntentId: existing.paymentIntentId ?? null, + paymentStatus: row.paymentStatus, + }); + const existingMethod = + normalizeStoredPaymentMethod(row.pspPaymentMethod) ?? + resolveDefaultMethodForProvider(existingProvider, row.currency as Currency); + const derivedExistingHash = hashIdempotencyRequest({ items: (existing.items as any[]).map(i => ({ productId: i.productId, @@ -898,11 +1013,8 @@ export async function createOrderWithItems({ })) as CheckoutItemWithVariant[], currency: row.currency, locale: locale ?? null, - paymentProvider: resolvePaymentProvider({ - paymentProvider: row.paymentProvider, - paymentIntentId: existing.paymentIntentId ?? null, - paymentStatus: row.paymentStatus, - }), + paymentProvider: existingProvider, + paymentMethod: existingMethod, shipping: row.shippingProvider === 'nova_poshta' && row.shippingMethodCode && @@ -944,6 +1056,46 @@ export async function createOrderWithItems({ }); } + const nextMeta = buildCheckoutMetadataPatch( + row.pspMetadata, + existingMethod ?? resolvedPaymentMethod + ); + const needsMethodBackfill = + row.pspPaymentMethod !== (existingMethod ?? resolvedPaymentMethod); + const currentStoredMethod = + row.pspMetadata && + typeof row.pspMetadata === 'object' && + !Array.isArray(row.pspMetadata) + ? ( + ((row.pspMetadata as Record).checkout as + | Record + | undefined) ?? {} + ).requestedMethod + : undefined; + const needsMetadataBackfill = + currentStoredMethod !== (existingMethod ?? resolvedPaymentMethod); + + if (needsMethodBackfill || needsMetadataBackfill) { + try { + await db + .update(orders) + .set({ + pspPaymentMethod: existingMethod ?? resolvedPaymentMethod, + pspMetadata: nextMeta, + updatedAt: new Date(), + }) + .where(eq(orders.id, row.id)); + } catch (e) { + if (process.env.DEBUG) { + logWarn('checkout_rejected', { + phase: 'payment_method_backfill', + orderId: row.id, + message: e instanceof Error ? e.message : String(e), + }); + } + } + } + if (row.paymentStatus === 'failed') { try { await restockOrder(existing.id, { reason: 'failed' }); @@ -1058,6 +1210,8 @@ export async function createOrderWithItems({ paymentStatus: initialPaymentStatus, paymentProvider, paymentIntentId: null, + pspPaymentMethod: resolvedPaymentMethod, + pspMetadata: buildCheckoutMetadataPatch({}, resolvedPaymentMethod), shippingRequired: preparedShipping.orderSummary.shippingRequired, shippingPayer: preparedShipping.orderSummary.shippingPayer, shippingProvider: preparedShipping.orderSummary.shippingProvider, diff --git a/frontend/lib/shop/payments.ts b/frontend/lib/shop/payments.ts index 7c1a3c7a..789e14a5 100644 --- a/frontend/lib/shop/payments.ts +++ b/frontend/lib/shop/payments.ts @@ -1,3 +1,5 @@ +import type { CurrencyCode } from '@/lib/shop/currency'; + export const paymentStatusValues = [ 'pending', 'requires_payment', @@ -12,3 +14,52 @@ export type PaymentStatus = (typeof paymentStatusValues)[number]; export const paymentProviderValues = ['stripe', 'monobank', 'none'] as const; export type PaymentProvider = (typeof paymentProviderValues)[number]; + +export const paymentMethodValues = [ + 'stripe_card', + 'monobank_invoice', + 'monobank_google_pay', +] as const; + +export type PaymentMethod = (typeof paymentMethodValues)[number]; + +export function resolveDefaultMethodForProvider( + provider: PaymentProvider, + currency: CurrencyCode +): PaymentMethod | null { + if (provider === 'stripe') return 'stripe_card'; + + if (provider === 'monobank') { + if (currency === 'UAH') return 'monobank_invoice'; + return null; + } + + return null; +} + +export function isMethodAllowed(args: { + provider: PaymentProvider; + method: PaymentMethod; + currency: CurrencyCode; + flags?: { + monobankGooglePayEnabled?: boolean; + }; +}): boolean { + if (args.method === 'stripe_card') { + return args.provider === 'stripe'; + } + + if (args.method === 'monobank_invoice') { + return args.provider === 'monobank' && args.currency === 'UAH'; + } + + if (args.method === 'monobank_google_pay') { + return ( + args.provider === 'monobank' && + args.currency === 'UAH' && + args.flags?.monobankGooglePayEnabled === true + ); + } + + return false; +} diff --git a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts index 25f1651c..190bce58 100644 --- a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts @@ -59,6 +59,7 @@ const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; const __prevAppOrigin = process.env.APP_ORIGIN; const __prevShopBaseUrl = process.env.SHOP_BASE_URL; const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; +const __prevMonobankGpayEnabled = process.env.SHOP_MONOBANK_GPAY_ENABLED; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; @@ -68,6 +69,7 @@ beforeAll(() => { process.env.SHOP_BASE_URL = 'http://localhost:3000'; process.env.SHOP_STATUS_TOKEN_SECRET = 'test_status_token_secret_test_status_token_secret'; + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; resetEnvCache(); }); @@ -93,11 +95,16 @@ afterAll(() => { delete process.env.SHOP_STATUS_TOKEN_SECRET; else process.env.SHOP_STATUS_TOKEN_SECRET = __prevStatusSecret; + if (__prevMonobankGpayEnabled === undefined) + delete process.env.SHOP_MONOBANK_GPAY_ENABLED; + else process.env.SHOP_MONOBANK_GPAY_ENABLED = __prevMonobankGpayEnabled; + resetEnvCache(); }); beforeEach(() => { vi.clearAllMocks(); + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; }); async function createIsolatedProduct(args: { @@ -165,7 +172,13 @@ afterAll(async () => { await cleanupSeededTemplateProduct(); }); -async function postCheckout(idemKey: string, productId: string) { +type MonobankCheckoutMethod = 'monobank_invoice' | 'monobank_google_pay'; + +async function postCheckout( + idemKey: string, + productId: string, + options?: { paymentMethod?: MonobankCheckoutMethod } +) { const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { POST: (req: NextRequest) => Promise; }; @@ -184,6 +197,9 @@ async function postCheckout(idemKey: string, productId: string) { body: JSON.stringify({ items: [{ productId, quantity: 1 }], paymentProvider: 'monobank', + ...(options?.paymentMethod + ? { paymentMethod: options.paymentMethod } + : {}), }), }); @@ -283,11 +299,21 @@ describe.sequential('checkout monobank contract', () => { expect(json1.totalAmountMinor).toBe(json2.totalAmountMinor); const [dbOrder] = await db - .select({ id: orders.id }) + .select({ + id: orders.id, + pspPaymentMethod: orders.pspPaymentMethod, + pspMetadata: orders.pspMetadata, + }) .from(orders) .where(eq(orders.idempotencyKey, idemKey)) .limit(1); expect(dbOrder?.id).toBe(orderId); + expect(dbOrder?.pspPaymentMethod).toBe('monobank_invoice'); + expect( + ((dbOrder?.pspMetadata ?? {}) as Record)?.checkout + ).toMatchObject({ + requestedMethod: 'monobank_invoice', + }); const attemptRows = await db .select({ id: paymentAttempts.id }) @@ -307,6 +333,58 @@ describe.sequential('checkout monobank contract', () => { } }, 20_000); + it('idempotency hash is method-aware for same key + different method', async () => { + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'true'; + + const { productId } = await createIsolatedProduct({ + stock: 3, + prices: [{ currency: 'UAH', priceMinor: 1000 }], + }); + const idemKey = crypto.randomUUID(); + let orderId: string | null = null; + + try { + const first = await postCheckout(idemKey, productId, { + paymentMethod: 'monobank_invoice', + }); + expect(first.status).toBe(201); + const firstJson: any = await first.json(); + orderId = typeof firstJson.orderId === 'string' ? firstJson.orderId : null; + + const second = await postCheckout(idemKey, productId, { + paymentMethod: 'monobank_google_pay', + }); + expect(second.status).toBe(409); + const secondJson: any = await second.json(); + expect(secondJson.code).toBe('CHECKOUT_IDEMPOTENCY_CONFLICT'); + + const [dbOrder] = await db + .select({ + id: orders.id, + pspPaymentMethod: orders.pspPaymentMethod, + }) + .from(orders) + .where(eq(orders.idempotencyKey, idemKey)) + .limit(1); + + if (!orderId) orderId = dbOrder?.id ?? null; + expect(dbOrder?.pspPaymentMethod).toBe('monobank_invoice'); + expect(createMonobankInvoiceMock).toHaveBeenCalledTimes(1); + } finally { + if (!orderId) { + const [row] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idemKey)) + .limit(1); + orderId = row?.id ?? null; + } + if (orderId) await cleanupOrder(orderId).catch(() => {}); + await cleanupProduct(productId).catch(() => {}); + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; + } + }, 20_000); + it('missing UAH price -> 422 PRICE_CONFIG_ERROR for monobank checkout', async () => { const { productId } = await createIsolatedProduct({ stock: 2, diff --git a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts index a8661867..4bf8f5c4 100644 --- a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts @@ -34,11 +34,13 @@ type MockedFn = ReturnType; const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; +const __prevMonobankGpayEnabled = process.env.SHOP_MONOBANK_GPAY_ENABLED; beforeAll(() => { process.env.RATE_LIMIT_DISABLED = '1'; process.env.PAYMENTS_ENABLED = 'true'; process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; }); afterAll(() => { @@ -52,10 +54,15 @@ afterAll(() => { if (__prevStripePaymentsEnabled === undefined) delete process.env.STRIPE_PAYMENTS_ENABLED; else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + + if (__prevMonobankGpayEnabled === undefined) + delete process.env.SHOP_MONOBANK_GPAY_ENABLED; + else process.env.SHOP_MONOBANK_GPAY_ENABLED = __prevMonobankGpayEnabled; }); beforeEach(() => { vi.clearAllMocks(); + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; }); function makeMonobankCheckoutReq(params: { @@ -81,6 +88,21 @@ function makeMonobankCheckoutReq(params: { ); } +function mockCreateOrderSuccess(mockFn: MockedFn, orderId: string) { + mockFn.mockResolvedValueOnce({ + order: { + id: orderId, + currency: 'UAH', + totalAmount: 10, + paymentStatus: 'paid', + paymentProvider: 'none', + paymentIntentId: null, + }, + isNew: true, + totalCents: 1000, + }); +} + describe('checkout monobank parse/validation', () => { it('rejects monobank checkout without idempotency key', async () => { const res = await POST( @@ -103,19 +125,7 @@ describe('checkout monobank parse/validation', () => { it('ignores client currency/amount fields for monobank payload validation', async () => { const createOrderWithItemsMock = createOrderWithItems as unknown as MockedFn; - - createOrderWithItemsMock.mockResolvedValueOnce({ - order: { - id: 'order_monobank_parse_1', - currency: 'UAH', - totalAmount: 10, - paymentStatus: 'paid', - paymentProvider: 'none', - paymentIntentId: null, - }, - isNew: true, - totalCents: 1000, - }); + mockCreateOrderSuccess(createOrderWithItemsMock, 'order_monobank_parse_1'); const idem = 'mono_idem_validation_0001'; const res = await POST( @@ -142,7 +152,94 @@ describe('checkout monobank parse/validation', () => { expect(args).toMatchObject({ idempotencyKey: idem, paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', }); expect(Array.isArray(args?.items)).toBe(true); }); + + it('defaults stripe method to stripe_card when paymentMethod is omitted', async () => { + const createOrderWithItemsMock = + createOrderWithItems as unknown as MockedFn; + mockCreateOrderSuccess(createOrderWithItemsMock, 'order_stripe_default_1'); + + const res = await POST( + makeMonobankCheckoutReq({ + idempotencyKey: 'stripe_idem_method_0001', + body: { + items: [ + { productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }, + ], + }, + }) + ); + + expect(res.status).toBe(201); + const args = createOrderWithItemsMock.mock.calls[0]?.[0]; + expect(args?.paymentProvider).toBeUndefined(); + expect(args?.paymentMethod).toBe('stripe_card'); + }); + + it('rejects incompatible provider/method pair', async () => { + const res = await POST( + makeMonobankCheckoutReq({ + idempotencyKey: 'mono_idem_invalid_method_0001', + body: { + paymentProvider: 'monobank', + paymentMethod: 'stripe_card', + items: [ + { productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }, + ], + }, + }) + ); + + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.code).toBe('INVALID_REQUEST'); + expect(createOrderWithItems).not.toHaveBeenCalled(); + }); + + it('enforces SHOP_MONOBANK_GPAY_ENABLED for monobank_google_pay', async () => { + const disabled = await POST( + makeMonobankCheckoutReq({ + idempotencyKey: 'mono_gpay_disabled_0001', + body: { + paymentProvider: 'monobank', + paymentMethod: 'monobank_google_pay', + items: [ + { productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }, + ], + }, + }) + ); + + expect(disabled.status).toBe(422); + const disabledJson = await disabled.json(); + expect(disabledJson.code).toBe('INVALID_REQUEST'); + + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'true'; + const createOrderWithItemsMock = + createOrderWithItems as unknown as MockedFn; + mockCreateOrderSuccess(createOrderWithItemsMock, 'order_monobank_gpay_1'); + + const enabled = await POST( + makeMonobankCheckoutReq({ + idempotencyKey: 'mono_gpay_enabled_0001', + body: { + paymentProvider: 'monobank', + paymentMethod: 'monobank_google_pay', + items: [ + { productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }, + ], + }, + }) + ); + + expect(enabled.status).toBe(201); + const args = createOrderWithItemsMock.mock.calls[0]?.[0]; + expect(args).toMatchObject({ + paymentProvider: 'monobank', + paymentMethod: 'monobank_google_pay', + }); + }); }); diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 416a3cb2..d04fb02e 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -10,10 +10,15 @@ import { } from '@/lib/config/catalog'; import { currencyValues } from '@/lib/shop/currency'; import { + paymentMethodValues, paymentProviderValues, paymentStatusValues, } from '@/lib/shop/payments'; -export type { PaymentProvider, PaymentStatus } from '@/lib/shop/payments'; +export type { + PaymentMethod, + PaymentProvider, + PaymentStatus, +} from '@/lib/shop/payments'; export const MAX_QUANTITY_PER_LINE = 20; @@ -28,6 +33,7 @@ const sortEnum = z.enum(sortValues as [SortValue, ...SortValue[]]); export const badgeSchema = z.enum(productBadgeValues); export const paymentStatusSchema = z.enum(paymentStatusValues); export const paymentProviderSchema = z.enum(paymentProviderValues); +export const paymentMethodSchema = z.enum(paymentMethodValues); export const currencySchema = z.enum(currencyValues); export type { CurrencyCode } from '@/lib/shop/currency'; @@ -398,6 +404,8 @@ export const checkoutLegalConsentSchema = z }) .strict(); +const checkoutRequestedProviderSchema = z.enum(['stripe', 'monobank']); + export const checkoutPayloadSchema = z .object({ items: z.array(checkoutItemSchema).min(1), @@ -410,8 +418,57 @@ export const checkoutPayloadSchema = z .optional(), shipping: checkoutShippingSchema.optional(), legalConsent: checkoutLegalConsentSchema.optional(), + paymentProvider: checkoutRequestedProviderSchema.optional(), + paymentMethod: paymentMethodSchema.optional(), + paymentCurrency: currencySchema.optional(), }) - .strict(); + .strict() + .superRefine((value, ctx) => { + if (!value.paymentMethod) return; + + if (!value.paymentProvider) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['paymentProvider'], + message: 'paymentProvider is required when paymentMethod is provided', + }); + return; + } + + const paymentCurrency = + value.paymentCurrency ?? + (value.paymentProvider === 'monobank' ? 'UAH' : 'USD'); + + const provider = value.paymentProvider; + const method = value.paymentMethod; + + const providerAllowed = + (method === 'stripe_card' && provider === 'stripe') || + ((method === 'monobank_invoice' || method === 'monobank_google_pay') && + provider === 'monobank'); + + if (!providerAllowed) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['paymentMethod'], + message: + 'paymentMethod is not allowed for the selected paymentProvider', + }); + return; + } + + if ( + (method === 'monobank_invoice' || method === 'monobank_google_pay') && + paymentCurrency !== 'UAH' + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['paymentMethod'], + message: + 'paymentMethod is not allowed for the selected provider and currency', + }); + } + }); export const cartRehydratePayloadSchema = z .object({ From 38a317f6e200db11bd4d4ffd265ea2cad8e67c7a Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 17:10:17 -0800 Subject: [PATCH 02/14] (SP: 1)[Wallets] lock Stripe wallets to card rail and add Apple Pay domain association placeholder --- .gitattributes | 1 + frontend/lib/psp/stripe.ts | 2 +- .../.well-known/apple-developer-merchantid-domain-association | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 frontend/public/.well-known/apple-developer-merchantid-domain-association diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..81c32805 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +frontend/public/.well-known/apple-developer-merchantid-domain-association -text diff --git a/frontend/lib/psp/stripe.ts b/frontend/lib/psp/stripe.ts index f471286d..1805a745 100644 --- a/frontend/lib/psp/stripe.ts +++ b/frontend/lib/psp/stripe.ts @@ -124,7 +124,7 @@ export async function createPaymentIntent({ amount, currency: currency.toLowerCase(), metadata: { orderId, mode: mode ?? 'test' }, - automatic_payment_methods: { enabled: true }, + payment_method_types: ['card'], }, idempotencyKey ? { idempotencyKey } : undefined ); diff --git a/frontend/public/.well-known/apple-developer-merchantid-domain-association b/frontend/public/.well-known/apple-developer-merchantid-domain-association new file mode 100644 index 00000000..d6fb27c1 --- /dev/null +++ b/frontend/public/.well-known/apple-developer-merchantid-domain-association @@ -0,0 +1,2 @@ +PLACEHOLDER-APPLE-PAY-DOMAIN-ASSOCIATION +REPLACE-WITH-EXACT-BYTES-FROM-STRIPE-OR-APPLE-BEFORE-PRODUCTION From 2df8b3b9f44859a1b5314952f5e26d88054b9e84 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 17:34:01 -0800 Subject: [PATCH 03/14] (SP: 2)[Wallets] add Monobank wallet payment foundation (adapter + wallet service) --- frontend/lib/psp/monobank.ts | 163 ++++- .../lib/services/orders/monobank-wallet.ts | 596 ++++++++++++++++++ .../shop/checkout-currency-policy.test.ts | 6 + .../tests/shop/monobank-api-methods.test.ts | 134 +++- .../shop/monobank-wallet-service.test.ts | 176 ++++++ .../order-items-snapshot-immutable.test.ts | 8 +- 6 files changed, 1073 insertions(+), 10 deletions(-) create mode 100644 frontend/lib/services/orders/monobank-wallet.ts create mode 100644 frontend/lib/tests/shop/monobank-wallet-service.test.ts diff --git a/frontend/lib/psp/monobank.ts b/frontend/lib/psp/monobank.ts index 7a2686e5..48466048 100644 --- a/frontend/lib/psp/monobank.ts +++ b/frontend/lib/psp/monobank.ts @@ -18,6 +18,7 @@ export type PspErrorCode = | 'PSP_TIMEOUT' | 'PSP_BAD_REQUEST' | 'PSP_AUTH_FAILED' + | 'PSP_UPSTREAM' | 'PSP_UNKNOWN'; export class PspError extends Error { @@ -87,6 +88,23 @@ export type MonobankRemoveInvoiceResult = { raw?: Record; }; +export type MonobankWalletPaymentInput = { + cardToken: string; + amountMinor: number; + ccy: number; + initiationKind?: 'client'; + redirectUrl: string; + webHookUrl: string; +}; + +export type MonobankWalletPaymentResult = { + invoiceId: string | null; + status: string | null; + redirectUrl: string | null; + modifiedDate: Date | null; + raw: Record; +}; + export type MonobankWebhookPubKeyResult = { pubKeyPemBytes: Uint8Array; }; @@ -237,6 +255,31 @@ type MonobankRemoveInvoiceResponse = { removed?: boolean; }; +type MonobankWalletPaymentRequest = { + cardToken: string; + amount: number; + ccy: number; + initiationKind: 'client'; + redirectUrl: string; + webHookUrl: string; +}; + +type MonobankWalletPaymentResponse = { + invoiceId?: string; + status?: string; + tdsUrl?: string; + redirectUrl?: string; + pageUrl?: string; + checkoutUrl?: string; + modifiedDate?: unknown; + modifiedAt?: unknown; + updatedAt?: unknown; + createdDate?: unknown; + createdAt?: unknown; + time?: unknown; + timestamp?: unknown; +}; + export function buildMonobankInvoicePayload( args: MonobankInvoiceCreateArgs ): MonobankInvoiceCreateRequest { @@ -293,6 +336,37 @@ export function buildMonobankInvoicePayload( return payload; } +export function buildMonobankWalletPayload( + args: MonobankWalletPaymentInput +): MonobankWalletPaymentRequest { + if (typeof args.cardToken !== 'string' || !args.cardToken.trim()) { + throw new Error('wallet cardToken is required'); + } + + if (!Number.isSafeInteger(args.amountMinor) || args.amountMinor <= 0) { + throw new Error('Invalid wallet amount (minor units)'); + } + + if (!Number.isSafeInteger(args.ccy) || args.ccy <= 0) { + throw new Error('Invalid wallet currency code'); + } + + const redirectUrl = args.redirectUrl.trim(); + const webHookUrl = args.webHookUrl.trim(); + if (!redirectUrl || !webHookUrl) { + throw new Error('wallet redirectUrl and webHookUrl are required'); + } + + return { + cardToken: args.cardToken, + amount: args.amountMinor, + ccy: args.ccy, + initiationKind: 'client', + redirectUrl, + webHookUrl, + }; +} + type MonoRequestArgs = { method: 'GET' | 'POST'; path: string; @@ -360,6 +434,39 @@ function parseErrorPayload(text: string): { return {}; } +function parseTimestampMs(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + const ms = value < 1e11 ? value * 1000 : value; + return Number.isFinite(ms) ? ms : null; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) return parsed; + } + return null; +} + +function extractProviderModifiedAt( + raw: MonobankWalletPaymentResponse & Record +): Date | null { + const candidates = [ + raw.modifiedDate, + raw.modifiedAt, + raw.updatedAt, + raw.createdDate, + raw.createdAt, + raw.time, + raw.timestamp, + ]; + + for (const candidate of candidates) { + const ms = parseTimestampMs(candidate); + if (ms !== null) return new Date(ms); + } + + return null; +} + function isAbortError(error: unknown): boolean { if (!error || typeof error !== 'object') return false; const err = error as { name?: unknown; message?: unknown }; @@ -439,11 +546,16 @@ async function requestMono( }); } - throw new PspError('PSP_UNKNOWN', 'Monobank request failed', { + throw new PspError('PSP_UPSTREAM', 'Monobank upstream error', { endpoint, method: args.method, httpStatus: status, durationMs, + ...(parsed.monoCode ? { monoCode: parsed.monoCode } : {}), + ...(parsed.monoMessage ? { monoMessage: parsed.monoMessage } : {}), + ...(parsed.responseSnippet + ? { responseSnippet: parsed.responseSnippet } + : {}), }); } @@ -633,6 +745,55 @@ export async function createMonobankInvoice( return requestCreateInvoice(payload); } +export async function walletPayment( + args: MonobankWalletPaymentInput +): Promise { + const env = getMonobankEnv(); + + if (!env.paymentsEnabled || !env.token) { + throw new Error('Monobank payments are disabled'); + } + + const payload = buildMonobankWalletPayload(args); + const res = await requestMono< + MonobankWalletPaymentResponse & Record + >({ + method: 'POST', + path: '/api/merchant/wallet/payment', + body: payload, + timeoutMs: env.invoiceTimeoutMs, + token: env.token, + baseUrl: env.apiBaseUrl, + }); + + if (!res.data || typeof res.data !== 'object') { + throw new Error('Monobank wallet payment returned invalid payload'); + } + + const raw = res.data as MonobankWalletPaymentResponse & Record; + const invoiceId = + typeof raw.invoiceId === 'string' && raw.invoiceId.trim() + ? raw.invoiceId.trim() + : null; + const status = + typeof raw.status === 'string' && raw.status.trim() + ? raw.status.trim() + : null; + const redirectUrl = + parsePageUrl(raw.tdsUrl) ?? + parsePageUrl(raw.redirectUrl) ?? + parsePageUrl(raw.pageUrl) ?? + parsePageUrl(raw.checkoutUrl); + + return { + invoiceId, + status, + redirectUrl, + modifiedDate: extractProviderModifiedAt(raw), + raw, + }; +} + export async function getInvoiceStatus( invoiceId: string ): Promise { diff --git a/frontend/lib/services/orders/monobank-wallet.ts b/frontend/lib/services/orders/monobank-wallet.ts new file mode 100644 index 00000000..b03411a9 --- /dev/null +++ b/frontend/lib/services/orders/monobank-wallet.ts @@ -0,0 +1,596 @@ +import 'server-only'; + +import { and, eq, inArray, sql } from 'drizzle-orm'; + +import { db } from '@/db'; +import { orders, paymentAttempts } from '@/db/schema'; +import { + MONO_CCY, + MONO_CURRENCY, + PspError, + walletPayment, + type MonobankWalletPaymentResult, +} from '@/lib/psp/monobank'; +import { + IdempotencyConflictError, + InvalidPayloadError, + OrderNotFoundError, + OrderStateInvalidError, +} from '@/lib/services/errors'; + +type PaymentAttemptRow = typeof paymentAttempts.$inferSelect; +type WalletOrderRow = Pick< + typeof orders.$inferSelect, + 'id' | 'paymentProvider' | 'paymentStatus' | 'currency' | 'totalAmountMinor' +>; + +const DEFAULT_MAX_ATTEMPTS = 2; +const ACTIVE_ATTEMPT_STATUSES = ['creating', 'active'] as const; + +type MonobankWalletSubmitOutcome = 'submitted' | 'unknown'; + +export type MonobankWalletSubmitResult = { + attemptId: string; + attemptNumber: number; + invoiceId: string | null; + redirectUrl: string | null; + outcome: MonobankWalletSubmitOutcome; + syncStatus: string | null; + providerModifiedAt: Date | null; + reused: boolean; +}; + +export class MonobankWalletConflictError extends Error { + readonly code = 'MONOBANK_WALLET_CONFLICT' as const; + readonly orderId: string; + readonly attemptId: string; + readonly activeIdempotencyKey: string; + readonly requestedIdempotencyKey: string; + + constructor(args: { + orderId: string; + attemptId: string; + activeIdempotencyKey: string; + requestedIdempotencyKey: string; + }) { + super( + 'Monobank wallet submit already in progress for this order with another idempotency key.' + ); + this.name = 'MonobankWalletConflictError'; + this.orderId = args.orderId; + this.attemptId = args.attemptId; + this.activeIdempotencyKey = args.activeIdempotencyKey; + this.requestedIdempotencyKey = args.requestedIdempotencyKey; + } +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function parseIsoDateOrNull(value: unknown): Date | null { + if (typeof value !== 'string' || !value.trim()) return null; + const ms = Date.parse(value); + if (Number.isNaN(ms)) return null; + return new Date(ms); +} + +function readReplayResult( + attempt: PaymentAttemptRow, + reused: boolean +): MonobankWalletSubmitResult { + const meta = asRecord(attempt.metadata); + const wallet = asRecord(meta.wallet); + + const submitOutcome = + wallet.submitOutcome === 'unknown' ? 'unknown' : 'submitted'; + const syncStatus = + typeof wallet.syncStatus === 'string' && wallet.syncStatus.trim() + ? wallet.syncStatus.trim() + : null; + const invoiceId = + typeof attempt.providerPaymentIntentId === 'string' && + attempt.providerPaymentIntentId.trim() + ? attempt.providerPaymentIntentId.trim() + : typeof wallet.invoiceId === 'string' && wallet.invoiceId.trim() + ? wallet.invoiceId.trim() + : null; + const redirectUrl = + typeof wallet.redirectUrl === 'string' && wallet.redirectUrl.trim() + ? wallet.redirectUrl.trim() + : null; + const providerModifiedAt = + attempt.providerModifiedAt ?? + parseIsoDateOrNull(wallet.providerModifiedAt ?? null); + + return { + attemptId: attempt.id, + attemptNumber: attempt.attemptNumber, + invoiceId, + redirectUrl, + outcome: submitOutcome, + syncStatus, + providerModifiedAt, + reused, + }; +} + +function assertWalletOrderPayable(order: WalletOrderRow): void { + if (order.paymentProvider !== 'monobank') { + throw new OrderStateInvalidError( + 'Order is not a Monobank payment for wallet submit.', + { + orderId: order.id, + field: 'paymentProvider', + rawValue: order.paymentProvider, + } + ); + } + + if (!['pending', 'requires_payment'].includes(order.paymentStatus)) { + throw new OrderStateInvalidError( + 'Order is not payable; Monobank wallet submit is not allowed in the current state.', + { + orderId: order.id, + field: 'paymentStatus', + rawValue: order.paymentStatus, + details: { allowed: ['pending', 'requires_payment'] }, + } + ); + } + + if (order.currency !== MONO_CURRENCY) { + throw new OrderStateInvalidError('Order currency is not UAH.', { + orderId: order.id, + field: 'currency', + rawValue: order.currency, + }); + } + + if ( + !Number.isSafeInteger(order.totalAmountMinor) || + order.totalAmountMinor <= 0 + ) { + throw new OrderStateInvalidError( + 'Invalid order total for Monobank wallet submit.', + { + orderId: order.id, + field: 'totalAmountMinor', + rawValue: order.totalAmountMinor, + } + ); + } +} + +function isUniqueViolation(error: unknown): boolean { + return ( + !!error && + typeof error === 'object' && + (error as { code?: unknown }).code === '23505' + ); +} + +async function readWalletOrder(orderId: string): Promise { + const rows = await db + .select({ + id: orders.id, + paymentProvider: orders.paymentProvider, + paymentStatus: orders.paymentStatus, + currency: orders.currency, + totalAmountMinor: orders.totalAmountMinor, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + return rows[0] ?? null; +} + +async function findAttemptByIdempotencyKey( + idempotencyKey: string +): Promise { + const rows = await db + .select() + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.provider, 'monobank'), + eq(paymentAttempts.idempotencyKey, idempotencyKey) + ) + ) + .limit(1); + + return rows[0] ?? null; +} + +async function findActiveAttempt(orderId: string): Promise { + const rows = await db + .select() + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderId), + eq(paymentAttempts.provider, 'monobank'), + inArray(paymentAttempts.status, [...ACTIVE_ATTEMPT_STATUSES]) + ) + ) + .limit(1); + + return rows[0] ?? null; +} + +async function readMaxAttemptNumber(orderId: string): Promise { + const rows = await db + .select({ + max: sql`coalesce(max(${paymentAttempts.attemptNumber}), 0)`, + }) + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderId), + eq(paymentAttempts.provider, 'monobank') + ) + ); + + return rows[0]?.max ?? 0; +} + +async function createCreatingAttempt(args: { + orderId: string; + idempotencyKey: string; + expectedAmountMinor: number; + maxAttempts: number; +}): Promise { + const nextAttemptNumber = (await readMaxAttemptNumber(args.orderId)) + 1; + if (nextAttemptNumber > args.maxAttempts) { + throw new InvalidPayloadError('Payment attempts exhausted.', { + code: 'PAYMENT_ATTEMPTS_EXHAUSTED', + }); + } + + const now = new Date(); + const inserted = await db + .insert(paymentAttempts) + .values({ + orderId: args.orderId, + provider: 'monobank', + status: 'creating', + attemptNumber: nextAttemptNumber, + currency: MONO_CURRENCY, + expectedAmountMinor: args.expectedAmountMinor, + idempotencyKey: args.idempotencyKey, + metadata: { + wallet: { + submitOutcome: 'creating', + lastSubmitAt: now.toISOString(), + }, + }, + }) + .returning(); + + const row = inserted[0]; + if (!row) throw new Error('Failed to create Monobank wallet attempt'); + return row; +} + +function mergeWalletMetadata( + current: unknown, + patch: Record +): Record { + const meta = asRecord(current); + const wallet = asRecord(meta.wallet); + return { + ...meta, + wallet: { + ...wallet, + ...patch, + }, + }; +} + +async function persistAttemptSubmitted(args: { + attempt: PaymentAttemptRow; + pspResult: MonobankWalletPaymentResult; +}): Promise { + const now = new Date(); + const invoiceId = + args.pspResult.invoiceId ?? + (typeof args.attempt.providerPaymentIntentId === 'string' && + args.attempt.providerPaymentIntentId.trim() + ? args.attempt.providerPaymentIntentId.trim() + : null); + + const syncStatus = + args.pspResult.status ?? + (args.pspResult.redirectUrl ? 'redirect_required' : 'submitted'); + + const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + submitOutcome: 'submitted', + syncStatus, + invoiceId, + redirectUrl: args.pspResult.redirectUrl, + providerModifiedAt: args.pspResult.modifiedDate + ? args.pspResult.modifiedDate.toISOString() + : null, + tdsUrlPresent: !!args.pspResult.redirectUrl, + lastSubmitAt: now.toISOString(), + }); + + await db + .update(paymentAttempts) + .set({ + status: 'active', + providerPaymentIntentId: invoiceId, + providerModifiedAt: args.pspResult.modifiedDate ?? null, + metadata: nextMetadata, + updatedAt: now, + lastErrorCode: null, + lastErrorMessage: null, + }) + .where(eq(paymentAttempts.id, args.attempt.id)); +} + +async function persistAttemptUnknown(args: { + attempt: PaymentAttemptRow; + errorCode: string; + errorMessage: string; +}): Promise { + const now = new Date(); + const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + submitOutcome: 'unknown', + syncStatus: 'unknown', + unknownReason: args.errorCode, + lastSubmitAt: now.toISOString(), + }); + + await db + .update(paymentAttempts) + .set({ + status: 'active', + metadata: nextMetadata, + updatedAt: now, + lastErrorCode: args.errorCode, + lastErrorMessage: args.errorMessage, + }) + .where(eq(paymentAttempts.id, args.attempt.id)); +} + +async function persistAttemptRejected(args: { + attempt: PaymentAttemptRow; + errorCode: string; + errorMessage: string; +}): Promise { + const now = new Date(); + const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + submitOutcome: 'rejected', + syncStatus: 'rejected', + rejectCode: args.errorCode, + lastSubmitAt: now.toISOString(), + }); + + await db + .update(paymentAttempts) + .set({ + status: 'failed', + finalizedAt: now, + updatedAt: now, + metadata: nextMetadata, + lastErrorCode: args.errorCode, + lastErrorMessage: args.errorMessage, + }) + .where(eq(paymentAttempts.id, args.attempt.id)); +} + +type SubmitMonobankWalletDeps = { + readWalletOrder: typeof readWalletOrder; + findAttemptByIdempotencyKey: typeof findAttemptByIdempotencyKey; + findActiveAttempt: typeof findActiveAttempt; + createCreatingAttempt: typeof createCreatingAttempt; + persistAttemptSubmitted: typeof persistAttemptSubmitted; + persistAttemptUnknown: typeof persistAttemptUnknown; + persistAttemptRejected: typeof persistAttemptRejected; + walletPayment: typeof walletPayment; +}; + +async function submitMonobankWalletPaymentImpl( + deps: SubmitMonobankWalletDeps, + args: { + orderId: string; + idempotencyKey: string; + cardToken: string; + redirectUrl: string; + webHookUrl: string; + maxAttempts?: number; + } +): Promise { + const idempotencyKey = args.idempotencyKey.trim(); + if (!idempotencyKey) { + throw new InvalidPayloadError('Idempotency-Key is required.', { + code: 'MISSING_IDEMPOTENCY_KEY', + }); + } + + const order = await deps.readWalletOrder(args.orderId); + if (!order) throw new OrderNotFoundError('Order not found'); + assertWalletOrderPayable(order); + + const byIdempotencyKey = await deps.findAttemptByIdempotencyKey(idempotencyKey); + if (byIdempotencyKey) { + if (byIdempotencyKey.orderId !== args.orderId) { + throw new IdempotencyConflictError( + 'Idempotency key already used for a different order.', + { + orderId: args.orderId, + existingOrderId: byIdempotencyKey.orderId, + } + ); + } + + if (byIdempotencyKey.status === 'failed') { + throw new InvalidPayloadError( + 'Payment attempt already failed for this idempotency key.', + { + code: + byIdempotencyKey.lastErrorCode && byIdempotencyKey.lastErrorCode.trim() + ? byIdempotencyKey.lastErrorCode + : 'WALLET_ATTEMPT_FAILED', + } + ); + } + + return readReplayResult(byIdempotencyKey, true); + } + + const activeAttempt = await deps.findActiveAttempt(args.orderId); + if (activeAttempt) { + if (activeAttempt.idempotencyKey !== idempotencyKey) { + throw new MonobankWalletConflictError({ + orderId: args.orderId, + attemptId: activeAttempt.id, + activeIdempotencyKey: activeAttempt.idempotencyKey, + requestedIdempotencyKey: idempotencyKey, + }); + } + + return readReplayResult(activeAttempt, true); + } + + let attempt: PaymentAttemptRow; + try { + attempt = await deps.createCreatingAttempt({ + orderId: args.orderId, + idempotencyKey, + expectedAmountMinor: order.totalAmountMinor, + maxAttempts: args.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + }); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + + const retryByKey = await deps.findAttemptByIdempotencyKey(idempotencyKey); + if (retryByKey && retryByKey.orderId === args.orderId) { + if (retryByKey.status === 'failed') { + throw new InvalidPayloadError( + 'Payment attempt already failed for this idempotency key.', + { + code: + retryByKey.lastErrorCode && retryByKey.lastErrorCode.trim() + ? retryByKey.lastErrorCode + : 'WALLET_ATTEMPT_FAILED', + } + ); + } + return readReplayResult(retryByKey, true); + } + + const retryActive = await deps.findActiveAttempt(args.orderId); + if (retryActive) { + if (retryActive.idempotencyKey !== idempotencyKey) { + throw new MonobankWalletConflictError({ + orderId: args.orderId, + attemptId: retryActive.id, + activeIdempotencyKey: retryActive.idempotencyKey, + requestedIdempotencyKey: idempotencyKey, + }); + } + return readReplayResult(retryActive, true); + } + + throw error; + } + + try { + const pspResult = await deps.walletPayment({ + cardToken: args.cardToken, + amountMinor: order.totalAmountMinor, + ccy: MONO_CCY, + initiationKind: 'client', + redirectUrl: args.redirectUrl, + webHookUrl: args.webHookUrl, + }); + + await deps.persistAttemptSubmitted({ + attempt, + pspResult, + }); + + return { + attemptId: attempt.id, + attemptNumber: attempt.attemptNumber, + invoiceId: pspResult.invoiceId, + redirectUrl: pspResult.redirectUrl, + outcome: 'submitted', + syncStatus: + pspResult.status ?? + (pspResult.redirectUrl ? 'redirect_required' : 'submitted'), + providerModifiedAt: pspResult.modifiedDate, + reused: false, + }; + } catch (error) { + const errorCode = + error instanceof PspError && error.code + ? error.code + : 'PSP_UNKNOWN'; + const errorMessage = + error instanceof Error && error.message ? error.message : 'PSP request failed'; + + if ( + errorCode === 'PSP_TIMEOUT' || + errorCode === 'PSP_UPSTREAM' || + errorCode === 'PSP_UNKNOWN' + ) { + await deps.persistAttemptUnknown({ + attempt, + errorCode, + errorMessage, + }); + + return { + attemptId: attempt.id, + attemptNumber: attempt.attemptNumber, + invoiceId: null, + redirectUrl: null, + outcome: 'unknown', + syncStatus: 'unknown', + providerModifiedAt: null, + reused: false, + }; + } + + await deps.persistAttemptRejected({ + attempt, + errorCode, + errorMessage, + }); + + throw error; + } +} + +export async function submitMonobankWalletPayment(args: { + orderId: string; + idempotencyKey: string; + cardToken: string; + redirectUrl: string; + webHookUrl: string; + maxAttempts?: number; +}): Promise { + return submitMonobankWalletPaymentImpl( + { + readWalletOrder, + findAttemptByIdempotencyKey, + findActiveAttempt, + createCreatingAttempt, + persistAttemptSubmitted, + persistAttemptUnknown, + persistAttemptRejected, + walletPayment, + }, + args + ); +} + +export const __test__ = { + submitMonobankWalletPaymentImpl, + readReplayResult, +}; diff --git a/frontend/lib/tests/shop/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts index 5bf12991..52406275 100644 --- a/frontend/lib/tests/shop/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -116,6 +116,11 @@ function makeIdempotencyKey(): string { return crypto.randomUUID(); } +function makeTestClientIp(seed: string): string { + const digest = crypto.createHash('sha256').update(seed).digest(); + return `${(digest[0] % 223) + 1}.${digest[1]}.${digest[2]}.${(digest[3] % 254) + 1}`; +} + function makeCheckoutRequest( payload: unknown, opts: { idempotencyKey: string; acceptLanguage: string } @@ -124,6 +129,7 @@ function makeCheckoutRequest( 'Content-Type': 'application/json', 'Idempotency-Key': opts.idempotencyKey, 'Accept-Language': opts.acceptLanguage, + 'X-Forwarded-For': makeTestClientIp(opts.idempotencyKey), Origin: 'http://localhost:3000', }); diff --git a/frontend/lib/tests/shop/monobank-api-methods.test.ts b/frontend/lib/tests/shop/monobank-api-methods.test.ts index 96a46490..569fafa6 100644 --- a/frontend/lib/tests/shop/monobank-api-methods.test.ts +++ b/frontend/lib/tests/shop/monobank-api-methods.test.ts @@ -130,12 +130,12 @@ describe('monobank api methods', () => { }); }); - it('createInvoice maps 500 to PSP_UNKNOWN', async () => { + it('createInvoice maps 500 to PSP_UPSTREAM', async () => { const fetchMock = vi.fn(async () => makeResponse(500, 'error')); globalThis.fetch = fetchMock as any; const { createInvoice } = await import('@/lib/psp/monobank'); - await expectPspError(() => createInvoice(createArgs), 'PSP_UNKNOWN', { + await expectPspError(() => createInvoice(createArgs), 'PSP_UPSTREAM', { httpStatus: 500, }); }); @@ -173,12 +173,12 @@ describe('monobank api methods', () => { }); }); - it('getInvoiceStatus maps 500 to PSP_UNKNOWN', async () => { + it('getInvoiceStatus maps 500 to PSP_UPSTREAM', async () => { const fetchMock = vi.fn(async () => makeResponse(500, 'error')); globalThis.fetch = fetchMock as any; const { getInvoiceStatus } = await import('@/lib/psp/monobank'); - await expectPspError(() => getInvoiceStatus('inv_2'), 'PSP_UNKNOWN', { + await expectPspError(() => getInvoiceStatus('inv_2'), 'PSP_UPSTREAM', { httpStatus: 500, }); }); @@ -232,7 +232,7 @@ describe('monobank api methods', () => { ); }); - it('cancelInvoicePayment maps 500 to PSP_UNKNOWN', async () => { + it('cancelInvoicePayment maps 500 to PSP_UPSTREAM', async () => { const fetchMock = vi.fn(async () => makeResponse(500, 'error')); globalThis.fetch = fetchMock as any; @@ -243,7 +243,7 @@ describe('monobank api methods', () => { invoiceId: 'inv_3', extRef: 'ext_3', }), - 'PSP_UNKNOWN', + 'PSP_UPSTREAM', { httpStatus: 500 } ); }); @@ -280,13 +280,131 @@ describe('monobank api methods', () => { }); }); - it('removeInvoice maps 500 to PSP_UNKNOWN', async () => { + it('removeInvoice maps 500 to PSP_UPSTREAM', async () => { const fetchMock = vi.fn(async () => makeResponse(500, 'error')); globalThis.fetch = fetchMock as any; const { removeInvoice } = await import('@/lib/psp/monobank'); - await expectPspError(() => removeInvoice('inv_4'), 'PSP_UNKNOWN', { + await expectPspError(() => removeInvoice('inv_4'), 'PSP_UPSTREAM', { httpStatus: 500, }); }); + + it('walletPayment sends required request shape', async () => { + const body = JSON.stringify({ + invoiceId: 'wallet_inv_1', + status: 'created', + tdsUrl: 'https://pay.example.test/3ds', + modifiedDate: 1710000000, + }); + const fetchMock = vi.fn(async () => makeResponse(200, body)); + globalThis.fetch = fetchMock as any; + + const { walletPayment } = await import('@/lib/psp/monobank'); + const result = await walletPayment({ + cardToken: 'token-value', + amountMinor: 1234, + ccy: 980, + initiationKind: 'client', + redirectUrl: 'https://shop.test/return/monobank', + webHookUrl: 'https://shop.test/api/shop/webhooks/monobank', + }); + + expect(result.invoiceId).toBe('wallet_inv_1'); + expect(result.status).toBe('created'); + expect(result.redirectUrl).toBe('https://pay.example.test/3ds'); + expect(result.modifiedDate).toBeInstanceOf(Date); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.example.test/api/merchant/wallet/payment'); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['X-Token']).toBe( + 'test_token' + ); + + const payload = JSON.parse(String(init.body)); + expect(payload).toMatchObject({ + cardToken: 'token-value', + amount: 1234, + ccy: 980, + initiationKind: 'client', + redirectUrl: 'https://shop.test/return/monobank', + webHookUrl: 'https://shop.test/api/shop/webhooks/monobank', + }); + }); + + it('walletPayment maps 400 and 500 deterministically', async () => { + const badRequestFetch = vi.fn(async () => + makeResponse(400, JSON.stringify({ errorCode: 'X', message: 'bad' })) + ); + globalThis.fetch = badRequestFetch as any; + + const { walletPayment } = await import('@/lib/psp/monobank'); + await expectPspError( + () => + walletPayment({ + cardToken: 'token', + amountMinor: 100, + ccy: 980, + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }), + 'PSP_BAD_REQUEST', + { + httpStatus: 400, + monoCode: 'X', + } + ); + + const upstreamFetch = vi.fn(async () => makeResponse(500, 'server error')); + globalThis.fetch = upstreamFetch as any; + + await expectPspError( + () => + walletPayment({ + cardToken: 'token', + amountMinor: 100, + ccy: 980, + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }), + 'PSP_UPSTREAM', + { + httpStatus: 500, + } + ); + + expect(upstreamFetch).toHaveBeenCalledTimes(1); + }); + + it('walletPayment timeout returns PSP_TIMEOUT without retries', async () => { + vi.useFakeTimers(); + process.env.MONO_INVOICE_TIMEOUT_MS = '25'; + resetEnvCache(); + + const fetchMock = vi.fn(() => new Promise(() => {})); + globalThis.fetch = fetchMock as any; + + const { walletPayment } = await import('@/lib/psp/monobank'); + const p = walletPayment({ + cardToken: 'token', + amountMinor: 100, + ccy: 980, + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }).then( + () => null, + e => e + ); + + await vi.advanceTimersByTimeAsync(25); + const error = await p; + const { PspError } = await import('@/lib/psp/monobank'); + expect(error).toBeInstanceOf(PspError); + expect((error as InstanceType).code).toBe('PSP_TIMEOUT'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); }); diff --git a/frontend/lib/tests/shop/monobank-wallet-service.test.ts b/frontend/lib/tests/shop/monobank-wallet-service.test.ts new file mode 100644 index 00000000..a20eedeb --- /dev/null +++ b/frontend/lib/tests/shop/monobank-wallet-service.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/db', () => ({ + db: new Proxy( + {}, + { + get() { + throw new Error('[unit-test] DB access is not allowed here'); + }, + } + ), +})); + +import { PspError } from '@/lib/psp/monobank'; +import { + __test__, + MonobankWalletConflictError, +} from '@/lib/services/orders/monobank-wallet'; + +describe('monobank wallet orchestration (unit, no DB)', () => { + const baseOrder = { + id: 'order_1', + paymentProvider: 'monobank', + paymentStatus: 'pending', + currency: 'UAH', + totalAmountMinor: 1250, + }; + + const creatingAttempt = { + id: 'attempt_1', + orderId: 'order_1', + provider: 'monobank', + status: 'creating', + attemptNumber: 1, + idempotencyKey: 'idem-1', + providerPaymentIntentId: null, + providerModifiedAt: null, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + finalizedAt: null, + currency: 'UAH', + expectedAmountMinor: 1250, + checkoutUrl: null, + providerCreatedAt: null, + janitorClaimedUntil: null, + janitorClaimedBy: null, + lastErrorCode: null, + lastErrorMessage: null, + }; + + it('same order + same idempotency key returns same result and skips PSP call', async () => { + const deps = { + readWalletOrder: vi.fn(async () => baseOrder), + findAttemptByIdempotencyKey: vi.fn(async () => ({ + ...creatingAttempt, + status: 'active', + providerPaymentIntentId: 'invoice_1', + metadata: { + wallet: { + submitOutcome: 'submitted', + syncStatus: 'created', + redirectUrl: 'https://pay.test/3ds', + }, + }, + })), + findActiveAttempt: vi.fn(async () => null), + createCreatingAttempt: vi.fn(async () => creatingAttempt), + persistAttemptSubmitted: vi.fn(async () => undefined), + persistAttemptUnknown: vi.fn(async () => undefined), + persistAttemptRejected: vi.fn(async () => undefined), + walletPayment: vi.fn(async () => { + throw new Error('should not be called'); + }), + }; + + const result = await __test__.submitMonobankWalletPaymentImpl(deps as any, { + orderId: 'order_1', + idempotencyKey: 'idem-1', + cardToken: 'token', + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }); + + expect(result.reused).toBe(true); + expect(result.invoiceId).toBe('invoice_1'); + expect(result.redirectUrl).toBe('https://pay.test/3ds'); + expect(deps.walletPayment).not.toHaveBeenCalled(); + }); + + it('concurrent different idempotency key for active attempt throws typed conflict', async () => { + const deps = { + readWalletOrder: vi.fn(async () => baseOrder), + findAttemptByIdempotencyKey: vi.fn(async () => null), + findActiveAttempt: vi.fn(async () => ({ + ...creatingAttempt, + idempotencyKey: 'active-key', + })), + createCreatingAttempt: vi.fn(async () => creatingAttempt), + persistAttemptSubmitted: vi.fn(async () => undefined), + persistAttemptUnknown: vi.fn(async () => undefined), + persistAttemptRejected: vi.fn(async () => undefined), + walletPayment: vi.fn(async () => ({}) as any), + }; + + await expect( + __test__.submitMonobankWalletPaymentImpl(deps as any, { + orderId: 'order_1', + idempotencyKey: 'new-key', + cardToken: 'token', + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }) + ).rejects.toBeInstanceOf(MonobankWalletConflictError); + + expect(deps.walletPayment).not.toHaveBeenCalled(); + }); + + it('timeout/upstream path returns unknown and does not retry', async () => { + const deps = { + readWalletOrder: vi.fn(async () => baseOrder), + findAttemptByIdempotencyKey: vi.fn(async () => null), + findActiveAttempt: vi.fn(async () => null), + createCreatingAttempt: vi.fn(async () => creatingAttempt), + persistAttemptSubmitted: vi.fn(async () => undefined), + persistAttemptUnknown: vi.fn(async () => undefined), + persistAttemptRejected: vi.fn(async () => undefined), + walletPayment: vi.fn(async () => { + throw new PspError('PSP_TIMEOUT', 'timeout'); + }), + }; + + const result = await __test__.submitMonobankWalletPaymentImpl(deps as any, { + orderId: 'order_1', + idempotencyKey: 'idem-1', + cardToken: 'token', + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }); + + expect(result.outcome).toBe('unknown'); + expect(result.reused).toBe(false); + expect(deps.walletPayment).toHaveBeenCalledTimes(1); + expect(deps.persistAttemptUnknown).toHaveBeenCalledTimes(1); + expect(deps.persistAttemptRejected).not.toHaveBeenCalled(); + }); + + it('4xx PSP error is persisted as rejected and rethrown', async () => { + const deps = { + readWalletOrder: vi.fn(async () => baseOrder), + findAttemptByIdempotencyKey: vi.fn(async () => null), + findActiveAttempt: vi.fn(async () => null), + createCreatingAttempt: vi.fn(async () => creatingAttempt), + persistAttemptSubmitted: vi.fn(async () => undefined), + persistAttemptUnknown: vi.fn(async () => undefined), + persistAttemptRejected: vi.fn(async () => undefined), + walletPayment: vi.fn(async () => { + throw new PspError('PSP_BAD_REQUEST', 'bad request'); + }), + }; + + await expect( + __test__.submitMonobankWalletPaymentImpl(deps as any, { + orderId: 'order_1', + idempotencyKey: 'idem-1', + cardToken: 'token', + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }) + ).rejects.toBeInstanceOf(PspError); + + expect(deps.walletPayment).toHaveBeenCalledTimes(1); + expect(deps.persistAttemptRejected).toHaveBeenCalledTimes(1); + expect(deps.persistAttemptUnknown).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts index 6f92582d..ba4ef7b9 100644 --- a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts +++ b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; import { NextRequest } from 'next/server'; @@ -48,6 +48,11 @@ function makeJsonRequest( }); } +function makeTestClientIp(seed: string): string { + const digest = createHash('sha256').update(seed).digest(); + return `${(digest[0] % 223) + 1}.${digest[1]}.${digest[2]}.${(digest[3] % 254) + 1}`; +} + async function cleanupByIds(params: { orderId?: string; productId: string }) { const { orderId, productId } = params; @@ -110,6 +115,7 @@ describe('P0-6 snapshots: order_items immutability', () => { 'Accept-Language': 'en-US,en;q=0.9', 'Content-Type': 'application/json', 'Idempotency-Key': idem, + 'X-Forwarded-For': makeTestClientIp(idem), Origin: 'http://localhost:3000', } ); From 8bc3b7784ab6bdceeeb99d91c6805a7a6bc9fb8b Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 17:58:21 -0800 Subject: [PATCH 04/14] (SP: 2)[Wallets] add Monobank Google Pay config/submit routes with invoice fallback --- .../orders/[id]/payment/monobank/_shared.ts | 150 ++++++ .../monobank/google-pay/config/route.ts | 159 ++++++ .../monobank/google-pay/submit/route.ts | 315 ++++++++++++ .../[id]/payment/monobank/invoice/route.ts | 156 ++++++ .../tests/shop/monobank-api-methods.test.ts | 5 +- .../monobank-google-pay-config-route.test.ts | 199 ++++++++ .../monobank-google-pay-submit-route.test.ts | 469 ++++++++++++++++++ 7 files changed, 1452 insertions(+), 1 deletion(-) create mode 100644 frontend/app/api/shop/orders/[id]/payment/monobank/_shared.ts create mode 100644 frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/config/route.ts create mode 100644 frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts create mode 100644 frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts create mode 100644 frontend/lib/tests/shop/monobank-google-pay-config-route.test.ts create mode 100644 frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts diff --git a/frontend/app/api/shop/orders/[id]/payment/monobank/_shared.ts b/frontend/app/api/shop/orders/[id]/payment/monobank/_shared.ts new file mode 100644 index 00000000..9055bb6a --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/payment/monobank/_shared.ts @@ -0,0 +1,150 @@ +import { eq } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; + +import { db } from '@/db'; +import { orders } from '@/db/schema'; +import { toDbMoney } from '@/lib/shop/money'; +import type { PaymentMethod } from '@/lib/shop/payments'; + +const ORDER_PAYABLE_STATUSES = new Set(['pending', 'requires_payment']); + +type OrderPaymentRow = { + id: string; + paymentProvider: string; + paymentStatus: string; + currency: string; + totalAmountMinor: number; + pspPaymentMethod: string | null; + pspMetadata: Record | null; +}; + +export function noStoreJson(body: unknown, status = 200) { + const res = NextResponse.json(body, { status }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export function isMonobankGooglePayEnabled(): boolean { + const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '') + .trim() + .toLowerCase(); + return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on'; +} + +export function getMonobankGooglePayMaxBodyBytes(): number { + const fallback = 16 * 1024; + const raw = (process.env.SHOP_MONOBANK_GPAY_MAX_BODY_BYTES ?? '').trim(); + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +export async function readOrderPaymentRow( + orderId: string +): Promise { + const rows = await db + .select({ + id: orders.id, + paymentProvider: orders.paymentProvider, + paymentStatus: orders.paymentStatus, + currency: orders.currency, + totalAmountMinor: orders.totalAmountMinor, + pspPaymentMethod: orders.pspPaymentMethod, + pspMetadata: orders.pspMetadata, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + return rows[0] ?? null; +} + +function parseKnownMethod(value: unknown): PaymentMethod | null { + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + if (normalized === 'stripe_card') return 'stripe_card'; + if (normalized === 'monobank_invoice') return 'monobank_invoice'; + if (normalized === 'monobank_google_pay') return 'monobank_google_pay'; + return null; +} + +export function readResolvedOrderPaymentMethod( + order: Pick +): PaymentMethod | null { + const direct = parseKnownMethod(order.pspPaymentMethod); + if (direct) return direct; + + const checkout = + order.pspMetadata && + typeof order.pspMetadata === 'object' && + !Array.isArray(order.pspMetadata) + ? ((order.pspMetadata.checkout as Record | undefined) ?? + null) + : null; + if (!checkout || typeof checkout !== 'object') return null; + + return parseKnownMethod(checkout.requestedMethod); +} + +export function ensureMonobankPayableOrder(args: { + order: OrderPaymentRow; + allowedMethods: PaymentMethod[]; +}) { + const { order, allowedMethods } = args; + + if (order.paymentProvider !== 'monobank') { + return { + ok: false as const, + status: 409, + code: 'PAYMENT_PROVIDER_NOT_ALLOWED', + message: 'Order payment provider is not Monobank.', + }; + } + + if (order.currency !== 'UAH') { + return { + ok: false as const, + status: 409, + code: 'ORDER_CURRENCY_NOT_SUPPORTED', + message: 'Order currency must be UAH.', + }; + } + + if (!ORDER_PAYABLE_STATUSES.has(order.paymentStatus)) { + return { + ok: false as const, + status: 409, + code: 'ORDER_NOT_PAYABLE', + message: 'Order is not payable in the current state.', + }; + } + + if ( + !Number.isSafeInteger(order.totalAmountMinor) || + order.totalAmountMinor <= 0 + ) { + return { + ok: false as const, + status: 409, + code: 'ORDER_TOTAL_INVALID', + message: 'Order total is invalid.', + }; + } + + const method = readResolvedOrderPaymentMethod(order); + if (!method || !allowedMethods.includes(method)) { + return { + ok: false as const, + status: 409, + code: 'PAYMENT_METHOD_NOT_ALLOWED', + message: 'Order payment method is not compatible with this endpoint.', + }; + } + + return { ok: true as const, method }; +} + +export function formatMinorToDecimalString(minor: number): string { + return toDbMoney(minor); +} diff --git a/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/config/route.ts b/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/config/route.ts new file mode 100644 index 00000000..b2a9dd35 --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/config/route.ts @@ -0,0 +1,159 @@ +import crypto from 'node:crypto'; + +import { NextRequest } from 'next/server'; + +import { logError, logWarn } from '@/lib/logging'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { authorizeOrderMutationAccess } from '@/lib/services/shop/order-access'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +import { + ensureMonobankPayableOrder, + formatMinorToDecimalString, + isMonobankGooglePayEnabled, + noStoreJson, + readOrderPaymentRow, +} from '../../_shared'; + +const ALLOWED_AUTH_METHODS = ['PAN_ONLY', 'CRYPTOGRAM_3DS'] as const; +const ALLOWED_CARD_NETWORKS = ['MASTERCARD', 'VISA'] as const; + +function buildGooglePaySkeleton(args: { + totalAmountMinor: number; + gatewayMerchantId: string; + merchantName: string; +}) { + const baseCardMethod = { + type: 'CARD' as const, + parameters: { + allowedAuthMethods: [...ALLOWED_AUTH_METHODS], + allowedCardNetworks: [...ALLOWED_CARD_NETWORKS], + }, + }; + + return { + paymentDataRequest: { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: [ + { + ...baseCardMethod, + tokenizationSpecification: { + type: 'PAYMENT_GATEWAY' as const, + parameters: { + gateway: 'monobank', + gatewayMerchantId: args.gatewayMerchantId, + }, + }, + }, + ], + merchantInfo: { + merchantName: args.merchantName, + }, + transactionInfo: { + totalPriceStatus: 'FINAL' as const, + totalPrice: formatMinorToDecimalString(args.totalAmountMinor), + currencyCode: 'UAH', + }, + }, + readinessHints: { + isReadyToPayRequest: { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: [baseCardMethod], + }, + }, + }; +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) return blocked; + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + 400 + ); + } + + const orderId = parsedParams.data.id; + const statusToken = request.nextUrl.searchParams.get('statusToken'); + const auth = await authorizeOrderMutationAccess({ + orderId, + statusToken, + requiredScope: 'order_payment_init', + }); + if (!auth.authorized) { + return noStoreJson({ code: auth.code }, auth.status); + } + + if (!isMonobankGooglePayEnabled()) { + return noStoreJson( + { + code: 'MONOBANK_GPAY_DISABLED', + message: 'Monobank Google Pay is disabled.', + }, + 409 + ); + } + + const order = await readOrderPaymentRow(orderId); + if (!order) { + return noStoreJson({ code: 'ORDER_NOT_FOUND' }, 404); + } + + const guard = ensureMonobankPayableOrder({ + order, + allowedMethods: ['monobank_google_pay'], + }); + if (!guard.ok) { + logWarn('monobank_google_pay_config_rejected', { + ...baseMeta, + orderId, + code: guard.code, + }); + return noStoreJson({ code: guard.code, message: guard.message }, guard.status); + } + + const gatewayMerchantId = ( + process.env.MONO_GOOGLE_PAY_GATEWAY_MERCHANT_ID ?? '' + ).trim(); + const merchantName = (process.env.MONO_GOOGLE_PAY_MERCHANT_NAME ?? '').trim(); + if (!gatewayMerchantId || !merchantName) { + logError('monobank_google_pay_config_missing_env', null, { + ...baseMeta, + orderId, + code: 'MONOBANK_GPAY_CONFIG_MISSING', + }); + return noStoreJson( + { + code: 'MONOBANK_GPAY_CONFIG_MISSING', + message: 'Monobank Google Pay configuration is missing.', + }, + 500 + ); + } + + return noStoreJson({ + success: true, + orderId, + ...buildGooglePaySkeleton({ + totalAmountMinor: order.totalAmountMinor, + gatewayMerchantId, + merchantName, + }), + }); +} diff --git a/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts b/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts new file mode 100644 index 00000000..ce249d48 --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts @@ -0,0 +1,315 @@ +import crypto from 'node:crypto'; + +import { NextRequest } from 'next/server'; + +import { logError, logWarn } from '@/lib/logging'; +import { PspError } from '@/lib/psp/monobank'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + IdempotencyConflictError, + InvalidPayloadError, + OrderNotFoundError, + OrderStateInvalidError, +} from '@/lib/services/errors'; +import { + MonobankWalletConflictError, + submitMonobankWalletPayment, +} from '@/lib/services/orders/monobank-wallet'; +import { authorizeOrderMutationAccess } from '@/lib/services/shop/order-access'; +import { toAbsoluteUrl } from '@/lib/shop/url'; +import { idempotencyKeySchema, orderIdParamSchema } from '@/lib/validation/shop'; + +import { + ensureMonobankPayableOrder, + getMonobankGooglePayMaxBodyBytes, + isMonobankGooglePayEnabled, + noStoreJson, + readOrderPaymentRow, +} from '../../_shared'; + +type SubmitPayload = { + gToken: string; +}; + +function sanitizeTokenForMonobank(gToken: string): string { + try { + const parsed = JSON.parse(gToken); + return JSON.stringify(parsed); + } catch { + return gToken; + } +} + +function parseIdempotencyKey(request: NextRequest) { + const raw = request.headers.get('idempotency-key'); + if (!raw || !raw.trim()) { + return { ok: false as const, code: 'MISSING_IDEMPOTENCY_KEY' }; + } + + const parsed = idempotencyKeySchema.safeParse(raw); + if (!parsed.success) { + return { ok: false as const, code: 'INVALID_IDEMPOTENCY_KEY' }; + } + + return { ok: true as const, idempotencyKey: parsed.data }; +} + +function parseSubmitPayload(rawBytes: Buffer, maxBytes: number) { + if (rawBytes.byteLength > maxBytes) { + return { ok: false as const, status: 413, code: 'PAYLOAD_TOO_LARGE' }; + } + + const text = rawBytes.toString('utf8').replace(/^\uFEFF/, ''); + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false as const, status: 400, code: 'INVALID_PAYLOAD' }; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false as const, status: 400, code: 'INVALID_PAYLOAD' }; + } + + const gToken = (parsed as Record).gToken; + if (typeof gToken !== 'string' || !gToken.trim()) { + return { ok: false as const, status: 400, code: 'INVALID_PAYLOAD' }; + } + + return { ok: true as const, payload: { gToken } satisfies SubmitPayload }; +} + +function buildPendingReturnUrl(orderId: string, statusToken: string | null): string { + const params = new URLSearchParams({ orderId }); + if (statusToken && statusToken.trim()) { + params.set('statusToken', statusToken.trim()); + } + return toAbsoluteUrl(`/shop/checkout/return/monobank?${params.toString()}`); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) return blocked; + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + 400 + ); + } + const orderId = parsedParams.data.id; + + const parsedIdempotency = parseIdempotencyKey(request); + if (!parsedIdempotency.ok) { + return noStoreJson( + { + code: parsedIdempotency.code, + message: 'Idempotency-Key header is required.', + }, + 400 + ); + } + const idempotencyKey = parsedIdempotency.idempotencyKey; + + const statusToken = request.nextUrl.searchParams.get('statusToken'); + const auth = await authorizeOrderMutationAccess({ + orderId, + statusToken, + requiredScope: 'order_payment_init', + }); + if (!auth.authorized) { + return noStoreJson({ code: auth.code }, auth.status); + } + + if (!isMonobankGooglePayEnabled()) { + return noStoreJson( + { + code: 'MONOBANK_GPAY_DISABLED', + message: 'Monobank Google Pay is disabled.', + }, + 409 + ); + } + + const order = await readOrderPaymentRow(orderId); + if (!order) { + return noStoreJson({ code: 'ORDER_NOT_FOUND' }, 404); + } + + const guard = ensureMonobankPayableOrder({ + order, + allowedMethods: ['monobank_google_pay'], + }); + if (!guard.ok) { + logWarn('monobank_google_pay_submit_rejected', { + ...baseMeta, + orderId, + code: guard.code, + }); + return noStoreJson({ code: guard.code, message: guard.message }, guard.status); + } + + const maxBytes = getMonobankGooglePayMaxBodyBytes(); + const contentLength = Number.parseInt( + request.headers.get('content-length') ?? '', + 10 + ); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + return noStoreJson( + { code: 'PAYLOAD_TOO_LARGE', message: 'Request payload is too large.' }, + 413 + ); + } + + let rawBodyBytes: Buffer; + try { + rawBodyBytes = Buffer.from(await request.arrayBuffer()); + } catch { + return noStoreJson( + { code: 'INVALID_PAYLOAD', message: 'Invalid request body.' }, + 400 + ); + } + + const parsedPayload = parseSubmitPayload(rawBodyBytes, maxBytes); + if (!parsedPayload.ok) { + const status = parsedPayload.status; + const code = parsedPayload.code; + return noStoreJson( + { + code, + message: + code === 'PAYLOAD_TOO_LARGE' + ? 'Request payload is too large.' + : 'Invalid payload.', + }, + status + ); + } + + const cardToken = sanitizeTokenForMonobank(parsedPayload.payload.gToken); + const webHookUrl = toAbsoluteUrl('/api/shop/webhooks/monobank'); + const pendingReturnUrl = buildPendingReturnUrl(orderId, statusToken); + + try { + const result = await submitMonobankWalletPayment({ + orderId, + idempotencyKey, + cardToken, + webHookUrl, + redirectUrl: pendingReturnUrl, + }); + + return noStoreJson( + { + success: true, + orderId, + status: 'pending', + submitOutcome: result.outcome, + reused: result.reused, + attemptId: result.attemptId, + attemptNumber: result.attemptNumber, + redirectUrl: result.redirectUrl, + returnUrl: pendingReturnUrl, + }, + result.outcome === 'unknown' ? 202 : 200 + ); + } catch (error) { + if (error instanceof MonobankWalletConflictError) { + return noStoreJson( + { + code: 'MONOBANK_WALLET_CONFLICT', + message: error.message, + }, + 409 + ); + } + + if (error instanceof IdempotencyConflictError) { + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + 409 + ); + } + + if (error instanceof OrderNotFoundError) { + return noStoreJson({ code: error.code }, 404); + } + + if (error instanceof OrderStateInvalidError) { + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + 409 + ); + } + + if (error instanceof InvalidPayloadError) { + const status = + error.code === 'PAYMENT_ATTEMPTS_EXHAUSTED' ? 409 : 400; + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + status + ); + } + + if (error instanceof PspError) { + if (error.code === 'PSP_BAD_REQUEST') { + return noStoreJson( + { + code: error.code, + message: 'Wallet token was rejected by payment provider.', + }, + 400 + ); + } + + logWarn('monobank_google_pay_submit_psp_error', { + ...baseMeta, + orderId, + code: error.code, + }); + return noStoreJson( + { + code: 'PSP_UNAVAILABLE', + message: 'Payment provider is unavailable.', + }, + 503 + ); + } + + logError('monobank_google_pay_submit_failed', error, { + ...baseMeta, + orderId, + code: 'INTERNAL_ERROR', + }); + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to submit Google Pay payment.' }, + 500 + ); + } +} diff --git a/frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts b/frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts new file mode 100644 index 00000000..6d2f2eeb --- /dev/null +++ b/frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts @@ -0,0 +1,156 @@ +import crypto from 'node:crypto'; + +import { NextRequest } from 'next/server'; + +import { logError, logWarn } from '@/lib/logging'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + InvalidPayloadError, + OrderNotFoundError, + OrderStateInvalidError, + PspInvoicePersistError, + PspUnavailableError, +} from '@/lib/services/errors'; +import { createMonobankAttemptAndInvoice } from '@/lib/services/orders/monobank'; +import { authorizeOrderMutationAccess } from '@/lib/services/shop/order-access'; +import { createStatusToken } from '@/lib/shop/status-token'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +import { ensureMonobankPayableOrder, noStoreJson, readOrderPaymentRow } from '../_shared'; + +function resolveStatusToken(orderId: string, statusToken: string | null): string { + const normalized = statusToken?.trim() ?? ''; + if (normalized) return normalized; + return createStatusToken({ + orderId, + scopes: ['status_lite', 'order_payment_init'], + }); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) return blocked; + + const parsedParams = orderIdParamSchema.safeParse(await context.params); + if (!parsedParams.success) { + return noStoreJson( + { code: 'INVALID_ORDER_ID', message: 'Invalid order id.' }, + 400 + ); + } + + const orderId = parsedParams.data.id; + const statusToken = request.nextUrl.searchParams.get('statusToken'); + const auth = await authorizeOrderMutationAccess({ + orderId, + statusToken, + requiredScope: 'order_payment_init', + }); + if (!auth.authorized) { + return noStoreJson({ code: auth.code }, auth.status); + } + + const order = await readOrderPaymentRow(orderId); + if (!order) { + return noStoreJson({ code: 'ORDER_NOT_FOUND' }, 404); + } + + const guard = ensureMonobankPayableOrder({ + order, + allowedMethods: ['monobank_invoice', 'monobank_google_pay'], + }); + if (!guard.ok) { + logWarn('monobank_invoice_fallback_rejected', { + ...baseMeta, + orderId, + code: guard.code, + }); + return noStoreJson({ code: guard.code, message: guard.message }, guard.status); + } + + try { + const result = await createMonobankAttemptAndInvoice({ + orderId, + statusToken: resolveStatusToken(orderId, statusToken), + requestId, + }); + + return noStoreJson({ + success: true, + orderId, + status: 'pending', + attemptId: result.attemptId, + attemptNumber: result.attemptNumber, + invoiceId: result.invoiceId, + pageUrl: result.pageUrl, + currency: result.currency, + totalAmountMinor: result.totalAmountMinor, + }); + } catch (error) { + if (error instanceof OrderNotFoundError) { + return noStoreJson({ code: error.code }, 404); + } + + if (error instanceof OrderStateInvalidError) { + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + 409 + ); + } + + if (error instanceof InvalidPayloadError) { + const status = + error.code === 'CHECKOUT_CONFLICT' || + error.code === 'PAYMENT_ATTEMPTS_EXHAUSTED' + ? 409 + : 400; + + return noStoreJson( + { + code: error.code, + message: error.message, + ...(error.details ? { details: error.details } : {}), + }, + status + ); + } + + if ( + error instanceof PspUnavailableError || + error instanceof PspInvoicePersistError + ) { + return noStoreJson( + { + code: error.code, + message: error.message, + }, + 503 + ); + } + + logError('monobank_invoice_fallback_failed', error, { + ...baseMeta, + orderId, + code: 'INTERNAL_ERROR', + }); + return noStoreJson( + { code: 'INTERNAL_ERROR', message: 'Unable to initialize invoice payment.' }, + 500 + ); + } +} diff --git a/frontend/lib/tests/shop/monobank-api-methods.test.ts b/frontend/lib/tests/shop/monobank-api-methods.test.ts index 569fafa6..0bd6d626 100644 --- a/frontend/lib/tests/shop/monobank-api-methods.test.ts +++ b/frontend/lib/tests/shop/monobank-api-methods.test.ts @@ -316,7 +316,10 @@ describe('monobank api methods', () => { expect(result.modifiedDate).toBeInstanceOf(Date); expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; expect(url).toBe('https://api.example.test/api/merchant/wallet/payment'); expect(init.method).toBe('POST'); expect((init.headers as Record)['X-Token']).toBe( diff --git a/frontend/lib/tests/shop/monobank-google-pay-config-route.test.ts b/frontend/lib/tests/shop/monobank-google-pay-config-route.test.ts new file mode 100644 index 00000000..d3233ab7 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-google-pay-config-route.test.ts @@ -0,0 +1,199 @@ +import crypto from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders } from '@/db/schema'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/services/shop/order-access', () => ({ + authorizeOrderMutationAccess: vi.fn(async () => ({ + authorized: true, + actorUserId: null, + code: 'OK', + status: 200, + })), +})); + +let getRoute: typeof import('@/app/api/shop/orders/[id]/payment/monobank/google-pay/config/route').GET; + +const prevFlag = process.env.SHOP_MONOBANK_GPAY_ENABLED; +const prevGatewayMerchantId = process.env.MONO_GOOGLE_PAY_GATEWAY_MERCHANT_ID; +const prevMerchantName = process.env.MONO_GOOGLE_PAY_MERCHANT_NAME; + +beforeAll(async () => { + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'true'; + process.env.MONO_GOOGLE_PAY_GATEWAY_MERCHANT_ID = 'mono-gateway-mid'; + process.env.MONO_GOOGLE_PAY_MERCHANT_NAME = 'Devlovers Test Merchant'; + ({ GET: getRoute } = + await import('@/app/api/shop/orders/[id]/payment/monobank/google-pay/config/route')); +}); + +afterAll(() => { + if (prevFlag === undefined) delete process.env.SHOP_MONOBANK_GPAY_ENABLED; + else process.env.SHOP_MONOBANK_GPAY_ENABLED = prevFlag; + + if (prevGatewayMerchantId === undefined) + delete process.env.MONO_GOOGLE_PAY_GATEWAY_MERCHANT_ID; + else process.env.MONO_GOOGLE_PAY_GATEWAY_MERCHANT_ID = prevGatewayMerchantId; + + if (prevMerchantName === undefined) delete process.env.MONO_GOOGLE_PAY_MERCHANT_NAME; + else process.env.MONO_GOOGLE_PAY_MERCHANT_NAME = prevMerchantName; +}); + +beforeEach(() => { + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'true'; + process.env.MONO_GOOGLE_PAY_GATEWAY_MERCHANT_ID = 'mono-gateway-mid'; + process.env.MONO_GOOGLE_PAY_MERCHANT_NAME = 'Devlovers Test Merchant'; +}); + +async function insertOrder(args: { + id: string; + paymentProvider?: 'monobank' | 'stripe'; + paymentStatus?: 'pending' | 'requires_payment' | 'paid' | 'failed' | 'refunded'; + currency?: 'UAH' | 'USD'; + totalAmountMinor?: number; + paymentMethod?: 'monobank_google_pay' | 'monobank_invoice' | 'stripe_card' | null; +}) { + await db.insert(orders).values({ + id: args.id, + totalAmountMinor: args.totalAmountMinor ?? 12345, + totalAmount: toDbMoney(args.totalAmountMinor ?? 12345), + currency: args.currency ?? 'UAH', + paymentProvider: args.paymentProvider ?? 'monobank', + paymentStatus: args.paymentStatus ?? 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: `idem_${crypto.randomUUID()}`, + pspPaymentMethod: args.paymentMethod ?? 'monobank_google_pay', + pspMetadata: { + checkout: { + requestedMethod: args.paymentMethod ?? 'monobank_google_pay', + }, + }, + } as any); +} + +async function cleanupOrder(orderId: string) { + await db.delete(orders).where(eq(orders.id, orderId)); +} + +function makeRequest(orderId: string) { + return new NextRequest( + `http://localhost/api/shop/orders/${orderId}/payment/monobank/google-pay/config?statusToken=tok_test`, + { + method: 'GET', + headers: { + origin: 'http://localhost:3000', + }, + } + ); +} + +describe.sequential('monobank google pay config route', () => { + it('returns server-authoritative PaymentDataRequest with decimal totalPrice and UAH currencyCode', async () => { + const orderId = crypto.randomUUID(); + await insertOrder({ + id: orderId, + totalAmountMinor: 12345, + currency: 'UAH', + paymentProvider: 'monobank', + paymentMethod: 'monobank_google_pay', + }); + + try { + const res = await getRoute(makeRequest(orderId), { + params: Promise.resolve({ id: orderId }), + }); + + expect(res.status).toBe(200); + const json: any = await res.json(); + expect(json.success).toBe(true); + expect(json.orderId).toBe(orderId); + + expect(json.paymentDataRequest.transactionInfo.totalPrice).toBe('123.45'); + expect(json.paymentDataRequest.transactionInfo.currencyCode).toBe('UAH'); + expect(json.paymentDataRequest.allowedPaymentMethods[0].tokenizationSpecification).toEqual( + { + type: 'PAYMENT_GATEWAY', + parameters: { + gateway: 'monobank', + gatewayMerchantId: 'mono-gateway-mid', + }, + } + ); + expect(json.paymentDataRequest.merchantInfo.merchantName).toBe( + 'Devlovers Test Merchant' + ); + } finally { + await cleanupOrder(orderId); + } + }); + + it('enforces feature flag and order provider/currency/method guards', async () => { + const flagOrderId = crypto.randomUUID(); + const providerOrderId = crypto.randomUUID(); + const currencyOrderId = crypto.randomUUID(); + const methodOrderId = crypto.randomUUID(); + + await insertOrder({ id: flagOrderId }); + await insertOrder({ + id: providerOrderId, + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + currency: 'USD', + }); + await insertOrder({ + id: currencyOrderId, + paymentProvider: 'monobank', + paymentMethod: 'monobank_google_pay', + currency: 'USD', + }); + await insertOrder({ + id: methodOrderId, + paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', + currency: 'UAH', + }); + + try { + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; + const flagRes = await getRoute(makeRequest(flagOrderId), { + params: Promise.resolve({ id: flagOrderId }), + }); + expect(flagRes.status).toBe(409); + expect((await flagRes.json()).code).toBe('MONOBANK_GPAY_DISABLED'); + + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'true'; + + const providerRes = await getRoute(makeRequest(providerOrderId), { + params: Promise.resolve({ id: providerOrderId }), + }); + expect(providerRes.status).toBe(409); + expect((await providerRes.json()).code).toBe('PAYMENT_PROVIDER_NOT_ALLOWED'); + + const currencyRes = await getRoute(makeRequest(currencyOrderId), { + params: Promise.resolve({ id: currencyOrderId }), + }); + expect(currencyRes.status).toBe(409); + expect((await currencyRes.json()).code).toBe('ORDER_CURRENCY_NOT_SUPPORTED'); + + const methodRes = await getRoute(makeRequest(methodOrderId), { + params: Promise.resolve({ id: methodOrderId }), + }); + expect(methodRes.status).toBe(409); + expect((await methodRes.json()).code).toBe('PAYMENT_METHOD_NOT_ALLOWED'); + } finally { + await cleanupOrder(flagOrderId); + await cleanupOrder(providerOrderId); + await cleanupOrder(currencyOrderId); + await cleanupOrder(methodOrderId); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts new file mode 100644 index 00000000..fbe9733e --- /dev/null +++ b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts @@ -0,0 +1,469 @@ +import crypto from 'node:crypto'; + +import { and, eq, inArray } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts } from '@/db/schema'; +import { toDbMoney } from '@/lib/shop/money'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/services/shop/order-access', () => ({ + authorizeOrderMutationAccess: vi.fn(async () => ({ + authorized: true, + actorUserId: null, + code: 'OK', + status: 200, + })), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), + }; +}); + +const walletPaymentMock = vi.fn(); + +type WalletResult = { + invoiceId: string | null; + status: string | null; + redirectUrl: string | null; + modifiedDate: string | null; + raw: unknown; +}; + +vi.mock('@/lib/psp/monobank', async () => { + const actual = await vi.importActual('@/lib/psp/monobank'); + return { + ...actual, + walletPayment: (...args: any[]) => walletPaymentMock(...args), + }; +}); + +let postRoute: typeof import('@/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route').POST; +let PspErrorCtor: typeof import('@/lib/psp/monobank').PspError; + +const prevAppOrigin = process.env.APP_ORIGIN; +const prevShopBaseUrl = process.env.SHOP_BASE_URL; +const prevFlag = process.env.SHOP_MONOBANK_GPAY_ENABLED; +const prevMaxBody = process.env.SHOP_MONOBANK_GPAY_MAX_BODY_BYTES; + +beforeAll(async () => { + process.env.APP_ORIGIN = 'http://localhost:3000'; + delete process.env.SHOP_BASE_URL; + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'true'; + delete process.env.SHOP_MONOBANK_GPAY_MAX_BODY_BYTES; + + ({ POST: postRoute } = + await import('@/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route')); + ({ PspError: PspErrorCtor } = await import('@/lib/psp/monobank')); +}); + +afterAll(() => { + if (prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = prevAppOrigin; + + if (prevShopBaseUrl === undefined) delete process.env.SHOP_BASE_URL; + else process.env.SHOP_BASE_URL = prevShopBaseUrl; + + if (prevFlag === undefined) delete process.env.SHOP_MONOBANK_GPAY_ENABLED; + else process.env.SHOP_MONOBANK_GPAY_ENABLED = prevFlag; + + if (prevMaxBody === undefined) delete process.env.SHOP_MONOBANK_GPAY_MAX_BODY_BYTES; + else process.env.SHOP_MONOBANK_GPAY_MAX_BODY_BYTES = prevMaxBody; +}); + +beforeEach(() => { + walletPaymentMock.mockReset(); + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'true'; + delete process.env.SHOP_MONOBANK_GPAY_MAX_BODY_BYTES; +}); + +async function insertOrder(args: { + id: string; + paymentProvider?: 'monobank' | 'stripe'; + paymentStatus?: 'pending' | 'requires_payment' | 'paid' | 'failed' | 'refunded'; + currency?: 'UAH' | 'USD'; + paymentMethod?: 'monobank_google_pay' | 'monobank_invoice' | 'stripe_card'; +}) { + await db.insert(orders).values({ + id: args.id, + totalAmountMinor: 4321, + totalAmount: toDbMoney(4321), + currency: args.currency ?? 'UAH', + paymentProvider: args.paymentProvider ?? 'monobank', + paymentStatus: args.paymentStatus ?? 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: `idem_${crypto.randomUUID()}`, + pspPaymentMethod: args.paymentMethod ?? 'monobank_google_pay', + pspMetadata: { + checkout: { + requestedMethod: args.paymentMethod ?? 'monobank_google_pay', + }, + }, + } as any); +} + +async function cleanupOrder(orderId: string) { + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +function makeSubmitRequest(args: { + orderId: string; + idempotencyKey?: string; + body: string; +}) { + const headers = new Headers({ + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }); + if (args.idempotencyKey) { + headers.set('idempotency-key', args.idempotencyKey); + } + + return new NextRequest( + `http://localhost/api/shop/orders/${args.orderId}/payment/monobank/google-pay/submit?statusToken=tok_test`, + { + method: 'POST', + headers, + body: args.body, + } + ); +} + +async function waitForCreatingAttempt(orderId: string, timeoutMs = 3_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const [attempt] = await db + .select({ + id: paymentAttempts.id, + status: paymentAttempts.status, + }) + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderId), + eq(paymentAttempts.provider, 'monobank') + ) + ) + .limit(1); + + if (attempt && attempt.status === 'creating') return; + await new Promise(resolve => setTimeout(resolve, 25)); + } + throw new Error('Timed out waiting for creating payment attempt'); +} + +describe.sequential('monobank google pay submit route', () => { + it('enforces payload cap before JSON.parse', async () => { + const orderId = crypto.randomUUID(); + await insertOrder({ id: orderId }); + process.env.SHOP_MONOBANK_GPAY_MAX_BODY_BYTES = '64'; + + const big = JSON.stringify({ gToken: `token_${'x'.repeat(200)}` }); + try { + const res = await postRoute( + makeSubmitRequest({ + orderId, + idempotencyKey: 'mono_submit_cap_test_key_0001', + body: big, + }), + { params: Promise.resolve({ id: orderId }) } + ); + + expect(res.status).toBe(413); + const json: any = await res.json(); + expect(json.code).toBe('PAYLOAD_TOO_LARGE'); + expect(walletPaymentMock).not.toHaveBeenCalled(); + } finally { + await cleanupOrder(orderId); + } + }); + + it('formats token via parse/stringify fallback and never persists raw token', async () => { + const orderJsonToken = crypto.randomUUID(); + const orderRawToken = crypto.randomUUID(); + await insertOrder({ id: orderJsonToken }); + await insertOrder({ id: orderRawToken }); + + walletPaymentMock + .mockResolvedValueOnce({ + invoiceId: 'inv_json_1', + status: 'created', + redirectUrl: null, + modifiedDate: null, + raw: {}, + }) + .mockResolvedValueOnce({ + invoiceId: 'inv_raw_1', + status: 'created', + redirectUrl: null, + modifiedDate: null, + raw: {}, + }); + + try { + const jsonTokenMarker = `tok_json_${crypto.randomUUID()}`; + const resJson = await postRoute( + makeSubmitRequest({ + orderId: orderJsonToken, + idempotencyKey: 'mono_submit_parse_json_key_0001', + body: JSON.stringify({ + gToken: ` { "payload":"${jsonTokenMarker}", "v":1 } `, + }), + }), + { params: Promise.resolve({ id: orderJsonToken }) } + ); + expect(resJson.status).toBe(200); + + const firstCallArgs = walletPaymentMock.mock.calls[0]?.[0]; + expect(firstCallArgs.cardToken).toBe( + JSON.stringify({ payload: jsonTokenMarker, v: 1 }) + ); + expect(firstCallArgs.ccy).toBe(980); + + const rawTokenMarker = `tok_raw_${crypto.randomUUID()}`; + const resRaw = await postRoute( + makeSubmitRequest({ + orderId: orderRawToken, + idempotencyKey: 'mono_submit_parse_raw_key_0001', + body: JSON.stringify({ gToken: rawTokenMarker }), + }), + { params: Promise.resolve({ id: orderRawToken }) } + ); + expect(resRaw.status).toBe(200); + + const secondCallArgs = walletPaymentMock.mock.calls[1]?.[0]; + expect(secondCallArgs.cardToken).toBe(rawTokenMarker); + + const attempts = await db + .select({ + orderId: paymentAttempts.orderId, + metadata: paymentAttempts.metadata, + }) + .from(paymentAttempts) + .where(inArray(paymentAttempts.orderId, [orderJsonToken, orderRawToken])); + + const serializedMeta = JSON.stringify(attempts); + expect(serializedMeta).not.toContain(jsonTokenMarker); + expect(serializedMeta).not.toContain(rawTokenMarker); + } finally { + await cleanupOrder(orderJsonToken); + await cleanupOrder(orderRawToken); + } + }); + + it('same order + same idempotency key returns same result and no second PSP call', async () => { + const orderId = crypto.randomUUID(); + await insertOrder({ id: orderId }); + + walletPaymentMock.mockResolvedValue({ + invoiceId: 'inv_replay_1', + status: 'created', + redirectUrl: 'https://pay.example.test/3ds', + modifiedDate: null, + raw: {}, + }); + + try { + const requestKey = 'mono_submit_replay_key_0001'; + const req = () => + makeSubmitRequest({ + orderId, + idempotencyKey: requestKey, + body: JSON.stringify({ gToken: `token_${crypto.randomUUID()}` }), + }); + + const first = await postRoute(req(), { + params: Promise.resolve({ id: orderId }), + }); + const second = await postRoute(req(), { + params: Promise.resolve({ id: orderId }), + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(walletPaymentMock).toHaveBeenCalledTimes(1); + + const secondJson: any = await second.json(); + expect(secondJson.reused).toBe(true); + expect(secondJson.status).toBe('pending'); + } finally { + await cleanupOrder(orderId); + } + }); + + it('concurrent different keys for same active attempt returns 409 conflict', async () => { + const orderId = crypto.randomUUID(); + await insertOrder({ id: orderId }); + + let release!: (value: WalletResult) => void; + const hold = new Promise(resolve => { + release = resolve; + }); + + walletPaymentMock.mockImplementationOnce(async () => { + return await hold; + }); + + try { + const leaderPromise = postRoute( + makeSubmitRequest({ + orderId, + idempotencyKey: 'mono_submit_conflict_key_a_0001', + body: JSON.stringify({ gToken: 'token-a' }), + }), + { params: Promise.resolve({ id: orderId }) } + ); + + await waitForCreatingAttempt(orderId); + + const follower = await postRoute( + makeSubmitRequest({ + orderId, + idempotencyKey: 'mono_submit_conflict_key_b_0001', + body: JSON.stringify({ gToken: 'token-b' }), + }), + { params: Promise.resolve({ id: orderId }) } + ); + + expect(follower.status).toBe(409); + expect((await follower.json()).code).toBe('MONOBANK_WALLET_CONFLICT'); + + release({ + invoiceId: 'inv_conflict_1', + status: 'created', + redirectUrl: null, + modifiedDate: null, + raw: {}, + }); + + const leader = await leaderPromise; + expect(leader.status).toBe(200); + expect(walletPaymentMock).toHaveBeenCalledTimes(1); + } finally { + await cleanupOrder(orderId); + } + }, 15_000); + + it('enforces flag/provider/currency/method compatibility guards', async () => { + const disabledOrderId = crypto.randomUUID(); + const providerOrderId = crypto.randomUUID(); + const currencyOrderId = crypto.randomUUID(); + const methodOrderId = crypto.randomUUID(); + + await insertOrder({ id: disabledOrderId }); + await insertOrder({ + id: providerOrderId, + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + currency: 'USD', + }); + await insertOrder({ + id: currencyOrderId, + paymentProvider: 'monobank', + paymentMethod: 'monobank_google_pay', + currency: 'USD', + }); + await insertOrder({ + id: methodOrderId, + paymentProvider: 'monobank', + paymentMethod: 'monobank_invoice', + currency: 'UAH', + }); + + try { + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; + const disabled = await postRoute( + makeSubmitRequest({ + orderId: disabledOrderId, + idempotencyKey: 'mono_submit_flag_guard_0001', + body: JSON.stringify({ gToken: 'token-disabled' }), + }), + { params: Promise.resolve({ id: disabledOrderId }) } + ); + expect(disabled.status).toBe(409); + expect((await disabled.json()).code).toBe('MONOBANK_GPAY_DISABLED'); + + process.env.SHOP_MONOBANK_GPAY_ENABLED = 'true'; + + const provider = await postRoute( + makeSubmitRequest({ + orderId: providerOrderId, + idempotencyKey: 'mono_submit_provider_guard_0001', + body: JSON.stringify({ gToken: 'token-provider' }), + }), + { params: Promise.resolve({ id: providerOrderId }) } + ); + expect(provider.status).toBe(409); + expect((await provider.json()).code).toBe('PAYMENT_PROVIDER_NOT_ALLOWED'); + + const currency = await postRoute( + makeSubmitRequest({ + orderId: currencyOrderId, + idempotencyKey: 'mono_submit_currency_guard_0001', + body: JSON.stringify({ gToken: 'token-currency' }), + }), + { params: Promise.resolve({ id: currencyOrderId }) } + ); + expect(currency.status).toBe(409); + expect((await currency.json()).code).toBe('ORDER_CURRENCY_NOT_SUPPORTED'); + + const method = await postRoute( + makeSubmitRequest({ + orderId: methodOrderId, + idempotencyKey: 'mono_submit_method_guard_0001', + body: JSON.stringify({ gToken: 'token-method' }), + }), + { params: Promise.resolve({ id: methodOrderId }) } + ); + expect(method.status).toBe(409); + expect((await method.json()).code).toBe('PAYMENT_METHOD_NOT_ALLOWED'); + expect(walletPaymentMock).not.toHaveBeenCalled(); + } finally { + await cleanupOrder(disabledOrderId); + await cleanupOrder(providerOrderId); + await cleanupOrder(currencyOrderId); + await cleanupOrder(methodOrderId); + } + }); + + it('returns pending/unknown on PSP timeout without retries', async () => { + const orderId = crypto.randomUUID(); + await insertOrder({ id: orderId }); + + walletPaymentMock.mockRejectedValueOnce( + new PspErrorCtor('PSP_TIMEOUT', 'timeout') + ); + + try { + const res = await postRoute( + makeSubmitRequest({ + orderId, + idempotencyKey: 'mono_submit_unknown_0001', + body: JSON.stringify({ gToken: 'token-timeout' }), + }), + { params: Promise.resolve({ id: orderId }) } + ); + + expect(res.status).toBe(202); + const json: any = await res.json(); + expect(json.submitOutcome).toBe('unknown'); + expect(json.status).toBe('pending'); + expect(walletPaymentMock).toHaveBeenCalledTimes(1); + } finally { + await cleanupOrder(orderId); + } + }); +}); From 00a7fc75dd3489dfe27c1315d7563d1e89499e48 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 18:31:19 -0800 Subject: [PATCH 05/14] (SP: 2)[Wallets] add Monobank Google Pay checkout UI with pending return polling --- .../app/[locale]/shop/cart/CartPageClient.tsx | 192 ++++++- frontend/app/[locale]/shop/cart/page.tsx | 6 + .../monobank/MonobankGooglePayClient.tsx | 502 ++++++++++++++++++ .../payment/monobank/[orderId]/page.tsx | 173 ++++++ .../return/monobank/MonobankReturnStatus.tsx | 252 +++++++++ .../shop/checkout/return/monobank/page.tsx | 101 ++++ frontend/messages/en.json | 21 + frontend/messages/pl.json | 23 +- frontend/messages/uk.json | 23 +- 9 files changed, 1280 insertions(+), 13 deletions(-) create mode 100644 frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx create mode 100644 frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx create mode 100644 frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx create mode 100644 frontend/app/[locale]/shop/checkout/return/monobank/page.tsx diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 5bffd616..8deb8698 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -72,9 +72,14 @@ const ORDERS_CARD = cn('border-border rounded-md border p-4'); type Props = { stripeEnabled: boolean; monobankEnabled: boolean; + monobankGooglePayEnabled: boolean; }; type CheckoutProvider = 'stripe' | 'monobank'; +type CheckoutPaymentMethod = + | 'stripe_card' + | 'monobank_invoice' + | 'monobank_google_pay'; function resolveInitialProvider(args: { stripeEnabled: boolean; @@ -85,11 +90,21 @@ function resolveInitialProvider(args: { const canUseStripe = args.stripeEnabled; const canUseMonobank = args.monobankEnabled && isUah; - if (canUseStripe) return 'stripe'; + // Monobank-first for UAH checkout. if (canUseMonobank) return 'monobank'; + if (canUseStripe) return 'stripe'; return 'stripe'; } +function resolveDefaultMethodForProvider(args: { + provider: CheckoutProvider; + monobankGooglePayEnabled: boolean; +}): CheckoutPaymentMethod { + if (args.provider === 'stripe') return 'stripe_card'; + void args.monobankGooglePayEnabled; + return 'monobank_invoice'; +} + type OrdersSummaryState = { count: number; latestOrderId: string | null; @@ -118,7 +133,11 @@ function isWarehouseMethod( return methodCode === 'NP_WAREHOUSE' || methodCode === 'NP_LOCKER'; } -export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { +export default function CartPage({ + stripeEnabled, + monobankEnabled, + monobankGooglePayEnabled, +}: Props) { const { cart, updateQuantity, removeFromCart } = useCart(); const router = useRouter(); const t = useTranslations('shop.cart'); @@ -134,14 +153,20 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ); const [isOrdersLoading, setIsOrdersLoading] = useState(false); - const [selectedProvider, setSelectedProvider] = useState( - () => - resolveInitialProvider({ - stripeEnabled, - monobankEnabled, - currency: cart?.summary?.currency, + const initialProvider = resolveInitialProvider({ + stripeEnabled, + monobankEnabled, + currency: cart?.summary?.currency, + }); + const [selectedProvider, setSelectedProvider] = + useState(initialProvider); + const [selectedPaymentMethod, setSelectedPaymentMethod] = + useState(() => + resolveDefaultMethodForProvider({ + provider: initialProvider, + monobankGooglePayEnabled, }) - ); + ); const [isClientReady, setIsClientReady] = useState(false); const [shippingMethods, setShippingMethods] = useState([]); const [shippingMethodsLoading, setShippingMethodsLoading] = useState(true); @@ -188,6 +213,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const isUahCheckout = cart.summary.currency === 'UAH'; const canUseStripe = stripeEnabled; const canUseMonobank = monobankEnabled && isUahCheckout; + const canUseMonobankGooglePay = canUseMonobank && monobankGooglePayEnabled; const hasSelectableProvider = canUseStripe || canUseMonobank; const country = localeToCountry(locale); const shippingUnavailableHardBlock = @@ -257,6 +283,35 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { } }, [canUseMonobank, canUseStripe, selectedProvider]); + useEffect(() => { + if (selectedProvider === 'stripe') { + if (selectedPaymentMethod !== 'stripe_card') { + setSelectedPaymentMethod('stripe_card'); + } + return; + } + + if ( + selectedPaymentMethod === 'monobank_google_pay' && + !canUseMonobankGooglePay + ) { + setSelectedPaymentMethod('monobank_invoice'); + return; + } + + if ( + selectedPaymentMethod !== 'monobank_invoice' && + selectedPaymentMethod !== 'monobank_google_pay' + ) { + setSelectedPaymentMethod( + resolveDefaultMethodForProvider({ + provider: 'monobank', + monobankGooglePayEnabled: canUseMonobankGooglePay, + }) + ); + } + }, [canUseMonobankGooglePay, selectedPaymentMethod, selectedProvider]); + useEffect(() => { let cancelled = false; const controller = new AbortController(); @@ -728,6 +783,14 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ); return; } + if ( + selectedProvider === 'monobank' && + selectedPaymentMethod === 'monobank_google_pay' && + !canUseMonobankGooglePay + ) { + setCheckoutError(t('checkout.paymentMethod.monobankGooglePayUnavailable')); + return; + } if (shippingMethodsLoading) { setCheckoutError(safeT('delivery.methodsLoading', 'METHODS_LOADING')); return; @@ -772,6 +835,8 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { } const idempotencyKey = generateIdempotencyKey(); + const checkoutPaymentMethod: CheckoutPaymentMethod = + selectedProvider === 'stripe' ? 'stripe_card' : selectedPaymentMethod; const response = await fetch('/api/shop/checkout', { method: 'POST', @@ -781,6 +846,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { }, body: JSON.stringify({ paymentProvider: selectedProvider, + paymentMethod: checkoutPaymentMethod, ...(shippingPayloadResult?.ok ? { shipping: shippingPayloadResult.shipping, @@ -826,6 +892,11 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { typeof data.pageUrl === 'string' && data.pageUrl.trim().length > 0 ? data.pageUrl : null; + const statusToken: string | null = + typeof data.statusToken === 'string' && + data.statusToken.trim().length > 0 + ? data.statusToken + : null; const orderId = String(data.orderId); setCreatedOrderId(orderId); @@ -839,9 +910,36 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { return; } if (paymentProvider === 'monobank' && monobankPageUrl) { + if (checkoutPaymentMethod === 'monobank_google_pay') { + if (!statusToken) { + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; + } + + router.push( + `${shopBase}/checkout/payment/monobank/${encodeURIComponent( + orderId + )}?statusToken=${encodeURIComponent(statusToken)}&clearCart=1` + ); + return; + } + window.location.assign(monobankPageUrl); return; } + if (paymentProvider === 'monobank' && checkoutPaymentMethod === 'monobank_google_pay') { + if (!statusToken) { + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; + } + + router.push( + `${shopBase}/checkout/payment/monobank/${encodeURIComponent( + orderId + )}?statusToken=${encodeURIComponent(statusToken)}&clearCart=1` + ); + return; + } if (paymentProvider === 'monobank' && !monobankPageUrl) { setCheckoutError(t('checkout.errors.unexpectedResponse')); return; @@ -866,8 +964,16 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const shippingUnavailableText = resolveShippingUnavailableText(shippingReasonCode); + const hasValidPaymentSelection = + selectedProvider === 'stripe' + ? canUseStripe && selectedPaymentMethod === 'stripe_card' + : canUseMonobank && + (selectedPaymentMethod === 'monobank_invoice' || + (selectedPaymentMethod === 'monobank_google_pay' && + canUseMonobankGooglePay)); const canPlaceOrder = hasSelectableProvider && + hasValidPaymentSelection && !shippingMethodsLoading && !shippingUnavailableHardBlock && (!shippingAvailable || !!selectedShippingMethod); @@ -1455,7 +1561,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { name="payment-provider" value="stripe" checked={selectedProvider === 'stripe'} - onChange={() => setSelectedProvider('stripe')} + onChange={() => { + setSelectedProvider('stripe'); + setSelectedPaymentMethod('stripe_card'); + }} className="h-4 w-4" /> @@ -1475,7 +1584,20 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { name="payment-provider" value="monobank" checked={selectedProvider === 'monobank'} - onChange={() => setSelectedProvider('monobank')} + onChange={() => { + setSelectedProvider('monobank'); + if ( + selectedPaymentMethod !== 'monobank_invoice' && + selectedPaymentMethod !== 'monobank_google_pay' + ) { + setSelectedPaymentMethod( + resolveDefaultMethodForProvider({ + provider: 'monobank', + monobankGooglePayEnabled: canUseMonobankGooglePay, + }) + ); + } + }} disabled={!canUseMonobank} className="h-4 w-4" /> @@ -1484,6 +1606,54 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { + {selectedProvider === 'monobank' && canUseMonobank ? ( +
+ {canUseMonobankGooglePay ? ( + + ) : null} + + + + {canUseMonobankGooglePay ? ( +

+ {t('checkout.paymentMethod.monobankGooglePayHint')} +

+ ) : ( +

+ {t('checkout.paymentMethod.monobankGooglePayFallbackHint')} +

+ )} +
+ ) : null} + {!canUseMonobank ? (

{monobankEnabled diff --git a/frontend/app/[locale]/shop/cart/page.tsx b/frontend/app/[locale]/shop/cart/page.tsx index b6324e22..504063f9 100644 --- a/frontend/app/[locale]/shop/cart/page.tsx +++ b/frontend/app/[locale]/shop/cart/page.tsx @@ -33,11 +33,17 @@ function resolveMonobankCheckoutEnabled(): boolean { } } +function resolveMonobankGooglePayEnabled(): boolean { + const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '').trim().toLowerCase(); + return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on'; +} + export default function CartPage() { return ( ); } diff --git a/frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx b/frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx new file mode 100644 index 00000000..aedb2402 --- /dev/null +++ b/frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx @@ -0,0 +1,502 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Link, useRouter } from '@/i18n/routing'; +import { generateIdempotencyKey } from '@/lib/shop/idempotency'; +import { + SHOP_CTA_BASE, + SHOP_CTA_INSET, + SHOP_CTA_INTERACTIVE, + SHOP_CTA_WAVE, + SHOP_DISABLED, + SHOP_FOCUS, + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, + shopCtaGradient, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; + +type MonobankGooglePayClientProps = { + orderId: string; + statusToken: string | null; +}; + +type GooglePayConfig = { + paymentDataRequest: unknown; + readinessHints?: { + isReadyToPayRequest?: unknown; + }; +}; + +type GooglePayPaymentData = { + paymentMethodData?: { + tokenizationData?: { + token?: unknown; + }; + }; +}; + +type GooglePayPaymentsClient = { + createButton(options: { + onClick: () => void; + buttonType?: string; + buttonColor?: string; + buttonSizeMode?: string; + }): HTMLElement; + isReadyToPay(request: unknown): Promise<{ result: boolean }>; + loadPaymentData(request: unknown): Promise; +}; + +type GooglePayPaymentsClientCtor = new (options: { + environment: 'TEST' | 'PRODUCTION'; +}) => GooglePayPaymentsClient; + +declare global { + interface Window { + google?: { + payments?: { + api?: { + PaymentsClient?: GooglePayPaymentsClientCtor; + }; + }; + }; + } +} + +const GOOGLE_PAY_SCRIPT_SRC = 'https://pay.google.com/gp/p/js/pay.js'; + +let googlePayScriptPromise: Promise | null = null; + +const SHOP_HERO_CTA = cn( + SHOP_CTA_BASE, + SHOP_CTA_INTERACTIVE, + SHOP_FOCUS, + SHOP_DISABLED, + 'w-full items-center justify-center gap-2 px-6 py-3 text-sm text-white', + 'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]' +); + +const SHOP_OUTLINE_BTN = cn( + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, + SHOP_FOCUS, + SHOP_DISABLED +); + +function HeroCtaInner({ children }: { children: React.ReactNode }) { + return ( + <> +

+

+ {t('monobankGooglePay.supportedDevices')} +

+ +

+ {t('monobankGooglePay.useInvoiceFallback')} +

+ + {isConfigLoading || isCheckingReadiness ? ( +

+ {t('monobankGooglePay.loading')} +

+ ) : null} + + {isReadyToPay ? ( +
+
+ +
+ ) : null} + + {!isConfigLoading && !isCheckingReadiness && !isReadyToPay ? ( +
+ +
+ ) : null} + + {isSubmitting ? ( +

+ {t('monobankGooglePay.submitting')} +

+ ) : null} + + {uiMessage ? ( +

+ {uiMessage} +

+ ) : null} + +
+ + {t('actions.backToCart')} + +
+
+ ); +} diff --git a/frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx b/frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx new file mode 100644 index 00000000..b153b505 --- /dev/null +++ b/frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx @@ -0,0 +1,173 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; + +import { ClearCartOnMount } from '@/components/shop/ClearCartOnMount'; +import { Link } from '@/i18n/routing'; +import { OrderNotFoundError } from '@/lib/services/errors'; +import { getOrderSummary } from '@/lib/services/orders'; +import { formatMoney } from '@/lib/shop/currency'; +import { + SHOP_FOCUS, + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +import MonobankGooglePayClient from '../MonobankGooglePayClient'; + +type SearchParams = Record; + +type PaymentPageProps = { + params: Promise<{ locale: string; orderId: string }>; + searchParams?: Promise; +}; + +export const metadata: Metadata = { + title: 'Monobank Google Pay | DevLovers', + description: 'Complete your payment with Google Pay via Monobank.', +}; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +const SHOP_OUTLINE_BTN = cn( + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, + SHOP_FOCUS +); + +function getStringParam(params: SearchParams | undefined, key: string): string { + const raw = params?.[key]; + if (!raw) return ''; + if (Array.isArray(raw)) return raw[0] ?? ''; + return raw; +} + +function parseStatusToken(params: SearchParams | undefined): string | null { + const value = getStringParam(params, 'statusToken').trim(); + return value.length ? value : null; +} + +function shouldClearCart(params: SearchParams | undefined): boolean { + const value = getStringParam(params, 'clearCart'); + return value === '1' || value === 'true'; +} + +function parseOrderId(rawOrderId: string): string | null { + const parsed = orderIdParamSchema.safeParse({ id: rawOrderId }); + if (!parsed.success) return null; + return parsed.data.id; +} + +export default async function MonobankGooglePayPage(props: PaymentPageProps) { + const { locale, orderId: rawOrderId } = await props.params; + const searchParams = props.searchParams ? await props.searchParams : undefined; + + const t = await getTranslations('shop.checkout'); + const orderId = parseOrderId(rawOrderId); + const statusToken = parseStatusToken(searchParams); + const clearCart = shouldClearCart(searchParams); + + if (!orderId) { + return ( +
+
+

+ {t('errors.invalidOrder')} +

+

+ {t('missingOrder.message')} +

+
+ + {t('actions.backToCart')} + +
+
+
+ ); + } + + let order: Awaited> | null = null; + let loadState: 'ok' | 'not_found' | 'error' = 'ok'; + + try { + order = await getOrderSummary(orderId); + } catch (error) { + loadState = error instanceof OrderNotFoundError ? 'not_found' : 'error'; + } + + if (loadState === 'not_found') { + return ( +
+
+

+ {t('errors.orderNotFound')} +

+

+ {t('notFoundOrder.message')} +

+
+ + {t('actions.backToCart')} + +
+
+
+ ); + } + + if (loadState === 'error' || !order) { + return ( +
+
+

+ {t('errors.unableToLoad')} +

+

+ {t('errors.tryAgainLater')} +

+
+
+ ); + } + + return ( +
+ + +
+

+ {t('payment.title')} +

+ +

+ {t('payment.payForOrder', { orderId: order.id.slice(0, 8) })} +

+ +

+ {t('monobankGooglePay.supportedDevices')} +

+ +
+
+ + {t('payment.amountDue')} + + + {formatMoney(order.totalAmountMinor, order.currency, locale)} + +
+

+ {order.currency} +

+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx b/frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx new file mode 100644 index 00000000..6677e9fb --- /dev/null +++ b/frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Link, useRouter } from '@/i18n/routing'; +import { + type CurrencyCode, + currencyValues, + formatMoneyCode, +} from '@/lib/shop/currency'; +import { + SHOP_FOCUS, + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; + +type MonobankReturnStatusProps = { + orderId: string; + statusToken: string | null; + locale: string; +}; + +type LiteOrderStatus = { + id: string; + paymentStatus: string; + totalAmountMinor: number; + currency: CurrencyCode; + itemsCount: number; +}; + +const POLL_DELAY_MS = 2_500; +const TERMINAL_NON_PAID = new Set([ + 'failed', + 'refunded', + 'canceled', + 'needs_review', +]); +const PENDING_STATUSES = new Set(['pending', 'requires_payment']); + +const SHOP_OUTLINE_BTN = cn( + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, + SHOP_FOCUS +); + +function normalizeToken(token: string | null): string | null { + if (!token) return null; + const normalized = token.trim(); + return normalized.length ? normalized : null; +} + +function getStatusLabelKey(status: string): string { + if (status === 'paid') return 'paymentStatus.paid'; + if (status === 'failed') return 'paymentStatus.failed'; + if (status === 'refunded') return 'paymentStatus.refunded'; + if (status === 'pending') return 'paymentStatus.pending'; + if (status === 'requires_payment') return 'paymentStatus.confirming'; + if (status === 'needs_review') return 'paymentStatus.needsReview'; + return 'paymentStatus.unknown'; +} + +function parseStatusPayload(payload: unknown): LiteOrderStatus | null { + if (!payload || typeof payload !== 'object') return null; + const root = payload as Record; + if ( + typeof root.id !== 'string' || + typeof root.paymentStatus !== 'string' || + typeof root.totalAmountMinor !== 'number' || + !currencyValues.includes(root.currency as CurrencyCode) || + typeof root.itemsCount !== 'number' + ) { + return null; + } + + return { + id: root.id, + paymentStatus: root.paymentStatus, + totalAmountMinor: root.totalAmountMinor, + currency: root.currency as CurrencyCode, + itemsCount: root.itemsCount, + }; +} + +export default function MonobankReturnStatus({ + orderId, + statusToken, + locale, +}: MonobankReturnStatusProps) { + const t = useTranslations('shop.checkout'); + const router = useRouter(); + + const [status, setStatus] = useState(null); + const [isPolling, setIsPolling] = useState(true); + const [pollError, setPollError] = useState(null); + const [refreshSeed, setRefreshSeed] = useState(0); + + const normalizedToken = normalizeToken(statusToken); + + const fetchStatus = useCallback(async (): Promise => { + const params = new URLSearchParams({ view: 'lite' }); + if (normalizedToken) params.set('statusToken', normalizedToken); + + const response = await fetch( + `/api/shop/orders/${encodeURIComponent(orderId)}/status?${params.toString()}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + } + ); + + const payload = await response.json().catch(() => null); + if (!response.ok) return null; + + return parseStatusPayload(payload); + }, [normalizedToken, orderId]); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + + async function poll() { + if (cancelled) return; + + try { + const nextStatus = await fetchStatus(); + if (!nextStatus) { + if (!cancelled) { + setPollError(t('errors.tryAgainLater')); + timer = setTimeout(poll, POLL_DELAY_MS); + } + return; + } + + if (cancelled) return; + + setStatus(nextStatus); + setPollError(null); + + if (nextStatus.paymentStatus === 'paid') { + router.replace( + `/shop/checkout/success?orderId=${encodeURIComponent( + orderId + )}&clearCart=1` + ); + return; + } + + if (TERMINAL_NON_PAID.has(nextStatus.paymentStatus)) { + router.replace( + `/shop/checkout/error?orderId=${encodeURIComponent(orderId)}` + ); + return; + } + + if (!PENDING_STATUSES.has(nextStatus.paymentStatus)) { + timer = setTimeout(poll, POLL_DELAY_MS); + return; + } + + timer = setTimeout(poll, POLL_DELAY_MS); + } catch { + if (!cancelled) { + setPollError(t('errors.tryAgainLater')); + timer = setTimeout(poll, POLL_DELAY_MS); + } + } + } + + void poll().finally(() => { + if (!cancelled) setIsPolling(false); + }); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [fetchStatus, orderId, refreshSeed, router, t]); + + const statusLabel = useMemo(() => { + if (!status) return t('paymentStatus.confirming'); + return t(getStatusLabelKey(status.paymentStatus) as any); + }, [status, t]); + + return ( +
+

+ {t('monobankReturn.processing')} +

+ +

+ {t('monobankReturn.checking')} +

+ +
+
+
+
{t('error.orderLabel')}
+
{orderId}
+
+ +
+
{t('error.statusLabel')}
+
{statusLabel}
+
+ + {status ? ( +
+
+ {t('success.totalAmount')} +
+
+ {formatMoneyCode( + status.totalAmountMinor, + status.currency, + locale + )} +
+
+ ) : null} +
+
+ + {pollError ? ( +

+ {pollError} +

+ ) : null} + +
+ + + + {t('actions.backToCart')} + +
+
+ ); +} diff --git a/frontend/app/[locale]/shop/checkout/return/monobank/page.tsx b/frontend/app/[locale]/shop/checkout/return/monobank/page.tsx new file mode 100644 index 00000000..863434e3 --- /dev/null +++ b/frontend/app/[locale]/shop/checkout/return/monobank/page.tsx @@ -0,0 +1,101 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; + +import { ClearCartOnMount } from '@/components/shop/ClearCartOnMount'; +import { Link } from '@/i18n/routing'; +import { + SHOP_FOCUS, + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +import MonobankReturnStatus from './MonobankReturnStatus'; + +type SearchParams = Record; + +export const metadata: Metadata = { + title: 'Monobank Payment Status | DevLovers', + description: 'Waiting for Monobank webhook confirmation.', +}; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +const SHOP_OUTLINE_BTN = cn( + SHOP_OUTLINE_BTN_BASE, + SHOP_OUTLINE_BTN_INTERACTIVE, + SHOP_FOCUS +); + +function getStringParam(params: SearchParams, key: string): string { + const raw = params[key]; + if (!raw) return ''; + if (Array.isArray(raw)) return raw[0] ?? ''; + return raw; +} + +function parseOrderId(searchParams: SearchParams): string | null { + const orderId = getStringParam(searchParams, 'orderId'); + const parsed = orderIdParamSchema.safeParse({ id: orderId }); + if (!parsed.success) return null; + return parsed.data.id; +} + +function parseStatusToken(searchParams: SearchParams): string | null { + const raw = getStringParam(searchParams, 'statusToken').trim(); + return raw.length ? raw : null; +} + +function shouldClearCart(searchParams: SearchParams): boolean { + const raw = getStringParam(searchParams, 'clearCart'); + return raw === '1' || raw === 'true'; +} + +export default async function MonobankReturnPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: Promise; +}) { + const { locale } = await params; + const resolvedSearchParams = await searchParams; + const t = await getTranslations('shop.checkout'); + + const orderId = parseOrderId(resolvedSearchParams); + const statusToken = parseStatusToken(resolvedSearchParams); + const clearCart = shouldClearCart(resolvedSearchParams); + + if (!orderId) { + return ( +
+
+

+ {t('errors.missingOrderId')} +

+

+ {t('missingOrder.message')} +

+
+ + {t('actions.backToCart')} + +
+
+
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0bd62f53..a40a98cd 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -461,6 +461,11 @@ "label": "Payment method", "stripe": "Stripe", "monobank": "Monobank", + "monobankGooglePay": "Google Pay (Monobank)", + "monobankInvoice": "Card/Invoice (Monobank)", + "monobankGooglePayHint": "Google Pay available on supported devices", + "monobankGooglePayFallbackHint": "If Google Pay not available, use invoice", + "monobankGooglePayUnavailable": "Google Pay is currently unavailable for this order.", "monobankUahOnlyHint": "Monobank is available only for UAH checkout.", "monobankUnavailable": "Monobank is unavailable in this environment.", "noAvailable": "No payment methods are currently available." @@ -718,6 +723,22 @@ "completePayment": "Complete payment to finish your order" } }, + "monobankGooglePay": { + "supportedDevices": "Google Pay available on supported devices", + "useInvoiceFallback": "If Google Pay not available, use invoice", + "loading": "Preparing Google Pay...", + "submitting": "Processing payment...", + "unableToInit": "Unable to initialize Google Pay. You can continue with invoice.", + "invoiceFallback": "Pay via invoice", + "invoiceLoading": "Opening invoice...", + "invoiceFallbackFailed": "Unable to start invoice payment right now.", + "cancelled": "Google Pay was canceled. You can try again or use invoice." + }, + "monobankReturn": { + "processing": "Processing payment...", + "checking": "Waiting for webhook confirmation before finalizing your order.", + "refresh": "Refresh status" + }, "actions": { "backToProducts": "Back to products", "goToCart": "Go to cart", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 9f270c06..0780da7d 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -463,7 +463,12 @@ "monobank": "Monobank", "monobankUahOnlyHint": "Monobank jest dostępny tylko dla płatności w UAH.", "monobankUnavailable": "Monobank jest niedostępny w tym środowisku.", - "noAvailable": "Brak dostępnych metod płatności." + "noAvailable": "Brak dostępnych metod płatności.", + "monobankGooglePay": "Google Pay (Monobank)", + "monobankInvoice": "Card/Invoice (Monobank)", + "monobankGooglePayHint": "Google Pay available on supported devices", + "monobankGooglePayFallbackHint": "If Google Pay not available, use invoice", + "monobankGooglePayUnavailable": "Google Pay is currently unavailable for this order." } }, "actions": { @@ -723,6 +728,22 @@ "goToCart": "Przejdź do koszyka", "backToCart": "Wróć do koszyka", "continueShopping": "Kontynuuj zakupy" + }, + "monobankGooglePay": { + "supportedDevices": "Google Pay available on supported devices", + "useInvoiceFallback": "If Google Pay not available, use invoice", + "loading": "Preparing Google Pay...", + "submitting": "Processing payment...", + "unableToInit": "Unable to initialize Google Pay. You can continue with invoice.", + "invoiceFallback": "Pay via invoice", + "invoiceLoading": "Opening invoice...", + "invoiceFallbackFailed": "Unable to start invoice payment right now.", + "cancelled": "Google Pay was canceled. You can try again or use invoice." + }, + "monobankReturn": { + "processing": "Processing payment...", + "checking": "Waiting for webhook confirmation before finalizing your order.", + "refresh": "Refresh status" } }, "orders": { diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index dbf081fa..f4dfa04d 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -463,7 +463,12 @@ "monobank": "Monobank", "monobankUahOnlyHint": "Monobank доступний лише для оплати в UAH.", "monobankUnavailable": "Monobank недоступний у цьому середовищі.", - "noAvailable": "Наразі немає доступних способів оплати." + "noAvailable": "Наразі немає доступних способів оплати.", + "monobankGooglePay": "Google Pay (Monobank)", + "monobankInvoice": "Card/Invoice (Monobank)", + "monobankGooglePayHint": "Google Pay available on supported devices", + "monobankGooglePayFallbackHint": "If Google Pay not available, use invoice", + "monobankGooglePayUnavailable": "Google Pay is currently unavailable for this order." } }, "actions": { @@ -723,6 +728,22 @@ "goToCart": "Перейти до кошика", "backToCart": "Назад до кошика", "continueShopping": "Продовжити покупки" + }, + "monobankGooglePay": { + "supportedDevices": "Google Pay available on supported devices", + "useInvoiceFallback": "If Google Pay not available, use invoice", + "loading": "Preparing Google Pay...", + "submitting": "Processing payment...", + "unableToInit": "Unable to initialize Google Pay. You can continue with invoice.", + "invoiceFallback": "Pay via invoice", + "invoiceLoading": "Opening invoice...", + "invoiceFallbackFailed": "Unable to start invoice payment right now.", + "cancelled": "Google Pay was canceled. You can try again or use invoice." + }, + "monobankReturn": { + "processing": "Processing payment...", + "checking": "Waiting for webhook confirmation before finalizing your order.", + "refresh": "Refresh status" } }, "orders": { From 2894a33fbcaf430a3142ffd04ba2717825b38c3d Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 18:43:13 -0800 Subject: [PATCH 06/14] (SP: 2)[Wallets] propagate Monobank Google Pay wallet attribution and lock lite status contract --- .../lib/services/orders/monobank-wallet.ts | 34 +++++++-- .../lib/services/orders/monobank-webhook.ts | 72 ++++++++++++++++--- .../monobank-google-pay-submit-route.test.ts | 7 ++ .../tests/shop/monobank-webhook-apply.test.ts | 68 +++++++++++++++++- .../lib/tests/shop/order-status-token.test.ts | 3 +- 5 files changed, 168 insertions(+), 16 deletions(-) diff --git a/frontend/lib/services/orders/monobank-wallet.ts b/frontend/lib/services/orders/monobank-wallet.ts index b03411a9..f175d6b1 100644 --- a/frontend/lib/services/orders/monobank-wallet.ts +++ b/frontend/lib/services/orders/monobank-wallet.ts @@ -7,9 +7,9 @@ import { orders, paymentAttempts } from '@/db/schema'; import { MONO_CCY, MONO_CURRENCY, + type MonobankWalletPaymentResult, PspError, walletPayment, - type MonobankWalletPaymentResult, } from '@/lib/psp/monobank'; import { IdempotencyConflictError, @@ -69,6 +69,13 @@ function asRecord(value: unknown): Record { return value as Record; } +function readWalletMetadata(meta: Record): Record { + const monobank = asRecord(meta.monobank); + const monobankWallet = asRecord(monobank.wallet); + if (Object.keys(monobankWallet).length > 0) return monobankWallet; + return asRecord(meta.wallet); +} + function parseIsoDateOrNull(value: unknown): Date | null { if (typeof value !== 'string' || !value.trim()) return null; const ms = Date.parse(value); @@ -81,7 +88,7 @@ function readReplayResult( reused: boolean ): MonobankWalletSubmitResult { const meta = asRecord(attempt.metadata); - const wallet = asRecord(meta.wallet); + const wallet = readWalletMetadata(meta); const submitOutcome = wallet.submitOutcome === 'unknown' ? 'unknown' : 'submitted'; @@ -250,6 +257,11 @@ async function createCreatingAttempt(args: { } const now = new Date(); + const walletMetadata = { + requested: 'google_pay', + submitOutcome: 'creating', + lastSubmitAt: now.toISOString(), + }; const inserted = await db .insert(paymentAttempts) .values({ @@ -261,10 +273,10 @@ async function createCreatingAttempt(args: { expectedAmountMinor: args.expectedAmountMinor, idempotencyKey: args.idempotencyKey, metadata: { - wallet: { - submitOutcome: 'creating', - lastSubmitAt: now.toISOString(), + monobank: { + wallet: walletMetadata, }, + wallet: walletMetadata, }, }) .returning(); @@ -280,8 +292,17 @@ function mergeWalletMetadata( ): Record { const meta = asRecord(current); const wallet = asRecord(meta.wallet); + const monobank = asRecord(meta.monobank); + const monobankWallet = asRecord(monobank.wallet); return { ...meta, + monobank: { + ...monobank, + wallet: { + ...monobankWallet, + ...patch, + }, + }, wallet: { ...wallet, ...patch, @@ -306,6 +327,7 @@ async function persistAttemptSubmitted(args: { (args.pspResult.redirectUrl ? 'redirect_required' : 'submitted'); const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + requested: 'google_pay', submitOutcome: 'submitted', syncStatus, invoiceId, @@ -338,6 +360,7 @@ async function persistAttemptUnknown(args: { }): Promise { const now = new Date(); const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + requested: 'google_pay', submitOutcome: 'unknown', syncStatus: 'unknown', unknownReason: args.errorCode, @@ -363,6 +386,7 @@ async function persistAttemptRejected(args: { }): Promise { const now = new Date(); const nextMetadata = mergeWalletMetadata(args.attempt.metadata, { + requested: 'google_pay', submitOutcome: 'rejected', syncStatus: 'rejected', rejectCode: args.errorCode, diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts index d5ea4289..0b0feed4 100644 --- a/frontend/lib/services/orders/monobank-webhook.ts +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -67,6 +67,7 @@ type AttemptRow = Pick< | 'expectedAmountMinor' | 'providerPaymentIntentId' | 'providerModifiedAt' + | 'metadata' >; type OrderRow = Pick< @@ -88,6 +89,12 @@ type PaymentStatusTarget = Parameters< typeof guardedPaymentStatusUpdate >[0]['to']; +type WalletAttribution = { + provider: 'monobank'; + type: 'google_pay'; + source: 'attempt'; +}; + const CLAIM_TTL_MS = (() => { const raw = process.env.MONO_WEBHOOK_CLAIM_TTL_MS; const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; @@ -102,6 +109,38 @@ const INSTANCE_ID = (() => { return value.length > 64 ? value.slice(0, 64) : value; })(); +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function resolveWalletAttributionFromAttempt( + attempt: AttemptRow +): WalletAttribution | null { + const metadata = asRecord(attempt.metadata); + const monobank = asRecord(metadata.monobank); + const monobankWallet = asRecord(monobank.wallet); + const legacyWallet = asRecord(metadata.wallet); + + const requestedRaw = + typeof monobankWallet.requested === 'string' + ? monobankWallet.requested + : typeof legacyWallet.requested === 'string' + ? legacyWallet.requested + : ''; + const requested = requestedRaw.trim().toLowerCase(); + + if (requested === 'google_pay') { + return { + provider: 'monobank', + type: 'google_pay', + source: 'attempt', + }; + } + + return null; +} + function toIssueMessage(error: unknown): string { const msg = error instanceof Error @@ -343,7 +382,8 @@ async function fetchAttemptForWebhook(args: { status as "status", expected_amount_minor as "expectedAmountMinor", provider_payment_intent_id as "providerPaymentIntentId", - provider_modified_at as "providerModifiedAt" + provider_modified_at as "providerModifiedAt", + metadata as "metadata" from payment_attempts where provider = 'monobank' and ( @@ -460,8 +500,11 @@ async function persistEventOutcome(args: { .where(eq(monobankEvents.id, args.eventId)); } -function buildMergedMetaSql(normalized: NormalizedWebhook) { - const metadataPatch = { +function buildMergedMetaSql( + normalized: NormalizedWebhook, + walletAttribution: WalletAttribution | null +) { + const metadataPatch: Record = { monobank: { invoiceId: normalized.invoiceId, status: normalized.status, @@ -470,6 +513,9 @@ function buildMergedMetaSql(normalized: NormalizedWebhook) { reference: normalized.reference ?? null, }, }; + if (walletAttribution) { + metadataPatch.wallet = walletAttribution; + } return sql`coalesce(${orders.pspMetadata}, '{}'::jsonb) || ${JSON.stringify( metadataPatch @@ -487,7 +533,17 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { enqueueShipment: boolean; canonicalDualWriteEnabled: boolean; canonicalEventDedupeKey: string; + walletAttribution: WalletAttribution | null; }): Promise<{ ok: boolean; shipmentQueued: boolean }> { + const paymentEventPayload: Record = { + monobankEventId: args.eventId, + invoiceId: args.invoiceId, + status: 'success', + }; + if (args.walletAttribution) { + paymentEventPayload.wallet = args.walletAttribution; + } + const res = args.canonicalDualWriteEnabled ? await db.execute(sql` with updated_order as ( @@ -555,11 +611,7 @@ async function atomicMarkPaidOrderAndSucceedAttempt(args: { null, uo.total_amount_minor::bigint, uo.currency, - ${JSON.stringify({ - monobankEventId: args.eventId, - invoiceId: args.invoiceId, - status: 'success', - })}::jsonb, + ${JSON.stringify(paymentEventPayload)}::jsonb, ${args.canonicalEventDedupeKey}, ${args.now}, ${args.now} @@ -887,7 +939,8 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { providerModifiedAt, attemptProviderModifiedAt ); - const mergedMetaSql = buildMergedMetaSql(normalized); + const walletAttribution = resolveWalletAttributionFromAttempt(attemptRow); + const mergedMetaSql = buildMergedMetaSql(normalized, walletAttribution); if ( providerModifiedAt && @@ -1135,6 +1188,7 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { monobankEventId: eventId, invoiceId: normalized.invoiceId, }), + walletAttribution, }); if (!atomicResult.ok) { diff --git a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts index fbe9733e..e9649329 100644 --- a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts +++ b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts @@ -255,6 +255,13 @@ describe.sequential('monobank google pay submit route', () => { .from(paymentAttempts) .where(inArray(paymentAttempts.orderId, [orderJsonToken, orderRawToken])); + for (const attempt of attempts) { + const metadata = (attempt.metadata ?? {}) as Record; + const wallet = metadata.monobank?.wallet; + expect(wallet?.requested).toBe('google_pay'); + expect(wallet?.submitOutcome).toBe('submitted'); + } + const serializedMeta = JSON.stringify(attempts); expect(serializedMeta).not.toContain(jsonTokenMarker); expect(serializedMeta).not.toContain(rawTokenMarker); diff --git a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts index b1b496cb..7ba752a8 100644 --- a/frontend/lib/tests/shop/monobank-webhook-apply.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-apply.test.ts @@ -7,8 +7,8 @@ import { db } from '@/db'; import { monobankEvents, orders, - paymentEvents, paymentAttempts, + paymentEvents, shippingShipments, } from '@/db/schema'; import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; @@ -47,6 +47,7 @@ async function insertOrderAndAttempt(args: { | 'released' | 'failed'; withShippingNp?: boolean; + attemptMetadata?: Record; }) { const orderId = crypto.randomUUID(); await db.insert(orders).values({ @@ -82,6 +83,7 @@ async function insertOrderAndAttempt(args: { expectedAmountMinor: args.amountMinor, idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), providerPaymentIntentId: args.invoiceId, + metadata: args.attemptMetadata ?? {}, } as any); return { orderId, attemptId }; @@ -227,6 +229,70 @@ describe.sequential('monobank webhook apply (persist-first)', () => { } }); + it('copies wallet attribution from attempt metadata and performs no outbound network calls', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + attemptMetadata: { + monobank: { + wallet: { + requested: 'google_pay', + }, + }, + }, + }); + + const rawBody = JSON.stringify({ + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + rawSha256: sha256HexUtf8(rawBody), + requestId: 'req_wallet_attr_1', + mode: 'apply', + }); + + expect(res.appliedResult).toBe('applied'); + + const [order] = await db + .select({ pspMetadata: orders.pspMetadata }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect((order?.pspMetadata as any)?.wallet).toEqual({ + provider: 'monobank', + type: 'google_pay', + source: 'attempt', + }); + + const [event] = await db + .select({ payload: paymentEvents.payload }) + .from(paymentEvents) + .where(eq(paymentEvents.orderId, orderId)) + .limit(1); + + expect((event?.payload as any)?.wallet).toEqual({ + provider: 'monobank', + type: 'google_pay', + source: 'attempt', + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + await cleanup(orderId, invoiceId); + } + }); + it('does not enqueue shipment when inventory is not committed', async () => { const invoiceId = `inv_${crypto.randomUUID()}`; const { orderId } = await insertOrderAndAttempt({ diff --git a/frontend/lib/tests/shop/order-status-token.test.ts b/frontend/lib/tests/shop/order-status-token.test.ts index 0df03938..e5904538 100644 --- a/frontend/lib/tests/shop/order-status-token.test.ts +++ b/frontend/lib/tests/shop/order-status-token.test.ts @@ -100,7 +100,7 @@ describe('order status token access control', () => { const token = createStatusToken({ orderId }); const { GET } = await import('@/app/api/shop/orders/[id]/status/route'); const req = new NextRequest( - `http://localhost/api/shop/orders/${orderId}/status?statusToken=${encodeURIComponent( + `http://localhost/api/shop/orders/${orderId}/status?view=lite&statusToken=${encodeURIComponent( token )}` ); @@ -112,6 +112,7 @@ describe('order status token access control', () => { expect(json.currency).toBe('UAH'); expect(json.totalAmountMinor).toBe(1000); expect(json.paymentStatus).toBe('pending'); + expect(json.itemsCount).toBe(0); expect(typeof json.updatedAt).toBe('string'); expect(json.order).toBeUndefined(); expect(json.attempt).toBeUndefined(); From 5c56a3a58e079c071acc663485cb8faf97b53437 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 3 Mar 2026 19:04:24 -0800 Subject: [PATCH 07/14] (SP: 1)[Wallets] add Stripe wallet attribution and harden well-known bypass --- .../app/api/shop/webhooks/stripe/route.ts | 42 ++++ .../shop/stripe-webhook-psp-fields.test.ts | 190 +++++++++++++++++- frontend/proxy.ts | 13 +- 3 files changed, 242 insertions(+), 3 deletions(-) diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index e3a63a6f..8188320a 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -289,6 +289,39 @@ function resolvePaymentMethod( return paymentMethodFromIntent ?? paymentMethodFromCharge ?? null; } +type StripeWalletType = 'apple_pay' | 'google_pay' | null; +type StripeWalletAttribution = { + provider: 'stripe'; + type: StripeWalletType; + source: 'event'; +}; + +function resolveStripeWalletType( + paymentIntent?: Stripe.PaymentIntent, + charge?: Stripe.Charge +): StripeWalletType { + const chargeWithWallet = charge ?? getLatestCharge(paymentIntent as any); + const walletTypeRaw = (chargeWithWallet as any)?.payment_method_details?.card + ?.wallet?.type; + + if (walletTypeRaw === 'apple_pay' || walletTypeRaw === 'google_pay') { + return walletTypeRaw; + } + + return null; +} + +function buildStripeWalletAttribution(args: { + paymentIntent?: Stripe.PaymentIntent; + charge?: Stripe.Charge; +}): StripeWalletAttribution { + return { + provider: 'stripe', + type: resolveStripeWalletType(args.paymentIntent, args.charge), + source: 'event', + }; +} + function buildPspMetadata(params: { eventType: string; paymentIntent?: Stripe.PaymentIntent; @@ -1140,6 +1173,10 @@ export async function POST(request: NextRequest) { const amountMatches = stripeAmount === orderAmountMinor; const currencyMatches = stripeCurrency?.toUpperCase() === order.currency.toUpperCase(); + const walletAttribution = buildStripeWalletAttribution({ + paymentIntent, + charge: getLatestCharge(paymentIntent as any), + }); if (stripeAmount == null || !amountMatches || !currencyMatches) { const mismatchReason = @@ -1157,6 +1194,7 @@ export async function POST(request: NextRequest) { paymentIntent, charge: chargeForIntent, extra: { + wallet: walletAttribution, mismatch: { reason: mismatchReason, eventId: event.id, @@ -1221,6 +1259,9 @@ export async function POST(request: NextRequest) { eventType, paymentIntent, charge: chargeForIntent ?? undefined, + extra: { + wallet: walletAttribution, + }, }); const nextMeta = mergePspMetadata({ prevMeta: order.pspMetadata, @@ -1256,6 +1297,7 @@ export async function POST(request: NextRequest) { paymentIntentId, chargeId: latestChargeId ?? chargeForIntent?.id ?? null, paymentIntentStatus: paymentIntent?.status ?? null, + wallet: walletAttribution, }, }; const applyResult = diff --git a/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts index 7a3cd55e..1a422df0 100644 --- a/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts @@ -20,11 +20,12 @@ vi.mock('@/lib/psp/stripe', async () => { return { ...actual, verifyWebhookSignature: vi.fn(), + retrieveCharge: vi.fn(), }; }); import { POST as webhookPOST } from '@/app/api/shop/webhooks/stripe/route'; -import { verifyWebhookSignature } from '@/lib/psp/stripe'; +import { retrieveCharge, verifyWebhookSignature } from '@/lib/psp/stripe'; function logTestCleanupFailed(meta: Record, error: unknown) { console.error('[test cleanup failed]', { @@ -234,7 +235,11 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { payment_intent: paymentIntentId, payment_method_details: { type: 'card', - card: { brand: 'visa', last4: '4242' }, + card: { + brand: 'visa', + last4: '4242', + wallet: { type: 'apple_pay' }, + }, }, }, ], @@ -244,6 +249,7 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { }; vi.mocked(verifyWebhookSignature).mockReturnValue(event as any); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); const rawBody = JSON.stringify({ any: 'payload' }); const req = makeWebhookRequest(rawBody); @@ -298,6 +304,11 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { Object.keys((updated1[0].pspMetadata ?? {}) as Record) .length ).toBeGreaterThan(0); + expect((updated1[0].pspMetadata as any)?.wallet).toEqual({ + provider: 'stripe', + type: 'apple_pay', + source: 'event', + }); const ev1 = await db .select({ eventId: stripeEvents.eventId }) @@ -310,12 +321,18 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { id: paymentEvents.id, eventName: paymentEvents.eventName, eventRef: paymentEvents.eventRef, + payload: paymentEvents.payload, }) .from(paymentEvents) .where(eq(paymentEvents.orderId, orderId)); expect(canonical1.length).toBe(1); expect(canonical1[0]?.eventName).toBe('paid_applied'); expect(canonical1[0]?.eventRef).toBe(eventId); + expect((canonical1[0]?.payload as any)?.wallet).toEqual({ + provider: 'stripe', + type: 'apple_pay', + source: 'event', + }); const queued1 = await db .select({ id: shippingShipments.id }) @@ -365,7 +382,176 @@ describe('P0-6 webhook: writes PSP fields on succeeded', () => { .from(shippingShipments) .where(eq(shippingShipments.orderId, orderId)); expect(queued2.length).toBe(1); + expect(vi.mocked(retrieveCharge)).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + await cleanup({ orderId, productId, eventId }); + } + }, 30_000); + + it('payment_intent.succeeded extracts google_pay wallet attribution into order and canonical event payload', async () => { + const productId = randomUUID(); + const priceId = randomUUID(); + + const orderId = randomUUID(); + const idemKey = `idem_${randomUUID()}`; + + const paymentIntentId = `pi_test_${randomUUID() + .replace(/-/g, '') + .slice(0, 24)}`; + const eventId = `evt_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + const chargeId = `ch_test_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + + const title = 'Webhook PSP Test Product GPay'; + const slug = `webhook-psp-gpay-${productId.slice(0, 8)}`; + const sku = `SKU-GP-${productId.slice(0, 8)}`; + + await db.insert(products).values({ + id: productId, + slug, + title, + description: 'webhook test', + imageUrl: 'https://res.cloudinary.com/devlovers/image/upload/v1/test.png', + imagePublicId: null, + price: '9.00', + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku, + }); + + await db.insert(productPrices).values({ + id: priceId, + productId, + currency: 'USD', + priceMinor: 900, + originalPriceMinor: null, + price: '9.00', + originalPrice: null, + }); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 900, + totalAmount: '9.00', + currency: 'USD', + shippingRequired: true, + shippingPayer: 'customer', + shippingProvider: 'nova_poshta', + shippingMethodCode: 'NP_WAREHOUSE', + shippingAmountMinor: null, + shippingStatus: 'pending', + paymentStatus: 'requires_payment', + paymentProvider: 'stripe', + paymentIntentId, + idempotencyKey: idemKey, + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + }); + + await db.insert(orderItems).values({ + id: randomUUID(), + orderId, + productId, + quantity: 1, + unitPriceMinor: 900, + lineTotalMinor: 900, + unitPrice: '9.00', + lineTotal: '9.00', + productTitle: title, + productSlug: slug, + productSku: sku, + }); + + const event = { + id: eventId, + object: 'event', + type: 'payment_intent.succeeded', + data: { + object: { + id: paymentIntentId, + object: 'payment_intent', + amount: 900, + currency: 'usd', + status: 'succeeded', + metadata: { orderId }, + charges: { + object: 'list', + data: [ + { + id: chargeId, + object: 'charge', + payment_intent: paymentIntentId, + payment_method_details: { + type: 'card', + card: { + brand: 'visa', + last4: '4242', + wallet: { type: 'google_pay' }, + }, + }, + }, + ], + }, + }, + }, + }; + + vi.mocked(verifyWebhookSignature).mockReturnValue(event as any); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const rawBody = JSON.stringify({ any: 'payload_google_wallet' }); + const req = makeWebhookRequest(rawBody); + + try { + const res = await webhookPOST(req); + expect(res.status).toBeGreaterThanOrEqual(200); + expect(res.status).toBeLessThan(300); + + const [updated] = await db + .select({ + paymentStatus: orders.paymentStatus, + pspMetadata: orders.pspMetadata, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(updated?.paymentStatus).toBe('paid'); + expect((updated?.pspMetadata as any)?.wallet).toEqual({ + provider: 'stripe', + type: 'google_pay', + source: 'event', + }); + + const [canonical] = await db + .select({ + eventName: paymentEvents.eventName, + payload: paymentEvents.payload, + }) + .from(paymentEvents) + .where(eq(paymentEvents.orderId, orderId)) + .limit(1); + + expect(canonical?.eventName).toBe('paid_applied'); + expect((canonical?.payload as any)?.wallet).toEqual({ + provider: 'stripe', + type: 'google_pay', + source: 'event', + }); + + expect(vi.mocked(retrieveCharge)).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); } finally { + fetchSpy.mockRestore(); await cleanup({ orderId, productId, eventId }); } }, 30_000); diff --git a/frontend/proxy.ts b/frontend/proxy.ts index ab2d29c7..21f8677c 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -85,6 +85,13 @@ function getScopeFromPathname(pathname: string): 'shop' | 'site' { } export function proxy(req: NextRequest) { + // TODO(wallets-phase8): replace placeholder bytes in + // frontend/public/.well-known/apple-developer-merchantid-domain-association + // with the exact Stripe/Apple file before production enablement. + if (req.nextUrl.pathname.startsWith('/.well-known/')) { + return NextResponse.next(); + } + if (req.nextUrl.pathname === '/') { return NextResponse.redirect(new URL('/en', req.url)); } @@ -109,5 +116,9 @@ export function proxy(req: NextRequest) { } export const config = { - matcher: ['/', '/(uk|en|pl)/:path*', '/((?!api|_next|.*\\..*).*)'], + matcher: [ + '/', + '/(uk|en|pl)/:path*', + '/((?!api|_next|\\.well-known(?:/|$)|.*\\..*).*)', + ], }; From 3981cb9aef80a704dc64691236f37196d05da21c Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 4 Mar 2026 17:43:53 -0800 Subject: [PATCH 08/14] (SP: 2)[Wallets] remove Stripe webhook fetch and treat Monobank 429 as pending unknown --- .../app/api/shop/webhooks/stripe/route.ts | 4 +- frontend/lib/psp/monobank.ts | 17 ++- .../tests/shop/monobank-api-methods.test.ts | 23 +++- .../monobank-google-pay-submit-route.test.ts | 28 +++++ .../shop/stripe-webhook-refund-full.test.ts | 117 +++++++++++++----- frontend/proxy.ts | 3 - 6 files changed, 153 insertions(+), 39 deletions(-) diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 8188320a..f1600707 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -8,7 +8,7 @@ import { db } from '@/db'; import { orders, stripeEvents } from '@/db/schema'; import { isCanonicalEventsDualWriteEnabled } from '@/lib/env/shop-canonical-events'; import { logError, logInfo, logWarn } from '@/lib/logging'; -import { retrieveCharge, verifyWebhookSignature } from '@/lib/psp/stripe'; +import { verifyWebhookSignature } from '@/lib/psp/stripe'; import { guardNonBrowserOnly } from '@/lib/security/origin'; import { enforceRateLimit, @@ -1714,8 +1714,6 @@ export async function POST(request: NextRequest) { if (typeof refund.charge === 'object' && refund.charge) { effectiveCharge = refund.charge as Stripe.Charge; - } else if (typeof refund.charge === 'string' && refund.charge.trim()) { - effectiveCharge = await retrieveCharge(refund.charge.trim()); } const amt = diff --git a/frontend/lib/psp/monobank.ts b/frontend/lib/psp/monobank.ts index 48466048..1b39785e 100644 --- a/frontend/lib/psp/monobank.ts +++ b/frontend/lib/psp/monobank.ts @@ -532,6 +532,20 @@ async function requestMono( }); } + if (status === 429) { + throw new PspError('PSP_UPSTREAM', 'Monobank upstream rate limit', { + endpoint, + method: args.method, + httpStatus: status, + durationMs, + ...(parsed.monoCode ? { monoCode: parsed.monoCode } : {}), + ...(parsed.monoMessage ? { monoMessage: parsed.monoMessage } : {}), + ...(parsed.responseSnippet + ? { responseSnippet: parsed.responseSnippet } + : {}), + }); + } + if (status >= 400 && status < 500) { throw new PspError('PSP_BAD_REQUEST', 'Monobank request rejected', { endpoint, @@ -770,7 +784,8 @@ export async function walletPayment( throw new Error('Monobank wallet payment returned invalid payload'); } - const raw = res.data as MonobankWalletPaymentResponse & Record; + const raw = res.data as MonobankWalletPaymentResponse & + Record; const invoiceId = typeof raw.invoiceId === 'string' && raw.invoiceId.trim() ? raw.invoiceId.trim() diff --git a/frontend/lib/tests/shop/monobank-api-methods.test.ts b/frontend/lib/tests/shop/monobank-api-methods.test.ts index 0bd6d626..0a828745 100644 --- a/frontend/lib/tests/shop/monobank-api-methods.test.ts +++ b/frontend/lib/tests/shop/monobank-api-methods.test.ts @@ -337,7 +337,7 @@ describe('monobank api methods', () => { }); }); - it('walletPayment maps 400 and 500 deterministically', async () => { + it('walletPayment maps 400, 429, and 500 deterministically', async () => { const badRequestFetch = vi.fn(async () => makeResponse(400, JSON.stringify({ errorCode: 'X', message: 'bad' })) ); @@ -360,6 +360,27 @@ describe('monobank api methods', () => { } ); + const rateLimitedFetch = vi.fn(async () => + makeResponse(429, JSON.stringify({ errorCode: 'R', message: 'rate' })) + ); + globalThis.fetch = rateLimitedFetch as any; + + await expectPspError( + () => + walletPayment({ + cardToken: 'token', + amountMinor: 100, + ccy: 980, + redirectUrl: 'https://shop.test/return', + webHookUrl: 'https://shop.test/webhook', + }), + 'PSP_UPSTREAM', + { + httpStatus: 429, + monoCode: 'R', + } + ); + const upstreamFetch = vi.fn(async () => makeResponse(500, 'server error')); globalThis.fetch = upstreamFetch as any; diff --git a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts index e9649329..5a35623c 100644 --- a/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts +++ b/frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts @@ -473,4 +473,32 @@ describe.sequential('monobank google pay submit route', () => { await cleanupOrder(orderId); } }); + + it('returns pending/unknown on transient 429 upstream without retries', async () => { + const orderId = crypto.randomUUID(); + await insertOrder({ id: orderId }); + + walletPaymentMock.mockRejectedValueOnce( + new PspErrorCtor('PSP_UPSTREAM', 'rate_limited', { httpStatus: 429 }) + ); + + try { + const res = await postRoute( + makeSubmitRequest({ + orderId, + idempotencyKey: 'mono_submit_unknown_429_0001', + body: JSON.stringify({ gToken: 'token-429' }), + }), + { params: Promise.resolve({ id: orderId }) } + ); + + expect(res.status).toBe(202); + const json: any = await res.json(); + expect(json.submitOutcome).toBe('unknown'); + expect(json.status).toBe('pending'); + expect(walletPaymentMock).toHaveBeenCalledTimes(1); + } finally { + await cleanupOrder(orderId); + } + }); }); diff --git a/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts index 8ed5730e..30e6e39e 100644 --- a/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts +++ b/frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts @@ -200,12 +200,21 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(events.length).toBe(1); }, 30_000); - it('full refund (charge.refund.updated) WITHOUT metadata.orderId resolves by paymentIntentId (via retrieveCharge), sets terminal status, calls restock once', async () => { + it('full refund (charge.refund.updated) WITHOUT metadata.orderId resolves by paymentIntentId, sets terminal status, calls restock once', async () => { inserted = await insertPaidOrder(); const eventId = `evt_${crypto.randomUUID()}`; const chargeId = `ch_${crypto.randomUUID()}`; const refundId = `re_${crypto.randomUUID()}`; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const expandedCharge = makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, + amount: 2500, + amountRefunded: 2500, + refunds: [{ id: refundId, amount: 2500 }], + }); const refund = { id: refundId, @@ -213,21 +222,11 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded amount: 2500, status: 'succeeded', reason: null, - charge: chargeId, + charge: expandedCharge, payment_intent: inserted.paymentIntentId, metadata: {}, }; - retrieveChargeMock.mockResolvedValue( - makeCharge({ - chargeId, - paymentIntentId: inserted.paymentIntentId, - amount: 2500, - amountRefunded: 2500, - refunds: [{ id: refundId, amount: 2500 }], - }) - ); - verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refund.updated', @@ -253,13 +252,68 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.stockRestored).toBe(true); expect(row.pspChargeId).toBe(chargeId); - expect(retrieveChargeMock).toHaveBeenCalledTimes(1); - expect(retrieveChargeMock).toHaveBeenCalledWith(chargeId); + expect(retrieveChargeMock).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); expect(restockOrderMock).toHaveBeenCalledTimes(1); expect(restockOrderMock).toHaveBeenCalledWith(inserted.orderId, { reason: 'refunded', }); + + fetchSpy.mockRestore(); + }, 30_000); + + it('charge.refund.updated fail-closed when refund.charge is a string id: no retrieve/no fetch/no terminal refund/no restock', async () => { + inserted = await insertPaidOrder(); + + const eventId = `evt_${crypto.randomUUID()}`; + const chargeId = `ch_${crypto.randomUUID()}`; + const refundId = `re_${crypto.randomUUID()}`; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const refund = { + id: refundId, + object: 'refund', + amount: 2500, + status: 'succeeded', + reason: null, + charge: chargeId, + payment_intent: inserted.paymentIntentId, + metadata: {}, + }; + + verifyWebhookSignatureMock.mockReturnValue({ + id: eventId, + type: 'charge.refund.updated', + data: { object: refund }, + } as unknown as Stripe.Event); + + try { + const res = await POST(makeRequest()); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + code: 'REFUND_FULLNESS_UNDETERMINED', + }); + + const [row] = await db + .select({ + paymentStatus: orders.paymentStatus, + status: orders.status, + stockRestored: orders.stockRestored, + }) + .from(orders) + .where(eq(orders.id, inserted.orderId)) + .limit(1); + + expect(row.paymentStatus).toBe('paid'); + expect(row.status).toBe('PAID'); + expect(row.stockRestored).toBe(false); + expect(retrieveChargeMock).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(restockOrderMock).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } }, 30_000); it('partial refund is ignored (no paymentStatus/status change, no restock)', async () => { @@ -375,36 +429,35 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded const eventId = `evt_${crypto.randomUUID()}`; const chargeId = `ch_${crypto.randomUUID()}`; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); const refund1Id = `re_${crypto.randomUUID()}`; const refund2Id = `re_${crypto.randomUUID()}`; const refund3Id = `re_${crypto.randomUUID()}`; + const expandedCharge = makeCharge({ + chargeId, + paymentIntentId: inserted.paymentIntentId, + amount: 2500, + amountRefunded: 2500, + refunds: [ + { id: refund1Id, amount: 1000 }, + { id: refund2Id, amount: 1000 }, + { id: refund3Id, amount: 500 }, + ], + }); + const refund = { id: refund3Id, object: 'refund', amount: 500, status: 'succeeded', reason: null, - charge: chargeId, + charge: expandedCharge, payment_intent: inserted.paymentIntentId, metadata: {}, }; - retrieveChargeMock.mockResolvedValue( - makeCharge({ - chargeId, - paymentIntentId: inserted.paymentIntentId, - amount: 2500, - amountRefunded: 2500, - refunds: [ - { id: refund1Id, amount: 1000 }, - { id: refund2Id, amount: 1000 }, - { id: refund3Id, amount: 500 }, - ], - }) - ); - verifyWebhookSignatureMock.mockReturnValue({ id: eventId, type: 'charge.refund.updated', @@ -428,10 +481,12 @@ describe('stripe webhook refund (full only): PI fallback + terminal status + ded expect(row.status).toBe('CANCELED'); expect(row.stockRestored).toBe(true); - expect(retrieveChargeMock).toHaveBeenCalledTimes(1); - expect(retrieveChargeMock).toHaveBeenCalledWith(chargeId); + expect(retrieveChargeMock).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); expect(restockOrderMock).toHaveBeenCalledTimes(1); + + fetchSpy.mockRestore(); }, 30_000); it('charge.refunded: fallback to sum(refunds) when amount_refunded is missing (still detects full refund)', async () => { inserted = await insertPaidOrder(); diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 21f8677c..dae0ce43 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -85,9 +85,6 @@ function getScopeFromPathname(pathname: string): 'shop' | 'site' { } export function proxy(req: NextRequest) { - // TODO(wallets-phase8): replace placeholder bytes in - // frontend/public/.well-known/apple-developer-merchantid-domain-association - // with the exact Stripe/Apple file before production enablement. if (req.nextUrl.pathname.startsWith('/.well-known/')) { return NextResponse.next(); } From 6693ecea86c1b5c5b7e6206e4b4b4b081e8d719b Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Thu, 5 Mar 2026 16:03:51 -0800 Subject: [PATCH 09/14] (SP: 2)[Wallets] restore NP warehouses caching + correct CityRef usage; harden sync payload encoding --- .gitattributes | 1 - .../app/[locale]/shop/cart/CartPageClient.tsx | 223 +++++++++++++++--- .../shop/internal/shipping/np/sync/route.ts | 28 ++- .../shop/shipping/nova-poshta-catalog.ts | 8 +- .../shop/shipping/nova-poshta-client.ts | 110 +++++++-- ...le-developer-merchantid-domain-association | 2 - frontend/scripts/np-mock-server.mjs | 82 ++++++- 7 files changed, 393 insertions(+), 61 deletions(-) delete mode 100644 .gitattributes delete mode 100644 frontend/public/.well-known/apple-developer-merchantid-domain-association diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 81c32805..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -frontend/public/.well-known/apple-developer-merchantid-domain-association -text diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 8deb8698..728f868e 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -90,7 +90,6 @@ function resolveInitialProvider(args: { const canUseStripe = args.stripeEnabled; const canUseMonobank = args.monobankEnabled && isUah; - // Monobank-first for UAH checkout. if (canUseMonobank) return 'monobank'; if (canUseStripe) return 'stripe'; return 'stripe'; @@ -127,6 +126,73 @@ type ShippingWarehouse = { address: string | null; }; +function normalizeLookupValue(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function normalizeShippingCity(raw: unknown): ShippingCity | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + + const item = raw as Record; + + const ref = typeof item.ref === 'string' ? item.ref.trim() : ''; + + const rawName = + typeof item.nameUa === 'string' + ? item.nameUa + : typeof item.name_ua === 'string' + ? item.name_ua + : typeof item.name === 'string' + ? item.name + : typeof item.present === 'string' + ? item.present + : ''; + + const nameUa = rawName.trim(); + + if (!ref || !nameUa) { + return null; + } + + return { + ref, + nameUa, + }; +} + +function parseShippingCitiesResponse(data: unknown): { + available: boolean | null; + items: ShippingCity[]; +} { + if (Array.isArray(data)) { + return { + available: null, + items: data + .map(normalizeShippingCity) + .filter((item): item is ShippingCity => item !== null), + }; + } + + if (!data || typeof data !== 'object') { + return { + available: null, + items: [], + }; + } + + const obj = data as Record; + const itemsRaw = Array.isArray(obj.items) ? obj.items : []; + + return { + available: typeof obj.available === 'boolean' ? obj.available : null, + items: itemsRaw + .map(normalizeShippingCity) + .filter((item): item is ShippingCity => item !== null), + }; +} + function isWarehouseMethod( methodCode: CheckoutDeliveryMethodCode | null ): boolean { @@ -272,6 +338,10 @@ export default function CartPage({ if (key) return safeT(key, code ?? 'SHIPPING_INVALID'); return safeT('delivery.validation.invalid', code ?? 'SHIPPING_INVALID'); }; + const clearCheckoutUiErrors = () => { + setDeliveryUiError(null); + setCheckoutError(null); + }; useEffect(() => { if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) { @@ -482,8 +552,10 @@ export default function CartPage({ let cancelled = false; const controller = new AbortController(); + const timeoutId = setTimeout(async () => { setCitiesLoading(true); + try { const qs = new URLSearchParams({ q: query, @@ -492,16 +564,20 @@ export default function CartPage({ ...(country ? { country } : {}), }); - const response = await fetch(`/api/shop/shipping/np/cities?${qs}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - cache: 'no-store', - signal: controller.signal, - }); + const response = await fetch( + `/api/shop/shipping/np/cities?${qs.toString()}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + } + ); const data = await response.json().catch(() => null); + const parsed = parseShippingCitiesResponse(data); - if (!response.ok || !data || data.available === false) { + if (!response.ok || parsed.available === false) { if (!cancelled) { setCityOptions([]); } @@ -509,10 +585,21 @@ export default function CartPage({ } if (!cancelled) { - const next = Array.isArray(data.items) - ? (data.items as ShippingCity[]) - : []; - setCityOptions(next); + const next = parsed.items; + const normalizedQuery = normalizeLookupValue(query); + + const exactMatches = next.filter( + city => normalizeLookupValue(city.nameUa) === normalizedQuery + ); + + if (exactMatches.length === 1) { + const exactCity = exactMatches[0]!; + setSelectedCityRef(exactCity.ref); + setSelectedCityName(exactCity.nameUa); + setCityOptions([]); + } else { + setCityOptions(next); + } } } catch { if (!cancelled) { @@ -788,7 +875,9 @@ export default function CartPage({ selectedPaymentMethod === 'monobank_google_pay' && !canUseMonobankGooglePay ) { - setCheckoutError(t('checkout.paymentMethod.monobankGooglePayUnavailable')); + setCheckoutError( + t('checkout.paymentMethod.monobankGooglePayUnavailable') + ); return; } if (shippingMethodsLoading) { @@ -927,7 +1016,10 @@ export default function CartPage({ window.location.assign(monobankPageUrl); return; } - if (paymentProvider === 'monobank' && checkoutPaymentMethod === 'monobank_google_pay') { + if ( + paymentProvider === 'monobank' && + checkoutPaymentMethod === 'monobank_google_pay' + ) { if (!statusToken) { setCheckoutError(t('checkout.errors.unexpectedResponse')); return; @@ -1301,9 +1393,10 @@ export default function CartPage({ name="delivery-method" value={method.methodCode} checked={selectedShippingMethod === method.methodCode} - onChange={() => - setSelectedShippingMethod(method.methodCode) - } + onChange={() => { + clearCheckoutUiErrors(); + setSelectedShippingMethod(method.methodCode); + }} className="h-4 w-4" /> @@ -1324,7 +1417,10 @@ export default function CartPage({ id="shipping-city-search" type="text" value={cityQuery} + autoComplete="off" + spellCheck={false} onChange={event => { + clearCheckoutUiErrors(); setCityQuery(event.target.value); setSelectedCityRef(null); setSelectedCityName(null); @@ -1354,9 +1450,11 @@ export default function CartPage({ key={city.ref} type="button" onClick={() => { + clearCheckoutUiErrors(); setSelectedCityRef(city.ref); setSelectedCityName(city.nameUa); setCityQuery(city.nameUa); + setCityOptions([]); }} className="hover:bg-secondary block w-full rounded px-2 py-1 text-left text-xs" > @@ -1365,6 +1463,18 @@ export default function CartPage({ ))} ) : null} + + {!citiesLoading && + cityQuery.trim().length >= 2 && + !selectedCityRef && + cityOptions.length === 0 ? ( +

+ {safeT( + 'delivery.city.noResults', + 'Міста не знайдено. Перевірте назву або локальні дані Nova Poshta' + )} +

+ ) : null} {isWarehouseSelectionMethod ? ( @@ -1387,15 +1497,35 @@ export default function CartPage({ type="text" value={warehouseQuery} onChange={event => { + clearCheckoutUiErrors(); setWarehouseQuery(event.target.value); setSelectedWarehouseRef(null); setSelectedWarehouseName(null); }} - placeholder={t('delivery.warehouse.placeholder')} + placeholder={ + selectedCityRef + ? t('delivery.warehouse.placeholder') + : safeT( + 'delivery.warehouse.selectCityFirst', + 'Спочатку оберіть місто' + ) + } className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" disabled={!selectedCityRef} /> + {!selectedCityRef ? ( +

+ {safeT( + 'delivery.warehouse.cityRequired', + 'Щоб вибрати відділення, спочатку оберіть місто зі списку' + )} +

+ ) : null} + {selectedWarehouseRef ? (

{t('delivery.warehouse.selected', { @@ -1418,6 +1548,7 @@ export default function CartPage({ key={warehouse.ref} type="button" onClick={() => { + clearCheckoutUiErrors(); setSelectedWarehouseRef(warehouse.ref); setSelectedWarehouseName(warehouse.name); setWarehouseQuery( @@ -1449,9 +1580,10 @@ export default function CartPage({ id="shipping-address-1" type="text" value={courierAddressLine1} - onChange={event => - setCourierAddressLine1(event.target.value) - } + onChange={event => { + clearCheckoutUiErrors(); + setCourierAddressLine1(event.target.value); + }} placeholder={t( 'delivery.courierAddress.line1Placeholder' )} @@ -1460,9 +1592,10 @@ export default function CartPage({ - setCourierAddressLine2(event.target.value) - } + onChange={event => { + clearCheckoutUiErrors(); + setCourierAddressLine2(event.target.value); + }} placeholder={t( 'delivery.courierAddress.line2Placeholder' )} @@ -1482,7 +1615,10 @@ export default function CartPage({ id="recipient-name" type="text" value={recipientName} - onChange={event => setRecipientName(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientName(event.target.value); + }} placeholder={t('delivery.recipientName.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1499,7 +1635,10 @@ export default function CartPage({ id="recipient-phone" type="tel" value={recipientPhone} - onChange={event => setRecipientPhone(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientPhone(event.target.value); + }} placeholder={t('delivery.recipientPhone.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1516,7 +1655,10 @@ export default function CartPage({ id="recipient-email" type="email" value={recipientEmail} - onChange={event => setRecipientEmail(event.target.value)} + onChange={event => { + clearCheckoutUiErrors(); + setRecipientEmail(event.target.value); + }} placeholder={t('delivery.recipientEmail.placeholder')} className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" /> @@ -1532,7 +1674,10 @@ export default function CartPage({