Skip to content

(SP: 3) [Backend/DevOps] Make shop janitor production-runnable; harden inventory idempotency, refunds, and concurrency invariants#118

Merged
ViktorSvertoka merged 12 commits into
developfrom
lso/feat/shop
Jan 8, 2026
Merged

(SP: 3) [Backend/DevOps] Make shop janitor production-runnable; harden inventory idempotency, refunds, and concurrency invariants#118
ViktorSvertoka merged 12 commits into
developfrom
lso/feat/shop

Conversation

@liudmylasovetovs
Copy link
Copy Markdown
Collaborator

@liudmylasovetovs liudmylasovetovs commented Jan 8, 2026

Description

This PR makes the shop “janitor” runnable in production and hardens the order/inventory lifecycle against real-world async and concurrency failures (abandoned checkouts, webhook retries, stuck reserving states). It adds secure internal execution, cross-instance rate limiting, bounded runtime/batch policies, and idempotent cleanup paths that restore stock exactly once and mark orders terminal to prevent reprocessing loops.


Related Issue

  • Issue: # (Janitor production runnable)
  • Issue: # (Stuck reserving cleanup)
  • Issue: # (Checkout concurrency stock=1 test)
  • Issue: # (Cleanup stale pending/requires_payment)
  • Issue: # (Refund model: dedupe + restock exactly-once)
  • Issue: # (SALE rule contract)

Changes

  • Implemented production-safe internal janitor endpoint POST /api/shop/internal/orders/restock-stale:

    • Internal auth via INTERNAL_JANITOR_SECRET (fail-closed; constant-time compare).
    • Guardrails for batchSize, olderThanMinutes (legacy + structured policy), and maxRuntimeMs.
    • Cross-instance DB rate limiter with Retry-After and Cache-Control: no-store.
    • Policy support for categories: stalePending, orphanNoPayment, and stuckReserving (handled by service).
  • Hardened orders/inventory service for exactly-once behavior:

    • Order-level claim/lease to avoid concurrent sweeps processing the same order.
    • restockOrder(...) finalize-once semantics (no double stock increment; stable markers/timestamps).
    • Sweep paths:
      • stale pending/requires_payment cleanup
      • no-payment orphan cleanup
      • stuck reserving cleanup -> terminal with reason STUCK_RESERVING_TIMEOUT
  • Added scheduler runner and GitHub Actions workflow:

    • Node script calls internal endpoint safely with body {}.
    • Treats 429 RATE_LIMITED as success (expected no-op for schedulers).
    • Workflow includes step-level guard to skip if secrets are not configured.
  • Strengthened Stripe refund + webhook reliability:

    • Dedupe via stripe_events table (no duplicate side effects).
    • Refund events mark orders refunded and restore stock exactly once (full refund scope).
  • Added concurrency coverage:

    • Integration test: two parallel checkouts against stock=1 -> exactly one success; no negative stock; no duplicate reserves.
  • Enforced SALE rule contract for admin product mutations:

    • When badge=SALE, requires originalPriceMinor and originalPriceMinor > priceMinor.
    • Stable 400 error contract (SALE_ORIGINAL_REQUIRED) for UI to render field-level feedback.

Database Changes (if applicable)

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

Notes

  • Added internal_job_state table for DB-backed rate limiting (cross-instance).

How Has This Been Tested?

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

Commands / evidence

  • npx vitest run (all suites green)
  • npm run build (Next build successful)
  • Manual smoke via PowerShell:
    • POST /api/shop/internal/orders/restock-stale returns 200; repeated calls return 429 with Retry-After (expected), runner exits 0.

Screenshots (if applicable)

N/A (backend + workflows).
(If needed: attach UI validation screenshot for SALE error rendering.)


Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors (test noise acceptable; no contract impact)
  • Code follows project conventions
  • Scope is limited to this feature/fix (this PR bundles multiple reliability tasks starting from janitor)
  • 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


Operational note (for reviewers/lead)

To activate the scheduled janitor runs:

  1. Configure GitHub Actions secrets:

    • JANITOR_URL
    • INTERNAL_JANITOR_SECRET
  2. Ensure production runtime env includes:

    • INTERNAL_JANITOR_SECRET=<prod>
    • INTERNAL_JANITOR_MIN_INTERVAL_SECONDS=300 (or 600)

Until secrets are set, the workflow will skip safely via the guard step.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added dedicated refund button for order management
    • Implemented automatic background job to restock orders stuck in processing
    • Sale product badge now enforces original price requirement
  • Bug Fixes

    • Enhanced Stripe webhook processing to correctly handle full and partial refunds with built-in deduplication
    • Improved payment status reconciliation and repair for edge-case order states

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

…ERNAL_JANITOR_SECRET, 503 disabled, clamp) and validate production run
…able (auth, DB rate limit, sweep policy + no-payment orphan cleanup)
…nal restock-stale endpoint + local runner script + scheduled workflow stub
…K_RESERVING_TIMEOUT) with exactly-once stock release + tests
… to prevent double-reserve and enforce inventory invariants
@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 8, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit 1f3ec7f
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/696001b57ad7670008953ba6
😎 Deploy Preview https://deploy-preview-118--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 8, 2026

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive order management system overhaul, adding periodic janitor-based restock automation, Stripe webhook refund handling with idempotency tracking, SALE badge pricing validation requiring originalPriceMinor, and order refund UI. Changes span GitHub Actions workflows, authentication, database schema, API endpoints, form validation, and extensive test coverage for concurrency and restock scenarios.

Changes

