Repository navigation
fix(aws-marketplace): cancel a Stripe subscription that binds to a customer AWS Marketplace already bills - #9973
waleedlatif1 wants to merge 1 commit into
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
[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
SummaryAdds a check when Stripe completes checkout to cancel a self-serve subscription if AWS Marketplace already bills its customer.
Acknowledged intent:
|
| 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.', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
4 issues found across 9 files
Confidence score: 3/5
- The notice in
notifications.tssays 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 returnnullon 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.tstreats 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.tsleaves an active unlinked license behind, andafterAlldoes 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>
| 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. |
There was a problem hiding this comment.
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>
| 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. |
| const organizationId = (await organizationExists(tx, referenceId)) ? referenceId : null | ||
| if (organizationId) await acquireOrganizationMutationLock(tx, organizationId) | ||
| const bound = await lockSelfServeStripeSubscription(tx, subscriptionId) | ||
| if (!bound) return null |
There was a problem hiding this comment.
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>
|
|
||
| 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 |
There was a problem hiding this comment.
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>
|
|
||
| const userIds: string[] = [] | ||
| const organizationIds: string[] = [] | ||
| const subscriptionIds: string[] = [] |
There was a problem hiding this comment.
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>
|
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. |
Summary
onSubscriptionCompletenow callscancelStripeSubscriptionBilledThroughMarketplacefirst. 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.Type of Change
Testing
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.lifecycle.integration.ts(103) andcheckout-guard.integration.ts(10) pass;outbox-handlers.test.tspasses;bun run lint.Checklist
test-auditauthoring gate)