Skip to content

fix(aws-marketplace): cancel a Stripe subscription that binds to a customer AWS Marketplace already bills - #9973

Closed
waleedlatif1 wants to merge 1 commit into
stagingfrom
fix/aws-marketplace-stripe-reconcile
Closed

waleedlatif1 wants to merge 1 commit into
stagingfrom
fix/aws-marketplace-stripe-reconcile

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • A Stripe hosted checkout left open while the same organization links an AWS Marketplace license passed both the checkout guard and the link-time Stripe check, so Stripe and AWS could both bill the customer. No lock can span a hosted checkout, so the fix runs when Stripe binds the subscription.
  • onSubscriptionComplete now calls cancelStripeSubscriptionBilledThroughMarketplace first. Under the organization mutation lock, then the subscription row lock, it rechecks for a live Marketplace license. If one bills the reference, it queues the existing immediate-cancel outbox event (prorated, invoiced now) and a team notice to refund the charge, then skips the paid-plan setup. A redelivered webhook queues nothing twice.
  • Both link paths (buyer and admin) take the same organization mutation lock first, so a binding either runs before a link's Stripe check (and the link is refused) or after it commits (and the binding sees the license).
  • Enterprise subscriptions the team issues, inactive licenses, unbound rows, and deployments that don't sell on AWS are left alone, matching the link-time check. Personal plans are fenced the same way the personal checkout guard refuses them.
  • The immediate-cancel payload's requester and organization are now optional: a system cancellation is audited where it is decided, not as an admin request.

Type of Change

  • Bug fix

Testing

  • New stripe-fence.integration.ts (real Postgres, 11 tests): cancels once across redelivery and queues the team notice; personal plan; leaves alone (no license, inactive, Enterprise, unbound, not selling); link-then-bind; bind-then-link (link refused, subscription kept); a binding waits for a link holding the lock and then sees its license; a link waits for a binding holding the lock.
  • Red→green: removing the fence's lock, the admin link's lock, the redelivery dedupe, or the Enterprise exclusion each turns exactly its test red.
  • lifecycle.integration.ts (103) and checkout-guard.integration.ts (10) pass; outbox-handlers.test.ts passes; bun run lint.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing (new tests pass the test-audit authoring gate)
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…stomer AWS Marketplace already bills

A Stripe hosted checkout left open while the same organization links an AWS
Marketplace license passed both the checkout guard and the link-time check, so
Stripe and AWS could both bill it. When Stripe binds the subscription, Sim now
rechecks under the organization mutation lock, which both link paths hold from
their Stripe check to commit, and queues an immediate Stripe cancellation plus
a team notice to refund the charge.
@vercel

vercel Bot commented Nov 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
docs Skipped Skipped Nov 13, 2026 3:00am UTC

Request Review

@greptile-apps

greptile-apps Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Critical impact] The change reaches Stripe subscription billing when AWS Marketplace already bills the same customer.

The PR appears safe to merge, with a non-blocking correction needed in the cancellation email.

Findings

  1. P2 Email reports cancellation too early ▶

Summary

Adds a check when Stripe completes checkout to cancel a self-serve subscription if AWS Marketplace already bills its customer.

  • Stripe checkout bindings cancel when AWS Marketplace already bills the customer.

Acknowledged intent: waleedlatif1 explicitly described leaving Enterprise subscriptions and inactive licenses alone.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Stripe checkout completes] --> B[Check subscription under locks]
  B --> C{Marketplace bills customer?}
  C -->|No| D[Continue paid-plan setup]
  C -->|Yes| E[Queue cancellation and team notice]
  E --> F[Skip paid-plan setup]
  E --> G[Outbox requests Stripe cancellation]
  E --> H[Outbox sends team notice]
  G --> I[Deletion webhook ends subscription]
Loading

Reviews (1) · Last reviewed commit: "fix(aws-marketplace): cancel a Stripe su..." · Reviewed by Greptile

Comment on lines +189 to +193
subject: `Stripe subscription cancelled: ${account}`,
opening: `A Stripe checkout completed for a customer AWS Marketplace already bills, so Sim cancelled the Stripe subscription at once.
Sim subscription: ${notice.subscriptionId}
Stripe subscription: ${notice.stripeSubscriptionId}`,
next: 'Refund the Stripe charge: from the admin dashboard for an organization, in Stripe for a personal plan. Cancelling credits only the unused time to the Stripe balance. Keep the customer on the AWS Marketplace terms below.',

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 Email reports cancellation too early

