Skip to content

Commit 54b6fe8

Browse files
committed
fix(billing): register new-product AWS Marketplace buyers, meet the listing guidelines, and handle agreement events
- ResolveCustomer leaves CustomerIdentifier empty for new SaaS products, so registration now requires the account ID and keys off it and the license ARN. customer_identifier is nullable, customer_aws_account_id is required (not unique), and agreement_id is stored. No license ARN yet means a future-dated agreement: nothing is saved and the buyer is told to return on the start date. - Listing guidelines: the claim step collects a contact email and optional company, the buyer gets a confirmation with turnaround and support contact, organization members see subscription status on the billing page through a contracted read, and the team is emailed when a license's status, quantity, or expiry changes, or when an event names a license nobody registered. - Agreement events: Ended re-verifies the licenses of that agreement; a RENEW tells the team once across both seller roles and links nothing; agreements for other products are dropped quietly. - Every writer of entitlement fields honours the newest read, so a slow registration or claim cannot restore a lapsed license, and a claim is refused if a newer read says inactive. - Post-commit notices, lookups included, never fail the request. Re-verifies order by last attempt, so failing licenses cannot starve the batch. The reconcile has a 6-minute wall-clock budget and a per-message abort that fit the receive visibility timeout and the cron lock. - The fulfillment URL redirects a GET to onboarding and accepts multipart forms under the same cap.
1 parent a62e318 commit 54b6fe8

24 files changed

Lines changed: 1711 additions & 280 deletions

File tree

‎apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
const logger = createLogger('AwsMarketplaceReconcileCron')
1212

1313
const LOCK_KEY = 'aws-marketplace-reconcile-lock'
14-
/** Covers a full drain plus the re-verification batch; the schedule runs every 15 minutes. */
14+
/** Outlasts the reconcile's 6-minute run budget; the schedule runs every 15 minutes. */
1515
const LOCK_TTL_SECONDS = 10 * 60
1616

1717
export const dynamic = 'force-dynamic'

