Skip to content

fix(billing): prevent duplicate checkout subscriptions - #2335

Merged
SirTenzin merged 6 commits into
devfrom
fix/dual-checkout-race
Jul 27, 2026
Merged

fix(billing): prevent duplicate checkout subscriptions#2335
SirTenzin merged 6 commits into
devfrom
fix/dual-checkout-race

Conversation

@SirTenzin

@SirTenzin SirTenzin commented Jul 21, 2026

Copy link
Copy Markdown
Member

Problem

A Runable customer got two active subscriptions and was double-charged for the same plan. The production sequence (from Axiom + Stripe):

  1. billing.attach(pro) → Stripe Checkout session A created.
  2. 2m13s later, the same attach again → the Redis checkout reservation had already expired (2-minute TTL vs Stripe Checkout's ~24h lifetime), so a second Checkout session B was created.
  3. The customer paid session B. Stripe created subscription added events to frontend + finished invoice below threshold #1 and saved the payment method.
  4. While the checkout.session.completed webhook was still materializing the plan in Autumn (~11s), a third billing.attach arrived. It read stale customer state (still on the free plan), saw the newly saved payment method, chose direct billing, and created subscription added org creation / switching #2.

Result: 2 active customer products, 2 Stripe subscriptions, 2 paid invoices.

Neither existing guard covers this overlap: the HTTP billing lock only serializes API requests (the webhook doesn't use it), and the checkout metadata claim only dedupes redelivery of the same session.

Fix

No new locks or queues — the two existing Redis keys are made correct:

  • Checkout reservation (checkout_lock:*) lives as long as the session is payable. TTL now derives from Stripe's expires_at instead of a fixed 2 minutes.
  • Attach snapshots the reservation at request entry, before loading customer state. The webhook clearing Redis mid-request can no longer hide the pending checkout from an in-flight attach. A null snapshot falls back to a check-time read on the canonical customer key, so internal-id callers are never worse off than before.
  • Same-params attach reuses the existing Checkout URL even when a payment method now exists (previously reuse only applied in stripe_checkout mode — the exact gap the third attach fell through). Conflicting non-checkout attaches still get 423.
  • Reservation cleanup is session-owned (atomic Lua compare-and-delete), so a stale session completing late can't clear a newer session's reservation.
  • Checkout completion takes the same per-customer billing lock as billing.attach (withBillingLock, bounded retry), so webhook materialization and API attaches serialize instead of interleaving. The webhook holds the lock under both customer identifiers (public + internal), since route locks key on whichever one the API caller passed.
  • All billing locks are ownership-token'd. acquireLock/clearLock gained an optional token (Lua compare-and-delete on release), wired through the route handler lock, withLock, and withBillingLock — a holder that outlives its 120s lease can no longer delete a successor's lock. (Addresses the greptile/Codex P1s from the previous PR fix/dual checkout race #2334.)

Test

server/tests/_temp/runable-dual-checkout-race.test.ts reproduces the exact production sequence (expired reservation → second session → completion racing a third attach). Red on the old code (2/2/2), green with this fix: the third attach returns the pending session's URL and exactly one product / subscription / invoice exists.

Supersedes #2334 (closed to re-run reviews on the review-fix commit).


Summary by cubic

Prevents duplicate Stripe subscriptions by fixing races between billing.attach and Stripe webhooks. Checkout reservations last for the session lifetime and webhook materialization now serializes with API attaches via a background-only withBillingLock.

  • Bug Fixes
    • Keep checkout reservations until Stripe expires_at (default 1h) in primary Redis; reuse same-params Checkout URL; session-owned compare-and-delete.
    • Snapshot the reservation at attach/createSchedule entry with a canonical-key fallback to avoid mid-request clearing.
    • On param mismatch or non-checkout re-attach, expire unpaid sessions at Stripe and proceed; if the session already paid/completes, return 423.
    • Serialize billing.attach, checkout.session.completed, and setup-payment webhooks with background-only withBillingLock (ordered multi-key, bounded wait, one-shot lease refresh); HTTP routes do not wait.
    • Tokenize all Redis locks with owned release and a one-shot lease refresh; route handler uses tokens; retry AutoTopUp jobs on lock conflicts.
    • Add the dual-checkout-completion-race test and update checkout lock tests.

Written for commit df352a7. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR prevents duplicate checkout subscriptions by coordinating pending sessions and customer billing work. The main changes are:

  • Bug fixes: Keeps checkout reservations until their Stripe sessions expire.
  • Bug fixes: Reuses matching sessions and safely replaces conflicting unpaid sessions.
  • Bug fixes: Serializes API attaches and webhook materialization with token-owned Redis locks.
  • Improvements: Retries auto-top-up jobs after billing-lock conflicts.

Confidence Score: 5/5

No additional blocking issue qualifies for this review round.

  • The latest changes add owned lock release, longer checkout reservations, and webhook serialization.
  • No distinct production failure remains to report within this review's scope.

Important Files Changed

Filename Overview
server/src/internal/billing/v2/utils/billingLock/withBillingLock.ts Adds ordered, token-owned customer billing locks with bounded waiting and lease refresh.
server/src/internal/billing/v2/actions/locks/checkoutSessionLock/checkoutSessionLock.ts Extends checkout reservations to session expiry and makes cleanup session-owned.
server/src/internal/billing/v2/actions/locks/checkoutSessionLock/checkCheckoutSessionLock.ts Reuses matching sessions and expires conflicting unpaid sessions before billing proceeds.
server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts Serializes checkout materialization under the customer's public and internal lock keys.

Reviews (2): Last reviewed commit: "Merge branch 'dev' into fix/dual-checkou..." | Re-trigger Greptile

Context used (4)

  • Context used - CLAUDE.md (source)
  • Context used - server/CLAUDE.md (source)
  • Context used - AGENTS.md (source)
  • Context used - When generating the key changes section of the sum... (source)

@SirTenzin
SirTenzin marked this pull request as ready for review July 21, 2026 15:44
Comment thread server/src/internal/billing/v2/utils/billingLock/withBillingLock.ts Outdated
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

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

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
checkout Ignored Ignored Jul 27, 2026 11:36am
landing-page Ignored Ignored Jul 27, 2026 11:36am

Request Review

@vercel
vercel Bot temporarily deployed to Preview – autumn-vite July 21, 2026 15:52 Inactive
@capy-ai

capy-ai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/src/external/redis/redisUtils.ts">

<violation number="1" location="server/src/external/redis/redisUtils.ts:33">
P1: Long-running billing materialization can still outlive its lock TTL and allow a concurrent holder, because `renewLock` is added but never invoked. Wire periodic owner-checked renewal into the lock-running path and fail/stop work when renewal returns false.

(Based on your team's feedback about renewing locks while work is running.) [FEEDBACK_USED]</violation>
</file>

Tip: cubic used a learning from your PR history. Let your coding agent read cubic learnings directly with the cubic MCP.

Re-trigger cubic

Comment thread server/src/external/redis/redisUtils.ts Outdated
Comment thread server/src/internal/billing/v2/utils/billingLock/withBillingLock.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found and verified against the latest diff

Confidence score: 4/5

  • In server/tests/_temp/runable-dual-checkout-race.test.ts, the paid-invoice check uses invoice.total === 1, which is likely mismatched with Stripe’s cent-based totals ($1.00 should be 100) and can cause the test to assert the wrong behavior or fail for the wrong reason; that weakens confidence in checkout race-condition coverage if merged as-is — update the assertion to the correct units (or normalize currency values) before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/tests/_temp/runable-dual-checkout-race.test.ts">

<violation number="1" location="server/tests/_temp/runable-dual-checkout-race.test.ts:159">
P2: The `invoice.total === 1` filter in the paid-invoice assertion is brittle and likely incorrect. The pro plan is $25/mo with a $24 coupon, so the actual invoice total is $1.00 = 100¢. If `total` is in Stripe cents, 100 !== 1 and the filter matches zero invoices, rendering the assertion meaningless. If `total` is in dollars, `=== 1` still silently fails on any proration, rounding, or fee that shifts the total. Replace with a range or meaningful threshold (e.g. `total > 0`) or verify the actual field semantics before merging.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread server/tests/_temp/runable-dual-checkout-race.test.ts Outdated
@vercel
vercel Bot temporarily deployed to Preview – autumn-vite July 21, 2026 16:30 Inactive
@vercel
vercel Bot temporarily deployed to Preview – autumn-vite July 21, 2026 16:35 Inactive

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

3 issues found across 14 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/src/internal/billing/v2/utils/billingLock/withBillingLock.ts">

<violation number="1" location="server/src/internal/billing/v2/utils/billingLock/withBillingLock.ts:51">
P1: Checkout materialization can overlap another billing operation after five minutes because this is the only lease refresh and `fn` is unbounded. Keep renewing owner-checked leases through `fn` (and abort/retry if ownership is lost) so the billing lock remains held until materialization finishes.

(Based on your team's feedback about renewing long-lived locks.) [FEEDBACK_USED]</violation>
</file>

<file name="server/src/internal/billing/v2/actions/locks/checkoutSessionLock/checkCheckoutSessionLock.ts">

<violation number="1" location="server/src/internal/billing/v2/actions/locks/checkoutSessionLock/checkCheckoutSessionLock.ts:54">
P1: A conflicting non-checkout attach now cancels the customer's open Checkout session and proceeds with billing instead of returning 423. Preserve the non-checkout guard here so a direct attach cannot silently invalidate an in-progress checkout flow.</violation>
</file>

<file name="server/src/queue/processMessage.ts">

<violation number="1" location="server/src/queue/processMessage.ts:65">
P2: A retried lock collision still emits an `auto_topup_failed` webhook before SQS retries it, so customers receive a failure notification even when the later delivery succeeds. Treat `LockAlreadyExists` as retryable in `autoTopup` before sending failure webhooks (and preserve pending state until retry handling completes).</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// Earlier keys' leases burned down while waiting for later ones — re-arm
// once so every lease covers the full critical section.
for (const lockKey of heldKeys) {
await refreshLockLease({ lockKey, token, ttlMs: BILLING_LOCK_TTL_MS });

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.

P1: Checkout materialization can overlap another billing operation after five minutes because this is the only lease refresh and fn is unbounded. Keep renewing owner-checked leases through fn (and abort/retry if ownership is lost) so the billing lock remains held until materialization finishes.

(Based on your team's feedback about renewing long-lived locks.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/utils/billingLock/withBillingLock.ts, line 51:

<comment>Checkout materialization can overlap another billing operation after five minutes because this is the only lease refresh and `fn` is unbounded. Keep renewing owner-checked leases through `fn` (and abort/retry if ownership is lost) so the billing lock remains held until materialization finishes.

(Based on your team's feedback about renewing long-lived locks.) </comment>

<file context>
@@ -38,20 +45,14 @@ export const withBillingLock = async <T>({
+		// Earlier keys' leases burned down while waiting for later ones — re-arm
+		// once so every lease covers the full critical section.
+		for (const lockKey of heldKeys) {
+			await refreshLockLease({ lockKey, token, ttlMs: BILLING_LOCK_TTL_MS });
+		}
 
</file context>

}

if (ctx.env === AppEnv.Sandbox) {
const cleared = await checkoutSessionLock.expireAndClearIfOwned({

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.

P1: A conflicting non-checkout attach now cancels the customer's open Checkout session and proceeds with billing instead of returning 423. Preserve the non-checkout guard here so a direct attach cannot silently invalidate an in-progress checkout flow.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/actions/locks/checkoutSessionLock/checkCheckoutSessionLock.ts, line 54:

<comment>A conflicting non-checkout attach now cancels the customer's open Checkout session and proceeds with billing instead of returning 423. Preserve the non-checkout guard here so a direct attach cannot silently invalidate an in-progress checkout flow.</comment>

<file context>
@@ -50,35 +51,25 @@ export const checkCheckoutSessionLock = async <T extends BillingContext>({
-		});
-		return null;
-	}
+	const cleared = await checkoutSessionLock.expireAndClearIfOwned({
+		ctx,
+		customerId,
</file context>

// Top-up shares the customer billing lock — retry instead of dropping the job
// when it collides with an attach or checkout materialization.
case JobName.AutoTopUp:
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.

P2: A retried lock collision still emits an auto_topup_failed webhook before SQS retries it, so customers receive a failure notification even when the later delivery succeeds. Treat LockAlreadyExists as retryable in autoTopup before sending failure webhooks (and preserve pending state until retry handling completes).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/queue/processMessage.ts, line 65:

<comment>A retried lock collision still emits an `auto_topup_failed` webhook before SQS retries it, so customers receive a failure notification even when the later delivery succeeds. Treat `LockAlreadyExists` as retryable in `autoTopup` before sending failure webhooks (and preserve pending state until retry handling completes).</comment>

<file context>
@@ -58,6 +59,12 @@ export const shouldRetrySqsJobError = ({
+		// Top-up shares the customer billing lock — retry instead of dropping the job
+		// when it collides with an attach or checkout materialization.
+		case JobName.AutoTopUp:
+			return (
+				error instanceof RecaseError && error.code === ErrCode.LockAlreadyExists
+			);
</file context>

@SirTenzin
SirTenzin merged commit 0dbba3d into dev Jul 27, 2026
14 of 15 checks passed
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.

1 participant