Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
authorizeSubscriptionReference,
isPersonalCheckoutRequest,
} from '@/lib/billing/authorization'
import { cancelStripeSubscriptionBilledThroughMarketplace } from '@/lib/billing/aws-marketplace/stripe-fence'
import {
type CheckoutAdmissionClaim,
claimCheckoutAdmission,
Expand Down Expand Up @@ -1615,6 +1616,11 @@ export const auth = betterAuth({
stripeSubscription: Stripe.Subscription
subscription: any
}) => {
// A checkout left open across an AWS Marketplace link would bill twice.
if (await cancelStripeSubscriptionBilledThroughMarketplace(subscription.id)) {
return
}

const { priceId, planFromStripe, isAnnual } =
resolvePlanFromStripeSubscription(stripeSubscription)

Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/billing/aws-marketplace/application/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
lockLicense,
organizationExists,
} from '@/lib/billing/aws-marketplace/repository'
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import { OrchestrationError } from '@/lib/core/orchestration/types'

/** The licenses no organization holds yet, the ones the team's notices ask to link. */
Expand Down Expand Up @@ -49,6 +50,7 @@ export async function linkAwsMarketplaceLicenseAsAdmin(
actor: AdminMutationActor
): Promise<{ id: string; organizationId: string }> {
const { license, newlyLinked } = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, input.organizationId)
const locked = await lockLicense(tx, input.licenseId)
if (!locked) throw new OrchestrationError('not_found', 'License')
requireLinkableTo(locked, input.organizationId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
recordLicenseVerification,
saveLicenseContact,
} from '@/lib/billing/aws-marketplace/repository'
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import {
defineAuthorizedOrganizationUseCase,
type OrganizationUseCaseContext,
Expand Down Expand Up @@ -98,7 +99,9 @@ export function requireLinkableTo(

/**
* AWS bills a linked organization for the listing, so one already paying Stripe for a plan would
* pay twice. Its subscription is cancelled first, by its owner or by support.
* pay twice. Its subscription is cancelled first, by its owner or by support. The caller holds the
* organization mutation lock until commit, which a subscription binding to Stripe also takes (see
* `cancelStripeSubscriptionBilledThroughMarketplace`).
*/
export async function requireNotBilledThroughStripe(
tx: DbTransaction,
Expand Down Expand Up @@ -260,6 +263,8 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({
}: OrganizationUseCaseContext<LinkLicenseInput>): Promise<LinkedLicense> {
const { claimHash, verification } = await verifyClaimedLicense(input, input.organizationId)
return db.transaction(async (tx) => {
// Held from the Stripe check to commit; a Stripe subscription binding meanwhile waits for it.
await acquireOrganizationMutationLock(tx, input.organizationId)
const locked = await lockOpenClaim(tx, input, claimHash)
requireLinkableTo(locked, input.organizationId)
requireNotOverruled(locked, verification.verifiedAt)
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/billing/aws-marketplace/notice-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export type AwsMarketplaceTeamNotice =
state: 'issued' | 'resolved'
detail: string
}
| {
kind: 'stripe-cancelled'
licenseIds: string[]
subscriptionId: string
stripeSubscriptionId: string
}

export interface AwsMarketplaceBuyerNotice {
licenseId: string
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/billing/aws-marketplace/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ New agreement: ${notice.agreementId}`,
Event detail: ${notice.detail}`,
next: 'Review the agreement in the AWS Marketplace Management Portal.',
}
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

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.',
Comment on lines +189 to +193

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.

}
}
}

Expand Down
140 changes: 124 additions & 16 deletions apps/sim/lib/billing/aws-marketplace/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
AwsMarketplaceProducts,
} from '@/lib/billing/aws-marketplace/products'
import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import type { DbTransaction } from '@/lib/db/types'

Expand All @@ -61,6 +62,9 @@ interface AgreementObservation {
seenAt: Date | null
}

/** Why the cancellation was queued; the outbox handler logs it. */
const STRIPE_CANCELLATION_REASON = 'billed-through-aws-marketplace'

function enqueueTeamNotice(tx: DbTransaction, notice: AwsMarketplaceTeamNotice, id?: string) {
return enqueueOutboxEvent(tx, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, notice, { id })
}
Expand Down Expand Up @@ -675,34 +679,51 @@ export async function listAccountLicenses(userId: string) {
}

/**
* Whether AWS Marketplace bills this billing reference: a license of a listing this deployment
* sells that is not inactive, linked to the organization, or registered to the user's account with
* no organization yet.
* A license of a listing this deployment sells that is not inactive and bills `referenceId`: linked
* to the organization, or registered to the user's account with no organization yet.
*/
function billsReference(products: AwsMarketplaceProducts, referenceId: string) {
return and(
ofProducts(products),
ne(awsMarketplaceLicense.status, 'inactive'),
or(
eq(awsMarketplaceLicense.organizationId, referenceId),
and(
isNull(awsMarketplaceLicense.organizationId),
eq(awsMarketplaceLicense.linkedByUserId, referenceId)
)
)
)
}

/** Whether AWS Marketplace bills this billing reference; see {@link billsReference}. */
export async function holdsMarketplaceLicense(
products: AwsMarketplaceProducts,
referenceId: string
): Promise<boolean> {
const [row] = await db
.select({ id: awsMarketplaceLicense.id })
.from(awsMarketplaceLicense)
.where(
and(
ofProducts(products),
ne(awsMarketplaceLicense.status, 'inactive'),
or(
eq(awsMarketplaceLicense.organizationId, referenceId),
and(
isNull(awsMarketplaceLicense.organizationId),
eq(awsMarketplaceLicense.linkedByUserId, referenceId)
)
)
)
)
.where(billsReference(products, referenceId))
.limit(1)
return Boolean(row)
}

/** The licenses that bill `referenceId`, read in the caller's transaction. */
export async function listLicensesBillingReference(
tx: DbTransaction,
products: AwsMarketplaceProducts,
referenceId: string
): Promise<string[]> {
const rows = await tx
.select({ id: awsMarketplaceLicense.id })
.from(awsMarketplaceLicense)
.where(billsReference(products, referenceId))
.orderBy(asc(awsMarketplaceLicense.createdAt))
.limit(MAX_LICENSES_PER_AGREEMENT)
return rows.map((row) => row.id)
}

/**
* Licenses with no organization, newest first, for the admin dashboard: those waiting for the team
* to link them, and inactive ones that never will be.
Expand Down Expand Up @@ -768,6 +789,93 @@ export async function paysForSelfServePlan(
return Boolean(row)
}

/** The billing reference a subscription row names, read without a lock. */
export async function findSubscriptionReference(
tx: DbTransaction,
subscriptionId: string
): Promise<string | null> {
const [row] = await tx
.select({ referenceId: subscription.referenceId })
.from(subscription)
.where(eq(subscription.id, subscriptionId))
.limit(1)
return row?.referenceId ?? null
}

/**
* Locks a subscription that pays Stripe for a self-serve plan, the kind {@link paysForSelfServePlan}
* refuses to link: bound to a Stripe subscription, not finished, and not Enterprise. Null when the
* row is not, or no longer, one.
*/
export async function lockSelfServeStripeSubscription(tx: DbTransaction, subscriptionId: string) {
const [row] = await tx
.select({
id: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
stripeSubscriptionId: subscription.stripeSubscriptionId,
})
.from(subscription)
.where(
and(
eq(subscription.id, subscriptionId),
isNotNull(subscription.stripeSubscriptionId),
notInArray(subscription.status, [...TERMINAL_SUBSCRIPTION_STATUSES]),
ne(subscription.plan, 'enterprise')
)
)
.for('update')
.limit(1)
if (!row?.stripeSubscriptionId) return null
return { ...row, stripeSubscriptionId: row.stripeSubscriptionId }
}

/**
* Queues the immediate Stripe cancellation of a subscription AWS Marketplace also bills, and tells
* the team, at most once per subscription: a redelivered webhook finds both already queued. The
* caller holds the subscription row lock.
*/
export async function enqueueStripeCancellationForMarketplace(
tx: DbTransaction,
input: {
subscriptionId: string
stripeSubscriptionId: string
organizationId: string | null
licenseIds: string[]
}
): Promise<boolean> {
const cancellationId = `aws-marketplace-stripe-cancel-${input.subscriptionId}`
const [queued] = await tx
.select({ id: outboxEvent.id })
.from(outboxEvent)
.where(eq(outboxEvent.id, cancellationId))
.limit(1)
if (queued) return false
await enqueueOutboxEvent(
tx,
OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY,
{
stripeSubscriptionId: input.stripeSubscriptionId,
subscriptionId: input.subscriptionId,
...(input.organizationId && { organizationId: input.organizationId }),
operationId: cancellationId,
reason: STRIPE_CANCELLATION_REASON,
},
{ id: cancellationId }
)
await enqueueTeamNotice(
tx,
{
kind: 'stripe-cancelled',
licenseIds: input.licenseIds,
subscriptionId: input.subscriptionId,
stripeSubscriptionId: input.stripeSubscriptionId,
},
`aws-marketplace-stripe-cancel-notice-${input.subscriptionId}`
)
return true
}

export async function organizationExists(tx: DbTransaction, organizationId: string) {
const [row] = await tx
.select({ id: organization.id })
Expand Down
Loading
Loading