Skip to content

Fix gift card amount not deducted from Stripe payment (V-3386)#135

Open
Cichorek wants to merge 4 commits intomainfrom
feature/v-3386-bug-gift-card-amount-not-deducted-from-payment-when-applied
Open

Fix gift card amount not deducted from Stripe payment (V-3386)#135
Cichorek wants to merge 4 commits intomainfrom
feature/v-3386-bug-gift-card-amount-not-deducted-from-payment-when-applied

Conversation

@Cichorek
Copy link
Copy Markdown
Contributor

@Cichorek Cichorek commented Apr 23, 2026

Summary

  • Fix double-charge where applying a gift card on checkout still charged the full order total via Stripe (order ended up as "Credit Owed"). PaymentSection now watches cart.amount_due ?? cart.total (gift cards reduce amount_due, not total).
  • Added updateCheckoutPaymentSession server action wrapping carts.paymentSessions.update. Instead of POSTing a new payment session on every cart change and leaving stale Incomplete Stripe PaymentIntents behind, we create once and PATCH the existing session with an explicit amount.
  • Covered with unit tests in payment.test.ts (4 new cases: empty params, amount-only, amount + stripe payment method id, error path).

Why

Customers were financially overcharged: the Stripe PaymentIntent was created/locked at the pre-gift-card total and not resized when the gift card was applied. Spree recorded both the full Stripe charge and the gift-card store-credit payment → Credit Owed.

Test plan

  • npx tsc --noEmit
  • npm run check (Biome)
  • npx vitest run src/lib/data/__tests__/payment.test.ts (23/23 passing)
  • Manual on app.vendo.dev: add $53.99 item → complete address → payment step → apply gift card (e.g. TEST9, $5). In DevTools Network: confirm PATCH /carts/:id/payment_sessions/:id fires with { "amount": "48.99" } in the request body. In Stripe dashboard: the PaymentIntent amount should reflect 48.99 after the PATCH.
  • Manual: click Pay Now → Stripe charges $48.99 (not $53.99). Exactly one Succeeded PaymentIntent, zero Incomplete leftovers. Spree admin shows "Paid", not "Credit Owed".
  • Regressions: discount code only (total changes, PATCH fires), shipping rate change, gift card removal, saved card switch mid-flow.
  • Edge case: gift card ≥ total (amount_due becomes "0.00"). If backend 422s on zero-amount PATCH, surface as follow-up ticket — this PR does not change the $0 flow.

Summary by CodeRabbit

  • Bug Fixes
    • Improved payment session accuracy during checkout when gift cards are applied to orders.
    • Enhanced payment tracking logic to correctly handle gift card scenarios.

When a gift card was applied on the payment step, the Spree backend
registered a store-credit payment but the storefront kept the original
Stripe PaymentIntent (sized to `cart.total`). Customer ended up charged
the full order total while the gift card was also processed, resulting
in overcharge / "Credit Owed" orders.

PaymentSection now watches `cart.amount_due ?? cart.total` and uses
`paymentSessions.update` (with explicit `amount`) to resize the same
Stripe PaymentIntent on cart changes, instead of creating a new session
every time and leaving stale `Incomplete` PaymentIntents behind.
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 23, 2026

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

Project Deployment Actions Updated (UTC)
storefront Ready Ready Preview, Comment Apr 28, 2026 9:14am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Walkthrough

This PR updates PaymentSection.tsx to track a computed paymentTarget value—derived as cart.amount_due ?? cart.total—instead of directly using cart.total when determining payment session recreation. References to lastTotalRef are renamed to lastPaymentTargetRef, and relevant dependency arrays are adjusted accordingly.

Changes

Cohort / File(s) Summary
Payment Session Tracking
src/components/checkout/PaymentSection.tsx
Replaces cart.total with paymentTarget (computed as cart.amount_due ?? cart.total) for session re-creation detection; renames lastTotalRef to lastPaymentTargetRef; updates useEffect logic and dependency arrays; adds clarifying comments on gift-card behavior.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

Suggested reviewers

  • damianlegawiec

Poem

🐰 A gift card here, an amount due there,
No more tracking totals in thin air!
The payment target shines bright and true,
When amount_due knows just what to do,
Sessions recreate with perfect care! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main fix: addressing gift card amounts not being deducted from Stripe payments, matching the core change in the PR which replaces cart.total with cart.amount_due ?? cart.total to properly account for gift cards.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/v-3386-bug-gift-card-amount-not-deducted-from-payment-when-applied

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

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/lib/data/payment.ts (1)

