Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
72c2547
improvement(billing): make usage ledger-only and close cycles off per…
icecrasher321 Aug 25, 2026
e298a42
Merge remote-tracking branch 'origin/staging' into billing-ledger-onl…
icecrasher321 Aug 25, 2026
0acbfc5
fix(billing): include departed actors in close refresh and gate thres…
icecrasher321 Aug 25, 2026
ad58f83
fix(billing): seal cycle-close races and align refresh actors with bi…
icecrasher321 Aug 25, 2026
313afe5
fix(billing): defer ownerless org closes and make the drift test load…
icecrasher321 Aug 25, 2026
2839efe
chore(helm): bump chart to 1.6.0 for the billing-cycle-close cron job
icecrasher321 Aug 25, 2026
426a764
fix(billing): revalidate the org roster under close locks and align i…
icecrasher321 Aug 25, 2026
8d3fb3f
fix(billing): hold cycle close for a settlement grace after rollover
icecrasher321 Aug 25, 2026
c8cd452
fix(billing): resolve reporting windows through the canonical period …
icecrasher321 Aug 25, 2026
6016cc4
fix(billing): union departed ledger actors in org threshold settlement
icecrasher321 Aug 25, 2026
a3fc091
fix(billing): claim the terminal period before deletion settlement an…
icecrasher321 Aug 25, 2026
f907385
fix(billing): scope daily refresh by entity stamps and close lagging …
icecrasher321 Aug 25, 2026
a538a2f
fix(billing): bucket refresh by clamped day so stamped stragglers sta…
icecrasher321 Aug 25, 2026
482413e
fix(billing): pair the overage tracker with the marker's period and p…
icecrasher321 Aug 25, 2026
866d3f0
fix(billing): reject lagging terminal claims and thread billingInterv…
icecrasher321 Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/sim/app/api/cron/billing-cycle-close/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { sweepBillingCycleCloses } from '@/lib/billing/cycle-close'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { runDetached } from '@/lib/core/utils/background'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('BillingCycleCloseCron')

const LOCK_KEY = 'billing-cycle-close-lock'
/** Lock TTL in seconds — generous enough to cover the full sweep. */
const LOCK_TTL_SECONDS = 15 * 60

export const dynamic = 'force-dynamic'