The team notice says Sim already cancelled the Stripe subscription, but it is queued alongside the cancellation request, not after it succeeds. The outbox handles these events separately, so support can receive the email while cancellation is still retrying or has failed. They could refund a subscription that continues to renew.

Say cancellation was requested and ask support to check its result, or send the success notice only after Stripe confirms cancellation.

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

4 issues found across 9 files

Confidence score: 3/5

  • The notice in notifications.ts says cancellation has happened even though the Stripe cancellation is only queued, so customers could be told something that may still fail. Say the cancellation is queued.
  • In stripe-fence.ts, a replay after cancellation completes can return null on the terminal row before checking the existing cancellation event, so it may skip normal completion handling. Check for the existing event before returning.
  • stripe-fence.integration.ts treats any request over 300 ms as proof of lock contention, so a slow or unscheduled request can pass without reaching the lock. Assert that PostgreSQL reports the request waiting on a lock.
  • The “refuses the link when the subscription binds first” case in stripe-fence.integration.ts leaves an active unlinked license behind, and afterAll does not remove it. Delete that fixture explicitly.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts">

<violation number="1" location="apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts:43">
P3: The lock-order tests treat every operation taking over 300 ms as evidence of lock contention, so a slow or unscheduled request can pass without reaching the lock. Wait for the request's PostgreSQL backend to report `wait_event_type = 'Lock'` before releasing the holder.</violation>

<violation number="2" location="apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts:47">
P3: The 'refuses the link when the subscription binds first' case leaves its unlinked license behind. `afterAll` only deletes licenses by organization or linker, and this row has neither, so every run adds another active `product-placeholder` row that the admin unlinked-license queue lists. Track inserted license IDs in `insertLicense` and delete them in `afterAll`.</violation>
</file>

<file name="apps/sim/lib/billing/aws-marketplace/notifications.ts">

<violation number="1" location="apps/sim/lib/billing/aws-marketplace/notifications.ts:190">
P2: This copy says the cancellation already happened, but it is only queued. The Stripe cancel runs as a separate outbox event that can be retried or fail, and this notice can be sent before it. Say it was queued in the subject and opening, e.g. 'Stripe cancellation queued', so staff do not refund while the subscription is still live.</violation>
</file>

<file name="apps/sim/lib/billing/aws-marketplace/stripe-fence.ts">

<violation number="1" location="apps/sim/lib/billing/aws-marketplace/stripe-fence.ts:45">
P2: A replay after the queued cancellation completes is treated as uncancelled: the deletion webhook leaves the row terminal, so this returns `null` before checking the existing cancellation event and normal completion setup runs. Check for the deterministic cancellation event before returning false so replayed completion deliveries continue to skip setup.</violation>
</file>

Turn on auto-fix | Re-trigger cubic