33-57: cartId parameter is ignored — requireCartId() is used instead.

Same pattern as createCheckoutPaymentSession above, so this is a pre-existing convention rather than a new defect. Worth noting that if a caller ever passes a cart id that differs from the cookie-resolved one, the call will silently target the cookie cart. Not blocking given the existing pattern; consider either dropping the parameter or honoring it in a follow-up.

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

In `@src/lib/data/payment.ts` around lines 33 - 57, The exported function
updateCheckoutPaymentSession currently ignores the cartId parameter and always
calls requireCartId(); change it to honor the passed cartId by using the
provided cartId when non-empty and falling back to requireCartId() otherwise
(e.g., const id = cartId || await requireCartId()); update references inside
updateCheckoutPaymentSession (notably the call to
getClient().carts.paymentSessions.update) to use that id; alternatively, if you
prefer the existing cookie-only behavior, remove the cartId parameter from
updateCheckoutPaymentSession and all callers—choose one approach and make the
signature, implementation, and callers consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/checkout/PaymentSection.tsx`:
- Around line 150-160: The PATCH payload built when switching cards omits
external_data if cardId is null, which can leave a prior
stripe_payment_method_id attached; modify the updateCheckoutPaymentSession call
site (the branch using existingSessionId) so that when cardId is null you
include external_data: { stripe_payment_method_id: null } in the body (i.e.,
always send external_data, setting stripe_payment_method_id to cardId or to
null), referencing the existingSessionId, cardId, updateCheckoutPaymentSession
and createCheckoutPaymentSession usage to locate the code to change.

---

Nitpick comments:
In `@src/lib/data/payment.ts`:
- Around line 33-57: The exported function updateCheckoutPaymentSession
currently ignores the cartId parameter and always calls requireCartId(); change
it to honor the passed cartId by using the provided cartId when non-empty and
falling back to requireCartId() otherwise (e.g., const id = cartId || await
requireCartId()); update references inside updateCheckoutPaymentSession (notably
the call to getClient().carts.paymentSessions.update) to use that id;
alternatively, if you prefer the existing cookie-only behavior, remove the
cartId parameter from updateCheckoutPaymentSession and all callers—choose one
approach and make the signature, implementation, and callers consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c8e1de4a-1a5a-4609-9711-a86683b98e0c

📥 Commits

Reviewing files that changed from the base of the PR and between 7ebaaf3 and a0f5cae.

📒 Files selected for processing (3)
  • src/components/checkout/PaymentSection.tsx
  • src/lib/data/__tests__/payment.test.ts
  • src/lib/data/payment.ts

Comment thread src/components/checkout/PaymentSection.tsx Outdated
Previously, switching from a saved card back to "add new payment method"
left the prior stripe_payment_method_id attached to the Spree payment
session. Stripe Elements overrides it at confirm time so no mischarge
occurred, but session metadata went stale.

Now updateCheckoutPaymentSession accepts `stripePaymentMethodId: string | null`,
and PaymentSection always syncs the current cardId (including null) on
PATCH, explicitly clearing the attached payment method when needed.
…cted-from-payment-when-applied

Resolved conflicts in src/lib/data/payment.ts and src/components/checkout/PaymentSection.tsx
by taking main's payment-provider abstraction (Stripe/Adyen/PayPal/manual) as the base and
re-applying the gift-card fix on top:

- updateCheckoutPaymentSession adapted to main's generic externalData shape
- PaymentSection watches `cart.amount_due ?? cart.total` instead of `cart.total`,
  so the session is re-synced when a gift card reduces the amount due (fixes the
  bug for all three gateways, not just Stripe)
- Stripe-only PATCH path inside createSession: when a session already exists,
  PATCH amount + stripe_payment_method_id on the same Spree session — keeps the
  same Stripe PaymentIntent and client_secret, so the form does not remount
  on every gift-card / shipping change
- Adyen/PayPal still recreate the session on amount change (their drop-in /
  PayPal order is bound to the initial amount)
- handleMethodSelect now also clears paymentSessionIdRef when switching to a
  direct/manual method
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

Inline comments:
In `@src/components/checkout/PaymentSection.tsx`:
- Around line 160-162: The current canPatch logic can PATCH any existing session
id regardless of which gateway created it; change it to only allow PATCH when
the existing session was created for the same payment method/gateway as the
currently selected one: store the session's originating payment method/gateway
in a ref alongside paymentSessionIdRef (e.g., paymentSessionMethodRef or
paymentGatewayRef) when createSession receives a new session, update all
canPatch checks to verify both paymentSessionIdRef and that
paymentSessionMethodRef === currentPaymentMethodId/currentGateway, and only call
updateCheckoutPaymentSession when that equality holds; otherwise treat it as no
valid session and call createSession to make a new session.
- Around line 208-214: The PATCH failure branch currently leaves the old Stripe
session live; immediately clear the stale session when canPatch is false by
calling setSessionExternalData(null) and setPaymentSessionId(null) and nulling
paymentSessionIdRef.current and gatewayHandleRef.current so the previous
client_secret cannot be re-confirmed; apply this same clearing logic in both
places where the PATCH path is handled (the block using canPatch around
setSessionExternalData/setPaymentSessionId and the other analogous block around
lines 277-283) to ensure no outdated payment session or gateway handle remains
after a failed resize.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a6ec706c-f09c-43c0-a9ec-f8adc8f7f26c

📥 Commits

Reviewing files that changed from the base of the PR and between a0f5cae and 3d4d538.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (3)
  • src/components/checkout/PaymentSection.tsx
  • src/lib/data/__tests__/payment.test.ts
  • src/lib/data/payment.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/lib/data/payment.ts
  • src/lib/data/tests/payment.test.ts

Comment thread src/components/checkout/PaymentSection.tsx Outdated
Comment thread src/components/checkout/PaymentSection.tsx Outdated
Drops the Stripe-only PATCH path (and the helper updateCheckoutPaymentSession)
introduced earlier in this branch. PaymentSection now always calls
createCheckoutPaymentSession on every relevant change — gift card / shipping /
saved-card switch / payment-method switch — so the gateway charges exactly
`cart.amount_due ?? cart.total`.

Reason: per project guideline, mutating an existing PaymentSession in place
risks the user being charged the wrong amount (and gateway-specific shortcuts
drift from each other). Uniform recreate is safer than the pi_… reuse it
replaces.

Kept: `paymentTarget = cart.amount_due ?? cart.total` watcher — the actual
gift-card fix, applied uniformly to Stripe / Adyen / PayPal.
Copy link
Copy Markdown

@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.

Caution

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

⚠️ Outside diff range comments (1)
src/components/checkout/PaymentSection.tsx (1)

372-377: ⚠️ Potential issue | 🟠 Major

Prevent duplicate session creation on method switch by syncing lastPaymentTargetRef in the non-init path.

When switching back to a session-based method (Line 377), lastPaymentTargetRef can still hold an old value. Then the effect at Line 305 may immediately fire and call createSession again for the same target, causing redundant backend session creation.

🔧 Minimal fix
       } else {
+        lastPaymentTargetRef.current = paymentTarget;
         createSession(selectedCardRef.current, newMethod);
       }

Based on learnings: "Ensure components in checkout flows guard auto-save or backend calls with a processing state ... to prevent redundant or erroneous backend calls ...".

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

In `@src/components/checkout/PaymentSection.tsx` around lines 372 - 377, The else
branch that calls createSession(selectedCardRef.current, newMethod) should first
sync lastPaymentTargetRef to avoid triggering the effect that re-creates the
same session; update the non-init path to set lastPaymentTargetRef.current =
selectedCardRef.current (or the equivalent payment target) before calling
createSession, referencing lastPaymentTargetRef, selectedCardRef, and
createSession so the immediate effect check at Line ~305 sees the current target
and does not create a duplicate session.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/components/checkout/PaymentSection.tsx`:
- Around line 372-377: The else branch that calls
createSession(selectedCardRef.current, newMethod) should first sync
lastPaymentTargetRef to avoid triggering the effect that re-creates the same
session; update the non-init path to set lastPaymentTargetRef.current =
selectedCardRef.current (or the equivalent payment target) before calling
createSession, referencing lastPaymentTargetRef, selectedCardRef, and
createSession so the immediate effect check at Line ~305 sees the current target
and does not create a duplicate session.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aade4613-2858-4d64-85bd-046a96e0a74d

📥 Commits

Reviewing files that changed from the base of the PR and between 3d4d538 and 74e306e.

📒 Files selected for processing (1)
  • src/components/checkout/PaymentSection.tsx

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