From 1211e36eeb74e8d094434f4028c566a76f004a42 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 1 Apr 2026 07:59:02 -0700 Subject: [PATCH 01/11] (SP: 1) [SHOP] add CP-01 policy memo and repair PR-A checkout test safety net --- .../shop/commercial-policy-batch-cp-01.md | 99 +++++++++++++++++++ .../shop/checkout-currency-policy.test.ts | 2 + ...checkout-monobank-parse-validation.test.ts | 8 +- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 frontend/docs/shop/commercial-policy-batch-cp-01.md diff --git a/frontend/docs/shop/commercial-policy-batch-cp-01.md b/frontend/docs/shop/commercial-policy-batch-cp-01.md new file mode 100644 index 00000000..d1ed10a8 --- /dev/null +++ b/frontend/docs/shop/commercial-policy-batch-cp-01.md @@ -0,0 +1,99 @@ +# Shop Commercial Policy Memo + +## Status + +Approved for Batch CP-01 preparation work. + +## Purpose + +This memo records the target commercial policy for the standard Shop storefront +without changing runtime behavior in PR-A. + +It exists to keep policy decisions explicit while checkout/storefront +implementation work is staged across later PRs. + +--- + +## Batch CP-01 Policy + +### 1. Locale + +Locale is a language/content selector only. + +Locale must not be the long-term source of truth for: + +- storefront currency, +- payment rail availability, +- commercial market selection. + +### 2. Standard Storefront Currency + +The standard storefront currency target is **UAH** on all locales. + +This is the intended commercial policy for the standard storefront, not a +statement that runtime behavior has already switched in this PR. + +### 3. Standard Storefront Payment Providers + +The standard storefront payment rails are: + +- Stripe +- Monobank + +Provider availability must ultimately be controlled by env/runtime capability +only. Locale must not be the gate. + +### 4. Intl Flow + +The existing `intl` flow remains untouched in Batch CP-01. + +This batch does not redesign, widen, or clean up the `intl` quote/payment +contract. + +### 5. Dormant USD Compatibility + +USD compatibility remains temporarily in place as a dormant compatibility path. + +This includes legacy/storage compatibility that may still be required while the +policy refactor is staged safely. + +Batch CP-01 does not require immediate USD removal. + +### 6. Schema Cleanup + +No schema cleanup is part of this batch. + +That means: + +- no migrations, +- no enum cleanup, +- no legacy compatibility removal, +- no destructive price/currency schema changes. + +--- + +## PR-A Scope Guardrail + +PR-A is preparation only. + +Allowed in PR-A: + +- documenting the policy, +- repairing stale targeted tests so they reach their intended assertions. + +Not allowed in PR-A: + +- switching storefront currency behavior, +- switching checkout/provider enforcement behavior, +- changing admin pricing policy, +- cleaning up schema or dormant compatibility paths. + +--- + +## Interpretation Rule + +If code, tests, or follow-up implementation planning conflict with this memo, +this memo defines the intended Batch CP-01 target policy. + +Runtime behavior changes must be delivered only in later PRs with explicit scope +approval. diff --git a/frontend/lib/tests/shop/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts index 049c19a3..3d8653fc 100644 --- a/frontend/lib/tests/shop/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -17,6 +17,7 @@ import { } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; import { rehydrateCartItems } from '@/lib/services/products'; +import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; vi.mock('@/lib/auth', async () => { const actual = @@ -162,6 +163,7 @@ async function makeCheckoutRequest( payload && typeof payload === 'object' && !Array.isArray(payload) ? ({ ...(payload as Record) } as Record) : {}; + body.legalConsent ??= TEST_LEGAL_CONSENT; const items = Array.isArray(body.items) ? body.items : []; const currency = opts.acceptLanguage.trim().toLowerCase().startsWith('uk') ? 'UAH' 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 9563c242..dddfcf00 100644 --- a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts @@ -15,6 +15,7 @@ import { hasStatusTokenScope, verifyStatusToken, } from '@/lib/shop/status-token'; +import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -94,6 +95,11 @@ function makeMonobankCheckoutReq(params: { idempotencyKey?: string; body: Record; }) { + const body = { + legalConsent: TEST_LEGAL_CONSENT, + ...params.body, + }; + const headers = new Headers({ 'content-type': 'application/json', 'accept-language': 'uk-UA', @@ -108,7 +114,7 @@ function makeMonobankCheckoutReq(params: { new Request('http://localhost:3000/api/shop/checkout', { method: 'POST', headers, - body: JSON.stringify(params.body), + body: JSON.stringify(body), }) ); } From c38bf05b9e8a98b3e86e1f667efd0f06b7443e02 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 1 Apr 2026 08:23:02 -0700 Subject: [PATCH 02/11] (SP: 3) [SHOP] extract centralized standard storefront commercial policy foundation --- .../app/[locale]/shop/cart/capabilities.ts | 33 ++------ frontend/lib/shop/commercial-policy.server.ts | 61 ++++++++++++++ frontend/lib/shop/commercial-policy.ts | 73 +++++++++++++++++ frontend/lib/shop/currency.ts | 5 +- frontend/lib/shop/locale.ts | 10 +-- frontend/lib/shop/payments.ts | 25 ++---- .../shop/commercial-policy-delegation.test.ts | 75 +++++++++++++++++ .../lib/tests/shop/commercial-policy.test.ts | 81 +++++++++++++++++++ 8 files changed, 307 insertions(+), 56 deletions(-) create mode 100644 frontend/lib/shop/commercial-policy.server.ts create mode 100644 frontend/lib/shop/commercial-policy.ts create mode 100644 frontend/lib/tests/shop/commercial-policy-delegation.test.ts create mode 100644 frontend/lib/tests/shop/commercial-policy.test.ts diff --git a/frontend/app/[locale]/shop/cart/capabilities.ts b/frontend/app/[locale]/shop/cart/capabilities.ts index 6afc488f..8cd79e33 100644 --- a/frontend/app/[locale]/shop/cart/capabilities.ts +++ b/frontend/app/[locale]/shop/cart/capabilities.ts @@ -1,37 +1,14 @@ -import { isMonobankEnabled } from '@/lib/env/monobank'; -import { readServerEnv } from '@/lib/env/server-env'; -import { isPaymentsEnabled as isStripePaymentsEnabled } from '@/lib/env/stripe'; - -function isFlagEnabled(value: string | undefined): boolean { - return (value ?? '').trim() === 'true'; -} +import { resolveStandardStorefrontProviderCapabilities } from '@/lib/shop/commercial-policy.server'; export function resolveStripeCheckoutEnabled(): boolean { - try { - return isStripePaymentsEnabled({ - requirePublishableKey: true, - }); - } catch { - return false; - } + return resolveStandardStorefrontProviderCapabilities().stripeCheckoutEnabled; } export function resolveMonobankCheckoutEnabled(): boolean { - const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED')); - if (!paymentsEnabled) return false; - - try { - return isMonobankEnabled(); - } catch { - return false; - } + return resolveStandardStorefrontProviderCapabilities().monobankCheckoutEnabled; } export function resolveMonobankGooglePayEnabled(): boolean { - if (!resolveMonobankCheckoutEnabled()) return false; - - const raw = (readServerEnv('SHOP_MONOBANK_GPAY_ENABLED') ?? '') - .trim() - .toLowerCase(); - return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on'; + return resolveStandardStorefrontProviderCapabilities() + .monobankGooglePayEnabled; } diff --git a/frontend/lib/shop/commercial-policy.server.ts b/frontend/lib/shop/commercial-policy.server.ts new file mode 100644 index 00000000..5fbc5def --- /dev/null +++ b/frontend/lib/shop/commercial-policy.server.ts @@ -0,0 +1,61 @@ +import 'server-only'; + +import { isMonobankEnabled } from '@/lib/env/monobank'; +import { readServerEnv } from '@/lib/env/server-env'; +import { isPaymentsEnabled as isStripePaymentsEnabled } from '@/lib/env/stripe'; + +export type StandardStorefrontProviderCapabilities = { + stripeCheckoutEnabled: boolean; + monobankCheckoutEnabled: boolean; + monobankGooglePayEnabled: boolean; + enabledProviders: ReadonlyArray<'monobank' | 'stripe'>; +}; + +function isFlagEnabled(value: string | undefined): boolean { + return (value ?? '').trim() === 'true'; +} + +export function resolveStandardStorefrontProviderCapabilities(): StandardStorefrontProviderCapabilities { + let stripeCheckoutEnabled = false; + try { + stripeCheckoutEnabled = isStripePaymentsEnabled({ + requirePublishableKey: true, + }); + } catch { + stripeCheckoutEnabled = false; + } + + const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED')); + + let monobankCheckoutEnabled = false; + if (paymentsEnabled) { + try { + monobankCheckoutEnabled = isMonobankEnabled(); + } catch { + monobankCheckoutEnabled = false; + } + } + + const rawMonobankGooglePay = ( + readServerEnv('SHOP_MONOBANK_GPAY_ENABLED') ?? '' + ) + .trim() + .toLowerCase(); + const monobankGooglePayEnabled = + monobankCheckoutEnabled && + (rawMonobankGooglePay === 'true' || + rawMonobankGooglePay === '1' || + rawMonobankGooglePay === 'yes' || + rawMonobankGooglePay === 'on'); + + const enabledProviders: Array<'monobank' | 'stripe'> = []; + if (monobankCheckoutEnabled) enabledProviders.push('monobank'); + if (stripeCheckoutEnabled) enabledProviders.push('stripe'); + + return { + stripeCheckoutEnabled, + monobankCheckoutEnabled, + monobankGooglePayEnabled, + enabledProviders, + }; +} diff --git a/frontend/lib/shop/commercial-policy.ts b/frontend/lib/shop/commercial-policy.ts new file mode 100644 index 00000000..b12a9874 --- /dev/null +++ b/frontend/lib/shop/commercial-policy.ts @@ -0,0 +1,73 @@ +export type StandardStorefrontCurrency = 'UAH'; +export type StandardStorefrontShippingCountry = 'UA'; +export type CompatibleCurrency = 'USD' | StandardStorefrontCurrency; +export type CompatibleCheckoutProvider = 'stripe' | 'monobank'; +export type CompatiblePaymentMethod = + | 'stripe_card' + | 'monobank_invoice' + | 'monobank_google_pay'; + +export const STANDARD_STOREFRONT_COMMERCIAL_POLICY = { + localePolicy: 'content_only', + currency: 'UAH', + shippingCountry: 'UA', + providerEnablement: 'env_runtime_capability', + intlFlow: 'untouched', + dormantUsd: 'compatibility_only', + schemaCleanup: 'deferred', +} as const; + +function normalizeLocaleTag(locale: string | null | undefined): string { + const raw = (locale ?? '').trim().toLowerCase(); + if (!raw) return ''; + return raw.split(/[-_]/)[0] ?? raw; +} + +export function resolveCurrentStandardStorefrontCurrencyFromLocale( + locale: string | null | undefined +): CompatibleCurrency { + const primary = normalizeLocaleTag(locale); + return primary === 'uk' + ? STANDARD_STOREFRONT_COMMERCIAL_POLICY.currency + : 'USD'; +} + +export function resolveCurrentStandardStorefrontShippingCountryFromLocale( + locale: string | null | undefined +): StandardStorefrontShippingCountry | null { + const primary = normalizeLocaleTag(locale); + return primary === 'uk' + ? STANDARD_STOREFRONT_COMMERCIAL_POLICY.shippingCountry + : null; +} + +export function inferCurrentCheckoutProviderFromMethod( + method: CompatiblePaymentMethod | null | undefined +): CompatibleCheckoutProvider | null { + if (method === 'stripe_card') return 'stripe'; + if (method === 'monobank_invoice' || method === 'monobank_google_pay') { + return 'monobank'; + } + + return null; +} + +export function resolveCurrentCheckoutProviderCandidates(args: { + requestedProvider?: CompatibleCheckoutProvider | null; + requestedMethod?: CompatiblePaymentMethod | null; + currency: CompatibleCurrency; +}): readonly CompatibleCheckoutProvider[] { + const explicitProvider = + args.requestedProvider ?? + inferCurrentCheckoutProviderFromMethod(args.requestedMethod); + + if (explicitProvider) { + return [explicitProvider]; + } + + if (args.currency === STANDARD_STOREFRONT_COMMERCIAL_POLICY.currency) { + return ['monobank', 'stripe']; + } + + return ['stripe']; +} diff --git a/frontend/lib/shop/currency.ts b/frontend/lib/shop/currency.ts index e9bf44de..d2e01b35 100644 --- a/frontend/lib/shop/currency.ts +++ b/frontend/lib/shop/currency.ts @@ -1,3 +1,5 @@ +import { resolveCurrentStandardStorefrontCurrencyFromLocale } from '@/lib/shop/commercial-policy'; + export const currencyValues = ['USD', 'UAH'] as const; export type CurrencyCode = (typeof currencyValues)[number]; @@ -31,8 +33,7 @@ function normalizeLocaleTag(locale: string | null | undefined): string { export function resolveCurrencyFromLocale( locale: string | null | undefined ): CurrencyCode { - const primary = normalizeLocaleTag(locale); - return primary === 'uk' ? 'UAH' : 'USD'; + return resolveCurrentStandardStorefrontCurrencyFromLocale(locale); } export function parsePrimaryLocaleFromAcceptLanguage( diff --git a/frontend/lib/shop/locale.ts b/frontend/lib/shop/locale.ts index 99574437..159685cc 100644 --- a/frontend/lib/shop/locale.ts +++ b/frontend/lib/shop/locale.ts @@ -1,13 +1,9 @@ +import { resolveCurrentStandardStorefrontShippingCountryFromLocale } from '@/lib/shop/commercial-policy'; + export function localeToCountry( input: string | null | undefined ): string | null { - const locale = (input ?? '').trim().toLowerCase(); - if (!locale) return null; - - const primary = locale.split(/[-_]/)[0]?.toLowerCase() ?? ''; - if (primary === 'uk') return 'UA'; - - return null; + return resolveCurrentStandardStorefrontShippingCountryFromLocale(input); } export const countryFromLocale = localeToCountry; diff --git a/frontend/lib/shop/payments.ts b/frontend/lib/shop/payments.ts index 42d82a81..8604b4d2 100644 --- a/frontend/lib/shop/payments.ts +++ b/frontend/lib/shop/payments.ts @@ -1,3 +1,7 @@ +import { + inferCurrentCheckoutProviderFromMethod, + resolveCurrentCheckoutProviderCandidates, +} from '@/lib/shop/commercial-policy'; import type { CurrencyCode } from '@/lib/shop/currency'; export const paymentStatusValues = [ @@ -28,12 +32,7 @@ export type PaymentMethod = (typeof paymentMethodValues)[number]; export function inferCheckoutProviderFromMethod( method: PaymentMethod | null | undefined ): CheckoutPaymentProvider | null { - if (method === 'stripe_card') return 'stripe'; - if (method === 'monobank_invoice' || method === 'monobank_google_pay') { - return 'monobank'; - } - - return null; + return inferCurrentCheckoutProviderFromMethod(method); } export function resolveCheckoutProviderCandidates(args: { @@ -41,19 +40,7 @@ export function resolveCheckoutProviderCandidates(args: { requestedMethod?: PaymentMethod | null; currency: CurrencyCode; }): readonly CheckoutPaymentProvider[] { - const explicitProvider = - args.requestedProvider ?? - inferCheckoutProviderFromMethod(args.requestedMethod); - - if (explicitProvider) { - return [explicitProvider]; - } - - if (args.currency === 'UAH') { - return ['monobank', 'stripe']; - } - - return ['stripe']; + return resolveCurrentCheckoutProviderCandidates(args); } export function resolveDefaultMethodForProvider( diff --git a/frontend/lib/tests/shop/commercial-policy-delegation.test.ts b/frontend/lib/tests/shop/commercial-policy-delegation.test.ts new file mode 100644 index 00000000..8f49acc6 --- /dev/null +++ b/frontend/lib/tests/shop/commercial-policy-delegation.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('commercial policy wrapper delegation', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('currency and locale helpers delegate to the centralized policy module', async () => { + const currencyDelegate = vi.fn(() => 'USD'); + const countryDelegate = vi.fn(() => 'UA'); + + vi.doMock('@/lib/shop/commercial-policy', async () => { + const actual = await vi.importActual('@/lib/shop/commercial-policy'); + return { + ...actual, + resolveCurrentStandardStorefrontCurrencyFromLocale: currencyDelegate, + resolveCurrentStandardStorefrontShippingCountryFromLocale: + countryDelegate, + }; + }); + + const currencyMod = await import('@/lib/shop/currency'); + const localeMod = await import('@/lib/shop/locale'); + + expect(currencyMod.resolveCurrencyFromLocale('uk')).toBe('USD'); + expect(currencyDelegate).toHaveBeenCalledWith('uk'); + + expect(localeMod.localeToCountry('uk')).toBe('UA'); + expect(countryDelegate).toHaveBeenCalledWith('uk'); + }); + + it('checkout provider helper delegates to the centralized policy module', async () => { + const candidateDelegate = vi.fn(() => ['stripe'] as const); + + vi.doMock('@/lib/shop/commercial-policy', async () => { + const actual = await vi.importActual('@/lib/shop/commercial-policy'); + return { + ...actual, + resolveCurrentCheckoutProviderCandidates: candidateDelegate, + }; + }); + + const paymentsMod = await import('@/lib/shop/payments'); + + expect( + paymentsMod.resolveCheckoutProviderCandidates({ + currency: 'UAH', + }) + ).toEqual(['stripe']); + expect(candidateDelegate).toHaveBeenCalledWith({ + currency: 'UAH', + }); + }); + + it('cart capability helpers delegate to the centralized server policy module', async () => { + const resolveCapabilities = vi.fn(() => ({ + stripeCheckoutEnabled: true, + monobankCheckoutEnabled: false, + monobankGooglePayEnabled: false, + enabledProviders: ['stripe'] as const, + })); + + vi.doMock('@/lib/shop/commercial-policy.server', () => ({ + resolveStandardStorefrontProviderCapabilities: resolveCapabilities, + })); + + const mod = await import('@/app/[locale]/shop/cart/capabilities'); + + expect(mod.resolveStripeCheckoutEnabled()).toBe(true); + expect(mod.resolveMonobankCheckoutEnabled()).toBe(false); + expect(mod.resolveMonobankGooglePayEnabled()).toBe(false); + expect(resolveCapabilities).toHaveBeenCalledTimes(3); + }); +}); diff --git a/frontend/lib/tests/shop/commercial-policy.test.ts b/frontend/lib/tests/shop/commercial-policy.test.ts new file mode 100644 index 00000000..bba95ea8 --- /dev/null +++ b/frontend/lib/tests/shop/commercial-policy.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { + inferCurrentCheckoutProviderFromMethod, + resolveCurrentCheckoutProviderCandidates, + resolveCurrentStandardStorefrontCurrencyFromLocale, + resolveCurrentStandardStorefrontShippingCountryFromLocale, + STANDARD_STOREFRONT_COMMERCIAL_POLICY, +} from '@/lib/shop/commercial-policy'; + +describe('commercial policy contract', () => { + it('encodes the Batch CP-01 standard storefront target policy in one place', () => { + expect(STANDARD_STOREFRONT_COMMERCIAL_POLICY).toEqual({ + localePolicy: 'content_only', + currency: 'UAH', + shippingCountry: 'UA', + providerEnablement: 'env_runtime_capability', + intlFlow: 'untouched', + dormantUsd: 'compatibility_only', + schemaCleanup: 'deferred', + }); + }); + + it('keeps current locale-based storefront currency compatibility unchanged', () => { + expect(resolveCurrentStandardStorefrontCurrencyFromLocale('uk')).toBe( + 'UAH' + ); + expect(resolveCurrentStandardStorefrontCurrencyFromLocale('uk-UA')).toBe( + 'UAH' + ); + expect(resolveCurrentStandardStorefrontCurrencyFromLocale('en')).toBe( + 'USD' + ); + expect(resolveCurrentStandardStorefrontCurrencyFromLocale('pl-PL')).toBe( + 'USD' + ); + expect(resolveCurrentStandardStorefrontCurrencyFromLocale(null)).toBe( + 'USD' + ); + }); + + it('keeps current locale-based shipping country compatibility unchanged', () => { + expect( + resolveCurrentStandardStorefrontShippingCountryFromLocale('uk') + ).toBe('UA'); + expect( + resolveCurrentStandardStorefrontShippingCountryFromLocale('uk-UA') + ).toBe('UA'); + expect( + resolveCurrentStandardStorefrontShippingCountryFromLocale('en') + ).toBe(null); + expect( + resolveCurrentStandardStorefrontShippingCountryFromLocale(null) + ).toBe(null); + }); + + it('keeps current checkout provider candidate compatibility unchanged', () => { + expect(inferCurrentCheckoutProviderFromMethod('stripe_card')).toBe( + 'stripe' + ); + expect(inferCurrentCheckoutProviderFromMethod('monobank_invoice')).toBe( + 'monobank' + ); + expect( + resolveCurrentCheckoutProviderCandidates({ + currency: 'UAH', + }) + ).toEqual(['monobank', 'stripe']); + expect( + resolveCurrentCheckoutProviderCandidates({ + currency: 'USD', + }) + ).toEqual(['stripe']); + expect( + resolveCurrentCheckoutProviderCandidates({ + requestedMethod: 'stripe_card', + currency: 'UAH', + }) + ).toEqual(['stripe']); + }); +}); From c3ec2c913d7b116a063c23b9d7b4f3de3d5f5cd4 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 1 Apr 2026 08:57:23 -0700 Subject: [PATCH 03/11] (SP: 3) [SHOP] switch public storefront read paths to standard storefront policy --- .../app/[locale]/shop/cart/CartPageClient.tsx | 47 ++------ .../app/[locale]/shop/cart/provider-policy.ts | 33 ++++++ frontend/app/api/shop/cart/rehydrate/route.ts | 4 +- .../app/api/shop/shipping/methods/route.ts | 11 +- .../app/api/shop/shipping/np/cities/route.ts | 9 +- .../api/shop/shipping/np/warehouses/route.ts | 9 +- frontend/lib/shop/commercial-policy.ts | 8 ++ frontend/lib/shop/data.ts | 11 +- .../shop/cart-public-provider-policy.test.ts | 27 +++++ .../shop/cart-rehydrate-route-policy.test.ts | 54 +++++++++ .../catalog-merchandising-cleanup.test.ts | 2 +- .../shop/public-product-visibility.test.ts | 53 +++++++++ .../public-storefront-read-policy.test.ts | 106 ++++++++++++++++++ .../shop/shipping-methods-route-p2.test.ts | 24 ++++ .../shop/shipping-np-cities-route-p2.test.ts | 32 ++++++ .../shipping-np-warehouses-route-p2.test.ts | 47 ++++++++ 16 files changed, 424 insertions(+), 53 deletions(-) create mode 100644 frontend/app/[locale]/shop/cart/provider-policy.ts create mode 100644 frontend/lib/tests/shop/cart-public-provider-policy.test.ts create mode 100644 frontend/lib/tests/shop/cart-rehydrate-route-policy.test.ts create mode 100644 frontend/lib/tests/shop/public-storefront-read-policy.test.ts diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 2d6d03f9..af999326 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -14,9 +14,9 @@ import { type CheckoutDeliveryMethodCode, type ShippingAvailabilityReasonCode, } from '@/lib/services/shop/shipping/checkout-payload'; +import { resolveStandardStorefrontShippingCountry } from '@/lib/shop/commercial-policy'; import { formatMoney } from '@/lib/shop/currency'; import { generateIdempotencyKey } from '@/lib/shop/idempotency'; -import { localeToCountry } from '@/lib/shop/locale'; import { SHOP_CART_CARD, SHOP_CART_FIELD, @@ -42,6 +42,11 @@ import { } from '@/lib/shop/ui-classes'; import { cn } from '@/lib/utils'; +import { + resolveDefaultMethodForProvider, + resolveInitialProvider, +} from './provider-policy'; + type Props = { stripeEnabled: boolean; monobankEnabled: boolean; @@ -81,31 +86,6 @@ type ShippingWarehouse = { isPostMachine: boolean; }; -function resolveInitialProvider(args: { - stripeEnabled: boolean; - monobankEnabled: boolean; - currency: string | null | undefined; -}): CheckoutProvider { - const isUah = args.currency === 'UAH'; - const canUseStripe = args.stripeEnabled; - const canUseMonobank = args.monobankEnabled && isUah; - - if (canUseMonobank) return 'monobank'; - if (canUseStripe) return 'stripe'; - return 'stripe'; -} - -function resolveDefaultMethodForProvider(args: { - provider: CheckoutProvider; - currency: string | null | undefined; -}): CheckoutPaymentMethod | null { - if (args.provider === 'stripe') return 'stripe_card'; - if (args.provider === 'monobank') { - return args.currency === 'UAH' ? 'monobank_invoice' : null; - } - return null; -} - function normalizeLookupValue(value: string): string { return value.trim().toLocaleLowerCase(); } @@ -502,12 +482,11 @@ export default function CartPage({ const params = useParams<{ locale?: string }>(); const locale = params.locale ?? 'en'; const shopBase = '/shop'; - const isUahCheckout = cart.summary.currency === 'UAH'; const canUseStripe = stripeEnabled; - const canUseMonobank = monobankEnabled && isUahCheckout; + const canUseMonobank = monobankEnabled; const canUseMonobankGooglePay = canUseMonobank && monobankGooglePayEnabled; const hasSelectableProvider = canUseStripe || canUseMonobank; - const country = localeToCountry(locale); + const country = resolveStandardStorefrontShippingCountry(); const shippingUnavailableHardBlock = shippingReasonCode === 'SHOP_SHIPPING_DISABLED' || @@ -1121,11 +1100,7 @@ export default function CartPage({ } if (selectedProvider === 'monobank' && !canUseMonobank) { - setCheckoutError( - monobankEnabled - ? t('checkout.paymentMethod.monobankUahOnlyHint') - : t('checkout.paymentMethod.monobankUnavailable') - ); + setCheckoutError(t('checkout.paymentMethod.monobankUnavailable')); return; } @@ -2238,9 +2213,7 @@ export default function CartPage({ {!canUseMonobank ? (

