Skip to content

Commit 4dc6f9e

Browse files
committed
fix(aws-marketplace): cancel a Stripe subscription that binds to a customer 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.
1 parent 6649d8a commit 4dc6f9e

9 files changed

Lines changed: 591 additions & 19 deletions

File tree

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {
8484
authorizeSubscriptionReference,
8585
isPersonalCheckoutRequest,
8686
} from '@/lib/billing/authorization'
87+
import { cancelStripeSubscriptionBilledThroughMarketplace } from '@/lib/billing/aws-marketplace/stripe-fence'
8788
import {
8889
type CheckoutAdmissionClaim,
8990
claimCheckoutAdmission,
@@ -1615,6 +1616,11 @@ export const auth = betterAuth({
16151616
stripeSubscription: Stripe.Subscription
16161617
subscription: any
16171618
}) => {
1619+
// A checkout left open across an AWS Marketplace link would bill twice.
1620+
if (await cancelStripeSubscriptionBilledThroughMarketplace(subscription.id)) {
1621+
return
1622+
}
1623+
16181624
const { priceId, planFromStripe, isAnnual } =
16191625
resolvePlanFromStripeSubscription(stripeSubscription)
16201626

‎apps/sim/lib/billing/aws-marketplace/application/admin.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
lockLicense,
1414
organizationExists,
1515
} from '@/lib/billing/aws-marketplace/repository'
16+
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
1617
import { OrchestrationError } from '@/lib/core/orchestration/types'
1718

1819
/** The licenses no organization holds yet, the ones the team's notices ask to link. */
@@ -49,6 +50,7 @@ export async function linkAwsMarketplaceLicenseAsAdmin(
4950
actor: AdminMutationActor
5051
): Promise<{ id: string; organizationId: string }> {
5152
const { license, newlyLinked } = await db.transaction(async (tx) => {
53+
await acquireOrganizationMutationLock(tx, input.organizationId)
5254
const locked = await lockLicense(tx, input.licenseId)
5355
if (!locked) throw new OrchestrationError('not_found', 'License')
5456
requireLinkableTo(locked, input.organizationId)

‎apps/sim/lib/billing/aws-marketplace/application/use-cases.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
recordLicenseVerification,
2323
saveLicenseContact,
2424
} from '@/lib/billing/aws-marketplace/repository'
25+
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
2526
import {
2627
defineAuthorizedOrganizationUseCase,
2728
type OrganizationUseCaseContext,
@@ -98,7 +99,9 @@ export function requireLinkableTo(
9899

99100
/**
100101
* AWS bills a linked organization for the listing, so one already paying Stripe for a plan would
101-
* pay twice. Its subscription is cancelled first, by its owner or by support.
102+
* pay twice. Its subscription is cancelled first, by its owner or by support. The caller holds the
103+
* organization mutation lock until commit, which a subscription binding to Stripe also takes (see
104+
* `cancelStripeSubscriptionBilledThroughMarketplace`).
102105
*/
103106
export async function requireNotBilledThroughStripe(
104107
tx: DbTransaction,
@@ -260,6 +263,8 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({
260263
}: OrganizationUseCaseContext<LinkLicenseInput>): Promise<LinkedLicense> {
261264
const { claimHash, verification } = await verifyClaimedLicense(input, input.organizationId)
262265
return db.transaction(async (tx) => {
266+
// Held from the Stripe check to commit; a Stripe subscription binding meanwhile waits for it.
267+
await acquireOrganizationMutationLock(tx, input.organizationId)
263268
const locked = await lockOpenClaim(tx, input, claimHash)
264269
requireLinkableTo(locked, input.organizationId)
265270
requireNotOverruled(locked, verification.verifiedAt)

‎apps/sim/lib/billing/aws-marketplace/notice-events.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export type AwsMarketplaceTeamNotice =
2828
state: 'issued' | 'resolved'
2929
detail: string
3030
}
31+
| {
32+
kind: 'stripe-cancelled'
33+
licenseIds: string[]
34+
subscriptionId: string
35+
stripeSubscriptionId: string
36+
}
3137

3238
export interface AwsMarketplaceBuyerNotice {
3339
licenseId: string

‎apps/sim/lib/billing/aws-marketplace/notifications.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,14 @@ New agreement: ${notice.agreementId}`,
184184
Event detail: ${notice.detail}`,
185185
next: 'Review the agreement in the AWS Marketplace Management Portal.',
186186
}
187+
case 'stripe-cancelled':
188+
return {
189+
subject: `Stripe subscription cancelled: ${account}`,
190+
opening: `A Stripe checkout completed for a customer AWS Marketplace already bills, so Sim cancelled the Stripe subscription at once.
191+
Sim subscription: ${notice.subscriptionId}
192+
Stripe subscription: ${notice.stripeSubscriptionId}`,
193+
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.',
194+
}
187195
}
188196
}
189197

‎apps/sim/lib/billing/aws-marketplace/repository.ts‎

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type {
3535
AwsMarketplaceProducts,
3636
} from '@/lib/billing/aws-marketplace/products'
3737
import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
38+
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
3839
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
3940
import type { DbTransaction } from '@/lib/db/types'
4041

@@ -61,6 +62,9 @@ interface AgreementObservation {
6162
seenAt: Date | null
6263
}
6364

65+
/** Why the cancellation was queued; the outbox handler logs it. */
66+
const STRIPE_CANCELLATION_REASON = 'billed-through-aws-marketplace'
67+
6468
function enqueueTeamNotice(tx: DbTransaction, notice: AwsMarketplaceTeamNotice, id?: string) {
6569
return enqueueOutboxEvent(tx, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, notice, { id })
6670
}
@@ -675,34 +679,51 @@ export async function listAccountLicenses(userId: string) {
675679
}
676680

677681
/**
678-
* Whether AWS Marketplace bills this billing reference: a license of a listing this deployment
679-
* sells that is not inactive, linked to the organization, or registered to the user's account with
680-
* no organization yet.
682+
* A license of a listing this deployment sells that is not inactive and bills `referenceId`: linked
683+
* to the organization, or registered to the user's account with no organization yet.
681684
*/
685+
function billsReference(products: AwsMarketplaceProducts, referenceId: string) {
686+
return and(
687+
ofProducts(products),
688+
ne(awsMarketplaceLicense.status, 'inactive'),
689+
or(
690+
eq(awsMarketplaceLicense.organizationId, referenceId),
691+
and(
692+
isNull(awsMarketplaceLicense.organizationId),
693+
eq(awsMarketplaceLicense.linkedByUserId, referenceId)
694+
)
695+
)
696+
)
697+
}
698+
699+
/** Whether AWS Marketplace bills this billing reference; see {@link billsReference}. */
682700
export async function holdsMarketplaceLicense(
683701
products: AwsMarketplaceProducts,
684702
referenceId: string
685703
): Promise<boolean> {
686704
const [row] = await db
687705
.select({ id: awsMarketplaceLicense.id })
688706
.from(awsMarketplaceLicense)
689-
.where(
690-
and(
691-
ofProducts(products),
692-
ne(awsMarketplaceLicense.status, 'inactive'),
693-
or(
694-
eq(awsMarketplaceLicense.organizationId, referenceId),
695-
and(
696-
isNull(awsMarketplaceLicense.organizationId),
697-
eq(awsMarketplaceLicense.linkedByUserId, referenceId)
698-
)
699-
)
700-
)
701-
)
707+
.where(billsReference(products, referenceId))
702708
.limit(1)
703709
return Boolean(row)
704710
}
705711

712+
/** The licenses that bill `referenceId`, read in the caller's transaction. */
713+
export async function listLicensesBillingReference(
714+
tx: DbTransaction,
715+
products: AwsMarketplaceProducts,
716+
referenceId: string
717+
): Promise<string[]> {
718+
const rows = await tx
719+
.select({ id: awsMarketplaceLicense.id })
720+
.from(awsMarketplaceLicense)
721+
.where(billsReference(products, referenceId))
722+
.orderBy(asc(awsMarketplaceLicense.createdAt))
723+
.limit(MAX_LICENSES_PER_AGREEMENT)
724+
return rows.map((row) => row.id)
725+
}
726+
706727
/**
707728
* Licenses with no organization, newest first, for the admin dashboard: those waiting for the team
708729
* to link them, and inactive ones that never will be.
@@ -768,6 +789,93 @@ export async function paysForSelfServePlan(
768789
return Boolean(row)
769790
}
770791

792+
/** The billing reference a subscription row names, read without a lock. */
793+
export async function findSubscriptionReference(
794+
tx: DbTransaction,
795+
subscriptionId: string
796+
): Promise<string | null> {
797+
const [row] = await tx
798+
.select({ referenceId: subscription.referenceId })
799+
.from(subscription)
800+
.where(eq(subscription.id, subscriptionId))
801+
.limit(1)
802+
return row?.referenceId ?? null
803+
}
804+
805+
/**
806+
* Locks a subscription that pays Stripe for a self-serve plan, the kind {@link paysForSelfServePlan}
807+
* refuses to link: bound to a Stripe subscription, not finished, and not Enterprise. Null when the
808+
* row is not, or no longer, one.
809+
*/
810+
export async function lockSelfServeStripeSubscription(tx: DbTransaction, subscriptionId: string) {
811+
const [row] = await tx
812+
.select({
813+
id: subscription.id,
814+
referenceId: subscription.referenceId,
815+
plan: subscription.plan,
816+
stripeSubscriptionId: subscription.stripeSubscriptionId,
817+
})
818+
.from(subscription)
819+
.where(
820+
and(
821+
eq(subscription.id, subscriptionId),
822+
isNotNull(subscription.stripeSubscriptionId),
823+
notInArray(subscription.status, [...TERMINAL_SUBSCRIPTION_STATUSES]),
824+
ne(subscription.plan, 'enterprise')
825+
)
826+
)
827+
.for('update')
828+
.limit(1)
829+
if (!row?.stripeSubscriptionId) return null
830+
return { ...row, stripeSubscriptionId: row.stripeSubscriptionId }
831+
}
832+
833+
/**
834+
* Queues the immediate Stripe cancellation of a subscription AWS Marketplace also bills, and tells
835+
* the team, at most once per subscription: a redelivered webhook finds both already queued. The
836+
* caller holds the subscription row lock.
837+
*/
838+
export async function enqueueStripeCancellationForMarketplace(
839+
tx: DbTransaction,
840+
input: {
841+
subscriptionId: string
842+
stripeSubscriptionId: string
843+
organizationId: string | null
844+
licenseIds: string[]
845+
}
846+
): Promise<boolean> {
847+
const cancellationId = `aws-marketplace-stripe-cancel-${input.subscriptionId}`
848+
const [queued] = await tx
849+
.select({ id: outboxEvent.id })
850+
.from(outboxEvent)
851+
.where(eq(outboxEvent.id, cancellationId))
852+
.limit(1)
853+
if (queued) return false
854+
await enqueueOutboxEvent(
855+
tx,
856+
OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY,
857+
{
858+
stripeSubscriptionId: input.stripeSubscriptionId,
859+
subscriptionId: input.subscriptionId,
860+
...(input.organizationId && { organizationId: input.organizationId }),
861+
operationId: cancellationId,
862+
reason: STRIPE_CANCELLATION_REASON,
863+
},
864+
{ id: cancellationId }
865+
)
866+
await enqueueTeamNotice(
867+
tx,
868+
{
869+
kind: 'stripe-cancelled',
870+
licenseIds: input.licenseIds,
871+
subscriptionId: input.subscriptionId,
872+
stripeSubscriptionId: input.stripeSubscriptionId,
873+
},
874+
`aws-marketplace-stripe-cancel-notice-${input.subscriptionId}`
875+
)
876+
return true
877+
}
878+
771879
export async function organizationExists(tx: DbTransaction, organizationId: string) {
772880
const [row] = await tx
773881
.select({ id: organization.id })

0 commit comments

Comments
 (0)