case 'stripe-cancelled':
return {
subject: `Stripe subscription cancelled: ${account}`,
opening: `A Stripe checkout completed for a customer AWS Marketplace already bills, so Sim cancelled the Stripe subscription at once.

@cubic-dev-ai cubic-dev-ai Bot Nov 13, 2026 •

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: This copy says the cancellation already happened, but it is only queued. The Stripe cancel runs as a separate outbox event that can be retried or fail, and this notice can be sent before it. Say it was queued in the subject and opening, e.g. 'Stripe cancellation queued', so staff do not refund while the subscription is still live.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/billing/aws-marketplace/notifications.ts, line 190:

<comment>This copy says the cancellation already happened, but it is only queued. The Stripe cancel runs as a separate outbox event that can be retried or fail, and this notice can be sent before it. Say it was queued in the subject and opening, e.g. 'Stripe cancellation queued', so staff do not refund while the subscription is still live.</comment>

<file context>
@@ -184,6 +184,14 @@ New agreement: ${notice.agreementId}`,
+    case 'stripe-cancelled':
+      return {
+        subject: `Stripe subscription cancelled: ${account}`,
+        opening: `A Stripe checkout completed for a customer AWS Marketplace already bills, so Sim cancelled the Stripe subscription at once.
+Sim subscription: ${notice.subscriptionId}
+Stripe subscription: ${notice.stripeSubscriptionId}`,
</file context>
Suggested change
opening: `A Stripe checkout completed for a customer AWS Marketplace already bills, so Sim cancelled the Stripe subscription at once.
opening: `A Stripe checkout completed for a customer AWS Marketplace already bills, so Sim queued an immediate Stripe cancellation.
Fix with cubic

const organizationId = (await organizationExists(tx, referenceId)) ? referenceId : null
if (organizationId) await acquireOrganizationMutationLock(tx, organizationId)
const bound = await lockSelfServeStripeSubscription(tx, subscriptionId)
if (!bound) return null

@cubic-dev-ai cubic-dev-ai Bot Nov 13, 2026 •

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 replay after the queued cancellation completes is treated as uncancelled: the deletion webhook leaves the row terminal, so this returns null before checking the existing cancellation event and normal completion setup runs. Check for the deterministic cancellation event before returning false so replayed completion deliveries continue to skip setup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/billing/aws-marketplace/stripe-fence.ts, line 45:

<comment>A replay after the queued cancellation completes is treated as uncancelled: the deletion webhook leaves the row terminal, so this returns `null` before checking the existing cancellation event and normal completion setup runs. Check for the deterministic cancellation event before returning false so replayed completion deliveries continue to skip setup.</comment>

<file context>
@@ -0,0 +1,85 @@
+    const organizationId = (await organizationExists(tx, referenceId)) ? referenceId : null
+    if (organizationId) await acquireOrganizationMutationLock(tx, organizationId)
+    const bound = await lockSelfServeStripeSubscription(tx, subscriptionId)
+    if (!bound) return null
+    if (bound.referenceId !== referenceId) {
+      throw new Error('Subscription changed billing reference while checking AWS Marketplace')
</file context>
Fix with cubic


const ADMIN = { id: null, name: 'Fence fixture admin', email: null }
/** Long enough that a statement not waiting on a lock has finished. */
const LOCK_WAIT_PROBE_MS = 300

@cubic-dev-ai cubic-dev-ai Bot Nov 13, 2026 •

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.

P3: The lock-order tests treat every operation taking over 300 ms as evidence of lock contention, so a slow or unscheduled request can pass without reaching the lock. Wait for the request's PostgreSQL backend to report wait_event_type = 'Lock' before releasing the holder.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts, line 43:

<comment>The lock-order tests treat every operation taking over 300 ms as evidence of lock contention, so a slow or unscheduled request can pass without reaching the lock. Wait for the request's PostgreSQL backend to report `wait_event_type = 'Lock'` before releasing the holder.</comment>

<file context>
@@ -0,0 +1,347 @@
+
+const ADMIN = { id: null, name: 'Fence fixture admin', email: null }
+/** Long enough that a statement not waiting on a lock has finished. */
+const LOCK_WAIT_PROBE_MS = 300
+
+const userIds: string[] = []
</file context>
Fix with cubic


const userIds: string[] = []
const organizationIds: string[] = []
const subscriptionIds: string[] = []

@cubic-dev-ai cubic-dev-ai Bot Nov 13, 2026 •

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.

P3: The 'refuses the link when the subscription binds first' case leaves its unlinked license behind. afterAll only deletes licenses by organization or linker, and this row has neither, so every run adds another active product-placeholder row that the admin unlinked-license queue lists. Track inserted license IDs in insertLicense and delete them in afterAll.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts, line 47:

<comment>The 'refuses the link when the subscription binds first' case leaves its unlinked license behind. `afterAll` only deletes licenses by organization or linker, and this row has neither, so every run adds another active `product-placeholder` row that the admin unlinked-license queue lists. Track inserted license IDs in `insertLicense` and delete them in `afterAll`.</comment>

<file context>
@@ -0,0 +1,347 @@
+
+const userIds: string[] = []
+const organizationIds: string[] = []
+const subscriptionIds: string[] = []
+
+async function createUser(): Promise<string> {
</file context>
Fix with cubic

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Closing: the window (a hosted Stripe checkout left open while the same organization links an AWS Marketplace license) is narrow and recoverable with a refund, and this fix would add a lock and a transaction to every Stripe subscription binding. Not worth the risk on that path.

@waleedlatif1
waleedlatif1 deleted the fix/aws-marketplace-stripe-reconcile branch November 13, 2026 03:04

This branch was previously deployed

1 inactive deployment
Preview — 4dc6f9e5 Deployed Nov 13, 2026 by vercel[bot]
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