diff --git a/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts b/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts index b25b37572af..cdf42349fce 100644 --- a/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts +++ b/apps/sim/app/api/cron/aws-marketplace-reconcile/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { reconcileAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/reconcile' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' @@ -20,7 +20,7 @@ export const dynamic = 'force-dynamic' * Applies AWS Marketplace agreement events from the SQS queue and re-verifies licenses no event * touched within a day. Acknowledges the cron call at once and works in the background; a Redis * lock keeps runs from overlapping, and every write is idempotent, so a replayed run is harmless. - * A no-op on deployments without `AWS_MARKETPLACE_PRODUCT_CODE`. + * A no-op on deployments without `AWS_MARKETPLACE_PRODUCTS`. */ export const GET = withRouteHandler(async (request: NextRequest) => { const authError = verifyCronAuth(request, 'AWS Marketplace reconcile') @@ -28,7 +28,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return authError } - if (!getAwsMarketplaceProductCode()) { + if (!isAwsMarketplaceEnabled()) { return NextResponse.json({ success: true, message: 'AWS Marketplace is not configured – skipped', diff --git a/apps/sim/app/api/marketplace/aws/register/route.ts b/apps/sim/app/api/marketplace/aws/register/route.ts index 0c4181072ae..796e54feefa 100644 --- a/apps/sim/app/api/marketplace/aws/register/route.ts +++ b/apps/sim/app/api/marketplace/aws/register/route.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace' import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { AWS_MARKETPLACE_ONBOARDING_PATH, type AwsMarketplaceOnboardingError, @@ -59,8 +59,7 @@ async function readRegistrationToken(request: NextRequest): Promise { - const productCode = getAwsMarketplaceProductCode() - if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 }) try { const rateLimited = await enforceIpRateLimit( @@ -76,7 +75,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return redirectToOnboarding('invalid') } - const outcome = await registerAwsMarketplaceBuyer(productCode, token) + const outcome = await registerAwsMarketplaceBuyer(token) if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind) const response = redirectToOnboarding() @@ -91,7 +90,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { /** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */ export const GET = withRouteHandler(async () => { - if (!getAwsMarketplaceProductCode()) - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + if (!isAwsMarketplaceEnabled()) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return redirectToOnboarding() }) diff --git a/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts b/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts index fb549dc5e7b..edde3610a67 100644 --- a/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts +++ b/apps/sim/app/api/v1/admin/dashboard/aws-marketplace-licenses/route.ts @@ -9,7 +9,7 @@ import { linkAwsMarketplaceLicenseAsAdmin, listUnlinkedAwsMarketplaceLicenses, } from '@/lib/billing/aws-marketplace/application/admin' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor' @@ -29,7 +29,7 @@ const logger = createLogger('AdminAwsMarketplaceLicensesAPI') /** The Marketplace licenses no organization holds yet. */ export const GET = withRouteHandler( withAdminAuth(async (request) => { - if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace') + if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace') const parsed = await parseRequest( adminDashboardListAwsMarketplaceLicensesContract, request, @@ -56,7 +56,7 @@ export const GET = withRouteHandler( /** Links a Marketplace license to an organization once the team has provisioned it. */ export const POST = withRouteHandler( withAdminAuth(async (request) => { - if (!getAwsMarketplaceProductCode()) return adminNotFoundResponse('AWS Marketplace') + if (!isAwsMarketplaceEnabled()) return adminNotFoundResponse('AWS Marketplace') const parsed = await parseRequest( adminDashboardLinkAwsMarketplaceLicenseContract, request, diff --git a/apps/sim/app/marketplace/aws/page.tsx b/apps/sim/app/marketplace/aws/page.tsx index 2ad232d3ba5..3f870179f35 100644 --- a/apps/sim/app/marketplace/aws/page.tsx +++ b/apps/sim/app/marketplace/aws/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { AWS_MARKETPLACE_ONBOARDING_ERRORS } from '@/lib/billing/aws-marketplace/onboarding' import { isRegistrationDisabled } from '@/lib/core/config/env-flags' import { AwsMarketplaceOnboarding } from '@/app/marketplace/aws/aws-marketplace-onboarding' @@ -18,7 +18,7 @@ interface AwsMarketplacePageProps { /** Where AWS Marketplace buyers land after registration, to link their subscription. */ export default async function AwsMarketplacePage({ searchParams }: AwsMarketplacePageProps) { - if (!getAwsMarketplaceProductCode()) notFound() + if (!isAwsMarketplaceEnabled()) notFound() const { error } = await searchParams const registrationError = AWS_MARKETPLACE_ONBOARDING_ERRORS.find((code) => code === error) ?? null return ( diff --git a/apps/sim/bootstrap.ts b/apps/sim/bootstrap.ts index be7987c8f54..5b2e7d6cee2 100644 --- a/apps/sim/bootstrap.ts +++ b/apps/sim/bootstrap.ts @@ -1,6 +1,8 @@ /** - * Container entrypoint. Hydrates `process.env` from the runtime secret, resolves an AWS - * Marketplace Enterprise license into `ENTERPRISE_ENABLED` on Marketplace installs, and drops + * Container entrypoint. Hydrates `process.env` from the runtime secret, refuses to start on a + * malformed `AWS_MARKETPLACE_PRODUCTS` (so a bad deploy rolls back instead of failing buyers and + * checkouts later), resolves an AWS Marketplace Enterprise license into `ENTERPRISE_ENABLED` on + * Marketplace installs, and drops * Next's route cache when an earlier build wrote it, before loading the Next.js standalone server, * so application modules that read env at import time see the full configuration. See * `@sim/runtime-secrets`. @@ -8,9 +10,13 @@ import { fileURLToPath } from 'node:url' import { loadRuntimeSecrets } from '@sim/runtime-secrets' import { applyAwsMarketplaceContainerLicense } from '@/lib/billing/aws-marketplace/container-license' +import { parseAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/products' import { clearRouteCacheFromOtherBuild } from '@/lib/core/utils/route-cache' await loadRuntimeSecrets() +if (process.env.AWS_MARKETPLACE_PRODUCTS) { + parseAwsMarketplaceProducts(process.env.AWS_MARKETPLACE_PRODUCTS) +} await applyAwsMarketplaceContainerLicense() await clearRouteCacheFromOtherBuild(fileURLToPath(new URL('./.next', import.meta.url))) // `server.js` is the Next standalone build artifact, a sibling of this file in diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index da71750cd50..51d4597cc45 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -3,7 +3,7 @@ import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { APIError } from 'better-auth/api' import { and, eq, isNotNull } from 'drizzle-orm' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config' import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { @@ -64,7 +64,7 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise * license lookup fails. */ async function assertNotBilledThroughMarketplace(referenceId: string): Promise { - if (!getAwsMarketplaceProductCode()) return + if (!isAwsMarketplaceEnabled()) return if (!(await holdsMarketplaceLicense(referenceId))) return logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId }) diff --git a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts index 4e1d3ec5383..33ff2edc216 100644 --- a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts +++ b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts @@ -3,7 +3,10 @@ import { db } from '@sim/db' import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace' import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations' import { hashClaimToken } from '@/lib/billing/aws-marketplace/claim' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { + findAwsMarketplaceProduct, + isAwsMarketplaceEnabled, +} from '@/lib/billing/aws-marketplace/config' import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' import { type AwsMarketplaceLicenseRecord, @@ -27,10 +30,15 @@ import { requireOrganizationSubjectMembership } from '@/lib/core/application/org import { OrchestrationError } from '@/lib/core/orchestration/types' /** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */ -function requireProductCode(): string { - const productCode = getAwsMarketplaceProductCode() - if (!productCode) throw new OrchestrationError('not_found', 'Not found') - return productCode +function requireMarketplaceEnabled(): void { + if (!isAwsMarketplaceEnabled()) throw new OrchestrationError('not_found', 'Not found') +} + +/** The listing a license was sold under; one this deployment no longer sells reads as unknown. */ +function requireLicenseProduct(license: AwsMarketplaceLicenseRecord) { + const product = findAwsMarketplaceProduct(license.productCode) + if (!product) throw new OrchestrationError('not_found', 'Not found') + return product } function isClaimOpen(license: AwsMarketplaceLicenseRecord, now: Date): boolean { @@ -115,7 +123,7 @@ export const readAwsMarketplaceClaim: OperationUseCase< operation: awsMarketplaceOperations.readClaim, async execute({ principal, input }) { assertOperationPrincipal(principal, awsMarketplaceOperations.readClaim) - requireProductCode() + requireMarketplaceEnabled() if (!input.claimToken) return { status: 'missing' } const license = await findLicenseByClaimHash(hashClaimToken(input.claimToken)) if (!license || !isClaimOpen(license, new Date())) return { status: 'missing' } @@ -169,7 +177,7 @@ function contactOf(input: BuyerContact) { * rechecks the claim under the row lock, so two tabs racing one claim take it at most once. */ async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) { - const productCode = requireProductCode() + requireMarketplaceEnabled() const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null const claimed = requireOpenClaim( claimHash ? await findLicenseByClaimHash(claimHash) : null, @@ -179,12 +187,12 @@ async function verifyClaimedLicense(input: ClaimInput, organizationId: string | requireLinkableTo(claimed, organizationId) const verifiedAt = new Date() - const summary = await verifyLicense(productCode, claimed.licenseArn) + const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn) const verification = { summary, verifiedAt } if (!summary.active) { const outcome = await recordLicenseVerification({ licenseArn: claimed.licenseArn, - productCode, + productCode: claimed.productCode, verification, }) throw new OrchestrationError( @@ -321,7 +329,7 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase< operation: awsMarketplaceOperations.saveClaimContact, async execute({ principal, input }) { assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact) - const productCode = requireProductCode() + requireMarketplaceEnabled() const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null const claimed = requireOpenClaim( claimHash ? await findLicenseByClaimHash(claimHash) : null, @@ -329,7 +337,7 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase< claimHash ) const verifiedAt = new Date() - const summary = await verifyLicense(productCode, claimed.licenseArn) + const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn) return db.transaction(async (tx) => { const locked = requireOpenClaim( await lockLicense(tx, input.licenseId), @@ -360,7 +368,7 @@ function toLicenseStatusList(rows: Awaited) { - if (!getAwsMarketplaceProductCode()) return [] + if (!isAwsMarketplaceEnabled()) return [] return toLicenseStatusList(await listOrganizationLicenses(context.organizationId)) }, }) @@ -374,7 +382,7 @@ export const listAccountAwsMarketplaceLicenses: OperationUseCase< operation: awsMarketplaceOperations.listAccountLicenses, async execute({ principal }) { assertOperationPrincipal(principal, awsMarketplaceOperations.listAccountLicenses) - if (!getAwsMarketplaceProductCode()) return [] + if (!isAwsMarketplaceEnabled()) return [] return toLicenseStatusList(await listAccountLicenses(principal.userId)) }, } diff --git a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts index 4a7ff2aeaa5..e871acd212a 100644 --- a/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts +++ b/apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts @@ -8,10 +8,10 @@ const PRODUCT = 'product-placeholder' const ACCOUNT = '000000000000' const TEAM_PLAN = 'team_6000' -const productCode = vi.hoisted(() => ({ current: 'product-placeholder' as string | null })) +const marketplace = vi.hoisted(() => ({ enabled: true })) vi.mock('@/lib/billing/aws-marketplace/config', () => ({ - getAwsMarketplaceProductCode: () => productCode.current, + isAwsMarketplaceEnabled: () => marketplace.enabled, })) import { @@ -84,7 +84,7 @@ async function organizationWithLicense(status: LicenseStatus | null) { } afterEach(() => { - productCode.current = PRODUCT + marketplace.enabled = true }) afterAll(async () => { @@ -147,7 +147,7 @@ describe('Stripe checkout for an AWS Marketplace-billed reference', () => { const { ownerId, organizationId } = await organizationWithLicense('active') const userId = await createUser() await insertLicense({ organizationId: null, linkedByUserId: userId, status: 'active' }) - productCode.current = null + marketplace.enabled = false await expect( authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN) diff --git a/apps/sim/lib/billing/aws-marketplace/config.ts b/apps/sim/lib/billing/aws-marketplace/config.ts index 778a029e5b0..9a3ff092924 100644 --- a/apps/sim/lib/billing/aws-marketplace/config.ts +++ b/apps/sim/lib/billing/aws-marketplace/config.ts @@ -1,10 +1,30 @@ +import { + type AwsMarketplaceProduct, + parseAwsMarketplaceProducts, +} from '@/lib/billing/aws-marketplace/products' import { env } from '@/lib/core/config/env' +let products: ReadonlyMap | undefined + /** - * The SaaS product code, or null when this deployment does not sell through AWS Marketplace: - * self-hosted installs and every environment but production. Null turns the registration, - * claim, and reconcile surfaces off. + * The products this deployment sells through AWS Marketplace, by product code. Empty on + * self-hosted installs and every environment but production, which turns the Marketplace surfaces + * off. The container entrypoint refuses to start on a malformed setting. */ -export function getAwsMarketplaceProductCode(): string | null { - return env.AWS_MARKETPLACE_PRODUCT_CODE ?? null +export function getAwsMarketplaceProducts(): ReadonlyMap { + products ??= env.AWS_MARKETPLACE_PRODUCTS + ? parseAwsMarketplaceProducts(env.AWS_MARKETPLACE_PRODUCTS) + : new Map() + return products +} + +export function isAwsMarketplaceEnabled(): boolean { + return getAwsMarketplaceProducts().size > 0 +} + +/** The configured product for a code AWS returned or a license row stored; null for any other. */ +export function findAwsMarketplaceProduct( + productCode: string | null | undefined +): AwsMarketplaceProduct | null { + return productCode ? (getAwsMarketplaceProducts().get(productCode) ?? null) : null } diff --git a/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts b/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts index 56088443a8c..eba529d05da 100644 --- a/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts +++ b/apps/sim/lib/billing/aws-marketplace/entitlements.test.ts @@ -13,23 +13,42 @@ vi.mock('@aws-sdk/client-marketplace-entitlement-service', () => ({ })) import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' +import { + type AwsMarketplaceProduct, + parseAwsMarketplaceProducts, +} from '@/lib/billing/aws-marketplace/products' + +const PRODUCTS = parseAwsMarketplaceProducts( + 'product-placeholder=contract,usage-product-placeholder=platform-with-usage' +) + +function product(code: string): AwsMarketplaceProduct { + const found = PRODUCTS.get(code) + if (!found) throw new Error(`no test product ${code}`) + return found +} -const PRODUCT = 'product-placeholder' +const CONTRACT = product('product-placeholder') +const USAGE = product('usage-product-placeholder') const LICENSE = 'license-placeholder' const NOW = new Date('2026-06-01T00:00:00.000Z') function entitlement(overrides: Partial = {}): Entitlement { return { - ProductCode: PRODUCT, + ProductCode: CONTRACT.productCode, Dimension: 'enterprise', Value: { IntegerValue: 25 }, ...overrides, } } -function read(...entitlements: Entitlement[]) { +function readAs(product: AwsMarketplaceProduct, ...entitlements: Entitlement[]) { mockSend.mockResolvedValueOnce({ Entitlements: entitlements }) - return verifyLicense(PRODUCT, LICENSE) + return verifyLicense(product, LICENSE) +} + +function read(...entitlements: Entitlement[]) { + return readAs(CONTRACT, ...entitlements) } describe('verifyLicense', () => { @@ -47,7 +66,7 @@ describe('verifyLicense', () => { const summary = await read( entitlement({ ExpirationDate: new Date('2026-05-31T23:59:59.000Z') }) ) - expect(summary).toMatchObject({ active: false, entitlementCount: 1, quantity: 25 }) + expect(summary).toMatchObject({ active: false, accessReported: true, quantity: 25 }) }) it('reads an entitlement with a future expiry, or none at all, as active', async () => { @@ -85,6 +104,34 @@ describe('verifyLicense', () => { expect(summary).toMatchObject({ active: true, dimension: 'b-current', quantity: 50 }) }) + it('reads a usage entitlement alone as no answer about access, not as access', async () => { + const summary = await readAs(USAGE, entitlement({ Dimension: 'llm_credits' })) + expect(summary).toMatchObject({ + active: false, + licenseSeen: true, + accessReported: false, + dimension: null, + }) + }) + + it('reads a lapsed platform as inactive even while usage is current', async () => { + const summary = await readAs( + USAGE, + entitlement({ Dimension: 'llm_credits' }), + entitlement({ Dimension: 'platform', ExpirationDate: new Date('2026-01-01T00:00:00.000Z') }) + ) + expect(summary).toMatchObject({ active: false, accessReported: true, dimension: 'platform' }) + }) + + it('reads a current platform as active and describes it, not the usage dimension', async () => { + const summary = await readAs( + USAGE, + entitlement({ Dimension: 'llm_credits', Value: { IntegerValue: 7 } }), + entitlement({ Dimension: 'platform', Value: { IntegerValue: 1 } }) + ) + expect(summary).toMatchObject({ active: true, dimension: 'platform', quantity: 1 }) + }) + it('reads quantity only from an integer value, never from another value kind', async () => { expect((await read(entitlement({ Value: { DoubleValue: 1200.5 } }))).quantity).toBeNull() }) @@ -95,9 +142,9 @@ describe('verifyLicense', () => { .mockResolvedValueOnce({ Entitlements: [], NextToken: 't2' }) .mockResolvedValueOnce({ Entitlements: [entitlement({ Dimension: 'two' })] }) - const summary = await verifyLicense(PRODUCT, LICENSE) + const summary = await verifyLicense(CONTRACT, LICENSE) - expect(summary).toMatchObject({ entitlementCount: 2, dimension: 'one' }) + expect(summary).toMatchObject({ accessReported: true, dimension: 'one' }) expect(mockSend.mock.calls.map(([command]) => command.input.NextToken)).toEqual([ undefined, 't1', diff --git a/apps/sim/lib/billing/aws-marketplace/entitlements.ts b/apps/sim/lib/billing/aws-marketplace/entitlements.ts index 0b8ef4e454c..47c5dbb085b 100644 --- a/apps/sim/lib/billing/aws-marketplace/entitlements.ts +++ b/apps/sim/lib/billing/aws-marketplace/entitlements.ts @@ -4,16 +4,26 @@ import { } from '@aws-sdk/client-marketplace-entitlement-service' import { compareStrings } from '@sim/utils/string' import { getEntitlementClient } from '@/lib/billing/aws-marketplace/client' +import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products' /** A license carries one entitlement per purchased dimension; this bounds a runaway `NextToken`. */ const MAX_PAGES = 20 /** What the last Entitlement Service read says about one license. */ export interface EntitlementSummary { - /** At least one entitlement has no expiry or expires in the future. */ + /** + * The product's required dimension (any dimension, for a plan with none) has an entitlement with + * no expiry or one in the future. + */ active: boolean - /** Entitlements AWS returned, current or lapsed; zero is ambiguous, see the license's status. */ - entitlementCount: number + /** AWS returned an entitlement of any dimension: the license exists and names its account. */ + licenseSeen: boolean + /** + * AWS returned an entitlement, current or lapsed, for the dimension that grants access (any + * dimension, for a plan with none). A usage dimension alone is not an answer about access, and + * no answer is ambiguous: see the license's status. + */ + accessReported: boolean dimension: string | null quantity: number | null expiresAt: Date | null @@ -25,15 +35,20 @@ function isCurrent(entitlement: Entitlement, now: Date): boolean { } /** - * Reduces a license's entitlements to the one the row records. A current entitlement wins, the - * lowest dimension name first so repeated reads agree; with none current, the latest-expiring one - * still describes what lapsed. Quantity is read only from an integer value: a contract's dollar - * amount is never inferred from it. + * Reduces a license's entitlements to the one the row records. Only the plan's required dimension + * counts, when it names one; usage dimensions on the same license never grant access. A current + * entitlement wins, the lowest dimension name first so repeated reads agree; with none current, + * the latest-expiring one still describes what lapsed. Quantity is read only from an integer + * value: a contract's dollar amount is never inferred from it. */ function summarizeEntitlements( - entitlements: readonly Entitlement[], + allEntitlements: readonly Entitlement[], + requiredDimension: string | null, now: Date ): EntitlementSummary { + const entitlements = requiredDimension + ? allEntitlements.filter((entitlement) => entitlement.Dimension === requiredDimension) + : allEntitlements const current = entitlements .filter((entitlement) => isCurrent(entitlement, now)) .sort((left, right) => compareStrings(left.Dimension ?? '', right.Dimension ?? '')) @@ -45,11 +60,13 @@ function summarizeEntitlements( const chosen = current[0] ?? lapsed[0] return { active: current.length > 0, - entitlementCount: entitlements.length, + licenseSeen: allEntitlements.length > 0, + accessReported: entitlements.length > 0, dimension: chosen?.Dimension ?? null, quantity: chosen?.Value?.IntegerValue ?? null, expiresAt: chosen?.ExpirationDate ?? null, - customerAwsAccountId: chosen?.CustomerAWSAccountId ?? null, + customerAwsAccountId: + chosen?.CustomerAWSAccountId ?? allEntitlements[0]?.CustomerAWSAccountId ?? null, } } @@ -87,10 +104,10 @@ async function fetchLicenseEntitlements( /** Reads and summarizes one license's entitlements as of now; `signal` bounds the whole read. */ export async function verifyLicense( - productCode: string, + product: AwsMarketplaceProduct, licenseArn: string, signal?: AbortSignal ): Promise { - const entitlements = await fetchLicenseEntitlements(productCode, licenseArn, signal) - return summarizeEntitlements(entitlements, new Date()) + const entitlements = await fetchLicenseEntitlements(product.productCode, licenseArn, signal) + return summarizeEntitlements(entitlements, product.plan.requiredDimension, new Date()) } diff --git a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts index db0d1d85e7c..d7ee1aa0d8d 100644 --- a/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts +++ b/apps/sim/lib/billing/aws-marketplace/lifecycle.integration.ts @@ -17,13 +17,16 @@ import { NextRequest } from 'next/server' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const PRODUCT = 'product-placeholder' +/** A second listing, sold as platform access plus metered usage. */ +const USAGE_PRODUCT = 'usage-product-placeholder' /** New SaaS products: ResolveCustomer names the buyer by account ID, never `CustomerIdentifier`. */ const ACCOUNT = '000000000000' const AGREEMENT = 'agmt-placeholder' const CONTACT = { contactEmail: 'buyer@example.test', companyName: 'Example Co' } const { meteringSend, entitlementSend, queueSend } = vi.hoisted(() => { - process.env.AWS_MARKETPLACE_PRODUCT_CODE = 'product-placeholder' + process.env.AWS_MARKETPLACE_PRODUCTS = + 'product-placeholder=contract,usage-product-placeholder=platform-with-usage' process.env.AWS_MARKETPLACE_EVENTS_QUEUE_URL = 'https://queue.example.test/aws-marketplace-placeholder' process.env.ADMIN_API_KEY = 'admin-key-placeholder' @@ -300,13 +303,18 @@ function deliverOnce(events: Array>) { return deleted } -function licenseUpdated(licenseArn: string, agreementId?: string, time?: Date) { +function licenseUpdated( + licenseArn: string, + agreementId?: string, + time?: Date, + productCode = PRODUCT +) { return { 'detail-type': 'License Updated - Manufacturer', ...(time && { time: time.toISOString() }), detail: { license: { arn: licenseArn }, - product: { code: PRODUCT }, + product: { code: productCode }, ...(agreementId && { agreement: { id: agreementId } }), }, } @@ -390,6 +398,39 @@ describe('POST /api/marketplace/aws/register', () => { expect(meteringSend.mock.calls[0][0].input.RegistrationToken).toBe('opaque+token') }) + it('admits a buyer of the usage listing only on a current platform entitlement', async () => { + const usageOnly = uniqueArn() + resolvesTo(usageOnly, USAGE_PRODUCT) + entitlementsByLicense.set(usageOnly, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }), + ]) + + const unconfirmed = await registration('x-amzn-marketplace-token=usage-only-token') + + expect(claimTokenOf(unconfirmed)).not.toBeNull() + expect(entitlementSend).toHaveBeenCalledTimes(3) + expect(await readLicense(usageOnly)).toMatchObject({ + productCode: USAGE_PRODUCT, + status: 'pending', + }) + + const withPlatform = uniqueArn() + resolvesTo(withPlatform, USAGE_PRODUCT) + entitlementsByLicense.set(withPlatform, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }), + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'platform' }), + ]) + + const admitted = await registration('x-amzn-marketplace-token=platform-token') + + expect(claimTokenOf(admitted)).not.toBeNull() + expect(await readLicense(withPlatform)).toMatchObject({ + productCode: USAGE_PRODUCT, + status: 'active', + dimension: 'platform', + }) + }) + it('records a lapsed license as inactive and issues no claim', async () => { const licenseArn = uniqueArn() resolvesTo(licenseArn) @@ -1382,6 +1423,47 @@ describe('reconcileAwsMarketplaceLicenses', () => { }) }) +describe('license events across listings', () => { + it('records a usage listing license seen first by event as pending until AWS reports platform', async () => { + const licenseArn = uniqueArn() + entitlementsByLicense.set(licenseArn, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }), + ]) + const deleted = deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 1, discarded: 0 }) + expect(deleted).toEqual(['r-0']) + expect(await readLicense(licenseArn)).toMatchObject({ + productCode: USAGE_PRODUCT, + status: 'pending', + dimension: null, + }) + expect(await emailsTo(TEAM)).toEqual([ + expect.objectContaining({ text: expect.stringMatching(/Unconfirmed/) }), + ]) + }) + + it("applies a usage listing's event under its own plan: a lapsed platform ends access", async () => { + const { licenseArn } = await insertLicense({ productCode: USAGE_PRODUCT, status: 'active' }) + entitlementsByLicense.set(licenseArn, [ + currentEntitlement({ ProductCode: USAGE_PRODUCT, Dimension: 'llm_credits' }), + currentEntitlement({ + ProductCode: USAGE_PRODUCT, + Dimension: 'platform', + ExpirationDate: new Date(Date.now() - 60_000), + }), + ]) + const deleted = deliverOnce([licenseUpdated(licenseArn, undefined, undefined, USAGE_PRODUCT)]) + + expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ applied: 1, discarded: 0 }) + expect(deleted).toEqual(['r-0']) + expect(await readLicense(licenseArn)).toMatchObject({ + status: 'inactive', + dimension: 'platform', + }) + }) +}) + describe('agreement events', () => { it('re-verifies the license of an ended agreement, from an event that names no product', async () => { const agreementId = uniqueAgreement() diff --git a/apps/sim/lib/billing/aws-marketplace/notifications.ts b/apps/sim/lib/billing/aws-marketplace/notifications.ts index 81957e7dd3e..ee9baac9d4e 100644 --- a/apps/sim/lib/billing/aws-marketplace/notifications.ts +++ b/apps/sim/lib/billing/aws-marketplace/notifications.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { findAwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/config' import { AWS_MARKETPLACE_BUYER_NOTICE_EVENT, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, @@ -60,6 +61,7 @@ async function describeLicense(license: AwsMarketplaceLicenseRecord): Promise = new Map( + PLANS.map((plan) => [plan.id, plan]) +) + +export interface AwsMarketplaceProduct { + readonly productCode: string + readonly plan: AwsMarketplacePlan +} + +/** + * Parses `AWS_MARKETPLACE_PRODUCTS`: comma-separated `=` pairs, for example + * `abc123=contract,def456=platform-with-usage`. Throws on a malformed entry, an unknown plan, or a + * repeated code. + */ +export function parseAwsMarketplaceProducts( + value: string +): ReadonlyMap { + const products = new Map() + for (const entry of value.split(',')) { + const [productCode, planId, ...rest] = entry.split('=').map((part) => part.trim()) + if (!productCode || !planId || rest.length > 0) { + throw new Error( + `AWS_MARKETPLACE_PRODUCTS: expected =, got "${entry.trim()}"` + ) + } + const plan = AWS_MARKETPLACE_PLANS.get(planId) + if (!plan) { + throw new Error( + `AWS_MARKETPLACE_PRODUCTS: unknown plan "${planId}"; expected one of ${[...AWS_MARKETPLACE_PLANS.keys()].join(', ')}` + ) + } + if (products.has(productCode)) { + throw new Error(`AWS_MARKETPLACE_PRODUCTS: product code "${productCode}" is listed twice`) + } + products.set(productCode, { productCode, plan }) + } + return products +} diff --git a/apps/sim/lib/billing/aws-marketplace/reconcile.ts b/apps/sim/lib/billing/aws-marketplace/reconcile.ts index 5c9a8dc6fea..98f19e34719 100644 --- a/apps/sim/lib/billing/aws-marketplace/reconcile.ts +++ b/apps/sim/lib/billing/aws-marketplace/reconcile.ts @@ -10,11 +10,12 @@ import { getErrorMessage } from '@sim/utils/errors' import { toRecord } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { getEventsQueueClient } from '@/lib/billing/aws-marketplace/client' -import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config' +import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config' import { isInvalidEntitlementRequest, verifyLicense, } from '@/lib/billing/aws-marketplace/entitlements' +import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products' import { applyAgreementRenewal, enqueueAgreementAdvisory, @@ -80,7 +81,8 @@ interface AwsMarketplaceReconcileSummary { type BudgetCheck = (workMs: number) => boolean interface DrainContext { - productCode: string + /** This run's configured products, by code; an event for any other product is not ours. */ + products: ReadonlyMap queueUrl: string summary: AwsMarketplaceReconcileSummary fits: BudgetCheck @@ -159,7 +161,7 @@ type MessageOutcome = 'applied' | 'discarded' | 'skipped' | 'retained' /** Stamps the attempt, asks the Entitlement Service, and records its answer. */ async function reverifyLicense( - productCode: string, + product: AwsMarketplaceProduct, licenseArn: string, options: { ended?: boolean @@ -171,13 +173,13 @@ async function reverifyLicense( const verifiedAt = new Date() await markVerificationAttempted(licenseArn, verifiedAt) const summary = await verifyLicense( - productCode, + product, licenseArn, AbortSignal.timeout(LICENSE_READ_BUDGET_MS) ) const outcome = await recordLicenseVerification({ licenseArn, - productCode, + productCode: product.productCode, verification: { summary, verifiedAt, ended: options.ended }, agreement: options.agreement, fallbackAccountId: options.fallbackAccountId, @@ -220,20 +222,20 @@ async function applyAgreementEnded( message: Message ): Promise { const messageId = message.MessageId - const licenseArns = await listLicensesForAgreement(context.productCode, agreementId) - if (licenseArns.length === 0) { + const licenses = await listLicensesForAgreement(context.products, agreementId) + if (licenses.length === 0) { logger.info('Ignoring an AWS Marketplace agreement event for no stored license', { messageId }) return 'discarded' } let incomplete = false - for (const [index, licenseArn] of licenseArns.entries()) { + for (const [index, { licenseArn, product }] of licenses.entries()) { if (!context.fits(LICENSE_READ_BUDGET_MS)) { context.summary.outOfBudget = true return 'retained' } if (index > 0) await extendVisibility(context, message, messageId) try { - const { summary } = await reverifyLicense(context.productCode, licenseArn, { ended: true }) + const { summary } = await reverifyLicense(product, licenseArn, { ended: true }) if (summary.active) { incomplete = true logger.info('AWS still reports a license of an ended agreement current', { messageId }) @@ -261,8 +263,8 @@ async function applyLicenseEvent( event: Extract, messageId: string | undefined ): Promise { - const { productCode } = context - if (event.productCode !== productCode) { + const product = event.productCode ? context.products.get(event.productCode) : undefined + if (!product) { logger.warn('Discarding an AWS Marketplace event for another product', { messageId }) return 'discarded' } @@ -272,7 +274,7 @@ async function applyLicenseEvent( }) return 'retained' } - const { summary, outcome } = await reverifyLicense(productCode, event.licenseArn, { + const { summary, outcome } = await reverifyLicense(product, event.licenseArn, { ended: event.deprovisioned, agreement: { agreementId: event.agreementId, seenAt: event.occurredAt }, fallbackAccountId: event.acceptorAccountId, @@ -303,7 +305,7 @@ async function applyEvent( event: MarketplaceEvent, message: Message ): Promise { - const { productCode } = context + const { products } = context const messageId = message.MessageId if (event.kind === 'license') return applyLicenseEvent(context, event, messageId) if (event.kind === 'agreement-ended') { @@ -312,7 +314,7 @@ async function applyEvent( const involved = event.kind === 'advisory' ? await enqueueAgreementAdvisory({ - productCode, + products, agreementId: event.agreementId, state: event.state, detail: event.detail, @@ -320,7 +322,7 @@ async function applyEvent( }) : event.previousAgreementId ? await applyAgreementRenewal({ - productCode, + products, previousAgreementId: event.previousAgreementId, agreementId: event.agreementId, }) @@ -412,19 +414,19 @@ async function drainEvents(context: DrainContext): Promise { } async function reverifyStaleLicenses( - productCode: string, + products: ReadonlyMap, summary: AwsMarketplaceReconcileSummary, fits: BudgetCheck ): Promise { const cutoff = new Date(Date.now() - REVERIFY_AFTER_MS) - const licenseArns = await listLicensesDueForReverify(productCode, cutoff, REVERIFY_BATCH_SIZE) - for (const licenseArn of licenseArns) { + const licenses = await listLicensesDueForReverify(products, cutoff, REVERIFY_BATCH_SIZE) + for (const { licenseArn, product } of licenses) { if (!fits(LICENSE_READ_BUDGET_MS)) { summary.outOfBudget = true return } try { - await reverifyLicense(productCode, licenseArn) + await reverifyLicense(product, licenseArn) summary.reverified++ } catch (error) { summary.reverifyFailed++ @@ -442,8 +444,8 @@ async function reverifyStaleLicenses( * feature is off. */ export async function reconcileAwsMarketplaceLicenses(): Promise { - const productCode = getAwsMarketplaceProductCode() - if (!productCode) return null + const products = getAwsMarketplaceProducts() + if (products.size === 0) return null const summary: AwsMarketplaceReconcileSummary = { received: 0, applied: 0, @@ -457,7 +459,7 @@ export async function reconcileAwsMarketplaceLicenses(): Promise Date.now() - startedAt + workMs <= RUN_BUDGET_MS const queueUrl = env.AWS_MARKETPLACE_EVENTS_QUEUE_URL - if (queueUrl) await drainEvents({ productCode, queueUrl, summary, fits }) - await reverifyStaleLicenses(productCode, summary, fits) + if (queueUrl) await drainEvents({ products, queueUrl, summary, fits }) + await reverifyStaleLicenses(products, summary, fits) return summary } diff --git a/apps/sim/lib/billing/aws-marketplace/registration.ts b/apps/sim/lib/billing/aws-marketplace/registration.ts index 0bbce158a0b..61888f75581 100644 --- a/apps/sim/lib/billing/aws-marketplace/registration.ts +++ b/apps/sim/lib/billing/aws-marketplace/registration.ts @@ -7,8 +7,10 @@ import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { AWS_MARKETPLACE_CLAIM_TTL_MS, issueClaimToken } from '@/lib/billing/aws-marketplace/claim' import { getMeteringClient } from '@/lib/billing/aws-marketplace/client' +import { findAwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/config' import { type EntitlementSummary, verifyLicense } from '@/lib/billing/aws-marketplace/entitlements' import type { AwsMarketplaceOnboardingError } from '@/lib/billing/aws-marketplace/onboarding' +import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products' import { saveRegisteredLicense } from '@/lib/billing/aws-marketplace/repository' const logger = createLogger('AwsMarketplaceRegistration') @@ -27,30 +29,30 @@ function errorName(error: unknown): string | undefined { return error instanceof Error ? error.name : undefined } -/** AWS may not have finished right after a purchase, so an empty answer is asked again briefly. */ +/** AWS may not have finished right after a purchase, so a read with no answer about access is asked again briefly. */ async function readEntitlements( - productCode: string, + product: AwsMarketplaceProduct, licenseArn: string ): Promise<{ summary: EntitlementSummary; verifiedAt: Date }> { let verifiedAt = new Date() - let summary = await verifyLicense(productCode, licenseArn) + let summary = await verifyLicense(product, licenseArn) for (const delayMs of NO_ENTITLEMENT_RETRY_DELAYS_MS) { - if (summary.entitlementCount > 0) break + if (summary.accessReported) break await sleep(delayMs) verifiedAt = new Date() - summary = await verifyLicense(productCode, licenseArn) + summary = await verifyLicense(product, licenseArn) } return { summary, verifiedAt } } /** - * Turns the registration token AWS posts to the fulfillment URL into a license row. Every - * registration reads the license's entitlements. A license AWS has not confirmed yet still gets a - * claim, so the buyer can leave contact details; only a lapsed one does not. The token itself is - * never logged. + * Turns the registration token AWS posts to the fulfillment URL into a license row. Every listing + * shares the one fulfillment URL, so the product comes from the code AWS resolves the token to, + * and a code this deployment does not sell is refused. Every registration reads the license's + * entitlements. A license AWS has not confirmed yet still gets a claim, so the buyer can leave + * contact details; only a lapsed one does not. The token itself is never logged. */ export async function registerAwsMarketplaceBuyer( - productCode: string, registrationToken: string ): Promise { let customer: ResolveCustomerCommandOutput @@ -68,7 +70,8 @@ export async function registerAwsMarketplaceBuyer( return { kind: 'unavailable' } } - if (customer.ProductCode !== productCode) { + const product = findAwsMarketplaceProduct(customer.ProductCode) + if (!product) { logger.warn('AWS Marketplace registration is for another product') return { kind: 'invalid' } } @@ -84,11 +87,11 @@ export async function registerAwsMarketplaceBuyer( } try { - const verification = await readEntitlements(productCode, customer.LicenseArn) + const verification = await readEntitlements(product, customer.LicenseArn) const claim = issueClaimToken() const saved = await saveRegisteredLicense({ licenseArn: customer.LicenseArn, - productCode, + productCode: product.productCode, customerAwsAccountId: customer.CustomerAWSAccountId, agreementId: customer.Metadata?.AgreementId ?? null, verification, diff --git a/apps/sim/lib/billing/aws-marketplace/repository.ts b/apps/sim/lib/billing/aws-marketplace/repository.ts index 0b3a8ff2ef4..6010beaed0d 100644 --- a/apps/sim/lib/billing/aws-marketplace/repository.ts +++ b/apps/sim/lib/billing/aws-marketplace/repository.ts @@ -10,9 +10,13 @@ import { type AwsMarketplaceNoticeTerms, type AwsMarketplaceTeamNotice, } from '@/lib/billing/aws-marketplace/notice-events' +import type { AwsMarketplaceProduct } from '@/lib/billing/aws-marketplace/products' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbTransaction } from '@/lib/db/types' +/** Configured products by product code, as `getAwsMarketplaceProducts` returns them. */ +type AwsMarketplaceProducts = ReadonlyMap + export type AwsMarketplaceLicenseRecord = typeof awsMarketplaceLicense.$inferSelect type AwsMarketplaceLicenseStatus = AwsMarketplaceLicenseRecord['status'] @@ -50,21 +54,21 @@ export function isNewerRead(license: AwsMarketplaceLicenseRecord, verifiedAt: Da } /** - * No entitlements after AWS has said the license ended, or on a license that was already confirmed - * once, is a lapse. Otherwise AWS may simply not have finished, so the license stays pending. + * No answer about access after AWS has said the license ended, or on a license that was already + * confirmed once, is a lapse. Otherwise AWS may simply not have finished, so the license stays pending. */ function statusFromRead( prior: AwsMarketplaceLicenseStatus | null, { summary, ended }: LicenseVerification ): AwsMarketplaceLicenseStatus { if (summary.active) return 'active' - if (summary.entitlementCount > 0 || ended) return 'inactive' + if (summary.accessReported || ended) return 'inactive' return prior === null || prior === 'pending' ? 'pending' : 'inactive' } /** - * The columns a read writes. With no entitlements the last-known dimension, quantity, and expiry - * stay as they were, and only the status can change. An inactive license has nothing to claim. + * The columns a read writes. With no answer about access the last-known dimension, quantity, and + * expiry stay as they were, and only the status can change. An inactive license has nothing to claim. */ function verificationFields( prior: AwsMarketplaceLicenseStatus | null, @@ -77,7 +81,7 @@ function verificationFields( lastVerifiedAt: verifiedAt, lastVerificationAttemptAt: verifiedAt, updatedAt: new Date(), - ...(summary.entitlementCount > 0 && { + ...(summary.accessReported && { dimension: summary.dimension, quantity: summary.quantity, expiresAt: summary.expiresAt, @@ -273,7 +277,7 @@ export async function recordLicenseVerification(input: { const stamp = licenseEventReceivedAt ? { licenseEventReceivedAt } : {} return db.transaction(async (tx) => { const accountId = verification.summary.customerAwsAccountId ?? input.fallbackAccountId - if (accountId && verification.summary.entitlementCount > 0) { + if (accountId && verification.summary.licenseSeen) { const [created] = await tx .insert(awsMarketplaceLicense) .values({ @@ -323,18 +327,18 @@ export async function recordLicenseVerification(input: { * Returns whether any stored license was involved. */ export async function applyAgreementRenewal(input: { - productCode: string + products: AwsMarketplaceProducts previousAgreementId: string agreementId: string }): Promise { - const { productCode, previousAgreementId, agreementId } = input + const { products, previousAgreementId, agreementId } = input return db.transaction(async (tx) => { const predecessors = await tx .select() .from(awsMarketplaceLicense) .where( and( - eq(awsMarketplaceLicense.productCode, productCode), + ofProducts(products), or( eq(awsMarketplaceLicense.agreementId, previousAgreementId), eq(awsMarketplaceLicense.renewedAgreementId, previousAgreementId) @@ -359,12 +363,7 @@ export async function applyAgreementRenewal(input: { const successors = await tx .select() .from(awsMarketplaceLicense) - .where( - and( - eq(awsMarketplaceLicense.productCode, productCode), - eq(awsMarketplaceLicense.agreementId, agreementId) - ) - ) + .where(and(ofProducts(products), eq(awsMarketplaceLicense.agreementId, agreementId))) .for('update') for (const successor of successors) await inheritFromRenewedLicense(tx, successor) @@ -389,7 +388,7 @@ export async function applyAgreementRenewal(input: { /** Tells the team of an AWS advisory on an agreement we hold; redelivery cannot send it twice. */ export async function enqueueAgreementAdvisory(input: { - productCode: string + products: AwsMarketplaceProducts agreementId: string state: 'issued' | 'resolved' detail: string @@ -399,7 +398,7 @@ export async function enqueueAgreementAdvisory(input: { const rows = await tx .select({ id: awsMarketplaceLicense.id }) .from(awsMarketplaceLicense) - .where(belongsToAgreement(input.productCode, input.agreementId)) + .where(belongsToAgreement(input.products, input.agreementId)) .limit(MAX_LICENSES_PER_AGREEMENT) if (rows.length === 0) return false const noticeId = `aws-marketplace-advisory-${input.messageId}` @@ -425,9 +424,13 @@ export async function enqueueAgreementAdvisory(input: { }) } -function belongsToAgreement(productCode: string, agreementId: string) { +function ofProducts(products: AwsMarketplaceProducts) { + return inArray(awsMarketplaceLicense.productCode, [...products.keys()]) +} + +function belongsToAgreement(products: AwsMarketplaceProducts, agreementId: string) { return and( - eq(awsMarketplaceLicense.productCode, productCode), + ofProducts(products), or( eq(awsMarketplaceLicense.agreementId, agreementId), eq(awsMarketplaceLicense.renewedAgreementId, agreementId) @@ -439,37 +442,54 @@ function belongsToAgreement(productCode: string, agreementId: string) { * The licenses an agreement covers, least recently attempted first, so a redelivered message moves * on to the ones an earlier delivery did not reach. */ +/** A license to re-read, with the product it was sold under. */ +export interface LicenseToVerify { + licenseArn: string + product: AwsMarketplaceProduct +} + +const LICENSE_TO_VERIFY_COLUMNS = { + licenseArn: awsMarketplaceLicense.licenseArn, + productCode: awsMarketplaceLicense.productCode, +} + +/** Pairs each row with its product; the queries already keep to configured products. */ +function withProducts( + rows: Array<{ licenseArn: string; productCode: string }>, + products: AwsMarketplaceProducts +): LicenseToVerify[] { + return rows.flatMap(({ licenseArn, productCode }) => { + const product = products.get(productCode) + return product ? [{ licenseArn, product }] : [] + }) +} + export async function listLicensesForAgreement( - productCode: string, + products: AwsMarketplaceProducts, agreementId: string -): Promise { +): Promise { const rows = await db - .select({ licenseArn: awsMarketplaceLicense.licenseArn }) + .select(LICENSE_TO_VERIFY_COLUMNS) .from(awsMarketplaceLicense) - .where(belongsToAgreement(productCode, agreementId)) + .where(belongsToAgreement(products, agreementId)) .orderBy(asc(awsMarketplaceLicense.lastVerificationAttemptAt)) .limit(MAX_LICENSES_PER_AGREEMENT) - return rows.map((row) => row.licenseArn) + return withProducts(rows, products) } -/** This product's licenses longest since an attempt, up to `limit`, none attempted after `cutoff`. */ +/** Licenses of these products longest since an attempt, up to `limit`, none after `cutoff`. */ export async function listLicensesDueForReverify( - productCode: string, + products: AwsMarketplaceProducts, cutoff: Date, limit: number -): Promise { +): Promise { const rows = await db - .select({ licenseArn: awsMarketplaceLicense.licenseArn }) + .select(LICENSE_TO_VERIFY_COLUMNS) .from(awsMarketplaceLicense) - .where( - and( - eq(awsMarketplaceLicense.productCode, productCode), - lt(awsMarketplaceLicense.lastVerificationAttemptAt, cutoff) - ) - ) + .where(and(ofProducts(products), lt(awsMarketplaceLicense.lastVerificationAttemptAt, cutoff))) .orderBy(asc(awsMarketplaceLicense.lastVerificationAttemptAt)) .limit(limit) - return rows.map((row) => row.licenseArn) + return withProducts(rows, products) } /** Stamps an attempt to verify a license, before its outcome is known. */ @@ -717,10 +737,12 @@ export async function getLicensesByIds(licenseIds: readonly string[]) { .where(inArray(awsMarketplaceLicense.id, [...licenseIds])) } -/** Organizations already linked to another license of this AWS account, for the team to match. */ +/** + * Organizations already linked to another license of this AWS account, on any listing, for the + * team to match: a buyer moving to a new listing usually wants the organization they already have. + */ export async function listOrganizationsLinkedToAccount(input: { customerAwsAccountId: string - productCode: string excludingLicenseArn: string }): Promise> { return db @@ -730,7 +752,6 @@ export async function listOrganizationsLinkedToAccount(input: { .where( and( eq(awsMarketplaceLicense.customerAwsAccountId, input.customerAwsAccountId), - eq(awsMarketplaceLicense.productCode, input.productCode), ne(awsMarketplaceLicense.licenseArn, input.excludingLicenseArn) ) ) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 99ce75e3603..36b076845ea 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -224,7 +224,7 @@ export const env = createEnv({ STRIPE_PRICE_TEAM_100_MO: z.string().min(1).optional(), // Team Max: $100/seat/mo STRIPE_PRICE_TEAM_100_YR: z.string().min(1).optional(), // Team Max: $1,020/seat/yr OVERAGE_THRESHOLD_DOLLARS: z.number().optional().default(100), // Dollar threshold for incremental overage billing (default: $100) - AWS_MARKETPLACE_PRODUCT_CODE: z.string().min(1).optional(), // AWS Marketplace SaaS product code; unset turns the Marketplace registration, claim, and reconcile surfaces off + AWS_MARKETPLACE_PRODUCTS: z.string().min(1).optional(), // AWS Marketplace SaaS products this deployment sells, as = pairs (lib/billing/aws-marketplace/products.ts); unset turns the Marketplace surfaces off AWS_MARKETPLACE_EVENTS_QUEUE_URL: z.string().url().optional(), // SQS queue receiving the Marketplace agreement EventBridge events the reconcile cron drains // Email & Communication