Skip to content

Commit e2027bd

Browse files
committed
fix(billing): keep ended agreements queued until AWS agrees and bound each message
An Agreement Ended event can arrive before the entitlement read reflects the end, and deleting it left the license current for up to a day, so the message now stays queued until AWS reports the license ended. The run budget is checked before each message, since one ended agreement can read many licenses, and messages not started stay queued for the next run. Removes a billing redirect nothing uses any more, and stops one test depending on the order of same-transaction notices.
1 parent 3277b99 commit e2027bd

4 files changed

Lines changed: 69 additions & 22 deletions

File tree

‎apps/sim/app/workspace/page.tsx‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,7 @@ export default function WorkspacePage() {
132132
? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined)
133133
: redirectTarget === 'settings'
134134
? `/workspace/${id}/settings/general`
135-
: redirectTarget === 'billing'
136-
? `/workspace/${id}/settings/billing`
137-
: `/workspace/${id}`
135+
: `/workspace/${id}`
138136

139137
const { workspaces, lastActiveWorkspaceId, creationPolicy, forwardOrganizationId } = data
140138

@@ -143,11 +141,7 @@ export default function WorkspacePage() {
143141
hasRedirectedRef.current = true
144142
const routes = organizationRoutes(forwardOrganizationId)
145143
router.replace(
146-
redirectTarget === 'settings'
147-
? routes.settingsSection('general')
148-
: redirectTarget === 'billing'
149-
? routes.settingsSection('billing')
150-
: routes.home
144+
redirectTarget === 'settings' ? routes.settingsSection('general') : routes.home
151145
)
152146
return
153147
}

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

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -767,13 +767,16 @@ describe('linking a claimed license', () => {
767767
companyName: CONTACT.companyName,
768768
})
769769
const team = await emailsTo(TEAM)
770-
expect(team.map((email) => email.subject)).toEqual([
771-
expect.stringMatching(/active → active/),
772-
expect.stringMatching(/Linked to an organization/),
773-
])
774-
expect(team[1].text).toMatch(/Company: Example Co/)
775-
expect(team[1].text).toMatch(/pausePaymentCollection: true/)
776-
expect(team[1].text).toMatch(/Never send a Stripe invoice/)
770+
expect(team.map((email) => email.subject)).toEqual(
771+
expect.arrayContaining([
772+
expect.stringMatching(/active → active/),
773+
expect.stringMatching(/Linked to an organization/),
774+
])
775+
)
776+
const linkNotice = team.find((email) => /Linked to an organization/.test(email.subject))
777+
expect(linkNotice?.text).toMatch(/Company: Example Co/)
778+
expect(linkNotice?.text).toMatch(/pausePaymentCollection: true/)
779+
expect(linkNotice?.text).toMatch(/Never send a Stripe invoice/)
777780
expect(await emailsTo(BUYER)).toEqual([
778781
expect.objectContaining({
779782
text: expect.stringMatching(
@@ -1307,6 +1310,35 @@ describe('reconcileAwsMarketplaceLicenses', () => {
13071310
}
13081311
})
13091312

1313+
it('leaves messages it has not started queued once the run budget cannot fit another', async () => {
1314+
vi.useFakeTimers({ toFake: ['Date'] })
1315+
try {
1316+
const licenses = await Promise.all([insertLicense(), insertLicense()])
1317+
for (const { licenseArn } of licenses) {
1318+
entitlementsByLicense.set(licenseArn, [currentEntitlement()])
1319+
}
1320+
const deleted = deliverOnce(
1321+
licenses.map(({ licenseArn }) => ({
1322+
'detail-type': 'License Updated - Manufacturer',
1323+
detail: { license: { arn: licenseArn }, product: { code: PRODUCT } },
1324+
}))
1325+
)
1326+
const receive = queueSend.getMockImplementation()
1327+
queueSend.mockImplementation(async (command: { kind: string }) => {
1328+
const answer = await receive?.(command)
1329+
if (command.kind === 'receive') vi.setSystemTime(Date.now() + 6 * 60_000)
1330+
return answer
1331+
})
1332+
1333+
const summary = await reconcileAwsMarketplaceLicenses()
1334+
1335+
expect(summary).toMatchObject({ received: 0, outOfBudget: true })
1336+
expect(deleted).toEqual([])
1337+
} finally {
1338+
vi.useRealTimers()
1339+
}
1340+
})
1341+
13101342
it('keeps licenses AWS keeps refusing from starving the rest of the re-verify', async () => {
13111343
const failing = Array.from({ length: 50 }, () => uniqueArn())
13121344
const healthyArn = uniqueArn()
@@ -1370,6 +1402,21 @@ describe('agreement events', () => {
13701402
])
13711403
})
13721404

1405+
it('keeps an ended agreement queued while AWS still reports its license current', async () => {
1406+
const agreementId = uniqueAgreement()
1407+
const { licenseArn } = await insertLicense({ agreementId })
1408+
entitlementsByLicense.set(licenseArn, [currentEntitlement()])
1409+
const deleted = deliverOnce([
1410+
{
1411+
'detail-type': 'Purchase Agreement Ended - Proposer',
1412+
detail: { agreement: { id: agreementId } },
1413+
},
1414+
])
1415+
1416+
expect(await reconcileAwsMarketplaceLicenses()).toMatchObject({ retained: 1, applied: 0 })
1417+
expect(deleted).toEqual([])
1418+
})
1419+
13731420
it('reads every license of an ended agreement even when one fails, and keeps the message', async () => {
13741421
const agreementId = uniqueAgreement()
13751422
const failing = await insertLicense({ agreementId, lastVerificationAttemptAt: new Date(0) })

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,9 @@ async function extendVisibility(
210210

211211
/**
212212
* Each license gets its own read bound and commits on its own, so one that fails or runs out of
213-
* time costs no other's progress. The message stays queued until all of them have been read, and
214-
* a redelivery starts with those least recently attempted. An agreement ID with no license is
215-
* normal: the seller account has other products.
213+
* time costs no other's progress. The message stays queued until AWS reports all of them ended,
214+
* since a read can lag the event, and a redelivery starts with those least recently attempted. An
215+
* agreement ID with no license is normal: the seller account has other products.
216216
*/
217217
async function applyAgreementEnded(
218218
context: DrainContext,
@@ -233,7 +233,11 @@ async function applyAgreementEnded(
233233
}
234234
if (index > 0) await extendVisibility(context, message, messageId)
235235
try {
236-
await reverifyLicense(context.productCode, licenseArn, { ended: true })
236+
const { summary } = await reverifyLicense(context.productCode, licenseArn, { ended: true })
237+
if (summary.active) {
238+
incomplete = true
239+
logger.info('AWS still reports a license of an ended agreement current', { messageId })
240+
}
237241
} catch (error) {
238242
if (isInvalidEntitlementRequest(error)) {
239243
logger.warn('The Entitlement Service rejected a license of an ended agreement', {
@@ -384,6 +388,11 @@ async function drainEvents(context: DrainContext): Promise<void> {
384388
if (messages.length === 0) return
385389

386390
for (const message of messages) {
391+
// An ended agreement can read many licenses; what is not started stays queued for the next run.
392+
if (!fits(LICENSE_READ_BUDGET_MS)) {
393+
summary.outOfBudget = true
394+
return
395+
}
387396
summary.received++
388397
const outcome = await handleMessage(context, message)
389398
summary[outcome]++

‎apps/sim/lib/navigation/paths.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,6 @@ export const WORKSPACES_PATH = '/workspace'
2525
/** Opens full settings in the viewer's most recent accessible workspace. */
2626
export const WORKSPACE_SETTINGS_PATH = `${WORKSPACES_PATH}?redirect=settings`
2727

28-
/** Opens billing settings in the viewer's most recent accessible workspace. */
29-
export const WORKSPACE_BILLING_PATH = `${WORKSPACES_PATH}?redirect=billing`
30-
3128
/** Root of the organization surface; `/o` alone resolves like {@link APP_ENTRY_PATH}. */
3229
const ORGANIZATIONS_PATH = '/o'
3330

0 commit comments

Comments
 (0)