Skip to content

(SP: 3)[SHOP][Wallets][NP] Stripe wallets (Apple/Google Pay) + Monobank Google Pay flow + Nova Poshta city_ref FK fixes#392

Merged
ViktorSvertoka merged 15 commits intodevelopfrom
lso/feat/wallets
Mar 6, 2026
Merged

(SP: 3)[SHOP][Wallets][NP] Stripe wallets (Apple/Google Pay) + Monobank Google Pay flow + Nova Poshta city_ref FK fixes#392
ViktorSvertoka merged 15 commits intodevelopfrom
lso/feat/wallets

Conversation

@liudmylasovetovs
Copy link
Collaborator

@liudmylasovetovs liudmylasovetovs commented Mar 6, 2026

Description

Wallets + shipping hardening for the Shop module.

This PR adds Stripe wallet support via Payment Element (Apple Pay / Google Pay eligibility surfaces), introduces a Monobank Google Pay payment rail (with invoice fallback and webhook-authoritative finalization), and fixes Nova Poshta schema drift to ensure warehouses reference cities via city_ref (restoring correct lookup/caching behavior).


Related Issue

Issue: #<issue_number>


Changes

  • Stripe wallets (Payment Element): lock PaymentIntent to card rail for deterministic wallet surfacing, add wallet attribution, and ensure webhook processing remains deterministic (no extra Stripe fetch).
  • Monobank Google Pay: add config + submit routes, wallet payment orchestration, pending return UX (no false success), and enforce idempotency / concurrency guards; keep invoice fallback.
  • Nova Poshta fixes: restore NP warehouses caching and correct city reference usage; replace settlement_ref FK with city_ref FK for consistent city/warehouse linking.

Database Changes (if applicable)

  • Schema migration required
  • Seed data updated
  • Breaking changes to existing queries
  • Transaction-safe migration
  • Migration tested locally on Neon

How Has This Been Tested?

  • Tested locally
  • Verified in development environment
  • Checked responsive layout (if UI-related)
  • Tested accessibility (keyboard / screen reader)

Commands (PowerShell):

  • 'cd C:\Users\milka\devlovers.net-clean\frontend'
  • '$env:APP_ENV="local"'
  • '$env:DATABASE_URL_LOCAL=
  • '$env:DATABASE_URL=" "'
  • '$env:SHOP_STRICT_LOCAL_DB="1"'
  • '$env:SHOP_REQUIRED_DATABASE_URL_LOCAL=$env:DATABASE_URL_LOCAL'
  • '$env:SHOP_STATUS_TOKEN_SECRET="test_status_token_secret_test_status_token_secret"'
  • 'npx vitest run --config vitest.shop.config.ts lib/tests/shop --pool=threads --maxWorkers=1'
  • 'npm run build'

Screenshots (if applicable)


Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors
  • Code follows project conventions
  • Scope is limited to this feature/fix
  • No unrelated refactors included
  • English used in code, commits, and docs
  • New dependencies discussed with team
  • Database migration tested locally on Neon
  • GitHub Projects card moved to In Review

Reviewers

Summary by CodeRabbit

  • New Features

    • Monobank Google Pay available as a selectable checkout method with an in-checkout Google Pay button, a dedicated payment page, and a server-backed submit flow.
    • New Monobank return/status pages with live polling and automatic success/error redirects.
  • Improvements

    • Place Order now enforces valid provider+method combinations to prevent invalid submissions.
    • Nova Poshta city/warehouse lookup improved (better matching, validation, clearer UI error handling).
    • Invoice-fallback and Monobank status UX refined; translations updated.

@vercel
Copy link
Contributor

vercel bot commented Mar 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
devlovers-net Ignored Ignored Preview Mar 6, 2026 5:48am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

Adds Monobank Google Pay end-to-end: frontend selection/UI and client, server config/submit/invoice routes, Monobank wallet submission service and webhook metadata, payment-method plumbing across checkout and validation, Nova Poshta city/warehouse refactor and DB migrations, plus many tests and i18n updates.

Changes

