Skip to content

Commit c8cd452

Browse files
icecrasher321claude
andcommitted
fix(billing): resolve reporting windows through the canonical period resolver
The close paths now ask resolveSubscriptionUsagePeriod whether a subscription derives its windows from a reporting anchor instead of re-checking metadata shape locally, so a malformed hand-edited anchor that the resolver rejects (falling back to Stripe bounds) books its Stripe-stamped ledger rows normally instead of skipping bookkeeping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8d3fb3f commit c8cd452

2 files changed

Lines changed: 25 additions & 11 deletions

File tree

apps/sim/lib/billing/cycle-close.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
mockEnqueueOutboxEvent,
1313
mockGetPlanPricing,
1414
mockGetPlanTierDollars,
15+
mockResolveSubscriptionUsagePeriod,
1516
mockIsEnterprise,
1617
mockIsFree,
1718
mockRecordAudit,
@@ -24,6 +25,7 @@ const {
2425
mockEnqueueOutboxEvent: vi.fn(),
2526
mockGetPlanPricing: vi.fn(),
2627
mockGetPlanTierDollars: vi.fn(),
28+
mockResolveSubscriptionUsagePeriod: vi.fn(),
2729
mockIsEnterprise: vi.fn(),
2830
mockIsFree: vi.fn(),
2931
mockRecordAudit: vi.fn(),
@@ -42,7 +44,7 @@ vi.mock('@/lib/billing/core/billing', () => ({
4244
}))
4345

4446
vi.mock('@/lib/billing/core/reporting-period', () => ({
45-
ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY: 'reportingPeriodAnchorDate',
47+
resolveSubscriptionUsagePeriod: mockResolveSubscriptionUsagePeriod,
4648
}))
4749

4850
vi.mock('@/lib/billing/core/usage-log', () => ({
@@ -141,6 +143,7 @@ describe('closeElapsedBillingPeriod', () => {
141143
mockIsSubscriptionOrgScoped.mockResolvedValue(true)
142144
mockIsEnterprise.mockReturnValue(false)
143145
mockIsFree.mockReturnValue(false)
146+
mockResolveSubscriptionUsagePeriod.mockReturnValue(null)
144147
mockGetPlanTierDollars.mockReturnValue(40)
145148
mockGetPlanPricing.mockReturnValue({ basePrice: 40 })
146149
mockComputeDailyRefreshConsumed.mockResolvedValue(0)
@@ -364,6 +367,7 @@ describe('closeElapsedBillingPeriod', () => {
364367

365368
it('only advances the marker for enterprise orgs on reporting anchors', async () => {
366369
mockIsEnterprise.mockReturnValue(true)
370+
mockResolveSubscriptionUsagePeriod.mockReturnValue({ source: 'reporting' })
367371

368372
const result = await closeElapsedBillingPeriod(
369373
subRow({ plan: 'enterprise', metadata: { reportingPeriodAnchorDate: '2026-05-01' } })
@@ -400,6 +404,7 @@ describe('writeFinalPeriodBookkeeping', () => {
400404
resetDbChainMock()
401405
mockIsSubscriptionOrgScoped.mockResolvedValue(true)
402406
mockIsEnterprise.mockReturnValue(false)
407+
mockResolveSubscriptionUsagePeriod.mockReturnValue(null)
403408
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 25]]))
404409
dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }])
405410
})
@@ -428,6 +433,7 @@ describe('writeFinalPeriodBookkeeping', () => {
428433

429434
it('only claims the marker for reporting-anchor enterprise subscriptions', async () => {
430435
mockIsEnterprise.mockReturnValue(true)
436+
mockResolveSubscriptionUsagePeriod.mockReturnValue({ source: 'reporting' })
431437

432438
await writeFinalPeriodBookkeeping({
433439
id: 'sub-1',

apps/sim/lib/billing/cycle-close.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@ import {
99
} from '@sim/db/schema'
1010
import { createLogger } from '@sim/logger'
1111
import { getErrorMessage } from '@sim/utils/errors'
12-
import { isRecordLike } from '@sim/utils/object'
1312
import { and, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm'
1413
import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants'
1514
import { computeOrgOverageAmount, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
16-
import { ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY } from '@/lib/billing/core/reporting-period'
15+
import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period'
1716
import {
1817
COPILOT_USAGE_SOURCES,
1918
getStampedPeriodRangeUsageCostByUser,
@@ -82,12 +81,21 @@ function rosterSignature(rows: { userId: string; role: string }[]): string {
8281
.join('|')
8382
}
8483

85-
function hasEnterpriseReportingAnchor(sub: { plan: string | null; metadata?: unknown }): boolean {
86-
return (
87-
isEnterprise(sub.plan) &&
88-
isRecordLike(sub.metadata) &&
89-
typeof sub.metadata[ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY] === 'string'
90-
)
84+
/**
85+
* Whether this subscription's usage windows derive from an enterprise
86+
* reporting anchor. Asks the same resolver the usage math uses, so a
87+
* malformed anchor (hand-edited Stripe metadata) that the resolver rejects —
88+
* falling back to Stripe bounds — is treated identically here: the ledger
89+
* rows are stamped with Stripe windows, and the close books them normally.
90+
*/
91+
function usesReportingWindows(sub: {
92+
plan?: string | null
93+
billingInterval?: string | null
94+
metadata?: unknown
95+
periodStart?: Date | null
96+
periodEnd?: Date | null
97+
}): boolean {
98+
return resolveSubscriptionUsagePeriod(sub)?.source === 'reporting'
9199
}
92100

93101
/**
@@ -260,7 +268,7 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise<C
260268
const closedRange = { from: closeFrom, to: periodStart }
261269

262270
const enterprise = isEnterprise(sub.plan)
263-
if (enterprise && hasEnterpriseReportingAnchor(sub)) {
271+
if (enterprise && usesReportingWindows(sub)) {
264272
// Reporting-anchor orgs derive every usage window live from the anchor;
265273
// there is nothing to bill or book here. Advance the marker so the sweep
266274
// stays quiet.
@@ -611,7 +619,7 @@ export async function writeFinalPeriodBookkeeping(sub: {
611619
if (!sub.periodStart) return
612620
const periodStart = sub.periodStart
613621

614-
if (hasEnterpriseReportingAnchor(sub)) {
622+
if (usesReportingWindows(sub)) {
615623
await db.transaction(async (tx) => claimCloseMarker(tx, sub.id, periodStart))
616624
return
617625
}

0 commit comments

Comments
 (0)