Skip to content

Commit 72c2547

Browse files
icecrasher321claude
andcommitted
improvement(billing): make usage ledger-only and close cycles off period advance
Usage is now the attributed usage_log ledger everywhere: the userStats baselines (currentPeriodCost / currentPeriodCopilotCost), the includeLegacyBaseline compatibility flag, the pro-snapshot join/leave machinery, and departedMemberUsage accrual are removed from all read and write paths. Cycle rollover (final sub-threshold overage collection, billedOverageThisPeriod reset, last-period bookkeeping) moves off the invoice.finalized payload parsing — dead for org subscriptions since May — onto a period-advance sweep with a durable per-subscription close marker (subscription.last_closed_period_start), transaction-enlisted Stripe outbox invoicing, and stamp-matched ledger sums. Enterprise closes are bookkeeping-only; reporting-anchor orgs advance the marker alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ed60fba commit 72c2547

37 files changed

Lines changed: 21378 additions & 1544 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createLogger } from '@sim/logger'
2+
import { generateShortId } from '@sim/utils/id'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { sweepBillingCycleCloses } from '@/lib/billing/cycle-close'
6+
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
7+
import { runDetached } from '@/lib/core/utils/background'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
10+
const logger = createLogger('BillingCycleCloseCron')
11+
12+
const LOCK_KEY = 'billing-cycle-close-lock'
13+
/** Lock TTL in seconds — generous enough to cover the full sweep. */
14+
const LOCK_TTL_SECONDS = 15 * 60
15+
16+
export const dynamic = 'force-dynamic'
17+
18+
/**
19+
* Cron endpoint that closes elapsed billing periods (final overage collection,
20+
* `billedOverageThisPeriod` reset, last-period bookkeeping). Configured in
21+
* helm/sim/values.yaml under cronjobs.jobs.billingCycleClose.
22+
*
23+
* Acknowledges the cron call immediately and sweeps in the background; a Redis
24+
* lock prevents overlapping runs, and each subscription's close is durably
25+
* marked (`subscription.last_closed_period_start`), so replays are no-ops.
26+
*/
27+
export const GET = withRouteHandler(async (request: NextRequest) => {
28+
const authError = verifyCronAuth(request, 'Billing cycle close')
29+
if (authError) {
30+
return authError
31+
}
32+
33+
const lockValue = generateShortId()
34+
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, {
35+
reclaimOnFailure: true,
36+
})
37+
if (!locked) {
38+
return NextResponse.json(
39+
{ success: true, message: 'Cycle-close sweep already in progress – skipped', status: 'skip' },
40+
{ status: 202 }
41+
)
42+
}
43+
44+
runDetached('billing-cycle-close', async () => {
45+
try {
46+
const summary = await sweepBillingCycleCloses()
47+
logger.info('Billing cycle-close sweep completed', { ...summary })
48+
} finally {
49+
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
50+
}
51+
})
52+
53+
return NextResponse.json(
54+
{ success: true, message: 'Billing cycle-close sweep started', status: 'started' },
55+
{ status: 202 }
56+
)
57+
})

apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ export const GET = withRouteHandler(
8989
if (includeUsage && hasAdminAccess) {
9090
const usageData = await db
9191
.select({
92-
currentPeriodCost: userStats.currentPeriodCost,
9392
currentUsageLimit: userStats.currentUsageLimit,
9493
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
9594
lastPeriodCost: userStats.lastPeriodCost,
@@ -99,20 +98,19 @@ export const GET = withRouteHandler(
9998
.limit(1)
10099

101100
if (usageData.length > 0) {
102-
const { billingPeriod, includeLegacyBaseline, usageByUser } =
103-
await getOrganizationMemberUsageSnapshot(organizationId, {
101+
const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot(
102+
organizationId,
103+
{
104104
executor: dbReplica,
105105
userIds: [memberId],
106-
})
106+
}
107+
)
107108
const memberLedger = usageByUser.get(memberId) ?? 0
108109
memberData = {
109110
...memberData,
110111
usage: {
111112
...usageData[0],
112-
currentPeriodCost: (
113-
(includeLegacyBaseline ? Number(usageData[0].currentPeriodCost ?? 0) : 0) +
114-
memberLedger
115-
).toString(),
113+
currentPeriodCost: memberLedger.toString(),
116114
billingPeriodStart: billingPeriod?.start ?? null,
117115
billingPeriodEnd: billingPeriod?.end ?? null,
118116
},

apps/sim/app/api/organizations/[id]/members/route.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ export const GET = withRouteHandler(
101101
createdAt: member.createdAt,
102102
userName: user.name,
103103
userEmail: user.email,
104-
currentPeriodCost: userStats.currentPeriodCost,
105104
currentUsageLimit: userStats.currentUsageLimit,
106105
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
107106
})
@@ -115,19 +114,18 @@ export const GET = withRouteHandler(
115114
totalQuery,
116115
])
117116

118-
const { billingPeriod, includeLegacyBaseline, usageByUser } =
119-
await getOrganizationMemberUsageSnapshot(organizationId, {
117+
const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot(
118+
organizationId,
119+
{
120120
userIds: base.map((row) => row.userId),
121-
})
121+
}
122+
)
122123
const billingPeriodStart = billingPeriod?.start ?? null
123124
const billingPeriodEnd = billingPeriod?.end ?? null
124125

125126
const membersWithUsage = base.map((row) => ({
126127
...row,
127-
currentPeriodCost: (
128-
(includeLegacyBaseline ? Number(row.currentPeriodCost ?? 0) : 0) +
129-
(usageByUser.get(row.userId) ?? 0)
130-
).toString(),
128+
currentPeriodCost: (usageByUser.get(row.userId) ?? 0).toString(),
131129
billingPeriodStart,
132130
billingPeriodEnd,
133131
}))

apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ export const GET = withRouteHandler(
9090
createdAt: member.createdAt,
9191
userName: user.name,
9292
userEmail: user.email,
93-
currentPeriodCost: userStats.currentPeriodCost,
9493
currentUsageLimit: userStats.currentUsageLimit,
9594
billingBlocked: userStats.billingBlocked,
9695
})
@@ -104,10 +103,9 @@ export const GET = withRouteHandler(
104103
return notFoundResponse('Member')
105104
}
106105

107-
const { includeLegacyBaseline, usageByUser } = await getOrganizationMemberUsageSnapshot(
108-
organizationId,
109-
{ userIds: [memberData.userId] }
110-
)
106+
const { usageByUser } = await getOrganizationMemberUsageSnapshot(organizationId, {
107+
userIds: [memberData.userId],
108+
})
111109

112110
const data: AdminMemberDetail = {
113111
id: memberData.id,
@@ -117,10 +115,7 @@ export const GET = withRouteHandler(
117115
createdAt: memberData.createdAt.toISOString(),
118116
userName: memberData.userName,
119117
userEmail: memberData.userEmail,
120-
currentPeriodCost: (
121-
(includeLegacyBaseline ? Number(memberData.currentPeriodCost ?? 0) : 0) +
122-
(usageByUser.get(memberData.userId) ?? 0)
123-
).toString(),
118+
currentPeriodCost: (usageByUser.get(memberData.userId) ?? 0).toString(),
124119
currentUsageLimit: memberData.currentUsageLimit,
125120
billingBlocked: memberData.billingBlocked ?? false,
126121
}

apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ export const GET = withRouteHandler(
118118
createdAt: member.createdAt,
119119
userName: user.name,
120120
userEmail: user.email,
121-
currentPeriodCost: userStats.currentPeriodCost,
122121
currentUsageLimit: userStats.currentUsageLimit,
123122
billingBlocked: userStats.billingBlocked,
124123
})
@@ -133,12 +132,9 @@ export const GET = withRouteHandler(
133132

134133
const total = countResult[0].count
135134

136-
const { includeLegacyBaseline, usageByUser } = await getOrganizationMemberUsageSnapshot(
137-
organizationId,
138-
{
139-
userIds: membersData.map((row) => row.userId),
140-
}
141-
)
135+
const { usageByUser } = await getOrganizationMemberUsageSnapshot(organizationId, {
136+
userIds: membersData.map((row) => row.userId),
137+
})
142138

143139
const data: AdminMemberDetail[] = membersData.map((m) => ({
144140
id: m.id,
@@ -148,10 +144,7 @@ export const GET = withRouteHandler(
148144
createdAt: m.createdAt.toISOString(),
149145
userName: m.userName,
150146
userEmail: m.userEmail,
151-
currentPeriodCost: (
152-
(includeLegacyBaseline ? Number(m.currentPeriodCost ?? 0) : 0) +
153-
(usageByUser.get(m.userId) ?? 0)
154-
).toString(),
147+
currentPeriodCost: (usageByUser.get(m.userId) ?? 0).toString(),
155148
currentUsageLimit: m.currentUsageLimit,
156149
billingBlocked: m.billingBlocked ?? false,
157150
}))

apps/sim/app/api/v1/admin/users/[id]/billing/route.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
* Body:
1313
* - currentUsageLimit?: number | null - Usage limit (null to use default)
1414
* - billingBlocked?: boolean - Block/unblock billing
15-
* - currentPeriodCost?: number - Reset/adjust current period cost (use with caution)
15+
* - currentPeriodCost?: number - Deprecated no-op: usage is the attributed
16+
* usage_log ledger and cannot be adjusted here
1617
* - reason?: string - Reason for the change (for audit logging)
1718
*
1819
* Response: AdminSingleResponse<{ success: true, updated: string[], warnings: string[] }>
@@ -79,9 +80,8 @@ export const GET = withRouteHandler(
7980

8081
const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1)
8182

82-
// currentPeriodCost is now only a baseline; canonical current-period usage
83-
// (baseline + attributed usage_log, refresh-adjusted) comes from the same
84-
// helper users see, so admin reflects real usage instead of a stale 0.
83+
// Canonical current-period usage (attributed usage_log, refresh-adjusted)
84+
// comes from the same helper users see.
8585
const usage = await getUserUsageData(userId)
8686

8787
const memberOrgs = await db
@@ -197,10 +197,10 @@ export const PATCH = withRouteHandler(
197197
if (currentUsageLimit === null) {
198198
updateData.currentUsageLimit = null
199199
} else {
200-
const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0')
201-
if (currentUsageLimit < currentCost) {
200+
const { currentUsage } = await getUserUsageData(userId)
201+
if (currentUsageLimit < currentUsage) {
202202
warnings.push(
203-
`New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.`
203+
`New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentUsage.toFixed(2)}). User may be immediately blocked.`
204204
)
205205
}
206206
updateData.currentUsageLimit = currentUsageLimit.toFixed(2)
@@ -225,13 +225,9 @@ export const PATCH = withRouteHandler(
225225
}
226226

227227
if (currentPeriodCost !== undefined) {
228-
const previousCost = existingStats?.currentPeriodCost || '0'
229228
warnings.push(
230-
`Manually adjusting currentPeriodCost from $${previousCost} to $${currentPeriodCost.toFixed(2)}. This may affect billing accuracy.`
229+
'currentPeriodCost adjustments are deprecated: usage is the attributed usage_log ledger and cannot be edited here. The field was ignored.'
231230
)
232-
233-
updateData.currentPeriodCost = currentPeriodCost.toFixed(2)
234-
updated.push('currentPeriodCost')
235231
}
236232

237233
if (updated.length === 0) {
@@ -256,7 +252,6 @@ export const PATCH = withRouteHandler(
256252
? {
257253
currentUsageLimit: existingStats.currentUsageLimit,
258254
billingBlocked: existingStats.billingBlocked,
259-
currentPeriodCost: existingStats.currentPeriodCost,
260255
}
261256
: null,
262257
newValues: updateData,

apps/sim/lib/admin/dashboard-organizations.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,13 +291,13 @@ describe('listDashboardOrganizations', () => {
291291
externalCollaboratorCount: 0,
292292
planLabel: 'No plan',
293293
})
294-
// Pagination, membership/collaborators, and two batched usage aggregates.
294+
// Pagination, membership/collaborators, and the batched ledger aggregate.
295295
// This count remains constant regardless of the number of organizations.
296-
expect(dbChainMockFns.select).toHaveBeenCalledTimes(6)
296+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(5)
297297
expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalledTimes(1)
298298
})
299299

300-
it('preserves the frozen baseline for an Enterprise subscription using its Stripe period', async () => {
300+
it('reports ledger usage for an Enterprise subscription using its Stripe period', async () => {
301301
queueTableRows(organization, [{ total: 1 }])
302302
queueTableRows(organization, [
303303
{ id: 'org-1', name: 'One', orgUsageLimit: '100', creditBalance: '0' },
@@ -325,13 +325,12 @@ describe('listDashboardOrganizations', () => {
325325
},
326326
])
327327
queueTableRows(usageLog, [{ organizationId: 'org-1', cost: '2.5', workflowRuns: 3 }])
328-
queueTableRows(member, [{ organizationId: 'org-1', cost: '1.5' }])
329328

330329
const result = await listDashboardOrganizations({ search: '', limit: 50, offset: 0 })
331330

332331
expect(result.data[0]).toMatchObject({
333332
reportingPeriod: { source: 'stripe' },
334-
usage: { usedDollars: 4, workflowRuns: 3 },
333+
usage: { usedDollars: 2.5, workflowRuns: 3 },
335334
})
336335
})
337336

apps/sim/lib/admin/dashboard.ts

Lines changed: 0 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -254,24 +254,6 @@ async function getDashboardOrganizationUsage(
254254
}
255255
}
256256

257-
const legacyOrganizationIds = contexts
258-
.filter((context) => context.period.source !== 'reporting')
259-
.map((context) => context.organizationId)
260-
if (legacyOrganizationIds.length > 0) {
261-
const baselineTotals = await db
262-
.select({
263-
organizationId: member.organizationId,
264-
cost: sql<string>`coalesce(sum(${userStats.currentPeriodCost}), 0)`,
265-
})
266-
.from(member)
267-
.leftJoin(userStats, eq(userStats.userId, member.userId))
268-
.where(inArray(member.organizationId, legacyOrganizationIds))
269-
.groupBy(member.organizationId)
270-
for (const row of baselineTotals) {
271-
const usage = result.get(row.organizationId)
272-
if (usage) usage.total += Number(row.cost)
273-
}
274-
}
275257
return result
276258
}
277259

@@ -307,34 +289,6 @@ async function getDashboardOrganizationUsage(
307289
)
308290
}
309291

310-
const legacyOrganizationIds = contexts
311-
.filter((context) => context.period.source !== 'reporting')
312-
.map((context) => context.organizationId)
313-
if (legacyOrganizationIds.length > 0) {
314-
const baselineRows = await db
315-
.select({
316-
organizationId: member.organizationId,
317-
userId: member.userId,
318-
cost: userStats.currentPeriodCost,
319-
})
320-
.from(member)
321-
.leftJoin(userStats, eq(userStats.userId, member.userId))
322-
.where(
323-
options.userIds
324-
? and(
325-
inArray(member.organizationId, legacyOrganizationIds),
326-
inArray(member.userId, options.userIds)
327-
)
328-
: inArray(member.organizationId, legacyOrganizationIds)
329-
)
330-
for (const row of baselineRows) {
331-
const usage = result.get(row.organizationId)
332-
if (!usage) continue
333-
const amount = Number(row.cost ?? 0)
334-
usage.total += amount
335-
usage.byUser.set(row.userId, (usage.byUser.get(row.userId) ?? 0) + amount)
336-
}
337-
}
338292
return result
339293
}
340294

@@ -661,22 +615,6 @@ export async function listDashboardUsers({ search, limit, offset }: PaginationIn
661615
: []
662616
)
663617
)
664-
const legacyPersonalIds = personalUserIds.filter(
665-
(userId) => personalPeriods.get(userId)?.source !== 'reporting'
666-
)
667-
if (legacyPersonalIds.length > 0) {
668-
const baselineRows = await db
669-
.select({ userId: userStats.userId, cost: userStats.currentPeriodCost })
670-
.from(userStats)
671-
.where(inArray(userStats.userId, legacyPersonalIds))
672-
for (const row of baselineRows) {
673-
const current = personalUsage.get(row.userId) ?? { dollars: 0, workflowRuns: 0 }
674-
personalUsage.set(row.userId, {
675-
...current,
676-
dollars: current.dollars + Number(row.cost ?? 0),
677-
})
678-
}
679-
}
680618

681619
return {
682620
data: rows.map((row) => {

apps/sim/lib/api/contracts/v1/admin/organizations.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export const adminV1TransferOwnershipBodySchema = z.object({
137137
const adminV1OrganizationMemberMutationResultSchema = adminV1MemberSchema.extend({
138138
action: z.enum(['created', 'updated', 'already_member']),
139139
billingActions: z.object({
140+
/** @deprecated Always false — ledger entity stamps replaced join-time snapshots. */
140141
proUsageSnapshotted: z.boolean(),
141142
proCancelledAtPeriodEnd: z.boolean(),
142143
}),
@@ -147,9 +148,10 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({
147148
memberId: z.string(),
148149
userId: z.string(),
149150
billingActions: z.object({
150-
/** Dollar amount of departed-member usage captured (0 when none). */
151+
/** @deprecated Always 0 — a departed member's ledger rows stay stamped to the org's period. */
151152
usageCaptured: z.number(),
152153
proRestored: z.boolean(),
154+
/** @deprecated Always false — no snapshot exists to restore. */
153155
usageRestored: z.boolean(),
154156
skipBillingLogic: z.boolean(),
155157
}),

0 commit comments

Comments
 (0)