Skip to content

Commit e99cece

Browse files
committed
feat(aws-marketplace): sell several listings from one integration through a product catalog
1 parent 56f7ed7 commit e99cece

17 files changed

Lines changed: 365 additions & 116 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { generateShortId } from '@sim/utils/id'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { verifyCronAuth } from '@/lib/auth/internal'
5-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
5+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
66
import { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile'
77
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
88
import { runDetached } from '@/lib/core/utils/background'
@@ -20,15 +20,15 @@ export const dynamic = 'force-dynamic'
2020
* Applies AWS Marketplace agreement events from the SQS queue and re-verifies licenses no event
2121
* touched within a day. Acknowledges the cron call at once and works in the background; a Redis
2222
* lock keeps runs from overlapping, and every write is idempotent, so a replayed run is harmless.
23-
* A no-op on deployments without `AWS_MARKETPLACE_PRODUCT_CODE`.
23+
* A no-op on deployments without `AWS_MARKETPLACE_PRODUCTS`.
2424
*/
2525
export const GET = withRouteHandler(async (request: NextRequest) => {
2626
const authError = verifyCronAuth(request, 'AWS Marketplace reconcile')
2727
if (authError) {
2828
return authError
2929
}
3030

31-
if (!getAwsMarketplaceProductCode()) {
31+
if (!isAwsMarketplaceEnabled()) {
3232
return NextResponse.json({
3333
success: true,
3434
message: 'AWS Marketplace is not configured – skipped',

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace'
55
import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim'
6-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
6+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
77
import {
88
AWS_MARKETPLACE_ONBOARDING_PATH,
99
type AwsMarketplaceOnboardingError,
@@ -59,8 +59,7 @@ async function readRegistrationToken(request: NextRequest): Promise<string | nul
5959
* the claim cookie unless the license lapsed. The token is never logged.
6060
*/
6161
export const POST = withRouteHandler(async (request: NextRequest) => {
62-
const productCode = getAwsMarketplaceProductCode()
63-
if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 })
62+
if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 })
6463

6564
try {
6665
const rateLimited = await enforceIpRateLimit(
@@ -76,7 +75,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7675
return redirectToOnboarding('invalid')
7776
}
7877

79-
const outcome = await registerAwsMarketplaceBuyer(productCode, token)
78+
const outcome = await registerAwsMarketplaceBuyer(token)
8079
if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind)
8180

8281
const response = redirectToOnboarding()
@@ -91,7 +90,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9190

9291
/** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */
9392
export const GET = withRouteHandler(async () => {
94-
if (!getAwsMarketplaceProductCode())
95-
return NextResponse.json({ error: 'Not found' }, { status: 404 })
93+
if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 })
9694
return redirectToOnboarding()
9795
})

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
linkAwsMarketplaceLicenseAsAdmin,
1010
listUnlinkedAwsMarketplaceLicenses,
1111
} from '@/lib/billing/aws-marketplace/application/admin'
12-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
12+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
1313
import { asOrchestrationError } from '@/lib/core/orchestration/types'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1515
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
@@ -29,7 +29,7 @@ const logger = createLogger('AdminAwsMarketplaceLicensesAPI')
2929
/** The Marketplace licenses no organization holds yet. */
3030
export const GET = withRouteHandler(
3131
withAdminAuth(async (request) => {
32-
if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace')
32+
if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace')
3333
const parsed = await parseRequest(
3434
adminDashboardListAwsMarketplaceLicensesContract,
3535
request,
@@ -56,7 +56,7 @@ export const GET = withRouteHandler(
5656
/** Links a Marketplace license to an organization once the team has provisioned it. */
5757
export const POST = withRouteHandler(
5858
withAdminAuth(async (request) => {
59-
if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace')
59+
if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace')
6060
const parsed = await parseRequest(
6161
adminDashboardLinkAwsMarketplaceLicenseContract,
6262
request,

‎apps/sim/app/marketplace/aws/page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Metadata } from 'next'
22
import { notFound } from 'next/navigation'
3-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
3+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
44
import { AWS_MARKETPLACE_ONBOARDING_ERRORS } from '@/lib/billing/aws-marketplace/onboarding'
55
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
66
import { AwsMarketplaceOnboarding } from '@/app/marketplace/aws/aws-marketplace-onboarding'
@@ -18,7 +18,7 @@ interface AwsMarketplacePageProps {
1818

1919
/** Where AWS Marketplace buyers land after registration, to link their subscription. */
2020
export default async function AwsMarketplacePage({ searchParams }: AwsMarketplacePageProps) {
21-
if (!getAwsMarketplaceProductCode()) notFound()
21+
if (!isAwsMarketplaceEnabled()) notFound()
2222
const { error } = await searchParams
2323
const registrationError = AWS_MARKETPLACE_ONBOARDING_ERRORS.find((code) => code === error) ?? null
2424
return (

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ 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'
6+
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
77
import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository'
88
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
99
import {
@@ -64,7 +64,7 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise
6464
* license lookup fails.
6565
*/
6666
async function assertNotBilledThroughMarketplace(referenceId: string): Promise<void> {
67-
if (!getAwsMarketplaceProductCode()) return
67+
if (!isAwsMarketplaceEnabled()) return
6868
if (!(await holdsMarketplaceLicense(referenceId))) return
6969

7070
logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId })

‎apps/sim/lib/billing/aws-marketplace/application/use-cases.ts‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { db } from '@sim/db'
33
import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace'
44
import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations'
55
import { hashClaimToken } from '@/lib/billing/aws-marketplace/claim'
6-
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
6+
import {
7+
findAwsMarketplaceProduct,
8+
isAwsMarketplaceEnabled,
9+
} from '@/lib/billing/aws-marketplace/config'
710
import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements'
811
import {
912
type AwsMarketplaceLicenseRecord,
@@ -27,10 +30,15 @@ import { requireOrganizationSubjectMembership } from '@/lib/core/application/org
2730
import { OrchestrationError } from '@/lib/core/orchestration/types'
2831

2932
/** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */
30-
function requireProductCode(): string {
31-
const productCode = getAwsMarketplaceProductCode()
32-
if (!productCode) throw new OrchestrationError('not_found', 'Not found')
33-
return productCode
33+
function requireMarketplaceEnabled(): void {
34+
if (!isAwsMarketplaceEnabled()) throw new OrchestrationError('not_found', 'Not found')
35+
}
36+
37+
/** The listing a license was sold under; one this deployment no longer sells reads as unknown. */
38+
function requireLicenseProduct(license: AwsMarketplaceLicenseRecord) {
39+
const product = findAwsMarketplaceProduct(license.productCode)
40+
if (!product) throw new OrchestrationError('not_found', 'Not found')
41+
return product
3442
}
3543

3644
function isClaimOpen(license: AwsMarketplaceLicenseRecord, now: Date): boolean {
@@ -115,7 +123,7 @@ export const readAwsMarketplaceClaim: OperationUseCase<
115123
operation: awsMarketplaceOperations.readClaim,
116124
async execute({ principal, input }) {
117125
assertOperationPrincipal(principal, awsMarketplaceOperations.readClaim)
118-
requireProductCode()
126+
requireMarketplaceEnabled()
119127
if (!input.claimToken) return { status: 'missing' }
120128
const license = await findLicenseByClaimHash(hashClaimToken(input.claimToken))
121129
if (!license || !isClaimOpen(license, new Date())) return { status: 'missing' }
@@ -169,7 +177,7 @@ function contactOf(input: BuyerContact) {
169177
* rechecks the claim under the row lock, so two tabs racing one claim take it at most once.
170178
*/
171179
async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) {
172-
const productCode = requireProductCode()
180+
requireMarketplaceEnabled()
173181
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
174182
const claimed = requireOpenClaim(
175183
claimHash ? await findLicenseByClaimHash(claimHash) : null,
@@ -179,12 +187,12 @@ async function verifyClaimedLicense(input: ClaimInput, organizationId: string |
179187
requireLinkableTo(claimed, organizationId)
180188

181189
const verifiedAt = new Date()
182-
const summary = await verifyLicense(productCode, claimed.licenseArn)
190+
const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
183191
const verification = { summary, verifiedAt }
184192
if (!summary.active) {
185193
const outcome = await recordLicenseVerification({
186194
licenseArn: claimed.licenseArn,
187-
productCode,
195+
productCode: claimed.productCode,
188196
verification,
189197
})
190198
throw new OrchestrationError(
@@ -321,15 +329,15 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase<
321329
operation: awsMarketplaceOperations.saveClaimContact,
322330
async execute({ principal, input }) {
323331
assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact)
324-
const productCode = requireProductCode()
332+
requireMarketplaceEnabled()
325333
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
326334
const claimed = requireOpenClaim(
327335
claimHash ? await findLicenseByClaimHash(claimHash) : null,
328336
input.licenseId,
329337
claimHash
330338
)
331339
const verifiedAt = new Date()
332-
const summary = await verifyLicense(productCode, claimed.licenseArn)
340+
const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
333341
return db.transaction(async (tx) => {
334342
const locked = requireOpenClaim(
335343
await lockLicense(tx, input.licenseId),
@@ -360,7 +368,7 @@ function toLicenseStatusList(rows: Awaited<ReturnType<typeof listOrganizationLic
360368
export const listOrganizationAwsMarketplaceLicenses = defineAuthorizedOrganizationUseCase({
361369
operation: awsMarketplaceOperations.listOrganizationLicenses,
362370
async execute({ context }: OrganizationUseCaseContext<{ organizationId: string }>) {
363-
if (!getAwsMarketplaceProductCode()) return []
371+
if (!isAwsMarketplaceEnabled()) return []
364372
return toLicenseStatusList(await listOrganizationLicenses(context.organizationId))
365373
},
366374
})
@@ -374,7 +382,7 @@ export const listAccountAwsMarketplaceLicenses: OperationUseCase<
374382
operation: awsMarketplaceOperations.listAccountLicenses,
375383
async execute({ principal }) {
376384
assertOperationPrincipal(principal, awsMarketplaceOperations.listAccountLicenses)
377-
if (!getAwsMarketplaceProductCode()) return []
385+
if (!isAwsMarketplaceEnabled()) return []
378386
return toLicenseStatusList(await listAccountLicenses(principal.userId))
379387
},
380388
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const TEAM_PLAN = 'team_6000'
1111
const productCode = vi.hoisted(() => ({ current: 'product-placeholder' as string | null }))
1212

1313
vi.mock('@/lib/billing/aws-marketplace/config', () => ({
14-
getAwsMarketplaceProductCode: () => productCode.current,
14+
isAwsMarketplaceEnabled: () => productCode.current !== null,
1515
}))
1616

1717
import {
Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
1+
import {
2+
type AwsMarketplaceProduct,
3+
parseAwsMarketplaceProducts,
4+
} from '@/lib/billing/aws-marketplace/products'
15
import { env } from '@/lib/core/config/env'
26

7+
/** Parsed once per distinct setting; env validation already rejected a malformed one at boot. */
8+
let parsed: { raw: string | undefined; products: ReadonlyMap<string, AwsMarketplaceProduct> } = {
9+
raw: undefined,
10+
products: new Map(),
11+
}
12+
313
/**
4-
* The SaaS product code, or null when this deployment does not sell through AWS Marketplace:
5-
* self-hosted installs and every environment but production. Null turns the registration,
6-
* claim, and reconcile surfaces off.
14+
* The products this deployment sells through AWS Marketplace, by product code. Empty on
15+
* self-hosted installs and every environment but production, which turns the registration, claim,
16+
* and reconcile surfaces off.
717
*/
8-
export function getAwsMarketplaceProductCode(): string | null {
9-
return env.AWS_MARKETPLACE_PRODUCT_CODE ?? null
18+
export function getAwsMarketplaceProducts(): ReadonlyMap<string, AwsMarketplaceProduct> {
19+
const raw = env.AWS_MARKETPLACE_PRODUCTS
20+
if (parsed.raw !== raw) {
21+
parsed = { raw, products: raw ? parseAwsMarketplaceProducts(raw) : new Map() }
22+
}
23+
return parsed.products
24+
}
25+
26+
export function isAwsMarketplaceEnabled(): boolean {
27+
return getAwsMarketplaceProducts().size > 0
28+
}
29+
30+
/** The configured product for a code AWS returned or a license row stored; null for any other. */
31+
export function findAwsMarketplaceProduct(
32+
productCode: string | null | undefined
33+
): AwsMarketplaceProduct | null {
34+
return productCode ? (getAwsMarketplaceProducts().get(productCode) ?? null) : null
35+
}
36+
37+
/** Every configured product code, for queries that span all of them. */
38+
export function getAwsMarketplaceProductCodes(): string[] {
39+
return [...getAwsMarketplaceProducts().keys()]
1040
}

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

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,40 @@ vi.mock('@aws-sdk/client-marketplace-entitlement-service', () => ({
1313
}))
1414

1515
import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements'
16-
17-
const PRODUCT = 'product-placeholder'
16+
import {
17+
AWS_MARKETPLACE_PLANS,
18+
type AwsMarketplaceProduct,
19+
} from '@/lib/billing/aws-marketplace/products'
20+
21+
const CONTRACT: AwsMarketplaceProduct = {
22+
productCode: 'product-placeholder',
23+
planId: 'contract',
24+
plan: AWS_MARKETPLACE_PLANS.contract,
25+
}
26+
const USAGE: AwsMarketplaceProduct = {
27+
productCode: 'usage-product-placeholder',
28+
planId: 'platform-with-usage',
29+
plan: AWS_MARKETPLACE_PLANS['platform-with-usage'],
30+
}
1831
const LICENSE = 'license-placeholder'
1932
const NOW = new Date('2026-06-01T00:00:00.000Z')
2033

2134
function entitlement(overrides: Partial<Entitlement> = {}): Entitlement {
2235
return {
23-
ProductCode: PRODUCT,
36+
ProductCode: CONTRACT.productCode,
2437
Dimension: 'enterprise',
2538
Value: { IntegerValue: 25 },
2639
...overrides,
2740
}
2841
}
2942

30-
function read(...entitlements: Entitlement[]) {
43+
function readAs(product: AwsMarketplaceProduct, ...entitlements: Entitlement[]) {
3144
mockSend.mockResolvedValueOnce({ Entitlements: entitlements })
32-
return verifyLicense(PRODUCT, LICENSE)
45+
return verifyLicense(product, LICENSE)
46+
}
47+
48+
function read(...entitlements: Entitlement[]) {
49+
return readAs(CONTRACT, ...entitlements)
3350
}
3451

3552
describe('verifyLicense', () => {
@@ -85,6 +102,29 @@ describe('verifyLicense', () => {
85102
expect(summary).toMatchObject({ active: true, dimension: 'b-current', quantity: 50 })
86103
})
87104

105+
it('does not let a current usage entitlement stand in for a required platform one', async () => {
106+
const summary = await readAs(USAGE, entitlement({ Dimension: 'llm_credits' }))
107+
expect(summary).toMatchObject({ active: false, entitlementCount: 1, dimension: null })
108+
})
109+
110+
it('reads a lapsed platform as inactive even while usage is current', async () => {
111+
const summary = await readAs(
112+
USAGE,
113+
entitlement({ Dimension: 'llm_credits' }),
114+
entitlement({ Dimension: 'platform', ExpirationDate: new Date('2026-01-01T00:00:00.000Z') })
115+
)
116+
expect(summary).toMatchObject({ active: false, entitlementCount: 2, dimension: 'platform' })
117+
})
118+
119+
it('reads a current platform as active and describes it, not the usage dimension', async () => {
120+
const summary = await readAs(
121+
USAGE,
122+
entitlement({ Dimension: 'llm_credits', Value: { IntegerValue: 7 } }),
123+
entitlement({ Dimension: 'platform', Value: { IntegerValue: 1 } })
124+
)
125+
expect(summary).toMatchObject({ active: true, dimension: 'platform', quantity: 1 })
126+
})
127+
88128
it('reads quantity only from an integer value, never from another value kind', async () => {
89129
expect((await read(entitlement({ Value: { DoubleValue: 1200.5 } }))).quantity).toBeNull()
90130
})
@@ -95,7 +135,7 @@ describe('verifyLicense', () => {
95135
.mockResolvedValueOnce({ Entitlements: [], NextToken: 't2' })
96136
.mockResolvedValueOnce({ Entitlements: [entitlement({ Dimension: 'two' })] })
97137

98-
const summary = await verifyLicense(PRODUCT, LICENSE)
138+
const summary = await verifyLicense(CONTRACT, LICENSE)
99139

100140
expect(summary).toMatchObject({ entitlementCount: 2, dimension: 'one' })
101141
expect(mockSend.mock.calls.map(([command]) => command.input.NextToken)).toEqual([

0 commit comments

Comments
 (0)