Skip to content

Commit ce2c853

Browse files
committed
fix(billing): make AWS Marketplace notices durable and carry licenses across agreement replacements
- Team notices and the buyer confirmation go through the outbox, enqueued in the transaction that changes the license, so a failed send is retried and reconcile never waits on email. Every writer decides under its row lock whether terms moved, dimension included, and a new registration tells the team at once. - A license with no entitlements yet is pending, not lapsed: it holds a claim, the buyer can leave contact details, and taking it waits for a read that says it is current. The notice reports whether the License Updated event has arrived. A Deprovisioned event the read contradicts stays queued, and a verification recording inactive clears the claim. - Agreement replacement keys on previousAgreementId, works in either arrival order, hands the owner on to the new license, refuses a late A to B over B to C, and uses the event time so a redelivered license event cannot roll the agreement back. A superseded license ending does not ask the team to cancel. Advisory events reach the team. - Provisioning stays manual. The buyer is told the account is being set up, to wait, the turnaround, and the contact. The team notice says to provision with payment collection paused and never to invoice through Stripe, and names the cancel flow for a lapse. An admin route lists unlinked licenses and links one, with audit. - Status for account-only buyers, and billing hides plan entry points while a license is active. The link card shows a masked account and the signed-in email with no preselected organization, and the claim lasts 4 hours. - Clients use request timeouts and SDK default retries, signing through fromHttp from @aws-sdk/credential-providers. Each license of an ended agreement gets its own read bound. The fulfillment URL always answers with a redirect and takes only URL-encoded forms.
1 parent a475b2d commit ce2c853

37 files changed

Lines changed: 2644 additions & 1480 deletions

File tree

‎apps/sim/app/_shell/providers/theme-provider.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const NON_LANDING_LIGHT_SEGMENTS = [
2828
'unsubscribe',
2929
'cli',
3030
'credential-groups',
31+
'marketplace',
3132
] as const
3233