Cohort / File(s) Summary
GitHub Actions & Automation
.github/workflows/shop-janitor-restock-stale.yml, frontend/scripts/shop-janitor-restock-stale.mjs, frontend/.env.example
Adds scheduled GitHub Actions workflow triggering internal janitor every 5 minutes; Node.js script invokes janitor endpoint with timeout and secret-based auth; env file adds JANITOR_URL, INTERNAL_JANITOR_SECRET, and interval config.
Internal Janitor Authentication
frontend/lib/auth/internal-janitor.ts
New module implementing timing-safe secret validation for janitor requests; returns 503 if disabled, 401 if unauthorized, 403 if forbidden, null on success.
Restock Orchestration & Rate Limiting
frontend/app/api/shop/internal/orders/restock-stale/route.ts
Extends restock endpoint with parameter validation (batchSize, olderThanMinutes, maxRuntimeMs, minIntervalSeconds), cross-instance DB-backed rate limiter (acquireJobSlot), time-budgeted work splitting across three restock categories, and detailed per-category result aggregation.
Job State Persistence
frontend/db/schema/shop.ts, frontend/drizzle/0013_add_internal_job_state.sql, frontend/drizzle/meta/0013_snapshot.json
Adds internalJobState table tracking nextAllowedAt and lastRunId per job for rate limiting; creates DbInternalJobState type export.
Stripe Webhook Enhancements
frontend/app/api/shop/webhooks/stripe/route.ts, frontend/db/schema/shop.ts, frontend/drizzle/0014_add-stripe-events-processed-at.sql
Adds processedAt field for idempotency; introduces refund event handling distinguishing full vs. partial refunds; implements order resolution fallback via paymentIntentId when metadata missing; adds repair path for inconsistent paid status; augments PSP metadata with refund/mismatch details.
Order Refund UI & Workflows
frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx, frontend/app/[locale]/shop/admin/orders/[id]/page.tsx
New RefundButton component with useTransition for UI state; replaces form-based refund with dedicated button; tightens refund eligibility (stripe provider, paid status, paymentIntentId required); introduces orderCurrency utility with type constraints.
SALE Badge Pricing Validation
frontend/app/[locale]/shop/admin/products/_components/product-form.tsx, frontend/app/api/shop/admin/products/[id]/route.ts, frontend/app/api/shop/admin/products/route.ts, frontend/lib/services/products.ts, frontend/lib/validation/shop.ts
Enforces originalPriceMinor presence and inequality for SALE badge across form and API; adds SaleRuleDetails type and per-currency error tracking; validates at parse and save stages; returns 400 SALE_ORIGINAL_REQUIRED with field-level details on violation.
Form & Payload Parsing
frontend/lib/admin/parseAdminProductForm.ts
Enhances boolean field parsing; normalizes currency handling when invalid; adds explicit error for non-string prices payload; multiline formatting for error objects.
Order Service Refactor
frontend/lib/services/orders.ts, frontend/lib/types/shop.ts
Introduces OrderSummaryWithMinor type; updates getOrderSummary, setOrderPaymentIntent, refundOrder, reconcileNoPaymentOrder to return WithMinor variant; adds time budget and workerId to restock functions; enforces deadline-aware batch processing with claim/lease semantics; updates requireTotalCents to consume totalAmountMinor directly.
Product Query & Validation
frontend/db/queries/shop/products.ts, frontend/lib/services/products.ts
Replaces sale condition from OR-based (badge or originalPrice) to originalPriceMinor presence check; normalizes string literals from double to single quotes; adds enforceSaleBadgeRequiresOriginal utility validating SALE invariant during create/update.
UI Layout
frontend/components/header/UnifiedHeader.tsx
Relocates "Back to main site" link from left cluster to right cluster within md:flex guarded area.
Database Snapshots & Cleanup
frontend/drizzle/meta/0014_snapshot.json, frontend/drizzle/meta/_journal.json
Comprehensive schema snapshot for PostgreSQL; removes quiz_attempts indexes and order_items.selected_size/selected_color columns; renames order_items constraint from order_variant_uq to order_product_uq; cleans up journal entry.
Testing Infrastructure
frontend/lib/tests/*
Adds 13+ test files covering: SALE contract validation, checkout concurrency (stock=1), restock ordering semantics, concurrent restock exclusivity, paid-status repair, refund webhook handling (full/partial), stripe PSP fields, stale/stuck restock sweeps, stripe orphan scenarios, order immutability, and admin product SALE rules.
Utility Files & Scripts
frontend/lib/shop/cart-item-key.ts, frontend/tmp/*.json, frontend/tmp/*.js, frontend/project-structure.txt
Adds cart-item-key utility; temporary test payload files for webhook replay (charge-refunded.json); replay script for manual webhook testing; updates project structure documentation.

Sequence Diagram(s)

sequenceDiagram
    participant GHA as GitHub Actions
    participant Janitor as Janitor Endpoint
    participant JobDB as Job State DB
    participant OrderSvc as Order Service
    participant OrdDB as Orders DB
    participant InvDB as Inventory DB

    GHA->>Janitor: POST /api/shop/internal/orders/restock-stale<br/>(with janitor secret, time budget)
    Janitor->>JobDB: acquireJobSlot(jobName, minIntervalSeconds)
    alt Rate Limit Active
        JobDB-->>Janitor: false (rate limited)
        Janitor-->>GHA: 429 Retry-After
    else Slot Available
        JobDB-->>Janitor: true, update nextAllowedAt
        Janitor->>OrderSvc: restockStuckReserving(budgetMs)
        OrderSvc->>OrdDB: find orders with reserving timeout
        OrderSvc->>InvDB: release reserves for timeout orders
        OrderSvc-->>Janitor: processedCount
        
        Janitor->>OrderSvc: restockStalePending(budgetMs)
        OrderSvc->>OrdDB: find orders with pending timeout
        OrderSvc->>InvDB: update inventory status
        OrderSvc-->>Janitor: processedCount
        
        Janitor->>OrderSvc: restockStaleNoPayment(budgetMs)
        OrderSvc->>OrdDB: find orders without payment timeout
        OrderSvc->>InvDB: release reserves
        OrderSvc-->>Janitor: processedCount
        
        Janitor-->>GHA: 200 with aggregated counts
    end
Loading
sequenceDiagram
    participant Stripe as Stripe Platform
    participant Webhook as Webhook Endpoint
    participant EventDB as Stripe Events DB
    participant OrdDB as Orders DB
    participant OrderSvc as Order Service

    Stripe->>Webhook: POST /api/shop/webhooks/stripe<br/>(charge.refunded or<br/>charge.refund.updated)
    
    Webhook->>EventDB: check if event.id already processedAt
    alt Event Already Processed
        EventDB-->>Webhook: processedAt exists
        Webhook-->>Stripe: 200 (idempotent ack)
    else First Time
        EventDB-->>Webhook: processedAt null
        Webhook->>Webhook: extract paymentIntentId<br/>(from charge, refund, or PI)
        Webhook->>OrdDB: resolve orderId from metadata<br/>or fallback via paymentIntentId
        alt Order Not Found
            Webhook->>EventDB: mark processedAt (ack)
            Webhook-->>Stripe: 200 (ack to avoid replay)
        else Order Found
            Webhook->>Webhook: determine refund type<br/>(full vs. partial)
            alt Full Refund
                Webhook->>OrderSvc: refundOrder(orderId)
                OrderSvc->>OrdDB: update paymentStatus→refunded<br/>update status→CANCELED
                OrderSvc->>InvDB: restore stock
                OrderSvc-->>Webhook: OrderSummaryWithMinor
            else Partial Refund
                Webhook->>OrdDB: log PARTIAL_REFUND_IGNORED
                Webhook->>OrdDB: update metadata with decision
            end
            Webhook->>EventDB: mark processedAt (ack)
            Webhook-->>Stripe: 200 (ack)
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • ViktorSvertoka
  • AM1007

Poem

🐰 Hop, hop! A janitor springs to work,
Restocking stale orders, no refund quirk.
SALE badges validated with minor delight,
Webhooks now idempotent, receipts set right!
a rabbit's cheer for commerce done bright 🐇✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.75% 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 and specifically summarizes the main changes: making the shop janitor production-runnable and hardening inventory-related invariants. It is directly related to the core changes in the changeset.

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

✨ 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: 11

Caution

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

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

1111-1130: Non-English comment should be translated.

Line 1111 contains a Russian comment: "иначе sweep будет подбирать снова". For consistency and maintainability, comments should be in English.

Suggested fix
-    // Stripe (or any non-none provider): stale orphan must become terminal, иначе sweep будет подбирать снова.
+    // Stripe (or any non-none provider): stale orphan must become terminal, otherwise the sweep will keep picking it up.
🤖 Fix all issues with AI agents
In @frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx:
- Around line 16-40: The onRefund function currently doesn't handle fetch
rejections; wrap the fetch call in a try/catch so network errors are caught,
call setError with the caught error's message (or a fallback like 'Network
error') and return early, and then proceed with the existing response.json
parsing and res.ok handling; keep the existing error extraction from json and
the startTransition(router.refresh()) on success so behavior is preserved.

In @frontend/app/[locale]/shop/admin/products/_components/product-form.tsx:
- Around line 324-338: In the SALE_ORIGINAL_REQUIRED branch of the submit
handler, call setIsSubmitting(false) before returning so the form doesn't remain
stuck; specifically, just before the early return in the block that checks if
(data.code === 'SALE_ORIGINAL_REQUIRED'), invoke setIsSubmitting(false) (keeping
the existing setOriginalPriceErrors and setError calls) so submission state is
cleared regardless of this error.

In @frontend/app/api/shop/admin/products/[id]/route.ts:
- Around line 66-71: getSaleViolationFromFormData currently swallows JSON.parse
errors and returns null, making malformed prices indistinguishable from "no
violations"; change the catch around JSON.parse(pricesRaw) so it does not return
null silently but instead surfaces a clear parse failure (either by throwing a
descriptive error or returning an explicit validation error object) so callers
like parseAdminProductForm and findSaleRuleViolation can distinguish "invalid
JSON" from "no sale violations"; reference the getSaleViolationFromFormData
function, the JSON.parse(pricesRaw) call, and the use of findSaleRuleViolation
when implementing this behavior.

In @frontend/app/api/shop/webhooks/stripe/route.ts:
- Around line 606-624: The full-refund detection for 'charge.refund.updated' is
incorrect because it compares a single refund.amount to order.totalAmountMinor;
instead compute cumulative refunded for the associated charge and compare that
to the original charge amount. In the 'charge.refund.updated' branch (where
refund is available), locate the related charge object (from refund.charge or by
retrieving the charge) and determine the cumulative refunded amount (use
charge.amount_refunded if present, or sum existing charge.refunds.data amounts
plus the current refund.amount) and set isFullRefund when cumulativeRefunded ===
charge.amount (or === order.totalAmountMinor if you prefer using order totals).
Update the code that sets isFullRefund in the charge.refund.updated case to use
this cumulative comparison rather than just refund.amount.

In @frontend/lib/auth/internal-janitor.ts:
- Around line 5-10: The timingSafeEqual function leaks secret length via the
early length check; modify timingSafeEqual to avoid early return by allocating
two buffers of equal length (max of aBuf.length and bBuf.length), copy a and b
into those fixed-size buffers (padding the remainder with zeros), then call
crypto.timingSafeEqual on the padded buffers (still handling non-buffer inputs
as before) so the comparison time does not vary with input length.

In @frontend/lib/tests/restock-stuck-reserving-sweep.test.ts:
- Around line 167-178: The finally block currently throws cleanup errors which
will overwrite any prior test assertion failures; capture the original error
from the test body (e.g., declare a let originalError variable and assign it in
the try/catch around the test assertions), then in the finally call
cleanupTestRows({ orderId, productId }) inside a try/catch: if cleanup fails,
log the cleanup error (console.error with orderId/productId) and if
originalError exists rethrow originalError, otherwise throw the cleanup
error—this preserves the real test failure while still surfacing cleanup
problems.

In @frontend/project-structure.txt:
- Line 407: The project now contains a tmp directory that must be ignored by
git; open the .gitignore file and add an entry for the tmp directory (e.g., tmp/
or /tmp) so temporary artifacts aren’t committed, then if tmp was already
tracked remove it from the index (git rm --cached -r tmp) and commit the
.gitignore change; reference the tmp directory and the .gitignore file when
making the update.

In @frontend/scripts/shop-janitor-restock-stale.mjs:
- Line 17: The timeout parsing uses Number(process.env.JANITOR_TIMEOUT_MS) which
can yield NaN and make setTimeout fire immediately; validate and normalize it by
parsing and checking that the result is a finite positive integer: read
process.env.JANITOR_TIMEOUT_MS, parse to a number (e.g., parseInt/Number), check
isFinite(value) and value > 0, and if invalid fall back to the default (25000)
or a configured minimum; update the const timeoutMs declaration and any use of
timeoutMs (e.g., the setTimeout call) to use the validated/normalized value.

In @frontend/tmp/replay-charge-refunded.json:
- Line 1: The JSON file begins with a BOM character that can break parsers and
contains the placeholder string "<PUT_EVT_ID_FROM_DB_HERE>" that must be
populated before replay; remove the leading BOM from
frontend/tmp/replay-charge-refunded.json, reformat the JSON for readability, and
replace or document the need to replace the "<PUT_EVT_ID_FROM_DB_HERE>"
placeholder (add a short note in the replay script or README explaining how to
obtain and insert the actual event ID from the DB).

In @frontend/tmp/replay-stripe-webhook.js:
- Line 1: The file has a UTF-8 BOM at the start (before the "const fs =
require('fs');" statement); remove the BOM so the file begins with the "const"
token, re-save the file as UTF-8 without BOM (or run a tool/IDE option to strip
BOM), and verify Node.js can load the file and linters/CI no longer flag the
leading invisible character.
- Around line 12-14: The template literals for the signed payload and header are
malformed: build the signed payload as the timestamp, a dot, then the JSON
string payload (e.g., use payload = JSON.stringify({id: "evt_test"}) and signed
= `${ts}.${payload}`), compute sig with crypto.createHmac('sha256',
secret).update(signed, 'utf8').digest('hex'), and set the header string to
`t=${ts},v1=${sig}` (use the existing variables signed, sig, secret, ts, and
payload).
🧹 Nitpick comments (17)
frontend/.env.example (1)

40-40: Add trailing newline for POSIX compliance.

Most text editors and POSIX standards expect files to end with a newline character.

📝 Proposed fix
 JANITOR_URL=
+
frontend/app/api/shop/admin/products/[id]/route.ts (2)

27-56: Consider stronger typing for findSaleRuleViolation input.

Using any for the input parameter reduces type safety. Consider defining a minimal interface for the expected shape.

♻️ Suggested typing improvement
+type SaleValidationInput = {
+  badge?: string | null;
+  prices?: Array<{
+    currency?: string | null;
+    priceMinor?: number | null;
+    originalPriceMinor?: number | null;
+  }>;
+};
+
-function findSaleRuleViolation(input: any): SaleRuleViolation | null {
+function findSaleRuleViolation(input: SaleValidationInput | null | undefined): SaleRuleViolation | null {

195-204: Casting to any to access error properties is fragile.

If InvalidPayloadError is expected to have code, field, and details properties, consider extending the error class or using a type guard instead of casting.

♻️ Suggested improvement
       if (error instanceof InvalidPayloadError) {
-        const anyErr = error as any;
+        const errWithDetails = error as InvalidPayloadError & {
+          code?: string;
+          field?: string;
+          details?: unknown;
+        };
         return NextResponse.json(
           {
             error: error.message || 'Invalid product data',
-            code: anyErr.code,
-            field: anyErr.field,
-            details: anyErr.details,
+            code: errWithDetails.code,
+            field: errWithDetails.field,
+            details: errWithDetails.details,
           },
           { status: 400 }
         );
       }
frontend/app/[locale]/shop/admin/orders/[id]/page.tsx (2)

21-27: Redundant type assertion in orderCurrency.

The ternary already produces 'UAH' or 'USD' string literals, making the as CurrencyCode cast unnecessary if CurrencyCode is 'UAH' | 'USD'.

♻️ Minor simplification
 function orderCurrency(
   order: { currency?: string | null } | null,
   locale: string
 ): CurrencyCode {
   const c = order?.currency ?? resolveCurrencyFromLocale(locale);
-  return (c === 'UAH' ? 'UAH' : 'USD') as CurrencyCode;
+  return c === 'UAH' ? 'UAH' : 'USD';
 }

43-46: Inconsistent indentation in canRefund assignment.

The condition body appears to have inconsistent indentation (continuation lines not indented relative to the assignment).

♻️ Formatting fix
   const canRefund =
-  order.paymentProvider === 'stripe' &&
-  order.paymentStatus === 'paid' &&
-  !!order.paymentIntentId;
+    order.paymentProvider === 'stripe' &&
+    order.paymentStatus === 'paid' &&
+    !!order.paymentIntentId;
frontend/drizzle/0013_add_internal_job_state.sql (1)

1-6: Schema design looks solid for rate-limiting use case.

The table structure appropriately supports job state tracking and rate limiting:

  • job_name as PRIMARY KEY ensures singleton semantics
  • next_allowed_at NOT NULL enforces rate limit contract
  • updated_at with default tracks mutation timestamps

For very small tables (few job types), the current design is sufficient. If the table grows or next_allowed_at is queried frequently in hot paths, consider adding an index.

📊 Optional: Add index if query patterns warrant

If rate limit checks become a hot path or the table grows beyond a few dozen rows:

CREATE INDEX "internal_job_state_next_allowed_at_idx" 
  ON "internal_job_state" ("next_allowed_at");

Monitor query performance before adding, as sequential scans are fast for small tables.

.github/workflows/shop-janitor-restock-stale.yml (1)

33-33: Translate comment to English for consistency.

The comment "тут secrets дозволені" (Ukrainian for "secrets are allowed here") should be in English for maintainability and consistency with the rest of the codebase.

Proposed fix
-      # Step-level guard: тут secrets дозволені
+      # Step-level guard: secrets are accessible here
frontend/lib/tests/restock-order-only-once.test.ts (2)

146-153: Empty catch blocks silently swallow cleanup errors.

The cleanup logic uses empty catch {} blocks which will silently ignore any cleanup failures. This can lead to test pollution if rows are not properly deleted. Consider logging the error or using a more robust cleanup strategy like the one in restock-stuck-reserving-sweep.test.ts.

Proposed improvement
     } finally {
       try {
         await db.delete(orders).where(eq(orders.id, orderId));
-      } catch {}
+      } catch (e) {
+        console.error('[test cleanup failed] orders', { orderId, error: e });
+      }
       try {
         await db.delete(products).where(eq(products.id, productId));
-      } catch {}
+      } catch (e) {
+        console.error('[test cleanup failed] products', { productId, error: e });
+      }
     }

The same pattern appears in the other two tests (lines 249-255 and 387-393).


258-394: Add explicit timeout to the third test case.

Tests 1 and 2 have explicit 30_000 timeouts, but the third test ("duplicate refund restock") lacks one. For consistency and to prevent unexpected timeout failures, add the timeout parameter.

Proposed fix
-  });
+  }, 30_000);
 }, 30000);
frontend/lib/tests/restock-stuck-reserving-sweep.test.ts (1)

11-23: Consider extracting shared test helpers to avoid duplication.

The readRows and countMoveKey functions are duplicated in restock-order-only-once.test.ts. Consider extracting these to a shared test utilities file.

frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts (1)

65-114: Consider adding test cleanup to prevent data pollution.

The tests insert orders and stripe events but don't clean up after themselves. While this may work with isolated test databases, it could cause issues in shared test environments or if test order changes. Consider adding an afterEach hook to remove test data.

💡 Suggested cleanup pattern
+afterEach(async () => {
+  // Clean up test data
+  await db.delete(stripeEvents).where(eq(stripeEvents.eventId, eventId));
+  await db.delete(orders).where(eq(orders.id, orderId));
+});

Alternatively, track inserted IDs and clean them in the afterEach hook similar to the pattern used in stripe-webhook-refund-full.test.ts.

frontend/lib/tests/stripe-webhook-refund-full.test.ts (2)

63-70: Consider using English comments for consistency.

The comment on lines 64-65 is in Ukrainian. For codebase consistency and maintainability by international contributors, consider translating to English.

💬 Suggested translation
 async function cleanupInserted(ins: Inserted) {
-  // stripeEvents вставляються навіть коли orderId null (metadata missing),
-  // тому чистимо по paymentIntentId
+  // stripeEvents are inserted even when orderId is null (metadata missing),
+  // so we clean up by paymentIntentId
   await db

269-270: Minor: Missing blank line before test case.

Add a blank line between line 269 and 270 for consistent formatting with other test cases.

🔧 Format fix
     expect(restockOrder).toHaveBeenCalledTimes(0);
   });
+
   it('retry after 500 must reprocess same event.id until processedAt is set (restock not lost)', async () => {
frontend/lib/tests/checkout-concurrency-stock1.test.ts (2)

62-77: Consider translating Ukrainian comments to English.

Comments on lines 63 and 66 are in Ukrainian. For consistency with the rest of the codebase, consider using English.

💬 Suggested translation
   beforeAll(() => {
-    // зберегти оригінальні env значення тільки для stripe-ключів
+    // save original env values only for stripe keys
     for (const k of stripeKeys) originalEnv[k] = process.env[k];
 
-    // тест має бути незалежним від Stripe — гасимо stripe env
+    // test should be independent of Stripe — disable stripe env
     for (const k of stripeKeys) delete process.env[k];
   });

215-240: Overly defensive field lookups can be simplified.

The pick() and normalizeMoveKind() patterns with multiple fallback field names are more defensive than necessary given the known Drizzle schema. The inventoryMoves table clearly defines type and quantity columns.

🔧 Simplified assertions
     const moves = await db
       .select()
       .from(inventoryMoves)
-      .where(eq((inventoryMoves as any).productId, productId));
+      .where(eq(inventoryMoves.productId, productId));

-    const reserveMoves = (moves as any[]).filter(m => {
-      const kind = normalizeMoveKind(
-        pick(m, ['kind', 'type', 'moveType', 'action', 'op'])
-      );
-      return kind === 'reserve' || kind === 'reserved';
-    });
+    const reserveMoves = moves.filter(m => m.type === 'reserve');

-    const reservedUnits = reserveMoves.reduce((sum, m) => {
-      const q = pick(m, [
-        'quantity',
-        'qty',
-        'units',
-        'delta',
-        'deltaQty',
-        'deltaQuantity',
-      ]);
-      return sum + Math.abs(toNum(q));
-    }, 0);
+    const reservedUnits = reserveMoves.reduce((sum, m) => sum + m.quantity, 0);
frontend/app/api/shop/internal/orders/restock-stale/route.ts (1)

424-427: Consider translating Ukrainian comment to English.

💬 Suggested translation
   } catch (e) {
-    // не ковтай: але без твого логера — мінімально
+    // don't swallow: but without your logger — minimal logging
     console.error('restock-stale failed', { runId, error: e });
frontend/lib/services/orders.ts (1)

1333-1468: LGTM! Handles edge case of stuck reservations.

The restockStuckReservingOrders function properly targets orders that crashed mid-reservation (inventoryStatus in ['reserving', 'release_pending']). The use of COALESCE for failureCode/failureMessage (lines 1436-1437) correctly preserves any existing error context while providing a default.

Consider extracting shared constants.

The parameter bounds (MIN_OLDER_MIN, MAX_BATCH, etc.) are duplicated across restockStalePendingOrders, restockStuckReservingOrders, and restockStaleNoPaymentOrders. Consider extracting to module-level constants for easier maintenance.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9bbcd82 and 1f3ec7f.

📒 Files selected for processing (40)
  • .github/workflows/shop-janitor-restock-stale.yml
  • frontend/.env.example
  • frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx
  • frontend/app/[locale]/shop/admin/orders/[id]/page.tsx
  • frontend/app/[locale]/shop/admin/products/_components/product-form.tsx
  • frontend/app/api/shop/admin/products/[id]/route.ts
  • frontend/app/api/shop/admin/products/route.ts
  • frontend/app/api/shop/internal/orders/restock-stale/route.ts
  • frontend/app/api/shop/webhooks/stripe/route.ts
  • frontend/components/header/UnifiedHeader.tsx
  • frontend/db/queries/shop/products.ts
  • frontend/db/schema/shop.ts
  • frontend/drizzle/0013_add_internal_job_state.sql
  • frontend/drizzle/0014_add-stripe-events-processed-at.sql
  • frontend/drizzle/0015_warm_dexter_bennett.sql
  • frontend/drizzle/meta/0013_snapshot.json
  • frontend/drizzle/meta/0014_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/admin/parseAdminProductForm.ts
  • frontend/lib/auth/internal-janitor.ts
  • frontend/lib/services/orders.ts
  • frontend/lib/services/products.ts
  • frontend/lib/tests/admin-product-sale-contract.test.ts
  • frontend/lib/tests/checkout-concurrency-stock1.test.ts
  • frontend/lib/tests/checkout-no-payments.test.ts
  • frontend/lib/tests/order-items-snapshot-immutable.test.ts
  • frontend/lib/tests/product-sale-invariant.test.ts
  • frontend/lib/tests/restock-order-only-once.test.ts
  • frontend/lib/tests/restock-stale-stripe-orphan.test.ts
  • frontend/lib/tests/restock-stuck-reserving-sweep.test.ts
  • frontend/lib/tests/stripe-webhook-mismatch.test.ts
  • frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts
  • frontend/lib/tests/stripe-webhook-psp-fields.test.ts
  • frontend/lib/tests/stripe-webhook-refund-full.test.ts
  • frontend/lib/types/shop.ts
  • frontend/lib/validation/shop.ts
  • frontend/project-structure.txt
  • frontend/scripts/shop-janitor-restock-stale.mjs
  • frontend/tmp/replay-charge-refunded.json
  • frontend/tmp/replay-stripe-webhook.js
💤 Files with no reviewable changes (1)
  • frontend/drizzle/meta/_journal.json
🧰 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/0014_snapshot.json
🧬 Code graph analysis (17)
frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx (1)
frontend/scripts/shop-janitor-restock-stale.mjs (1)
  • res (23-33)
frontend/lib/tests/checkout-concurrency-stock1.test.ts (2)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (1)
  • inventoryMoves (334-363)
frontend/app/api/shop/admin/products/route.ts (1)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/lib/services/products.ts (3)
frontend/lib/services/errors.ts (1)
  • InvalidPayloadError (35-41)
frontend/db/schema/shop.ts (1)
  • productPrices (278-332)
frontend/lib/shop/currency.ts (1)
  • CurrencyCode (2-2)
frontend/lib/tests/stripe-webhook-refund-full.test.ts (4)
frontend/tmp/replay-stripe-webhook.js (1)
  • crypto (2-2)
frontend/db/schema/shop.ts (2)
  • orders (107-198)
  • stripeEvents (262-276)
frontend/lib/psp/stripe.ts (1)
  • verifyWebhookSignature (97-122)
frontend/app/api/shop/webhooks/stripe/route.ts (1)
  • POST (115-707)
frontend/app/api/shop/admin/products/[id]/route.ts (1)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/lib/types/shop.ts (2)
frontend/lib/validation/shop.ts (1)
  • OrderSummary (432-432)
frontend/db/queries/shop/orders.ts (1)
  • OrderSummary (33-33)
frontend/app/[locale]/shop/admin/products/_components/product-form.tsx (3)
frontend/lib/validation/shop.ts (2)
  • CurrencyCode (32-32)
  • ProductAdminInput (433-433)
frontend/lib/shop/currency.ts (1)
  • CurrencyCode (2-2)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/lib/tests/restock-stuck-reserving-sweep.test.ts (4)
frontend/db/index.ts (1)
  • db (39-39)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/lib/services/inventory.ts (1)
  • applyReserveMove (49-116)
frontend/lib/services/orders.ts (1)
  • restockStuckReservingOrders (1334-1468)
frontend/app/api/shop/webhooks/stripe/route.ts (3)
frontend/db/index.ts (1)
  • db (39-39)
frontend/db/schema/shop.ts (2)
  • stripeEvents (262-276)
  • orders (107-198)
frontend/lib/services/orders.ts (2)
  • restockOrder (1039-1197)
  • restockOrder (1640-1640)
frontend/lib/tests/product-sale-invariant.test.ts (1)
frontend/lib/shop/money.ts (1)
  • toDbMoney (58-60)
frontend/lib/tests/admin-product-sale-contract.test.ts (2)
frontend/app/api/shop/admin/products/route.ts (1)
  • POST (68-193)
frontend/app/api/shop/admin/products/[id]/route.ts (1)
  • PATCH (115-257)
frontend/lib/tests/restock-order-only-once.test.ts (4)
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 (1039-1197)
  • restockOrder (1640-1640)
frontend/lib/services/orders.ts (3)
frontend/lib/types/shop.ts (2)
  • OrderSummaryWithMinor (32-35)
  • PaymentStatus (44-44)
frontend/db/schema/shop.ts (1)
  • orders (107-198)
frontend/lib/shop/payments.ts (1)
  • PaymentStatus (9-9)
frontend/lib/auth/internal-janitor.ts (1)
frontend/tmp/replay-stripe-webhook.js (1)
  • crypto (2-2)
frontend/lib/admin/parseAdminProductForm.ts (1)
frontend/drizzle/schema.ts (1)
  • currency (7-7)
frontend/app/[locale]/shop/admin/orders/[id]/page.tsx (4)
frontend/app/[locale]/layout.tsx (1)
  • dynamic (17-17)
frontend/lib/shop/money.ts (1)
  • fromDbMoney (51-56)
frontend/lib/shop/currency.ts (3)
  • CurrencyCode (2-2)
  • resolveCurrencyFromLocale (23-28)
  • formatMoney (115-128)
frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx (1)
  • RefundButton (11-59)
🪛 Biome (2.1.2)
frontend/lib/tests/restock-stuck-reserving-sweep.test.ts

[error] 177-178: Unsafe usage of 'throw'.

'throw' in 'finally' overwrites the control flow statements inside 'try' and 'catch'.

(lint/correctness/noUnsafeFinally)

frontend/tmp/replay-stripe-webhook.js

[error] 12-12: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 12-12: Expected a statement but instead found '.'.

Expected a statement here.

(parse)


[error] 12-12: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 14-14: Expected an expression, or an assignment but instead found '='.

Expected an expression, or an assignment here.

(parse)

🪛 dotenv-linter (4.0.0)
frontend/.env.example

[warning] 39-39: [UnorderedKey] The INTERNAL_JANITOR_MIN_INTERVAL_SECONDS key should go before the INTERNAL_JANITOR_SECRET key

(UnorderedKey)


[warning] 40-40: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)

🪛 Gitleaks (8.30.0)
frontend/.env.example

[high] 38-39: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🔇 Additional comments (72)
frontend/components/header/UnifiedHeader.tsx (1)

74-84: LGTM! Clean conditional rendering with proper accessibility.

The Home link implementation is solid—proper aria-label for screen readers, placeholder div prevents layout shift between variants, and flex-shrink-0 ensures the icon doesn't collapse under flex constraints. The positioning in the right action cluster is logical alongside other utility buttons.

frontend/.env.example (1)

38-40: LGTM! New janitor environment variables are well-structured.

The additions support the internal janitor authentication and rate limiting as described in the PR objectives.

Note: The Gitleaks warning about INTERNAL_JANITOR_SECRET is a false positive—this is an example file with placeholder values, not actual secrets.

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

213-213: LGTM! Timeout increase aligns with integration test requirements.

The 30-second timeout is appropriate for end-to-end tests that involve database operations, product mutations, and snapshot validation.

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

446-477: LGTM! Test data updated to reflect new sweep policy thresholds.

The changes correctly align with the PR's updated janitor sweep behavior:

  • Orphan created 11 minutes ago (exceeds the 10-minute threshold)
  • inventoryStatus: 'reserving' accurately represents a stuck-in-progress state
  • olderThanMinutes: 10 matches the configured threshold

These adjustments ensure the test validates the production sweep behavior.


505-505: LGTM! Timeout appropriate for integration test scope.

The 20-second timeout accommodates the orphan cleanup path's database operations and restock logic.

frontend/lib/validation/shop.ts (2)

246-257: LGTM! SALE badge validation enforces pricing contract.

The validation correctly implements the requirement that SALE products must include originalPriceMinor for all currencies. This ensures the UI can display the discount comparison.

The error messages are clear and the validation is positioned after duplicate currency and USD requirement checks, maintaining a logical validation order.


297-307: LGTM! Update schema mirrors create schema validation.

The SALE badge validation in productAdminUpdateSchema correctly mirrors the create schema logic, ensuring consistent behavior across both operations.

frontend/lib/admin/parseAdminProductForm.ts (3)

29-36: LGTM! Enhanced type handling in parseBooleanField.

Adding support for boolean type values (line 35) improves robustness when FormData contains actual boolean values instead of just strings.


165-169: LGTM! More specific error message improves debugging.

The new error message 'Invalid prices payload type' clearly indicates the issue when the prices field is not a string, making debugging easier compared to a generic error.


15-23: LGTM! Formatting improvements enhance readability.

The multiline function signatures and reformatted objects improve code readability without changing behavior. The style is consistent throughout the file.

Also applies to: 25-37, 39-46, 98-105, 148-157, 181-191, 197-199, 265-268, 296-305

frontend/lib/tests/product-sale-invariant.test.ts (4)

1-17: LGTM! Clean test setup with Cloudinary mocks.

The mock setup properly isolates external dependencies and provides deterministic test data.


19-31: LGTM! Proper test isolation with cleanup.

The unique slug helper and afterEach cleanup ensure tests remain isolated without polluting the database.


33-50: LGTM! Test correctly validates SALE invariant on create.

The test properly asserts that creating a SALE product without originalPriceMinor is rejected with the expected error message.


52-116: LGTM! Comprehensive test for SALE invariant on update.

The test properly seeds a valid SALE product, attempts an invalid update (removing originalPriceMinor), and verifies both rejection and database integrity. The post-failure verification query is a nice defensive check.

frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx (1)

42-58: LGTM! Well-structured UI with proper loading and error states.

The button correctly handles disabled states, provides loading feedback, and displays inline errors effectively.

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

338-356: LGTM! SALE invariant helper is well-structured.

The helper correctly validates that SALE badges require originalPriceMinor for each currency and that it exceeds priceMinor. The defensive check at Line 350-354 provides additional safety, even though validatePriceRows already validates this constraint (lines 329-333).


378-382: LGTM! SALE validation properly integrated into createProduct.

The validation sequence correctly enforces SALE invariants before any database operations, ensuring data integrity at the service boundary.


495-532: LGTM! Comprehensive SALE validation for update operations.

The update flow correctly computes the final badge state and merges existing + incoming prices to validate the complete post-update state. This ensures SALE invariants are preserved across partial updates (PATCH operations).

frontend/app/api/shop/admin/products/route.ts (4)

15-49: LGTM! Well-structured SALE validation helpers.

The SaleRuleViolation type and findSaleRuleViolation helper provide clear, structured error information for API responses, enabling client-side field-specific error handling.


51-66: LGTM! Pre-parse validation provides early feedback.

The helper extracts and validates SALE pricing rules before full form parsing, enabling faster error responses for invalid SALE data.


84-125: LGTM! Consistent SALE validation with defense in depth.

The dual validation approach (pre-parse and post-parse) ensures SALE pricing rules are enforced early while maintaining robustness. Error responses provide structured details for client-side field mapping.


142-152: LGTM! Enhanced error handling with richer context.

The improved InvalidPayloadError handling extracts additional error context (code, field, details) for more informative API responses, supporting better client-side error handling.

frontend/lib/tests/admin-product-sale-contract.test.ts (4)

1-58: LGTM! Comprehensive mock setup for route isolation.

The mock setup properly isolates API route behavior by mocking authentication, form parsing, and product services. Helper functions provide clean test data construction.


60-67: LGTM! Proper test isolation with beforeEach.

The beforeEach hook correctly resets all mocks to ensure tests remain independent.


69-108: LGTM! Comprehensive contract test for POST with SALE validation.

The test properly validates that the POST endpoint rejects SALE products missing originalPriceMinor, returns structured error information, and prevents the invalid product from being created.


110-157: LGTM! Comprehensive contract test for PATCH with SALE validation.

The test properly validates that the PATCH endpoint rejects SALE products where originalPriceMinor is not greater than priceMinor, returns structured error information, and prevents the invalid update.

frontend/app/api/shop/admin/products/[id]/route.ts (1)

133-176: Dual SALE validation (pre-parse and post-parse) appears intentional but adds complexity.

The code validates SALE rules twice: once from raw FormData (lines 134-150) and again from parsed data (lines 160-176). If parseAdminProductForm transforms the data significantly, this defense-in-depth approach is reasonable. Otherwise, consider consolidating to reduce duplication.

frontend/app/[locale]/shop/admin/orders/[id]/page.tsx (1)

59-66: Refund eligibility and UI integration look correct.

The canRefund check appropriately gates the refund action to Stripe orders that are paid and have a payment intent ID. The RefundButton component receives proper props with disabled state based on eligibility.

frontend/app/[locale]/shop/admin/products/_components/product-form.tsx (2)

206-212: Good UX: Clearing per-currency errors when the user edits the field.

This provides immediate feedback that the user's correction is being acknowledged.


700-707: Good defensive behavior: clearing SALE errors when badge changes.

When the user switches away from the SALE badge, clearing originalPriceErrors prevents stale validation messages from persisting.

frontend/project-structure.txt (1)

344-362: Comprehensive test coverage additions for critical paths.

The new test files cover:

  • Admin API killswitch
  • Checkout concurrency (stock=1 scenario)
  • Order item immutability
  • Restock idempotency and sweep claim logic
  • Stripe webhook contract, mismatch, PSP fields, paid-status repair, and refunds

This aligns well with the PR's goal of hardening inventory idempotency and concurrency invariants.

frontend/db/queries/shop/products.ts (2)

218-219: Safe handling of count result type.

The bigint check ensures compatibility with different database drivers that may return count as bigint or number.


144-152: Sale detection logic change: now based on originalPriceMinor presence.

The sale category filter now checks originalPriceMinor IS NOT NULL instead of checking the badge. This aligns with the new SALE validation invariant that requires originalPriceMinor > priceMinor for SALE badge products.

However, note that badge display has fallback logic: products with an explicit badge = 'SALE' will still display the SALE badge even without originalPriceMinor (via the direct badge check in deriveBadge). Legacy data with SALE badge but missing originalPriceMinor won't appear in the sale category filter query, though they'll still display the badge elsewhere. New products cannot have this mismatch due to validation enforcement.

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

56-263: Well-structured integration test for PSP field persistence and idempotency.

The test:

  1. Seeds realistic test data (product, price, order, items)
  2. Verifies PSP fields are written on payment_intent.succeeded
  3. Confirms idempotency by replaying the same event
  4. Properly cleans up in a finally block

The 30-second timeout is appropriate for this database-heavy integration test.

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

55-220: Critical security test: verifying mismatch detection prevents incorrect payment acceptance.

This test validates that when Stripe reports a different amount than expected, the order is NOT marked as paid and the mismatch details are recorded in pspStatusReason and pspMetadata. This is essential for preventing revenue loss or fraud scenarios.

The test correctly:

  • Creates a real order flow
  • Simulates a webhook with amount + 1 mismatch
  • Asserts paymentStatus !== 'paid'
  • Verifies audit trail in metadata
frontend/lib/tests/restock-stale-stripe-orphan.test.ts (1)

11-11: LGTM! Test description and assertion are consistent.

The updated test correctly reflects the new behavior where stale orphan orders (no inventory reservations) are marked with inventoryStatus: 'released' instead of 'failed'. This semantic distinction makes sense since there's no inventory to fail—it was never reserved.

The description update ("and releases") accurately documents this behavior change.

Also applies to: 72-72

frontend/scripts/shop-janitor-restock-stale.mjs (2)

23-33: LGTM! Dual authentication headers and safe request configuration.

The fetch configuration correctly:

  • Sends both header variants (x-internal-janitor-secret and Bearer) as documented
  • Uses '{}' body to avoid NextRequest.json() parse errors
  • Implements timeout via AbortController signal
  • Sets appropriate content-type header

39-41: Correctly treats rate-limit response as success for schedulers.

Treating HTTP 429 as success (exit code 0) is appropriate for scheduled jobs that may overlap. This prevents alerting on expected rate-limiting and aligns with the PR's DB-backed rate limiter design.

frontend/drizzle/0014_add-stripe-events-processed-at.sql (1)

1-1: Migration and idempotency logic alignment confirmed.

The nullable processed_at column is correctly used in the webhook handler:

  • New events inserted with processedAt: null (unprocessed state)
  • After successful processing, processedAt is set to new Date()
  • Idempotency check at line 258 (if (existing?.processedAt)) correctly distinguishes between NULL (unprocessed, allowing reprocessing) and timestamp values (already processed, skipped)

The migration safely aligns with the webhook logic.

frontend/lib/types/shop.ts (1)

32-42: LGTM! Clean type extension for canonical minor-unit money model.

The new OrderSummaryWithMinor type and updated CheckoutResult properly introduce the canonical totalAmountMinor field while maintaining backward compatibility via the legacy totalCents. The comment on line 40 clearly documents the rationale.

.github/workflows/shop-janitor-restock-stale.yml (1)

1-53: LGTM! Well-structured workflow with proper safeguards.

The workflow correctly:

  • Uses cancel-in-progress: false to ensure jobs complete
  • Has a 3-minute timeout that accommodates the 25s janitor timeout
  • Gracefully skips when secrets are missing (emits warning instead of failing)
  • Uses minimal contents: read permissions
frontend/db/schema/shop.ts (3)

365-372: Consider adding $onUpdate to updatedAt for consistency.

Other tables in this schema (e.g., products, orders, productPrices) use $onUpdate(() => new Date()) on their updatedAt fields. The internalJobState table lacks this, which means updatedAt will only be set on insert unless explicitly updated in queries.

This may be intentional if you want explicit control over updates, but verify this is the desired behavior.


272-272: LGTM! processedAt field enables idempotent webhook handling.

The new processedAt timestamp on stripeEvents supports exactly-once processing semantics for webhook deduplication.


378-378: LGTM! Type export aligns with new schema.

frontend/lib/tests/restock-order-only-once.test.ts (2)

25-154: LGTM! Comprehensive idempotency test for failed order restock.

The test thoroughly validates:

  • Reserve move decrements stock correctly
  • First restock restores stock and sets stockRestored/restockedAt
  • Second restock is a no-op (stock unchanged, timestamps preserved, single move key)

156-256: LGTM! Good concurrent restock test using Promise.all.

The test correctly verifies that two concurrent restockOrder calls result in exactly one stock restoration and one inventory move record, validating the concurrency guards.

frontend/lib/tests/restock-stuck-reserving-sweep.test.ts (2)

25-38: Good cleanup helper that respects FK constraints.

The cleanupTestRows function properly deletes child records (inventory_moves, order_items) before parent records, avoiding FK constraint violations. This is better than the cleanup pattern in the other test file.


40-166: LGTM! Thorough test for stuck-reserving sweep with exactly-once semantics.

The test comprehensively validates:

  • Stuck order is picked up by sweep and transitioned to terminal state
  • failureCode is set to STUCK_RESERVING_TIMEOUT
  • Stock is restored exactly once
  • Second sweep is a no-op (0 processed, same restockedAt)
frontend/lib/auth/internal-janitor.ts (1)

12-43: LGTM! Auth flow is well-structured with appropriate HTTP status codes.

The function correctly:

  • Returns 503 when janitor is disabled (missing secret)
  • Returns 401 for missing credentials
  • Returns 403 for invalid credentials
  • Returns null to signal success (allowing request to proceed)

The fallback header x-internal-secret provides flexibility for callers.

frontend/drizzle/meta/0013_snapshot.json (1)

1-2220: Auto-generated Drizzle snapshot file.

This is a Drizzle ORM metadata snapshot that reflects schema state. The notable change is the order_items_order_product_uq unique constraint which now covers only (order_id, product_id) without variant columns. Ensure this aligns with the intended business logic where a product can only appear once per order regardless of size/color selection.

frontend/lib/tests/stripe-webhook-paid-status-repair.test.ts (1)

28-63: Well-structured webhook test helper with proper mocking.

The callWebhook helper correctly uses vi.resetModules() before vi.doMock() to ensure fresh module state for each call, which is essential for testing idempotency scenarios. The mock returns a synthetic payment_intent.succeeded event with the necessary metadata.

frontend/lib/tests/stripe-webhook-refund-full.test.ts (1)

72-168: Comprehensive refund webhook test with proper deduplication verification.

This test thoroughly validates the full refund flow via charge.refunded: PI fallback resolution, terminal status transition to CANCELED, stock restoration, and deduplication by eventId. The assertions correctly verify that restockOrder is called exactly once across duplicate webhook deliveries.

frontend/lib/tests/checkout-concurrency-stock1.test.ts (1)

149-168: Well-implemented concurrent execution pattern.

The gate pattern using a Promise with externally-resolved release() function ensures both checkout requests start simultaneously, providing a proper race condition test. This is the correct approach for testing concurrency invariants.

frontend/drizzle/meta/0014_snapshot.json (2)

1182-1219: New internal_job_state table for cross-instance job coordination.

This table provides the foundation for the DB-backed rate limiter used by the janitor endpoint. The schema correctly uses job_name as the primary key for efficient upsert operations with atomic conflict handling.


2166-2171: processed_at column enables idempotent webhook handling.

The nullable processed_at timestamp on stripe_events allows distinguishing between: (1) never seen, (2) seen but failed (processedAt=NULL), and (3) successfully processed. This three-state model supports proper retry semantics.

frontend/app/api/shop/internal/orders/restock-stale/route.ts (3)

223-260: Well-implemented DB-backed rate limiter with atomic semantics.

The acquireJobSlot function correctly uses PostgreSQL's INSERT...ON CONFLICT DO UPDATE WHERE pattern for atomic cross-instance coordination. The two-query approach (attempt insert/update, then select if rate-limited) is the right pattern since ON CONFLICT DO UPDATE WHERE doesn't return rows when the WHERE condition fails.


199-208: Test environment bypass for rate limiter is appropriate.

Returning 0 for minIntervalSeconds in test environment prevents test flakes from persistent DB state affecting test isolation. This is a pragmatic approach for test reliability.


373-407: Sequential sweep execution with time budgets.

The three sweep categories (stuckReserving, stalePending, orphanNoPayment) execute sequentially with decreasing time budgets. This ensures the endpoint respects maxRuntimeMs across all operations. The aggregation in processedByCategory provides good observability.

frontend/app/api/shop/webhooks/stripe/route.ts (4)

229-235: Centralized ack() helper improves maintainability.

The ack() function ensures processedAt is always set before returning success, preventing duplicate processing on retries. This is a clean pattern for idempotent webhook handling.


268-294: PI fallback order resolution handles missing metadata gracefully.

When metadata.orderId is missing (e.g., refunds triggered externally), the fallback queries orders by paymentIntentId. Limiting to 2 results correctly detects ambiguous cases (multiple orders for same PI) and logs appropriately before acknowledging.


440-468: Repair path handles paid status inconsistencies.

The repair update at lines 443-468 fixes edge cases where paymentStatus='paid' but status!='PAID' (e.g., from a previous partial failure or race condition). The WHERE conditions ensure this only applies when safe (not restocked, not released).


664-692: Full refund flow correctly transitions to terminal state.

The full refund handling sets paymentStatus='refunded' and status='CANCELED', then calls restockOrder with reason='refunded'. The WHERE clause with ne(orders.paymentStatus, 'refunded') prevents duplicate processing.

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

107-115: LGTM! Type migration to OrderSummaryWithMinor is correct.

The requireTotalCents helper now properly validates the canonical totalAmountMinor field, ensuring it's a finite number before use. This aligns with the minor-unit money model.


211-305: LGTM! Robust parsing with legacy fallback.

The function properly handles the migration path by falling back to legacy money fields when minor-unit fields are null/undefined, while validating data integrity with requireMinor and readLegacyMoneyCentsOrThrow. The consistency check for paymentProvider=none with paymentIntentId set is a good invariant guard.


307-309: LGTM! Return type properly updated.

The reconcileNoPaymentOrder function correctly returns OrderSummaryWithMinor and maintains proper inventory reconciliation logic with appropriate error handling and rollback semantics.


453-456: LGTM!

Return type correctly updated to OrderSummaryWithMinor | null.


610-610: LGTM!

Parameter type correctly updated to OrderSummaryWithMinor for idempotency validation.


930-942: LGTM!

Return types for getOrderSummary and setOrderPaymentIntent correctly updated to OrderSummaryWithMinor.


1199-1246: LGTM! Good production hardening with parameter bounds.

The parameter validation with min/max clamping (e.g., MIN_OLDER_MIN=10, MAX_BATCH=100, MAX_TIME_BUDGET_MS=25_000) prevents misconfiguration and abuse. The time budget mechanism ensures sweeps don't run indefinitely.


1264-1287: LGTM! Proper separation of payment provider sweeps.

Filtering by paymentProvider='stripe' ensures this sweep only handles Stripe orders, while no-payment orders are handled by restockStaleNoPaymentOrders. The conditional age cutoff (!hasExplicitIds) allows targeted processing in tests.


1470-1590: LGTM! Proper handling of no-payment order sweeps.

The function correctly targets paymentProvider='none' orders with incomplete inventory states (['none', 'reserving', 'release_pending']). This separation from Stripe sweeps ensures each payment workflow has its own cleanup logic aligned with its specific constraints (e.g., DB CHECK forcing payment_status for provider='none').


1592-1638: LGTM! Refund flow correctly updated.

Return type properly changed to OrderSummaryWithMinor. The refund logic correctly validates the order state and calls restockOrder with reason: 'refunded' to ensure proper inventory restoration.

Comment on lines +16 to +40
async function onRefund() {
setError(null);

const res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
});

let json: any = null;
try {
json = await res.json();
} catch {
// ignore
}

if (!res.ok) {
setError(json?.error ?? json?.code ?? `HTTP_${res.status}`);
return;
}

startTransition(() => {
router.refresh();
});
}
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

Add error handling for network failures.

The onRefund function does not catch network errors (e.g., fetch rejection due to network issues). If the fetch fails, the error will be unhandled and not displayed to the user.

🛡️ Proposed fix
  async function onRefund() {
    setError(null);

-   const res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, {
-     method: 'POST',
-     credentials: 'same-origin',
-     headers: { 'Content-Type': 'application/json' },
-   });
+   let res: Response;
+   try {
+     res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, {
+       method: 'POST',
+       credentials: 'same-origin',
+       headers: { 'Content-Type': 'application/json' },
+     });
+   } catch (err) {
+     setError('NETWORK_ERROR');
+     return;
+   }

    let json: any = null;
    try {
      json = await res.json();
    } catch {
      // ignore
    }

    if (!res.ok) {
      setError(json?.error ?? json?.code ?? `HTTP_${res.status}`);
      return;
    }

    startTransition(() => {
      router.refresh();
    });
  }
📝 Committable suggestion

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

Suggested change
async function onRefund() {
setError(null);
const res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
});
let json: any = null;
try {
json = await res.json();
} catch {
// ignore
}
if (!res.ok) {
setError(json?.error ?? json?.code ?? `HTTP_${res.status}`);
return;
}
startTransition(() => {
router.refresh();
});
}
async function onRefund() {
setError(null);
let res: Response;
try {
res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
setError('NETWORK_ERROR');
return;
}
let json: any = null;
try {
json = await res.json();
} catch {
// ignore
}
if (!res.ok) {
setError(json?.error ?? json?.code ?? `HTTP_${res.status}`);
return;
}
startTransition(() => {
router.refresh();
});
}
🤖 Prompt for AI Agents
In @frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx around lines
16 - 40, The onRefund function currently doesn't handle fetch rejections; wrap
the fetch call in a try/catch so network errors are caught, call setError with
the caught error's message (or a fallback like 'Network error') and return
early, and then proceed with the existing response.json parsing and res.ok
handling; keep the existing error extraction from json and the
startTransition(router.refresh()) on success so behavior is preserved.

Comment on lines +324 to +338
if (data.code === 'SALE_ORIGINAL_REQUIRED') {
const details = data.details as SaleRuleDetails | undefined;
const currency = details?.currency;
const msg =
details?.rule === 'greater_than_price'
? SALE_GREATER_MSG
: SALE_REQUIRED_MSG;

if (currency === 'USD' || currency === 'UAH') {
setOriginalPriceErrors(prev => ({ ...prev, [currency]: msg }));
}

setError(data.error ?? msg);
return;
}
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 | 🟠 Major

Missing setIsSubmitting(false) before early return on SALE error.

When a SALE_ORIGINAL_REQUIRED error is returned from the API, the function returns early without resetting isSubmitting, leaving the form in a stuck "submitting" state.

🐛 Proposed fix
         if (data.code === 'SALE_ORIGINAL_REQUIRED') {
           const details = data.details as SaleRuleDetails | undefined;
           const currency = details?.currency;
           const msg =
             details?.rule === 'greater_than_price'
               ? SALE_GREATER_MSG
               : SALE_REQUIRED_MSG;
 
           if (currency === 'USD' || currency === 'UAH') {
             setOriginalPriceErrors(prev => ({ ...prev, [currency]: msg }));
           }
 
           setError(data.error ?? msg);
+          setIsSubmitting(false);
           return;
         }
📝 Committable suggestion

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

Suggested change
if (data.code === 'SALE_ORIGINAL_REQUIRED') {
const details = data.details as SaleRuleDetails | undefined;
const currency = details?.currency;
const msg =
details?.rule === 'greater_than_price'
? SALE_GREATER_MSG
: SALE_REQUIRED_MSG;
if (currency === 'USD' || currency === 'UAH') {
setOriginalPriceErrors(prev => ({ ...prev, [currency]: msg }));
}
setError(data.error ?? msg);
return;
}
if (data.code === 'SALE_ORIGINAL_REQUIRED') {
const details = data.details as SaleRuleDetails | undefined;
const currency = details?.currency;
const msg =
details?.rule === 'greater_than_price'
? SALE_GREATER_MSG
: SALE_REQUIRED_MSG;
if (currency === 'USD' || currency === 'UAH') {
setOriginalPriceErrors(prev => ({ ...prev, [currency]: msg }));
}
setError(data.error ?? msg);
setIsSubmitting(false);
return;
}
🤖 Prompt for AI Agents
In @frontend/app/[locale]/shop/admin/products/_components/product-form.tsx
around lines 324 - 338, In the SALE_ORIGINAL_REQUIRED branch of the submit
handler, call setIsSubmitting(false) before returning so the form doesn't remain
stuck; specifically, just before the early return in the block that checks if
(data.code === 'SALE_ORIGINAL_REQUIRED'), invoke setIsSubmitting(false) (keeping
the existing setOriginalPriceErrors and setError calls) so submission state is
cleared regardless of this error.

Comment on lines +66 to +71
try {
const prices = JSON.parse(pricesRaw);
return findSaleRuleViolation({ badge, prices });
} catch {
return null;
}
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:

cd frontend && find . -name "route.ts" -path "*/admin/products/\[id\]/*" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 165


🏁 Script executed:

cd frontend && find . -type f -name "*.ts" | xargs grep -l "parseAdminProductForm" | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 238


🏁 Script executed:

cd frontend && cat -n app/api/shop/admin/products/\[id\]/route.ts | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 3775


🏁 Script executed:

cd frontend && cat -n app/api/shop/admin/products/\[id\]/route.ts | sed -n '100,200p'

Repository: DevLoversTeam/devlovers.net

Length of output: 3819


🏁 Script executed:

cd frontend && cat -n lib/admin/parseAdminProductForm.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 12364


Silent failure on JSON parse error creates ambiguous validation state.

When JSON.parse fails in getSaleViolationFromFormData, returning null makes the early SALE validation pass silently without distinguishing between "no violations found" and "couldn't validate because JSON is malformed." While the malformed JSON will eventually be caught by parseAdminProductForm with a generic "Invalid prices JSON" error, the early SALE check should explicitly handle and report JSON parse errors rather than treating them as absence of violations.

🤖 Prompt for AI Agents
In @frontend/app/api/shop/admin/products/[id]/route.ts around lines 66 - 71,
getSaleViolationFromFormData currently swallows JSON.parse errors and returns
null, making malformed prices indistinguishable from "no violations"; change the
catch around JSON.parse(pricesRaw) so it does not return null silently but
instead surfaces a clear parse failure (either by throwing a descriptive error
or returning an explicit validation error object) so callers like
parseAdminProductForm and findSaleRuleViolation can distinguish "invalid JSON"
from "no sale violations"; reference the getSaleViolationFromFormData function,
the JSON.parse(pricesRaw) call, and the use of findSaleRuleViolation when
implementing this behavior.

Comment on lines +606 to +624

if (eventType === 'charge.refunded' && charge) {
const amt =
typeof (charge as any).amount === 'number'
? (charge as any).amount
: null;
const refunded =
typeof (charge as any).amount_refunded === 'number'
? (charge as any).amount_refunded
: null;
isFullRefund = amt != null && refunded != null && refunded === amt;
} else if (eventType === 'charge.refund.updated' && refund) {
const refundAmt =
typeof (refund as any).amount === 'number'
? (refund as any).amount
: null;
isFullRefund =
refundAmt != null && refundAmt === order.totalAmountMinor;
}
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 | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Stripe charge.refund.updated event payload structure documentation

💡 Result:

Short answer: charge.refund.updated (and related refund events) are delivered as a Stripe Event JSON whose data.object is a Refund object. See Stripe’s webhook/Event docs for the Event structure, the Refund object reference for fields inside data.object, and the changelog noting refund.updated behavior.

Sources:
[1] Stripe — Receive webhook events / Event object (webhooks).
[2] Stripe API reference — Refund object (fields & example payload).
[3] Stripe changelog — Adds refund.created / refund.updated / refund.failed (2024-10-28).


🌐 Web query:

Stripe Refund object amount field documentation API reference

💡 Result:

The Refund object’s amount is an integer in the currency’s smallest unit (e.g., cents for USD) representing how much was refunded. It’s a positive integer and can only be up to the remaining unrefunded amount of the payment. [1][2]

Sources:
[1] Stripe API reference — Refund object.
[2] Stripe API reference — Report a refund (amount field details).


Full refund detection logic is inconsistent across event types.

For charge.refunded, full refund detection correctly compares the cumulative amount_refunded against the charge amount. However, for charge.refund.updated, comparing a single refund's amount directly to order.totalAmountMinor fails to account for multiple partial refunds and incorrectly marks subsequent refunds as incomplete even when they complete a full refund sequence.

The charge.refund.updated handler should either:

  • Track cumulative refund amounts across multiple refund events, or
  • Compare against the charge's remaining unrefunded amount (original charge amount minus previously refunded total)
🤖 Prompt for AI Agents
In @frontend/app/api/shop/webhooks/stripe/route.ts around lines 606 - 624, The
full-refund detection for 'charge.refund.updated' is incorrect because it
compares a single refund.amount to order.totalAmountMinor; instead compute
cumulative refunded for the associated charge and compare that to the original
charge amount. In the 'charge.refund.updated' branch (where refund is
available), locate the related charge object (from refund.charge or by
retrieving the charge) and determine the cumulative refunded amount (use
charge.amount_refunded if present, or sum existing charge.refunds.data amounts
plus the current refund.amount) and set isFullRefund when cumulativeRefunded ===
charge.amount (or === order.totalAmountMinor if you prefer using order totals).
Update the code that sets isFullRefund in the charge.refund.updated case to use
this cumulative comparison rather than just refund.amount.

Comment on lines +5 to +10
function timingSafeEqual(a: string, b: string) {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
}
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 | 🟠 Major

Timing leak in length comparison.

The early return false on length mismatch (line 8) reveals information about the secret's length through timing differences. An attacker can determine the exact length of the secret by measuring response times.

To maintain constant-time behavior regardless of input length, pad both buffers to the same size before comparison.

Proposed fix
 function timingSafeEqual(a: string, b: string) {
   const aBuf = Buffer.from(a);
   const bBuf = Buffer.from(b);
-  if (aBuf.length !== bBuf.length) return false;
-  return crypto.timingSafeEqual(aBuf, bBuf);
+  // Pad to same length to avoid timing leak on length difference
+  const maxLen = Math.max(aBuf.length, bBuf.length);
+  if (maxLen === 0) return true;
+  const aPadded = Buffer.alloc(maxLen);
+  const bPadded = Buffer.alloc(maxLen);
+  aBuf.copy(aPadded);
+  bBuf.copy(bPadded);
+  // Compare padded buffers AND check original lengths match
+  return crypto.timingSafeEqual(aPadded, bPadded) && aBuf.length === bBuf.length;
 }
🤖 Prompt for AI Agents
In @frontend/lib/auth/internal-janitor.ts around lines 5 - 10, The
timingSafeEqual function leaks secret length via the early length check; modify
timingSafeEqual to avoid early return by allocating two buffers of equal length
(max of aBuf.length and bBuf.length), copy a and b into those fixed-size buffers
(padding the remainder with zeros), then call crypto.timingSafeEqual on the
padded buffers (still handling non-buffer inputs as before) so the comparison
time does not vary with input length.

📁 scripts
📄 guard-non-preview.ts
📄 shop-janitor-restock-stale.mjs
📁 tmp
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:

#!/bin/bash
# Check if tmp is in .gitignore
grep -E '^\s*/?tmp/?$|^\s*/tmp\s*$' .gitignore || echo "tmp NOT found in .gitignore"

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

#!/bin/bash
# Check the actual .gitignore content for any tmp-related patterns
echo "=== Checking .gitignore for tmp-related patterns ==="
grep -i 'tmp' .gitignore || echo "No tmp patterns found in .gitignore"

echo ""
echo "=== Full .gitignore content ==="
cat .gitignore | head -50

echo ""
echo "=== Checking if frontend/project-structure.txt exists ==="
if [ -f "frontend/project-structure.txt" ]; then
  echo "File exists"
  echo "=== Lines around line 407 ==="
  sed -n '405,410p' "frontend/project-structure.txt"
else
  echo "File not found"
fi

Repository: DevLoversTeam/devlovers.net

Length of output: 994


Add tmp directory to .gitignore.

The tmp directory has been added to the project structure but is not currently ignored by git. Temporary directories should be excluded from version control to prevent committing temporary artifacts.

🤖 Prompt for AI Agents
In @frontend/project-structure.txt at line 407, The project now contains a tmp
directory that must be ignored by git; open the .gitignore file and add an entry
for the tmp directory (e.g., tmp/ or /tmp) so temporary artifacts aren’t
committed, then if tmp was already tracked remove it from the index (git rm
--cached -r tmp) and commit the .gitignore change; reference the tmp directory
and the .gitignore file when making the update.

