Skip to content

(SP: 3) [Backend] Transactionless checkout + Stripe webhook reliability pack (idempotency, dedupe, restock-once, money invariants)#110

Merged
ViktorSvertoka merged 17 commits into
developfrom
lso/feat/shop
Jan 6, 2026
Merged

(SP: 3) [Backend] Transactionless checkout + Stripe webhook reliability pack (idempotency, dedupe, restock-once, money invariants)#110
ViktorSvertoka merged 17 commits into
developfrom
lso/feat/shop

Conversation

@liudmylasovetovs
Copy link
Copy Markdown
Collaborator

@liudmylasovetovs liudmylasovetovs commented Jan 5, 2026

Description

This PR stabilizes the Shop checkout and payments flow under Neon HTTP (no db.transaction) by moving consistency guarantees to:

  • single-statement Postgres atomicity,
  • an inventory ledger (inventory_moves) for reserve/release,
  • strict idempotency for checkout and webhook side effects,
  • money model in minor units with contract-level error handling.

It also includes a hardening sweep across related P0 tasks discovered/validated during the transactionless migration (admin safety, public visibility rules, webhook contract, dedupe, restock-only-once, PSP fields, and order item snapshots).


Related Issue

Issue: #<issue_number>


Changes

  • Implemented transactionless checkout core (Neon HTTP compatible):

    • inventory reserve/release via ledger (inventory_moves) with deterministic move keys
    • strict Idempotency-Key contract (same payload → same order; different payload → 409)
    • partial failure handling (reserve → release) without orphaning stock
    • orphan / stale cleanup paths for no-payments and pathological cases
  • Hardened money + currency invariants:

    • locale → currency is server source of truth (uk→UAH, others→USD)
    • totals computed/persisted in minor units (total_amount_minor, *_minor snapshots)
    • stable error contracts: missing price → PRICE_CONFIG_ERROR (400); corrupted money/data → PRICE_DATA_ERROR (500)
  • Security + governance hardening:

    • admin mutation kill-switch via ENABLE_ADMIN_API (prod-safe gate) with coverage
    • public product visibility rule: inactive products are not accessible publicly (404)
    • order access control regression coverage for GET /api/shop/orders/[id] (IDOR / existence-hiding)
  • Stripe reliability pack (webhook + state machine):

    • explicit webhook contract: WEBHOOK_DISABLED vs INVALID_SIGNATURE
    • webhook dedupe via stripe_events (ON CONFLICT DO NOTHING early-exit)
    • amount/currency verification in minor units; mismatch blocks paid and stores diagnostics in pspStatusReason + pspMetadata
    • guarded state transitions with UPDATE … WHERE … RETURNING:
      • “mark paid only once”
      • “restock only once” (order-level gate using stock_restored/restocked_at)
    • persisted PSP fields on orders + immutable order_items snapshots (post-checkout updates to products/prices do not mutate snapshots)
  • Logging/test hygiene:

    • expected business 4xx paths no longer emit stack traces to stderr (warn/info instead)
    • added cleanup to keep DB state repeatable across integration tests

Database Changes (if applicable)

  • Schema migration required (shop orders/order_items PSP fields + ledger-related invariants, if not already applied)
  • 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)

Automated

  • npm run build (passes)
  • npx vitest run (full suite passes; latest run: 16 test files / 39 tests)

Key coverage added/validated (representative):

  • no-payments checkout invariants (success + idempotency + orphan cleanup)
  • currency policy + missing price contract (PRICE_CONFIG_ERROR)
  • admin API kill-switch coverage across all mutating endpoints
  • public product visibility (inactive → 404)
  • Stripe webhook contract tests (disabled vs invalid signature)
  • Stripe event dedupe (same event.id delivered twice → side effects once)
  • amount/currency mismatch blocks paid and writes PSP diagnostics
  • restock only once (duplicate + concurrent calls)
  • order item snapshot immutability
  • PSP fields persisted via webhook

Manual smoke (PowerShell + Stripe CLI)

  • Checkout smoke with Accept-Language (en/uk) validating currency + minor totals
  • Idempotency replay + conflict scenarios
  • Stripe E2E: checkout → confirm PaymentIntent → webhook → order paid + PSP fields + dedupe + inventory consistency

Screenshots (if applicable)

N/A (backend + API behavior changes)


Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors (expected business conflicts do not spam stderr)
  • Code follows project conventions
  • Scope is limited to this feature/fix (note: this PR also includes required P0 hardening discovered during the transactionless migration)
  • No unrelated refactors included
  • English used in code, commits, and docs
  • New dependencies discussed with team
  • Database migration tested locally (if applicable)
  • GitHub Projects card moved to In Review

Reviewers

Summary by CodeRabbit

  • New Features

    • Stripe checkout flow with webhook-driven state updates and automatic order-status auto-refresh.
  • Improvements

    • Prices moved to minor-unit handling and locale-aware money formatting; inventory reservation/release workflow added; safer image handling and lazy third-party initialization; locale-consistent navigation links and product visibility by currency.
  • Bug Fixes

    • Better handling of payment amount/currency mismatches and cleaner checkout error reporting.
  • Tests

    • Extensive new and expanded tests covering checkout, webhooks, restock, variants, and access controls.

✏️ Tip: You can customize this high-level summary in your review settings.

…v vars are missing

(SP: 1) [Backend] Add Vitest coverage for Orders IDOR access control (/api/shop/orders/[id])
…pected 4xx error logs; remove cart console.error
…s INVALID_SIGNATURE) and validate end-to-end payment flow
…erification and persist mismatch diagnostics (pspStatusReason/pspMetadata)
…ipe webhook (idempotent) + add coverage tests
@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 5, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit 715eb2d
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/695c900b954fbc0008c42cd0
😎 Deploy Preview https://deploy-preview-110--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 5, 2026

📝 Walkthrough

Walkthrough

Adds canonical minor‑unit money handling, idempotent order creation with inventory reserve/release moves, Stripe payment‑intent client/server integration and webhook deduplication, restock/sweep claim mechanics, DB schema and migration updates, env/config and Cloudinary lazy init changes, UI/payment adjustments, many new tests, and new error/logging utilities.

Changes