‎apps/sim/app/api/marketplace/aws/claim/link/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export const POST = defineInternalJsonRoute({
2222
mapInput: ({ body }, { request }) => ({
2323
organizationId: body.organizationId,
2424
licenseId: body.licenseId,
25+
contactEmail: body.contactEmail,
26+
companyName: body.companyName || null,
2527
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
2628
}),
2729
present: (_linked, { input }) => ({

‎apps/sim/app/api/marketplace/aws/claim/register-account/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const POST = defineInternalJsonRoute({
2121
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
2222
mapInput: ({ body }, { request }) => ({
2323
licenseId: body.licenseId,
24+
contactEmail: body.contactEmail,
25+
companyName: body.companyName || null,
2426
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
2527
}),
2628
present: () => ({ success: true as const }),

‎apps/sim/app/api/marketplace/aws/register/route.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export const dynamic = 'force-dynamic'
1717

1818
const logger = createLogger('AwsMarketplaceRegisterAPI')
1919

20+
/** AWS posts a URL-encoded form; multipart is accepted too, under the same cap. */
21+
const FORM_MEDIA_TYPES = new Set(['application/x-www-form-urlencoded', 'multipart/form-data'])
22+
2023
/** The form carries one opaque token; anything larger is not from AWS. */
2124
const MAX_FORM_BYTES = 16 * 1024
2225

@@ -34,7 +37,7 @@ function redirectToOnboarding(error?: AwsMarketplaceOnboardingError): NextRespon
3437

3538
async function readRegistrationToken(request: NextRequest): Promise<string | null> {
3639
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
37-
if (mediaType !== 'application/x-www-form-urlencoded') return null
40+
if (!mediaType || !FORM_MEDIA_TYPES.has(mediaType)) return null
3841
let form: FormData
3942
try {
4043
form = await readFormDataWithLimit(request, {
@@ -44,8 +47,9 @@ async function readRegistrationToken(request: NextRequest): Promise<string | nul
4447
} catch {
4548
return null
4649
}
50+
const token = form.get('x-amzn-marketplace-token')
4751
const parsed = awsMarketplaceRegistrationFormSchema.safeParse({
48-
'x-amzn-marketplace-token': form.get('x-amzn-marketplace-token'),
52+
'x-amzn-marketplace-token': typeof token === 'string' ? token : undefined,
4953
})
5054
return parsed.success ? parsed.data['x-amzn-marketplace-token'] : null
5155
}
@@ -81,3 +85,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8185
response.headers.set('Cache-Control', 'no-store')
8286
return response
8387
})
88+
89+
/** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */
90+
export const GET = withRouteHandler(async () => {
91+
if (!getAwsMarketplaceProductCode())
92+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
93+
return redirectToOnboarding()
94+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { listOrganizationAwsMarketplaceLicensesContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { listOrganizationAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
10+
export const GET = defineInternalJsonRoute({
11+
contract: listOrganizationAwsMarketplaceLicensesContract,
12+
operation: listOrganizationAwsMarketplaceLicenses.operation,
13+
useCase: listOrganizationAwsMarketplaceLicenses,
14+
auth: internalSessionAuth,
15+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-licenses' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
18+
mapInput: ({ params }) => ({ organizationId: params.id }),
19+
present: (licenses) => ({
20+
licenses: licenses.map((license) => ({
21+
...license,
22+
expiresAt: license.expiresAt?.toISOString() ?? null,
23+
lastVerifiedAt: license.lastVerifiedAt.toISOString(),
24+
})),
25+
}),
26+
})

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

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
'use client'
22

3-
import { useState } from 'react'
4-
import { AuthField, AuthFormMessage, AuthSelect, AuthTag } from '@sim/auth-ui'
3+
import { type ReactNode, useState } from 'react'
4+
import {
5+
AuthField,
6+
AuthFormMessage,
7+
AuthInput,
8+
AuthSelect,
9+
AuthTag,
10+
AuthTextLink,
11+
} from '@sim/auth-ui'
512
import { useRouter } from 'next/navigation'
613
import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace'
714
import { useSession } from '@/lib/auth/client'
815
import {
916
AWS_MARKETPLACE_ONBOARDING_PATH,
17+
AWS_MARKETPLACE_SETUP_TURNAROUND,
1018
type AwsMarketplaceOnboardingError,
1119
} from '@/lib/billing/aws-marketplace/onboarding'
1220
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
1321
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1422
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
23+
import { useBrandConfig } from '@/ee/whitelabeling'
1524
import {
1625
useAwsMarketplaceClaim,
1726
useLinkAwsMarketplaceLicense,
@@ -30,6 +39,11 @@ const REGISTRATION_ERROR_COPY: Record<
3039
description:
3140
'AWS Marketplace has no active Sim subscription for this AWS account. Subscribe to Sim in AWS Marketplace, then choose Set up your account to return here.',
3241
},
42+
'not-started': {
43+
title: 'Your agreement has not started',
44+
description:
45+
'Come back to AWS Marketplace on your agreement start date and choose Set up your account.',
46+
},
3347
invalid: {
3448
title: 'This setup link did not work',
3549
description: RESTART_FROM_AWS,
@@ -47,6 +61,21 @@ function authLink(path: '/login' | '/signup'): string {
4761
})
4862
}
4963

64+
const SETUP_NEXT_STEP = `Our team will set up your Enterprise workspace ${AWS_MARKETPLACE_SETUP_TURNAROUND} and email you when it is ready.`
65+
66+
interface SupportContactProps {
67+
supportEmail: string
68+
}
69+
70+
/** AWS requires support contact options on the fulfillment landing page. */
71+
function SupportContact({ supportEmail }: SupportContactProps) {
72+
return (
73+
<p className='au:text-center au:text-sm'>
74+
Questions? Email <AuthTextLink href={`mailto:${supportEmail}`}>{supportEmail}</AuthTextLink>
75+
</p>
76+
)
77+
}
78+
5079
function formatDate(iso: string): string {
5180
return new Date(iso).toLocaleDateString(undefined, { dateStyle: 'medium' })
5281
}
@@ -66,6 +95,8 @@ export function AwsMarketplaceOnboarding({
6695
const claimQuery = useAwsMarketplaceClaim(registrationError ? null : viewerId)
6796
const linkLicense = useLinkAwsMarketplaceLicense()
6897
const registerToAccount = useRegisterAwsMarketplaceLicenseToAccount()
98+
const { supportEmail } = useBrandConfig()
99+
const supportContact = supportEmail ? <SupportContact supportEmail={supportEmail} /> : undefined
69100
const returnHome = { label: 'Return to Home', onClick: () => router.push('/') }
70101

71102
if (registrationError) {
@@ -117,7 +148,8 @@ export function AwsMarketplaceOnboarding({
117148
<InviteStatusCard
118149
type='success'
119150
title='Subscription linked'
120-
description='Your AWS Marketplace subscription is linked to your organization. Our team will finish setting up your plan and contact you.'
151+
description={`Your AWS Marketplace subscription is linked to your organization. ${SETUP_NEXT_STEP}`}
152+
details={supportContact}
121153
actions={[{ label: 'Go to Sim', onClick: () => router.push(APP_ENTRY_PATH) }]}
122154
/>
123155
</InviteLayout>
@@ -130,7 +162,8 @@ export function AwsMarketplaceOnboarding({
130162
<InviteStatusCard
131163
type='success'
132164
title='Subscription registered'
133-
description='Our team will set up your Enterprise workspace and email you when it is ready.'
165+
description={SETUP_NEXT_STEP}
166+
details={supportContact}
134167
actions={[{ label: 'Go to Sim', onClick: () => router.push(APP_ENTRY_PATH) }]}
135168
/>
136169
</InviteLayout>
@@ -176,31 +209,40 @@ export function AwsMarketplaceOnboarding({
176209
return (
177210
<LinkLicenseCard
178211
claim={claim}
212+
defaultContactEmail={session.user.email ?? ''}
213+
supportContact={supportContact}
179214
pending={pending}
180215
linking={linkLicense.isPending}
181216
registering={registerToAccount.isPending}
182217
error={(linkLicense.error ?? registerToAccount.error)?.message ?? null}
183-
onLink={(organizationId) => {
218+
onLink={(organizationId, contact) => {
184219
registerToAccount.reset()
185-
linkLicense.mutate({ licenseId: claim.license.id, organizationId })
220+
linkLicense.mutate({ licenseId: claim.license.id, organizationId, ...contact })
186221
}}
187-
onRegister={() => {
222+
onRegister={(contact) => {
188223
linkLicense.reset()
189-
registerToAccount.mutate({ licenseId: claim.license.id })
224+
registerToAccount.mutate({ licenseId: claim.license.id, ...contact })
190225
}}
191226
onCancel={returnHome.onClick}
192227
/>
193228
)
194229
}
195230

231+
interface BuyerContact {
232+
contactEmail: string
233+
companyName?: string
234+
}
235+
196236
interface LinkLicenseCardProps {
197237
claim: Extract<AwsMarketplaceClaim, { status: 'ready' }>
238+
defaultContactEmail: string
239+
supportContact: ReactNode
198240
pending: boolean
199241
linking: boolean
200242
registering: boolean
201243
error: string | null
202-
onLink: (organizationId: string) => void
203-
onRegister: () => void
244+
onLink: (organizationId: string, contact: BuyerContact) => void
245+
onRegister: (contact: BuyerContact) => void
204246
onCancel: () => void
205247
}
206248

@@ -210,6 +252,8 @@ interface LinkLicenseCardProps {
210252
*/
211253
function LinkLicenseCard({
212254
claim,
255+
defaultContactEmail,
256+
supportContact,
213257
pending,
214258
linking,
215259
registering,
@@ -220,6 +264,13 @@ function LinkLicenseCard({
220264
}: LinkLicenseCardProps) {
221265
const { license, organizations } = claim
222266
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string>()
267+
const [contactEmail, setContactEmail] = useState(defaultContactEmail)
268+
const [companyName, setCompanyName] = useState('')
269+
const contact: BuyerContact = {
270+
contactEmail: contactEmail.trim(),
271+
...(companyName.trim() && { companyName: companyName.trim() }),
272+
}
273+
const hasContact = contact.contactEmail.length > 0
223274
const linkedOrganization = organizations.find((org) => org.id === license.organizationId)
224275
const organizationId =
225276
selectedOrganizationId ??
@@ -238,8 +289,8 @@ function LinkLicenseCard({
238289
const hasOrganizations = organizations.length > 0
239290
const registerAction = {
240291
label: hasOrganizations ? 'Register to my account instead' : 'Register to my account',
241-
onClick: onRegister,
242-
disabled: pending,
292+
onClick: () => onRegister(contact),
293+
disabled: pending || !hasContact,
243294
loading: registering,
244295
}
245296

@@ -254,8 +305,8 @@ function LinkLicenseCard({
254305
}
255306
description={
256307
hasOrganizations
257-
? 'Choose the organization this subscription is for, or register it to your account. Our team will finish setting up your plan.'
258-
: 'Register this subscription to your account. Our team will set up your Enterprise workspace and email you.'
308+
? `Choose the organization this subscription is for, or register it to your account. ${SETUP_NEXT_STEP}`
309+
: `Register this subscription to your account. ${SETUP_NEXT_STEP}`
259310
}
260311
details={
261312
<div className='au:w-full au:space-y-4'>
@@ -285,16 +336,35 @@ function LinkLicenseCard({
285336
/>
286337
</AuthField>
287338
)}
339+
<AuthField htmlFor='aws-marketplace-contact-email' label='Contact email'>
340+
<AuthInput
341+
id='aws-marketplace-contact-email'
342+
type='email'
343+
autoComplete='email'
344+
value={contactEmail}
345+
onChange={(event) => setContactEmail(event.target.value)}
346+
required
347+
/>
348+
</AuthField>
349+
<AuthField htmlFor='aws-marketplace-company' label='Company (optional)'>
350+
<AuthInput
351+
id='aws-marketplace-company'
352+
autoComplete='organization'
353+
value={companyName}
354+
onChange={(event) => setCompanyName(event.target.value)}
355+
/>
356+
</AuthField>
288357
{error && <AuthFormMessage type='error'>{error}</AuthFormMessage>}
358+
{supportContact}
289359
</div>
290360
}
291361
actions={[
292362
...(hasOrganizations
293363
? [
294364
{
295365
label: 'Link subscription',
296-
onClick: () => organizationId && onLink(organizationId),
297-
disabled: !organizationId || pending,
366+
onClick: () => organizationId && onLink(organizationId, contact),
367+
disabled: !organizationId || !hasContact || pending,
298368
loading: linking,
299369
},
300370
]

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
} from '@/lib/billing/subscriptions/utils'
4646
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
4747
import { getBaseUrl } from '@/lib/core/utils/urls'
48+
import { AwsMarketplaceSubscriptionSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/aws-marketplace-subscription-section/aws-marketplace-subscription-section'
4849
import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section'
4950
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
5051
import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions'
@@ -660,6 +661,10 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
660661
</SettingsSection>
661662
)}
662663

664+
{billingOrganizationId && (
665+
<AwsMarketplaceSubscriptionSection organizationId={billingOrganizationId} />
666+
)}
667+
663668
{!subscription.isFree && invoices.length > 0 && (
664669
<SettingsSection label='Invoices'>
665670
<div className='-mx-2 flex flex-col gap-y-0.5'>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use client'
2+
3+
import { Badge } from '@sim/emcn'
4+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
5+
import { useOrganizationAwsMarketplaceLicenses } from '@/hooks/queries/aws-marketplace'
6+
7+
function formatDate(iso: string): string {
8+
return new Date(iso).toLocaleDateString()
9+
}
10+
11+
interface AwsMarketplaceSubscriptionSectionProps {
12+
organizationId: string
13+
}
14+
15+
/** Read-only: AWS requires buyers to see their subscription status in the product. */
16+
export function AwsMarketplaceSubscriptionSection({
17+
organizationId,
18+
}: AwsMarketplaceSubscriptionSectionProps) {
19+
const { data: licenses } = useOrganizationAwsMarketplaceLicenses(organizationId)
20+
if (!licenses?.length) return null
21+
22+
return (
23+
<SettingsSection label='AWS Marketplace subscription'>
24+
<div className='flex flex-col gap-6'>
25+
{licenses.map((license) => {
26+
const rows = [
27+
{ label: 'Plan', value: license.dimension ?? 'Not specified' },
28+
{ label: 'Quantity', value: license.quantity?.toLocaleString() ?? 'Not specified' },
29+
{
30+
label: 'Expires',
31+
value: license.expiresAt ? formatDate(license.expiresAt) : 'No expiry',
32+
},
33+
{ label: 'Last verified', value: formatDate(license.lastVerifiedAt) },
34+
]
35+
return (
36+
<div key={license.id} className='flex flex-col gap-4'>
37+
<div className='flex items-center justify-between'>
38+
<span className='text-[var(--text-body)] text-small'>Status</span>
39+
<Badge variant={license.status === 'active' ? 'green' : 'gray'} size='sm'>
40+
{license.status === 'active' ? 'Active' : 'Inactive'}
41+
</Badge>
42+
</div>
43+
{rows.map((row) => (
44+
<div key={row.label} className='flex items-center justify-between'>
45+
<span className='text-[var(--text-body)] text-small'>{row.label}</span>
46+
<span className='text-[var(--text-muted)] text-small'>{row.value}</span>
47+
</div>
48+
))}
49+
</div>
50+
)
51+
})}
52+
</div>
53+
</SettingsSection>
54+
)
55+
}

0 commit comments

Comments
 (0)