process.exit(1);
}

const timeoutMs = Number(process.env.JANITOR_TIMEOUT_MS ?? '25000');
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.

🛠️ Refactor suggestion | 🟠 Major

Validate timeout to prevent NaN from invalid input.

Number() returns NaN for invalid inputs, which would cause setTimeout to fire immediately. Add validation to ensure a positive timeout value.

🛡️ Proposed validation
-const timeoutMs = Number(process.env.JANITOR_TIMEOUT_MS ?? '25000');
+const timeoutMs = Number(process.env.JANITOR_TIMEOUT_MS ?? '25000');
+if (isNaN(timeoutMs) || timeoutMs <= 0) {
+  console.error('[janitor] Invalid JANITOR_TIMEOUT_MS');
+  process.exit(1);
+}
📝 Committable suggestion

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

Suggested change
const timeoutMs = Number(process.env.JANITOR_TIMEOUT_MS ?? '25000');
const timeoutMs = Number(process.env.JANITOR_TIMEOUT_MS ?? '25000');
if (isNaN(timeoutMs) || timeoutMs <= 0) {
console.error('[janitor] Invalid JANITOR_TIMEOUT_MS');
process.exit(1);
}
🤖 Prompt for AI Agents
In @frontend/scripts/shop-janitor-restock-stale.mjs at line 17, The timeout
parsing uses Number(process.env.JANITOR_TIMEOUT_MS) which can yield NaN and make
setTimeout fire immediately; validate and normalize it by parsing and checking
that the result is a finite positive integer: read
process.env.JANITOR_TIMEOUT_MS, parse to a number (e.g., parseInt/Number), check
isFinite(value) and value > 0, and if invalid fall back to the default (25000)
or a configured minimum; update the const timeoutMs declaration and any use of
timeoutMs (e.g., the setTimeout call) to use the validated/normalized value.

