Skip to content

Commit 3277b99

Browse files
committed
fix(billing): base staff instructions on current license state and scope account status
A retried notice could tell staff to cancel a license that had since recovered, and a notice for a lapsed license could tell them to provision it, so next steps now read the license as it stands. A viewer's account license no longer labels an organization's Stripe billing as Marketplace-billed, and upgrade entry points stay hidden until the lookup settles. A buyer registered to their account sees status on the onboarding page itself, since they may not be able to open any billing page. Account license reads are keyed by viewer, reverify is scoped to the configured product, lapsed dimension ties resolve deterministically, and the admin routes honor the feature switch.
1 parent 2807b82 commit 3277b99

10 files changed

Lines changed: 204 additions & 55 deletions

File tree

‎apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
linkAwsMarketplaceLicenseAsAdmin,
1010
listUnlinkedAwsMarketplaceLicenses,
1111
} from '@/lib/billing/aws-marketplace/application/admin'
12+
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
1213
import { asOrchestrationError } from '@/lib/core/orchestration/types'
1314
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1415
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
@@ -28,6 +29,7 @@ const logger = createLogger('AdminAwsMarketplaceLicensesAPI')
2829
/** The Marketplace licenses no organization holds yet. */
2930
export const GET = withRouteHandler(
3031
withAdminAuth(async (request) => {
32+
if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace')
3133
const parsed = await parseRequest(
3234
adminDashboardListAwsMarketplaceLicensesContract,
3335
request,
@@ -54,6 +56,7 @@ export const GET = withRouteHandler(
5456
/** Links a Marketplace license to an organization once the team has provisioned it. */
5557
export const POST = withRouteHandler(
5658
withAdminAuth(async (request) => {
59+
if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace')
5760
const parsed = await parseRequest(
5861
adminDashboardLinkAwsMarketplaceLicenseContract,
5962
request,

‎apps/sim/app/marketplace/aws/aws-marketplace-onboarding.tsx‎

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ import {
1818
AWS_MARKETPLACE_SETUP_TURNAROUND,
1919
type AwsMarketplaceOnboardingError,
2020
} from '@/lib/billing/aws-marketplace/onboarding'
21-
import { organizationRoutes, WORKSPACE_BILLING_PATH } from '@/lib/navigation/paths'
21+
import { organizationRoutes } from '@/lib/navigation/paths'
2222
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
2323
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
2424
import { useBrandConfig } from '@/ee/whitelabeling'
2525
import {
2626
useAwsMarketplaceClaim,
27+
useAwsMarketplaceLicenses,
2728
useLinkAwsMarketplaceLicense,
2829
useRegisterAwsMarketplaceLicenseToAccount,
2930
useSaveAwsMarketplaceClaimContact,
@@ -59,6 +60,12 @@ const REGISTRATION_ERROR_COPY: Record<
5960
/** AWS's manual-setup guidance: being set up, wait to be contacted, turnaround, contact. */
6061
const BEING_SET_UP = `Your account is being set up. Please wait for us to contact you. We expect to finish ${AWS_MARKETPLACE_SETUP_TURNAROUND}.`
6162

63+
const LICENSE_STATUS_LABELS = {
64+
active: 'Active',
65+
pending: 'Confirming with AWS',
66+
inactive: 'Inactive',
67+
} as const
68+
6269
function authLink(path: '/login' | '/signup'): string {
6370
return buildAuthCrossLink(path, {
6471
callbackUrl: AWS_MARKETPLACE_ONBOARDING_PATH,
@@ -80,6 +87,69 @@ function SupportContact({ supportEmail }: SupportContactProps) {
8087
)
8188
}
8289

90+
/** Status for a buyer with no organization, who may not be able to open any billing page. */
91+
function AccountSubscriptionStatus() {
92+
const { data: licenses } = useAwsMarketplaceLicenses(null)
93+
if (!licenses?.length) return null
94+
return (
95+
<ul className='au:w-full au:space-y-1 au:text-center au:text-sm'>
96+
{licenses.map((license) => (
97+
<li key={license.id}>
98+
{LICENSE_STATUS_LABELS[license.status]}
99+
{license.expiresAt && `, expires ${formatDate(new Date(license.expiresAt))}`}
100+
</li>
101+
))}
102+
</ul>
103+
)
104+
}
105+
106+
interface NoSetupInProgressProps {
107+
supportContact: ReactNode
108+
actions: Array<{ label: string; onClick: () => void }>
109+
}
110+
111+
/** A finished buyer who returns sees their account's status; anyone else, how to start. */
112+
function NoSetupInProgress({ supportContact, actions }: NoSetupInProgressProps) {
113+
const { data: licenses, isPending } = useAwsMarketplaceLicenses(null)
114+
if (isPending) {
115+
return (
116+
<InviteLayout>
117+
<InviteStatusCard type='loading' title='' description='Loading your subscription...' />
118+
</InviteLayout>
119+
)
120+
}
121+
if (licenses?.length) {
122+
const underway = licenses.some((license) => license.status !== 'inactive')
123+
return (
124+
<InviteLayout>
125+
<InviteStatusCard
126+
type={underway ? 'success' : 'warning'}
127+
title='Your AWS Marketplace subscription'
128+
description={underway ? BEING_SET_UP : 'This subscription is no longer active.'}
129+
details={
130+
<div className='au:w-full au:space-y-2'>
131+
<AccountSubscriptionStatus />
132+
{supportContact}
133+
</div>
134+
}
135+
actions={actions}
136+
/>
137+
</InviteLayout>
138+
)
139+
}
140+
return (
141+
<InviteLayout>
142+
<InviteStatusCard
143+
type='warning'
144+
title='No setup in progress'
145+
description={`${RESTART_FROM_AWS} If you already finished setup, we will contact you ${AWS_MARKETPLACE_SETUP_TURNAROUND}.`}
146+
details={supportContact}
147+
actions={actions}
148+
/>
149+
</InviteLayout>
150+
)
151+
}
152+
83153
interface AwsMarketplaceOnboardingProps {
84154
registrationError: AwsMarketplaceOnboardingError | null
85155
registrationDisabled: boolean
@@ -134,7 +204,11 @@ export function AwsMarketplaceOnboarding({
134204
<InviteStatusCard
135205
type='login'
136206
title='Set up your AWS Marketplace subscription'
137-
description='Sign in or create an account to set up this subscription.'
207+
description={
208+
registrationDisabled
209+
? 'Sign in to set up this subscription.'
210+
: 'Sign in or create an account to set up this subscription.'
211+
}
138212
details={supportContact}
139213
actions={[
140214
...(registrationDisabled
@@ -157,17 +231,17 @@ export function AwsMarketplaceOnboarding({
157231
description={BEING_SET_UP}
158232
details={
159233
<div className='au:w-full au:space-y-2 au:text-center au:text-sm'>
160-
<p>
161-
<AuthTextLink
162-
href={
163-
organizationId
164-
? organizationRoutes(organizationId).settingsSection('billing')
165-
: WORKSPACE_BILLING_PATH
166-
}
167-
>
168-
View subscription status
169-
</AuthTextLink>
170-
</p>
234+
{organizationId ? (
235+
<p>
236+
<AuthTextLink
237+
href={organizationRoutes(organizationId).settingsSection('billing')}
238+
>
239+
View subscription status
240+
</AuthTextLink>
241+
</p>
242+
) : (
243+
<AccountSubscriptionStatus />
244+
)}
171245
{supportContact}
172246
</div>
173247
}
@@ -200,17 +274,7 @@ export function AwsMarketplaceOnboarding({
200274

201275
const claim = claimQuery.data
202276
if (claim.status === 'missing') {
203-
return (
204-
<InviteLayout>
205-
<InviteStatusCard
206-
type='warning'
207-
title='No setup in progress'
208-
description={`${RESTART_FROM_AWS} If you already finished setup, we will contact you ${AWS_MARKETPLACE_SETUP_TURNAROUND}.`}
209-
details={supportContact}
210-
actions={[returnHome]}
211-
/>
212-
</InviteLayout>
213-
)
277+
return <NoSetupInProgress supportContact={supportContact} actions={[returnHome]} />
214278
}
215279

216280
if (claim.status === 'linked') {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
135135
enabled: !isOrganizationScope,
136136
})
137137
const billingOrganizationId = isOrganizationScope ? (organizationId ?? null) : null
138-
const { billedThroughMarketplace } = useAwsMarketplaceBilling(billingOrganizationId)
138+
const marketplaceBilling = useAwsMarketplaceBilling(billingOrganizationId)
139+
const { billedThroughMarketplace } = marketplaceBilling
139140

140141
const {
141142
data: organizationBillingData,
@@ -521,6 +522,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
521522
</div>
522523
{!subscription.isEnterprise &&
523524
!billedThroughMarketplace &&
525+
!marketplaceBilling.isPending &&
524526
(canExplorePlans && upgradeHref ? (
525527
<ChipLink
526528
href={upgradeHref}

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

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
registerAwsMarketplaceLicenseToAccountContract,
1010
saveAwsMarketplaceClaimContactContract,
1111
} from '@/lib/api/contracts/aws-marketplace'
12+
import { useSession } from '@/lib/auth/client'
1213

1314
const AWS_MARKETPLACE_CLAIM_STALE_TIME = 30 * 1000
1415
const AWS_MARKETPLACE_LICENSES_STALE_TIME = 60 * 1000
@@ -19,9 +20,9 @@ const awsMarketplaceKeys = {
1920
/** The claim rides in an httpOnly cookie; the viewer scopes which organizations it lists. */
2021
claim: (viewerId: string) => [...awsMarketplaceKeys.claims(), viewerId] as const,
2122
licenses: () => [...awsMarketplaceKeys.all, 'licenses'] as const,
22-
/** An organization's licenses, or with null the viewer's own account's. */
23-
licenseList: (organizationId: string | null) =>
24-
[...awsMarketplaceKeys.licenses(), organizationId ?? ''] as const,
23+
/** An organization's licenses, or one viewer's own account's: the session scopes that read. */
24+
licenseList: (scope: { organizationId: string } | { viewerId: string }) =>
25+
[...awsMarketplaceKeys.licenses(), scope] as const,
2526
}
2627

2728
export function useAwsMarketplaceClaim(viewerId: string | null) {
@@ -68,25 +69,25 @@ export function useSaveAwsMarketplaceClaimContact() {
6869
}
6970

7071
/**
71-
* Whether AWS Marketplace bills this organization or the viewer's own account: a pending or active
72-
* license on either. Pending until both reads settle.
72+
* 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.
7374
*/
7475
export function useAwsMarketplaceBilling(organizationId: string | null) {
75-
const organizationLicenses = useAwsMarketplaceLicenses(organizationId)
76-
const accountLicenses = useAwsMarketplaceLicenses(null)
76+
const licenses = useAwsMarketplaceLicenses(organizationId)
7777
return {
78-
isPending: organizationLicenses.isPending || accountLicenses.isPending,
79-
billedThroughMarketplace: [
80-
...(organizationLicenses.data ?? []),
81-
...(accountLicenses.data ?? []),
82-
].some((license) => license.status !== 'inactive'),
78+
isPending: licenses.isPending,
79+
billedThroughMarketplace:
80+
licenses.data?.some((license) => license.status !== 'inactive') ?? false,
8381
}
8482
}
8583

8684
/** The Marketplace subscriptions an organization holds, or with null the viewer's own account. */
8785
export function useAwsMarketplaceLicenses(organizationId: string | null) {
86+
const { data: session } = useSession()
87+
const viewerId = session?.user?.id ?? ''
8888
return useQuery({
89-
queryKey: awsMarketplaceKeys.licenseList(organizationId),
89+
queryKey: awsMarketplaceKeys.licenseList(organizationId ? { organizationId } : { viewerId }),
90+
enabled: Boolean(organizationId || viewerId),
9091
queryFn: ({ signal }) =>
9192
organizationId
9293
? requestJson(listOrganizationAwsMarketplaceLicensesContract, {

‎apps/sim/lib/billing/aws-marketplace/entitlements.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ describe('verifyLicense', () => {
6060
expect((await read(entitlement({ ExpirationDate: NOW }))).active).toBe(false)
6161
})
6262

63+
it('reads the same lapsed dimension whichever order AWS returns ties in', async () => {
64+
const expired = new Date('2026-01-01T00:00:00.000Z')
65+
const alpha = entitlement({ Dimension: 'alpha', ExpirationDate: expired })
66+
const zeta = entitlement({ Dimension: 'zeta', ExpirationDate: expired })
67+
68+
expect((await read(alpha, zeta)).dimension).toBe('alpha')
69+
expect((await read(zeta, alpha)).dimension).toBe('alpha')
70+
})
71+
6372
it('describes the current entitlement when another one has lapsed', async () => {
6473
const summary = await read(
6574
entitlement({

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ function summarizeEntitlements(
3838
.filter((entitlement) => isCurrent(entitlement, now))
3939
.sort((left, right) => compareStrings(left.Dimension ?? '', right.Dimension ?? ''))
4040
const lapsed = [...entitlements].sort(
41-
(left, right) => (right.ExpirationDate?.getTime() ?? 0) - (left.ExpirationDate?.getTime() ?? 0)
41+
(left, right) =>
42+
(right.ExpirationDate?.getTime() ?? 0) - (left.ExpirationDate?.getTime() ?? 0) ||
43+
compareStrings(left.Dimension ?? '', right.Dimension ?? '')
4244
)
4345
const chosen = current[0] ?? lapsed[0]
4446
return {

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,62 @@ describe('reconcileAwsMarketplaceLicenses', () => {
10651065
)
10661066
})
10671067

1068+
it('does not tell the team to cancel when a retried notice arrives after the license recovered', async () => {
1069+
const organizationId = await createOrganization([{ userId: await createUser(), role: 'owner' }])
1070+
const license = await insertLicense({ organizationId })
1071+
const event = {
1072+
'detail-type': 'License Updated - Manufacturer',
1073+
detail: { license: { arn: license.licenseArn }, product: { code: PRODUCT } },
1074+
}
1075+
entitlementsByLicense.set(license.licenseArn, [lapsedEntitlement()])
1076+
deliverOnce([event])
1077+
await reconcileAwsMarketplaceLicenses()
1078+
entitlementsByLicense.set(license.licenseArn, [currentEntitlement()])
1079+
deliverOnce([event])
1080+
await reconcileAwsMarketplaceLicenses()
1081+
1082+
const notices = await emailsTo(TEAM)
1083+
1084+
expect(notices.length).toBeGreaterThan(1)
1085+
for (const notice of notices) expect(notice.text).not.toMatch(CANCEL_CALL)
1086+
})
1087+
1088+
it('does not ask the team to provision a license that lapsed before setup', async () => {
1089+
const licenseArn = uniqueArn()
1090+
entitlementsByLicense.set(licenseArn, [lapsedEntitlement()])
1091+
deliverOnce([
1092+
{
1093+
'detail-type': 'License Updated - Manufacturer',
1094+
detail: {
1095+
license: { arn: licenseArn },
1096+
product: { code: PRODUCT },
1097+
acceptor: { accountId: ACCOUNT },
1098+
},
1099+
},
1100+
])
1101+
1102+
await reconcileAwsMarketplaceLicenses()
1103+
1104+
const [notice] = await emailsTo(TEAM)
1105+
expect(notice.text).toMatch(/Do not provision this license/)
1106+
expect(notice.text).not.toMatch(/pausePaymentCollection/)
1107+
})
1108+
1109+
it('re-verifies only the configured product', async () => {
1110+
const longAgo = new Date(Date.now() - 3 * DAY_MS)
1111+
const other = await insertLicense({
1112+
productCode: 'another-product',
1113+
lastVerifiedAt: longAgo,
1114+
lastVerificationAttemptAt: longAgo,
1115+
})
1116+
entitlementsByLicense.set(other.licenseArn, [lapsedEntitlement()])
1117+
queueSend.mockResolvedValue({ Messages: [] })
1118+
1119+
await reconcileAwsMarketplaceLicenses()
1120+
1121+
expect((await readLicense(other.licenseArn))?.lastVerificationAttemptAt).toEqual(longAgo)
1122+
})
1123+
10681124
it('sends the team nothing when a re-verify finds the same terms', async () => {
10691125
const expiresAt = new Date(Date.now() + 30 * DAY_MS)
10701126
const { licenseArn } = await insertLicense({

0 commit comments

Comments
 (0)