Cohort / File(s) Summary
Cart & Checkout UI
frontend/app/[locale]/shop/cart/CartPageClient.tsx, frontend/app/[locale]/shop/cart/page.tsx
Adds paymentMethod/paymentProvider types and state, monobankGooglePayEnabled prop, UI selection for Monobank Google Pay, and propagates selection to checkout payloads.
Monobank Google Pay client & pages
frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx, frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx, frontend/app/[locale]/shop/checkout/return/monobank/*
New Google Pay client component, server page for payment completion, and return-status client with polling and redirect logic.
Checkout API & order payment routes
frontend/app/api/shop/checkout/route.ts, frontend/app/api/shop/orders/[id]/payment/monobank/*, frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/*, frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts
Adds parsing/validation for paymentMethod, feature-flag gating, statusToken handling, config & submit endpoints for Google Pay and invoice fallback, and enhanced error mappings.
Monobank wallet service & PSP
frontend/lib/services/orders/monobank-wallet.ts, frontend/lib/services/orders/monobank-webhook.ts, frontend/lib/psp/monobank.ts
Implements walletPayment adapter, wallet submission workflow with idempotency/conflict handling, attempt persistence, and wallet attribution propagation into webhook metadata/events.
Order creation / idempotency / payments model
frontend/lib/services/orders/checkout.ts, frontend/lib/services/orders/_shared.ts, frontend/lib/shop/payments.ts, frontend/lib/validation/shop.ts
Propagates paymentMethod into idempotency hash and order creation, adds PaymentMethod type, default resolver, isMethodAllowed checks and schema refinements/validation.
Nova Poshta (NP) changes
frontend/lib/services/shop/shipping/nova-poshta-client.ts, frontend/lib/services/shop/shipping/nova-poshta-catalog.ts, frontend/lib/services/shop/shipping/..., frontend/db/schema/shop.ts, frontend/drizzle/*, frontend/scripts/np-mock-server.mjs
Switched warehouse plumbing from settlementRef→cityRef, added city lookup/parsing and fallback, adjusted queries, added city_ref FK, migrations and mock server updates.
Stripe webhook & PSP tweaks
frontend/app/api/shop/webhooks/stripe/route.ts, frontend/lib/psp/stripe.ts
Extracts wallet attribution (apple_pay/google_pay) into PSP metadata/events; restricts stripe creation to card payment types.
Translations & messages
frontend/messages/en.json, frontend/messages/pl.json, frontend/messages/uk.json
Adds Monobank Google Pay/invoice/return-flow strings and NP lookup messages; updates related hints and labels.
Tests
frontend/lib/tests/shop/*
Extensive new/updated tests: Google Pay config/submit, wallet service unit tests, idempotency method-aware tests, NP client tests, webhook attribution tests, and test helpers.
Migrations & schema journal
frontend/drizzle/0029_*.sql, frontend/drizzle/0030_*.sql, frontend/drizzle/meta/_journal.json, frontend/db/schema/shop.ts
Add conditional FK creation and backfill migration entries; adjust schema to add city_ref FK and remove settlement_ref FK where applicable.
Misc / infra / cleanups
frontend/proxy.ts, frontend/drizzle.config.ts, various small files...frontend/lib/...
Add .well-known bypass in proxy matcher, adjust drizzle export, many import reorderings and minor cleanups with no behavior change.

Sequence Diagram(s)

sequenceDiagram
    actor Browser
    participant Cart as CartPageClient
    participant ConfigAPI as GET /api/.../google-pay/config
    participant GPay as GooglePay JS
    participant SubmitAPI as POST /api/.../google-pay/submit
    participant WalletSvc as Monobank Wallet Service
    participant Monobank as Monobank PSP
    participant Return as MonobankReturnStatus

    Browser->>Cart: select Monobank Google Pay, place order
    Cart->>ConfigAPI: fetch Google Pay paymentDataRequest
    ConfigAPI-->>Cart: paymentDataRequest
    Cart->>GPay: init button, isReadyToPay → user approves
    GPay-->>Cart: gToken (card token)
    Cart->>SubmitAPI: POST gToken + idempotency + return URLs
    SubmitAPI->>WalletSvc: submitMonobankWalletPayment(...)
    WalletSvc->>Monobank: walletPayment request
    Monobank-->>WalletSvc: response (invoiceId/redirect/status)
    WalletSvc-->>SubmitAPI: submission result (statusToken/outcome)
    SubmitAPI-->>Browser: response (redirect or pending + statusToken)
    Browser->>Return: open return URL with statusToken
    Return->>Return: poll /api/orders/{id}/status?view=lite&statusToken=...
    Return-->>Browser: redirect to success or error when terminal
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

enhancement, UI, refactor

Suggested reviewers

  • AM1007
  • ViktorSvertoka

Poem

🐰 I nibble code and hop with cheer,

Google Pay appears, Monobank near.
Tokens hum and polls go round,
Invoices fall back safe and sound.
The cart hops onward—order clear!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main changes: Stripe wallets (Apple/Google Pay), Monobank Google Pay flow, and Nova Poshta city_ref FK fixes. It is specific and directly reflects the substantial work in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch lso/feat/wallets

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04e7f5f83f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +220 to +223
eq(paymentAttempts.orderId, orderId),
eq(paymentAttempts.provider, 'monobank'),
inArray(paymentAttempts.status, [...ACTIVE_ATTEMPT_STATUSES])
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude invoice attempts from wallet active-attempt check

This active-attempt lookup treats any Monobank creating/active attempt as a wallet conflict, including invoice attempts created by checkout, so a monobank_google_pay order can be blocked with MONOBANK_WALLET_CONFLICT before wallet submission starts. In the current flow, checkout still initializes a Monobank invoice attempt first, so guest/user Google Pay submits hit this branch and return 409 instead of calling the PSP. Filter this query to wallet-tagged attempts (or avoid creating invoice attempts for Google Pay orders) so the wallet rail can proceed.

Useful? React with 👍 / 👎.

const auth = await authorizeOrderMutationAccess({
orderId,
statusToken,
requiredScope: 'order_payment_init',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align Google Pay config auth scope with checkout token

This endpoint requires order_payment_init, but checkout-generated status tokens are created with the default status_lite scope, and the cart now forwards that token into the Google Pay page. For guest orders (no authenticated user fallback), config requests are rejected with STATUS_TOKEN_SCOPE_FORBIDDEN, so Google Pay cannot initialize at all. Either mint checkout tokens with order_payment_init for this flow or relax the required scope on the config/init path.

Useful? React with 👍 / 👎.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts (1)

209-263: ⚠️ Potential issue | 🟡 Minor

Wrap fetch spy cleanup in try/finally for two tests to prevent spy leakage on assertion failures.

The vi.spyOn() calls on lines 209 and 432 restore the spy only on the success path. If an assertion fails before fetchSpy.mockRestore() runs, the spy leaks into subsequent tests. The fail-closed test (line 266) already uses try/finally correctly; apply the same pattern to the other two tests.

Pattern
-    const fetchSpy = vi.spyOn(globalThis, 'fetch');
-    const res = await POST(makeRequest());
+    const fetchSpy = vi.spyOn(globalThis, 'fetch');
+    try {
+      const res = await POST(makeRequest());
     expect(res.status).toBe(200);
-    ...
-    fetchSpy.mockRestore();
+      ...
+    } finally {
+      fetchSpy.mockRestore();
+    }

Also applies to: 432-489

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts` around lines 209
- 263, The fetch spy created with vi.spyOn(globalThis, 'fetch') in the two tests
(the one around the POST(makeRequest()) call and the other at lines ~432-489)
must be restored in a finally block to avoid leaking the spy on assertion
failures; wrap the part from creating fetchSpy through the POST and subsequent
assertions in try { ... } finally { fetchSpy.mockRestore(); } so that
fetchSpy.mockRestore() always runs (keep the existing assertions and calls to
POST, expect(...), and checks like
expect(retrieveChargeMock).not.toHaveBeenCalled()).
frontend/lib/services/orders/monobank-webhook.ts (1)

503-522: ⚠️ Potential issue | 🟠 Major

Clear stale pspMetadata.wallet when payment succeeds or fails on non-wallet attempts.

Orders can have multiple payment attempts. If a first attempt with wallet metadata (e.g., Google Pay) fails, then a second attempt without wallet metadata succeeds, the jsonb || patch merge preserves the stale wallet key from the old metadata since patch doesn't include it. The paid order remains incorrectly attributed to Google Pay.

This affects both the success path (atomicMarkPaidOrderAndSucceedAttempt) and failure path (atomicFinalizeOrderAndAttempt).

Suggested fix
 function buildMergedMetaSql(
   normalized: NormalizedWebhook,
   walletAttribution: WalletAttribution | null
 ) {
   const metadataPatch: Record<string, unknown> = {
     monobank: {
       invoiceId: normalized.invoiceId,
       status: normalized.status,
       amount: normalized.amount ?? null,
       ccy: normalized.ccy ?? null,
       reference: normalized.reference ?? null,
     },
   };
-  if (walletAttribution) {
-    metadataPatch.wallet = walletAttribution;
-  }
-
-  return sql`coalesce(${orders.pspMetadata}, '{}'::jsonb) || ${JSON.stringify(
-    metadataPatch
-  )}::jsonb`;
+  const baseMeta = walletAttribution
+    ? sql`jsonb_set(
+        coalesce(${orders.pspMetadata}, '{}'::jsonb) - 'wallet',
+        '{wallet}',
+        ${JSON.stringify(walletAttribution)}::jsonb,
+        true
+      )`
+    : sql`coalesce(${orders.pspMetadata}, '{}'::jsonb) - 'wallet'`;
+
+  return sql`${baseMeta} || ${JSON.stringify(metadataPatch)}::jsonb`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/monobank-webhook.ts` around lines 503 - 522, The
merge currently preserves a stale pspMetadata.wallet because jsonb || only
overwrites keys present in the RHS; modify buildMergedMetaSql so when
walletAttribution is null it first removes the wallet key from
orders.pspMetadata before merging. Concretely, keep constructing metadataPatch
without wallet when walletAttribution is null, and return SQL that uses
(coalesce(${orders.pspMetadata}, '{}'::jsonb) - 'wallet') ||
${JSON.stringify(metadataPatch)}::jsonb in that branch; when walletAttribution
is present keep the existing coalesce(...) ||
${JSON.stringify(metadataPatch)}::jsonb behavior. This will clear stale wallet
data for both atomicMarkPaidOrderAndSucceedAttempt and
atomicFinalizeOrderAndAttempt flows that call buildMergedMetaSql.
🧹 Nitpick comments (16)
frontend/lib/tests/shop/checkout-shipping-phase3.test.ts (1)

159-161: Keep the unsupported-currency assertion type-aware.

Line 159 now passes for any thrown value with a matching code, including a plain object, so this test no longer protects the domain-error contract for this path. Prefer capturing the rejection once and asserting both the code and that the value is an Error or the expected service error type.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/checkout-shipping-phase3.test.ts` around lines 159 -
161, The test currently uses .rejects.toMatchObject({ code:
'SHIPPING_CURRENCY_UNSUPPORTED' }) which will match any plain object with that
shape; change the assertion to capture the rejection and assert both that the
thrown value is an Error (or the expected service error type) and that it
carries the code 'SHIPPING_CURRENCY_UNSUPPORTED' — e.g. replace the single
toMatchObject chain with two assertions like await
expect(promise).rejects.toBeInstanceOf(Error) (or ShippingServiceError if that
class exists) and await expect(promise).rejects.toHaveProperty('code',
'SHIPPING_CURRENCY_UNSUPPORTED'), or alternatively await promise.catch(e => {
expect(e).toBeInstanceOf(Error);
expect(e.code).toBe('SHIPPING_CURRENCY_UNSUPPORTED'); }) so the test verifies
type and code instead of just shape.
frontend/app/api/shop/internal/shipping/np/sync/route.ts (1)

97-99: Use an app-stage flag or platform-specific variable instead of NODE_ENV.

In deployed Next.js apps (including Vercel Preview deployments), NODE_ENV is set to production for all built deployments because they run in production mode. This makes the condition NODE_ENV !== 'production' ineffective for distinguishing staging/preview from production environments. Use VERCEL_ENV (if on Vercel) or a dedicated flag like APP_ENV to reliably gate debug behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/internal/shipping/np/sync/route.ts` around lines 97 -
99, The debug gating currently uses process.env.NODE_ENV in the debugEnabled
assignment; replace that check with a platform-aware environment variable (e.g.
process.env.VERCEL_ENV === 'preview' || process.env.APP_ENV === 'staging') so
debugEnabled reliably distinguishes preview/stage vs production; update the
expression that defines debugEnabled (the const debugEnabled and its use of
request.headers.get('x-shop-debug')) to combine the chosen env-var check with
the existing header check and trim logic so debug is enabled only when running
in a non-production app-stage (VERCEL_ENV/APP_ENV) and the x-shop-debug header
equals '1'.
frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts (1)

291-313: Assert the event stays retryable on this fail-closed branch.

This new 500-path only checks order-side effects. Please also assert that the stripeEvents row exists and processedAt is still NULL; otherwise this branch can accidentally mark the event handled and break retries without this test catching it.

Suggested assertion block
       expect(await res.json()).toEqual({
         code: 'REFUND_FULLNESS_UNDETERMINED',
       });
+
+      const [evt] = await db
+        .select({
+          processedAt: stripeEvents.processedAt,
+          paymentIntentId: stripeEvents.paymentIntentId,
+        })
+        .from(stripeEvents)
+        .where(eq(stripeEvents.eventId, eventId))
+        .limit(1);
+
+      expect(evt).toBeTruthy();
+      expect(evt.processedAt).toBeNull();
+      expect(evt.paymentIntentId).toBe(inserted.paymentIntentId);

       const [row] = await db
         .select({
           paymentStatus: orders.paymentStatus,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts` around lines 291
- 313, Add an assertion that the stripeEvents row for this webhook still exists
and remains unprocessed (processedAt is NULL) so the event stays retryable;
query the stripeEvents table (using the stripeEvents symbol) for the test event
id (e.g., use the previously created inserted.eventId or inserted.id as the
identifier) and assert the row is found and that its processedAt column is null.
frontend/lib/services/shop/shipping/nova-poshta-client.ts (1)

434-460: Rename this API to getWarehousesByCityRef.

The implementation no longer accepts a settlement ref: it calls Nova Poshta with CityRef. Keeping the old name makes the old semantics look valid at every call site and obscures the city-based migration.

Suggested rename in this module
-export async function getWarehousesBySettlementRef(
-  settlementRef: string
+export async function getWarehousesByCityRef(
+  cityRef: string
 ): Promise<NovaPoshtaWarehouse[]> {
@@
     const batch = await callNp<WarehouseItem>({
       modelName: 'Address',
       calledMethod: 'getWarehouses',
       methodProperties: {
-        CityRef: settlementRef,
+        CityRef: cityRef,
         Limit: String(limit),
         Page: String(page),
         Language: 'ua',
       },
     });

The catalog-side wrappers/args should follow the same rename in the same pass.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts` around lines 434 -
460, The function getWarehousesBySettlementRef is misnamed because it uses
CityRef in the API request; rename the function to getWarehousesByCityRef and
update all local references and exports accordingly (including any wrapper
functions and argument names in this module and catalog-side wrappers) so
callers clearly reflect city-based semantics; ensure the implementation
signature remains the same (settlementRef parameter should be renamed to cityRef
where present), and update any tests or imports that reference
getWarehousesBySettlementRef and the documented API name to the new
getWarehousesByCityRef; verify usages of callNp and the methodProperties.CityRef
remain correct.
frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx (1)

190-193: Reuse one idempotency key per in-flight wallet submit.

Line 341 generates a brand-new key on every click. If the first POST reaches the server but the client never receives the response, the retry becomes a conflict/new-attempt path instead of an idempotent replay of the original submit.

♻️ Proposed change
   const paymentsClientRef = useRef<GooglePayPaymentsClient | null>(null);
   const buttonHostRef = useRef<HTMLDivElement | null>(null);
+  const submitIdempotencyKeyRef = useRef<string | null>(null);
@@
+      submitIdempotencyKeyRef.current ??= generateIdempotencyKey();
       const response = await fetch(
         `/api/shop/orders/${encodeURIComponent(
           orderId
         )}/payment/monobank/google-pay/submit${statusTokenQuery}`,
         {
           method: 'POST',
           headers: {
             'Content-Type': 'application/json',
-            'Idempotency-Key': generateIdempotencyKey(),
+            'Idempotency-Key': submitIdempotencyKeyRef.current,
           },
           body: JSON.stringify({ gToken }),
         }
       );

If this component can ever be reused for a different orderId, reset the ref when orderId changes.

Also applies to: 333-342

frontend/app/api/shop/checkout/route.ts (2)

111-116: Duplicate isMonobankGooglePayEnabled implementation.

This is the same function as in frontend/lib/services/orders/checkout.ts (lines 744-749). Extract to a shared location like @/lib/env/monobank to avoid drift and ensure consistent behavior.

♻️ Proposed extraction

Create in frontend/lib/env/monobank.ts:

export function isMonobankGooglePayEnabled(): boolean {
  const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '')
    .trim()
    .toLowerCase();
  return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
}

Then import from both files:

-function isMonobankGooglePayEnabled(): boolean {
-  const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '')
-    .trim()
-    .toLowerCase();
-  return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
-}
+import { isMonobankGooglePayEnabled } from '@/lib/env/monobank';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/checkout/route.ts` around lines 111 - 116, Duplicate
implementation of isMonobankGooglePayEnabled should be extracted to a single
shared module: create a new module (e.g., export function
isMonobankGooglePayEnabled in a new monobank env module) and replace both local
implementations by importing that function; update callers in checkout route and
orders/checkout to import the shared isMonobankGooglePayEnabled from the new
module so behavior is centralized and avoids drift.

651-656: Default method mapping is hardcoded inline.

The comment "Explicit default table (locked)" suggests this is intentional, but this duplicates logic that also exists in resolveDefaultMethodForProvider from @/lib/shop/payments. Consider using that function for consistency.

♻️ Suggested change
-  // Explicit default table (locked): stripe -> stripe_card, monobank -> monobank_invoice.
-  const defaultMethod: PaymentMethod =
-    selectedProvider === 'monobank' ? 'monobank_invoice' : 'stripe_card';
-  const selectedMethod: PaymentMethod = requestedMethod ?? defaultMethod;
+  const selectedMethod: PaymentMethod =
+    requestedMethod ?? resolveDefaultMethodForProvider(selectedProvider, selectedCurrency);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/checkout/route.ts` around lines 651 - 656, The default
payment method is hardcoded here duplicating logic in
resolveDefaultMethodForProvider; replace the inline defaultMethod calculation
with a call to resolveDefaultMethodForProvider(selectedProvider) and use its
result when requestedMethod is null (affecting selectedMethod), keeping
selectedProvider and selectedCurrency logic intact; ensure you import
resolveDefaultMethodForProvider from "@/lib/shop/payments" and retain the
existing resolveCurrencyFromLocale(locale) usage for selectedCurrency.
frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx (1)

40-55: Helper functions duplicated across pages.

getStringParam, parseStatusToken, and shouldClearCart are duplicated from frontend/app/[locale]/shop/checkout/return/monobank/page.tsx. Consider extracting these to a shared utility file.

♻️ Proposed extraction

Create frontend/app/[locale]/shop/checkout/_utils/search-params.ts:

export type SearchParams = Record<string, string | string[] | undefined>;

export function getStringParam(params: SearchParams | undefined, key: string): string {
  const raw = params?.[key];
  if (!raw) return '';
  if (Array.isArray(raw)) return raw[0] ?? '';
  return raw;
}

export function parseStatusToken(params: SearchParams | undefined): string | null {
  const value = getStringParam(params, 'statusToken').trim();
  return value.length ? value : null;
}

export function shouldClearCart(params: SearchParams | undefined): boolean {
  const value = getStringParam(params, 'clearCart');
  return value === '1' || value === 'true';
}

Then import in both pages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx
around lines 40 - 55, Extract the duplicated helpers getStringParam,
parseStatusToken, and shouldClearCart into a single shared module (e.g.,
search-params.ts) and import them from both pages; specifically, move the
implementations of getStringParam, parseStatusToken, and shouldClearCart into
the new module, export them (and the SearchParams type), then replace the local
implementations in page.tsx files with imports of those functions to remove
duplication.
frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts (2)

145-166: Polling helper could mask intermittent failures.

waitForCreatingAttempt polls every 25ms for up to 3 seconds but throws a generic error on timeout. Consider adding diagnostic information about what was found (if anything) to help debug flaky tests.

♻️ Suggested improvement
 async function waitForCreatingAttempt(orderId: string, timeoutMs = 3_000) {
   const deadline = Date.now() + timeoutMs;
+  let lastAttempt: { id: string; status: string } | undefined;
   while (Date.now() < deadline) {
     const [attempt] = await db
       .select({
         id: paymentAttempts.id,
         status: paymentAttempts.status,
       })
       .from(paymentAttempts)
       .where(
         and(
           eq(paymentAttempts.orderId, orderId),
           eq(paymentAttempts.provider, 'monobank')
         )
       )
       .limit(1);

-    if (attempt && attempt.status === 'creating') return;
+    lastAttempt = attempt;
+    if (attempt?.status === 'creating') return;
     await new Promise(resolve => setTimeout(resolve, 25));
   }
-  throw new Error('Timed out waiting for creating payment attempt');
+  throw new Error(
+    `Timed out waiting for creating payment attempt. Last seen: ${JSON.stringify(lastAttempt)}`
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts` around
lines 145 - 166, The helper waitForCreatingAttempt currently throws a generic
timeout error which hides intermittent failures; update the function
(waitForCreatingAttempt) to retain the last fetched row (from the
paymentAttempts select) and include diagnostic details in the thrown error
(e.g., JSON.stringify(lastAttempt), orderId, provider, and timeoutMs) or log
them before throwing so tests show what was observed when the wait expired;
ensure you reference the same query on paymentAttempts and keep the polling
behavior otherwise unchanged.

91-115: Type assertion on insert values may mask schema drift.

The as any cast on line 114 bypasses type checking for the order insert. If the schema changes, this test helper won't catch mismatches at compile time. Consider defining a proper type or using Drizzle's inferred insert type.

♻️ Suggested improvement
+import type { InferInsertModel } from 'drizzle-orm';
+
+type OrderInsert = InferInsertModel<typeof orders>;
+
 async function insertOrder(args: {
   id: string;
   paymentProvider?: 'monobank' | 'stripe';
   paymentStatus?: 'pending' | 'requires_payment' | 'paid' | 'failed' | 'refunded';
   currency?: 'UAH' | 'USD';
   paymentMethod?: 'monobank_google_pay' | 'monobank_invoice' | 'stripe_card';
 }) {
-  await db.insert(orders).values({
+  const values: OrderInsert = {
     id: args.id,
     totalAmountMinor: 4321,
     totalAmount: toDbMoney(4321),
     currency: args.currency ?? 'UAH',
     paymentProvider: args.paymentProvider ?? 'monobank',
     paymentStatus: args.paymentStatus ?? 'pending',
     status: 'INVENTORY_RESERVED',
     inventoryStatus: 'reserved',
     idempotencyKey: `idem_${crypto.randomUUID()}`,
     pspPaymentMethod: args.paymentMethod ?? 'monobank_google_pay',
     pspMetadata: {
       checkout: {
         requestedMethod: args.paymentMethod ?? 'monobank_google_pay',
       },
     },
-  } as any);
+  };
+  await db.insert(orders).values(values);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts` around
lines 91 - 115, The test helper insertOrder uses a loose "as any" on the
db.insert(orders).values(...) payload which hides schema mismatches; replace the
cast by constructing a properly typed payload (use Drizzle's inferred insert
type for the orders table or an explicit OrderInsert type) and pass that typed
object to db.insert instead, making sure fields like totalAmount
(toDbMoney(...)), pspPaymentMethod, and pspMetadata.checkout.requestedMethod
match the orders schema; update the insertOrder function signature to use the
typed payload so the compiler will catch any schema drift.
frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx (2)

157-163: **Polling continuation logic appears inverted.**The logic at lines 158-161 appears suspicious. After checking that the status is NOT in TERMINAL_NON_PAID (line 151-156) and NOT paid (line 142-148), the code checks if !PENDING_STATUSES.has(nextStatus.paymentStatus) and continues polling. This means:

  • If status IS pending → goes to line 163, continues polling ✓
  • If status is NOT pending (and not terminal/paid) → goes to line 159, continues polling ✓

Both paths continue polling, which seems redundant. Consider simplifying.

♻️ Suggested simplification
         if (TERMINAL_NON_PAID.has(nextStatus.paymentStatus)) {
           router.replace(
             `/shop/checkout/error?orderId=${encodeURIComponent(orderId)}`
           );
           return;
         }

-        if (!PENDING_STATUSES.has(nextStatus.paymentStatus)) {
-          timer = setTimeout(poll, POLL_DELAY_MS);
-          return;
-        }
-
+        // Continue polling for any non-terminal state
         timer = setTimeout(poll, POLL_DELAY_MS);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx
around lines 157 - 163, The polling logic currently schedules setTimeout twice
and effectively always continues polling; update the poll control in the poll
function so it only reschedules when the payment is actually pending: replace
the duplicate timer scheduling with a single conditional that checks
PENDING_STATUSES.has(nextStatus.paymentStatus) and calls setTimeout(poll,
POLL_DELAY_MS) only in that case (leave existing early-return behavior for
TERMINAL_NON_PAID and paid checks intact), removing the redundant timer
assignment; refer to PENDING_STATUSES, nextStatus.paymentStatus, poll, and
POLL_DELAY_MS when making the change.

182-185: Type assertion on translation key.

The as any cast on line 184 bypasses TypeScript's type checking for translation keys. If the translation namespace changes, this won't be caught at compile time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx
around lines 182 - 185, The code bypasses type checking by casting the
translation key with "as any" in the statusLabel useMemo; instead update
getStatusLabelKey to return the correct translation key type (the union/keyof
used by your i18n `t` function) or a typed wrapper (e.g.,
`getStatusLabelKey(paymentStatus): TranslationKey`) and then remove the "as any"
cast so the call becomes `t(getStatusLabelKey(status.paymentStatus))`; ensure
the `statusLabel` useMemo signature stays the same and adjust any related types
where getStatusLabelKey is defined so TypeScript enforces valid translation
keys.
frontend/lib/services/orders/checkout.ts (2)

1062-1100: Backfill logic silently swallows update failures.

The backfill for pspPaymentMethod and pspMetadata catches errors and only logs when DEBUG is enabled. While this prevents checkout failures from backfill issues, it could mask persistent database problems. Consider logging at logWarn level unconditionally to improve observability.

♻️ Suggested improvement
       } catch (e) {
-        if (process.env.DEBUG) {
-          logWarn('checkout_rejected', {
-            phase: 'payment_method_backfill',
-            orderId: row.id,
-            message: e instanceof Error ? e.message : String(e),
-          });
-        }
+        logWarn('checkout_payment_method_backfill_failed', {
+          orderId: row.id,
+          message: e instanceof Error ? e.message : String(e),
+        });
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/checkout.ts` around lines 1062 - 1100, The catch
block in the backfill branch (around the db.update(orders).set(...) call guarded
by needsMethodBackfill or needsMetadataBackfill) only calls logWarn when
process.env.DEBUG is set, which hides update failures; change the catch to
always call logWarn('checkout_rejected', { phase: 'payment_method_backfill',
orderId: row.id, message: e instanceof Error ? e.message : String(e) }) (remove
the DEBUG conditional) so failures are logged at warn level unconditionally;
keep the existing error message extraction and do not rethrow unless you want
the backfill to fail the checkout.

744-749: Consider extracting shared env parsing logic.

isMonobankGooglePayEnabled() duplicates the same environment variable parsing logic that appears in frontend/app/api/shop/checkout/route.ts (lines 111-116). Consider extracting this to a shared utility in @/lib/env/monobank alongside isMonobankEnabled() to ensure consistent behavior and reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/checkout.ts` around lines 744 - 749, Extract the
duplicated env parsing into a single utility (e.g., parseEnvBoolean(envValue:
string | undefined): boolean) and replace the inline logic in
isMonobankGooglePayEnabled and the other occurrence used by isMonobankEnabled to
call this utility; ensure parseEnvBoolean normalizes (trim, toLowerCase) and
returns true for 'true','1','yes','on', then update both
isMonobankGooglePayEnabled() and isMonobankEnabled() to delegate to
parseEnvBoolean(process.env.SHOP_MONOBANK_GPAY_ENABLED) /
parseEnvBoolean(process.env.SHOP_MONOBANK_ENABLED) respectively so behavior is
consistent and duplication removed.
frontend/app/[locale]/shop/cart/CartPageClient.tsx (2)

98-105: Dead code: void args.monobankGooglePayEnabled has no effect.

The void expression silences unused variable warnings but doesn't use the parameter. If the intent is to always default to monobank_invoice regardless of Google Pay availability, the parameter should be removed. Otherwise, implement the conditional logic.

♻️ Proposed fix

If Google Pay should be the default when enabled:

 function resolveDefaultMethodForProvider(args: {
   provider: CheckoutProvider;
   monobankGooglePayEnabled: boolean;
 }): CheckoutPaymentMethod {
   if (args.provider === 'stripe') return 'stripe_card';
-  void args.monobankGooglePayEnabled;
-  return 'monobank_invoice';
+  return args.monobankGooglePayEnabled ? 'monobank_google_pay' : 'monobank_invoice';
 }

Or if always defaulting to invoice is intentional, remove the unused parameter:

-function resolveDefaultMethodForProvider(args: {
-  provider: CheckoutProvider;
-  monobankGooglePayEnabled: boolean;
-}): CheckoutPaymentMethod {
+function resolveDefaultMethodForProvider(
+  provider: CheckoutProvider
+): CheckoutPaymentMethod {
-  if (args.provider === 'stripe') return 'stripe_card';
-  void args.monobankGooglePayEnabled;
+  if (provider === 'stripe') return 'stripe_card';
   return 'monobank_invoice';
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx around lines 98 - 105,
The function resolveDefaultMethodForProvider currently contains a no-op void
args.monobankGooglePayEnabled which causes dead code; either remove
monobankGooglePayEnabled from the parameter list and related type references
(CheckoutProvider/CheckoutPaymentMethod usage) if the default should always be
'monobank_invoice', or implement the conditional: inside
resolveDefaultMethodForProvider check args.monobankGooglePayEnabled when
args.provider !== 'stripe' and return the Google Pay default (e.g.,
'monobank_google_pay' or the correct CheckoutPaymentMethod enum/value used
elsewhere) when true, otherwise return 'monobank_invoice'; also update any call
sites and the function signature accordingly to keep types consistent.

1001-1034: Duplicate redirect logic for monobank_google_pay.

The redirect to the Monobank Google Pay status page is duplicated: once when monobankPageUrl exists (Lines 1002-1014) and again when it doesn't (Lines 1019-1034). Both branches perform the same redirect using statusToken. This creates dead code since the second block's condition (paymentProvider === 'monobank' && checkoutPaymentMethod === 'monobank_google_pay') without monobankPageUrl would never be reached if the first block already handles Google Pay with monobankPageUrl.

♻️ Consolidate the Google Pay redirect logic
       if (paymentProvider === 'monobank' && monobankPageUrl) {
-        if (checkoutPaymentMethod === 'monobank_google_pay') {
-          if (!statusToken) {
-            setCheckoutError(t('checkout.errors.unexpectedResponse'));
-            return;
-          }
-
-          router.push(
-            `${shopBase}/checkout/payment/monobank/${encodeURIComponent(
-              orderId
-            )}?statusToken=${encodeURIComponent(statusToken)}&clearCart=1`
-          );
-          return;
-        }
-
         window.location.assign(monobankPageUrl);
         return;
       }
       if (
         paymentProvider === 'monobank' &&
         checkoutPaymentMethod === 'monobank_google_pay'
       ) {
         if (!statusToken) {
           setCheckoutError(t('checkout.errors.unexpectedResponse'));
           return;
         }

         router.push(
           `${shopBase}/checkout/payment/monobank/${encodeURIComponent(
             orderId
           )}?statusToken=${encodeURIComponent(statusToken)}&clearCart=1`
         );
         return;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx around lines 1001 - 1034,
Duplicate redirect logic for Monobank Google Pay exists: consolidate the two
branches that handle paymentProvider === 'monobank' && checkoutPaymentMethod ===
'monobank_google_pay' into a single conditional that first validates statusToken
(call setCheckoutError(t('checkout.errors.unexpectedResponse')) and return if
missing) and then redirects using router.push to
`${shopBase}/checkout/payment/monobank/${encodeURIComponent(orderId)}?statusToken=${encodeURIComponent(statusToken)}&clearCart=1`;
keep the separate window.location.assign(monobankPageUrl) path only for
non-Google-Pay flows where monobankPageUrl is present, and remove the duplicated
second block to avoid dead code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@frontend/app/`[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx:
- Around line 113-140: The cached googlePayScriptPromise (used in
loadGooglePayScript) is never cleared on load failure, causing all future calls
to reuse a rejected promise; update the error paths (the existing element
'error' listener and the created script.onerror handler and the Promise
rejection branch) to clear or set googlePayScriptPromise to undefined before
rejecting so subsequent mounts can retry loading GOOGLE_PAY_SCRIPT_SRC; keep the
success path unchanged so the resolved promise remains cached.

In `@frontend/app/api/shop/internal/shipping/np/sync/route.ts`:
- Around line 248-273: The current debug payload built when debugEnabled is true
exposes raw error.message and sensitive deployment/config values (NP_API_BASE,
NP_API_KEY length) in the response; change the assembly of debug in route.ts
(the object built before calling noStoreJson) to never include raw messages or
raw config values: replace error.message with a sanitized field like errorType
or errorClass (use error instanceof NovaPoshtaApiError to set isNovaPoshtaError
and include only error.code/status if non-sensitive), and replace env entries
with an allowlist of non-sensitive booleans/strings (e.g., isNpApiKeyConfigured:
boolean, shopShippingNpEnabled: string|null, appEnv: string|null) while omitting
NP_API_BASE and any API key lengths; keep full error.message/stack logged to
processLogger (or existing logger) but only return the sanitized debug object in
the noStoreJson response.

In
`@frontend/app/api/shop/orders/`[id]/payment/monobank/google-pay/submit/route.ts:
- Around line 19-20: The Monobank Google Pay submit route must mint a portable
status token when the incoming query token is absent so status polling works
outside the original session; update the route handler that builds the Monobank
return URL (the code using toAbsoluteUrl and reading the query token validated
by idempotencyKeySchema/orderIdParamSchema) to create a portable token with type
"status_lite" and purpose "order_payment_init" when no token was supplied,
append that token to the return/status URL, and ensure any existing logic that
reuses the incoming query token falls back to this newly minted token for status
polling and redirects.

In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Line 11: The charge.refund.updated handler currently throws
REFUND_FULLNESS_UNDETERMINED when the webhook provides a Refund with charge as a
string (no expanded charge); update the branch in route.ts to either (preferred)
fetch/expand the charge before inspecting fullness (use the extracted
refundChargeId to call Stripe API and set effectiveCharge) or (alternate) treat
an ID-only refund payload as non-fatal by logging a warning and returning a 200
without throwing; modify the code paths that reference effectiveCharge and
REFUND_FULLNESS_UNDETERMINED accordingly (look for variables/identifiers
refundChargeId, effectiveCharge, and the REFUND_FULLNESS_UNDETERMINED error) so
the handler no longer returns HTTP 500 for ID-only refund webhooks.

In `@frontend/drizzle/0029_shop_np_warehouses_city_ref_fk.sql`:
- Around line 1-13: Before adding the new FK constraint
np_warehouses_city_ref_np_cities_ref_fk on table np_warehouses(city_ref),
sanitize existing city_ref values by nulling any refs that do not exist in
np_cities(ref); i.e., run an update to set city_ref = NULL where city_ref IS NOT
NULL AND NOT EXISTS (SELECT 1 FROM np_cities WHERE ref = np_warehouses.city_ref)
and only then add the ALTER TABLE ... ADD CONSTRAINT statement, so the ADD
CONSTRAINT cannot fail due to orphaned city_ref values.

In `@frontend/lib/psp/monobank.ts`:
- Around line 783-809: The walletPayment() parsing currently returns invoiceId:
null when the provider omits it; change walletPayment() to validate invoiceId
after extracting it (from raw.invoiceId) and throw a descriptive Error if
invoiceId is missing/empty so callers can fail fast—this keeps the contract
expected by getInvoiceStatus() and createInvoice() which require a non-null
invoiceId. Locate the invoiceId extraction in monobank.ts (the block assigning
invoiceId, status, redirectUrl and returning the object) and replace the silent
null-return with a runtime check that throws (e.g., "Monobank wallet payment
missing invoiceId") before returning the result.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts`:
- Around line 397-409: The code extracts SettlementRef into ref (via
readString(item.Ref)) and stores it in the results, but the subsequent call to
callNp for getWarehouses uses the wrong parameter name (CityRef) — change the
methodProperties key from CityRef to SettlementRef in the getWarehouses call
(the call with modelName: 'Address' and calledMethod: 'getWarehouses'), and
ensure it passes the settlement identifier variable (the same ref/settlementRef
produced from readString(item.Ref)) as the value.

In `@frontend/lib/tests/shop/monobank-api-methods.test.ts`:
- Around line 405-433: The test sets fake timers with vi.useFakeTimers() and
restores them at the end with vi.useRealTimers(), but restoration is not
guaranteed if assertions fail; wrap the test body that advances timers and
asserts (the block using vi.useFakeTimers(), vi.advanceTimersByTimeAsync(25),
the promise p, and the expect checks for PspError/code and fetchMock calls) in a
try/finally so vi.useRealTimers() is always executed; locate the walletPayment
test in monobank-api-methods.test.ts (the it('walletPayment timeout returns
PSP_TIMEOUT without retries' ...) block) and move the vi.useRealTimers() call
into the finally clause to guarantee cleanup.

In `@frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts`:
- Around line 51-54: The test reimplements IP generation in makeTestClientIp;
replace its usage with the standard helper deriveTestIpFromIdemKey from
"@/lib/tests/helpers/ip.ts": remove or delete makeTestClientIp, import
deriveTestIpFromIdemKey, and call deriveTestIpFromIdemKey(idem) (or
deriveTestIpFromIdemKey(seed/idempotency key variable used in this test)
wherever makeTestClientIp was used) so tests use the RFC‑5737 TEST‑NET‑3
addresses consistently.

In `@frontend/messages/pl.json`:
- Around line 467-471: The JSON keys monobankGooglePay, monobankInvoice,
monobankGooglePayHint, monobankGooglePayFallbackHint, and
monobankGooglePayUnavailable contain English strings and must be localized to
Polish (or removed to allow fallback to the default locale) so the checkout and
Monobank flows don't show mixed-language UI; update those values with proper
Polish translations (or omit them so the i18n system falls back) and also apply
the same fix for the other block referenced (keys around lines 732-747) to
ensure all new Monobank/checkout copy is translated consistently.

In `@frontend/messages/uk.json`:
- Around line 467-471: The new Ukrainian locale keys (monobankGooglePay,
monobankInvoice, monobankGooglePayHint, monobankGooglePayFallbackHint,
monobankGooglePayUnavailable) contain English copy; update their values to
proper Ukrainian translations (or remove them to let the default locale be used)
so the checkout and Monobank return flows aren't mixed-language; apply the same
fix to the other untranslated block referenced (the similar keys around the
other section).

In `@frontend/scripts/np-mock-server.mjs`:
- Around line 184-223: The mock NP payload is missing ShortAddress and
ShortAddressRu fields so the warehouse mapping in nova-poshta-client.ts (which
expects ShortAddress/ShortAddressRu) yields null addresses; update each
warehouse object returned by okNp (the two entries with Ref '11111111-...' and
'22222222-...') to include ShortAddress and ShortAddressRu (e.g., mirror Address
and AddressRu or provide a concise variant) so the mock aligns with production
shape.

---

Outside diff comments:
In `@frontend/lib/services/orders/monobank-webhook.ts`:
- Around line 503-522: The merge currently preserves a stale pspMetadata.wallet
because jsonb || only overwrites keys present in the RHS; modify
buildMergedMetaSql so when walletAttribution is null it first removes the wallet
key from orders.pspMetadata before merging. Concretely, keep constructing
metadataPatch without wallet when walletAttribution is null, and return SQL that
uses (coalesce(${orders.pspMetadata}, '{}'::jsonb) - 'wallet') ||
${JSON.stringify(metadataPatch)}::jsonb in that branch; when walletAttribution
is present keep the existing coalesce(...) ||
${JSON.stringify(metadataPatch)}::jsonb behavior. This will clear stale wallet
data for both atomicMarkPaidOrderAndSucceedAttempt and
atomicFinalizeOrderAndAttempt flows that call buildMergedMetaSql.

In `@frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts`:
- Around line 209-263: The fetch spy created with vi.spyOn(globalThis, 'fetch')
in the two tests (the one around the POST(makeRequest()) call and the other at
lines ~432-489) must be restored in a finally block to avoid leaking the spy on
assertion failures; wrap the part from creating fetchSpy through the POST and
subsequent assertions in try { ... } finally { fetchSpy.mockRestore(); } so that
fetchSpy.mockRestore() always runs (keep the existing assertions and calls to
POST, expect(...), and checks like
expect(retrieveChargeMock).not.toHaveBeenCalled()).

---

Nitpick comments:
In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 98-105: The function resolveDefaultMethodForProvider currently
contains a no-op void args.monobankGooglePayEnabled which causes dead code;
either remove monobankGooglePayEnabled from the parameter list and related type
references (CheckoutProvider/CheckoutPaymentMethod usage) if the default should
always be 'monobank_invoice', or implement the conditional: inside
resolveDefaultMethodForProvider check args.monobankGooglePayEnabled when
args.provider !== 'stripe' and return the Google Pay default (e.g.,
'monobank_google_pay' or the correct CheckoutPaymentMethod enum/value used
elsewhere) when true, otherwise return 'monobank_invoice'; also update any call
sites and the function signature accordingly to keep types consistent.
- Around line 1001-1034: Duplicate redirect logic for Monobank Google Pay
exists: consolidate the two branches that handle paymentProvider === 'monobank'
&& checkoutPaymentMethod === 'monobank_google_pay' into a single conditional
that first validates statusToken (call
setCheckoutError(t('checkout.errors.unexpectedResponse')) and return if missing)
and then redirects using router.push to
`${shopBase}/checkout/payment/monobank/${encodeURIComponent(orderId)}?statusToken=${encodeURIComponent(statusToken)}&clearCart=1`;
keep the separate window.location.assign(monobankPageUrl) path only for
non-Google-Pay flows where monobankPageUrl is present, and remove the duplicated
second block to avoid dead code.

In `@frontend/app/`[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx:
- Around line 40-55: Extract the duplicated helpers getStringParam,
parseStatusToken, and shouldClearCart into a single shared module (e.g.,
search-params.ts) and import them from both pages; specifically, move the
implementations of getStringParam, parseStatusToken, and shouldClearCart into
the new module, export them (and the SearchParams type), then replace the local
implementations in page.tsx files with imports of those functions to remove
duplication.

In
`@frontend/app/`[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx:
- Around line 157-163: The polling logic currently schedules setTimeout twice
and effectively always continues polling; update the poll control in the poll
function so it only reschedules when the payment is actually pending: replace
the duplicate timer scheduling with a single conditional that checks
PENDING_STATUSES.has(nextStatus.paymentStatus) and calls setTimeout(poll,
POLL_DELAY_MS) only in that case (leave existing early-return behavior for
TERMINAL_NON_PAID and paid checks intact), removing the redundant timer
assignment; refer to PENDING_STATUSES, nextStatus.paymentStatus, poll, and
POLL_DELAY_MS when making the change.
- Around line 182-185: The code bypasses type checking by casting the
translation key with "as any" in the statusLabel useMemo; instead update
getStatusLabelKey to return the correct translation key type (the union/keyof
used by your i18n `t` function) or a typed wrapper (e.g.,
`getStatusLabelKey(paymentStatus): TranslationKey`) and then remove the "as any"
cast so the call becomes `t(getStatusLabelKey(status.paymentStatus))`; ensure
the `statusLabel` useMemo signature stays the same and adjust any related types
where getStatusLabelKey is defined so TypeScript enforces valid translation
keys.

In `@frontend/app/api/shop/checkout/route.ts`:
- Around line 111-116: Duplicate implementation of isMonobankGooglePayEnabled
should be extracted to a single shared module: create a new module (e.g., export
function isMonobankGooglePayEnabled in a new monobank env module) and replace
both local implementations by importing that function; update callers in
checkout route and orders/checkout to import the shared
isMonobankGooglePayEnabled from the new module so behavior is centralized and
avoids drift.
- Around line 651-656: The default payment method is hardcoded here duplicating
logic in resolveDefaultMethodForProvider; replace the inline defaultMethod
calculation with a call to resolveDefaultMethodForProvider(selectedProvider) and
use its result when requestedMethod is null (affecting selectedMethod), keeping
selectedProvider and selectedCurrency logic intact; ensure you import
resolveDefaultMethodForProvider from "@/lib/shop/payments" and retain the
existing resolveCurrencyFromLocale(locale) usage for selectedCurrency.

In `@frontend/app/api/shop/internal/shipping/np/sync/route.ts`:
- Around line 97-99: The debug gating currently uses process.env.NODE_ENV in the
debugEnabled assignment; replace that check with a platform-aware environment
variable (e.g. process.env.VERCEL_ENV === 'preview' || process.env.APP_ENV ===
'staging') so debugEnabled reliably distinguishes preview/stage vs production;
update the expression that defines debugEnabled (the const debugEnabled and its
use of request.headers.get('x-shop-debug')) to combine the chosen env-var check
with the existing header check and trim logic so debug is enabled only when
running in a non-production app-stage (VERCEL_ENV/APP_ENV) and the x-shop-debug
header equals '1'.

In `@frontend/lib/services/orders/checkout.ts`:
- Around line 1062-1100: The catch block in the backfill branch (around the
db.update(orders).set(...) call guarded by needsMethodBackfill or
needsMetadataBackfill) only calls logWarn when process.env.DEBUG is set, which
hides update failures; change the catch to always call
logWarn('checkout_rejected', { phase: 'payment_method_backfill', orderId:
row.id, message: e instanceof Error ? e.message : String(e) }) (remove the DEBUG
conditional) so failures are logged at warn level unconditionally; keep the
existing error message extraction and do not rethrow unless you want the
backfill to fail the checkout.
- Around line 744-749: Extract the duplicated env parsing into a single utility
(e.g., parseEnvBoolean(envValue: string | undefined): boolean) and replace the
inline logic in isMonobankGooglePayEnabled and the other occurrence used by
isMonobankEnabled to call this utility; ensure parseEnvBoolean normalizes (trim,
toLowerCase) and returns true for 'true','1','yes','on', then update both
isMonobankGooglePayEnabled() and isMonobankEnabled() to delegate to
parseEnvBoolean(process.env.SHOP_MONOBANK_GPAY_ENABLED) /
parseEnvBoolean(process.env.SHOP_MONOBANK_ENABLED) respectively so behavior is
consistent and duplication removed.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts`:
- Around line 434-460: The function getWarehousesBySettlementRef is misnamed
because it uses CityRef in the API request; rename the function to
getWarehousesByCityRef and update all local references and exports accordingly
(including any wrapper functions and argument names in this module and
catalog-side wrappers) so callers clearly reflect city-based semantics; ensure
the implementation signature remains the same (settlementRef parameter should be
renamed to cityRef where present), and update any tests or imports that
reference getWarehousesBySettlementRef and the documented API name to the new
getWarehousesByCityRef; verify usages of callNp and the methodProperties.CityRef
remain correct.

In `@frontend/lib/tests/shop/checkout-shipping-phase3.test.ts`:
- Around line 159-161: The test currently uses .rejects.toMatchObject({ code:
'SHIPPING_CURRENCY_UNSUPPORTED' }) which will match any plain object with that
shape; change the assertion to capture the rejection and assert both that the
thrown value is an Error (or the expected service error type) and that it
carries the code 'SHIPPING_CURRENCY_UNSUPPORTED' — e.g. replace the single
toMatchObject chain with two assertions like await
expect(promise).rejects.toBeInstanceOf(Error) (or ShippingServiceError if that
class exists) and await expect(promise).rejects.toHaveProperty('code',
'SHIPPING_CURRENCY_UNSUPPORTED'), or alternatively await promise.catch(e => {
expect(e).toBeInstanceOf(Error);
expect(e.code).toBe('SHIPPING_CURRENCY_UNSUPPORTED'); }) so the test verifies
type and code instead of just shape.

In `@frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts`:
- Around line 145-166: The helper waitForCreatingAttempt currently throws a
generic timeout error which hides intermittent failures; update the function
(waitForCreatingAttempt) to retain the last fetched row (from the
paymentAttempts select) and include diagnostic details in the thrown error
(e.g., JSON.stringify(lastAttempt), orderId, provider, and timeoutMs) or log
them before throwing so tests show what was observed when the wait expired;
ensure you reference the same query on paymentAttempts and keep the polling
behavior otherwise unchanged.
- Around line 91-115: The test helper insertOrder uses a loose "as any" on the
db.insert(orders).values(...) payload which hides schema mismatches; replace the
cast by constructing a properly typed payload (use Drizzle's inferred insert
type for the orders table or an explicit OrderInsert type) and pass that typed
object to db.insert instead, making sure fields like totalAmount
(toDbMoney(...)), pspPaymentMethod, and pspMetadata.checkout.requestedMethod
match the orders schema; update the insertOrder function signature to use the
typed payload so the compiler will catch any schema drift.

In `@frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts`:
- Around line 291-313: Add an assertion that the stripeEvents row for this
webhook still exists and remains unprocessed (processedAt is NULL) so the event
stays retryable; query the stripeEvents table (using the stripeEvents symbol)
for the test event id (e.g., use the previously created inserted.eventId or
inserted.id as the identifier) and assert the row is found and that its
processedAt column is null.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3734e522-0919-42fc-9d42-6a96c4f7513f

📥 Commits

Reviewing files that changed from the base of the PR and between f909b9a and 04e7f5f.

📒 Files selected for processing (63)
  • frontend/app/[locale]/shop/cart/CartPageClient.tsx
  • frontend/app/[locale]/shop/cart/page.tsx
  • frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx
  • frontend/app/[locale]/shop/checkout/payment/monobank/[orderId]/page.tsx
  • frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx
  • frontend/app/[locale]/shop/checkout/return/monobank/page.tsx
  • frontend/app/api/shop/checkout/route.ts
  • frontend/app/api/shop/internal/shipping/np/sync/route.ts
  • frontend/app/api/shop/orders/[id]/payment/monobank/_shared.ts
  • frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/config/route.ts
  • frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts
  • frontend/app/api/shop/orders/[id]/payment/monobank/invoice/route.ts
  • frontend/app/api/shop/orders/[id]/status/route.ts
  • frontend/app/api/shop/shipping/methods/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/components/shop/header/CartButton.tsx
  • frontend/db/queries/shop/admin-orders.ts
  • frontend/db/schema/shop.ts
  • frontend/drizzle.config.ts
  • frontend/drizzle/0029_shop_np_warehouses_city_ref_fk.sql
  • frontend/drizzle/meta/0029_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/psp/monobank.ts
  • frontend/lib/psp/stripe.ts
  • frontend/lib/services/orders/_shared.ts
  • frontend/lib/services/orders/checkout.ts
  • frontend/lib/services/orders/monobank-wallet.ts
  • frontend/lib/services/orders/monobank-webhook.ts
  • frontend/lib/services/shop/notifications/outbox-worker.ts
  • frontend/lib/services/shop/quotes.ts
  • frontend/lib/services/shop/returns.ts
  • frontend/lib/services/shop/shipping/admin-actions.ts
  • frontend/lib/services/shop/shipping/inventory-eligibility.ts
  • frontend/lib/services/shop/shipping/metrics.ts
  • frontend/lib/services/shop/shipping/nova-poshta-catalog.ts
  • frontend/lib/services/shop/shipping/nova-poshta-client.ts
  • frontend/lib/services/shop/transitions/order-state.ts
  • frontend/lib/shop/payments.ts
  • frontend/lib/tests/shop/checkout-currency-policy.test.ts
  • frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts
  • frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts
  • frontend/lib/tests/shop/checkout-shipping-phase3.test.ts
  • frontend/lib/tests/shop/monobank-api-methods.test.ts
  • frontend/lib/tests/shop/monobank-google-pay-config-route.test.ts
  • frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts
  • frontend/lib/tests/shop/monobank-janitor-job3.test.ts
  • frontend/lib/tests/shop/monobank-wallet-service.test.ts
  • frontend/lib/tests/shop/monobank-webhook-apply.test.ts
  • frontend/lib/tests/shop/notifications-worker-transport-phase3.test.ts
  • frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts
  • frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts
  • frontend/lib/tests/shop/order-payment-init-token-scope-phase7.test.ts
  • frontend/lib/tests/shop/order-status-token.test.ts
  • frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts
  • frontend/lib/tests/shop/shipping-retention-phase7.test.ts
  • frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts
  • frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts
  • frontend/lib/validation/shop.ts
  • frontend/messages/en.json
  • frontend/messages/pl.json
  • frontend/messages/uk.json
  • frontend/proxy.ts
  • frontend/scripts/np-mock-server.mjs
💤 Files with no reviewable changes (3)
  • frontend/lib/tests/shop/product-prices-write-authority-phase8.test.ts
  • frontend/lib/services/shop/notifications/outbox-worker.ts
  • frontend/lib/services/shop/shipping/metrics.ts

Comment on lines +1 to +13
ALTER TABLE np_warehouses
DROP CONSTRAINT IF EXISTS np_warehouses_settlement_ref_np_cities_ref_fk;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'np_warehouses_city_ref_np_cities_ref_fk'
) THEN
ALTER TABLE np_warehouses
ADD CONSTRAINT np_warehouses_city_ref_np_cities_ref_fk
FOREIGN KEY (city_ref) REFERENCES np_cities(ref)
ON DELETE SET NULL;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize existing city_ref data before enforcing the FK.

city_ref was previously unconstrained, so one orphaned value is enough to make this ADD CONSTRAINT fail on deploy. Backfill or null invalid refs in the migration before adding the foreign key.

Suggested migration hardening
 ALTER TABLE np_warehouses
   DROP CONSTRAINT IF EXISTS np_warehouses_settlement_ref_np_cities_ref_fk;
 
+UPDATE np_warehouses w
+SET city_ref = NULL
+WHERE city_ref IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1
+    FROM np_cities c
+    WHERE c.ref = w.city_ref
+  );
+
 DO $$
 BEGIN
   IF NOT EXISTS (
     SELECT 1 FROM pg_constraint
     WHERE conname = 'np_warehouses_city_ref_np_cities_ref_fk'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ALTER TABLE np_warehouses
DROP CONSTRAINT IF EXISTS np_warehouses_settlement_ref_np_cities_ref_fk;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'np_warehouses_city_ref_np_cities_ref_fk'
) THEN
ALTER TABLE np_warehouses
ADD CONSTRAINT np_warehouses_city_ref_np_cities_ref_fk
FOREIGN KEY (city_ref) REFERENCES np_cities(ref)
ON DELETE SET NULL;
ALTER TABLE np_warehouses
DROP CONSTRAINT IF EXISTS np_warehouses_settlement_ref_np_cities_ref_fk;
UPDATE np_warehouses w
SET city_ref = NULL
WHERE city_ref IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM np_cities c
WHERE c.ref = w.city_ref
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'np_warehouses_city_ref_np_cities_ref_fk'
) THEN
ALTER TABLE np_warehouses
ADD CONSTRAINT np_warehouses_city_ref_np_cities_ref_fk
FOREIGN KEY (city_ref) REFERENCES np_cities(ref)
ON DELETE SET NULL;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/drizzle/0029_shop_np_warehouses_city_ref_fk.sql` around lines 1 -
13, Before adding the new FK constraint np_warehouses_city_ref_np_cities_ref_fk
on table np_warehouses(city_ref), sanitize existing city_ref values by nulling
any refs that do not exist in np_cities(ref); i.e., run an update to set
city_ref = NULL where city_ref IS NOT NULL AND NOT EXISTS (SELECT 1 FROM
np_cities WHERE ref = np_warehouses.city_ref) and only then add the ALTER TABLE
... ADD CONSTRAINT statement, so the ADD CONSTRAINT cannot fail due to orphaned
city_ref values.

Comment on lines 184 to 223
return json(
res,
200,
okNp([
{
Ref: '11111111-1111-1111-1111-111111111111',
Description: 'Warehouse #1 (mock)',
CityRef: cityRef,
SettlementRef: cityRef,

Number: '1',
TypeOfWarehouse: 'Warehouse',

Description: 'Відділення №1 (mock)',
DescriptionRu: 'Отделение №1 (mock)',

Address: 'вул. Хрещатик, 1',
AddressRu: 'ул. Крещатик, 1',

PostMachine: '0',
IsPostMachine: false,
CategoryOfWarehouse: 'Branch',
},
{
Ref: '22222222-2222-2222-2222-222222222222',
CityRef: cityRef,
SettlementRef: cityRef,

Number: '2',
TypeOfWarehouse: 'Postomat',

Description: 'Поштомат №1 (mock)',
DescriptionRu: 'Почтомат №1 (mock)',

Address: 'вул. Хрещатик, 2',
AddressRu: 'ул. Крещатик, 2',

PostMachine: '1',
IsPostMachine: true,
CategoryOfWarehouse: 'Postomat',
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Expose ShortAddress in the mock payload as well.

frontend/lib/services/shop/shipping/nova-poshta-client.ts still maps warehouses from ShortAddress / ShortAddressRu. With only Address / AddressRu here, mock-backed sync writes address = null, so address search and local UI coverage drift from production.

Suggested mock payload fix
         {
           Ref: '11111111-1111-1111-1111-111111111111',
           CityRef: cityRef,
           SettlementRef: cityRef,
@@
           Description: 'Відділення №1 (mock)',
           DescriptionRu: 'Отделение №1 (mock)',
 
           Address: 'вул. Хрещатик, 1',
           AddressRu: 'ул. Крещатик, 1',
+          ShortAddress: 'вул. Хрещатик, 1',
+          ShortAddressRu: 'ул. Крещатик, 1',
@@
         {
           Ref: '22222222-2222-2222-2222-222222222222',
           CityRef: cityRef,
           SettlementRef: cityRef,
@@
           Description: 'Поштомат №1 (mock)',
           DescriptionRu: 'Почтомат №1 (mock)',
 
           Address: 'вул. Хрещатик, 2',
           AddressRu: 'ул. Крещатик, 2',
+          ShortAddress: 'вул. Хрещатик, 2',
+          ShortAddressRu: 'ул. Крещатик, 2',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/scripts/np-mock-server.mjs` around lines 184 - 223, The mock NP
payload is missing ShortAddress and ShortAddressRu fields so the warehouse
mapping in nova-poshta-client.ts (which expects ShortAddress/ShortAddressRu)
yields null addresses; update each warehouse object returned by okNp (the two
entries with Ref '11111111-...' and '22222222-...') to include ShortAddress and
ShortAddressRu (e.g., mirror Address and AddressRu or provide a concise variant)
so the mock aligns with production shape.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/lib/services/orders/checkout.ts (1)

875-882: ⚠️ Potential issue | 🟠 Major

Honor an explicit paymentProvider: 'none'.

requestedProvider === 'none' currently falls through to Stripe whenever Stripe is enabled, so an explicit no-PSP checkout still creates a Stripe-backed order and never reaches the no-payment reconciliation path.

💡 Suggested fix
   const paymentProvider: PaymentProvider =
-    requestedProvider === 'monobank'
-      ? 'monobank'
-      : stripePaymentsEnabled
-        ? 'stripe'
-        : 'none';
-  const paymentsEnabled =
-    paymentProvider === 'monobank' ? true : stripePaymentsEnabled;
+    requestedProvider === 'none'
+      ? 'none'
+      : requestedProvider === 'monobank'
+        ? 'monobank'
+        : stripePaymentsEnabled
+          ? 'stripe'
+          : 'none';
+  const paymentsEnabled = paymentProvider !== 'none';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/orders/checkout.ts` around lines 875 - 882, The logic
computing paymentProvider and paymentsEnabled wrongly treats requestedProvider
=== 'none' as fallthrough to Stripe; update the selection so paymentProvider is
exactly requestedProvider when it is one of 'monobank'|'stripe'|'none' (or
explicitly check for 'none') and only choose 'stripe' as a fallback when
requestedProvider is not provided and stripePaymentsEnabled is true; also
compute paymentsEnabled as paymentProvider !== 'none' && (paymentProvider ===
'monobank' || stripePaymentsEnabled) so an explicit requestedProvider === 'none'
produces paymentProvider === 'none' and paymentsEnabled === false.
frontend/app/[locale]/shop/cart/CartPageClient.tsx (1)

1523-1532: ⚠️ Potential issue | 🟡 Minor

Collapse the warehouse suggestions after a selection.

This handler keeps warehouseOptions populated, so the dropdown stays open and the follow-up fetch can repopulate it even though a warehouse is already chosen.

💡 Minimal fix
                           onClick={() => {
                             clearCheckoutUiErrors();
                             setSelectedWarehouseRef(warehouse.ref);
                             setSelectedWarehouseName(warehouse.name);
                             setWarehouseQuery(
                               warehouse.address
                                 ? `${warehouse.name} (${warehouse.address})`
                                 : warehouse.name
                             );
+                            setWarehouseOptions([]);
                           }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx around lines 1523 - 1532,
The click handler selects a warehouse but leaves warehouseOptions populated so
the suggestions dropdown remains open; update the handler (alongside
clearCheckoutUiErrors, setSelectedWarehouseRef, setSelectedWarehouseName,
setWarehouseQuery) to collapse the suggestions by clearing the options and/or
hiding the list—e.g. call setWarehouseOptions([]) and/or
setShowWarehouseOptions(false) after setting the selected values so the dropdown
closes and subsequent fetches won't keep it open.
♻️ Duplicate comments (1)
frontend/lib/psp/monobank.ts (1)

342-366: ⚠️ Potential issue | 🟡 Minor

Tighten wallet payload normalization before the PSP call.

cardToken is validated against trim() but the untrimmed value is still sent, and this helper accepts any positive ccy even though the Monobank integration here is UAH-only elsewhere. Failing fast here avoids avoidable upstream 4xx responses.

💡 Minimal fix
 export function buildMonobankWalletPayload(
   args: MonobankWalletPaymentInput
 ): MonobankWalletPaymentRequest {
-  if (typeof args.cardToken !== 'string' || !args.cardToken.trim()) {
+  const cardToken =
+    typeof args.cardToken === 'string' ? args.cardToken.trim() : '';
+  if (!cardToken) {
     throw new Error('wallet cardToken is required');
   }

   if (!Number.isSafeInteger(args.amountMinor) || args.amountMinor <= 0) {
     throw new Error('Invalid wallet amount (minor units)');
   }

-  if (!Number.isSafeInteger(args.ccy) || args.ccy <= 0) {
-    throw new Error('Invalid wallet currency code');
+  if (args.ccy !== MONO_CCY) {
+    throw new Error(`Monobank wallet payments require ccy=${MONO_CCY}`);
   }

   const redirectUrl = args.redirectUrl.trim();
   const webHookUrl = args.webHookUrl.trim();
   if (!redirectUrl || !webHookUrl) {
     throw new Error('wallet redirectUrl and webHookUrl are required');
   }

   return {
-    cardToken: args.cardToken,
+    cardToken,
     amount: args.amountMinor,
     ccy: args.ccy,
     initiationKind: 'client',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/psp/monobank.ts` around lines 342 - 366, Trim and normalize the
wallet payload before returning: compute and use a trimmed cardToken (e.g.,
const cardToken = args.cardToken.trim()) instead of sending the untrimmed
args.cardToken, and tighten currency validation to only accept UAH (require
args.ccy === 980 or compare against a MONOBANK_UAH constant) rather than any
positive integer; keep the existing amountMinor and URL checks but ensure
redirectUrl and webHookUrl are derived from their trimmed values when building
the returned object (use cardToken, amount: args.amountMinor, ccy: 980,
initiationKind: 'client', redirectUrl, webHookUrl).
🧹 Nitpick comments (5)
frontend/lib/tests/shop/monobank-api-methods.test.ts (1)

304-311: Drop initiationKind from the test input.

buildMonobankWalletPayload() already hardcodes initiationKind: 'client' in frontend/lib/psp/monobank.ts:339-368. Passing it here makes this test less useful, because it no longer proves that walletPayment is enforcing that field itself.

Suggested fix
     const result = await walletPayment({
       cardToken: 'token-value',
       amountMinor: 1234,
       ccy: 980,
-      initiationKind: 'client',
       redirectUrl: 'https://shop.test/return/monobank',
       webHookUrl: 'https://shop.test/api/shop/webhooks/monobank',
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/monobank-api-methods.test.ts` around lines 304 - 311,
The test is passing initiationKind explicitly which masks enforcement in
walletPayment; remove the initiationKind property from the test payload in
monobank-api-methods.test.ts so walletPayment/buildMonobankWalletPayload must
supply initiationKind itself (see walletPayment and buildMonobankWalletPayload),
then run the test to confirm behavior still passes and that initiationKind is
set by the code under test.
frontend/lib/services/shop/shipping/nova-poshta-client.ts (2)

434-482: Pagination fallback logic is well-designed but could benefit from a comment.

The fallback from LIMIT_PRIMARY=500 to LIMIT_FALLBACK=200 on empty first-page results is a good defensive pattern for APIs that may reject large page sizes. However, the reasoning isn't immediately obvious.

Consider adding a brief comment explaining why this fallback exists (e.g., some NP endpoints may return empty results for large limits).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts` around lines 434 -
482, Add a short clarifying comment above the first-page fallback logic in
getWarehousesByCityRef explaining why LIMIT_PRIMARY (500) is reduced to
LIMIT_FALLBACK (200) when the first page returns empty — note that some Nova
Poshta endpoints can return empty results for large page sizes so we retry with
a smaller limit by resetting page to 1 and setting firstPageAttempted; reference
the LIMIT_PRIMARY, LIMIT_FALLBACK, firstPageAttempted flag and the page reset
behavior so the intent is clear to future readers.

419-433: Hardcoded error code may be fragile.

The fallback to searchSettlementsByCityName is triggered only by the exact error code '20000900746'. While the comment explains this is based on observed behavior, hardcoded error codes can be brittle if Nova Poshta changes their API responses.

Consider:

  1. Adding a constant with a descriptive name for documentation
  2. Logging when the fallback is triggered for observability
🔧 Suggested improvement
+const NP_CITIES_NOT_FOUND_ERROR_CODE = '20000900746';
+
 export async function searchSettlements(args: {
   q: string;
   page?: number;
   limit?: number;
 }): Promise<NovaPoshtaSettlement[]> {
   try {
     return await getCitiesByFindByString(args);
   } catch (err) {
-    // precise fallback for the exact NP error we saw in debug:
-    if (err instanceof NovaPoshtaApiError && err.code === '20000900746') {
+    // Fallback for NP error when getCities finds no matches
+    if (err instanceof NovaPoshtaApiError && err.code === NP_CITIES_NOT_FOUND_ERROR_CODE) {
       return await searchSettlementsByCityName(args);
     }
     throw err;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts` around lines 419 -
433, Replace the hardcoded Nova Poshta error code in searchSettlements with a
named constant (e.g. NOVA_POSHTA_FALLBACK_ERROR_CODE = '20000900746') and use
that constant in the err.code comparison inside the catch block of
searchSettlements; additionally, add an observability log statement (using the
existing logger in this module—e.g., processLogger or the module's logger) right
before calling searchSettlementsByCityName to record that the fallback path was
triggered and include the error details and args.q for context. Ensure the
constant is declared near related functions and exported/typed appropriately if
used elsewhere, and keep the matching logic to err instanceof NovaPoshtaApiError
&& err.code === NOVA_POSHTA_FALLBACK_ERROR_CODE.
frontend/app/api/shop/internal/shipping/np/sync/route.ts (1)

107-120: Consider defaulting to true when both environment variables are unset.

When both APP_ENV and VERCEL_ENV are empty/unset, the function returns false, which treats the environment as production. This is a safe default (fail-closed), but the naming isNonProductionAppStage might be misleading since an unconfigured environment would be considered "production" by this logic.

If this is intentional for safety, a brief comment would clarify the design choice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/internal/shipping/np/sync/route.ts` around lines 107 -
120, The current isNonProductionAppStage function treats an unset APP_ENV and
VERCEL_ENV as production (returns false); change it to default to non-production
(return true) when both environment variables are empty/unset by checking
APP_ENV and VERCEL_ENV (process.env.APP_ENV and process.env.VERCEL_ENV) after
trimming/lowering and returning true if both are falsy, otherwise keep the
existing logic (APP_ENV !== 'production' && APP_ENV !== 'prod' or VERCEL_ENV !==
'production'); update the function implementation and add a short comment
explaining the deliberate default-to-non-production behavior.
frontend/app/api/shop/webhooks/stripe/route.ts (1)

298-329: Consider omitting wallet attribution when type is null.

Currently buildStripeWalletAttribution always returns an object, adding wallet: { provider: 'stripe', type: null, source: 'event' } to metadata even for non-wallet payments. This adds noise to stored data.

♻️ Optional: Return null when no wallet detected
 function buildStripeWalletAttribution(args: {
   paymentIntent?: Stripe.PaymentIntent;
   charge?: Stripe.Charge;
-}): StripeWalletAttribution {
-  return {
-    provider: 'stripe',
-    type: resolveStripeWalletType(args.paymentIntent, args.charge),
-    source: 'event',
-  };
+}): StripeWalletAttribution | null {
+  const type = resolveStripeWalletType(args.paymentIntent, args.charge);
+  if (!type) return null;
+  return { provider: 'stripe', type, source: 'event' };
 }

Then at call sites, conditionally include:

extra: {
  ...(walletAttribution && { wallet: walletAttribution }),
  // ...
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 298 - 329,
buildStripeWalletAttribution currently always returns a StripeWalletAttribution
object even when resolveStripeWalletType returns null, causing wallet: {type:
null} to be stored; change buildStripeWalletAttribution to return
StripeWalletAttribution | null (call resolveStripeWalletType and if it returns
null, return null), update its signature and any callers to conditionally
include the wallet field (e.g., ...(walletAttribution && { wallet:
walletAttribution })) so wallet metadata is omitted for non-wallet payments;
reference resolveStripeWalletType and buildStripeWalletAttribution when making
these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 576-580: The code currently treats non-OK responses and
parsed.available === false as "no results" by calling setCityOptions([]);
instead introduce and set a separate lookup-failure state (e.g.,
cityLookupFailed or cityLookupError) when response.ok is false or
parsed.available === false, avoid clearing cityOptions on transient failures,
and update the UI logic that decides "noResults" to only use an empty
cityOptions when the lookup succeeded; apply the same change for the duplicate
logic block referenced around the other occurrence (lines 1449-1456) so both the
setCityOptions([])/parsed.available checks and the UI noResults condition are
aligned.

In
`@frontend/app/`[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx:
- Around line 370-385: The current branch ignores the API's returnUrl and sends
all non-redirect responses to goToPending(), which loses the minted token and
hides hard submit errors; update the logic in MonobankGooglePayClient (the block
that builds data, redirectUrl and checks response.ok) to: 1) prefer and navigate
to data.returnUrl (if typeof data?.returnUrl === 'string' and non-empty) before
falling back to redirectUrl; 2) only call goToPending() for true pending flows
when the API indicates pending (or when a pending returnUrl is present and
used); and 3) do not route non-ok responses (response.ok === false) to
goToPending—surface an error (throw or navigate to your error handler) so
4xx/5xx failures are not shown as “processing payment.” Ensure you reference and
update the variables/data checks (data, redirectUrl, returnUrl) and the calls to
window.location.assign(...) and goToPending() accordingly.

In
`@frontend/app/`[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx:
- Around line 41-46: TERMINAL_NON_PAID currently omits the 'requires_payment'
status causing the return page to poll indefinitely; update the
TERMINAL_NON_PAID Set in MonobankReturnStatus.tsx to include 'requires_payment'
and make the identical change to the other equivalent terminal-status set used
later in the file (the second definition around the return-status handling) so
that orders requiring payment are treated as terminal and trigger the checkout
error/retry flow.

In `@frontend/drizzle/meta/_journal.json`:
- Around line 208-220: The migrations run in the wrong order: tag
"0030_shop_np_warehouses_city_ref_fk_backfill" (data cleanup) is scheduled after
tag "0029_shop_np_warehouses_city_ref_fk" (adds the FK), which causes 0029 to
fail on drifted DBs. Fix by either folding the cleanup SQL into the start of the
"0029_shop_np_warehouses_city_ref_fk" migration (run the orphaned
np_warehouses.city_ref cleanup statements immediately before the ALTER TABLE/add
constraint in migration 0029) or regenerate/reorder the journal so
"0030_shop_np_warehouses_city_ref_fk_backfill" runs before
"0029_shop_np_warehouses_city_ref_fk" (ensure the backfill/DELETE/UPDATE runs
first, then the FK creation). Ensure the migration that adds the FK references
the cleaned state and include idempotent checks in the cleanup to be safe on
repeated runs.

In `@frontend/lib/services/orders/checkout.ts`:
- Around line 1007-1017: existingMethod can default to the provider instead of
using a stored choice in pspMetadata, causing wrong hashing; modify the
existingMethod resolution so that after calling
normalizeStoredPaymentMethod(row.pspPaymentMethod) you also check
row.pspMetadata?.checkout?.requestedMethod (parse/use the stored
checkout.requestedMethod value) and normalize that before falling back to
resolveDefaultMethodForProvider; update the logic around existingMethod (and the
same pattern in the later block 1019-1050) to prefer the stored
pspMetadata.checkout.requestedMethod when present, using the same normalization
routine as normalizeStoredPaymentMethod.

In `@frontend/lib/tests/shop/checkout-shipping-phase3.test.ts`:
- Around line 15-16: The test must assert the specific error class instead of a
generic Error: update the failing assertions that only check `.code` to assert
the thrown error is an instance of InvalidPayloadError (e.g.
expect(err).toBeInstanceOf(InvalidPayloadError)); add or update the import to
bring in InvalidPayloadError if missing, and apply the same change for the
second occurrence mentioned (lines ~159-163) so createOrderWithItems path and
any API handler-specific instanceof InvalidPayloadError handling remain covered.

In `@frontend/lib/tests/shop/monobank-janitor-job1.test.ts`:
- Around line 289-292: The current assertions check that getInvoiceStatusMock
was called at least twice overall and that invoiceId appeared at least once;
tighten this to assert the mock was invoked for this specific invoiceId at least
twice by counting calls where call[0] === invoiceId and expecting that count to
be >= 2 (update the assertions around getInvoiceStatusMock and invoiceId to
compute and assert on that per-invoice call count).

---

Outside diff comments:
In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 1523-1532: The click handler selects a warehouse but leaves
warehouseOptions populated so the suggestions dropdown remains open; update the
handler (alongside clearCheckoutUiErrors, setSelectedWarehouseRef,
setSelectedWarehouseName, setWarehouseQuery) to collapse the suggestions by
clearing the options and/or hiding the list—e.g. call setWarehouseOptions([])
and/or setShowWarehouseOptions(false) after setting the selected values so the
dropdown closes and subsequent fetches won't keep it open.

In `@frontend/lib/services/orders/checkout.ts`:
- Around line 875-882: The logic computing paymentProvider and paymentsEnabled
wrongly treats requestedProvider === 'none' as fallthrough to Stripe; update the
selection so paymentProvider is exactly requestedProvider when it is one of
'monobank'|'stripe'|'none' (or explicitly check for 'none') and only choose
'stripe' as a fallback when requestedProvider is not provided and
stripePaymentsEnabled is true; also compute paymentsEnabled as paymentProvider
!== 'none' && (paymentProvider === 'monobank' || stripePaymentsEnabled) so an
explicit requestedProvider === 'none' produces paymentProvider === 'none' and
paymentsEnabled === false.

---

Duplicate comments:
In `@frontend/lib/psp/monobank.ts`:
- Around line 342-366: Trim and normalize the wallet payload before returning:
compute and use a trimmed cardToken (e.g., const cardToken =
args.cardToken.trim()) instead of sending the untrimmed args.cardToken, and
tighten currency validation to only accept UAH (require args.ccy === 980 or
compare against a MONOBANK_UAH constant) rather than any positive integer; keep
the existing amountMinor and URL checks but ensure redirectUrl and webHookUrl
are derived from their trimmed values when building the returned object (use
cardToken, amount: args.amountMinor, ccy: 980, initiationKind: 'client',
redirectUrl, webHookUrl).

---

Nitpick comments:
In `@frontend/app/api/shop/internal/shipping/np/sync/route.ts`:
- Around line 107-120: The current isNonProductionAppStage function treats an
unset APP_ENV and VERCEL_ENV as production (returns false); change it to default
to non-production (return true) when both environment variables are empty/unset
by checking APP_ENV and VERCEL_ENV (process.env.APP_ENV and
process.env.VERCEL_ENV) after trimming/lowering and returning true if both are
falsy, otherwise keep the existing logic (APP_ENV !== 'production' && APP_ENV
!== 'prod' or VERCEL_ENV !== 'production'); update the function implementation
and add a short comment explaining the deliberate default-to-non-production
behavior.

In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 298-329: buildStripeWalletAttribution currently always returns a
StripeWalletAttribution object even when resolveStripeWalletType returns null,
causing wallet: {type: null} to be stored; change buildStripeWalletAttribution
to return StripeWalletAttribution | null (call resolveStripeWalletType and if it
returns null, return null), update its signature and any callers to
conditionally include the wallet field (e.g., ...(walletAttribution && { wallet:
walletAttribution })) so wallet metadata is omitted for non-wallet payments;
reference resolveStripeWalletType and buildStripeWalletAttribution when making
these changes.

In `@frontend/lib/services/shop/shipping/nova-poshta-client.ts`:
- Around line 434-482: Add a short clarifying comment above the first-page
fallback logic in getWarehousesByCityRef explaining why LIMIT_PRIMARY (500) is
reduced to LIMIT_FALLBACK (200) when the first page returns empty — note that
some Nova Poshta endpoints can return empty results for large page sizes so we
retry with a smaller limit by resetting page to 1 and setting
firstPageAttempted; reference the LIMIT_PRIMARY, LIMIT_FALLBACK,
firstPageAttempted flag and the page reset behavior so the intent is clear to
future readers.
- Around line 419-433: Replace the hardcoded Nova Poshta error code in
searchSettlements with a named constant (e.g. NOVA_POSHTA_FALLBACK_ERROR_CODE =
'20000900746') and use that constant in the err.code comparison inside the catch
block of searchSettlements; additionally, add an observability log statement
(using the existing logger in this module—e.g., processLogger or the module's
logger) right before calling searchSettlementsByCityName to record that the
fallback path was triggered and include the error details and args.q for
context. Ensure the constant is declared near related functions and
exported/typed appropriately if used elsewhere, and keep the matching logic to
err instanceof NovaPoshtaApiError && err.code ===
NOVA_POSHTA_FALLBACK_ERROR_CODE.

In `@frontend/lib/tests/shop/monobank-api-methods.test.ts`:
- Around line 304-311: The test is passing initiationKind explicitly which masks
enforcement in walletPayment; remove the initiationKind property from the test
payload in monobank-api-methods.test.ts so
walletPayment/buildMonobankWalletPayload must supply initiationKind itself (see
walletPayment and buildMonobankWalletPayload), then run the test to confirm
behavior still passes and that initiationKind is set by the code under test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5eece10d-5566-4aed-b3cf-751ea140e27d

📥 Commits

Reviewing files that changed from the base of the PR and between 04e7f5f and 472d76f.

📒 Files selected for processing (26)
  • frontend/app/[locale]/shop/cart/CartPageClient.tsx
  • frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx
  • frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx
  • frontend/app/api/shop/internal/shipping/np/sync/route.ts
  • frontend/app/api/shop/orders/[id]/payment/monobank/google-pay/submit/route.ts
  • frontend/app/api/shop/shipping/np/warehouses/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/drizzle/0030_shop_np_warehouses_city_ref_fk_backfill.sql
  • frontend/drizzle/meta/0030_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/psp/monobank.ts
  • frontend/lib/services/orders/checkout.ts
  • frontend/lib/services/orders/monobank-webhook.ts
  • frontend/lib/services/shop/shipping/nova-poshta-catalog.ts
  • frontend/lib/services/shop/shipping/nova-poshta-client.ts
  • frontend/lib/tests/shop/checkout-shipping-phase3.test.ts
  • frontend/lib/tests/shop/monobank-api-methods.test.ts
  • frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts
  • frontend/lib/tests/shop/monobank-janitor-job1.test.ts
  • frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts
  • frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts
  • frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts
  • frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts
  • frontend/messages/en.json
  • frontend/messages/pl.json
  • frontend/messages/uk.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/lib/tests/shop/monobank-google-pay-submit-route.test.ts
  • frontend/lib/services/orders/monobank-webhook.ts

Comment on lines +41 to +46
const TERMINAL_NON_PAID = new Set([
'failed',
'refunded',
'canceled',
'needs_review',
]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat requires_payment as terminal here.

payment_status includes requires_payment, but this set omits it. When the order falls back to a retry-required state, the return page keeps polling forever instead of sending the user to the checkout error/retry flow.

💡 Suggested fix
 const TERMINAL_NON_PAID = new Set([
   'failed',
   'refunded',
+  'requires_payment',
   'canceled',
   'needs_review',
 ]);

Also applies to: 157-164

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx
around lines 41 - 46, TERMINAL_NON_PAID currently omits the 'requires_payment'
status causing the return page to poll indefinitely; update the
TERMINAL_NON_PAID Set in MonobankReturnStatus.tsx to include 'requires_payment'
and make the identical change to the other equivalent terminal-status set used
later in the file (the second definition around the return-status handling) so
that orders requiring payment are treated as terminal and trigger the checkout
error/retry flow.

Comment on lines +208 to +220
{
"idx": 29,
"version": "7",
"when": 1772756436792,
"tag": "0029_shop_np_warehouses_city_ref_fk",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1772764398365,
"tag": "0030_shop_np_warehouses_city_ref_fk_backfill",
"breakpoints": true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Backfill runs too late to save broken environments.

This journal schedules 0030_shop_np_warehouses_city_ref_fk_backfill after 0029_shop_np_warehouses_city_ref_fk, but frontend/drizzle/0029_shop_np_warehouses_city_ref_fk.sql:1-16 already tries to add the FK before orphaned np_warehouses.city_ref rows are cleaned up. On any database with drifted data, migration 0029 can fail and block 0030 from ever running. Please fold the cleanup into 0029 or regenerate the sequence so the data repair runs before the FK is created.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/drizzle/meta/_journal.json` around lines 208 - 220, The migrations
run in the wrong order: tag "0030_shop_np_warehouses_city_ref_fk_backfill" (data
cleanup) is scheduled after tag "0029_shop_np_warehouses_city_ref_fk" (adds the
FK), which causes 0029 to fail on drifted DBs. Fix by either folding the cleanup
SQL into the start of the "0029_shop_np_warehouses_city_ref_fk" migration (run
the orphaned np_warehouses.city_ref cleanup statements immediately before the
ALTER TABLE/add constraint in migration 0029) or regenerate/reorder the journal
so "0030_shop_np_warehouses_city_ref_fk_backfill" runs before
"0029_shop_np_warehouses_city_ref_fk" (ensure the backfill/DELETE/UPDATE runs
first, then the FK creation). Ensure the migration that adds the FK references
the cleaned state and include idempotent checks in the cleanup to be safe on
repeated runs.

Comment on lines +15 to +16
import { IdempotencyConflictError } from '@/lib/services/errors';
import { createOrderWithItems } from '@/lib/services/orders';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the typed error assertion here.

This test now accepts any Error with the right .code, but createOrderWithItems still throws InvalidPayloadError on this path, and the API handler still has special instanceof InvalidPayloadError handling. A regression to a plain Error would now pass unnoticed.

Suggested fix
-import { IdempotencyConflictError } from '@/lib/services/errors';
+import {
+  IdempotencyConflictError,
+  InvalidPayloadError,
+} from '@/lib/services/errors';-      await expect(promise).rejects.toBeInstanceOf(Error);
+      await expect(promise).rejects.toBeInstanceOf(InvalidPayloadError);
       await expect(promise).rejects.toHaveProperty(
         'code',
         'SHIPPING_CURRENCY_UNSUPPORTED'
       );

Also applies to: 159-163

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/checkout-shipping-phase3.test.ts` around lines 15 -
16, The test must assert the specific error class instead of a generic Error:
update the failing assertions that only check `.code` to assert the thrown error
is an instance of InvalidPayloadError (e.g.
expect(err).toBeInstanceOf(InvalidPayloadError)); add or update the import to
bring in InvalidPayloadError if missing, and apply the same change for the
second occurrence mentioned (lines ~159-163) so createOrderWithItems path and
any API handler-specific instanceof InvalidPayloadError handling remain covered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx (1)

347-352: Consider adding user feedback when token extraction fails.

When gToken is null after Google Pay returns data, the user is silently redirected to pending. While this may be intentional (letting webhook finalize), a brief UI message before redirect could improve UX.

💡 Optional: Add transient message before pending redirect
       const gToken = extractGToken(paymentData);
       if (!gToken) {
         submitIdempotencyKeyRef.current = null;
+        setUiMessage(t('monobankGooglePay.processingFallback'));
         goToPending();
         return;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@frontend/app/`[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx
around lines 347 - 352, When extractGToken(paymentData) returns null you
currently reset submitIdempotencyKeyRef.current and call goToPending() silently;
change this so you display a brief user-facing message (e.g., via existing
showToast/setNotification or a local state like setErrorMessage) indicating
token extraction failed and the order is being finalized, then clear
submitIdempotencyKeyRef.current and call goToPending() after showing the message
(either immediately if using a non-blocking toast or after a short
timeout/acknowledgement if using an inline banner). Locate the null-check around
extractGToken, update the branch to invoke the app’s notification API or set
component state before calling goToPending, and ensure existing behavior of
resetting submitIdempotencyKeyRef.current is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 98-103: The client-side resolveDefaultMethodForProvider currently
ignores currency and always returns 'monobank_invoice' for Monobank; update the
function signature to accept a currency parameter and implement the same logic
as the server: if provider === 'stripe' return 'stripe_card'; if provider ===
'monobank' return 'monobank_invoice' only when currency === 'UAH' otherwise
return null; ensure callers of resolveDefaultMethodForProvider are updated to
pass the current currency so the client-side selection matches the server-side
resolveDefaultMethodForProvider.

In
`@frontend/app/`[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx:
- Around line 400-410: The check for a canceled Google Pay flow unnecessarily
calls .toUpperCase() on statusCode; in the catch block inside
MonobankGooglePayClient (where submitIdempotencyKeyRef.current is cleared and
setUiMessage(t('monobankGooglePay.cancelled')) is called), change the condition
to compare statusCode directly to 'CANCELED' (i.e., replace
statusCode.toUpperCase() === 'CANCELED' with statusCode === 'CANCELED') while
keeping the existing defensive extraction of statusCode intact.

---

Nitpick comments:
In
`@frontend/app/`[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx:
- Around line 347-352: When extractGToken(paymentData) returns null you
currently reset submitIdempotencyKeyRef.current and call goToPending() silently;
change this so you display a brief user-facing message (e.g., via existing
showToast/setNotification or a local state like setErrorMessage) indicating
token extraction failed and the order is being finalized, then clear
submitIdempotencyKeyRef.current and call goToPending() after showing the message
(either immediately if using a non-blocking toast or after a short
timeout/acknowledgement if using an inline banner). Locate the null-check around
extractGToken, update the branch to invoke the app’s notification API or set
component state before calling goToPending, and ensure existing behavior of
resetting submitIdempotencyKeyRef.current is preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: df435652-4215-4669-8cc4-35a8a0ec43dc

📥 Commits

Reviewing files that changed from the base of the PR and between 472d76f and 0043209.

📒 Files selected for processing (11)
  • frontend/app/[locale]/shop/cart/CartPageClient.tsx
  • frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx
  • frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx
  • frontend/app/api/shop/internal/shipping/np/sync/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/lib/psp/monobank.ts
  • frontend/lib/services/orders/checkout.ts
  • frontend/lib/services/shop/shipping/nova-poshta-client.ts
  • frontend/lib/tests/shop/checkout-shipping-phase3.test.ts
  • frontend/lib/tests/shop/monobank-api-methods.test.ts
  • frontend/lib/tests/shop/monobank-janitor-job1.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/lib/tests/shop/monobank-janitor-job1.test.ts
  • frontend/lib/tests/shop/monobank-api-methods.test.ts
  • frontend/app/[locale]/shop/checkout/return/monobank/MonobankReturnStatus.tsx
  • frontend/app/api/shop/webhooks/stripe/route.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
frontend/app/[locale]/shop/cart/CartPageClient.tsx (1)

78-107: Consider importing types and helper from payments.ts to reduce duplication.

CheckoutPaymentMethod and CheckoutProvider duplicate PaymentMethod and PaymentProvider from frontend/lib/shop/payments.ts. Similarly, resolveDefaultMethodForProvider reimplements the same logic exported there (context snippet 2). Importing these would reduce drift risk and keep a single source of truth.

♻️ Suggested refactor
+import {
+  type PaymentMethod,
+  type PaymentProvider,
+  resolveDefaultMethodForProvider,
+} from '@/lib/shop/payments';
+
-type CheckoutProvider = 'stripe' | 'monobank';
-type CheckoutPaymentMethod =
-  | 'stripe_card'
-  | 'monobank_invoice'
-  | 'monobank_google_pay';
+type CheckoutProvider = Exclude<PaymentProvider, 'none'>;
+type CheckoutPaymentMethod = PaymentMethod;

-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;
-}

Then update call sites to use positional arguments:

resolveDefaultMethodForProvider(provider, currency)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx around lines 78 - 107,
This file duplicates types and logic: replace local CheckoutProvider and
CheckoutPaymentMethod and the resolveDefaultMethodForProvider implementation by
importing the canonical PaymentProvider, PaymentMethod types and the
resolveDefaultMethodForProvider (or equivalent) helper from
frontend/lib/shop/payments.ts; remove the local resolveDefaultMethodForProvider
and Checkout* type declarations, update resolveInitialProvider (if still needed)
to use the imported PaymentProvider type, and update all call sites in this
module to call the imported helper with the positional arguments (provider,
currency) as suggested so the single source of truth in payments.ts is used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx:
- Around line 78-107: This file duplicates types and logic: replace local
CheckoutProvider and CheckoutPaymentMethod and the
resolveDefaultMethodForProvider implementation by importing the canonical
PaymentProvider, PaymentMethod types and the resolveDefaultMethodForProvider (or
equivalent) helper from frontend/lib/shop/payments.ts; remove the local
resolveDefaultMethodForProvider and Checkout* type declarations, update
resolveInitialProvider (if still needed) to use the imported PaymentProvider
type, and update all call sites in this module to call the imported helper with
the positional arguments (provider, currency) as suggested so the single source
of truth in payments.ts is used.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5fef13d9-5efb-44b4-babe-b4a7393f88f7

📥 Commits

Reviewing files that changed from the base of the PR and between 0043209 and 1b76792.

📒 Files selected for processing (5)
  • frontend/app/[locale]/shop/cart/CartPageClient.tsx
  • frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx
  • frontend/messages/en.json
  • frontend/messages/pl.json
  • frontend/messages/uk.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/app/[locale]/shop/checkout/payment/monobank/MonobankGooglePayClient.tsx

@ViktorSvertoka ViktorSvertoka merged commit 27de4eb into develop Mar 6, 2026
7 checks passed
@ViktorSvertoka ViktorSvertoka deleted the lso/feat/wallets branch March 6, 2026 07:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants