Skip to content

(SP: 3) [Checkout] Money no-float + DB-canonical Stripe init + durable payment_attempts (bounded retries)#151

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

(SP: 3) [Checkout] Money no-float + DB-canonical Stripe init + durable payment_attempts (bounded retries)#151
ViktorSvertoka merged 6 commits into
developfrom
lso/feat/shop

Conversation

@liudmylasovetovs
Copy link
Copy Markdown
Collaborator

@liudmylasovetovs liudmylasovetovs commented Jan 17, 2026

Description

This PR hardens the checkout/payment critical path by eliminating float-based money conversions, ensuring Stripe PaymentIntent creation uses DB-canonical totals/currency, and introducing a durable payment_attempts layer with strict uniqueness and bounded retries. The goal is to remove rounding risk, prevent infinite payment init loops, and provide an auditable retry trail.


Related Issue

Issue: #<issue_number>


Changes

  • Money: removed float conversions for DECIMAL/string → minor units; replaced Number(...) * 100 / Math.round(...) with string-safe parsing in the critical path.
  • Checkout (Stripe): PaymentIntent creation now reads totalAmountMinor + currency from the persisted order row immediately before the PSP call (DB is the canonical source).
  • Payment attempts: added durable payment_attempts table + service logic to enforce unique constraints, one active attempt per (order, provider), bounded retries, and an auditable attempt history.

Database Changes (if applicable)

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

How Has This Been Tested?

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

Commands / checks

  • npx vitest run (31/31 files, 78/78 tests passing)
  • Verified payment_attempts table exists and indexes/unique constraints are present in Neon (including partial unique for status='active').

Screenshots (if applicable)

N/A (no UI changes)


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

    • Durable, auditable payment-attempt tracking for Stripe with retry and recovery flows.
    • Cart merges duplicate items and sanitizes selected variants.
  • Improvements

    • Locale-aware checkout and payment redirects/links.
    • Safer money handling with stricter parsing, validation, and overflow protection.
    • Webhook processing now records final payment outcomes for clearer recovery.
  • Tests

    • New tests for payment attempts, checkout error paths, and cart variant sanitization.

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

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 17, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit 9d89d65
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/696af5b65daf3f0009116596
😎 Deploy Preview https://deploy-preview-151--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 17, 2026

📝 Walkthrough

Walkthrough

Introduces durable, auditable Stripe payment-attempt tracking (DB table, migration, service layer) and replaces direct PaymentIntent lifecycle with ensureStripePaymentIntentForOrder across checkout API and pages. Adds webhook finalization calls, locale-aware checkout routing and encoded orderId in frontend, variant sanitization/aggregation in cart rehydrate, and stricter money parsing/validation.

Changes

Cohort / File(s) Summary
Database Schema & Migrations
frontend/db/schema/shop.ts, frontend/drizzle/0001_add_payment_attempts.sql, frontend/drizzle/meta/0001_snapshot.json, frontend/drizzle/meta/_journal.json
Add payment_attempts table, constraints, indexes, filtered unique active-attempt index, migration SQL and snapshot/journal entries.
Payment Attempt Services
frontend/lib/services/orders/payment-attempts.ts, frontend/lib/services/orders/payment-intent.ts
New durable/payment-attempt workflow: ensureStripePaymentIntentForOrder, attempt creation/backfill, exhaustion error PaymentAttemptsExhaustedError, markStripeAttemptFinal, and readStripePaymentIntentParams for validated PI params.
Checkout API & Webhooks
frontend/app/api/shop/checkout/route.ts, frontend/app/api/shop/webhooks/stripe/route.ts
Checkout route switched to ensureStripe flow; handles PAYMENT_ATTEMPTS_EXHAUSTED, restock-on-exhaustion. Webhook handler records final attempt status via markStripeAttemptFinal in success/failed/canceled branches.
Frontend Checkout Pages & Components
frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx, frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
Add buildShopBase(locale) and memoized shopBase; encode orderId via encodeURIComponent for success/error/cart links and return_url; payment page uses ensureStripePaymentIntentForOrder.
PSP Stripe Helpers
frontend/lib/psp/stripe.ts
Introduce StripePspError (withCause), expand retrievePaymentIntent return to include PI status alongside clientSecret/paymentIntentId.
Cart Rehydration & Provider
frontend/lib/services/products/cart/rehydrate.ts, frontend/components/shop/cart-provider.tsx
Aggregate duplicate client lines by sanitized/normalized variant, merge quantities, sanitize variants against product sizes/colors, preserve sanitized values; minor formatting change in cart-provider.
Money Utilities & Validation
frontend/lib/shop/money.ts, frontend/lib/validation/shop.ts
Strict major→minor conversion (parseMajorToMinor) with HALF_UP rounding, safe-integer invariants, improved validators; product admin price validation tightened (min(1) on patched prices, duplicate-currency refinement).
Tests & Test Helpers
frontend/lib/tests/*, frontend/project-structure.txt
Add tests for cart rehydrate variant sanitization and admin price policy; add mocks for PI param reads and ensureStripePaymentIntentForOrder rejections; add project-structure entries and test helpers.
Product Price Policy
frontend/lib/services/products/prices.ts, frontend/lib/services/products/mutations/update.ts
Add assertMergedPricesPolicy and enforce merged-state price policy during product updates; new tests and error handling (PriceConfigError).
Errors & Refunds
frontend/lib/services/errors.ts, frontend/lib/services/orders/refund.ts
Error classes updated (readonly fields, code options); refund error construction simplified to use constructor opts.

Sequence Diagram

sequenceDiagram
    actor Client
    participant StripeClient as StripePaymentClient
    participant PaymentPage as Payment Page
    participant CheckoutAPI as Checkout API
    participant AttemptSvc as PaymentAttempt Service
    participant StripeAPI as Stripe API
    participant Webhook as Stripe Webhook Handler
    participant DB as Database

    Client->>StripeClient: start payment (locale, orderId)
    StripeClient->>PaymentPage: navigate with encoded orderId & shopBase

    PaymentPage->>CheckoutAPI: request payment intent (orderId)
    CheckoutAPI->>AttemptSvc: ensureStripePaymentIntentForOrder(orderId)
    AttemptSvc->>DB: getActiveAttempt(orderId,'stripe')
    DB-->>AttemptSvc: attempt or null

    alt existing active attempt
        AttemptSvc->>StripeAPI: retrieve payment intent
        StripeAPI-->>AttemptSvc: clientSecret, status, piId
    else create/backfill attempt
        AttemptSvc->>DB: createActiveAttempt(...)
        DB-->>AttemptSvc: attempt record
        AttemptSvc->>StripeAPI: create payment intent
        StripeAPI-->>AttemptSvc: clientSecret, piId
        AttemptSvc->>DB: link attempt -> piId
    end

    AttemptSvc-->>CheckoutAPI: {clientSecret, paymentIntentId, attemptId}
    CheckoutAPI-->>PaymentPage: clientSecret
    PaymentPage->>StripeClient: render form with clientSecret
    Client->>StripeAPI: confirm payment

    StripeAPI->>Webhook: emit payment event
    Webhook->>AttemptSvc: markStripeAttemptFinal(piId, status, error?)
    AttemptSvc->>DB: update attempt status & finalizedAt
    DB-->>AttemptSvc: ok
    AttemptSvc-->>Webhook: ack
    Webhook-->>StripeAPI: 200
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • AM1007
  • ViktorSvertoka

🐰 Hops with glee, paws dancing
Payment attempts tracked with care,
Each stripe request logged with flair—
No more lost intents in the air!
Locale-aware paths and merged-cart cheer ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.37% 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: money handling refactor (no-float), DB-canonical Stripe initialization, and bounded retry logic via payment_attempts table.

✏️ 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: 1

Caution

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

⚠️ Outside diff range comments (1)
frontend/lib/validation/shop.ts (1)

260-305: Prices schema validation conflicts with PATCH semantics.

The productAdminUpdateSchema requires USD when any prices are provided (.min(1).optional() with USD enforcement in superRefine). However, updateProduct handles prices via UPSERT—it merges with existing prices rather than replacing them. This means partial updates (e.g., updating only EUR) will fail validation even though the handler would process them correctly.

Either merge existing prices into the payload before validation, move the USD + non-empty check to the service layer, or clarify if prices must always be replace-all (breaking PATCH semantics).

🤖 Fix all issues with AI agents
In `@frontend/lib/services/orders/payment-attempts.ts`:
- Around line 108-159: The query in upsertBackfillAttemptForExistingPI currently
returns a payment attempt by provider+providerPaymentIntentId but may belong to
a different order; before returning found[0], verify found[0].orderId ===
orderId and only reuse it when it matches the passed orderId, otherwise ignore
the found row and proceed with computing next attempt/insert (and keep the
existing conflict handling via getActiveAttempt). Locate the check after the
db.select(...) result and add this equality guard referencing paymentAttempts
and upsertBackfillAttemptForExistingPI so you don't prematurely return a
cross-order attempt.
🧹 Nitpick comments (4)
frontend/lib/services/orders/payment-intent.ts (1)

93-129: Verify payable‑state gating and avoid any error mutation.
readStripePaymentIntentParams only checks provider + totalAmountMinor; please confirm callers block paid/failed orders (or add a status guard here). Also, the diagnostics can be set via the OrderStateInvalidError options to avoid (err as any) mutation.

♻️ Typed diagnostics without `any`
-    const err = new OrderStateInvalidError(
-      'Invalid order total for Stripe payment intent creation.'
-    );
-
-    // attach diagnostics for API handler (keeps existing errorResponse shape)
-    (err as any).orderId = orderId;
-    (err as any).field = 'totalAmountMinor';
-    (err as any).rawValue = amountMinor;
-    (err as any).details = {
-      reason: 'Invalid order total for Stripe payment intent creation.',
-    };
+    const err = new OrderStateInvalidError(
+      'Invalid order total for Stripe payment intent creation.',
+      {
+        orderId,
+        field: 'totalAmountMinor',
+        rawValue: amountMinor,
+        details: {
+          reason: 'Invalid order total for Stripe payment intent creation.',
+        },
+      }
+    );
frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx (1)

168-212: Remove commented-out dead code.

This large block of commented code adds noise and reduces maintainability. Since the new ensureStripePaymentIntentForOrder flow is implemented below, this old logic should be deleted rather than kept as comments.

🧹 Remove dead code
-  // if (
-  //   paymentsEnabled &&
-  //   publishableKey &&
-  //   (!clientSecret || !clientSecret.trim())
-  // ) {
-  //   const existingPi = order.paymentIntentId?.trim() ?? '';
-  //   let phase:
-  //     | 'retrievePaymentIntent'
-  //     | 'createPaymentIntent'
-  //     | 'setOrderPaymentIntent'
-  //     | 'unknown' = 'unknown';
-
-  //   try {
-  //     if (existingPi) {
-  //       phase = 'retrievePaymentIntent';
-  //       const retrieved = await retrievePaymentIntent(existingPi);
-  //       clientSecret = retrieved.clientSecret;
-  //     } else {
-  //       phase = 'createPaymentIntent';
-  //       const snapshot = await readStripePaymentIntentParams(order.id);
-  //       const created = await createPaymentIntent({
-  //         amount: snapshot.amountMinor,
-  //         currency: snapshot.currency,
-  //         orderId: order.id,
-  //         idempotencyKey: `pi:${order.id}`,
-  //       });
-
-  //       phase = 'setOrderPaymentIntent';
-  //       await setOrderPaymentIntent({
-  //         orderId: order.id,
-  //         paymentIntentId: created.paymentIntentId,
-  //       });
-
-  //       clientSecret = created.clientSecret;
-  //     }
-  //   } catch (error) {
-  //     logError('payment_page_failed', error, {
-  //       orderId: order.id,
-  //       existingPi,
-  //       phase,
-  //     });
-
-  //     // Leave clientSecret empty -> UI shows "Payment cannot be initialized"
-  //   }
-  // }
-
frontend/app/api/shop/checkout/route.ts (1)

295-303: Consider inlining the ensureStripePI helper.

This helper is a thin wrapper that doesn't add meaningful abstraction—it just forwards parameters to ensureStripePaymentIntentForOrder. Consider inlining it at the two call sites to reduce indirection.

♻️ Inline the helper
-    async function ensureStripePI(
-      orderId: string,
-      existingPaymentIntentId: string | null
-    ) {
-      return await ensureStripePaymentIntentForOrder({
-        orderId,
-        existingPaymentIntentId,
-      });
-    }

Then replace calls like:

-          const ensured = await ensureStripePI(
-            order.id,
-            order.paymentIntentId ?? null
-          );
+          const ensured = await ensureStripePaymentIntentForOrder({
+            orderId: order.id,
+            existingPaymentIntentId: order.paymentIntentId ?? null,
+          });
frontend/drizzle/0001_add_payment_attempts.sql (1)

4-16: Add a provider CHECK/enum to prevent invalid values.

provider is free-text in the DB while TS restricts to 'stripe'. A DB-level constraint avoids accidental bad data from manual SQL or future code paths.

♻️ Suggested migration tweak
 CREATE TABLE "payment_attempts" (
 	"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
 	"order_id" uuid NOT NULL,
 	"provider" text NOT NULL,
 	"status" text DEFAULT 'active' NOT NULL,
 	"attempt_number" integer NOT NULL,
 	"idempotency_key" text NOT NULL,
 	"provider_payment_intent_id" text,
 	"last_error_code" text,
 	"last_error_message" text,
 	"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
 	"created_at" timestamp with time zone DEFAULT now() NOT NULL,
 	"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
 	"finalized_at" timestamp with time zone,
+	CONSTRAINT "payment_attempts_provider_check" CHECK ("payment_attempts"."provider" in ('stripe')),
 	CONSTRAINT "payment_attempts_status_check" CHECK ("payment_attempts"."status" in ('active','succeeded','failed','canceled')),
 	CONSTRAINT "payment_attempts_attempt_number_check" CHECK ("payment_attempts"."attempt_number" >= 1)
 );

Comment thread frontend/lib/services/orders/payment-attempts.ts
…oss-order PI guard, payable-state gating; cleanup dead code; add provider CHECK; inline ensureStripePI
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