/**
* Cron endpoint that closes elapsed billing periods (final overage collection,
* `billedOverageThisPeriod` reset, last-period bookkeeping). Configured in
* helm/sim/values.yaml under cronjobs.jobs.billingCycleClose.
*
* Acknowledges the cron call immediately and sweeps in the background; a Redis
* lock prevents overlapping runs, and each subscription's close is durably
* marked (`subscription.last_closed_period_start`), so replays are no-ops.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const authError = verifyCronAuth(request, 'Billing cycle close')
if (authError) {
return authError
}

const lockValue = generateShortId()
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, {
reclaimOnFailure: true,
})
if (!locked) {
return NextResponse.json(
{ success: true, message: 'Cycle-close sweep already in progress – skipped', status: 'skip' },
{ status: 202 }
)
}

runDetached('billing-cycle-close', async () => {
try {
const summary = await sweepBillingCycleCloses()
logger.info('Billing cycle-close sweep completed', { ...summary })
} finally {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
})

return NextResponse.json(
{ success: true, message: 'Billing cycle-close sweep started', status: 'started' },
{ status: 202 }
)
})
14 changes: 6 additions & 8 deletions apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export const GET = withRouteHandler(
if (includeUsage && hasAdminAccess) {
const usageData = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
lastPeriodCost: userStats.lastPeriodCost,
Expand All @@ -99,20 +98,19 @@ export const GET = withRouteHandler(
.limit(1)

if (usageData.length > 0) {
const { billingPeriod, includeLegacyBaseline, usageByUser } =
await getOrganizationMemberUsageSnapshot(organizationId, {
const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot(
organizationId,
{
executor: dbReplica,
userIds: [memberId],
})
}
)
const memberLedger = usageByUser.get(memberId) ?? 0
memberData = {
...memberData,
usage: {
...usageData[0],
currentPeriodCost: (
(includeLegacyBaseline ? Number(usageData[0].currentPeriodCost ?? 0) : 0) +
memberLedger
).toString(),
currentPeriodCost: memberLedger.toString(),
billingPeriodStart: billingPeriod?.start ?? null,
billingPeriodEnd: billingPeriod?.end ?? null,
},
Expand Down
14 changes: 6 additions & 8 deletions apps/sim/app/api/organizations/[id]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ export const GET = withRouteHandler(
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
})
Expand All @@ -115,19 +114,18 @@ export const GET = withRouteHandler(
totalQuery,
])

const { billingPeriod, includeLegacyBaseline, usageByUser } =
await getOrganizationMemberUsageSnapshot(organizationId, {
const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot(
organizationId,
{
userIds: base.map((row) => row.userId),
})
}
)
const billingPeriodStart = billingPeriod?.start ?? null
const billingPeriodEnd = billingPeriod?.end ?? null

const membersWithUsage = base.map((row) => ({
...row,
currentPeriodCost: (
(includeLegacyBaseline ? Number(row.currentPeriodCost ?? 0) : 0) +
(usageByUser.get(row.userId) ?? 0)
).toString(),
currentPeriodCost: (usageByUser.get(row.userId) ?? 0).toString(),
billingPeriodStart,
billingPeriodEnd,
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ export const GET = withRouteHandler(
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingBlocked: userStats.billingBlocked,
})
Expand All @@ -104,10 +103,9 @@ export const GET = withRouteHandler(
return notFoundResponse('Member')
}

const { includeLegacyBaseline, usageByUser } = await getOrganizationMemberUsageSnapshot(
organizationId,
{ userIds: [memberData.userId] }
)
const { usageByUser } = await getOrganizationMemberUsageSnapshot(organizationId, {
userIds: [memberData.userId],
})

const data: AdminMemberDetail = {
id: memberData.id,
Expand All @@ -117,10 +115,7 @@ export const GET = withRouteHandler(
createdAt: memberData.createdAt.toISOString(),
userName: memberData.userName,
userEmail: memberData.userEmail,
currentPeriodCost: (
(includeLegacyBaseline ? Number(memberData.currentPeriodCost ?? 0) : 0) +
(usageByUser.get(memberData.userId) ?? 0)
).toString(),
currentPeriodCost: (usageByUser.get(memberData.userId) ?? 0).toString(),
currentUsageLimit: memberData.currentUsageLimit,
billingBlocked: memberData.billingBlocked ?? false,
}
Expand Down
15 changes: 4 additions & 11 deletions apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ export const GET = withRouteHandler(
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingBlocked: userStats.billingBlocked,
})
Expand All @@ -133,12 +132,9 @@ export const GET = withRouteHandler(

const total = countResult[0].count

const { includeLegacyBaseline, usageByUser } = await getOrganizationMemberUsageSnapshot(
organizationId,
{
userIds: membersData.map((row) => row.userId),
}
)
const { usageByUser } = await getOrganizationMemberUsageSnapshot(organizationId, {
userIds: membersData.map((row) => row.userId),
})

const data: AdminMemberDetail[] = membersData.map((m) => ({
id: m.id,
Expand All @@ -148,10 +144,7 @@ export const GET = withRouteHandler(
createdAt: m.createdAt.toISOString(),
userName: m.userName,
userEmail: m.userEmail,
currentPeriodCost: (
(includeLegacyBaseline ? Number(m.currentPeriodCost ?? 0) : 0) +
(usageByUser.get(m.userId) ?? 0)
).toString(),
currentPeriodCost: (usageByUser.get(m.userId) ?? 0).toString(),
currentUsageLimit: m.currentUsageLimit,
billingBlocked: m.billingBlocked ?? false,
}))
Expand Down
21 changes: 8 additions & 13 deletions apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
* Body:
* - currentUsageLimit?: number | null - Usage limit (null to use default)
* - billingBlocked?: boolean - Block/unblock billing
* - currentPeriodCost?: number - Reset/adjust current period cost (use with caution)
* - currentPeriodCost?: number - Deprecated no-op: usage is the attributed
* usage_log ledger and cannot be adjusted here
* - reason?: string - Reason for the change (for audit logging)
*
* Response: AdminSingleResponse<{ success: true, updated: string[], warnings: string[] }>
Expand Down Expand Up @@ -79,9 +80,8 @@ export const GET = withRouteHandler(

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

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

const memberOrgs = await db
Expand Down Expand Up @@ -197,10 +197,10 @@ export const PATCH = withRouteHandler(
if (currentUsageLimit === null) {
updateData.currentUsageLimit = null
} else {
const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0')
if (currentUsageLimit < currentCost) {
const { currentUsage } = await getUserUsageData(userId)
if (currentUsageLimit < currentUsage) {
warnings.push(
`New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.`
`New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentUsage.toFixed(2)}). User may be immediately blocked.`
)
}
updateData.currentUsageLimit = currentUsageLimit.toFixed(2)
Expand All @@ -225,13 +225,9 @@ export const PATCH = withRouteHandler(
}

if (currentPeriodCost !== undefined) {
const previousCost = existingStats?.currentPeriodCost || '0'
warnings.push(
`Manually adjusting currentPeriodCost from $${previousCost} to $${currentPeriodCost.toFixed(2)}. This may affect billing accuracy.`
'currentPeriodCost adjustments are deprecated: usage is the attributed usage_log ledger and cannot be edited here. The field was ignored.'
)

updateData.currentPeriodCost = currentPeriodCost.toFixed(2)
updated.push('currentPeriodCost')
}

if (updated.length === 0) {
Expand All @@ -256,7 +252,6 @@ export const PATCH = withRouteHandler(
? {
currentUsageLimit: existingStats.currentUsageLimit,
billingBlocked: existingStats.billingBlocked,
currentPeriodCost: existingStats.currentPeriodCost,
}
: null,
newValues: updateData,
Expand Down
9 changes: 4 additions & 5 deletions apps/sim/lib/admin/dashboard-organizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,13 @@ describe('listDashboardOrganizations', () => {
externalCollaboratorCount: 0,
planLabel: 'No plan',
})
// Pagination, membership/collaborators, and two batched usage aggregates.
// Pagination, membership/collaborators, and the batched ledger aggregate.
// This count remains constant regardless of the number of organizations.
expect(dbChainMockFns.select).toHaveBeenCalledTimes(6)
expect(dbChainMockFns.select).toHaveBeenCalledTimes(5)
expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalledTimes(1)
})

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

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

expect(result.data[0]).toMatchObject({
reportingPeriod: { source: 'stripe' },
usage: { usedDollars: 4, workflowRuns: 3 },
usage: { usedDollars: 2.5, workflowRuns: 3 },
})
})

Expand Down
62 changes: 0 additions & 62 deletions apps/sim/lib/admin/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,24 +254,6 @@ async function getDashboardOrganizationUsage(
}
}

const legacyOrganizationIds = contexts
.filter((context) => context.period.source !== 'reporting')
.map((context) => context.organizationId)
if (legacyOrganizationIds.length > 0) {
const baselineTotals = await db
.select({
organizationId: member.organizationId,
cost: sql<string>`coalesce(sum(${userStats.currentPeriodCost}), 0)`,
})
.from(member)
.leftJoin(userStats, eq(userStats.userId, member.userId))
.where(inArray(member.organizationId, legacyOrganizationIds))
.groupBy(member.organizationId)
for (const row of baselineTotals) {
const usage = result.get(row.organizationId)
if (usage) usage.total += Number(row.cost)
}
}
return result
}

Expand Down Expand Up @@ -307,34 +289,6 @@ async function getDashboardOrganizationUsage(
)
}

const legacyOrganizationIds = contexts
.filter((context) => context.period.source !== 'reporting')
.map((context) => context.organizationId)
if (legacyOrganizationIds.length > 0) {
const baselineRows = await db
.select({
organizationId: member.organizationId,
userId: member.userId,
cost: userStats.currentPeriodCost,
})
.from(member)
.leftJoin(userStats, eq(userStats.userId, member.userId))
.where(
options.userIds
? and(
inArray(member.organizationId, legacyOrganizationIds),
inArray(member.userId, options.userIds)
)
: inArray(member.organizationId, legacyOrganizationIds)
)
for (const row of baselineRows) {
const usage = result.get(row.organizationId)
if (!usage) continue
const amount = Number(row.cost ?? 0)
usage.total += amount
usage.byUser.set(row.userId, (usage.byUser.get(row.userId) ?? 0) + amount)
}
}
return result
}

Expand Down Expand Up @@ -661,22 +615,6 @@ export async function listDashboardUsers({ search, limit, offset }: PaginationIn
: []
)
)
const legacyPersonalIds = personalUserIds.filter(
(userId) => personalPeriods.get(userId)?.source !== 'reporting'
)
if (legacyPersonalIds.length > 0) {
const baselineRows = await db
.select({ userId: userStats.userId, cost: userStats.currentPeriodCost })
.from(userStats)
.where(inArray(userStats.userId, legacyPersonalIds))
for (const row of baselineRows) {
const current = personalUsage.get(row.userId) ?? { dollars: 0, workflowRuns: 0 }
personalUsage.set(row.userId, {
...current,
dollars: current.dollars + Number(row.cost ?? 0),
})
}
}

return {
data: rows.map((row) => {
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/api/contracts/v1/admin/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export const adminV1TransferOwnershipBodySchema = z.object({
const adminV1OrganizationMemberMutationResultSchema = adminV1MemberSchema.extend({
action: z.enum(['created', 'updated', 'already_member']),
billingActions: z.object({
/** @deprecated Always false — ledger entity stamps replaced join-time snapshots. */
proUsageSnapshotted: z.boolean(),
proCancelledAtPeriodEnd: z.boolean(),
}),
Expand All @@ -147,9 +148,10 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({
memberId: z.string(),
userId: z.string(),
billingActions: z.object({
/** Dollar amount of departed-member usage captured (0 when none). */
/** @deprecated Always 0 — a departed member's ledger rows stay stamped to the org's period. */
usageCaptured: z.number(),
proRestored: z.boolean(),
/** @deprecated Always false — no snapshot exists to restore. */
usageRestored: z.boolean(),
skipBillingLogic: z.boolean(),
}),
Expand Down
Loading
Loading