Skip to content

Commit 483ff12

Browse files
feat(billing): align enterprise reporting periods (#6851)
* feat(billing): align enterprise reporting periods * fix(billing): harden enterprise reporting flow * fix(outbox): preserve handler compatibility * fix(billing): bound enterprise provisioning reads
1 parent bbe1183 commit 483ff12

59 files changed

Lines changed: 24830 additions & 729 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/billing/route.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
117117
)
118118
if (!parsed.success) return parsed.response
119119

120-
const { context, id: contextId, includeOrg } = parsed.data.query
120+
const { context, id: contextId, includeOrg, memberLimit, memberOffset } = parsed.data.query
121121
if (context === 'organization' && !contextId) {
122122
return NextResponse.json(
123123
{ error: 'Organization ID is required when context=organization' },
@@ -190,7 +190,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
190190
billingStatus,
191191
upgradeWorkspaceId,
192192
] = await Promise.all([
193-
getOrganizationBillingData(organizationId, dbReplica),
193+
getOrganizationBillingData(organizationId, dbReplica, {
194+
limit: memberLimit,
195+
offset: memberOffset,
196+
}),
194197
getOrganizationSubscription(organizationId, { executor: dbReplica, onError: 'throw' }),
195198
dbReplica
196199
.select({ id: organizationTable.id, name: organizationTable.name })
@@ -254,6 +257,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
254257
rawBillingData?.billingPeriodEnd?.toISOString() ??
255258
displayedSubscription?.periodEnd?.toISOString() ??
256259
null,
260+
membersTotal: rawBillingData?.membersTotal ?? 0,
261+
memberPagination: rawBillingData?.memberPagination ?? {
262+
total: 0,
263+
limit: memberLimit,
264+
offset: memberOffset,
265+
hasMore: false,
266+
},
257267
members:
258268
rawBillingData?.members.map((organizationMember) => ({
259269
...organizationMember,

apps/sim/app/api/billing/update-cost/route.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ const ACCOUNT_BILLING_DECISION = {
8888
billingPeriod: {
8989
start: '2026-07-01T00:00:00.000Z',
9090
end: '2026-08-01T00:00:00.000Z',
91+
source: 'reporting' as const,
9192
},
9293
}
9394

@@ -418,6 +419,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
418419
billingPeriod: {
419420
start: new Date('2026-07-01T00:00:00.000Z'),
420421
end: new Date('2026-08-01T00:00:00.000Z'),
422+
source: 'reporting',
421423
},
422424
})
423425
)
@@ -431,6 +433,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
431433
expectedBillingPeriod: {
432434
start: new Date('2026-07-01T00:00:00.000Z'),
433435
end: new Date('2026-08-01T00:00:00.000Z'),
436+
source: 'reporting',
434437
},
435438
}
436439
)
@@ -660,6 +663,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
660663
expectedBillingPeriod: {
661664
start: new Date('2026-07-01T00:00:00.000Z'),
662665
end: new Date('2026-08-01T00:00:00.000Z'),
666+
source: 'reporting',
663667
},
664668
}
665669
)

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,9 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
280280
billingPeriod: {
281281
start: new Date(accountDecision.billingPeriod.start),
282282
end: new Date(accountDecision.billingPeriod.end),
283+
...(accountDecision.billingPeriod.source
284+
? { source: accountDecision.billingPeriod.source }
285+
: {}),
283286
},
284287
}
285288
: undefined

apps/sim/app/api/copilot/api-keys/validate/route.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const ATTRIBUTION = {
5252
billingPeriod: {
5353
start: '2026-07-01T00:00:00.000Z',
5454
end: '2026-08-01T00:00:00.000Z',
55+
source: 'reporting' as const,
5556
},
5657
payerSubscription: null,
5758
}
@@ -63,6 +64,7 @@ const ACCOUNT_BILLING_DECISION = {
6364
billingPeriod: {
6465
start: '2026-07-01T00:00:00.000Z',
6566
end: '2026-08-01T00:00:00.000Z',
67+
source: 'reporting' as const,
6668
},
6769
}
6870

@@ -174,6 +176,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
174176
billingPeriod: {
175177
start: new Date(ACCOUNT_BILLING_DECISION.billingPeriod.start),
176178
end: new Date(ACCOUNT_BILLING_DECISION.billingPeriod.end),
179+
source: ACCOUNT_BILLING_DECISION.billingPeriod.source,
177180
},
178181
})
179182
mockGetUserEntityPermissions.mockResolvedValue('read')
@@ -387,11 +390,16 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
387390
)
388391

389392
expect(res.status).toBe(200)
390-
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
393+
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith(
394+
'user-1',
395+
ACCOUNT_SUBSCRIPTION,
396+
expect.objectContaining({ billingEntity: ACCOUNT_BILLING_DECISION.billingEntity })
397+
)
391398
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
392399
expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
393400
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
394401
expect(mockGetWorkspaceBillingSettings).not.toHaveBeenCalled()
402+
expect(mockSerializeAccountBillingDecisionHeader).toHaveBeenCalledWith(ACCOUNT_BILLING_DECISION)
395403
expect(res.headers.get('x-sim-billing-account-decision')).toBe('serialized-account-decision')
396404
})
397405

