Skip to content

Commit 56f101b

Browse files
committed
fix(aws-marketplace): treat any live bound Stripe subscription as billing the organization
- A trialing or incomplete self-serve subscription becomes a charge, so linking refuses any bound subscription that is not finished; an abandoned checkout with no Stripe subscription stays linkable - Example values name the CloudFormation outputs as secret ARNs and mark the product ID as editable
1 parent c48df42 commit 56f101b

3 files changed

Lines changed: 72 additions & 43 deletions

File tree

‎apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts‎

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -781,57 +781,70 @@ describe('linking a claimed license', () => {
781781
expect((await readLicense(license.licenseArn))?.organizationId).toBe(otherOrganizationId)
782782
})
783783

784-
it('refuses an organization paying Stripe for a self-serve plan, but not one on Enterprise', async () => {
785-
async function organizationOnPlan(plan: string) {
784+
it('refuses an organization with a live Stripe self-serve subscription, but not Enterprise or an abandoned checkout', async () => {
785+
async function organizationWith(sub: { plan: string; status: string; bound: boolean }) {
786786
const planOwnerId = await createUser()
787787
const id = await createOrganization([{ userId: planOwnerId, role: 'owner' }])
788788
await db.insert(subscription).values({
789789
id: generateId(),
790-
plan,
790+
plan: sub.plan,
791791
referenceId: id,
792-
status: 'active',
793-
stripeSubscriptionId: `sub_${generateId()}`,
794-
metadata: { plan, referenceId: id, seats: 10, monthlyPrice: 100 },
792+
status: sub.status,
793+
stripeSubscriptionId: sub.bound ? `sub_${generateId()}` : null,
794+
metadata: { plan: sub.plan, referenceId: id, seats: 10, monthlyPrice: 100 },
795795
})
796796
return { id, planOwnerId }
797797
}
798-
const onTeam = await organizationOnPlan('team_6000')
799-
const onEnterprise = await organizationOnPlan('enterprise')
800-
const refused = await createClaimedLicense()
801-
const linked = await createClaimedLicense()
802-
entitlementsByLicense.set(refused.licenseArn, [currentEntitlement()])
803-
entitlementsByLicense.set(linked.licenseArn, [currentEntitlement()])
798+
async function linkTo(organization: { id: string; planOwnerId: string }) {
799+
const license = await createClaimedLicense()
800+
entitlementsByLicense.set(license.licenseArn, [currentEntitlement()])
801+
const refusal = await linkAwsMarketplaceLicense
802+
.execute({
803+
principal: createSessionPrincipal({ userId: organization.planOwnerId }),
804+
input: {
805+
...CONTACT,
806+
organizationId: organization.id,
807+
licenseId: license.id,
808+
claimToken: license.token,
809+
},
810+
})
811+
.then(
812+
() => null,
813+
(error: unknown) => error
814+
)
815+
return { license, refusal }
816+
}
804817

805-
const error = await rejection(
806-
linkAwsMarketplaceLicense.execute({
807-
principal: createSessionPrincipal({ userId: onTeam.planOwnerId }),
808-
input: {
809-
...CONTACT,
810-
organizationId: onTeam.id,
811-
licenseId: refused.id,
812-
claimToken: refused.token,
813-
},
814-
})
818+
const trial = await linkTo(
819+
await organizationWith({ plan: 'team_6000', status: 'trialing', bound: true })
815820
)
816-
await linkAwsMarketplaceLicense.execute({
817-
principal: createSessionPrincipal({ userId: onEnterprise.planOwnerId }),
818-
input: {
819-
...CONTACT,
820-
organizationId: onEnterprise.id,
821-
licenseId: linked.id,
822-
claimToken: linked.token,
823-
},
821+
const onEnterprise = await organizationWith({
822+
plan: 'enterprise',
823+
status: 'active',
824+
bound: true,
824825
})
826+
const enterprise = await linkTo(onEnterprise)
827+
const abandoned = await organizationWith({
828+
plan: 'team_6000',
829+
status: 'incomplete',
830+
bound: false,
831+
})
832+
const afterAbandoned = await linkTo(abandoned)
825833

826-
expect(error).toMatchObject({
834+
expect(trial.refusal).toMatchObject({
827835
code: 'conflict',
828836
message: expect.stringMatching(/already pays/),
829837
})
830-
expect(await readLicense(refused.licenseArn)).toMatchObject({
838+
expect(await readLicense(trial.license.licenseArn)).toMatchObject({
831839
organizationId: null,
832-
claimTokenHash: hashClaimToken(refused.token),
840+
claimTokenHash: hashClaimToken(trial.license.token),
833841
})
834-
expect((await readLicense(linked.licenseArn))?.organizationId).toBe(onEnterprise.id)
842+
expect(enterprise.refusal).toBeNull()
843+
expect(afterAbandoned.refusal).toBeNull()
844+
expect((await readLicense(enterprise.license.licenseArn))?.organizationId).toBe(onEnterprise.id)
845+
expect((await readLicense(afterAbandoned.license.licenseArn))?.organizationId).toBe(
846+
abandoned.id
847+
)
835848
})
836849

837850
it('links under a newer active read without overwriting it with its own older one', async () => {

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,20 @@ import {
88
user,
99
} from '@sim/db/schema'
1010
import { generateId } from '@sim/utils/id'
11-
import { and, asc, count, desc, eq, inArray, isNotNull, isNull, lt, ne, or } from 'drizzle-orm'
11+
import {
12+
and,
13+
asc,
14+
count,
15+
desc,
16+
eq,
17+
inArray,
18+
isNotNull,
19+
isNull,
20+
lt,
21+
ne,
22+
notInArray,
23+
or,
24+
} from 'drizzle-orm'
1225
import type { EntitlementSummary } from '@/lib/billing/aws-marketplace/entitlements'
1326
import {
1427
AWS_MARKETPLACE_BUYER_NOTICE_EVENT,
@@ -21,7 +34,7 @@ import type {
2134
AwsMarketplaceProduct,
2235
AwsMarketplaceProducts,
2336
} from '@/lib/billing/aws-marketplace/products'
24-
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
37+
import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
2538
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
2639
import type { DbTransaction } from '@/lib/db/types'
2740

@@ -732,8 +745,9 @@ export async function linkLicenseForTeam(
732745
}
733746

734747
/**
735-
* Whether the organization pays for a self-serve plan through Stripe. Enterprise is provisioned by
736-
* the team, who pause its collection for a Marketplace customer.
748+
* Whether the organization has a live Stripe subscription to a self-serve plan: any it is bound to
749+
* that has not finished, since a trial or a payment still pending turns into a charge. Enterprise
750+
* is provisioned by the team, who pause its collection for a Marketplace customer.
737751
*/
738752
export async function paysForSelfServePlan(
739753
tx: DbTransaction,
@@ -745,7 +759,8 @@ export async function paysForSelfServePlan(
745759
.where(
746760
and(
747761
eq(subscription.referenceId, organizationId),
748-
inArray(subscription.status, [...ENTITLED_SUBSCRIPTION_STATUSES]),
762+
isNotNull(subscription.stripeSubscriptionId),
763+
notInArray(subscription.status, [...TERMINAL_SUBSCRIPTION_STATUSES]),
749764
ne(subscription.plan, 'enterprise')
750765
)
751766
)

‎helm/sim/examples/values-aws-marketplace.yaml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
# (the CloudFormation template creates it).
2626
# 4. An ACM certificate for the two hostnames below.
2727
# 5. Two Secrets in the install namespace (no secret ever goes in this file). The template's
28-
# DatabasePasswordArn and CacheAuthTokenArn outputs hold the two passwords as plain strings:
28+
# DatabasePasswordArn and CacheAuthTokenArn outputs are the ARNs of the Secrets Manager
29+
# secrets holding the two passwords; read each password with:
2930
# aws secretsmanager get-secret-value --secret-id <arn> --query SecretString --output text
3031
# kubectl create secret generic sim-app-secrets --namespace sim \
3132
# --from-literal=BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
@@ -50,9 +51,9 @@ app:
5051
NEXT_PUBLIC_APP_URL: "https://sim.example.com" # EDIT: your public URL
5152
BETTER_AUTH_URL: "https://sim.example.com" # EDIT: same as NEXT_PUBLIC_APP_URL
5253
NEXT_PUBLIC_SOCKET_URL: "wss://sim-ws.example.com" # EDIT: the realtime hostname below
53-
# The listing's product ID, which the Marketplace chart sets. Its presence turns on the AWS
54-
# License Manager check.
55-
AWS_MARKETPLACE_LICENSE_PRODUCT_SKU: "prod-xxxxxxxxxxxxx"
54+
# The listing's product ID, which the Marketplace chart sets; rendered from this repository,
55+
# set it to the product ID shown on the listing. Its presence turns on the License Manager check.
56+
AWS_MARKETPLACE_LICENSE_PRODUCT_SKU: "prod-xxxxxxxxxxxxx" # EDIT
5657

5758
realtime:
5859
replicaCount: 2

0 commit comments

Comments
 (0)