Skip to content

(SP: 3) [SHOP] Harden Shop launch readiness: fail-closed checkout, refund containment, and shipping admin guards#405

Merged
ViktorSvertoka merged 10 commits intodevelopfrom
lso/feat/shop-audit-readiness
Mar 17, 2026
Merged

(SP: 3) [SHOP] Harden Shop launch readiness: fail-closed checkout, refund containment, and shipping admin guards#405
ViktorSvertoka merged 10 commits intodevelopfrom
lso/feat/shop-audit-readiness

Conversation

@liudmylasovetovs
Copy link
Collaborator

@liudmylasovetovs liudmylasovetovs commented Mar 16, 2026

Description

This PR implements the launch-readiness remediation work for the Shop module based on the production audit findings.

The changes focus on closing launch-critical gaps in checkout, refund handling, and shipping operations:

  • checkout provider resolution is now server-authoritative and fail-closed
  • new orders can no longer be created through the legacy paymentProvider='none' path
  • Stripe admin refunds now create a contained non-shippable state and converge through webhook/reconciliation
  • Monobank admin refunds and return refunds are disabled for launch
  • admin shipping actions now require a valid compatible shipment record and respect centralized non-shippable eligibility rules

The goal of this PR is to make the Shop safe for production launch with real orders and payments, without expanding scope into post-launch refund features.


Related Issue

Issue: #<issue_number>


Changes

  • Implemented server-authoritative checkout provider resolution with fail-closed 503 PSP_UNAVAILABLE behavior when no valid payment provider is available
  • Removed production-facing checkout creation paths that could persist new orders with paymentProvider='none'
  • Added Stripe refund containment and recovery flow using orders.psp_status_reason = 'REFUND_REQUESTED' as the single refund-requested marker
  • Added Stripe refund reconciliation for terminal success/failure and protected against double finalization / double restock
  • Disabled Monobank admin refunds for launch
  • Disabled in-app return refunds for launch
  • Centralized shipping eligibility checks for refund-contained orders
  • Hardened admin shipping actions to require a valid shipment row in a compatible state before mark_shipped / mark_delivered
  • Updated and added Shop tests covering checkout fail-closed behavior, refund containment/convergence, and admin shipping guards
  • Fixed follow-up regressions in checkout currency-policy and test setup so the full Shop suite aligns with the new launch-safe contracts

Database Changes (if applicable)

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

How Has This Been Tested?

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

Additional local validation:

  • Batch-focused Shop tests for checkout fail-closed behavior
  • Stripe refund containment, webhook finalization, and stale reconciliation tests
  • Monobank refund disabled guard tests
  • Return refund disabled guard tests
  • Admin shipping state/eligibility tests
  • Full Shop test suite regression run on the local Shop test DB

Screenshots (if applicable)

N/A


Checklist

Before submitting

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

Reviewers

Summary by CodeRabbit

  • New Features

    • Batch Stripe refund reconciliation and automated recovery for stale refunds.
    • Dynamic checkout provider selection considering requested provider/method and currency.
    • New Stripe refund retrieval utility for reconciliation flows.
  • Bug Fixes

    • Shipping eligibility updated to block shipment queuing when refunds are pending/contained.
    • Safer webhook handling and improved restoration paths for failed refunds.
  • Refactor

    • Centralized refund finalization and unified PSP metadata containment.
  • Chores

    • Refund and return refund flows disabled for launch.
  • Tests

    • Many new and updated tests covering refunds, shipping eligibility, checkout providers, and webhooks.

@vercel
Copy link
Contributor

vercel bot commented Mar 16, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
devlovers-net Ignored Ignored Preview Mar 16, 2026 10:31pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

Warning

Rate limit exceeded

@liudmylasovetovs has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 7 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 62a9b697-db92-4a69-b5ed-d64f8b576289

📥 Commits

Reviewing files that changed from the base of the PR and between f68f0db and b3c9540.

📒 Files selected for processing (1)
  • frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts
📝 Walkthrough

Walkthrough

Disables Monobank and return refunds at runtime, adds a Stripe refund reconciliation subsystem, introduces provider-aware checkout selection with fail‑closed behavior, threads refund-containment into shipping eligibility/workers/admin actions, and updates many tests and janitor/admin reconcile routes.

Changes

Cohort / File(s) Summary
Admin refund routes
frontend/app/api/shop/admin/orders/[id]/refund/route.ts, frontend/app/api/shop/admin/returns/[id]/refund/route.ts
Removed runtime Monobank/return refund execution; routes now short‑circuit with REFUND_DISABLED / RETURN_REFUND_DISABLED (HTTP 409). Previously-triggered refund calls and result handling removed.
Stripe refund reconciliation (new)
frontend/lib/services/orders/stripe-refund-reconciliation.ts, frontend/lib/services/orders.ts
New module implementing per‑order and batch Stripe refund reconciliation (finalize/restore/reconcileStale). Adds containment snapshots, PSP metadata updates, restock/restore, and public re-exports. Review dedupe, DB writes, and event emission.
PSP Stripe helper
frontend/lib/psp/stripe.ts
Added retrieveRefund(refundId) and result type to fetch/normalize Stripe refund objects; new error mappings for retrieval.
Checkout / payments resolution
frontend/app/api/shop/checkout/route.ts, frontend/lib/services/orders/checkout.ts, frontend/lib/shop/payments.ts
Introduced provider candidate resolution (resolveCheckoutProviderCandidates, infer/resolve defaults), provider-aware method/currency selection, PSP availability checks and PSP_UNAVAILABLE (503) fail‑closed behavior.
Restock / janitor & admin reconcile
frontend/app/api/shop/internal/orders/restock-stale/route.ts, frontend/app/api/shop/admin/orders/reconcile-stale/route.ts
Integrated stale Stripe refund sweep (reconcileStaleStripeRefundOrders) into restock-stale janitor and admin reconcile flows; added per-category counters and logging.
Refund flow & returns service
frontend/lib/services/orders/refund.ts, frontend/lib/services/shop/returns.ts
Refund path now embeds a containment snapshot into pspMetadata and triggers shipping pipeline closure; refundReturnRequest replaced with an immediate RETURN_REFUND_DISABLED error (always throws).
Webhook handling (Stripe)
frontend/app/api/shop/webhooks/stripe/route.ts
Replaced inline refund dual‑write/atomic apply logic with centralized reconciliation calls (finalizeStripeRefundSuccess, restoreStripeRefundFailure); updated order selection to include PSP fields and eligibility checks.
Shipping eligibility & workers
frontend/lib/services/shop/shipping/eligibility.ts, frontend/lib/services/shop/shipping/admin-actions.ts, frontend/lib/services/shop/shipping/shipments-worker.ts
Added psp_status_reason and REFUND_REQUESTED containment; eligibility functions and worker/admin flows now consider containment and may short‑circuit shipping actions.
Monobank webhook & SQL changes
frontend/lib/services/orders/monobank-webhook.ts
Replaced prior inventory eligibility SQL with orderShippingEligibilityWhereSql(...) predicate usage, affecting which orders qualify for shipping transitions.
Tests — refund, checkout, shipping, reconcilers
frontend/lib/tests/... (many files)
Extensive test additions/updates: refund containment, Stripe reconciliation, checkout provider availability, shipping eligibility, disabled-return/monobank refund expectations, env mocks, and new integration suites. Review mock/env changes and CI implications.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant Checkout as Checkout Route
    participant Payments as Payments Resolver
    participant PSP as PSP (Stripe/Monobank)
    participant DB as Database

    Client->>Checkout: POST /checkout (provider?, method, locale)
    Checkout->>Payments: resolveCheckoutProviderCandidates(requestedProvider, requestedMethod, currency)
    Payments-->>Checkout: resolvedProvider(s)
    alt Provider available
        Checkout->>PSP: create/payment-init (provider-specific)
        PSP-->>Checkout: payment ids / errors
        Checkout->>DB: create order with paymentProvider & PSP ids
        DB-->>Checkout: created order
        Checkout-->>Client: 200 OK (order + payment details)
    else No provider available
        Checkout-->>Client: 503 PSP_UNAVAILABLE
    end
