Skip to content

Commit dd2a2cc

Browse files
committed
fix(billing): refuse Stripe checkout for AWS Marketplace-billed customers
AWS bills a Marketplace customer, and AWS's SaaS guidelines require a listed product to be billed entirely through its Marketplace dimensions. A Marketplace-billed organization or account looks Free to Stripe, so a Stripe checkout would start a second, real subscription. Until now only a client gate stood in the way, and it failed open: a failed license lookup reported "not Marketplace-billed" and showed Stripe plans, with no retry short of a reload. Server: authorizeSubscriptionReference (organization references) and assertPersonalCheckoutAllowed (personal references) now refuse an upgrade with 409 when the reference holds a Marketplace license that is not inactive, only where AWS_MARKETPLACE_PRODUCT_CODE is set. The personal check lives in assertPersonalCheckoutAllowed because the Better Auth Stripe plugin never calls authorizeReference for a personal reference, so a check there would never run for one. Other actions, and staff Enterprise provisioning, are unaffected. Client: useAwsMarketplaceBilling exposes isResolved, isError, isRetrying and retry, and an unknown billing state now gates plan changes. The billing page hides "Explore plans" until the lookup resolves; the upgrade page shows "Workspace plans unavailable" with a "Try again" chip when the lookup fails.
1 parent 743daea commit dd2a2cc

8 files changed