- {monobankEnabled - ? t('checkout.paymentMethod.monobankUahOnlyHint') - : t('checkout.paymentMethod.monobankUnavailable')} + {t('checkout.paymentMethod.monobankUnavailable')}

) : null} diff --git a/frontend/app/[locale]/shop/cart/provider-policy.ts b/frontend/app/[locale]/shop/cart/provider-policy.ts new file mode 100644 index 00000000..e32b562b --- /dev/null +++ b/frontend/app/[locale]/shop/cart/provider-policy.ts @@ -0,0 +1,33 @@ +type CheckoutProvider = 'stripe' | 'monobank'; +type CheckoutPaymentMethod = + | 'stripe_card' + | 'monobank_invoice' + | 'monobank_google_pay'; + +export function resolveInitialProvider(args: { + stripeEnabled: boolean; + monobankEnabled: boolean; + currency: string | null | undefined; +}): CheckoutProvider { + void args.currency; + + const canUseStripe = args.stripeEnabled; + const canUseMonobank = args.monobankEnabled; + + if (canUseMonobank) return 'monobank'; + if (canUseStripe) return 'stripe'; + return 'stripe'; +} + +export function resolveDefaultMethodForProvider(args: { + provider: CheckoutProvider; + currency: string | null | undefined; +}): CheckoutPaymentMethod | null { + void args.currency; + + if (args.provider === 'stripe') return 'stripe_card'; + if (args.provider === 'monobank') { + return 'monobank_invoice'; + } + return null; +} diff --git a/frontend/app/api/shop/cart/rehydrate/route.ts b/frontend/app/api/shop/cart/rehydrate/route.ts index d6ce9478..3b2a683c 100644 --- a/frontend/app/api/shop/cart/rehydrate/route.ts +++ b/frontend/app/api/shop/cart/rehydrate/route.ts @@ -6,7 +6,7 @@ import { MoneyValueError } from '@/db/queries/shop/orders'; import { logError, logInfo, logWarn } from '@/lib/logging'; import { InvalidPayloadError, PriceConfigError } from '@/lib/services/errors'; import { rehydrateCartItems } from '@/lib/services/products'; -import { resolveLocaleAndCurrency } from '@/lib/shop/request-locale'; +import { resolveStandardStorefrontCurrency } from '@/lib/shop/commercial-policy'; import { cartRehydratePayloadSchema } from '@/lib/validation/shop'; function normalizeCartPayload(body: unknown) { @@ -59,7 +59,7 @@ export async function POST(request: NextRequest) { route: request.nextUrl.pathname, method: request.method, }; - const { currency } = resolveLocaleAndCurrency(request); + const currency = resolveStandardStorefrontCurrency(); const meta = { ...baseMeta, diff --git a/frontend/app/api/shop/shipping/methods/route.ts b/frontend/app/api/shop/shipping/methods/route.ts index dcce7f0d..d96704d9 100644 --- a/frontend/app/api/shop/shipping/methods/route.ts +++ b/frontend/app/api/shop/shipping/methods/route.ts @@ -23,7 +23,10 @@ import { sanitizeShippingErrorForLog, sanitizeShippingLogMeta, } from '@/lib/services/shop/shipping/log-sanitizer'; -import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; +import { + resolveStandardStorefrontCurrency, + resolveStandardStorefrontShippingCountry, +} from '@/lib/shop/commercial-policy'; import { resolveRequestLocale } from '@/lib/shop/request-locale'; import { shippingMethodsQuerySchema } from '@/lib/validation/shop-shipping'; @@ -172,13 +175,15 @@ export async function GET(request: NextRequest) { try { const locale = parsed.data.locale ?? resolveRequestLocale(request); - const currency = parsed.data.currency ?? resolveCurrencyFromLocale(locale); + const currency = + parsed.data.currency ?? resolveStandardStorefrontCurrency(); const flags = getShopShippingFlags(); const availability = resolveShippingAvailability({ shippingEnabled: flags.shippingEnabled, npEnabled: flags.npEnabled, locale, - country: parsed.data.country ?? null, + country: + parsed.data.country ?? resolveStandardStorefrontShippingCountry(), currency, }); diff --git a/frontend/app/api/shop/shipping/np/cities/route.ts b/frontend/app/api/shop/shipping/np/cities/route.ts index 36d9259a..d9b55b19 100644 --- a/frontend/app/api/shop/shipping/np/cities/route.ts +++ b/frontend/app/api/shop/shipping/np/cities/route.ts @@ -20,7 +20,10 @@ import { } from '@/lib/services/shop/shipping/log-sanitizer'; import { findCitiesWithCacheOnMiss } from '@/lib/services/shop/shipping/nova-poshta-catalog'; import { NovaPoshtaApiError } from '@/lib/services/shop/shipping/nova-poshta-client'; -import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; +import { + resolveStandardStorefrontCurrency, + resolveStandardStorefrontShippingCountry, +} from '@/lib/shop/commercial-policy'; import { resolveRequestLocale } from '@/lib/shop/request-locale'; import { shippingCitiesQuerySchema } from '@/lib/validation/shop-shipping'; @@ -92,13 +95,13 @@ export async function GET(request: NextRequest) { } const locale = parsed.data.locale ?? resolveRequestLocale(request); - const currency = parsed.data.currency ?? resolveCurrencyFromLocale(locale); + const currency = parsed.data.currency ?? resolveStandardStorefrontCurrency(); const flags = getShopShippingFlags(); const availability = resolveShippingAvailability({ shippingEnabled: flags.shippingEnabled, npEnabled: flags.npEnabled, locale, - country: parsed.data.country ?? null, + country: parsed.data.country ?? resolveStandardStorefrontShippingCountry(), currency, }); diff --git a/frontend/app/api/shop/shipping/np/warehouses/route.ts b/frontend/app/api/shop/shipping/np/warehouses/route.ts index 38f74753..1ec7f273 100644 --- a/frontend/app/api/shop/shipping/np/warehouses/route.ts +++ b/frontend/app/api/shop/shipping/np/warehouses/route.ts @@ -20,7 +20,10 @@ import { } from '@/lib/services/shop/shipping/log-sanitizer'; import { findWarehousesWithCacheOnMiss } from '@/lib/services/shop/shipping/nova-poshta-catalog'; import { NovaPoshtaApiError } from '@/lib/services/shop/shipping/nova-poshta-client'; -import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; +import { + resolveStandardStorefrontCurrency, + resolveStandardStorefrontShippingCountry, +} from '@/lib/shop/commercial-policy'; import { resolveRequestLocale } from '@/lib/shop/request-locale'; import { shippingWarehousesQuerySchema } from '@/lib/validation/shop-shipping'; @@ -95,13 +98,13 @@ export async function GET(request: NextRequest) { } const locale = parsed.data.locale ?? resolveRequestLocale(request); - const currency = parsed.data.currency ?? resolveCurrencyFromLocale(locale); + const currency = parsed.data.currency ?? resolveStandardStorefrontCurrency(); const flags = getShopShippingFlags(); const availability = resolveShippingAvailability({ shippingEnabled: flags.shippingEnabled, npEnabled: flags.npEnabled, locale, - country: parsed.data.country ?? null, + country: parsed.data.country ?? resolveStandardStorefrontShippingCountry(), currency, }); diff --git a/frontend/lib/shop/commercial-policy.ts b/frontend/lib/shop/commercial-policy.ts index b12a9874..bfd72523 100644 --- a/frontend/lib/shop/commercial-policy.ts +++ b/frontend/lib/shop/commercial-policy.ts @@ -17,6 +17,14 @@ export const STANDARD_STOREFRONT_COMMERCIAL_POLICY = { schemaCleanup: 'deferred', } as const; +export function resolveStandardStorefrontCurrency(): StandardStorefrontCurrency { + return STANDARD_STOREFRONT_COMMERCIAL_POLICY.currency; +} + +export function resolveStandardStorefrontShippingCountry(): StandardStorefrontShippingCountry { + return STANDARD_STOREFRONT_COMMERCIAL_POLICY.shippingCountry; +} + function normalizeLocaleTag(locale: string | null | undefined): string { const raw = (locale ?? '').trim().toLowerCase(); if (!raw) return ''; diff --git a/frontend/lib/shop/data.ts b/frontend/lib/shop/data.ts index c3fa742a..f992b1f5 100644 --- a/frontend/lib/shop/data.ts +++ b/frontend/lib/shop/data.ts @@ -24,7 +24,7 @@ import { shopProductSchema, } from '@/lib/validation/shop'; -import { resolveCurrencyFromLocale } from './currency'; +import { resolveStandardStorefrontCurrency } from './commercial-policy'; import { fromDbMoney } from './money'; export type ShopProduct = ValidationShopProduct; @@ -87,7 +87,8 @@ export async function getProductPageData( slug: string, locale: string = 'en' ): Promise { - const currency = resolveCurrencyFromLocale(locale); + void locale; + const currency = resolveStandardStorefrontCurrency(); const dbProduct = await getPublicProductBySlug(slug, currency); if (dbProduct) { @@ -368,7 +369,8 @@ export async function getCatalogProducts( const { category, type, color, size, sort, page, limit } = validateCatalogFilters(filters); - const currency = resolveCurrencyFromLocale(locale); + void locale; + const currency = resolveStandardStorefrontCurrency(); const { items, total } = await getActiveProductsPage({ currency, @@ -395,7 +397,8 @@ export async function getProductDetail( locale: string = 'en' ): Promise { try { - const currency = resolveCurrencyFromLocale(locale); + void locale; + const currency = resolveStandardStorefrontCurrency(); const dbProduct = await getPublicProductBySlug(slug, currency); diff --git a/frontend/lib/tests/shop/cart-public-provider-policy.test.ts b/frontend/lib/tests/shop/cart-public-provider-policy.test.ts new file mode 100644 index 00000000..ea17b5e8 --- /dev/null +++ b/frontend/lib/tests/shop/cart-public-provider-policy.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveDefaultMethodForProvider, + resolveInitialProvider, +} from '@/app/[locale]/shop/cart/provider-policy'; + +describe('cart public provider policy', () => { + it('does not gate initial Monobank selection on cart currency', () => { + expect( + resolveInitialProvider({ + stripeEnabled: false, + monobankEnabled: true, + currency: 'USD', + }) + ).toBe('monobank'); + }); + + it('keeps Monobank payment method selection available even before checkout enforcement switches', () => { + expect( + resolveDefaultMethodForProvider({ + provider: 'monobank', + currency: 'USD', + }) + ).toBe('monobank_invoice'); + }); +}); diff --git a/frontend/lib/tests/shop/cart-rehydrate-route-policy.test.ts b/frontend/lib/tests/shop/cart-rehydrate-route-policy.test.ts new file mode 100644 index 00000000..b77b6030 --- /dev/null +++ b/frontend/lib/tests/shop/cart-rehydrate-route-policy.test.ts @@ -0,0 +1,54 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const rehydrateCartItemsMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/services/products', () => ({ + rehydrateCartItems: (...args: unknown[]) => rehydrateCartItemsMock(...args), +})); + +vi.mock('@/lib/logging', () => ({ + logWarn: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), +})); + +const { POST } = await import('@/app/api/shop/cart/rehydrate/route'); + +describe('cart rehydrate route public policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + rehydrateCartItemsMock.mockResolvedValue({ + items: [], + summary: { + quantity: 0, + totalAmountMinor: 0, + currency: 'UAH', + pricingFingerprint: 'f'.repeat(64), + }, + }); + }); + + it('rehydrates carts in the standard storefront UAH currency even on en locale requests', async () => { + const request = new NextRequest( + 'http://localhost/api/shop/cart/rehydrate', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + }, + body: JSON.stringify({ + items: [], + }), + } + ); + + const response = await POST(request); + const json: any = await response.json(); + + expect(response.status).toBe(200); + expect(rehydrateCartItemsMock).toHaveBeenCalledWith([], 'UAH'); + expect(json.summary.currency).toBe('UAH'); + }); +}); diff --git a/frontend/lib/tests/shop/catalog-merchandising-cleanup.test.ts b/frontend/lib/tests/shop/catalog-merchandising-cleanup.test.ts index 3a54bc21..d6ca255b 100644 --- a/frontend/lib/tests/shop/catalog-merchandising-cleanup.test.ts +++ b/frontend/lib/tests/shop/catalog-merchandising-cleanup.test.ts @@ -138,7 +138,7 @@ describe('catalog merchandising cleanup', () => { const content = await getHomepageContent('en'); expect(shopQueryMocks.getActiveProductsPage).toHaveBeenCalledWith({ - currency: 'USD', + currency: 'UAH', limit: 12, offset: 0, category: undefined, diff --git a/frontend/lib/tests/shop/public-product-visibility.test.ts b/frontend/lib/tests/shop/public-product-visibility.test.ts index 6399b2d6..2c79804f 100644 --- a/frontend/lib/tests/shop/public-product-visibility.test.ts +++ b/frontend/lib/tests/shop/public-product-visibility.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'; import { db } from '@/db'; import { getPublicProductBySlug } from '@/db/queries/shop/products'; import { productPrices, products } from '@/db/schema'; +import { getProductPageData } from '@/lib/shop/data'; function logTestCleanupFailed(meta: Record, error: unknown) { console.error('[test cleanup failed]', { @@ -123,4 +124,56 @@ describe('P0-5 Public products: inactive not visible', () => { await cleanup(productId); } }); + + it('keeps PDP product visibility on non-uk locales when only the UAH price row exists', async () => { + const productId = randomUUID(); + const slug = `uah-only-${randomUUID()}`; + + try { + await db.insert(products).values({ + id: productId, + slug, + title: 'UAH-only product', + description: null, + imageUrl: 'https://placehold.co/600x600', + imagePublicId: null, + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 5, + sku: null, + + price: '10.00', + originalPrice: null, + currency: 'USD', + }); + + await db.insert(productPrices).values({ + id: randomUUID(), + productId, + currency: 'UAH', + priceMinor: 4400, + originalPriceMinor: null, + price: '44.00', + originalPrice: null, + }); + + const result = await getProductPageData(slug, 'en'); + + expect(result.kind).toBe('available'); + if (result.kind !== 'available') { + throw new Error('Expected PDP data to stay available for UAH-only row'); + } + + expect(result.commerceProduct.slug).toBe(slug); + expect(result.commerceProduct.currency).toBe('UAH'); + expect(result.commerceProduct.price).toBe(4400); + } finally { + await cleanup(productId); + } + }); }); diff --git a/frontend/lib/tests/shop/public-storefront-read-policy.test.ts b/frontend/lib/tests/shop/public-storefront-read-policy.test.ts new file mode 100644 index 00000000..56db367e --- /dev/null +++ b/frontend/lib/tests/shop/public-storefront-read-policy.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const shopQueryMocks = vi.hoisted(() => ({ + getPublicProductBySlug: vi.fn(), + getPublicProductBaseBySlug: vi.fn(), + getActiveProductsPage: vi.fn(), +})); + +vi.mock('@/db/queries/shop/products', () => shopQueryMocks); + +import { getCatalogProducts, getProductPageData } from '@/lib/shop/data'; + +function makeDbProduct(overrides?: Record) { + const createdAt = new Date('2026-03-01T00:00:00.000Z'); + + return { + id: 'product-1', + slug: 'product-1', + title: 'Product 1', + description: null, + imageUrl: '/placeholder.svg', + imagePublicId: null, + price: '44.00', + originalPrice: null, + currency: 'UAH' as const, + isActive: true, + isFeatured: false, + stock: 5, + sku: null, + category: 'apparel' as const, + type: 'shirts' as const, + colors: ['black'] as const, + sizes: ['M'] as const, + badge: 'NONE' as const, + images: [], + primaryImage: undefined, + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +describe('public storefront read policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the standard storefront UAH currency for catalog reads on every locale', async () => { + shopQueryMocks.getActiveProductsPage.mockResolvedValue({ + items: [makeDbProduct()], + total: 1, + }); + + for (const locale of ['uk', 'en', 'pl']) { + await getCatalogProducts( + { category: 'all', sort: 'newest', page: 1, limit: 24 }, + locale + ); + } + + expect(shopQueryMocks.getActiveProductsPage).toHaveBeenNthCalledWith(1, { + currency: 'UAH', + limit: 24, + offset: 0, + category: undefined, + type: undefined, + color: undefined, + size: undefined, + sort: 'newest', + }); + expect(shopQueryMocks.getActiveProductsPage).toHaveBeenNthCalledWith(2, { + currency: 'UAH', + limit: 24, + offset: 0, + category: undefined, + type: undefined, + color: undefined, + size: undefined, + sort: 'newest', + }); + expect(shopQueryMocks.getActiveProductsPage).toHaveBeenNthCalledWith(3, { + currency: 'UAH', + limit: 24, + offset: 0, + category: undefined, + type: undefined, + color: undefined, + size: undefined, + sort: 'newest', + }); + }); + + it('uses the standard storefront UAH currency for PDP reads on non-uk locales', async () => { + shopQueryMocks.getPublicProductBySlug.mockResolvedValueOnce( + makeDbProduct({ slug: 'policy-product' }) + ); + + const result = await getProductPageData('policy-product', 'en'); + + expect(shopQueryMocks.getPublicProductBySlug).toHaveBeenCalledWith( + 'policy-product', + 'UAH' + ); + expect(result.kind).toBe('available'); + }); +}); diff --git a/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts b/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts index 54342d9b..000007fb 100644 --- a/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts +++ b/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts @@ -132,6 +132,30 @@ describe('shop shipping methods route (phase 2)', () => { } }); + it('uses the standard storefront UA + UAH policy for en locale requests when query policy fields are omitted', async () => { + vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', '500'); + vi.stubEnv('SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR', '400'); + vi.stubEnv('SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR', '700'); + + const req = new NextRequest( + 'http://localhost/api/shop/shipping/methods?locale=en' + ); + const res = await GET(req); + const json: any = await res.json(); + + expect(res.status).toBe(200); + expect(json).toMatchObject({ + success: true, + available: true, + reasonCode: 'OK', + country: 'UA', + currency: 'UAH', + }); + expect(json.methods).toHaveLength(3); + }); + it('fails closed with NP_MISCONFIG in production-like runtime when NP config is placeholder', async () => { vi.stubEnv('APP_ENV', 'production'); vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); diff --git a/frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts b/frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts index 1b17bd2d..bb145dd7 100644 --- a/frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts +++ b/frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts @@ -132,6 +132,38 @@ describe('shop shipping np cities route (phase 2)', () => { } }); + it('allows standard storefront city lookup on en locale without explicit currency or country params', async () => { + const cityRef = crypto.randomUUID(); + await db.insert(npCities).values({ + ref: cityRef, + nameUa: 'Kyiv Policy Local', + nameRu: null, + area: 'Kyivska', + region: 'Kyiv', + settlementType: 'City', + isActive: true, + }); + + try { + const req = new NextRequest( + 'http://localhost/api/shop/shipping/np/cities?q=kyiv&locale=en' + ); + const res = await GET(req); + const json: any = await res.json(); + + expect(res.status).toBe(200); + expect(json).toMatchObject({ + success: true, + available: true, + reasonCode: 'OK', + }); + expect(json.items.some((it: any) => it.ref === cityRef)).toBe(true); + expect(searchSettlementsMock).toHaveBeenCalledTimes(0); + } finally { + await db.delete(npCities).where(eq(npCities.ref, cityRef)); + } + }); + it('NP down returns 200 + available=false NP_UNAVAILABLE with empty items', async () => { searchSettlementsMock.mockRejectedValue( new NovaPoshtaApiError('NP_HTTP_ERROR', 'temporary', 503) diff --git a/frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts b/frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts index d16f18bd..0c802a2e 100644 --- a/frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts +++ b/frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts @@ -129,6 +129,53 @@ describe('shop shipping np warehouses route (phase 2)', () => { } }); + it('allows standard storefront warehouse lookup on en locale without explicit currency or country params', async () => { + const cityRef = crypto.randomUUID(); + const warehouseRef = crypto.randomUUID(); + + await db.insert(npCities).values({ + ref: cityRef, + nameUa: 'Kyiv Policy Local', + nameRu: null, + area: 'Kyivska', + region: 'Kyiv', + settlementType: 'City', + isActive: true, + }); + + await db.insert(npWarehouses).values({ + ref: warehouseRef, + settlementRef: cityRef, + cityRef, + number: '15', + type: 'Warehouse', + name: 'Policy Warehouse 15', + address: 'Kyiv, Policy 15', + isPostMachine: false, + isActive: true, + }); + + try { + const req = new NextRequest( + `http://localhost/api/shop/shipping/np/warehouses?cityRef=${cityRef}&q=policy&locale=en` + ); + const res = await GET(req); + const json: any = await res.json(); + + expect(res.status).toBe(200); + expect(json).toMatchObject({ + success: true, + available: true, + reasonCode: 'OK', + }); + expect(json.items.some((it: any) => it.ref === warehouseRef)).toBe(true); + expect(getWarehousesByCityRefMock).toHaveBeenCalledTimes(0); + } finally { + await db.delete(npWarehouses).where(eq(npWarehouses.ref, warehouseRef)); + await db.delete(npCities).where(eq(npCities.ref, cityRef)); + } + }); + it('NP down returns 200 + available=false NP_UNAVAILABLE with empty items', async () => { const cityRef = crypto.randomUUID(); getWarehousesByCityRefMock.mockRejectedValue( From 5fc8d020064a6530960607a0b47e0f749509900b Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 1 Apr 2026 09:27:49 -0700 Subject: [PATCH 04/11] (SP: 3) [SHOP] align checkout enforcement with standard storefront commercial policy --- .../app/[locale]/shop/checkout/error/page.tsx | 5 +- .../checkout/payment/StripePaymentClient.tsx | 22 ++----- frontend/app/api/shop/checkout/route.ts | 59 ++++++++----------- frontend/lib/services/orders/checkout.ts | 21 +++---- .../lib/shop/checkout-display-currency.ts | 13 ++++ frontend/lib/shop/commercial-policy.ts | 15 +++++ .../shop/checkout-currency-policy.test.ts | 42 +++++++++++-- .../shop/checkout-display-currency.test.ts | 16 +++++ .../shop/checkout-monobank-happy-path.test.ts | 31 ++++++---- ...checkout-monobank-parse-validation.test.ts | 51 +++++++++++++++- .../tests/shop/checkout-no-payments.test.ts | 2 + .../checkout-stripe-payments-disabled.test.ts | 29 +++++++-- .../lib/tests/shop/commercial-policy.test.ts | 18 ++++++ 13 files changed, 232 insertions(+), 92 deletions(-) create mode 100644 frontend/lib/shop/checkout-display-currency.ts create mode 100644 frontend/lib/tests/shop/checkout-display-currency.test.ts diff --git a/frontend/app/[locale]/shop/checkout/error/page.tsx b/frontend/app/[locale]/shop/checkout/error/page.tsx index 2b06c97a..5c450278 100644 --- a/frontend/app/[locale]/shop/checkout/error/page.tsx +++ b/frontend/app/[locale]/shop/checkout/error/page.tsx @@ -4,7 +4,8 @@ import { getTranslations } from 'next-intl/server'; import { Link } from '@/i18n/routing'; import { OrderNotFoundError } from '@/lib/services/errors'; import { getOrderSummary } from '@/lib/services/orders'; -import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency'; +import { resolveCheckoutDisplayCurrency } from '@/lib/shop/checkout-display-currency'; +import { formatMoney } from '@/lib/shop/currency'; import { SHOP_CTA_BASE, SHOP_CTA_INSET, @@ -223,7 +224,7 @@ export default async function CheckoutErrorPage({ ? (order as any).totalAmountMinor : null; - const currency = (order as any).currency ?? resolveCurrencyFromLocale(locale); + const currency = resolveCheckoutDisplayCurrency((order as any).currency); return (
toCurrencyCode(currency, locale), - [currency, locale] + () => resolveCheckoutDisplayCurrency(currency), + [currency] ); const stripePromise = useMemo(() => { diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index eaa799b1..2253d869 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -4,9 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { MoneyValueError } from '@/db/queries/shop/orders'; import { getCurrentUser } from '@/lib/auth'; -import { isMonobankEnabled } from '@/lib/env/monobank'; import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; -import { isPaymentsEnabled as isStripePaymentsEnabled } from '@/lib/env/stripe'; import { logError, logInfo, logWarn } from '@/lib/logging'; import { MONO_MISMATCH, monoLogWarn } from '@/lib/logging/monobank'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; @@ -33,7 +31,11 @@ import { ensureStripePaymentIntentForOrder, PaymentAttemptsExhaustedError, } from '@/lib/services/orders/payment-attempts'; -import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; +import { + resolveStandardStorefrontCheckoutProviderCandidates, + resolveStandardStorefrontCurrency, +} from '@/lib/shop/commercial-policy'; +import { resolveStandardStorefrontProviderCapabilities } from '@/lib/shop/commercial-policy.server'; import { isMethodAllowed, type PaymentMethod, @@ -41,7 +43,6 @@ import { paymentProviderValues, type PaymentStatus, paymentStatusValues, - resolveCheckoutProviderCandidates, resolveDefaultMethodForProvider, } from '@/lib/shop/payments'; import { resolveRequestLocale } from '@/lib/shop/request-locale'; @@ -177,13 +178,6 @@ function isMonoAlias(raw: unknown): boolean { 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; @@ -880,27 +874,18 @@ export async function POST(request: NextRequest) { payloadForValidation = rest; } - const localeCurrency = resolveCurrencyFromLocale(locale); - const paymentsEnabled = - (process.env.PAYMENTS_ENABLED ?? '').trim() === 'true'; + const storefrontCurrency = resolveStandardStorefrontCurrency(); + const providerCapabilities = + resolveStandardStorefrontProviderCapabilities(); + const stripeCheckoutAvailable = + providerCapabilities.stripeCheckoutEnabled; + const monobankCheckoutAvailable = + providerCapabilities.monobankCheckoutEnabled; - const stripeCheckoutAvailable = isStripePaymentsEnabled({ - requirePublishableKey: true, - }); - let monobankCheckoutAvailable = false; - try { - monobankCheckoutAvailable = paymentsEnabled && isMonobankEnabled(); - } catch (error) { - logError('monobank_env_invalid', error, { - ...baseMeta, - code: 'MONOBANK_ENV_INVALID', - }); - } - - const checkoutProviderCandidates = resolveCheckoutProviderCandidates({ + const checkoutProviderCandidates = + resolveStandardStorefrontCheckoutProviderCandidates({ requestedProvider, requestedMethod, - currency: localeCurrency, }); const selectedProvider = checkoutProviderCandidates.find(candidate => @@ -911,8 +896,7 @@ export async function POST(request: NextRequest) { const fallbackProvider = selectedProvider ?? checkoutProviderCandidates[0] ?? null; - const selectedCurrency = - fallbackProvider === 'monobank' ? 'UAH' : localeCurrency; + const selectedCurrency = storefrontCurrency; const selectedMethod = requestedMethod ?? (fallbackProvider @@ -934,7 +918,7 @@ export async function POST(request: NextRequest) { code: 'PAYMENTS_DISABLED', requestedProvider, requestedMethod, - localeCurrency, + storefrontCurrency, candidates: checkoutProviderCandidates, stripeCheckoutAvailable, monobankCheckoutAvailable, @@ -953,7 +937,7 @@ export async function POST(request: NextRequest) { code: 'PAYMENTS_DISABLED', requestedProvider, requestedMethod, - localeCurrency, + storefrontCurrency, candidates: checkoutProviderCandidates, stripeCheckoutAvailable, monobankCheckoutAvailable, @@ -973,7 +957,7 @@ export async function POST(request: NextRequest) { code: 'PAYMENTS_DISABLED', requestedProvider, requestedMethod, - localeCurrency, + storefrontCurrency, candidates: checkoutProviderCandidates, stripeCheckoutAvailable, monobankCheckoutAvailable, @@ -988,7 +972,7 @@ export async function POST(request: NextRequest) { if ( selectedMethod === 'monobank_google_pay' && - !isMonobankGooglePayEnabled() + !providerCapabilities.monobankGooglePayEnabled ) { return errorResponse('INVALID_REQUEST', 'Invalid request.', 422); } @@ -998,7 +982,10 @@ export async function POST(request: NextRequest) { provider: resolvedProvider, method: selectedMethod, currency: selectedCurrency, - flags: { monobankGooglePayEnabled: isMonobankGooglePayEnabled() }, + flags: { + monobankGooglePayEnabled: + providerCapabilities.monobankGooglePayEnabled, + }, }) ) { if (resolvedProvider === 'monobank') { diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index 54fbc058..4180a904 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -28,8 +28,11 @@ import { resolveCheckoutShippingQuote, } from '@/lib/services/shop/shipping/checkout-quote'; import { createCheckoutPricingFingerprint } from '@/lib/shop/checkout-pricing'; -import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; -import { localeToCountry } from '@/lib/shop/locale'; +import { + resolveStandardStorefrontCheckoutProviderCandidates, + resolveStandardStorefrontCurrency, + resolveStandardStorefrontShippingCountry, +} from '@/lib/shop/commercial-policy'; import { calculateLineTotal, fromCents, @@ -40,7 +43,6 @@ import { type PaymentMethod, type PaymentProvider, type PaymentStatus, - resolveCheckoutProviderCandidates, resolveDefaultMethodForProvider, } from '@/lib/shop/payments'; import { @@ -356,7 +358,7 @@ async function prepareCheckoutShipping(args: { shippingEnabled: flags.shippingEnabled, npEnabled: flags.npEnabled, locale: args.locale ?? null, - country: args.country ?? localeToCountry(args.locale), + country: args.country ?? resolveStandardStorefrontShippingCountry(), currency: args.currency, }); @@ -603,7 +605,7 @@ function resolveCheckoutLegalConsent(args: { const source = 'checkout_explicit'; const normalizedLocale = normVariant(args.locale).toLowerCase() || null; const normalizedCountry = normalizeCountryCode( - args.country ?? localeToCountry(args.locale) + args.country ?? resolveStandardStorefrontShippingCountry() ); return { @@ -862,19 +864,18 @@ export async function createOrderWithItems({ }); } - const localeCurrency: Currency = resolveCurrencyFromLocale(locale); - const checkoutProviderCandidates = resolveCheckoutProviderCandidates({ + const storefrontCurrency: Currency = resolveStandardStorefrontCurrency(); + const checkoutProviderCandidates = + resolveStandardStorefrontCheckoutProviderCandidates({ requestedProvider: requestedProvider === 'stripe' || requestedProvider === 'monobank' ? requestedProvider : null, requestedMethod, - currency: localeCurrency, }); const paymentProvider: PaymentProvider = checkoutProviderCandidates[0] ?? 'stripe'; - const currency: Currency = - paymentProvider === 'monobank' ? 'UAH' : localeCurrency; + const currency: Currency = storefrontCurrency; const initialPaymentStatus: PaymentStatus = 'pending'; const resolvedPaymentMethod = resolveCheckoutPaymentMethod({ diff --git a/frontend/lib/shop/checkout-display-currency.ts b/frontend/lib/shop/checkout-display-currency.ts new file mode 100644 index 00000000..797f1b92 --- /dev/null +++ b/frontend/lib/shop/checkout-display-currency.ts @@ -0,0 +1,13 @@ +import { type CurrencyCode, currencyValues } from '@/lib/shop/currency'; + +import { resolveStandardStorefrontCurrency } from './commercial-policy'; + +export function resolveCheckoutDisplayCurrency( + value: string | null | undefined +): CurrencyCode { + const normalized = (value ?? '').trim().toUpperCase(); + + return currencyValues.includes(normalized as CurrencyCode) + ? (normalized as CurrencyCode) + : resolveStandardStorefrontCurrency(); +} diff --git a/frontend/lib/shop/commercial-policy.ts b/frontend/lib/shop/commercial-policy.ts index bfd72523..a5bed827 100644 --- a/frontend/lib/shop/commercial-policy.ts +++ b/frontend/lib/shop/commercial-policy.ts @@ -25,6 +25,21 @@ export function resolveStandardStorefrontShippingCountry(): StandardStorefrontSh return STANDARD_STOREFRONT_COMMERCIAL_POLICY.shippingCountry; } +export function resolveStandardStorefrontCheckoutProviderCandidates(args: { + requestedProvider?: CompatibleCheckoutProvider | null; + requestedMethod?: CompatiblePaymentMethod | null; +}): readonly CompatibleCheckoutProvider[] { + const explicitProvider = + args.requestedProvider ?? + inferCurrentCheckoutProviderFromMethod(args.requestedMethod); + + if (explicitProvider) { + return [explicitProvider]; + } + + return ['monobank', 'stripe']; +} + function normalizeLocaleTag(locale: string | null | undefined): string { const raw = (locale ?? '').trim().toLowerCase(); if (!raw) return ''; diff --git a/frontend/lib/tests/shop/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts index 3d8653fc..02083f09 100644 --- a/frontend/lib/tests/shop/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -165,9 +165,7 @@ async function makeCheckoutRequest( : {}; body.legalConsent ??= TEST_LEGAL_CONSENT; const items = Array.isArray(body.items) ? body.items : []; - const currency = opts.acceptLanguage.trim().toLowerCase().startsWith('uk') - ? 'UAH' - : 'USD'; + const currency = 'UAH'; if (items.length > 0) { try { @@ -275,7 +273,7 @@ describe('P0-CUR-3 checkout currency policy', () => { expect(json.order.totalAmount).toBe(100); }); - it('locale en -> order.currency USD and totals correct', async () => { + it('locale en -> order.currency UAH and totals correct', async () => { const slug = `t-en-${crypto.randomUUID()}`; const productId = await seedProduct({ slug, @@ -303,8 +301,40 @@ describe('P0-CUR-3 checkout currency policy', () => { const json = await res.json(); createdOrderIds.push(json.order.id); - expect(json.order.currency).toBe('USD'); - expect(json.order.totalAmount).toBe(67); + expect(json.order.currency).toBe('UAH'); + expect(json.order.totalAmount).toBe(100); + }); + + it('locale pl -> order.currency UAH and totals correct', async () => { + const slug = `t-pl-${crypto.randomUUID()}`; + const productId = await seedProduct({ + slug, + title: 'Test Product PL', + stock: 10, + prices: [ + { currency: 'USD', priceMinor: 6700, price: '67.00' }, + { currency: 'UAH', priceMinor: 10000, price: '100.00' }, + ], + }); + + const req = await makeCheckoutRequest( + { + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + items: [{ productId, quantity: 1 }], + }, + { idempotencyKey: makeIdempotencyKey(), acceptLanguage: 'pl-PL,pl;q=0.9' } + ); + + const res = await POST(req); + await debugIfNotExpected(res, 201); + expect(res.status).toBe(201); + + const json = await res.json(); + createdOrderIds.push(json.order.id); + + expect(json.order.currency).toBe('UAH'); + expect(json.order.totalAmount).toBe(100); }); it('missing price for currency -> 400 PRICE_CONFIG_ERROR', async () => { diff --git a/frontend/lib/tests/shop/checkout-display-currency.test.ts b/frontend/lib/tests/shop/checkout-display-currency.test.ts new file mode 100644 index 00000000..51bc3159 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-display-currency.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveCheckoutDisplayCurrency } from '@/lib/shop/checkout-display-currency'; + +describe('checkout display currency fallback', () => { + it('falls back to standard storefront UAH when checkout display currency is missing or invalid', () => { + expect(resolveCheckoutDisplayCurrency(null)).toBe('UAH'); + expect(resolveCheckoutDisplayCurrency('')).toBe('UAH'); + expect(resolveCheckoutDisplayCurrency('invalid')).toBe('UAH'); + }); + + it('preserves explicit persisted order currency values for compatibility paths', () => { + expect(resolveCheckoutDisplayCurrency('USD')).toBe('USD'); + expect(resolveCheckoutDisplayCurrency('UAH')).toBe('UAH'); + }); +}); diff --git a/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts b/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts index 62b2bf7b..84d78215 100644 --- a/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts @@ -15,8 +15,10 @@ import { db } from '@/db'; import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; import { toDbMoney } from '@/lib/shop/money'; +import { rehydrateCartItems } from '@/lib/services/products'; import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -163,22 +165,25 @@ async function postCheckout(idemKey: string, productId: string) { const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { POST: (req: NextRequest) => Promise; }; + const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'UAH'); const req = new NextRequest('http://localhost/api/shop/checkout', { method: 'POST', - headers: { - 'content-type': 'application/json', - 'accept-language': 'uk-UA', - 'idempotency-key': idemKey, - 'x-request-id': `mono-happy-${idemKey}`, - 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), - origin: 'http://localhost:3000', - }, - body: JSON.stringify({ - items: [{ productId, quantity: 1 }], - paymentProvider: 'monobank', - }), - }); + headers: { + 'content-type': 'application/json', + 'accept-language': 'en-US,en;q=0.9', + 'idempotency-key': idemKey, + 'x-request-id': `mono-happy-${idemKey}`, + 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + items: [{ productId, quantity: 1 }], + paymentProvider: 'monobank', + pricingFingerprint: quote.summary.pricingFingerprint, + legalConsent: TEST_LEGAL_CONSENT, + }), + }); return mod.POST(req); } 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 dddfcf00..2ffd31f0 100644 --- a/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts @@ -25,6 +25,19 @@ vi.mock('@/lib/env/monobank', () => ({ isMonobankEnabled: () => true, })); +vi.mock('@/lib/env/stripe', () => ({ + isPaymentsEnabled: () => true, +})); + +vi.mock('@/lib/services/orders/payment-attempts', () => ({ + ensureStripePaymentIntentForOrder: vi.fn(async (args: { orderId: string }) => ({ + paymentIntentId: `pi_test_${args.orderId}`, + clientSecret: `cs_test_${args.orderId}`, + attemptId: `attempt_${args.orderId}`, + attemptNumber: 1, + })), +})); + vi.mock('@/lib/services/orders', async () => { const actual = await vi.importActual('@/lib/services/orders'); return { @@ -81,6 +94,8 @@ afterAll(() => { beforeEach(() => { vi.clearAllMocks(); process.env.SHOP_MONOBANK_GPAY_ENABLED = 'false'; + (createOrderWithItems as unknown as MockedFn).mockReset(); + createMonobankAttemptAndInvoiceMock.mockReset(); createMonobankAttemptAndInvoiceMock.mockResolvedValue({ attemptId: 'attempt_mono_scope_1', attemptNumber: 1, @@ -94,6 +109,7 @@ beforeEach(() => { function makeMonobankCheckoutReq(params: { idempotencyKey?: string; body: Record; + acceptLanguage?: string; }) { const body = { legalConsent: TEST_LEGAL_CONSENT, @@ -102,7 +118,7 @@ function makeMonobankCheckoutReq(params: { const headers = new Headers({ 'content-type': 'application/json', - 'accept-language': 'uk-UA', + 'accept-language': params.acceptLanguage ?? 'uk-UA', origin: 'http://localhost:3000', }); @@ -204,7 +220,7 @@ describe('checkout monobank parse/validation', () => { expect(Array.isArray(args?.items)).toBe(true); }); - it('omitted provider resolves to the server-preferred monobank rail for UAH checkout', async () => { + it('omitted provider resolves to the server-preferred monobank rail on en locale too', async () => { const createOrderWithItemsMock = createOrderWithItems as unknown as MockedFn; mockCreateOrderSuccess(createOrderWithItemsMock, 'order_stripe_default_1'); @@ -212,6 +228,7 @@ describe('checkout monobank parse/validation', () => { const res = await POST( makeMonobankCheckoutReq({ idempotencyKey: 'stripe_idem_method_0001', + acceptLanguage: 'en-US,en;q=0.9', body: { items: [ { productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }, @@ -226,6 +243,36 @@ describe('checkout monobank parse/validation', () => { expect(args?.paymentMethod).toBe('monobank_invoice'); }); + it('explicit stripe rail remains available on pl locale when enabled', async () => { + const createOrderWithItemsMock = + createOrderWithItems as unknown as MockedFn; + mockCreateOrderSuccess(createOrderWithItemsMock, 'order_stripe_pl_1', { + currency: 'UAH', + paymentProvider: 'stripe', + paymentStatus: 'pending', + paymentIntentId: null, + }); + + const res = await POST( + makeMonobankCheckoutReq({ + idempotencyKey: 'stripe_pl_enabled_0001', + acceptLanguage: 'pl-PL,pl;q=0.9', + body: { + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + items: [ + { productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }, + ], + }, + }) + ); + + expect(res.status).toBe(201); + const args = createOrderWithItemsMock.mock.calls[0]?.[0]; + expect(args?.paymentProvider).toBe('stripe'); + expect(args?.paymentMethod).toBe('stripe_card'); + }); + it('rejects incompatible provider/method pair', async () => { const res = await POST( makeMonobankCheckoutReq({ diff --git a/frontend/lib/tests/shop/checkout-no-payments.test.ts b/frontend/lib/tests/shop/checkout-no-payments.test.ts index baad69b3..a2593c36 100644 --- a/frontend/lib/tests/shop/checkout-no-payments.test.ts +++ b/frontend/lib/tests/shop/checkout-no-payments.test.ts @@ -8,6 +8,7 @@ import { orders, productPrices, products } from '@/db/schema'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; import { getOrSeedActiveTemplateProduct } from '@/lib/tests/helpers/seed-product'; +import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; @@ -176,6 +177,7 @@ async function postCheckout(params: { body: JSON.stringify({ items: params.items, + legalConsent: TEST_LEGAL_CONSENT, ...(params.paymentProvider ? { paymentProvider: params.paymentProvider } : {}), diff --git a/frontend/lib/tests/shop/checkout-stripe-payments-disabled.test.ts b/frontend/lib/tests/shop/checkout-stripe-payments-disabled.test.ts index 263c7a17..24388741 100644 --- a/frontend/lib/tests/shop/checkout-stripe-payments-disabled.test.ts +++ b/frontend/lib/tests/shop/checkout-stripe-payments-disabled.test.ts @@ -22,8 +22,10 @@ import { } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; import { toDbMoney } from '@/lib/shop/money'; +import { rehydrateCartItems } from '@/lib/services/products'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; import { getOrSeedActiveTemplateProduct } from '@/lib/tests/helpers/seed-product'; +import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -203,6 +205,16 @@ async function postCheckout(args: { const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { POST: (req: NextRequest) => Promise; }; + const items = Array.isArray(args.body.items) + ? (args.body.items as Array<{ + productId: string; + quantity: number; + selectedSize?: string; + selectedColor?: string; + }>) + : []; + const quote = + items.length > 0 ? await rehydrateCartItems(items, 'UAH') : null; const req = new NextRequest('http://localhost/api/shop/checkout', { method: 'POST', @@ -214,7 +226,13 @@ async function postCheckout(args: { 'x-forwarded-for': deriveTestIpFromIdemKey(args.idemKey), origin: 'http://localhost:3000', }, - body: JSON.stringify(args.body), + body: JSON.stringify({ + legalConsent: TEST_LEGAL_CONSENT, + ...(quote + ? { pricingFingerprint: quote.summary.pricingFingerprint } + : {}), + ...args.body, + }), }); return mod.POST(req); @@ -363,6 +381,7 @@ describe.sequential('checkout stripe fail-closed + tamper guards', () => { paymentProvider: 'stripe', paymentMethod: 'stripe_card', paymentCurrency: 'UAH', + legalConsent: TEST_LEGAL_CONSENT, items: [{ productId, quantity: 1 }], }, }); @@ -370,8 +389,8 @@ describe.sequential('checkout stripe fail-closed + tamper guards', () => { expect(res.status).toBe(201); const json: any = await res.json(); expect(json?.order?.paymentProvider).toBe('stripe'); - expect(json?.order?.currency).toBe('USD'); - expect(json?.order?.totalAmount).toBe(67); + expect(json?.order?.currency).toBe('UAH'); + expect(json?.order?.totalAmount).toBe(100); expect(typeof json?.clientSecret).toBe('string'); const [row] = await db @@ -387,8 +406,8 @@ describe.sequential('checkout stripe fail-closed + tamper guards', () => { .limit(1); expect(row).toBeTruthy(); - expect(row?.currency).toBe('USD'); - expect(row?.totalAmountMinor).toBe(6700); + expect(row?.currency).toBe('UAH'); + expect(row?.totalAmountMinor).toBe(10000); expect(row?.paymentProvider).toBe('stripe'); expect(row?.paymentStatus).toBe('pending'); createdOrderId = row?.id ?? null; diff --git a/frontend/lib/tests/shop/commercial-policy.test.ts b/frontend/lib/tests/shop/commercial-policy.test.ts index bba95ea8..4f325ee7 100644 --- a/frontend/lib/tests/shop/commercial-policy.test.ts +++ b/frontend/lib/tests/shop/commercial-policy.test.ts @@ -5,6 +5,7 @@ import { resolveCurrentCheckoutProviderCandidates, resolveCurrentStandardStorefrontCurrencyFromLocale, resolveCurrentStandardStorefrontShippingCountryFromLocale, + resolveStandardStorefrontCheckoutProviderCandidates, STANDARD_STOREFRONT_COMMERCIAL_POLICY, } from '@/lib/shop/commercial-policy'; @@ -78,4 +79,21 @@ describe('commercial policy contract', () => { }) ).toEqual(['stripe']); }); + + it('resolves standard storefront checkout provider candidates without locale-derived currency input', () => { + expect(resolveStandardStorefrontCheckoutProviderCandidates({})).toEqual([ + 'monobank', + 'stripe', + ]); + expect( + resolveStandardStorefrontCheckoutProviderCandidates({ + requestedProvider: 'stripe', + }) + ).toEqual(['stripe']); + expect( + resolveStandardStorefrontCheckoutProviderCandidates({ + requestedMethod: 'monobank_invoice', + }) + ).toEqual(['monobank']); + }); }); From 7326f6f2fa5e28e6ba763db459941339cebd00f4 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 1 Apr 2026 09:41:50 -0700 Subject: [PATCH 05/11] (SP: 1) [SHOP] add CP-01 acceptance gate and targeted regression lock --- ...ommercial-policy-batch-cp-01-acceptance.md | 49 +++++++++++++++++++ ...kout-monobank-idempotency-contract.test.ts | 21 ++++++-- .../order-items-snapshot-immutable.test.ts | 12 ++++- 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 frontend/docs/shop/commercial-policy-batch-cp-01-acceptance.md diff --git a/frontend/docs/shop/commercial-policy-batch-cp-01-acceptance.md b/frontend/docs/shop/commercial-policy-batch-cp-01-acceptance.md new file mode 100644 index 00000000..a2b8c3f3 --- /dev/null +++ b/frontend/docs/shop/commercial-policy-batch-cp-01-acceptance.md @@ -0,0 +1,49 @@ +# Batch CP-01 Acceptance Gate + +This note is the merge/release gate for the coupled PR-C + PR-D batch. + +## Scope + +- Standard storefront public read paths +- Standard storefront checkout/write enforcement +- Shipping read paths for the UA storefront +- Snapshot and Monobank regression-sensitive paths + +## Automated Acceptance Targets + +- `uk` / `en` / `pl` public storefront reads resolve `UAH` +- Products with only a `UAH` price row stay visible on non-`uk` locales +- Stripe visibility is capability/env-based, not locale-based +- Monobank visibility is capability/env-based, not locale-based +- Shipping methods and NP lookup read paths work on non-`uk` locales for the UA + storefront +- Standard storefront checkout persists `UAH` on `uk` / `en` / `pl` +- Provider rail selection is locale-agnostic for the standard storefront +- Client currency fields do not control persisted order currency +- Monobank payment init and idempotency remain intact +- Order item snapshots remain structurally stable after CP-01 + +## Manual Smoke Checklist + +Manual browser smoke was not executed in the coding environment. A human release +check should confirm: + +1. On `uk`, `en`, and `pl`, open catalog and PDP pages and confirm displayed + prices are `UAH`. +2. On `uk`, `en`, and `pl`, open the cart with payments enabled and confirm both + Stripe and Monobank options are visible. +3. On `en` or `pl`, open checkout with a shippable cart and confirm Nova Poshta + methods load without needing locale `uk`. +4. On `en` or `pl`, complete a Stripe checkout attempt and confirm the created + order shows `UAH`. +5. On `en` or `pl`, complete a Monobank checkout attempt and confirm the payment + page opens and the order shows `UAH`. +6. Open checkout recovery/error/status pages and confirm displayed money remains + `UAH` for standard storefront orders. + +## Release Rule + +- `intl` remains untouched in Batch CP-01. +- Schema remains untouched in Batch CP-01. +- Dormant `USD` remains a compatibility path only. +- PR-C and PR-D must ship together as one release batch. 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 89a57518..a0ce2e06 100644 --- a/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts +++ b/frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts @@ -14,12 +14,14 @@ import { import { db } from '@/db'; import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; +import { rehydrateCartItems } from '@/lib/services/products'; import { toDbMoney } from '@/lib/shop/money'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; import { cleanupSeededTemplateProduct, getOrSeedActiveTemplateProduct, } from '@/lib/tests/helpers/seed-product'; +import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; vi.mock('@/lib/auth', () => ({ getCurrentUser: vi.fn().mockResolvedValue(null), @@ -177,11 +179,19 @@ type MonobankCheckoutMethod = 'monobank_invoice' | 'monobank_google_pay'; async function postCheckout( idemKey: string, productId: string, - options?: { paymentMethod?: MonobankCheckoutMethod } + options?: { + paymentMethod?: MonobankCheckoutMethod; + includePricingFingerprint?: boolean; + } ) { const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { POST: (req: NextRequest) => Promise; }; + const pricingFingerprint = + options?.includePricingFingerprint === false + ? null + : (await rehydrateCartItems([{ productId, quantity: 1 }], 'UAH')).summary + .pricingFingerprint; const req = new NextRequest('http://localhost/api/shop/checkout', { method: 'POST', @@ -196,7 +206,9 @@ async function postCheckout( body: JSON.stringify({ items: [{ productId, quantity: 1 }], + legalConsent: TEST_LEGAL_CONSENT, paymentProvider: 'monobank', + ...(pricingFingerprint ? { pricingFingerprint } : {}), ...(options?.paymentMethod ? { paymentMethod: options.paymentMethod } : {}), @@ -349,7 +361,8 @@ describe.sequential('checkout monobank contract', () => { }); expect(first.status).toBe(201); const firstJson: any = await first.json(); - orderId = typeof firstJson.orderId === 'string' ? firstJson.orderId : null; + orderId = + typeof firstJson.orderId === 'string' ? firstJson.orderId : null; const second = await postCheckout(idemKey, productId, { paymentMethod: 'monobank_google_pay', @@ -393,7 +406,9 @@ describe.sequential('checkout monobank contract', () => { const idemKey = crypto.randomUUID(); try { - const res = await postCheckout(idemKey, productId); + const res = await postCheckout(idemKey, productId, { + includePricingFingerprint: false, + }); expect(res.status).toBe(400); const json: any = await res.json(); expect(json.code).toBe('PRICE_CONFIG_ERROR'); 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 914d3fa9..f778693f 100644 --- a/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts +++ b/frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts @@ -22,7 +22,9 @@ import { products, } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; +import { rehydrateCartItems } from '@/lib/services/products'; import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; +import { TEST_LEGAL_CONSENT } from '@/lib/tests/shop/test-legal-consent'; vi.mock('@/lib/auth', async () => { resetEnvCache(); @@ -181,7 +183,7 @@ describe('P0-6 snapshots: order_items immutability', () => { await db.insert(productPrices).values({ id: priceId, productId, - currency: 'USD', + currency: 'UAH', priceMinor: 900, originalPriceMinor: null, price: '9.00', @@ -189,12 +191,18 @@ describe('P0-6 snapshots: order_items immutability', () => { }); const idem = randomUUID(); + const quote = await rehydrateCartItems( + [{ productId, quantity: 1 }], + 'UAH' + ); const req = makeJsonRequest( 'http://localhost:3000/api/shop/checkout', { items: [{ productId, quantity: 1 }], + legalConsent: TEST_LEGAL_CONSENT, paymentProvider: 'stripe', paymentMethod: 'stripe_card', + pricingFingerprint: quote.summary.pricingFingerprint, }, { 'Accept-Language': 'en-US,en;q=0.9', @@ -264,7 +272,7 @@ describe('P0-6 snapshots: order_items immutability', () => { .where( and( eq(productPrices.productId, productId), - eq(productPrices.currency, 'USD') + eq(productPrices.currency, 'UAH') ) ); From 5f1f9d1d550914d8301812b225612416eb42ad5f Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 1 Apr 2026 11:40:25 -0700 Subject: [PATCH 06/11] (SP: 3) [SHOP] relax admin pricing contract for UAH-first standard storefront --- .../shop/products/_components/ProductForm.tsx | 107 ++++++++-------- .../lib/services/products/mutations/create.ts | 13 +- .../lib/services/products/mutations/update.ts | 5 +- frontend/lib/services/products/prices.ts | 26 ++-- ...-patch-price-config-error-contract.test.ts | 3 +- ...roduct-admin-create-pricing-policy.test.ts | 120 ++++++++++++++++++ ...product-admin-merged-prices-policy.test.ts | 23 ++-- ...duct-prices-write-authority-phase8.test.ts | 63 +++++++++ frontend/lib/validation/shop.ts | 9 -- 9 files changed, 279 insertions(+), 90 deletions(-) create mode 100644 frontend/lib/tests/shop/product-admin-create-pricing-policy.test.ts diff --git a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx index b01f4721..8a7479d7 100644 --- a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx +++ b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx @@ -580,6 +580,11 @@ export function ProductForm({ p => p.price.length > 0 || p.originalPrice.length > 0 ); + if (effectivePrices.length === 0) { + setError('At least one price is required.'); + return; + } + for (const p of effectivePrices) { if (!p.price.length && p.originalPrice.length) { setError( @@ -589,12 +594,6 @@ export function ProductForm({ } } - const usd = effectivePrices.find(p => p.currency === 'USD'); - if (!usd || !usd.price.length) { - setError('USD price is required.'); - return; - } - let minorPrices: Array<{ currency: CurrencyCode; priceMinor: number; @@ -838,59 +837,63 @@ export function ProductForm({ Prices +

+ UAH is the standard storefront price. USD is optional and kept only + as a compatibility price. +

+
- USD (required) + UAH (standard storefront)
-
-
@@ -898,55 +901,55 @@ export function ProductForm({
- UAH (optional) + USD (compatibility optional)
-
-
@@ -954,9 +957,9 @@ export function ProductForm({

- Checkout currency is server-selected by locale. Prices must exist in{' '} - product_prices for that currency, - or checkout fails. + Standard storefront checkout uses UAH. Prices must exist in{' '} + product_prices for the standard + storefront currency. USD is optional compatibility only.

diff --git a/frontend/lib/services/products/mutations/create.ts b/frontend/lib/services/products/mutations/create.ts index ed7efd77..bc036da5 100644 --- a/frontend/lib/services/products/mutations/create.ts +++ b/frontend/lib/services/products/mutations/create.ts @@ -14,7 +14,7 @@ import { resolvePhotoPlan } from '../photo-plan'; import { enforceSaleBadgeRequiresOriginal, normalizePricesFromInput, - requireUsd, + resolveLegacyCompatPriceMirror, validatePriceRows, } from '../prices'; import { normalizeSlug } from '../slug'; @@ -35,7 +35,7 @@ export async function createProduct(input: ProductInput): Promise { const badge = (input as any).badge ?? 'NONE'; enforceSaleBadgeRequiresOriginal(badge, prices); - const usd = requireUsd(prices); + const legacyMirror = resolveLegacyCompatPriceMirror(prices); const legacyImage = (input as any).image instanceof File && (input as any).image.size > 0 @@ -142,11 +142,14 @@ export async function createProduct(input: ProductInput): Promise { description: (input as any).description ?? null, imageUrl: primaryUpload.secureUrl, imagePublicId: primaryUpload.publicId, - price: toDbMoney(usd.priceMinor), + // Legacy products.* price fields remain schema-constrained to USD. + // When no dormant USD row is supplied, mirror the canonical admin row + // for compatibility only while product_prices stays authoritative. + price: toDbMoney(legacyMirror.priceMinor), originalPrice: - usd.originalPriceMinor == null + legacyMirror.originalPriceMinor == null ? null - : toDbMoney(usd.originalPriceMinor), + : toDbMoney(legacyMirror.originalPriceMinor), currency: 'USD', category: (input as any).category ?? null, diff --git a/frontend/lib/services/products/mutations/update.ts b/frontend/lib/services/products/mutations/update.ts index b10e29c6..b634f29e 100644 --- a/frontend/lib/services/products/mutations/update.ts +++ b/frontend/lib/services/products/mutations/update.ts @@ -116,7 +116,10 @@ export async function updateProduct( const mergedRows = Array.from(merged.values()); if (prices.length) { - assertMergedPricesPolicy(mergedRows, { productId: id, requireUsd: true }); + assertMergedPricesPolicy(mergedRows, { + productId: id, + requireUsd: false, + }); } if (finalBadge === 'SALE') { diff --git a/frontend/lib/services/products/prices.ts b/frontend/lib/services/products/prices.ts index ad0aefc4..cd42b074 100644 --- a/frontend/lib/services/products/prices.ts +++ b/frontend/lib/services/products/prices.ts @@ -76,6 +76,24 @@ export function assertMergedPricesPolicy( } } } + +export function resolveLegacyCompatPriceMirror( + prices: NormalizedPriceRow[] +): NormalizedPriceRow { + const usd = prices.find(p => p.currency === 'USD' && p.priceMinor >= 1); + if (usd) return usd; + + const storefrontFallback = prices.find( + p => p.currency === 'UAH' && p.priceMinor >= 1 + ); + if (storefrontFallback) return storefrontFallback; + + const firstUsable = prices.find(p => p.priceMinor >= 1); + if (firstUsable) return firstUsable; + + throw new InvalidPayloadError('At least one price is required.'); +} + function toMoneyMinor(value: string, field: string): number { const n = assertMoneyString(value, field); return toCents(n); @@ -172,14 +190,6 @@ export function normalizePricesFromInput(input: unknown): NormalizedPriceRow[] { return []; } -export function requireUsd(prices: NormalizedPriceRow[]): NormalizedPriceRow { - const usd = prices.find(p => p.currency === 'USD'); - if (!usd?.priceMinor) { - throw new InvalidPayloadError('USD price is required.'); - } - return usd; -} - export function validatePriceRows(prices: NormalizedPriceRow[]) { const seen = new Set(); for (const p of prices) { diff --git a/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts b/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts index b9a9509e..c9089d68 100644 --- a/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts +++ b/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts @@ -29,9 +29,8 @@ vi.mock('@/lib/admin/parseAdminProductForm', () => ({ vi.mock('@/lib/services/products', () => ({ updateProduct: vi.fn(async () => { - throw new PriceConfigError('USD price is required.', { + throw new PriceConfigError('At least one price is required.', { productId: 'p1', - currency: 'USD', }); }), getAdminProductByIdWithPrices: vi.fn(), diff --git a/frontend/lib/tests/shop/product-admin-create-pricing-policy.test.ts b/frontend/lib/tests/shop/product-admin-create-pricing-policy.test.ts new file mode 100644 index 00000000..14521f23 --- /dev/null +++ b/frontend/lib/tests/shop/product-admin-create-pricing-policy.test.ts @@ -0,0 +1,120 @@ +import { and, eq } from 'drizzle-orm'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { productPrices, products } from '@/db/schema'; +import { createProduct } from '@/lib/services/products'; +import { toDbMoney } from '@/lib/shop/money'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; +import { productAdminSchema } from '@/lib/validation/shop'; + +vi.mock('@/lib/cloudinary', () => ({ + uploadProductImageFromFile: vi.fn(async () => ({ + secureUrl: 'https://example.com/admin-uah-only.png', + publicId: 'products/admin-uah-only', + })), + destroyProductImage: vi.fn(async () => {}), +})); + +function uniqueSlug(prefix = 'admin-create-price-policy') { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +describe.sequential('admin create pricing policy', () => { + const createdProductIds: string[] = []; + + beforeAll(() => { + assertNotProductionDb(); + }); + + afterEach(async () => { + for (const productId of createdProductIds.splice(0)) { + await db + .delete(productPrices) + .where(eq(productPrices.productId, productId)) + .catch(() => undefined); + await db + .delete(products) + .where(eq(products.id, productId)) + .catch(() => undefined); + } + }); + + it('accepts UAH-only pricing in the admin create schema', () => { + const result = productAdminSchema.safeParse({ + title: 'UAH-only schema product', + slug: uniqueSlug('admin-schema-uah-only'), + prices: [{ currency: 'UAH', priceMinor: 5100, originalPriceMinor: null }], + badge: 'NONE', + colors: [], + sizes: [], + stock: 2, + isActive: true, + isFeatured: false, + }); + + expect(result.success).toBe(true); + }); + + it('creates a product from UAH-only admin pricing while keeping the legacy USD mirror explicit', async () => { + const created = await createProduct({ + title: 'UAH-only create product', + slug: uniqueSlug('admin-create-uah-only'), + description: null, + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 4, + prices: [{ currency: 'UAH', priceMinor: 5100, originalPriceMinor: null }], + image: new File([new Uint8Array([1, 2, 3, 4])], 'create-uah-only.png', { + type: 'image/png', + }), + } as any); + + createdProductIds.push(created.id); + + const [legacy] = await db + .select({ + price: products.price, + originalPrice: products.originalPrice, + currency: products.currency, + }) + .from(products) + .where(eq(products.id, created.id)) + .limit(1); + + const [uah] = await db + .select({ + priceMinor: productPrices.priceMinor, + originalPriceMinor: productPrices.originalPriceMinor, + }) + .from(productPrices) + .where( + and( + eq(productPrices.productId, created.id), + eq(productPrices.currency, 'UAH') + ) + ) + .limit(1); + + const [usd] = await db + .select({ + priceMinor: productPrices.priceMinor, + }) + .from(productPrices) + .where( + and( + eq(productPrices.productId, created.id), + eq(productPrices.currency, 'USD') + ) + ) + .limit(1); + + expect(legacy.currency).toBe('USD'); + expect(String(legacy.price)).toBe(String(toDbMoney(5100))); + expect(legacy.originalPrice).toBeNull(); + expect(uah?.priceMinor).toBe(5100); + expect(uah?.originalPriceMinor).toBeNull(); + expect(usd).toBeUndefined(); + }); +}); diff --git a/frontend/lib/tests/shop/product-admin-merged-prices-policy.test.ts b/frontend/lib/tests/shop/product-admin-merged-prices-policy.test.ts index 9d788d7a..99672305 100644 --- a/frontend/lib/tests/shop/product-admin-merged-prices-policy.test.ts +++ b/frontend/lib/tests/shop/product-admin-merged-prices-policy.test.ts @@ -4,7 +4,16 @@ import { PriceConfigError } from '@/lib/services/errors'; import { assertMergedPricesPolicy } from '@/lib/services/products/prices'; describe('assertMergedPricesPolicy (merged-state)', () => { - it('throws PRICE_CONFIG_ERROR when USD is missing after merge', () => { + it('allows UAH-only merged state when USD compatibility is not required', () => { + expect(() => + assertMergedPricesPolicy( + [{ currency: 'UAH', priceMinor: 1000, originalPriceMinor: null }], + { productId: 'p1', requireUsd: false } + ) + ).not.toThrow(); + }); + + it('still throws PRICE_CONFIG_ERROR when explicit USD compatibility is required', () => { try { assertMergedPricesPolicy( [{ currency: 'UAH', priceMinor: 1000, originalPriceMinor: null }], @@ -16,16 +25,4 @@ describe('assertMergedPricesPolicy (merged-state)', () => { expect((e as any).code).toBe('PRICE_CONFIG_ERROR'); } }); - - it('passes when USD exists in merged state', () => { - expect(() => - assertMergedPricesPolicy( - [ - { currency: 'UAH', priceMinor: 1000, originalPriceMinor: null }, - { currency: 'USD', priceMinor: 500, originalPriceMinor: null }, - ], - { productId: 'p1', requireUsd: true } - ) - ).not.toThrow(); - }); }); diff --git a/frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts b/frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts index 8954446f..1ff7cb73 100644 --- a/frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts +++ b/frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; import { productPrices, products } from '@/db/schema'; +import { createProduct } from '@/lib/services/products'; import { updateProduct } from '@/lib/services/products'; import { toDbMoney } from '@/lib/shop/money'; import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; @@ -186,4 +187,66 @@ describe.sequential('product_prices write authority (phase 8)', () => { expect(usd.priceMinor).toBe(1200); expect(uah.priceMinor).toBe(4700); }); + + it('updates a UAH-only product row without requiring a dormant USD price row', async () => { + const created = await createProduct({ + title: 'Phase8 UAH-only update', + slug: uniqueSlug(), + description: null, + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + prices: [{ currency: 'UAH', priceMinor: 4100, originalPriceMinor: null }], + image: new File([new Uint8Array([1, 2, 3])], 'p8-uah-only.png', { + type: 'image/png', + }), + } as any); + + createdProductIds.push(created.id); + + await updateProduct(created.id, { + prices: [{ currency: 'UAH', priceMinor: 4300, originalPriceMinor: null }], + } as any); + + const [legacy] = await db + .select({ + price: products.price, + currency: products.currency, + }) + .from(products) + .where(eq(products.id, created.id)) + .limit(1); + + const [usd] = await db + .select({ + priceMinor: productPrices.priceMinor, + }) + .from(productPrices) + .where( + and( + eq(productPrices.productId, created.id), + eq(productPrices.currency, 'USD') + ) + ) + .limit(1); + + const [uah] = await db + .select({ + priceMinor: productPrices.priceMinor, + }) + .from(productPrices) + .where( + and( + eq(productPrices.productId, created.id), + eq(productPrices.currency, 'UAH') + ) + ) + .limit(1); + + expect(legacy.currency).toBe('USD'); + expect(String(legacy.price)).toBe(String(toDbMoney(4100))); + expect(usd).toBeUndefined(); + expect(uah?.priceMinor).toBe(4300); + }); }); diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 0b432acc..5f64b160 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -363,15 +363,6 @@ export const productAdminSchema = z .superRefine((data, ctx) => { refineNoDuplicateCurrencies(data.prices, ctx); - const usd = data.prices.find(p => p.currency === 'USD'); - if (!usd) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['prices'], - message: 'USD price is required', - }); - } - if (data.badge === 'SALE') { data.prices.forEach((p, idx) => { if (p.originalPriceMinor == null) { From ceedc853e8b3773b681ebdbeabd031c3194111eb Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 1 Apr 2026 13:02:02 -0700 Subject: [PATCH 07/11] (SP: 3) [SHOP] polish CP-01 i18n surfaces and unify UAH storefront formatting --- .../shop/products/_components/ProductForm.tsx | 197 +++++++++++------- .../app/[locale]/shop/cart/CartPageClient.tsx | 27 +-- .../app/[locale]/shop/checkout/error/page.tsx | 24 ++- .../checkout/payment/StripePaymentClient.tsx | 39 ++-- .../shop/commercial-policy-batch-cp-01.md | 3 + .../lib/services/products/mutations/create.ts | 5 + .../lib/services/products/mutations/update.ts | 1 + frontend/lib/services/products/prices.ts | 19 +- frontend/lib/shop/currency.ts | 4 +- .../shop/admin-product-form-messages.test.ts | 71 +++++++ ...-patch-price-config-error-contract.test.ts | 5 +- .../shop/cart-public-provider-policy.test.ts | 2 +- .../checkout-legal-consent-contract.test.ts | 33 +++ frontend/lib/tests/shop/currency.test.ts | 49 ++++- ...roduct-admin-create-pricing-policy.test.ts | 40 ++++ ...product-admin-merged-prices-policy.test.ts | 18 +- .../shop/product-photo-plan-fixes.test.ts | 96 ++++++++- ...duct-prices-write-authority-phase8.test.ts | 88 +++++++- frontend/lib/validation/shop.ts | 25 ++- frontend/messages/en.json | 124 +++++++++++ frontend/messages/pl.json | 124 +++++++++++ frontend/messages/uk.json | 124 +++++++++++ 22 files changed, 957 insertions(+), 161 deletions(-) create mode 100644 frontend/lib/tests/shop/admin-product-form-messages.test.ts diff --git a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx index 8a7479d7..eb18e41e 100644 --- a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx +++ b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx @@ -1,6 +1,7 @@ 'use client'; import Image from 'next/image'; +import { useTranslations } from 'next-intl'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from '@/i18n/routing'; @@ -65,8 +66,6 @@ type SaleRuleDetails = { rule?: 'required' | 'greater_than_price'; }; -const SALE_REQUIRED_MSG = 'Original price is required for SALE.'; -const SALE_GREATER_MSG = 'Original price must be greater than price for SALE.'; const CARD_CLASS = 'rounded-xl border border-border bg-background/80 p-5 shadow-sm'; const LABEL_CLASS = 'block text-sm font-medium text-foreground'; @@ -81,6 +80,16 @@ const SECONDARY_BUTTON_CLASS = const PRIMARY_BUTTON_CLASS = 'inline-flex h-10 w-full items-center justify-center rounded-md bg-foreground px-4 text-sm font-semibold text-background transition-colors hover:bg-foreground/90 disabled:opacity-60'; +class InvalidMoneyValueError extends Error { + rawValue: string; + + constructor(rawValue: string) { + super('INVALID_MONEY_VALUE'); + this.name = 'InvalidMoneyValueError'; + this.rawValue = rawValue; + } +} + function formatMinorToMajor(value: number): string { if (!Number.isFinite(value)) return ''; const abs = Math.abs(Math.trunc(value)); @@ -93,13 +102,13 @@ function formatMinorToMajor(value: number): string { function parseMajorToMinor(value: string): number { const s = value.trim().replace(',', '.'); if (!/^\d+(\.\d{1,2})?$/.test(s)) { - throw new Error(`Invalid money value: "${value}"`); + throw new InvalidMoneyValueError(value); } const [whole, frac = ''] = s.split('.'); const frac2 = (frac + '00').slice(0, 2); const minor = Number(whole) * 100 + Number(frac2); if (!Number.isSafeInteger(minor) || minor < 0) { - throw new Error(`Invalid money value: "${value}"`); + throw new InvalidMoneyValueError(value); } return minor; } @@ -185,8 +194,9 @@ function isSerializableUiPhoto(photo: UiPhoto): photo is SerializableUiPhoto { return false; } -export const LEGACY_PHOTO_MIGRATION_REQUIRED_MESSAGE = - 'Legacy product photos must be migrated before adding or reordering gallery photos.'; +type ProductFormErrorMessages = { + legacyPhotoMigrationRequired: string; +}; class PhotoPlanSubmissionError extends Error { constructor(message: string) { @@ -195,22 +205,28 @@ class PhotoPlanSubmissionError extends Error { } } -export function getPhotoPlanSubmissionError(photos: UiPhoto[]): string | null { +export function getPhotoPlanSubmissionError( + photos: UiPhoto[], + messages: ProductFormErrorMessages +): string | null { const hasLegacyPhotos = photos.some(photo => photo.source === 'legacy'); const hasNonLegacyPhotos = photos.some(photo => photo.source !== 'legacy'); if (hasLegacyPhotos && hasNonLegacyPhotos) { - return LEGACY_PHOTO_MIGRATION_REQUIRED_MESSAGE; + return messages.legacyPhotoMigrationRequired; } return null; } -export function buildPhotoPlanSubmission(photos: UiPhoto[]): { +export function buildPhotoPlanSubmission( + photos: UiPhoto[], + messages: ProductFormErrorMessages +): { photoPlan?: AdminProductPhotoPlan; newPhotos: Array; } { - const submissionError = getPhotoPlanSubmissionError(photos); + const submissionError = getPhotoPlanSubmissionError(photos, messages); if (submissionError) { throw new PhotoPlanSubmissionError(submissionError); } @@ -311,6 +327,7 @@ export function ProductForm({ csrfToken, }: ProductFormProps) { const router = useRouter(); + const t = useTranslations('shop.admin.products.form'); const idBase = useMemo(() => { const pid = @@ -563,7 +580,7 @@ export function ProductForm({ setOriginalPriceErrors({}); if (photos.length === 0) { - setImageError('At least one product photo is required.'); + setImageError(t('errors.photoRequired')); return; } @@ -581,14 +598,24 @@ export function ProductForm({ ); if (effectivePrices.length === 0) { - setError('At least one price is required.'); + setError(t('errors.atLeastOnePrice')); + return; + } + + const storefrontUahPrice = normalizedPrices.find( + price => price.currency === 'UAH' + ); + if (!storefrontUahPrice?.price.length) { + setError(t('errors.uahRequired')); return; } for (const p of effectivePrices) { if (!p.price.length && p.originalPrice.length) { setError( - `${p.currency}: price is required when original price is set.` + t('errors.priceRequiredWhenOriginalSet', { + currency: p.currency, + }) ); return; } @@ -609,7 +636,11 @@ export function ProductForm({ : null, })); } catch (e) { - setError(e instanceof Error ? e.message : 'Invalid price value.'); + if (e instanceof InvalidMoneyValueError) { + setError(t('errors.invalidMoneyValue', { value: e.rawValue })); + return; + } + setError(t('errors.invalidPriceValue')); return; } @@ -633,7 +664,11 @@ export function ProductForm({ const photoSubmission = (() => { try { - return buildPhotoPlanSubmission(photos); + return buildPhotoPlanSubmission(photos, { + legacyPhotoMigrationRequired: t( + 'errors.legacyPhotoMigrationRequired' + ), + }); } catch (photoPlanError) { if (photoPlanError instanceof PhotoPlanSubmissionError) { setImageError(photoPlanError.message); @@ -662,7 +697,7 @@ export function ProductForm({ } if (!csrfToken) { - setError('Security token missing. Refresh the page and retry.'); + setError(t('errors.securityMissing')); setIsSubmitting(false); return; } @@ -684,7 +719,7 @@ export function ProductForm({ if (!response.ok) { if (data.code === 'SLUG_CONFLICT' || data.field === 'slug') { - setSlugError('This slug is already used. Try changing the title.'); + setSlugError(t('errors.slugConflict')); } const photoErrorFields = new Set([ @@ -701,7 +736,7 @@ export function ProductForm({ data.code === 'IMAGE_UPLOAD_FAILED' || data.code === 'IMAGE_REQUIRED' ) { - setImageError(data.error ?? 'Failed to update product photos'); + setImageError(data.error ?? t('errors.photoUpdateFailed')); return; } @@ -710,8 +745,8 @@ export function ProductForm({ const currency = details?.currency; const msg = details?.rule === 'greater_than_price' - ? SALE_GREATER_MSG - : SALE_REQUIRED_MSG; + ? t('errors.saleOriginalGreater') + : t('errors.saleOriginalRequired'); if (currency === 'USD' || currency === 'UAH') { setOriginalPriceErrors(prev => ({ ...prev, [currency]: msg })); @@ -725,13 +760,15 @@ export function ProductForm({ response.status === 403 && (data.code === 'CSRF_MISSING' || data.code === 'CSRF_INVALID') ) { - setError('Security token expired. Refresh the page and retry.'); + setError(t('errors.securityExpired')); return; } setError( data.error ?? - `Failed to ${mode === 'create' ? 'create' : 'update'} product` + (mode === 'create' + ? t('errors.createFailed') + : t('errors.updateFailed')) ); return; } @@ -746,9 +783,9 @@ export function ProductForm({ }); setError( - `Unexpected error while ${ - mode === 'create' ? 'creating' : 'updating' - } product.` + mode === 'create' + ? t('errors.unexpectedCreate') + : t('errors.unexpectedUpdate') ); } finally { setIsSubmitting(false); @@ -762,7 +799,7 @@ export function ProductForm({ return (

- {mode === 'create' ? 'Create new product' : 'Edit product'} + {mode === 'create' ? t('headings.create') : t('headings.edit')}

{error ? (
- Auto-generated from title + {t('fields.slugHelp')}
- Prices + {t('pricing.legend')}

- UAH is the standard storefront price. USD is optional and kept only - as a compatibility price. + {t('pricing.helper')}

- UAH (standard storefront) + {t('pricing.uahLegend')}
- USD (compatibility optional) + {t('pricing.usdLegend')}

- Standard storefront checkout uses UAH. Prices must exist in{' '} - product_prices for the standard - storefront currency. USD is optional compatibility only. + {t('pricing.policyPrefix')}{' '} + product_prices{' '} + {t('pricing.policySuffix')}

setType(event.target.value)} > - + {PRODUCT_TYPES.map(productType => (
-
+