Loading
sequenceDiagram
    participant StripeWebhook as Stripe Webhook
    participant Reconciler as Refund Reconciler
    participant PSP as Stripe PSP
    participant DB as Database
    participant Shipping as Shipping pipeline

    StripeWebhook->>Reconciler: refund event (refundId, chargeId)
    Reconciler->>PSP: retrieveRefund(refundId)
    PSP-->>Reconciler: refund status (succeeded/failed/pending)
    Reconciler->>DB: load order + PSP metadata (containment snapshot)
    alt refund succeeded
        Reconciler->>DB: finalizeStripeRefundSuccess (mark refunded, emit payment_event)
        Reconciler->>Shipping: restock / cancel shipments
    else refund failed/canceled
        Reconciler->>DB: restoreStripeRefundFailure (restore paid, replay shipping)
        Reconciler->>Shipping: re-queue shipments from containment snapshot
    else refund pending / unknown
        Reconciler->>DB: mark psp_status_reason = REFUND_REQUESTED, close queued ship work
    end
    Reconciler-->>StripeWebhook: ack (200)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

enhancement, refactor

Suggested reviewers

  • AM1007
  • ViktorSvertoka

"I hopped through ledgers, soft and fleet,
I held back ships when refunds did meet,
I stitched Stripe’s threads and kept states sound,
Closed doors where refunds must not outbound,
A rabbit’s patch — reconciled and neat. 🐰"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: hardening Shop launch readiness with fail-closed checkout, refund containment via PSP status tracking, and shipping admin action guards.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch lso/feat/shop-audit-readiness
📝 Coding Plan
  • Generate coding plan for human review comments

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

❤️ Share

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

Copy link

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

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42745c1dc2

ℹ️ 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".

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (6)
frontend/app/api/shop/webhooks/stripe/route.ts (1)

867-887: ⚠️ Potential issue | 🟠 Major

Keep omitted-provider retries idempotent across PSP availability changes.

When the client omits paymentProvider, these lines resolve the rail from current availability and then persist that resolved rail/method through createOrderWithItems(). A UAH checkout can therefore hash as monobank/monobank_invoice on the first request and stripe/stripe_card on a retry after Monobank goes down, so the same Idempotency-Key turns into IDEMPOTENCY_CONFLICT instead of recovering the original order.

Also applies to: 889-891, 989-992, 1176-1178

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

In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 867 - 887, When
paymentProvider is omitted the code currently recomputes the provider/method on
each request (which can flip when PSP availability changes), so compute and
freeze the resolved provider/method once at the start (e.g., set a
resolvedPaymentProvider and resolvedPaymentMethod variable when first deriving
them from the incoming payload and availability lookup) and always pass those
frozen values into createOrderWithItems() and any later logic that persists the
order; update the branches that handle paymentIntentId/refundObject to use the
same frozen resolved values (reference paymentIntentId, refundObject and
createOrderWithItems) so retries use the originally resolved rail/method rather
than re-resolving.
frontend/lib/services/shop/shipping/admin-actions.ts (1)

584-621: ⚠️ Potential issue | 🟠 Major

Preserve the no-op fast path for repeated shipping actions.

These new shipment checks run before the existing shipping_status === 'shipped' / 'delivered' early returns. A duplicate admin request can now start failing if the shipment row drifted after the first successful action, even though no transition is being attempted.