@@ -0,0 +1 @@
{"data":{"object":{"id":"ch_3Sn5WRAzdq4b0hR02JIDJhDx","payment_intent":"pi_3Sn5WRAzdq4b0hR027sBGFiq","amount":5900,"refunds":{"data":[{"amount":5900,"status":"succeeded","id":"re_3Sn5WRAzdq4b0hR02gbWWFvP","reason":null}]},"metadata":{},"amount_refunded":5900,"status":"succeeded"}},"id":"\u003cPUT_EVT_ID_FROM_DB_HERE\u003e","type":"charge.refunded"}
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

Remove BOM character and document placeholder replacement.

The file contains:

  1. A BOM (byte order mark) character at the start that can cause JSON parsing issues in some tools
  2. A placeholder <PUT_EVT_ID_FROM_DB_HERE> requiring manual replacement before use

Consider removing the BOM and adding a comment in the replay script or README documenting how to populate the event ID.

🧹 Suggested cleanup

Remove the BOM character and consider formatting the JSON for readability:

{
  "data": {
    "object": {
      "id": "ch_3Sn5WRAzdq4b0hR02JIDJhDx",
      "payment_intent": "pi_3Sn5WRAzdq4b0hR027sBGFiq",
      "amount": 5900,
      "refunds": {
        "data": [
          {
            "amount": 5900,
            "status": "succeeded",
            "id": "re_3Sn5WRAzdq4b0hR02gbWWFvP",
            "reason": null
          }
        ]
      },
      "metadata": {},
      "amount_refunded": 5900,
      "status": "succeeded"
    }
  },
  "id": "<PUT_EVT_ID_FROM_DB_HERE>",
  "type": "charge.refunded"
}