Cohort / File(s) Summary
Checkout UI & Stripe client
frontend/app/[locale]/shop/cart/page.tsx, frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx, frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
Migrate UI to minor‑unit money props (amountMinor), update Stripe confirm/return URLs and routing (remove some locale prefixes), add clientSecret create/retrieve flow, and simplify a catch to parameterless catch {} (remove console.error).
Checkout success / auto-refresh
frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx, frontend/app/[locale]/shop/checkout/success/page.tsx
Add OrderStatusAutoRefresh client component; success page set to force-dynamic with revalidate=0, use formatMoney, and accept params for locale-aware rendering/links.
Webhook & PSP processing
frontend/app/api/shop/webhooks/stripe/route.ts, frontend/lib/psp/stripe.ts
Add event deduplication (stripe_events), helper getLatestChargeId, standardized code-based error responses, non‑transactional idempotent webhook processing, detailed payment_intent/charge handling (validation, PSP field writes, restock triggers), and lazy/cached Stripe client with tightened webhook verification.
Orders & inventory services
frontend/lib/services/orders.ts, frontend/lib/services/inventory.ts
Add idempotency hashing (IdempotencyConflictError), variant-aware items (selectedSize/selectedColor), transactional reserve/release moves (applyReserveMove/applyReleaseMove), restock APIs, sweep claim mechanics and related public APIs/option shapes.
DB schema & migrations
frontend/db/schema/shop.ts, frontend/drizzle/0009_p0_inventory_workflow_baseline.sql, frontend/drizzle/0011_add_orders_sweep_claim_index.sql, frontend/drizzle/0012_inventory_moves_product_fk_restrict.sql, frontend/drizzle/meta/*
Add enums (order_status, inventory_status, inventory_move_type), new inventory_moves table, extend orders/order_items with minor‑unit & workflow fields, new indexes and CHECK constraints, snapshots and journal entries.
Pricing, product visibility & cart
frontend/app/[locale]/shop/products/[slug]/page.tsx, frontend/lib/services/products.ts, frontend/lib/validation/shop.ts, frontend/lib/cart.ts, frontend/lib/shop/cart-item-key.ts
Canonical minor‑unit price fields (priceMinor, unitPriceMinor, lineTotalMinor, totalAmountMinor), gate product pages by public product+currency, update validation schemas (UUID productId, coercible dates), cart summary includes totalAmountMinor, add createCartItemKey.
Cloudinary & env handling
frontend/lib/cloudinary.ts, frontend/lib/env/cloudinary.ts, frontend/lib/env/index.ts, frontend/lib/env/stripe.ts
Lazy Cloudinary init with ensureConfigured and typed env accessors (CloudinaryDisabledError), make CLOUDINARY env optional/defaults, simplify Stripe env/paymentsEnabled logic and fallback behavior.
Logging & errors
frontend/lib/logging.ts, frontend/lib/services/errors.ts
Add logWarn() and new error classes (IdempotencyConflictError, InvalidPayloadError, SlugConflictError), extend OrderStateInvalidError with details.
Product card / image safety
frontend/components/shop/product-card.tsx
Add safeImageSrc helper with placeholder and allowed-host checks; switch image usage to safeImageSrc(product.image).
Stripe & checkout route
frontend/app/api/shop/checkout/route.ts, frontend/app/[locale]/shop/cart/page.tsx
Centralize expected business error handling, improved payload parsing/warning, expanded order state validation when payments disabled, and updated catch behavior (see UI change for simplified catch).
Tests
frontend/lib/tests/* (many files)
Large suite of new/updated Vitest tests covering checkout (payments/no-payments), idempotency, inventory reserve/release, restock concurrency/claims, Stripe webhook contract/mismatch/psp-fields, public-product visibility, admin API kill-switch, and order‑item/variant snapshots.
Misc & packaging
frontend/package.json, frontend/project-structure.txt, frontend/drizzle/meta/_journal.json
Lock stripe to exact 20.0.0, project structure updates, and new drizzle snapshot/journal entries.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Client
    participant CheckoutAPI as Checkout API
    participant Orders as Orders Service
    participant Inventory as Inventory Service
    participant DB as Database
    participant Stripe as Stripe

    Client->>CheckoutAPI: POST /api/shop/checkout (items, idempotencyKey, locale)
    CheckoutAPI->>Orders: createOrderWithItems(items, idempotencyKey, locale)
    Orders->>DB: SELECT by idempotency_hash
    alt existing order
        DB-->>Orders: existing order
        Orders-->>CheckoutAPI: return existing order
    else new order
        Orders->>DB: INSERT order (status=CREATED, idempotency_hash)
        Orders->>Inventory: applyReserveMove(orderId, productId, qty)
        Inventory->>DB: advisory lock, check/update stock, INSERT inventory_moves (RESERVE)
        alt stock sufficient
            DB-->>Inventory: applied
            Inventory-->>Orders: applied
            Orders->>DB: update order (inventoryStatus=reserved)
            alt payments enabled
                Orders->>Stripe: createPaymentIntent(amountMinor, metadata)
                Stripe-->>Orders: client_secret
                Orders-->>CheckoutAPI: return order + clientSecret
            else
                Orders-->>CheckoutAPI: return order (no payments)
            end
        else insufficient
            Inventory-->>Orders: insufficient
            Orders->>DB: update order (INVENTORY_FAILED)
            Orders-->>CheckoutAPI: 400 insufficient stock
        end
    end
    CheckoutAPI-->>Client: 201 or error
Loading
sequenceDiagram
    autonumber
    participant Stripe as Stripe Webhook
    participant WebhookAPI as /api/shop/webhooks/stripe
    participant DB as Database
    participant Orders as Orders Service
    participant Inventory as Inventory Service

    Stripe->>WebhookAPI: POST event (id, type, data)
    WebhookAPI->>DB: INSERT stripe_events ON CONFLICT DO NOTHING
    alt duplicate
        DB-->>WebhookAPI: duplicate detected
        WebhookAPI-->>Stripe: 200 ACK
    else first time
        DB-->>WebhookAPI: recorded
        WebhookAPI->>DB: lookup order by metadata.orderId
        alt payment_intent.succeeded
            WebhookAPI->>DB: validate amount & currency vs order.total_amount_minor
            alt mismatch
                DB-->>WebhookAPI: update psp_status_reason (mismatch)
                WebhookAPI-->>Stripe: 200 ACK
            else match
                DB-->>WebhookAPI: update order (paymentStatus=paid, set psp fields)
                WebhookAPI-->>Stripe: 200 ACK
            end
        else payment_failed/canceled/refund
            WebhookAPI->>DB: update order (paymentStatus failed/refunded, set psp fields)
            WebhookAPI->>Inventory: applyReleaseMove(orderId, productId, qty)
            Inventory-->>DB: INSERT RELEASE move, update stock
            WebhookAPI-->>Stripe: 200 ACK
        end
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • AM1007
  • ViktorSvertoka

Poem

🐇 I counted cents and hopped between,

Reserved a move and cleared the scene,
Webhooks hum and dedupe sings,
Orders settle, stock takes wings,
A happy rabbit nets the green.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.93% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ 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 accurately summarizes the main changes: transactionless checkout with Stripe webhook reliability improvements, idempotency, deduplication, restock-once logic, and money invariants.
✨ Finishing touches
  • 📝 Generate docstrings

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
Copy Markdown
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: 9

Fix all issues with AI Agents 🤖
In @frontend/app/[locale]/shop/checkout/success/page.tsx:
- Around line 56-57: Remove the manual locale prefix from Link hrefs: any Link
instances in this file (e.g., the Link elements currently using
href={`/${locale}/shop/products`}) should use path-only hrefs like
"/shop/products" (and similarly "/shop/cart", "/shop/checkout", etc.) so the
next-intl Link with localePrefix: 'always' can prepend the locale automatically;
update all similar Link usages in this file and the related checkout files to
use locale-agnostic href strings instead of interpolating the locale.
- Around line 117-120: The code uses unnecessary and unsafe casts when computing
totalMinor; since getOrderSummary returns an OrderSummary that includes
totalAmountMinor, replace the conditional with a direct access: set totalMinor =
order.totalAmountMinor (remove the Math.round fallback and all "as any" casts).
Update the line initializing totalMinor (referencing the order variable and
totalAmountMinor field) to use the typed property directly to restore type
safety and match the pattern used in payment page code.

In @frontend/drizzle/0009_p0_inventory_workflow_baseline.sql:
- Around line 24-25: The product_id foreign key currently has no explicit ON
DELETE behavior; update the constraint in the inventory moves table definition
by adding an explicit ON DELETE clause: e.g., change "product_id uuid not null
references products(id)" to "product_id uuid not null references products(id) ON
DELETE CASCADE" to cascade deletions, or if you need to preserve historical
moves, make product_id nullable and use "product_id uuid references products(id)
ON DELETE SET NULL" instead; apply the chosen change to the product_id column
definition in the SQL diff.

In @frontend/lib/cart.ts:
- Around line 124-146: computeSummaryFromItems is summing the wrong field:
replace accumulation of item.lineTotal with the canonical integer cents
item.lineTotalMinor when building totals.totalMinor so
totalAmountMinor/totalAmount are correct; keep using
fromCents(totals.totalMinor) for totalAmount. Also, in the same function
consider validating that all CartRehydrateItem.currency values match (or
explicitly document the assumption) instead of unconditionally taking
items[0]?.currency for totals.currency.

In @frontend/lib/env/cloudinary.ts:
- Around line 30-44: The function getCloudinaryEnvOptional claims in its
docstring to never throw, but the cloudinaryRequiredSchema.parse() call can
throw a ZodError when validation fails. To fix this, replace the parse() method
with safeParse() which returns a result object with success/data properties
instead of throwing. Then check the success property of the result and return
null if validation fails, or extract and return the parsed data if validation
succeeds, maintaining the function's promise to never throw.

In @frontend/lib/psp/stripe.ts:
- Around line 22-25: The Stripe client instantiation sets apiVersion to
"2025-12-15.clover" with an `as any` cast on `_stripe = new Stripe(secretKey, {
apiVersion: "2025-12-15.clover" as any })`; change this to use the SDK-aligned
version `"2025-11-17.clover"` (remove the `as any` cast) OR keep the newer
version but add a clear comment next to the `apiVersion` option documenting why
the override is required, which API changes from 2025-12-15 are relied on, and
why it is safe with stripe-node v20.0.0, so reviewers understand the risk and
rationale.

In @frontend/lib/services/products.ts:
- Around line 504-534: The try block and its contents are mis-indented; fix by
aligning the `try` keyword and its inner block with the surrounding function
scope and existing indentation style: move `try {` to the same indentation level
as the surrounding statements, and re-indent the inner lines (the `if
(prices.length) {`, the mapping to `upsertRows`, and the `await
db.insert(productPrices)...onConflictDoUpdate(...)` chain) so they are
consistently nested under the `try` block; ensure symbols referenced remain
unchanged (prices, upsertRows, productPrices, toDbMoney, db.insert,
onConflictDoUpdate).

In @frontend/lib/tests/admin-api-killswitch.test.ts:
- Around line 95-106: readCodeFromResponse currently calls res.json() and falls
back to res.text(), but res.json() consumes the body so res.text() will fail;
fix by reading the body once and parsing safely: obtain the raw body via
res.clone().text() or text = await res.text() and then try JSON.parse(text) to
extract code/error.code, returning { status, code, raw: text }. Update
readCodeFromResponse to use the single-read approach and extract code from the
parsed JSON (or undefined if parsing fails) while always returning the raw text.

In @frontend/lib/tests/restock-order-only-once.test.ts:
- Around line 263-265: The failing organization is that the it test named
'duplicate refund restock must not increment stock twice and must not change
restocked_at' is placed after the describe('P0-8.4.2', ...) block closes; move
the describe block's closing "}, 30000);" so that the closing brace/timeout call
comes after this it(...) test, thereby enclosing that test inside the describe;
adjust surrounding braces/indentation accordingly so the describe contains all
three tests and retain the existing timeout argument.

Caution

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

⚠️ Outside diff range comments (2)
frontend/app/api/shop/checkout/route.ts (1)

488-498: Standardize error codes for MoneyValueError across endpoints.

The same error type returns different error codes depending on the endpoint: PRICE_DATA_ERROR in checkout (line 488) and PRICE_CONFIG_ERROR in cart/rehydrate (line 75). This inconsistency will break clients and monitoring that expect a uniform error code for the same error condition. Choose one error code and apply it consistently across both handlers.

frontend/lib/services/orders.ts (1)

96-115: Merging checkout items by productId loses variant information.

The mergeCheckoutItems function uses only productId as the key, but CheckoutItem includes selectedSize and selectedColor fields that distinguish product variants. Items with the same product but different sizes or colors would be incorrectly merged. The cart system already treats variants as separate items using createCartItemKey(productId, selectedSize, selectedColor), so the merge key should include these variant fields to maintain consistency and preserve order details.

🧹 Nitpick comments (26)
frontend/lib/tests/order-items-snapshot-immutable.test.ts (3)

44-54: Avoid as any type assertion.

The as any cast on line 53 bypasses TypeScript's type checking and could hide incompatibilities with the NextRequest constructor. Consider properly typing the options object or using a more specific cast if there's a known mismatch between Next.js types and runtime behavior.

🔎 Proposed refactor
 function makeJsonRequest(
   url: string,
   body: unknown,
   headers: Record<string, string>
 ) {
   return new NextRequest(url, {
     method: 'POST',
     headers,
     body: JSON.stringify(body),
-  } as any);
+  });
 }

If TypeScript reports errors, address the specific type mismatch rather than silencing all checks.


126-137: Tighten response validation.

Two observations:

  1. Status code check vs. comment mismatch: Line 128's comment states "returns 201 Created on success," but lines 129-130 accept any 2xx status. If 201 is the expected contract, consider checking for it explicitly; otherwise, update the comment to reflect that any 2xx is acceptable.

  2. orderId validation logic: Lines 135-137 check typeof orderId === 'string' and then throw if orderId is falsy. This is slightly redundant—consider simplifying to a direct truthiness check with a type guard or a more explicit assertion.

🔎 Proposed refactor
-    // Your checkout returns 201 Created on success.
-    expect(res.status).toBeGreaterThanOrEqual(200);
-    expect(res.status).toBeLessThan(300);
+    // Checkout returns 201 Created on success
+    expect(res.status).toBe(201);

     const json = (await res.json()) as CheckoutResponse;
     expect(json.success).toBe(true);

     const orderId = json.orderId ?? json.order?.id;
-    expect(typeof orderId).toBe('string');
-    if (!orderId) throw new Error('Missing orderId from checkout response');
+    if (!orderId || typeof orderId !== 'string') {
+      throw new Error('Missing orderId from checkout response');
+    }

73-214: Consider multi-currency test coverage.

This test validates snapshot immutability for USD-only orders. Given the PR's focus on "locale→currency mapping (uk→UAH, others→USD)" and minor-unit handling across currencies, consider whether snapshot immutability should also be tested for non-USD currencies (e.g., UAH) to ensure the snapshot mechanism works correctly regardless of currency.

If currency-specific snapshot behavior is already covered elsewhere or deemed unnecessary, a brief comment documenting the USD-only scope would help future maintainers.

frontend/lib/tests/public-product-visibility.test.ts (2)

38-38: Minor formatting inconsistency.

Extra leading spaces before price: compared to surrounding lines. While functionally correct, consider normalizing indentation for consistency.

🔎 Suggested fix
-       price: "10.00",
+        price: "10.00",

85-85: Minor formatting inconsistency.

Extra leading spaces before price: compared to surrounding lines. While functionally correct, consider normalizing indentation for consistency.

🔎 Suggested fix
-       price: "19.99",
+        price: "19.99",
frontend/app/[locale]/shop/products/[slug]/page.tsx (1)

23-31: Public visibility gate correctly implements the feature.

The gating logic properly enforces that inactive products or products without currency-specific pricing return 404 before any further processing. The implementation aligns with the PR's security objectives.

However, note that this introduces a double-fetch pattern: getPublicProductBySlug (line 28) followed by getProductPageData (line 33). While this provides defense-in-depth for access control, it could be optimized if getProductPageData were refactored to include the public visibility check internally.

frontend/lib/tests/admin-api-killswitch.test.ts (1)

148-148: Test name displays [object Object] instead of route name.

Using %s with an object array in it.each will show [object Object] in test output. Use $name for property interpolation.

🔎 Proposed fix
-  it.each(cases)('returns 403 ADMIN_API_DISABLED for all mutating handlers: %s', async (c) => {
+  it.each(cases)('returns 403 ADMIN_API_DISABLED for all mutating handlers: $name', async (c) => {
frontend/lib/tests/checkout-no-payments.test.ts (1)

101-113: Consider logging suppressed cleanup errors for debugging.

The empty catch blocks in bestEffortHardDeleteOrder are acceptable for cleanup purposes, but suppressed errors can make debugging difficult if cleanup unexpectedly fails.

🔎 Optional: Log cleanup failures at debug level
 async function bestEffortHardDeleteOrder(orderId: string) {
   // Keep DB reasonably clean in dev.
   // Use raw SQL because inventory_moves/order_items may not be exported as Drizzle tables.
   try {
     await db.execute(sql`delete from inventory_moves where order_id = ${orderId}::uuid`);
-  } catch {}
+  } catch (e) {
+    // Cleanup failure is non-fatal but useful for debugging
+    console.debug('cleanup: inventory_moves delete failed', e);
+  }
   try {
     await db.execute(sql`delete from order_items where order_id = ${orderId}::uuid`);
-  } catch {}
+  } catch (e) {
+    console.debug('cleanup: order_items delete failed', e);
+  }
   try {
     await db.delete(orders).where(eq(orders.id, orderId));
-  } catch {}
+  } catch (e) {
+    console.debug('cleanup: orders delete failed', e);
+  }
 }
frontend/lib/tests/restock-sweep-claim.test.ts (1)

45-62: Solid concurrency test for sweep claim deduplication.

The test correctly validates that two concurrent restockStalePendingOrders calls process the same order exactly once (a + b === 1).

Optional enhancement: Consider adding post-processing assertions (similar to restock-stale-claim-gate.test.ts) to verify that stockRestored is true and restockedAt is set after the concurrent run, confirming the order was actually restocked.

frontend/lib/tests/restock-stale-claim-gate.test.ts (1)

81-137: Good coverage for expired claim processing.

The test validates that orders with an expired claim can be reclaimed and processed.

Optional: For symmetry with the first test, consider adding post-processing assertions to verify stockRestored is true after the sweep processes the order.

frontend/lib/cloudinary.ts (1)

35-43: Redundant call to getCloudinaryEnvRequired().

ensureConfigured() on line 35 already calls getCloudinaryEnvRequired() internally (line 10). Calling it again on line 36 just to access uploadFolder is wasteful. Consider caching the env result or returning it from ensureConfigured().

🔎 Proposed refactor
+let cachedEnv: ReturnType<typeof getCloudinaryEnvRequired> | null = null;
+
 function ensureConfigured() {
   if (isConfigured) return;
 
   const env = getCloudinaryEnvRequired();
   cloudinary.config({
     cloud_name: env.cloudName,
     api_key: env.apiKey,
     api_secret: env.apiSecret,
   });
 
+  cachedEnv = env;
   isConfigured = true;
 }

 export async function uploadImage(
   fileOrBuffer: File | Buffer,
   options?: { folder?: string }
 ): Promise<{ url: string; publicId: string }> {
   ensureConfigured();
-  const env = getCloudinaryEnvRequired();
+  const env = cachedEnv!;

   const buffer = await toBuffer(fileOrBuffer);
   // ...
frontend/lib/psp/stripe.ts (1)

110-115: Empty catch swallows all errors, not just signature validation failures.

While the comment explains this is expected business behavior, the catch block swallows all errors (including potential unexpected ones like network issues during signature verification). The constructEvent is synchronous and unlikely to throw network errors, but consider logging unexpected error types in debug mode.

frontend/lib/tests/stripe-webhook-psp-fields.test.ts (1)

161-161: Consider using vi.mocked() for cleaner mock typing.

The type assertion (verifyWebhookSignature as unknown as { mockReturnValue: (v: any) => void }) is verbose. Vitest provides vi.mocked() for type-safe mock access.

🔎 Proposed refactor
-    (verifyWebhookSignature as unknown as { mockReturnValue: (v: any) => void }).mockReturnValue(event);
+    vi.mocked(verifyWebhookSignature).mockReturnValue(event as any);
frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx (2)

125-147: Excessive type assertions suggest missing type definitions.

The repeated (order as any) casts (lines 125-144) indicate that OrderSummary type from getOrderSummary may not include paymentIntentId, totalAmountMinor, currency, or id. Consider extending the type definition rather than using unsafe casts throughout.

Additionally, there are inconsistent indentation issues on lines 133, 138, and 144 (varying space counts).


148-151: Silent error swallowing loses debugging context.

While the comment explains the intent (UI shows "Payment cannot be initialized"), silently catching all errors makes debugging difficult. Consider logging the error at a lower level.

🔎 Proposed improvement
     } catch (err) {
-      // Leave clientSecret empty -> UI shows "Payment cannot be initialized"
+      // Leave clientSecret empty -> UI shows "Payment cannot be initialized"
+      console.debug('Payment intent initialization failed', err);
     }
frontend/lib/tests/checkout-currency-policy.test.ts (1)

61-71: Consider combining the two beforeAll blocks.

Having two separate beforeAll blocks (lines 61-65 and 67-71) works but can be confusing. The production check and dynamic import could be in the same block.

🔎 Proposed refactor
-beforeAll(() => {
+beforeAll(async () => {
   if (process.env.NODE_ENV === 'production') {
     throw new Error('Refusing to run DB-mutating tests in production environment.');
   }
-});

-beforeAll(async () => {
   // Import route after env + mocks are set
   const mod = await import('@/app/api/shop/checkout/route');
   POST = mod.POST;
 });
frontend/lib/tests/restock-order-only-once.test.ts (2)

183-184: Minor: Inconsistent indentation.

Line 184 has inconsistent leading whitespace compared to surrounding lines.

🔎 Proposed fix
       price: toDbMoney(1000),
-currency: 'USD',
+      currency: 'USD',
       createdAt,

152-159: Consider extracting cleanup logic to a shared helper.

The cleanup pattern is repeated three times with identical structure. Consider a helper like cleanupTestEntities(orderId, productId) to reduce duplication.

Also applies to: 254-261, 405-412

frontend/lib/services/errors.ts (1)

1-3: Remove commented-out code.

The commented InvalidPayloadError and SlugConflictError stubs at lines 1-3 and 32-34 are now implemented below. Remove the commented versions to reduce noise.

🔎 Proposed fix
-// export class InvalidPayloadError extends Error {
-//   code = "INVALID_PAYLOAD" as const
-// }
-
 export class IdempotencyConflictError extends Error {
-
-// export class SlugConflictError extends Error {
-//   code = "SLUG_CONFLICT" as const
-// }
 export class InvalidPayloadError extends Error {

Also applies to: 32-34

frontend/lib/services/products.ts (3)

351-426: Product creation with best-effort cleanup looks correct.

The flow properly:

  1. Creates the product with onConflictDoNothing for slug collision detection
  2. Inserts price rows after product creation
  3. Cleans up the product if price insertion fails via best-effort delete

One minor observation: the cleanup on line 416 only deletes the product row. Cloudinary image cleanup for uploaded?.publicId is not performed in the catch block if product insert succeeded but price insert failed.

🔎 Suggested improvement for image cleanup on failure
   } catch (error) {
     // якщо product_prices впало після створення продукту — прибираємо продукт (best-effort)
     if (createdProductId) {
       try {
         await db.delete(products).where(eq(products.id, createdProductId));
       } catch (cleanupDbError) {
         logError(
           'Failed to cleanup product after create failure',
           cleanupDbError
         );
       }
+      // Also cleanup uploaded image if product was rolled back
+      if (uploaded?.publicId) {
+        try {
+          await destroyProductImage(uploaded.publicId);
+        } catch (cleanupImageError) {
+          logError('Failed to cleanup image after create failure', cleanupImageError);
+        }
+      }
     }
     throw error;
   }

612-633: Return type uses unknown for price fields, reducing type safety.

The priceMinor, originalPriceMinor, price, and originalPrice fields are typed as unknown. Since these are being read from strongly-typed Drizzle schema columns, they should retain their actual types (number, number | null, string, string | null).

🔎 Suggested type improvement
 export async function getAdminProductPrices(productId: string): Promise<
   Array<{
     currency: CurrencyCode;
-    priceMinor: unknown;
-    originalPriceMinor: unknown;
-    price: unknown;
-    originalPrice: unknown;
+    priceMinor: number;
+    originalPriceMinor: number | null;
+    price: string;
+    originalPrice: string | null;
   }>
 > {

635-649: Same unknown typing issue in getAdminProductByIdWithPrices.

This follows from the previous comment - the prices array should have proper types for consistency and type safety across the admin API surface.

frontend/lib/services/orders.ts (4)

546-612: Idempotency compatibility check is thorough but has an empty catch.

The assertIdempotencyCompatible function correctly:

  • Validates currency match
  • Derives hash for legacy orders without stored hash
  • Handles failed orders by triggering restock

However, the empty catch {} on line 593 during hash backfill silently swallows errors. Consider logging this for observability.

🔎 Suggested improvement for observability
       try {
         await db
           .update(orders)
           .set({
             idempotencyRequestHash: derivedExistingHash,
             updatedAt: new Date(),
           })
           .where(eq(orders.id, row.id));
-      } catch {}
+      } catch (backfillError) {
+        // Non-critical: hash backfill is best-effort for future strict checks
+      }

1186-1224: restockStaleNoPaymentOrders lacks lease claiming unlike its sibling.

Unlike restockStalePendingOrders, this function doesn't use the lease claiming pattern. If multiple workers run this sweep concurrently, they could process the same orders redundantly. While restockOrder has its own idempotency guards, adding lease claiming would reduce wasted work.


1064-1065: Sequential release moves could be parallelized.

The release loop processes items sequentially. Since applyReleaseMove is idempotent (via move_key), these could be parallelized with Promise.all for better performance on orders with many items.

🔎 Optional parallelization
-  for (const item of reservedMoves)
-    await applyReleaseMove(orderId, item.productId, item.quantity);
+  await Promise.all(
+    reservedMoves.map(item =>
+      applyReleaseMove(orderId, item.productId, item.quantity)
+    )
+  );

978-984: Replace raw SQL with Drizzle query builder for type safety.

The inventoryMoves table is exported from the schema (frontend/db/schema/shop.ts) and should be used instead of raw SQL. Replace .from(sql\inventory_moves`)with.from(inventoryMoves) and use the Drizzle column references (inventoryMoves.productId, inventoryMoves.orderId, inventoryMoves.type, inventoryMoves.quantity`) for consistency with the rest of the file and to maintain type safety.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bb117d1 and 54c1a6d.

📒 Files selected for processing (40)
  • frontend/app/[locale]/shop/cart/page.tsx
  • frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx
  • frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
  • frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx
  • frontend/app/[locale]/shop/checkout/success/page.tsx
  • frontend/app/[locale]/shop/products/[slug]/page.tsx
  • frontend/app/api/shop/checkout/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/components/shop/product-card.tsx
  • frontend/db/schema/shop.ts
  • frontend/drizzle/0009_p0_inventory_workflow_baseline.sql
  • frontend/drizzle/0011_add_orders_sweep_claim_index.sql
  • frontend/drizzle/meta/0011_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/cart.ts
  • frontend/lib/cloudinary.ts
  • frontend/lib/env/cloudinary.ts
  • frontend/lib/env/index.ts
  • frontend/lib/env/stripe.ts
  • frontend/lib/logging.ts
  • frontend/lib/psp/stripe.ts
  • frontend/lib/services/errors.ts
  • frontend/lib/services/inventory.ts
  • frontend/lib/services/orders.ts
  • frontend/lib/services/products.ts
  • frontend/lib/tests/admin-api-killswitch.test.ts
  • frontend/lib/tests/checkout-currency-policy.test.ts
  • frontend/lib/tests/checkout-no-payments.test.ts
  • frontend/lib/tests/order-items-snapshot-immutable.test.ts
  • frontend/lib/tests/orders-access.test.ts
  • frontend/lib/tests/public-product-visibility.test.ts
  • frontend/lib/tests/restock-order-only-once.test.ts
  • frontend/lib/tests/restock-stale-claim-gate.test.ts
  • frontend/lib/tests/restock-stale-stripe-orphan.test.ts
  • frontend/lib/tests/restock-sweep-claim.test.ts
  • frontend/lib/tests/stripe-webhook-contract.test.ts
  • frontend/lib/tests/stripe-webhook-mismatch.test.ts
  • frontend/lib/tests/stripe-webhook-psp-fields.test.ts
  • frontend/lib/validation/shop.ts
  • frontend/project-structure.txt
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-30T16:01:02.523Z
Learnt from: kryvosheyin
Repo: DevLoversTeam/devlovers.net PR: 88
File: frontend/drizzle/schema.ts:153-153
Timestamp: 2025-12-30T16:01:02.523Z
Learning: The file frontend/drizzle/schema.ts is a legacy file not used by Drizzle or the runtime. The canonical schemas live in frontend/db/schema/* as configured in drizzle-config.ts, and that's what migrations and runtime use.

Applied to files:

  • frontend/drizzle/meta/0011_snapshot.json
🧬 Code graph analysis (22)
frontend/lib/tests/checkout-no-payments.test.ts (5)
frontend/db/index.ts (1)
  • db (39-39)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/db/schema/shop.ts (1)
  • orders (107-198)
frontend/lib/services/orders.ts (3)
  • restockOrder (944-1097)
  • restockOrder (1272-1272)
  • restockStaleNoPaymentOrders (1187-1224)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx (1)
frontend/lib/shop/currency.ts (1)
  • formatMoney (115-128)
frontend/lib/tests/restock-stale-claim-gate.test.ts (4)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (1)
  • orders (107-198)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/lib/services/orders.ts (1)
  • restockStalePendingOrders (1099-1184)
frontend/lib/tests/public-product-visibility.test.ts (2)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/queries/shop/products.ts (1)
  • getPublicProductBySlug (252-265)
frontend/lib/tests/orders-access.test.ts (2)
frontend/db/index.ts (1)
  • db (39-39)
frontend/lib/auth.ts (1)
  • getCurrentUser (95-116)
frontend/lib/tests/order-items-snapshot-immutable.test.ts (2)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (1)
  • inventoryMoves (325-354)
frontend/lib/tests/restock-order-only-once.test.ts (5)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (2)
  • products (56-105)
  • orders (107-198)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/lib/services/inventory.ts (1)
  • applyReserveMove (49-116)
frontend/lib/services/orders.ts (2)
  • restockOrder (944-1097)
  • restockOrder (1272-1272)
frontend/lib/tests/restock-stale-stripe-orphan.test.ts (4)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (1)
  • orders (107-198)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/lib/services/orders.ts (1)
  • restockStalePendingOrders (1099-1184)
frontend/lib/services/inventory.ts (1)
frontend/db/index.ts (1)
  • db (39-39)
frontend/app/[locale]/shop/checkout/success/page.tsx (3)
frontend/lib/validation/shop.ts (1)
  • orderIdParamSchema (371-373)
frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx (1)
  • OrderStatusAutoRefresh (16-42)
frontend/lib/shop/currency.ts (1)
  • formatMoney (115-128)
frontend/lib/services/products.ts (5)
frontend/db/schema/shop.ts (2)
  • products (56-105)
  • productPrices (269-323)
frontend/lib/shop/money.ts (2)
  • toDbMoney (58-60)
  • fromCents (44-46)
frontend/lib/services/errors.ts (1)
  • SlugConflictError (54-60)
frontend/lib/logging.ts (1)
  • logError (1-14)
frontend/lib/cloudinary.ts (1)
  • destroyProductImage (77-79)
frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx (1)
frontend/drizzle/schema.ts (1)
  • paymentStatus (8-8)
frontend/app/[locale]/shop/products/[slug]/page.tsx (3)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/lib/shop/currency.ts (1)
  • resolveCurrencyFromLocale (23-28)
frontend/db/queries/shop/products.ts (1)
  • getPublicProductBySlug (252-265)
frontend/lib/tests/stripe-webhook-mismatch.test.ts (2)
frontend/drizzle/schema.ts (2)
  • currency (7-7)
  • paymentStatus (8-8)
frontend/db/index.ts (1)
  • db (39-39)
frontend/lib/services/errors.ts (1)
frontend/db/queries/shop/orders.ts (1)
  • InsufficientStockError (8-8)
frontend/lib/env/stripe.ts (1)
frontend/lib/env/index.ts (2)
  • getRuntimeEnv (58-62)
  • getClientEnv (52-56)
frontend/lib/cloudinary.ts (1)
frontend/lib/env/cloudinary.ts (1)
  • getCloudinaryEnvRequired (50-69)
frontend/components/shop/product-card.tsx (1)
frontend/lib/shop/currency.ts (1)
  • formatMoney (115-128)
frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx (3)
frontend/lib/psp/stripe.ts (2)
  • retrievePaymentIntent (69-88)
  • createPaymentIntent (30-67)
frontend/lib/services/orders.ts (1)
  • setOrderPaymentIntent (841-898)
frontend/lib/shop/currency.ts (1)
  • formatMoney (115-128)
frontend/lib/tests/checkout-currency-policy.test.ts (3)
frontend/app/api/shop/checkout/route.ts (1)
  • POST (148-503)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (3)
  • orders (107-198)
  • productPrices (269-323)
  • products (56-105)
frontend/app/api/shop/checkout/route.ts (2)
frontend/lib/services/errors.ts (5)
  • IdempotencyConflictError (5-13)
  • InvalidPayloadError (35-41)
  • InsufficientStockError (15-21)
  • PriceConfigError (43-53)
  • OrderStateInvalidError (62-85)
frontend/lib/logging.ts (2)
  • logWarn (17-21)
  • logError (1-14)
frontend/lib/tests/restock-sweep-claim.test.ts (4)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (1)
  • orders (107-198)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/lib/services/orders.ts (1)
  • restockStalePendingOrders (1099-1184)
🪛 LanguageTool
frontend/project-structure.txt

[uncategorized] ~10-~10: The official name of this software platform is spelled with a capital “H”.
Context: ...ts 📁 app 📁 api 📁 auth 📁 github 📁 callback 📄 route....

(GITHUB)

Comment on lines +56 to +57
<Link
href="/shop/products"
href={`/${locale}/shop/products`}
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Jan 5, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check how the i18n Link component handles locale prefixing
rg -n "Link" frontend/i18n/routing.ts -A 10 -B 2

# Check if other pages use the same pattern
rg -n 'href=\{`/\$\{locale\}' --type=tsx --type=ts -C2

# Get the entire routing.ts file to understand the setup
cat -n frontend/i18n/routing.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 647


🏁 Script executed:

# Check the checkout success page file
cat -n frontend/app/\[locale\]/shop/checkout/success/page.tsx

# Search for the href pattern more broadly
rg 'href=.*locale.*shop' -C2

Repository: DevLoversTeam/devlovers.net

Length of output: 18058


Remove manual locale prefix from Link href attributes.

The next-intl Link component with localePrefix: 'always' automatically prepends the locale to URLs. Using href={/${locale}/shop/products} will cause double-prefixing (/en/en/shop/products). Replace with href="/shop/products" and let the Link component handle locale automatically.

Affects lines 56-57, 62-63, 89-90, 95-96, 178-179, 184-185 in this file, plus similar usage in related checkout files.

🤖 Prompt for AI Agents
In @frontend/app/[locale]/shop/checkout/success/page.tsx around lines 56-57,
Remove the manual locale prefix from Link hrefs: any Link instances in this file
(e.g., the Link elements currently using href={`/${locale}/shop/products`})
should use path-only hrefs like "/shop/products" (and similarly "/shop/cart",
"/shop/checkout", etc.) so the next-intl Link with localePrefix: 'always' can
prepend the locale automatically; update all similar Link usages in this file
and the related checkout files to use locale-agnostic href strings instead of
interpolating the locale.

✅ Addressed in commit b8d6aa8

Comment thread frontend/app/[locale]/shop/checkout/success/page.tsx Outdated
Comment on lines +24 to +25
order_id uuid not null references orders(id) on delete cascade,
product_id uuid not null references products(id),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the migration file in full context
cat -n frontend/drizzle/0009_p0_inventory_workflow_baseline.sql

Repository: DevLoversTeam/devlovers.net

Length of output: 8227


🏁 Script executed:

# Search for product deletion logic in the codebase
rg -n "delete.*product|remove.*product|drop.*product" --type ts --type js -A 3 -B 3 | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 7296


🏁 Script executed:

# Look for inventory_moves references to understand its purpose
rg -n "inventory_moves" --type ts --type js -A 2 -B 2 | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 6236


🏁 Script executed:

# Check if there are other migrations that might show patterns for FK constraints
fd "\.sql$" frontend/drizzle/ | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 668


Add explicit ON DELETE behavior for product_id foreign key.

The product_id foreign key lacks an ON DELETE clause and defaults to NO ACTION. This means attempting to delete a product with existing inventory moves will fail. While the codebase handles this through careful deletion ordering in tests, add an explicit constraint to prevent runtime failures: ON DELETE CASCADE if inventory records should follow product lifecycle, ON DELETE SET NULL if they should be preserved as historical records, or explicitly document NO ACTION if preventing product deletion while moves exist is intentional.

🤖 Prompt for AI Agents
In @frontend/drizzle/0009_p0_inventory_workflow_baseline.sql around lines 24-25,
The product_id foreign key currently has no explicit ON DELETE behavior; update
the constraint in the inventory moves table definition by adding an explicit ON
DELETE clause: e.g., change "product_id uuid not null references products(id)"
to "product_id uuid not null references products(id) ON DELETE CASCADE" to
cascade deletions, or if you need to preserve historical moves, make product_id
nullable and use "product_id uuid references products(id) ON DELETE SET NULL"
instead; apply the chosen change to the product_id column definition in the SQL
diff.

Comment thread frontend/lib/cart.ts
Comment thread frontend/lib/env/cloudinary.ts
Comment thread frontend/lib/psp/stripe.ts Outdated
Comment thread frontend/lib/services/products.ts Outdated
Comment thread frontend/lib/tests/admin-api-killswitch.test.ts Outdated
Comment thread frontend/lib/tests/restock-order-only-once.test.ts Outdated
Copy link
Copy Markdown

@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: 54c1a6dae9

ℹ️ 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 +367 to +370
eq(orders.id, order.id),
ne(orders.paymentStatus, 'paid'),
ne(orders.paymentStatus, 'failed'),
ne(orders.paymentStatus, 'refunded')
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow success webhook to update failed orders

Stripe can emit payment_intent.payment_failed for an initial attempt and then later emit payment_intent.succeeded if the customer retries with a new payment method; this handler marks the order as failed (and restocks) on the failed event, and then the success update is gated by ne(orders.paymentStatus, 'failed'), so the later success is ignored and the order stays failed even though funds were captured. This can lead to paid orders not being fulfilled and inventory already released to others. Consider allowing the succeeded transition from failed (or only marking failed on terminal cancel/refund events) to keep webhook state consistent.

Useful? React with 👍 / 👎.

…s, minor-unit totals, env parsing, Stripe apiVersion, test robustness, and variant-safe item merging
Copy link
Copy Markdown
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: 1

Fix all issues with AI Agents 🤖
In @frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx:
- Around line 121-151: The code is using unnecessary (order as any) casts and
swallowing errors silently; remove all `(order as any)` assertions and access
fields directly (e.g., use order.paymentIntentId?.trim() ?? '' and order.id,
order.totalAmountMinor, order.currency) inside the payments initialization
block, and in the catch block log the caught error with context (e.g.,
console.error or the project logger) so failures in retrievePaymentIntent,
createPaymentIntent, or setOrderPaymentIntent are observable while keeping
behavior unchanged.
🧹 Nitpick comments (9)
frontend/lib/env/cloudinary.ts (1)

54-72: Consider safeParse for defensive error handling.

While the manual checks (lines 56-64) ensure the required env vars are present, using safeParse on line 66 would be more defensive and ensure only CloudinaryDisabledError is thrown, maintaining a consistent error contract even in unexpected edge cases.

🔎 Proposed refactor
   if (missing.length) {
     throw new CloudinaryDisabledError(
       `Cloudinary is not configured. Missing: ${missing.join(", ")}`
     );
   }

-  const parsed = cloudinaryRequiredSchema.parse(process.env);
+  const res = cloudinaryRequiredSchema.safeParse(process.env);
+  if (!res.success) {
+    throw new CloudinaryDisabledError(
+      `Cloudinary configuration is invalid: ${res.error.message}`
+    );
+  }
+
+  const parsed = res.data;
   return {
     cloudName: parsed.CLOUDINARY_CLOUD_NAME,
     apiKey: parsed.CLOUDINARY_API_KEY,
     apiSecret: parsed.CLOUDINARY_API_SECRET,
     uploadFolder: parsed.CLOUDINARY_UPLOAD_FOLDER,
   };
frontend/lib/services/products.ts (2)

559-569: Clarify cleanup comment for accuracy.

The comment states "product update is not yet guaranteed to have happened," but the price upsert completes before the product update (lines 522-534 precede lines 538-542). The cleanup logic is correct—the uploaded image should be removed on any failure—but the comment could mislead readers.

🔎 Suggested clarification
-    // IMPORTANT: цей cleanup валідний, бо product update ще не гарантує що відбувся
+    // IMPORTANT: cleanup new image on failure (price upsert or product update)

614-635: Consider more specific types instead of unknown for price fields.

The return type uses unknown for priceMinor and originalPriceMinor, which forces consumers to validate these values. While this may be intentional for transitional compatibility, using number | null would provide better type safety and align with the DB schema expectations.

🔎 Proposed type refinement
 export async function getAdminProductPrices(productId: string): Promise<
   Array<{
     currency: CurrencyCode;
-    priceMinor: unknown;
-    originalPriceMinor: unknown;
+    priceMinor: number;
+    originalPriceMinor: number | null;
     price: unknown;
     originalPrice: unknown;
   }>
frontend/db/schema/shop.ts (1)

333-362: Callback should return an array for consistency with other table definitions.

The inventoryMoves table's callback returns an object ({ moveKeyUq, orderIdx, ... }), but other tables in this file (e.g., products, orders, orderItems) return arrays. While both work functionally, using arrays maintains consistency.

🔎 Suggested fix for consistency
   t => ({
-    moveKeyUq: uniqueIndex('inventory_moves_move_key_uq').on(t.moveKey),
-    orderIdx: index('inventory_moves_order_id_idx').on(t.orderId),
-    productIdx: index('inventory_moves_product_id_idx').on(t.productId),
-    qtyCheck: check('inventory_moves_quantity_gt_0', sql`${t.quantity} > 0`),
-  })
+  t => [
+    uniqueIndex('inventory_moves_move_key_uq').on(t.moveKey),
+    index('inventory_moves_order_id_idx').on(t.orderId),
+    index('inventory_moves_product_id_idx').on(t.productId),
+    check('inventory_moves_quantity_gt_0', sql`${t.quantity} > 0`),
+  ]
 );
frontend/lib/tests/order-items-variants.test.ts (1)

78-99: DB query uses as any for schema columns that should be typed.

Lines 82-83 and 93-95 cast orderItems.selectedSize and orderItems.selectedColor as any, suggesting the Drizzle schema inference isn't exposing these columns. This is likely because the columns were added recently and the inferred type hasn't been updated.

The schema in shop.ts defines these columns, so they should be accessible without casts. Verify the schema exports are correctly regenerated.

frontend/lib/services/orders.ts (2)

67-82: Type casts for variant fields should be addressed at schema level.

Lines 69-70 cast orderItems.selectedSize and orderItems.selectedColor as any. These fields are defined in the schema but the inferred type may not include them. Consider regenerating types or adding explicit type augmentation.


1039-1094: Verify raw SQL table reference matches Drizzle schema.

Line 1044 uses raw SQL sql\inventory_moves`instead of the importedinventoryMoves` table. While functional, this bypasses Drizzle's type safety. Consider using the schema reference for consistency.

🔎 Suggested improvement
+import { inventoryMoves } from '@/db/schema';
...
   const reservedMoves = await db
     .select({
-      productId: sql<string>`product_id`,
-      quantity: sql<number>`quantity`,
+      productId: inventoryMoves.productId,
+      quantity: inventoryMoves.quantity,
     })
-    .from(sql`inventory_moves`)
-    .where(and(sql`order_id = ${orderId}::uuid`, sql`type = 'reserve'`));
+    .from(inventoryMoves)
+    .where(and(
+      eq(inventoryMoves.orderId, orderId),
+      eq(inventoryMoves.type, 'reserve')
+    ));
frontend/lib/psp/stripe.ts (1)

118-120: Consider logging the original error for debugging.

The catch block discards the original error, making it harder to diagnose legitimate signature verification failures (e.g., clock skew, malformed headers). Unlike createPaymentIntent, this doesn't log before re-throwing.

🔎 Proposed fix
-  } catch {
+  } catch (error) {
+    logError('Stripe webhook signature verification failed', error);
     throw new Error('STRIPE_INVALID_SIGNATURE');
   }
frontend/app/api/shop/checkout/route.ts (1)

488-498: Verify error code consistency.

MoneyValueError is mapped to PRICE_CONFIG_ERROR with a 500 status, but PriceConfigError (lines 477-482) uses the same code with 400 status. This could confuse clients—same code, different status codes. Consider using a distinct code like PRICE_DATA_ERROR for the 500 case (corrupted stored data) vs PRICE_CONFIG_ERROR for the 400 case (missing price configuration).

🔎 Proposed fix
     if (error instanceof MoneyValueError) {
       return errorResponse(
-        'PRICE_CONFIG_ERROR',
+        'PRICE_DATA_ERROR',
         'Invalid stored price data for one or more products.',
         500,
         {
           productId: error.productId,
           field: error.field,
           rawValue: error.rawValue,
         }
       );
     }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 54c1a6d and b8d6aa8.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (18)
  • frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx
  • frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
  • frontend/app/[locale]/shop/checkout/success/page.tsx
  • frontend/app/api/shop/checkout/route.ts
  • frontend/db/schema/shop.ts
  • frontend/drizzle/0012_inventory_moves_product_fk_restrict.sql
  • frontend/drizzle/meta/0012_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/cart.ts
  • frontend/lib/env/cloudinary.ts
  • frontend/lib/psp/stripe.ts
  • frontend/lib/services/orders.ts
  • frontend/lib/services/products.ts
  • frontend/lib/shop/cart-item-key.ts
  • frontend/lib/tests/admin-api-killswitch.test.ts
  • frontend/lib/tests/order-items-variants.test.ts
  • frontend/lib/tests/restock-order-only-once.test.ts
  • frontend/package.json
✅ Files skipped from review due to trivial changes (1)
  • frontend/drizzle/meta/0012_snapshot.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • frontend/lib/tests/restock-order-only-once.test.ts
  • frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx
  • frontend/drizzle/meta/_journal.json
🧰 Additional context used
🧬 Code graph analysis (6)
frontend/lib/psp/stripe.ts (2)
frontend/lib/env/stripe.ts (1)
  • getStripeEnv (17-49)
frontend/lib/logging.ts (1)
  • logError (1-14)
frontend/lib/shop/cart-item-key.ts (1)
frontend/lib/cart.ts (1)
  • createCartItemKey (49-49)
frontend/app/api/shop/checkout/route.ts (2)
frontend/lib/services/errors.ts (5)
  • IdempotencyConflictError (5-13)
  • InvalidPayloadError (35-41)
  • InsufficientStockError (15-21)
  • PriceConfigError (43-53)
  • OrderStateInvalidError (62-85)
frontend/lib/logging.ts (2)
  • logWarn (17-21)
  • logError (1-14)
frontend/app/[locale]/shop/checkout/success/page.tsx (3)
frontend/lib/validation/shop.ts (1)
  • orderIdParamSchema (371-373)
frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx (1)
  • OrderStatusAutoRefresh (16-42)
frontend/lib/shop/currency.ts (1)
  • formatMoney (115-128)
frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx (1)
frontend/lib/psp/stripe.ts (2)
  • retrievePaymentIntent (71-90)
  • createPaymentIntent (29-69)
frontend/lib/services/orders.ts (7)
frontend/db/schema/shop.ts (1)
  • orders (107-198)
frontend/lib/shop/cart-item-key.ts (1)
  • createCartItemKey (1-7)
frontend/lib/shop/money.ts (3)
  • fromCents (44-46)
  • sumLineTotals (70-76)
  • toDbMoney (58-60)
frontend/db/queries/shop/orders.ts (2)
  • OrderSummary (33-33)
  • InsufficientStockError (8-8)
frontend/lib/services/errors.ts (5)
  • OrderNotFoundError (23-29)
  • OrderStateInvalidError (62-85)
  • InsufficientStockError (15-21)
  • InvalidPayloadError (35-41)
  • IdempotencyConflictError (5-13)
frontend/lib/services/inventory.ts (2)
  • applyReserveMove (49-116)
  • applyReleaseMove (118-178)
frontend/lib/env/stripe.ts (1)
  • isPaymentsEnabled (51-53)
🔇 Additional comments (39)
frontend/lib/env/cloudinary.ts (3)

3-10: LGTM!

The custom error class follows best practices with a typed code property and proper name assignment.


12-17: LGTM!

The schema correctly validates required Cloudinary configuration with appropriate defaults for the upload folder.


30-48: Past concern resolved—function now matches its "never throws" contract.

The previous review correctly identified that .parse() could throw a ZodError. The current implementation uses .safeParse() (line 37) and returns null on validation failure (line 38), which properly honors the docstring guarantee.

frontend/lib/services/products.ts (3)

351-425: Transactionless design trade-off is acceptable.

The migration from transactional to sequential inserts (product → prices) introduces a window where the product exists without prices. The best-effort cleanup on failure is appropriate for Neon HTTP compatibility goals stated in the PR objectives.


687-687: LGTM: Dual price representation is consistent.

The cart summary correctly maintains both canonical minor units (totalAmountMinor) and display values (totalAmount) for empty and non-empty carts. This aligns with the PR's minor-unit money handling objectives.

Also applies to: 833-840


739-795: Excellent defensive validation for price data.

The price derivation logic correctly handles both canonical priceMinor and legacy price fields with comprehensive validation:

  • Critical rejection of fractional minor units (line 762)
  • Safe integer range checks (lines 771-776, 789-795)
  • Graceful fallback to legacy pricing when needed

The dual representation (canonical unitPriceMinor/lineTotalMinor and display unitPrice/lineTotal) is well-structured and consistent.

Also applies to: 809-814

frontend/drizzle/0012_inventory_moves_product_fk_restrict.sql (1)

1-6: LGTM! ON DELETE RESTRICT preserves inventory audit trail.

The migration correctly enforces referential integrity by preventing product deletion when related inventory_moves records exist. The DROP IF EXISTS makes the migration re-runnable.

frontend/db/schema/shop.ts (4)

34-54: LGTM! Well-structured enums for the inventory workflow.

The enum definitions properly model the order and inventory state machines. The inventory_move_type enum with reserve/release aligns with the ledger-based inventory tracking approach.


144-166: LGTM! Sweep claim fields enable distributed restock coordination.

The sweepClaimedAt, sweepClaimExpiresAt, sweepRunId, and sweepClaimedBy fields provide the necessary infrastructure for distributed lease-based restock claim gating, preventing concurrent double-processing.


180-196: LGTM! CHECK constraint enforces PSP field invariants.

The orders_psp_fields_null_when_none constraint correctly ensures that PSP-specific fields (pspChargeId, pspPaymentMethod, pspStatusReason) are NULL when provider is none. The sweep claim index supports efficient stale order queries.


211-237: LGTM! Variant fields with NOT NULL defaults enable reliable upserts.

Using NOT NULL DEFAULT '' for selectedSize and selectedColor ensures the unique index order_items_order_variant_uq works correctly with ON CONFLICT clauses, avoiding NULL comparison issues.

frontend/lib/shop/cart-item-key.ts (1)

1-7: LGTM! Clean deterministic key generation for variant-aware cart items.

The :: delimiter creates a stable composite key for productId + size + color combinations, enabling reliable deduplication and merging across cart operations.

frontend/lib/tests/order-items-variants.test.ts (2)

100-116: LGTM! Thorough cleanup with cascade awareness.

The cleanup correctly relies on cascade deletes for order_items and inventory_moves, with a best-effort fallback for orphaned product_prices. The empty catch block is acceptable here as it's truly best-effort cleanup.


48-63: Remove unnecessary as any casts; CheckoutItem already includes variant fields.

The CheckoutItem type already defines selectedSize and selectedColor as optional properties via the checkoutItemSchema (see frontend/lib/validation/shop.ts:287-294). The as any casts on lines 55 and 62 are unnecessary and should be removed to maintain type safety.

Likely an incorrect or invalid review comment.

frontend/lib/tests/admin-api-killswitch.test.ts (2)

95-115: LGTM! Body stream consumption issue resolved.

The implementation correctly reads the response body once via res.text() and then parses JSON, avoiding the "body already read" error that would occur with separate res.json() and res.text() calls.


143-172: LGTM! Comprehensive kill-switch coverage for admin routes.

The test properly:

  1. Mocks production environment with disabled admin API
  2. Tests all mutation methods (POST, PUT, PATCH, DELETE)
  3. Verifies 403 response with ADMIN_API_DISABLED code
  4. Covers both static and dynamic routes with appropriate context
frontend/lib/cart.ts (2)

119-154: LGTM! Minor-unit accumulation and currency validation properly implemented.

The function now correctly:

  1. Accumulates item.lineTotalMinor (canonical integer cents) instead of the display value
  2. Computes totalAmount via fromCents(totalMinor) for display consistency
  3. Throws on mixed currencies with a clear error message

This addresses the previous review concern about incorrect field accumulation.


22-31: LGTM! Empty cart summary includes canonical totalAmountMinor.

The emptyCart constant properly initializes both totalAmountMinor: 0 (canonical) and totalAmount: 0 (display), maintaining consistency with the new money handling approach.

frontend/lib/services/orders.ts (9)

43-51: LGTM! Variant normalization ensures consistent key generation.

The normVariant function trims whitespace and handles null/undefined, ensuring consistent composite keys across cart merging and idempotency hashing.


147-182: LGTM! Deterministic idempotency hashing with stable canonical form.

The hashIdempotencyRequest function:

  1. Normalizes variants via normVariant
  2. Sorts items by composite key for determinism
  3. Includes version, currency, userId, and items in the hash payload
  4. Uses SHA-256 for collision resistance

This ensures the same logical request always produces the same hash.


301-438: LGTM! reconcileNoPaymentOrder handles crash recovery correctly.

The function properly:

  1. Validates order state before reconciliation
  2. Aggregates reserves by productId
  3. Transitions through reservingreserved or rolls back to released
  4. Uses applyReleaseMove in a best-effort catch block during failure

The empty catch on line 416 is intentional—release is best-effort during error recovery.


571-665: Idempotency conflict handling is robust.

The assertIdempotencyCompatible function:

  1. Validates currency match (hard conflict if different)
  2. Falls back to derived hash for legacy orders without stored hash
  3. Backfills missing hash for future strict checks
  4. Re-throws InsufficientStockError for failed orders with cleanup attempt

This ensures the same idempotency key with different payloads triggers a 409.


840-872: LGTM! Proper rollback on reservation failure.

The error handling:

  1. Sets inventoryStatus: 'release_pending' immediately
  2. Best-effort releases each reserved item
  3. Updates order to terminal INVENTORY_FAILED state
  4. Preserves error context in failureCode/failureMessage
  5. Re-throws original error for caller handling

The empty catch blocks during release are intentional for best-effort cleanup.


972-1003: LGTM! Distributed lease claim with expiration prevents concurrent processing.

tryClaimRestockLease atomically claims the restock lease using conditional UPDATE ... WHERE ... RETURNING:

  1. Only claims if stockRestored = false
  2. Respects existing unexpired claims
  3. Sets sweepRunId for traceability

The or(isNull(...), lt(...)) condition correctly handles both unclaimed and expired leases.


1160-1249: LGTM! Batch restock sweep with atomic claim pattern.

The restockStalePendingOrders function:

  1. Uses a subquery to select candidates deterministically (oldest first)
  2. Atomically claims a batch with UPDATE ... WHERE id IN (subquery) ... RETURNING
  3. Re-checks claim gate at UPDATE time to prevent stealing active claims
  4. Processes claimed orders sequentially with alreadyClaimed: true
  5. Loops until no more candidates

This pattern safely handles concurrent sweep workers.


1251-1289: LGTM! No-payment stale order cleanup handles orphans.

restockStaleNoPaymentOrders specifically targets orders with paymentProvider = 'none' that didn't complete inventory reservation. It correctly excludes already reserved or released states and marks orphans as terminal failed.


782-799: Unique constraint verified. The database constraint order_items_order_variant_uq (in frontend/db/schema/shop.ts) correctly includes all four columns: orderId, productId, selectedSize, and selectedColor. The upsert operation is properly configured.

frontend/package.json (1)

48-48: Good practice: exact version pinning for Stripe SDK.

Pinning to 20.0.0 (removing the ^ caret) ensures deterministic builds and prevents unintended upgrades that could introduce breaking changes in payment flows. This is particularly important for payment integrations.

frontend/lib/psp/stripe.ts (2)

12-27: LGTM: Lazy client caching with key invalidation.

The caching pattern correctly handles secretKey rotation by comparing the current key against the cached one. This prevents unnecessary client recreation while ensuring fresh credentials are used when they change.


29-69: LGTM: Payment intent creation with proper validation.

Good defensive checks for payments being enabled, valid client, and positive finite amounts. The idempotency key pass-through correctly uses conditional spreading. Error logging before re-throwing preserves diagnostics while exposing clean error codes to callers.

frontend/app/[locale]/shop/checkout/success/page.tsx (3)

10-11: LGTM: Proper dynamic rendering configuration.

Setting dynamic = 'force-dynamic' and revalidate = 0 ensures the page always fetches fresh order data, which is critical for displaying accurate payment status after webhook processing.


126-131: LGTM: Clean implementation addressing prior feedback.

Direct access to order.totalAmountMinor (line 126) and locale-agnostic Link hrefs (e.g., /shop/products) correctly address the prior review comments about unnecessary type assertions and double locale prefixing.


144-147: Good UX: Informative payment status messaging.

The conditional messaging clearly communicates to users whether payment is confirmed or still processing, and the auto-refresh component handles the polling automatically.

frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx (2)

60-69: LGTM: Locale-agnostic navigation paths.

Links correctly use paths like /shop/cart and /shop/products without manual locale prefixing, allowing the next-intl Link component to handle locale automatically.


204-204: LGTM: Consistent minor-unit money formatting.

The formatMoney calls correctly use order.totalAmountMinor with the currency and locale, ensuring consistent minor-unit handling across the payment flow.

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

28-52: LGTM: Clean separation of expected vs unexpected errors.

The EXPECTED_BUSINESS_ERROR_CODES set combined with isExpectedBusinessError() helper enables appropriate logging levels—warnings for expected business rejections (idempotency conflicts, validation failures) and errors for unexpected failures. The instanceof fallback ensures robustness if .code is missing.


152-161: Good change: Downgrade to warning for expected parse failures.

Using logWarn instead of logError for JSON parse failures prevents noisy error logs in CI/tests when clients send malformed payloads—a 4xx client error, not a server issue.


241-252: Good addition: Stable 409 response for already-failed orders.

Returning a 409 Conflict with CHECKOUT_FAILED for orders that already failed (e.g., due to inventory issues) provides clients with a stable, idempotent response instead of a confusing 500 error.


464-466: LGTM: Idempotency conflict handling.

Correctly returns 409 with the error code, message, and details for IdempotencyConflictError, enabling clients to understand why their idempotent request was rejected (payload mismatch).

Comment thread frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
Copy link
Copy Markdown
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: 1

🤖 Fix all issues with AI Agents
In @frontend/app/api/shop/checkout/route.ts:
- Around line 28-34: Remove 'OUT_OF_STOCK' from the
EXPECTED_BUSINESS_ERROR_CODES Set in frontend/app/api/shop/checkout/route.ts
because that code is never returned to the client; update the constant
declaration (EXPECTED_BUSINESS_ERROR_CODES) to exclude 'OUT_OF_STOCK' and leave
the remaining codes intact, relying on the existing instanceof
InsufficientStockError check in the checkout route to handle stock-related
fallback cases and noting orders.ts still storing 'OUT_OF_STOCK' as a DB
failureCode is fine.
🧹 Nitpick comments (6)
frontend/lib/tests/stripe-webhook-psp-fields.test.ts (2)

29-39: Consider removing the as any type assertion.

The type assertion on line 38 bypasses TypeScript's type checking. While common in tests, it could hide type mismatches.

🔎 Suggested improvement
 function makeWebhookRequest(rawBody: string) {
   return new NextRequest('http://localhost:3000/api/shop/webhooks/stripe', {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
       // route calls verifyWebhookSignature(rawBody, signatureHeader)
       'Stripe-Signature': 't=1,v1=test',
     },
     body: rawBody,
-  } as any);
+  });
 }

If this causes type errors, explicitly type the init parameter rather than using as any.


165-253: Consider verifying the mock was called and order status transitions.

The test thoroughly validates PSP field population and idempotency. Two optional enhancements:

  1. Mock verification: Confirm verifyWebhookSignature was called with expected arguments.
  2. Order status transition: The test checks paymentStatus transitions to 'paid' but doesn't verify if order.status should also transition (e.g., from 'INVENTORY_RESERVED' to a final state like 'COMPLETED').
🔎 Optional enhancements

1. Add mock verification after line 175:

expect(verifyWebhookSignature).toHaveBeenCalledWith({
  rawBody,
  signatureHeader: 't=1,v1=test',
});

2. If order.status should transition, add assertion after line 194:

// Verify order.status transitions appropriately
expect(updated1[0].status).toBe('COMPLETED'); // or expected final status
frontend/app/api/shop/checkout/route.ts (1)

36-52: Consider using unknown instead of any for safer type handling.

The getErrorCode function casts err to any for property access. While pragmatic, consider using unknown with explicit type guards for better type safety:

function getErrorCode(err: unknown): string | null {
  if (typeof err !== 'object' || err === null) return null;
  const e = err as { code?: unknown };
  return typeof e.code === 'string' ? e.code : null;
}

The dual checking strategy (code-based + instanceof) in isExpectedBusinessError provides good defense in depth.

frontend/lib/tests/checkout-no-payments.test.ts (1)

85-102: Consider logging suppressed cleanup errors.

The empty catch {} blocks in cleanup functions silently swallow errors. While this is intentional for resilience during test teardown, it can make debugging difficult when cleanup genuinely fails.

🔎 Optional: Add debug logging for cleanup failures
 async function cleanupIsolatedProduct(productId: string) {
   // Make sure it won't be visible for any selector.
   try {
     await db
       .update(products)
       .set({ isActive: false, updatedAt: new Date() } as any)
       .where(eq(products.id, productId));
-  } catch {}
+  } catch (e) {
+    // Cleanup failure is non-fatal but useful for debugging
+    if (process.env.DEBUG) console.warn('cleanupIsolatedProduct: deactivate failed', e);
+  }
frontend/lib/services/orders.ts (2)

855-859: Empty catch blocks in release loops may hide critical failures.

The best-effort release loops silently swallow all errors. While this prevents one failed release from blocking others, it could hide systematic issues like database connectivity problems.

🔎 Optional: Log release failures for observability
     for (const it of itemsToReserve) {
       try {
         await applyReleaseMove(orderId, it.productId, it.quantity);
-      } catch {}
+      } catch (releaseErr) {
+        // Best-effort: log but don't throw to continue releasing other items
+        console.error(`[restockOrder] release failed for product ${it.productId}:`, releaseErr);
+      }
     }

Also applies to: 420-424


1263-1301: restockStaleNoPaymentOrders queries without atomic claiming, unlike restockStalePendingOrders.

Two concurrent workers could select the same stale order. However, restockOrder internally acquires a per-call lease (lines 1122-1128), so actual double-processing is prevented. The inefficiency is limited to wasted DB reads and failed lease attempts.

Currently, restockStaleNoPaymentOrders is test-only and not wired to any production endpoint. If used in a concurrent context, consider adding atomic claiming for consistency with restockStalePendingOrders (which uses batch-level claiming at lines 1226-1248), though the current approach is acceptable given the expected low volume of stale no-payment orders.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b8d6aa8 and c699d9d.

📒 Files selected for processing (12)
  • frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
  • frontend/app/api/shop/checkout/route.ts
  • frontend/components/shop/cart-provider.tsx
  • frontend/db/schema/shop.ts
  • frontend/lib/env/cloudinary.ts
  • frontend/lib/psp/stripe.ts
  • frontend/lib/services/orders.ts
  • frontend/lib/services/products.ts
  • frontend/lib/tests/checkout-no-payments.test.ts
  • frontend/lib/tests/order-items-snapshot-immutable.test.ts
  • frontend/lib/tests/order-items-variants.test.ts
  • frontend/lib/tests/stripe-webhook-psp-fields.test.ts
✅ Files skipped from review due to trivial changes (1)
  • frontend/components/shop/cart-provider.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/lib/psp/stripe.ts
  • frontend/lib/tests/order-items-variants.test.ts
🧰 Additional context used
🧬 Code graph analysis (3)
frontend/lib/tests/checkout-no-payments.test.ts (4)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (3)
  • products (56-105)
  • productPrices (277-331)
  • orders (107-198)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/lib/services/orders.ts (3)
  • restockOrder (1012-1170)
  • restockOrder (1349-1349)
  • restockStaleNoPaymentOrders (1264-1301)
frontend/lib/tests/stripe-webhook-psp-fields.test.ts (3)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (5)
  • stripeEvents (262-275)
  • orderItems (200-260)
  • orders (107-198)
  • productPrices (277-331)
  • products (56-105)
frontend/lib/psp/stripe.ts (1)
  • verifyWebhookSignature (97-122)
frontend/app/api/shop/checkout/route.ts (2)
frontend/lib/services/errors.ts (5)
  • IdempotencyConflictError (5-13)
  • InvalidPayloadError (35-41)
  • InsufficientStockError (15-21)
  • PriceConfigError (43-53)
  • OrderStateInvalidError (62-85)
frontend/lib/logging.ts (2)
  • logWarn (17-21)
  • logError (1-14)
🔇 Additional comments (45)
frontend/lib/tests/stripe-webhook-psp-fields.test.ts (2)

1-28: LGTM: Clean test setup with appropriate mocking.

The partial mock of @/lib/psp/stripe preserves actual implementations while allowing controlled test behavior for webhook signature verification.


135-163: Verify amount/currency matching is tested per PR requirements.

The PR description highlights "amount/currency verification in minor units with diagnostics stored in pspStatusReason/pspMetadata" as a key reliability improvement. This test validates the happy path (matching amount 900 and currency), but doesn't verify that mismatches are rejected.

Consider adding a companion test case that verifies webhook rejection when:

  • Event amount doesn't match order.totalAmountMinor
  • Event currency doesn't match order.currency

This would ensure the webhook's defensive verification logic is covered, especially since the PR emphasizes this as a hardening measure.

frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx (7)

1-11: LGTM!

Imports are well-organized and appropriate for the Stripe payment intent lifecycle integration.


12-37: LGTM!

Helper functions are well-structured: getOrderId validates with Zod, resolveClientSecret safely handles array/undefined searchParams, and buildStatusMessage provides clear user-facing status text.


80-127: LGTM!

The type annotation at line 80 properly types order using the return type of getOrderSummary, eliminating the need for type assertions. Error handling correctly differentiates OrderNotFoundError from generic failures with appropriate user-facing messages.


135-184: Well-implemented payment intent lifecycle with proper idempotency.

This addresses the concerns from previous reviews:

  • Direct field access without as any casts (line 142, 157-160, 165-166)
  • Structured error logging with phase tracking and context (lines 172-180)

The idempotency key pi:${order.id} ensures Stripe returns the same PaymentIntent on retries, and the phase tracking provides good observability for debugging failures in each step.


186-213: LGTM!

Correct early return for already-paid orders prevents users from re-entering the payment flow unnecessarily.


62-73: LGTM!

Using absolute paths (e.g., /shop/cart, /shop/products) with the Link component from @/i18n/routing is correct—the routing system handles locale prefixing automatically.


250-259: formatMoney correctly expects minor units as the first argument.

The function signature confirms it accepts amountMinor: number and internally converts to major units via minorToMajor() before formatting. The consistent use of order.totalAmountMinor throughout the file aligns with this design.

frontend/lib/env/cloudinary.ts (1)

1-79: LGTM! Robust environment configuration with proper error contracts.

The past review concern about getCloudinaryEnvOptional potentially throwing has been addressed by using safeParse (line 37). The implementation now correctly fulfills its documented contract of never throwing.

The dual-accessor pattern is well-designed:

  • getCloudinaryEnvOptional safely returns null for any missing or invalid configuration
  • getCloudinaryEnvRequired throws a typed CloudinaryDisabledError with actionable error messages

The manual presence checks before schema validation (lines 31-35, 54-57) provide better developer experience than raw Zod errors, while safeParse ensures comprehensive validation including the optional CLOUDINARY_UPLOAD_FOLDER field.

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

5-5: LGTM! Imports support enhanced error handling.

The new imports for logWarn and IdempotencyConflictError correctly support the improved error classification and logging strategy introduced in this PR.

Also applies to: 7-7


152-156: LGTM! Appropriate use of logWarn for expected client errors.

The change from logError to logWarn for JSON parsing failures correctly treats these as expected business errors (4xx) rather than internal failures, avoiding unnecessary stack trace noise in logs.


186-188: LGTM! Consistent warning-level logging for validation errors.

Appropriately uses logWarn for schema validation failures, maintaining consistency with other expected client errors.


241-252: LGTM! Proper handling of pre-failed orders when payments are disabled.

This correctly handles the edge case where an order was created but failed (e.g., due to inventory issues) when payments are disabled. Returning 409 with the order ID maintains idempotency contract and provides useful client context.


266-268: LGTM! Enhanced validation prevents inconsistent payment state.

The additional check for paymentIntentId ensures that orders with paymentProvider: 'none' don't have orphaned payment intents, preventing data inconsistencies.


447-454: LGTM! Proper error classification improves observability.

The differentiation between expected business errors (warning-level) and unexpected internal errors (error-level) significantly improves log quality and reduces noise in monitoring systems.


464-466: LGTM! Correct idempotency conflict handling.

Properly returns 409 status with detailed context, maintaining the strict idempotency contract described in the PR objectives.


471-473: LGTM! Enhanced diagnostics for order state errors.

The additional field, rawValue, and details fields provide valuable debugging context for internal data integrity issues without exposing sensitive information.


490-491: LGTM! Correctly differentiates price configuration from data corruption errors.

This change properly implements the error contract described in the PR objectives: PRICE_DATA_ERROR (500) for corrupted stored data vs. PRICE_CONFIG_ERROR (400) for missing price configuration.

frontend/lib/tests/order-items-snapshot-immutable.test.ts (3)

1-56: LGTM! Test setup and utilities are well-structured.

The mocking strategy correctly isolates the test from auth and payment dependencies. The makeJsonRequest helper and cleanup function follow established patterns in the test suite.


73-162: Test correctly validates snapshot immutability invariant.

The test flow is comprehensive: seed → checkout → capture baseline → mutate source data → verify snapshot unchanged. The assertions on Lines 155-161 properly validate all snapshotted fields before mutation.


163-213: LGTM! Mutation and verification logic is correct.

The test aggressively mutates both products and productPrices tables, then verifies the orderItems snapshot remains unchanged via toEqual. The finally block ensures cleanup regardless of test outcome.

frontend/lib/tests/checkout-no-payments.test.ts (4)

34-83: Test isolation strategy is sound but relies on existing data.

The createIsolatedProductForCurrency function clones an existing active product to satisfy NOT NULL constraints. This is a pragmatic approach for integration tests, but the error message at Line 48-50 clearly communicates the prerequisite.

The pattern of creating products as inactive by default (Line 65) and activating only during the test window is a good strategy to prevent interference from parallel tests.


160-277: LGTM! No-payments success path test is thorough.

The test validates the complete lifecycle:

  1. Product activation window minimization (Lines 170-174, 201-205)
  2. Response-level contract (Lines 207-210)
  3. DB state verification including inventoryStatus: 'reserved' as the finality signal (Lines 212-233)
  4. Ledger integrity with exactly one reserve move (Lines 235-243)
  5. Stock decrement verification (Lines 245-253)
  6. Restock cleanup restores stock (Lines 255-266)

The 20-second timeout is appropriate for integration tests with DB operations.


279-368: Idempotency test correctly validates contract.

The test verifies three critical scenarios:

  1. Same idempotency key + same payload → returns same order ID (Line 333)
  2. No double-reserve on replay (Line 337)
  3. Same key + different payload → 409 conflict (Lines 340-345)

370-436: Orphan cleanup test validates terminal state transition.

The test inserts an orphan order (no inventory_moves) with status: 'CREATED' and inventoryStatus: 'none', then validates restockStaleNoPaymentOrders marks it terminal with failureCode: 'STALE_ORPHAN'.

Note: The orphan is created with paymentStatus: 'paid' (Line 380) which is required by the DB CHECK constraint for paymentProvider: 'none', but the test expects paymentStatus: 'failed' after sweep (Line 430). This is correct behavior—the sweep transitions it to a terminal failed state.

frontend/db/schema/shop.ts (5)

34-54: LGTM! New enums model order and inventory lifecycle correctly.

The orderStatusEnum captures the order state machine: CREATED → INVENTORY_RESERVED → PAID or CREATED → INVENTORY_FAILED → CANCELED. The inventoryStatusEnum provides fine-grained tracking of reservation lifecycle for transactionless safety.


144-166: LGTM! Order table extensions support idempotency and distributed sweeps.

The new columns enable:

  • status/inventoryStatus: Explicit state machine tracking
  • failureCode/failureMessage: Structured error diagnostics
  • idempotencyRequestHash: Payload fingerprinting for conflict detection
  • sweepClaimed* fields: Distributed lease mechanism for concurrent sweep workers

180-197: LGTM! CHECK constraints enforce provider-specific invariants.

orders_psp_fields_null_when_none correctly ensures PSP fields are null when paymentProvider = 'none'. The updated orders_payment_status_valid_when_none constraint restricts status to ('paid','failed') for no-payment orders, preventing invalid pending/requires_payment states.

The orders_sweep_claim_expires_idx index optimizes sweep queries filtering by claim expiration.


200-259: LGTM! Order items support variant selection with composite uniqueness.

The selectedSize and selectedColor columns with NOT NULL DEFAULT '' enable the unique composite index order_items_order_variant_uq to work reliably with ON CONFLICT clauses. This is a well-known pattern for making nullable-like columns participate in unique constraints.


333-362: LGTM! Inventory moves table design supports idempotent operations.

Key design choices:

  • moveKey unique index enables idempotent reserve/release via ON CONFLICT DO NOTHING
  • ON DELETE CASCADE on orderId ensures cleanup when orders are deleted
  • No cascade on productId preserves audit trail even if products are deleted
  • quantity > 0 check prevents invalid zero-quantity moves
  • Separate indices on orderId and productId optimize common query patterns
frontend/lib/services/products.ts (6)

43-51: LGTM! AdminProductPriceRow type provides dual representation.

The type exposes both canonical minor-unit fields (priceMinor, originalPriceMinor) and legacy string fields (price, originalPrice) for backward compatibility during rollout.


361-434: LGTM! createProduct implements robust insert-then-cleanup pattern.

The implementation:

  1. Inserts product with onConflictDoNothing (Line 391)
  2. Checks for null return indicating slug conflict (Lines 394-396)
  3. Stores createdProductId for cleanup (Line 398)
  4. Inserts prices with canonical and legacy mirrors (Lines 400-419)
  5. On any error, deletes the product (CASCADE handles prices) at Lines 423-431

This pattern provides atomicity without transactions by using cleanup on failure.


513-565: LGTM! updateProduct uses upsert for prices with proper conflict handling.

The onConflictDoUpdate on [productPrices.productId, productPrices.currency] (Line 535) correctly handles both insert and update cases. The sql\excluded.*`` syntax (Lines 537-541) is the correct Drizzle pattern for referencing the conflicting row's values.

The product update happens after price upsert (Lines 546-555), and image cleanup on failure (Lines 567-577) ensures no orphaned Cloudinary assets.


622-655: Defensive runtime validation on priceMinor values.

The assertMoneyMinorInt calls (Lines 641-651) guard against driver bugs or data corruption where integer columns might return non-integer values. This is appropriately paranoid for financial data.


752-760: PriceConfigError correctly distinguishes missing price configuration.

When both priceMinor and price are null (indicating no price row for the requested currency), a PriceConfigError is thrown with context (productId, currency). This aligns with the PR's error contract differentiating PRICE_CONFIG_ERROR (400) from PRICE_DATA_ERROR (500).


846-854: LGTM! Cart summary exposes both canonical and display totals.

The summary object now includes:

  • totalAmountMinor: Canonical integer minor units for calculations
  • totalAmount: Display value from fromCents(totalCents) for UI

This dual representation supports the PR's money handling strategy.

frontend/lib/services/orders.ts (8)

1-46: LGTM! Import additions support new inventory and idempotency features.

The new imports correctly bring in:

  • Inventory operations (applyReserveMove, applyReleaseMove, inventoryMoves)
  • Error types (IdempotencyConflictError)
  • Variant key generation (createCartItemKey)

118-152: LGTM! Item merging and aggregation handle variants correctly.

mergeCheckoutItems (Lines 118-140) keys by variant-aware createCartItemKey, allowing multiple entries for the same product with different size/color combinations.

aggregateReserveByProductId (Lines 142-152) correctly aggregates quantities by productId only (ignoring variants) because stock is tracked at the product level, not variant level. The deterministic sort ensures consistent reservation order.


154-189: LGTM! Idempotency hash is deterministic and collision-resistant.

The hash includes:

  • Version field (v: 1) for future schema evolution
  • Normalized items sorted by cart key for stability
  • Currency and userId for request identity

SHA256 provides sufficient collision resistance for idempotency purposes.


308-445: reconcileNoPaymentOrder handles crash recovery correctly.

The function:

  1. Validates state (Lines 323-358): checks for terminal states, provider consistency
  2. Sets inventoryStatus: 'reserving' with guard (Lines 373-382)
  3. Reserves inventory per product (Lines 384-398)
  4. On success: transitions to PAID/reserved (Lines 400-412)
  5. On failure: rolls back to release_pending, releases moves, marks INVENTORY_FAILED (Lines 413-444)

The guard at Line 377-381 (ne(orders.inventoryStatus, 'reserved')) prevents double-reservation on concurrent calls.


578-697: createOrderWithItems entry point handles idempotency correctly.

Key flows:

  • Currency derived from locale (Line 589)
  • Payment status set based on provider (Lines 591-593)
  • Items normalized and hashed (Lines 595-602)
  • assertIdempotencyCompatible validates existing orders (Lines 604-672)
  • No-payment replay triggers reconcileNoPaymentOrder (Lines 680-687)

The hash backfill at Lines 643-654 is a pragmatic migration strategy for orders created before hash storage.


709-807: Order creation with upsert handles race conditions safely.

The flow:

  1. Insert order skeleton with status: 'CREATED' (Lines 712-741)
  2. On unique constraint violation, retrieve existing and validate compatibility (Lines 742-764)
  3. Upsert order items with onConflictDoUpdate (Lines 767-806)

The upsert on [orderId, productId, selectedSize, selectedColor] (Lines 790-795) ensures idempotent item creation even under concurrent retries.


820-883: Inventory reservation with rollback on failure.

The reservation loop (Lines 820-833) processes items sequentially. On failure:

  1. Sets inventoryStatus: 'release_pending' (Lines 849-852)
  2. Best-effort release for all reserved items (Lines 854-859)
  3. Marks order as INVENTORY_FAILED with appropriate failure code (Lines 861-876)

The sequential processing ensures partial failures can be rolled back cleanly.


1012-1170: restockOrder implements robust distributed restock with lease.

Key safety mechanisms:

  1. Early return if already released (Lines 1038-1043)
  2. Check for reserved moves to determine work needed (Lines 1046-1057)
  3. Orphan handling for orders with no moves (Lines 1059-1106)
  4. Lease claim before processing (Lines 1120-1129)
  5. Release pending state transition (Lines 1132-1135)
  6. Atomic finalization with WHERE stockRestored = false RETURNING (Lines 1142-1152)

The guard at Line 1111-1116 prevents restocking paid orders without refund reason, but correctly exempts no-payment orders where paid is a DB CHECK artifact.

Comment thread frontend/app/api/shop/checkout/route.ts
…es and harden error code parsing (unknown + type guard)
Copy link
Copy Markdown
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: 0

🧹 Nitpick comments (6)
frontend/lib/tests/checkout-no-payments.test.ts (2)

34-83: Consider cleanup on failure in createIsolatedProductForCurrency.

If the db.insert(productPrices) call fails (lines 71-80), the product created at lines 58-68 will be orphaned. This is a minor test hygiene concern.

🔎 Suggested improvement
 async function createIsolatedProductForCurrency(opts: {
   currency: 'USD' | 'UAH';
   stock: number;
 }): Promise<{ productId: string }> {
   const now = new Date();
 
   // Clone a real product row to satisfy NOT NULL columns (schema varies).
   const [tpl] = await db
     .select()
     .from(products)
     .where(eq(products.isActive as any, true))
     .limit(1);
 
   if (!tpl) {
     throw new Error(
       'No template product found to clone (need at least 1 active product).'
     );
   }
 
   const productId = crypto.randomUUID();
   const slug = `t-iso-nopay-${crypto.randomUUID()}`;
   const sku = `t-iso-nopay-${crypto.randomUUID()}`;
 
   // Keep inactive by default to avoid being picked by other tests.
   await db.insert(products).values({
     ...(tpl as any),
     id: productId,
     slug,
     sku,
     title: `Test ${slug}`,
     stock: opts.stock,
     isActive: false,
     createdAt: now,
     updatedAt: now,
   } as any);
 
+  try {
     // Ensure price exists for requested currency (minor + legacy).
     await db.insert(productPrices).values({
       productId,
       currency: opts.currency,
       priceMinor: 1000,
       price: toDbMoney(1000),
       originalPriceMinor: null,
       originalPrice: null,
       createdAt: now,
       updatedAt: now,
     } as any);
+  } catch (e) {
+    // Cleanup orphaned product on price insert failure
+    await db.delete(products).where(eq(products.id, productId)).catch(() => {});
+    throw e;
+  }
 
   return { productId };
 }

150-166: Consider typing the raw SQL result more safely.

The readMoves function casts the result with as unknown as MoveRow[], which bypasses type checking. If the SQL column names change, this will fail at runtime without compile-time warnings.

🔎 Suggested improvement
 async function readMoves(orderId: string): Promise<MoveRow[]> {
   const res = await db.execute(
     sql`
       select
         product_id as "productId",
         type,
         quantity
       from inventory_moves
       where order_id = ${orderId}::uuid
       order by created_at asc
     `
   );
 
-  return (res.rows ?? []) as unknown as MoveRow[];
+  return (res.rows ?? []).map(row => ({
+    productId: String((row as any).productId ?? ''),
+    type: String((row as any).type ?? ''),
+    quantity: Number((row as any).quantity ?? 0),
+  }));
 }
frontend/lib/services/orders.ts (4)

420-430: Silent console.error in catch block may lose error context.

The release loop catches errors but only logs to console.error. Consider using logError for consistency with the rest of the codebase and better observability.

🔎 Suggested improvement
+import { logError } from '@/lib/logging';
 
     for (const item of itemsToReserve) {
       try {
         await applyReleaseMove(orderId, item.productId, item.quantity);
       } catch (releaseErr) {
-        console.error(
+        logError(
           '[reconcileNoPaymentOrder] release failed',
-          { orderId, productId: item.productId, quantity: item.quantity },
           releaseErr
         );
       }
     }

649-660: Empty catch block silently swallows backfill errors.

The backfill of idempotencyRequestHash at lines 651-659 has an empty catch block. While this is intentional (best-effort), consider at least logging at debug level for troubleshooting.

🔎 Suggested improvement
       try {
         await db
           .update(orders)
           .set({
             idempotencyRequestHash: derivedExistingHash,
             updatedAt: new Date(),
           })
           .where(eq(orders.id, row.id));
-      } catch {}
+      } catch (e) {
+        // Best-effort backfill; log for debugging if needed
+        if (process.env.DEBUG) {
+          console.warn('[assertIdempotencyCompatible] hash backfill failed', { orderId: row.id }, e);
+        }
+      }

669-677: Best-effort restock before throwing may mask the original error.

At lines 671-673, restockOrder is called in a try-catch that swallows errors before throwing InsufficientStockError. If the restock fails, the stock remains locked but the error message says "Insufficient stock". Consider logging the restock failure.

🔎 Suggested improvement
     if (row.paymentStatus === 'failed') {
       // Best-effort cleanup if inventory was left reserved due to crash.
       try {
         await restockOrder(existing.id, { reason: 'failed' });
-      } catch {}
+      } catch (restockErr) {
+        logError('[assertIdempotencyCompatible] cleanup restock failed', restockErr);
+      }
       throw new InsufficientStockError(
         row.failureMessage ?? 'Insufficient stock.'
       );
     }

860-871: Release loop on failure uses console.error instead of logError.

Same issue as in reconcileNoPaymentOrder - consider using logError for consistency.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c699d9d and 6f6922e.

📒 Files selected for processing (4)
  • frontend/app/api/shop/checkout/route.ts
  • frontend/lib/services/orders.ts
  • frontend/lib/tests/checkout-no-payments.test.ts
  • frontend/lib/tests/stripe-webhook-psp-fields.test.ts
🧰 Additional context used
🧬 Code graph analysis (1)
frontend/lib/tests/stripe-webhook-psp-fields.test.ts (3)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (5)
  • stripeEvents (262-275)
  • orderItems (200-260)
  • orders (107-198)
  • productPrices (277-331)
  • products (56-105)
frontend/lib/psp/stripe.ts (1)
  • verifyWebhookSignature (97-122)
🔇 Additional comments (26)
frontend/lib/tests/stripe-webhook-psp-fields.test.ts (4)

16-27: LGTM!

The mock setup correctly hoists vi.mock before the import of verifyWebhookSignature, and uses vi.importActual to preserve other module exports while only mocking the webhook signature verification.


29-39: LGTM!

Clean helper function for creating webhook requests. The dummy signature is appropriate since verifyWebhookSignature is mocked.


41-54: LGTM!

The cleanup function correctly respects FK constraints by deleting in the right order: stripeEventsorderItemsordersproductPricesproducts.


56-264: Thorough test coverage for webhook PSP fields and idempotency.

The test properly:

  • Seeds all required data with correct FK relationships
  • Verifies PSP fields (pspChargeId, pspPaymentMethod, pspStatusReason, pspMetadata) are populated on payment_intent.succeeded
  • Confirms idempotency by resubmitting the same event and verifying no duplicate stripeEvents records
  • Uses try/finally to ensure cleanup runs regardless of test outcome

One minor observation on line 234: the idempotency response check allows < 500 rather than strictly 2xx. This is intentional flexibility since idempotent duplicate handling may return various success codes.

frontend/lib/tests/checkout-no-payments.test.ts (4)

11-26: LGTM on the module mocks.

The mocks correctly isolate the test environment by disabling payments and preventing cookies() calls in tests. The use of vi.importActual to spread actual implementations while overriding specific functions is the right approach.


213-329: Solid test coverage for no-payments success path.

The test thoroughly validates:

  • API response contract (status, success flag, order shape)
  • DB state (paymentProvider, paymentStatus, inventoryStatus, status, currency, totalAmountMinor)
  • Ledger entries (single reserve, no releases)
  • Stock decrement and restoration via restockOrder

The try/finally cleanup pattern ensures test artifacts are removed even on failure.


374-380: Verify idempotent return behavior when product is inactive.

The second checkout call (lines 375-380) happens after the product is deactivated (lines 369-372). This works because idempotency returns the existing order without re-validating product availability. Worth noting this is intentional behavior for idempotent replays.


422-488: Good orphan cleanup test coverage.

The test properly validates the orphan order lifecycle:

  • Inserts an orphan with inventoryStatus: 'none' and no inventory_moves
  • Runs restockStaleNoPaymentOrders with olderThanMinutes: 0
  • Verifies terminal state: INVENTORY_FAILED, released, failed, STALE_ORPHAN
frontend/app/api/shop/checkout/route.ts (7)

28-53: LGTM on business error classification.

The EXPECTED_BUSINESS_ERROR_CODES set and helper functions provide clean separation between expected business errors (logged as warnings) and unexpected errors (logged as errors). The fallback instanceof checks handle cases where error objects might not have a .code property.


136-147: BOM handling is good but consider additional normalization.

The BOM removal is helpful for cross-platform compatibility. Consider also handling other common encoding issues like CRLF or zero-width characters if you've seen them in production.


241-253: Good defensive handling for already-failed orders.

Returning a stable 409 for orders that already failed prevents clients from receiving inconsistent 500 errors on idempotent retries. The CHECKOUT_FAILED code with orderId in details enables client-side debugging.


266-285: Condition logic looks correct but could be simplified.

The condition at line 268 checks !['paid', 'failed'].includes(order.paymentStatus) which is correct given the DB CHECK constraint. The additional order.paymentIntentId check ensures no-payment orders don't have Stripe artifacts.


415-446: Restocking on payment intent failure is appropriate.

The pattern of attempting to restock on Stripe failure (lines 418-425) prevents inventory from being held indefinitely. The best-effort approach with error logging is correct since the primary error should be surfaced to the client.


489-500: MoneyValueError handling correctly returns 500.

The PRICE_DATA_ERROR code and 500 status appropriately signal a server-side data integrity issue. Including productId, field, and rawValue provides good diagnostics without exposing sensitive data.


465-467: The error.details handling is correct, but consider stricter typing.

The details property is properly optional and correctly handled with the conditional spread operator in errorResponse. All instantiations pass plain serializable objects. However, the type Record<string, unknown> allows non-serializable values (e.g., Error objects) to slip through, which could serialize unexpectedly with JSON.stringify(). Consider either:

  • Adding runtime validation: details && Object.values(details).some(v => !isSerializable(v))
  • Narrowing the type to Record<string, string | number | boolean | null> to prevent non-serializable values by design
frontend/lib/services/orders.ts (11)

55-58: normVariant returns empty string for falsy input.

This is intentional for consistent key generation, but consider documenting that empty string is the canonical form for "no variant".


142-152: Sorting by productId ensures deterministic reserve order.

Sorting aggregated items by productId (line 151) prevents potential deadlocks when multiple concurrent orders reserve the same products in different orders. Good defensive pattern.


154-189: Idempotency hash implementation is robust.

The hash includes:

  • Version number (v: 1) for future schema changes
  • Currency and userId for full request identity
  • Sorted, normalized items for deterministic hashing

Using SHA-256 is appropriate for this use case.


308-451: reconcileNoPaymentOrder handles crash recovery well.

The function correctly:

  1. Validates order state before reconciliation
  2. Sets inventoryStatus: 'reserving' before attempting reserves
  3. Rolls back with releases on failure
  4. Sets terminal failure state with appropriate codes

One note: the ne conditions at lines 379-381 prevent re-reconciliation of already-processed orders.


771-813: Order items upsert with onConflictDoUpdate is a good idempotency pattern.

The upsert on the composite key (orderId, productId, selectedSize, selectedColor) ensures that retried requests don't create duplicate line items. The excluded.* references correctly update to the latest values.


826-839: Sequential reserve loop could be optimized but is safer.

Processing reserves sequentially (rather than in parallel) is safer for inventory consistency. The applyReserveMove function handles idempotency via move_key, so retries are safe.


991-1022: Lease claim mechanism is well-designed.

The tryClaimRestockLease function:

  • Uses atomic UPDATE with WHERE conditions
  • Checks both unclaimed (isNull) and expired (lt) claims
  • Generates unique sweepRunId for traceability
  • Returns boolean indicating claim success

This prevents concurrent workers from double-processing the same order.


1119-1128: Guard against restocking paid Stripe orders without refund.

The condition at line 1123 correctly prevents restocking paid Stripe orders unless reason === 'refunded'. The !isNoPayment check is necessary because no-payment orders have paymentStatus: 'paid' by DB constraint but should be restockable for other reasons.


1149-1150: Sequential release loop is correct for idempotent releases.

The sequential for loop ensures releases happen one at a time. Given that applyReleaseMove should be idempotent (via move_key), concurrent releases would be safe but sequential is clearer.


1329-1334: Refund validation correctly restricts to Stripe orders.

The check at line 1330-1334 prevents refunds on no-payment orders, which is correct since there's no payment to refund.


1275-1313: Function is test-only and not used in production code.

This function is only called in test files (checkout-no-payments.test.ts) and never in production. The "test-only sweep; no batch claiming" comment is accurate and appropriate. The concern about adding claim logic for production concurrency is not applicable since this function has no production usage.

Likely an incorrect or invalid review comment.

…d add debug-only logging for best-effort cleanup
Copy link
Copy Markdown
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: 0

🧹 Nitpick comments (1)
frontend/lib/tests/checkout-no-payments.test.ts (1)

42-100: Verify template product availability and consider type safety.

The function clones an existing product to satisfy NOT NULL constraints. While pragmatic, this creates a hidden dependency on the database state (at least one active product must exist).

The multiple as any type casts (lines 52, 67, 76, 89) bypass type safety. If the schema changes, these casts could mask issues.

Consider:

  • Documenting this dependency in test setup/README
  • Using explicit type guards or defining a proper type for the insert payload instead of as any

Alternatively, if test isolation is critical, you could seed a known template product in a global test setup hook.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6f6922e and 715eb2d.

📒 Files selected for processing (2)
  • frontend/lib/services/orders.ts
  • frontend/lib/tests/checkout-no-payments.test.ts
🧰 Additional context used
🧬 Code graph analysis (1)
frontend/lib/services/orders.ts (10)
frontend/db/schema/shop.ts (3)
  • orders (107-198)
  • orderItems (200-260)
  • inventoryMoves (333-362)
frontend/db/index.ts (1)
  • db (39-39)
frontend/lib/shop/cart-item-key.ts (1)
  • createCartItemKey (1-7)
frontend/lib/shop/money.ts (2)
  • fromCents (44-46)
  • toDbMoney (58-60)
frontend/db/queries/shop/orders.ts (2)
  • OrderSummary (33-33)
  • InsufficientStockError (8-8)
frontend/lib/services/errors.ts (4)
  • OrderNotFoundError (23-29)
  • OrderStateInvalidError (62-85)
  • InsufficientStockError (15-21)
  • InvalidPayloadError (35-41)
frontend/lib/shop/payments.ts (1)
  • PaymentStatus (9-9)
frontend/lib/services/inventory.ts (2)
  • applyReserveMove (49-116)
  • applyReleaseMove (118-178)
frontend/lib/logging.ts (1)
  • logError (1-14)
frontend/lib/env/stripe.ts (1)
  • isPaymentsEnabled (51-53)
🔇 Additional comments (20)
frontend/lib/tests/checkout-no-payments.test.ts (8)

11-34: LGTM: Test isolation setup is well-structured.

The mock setup appropriately isolates the test environment by:

  • Silencing expected business warnings
  • Forcing no-payments mode for the entire test file
  • Avoiding cookies() access in tests by mocking getCurrentUser

102-145: LGTM: Best-effort cleanup is appropriately structured.

The sequential cleanup order (deactivate → delete prices → delete product) is correct, and the DEBUG-only logging keeps test output clean while providing observability when needed.


147-165: LGTM: Dynamic import ensures mocks are applied.

The dynamic import of the POST handler (line 152) ensures that module-level mocks are established before the route handler is loaded. This is a correct pattern for Vitest module mocking.


169-187: LGTM: Raw SQL query with appropriate type coercion.

The manual type coercion handles potential null/undefined values from the raw SQL results. The ordering by created_at ensures deterministic verification of move sequences.


189-231: LGTM: Test cleanup helper with correct deletion order.

The deletion sequence (inventory_moves → order_items → orders) respects foreign key constraints and uses raw SQL appropriately for tables not exported in the Drizzle schema.


234-350: LGTM: Comprehensive success path test with proper isolation.

This test thoroughly validates:

  • API response contract
  • DB state consistency
  • Inventory ledger correctness (single reserve, no duplicate)
  • Stock accounting
  • Restock functionality

The try-finally cleanup ensures test artifacts are removed even on failure.


352-441: LGTM: Idempotency contract is thoroughly tested.

This test validates the strict idempotency requirements mentioned in the PR objectives:

  • Same key + same payload → same order ID (no double-reserve)
  • Same key + different payload → 409 conflict

The assertion at line 410 is particularly important for preventing inventory corruption under retries.


443-509: LGTM: Orphan cleanup path test validates stale order handling.

This test correctly validates the orphan cleanup behavior described in the PR objectives, ensuring that no-payment orders with no inventory reservation are marked as terminal failures with appropriate metadata.

frontend/lib/services/orders.ts (12)

1-73: LGTM: Imports and type definitions support variant-aware checkout.

The additions properly support the variant (size/color) tracking and idempotency features introduced in this PR.


118-140: LGTM: Variant-aware item merging with quantity limits.

The merging logic correctly deduplicates items by productId + variant combination and enforces per-line quantity limits.


142-152: LGTM: Product-level aggregation for inventory reservation.

Since stock is tracked at the product level (not per-variant), this aggregation correctly consolidates quantities across all variants of the same product.


154-189: LGTM: Stable idempotency hash with variant support.

The hash includes all request-sensitive fields (currency, userId, items with variants) in a canonical sorted form. The version field (v: 1) allows for future hash format evolution.


212-306: LGTM: Order parsing preserves variants and minor-unit pricing.

The parsing logic correctly:

  • Preserves variant fields (size/color) in item summaries
  • Handles migration from legacy decimal to minor-unit pricing
  • Validates data integrity with appropriate error types

308-450: LGTM: No-payment reconciliation with correct finality semantics.

This function correctly implements the no-payment workflow described in the PR objectives:

  • Uses inventoryStatus='reserved' (not paymentStatus) as the finality signal (lines 341-347)
  • Handles partial failures with inventory rollback (lines 413-449)
  • Properly marks terminal failure states

The error handling on lines 424-428 uses logError instead of console.error, aligning with the PR commit message.


609-691: LGTM: Idempotency enforcement with migration path.

This function correctly:

  • Enforces strict currency compatibility (lines 626-632)
  • Handles legacy orders without stored hashes by deriving from persisted state (lines 634-646)
  • Performs best-effort backfill for future checks (lines 648-667)
  • Cleans up failed orders defensively (lines 676-690)

583-907: LGTM: Comprehensive order creation with idempotency and inventory integration.

The refactored flow correctly implements the transactionless checkout described in the PR objectives:

  • Currency derived from locale (line 594)
  • Strict idempotency with hash verification (lines 609-691, 696)
  • Minor-unit pricing throughout (lines 734-735, 797-798)
  • Variant fields persisted in order_items (lines 793-794)
  • Product-level inventory aggregation (lines 835-837)
  • Defensive inventory rollback on failure (lines 873-883)

The onConflictDoUpdate at lines 808-825 handles race conditions during idempotent retries, ensuring the order_items are updated consistently even if the insert races with an existing row.


1036-1194: LGTM: Restock-once semantics with lease-based concurrency control.

This function implements the "restock-once" guarantee mentioned in the PR objectives:

  • Checks if already released (lines 1062-1067) for idempotency
  • Handles orphan orders (no inventory reserved) by marking them terminal (lines 1083-1130)
  • Claims a lease before processing to prevent concurrent restocks (lines 1144-1153)
  • Uses WHERE stockRestored = false to ensure finalization happens exactly once (lines 1173)

The pattern correctly prevents double-restocking even under high concurrency.


1196-1285: LGTM: Batch restock sweep with lease-based work distribution.

The sweep implementation correctly:

  • Uses atomic claim-and-process pattern (lines 1243-1270)
  • Re-validates claim gate at UPDATE time to prevent stealing (lines 1264-1267)
  • Processes deterministically (oldest first) for fairness (line 1247)
  • Passes alreadyClaimed: true to avoid redundant locking (line 1277)

1288-1325: LGTM: No-payment specific restock sweep.

This function correctly targets the no-payment flow mentioned in the PR objectives:

  • Filters by paymentProvider='none' (line 1305)
  • Uses shorter timeout (10 min) appropriate for no-payment orders (line 1292)
  • Relies on restockOrder's internal lease mechanism for concurrency control

1327-1371: LGTM: Refund flow updated for provider-aware logic.

The refund function correctly restricts refunds to Stripe orders (lines 1342-1346) and integrates with the new restock workflow.

@ViktorSvertoka ViktorSvertoka merged commit 5bf2606 into develop Jan 6, 2026
8 checks passed
@ViktorSvertoka ViktorSvertoka deleted the lso/feat/shop branch January 6, 2026 06:55
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