mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
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:
committed by
GitHub
parent
6a45b0d4a6
commit
cd0516cade
+13
-8
@@ -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')
|
||||
)
|
||||
}
|
||||
})
|
||||
+15
-8
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user