Add documentation explaining the manual step to replace <PUT_EVT_ID_FROM_DB_HERE> with an actual event ID from the database.

🤖 Prompt for AI Agents
In @frontend/tmp/replay-charge-refunded.json at line 1, The JSON file begins
with a BOM character that can break parsers and contains the placeholder string
"<PUT_EVT_ID_FROM_DB_HERE>" that must be populated before replay; remove the
leading BOM from frontend/tmp/replay-charge-refunded.json, reformat the JSON for
readability, and replace or document the need to replace the
"<PUT_EVT_ID_FROM_DB_HERE>" placeholder (add a short note in the replay script
or README explaining how to obtain and insert the actual event ID from the DB).

@@ -0,0 +1,23 @@
const fs = require('fs');
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

Remove UTF-8 BOM character.

Line 1 contains a UTF-8 BOM () before the const keyword. This can cause issues with Node.js execution or when the file is read by other tools.

Proposed fix
-const fs = require('fs');
+const fs = require('fs');
📝 Committable suggestion

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

Suggested change
const fs = require('fs');
const fs = require('fs');
🤖 Prompt for AI Agents
In @frontend/tmp/replay-stripe-webhook.js at line 1, The file has a UTF-8 BOM at
the start (before the "const fs = require('fs');" statement); remove the BOM so
the file begins with the "const" token, re-save the file as UTF-8 without BOM
(or run a tool/IDE option to strip BOM), and verify Node.js can load the file
and linters/CI no longer flag the leading invisible character.