Lines changed: 326 additions & 11 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,8 +521,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
521521
</div>
522522
</div>
523523
{!subscription.isEnterprise &&
524+
marketplaceBilling.isResolved &&
524525
!billedThroughMarketplace &&
525-
!marketplaceBilling.isPending &&
526526
(canExplorePlans && upgradeHref ? (
527527
<ChipLink
528528
href={upgradeHref}

‎apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export interface UpgradeState {
4040
}
4141
showUpgradePlans: boolean
4242
billedThroughMarketplace: boolean
43+
/** How this workspace is billed could not be read, so plans stay hidden until a retry succeeds. */
44+
billingLookupFailed: boolean
45+
isRetryingBillingLookup: boolean
46+
retryBillingLookup: () => void
4347
proTier: { credits: number; dollars: number; name: string }
4448
maxTier: { credits: number; dollars: number; name: string }
4549
isOnPro: boolean
@@ -201,8 +205,11 @@ export function useUpgradeState({
201205
setIsAnnual,
202206
subscription,
203207
showUpgradePlans:
204-
!subscription.isEnterprise && !marketplace.isPending && !marketplace.billedThroughMarketplace,
208+
!subscription.isEnterprise && marketplace.isResolved && !marketplace.billedThroughMarketplace,
205209
billedThroughMarketplace: marketplace.billedThroughMarketplace,
210+
billingLookupFailed: !marketplace.isResolved && marketplace.isError,
211+
isRetryingBillingLookup: marketplace.isRetrying,
212+
retryBillingLookup: marketplace.retry,
206213
proTier: PRO_TIER,
207214
maxTier: MAX_TIER,
208215
isOnPro,

‎apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,15 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
8585
return null
8686
}
8787

88-
if (!canManageBilling || state.billedThroughMarketplace) {
89-
const description = canManageBilling
90-
? 'Your subscription is billed through AWS Marketplace, so plans can’t be changed here.'
91-
: hostContext.hostOrganizationId
88+
if (!canManageBilling || state.billedThroughMarketplace || state.billingLookupFailed) {
89+
const description = !canManageBilling
90+
? hostContext.hostOrganizationId
9291
? `Plans for ${hostContext.workspace.name} are managed by its organization administrators. Contact an organization admin to change this workspace’s plan.`
9392
: `Only the owner of ${hostContext.workspace.name} can change this workspace’s plan.`
93+
: state.billedThroughMarketplace
94+
? 'Your subscription is billed through AWS Marketplace, so plans can’t be changed here.'
95+
: 'We couldn’t confirm how this workspace is billed.'
96+
const canRetryLookup = canManageBilling && state.billingLookupFailed
9497

9598
return (
9699
<div className='flex h-full flex-col bg-[var(--bg)]'>
@@ -103,6 +106,15 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
103106
<div className='flex max-w-md flex-col items-center gap-3 text-center'>
104107
<h1 className='text-[var(--text-body)] text-lg'>Workspace plans unavailable</h1>
105108
<p className='text-[var(--text-muted)] text-sm'>{description}</p>
109+
{canRetryLookup && (
110+
<Chip
111+
variant='border'
112+
disabled={state.isRetryingBillingLookup}
113+
onClick={state.retryBillingLookup}
114+
>
115+
{state.isRetryingBillingLookup ? 'Retrying…' : 'Try again'}
116+
</Chip>
117+
)}
106118
</div>
107119
</div>
108120
</div>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/** @vitest-environment jsdom */
2+
3+
import { act } from 'react'
4+
import {
5+
apiClientRequestMock,
6+
apiClientRequestMockFns,
7+
} from '@sim/testing/mocks/api-client-request.mock'
8+
import { authClientMock } from '@sim/testing/mocks/auth-client.mock'
9+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
10+
import { createRoot, type Root } from 'react-dom/client'
11+
import { afterEach, describe, expect, it, vi } from 'vitest'
12+
13+
vi.mock('@/lib/api/client/request', () => apiClientRequestMock)
14+
vi.mock('@/lib/auth/client', () => authClientMock)
15+
16+
import { useAwsMarketplaceBilling } from '@/hooks/queries/aws-marketplace'
17+
18+
const mockRequestJson = apiClientRequestMockFns.mockRequestJson
19+
20+
let root: Root | null = null
21+
let billing: ReturnType<typeof useAwsMarketplaceBilling> | undefined
22+
23+
function Probe() {
24+
billing = useAwsMarketplaceBilling('org-1')
25+
return null
26+
}
27+
28+
function render() {
29+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
30+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
31+
const container = document.createElement('div')
32+
document.body.appendChild(container)
33+
root = createRoot(container)
34+
act(() => {
35+
root?.render(
36+
<QueryClientProvider client={queryClient}>
37+
<Probe />
38+
</QueryClientProvider>
39+
)
40+
})
41+
}
42+
43+
async function settled(assertion: () => void) {
44+
await act(async () => {
45+
await vi.waitFor(assertion, { interval: 1, timeout: 2_000 })
46+
})
47+
}
48+
49+
afterEach(() => {
50+
act(() => root?.unmount())
51+
root = null
52+
billing = undefined
53+
document.body.replaceChildren()
54+
})
55+
56+
describe('useAwsMarketplaceBilling', () => {
57+
it('stays unresolved when the license lookup fails, so plan changes stay gated', async () => {
58+
mockRequestJson.mockRejectedValueOnce(new Error('lookup failed'))
59+
render()
60+
61+
await settled(() => expect(billing?.isError).toBe(true))
62+
expect(billing?.isResolved).toBe(false)
63+
expect(billing?.billedThroughMarketplace).toBe(false)
64+
})
65+
66+
it('resolves to not Marketplace-billed when the scope holds no license', async () => {
67+
mockRequestJson.mockResolvedValueOnce({ licenses: [] })
68+
render()
69+
70+
await settled(() => expect(billing?.isResolved).toBe(true))
71+
expect(billing?.billedThroughMarketplace).toBe(false)
72+
expect(billing?.isError).toBe(false)
73+
})
74+
})

‎apps/sim/hooks/queries/aws-marketplace.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,17 @@ export function useSaveAwsMarketplaceClaimContact() {
7070

7171
/**
7272
* Whether AWS Marketplace bills this scope: the organization, or with null the viewer's account.
73-
* A failed read reports false, so a lookup error never blocks a customer who pays through Stripe.
73+
* Until `isResolved`, how the scope is billed is unknown, and an unknown state gates plan changes
74+
* the same as Marketplace billing does: a failed lookup must not open Stripe checkout to a
75+
* customer AWS bills. `retry` reads it again.
7476
*/
7577
export function useAwsMarketplaceBilling(organizationId: string | null) {
7678
const licenses = useAwsMarketplaceLicenses(organizationId)
7779
return {
78-
isPending: licenses.isPending,
80+
isResolved: licenses.data !== undefined,
81+
isError: licenses.isError,
82+
isRetrying: licenses.isFetching,
83+
retry: () => void licenses.refetch(),
7984
billedThroughMarketplace:
8085
licenses.data?.some((license) => license.status !== 'inactive') ?? false,
8186
}

‎apps/sim/lib/billing/authorization.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { subscription } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { APIError } from 'better-auth/api'
55
import { and, eq, isNotNull } from 'drizzle-orm'
6+
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
7+
import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository'
68
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
79
import {
810
getOrganizationCoverageForMember,
@@ -55,6 +57,22 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise
5557
})
5658
}
5759

60+
/**
61+
* Refuse a Stripe checkout for a reference AWS Marketplace bills. A listed product must be billed
62+
* entirely through AWS, and such a customer looks Free to Stripe, so a checkout would start a
63+
* second, real subscription. The client hides plans too, but only this check holds when its
64+
* license lookup fails.
65+
*/
66+
async function assertNotBilledThroughMarketplace(referenceId: string): Promise<void> {
67+
if (!getAwsMarketplaceProductCode()) return
68+
if (!(await holdsMarketplaceLicense(referenceId))) return
69+
70+
logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId })
71+
throw new APIError('CONFLICT', {
72+
message: 'This subscription is billed through AWS Marketplace. Contact support to change it.',
73+
})
74+
}
75+
5876
/**
5977
* Classify a `/subscription/upgrade` request as a personal checkout using
6078
* the same reference resolution as the Better Auth Stripe plugin
@@ -81,9 +99,10 @@ export function isPersonalCheckoutRequest(
8199
/**
82100
* Guard for personal (user-referenced) checkouts on `/subscription/upgrade`.
83101
*
84-
* Blocks both a bound, incomplete Stripe subscription and a member already
85-
* covered by an entitled organization subscription. Throws {@link APIError}
86-
* with a user-facing message; the checkout UI surfaces it as-is.
102+
* Blocks an account AWS Marketplace bills, a bound, incomplete Stripe
103+
* subscription, and a member already covered by an entitled organization
104+
* subscription. Throws {@link APIError} with a user-facing message; the
105+
* checkout UI surfaces it as-is.
87106
*
88107
* Called from the Better Auth `hooks.before` middleware, NOT from
89108
* `authorizeReference`: the Stripe plugin skips `authorizeReference`
@@ -94,6 +113,7 @@ export function isPersonalCheckoutRequest(
94113
* rejected rather than risking a duplicate subscription.
95114
*/
96115
export async function assertPersonalCheckoutAllowed(userId: string): Promise<void> {
116+
await assertNotBilledThroughMarketplace(userId)
97117
await assertNoBoundIncompleteSubscription(userId)
98118

99119
const coverage = await getOrganizationCoverageForMember(userId)
@@ -130,6 +150,7 @@ export async function assertPersonalCheckoutAllowed(userId: string): Promise<voi
130150
* For `upgrade-subscription` (checkout) this enforces, each thrown as an
131151
* {@link APIError} with a descriptive message so callers see the real
132152
* reason instead of a generic "Unauthorized":
153+
* - An organization AWS Marketplace bills cannot check out through Stripe.
133154
* - Organizations can only check out Team or Enterprise plans — a `pro_*`
134155
* plan can never become org-referenced.
135156
* - A bound, incomplete Stripe subscription must finish before another
@@ -148,6 +169,10 @@ export async function authorizeSubscriptionReference(
148169
return true
149170
}
150171

172+
if (action === 'upgrade-subscription') {
173+
await assertNotBilledThroughMarketplace(referenceId)
174+
}
175+
151176
if (action === 'upgrade-subscription' && !isOrgPlan(plan)) {
152177
logger.warn('Blocking checkout - organizations can only subscribe to Team or Enterprise', {
153178
userId,

0 commit comments

Comments
 (0)