fix(billing): separate Enterprise reporting periods from Stripe terms (#6942)

* fix(billing): separate Enterprise reporting periods from Stripe terms

* fix(billing): reconcile accepted legacy intents

* fix(billing): keep accepted legacy intents fail-closed

* fix(billing): reconcile accepted retired intents

* fix(billing): retire invalid legacy intents
This commit is contained in:
Vikhyath Mondreti
2026-08-21 12:52:49 -07:00
committed by GitHub
parent 6a45b0d4a6
commit cd0516cade
13 changed files with 592 additions and 333 deletions
@@ -1,6 +1,6 @@
import { getErrorMessage } from '@sim/utils/errors'
import { previewDashboardEnterpriseBillingTerms } from '@/lib/admin/dashboard'
import { adminDashboardPreviewBillingTermsContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { previewDashboardEnterpriseReportingPeriod } from '@/lib/admin/dashboard'
import { adminDashboardPreviewReportingPeriodContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
@@ -13,18 +13,23 @@ import {
export const POST = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(adminDashboardPreviewBillingTermsContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
const parsed = await parseRequest(
adminDashboardPreviewReportingPeriodContract,
request,
context,
{
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
try {
return singleResponse(
await previewDashboardEnterpriseBillingTerms(parsed.data.params.id, parsed.data.body)
await previewDashboardEnterpriseReportingPeriod(parsed.data.params.id, parsed.data.body)
)
} catch (error) {
return badRequestResponse(
getErrorMessage(error, 'Failed to preview Enterprise billing terms')
getErrorMessage(error, 'Failed to preview Enterprise reporting period')
)
}
})
@@ -1,6 +1,6 @@
import { getErrorMessage } from '@sim/utils/errors'
import { updateDashboardEnterpriseBillingTerms } from '@/lib/admin/dashboard'
import { adminDashboardUpdateBillingTermsContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { updateDashboardEnterpriseReportingPeriod } from '@/lib/admin/dashboard'
import { adminDashboardUpdateReportingPeriodContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
@@ -14,20 +14,27 @@ import {
export const PATCH = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(adminDashboardUpdateBillingTermsContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
const parsed = await parseRequest(
adminDashboardUpdateReportingPeriodContract,
request,
context,
{
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
try {
await updateDashboardEnterpriseBillingTerms(
await updateDashboardEnterpriseReportingPeriod(
parsed.data.params.id,
parsed.data.body,
await getAdminAuditActor(request)
)
return singleResponse({ success: true as const })
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Failed to update Enterprise billing terms'))
return badRequestResponse(
getErrorMessage(error, 'Failed to update Enterprise reporting period')
)
}
})
)
@@ -79,7 +79,7 @@ import {
getDashboardOrganization,
listDashboardOrganizations,
toDashboardConfigurationUpdate,
updateDashboardEnterpriseBillingTerms,
updateDashboardEnterpriseReportingPeriod,
updateDashboardEnterpriseSeats,
updateDashboardOrganizationLimits,
} from '@/lib/admin/dashboard'
@@ -189,6 +189,7 @@ describe('toDashboardConfigurationUpdate', () => {
concurrencyLimit: 50,
},
requestedTerms: null,
providerAccepted: false,
error: null,
},
})
@@ -196,15 +197,43 @@ describe('toDashboardConfigurationUpdate', () => {
id: 'config-2',
status: 'pending',
requestedUsageLimitDollars: 50_000,
requestedInvoiceAmountUsd: null,
requestedBillingInterval: null,
requestedReportingPeriodInterval: null,
requestedReportingPeriodAnchorDate: null,
requestedSeats: 20,
requestedConcurrencyLimit: 50,
requestedWorkflowExecutionTimeoutSeconds: null,
providerAccepted: false,
retryable: true,
error: null,
})
})
it('surfaces a legacy coupled cadence as reporting-only and disables retry', () => {
expect(
toDashboardConfigurationUpdate({
latestRevision: 3,
desiredMetadata: {},
desiredTerms: null,
hasUnappliedIntent: true,
effectiveSeatCapacity: 20,
configurationUpdate: {
id: 'legacy-config',
status: 'failed',
requestedMetadata: {
reportingPeriodAnchorDate: '2026-05-01',
seats: 20,
},
requestedTerms: { invoiceAmountCents: 50_000, billingInterval: 'year' },
providerAccepted: false,
error: 'Commercial-term updates are unsupported',
},
})
).toMatchObject({
requestedReportingPeriodAnchorDate: '2026-05-01',
requestedReportingPeriodInterval: 'year',
retryable: false,
})
})
})
describe('listDashboardOrganizations', () => {
@@ -458,7 +487,7 @@ describe('updateDashboardOrganizationLimits', () => {
})
})
describe('updateDashboardEnterpriseBillingTerms', () => {
describe('updateDashboardEnterpriseReportingPeriod', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
@@ -477,7 +506,7 @@ describe('updateDashboardEnterpriseBillingTerms', () => {
})
})
it('queues a cadence and immutable Price change through the existing Stripe intent', async () => {
it('queues only independent reporting metadata and preserves commercial metadata', async () => {
queueTableRows(subscription, [
{
id: 'sub-1',
@@ -485,15 +514,19 @@ describe('updateDashboardEnterpriseBillingTerms', () => {
plan: 'enterprise',
status: 'active',
billingInterval: 'month',
metadata: { plan: 'enterprise', referenceId: 'org-1', monthlyPrice: 125, seats: 10 },
metadata: {
plan: 'enterprise',
referenceId: 'org-1',
monthlyPrice: 125,
seats: 10,
},
},
])
await updateDashboardEnterpriseBillingTerms(
await updateDashboardEnterpriseReportingPeriod(
'org-1',
{
invoiceAmountUsd: 1200,
billingInterval: 'year',
reportingPeriodInterval: 'year',
reportingPeriodAnchorDate: '2026-01-31',
},
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
@@ -504,19 +537,17 @@ describe('updateDashboardEnterpriseBillingTerms', () => {
'stripe.sync-enterprise-metadata',
expect.objectContaining({
revision: 4,
terms: { invoiceAmountCents: 120_000, billingInterval: 'year' },
metadata: expect.objectContaining({
invoiceAmountCents: 120_000,
monthlyPrice: 125,
reportingPeriodAnchorDate: '2026-01-31',
reportingPeriodInterval: 'year',
}),
})
)
expect(
(mocks.enqueueOutboxEvent.mock.calls[0][2] as { metadata: Record<string, unknown> }).metadata
).toMatchObject({ monthlyPrice: null })
expect(mocks.enqueueOutboxEvent.mock.calls[0][2]).not.toHaveProperty('terms')
})
it('updates only metadata when the applied Price already matches', async () => {
it('does not compare the requested reporting cadence with the Stripe cadence', async () => {
queueTableRows(subscription, [
{
id: 'sub-1',
@@ -528,16 +559,25 @@ describe('updateDashboardEnterpriseBillingTerms', () => {
},
])
await updateDashboardEnterpriseBillingTerms(
await updateDashboardEnterpriseReportingPeriod(
'org-1',
{
invoiceAmountUsd: 1200,
billingInterval: 'year',
reportingPeriodInterval: 'month',
reportingPeriodAnchorDate: '2025-01-31',
},
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
)
expect(mocks.enqueueOutboxEvent.mock.calls[0][2]).not.toHaveProperty('terms')
expect(mocks.enqueueOutboxEvent.mock.calls[0][2]).toMatchObject({
metadata: {
plan: 'enterprise',
referenceId: 'org-1',
seats: 10,
monthlyPrice: 125,
reportingPeriodAnchorDate: '2025-01-31',
reportingPeriodInterval: 'month',
},
})
})
})
+30 -47
View File
@@ -110,7 +110,6 @@ async function enqueueEnterpriseMetadataIntent(
subscriptionId: string
appliedMetadata: unknown
buildDesiredMetadata: (current: Record<string, unknown>) => Record<string, unknown>
terms?: { invoiceAmountCents: number; billingInterval: 'month' | 'year' } | null
}
): Promise<{ version: number; desiredMetadata: Record<string, unknown> }> {
const intent = await resolveEnterpriseMetadataIntent(
@@ -135,7 +134,6 @@ async function enqueueEnterpriseMetadataIntent(
...intent.desiredMetadata,
}
const desiredMetadata = params.buildDesiredMetadata(current)
const desiredTerms = params.terms === undefined ? intent.desiredTerms : params.terms
const version = intent.latestRevision + 1
await enqueueOutboxEvent(tx, ENTERPRISE_METADATA_SYNC_EVENT_TYPE, {
@@ -143,7 +141,6 @@ async function enqueueEnterpriseMetadataIntent(
revision: version,
deliveryRevision: 0,
metadata: desiredMetadata,
...(desiredTerms ? { terms: desiredTerms } : {}),
})
return { version, desiredMetadata }
}
@@ -524,8 +521,12 @@ export function toDashboardConfigurationUpdate(
usageLimitCredits === null
? null
: creditsToDollars(usageLimitCredits) + prepaidBalanceDollars,
requestedInvoiceAmountUsd: terms ? terms.invoiceAmountCents / 100 : null,
requestedBillingInterval: terms?.billingInterval ?? null,
requestedReportingPeriodInterval:
metadata.reportingPeriodInterval === 'month' || metadata.reportingPeriodInterval === 'year'
? metadata.reportingPeriodInterval
: typeof metadata.reportingPeriodAnchorDate === 'string'
? (terms?.billingInterval ?? null)
: null,
requestedReportingPeriodAnchorDate:
typeof metadata.reportingPeriodAnchorDate === 'string'
? metadata.reportingPeriodAnchorDate
@@ -535,6 +536,7 @@ export function toDashboardConfigurationUpdate(
requestedWorkflowExecutionTimeoutSeconds:
workflowExecutionTimeoutSeconds === null ? null : Math.round(workflowExecutionTimeoutSeconds),
providerAccepted: update.providerAccepted,
retryable: terms === null,
error: update.error,
}
}
@@ -1147,36 +1149,27 @@ export async function updateDashboardEnterpriseSeats(
})
}
interface DashboardEnterpriseBillingTerms {
invoiceAmountUsd: number
billingInterval: 'month' | 'year'
interface DashboardEnterpriseReportingPeriod {
reportingPeriodInterval: 'month' | 'year'
reportingPeriodAnchorDate: string
}
function validateDashboardEnterpriseBillingTerms(values: DashboardEnterpriseBillingTerms) {
const invoiceAmountCents = Math.round(values.invoiceAmountUsd * 100)
if (
invoiceAmountCents <= 0 ||
!Number.isSafeInteger(invoiceAmountCents) ||
Math.abs(values.invoiceAmountUsd * 100 - invoiceAmountCents) > 1e-8
) {
throw new Error('Invoice amount must be at least $0.01 and use whole cents')
}
function validateDashboardEnterpriseReportingPeriod(values: DashboardEnterpriseReportingPeriod) {
const reportingPeriod = resolveEnterpriseReportingPeriod(
values.reportingPeriodAnchorDate,
values.billingInterval
values.reportingPeriodInterval
)
if (!reportingPeriod) {
throw new Error('Contract start must be a valid UTC date that is not in the future')
throw new Error('Reporting-period anchor must be a valid UTC date that is not in the future')
}
return { invoiceAmountCents, reportingPeriod }
return reportingPeriod
}
export async function previewDashboardEnterpriseBillingTerms(
export async function previewDashboardEnterpriseReportingPeriod(
organizationId: string,
values: DashboardEnterpriseBillingTerms
values: DashboardEnterpriseReportingPeriod
) {
const { reportingPeriod } = validateDashboardEnterpriseBillingTerms(values)
const reportingPeriod = validateDashboardEnterpriseReportingPeriod(values)
const [[org], [subscriptionRow]] = await Promise.all([
db
.select({ orgUsageLimit: organization.orgUsageLimit })
@@ -1221,12 +1214,12 @@ export async function previewDashboardEnterpriseBillingTerms(
}
}
export async function updateDashboardEnterpriseBillingTerms(
export async function updateDashboardEnterpriseReportingPeriod(
organizationId: string,
values: DashboardEnterpriseBillingTerms,
values: DashboardEnterpriseReportingPeriod,
actor: AdminMutationActor
) {
const { invoiceAmountCents } = validateDashboardEnterpriseBillingTerms(values)
validateDashboardEnterpriseReportingPeriod(values)
await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, organizationId)
const [subscriptionRow] = await tx
@@ -1244,29 +1237,14 @@ export async function updateDashboardEnterpriseBillingTerms(
if (!subscriptionRow?.stripeSubscriptionId) {
throw new Error('Active Stripe-backed Enterprise subscription not found')
}
const appliedMetadata = metadataRecord(subscriptionRow.metadata)
const appliedInvoiceAmountCents =
metadataNumber(appliedMetadata, 'invoiceAmountCents') ??
Math.round((metadataNumber(appliedMetadata, 'monthlyPrice') ?? 0) * 100)
const appliedBillingInterval = subscriptionRow.billingInterval === 'year' ? 'year' : 'month'
const termsChanged =
appliedInvoiceAmountCents !== invoiceAmountCents ||
appliedBillingInterval !== values.billingInterval
await enqueueEnterpriseMetadataIntent(tx, {
subscriptionId: subscriptionRow.id,
appliedMetadata: subscriptionRow.metadata,
terms: termsChanged ? { invoiceAmountCents, billingInterval: values.billingInterval } : null,
buildDesiredMetadata: (current) => {
const { monthlyPrice: _legacyMonthlyPrice, ...rest } = current
return {
...rest,
// Stripe metadata updates merge by default. An empty value removes
// the legacy key after the neutral amount has been written.
monthlyPrice: null,
invoiceAmountCents,
reportingPeriodAnchorDate: values.reportingPeriodAnchorDate,
}
},
buildDesiredMetadata: (current) => ({
...current,
reportingPeriodAnchorDate: values.reportingPeriodAnchorDate,
reportingPeriodInterval: values.reportingPeriodInterval,
}),
})
})
recordAudit({
@@ -1276,7 +1254,7 @@ export async function updateDashboardEnterpriseBillingTerms(
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: 'Admin requested Enterprise billing-term update',
description: 'Admin requested Enterprise reporting-period update',
metadata: { ...values },
})
}
@@ -1319,6 +1297,11 @@ export async function retryDashboardEnterpriseConfigurationUpdate(
if (event.status !== 'dead_letter') {
throw new Error('Only a failed Enterprise configuration update can be retried')
}
if (payload.data.terms) {
throw new Error(
'Legacy Enterprise commercial-term updates cannot be retried. Submit a new reporting-period change instead.'
)
}
await tx
.update(outboxEvent)
.set({
@@ -161,8 +161,7 @@ export const adminDashboardOrganizationDetailSchema =
id: z.string(),
status: z.enum(['pending', 'processing', 'failed']),
requestedUsageLimitDollars: dollarAmountSchema.nullable(),
requestedInvoiceAmountUsd: z.number().positive().nullable(),
requestedBillingInterval: adminDashboardBillingIntervalSchema.nullable(),
requestedReportingPeriodInterval: adminDashboardBillingIntervalSchema.nullable(),
requestedReportingPeriodAnchorDate: adminDashboardDateOnlySchema.nullable(),
requestedSeats: z.number().int().positive().nullable(),
requestedConcurrencyLimit: z
@@ -178,6 +177,7 @@ export const adminDashboardOrganizationDetailSchema =
.max(MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS)
.nullable(),
providerAccepted: z.boolean(),
retryable: z.boolean(),
error: z.string().nullable(),
})
.nullable(),
@@ -551,13 +551,12 @@ export const adminDashboardEnterpriseReviewSchema = z.object({
}),
})
export const adminDashboardBillingTermsBodySchema = z.object({
invoiceAmountUsd: z.number().min(0.01).max(10_000_000).multipleOf(0.01),
billingInterval: adminDashboardBillingIntervalSchema,
export const adminDashboardReportingPeriodBodySchema = z.object({
reportingPeriodInterval: adminDashboardBillingIntervalSchema,
reportingPeriodAnchorDate: adminDashboardDateOnlySchema,
})
export const adminDashboardBillingTermsPreviewSchema = z.object({
export const adminDashboardReportingPeriodPreviewSchema = z.object({
reportingPeriod: adminDashboardReportingPeriodSchema,
usage: adminDashboardUsageSchema,
exceedsLimit: z.boolean(),
@@ -876,22 +875,22 @@ export const adminDashboardUpdateLimitsContract = defineRouteContract({
},
})
export const adminDashboardPreviewBillingTermsContract = defineRouteContract({
export const adminDashboardPreviewReportingPeriodContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/organizations/[id]/billing-terms/preview',
path: '/api/v1/admin/dashboard/organizations/[id]/reporting-period/preview',
params: adminV1IdParamsSchema,
body: adminDashboardBillingTermsBodySchema,
body: adminDashboardReportingPeriodBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardBillingTermsPreviewSchema),
schema: adminV1SingleResponseSchema(adminDashboardReportingPeriodPreviewSchema),
},
})
export const adminDashboardUpdateBillingTermsContract = defineRouteContract({
export const adminDashboardUpdateReportingPeriodContract = defineRouteContract({
method: 'PATCH',
path: '/api/v1/admin/dashboard/organizations/[id]/billing-terms',
path: '/api/v1/admin/dashboard/organizations/[id]/reporting-period',
params: adminV1IdParamsSchema,
body: adminDashboardBillingTermsBodySchema,
body: adminDashboardReportingPeriodBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardMutationResultSchema),
@@ -61,6 +61,70 @@ describe('Enterprise reporting periods', () => {
).toMatchObject({ source: 'stripe' })
})
it('uses the reporting metadata interval independently from the Stripe cadence', () => {
expect(
resolveSubscriptionUsagePeriod(
{
plan: 'enterprise',
billingInterval: 'month',
metadata: {
reportingPeriodAnchorDate: '2026-05-01',
reportingPeriodInterval: 'year',
},
periodStart: new Date('2026-07-21T19:37:47.000Z'),
periodEnd: new Date('2026-08-21T19:37:47.000Z'),
},
new Date('2026-08-21T18:00:00.000Z')
)
).toMatchObject({
source: 'reporting',
anchorDate: '2026-05-01',
interval: 'year',
start: new Date('2026-05-01T00:00:00.000Z'),
end: new Date('2027-05-01T00:00:00.000Z'),
})
})
it('ignores custom reporting metadata for standard plans', () => {
expect(
resolveSubscriptionUsagePeriod(
{
plan: 'team_25000',
billingInterval: 'month',
metadata: {
reportingPeriodAnchorDate: '2026-05-01',
reportingPeriodInterval: 'year',
},
periodStart: new Date('2026-08-01T00:00:00.000Z'),
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
},
new Date('2026-08-21T18:00:00.000Z')
)
).toMatchObject({
source: 'stripe',
interval: 'month',
start: new Date('2026-08-01T00:00:00.000Z'),
end: new Date('2026-09-01T00:00:00.000Z'),
})
})
it('preserves custom reporting periods written before the interval metadata split', () => {
expect(
resolveSubscriptionUsagePeriod(
{
plan: 'enterprise',
billingInterval: 'year',
metadata: { reportingPeriodAnchorDate: '2026-05-01' },
},
new Date('2026-08-21T18:00:00.000Z')
)
).toMatchObject({
source: 'reporting',
anchorDate: '2026-05-01',
interval: 'year',
})
})
it('uses the same open fallback window when a subscription has no usable dates', () => {
expect(
resolveSubscriptionUsagePeriodOrDefault({ plan: 'enterprise', metadata: {} })
@@ -3,6 +3,7 @@ import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { isEnterprise } from '@/lib/billing/plan-helpers'
export const ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY = 'reportingPeriodAnchorDate'
export const ENTERPRISE_REPORTING_PERIOD_INTERVAL_METADATA_KEY = 'reportingPeriodInterval'
export type BillingInterval = 'month' | 'year'
export type UsagePeriodSource = 'reporting' | 'stripe' | 'default'
@@ -92,7 +93,9 @@ export function resolveSubscriptionUsagePeriod(
if (subscription && isEnterprise(subscription.plan)) {
const metadata = isRecordLike(subscription.metadata) ? subscription.metadata : {}
const anchor = metadata[ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY]
const interval = parseBillingInterval(subscription.billingInterval)
const interval =
parseBillingInterval(metadata[ENTERPRISE_REPORTING_PERIOD_INTERVAL_METADATA_KEY]) ??
parseBillingInterval(subscription.billingInterval)
if (typeof anchor === 'string' && interval) {
const reportingPeriod = resolveEnterpriseReportingPeriod(anchor, interval, now)
if (reportingPeriod) return reportingPeriod
@@ -302,6 +302,138 @@ describe('Enterprise metadata intent admission state', () => {
})
})
it('keeps a legacy commercial-term intent pending until Stripe state is checked', async () => {
const state = await resolveEnterpriseMetadataIntent(
executorReturning([
{
id: 'config-2',
status: 'pending',
payload: {
subscriptionId: 'sub-local',
revision: 2,
metadata: { seats: 7 },
terms: { invoiceAmountCents: 500_00, billingInterval: 'year' },
},
},
]),
'sub-local',
{ seats: '10', simConfigRevision: '1', simConfigOperationId: 'config-1' }
)
expect(state.hasUnappliedIntent).toBe(true)
expect(state.effectiveSeatCapacity).toBe(7)
expect(state.configurationUpdate).toMatchObject({
id: 'config-2',
status: 'pending',
providerAccepted: false,
error: null,
})
})
it('releases a legacy commercial-term intent after Stripe confirms it was not applied', async () => {
const state = await resolveEnterpriseMetadataIntent(
executorReturning([
{
id: 'config-2',
status: 'pending',
payload: {
subscriptionId: 'sub-local',
revision: 2,
metadata: {
seats: 7,
reportingPeriodAnchorDate: '2026-05-01',
},
terms: { invoiceAmountCents: 500_00, billingInterval: 'year' },
commercialTermsRetiredAt: '2026-08-01T00:00:00.000Z',
},
},
]),
'sub-local',
{ seats: '10', simConfigRevision: '1', simConfigOperationId: 'config-1' }
)
expect(state.hasUnappliedIntent).toBe(false)
expect(state.effectiveSeatCapacity).toBe(10)
expect(state.configurationUpdate).toEqual({
id: 'config-2',
status: 'failed',
requestedMetadata: {
seats: 7,
reportingPeriodAnchorDate: '2026-05-01',
},
requestedTerms: { invoiceAmountCents: 500_00, billingInterval: 'year' },
providerAccepted: false,
error:
'Enterprise commercial-term updates are no longer supported. Submit a reporting-period change instead.',
})
})
it('keeps a Stripe-accepted legacy commercial-term intent fail-closed', async () => {
const state = await resolveEnterpriseMetadataIntent(
executorReturning([
{
id: 'config-2',
status: 'dead_letter',
payload: {
subscriptionId: 'sub-local',
revision: 2,
metadata: { seats: 7 },
terms: { invoiceAmountCents: 500_00, billingInterval: 'year' },
acknowledgement: {
startedAt: '2026-08-01T00:00:00.000Z',
deadlineAt: '2026-08-01T00:30:00.000Z',
},
},
},
]),
'sub-local',
{ seats: '10', simConfigRevision: '1', simConfigOperationId: 'config-1' }
)
expect(state.hasUnappliedIntent).toBe(true)
expect(state.effectiveSeatCapacity).toBe(7)
expect(state.configurationUpdate).toMatchObject({
id: 'config-2',
status: 'failed',
requestedTerms: { invoiceAmountCents: 500_00, billingInterval: 'year' },
providerAccepted: true,
})
})
it('does not release an accepted legacy intent even if a retirement marker is present', async () => {
const state = await resolveEnterpriseMetadataIntent(
executorReturning([
{
id: 'config-2',
status: 'dead_letter',
payload: {
subscriptionId: 'sub-local',
revision: 2,
metadata: { seats: 7 },
terms: { invoiceAmountCents: 500_00, billingInterval: 'year' },
commercialTermsRetiredAt: '2026-08-01T00:00:00.000Z',
deliveryState: {
priorPause: null,
billingIntervalChanged: true,
providerAcceptedAt: '2026-08-01T00:00:00.000Z',
},
},
},
]),
'sub-local',
{ seats: '10', simConfigRevision: '1', simConfigOperationId: 'config-1' }
)
expect(state.hasUnappliedIntent).toBe(true)
expect(state.effectiveSeatCapacity).toBe(7)
expect(state.configurationUpdate).toMatchObject({
id: 'config-2',
status: 'failed',
providerAccepted: true,
error: null,
})
})
it('keeps a Stripe-accepted dead letter fail-closed until reconciliation', async () => {
const state = await resolveEnterpriseMetadataIntent(
executorReturning([
+25 -7
View File
@@ -94,6 +94,7 @@ export const enterpriseMetadataSyncPayloadSchema = z.object({
billingInterval: z.enum(['month', 'year']),
})
.optional(),
commercialTermsRetiredAt: z.string().datetime().optional(),
stripeProgress: z.object({ priceId: z.string().min(1).optional() }).default({}),
deliveryState: z
.object({
@@ -118,6 +119,14 @@ export function enterpriseMetadataDeliveryIsVerified(
return payload.deliveryState?.verifiedAt !== undefined
}
export function enterpriseMetadataIntentProviderAccepted(
payload: EnterpriseMetadataSyncPayload
): boolean {
return (
payload.deliveryState?.providerAcceptedAt !== undefined || payload.acknowledgement !== undefined
)
}
function stripeMetadataValueMatches(
metadata: Stripe.Metadata,
key: string,
@@ -274,7 +283,8 @@ export function enterpriseOperationMatchesStripeSubscription(
stripeMetadataInteger(metadata, 'invoiceAmountCents') === request.invoiceAmountCents &&
stripeMetadataInteger(metadata, 'usageLimitCredits') === request.usageLimitCredits &&
(request.reportingPeriodAnchorDate === undefined ||
metadata.reportingPeriodAnchorDate === request.reportingPeriodAnchorDate) &&
(metadata.reportingPeriodAnchorDate === request.reportingPeriodAnchorDate &&
metadata.reportingPeriodInterval === request.billingInterval)) &&
stripeMetadataInteger(metadata, 'seats') === request.seats &&
(request.concurrencyLimit === undefined ||
stripeMetadataInteger(metadata, 'concurrencyLimit') === request.concurrencyLimit) &&
@@ -422,11 +432,15 @@ export async function resolveEnterpriseMetadataIntent(
const appliedOperationId = appliedMetadata.simConfigOperationId
const operationApplied = appliedOperationId === latest.id
const providerAccepted =
parsed.data.deliveryState?.providerAcceptedAt !== undefined ||
parsed.data.acknowledgement !== undefined
const providerAccepted = enterpriseMetadataIntentProviderAccepted(parsed.data)
const retiredCommercialTerms =
parsed.data.terms !== undefined &&
parsed.data.commercialTermsRetiredAt !== undefined &&
!providerAccepted
const hasUnappliedIntent =
!operationApplied && (latest.status !== 'dead_letter' || providerAccepted)
!operationApplied &&
!retiredCommercialTerms &&
(latest.status !== 'dead_letter' || providerAccepted)
const desiredMetadata = hasUnappliedIntent ? parsed.data.metadata : appliedMetadata
const desiredSeats = positiveInteger(parsed.data.metadata.seats)
const effectiveSeatCapacity = hasUnappliedIntent
@@ -448,7 +462,7 @@ export async function resolveEnterpriseMetadataIntent(
: {
id: latest.id,
status:
latest.status === 'dead_letter'
retiredCommercialTerms || latest.status === 'dead_letter'
? 'failed'
: latest.status === 'processing'
? 'processing'
@@ -456,7 +470,11 @@ export async function resolveEnterpriseMetadataIntent(
requestedMetadata: parsed.data.metadata,
requestedTerms: parsed.data.terms ?? null,
providerAccepted,
error: latest.status === 'dead_letter' ? (latest.lastError ?? null) : null,
error: retiredCommercialTerms
? 'Enterprise commercial-term updates are no longer supported. Submit a reporting-period change instead.'
: latest.status === 'dead_letter'
? (latest.lastError ?? null)
: null,
},
}
}
@@ -1078,6 +1078,7 @@ describe('Enterprise issuance outbox handler', () => {
metadata: expect.objectContaining({
invoiceAmountCents: '120000',
reportingPeriodAnchorDate: '2026-08-01',
reportingPeriodInterval: 'year',
}),
}),
expect.any(Object)
@@ -1251,6 +1252,8 @@ describe('Enterprise metadata outbox handler', () => {
seats: 15,
usageLimitCredits: 35000,
concurrencyLimit: 1250,
reportingPeriodAnchorDate: '2026-05-01',
reportingPeriodInterval: 'year',
},
}
queueTableRows(schemaMock.subscription, [
@@ -1259,7 +1262,15 @@ describe('Enterprise metadata outbox handler', () => {
queueTableRows(schemaMock.subscription, [{ metadata: {} }])
queueTableRows(schemaMock.outboxEvent, [{ id: 'metadata-event-1', payload }])
queueTableRows(schemaMock.member, [{ value: 10 }])
mocks.subscriptionsUpdate.mockResolvedValue({ id: 'sub_1' })
mocks.subscriptionsRetrieve.mockResolvedValue({
id: 'sub_1',
metadata: {},
pause_collection: { behavior: 'keep_as_draft', resumes_at: null },
})
mocks.subscriptionsUpdate.mockResolvedValue({
id: 'sub_1',
pause_collection: { behavior: 'keep_as_draft', resumes_at: null },
})
const checkpointPayload = vi.fn()
await expect(
@@ -1289,17 +1300,21 @@ describe('Enterprise metadata outbox handler', () => {
metadata: expect.objectContaining({
seats: '15',
concurrencyLimit: '1250',
reportingPeriodAnchorDate: '2026-05-01',
reportingPeriodInterval: 'year',
simConfigRevision: '4',
simConfigOperationId: 'metadata-event-1',
simConfigDeliveryRevision: '0',
simConfigDeliveryAttempt: '0',
}),
expand: ['latest_invoice'],
},
{
idempotencyKey: 'enterprise-config:local-sub-1:metadata-event-1:delivery:0:attempt:0',
}
)
expect(mocks.pricesCreate).not.toHaveBeenCalled()
expect(mocks.invoicesUpdate).not.toHaveBeenCalled()
expect(mocks.subscriptionsRetrieve).toHaveBeenCalledWith('sub_1')
})
it('does not send a seat decrease below current pending reservations to Stripe', async () => {
@@ -1351,7 +1366,12 @@ describe('Enterprise metadata outbox handler', () => {
queueTableRows(schemaMock.subscription, [{ metadata: {} }])
queueTableRows(schemaMock.outboxEvent, [{ id: 'metadata-event-2', payload }])
queueTableRows(schemaMock.member, [{ value: 10 }])
mocks.subscriptionsUpdate.mockResolvedValue({ id: 'sub_1' })
mocks.subscriptionsRetrieve.mockResolvedValue({
id: 'sub_1',
metadata: {},
pause_collection: null,
})
mocks.subscriptionsUpdate.mockResolvedValue({ id: 'sub_1', pause_collection: null })
await expect(
syncEnterpriseMetadataInStripe(payload, {
@@ -1430,33 +1450,79 @@ describe('Enterprise metadata outbox handler', () => {
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
})
it('repairs a paused cadence-change invoice before waiting for the webhook', async () => {
it('retires an unapplied legacy commercial intent even when its seats are now too low', async () => {
const payload = {
subscriptionId: 'local-sub-1',
revision: 7,
deliveryRevision: 1,
acknowledgement: {
startedAt: '2026-08-13T00:00:00.000Z',
deadlineAt: '2099-08-13T00:30:00.000Z',
},
deliveryRevision: 0,
metadata: {
plan: 'enterprise',
referenceId: 'org-1',
seats: 15,
seats: 5,
invoiceAmountCents: 120000,
reportingPeriodAnchorDate: '2026-05-01',
},
terms: { invoiceAmountCents: 120000, billingInterval: 'year' as const },
stripeProgress: { priceId: 'price_year' },
deliveryState: {
priorPause: { behavior: 'keep_as_draft' as const, resumesAt: null },
billingIntervalChanged: true,
},
stripeProgress: {},
}
queueTableRows(schemaMock.subscription, [
{ stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} },
])
queueTableRows(schemaMock.subscription, [{ metadata: {} }])
queueTableRows(schemaMock.outboxEvent, [{ id: 'metadata-event-recovery', payload }])
queueTableRows(schemaMock.outboxEvent, [{ id: 'legacy-terms-event', payload }])
queueTableRows(schemaMock.member, [{ value: 10 }])
mocks.subscriptionsRetrieve.mockResolvedValue({
id: 'sub_1',
metadata: {},
items: { data: [] },
pause_collection: { behavior: 'keep_as_draft', resumes_at: null },
})
const checkpointPayload = vi.fn()
await expect(
syncEnterpriseMetadataInStripe(payload, {
eventId: 'legacy-terms-event',
eventType: 'stripe.sync-enterprise-metadata',
attempts: 7,
checkpointPayload,
})
).resolves.toBeUndefined()
expect(mocks.subscriptionsRetrieve).toHaveBeenCalledWith('sub_1')
expect(checkpointPayload).toHaveBeenCalledWith({
commercialTermsRetiredAt: expect.any(String),
})
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
expect(mocks.pricesCreate).not.toHaveBeenCalled()
expect(mocks.invoicesUpdate).not.toHaveBeenCalled()
})
it('finishes verification when Stripe already contains an accepted legacy commercial intent', async () => {
const payload = {
subscriptionId: 'local-sub-1',
revision: 7,
deliveryRevision: 2,
metadata: {
plan: 'enterprise',
referenceId: 'org-1',
seats: 15,
invoiceAmountCents: 120000,
reportingPeriodAnchorDate: '2026-05-01',
},
terms: { invoiceAmountCents: 120000, billingInterval: 'year' as const },
commercialTermsRetiredAt: '2026-08-21T18:01:00.000Z',
deliveryState: {
priorPause: { behavior: 'keep_as_draft' as const, resumesAt: null },
billingIntervalChanged: true,
providerAcceptedAt: '2026-08-21T18:00:00.000Z',
},
stripeProgress: { priceId: 'price_year' },
}
queueTableRows(schemaMock.subscription, [
{ stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} },
])
queueTableRows(schemaMock.subscription, [{ metadata: {} }])
queueTableRows(schemaMock.outboxEvent, [{ id: 'accepted-legacy-terms-event', payload }])
queueTableRows(schemaMock.member, [{ value: 10 }])
mocks.subscriptionsRetrieve.mockResolvedValue({
id: 'sub_1',
@@ -1465,15 +1531,15 @@ describe('Enterprise metadata outbox handler', () => {
referenceId: 'org-1',
seats: '15',
invoiceAmountCents: '120000',
simConfigOperationId: 'metadata-event-recovery',
reportingPeriodAnchorDate: '2026-05-01',
simConfigOperationId: 'accepted-legacy-terms-event',
simConfigRevision: '7',
simConfigDeliveryRevision: '1',
simConfigDeliveryRevision: '2',
},
schedule: null,
collection_method: 'send_invoice',
days_until_due: 30,
schedule: null,
pause_collection: { behavior: 'keep_as_draft', resumes_at: null },
latest_invoice: { id: 'in_change', status: 'draft', auto_advance: true },
items: {
data: [
{
@@ -1491,25 +1557,86 @@ describe('Enterprise metadata outbox handler', () => {
await expect(
syncEnterpriseMetadataInStripe(payload, {
eventId: 'metadata-event-recovery',
eventId: 'accepted-legacy-terms-event',
eventType: 'stripe.sync-enterprise-metadata',
attempts: 2,
attempts: 7,
checkpointPayload,
})
).resolves.toMatchObject({ outcome: 'deferred', consumeAttempt: false })
).resolves.toMatchObject({
outcome: 'deferred',
consumeAttempt: false,
reason: 'Waiting for the verified Stripe webhook acknowledgement',
})
expect(checkpointPayload).toHaveBeenNthCalledWith(1, {
deliveryState: expect.objectContaining({
providerAcceptedAt: '2026-08-21T18:00:00.000Z',
verifiedAt: expect.any(String),
}),
})
expect(checkpointPayload).toHaveBeenNthCalledWith(2, {
acknowledgement: expect.objectContaining({
startedAt: expect.any(String),
deadlineAt: expect.any(String),
}),
})
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
expect(mocks.invoicesUpdate).toHaveBeenCalledWith(
'in_change',
{ auto_advance: false },
{ idempotencyKey: 'enterprise:metadata-event-recovery:initial-invoice-draft' }
expect(mocks.pricesCreate).not.toHaveBeenCalled()
expect(mocks.invoicesUpdate).not.toHaveBeenCalled()
})
it('keeps an accepted legacy commercial intent fail-closed when Stripe no longer matches', async () => {
const payload = {
subscriptionId: 'local-sub-1',
revision: 7,
deliveryRevision: 2,
metadata: {
plan: 'enterprise',
referenceId: 'org-1',
seats: 15,
invoiceAmountCents: 120000,
reportingPeriodAnchorDate: '2026-05-01',
},
terms: { invoiceAmountCents: 120000, billingInterval: 'year' as const },
commercialTermsRetiredAt: '2026-08-21T18:01:00.000Z',
deliveryState: {
priorPause: { behavior: 'keep_as_draft' as const, resumesAt: null },
billingIntervalChanged: true,
providerAcceptedAt: '2026-08-21T18:00:00.000Z',
},
stripeProgress: { priceId: 'price_year' },
}
queueTableRows(schemaMock.subscription, [
{ stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} },
])
queueTableRows(schemaMock.subscription, [{ metadata: {} }])
queueTableRows(schemaMock.outboxEvent, [{ id: 'accepted-legacy-terms-event', payload }])
queueTableRows(schemaMock.member, [{ value: 10 }])
mocks.subscriptionsRetrieve.mockResolvedValue({
id: 'sub_1',
metadata: {},
items: { data: [] },
pause_collection: { behavior: 'keep_as_draft', resumes_at: null },
})
const checkpointPayload = vi.fn()
await expect(
syncEnterpriseMetadataInStripe(payload, {
eventId: 'accepted-legacy-terms-event',
eventType: 'stripe.sync-enterprise-metadata',
attempts: 7,
checkpointPayload,
})
).rejects.toThrow(
'Legacy Enterprise commercial terms were accepted by Stripe but no longer match; manual reconciliation is required'
)
expect(checkpointPayload).toHaveBeenCalledWith({
deliveryState: expect.objectContaining({ providerAcceptedAt: expect.any(String) }),
})
expect(checkpointPayload).toHaveBeenCalledWith({
deliveryState: expect.objectContaining({ verifiedAt: expect.any(String) }),
expect(checkpointPayload).not.toHaveBeenCalledWith({
commercialTermsRetiredAt: expect.any(String),
})
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
expect(mocks.pricesCreate).not.toHaveBeenCalled()
expect(mocks.invoicesUpdate).not.toHaveBeenCalled()
})
it('consumes the finite missing-ack budget only after the durable grace deadline', async () => {
@@ -1551,7 +1678,7 @@ describe('Enterprise metadata outbox handler', () => {
})
})
it('replaces the single Enterprise Price in place without proration', async () => {
it('does not replace a Stripe Price for a legacy interval-change intent', async () => {
const payload = {
subscriptionId: 'local-sub-1',
revision: 6,
@@ -1606,27 +1733,17 @@ describe('Enterprise metadata outbox handler', () => {
attempts: 0,
checkpointPayload,
})
).resolves.toMatchObject({ outcome: 'deferred' })
).resolves.toBeUndefined()
expect(mocks.subscriptionsUpdate).toHaveBeenCalledWith(
'sub_1',
expect.objectContaining({
items: [{ id: 'si_1', price: 'price_year', quantity: 1 }],
proration_behavior: 'none',
billing_cycle_anchor: 'now',
metadata: expect.objectContaining({
invoiceAmountCents: '120000',
reportingPeriodAnchorDate: '2026-01-31',
}),
}),
expect.any(Object)
)
expect(mocks.subscriptionsRetrieve).toHaveBeenCalledWith('sub_1')
expect(mocks.pricesCreate).not.toHaveBeenCalled()
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
expect(checkpointPayload).toHaveBeenCalledWith({
stripeProgress: { priceId: 'price_year' },
commercialTermsRetiredAt: expect.any(String),
})
})
it('preserves paused collection and freezes the cadence-change invoice as a draft', async () => {
it('does not touch a paused subscription for a legacy commercial intent', async () => {
const payload = {
subscriptionId: 'local-sub-1',
revision: 7,
@@ -1677,22 +1794,21 @@ describe('Enterprise metadata outbox handler', () => {
latest_invoice: { id: 'in_change', status: 'draft', auto_advance: true },
})
await syncEnterpriseMetadataInStripe(payload, {
eventId: 'metadata-event-paused',
eventType: 'stripe.sync-enterprise-metadata',
attempts: 0,
checkpointPayload: vi.fn(),
})
await expect(
syncEnterpriseMetadataInStripe(payload, {
eventId: 'metadata-event-paused',
eventType: 'stripe.sync-enterprise-metadata',
attempts: 0,
checkpointPayload: vi.fn(),
})
).resolves.toBeUndefined()
expect(mocks.invoicesUpdate).toHaveBeenCalledWith(
'in_change',
{ auto_advance: false },
{ idempotencyKey: 'enterprise:metadata-event-paused:initial-invoice-draft' }
)
expect(mocks.subscriptionsUpdate.mock.calls[0][1]).not.toHaveProperty('pause_collection')
expect(mocks.subscriptionsRetrieve).toHaveBeenCalledWith('sub_1')
expect(mocks.invoicesUpdate).not.toHaveBeenCalled()
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
})
it('does not treat an old paid invoice as a failed paused amount-only update', async () => {
it('does not inspect invoices for a legacy amount-change intent', async () => {
const payload = {
subscriptionId: 'local-sub-1',
revision: 8,
@@ -1751,13 +1867,14 @@ describe('Enterprise metadata outbox handler', () => {
attempts: 0,
checkpointPayload: vi.fn(),
})
).resolves.toMatchObject({ outcome: 'deferred' })
).resolves.toBeUndefined()
expect(mocks.subscriptionsRetrieve).toHaveBeenCalledWith('sub_1')
expect(mocks.invoicesUpdate).not.toHaveBeenCalled()
expect(mocks.subscriptionsUpdate.mock.calls[0][1]).not.toHaveProperty('billing_cycle_anchor')
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
})
it('rejects billing-term changes controlled by a Stripe Schedule', async () => {
it('retires a legacy billing-term intent without touching its Stripe Schedule', async () => {
const payload = {
subscriptionId: 'local-sub-1',
revision: 6,
@@ -1788,7 +1905,8 @@ describe('Enterprise metadata outbox handler', () => {
attempts: 0,
checkpointPayload: vi.fn(),
})
).rejects.toThrow('Stripe Schedule')
).resolves.toBeUndefined()
expect(mocks.subscriptionsRetrieve).toHaveBeenCalledWith('sub_1')
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
})
+52 -165
View File
@@ -57,6 +57,7 @@ import {
enterpriseInvitePeoplePayloadSchema,
enterpriseMemberReconciliationPayloadSchema,
enterpriseMetadataIntentMatchesStripeSubscription,
enterpriseMetadataIntentProviderAccepted,
enterpriseMetadataSyncPayloadSchema,
enterpriseProvisionPayloadSchema,
enterpriseWorkspaceMovePayloadSchema,
@@ -302,33 +303,6 @@ async function findOperationPrice(
return match
}
async function findConfigurationPrice(
stripe: Stripe,
productId: string,
operationId: string
): Promise<Stripe.Price | null> {
let match: Stripe.Price | null = null
let startingAfter: string | undefined
for (;;) {
const page = await stripe.prices.list({
product: productId,
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
})
for (const candidate of page.data) {
if (candidate.metadata?.enterpriseConfigOperationId !== operationId) continue
if (match && match.id !== candidate.id) {
throw new Error('Multiple Stripe prices exist for this Enterprise configuration update')
}
match = candidate
}
if (!page.has_more) break
startingAfter = page.data.at(-1)?.id
if (!startingAfter) break
}
return match
}
function isStripeMissingResource(error: unknown): boolean {
return Boolean(
error &&
@@ -374,25 +348,6 @@ function assertEnterprisePrice(
}
}
function assertEnterpriseConfigurationPrice(
price: Stripe.Price,
terms: { invoiceAmountCents: number; billingInterval: 'month' | 'year' },
operationId: string,
expectedProductId: string
): void {
const productId = typeof price.product === 'string' ? price.product : price.product?.id
if (
price.currency !== 'usd' ||
price.unit_amount !== terms.invoiceAmountCents ||
price.recurring?.interval !== terms.billingInterval ||
(price.recurring.interval_count ?? 1) !== 1 ||
price.metadata?.enterpriseConfigOperationId !== operationId ||
productId !== expectedProductId
) {
throw new Error('Recovered Stripe price does not match the Enterprise billing-term update')
}
}
export interface IssueEnterpriseProvisioningInput {
ownerUserId: string
organizationName?: string
@@ -2319,9 +2274,7 @@ function stripePauseMatchesDeliveryState(
}
async function verifyEnterpriseMetadataDelivery(params: {
stripe: Stripe
subscription: Stripe.Subscription
operationId: string
deliveryState: EnterpriseMetadataDeliveryState
context: OutboxEventContext
}): Promise<void> {
@@ -2337,17 +2290,6 @@ async function verifyEnterpriseMetadataDelivery(params: {
throw new Error('Stripe did not preserve Enterprise payment-collection pause settings')
}
if (
acceptedState.billingIntervalChanged &&
acceptedState.priorPause?.behavior === 'keep_as_draft'
) {
await keepInitialEnterpriseInvoiceAsDraft({
stripe: params.stripe,
subscription: params.subscription,
operationId: params.operationId,
})
}
if (!acceptedState.verifiedAt) {
await params.context.checkpointPayload({
deliveryState: { ...acceptedState, verifiedAt: new Date().toISOString() },
@@ -2424,7 +2366,10 @@ export const provisionEnterpriseInStripe: OutboxHandler<unknown> = async (rawPay
enterpriseOperationId: context.eventId,
invoiceAmountCents: request.invoiceAmountCents.toString(),
...(request.reportingPeriodAnchorDate
? { reportingPeriodAnchorDate: request.reportingPeriodAnchorDate }
? {
reportingPeriodAnchorDate: request.reportingPeriodAnchorDate,
reportingPeriodInterval: request.billingInterval,
}
: {}),
usageLimitCredits: request.usageLimitCredits.toString(),
seats: request.seats.toString(),
@@ -2659,6 +2604,52 @@ export const syncEnterpriseMetadataInStripe: OutboxHandler<unknown> = async (
const latestPayload = enterpriseMetadataSyncPayloadSchema.safeParse(latest.payload)
if (!latestPayload.success) throw new Error('Latest Enterprise metadata intent is invalid')
if (
latestPayload.data.terms &&
latestPayload.data.commercialTermsRetiredAt &&
!enterpriseMetadataIntentProviderAccepted(latestPayload.data)
) {
return
}
const metadata: Record<string, string> = {}
for (const [key, value] of Object.entries(latestPayload.data.metadata)) {
if (value === null) metadata[key] = ''
else if (value !== undefined) metadata[key] = String(value)
}
metadata.simConfigRevision = String(latestPayload.data.revision)
metadata.simConfigOperationId = context.eventId
metadata.simConfigDeliveryRevision = String(latestPayload.data.deliveryRevision)
metadata.simConfigDeliveryAttempt = String(context.attempts)
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
const deliveryAlreadyWritten = enterpriseMetadataIntentMatchesStripeSubscription(
latestPayload.data,
context.eventId,
stripeSubscription
)
if (deliveryAlreadyWritten) {
const deliveryState = latestPayload.data.deliveryState
if (!deliveryState) {
throw new Error('Enterprise configuration delivery state was not checkpointed')
}
await verifyEnterpriseMetadataDelivery({
subscription: stripeSubscription,
deliveryState,
context,
})
return waitForEnterpriseWebhookAcknowledgement(latestPayload.data.acknowledgement, context)
}
if (latestPayload.data.terms) {
if (enterpriseMetadataIntentProviderAccepted(latestPayload.data)) {
throw new Error(
'Legacy Enterprise commercial terms were accepted by Stripe but no longer match; manual reconciliation is required'
)
}
await context.checkpointPayload({ commercialTermsRetiredAt: new Date().toISOString() })
return
}
const desiredSeats = Number(latestPayload.data.metadata.seats)
const currentSeatRequirement = await getEnterpriseIssuanceSeatRequirement({
executor: db,
@@ -2673,103 +2664,9 @@ export const syncEnterpriseMetadataInStripe: OutboxHandler<unknown> = async (
throw new Error('Enterprise seat intent is below current occupied or reserved seats')
}
const metadata: Record<string, string> = {}
for (const [key, value] of Object.entries(latestPayload.data.metadata)) {
if (value === null) metadata[key] = ''
else if (value !== undefined) metadata[key] = String(value)
}
metadata.simConfigRevision = String(latestPayload.data.revision)
metadata.simConfigOperationId = context.eventId
metadata.simConfigDeliveryRevision = String(latestPayload.data.deliveryRevision)
metadata.simConfigDeliveryAttempt = String(context.attempts)
const stripe = requireStripeClient()
const terms = latestPayload.data.terms
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId, {
expand: ['latest_invoice'],
})
const deliveryAlreadyWritten = enterpriseMetadataIntentMatchesStripeSubscription(
latestPayload.data,
context.eventId,
stripeSubscription
)
if (deliveryAlreadyWritten) {
const deliveryState = latestPayload.data.deliveryState
if (!deliveryState) {
throw new Error('Enterprise configuration delivery state was not checkpointed')
}
await verifyEnterpriseMetadataDelivery({
stripe,
subscription: stripeSubscription,
operationId: context.eventId,
deliveryState,
context,
})
return waitForEnterpriseWebhookAcknowledgement(latestPayload.data.acknowledgement, context)
}
let priceId = latestPayload.data.stripeProgress.priceId ?? null
let updateItems: Stripe.SubscriptionUpdateParams.Item[] | undefined
let billingIntervalChanged = false
if (terms) {
if (stripeSubscription.schedule) {
throw new Error(
'Enterprise billing terms cannot be changed while a Stripe Schedule controls the subscription'
)
}
if (
stripeSubscription.collection_method !== 'send_invoice' ||
stripeSubscription.days_until_due !== 30
) {
throw new Error(
'Enterprise billing-term updates require send-invoice collection with 30-day terms'
)
}
const items = stripeSubscription.items.data
if (items.length !== 1) {
throw new Error(
'Enterprise billing-term updates require exactly one Stripe subscription item'
)
}
const currentItem = items[0]
billingIntervalChanged = currentItem.price.recurring?.interval !== terms.billingInterval
const productId =
typeof currentItem.price.product === 'string'
? currentItem.price.product
: currentItem.price.product?.id
if (!productId) throw new Error('Enterprise subscription price has no reusable product')
let price: Stripe.Price | null = null
if (priceId) {
price = await stripe.prices.retrieve(priceId)
} else {
price = await findConfigurationPrice(stripe, productId, context.eventId)
}
if (!price) {
price = await stripe.prices.create(
{
currency: 'usd',
unit_amount: terms.invoiceAmountCents,
recurring: { interval: terms.billingInterval },
product: productId,
metadata: { enterpriseConfigOperationId: context.eventId },
},
{
idempotencyKey: `enterprise-config:${payload.subscriptionId}:${context.eventId}:price`,
}
)
}
assertEnterpriseConfigurationPrice(price, terms, context.eventId, productId)
priceId = price.id
if (latestPayload.data.stripeProgress.priceId !== priceId) {
await context.checkpointPayload({ stripeProgress: { priceId } })
}
updateItems = [{ id: currentItem.id, price: priceId, quantity: 1 }]
}
const deliveryState: EnterpriseMetadataDeliveryState = {
priorPause: stripePauseState(stripeSubscription.pause_collection),
billingIntervalChanged,
billingIntervalChanged: false,
}
await context.checkpointPayload({ deliveryState })
@@ -2777,23 +2674,13 @@ export const syncEnterpriseMetadataInStripe: OutboxHandler<unknown> = async (
stripeSubscriptionId,
{
metadata,
...(updateItems
? {
items: updateItems,
proration_behavior: 'none' as const,
...(billingIntervalChanged ? { billing_cycle_anchor: 'now' as const } : {}),
}
: {}),
expand: ['latest_invoice'],
},
{
idempotencyKey: `enterprise-config:${payload.subscriptionId}:${context.eventId}:delivery:${latestPayload.data.deliveryRevision}:attempt:${context.attempts}`,
}
)
await verifyEnterpriseMetadataDelivery({
stripe,
subscription: updatedSubscription,
operationId: context.eventId,
deliveryState,
context,
})
+2
View File
@@ -81,11 +81,13 @@ describe('Enterprise subscription metadata', () => {
invoiceAmountCents: '120000',
seats: '25',
reportingPeriodAnchorDate: '2026-01-31',
reportingPeriodInterval: 'year',
})
).toMatchObject({
invoiceAmountCents: 120000,
invoiceAmountUsd: 1200,
reportingPeriodAnchorDate: '2026-01-31',
reportingPeriodInterval: 'year',
})
})
})
+1
View File
@@ -32,6 +32,7 @@ export const enterpriseSubscriptionMetadataSchema = z
)
}, 'Reporting-period anchor must be a valid UTC date that is not in the future')
.optional(),
reportingPeriodInterval: z.enum(['month', 'year']).optional(),
concurrencyLimit: z.coerce
.number()
.int()