3334
/**
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { saveAwsMarketplaceClaimContactContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { saveAwsMarketplaceClaimContact } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
import { AWS_MARKETPLACE_CLAIM_COOKIE } from '@/lib/billing/aws-marketplace/claim'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: saveAwsMarketplaceClaimContactContract,
13+
operation: saveAwsMarketplaceClaimContact.operation,
14+
useCase: saveAwsMarketplaceClaimContact,
15+
auth: internalSessionAuth,
16+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-link' }),
17+
errorPolicy: internalOrchestrationErrorPolicy,
18+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
19+
mapInput: ({ body }, { request }) => ({
20+
licenseId: body.licenseId,
21+
contactEmail: body.contactEmail,
22+
companyName: body.companyName || null,
23+
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
24+
}),
25+
})

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@ export const POST = defineInternalJsonRoute({
2626
companyName: body.companyName || null,
2727
claimToken: request.cookies.get(AWS_MARKETPLACE_CLAIM_COOKIE)?.value ?? null,
2828
}),
29-
present: (_linked, { input }) => ({
30-
success: true as const,
31-
organizationId: input.organizationId,
32-
}),
29+
present: () => ({ success: true as const }),
3330
/** The claim is spent once the link commits; the cookie that carried it goes with it. */
3431
finalizeResponse: () => ({ headers: { 'set-cookie': serializeClaimCookie(null) } }),
3532
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { listAccountAwsMarketplaceLicensesContract } from '@/lib/api/contracts/aws-marketplace'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { listAccountAwsMarketplaceLicenses } from '@/lib/billing/aws-marketplace/application/use-cases'
9+
10+
export const GET = defineInternalJsonRoute({
11+
contract: listAccountAwsMarketplaceLicensesContract,
12+
operation: listAccountAwsMarketplaceLicenses.operation,
13+
useCase: listAccountAwsMarketplaceLicenses,
14+
auth: internalSessionAuth,
15+
rateLimit: internalRateLimits.user({ bucketName: 'aws-marketplace-licenses' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
18+
mapInput: () => ({}),
19+
present: (licenses) => ({ licenses }),
20+
})

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

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
23
import { type NextRequest, NextResponse } from 'next/server'
34
import { awsMarketplaceRegistrationFormSchema } from '@/lib/api/contracts/aws-marketplace'
45
import { serializeClaimCookie } from '@/lib/billing/aws-marketplace/claim'
@@ -17,9 +18,6 @@ export const dynamic = 'force-dynamic'
1718

1819
const logger = createLogger('AwsMarketplaceRegisterAPI')
1920

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-
2321
/** The form carries one opaque token; anything larger is not from AWS. */
2422
const MAX_FORM_BYTES = 16 * 1024
2523

@@ -37,7 +35,7 @@ function redirectToOnboarding(error?: AwsMarketplaceOnboardingError): NextRespon
3735

3836
async function readRegistrationToken(request: NextRequest): Promise<string | null> {
3937
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
40-
if (!mediaType || !FORM_MEDIA_TYPES.has(mediaType)) return null
38+
if (mediaType !== 'application/x-www-form-urlencoded') return null
4139
let form: FormData
4240
try {
4341
form = await readFormDataWithLimit(request, {
@@ -58,32 +56,37 @@ async function readRegistrationToken(request: NextRequest): Promise<string | nul
5856
* The listing's fulfillment URL. AWS sends the buyer's browser here with a cross-site form POST,
5957
* so the request carries no Sim session and is authenticated only by the registration token,
6058
* which ResolveCustomer verifies. The response is always a 303 to the onboarding page, carrying
61-
* the claim cookie when the license is active. The token is never logged.
59+
* the claim cookie unless the license lapsed. The token is never logged.
6260
*/
6361
export const POST = withRouteHandler(async (request: NextRequest) => {
6462
const productCode = getAwsMarketplaceProductCode()
6563
if (!productCode) return NextResponse.json({ error: 'Not found' }, { status: 404 })
6664

67-
const rateLimited = await enforceIpRateLimit(
68-
'aws-marketplace-register',
69-
request,
70-
REGISTER_RATE_LIMIT
71-
)
72-
if (rateLimited) return rateLimited
65+
try {
66+
const rateLimited = await enforceIpRateLimit(
67+
'aws-marketplace-register',
68+
request,
69+
REGISTER_RATE_LIMIT
70+
)
71+
if (rateLimited) return redirectToOnboarding('unavailable')
7372

74-
const token = await readRegistrationToken(request)
75-
if (!token) {
76-
logger.warn('AWS Marketplace registration arrived without a usable token')
77-
return redirectToOnboarding('invalid')
78-
}
73+
const token = await readRegistrationToken(request)
74+
if (!token) {
75+
logger.warn('AWS Marketplace registration arrived without a usable token')
76+
return redirectToOnboarding('invalid')
77+
}
7978

80-
const outcome = await registerAwsMarketplaceBuyer(productCode, token)
81-
if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind)
79+
const outcome = await registerAwsMarketplaceBuyer(productCode, token)
80+
if (outcome.kind !== 'claimed') return redirectToOnboarding(outcome.kind)
8281

83-
const response = redirectToOnboarding()
84-
response.headers.append('set-cookie', serializeClaimCookie(outcome.claimToken))
85-
response.headers.set('Cache-Control', 'no-store')
86-
return response
82+
const response = redirectToOnboarding()
83+
response.headers.append('set-cookie', serializeClaimCookie(outcome.claimToken))
84+
response.headers.set('Cache-Control', 'no-store')
85+
return response
86+
} catch (error) {
87+
logger.error('AWS Marketplace registration failed', { error: getErrorMessage(error) })
88+
return redirectToOnboarding('unavailable')
89+
}
8790
})
8891

8992
/** A refresh, back button, or bookmark of the fulfillment URL lands on the onboarding page. */

‎apps/sim/app/api/organizations/[id]/aws-marketplace-licenses/route.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,5 @@ export const GET = defineInternalJsonRoute({
1616
errorPolicy: internalOrchestrationErrorPolicy,
1717
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
1818
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-
}),
19+
present: (licenses) => ({ licenses }),
2620
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import {
4+
adminDashboardLinkAwsMarketplaceLicenseContract,
5+
adminDashboardListAwsMarketplaceLicensesContract,
6+
} from '@/lib/api/contracts/v1/admin/dashboard'
7+
import { parseRequest } from '@/lib/api/server'
8+
import {
9+
linkAwsMarketplaceLicenseAsAdmin,
10+
listUnlinkedAwsMarketplaceLicenses,
11+
} from '@/lib/billing/aws-marketplace/application/admin'
12+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
13+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
15+
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
16+
import {
17+
adminConflictResponse,
18+
adminInternalErrorResponse,
19+
adminInvalidJsonResponse,
20+
adminNotFoundResponse,
21+
adminValidationErrorResponse,
22+
listResponse,
23+
singleResponse,
24+
} from '@/app/api/v1/admin/responses'
25+
26+
const logger = createLogger('AdminAwsMarketplaceLicensesAPI')
27+
28+
/** The Marketplace licenses no organization holds yet. */
29+
export const GET = withRouteHandler(
30+
withAdminAuth(async (request) => {
31+
const parsed = await parseRequest(
32+
adminDashboardListAwsMarketplaceLicensesContract,
33+
request,
34+
{},
35+
{ validationErrorResponse: adminValidationErrorResponse }
36+
)
37+
if (!parsed.success) return parsed.response
38+
try {
39+
const { limit, offset } = parsed.data.query
40+
const { total, licenses } = await listUnlinkedAwsMarketplaceLicenses({ limit, offset })
41+
return listResponse(licenses, {
42+
total,
43+
limit,
44+
offset,
45+
hasMore: offset + licenses.length < total,
46+
})
47+
} catch (error) {
48+
logger.error('Failed to list AWS Marketplace licenses', { error })
49+
return adminInternalErrorResponse(getErrorMessage(error, 'Failed to list licenses'))
50+
}
51+
})
52+
)
53+
54+
/** Links a Marketplace license to an organization once the team has provisioned it. */
55+
export const POST = withRouteHandler(
56+
withAdminAuth(async (request) => {
57+
const parsed = await parseRequest(
58+
adminDashboardLinkAwsMarketplaceLicenseContract,
59+
request,
60+
{},
61+
{
62+
validationErrorResponse: adminValidationErrorResponse,
63+
invalidJsonResponse: adminInvalidJsonResponse,
64+
}
65+
)
66+
if (!parsed.success) return parsed.response
67+
try {
68+
const actor = await getAdminAuditActor(request)
69+
return singleResponse(await linkAwsMarketplaceLicenseAsAdmin(parsed.data.body, actor))
70+
} catch (error) {
71+
const refusal = asOrchestrationError(error)
72+
if (refusal?.code === 'not_found') return adminNotFoundResponse(refusal.message)
73+
if (refusal?.code === 'conflict') return adminConflictResponse(refusal.message)
74+
logger.error('Failed to link an AWS Marketplace license', { error })
75+
return adminInternalErrorResponse(getErrorMessage(error, 'Failed to link the license'))
76+
}
77+
})
78+
)

0 commit comments

Comments
 (0)