Comment on lines +12 to +14
const signed = ${ts}.{"id":"evt_test"};
const sig = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
const header = =,v1=;
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 | 🔴 Critical

Critical syntax errors in template literal construction.

Lines 12 and 14 have broken template literal syntax that will cause parse errors (confirmed by static analysis). The Stripe signature header requires a specific format.

Proposed fix
-const signed = ${ts}.{"id":"evt_test"};
-const sig = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
-const header = 	=,v1=;
+const signed = `${ts}.${payload}`;
+const sig = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
+const header = `t=${ts},v1=${sig}`;

The signed payload for Stripe webhooks should be {timestamp}.{payload} and the header format should be t={timestamp},v1={signature}.

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Biome (2.1.2)

[error] 12-12: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 12-12: Expected a statement but instead found '.'.

Expected a statement here.

(parse)


[error] 12-12: Expected a semicolon or an implicit semicolon after a statement, but found none

An explicit or implicit semicolon is expected here...

...Which is required to end this statement

(parse)


[error] 14-14: Expected an expression, or an assignment but instead found '='.

Expected an expression, or an assignment here.

(parse)

🤖 Prompt for AI Agents
In @frontend/tmp/replay-stripe-webhook.js around lines 12 - 14, The template
literals for the signed payload and header are malformed: build the signed
payload as the timestamp, a dot, then the JSON string payload (e.g., use payload
= JSON.stringify({id: "evt_test"}) and signed = `${ts}.${payload}`), compute sig
with crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex'),
and set the header string to `t=${ts},v1=${sig}` (use the existing variables
signed, sig, secret, ts, and payload).

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: 1f3ec7f21b

ℹ️ 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 +12 to +14
const signed = ${ts}.{"id":"evt_test"};
const sig = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
const header = =,v1=;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fix invalid JS in replay script to avoid lint/parse failures

The new replay helper is syntactically invalid: lines 12–14 contain unquoted template placeholders (e.g., const signed = ${ts}...) and an incomplete header assignment. As written, this file cannot be executed and will fail parsing for any tool that scans JS sources (e.g., npm run lint uses ESLint over the repo and the config does not ignore tmp/). This will break lint/CI and makes the replay script unusable until the placeholders are converted into real string/template literals.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

@ViktorSvertoka ViktorSvertoka left a comment

Choose a reason for hiding this comment

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

Great job!

@ViktorSvertoka ViktorSvertoka merged commit aeca682 into develop Jan 8, 2026
9 checks passed
@ViktorSvertoka ViktorSvertoka deleted the lso/feat/shop branch January 8, 2026 20:49
liudmylasovetovs pushed a commit that referenced this pull request Jan 9, 2026
(SP: 3) [Backend/DevOps] Make shop janitor production-runnable; harden inventory idempotency, refunds, and concurrency invariants
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