@@ -407,7 +415,11 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
407415
)
408416

409417
expect(res.status).toBe(200)
410-
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
418+
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith(
419+
'user-1',
420+
ACCOUNT_SUBSCRIPTION,
421+
expect.objectContaining({ billingEntity: ACCOUNT_BILLING_DECISION.billingEntity })
422+
)
411423
})
412424

413425
it('fails direct-v1 admission closed when its payer cannot be resolved', async () => {

apps/sim/app/api/copilot/api-keys/validate/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
169169
onError: 'throw',
170170
})
171171
const billingContext = deriveBillingContext(admission.userId, subscription)
172-
const usage = await checkServerSideUsageLimits(admission.userId, subscription)
172+
const usage = await checkServerSideUsageLimits(admission.userId, subscription, billingContext)
173173
return {
174174
isExceeded: usage.isExceeded,
175175
currentUsage: usage.currentUsage,
@@ -181,6 +181,9 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
181181
billingPeriod: {
182182
start: billingContext.billingPeriod.start.toISOString(),
183183
end: billingContext.billingPeriod.end.toISOString(),
184+
...(billingContext.billingPeriod.source
185+
? { source: billingContext.billingPeriod.source }
186+
: {}),
184187
},
185188
},
186189
}

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

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ import { updateOrganizationMemberRoleContract } from '@/lib/api/contracts/organi
99
import { parseRequest } from '@/lib/api/server'
1010
import { getSession } from '@/lib/auth'
1111
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
12-
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
13-
import { getUserUsageData } from '@/lib/billing/core/usage'
12+
import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization'
1413
import {
1514
removeExternalUserFromOrganizationWorkspaces,
1615
removeUserFromOrganization,
@@ -99,31 +98,23 @@ export const GET = withRouteHandler(
9998
.where(eq(userStats.userId, memberId))
10099
.limit(1)
101100

102-
const computed = await getUserUsageData(memberId, dbReplica)
103-
104101
if (usageData.length > 0) {
105-
// currentPeriodCost is only a baseline; add this member's attributed
106-
// usage_log for the period. (getUserUsageData returns the org POOL for
107-
// org-scoped members, so it can't supply the per-member figure.)
108-
const memberLedger =
109-
(
110-
await getOrgMemberLedgerByUser(
111-
organizationId,
112-
computed.billingPeriodStart && computed.billingPeriodEnd
113-
? { start: computed.billingPeriodStart, end: computed.billingPeriodEnd }
114-
: null,
115-
dbReplica
116-
)
117-
).get(memberId) ?? 0
102+
const { billingPeriod, includeLegacyBaseline, usageByUser } =
103+
await getOrganizationMemberUsageSnapshot(organizationId, {
104+
executor: dbReplica,
105+
userIds: [memberId],
106+
})
107+
const memberLedger = usageByUser.get(memberId) ?? 0
118108
memberData = {
119109
...memberData,
120110
usage: {
121111
...usageData[0],
122112
currentPeriodCost: (
123-
Number(usageData[0].currentPeriodCost ?? 0) + memberLedger
113+
(includeLegacyBaseline ? Number(usageData[0].currentPeriodCost ?? 0) : 0) +
114+
memberLedger
124115
).toString(),
125-
billingPeriodStart: computed.billingPeriodStart,
126-
billingPeriodEnd: computed.billingPeriodEnd,
116+
billingPeriodStart: billingPeriod?.start ?? null,
117+
billingPeriodEnd: billingPeriod?.end ?? null,
127118
},
128119
} as typeof memberData & {
129120
usage: (typeof usageData)[0] & {

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

Lines changed: 61 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import { db } from '@sim/db'
2-
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
2+
import { member, user, userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
5-
import { and, eq, inArray } from 'drizzle-orm'
5+
import { and, count, eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import {
88
organizationMemberQuerySchema,
99
organizationParamsSchema,
1010
} from '@/lib/api/contracts/organization'
1111
import { getValidationErrorMessage } from '@/lib/api/server'
1212
import { getSession } from '@/lib/auth'
13-
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
14-
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
13+
import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization'
1514
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1615

1716
const logger = createLogger('OrganizationMembersAPI')
@@ -47,6 +46,7 @@ export const GET = withRouteHandler(
4746
{ status: 400 }
4847
)
4948
}
49+
const { limit, offset } = queryResult.data
5050
const includeUsage = queryResult.data.include === 'usage'
5151

5252
// Verify user has access to this organization
@@ -67,7 +67,7 @@ export const GET = withRouteHandler(
6767
const hasAdminAccess = isOrgAdminRole(userRole)
6868

6969
// Get organization members
70-
const query = db
70+
const memberPageQuery = db
7171
.select({
7272
id: member.id,
7373
userId: member.userId,
@@ -80,82 +80,87 @@ export const GET = withRouteHandler(
8080
.from(member)
8181
.innerJoin(user, eq(member.userId, user.id))
8282
.where(eq(member.organizationId, organizationId))
83+
.orderBy(user.name, user.id)
84+
.limit(limit)
85+
.offset(offset)
86+
87+
const totalQuery = db
88+
.select({ value: count() })
89+
.from(member)
90+
.where(eq(member.organizationId, organizationId))
8391

8492
// Include usage data if requested and user has admin access
8593
if (includeUsage && hasAdminAccess) {
86-
const base = await db
87-
.select({
88-
id: member.id,
89-
userId: member.userId,
90-
organizationId: member.organizationId,
91-
role: member.role,
92-
createdAt: member.createdAt,
93-
userName: user.name,
94-
userEmail: user.email,
95-
currentPeriodCost: userStats.currentPeriodCost,
96-
currentUsageLimit: userStats.currentUsageLimit,
97-
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
94+
const [base, totalRows] = await Promise.all([
95+
db
96+
.select({
97+
id: member.id,
98+
userId: member.userId,
99+
organizationId: member.organizationId,
100+
role: member.role,
101+
createdAt: member.createdAt,
102+
userName: user.name,
103+
userEmail: user.email,
104+
currentPeriodCost: userStats.currentPeriodCost,
105+
currentUsageLimit: userStats.currentUsageLimit,
106+
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
107+
})
108+
.from(member)
109+
.innerJoin(user, eq(member.userId, user.id))
110+
.leftJoin(userStats, eq(user.id, userStats.userId))
111+
.where(eq(member.organizationId, organizationId))
112+
.orderBy(user.name, user.id)
113+
.limit(limit)
114+
.offset(offset),
115+
totalQuery,
116+
])
117+
118+
const { billingPeriod, includeLegacyBaseline, usageByUser } =
119+
await getOrganizationMemberUsageSnapshot(organizationId, {
120+
userIds: base.map((row) => row.userId),
98121
})
99-
.from(member)
100-
.innerJoin(user, eq(member.userId, user.id))
101-
.leftJoin(userStats, eq(user.id, userStats.userId))
102-
.where(eq(member.organizationId, organizationId))
103-
104-
// The billing period is the same for every member — it comes from
105-
// whichever subscription covers them. Fetch once and attach to
106-
// every row instead of calling `getUserUsageData` per-member,
107-
// which would run an O(N) pooled query for each of N rows.
108-
const [orgSub] = await db
109-
.select({
110-
periodStart: subscriptionTable.periodStart,
111-
periodEnd: subscriptionTable.periodEnd,
112-
})
113-
.from(subscriptionTable)
114-
.where(
115-
and(
116-
eq(subscriptionTable.referenceId, organizationId),
117-
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
118-
)
119-
)
120-
.limit(1)
121-
122-
const billingPeriodStart = orgSub?.periodStart ?? null
123-
const billingPeriodEnd = orgSub?.periodEnd ?? null
124-
125-
// currentPeriodCost is only a baseline; add each member's attributed
126-
// usage_log for the period (batched, one query) so the roster shows real
127-
// usage rather than the frozen baseline.
128-
const usageByUser = await getOrgMemberLedgerByUser(
129-
organizationId,
130-
billingPeriodStart && billingPeriodEnd
131-
? { start: billingPeriodStart, end: billingPeriodEnd }
132-
: null
133-
)
122+
const billingPeriodStart = billingPeriod?.start ?? null
123+
const billingPeriodEnd = billingPeriod?.end ?? null
134124

135125
const membersWithUsage = base.map((row) => ({
136126
...row,
137127
currentPeriodCost: (
138-
Number(row.currentPeriodCost ?? 0) + (usageByUser.get(row.userId) ?? 0)
128+
(includeLegacyBaseline ? Number(row.currentPeriodCost ?? 0) : 0) +
129+
(usageByUser.get(row.userId) ?? 0)
139130
).toString(),
140131
billingPeriodStart,
141132
billingPeriodEnd,
142133
}))
143134

135+
const total = totalRows[0]?.value ?? 0
144136
return NextResponse.json({
145137
success: true,
146138
data: membersWithUsage,
147-
total: membersWithUsage.length,
139+
total,
140+
pagination: {
141+
total,
142+
limit,
143+
offset,
144+
hasMore: offset + membersWithUsage.length < total,
145+
},
148146
userRole,
149147
hasAdminAccess,
150148
})
151149
}
152150

153-
const members = await query
151+
const [members, totalRows] = await Promise.all([memberPageQuery, totalQuery])
152+
const total = totalRows[0]?.value ?? 0
154153

155154
return NextResponse.json({
156155
success: true,
157156
data: members,
158-
total: members.length,
157+
total,
158+
pagination: {
159+
total,
160+
limit,
161+
offset,
162+
hasMore: offset + members.length < total,
163+
},
159164
userRole,
160165
hasAdminAccess,
161166
})

0 commit comments

Comments
 (0)