🐛 Suggested fix
   if (args.action === 'mark_shipped') {
-    assertShipmentSupportsManualFulfillment(state, 'mark_shipped');
-
     if (state.shipping_status === 'shipped') {
       await appendAuditEntry({
         orderId: args.orderId,
         entry: {
@@
         changed: false,
         action: args.action,
       };
     }
+
+    assertShipmentSupportsManualFulfillment(state, 'mark_shipped');
 
     if (!isShippingStatusTransitionAllowed(state.shipping_status, 'shipped')) {
       throw new ShippingAdminActionError(
         'INVALID_SHIPPING_TRANSITION',
         'mark_shipped is allowed only from label_created.',
@@
-  assertShipmentSupportsManualFulfillment(state, 'mark_delivered');
-
   if (state.shipping_status === 'delivered') {
     await appendAuditEntry({
       orderId: args.orderId,
       entry: {
@@
       changed: false,
       action: args.action,
     };
   }
+
+  assertShipmentSupportsManualFulfillment(state, 'mark_delivered');
 
   if (!isShippingStatusTransitionAllowed(state.shipping_status, 'delivered')) {
     throw new ShippingAdminActionError(
       'INVALID_SHIPPING_TRANSITION',
       'mark_delivered is allowed only from shipped.',

Also applies to: 684-720

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

In `@frontend/lib/services/shop/shipping/admin-actions.ts` around lines 584 - 621,
The new shipment-row validation runs before the existing early-return fast paths
causing duplicate admin requests to fail; restore the no-op fast path by moving
the checks that validate the shipment row so they occur after the existing
shipping_status === 'shipped' / 'delivered' early-return blocks. Concretely, in
the handler for args.action === 'mark_shipped' (and the analogous block around
lines 684-720), ensure the assertShipmentSupportsManualFulfillment and any
shipment-row consistency checks are executed only after you perform the existing
shipping_status === 'shipped' audit append (appendAuditEntry /
buildShippingAdminAuditDedupe) and return the no-change response, so repeated
identical admin requests still hit the preserved fast path.
frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts (1)

133-202: ⚠️ Potential issue | 🟠 Major

Move the DB teardown to an outer finally.

If checkout fails before Line 207—503, invalid JSON, missing orderId, etc.—cleanupByIds() never runs and the seeded product/price rows leak into the shared test database. Since cleanupByIds() already tolerates an absent orderId, it should be called from an outer finally.

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

In `@frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts` around lines
133 - 202, The test currently only calls cleanupByIds() after the happy-path
assertions, so failures before orderId extraction leak seeded rows; refactor the
test to wrap the whole operation in a try/finally (outer finally) and call
cleanupByIds(productId, priceId, orderId) from that finally block so cleanup
always runs even if checkoutPOST(req) throws, res.json() is invalid, or orderId
is missing; keep the existing local variables (productId, priceId, idem,
orderId) and ensure orderId is passed (or undefined) into cleanupByIds so its
tolerant behavior remains intact.
frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts (1)

119-296: ⚠️ Potential issue | 🟠 Major

Always clean up seeded checkout data in a finally.

If any assertion before Line 268 fails, this test skips teardown and leaves behind products, inventory moves, and the winning order. That leaks mutable stock state into later tests. Wrap the whole scenario in try/finally and keep the existing cleanup inside the finally.

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

In `@frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts` around lines 119
- 296, Wrap the entire test body (from after inserting the product/productPrices
through the assertions and before the current cleanup) in a try block and move
the current cleanup logic (the code that queries orderItems, deletes orderItems,
inventoryMoves, productPrices, orders, and products) into a finally block so it
always runs; keep the special-case rethrow behavior for CI by rethrowing the
caught error after cleanup (or capture the original error and rethrow it in the
finally), and ensure references to productId, db.delete(...), inventoryMoves,
productPrices, orderItems and orders remain unchanged so teardown reliably
cleans seeded data even if assertions fail.
frontend/lib/services/shop/shipping/shipments-worker.ts (2)

693-749: ⚠️ Potential issue | 🔴 Critical

Re-check refund containment at the success write boundary.

A refund request can land after this eligibility check and before createInternetDocument()/markSucceeded(). In that window the worker still creates the label and advances the order to label_created, because the success path only guards on shipping-state transitions. That leaves containment fail-open for the in-flight case.

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

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 693 -
749, Re-check the order refund/containment state immediately before proceeding
to create labels: after running evaluateOrderShippingEligibility and before
calling createInternetDocument/markSucceeded, fetch the latest order state (or
re-run evaluateOrderShippingEligibility against the latest order snapshot) and
confirm there is no refund/requested refund; if a refund is now present, throw
the appropriate buildFailure instead of creating a label. Update the flow around
evaluateOrderShippingEligibility, createInternetDocument, and markSucceeded to
perform this final containment check using the same identifying fields on
details (e.g., refund status fields) and abort before calling
markSucceeded/createInternetDocument when a refund has appeared.

445-450: ⚠️ Potential issue | 🟠 Major

Don’t exclude expired processing rows from reclamation.

This filter now blocks stale processing shipments from being re-claimed after an order becomes refund-contained. Those rows stay stuck in processing with an expired lease forever, because the worker never reaches processClaimedShipment() to downgrade them. Keep the eligibility gate for queued/failed, but let expired processing rows through so recovery still works.

Possible fix
-        and ${orderShippingEligibilityWhereSql({
-          paymentStatusColumn: sql`o.payment_status`,
-          orderStatusColumn: sql`o.status`,
-          inventoryStatusColumn: sql`o.inventory_status`,
-          pspStatusReasonColumn: sql`o.psp_status_reason`,
-        })}
+        and (
+          s.status = 'processing'
+          or ${orderShippingEligibilityWhereSql({
+            paymentStatusColumn: sql`o.payment_status`,
+            orderStatusColumn: sql`o.status`,
+            inventoryStatusColumn: sql`o.inventory_status`,
+            pspStatusReasonColumn: sql`o.psp_status_reason`,
+          })}
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 445 -
450, The current eligibility filter call to orderShippingEligibilityWhereSql
(used in the query that precedes processClaimedShipment) excludes all
"processing" rows, which prevents reclaiming stale processing shipments with
expired leases; update the logic so that orderShippingEligibilityWhereSql keeps
its filtering for queued/failed but does not exclude processing rows whose lease
has expired (e.g., add a condition/flag to orderShippingEligibilityWhereSql to
allow processing rows when lease_expires_at <= now or when a passed-in
allowExpiredProcessing=true), then use that updated call in the query so
processClaimedShipment can still be reached to downgrade expired processing
rows.
🧹 Nitpick comments (4)
frontend/lib/services/orders/refund.ts (1)

27-32: Consider extracting shared readRows helper.

This helper duplicates readDbRows in monobank-webhook.ts (line 154). Consider extracting to a shared utility (e.g., @/lib/db/utils) to reduce duplication.

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

In `@frontend/lib/services/orders/refund.ts` around lines 27 - 32, The helper
function readRows duplicates logic found in readDbRows (in monobank-webhook.ts);
extract this logic into a shared utility (e.g., create a function named readRows
in a new module like "@/lib/db/utils") and replace the local readRows and the
existing readDbRows with imports from that module; update the files that used
the previous implementations (frontend/lib/services/orders/refund.ts and
monobank-webhook.ts) to import the shared readRows, ensuring the exported
function signature remains generic (readRows<T>(res: unknown): T[]) and behavior
unchanged.
frontend/app/api/shop/admin/returns/[id]/refund/route.ts (1)

74-79: Make the post-refund branch explicitly unreachable.

This handler now depends on refundReturnRequest() throwing. If that function ever resolves again, the route will silently emit INTERNAL_ERROR instead of a real success response.

♻️ Proposed cleanup
     await refundReturnRequest({
       returnRequestId: returnRequestIdForLog,
       actorUserId: typeof admin.id === 'string' ? admin.id : null,
       requestId,
     });
-    return noStoreJson({ code: 'INTERNAL_ERROR' }, 500);
+    throw new Error('refundReturnRequest unexpectedly resolved');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/admin/returns/`[id]/refund/route.ts around lines 74 -
79, The post-refund branch is currently reachable if refundReturnRequest() ever
resolves, causing a silent INTERNAL_ERROR response; after calling
refundReturnRequest({ returnRequestId: ..., actorUserId: ..., requestId }), make
the fallback explicitly unreachable by throwing an error (e.g., throw new
Error('refundReturnRequest resolved unexpectedly')) instead of returning
noStoreJson, or replace the noStoreJson fallback with an explicit thrown
Response so the route cannot silently succeed; update the code referencing
refundReturnRequest and the current noStoreJson fallback accordingly.
frontend/lib/tests/shop/stripe-refund-convergence.test.ts (1)

231-247: Make the second-pass expectation strict.

Allowing finalized_success on the second reconciliation weakens the regression for “no double finalization” and can still hide duplicate terminal side effects. Prefer asserting noop here, or explicitly counting the terminal writes/events as well.

♻️ Proposed tightening
     const second = await reconcileStripeRefundOrder({
       orderId: seeded.orderId,
     });
-    expect(['finalized_success', 'noop']).toContain(second);
+    expect(second).toBe('noop');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/stripe-refund-convergence.test.ts` around lines 231 -
247, The test currently allows the second call to reconcileStripeRefundOrder({
orderId: seeded.orderId }) to return either 'finalized_success' or 'noop', which
weakens the regression; change the assertion to require 'noop' only (i.e.,
expect the variable named second to strictly equal 'noop') and ensure any
terminal side-effects are explicitly asserted (you already check
stockRestored/restockedAt and countMoveKey(releaseKey) — keep those assertions
to confirm no duplicate terminal writes). Update the assertion around
reconcileStripeRefundOrder in stripe-refund-convergence.test.ts accordingly so
the second pass must be 'noop'.
frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts (1)

122-130: Consider persisting pspStatusReason on the direct seed path too.

Line 129 only writes the new field when requiresPostInsertBlockedTransition is true. If this helper later seeds an already-shippable order with some other non-null PSP reason, the row won't match the requested fixture state.

♻️ Small fixture-consistency tweak
   await db.insert(orders).values({
     id: orderId,
     totalAmountMinor: 1000,
     totalAmount: toDbMoney(1000),
     currency: 'USD',
     paymentProvider: 'stripe',
     paymentStatus: seedPaymentStatus,
     status: seedOrderStatus,
     inventoryStatus: seedInventoryStatus,
+    pspStatusReason: requiresPostInsertBlockedTransition
+      ? null
+      : (args.pspStatusReason ?? null),
     shippingRequired: true,
     shippingPayer: 'customer',
     shippingProvider: 'nova_poshta',
     shippingMethodCode: 'NP_WAREHOUSE',
     shippingAmountMinor: null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts` around lines 122
- 130, The new pspStatusReason field is only persisted in the post-insert
transition branch; ensure the direct-seed path also writes it so fixtures match
expected state by adding pspStatusReason (args.pspStatusReason ?? null) to the
.set(...) payload used when !requiresPostInsertBlockedTransition (the same
update flow that calls db.update(orders).set(...)). Locate the branch guarded by
requiresPostInsertBlockedTransition and mirror the pspStatusReason handling into
the other update/insert path so both code paths persist the field consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 533-538: The replay/fallback branch for handling
payment_intent.succeeded still uses only the old checks (shippingRequired,
shippingProvider, shippingMethodCode, inventoryStatus) and needs the same
orderShippingEligibilityWhereSql gating used in the main enqueue SQL; update the
manual requeue SQL/where clause in the replay path to include the result of
orderShippingEligibilityWhereSql (pass the same column aliases used elsewhere:
paymentStatusColumn: sql`o.payment_status`, orderStatusColumn: sql`o.status`,
inventoryStatusColumn: sql`o.inventory_status`, pspStatusReasonColumn:
sql`o.psp_status_reason`) so applyStripePaidAndQueueShipmentAtomic() and the
replay fallback share identical eligibility logic. Ensure the replay branch that
reopens shipping respects psp_status_reason='REFUND_REQUESTED' by incorporating
that generated SQL fragment into its WHERE condition.
- Around line 1809-1853: The new restore branch for charge.refund.updated is
currently unreachable because the earlier isRefundChargeIdOnly(refund) fast-path
acks those events before we can handle REFUND_REQUESTED orders with
failed/canceled refunds; modify the control flow so events with refund.charge as
an ID still hit the restore logic when the order has pspStatusReason ===
'REFUND_REQUESTED' and refund.status is 'failed' or 'canceled' — either move the
block that calls buildPspMetadata / mergePspMetadata /
restoreStripeRefundFailure / logWebhookEvent / ack() to run before the
isRefundChargeIdOnly(refund) fast-path, or change the
isRefundChargeIdOnly(refund) check to skip acking and allow processing when
order.pspStatusReason === 'REFUND_REQUESTED' and refund.status is
failed/canceled, ensuring restoreStripeRefundFailure is invoked with refund.id,
chargeId (charge?.id ?? refundChargeId ?? null), paymentIntentId, nextMeta, and
then ack() is returned.

In `@frontend/lib/tests/shop/checkout-currency-policy.test.ts`:
- Around line 32-47: The mock for payment-attempts loads the actual module via
vi.importActual before your test hooks run, causing stale env reads; call
resetEnvCache() immediately before invoking vi.importActual (and again after any
env flag changes) so the imported module reads the updated payment flags; apply
this change around the vi.mock block that wraps
ensureStripePaymentIntentForOrder (and replicate the same resetEnvCache()
placement for the other similar vi.mock blocks that affect Stripe/Monobank
cases).

---

Outside diff comments:
In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 867-887: When paymentProvider is omitted the code currently
recomputes the provider/method on each request (which can flip when PSP
availability changes), so compute and freeze the resolved provider/method once
at the start (e.g., set a resolvedPaymentProvider and resolvedPaymentMethod
variable when first deriving them from the incoming payload and availability
lookup) and always pass those frozen values into createOrderWithItems() and any
later logic that persists the order; update the branches that handle
paymentIntentId/refundObject to use the same frozen resolved values (reference
paymentIntentId, refundObject and createOrderWithItems) so retries use the
originally resolved rail/method rather than re-resolving.

In `@frontend/lib/services/shop/shipping/admin-actions.ts`:
- Around line 584-621: The new shipment-row validation runs before the existing
early-return fast paths causing duplicate admin requests to fail; restore the
no-op fast path by moving the checks that validate the shipment row so they
occur after the existing shipping_status === 'shipped' / 'delivered'
early-return blocks. Concretely, in the handler for args.action ===
'mark_shipped' (and the analogous block around lines 684-720), ensure the
assertShipmentSupportsManualFulfillment and any shipment-row consistency checks
are executed only after you perform the existing shipping_status === 'shipped'
audit append (appendAuditEntry / buildShippingAdminAuditDedupe) and return the
no-change response, so repeated identical admin requests still hit the preserved
fast path.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts`:
- Around line 693-749: Re-check the order refund/containment state immediately
before proceeding to create labels: after running
evaluateOrderShippingEligibility and before calling
createInternetDocument/markSucceeded, fetch the latest order state (or re-run
evaluateOrderShippingEligibility against the latest order snapshot) and confirm
there is no refund/requested refund; if a refund is now present, throw the
appropriate buildFailure instead of creating a label. Update the flow around
evaluateOrderShippingEligibility, createInternetDocument, and markSucceeded to
perform this final containment check using the same identifying fields on
details (e.g., refund status fields) and abort before calling
markSucceeded/createInternetDocument when a refund has appeared.
- Around line 445-450: The current eligibility filter call to
orderShippingEligibilityWhereSql (used in the query that precedes
processClaimedShipment) excludes all "processing" rows, which prevents
reclaiming stale processing shipments with expired leases; update the logic so
that orderShippingEligibilityWhereSql keeps its filtering for queued/failed but
does not exclude processing rows whose lease has expired (e.g., add a
condition/flag to orderShippingEligibilityWhereSql to allow processing rows when
lease_expires_at <= now or when a passed-in allowExpiredProcessing=true), then
use that updated call in the query so processClaimedShipment can still be
reached to downgrade expired processing rows.

In `@frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts`:
- Around line 119-296: Wrap the entire test body (from after inserting the
product/productPrices through the assertions and before the current cleanup) in
a try block and move the current cleanup logic (the code that queries
orderItems, deletes orderItems, inventoryMoves, productPrices, orders, and
products) into a finally block so it always runs; keep the special-case rethrow
behavior for CI by rethrowing the caught error after cleanup (or capture the
original error and rethrow it in the finally), and ensure references to
productId, db.delete(...), inventoryMoves, productPrices, orderItems and orders
remain unchanged so teardown reliably cleans seeded data even if assertions
fail.

In `@frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts`:
- Around line 133-202: The test currently only calls cleanupByIds() after the
happy-path assertions, so failures before orderId extraction leak seeded rows;
refactor the test to wrap the whole operation in a try/finally (outer finally)
and call cleanupByIds(productId, priceId, orderId) from that finally block so
cleanup always runs even if checkoutPOST(req) throws, res.json() is invalid, or
orderId is missing; keep the existing local variables (productId, priceId, idem,
orderId) and ensure orderId is passed (or undefined) into cleanupByIds so its
tolerant behavior remains intact.

---

Nitpick comments:
In `@frontend/app/api/shop/admin/returns/`[id]/refund/route.ts:
- Around line 74-79: The post-refund branch is currently reachable if
refundReturnRequest() ever resolves, causing a silent INTERNAL_ERROR response;
after calling refundReturnRequest({ returnRequestId: ..., actorUserId: ...,
requestId }), make the fallback explicitly unreachable by throwing an error
(e.g., throw new Error('refundReturnRequest resolved unexpectedly')) instead of
returning noStoreJson, or replace the noStoreJson fallback with an explicit
thrown Response so the route cannot silently succeed; update the code
referencing refundReturnRequest and the current noStoreJson fallback
accordingly.

In `@frontend/lib/services/orders/refund.ts`:
- Around line 27-32: The helper function readRows duplicates logic found in
readDbRows (in monobank-webhook.ts); extract this logic into a shared utility
(e.g., create a function named readRows in a new module like "@/lib/db/utils")
and replace the local readRows and the existing readDbRows with imports from
that module; update the files that used the previous implementations
(frontend/lib/services/orders/refund.ts and monobank-webhook.ts) to import the
shared readRows, ensuring the exported function signature remains generic
(readRows<T>(res: unknown): T[]) and behavior unchanged.

In `@frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts`:
- Around line 122-130: The new pspStatusReason field is only persisted in the
post-insert transition branch; ensure the direct-seed path also writes it so
fixtures match expected state by adding pspStatusReason (args.pspStatusReason ??
null) to the .set(...) payload used when !requiresPostInsertBlockedTransition
(the same update flow that calls db.update(orders).set(...)). Locate the branch
guarded by requiresPostInsertBlockedTransition and mirror the pspStatusReason
handling into the other update/insert path so both code paths persist the field
consistently.

In `@frontend/lib/tests/shop/stripe-refund-convergence.test.ts`:
- Around line 231-247: The test currently allows the second call to
reconcileStripeRefundOrder({ orderId: seeded.orderId }) to return either
'finalized_success' or 'noop', which weakens the regression; change the
assertion to require 'noop' only (i.e., expect the variable named second to
strictly equal 'noop') and ensure any terminal side-effects are explicitly
asserted (you already check stockRestored/restockedAt and
countMoveKey(releaseKey) — keep those assertions to confirm no duplicate
terminal writes). Update the assertion around reconcileStripeRefundOrder in
stripe-refund-convergence.test.ts accordingly so the second pass must be 'noop'.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: da9743a2-a409-498d-9586-cfc5a9bc9a47

📥 Commits

Reviewing files that changed from the base of the PR and between 4f506e0 and 42745c1.

📒 Files selected for processing (34)
  • frontend/app/api/shop/admin/orders/[id]/refund/route.ts
  • frontend/app/api/shop/admin/orders/reconcile-stale/route.ts
  • frontend/app/api/shop/admin/returns/[id]/refund/route.ts
  • frontend/app/api/shop/checkout/route.ts
  • frontend/app/api/shop/internal/orders/restock-stale/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/lib/psp/stripe.ts
  • frontend/lib/services/orders.ts
  • frontend/lib/services/orders/checkout.ts
  • frontend/lib/services/orders/monobank-webhook.ts
  • frontend/lib/services/orders/refund.ts
  • frontend/lib/services/orders/stripe-refund-reconciliation.ts
  • frontend/lib/services/shop/returns.ts
  • frontend/lib/services/shop/shipping/admin-actions.ts
  • frontend/lib/services/shop/shipping/eligibility.ts
  • frontend/lib/services/shop/shipping/shipments-worker.ts
  • frontend/lib/shop/payments.ts
  • frontend/lib/tests/shop/admin-shipping-canonical-audit.test.ts
  • frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts
  • frontend/lib/tests/shop/admin-shipping-state-sync.test.ts
  • frontend/lib/tests/shop/admin-stripe-refund-containment.test.ts
  • frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts
  • frontend/lib/tests/shop/checkout-currency-policy.test.ts
  • frontend/lib/tests/shop/checkout-monobank-idempotency-contract.test.ts
  • frontend/lib/tests/shop/checkout-monobank-parse-validation.test.ts
  • frontend/lib/tests/shop/checkout-no-payments.test.ts
  • frontend/lib/tests/shop/checkout-route-stripe-disabled-recovery.test.ts
  • frontend/lib/tests/shop/monobank-refund-route-f4.test.ts
  • frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts
  • frontend/lib/tests/shop/returns-phase4.test.ts
  • frontend/lib/tests/shop/returns-refund-disabled-for-launch.test.ts
  • frontend/lib/tests/shop/shipping-eligibility-refund-contained.test.ts
  • frontend/lib/tests/shop/stripe-refund-convergence.test.ts
  • frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

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/webhooks/stripe/route.ts (1)

1052-1075: ⚠️ Potential issue | 🟠 Major

Add an explicit Stripe provider guard before any order mutation.

paymentProvider is selected (Line 1054) but not enforced. Later branches still update by orders.id only (e.g., Line 1159, Line 1904, Line 1954), so a mis-associated event could mutate non-Stripe orders.

🔧 Proposed guard
     if (!order) {
       logWebhookEvent({
         eventType,
         paymentIntentId,
         paymentStatus,
         chargeId: bestEffortChargeId,
         refundId: bestEffortRefundId,
         requestId,
         stripeEventId,
       });
       return ack();
     }
+
+    if (order.paymentProvider !== 'stripe') {
+      logWarn('stripe_webhook_provider_mismatch', {
+        ...eventMeta(),
+        code: 'PAYMENT_PROVIDER_MISMATCH',
+        orderId: order.id,
+        paymentProvider: order.paymentProvider,
+        paymentIntentId,
+      });
+      return ack();
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 1052 - 1075, The
selected order row includes paymentProvider but there is no enforcement that it
is Stripe before performing updates; add an explicit guard that verifies
orders.paymentProvider === 'stripe' for the resolvedOrderId before any mutation
(or include it in all update where clauses) so that functions using
resolvedOrderId (and update branches that reference orders.id) only mutate
Stripe-backed orders; update the selection/check logic around resolvedOrderId
and the update statements that touch orders (the branches referenced by
orders.id at the later update sites) to either short-circuit when
paymentProvider !== 'stripe' or add a where(eq(orders.paymentProvider,
'stripe')) predicate to each update.
frontend/lib/services/shop/shipping/shipments-worker.ts (1)

418-454: ⚠️ Potential issue | 🟠 Major

Move the eligibility filter ahead of LIMIT.

candidates now picks the first limit queued/failed rows before orderShippingEligibilityWhereSql(...) runs. Refund-contained or otherwise blocked shipments stay in queued/failed, never reach released_blocked, and get selected again on every run. Once enough of them accumulate, eligible shipments behind them stop getting claimed.

💡 One way to avoid starving eligible shipments
     with candidates as (
       select
         s.id,
         s.order_id,
         s.status as candidate_status
       from shipping_shipments s
+      join orders o on o.id = s.order_id
       where (
-        (
-          s.status in ('queued', 'failed')
-          and (s.next_attempt_at is null or s.next_attempt_at <= now())
-        )
-        or s.status = 'processing'
+        s.status = 'processing'
+        or (
+          s.status in ('queued', 'failed')
+          and (s.next_attempt_at is null or s.next_attempt_at <= now())
+          and ${orderShippingEligibilityWhereSql({
+            paymentStatusColumn: sql`o.payment_status`,
+            orderStatusColumn: sql`o.status`,
+            inventoryStatusColumn: sql`o.inventory_status`,
+            pspStatusReasonColumn: sql`o.psp_status_reason`,
+          })}
+          and ${shippingStatusTransitionWhereSql({
+            column: sql`o.shipping_status`,
+            to: 'creating_label',
+            allowNullFrom: true,
+            includeSame: true,
+          })}
+        )
       )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts` around lines 418 -
454, The candidates CTE is selecting the first args.limit shipments before the
orderShippingEligibilityWhereSql filter runs, causing blocked/refund shipments
to persist and starve eligible ones; move the eligibility check into the
candidates CTE (join orders and apply orderShippingEligibilityWhereSql there) so
the WHERE that uses
sql`o.payment_status`/`o.status`/`o.inventory_status`/`o.psp_status_reason` runs
before the ORDER BY ... LIMIT ${args.limit} and FOR UPDATE SKIP LOCKED, then
keep the claimed CTE as the UPDATE that sets status='processing',
lease_owner=${args.runId}, lease_expires_at using ${args.leaseSeconds}, and
updated_at=now().
♻️ Duplicate comments (2)
frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts (1)

35-42: ⚠️ Potential issue | 🟠 Major

Reset the env cache before both hoisted vi.importActual() calls.

These factories can still load the real Stripe modules before beforeAll mutates the payment env, so this test stays import-order dependent. Add resetEnvCache() at the top of each factory, like the currency-policy test already does.

Suggested fix
 vi.mock('@/lib/env/stripe', async () => {
+  resetEnvCache();
   const actual =
     await vi.importActual<Record<string, unknown>>('@/lib/env/stripe');
   return {
     ...actual,
     isPaymentsEnabled: () => true,
   };
 });

 vi.mock('@/lib/services/orders/payment-attempts', async () => {
+  resetEnvCache();
   const actual = await vi.importActual<any>(
     '@/lib/services/orders/payment-attempts'
   );

Also applies to: 44-58

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

In `@frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts` around lines
35 - 42, The vi.mock factories that call vi.importActual('@/lib/env/stripe')
must call resetEnvCache() first to avoid import-order dependence; update both
factories (the one overriding isPaymentsEnabled in this file and the other
similar factory around lines 44-58) to invoke resetEnvCache() at the top of each
factory before calling vi.importActual so the real Stripe/env module is reloaded
after tests mutate the payment env.
frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts (1)

26-41: ⚠️ Potential issue | 🟠 Major

Reset the env cache before importing the real payment-attempts module.

This mock still calls vi.importActual() before the file's Stripe env is established in beforeAll, so provider availability can depend on import order. Add resetEnvCache() at the top of the factory.

Suggested fix
 vi.mock('@/lib/services/orders/payment-attempts', async () => {
+  resetEnvCache();
   const actual = await vi.importActual<any>(
     '@/lib/services/orders/payment-attempts'
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts` around lines 26
- 41, The mock factory for payment-attempts calls vi.importActual(...) before
the test env is prepared; to fix it call resetEnvCache() as the first statement
inside the vi.mock factory so environment variables are cleared before importing
the real module, then proceed to await vi.importActual(...) and override
ensureStripePaymentIntentForOrder (the mocked function name) as currently
implemented; this ensures the real module sees the correct Stripe env set in
beforeAll.
🧹 Nitpick comments (4)
frontend/lib/tests/shop/stripe-refund-convergence.test.ts (2)

159-166: Consider cleaning up inventory_moves records in teardown.

The cleanup function deletes products, orders, and shipments, but countMoveKey queries inventory_moves which isn't cleaned. While UUIDs make collisions unlikely, orphaned test records could accumulate in shared test databases.

🧹 Proposed cleanup addition
 async function cleanup(seed: SeededOrder | null) {
   if (!seed) return;
+  await db.execute(
+    sql`DELETE FROM inventory_moves WHERE move_key LIKE ${'%' + seed.orderId + '%'}`
+  );
   await db
     .delete(shippingShipments)
     .where(eq(shippingShipments.orderId, seed.orderId));
   await db.delete(orders).where(eq(orders.id, seed.orderId));
   await db.delete(products).where(eq(products.id, seed.productId));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/stripe-refund-convergence.test.ts` around lines 159 -
166, The cleanup function for tests (async function cleanup) currently deletes
from shippingShipments, orders, and products but doesn't remove related
inventory_moves rows queried by countMoveKey; update cleanup to also delete from
inventory_moves where orderId = seed.orderId OR productId = seed.productId (use
the same db.delete(...).where(eq(inventory_moves.orderId, seed.orderId)) and
db.delete(...).where(eq(inventory_moves.productId, seed.productId)) pattern or a
combined OR condition) so that inventory_moves entries created during tests are
removed along with shipments/orders/products.

67-78: Type assertions (as any) bypass schema validation.

Using as any on insert values (lines 78, 99, 110) silences type errors but could mask issues if the schema changes. Consider using partial type helpers or satisfying the full schema type for test data.

Also applies to: 80-99, 101-110

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

In `@frontend/lib/tests/shop/stripe-refund-convergence.test.ts` around lines 67 -
78, The test is using a blanket type assertion "as any" when calling
db.insert(products).values(...) which bypasses schema/type checks; replace those
assertions by constructing test records that satisfy the products table type (or
use a Partial/DeepPartial helper or the specific Insert/Insertable type for the
products model) so the compiler enforces schema fields and types. Locate the
insert calls (db.insert(products).values) and change them to use the appropriate
product insert type or a helper that marks optional fields, ensuring required
fields like id, title, slug, sku, price, currency, stock, isActive, badge, and
imageUrl match types without "as any".
frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts (1)

75-75: Consider de-duplicating the refund marker literal.

'REFUND_REQUESTED' appears in multiple places. Using a shared constant/enum would reduce drift risk between test fixtures and production guard logic.

Also applies to: 200-200

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

In `@frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts` at line 75, The
test uses the string literal 'REFUND_REQUESTED' directly (seen in the expression
(args.pspStatusReason ?? null) !== 'REFUND_REQUESTED'); extract this status into
a shared constant or enum (e.g., RefundStatus.REFUND_REQUESTED) used by both the
test fixture and the production guard logic so the comparison uses the shared
symbol instead of a hardcoded string; update references in the test
(args.pspStatusReason) and any other occurrences (mentioned around lines 200) to
import and use that constant to avoid drift.
frontend/app/api/shop/admin/returns/[id]/refund/route.ts (1)

74-79: Solid defensive pattern for a Promise<never> function.

Since refundReturnRequest is typed as Promise<never> and always throws InvalidPayloadError with code RETURN_REFUND_DISABLED, line 79 acts as a safeguard. If the implementation ever changes to resolve instead of throw, this will fail explicitly rather than silently returning no response.

Consider adding a brief comment to explain the intent for future maintainers:

📝 Optional: Add clarifying comment
     await refundReturnRequest({
       returnRequestId: returnRequestIdForLog,
       actorUserId: typeof admin.id === 'string' ? admin.id : null,
       requestId,
     });
+    // refundReturnRequest returns Promise<never> (always throws); guard against future changes
     throw new Error('refundReturnRequest unexpectedly resolved');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/admin/returns/`[id]/refund/route.ts around lines 74 -
79, Add an explanatory comment above the call to refundReturnRequest to document
that refundReturnRequest is intentionally typed as Promise<never> and always
throws (InvalidPayloadError with code RETURN_REFUND_DISABLED), and that the
subsequent throw new Error('refundReturnRequest unexpectedly resolved') is a
defensive safeguard to fail loudly if refundReturnRequest ever changes to
resolve; reference the refundReturnRequest call and the explicit throw to help
future maintainers understand the intent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/lib/services/shop/shipping/shipments-worker.ts`:
- Around line 745-766: There is a TOCTOU gap allowing label creation after a
refund flip; to fix it, reload and re-validate the persisted order state
immediately before making the non-idempotent external call and before calling
markSucceeded: call loadOrderShippingDetails again (or a minimal status-only
read) after computing latestPayload, re-run assertOrderStillShippable or
explicitly check latestDetails.psp_status_reason !== 'REFUND_REQUESTED' and that
latestSnapshot.methodCode === latestDetails.shipping_method_code, and only then
call createInternetDocument and markSucceeded; also add an eligibility predicate
to markSucceeded (or guard its caller) to refuse marking label_created if the
final-state check fails so label_created cannot be recorded when a refund was
requested.

In `@frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts`:
- Around line 295-296: The cleanup catch inside the finally block currently does
"if (process.env.CI) throw err;" which violates noUnsafeFinally; modify the
cleanup to capture the error instead of throwing: in the finally scope replace
the throw with storing the error into a local variable (e.g. let cleanupErr =
null; cleanupErr = err; and optionally log it), and after the entire try/finally
completes (just after the finally), check if cleanupErr is set and then rethrow
it (or throw a wrapped error). Update the block that references process.env.CI
so it only records the error (or logs) and defers throwing until after the
finally completes.

In `@frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts`:
- Around line 295-296: The finally block currently rethrows cleanupError which
violates noUnsafeFinally; capture cleanupError inside finally (using
cleanupByIds and the existing cleanupError variable), remove the throw from the
finally block, and after the try/finally completes check if (!primaryError &&
cleanupError) then throw cleanupError; reference the variables primaryError and
cleanupError and the cleanupByIds call in the test to locate and move the
conditional throw out of the finally.

---

Outside diff comments:
In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 1052-1075: The selected order row includes paymentProvider but
there is no enforcement that it is Stripe before performing updates; add an
explicit guard that verifies orders.paymentProvider === 'stripe' for the
resolvedOrderId before any mutation (or include it in all update where clauses)
so that functions using resolvedOrderId (and update branches that reference
orders.id) only mutate Stripe-backed orders; update the selection/check logic
around resolvedOrderId and the update statements that touch orders (the branches
referenced by orders.id at the later update sites) to either short-circuit when
paymentProvider !== 'stripe' or add a where(eq(orders.paymentProvider,
'stripe')) predicate to each update.

In `@frontend/lib/services/shop/shipping/shipments-worker.ts`:
- Around line 418-454: The candidates CTE is selecting the first args.limit
shipments before the orderShippingEligibilityWhereSql filter runs, causing
blocked/refund shipments to persist and starve eligible ones; move the
eligibility check into the candidates CTE (join orders and apply
orderShippingEligibilityWhereSql there) so the WHERE that uses
sql`o.payment_status`/`o.status`/`o.inventory_status`/`o.psp_status_reason` runs
before the ORDER BY ... LIMIT ${args.limit} and FOR UPDATE SKIP LOCKED, then
keep the claimed CTE as the UPDATE that sets status='processing',
lease_owner=${args.runId}, lease_expires_at using ${args.leaseSeconds}, and
updated_at=now().

---

Duplicate comments:
In `@frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts`:
- Around line 26-41: The mock factory for payment-attempts calls
vi.importActual(...) before the test env is prepared; to fix it call
resetEnvCache() as the first statement inside the vi.mock factory so environment
variables are cleared before importing the real module, then proceed to await
vi.importActual(...) and override ensureStripePaymentIntentForOrder (the mocked
function name) as currently implemented; this ensures the real module sees the
correct Stripe env set in beforeAll.

In `@frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts`:
- Around line 35-42: The vi.mock factories that call
vi.importActual('@/lib/env/stripe') must call resetEnvCache() first to avoid
import-order dependence; update both factories (the one overriding
isPaymentsEnabled in this file and the other similar factory around lines 44-58)
to invoke resetEnvCache() at the top of each factory before calling
vi.importActual so the real Stripe/env module is reloaded after tests mutate the
payment env.

---

Nitpick comments:
In `@frontend/app/api/shop/admin/returns/`[id]/refund/route.ts:
- Around line 74-79: Add an explanatory comment above the call to
refundReturnRequest to document that refundReturnRequest is intentionally typed
as Promise<never> and always throws (InvalidPayloadError with code
RETURN_REFUND_DISABLED), and that the subsequent throw new
Error('refundReturnRequest unexpectedly resolved') is a defensive safeguard to
fail loudly if refundReturnRequest ever changes to resolve; reference the
refundReturnRequest call and the explicit throw to help future maintainers
understand the intent.

In `@frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts`:
- Line 75: The test uses the string literal 'REFUND_REQUESTED' directly (seen in
the expression (args.pspStatusReason ?? null) !== 'REFUND_REQUESTED'); extract
this status into a shared constant or enum (e.g., RefundStatus.REFUND_REQUESTED)
used by both the test fixture and the production guard logic so the comparison
uses the shared symbol instead of a hardcoded string; update references in the
test (args.pspStatusReason) and any other occurrences (mentioned around lines
200) to import and use that constant to avoid drift.

In `@frontend/lib/tests/shop/stripe-refund-convergence.test.ts`:
- Around line 159-166: The cleanup function for tests (async function cleanup)
currently deletes from shippingShipments, orders, and products but doesn't
remove related inventory_moves rows queried by countMoveKey; update cleanup to
also delete from inventory_moves where orderId = seed.orderId OR productId =
seed.productId (use the same db.delete(...).where(eq(inventory_moves.orderId,
seed.orderId)) and db.delete(...).where(eq(inventory_moves.productId,
seed.productId)) pattern or a combined OR condition) so that inventory_moves
entries created during tests are removed along with shipments/orders/products.
- Around line 67-78: The test is using a blanket type assertion "as any" when
calling db.insert(products).values(...) which bypasses schema/type checks;
replace those assertions by constructing test records that satisfy the products
table type (or use a Partial/DeepPartial helper or the specific
Insert/Insertable type for the products model) so the compiler enforces schema
fields and types. Locate the insert calls (db.insert(products).values) and
change them to use the appropriate product insert type or a helper that marks
optional fields, ensuring required fields like id, title, slug, sku, price,
currency, stock, isActive, badge, and imageUrl match types without "as any".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d37768cd-08ef-4919-8952-4b5a011db0c9

📥 Commits

Reviewing files that changed from the base of the PR and between 42745c1 and b6f8edd.

📒 Files selected for processing (12)
  • frontend/app/api/shop/admin/returns/[id]/refund/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/lib/services/orders/stripe-refund-reconciliation.ts
  • frontend/lib/services/shop/shipping/admin-actions.ts
  • frontend/lib/services/shop/shipping/shipments-worker.ts
  • frontend/lib/tests/shop/admin-shipping-payment-gate.test.ts
  • frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts
  • frontend/lib/tests/shop/checkout-currency-policy.test.ts
  • frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts
  • frontend/lib/tests/shop/stripe-refund-convergence.test.ts
  • frontend/lib/tests/shop/stripe-webhook-psp-fields.test.ts
  • frontend/lib/tests/shop/stripe-webhook-refund-full.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/lib/services/orders/stripe-refund-reconciliation.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts (1)

274-276: Remove redundant catch/rethrow block.

Line 274–Line 276 (catch (err) { throw err; }) adds no behavior and can be dropped to simplify control flow.

♻️ Proposed simplification
-    } catch (err) {
-      throw err;
-    } finally {
+    } finally {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts` around lines 274
- 276, The catch block that simply rethrows the caught error around the
try/finally in the test is redundant—remove the entire "catch (err) { throw err;
}" block so the try block flows directly into the existing finally. Update the
test (the try/finally surrounding the checkout concurrency logic in
checkout-concurrency-stock1.test.ts) by deleting that catch clause and ensuring
indentation/formatting is adjusted accordingly; no other behavioral changes are
needed.
frontend/app/api/shop/webhooks/stripe/route.ts (1)

1717-1726: Consider type-safe casting for restoreStripeRefundFailure call.

The order as any cast at line 1718 bypasses TypeScript's type checking. While the order object structurally matches StripeRefundOrderRow (all required fields are fetched at lines 1051-1075), using as any loses compile-time safety.

If the order select type doesn't match StripeRefundOrderRow due to Drizzle's inference, consider defining a shared type or using a more specific assertion to maintain type safety during future refactors.

This is a minor ergonomic suggestion; the current code is functionally correct.

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

In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 1717 - 1726, The
call to restoreStripeRefundFailure uses an unsafe cast "order as any" which
bypasses TypeScript checking; replace this with a properly-typed assertion or
shared type so the compiler verifies the shape (e.g., ensure the variable order
is asserted/typed as StripeRefundOrderRow or a shared interface used by
restoreStripeRefundFailure) — update the declaration or the select mapping that
produces order (the variable named order returned earlier) or change the
function signature of restoreStripeRefundFailure to accept the precise
StripeRefundOrderRow type so you can pass order without casting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 1906-1957: The current charge.refund.updated handler (the block
using buildPspMetadata, mergePspMetadata and updating orders) treats any
refund.status !== 'succeeded' as "waiting_terminal_refund_status"; change the
condition so only non-terminal statuses (e.g., 'pending', 'requires_action')
produce the waiting decision and terminal statuses ('failed', 'canceled') are
handled separately: detect refund.status in ['failed','canceled'] and set a
terminal failure decision (or log a warning and set an appropriate
pspStatusReason/pspMetadata entry such as 'terminal_refund_failure'), and only
use decision 'waiting_terminal_refund_status' for transient statuses; ensure
this logic also accounts for order.pspStatusReason !== 'REFUND_REQUESTED' so
terminal failures do not silently become "waiting".

---

Nitpick comments:
In `@frontend/app/api/shop/webhooks/stripe/route.ts`:
- Around line 1717-1726: The call to restoreStripeRefundFailure uses an unsafe
cast "order as any" which bypasses TypeScript checking; replace this with a
properly-typed assertion or shared type so the compiler verifies the shape
(e.g., ensure the variable order is asserted/typed as StripeRefundOrderRow or a
shared interface used by restoreStripeRefundFailure) — update the declaration or
the select mapping that produces order (the variable named order returned
earlier) or change the function signature of restoreStripeRefundFailure to
accept the precise StripeRefundOrderRow type so you can pass order without
casting.

In `@frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts`:
- Around line 274-276: The catch block that simply rethrows the caught error
around the try/finally in the test is redundant—remove the entire "catch (err) {
throw err; }" block so the try block flows directly into the existing finally.
Update the test (the try/finally surrounding the checkout concurrency logic in
checkout-concurrency-stock1.test.ts) by deleting that catch clause and ensuring
indentation/formatting is adjusted accordingly; no other behavioral changes are
needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9ba4b3d2-6adc-4cdf-9a35-619285f0bbf8

📥 Commits

Reviewing files that changed from the base of the PR and between b6f8edd and 2b36210.

📒 Files selected for processing (4)
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/lib/services/shop/shipping/shipments-worker.ts
  • frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts
  • frontend/lib/tests/shop/order-items-snapshot-immutable.test.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts`:
- Around line 220-229: The test currently only asserts stock-related failure
when failCode is truthy; make the stock-intent assertion unconditional by
deriving a fallback failure indicator from the response body when failCode is
empty. In the test around failCode (in checkout-concurrency-stock1.test.ts),
compute a single string to check (e.g., const failureIndicator = failCode ||
extract code/error from the response body) and then assert that failureIndicator
includes one of ['OUT_OF_STOCK','INSUFFICIENT_STOCK','STOCK','NOT_ENOUGH_STOCK']
using the existing some(...).toBe(true) check so the stock hint is always
enforced even when failCode is empty.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c7d6e580-f2f2-4904-b56e-60f12b7d30a6

📥 Commits

Reviewing files that changed from the base of the PR and between 2b36210 and f68f0db.

📒 Files selected for processing (2)
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/lib/tests/shop/checkout-concurrency-stock1.test.ts

@ViktorSvertoka ViktorSvertoka merged commit e5a8ff6 into develop Mar 17, 2026
7 checks passed
@ViktorSvertoka ViktorSvertoka deleted the lso/feat/shop-audit-readiness branch March 17, 2026 07:12
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