Skip to content

Commit 89c238d

Browse files
icecrasher321claude
andcommitted
fix(usage): key the chart to the calendar the query grouped by
Three review findings, all real. Densification walked UTC dates while `readUsageTimeSeries` groups by `date_trunc($bucket, created_at AT TIME ZONE $timezone)` — the viewer's calendar. For a non-UTC viewer the edge buckets never matched, so their cost stayed in the headline while their bar read zero. Week and month were worse: Postgres aligns those to Monday and the 1st, so a cursor stepping from an arbitrary period start shared no key with the query at all and the whole chart came back zeroed against a correct total. That is reachable today through an annual enterprise period, which resolves to `week`. `densifyUsageSeries` now takes the timezone, derives its first and last bucket through `Intl.DateTimeFormat('en-CA', { timeZone })`, and truncates both to the bucket boundary so the keys are the ones `date_trunc` emits. Stepping stays civil `YYYY-MM-DD` arithmetic — UTC as a proleptic calendar, never converted back to an instant, so no DST transition can shift a bucket. The custom-range picker passed `showTime`, so it serialized its end bound as an inclusive `…T23:59:59` local wall time; the resolver then added a further day. Every custom range covered 24 hours too many, a legal 92-day selection measured 93 and was rejected, and the wall-clock string parsed as local while the rest of the window logic is UTC. Dropped `showTime`: a time of day is precision a day-bucketed panel cannot render, and bare `YYYY-MM-DD` bounds parse as UTC midnight, which is what makes the half-open `+ DAY_MS` correct. Admin organization provisioning answered 500 for state it had already committed, and the existing-membership check then blocked the retry, leaving an organization no workspace could reach. Attachment is a follow-on effect, not part of creating the organization, and it is deliberately not folded into the creation transaction: it runs its own under a lock order that exists to avoid deadlocking against invitation acceptance, and re-deriving that in a route is how a deadlock ships. Its failure is now caught and logged, and the endpoint returns the organization it created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9999238 commit 89c238d

5 files changed

Lines changed: 190 additions & 16 deletions

File tree

apps/sim/app/api/v1/admin/organizations/route.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2525
import { db, dbReplica } from '@sim/db'
2626
import { member, organization, organizationColumns, user } from '@sim/db/schema'
2727
import { createLogger } from '@sim/logger'
28+
import { getErrorMessage } from '@sim/utils/errors'
2829
import { slugify } from '@sim/utils/string'
2930
import { count, eq } from 'drizzle-orm'
3031
import {
@@ -162,13 +163,34 @@ export const POST = withRouteHandler(
162163
* one exists. Creating a workspace only closes that gap once the organization
163164
* carries a usable Team/Enterprise plan; without one the creation policy still
164165
* resolves to a personal workspace.
166+
*
167+
* Attachment is a follow-on effect, not part of creating the organization, and
168+
* it is deliberately not folded into the creation transaction: it runs its own,
169+
* under a documented lock order (invitation scope, then organization, then
170+
* workspace rows) that exists to avoid deadlocking against invitation
171+
* acceptance. Re-deriving that ordering in a route is how a deadlock ships.
172+
*
173+
* So its failure must not be reported as a failure to create: the organization
174+
* is already committed, and answering 500 for state that exists left the retry
175+
* blocked by the existing-membership check above, with no way to reach the
176+
* organization at all. Log it and return the organization that was created —
177+
* attaching a workspace afterwards is a normal, repeatable operation.
165178
*/
166-
const { attachedWorkspaceIds } = await attachOwnedWorkspacesToOrganization({
167-
ownerUserId: ownerId,
168-
organizationId,
169-
externalMemberPolicy: 'keep-external',
170-
includeArchived: true,
171-
})
179+
let attachedWorkspaceIds: string[] = []
180+
try {
181+
;({ attachedWorkspaceIds } = await attachOwnedWorkspacesToOrganization({
182+
ownerUserId: ownerId,
183+
organizationId,
184+
externalMemberPolicy: 'keep-external',
185+
includeArchived: true,
186+
}))
187+
} catch (attachError) {
188+
logger.error('Admin API: Created organization but could not attach its workspaces', {
189+
organizationId,
190+
ownerId,
191+
error: getErrorMessage(attachError),
192+
})
193+
}
172194

173195
const [createdOrg] = await db
174196
.select(organizationColumns)

apps/sim/ee/organization-usage/components/usage-monitoring.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,17 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
261261
>
262262
<PopoverAnchor className='pointer-events-none absolute inset-0' />
263263
<PopoverContent align='end' sideOffset={4} className='w-auto p-0'>
264+
{/*
265+
No `showTime`: the panel buckets by calendar day, so a time of day is
266+
precision it cannot render. It also emitted the end bound as an
267+
inclusive `…T23:59:59` local wall time, which the window resolver then
268+
treated as a midnight and pushed a further 24h — every custom range
269+
covered an extra day, and a legal 92-day pick measured 93 and was
270+
rejected. Bare `YYYY-MM-DD` bounds parse as UTC midnight, matching the
271+
rest of the window logic.
272+
*/}
264273
<Calendar
265274
mode='range'
266-
showTime
267275
startDate={startDate ?? undefined}
268276
endDate={endDate ?? undefined}
269277
onRangeChange={handleDateRangeApply}

apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
7575
bucket,
7676
totals: { credits: dollarsToCredits(totals.cost) },
7777
previousTotals: previous ? { credits: dollarsToCredits(previous.cost) } : null,
78-
series: densifyUsageSeries(seriesRows, window, bucket).map((point) => ({
78+
// Same timezone the query grouped by, or the series keys cannot match its rows.
79+
series: densifyUsageSeries(seriesRows, window, bucket, input.timezone).map((point) => ({
7980
timestamp: point.timestamp,
8081
credits: dollarsToCredits(point.cost),
8182
events: point.events,

apps/sim/lib/billing/core/usage-analytics.test.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,38 @@ describe('resolveUsageAnalyticsWindow', () => {
114114
})
115115
})
116116

117+
it('covers exactly the days the picker emitted, from its bare date bounds', () => {
118+
// The picker sends `YYYY-MM-DD`, which parses as UTC midnight. It must not send
119+
// an inclusive `…T23:59:59` wall time: the extra day added below would then land
120+
// on the *following* day, and every custom range would overrun by 24 hours.
121+
const window = resolveUsageAnalyticsWindow({
122+
preset: 'custom',
123+
period: period(),
124+
customStart: new Date('2026-06-01'),
125+
customEnd: new Date('2026-08-31'),
126+
now,
127+
})
128+
expect(window).toEqual({
129+
kind: 'range',
130+
from: new Date('2026-06-01T00:00:00.000Z'),
131+
to: new Date('2026-09-01T00:00:00.000Z'),
132+
})
133+
})
134+
135+
it('accepts a selection of exactly the maximum span', () => {
136+
// June 1 through August 31 inclusive is 92 days. It measured 93 while the end
137+
// bound carried a time of day, so the longest legal pick was rejected.
138+
expect(() =>
139+
resolveUsageAnalyticsWindow({
140+
preset: 'custom',
141+
period: period(),
142+
customStart: new Date('2026-06-01'),
143+
customEnd: new Date('2026-08-31'),
144+
now,
145+
})
146+
).not.toThrow()
147+
})
148+
117149
it('refuses a custom range beyond the cap rather than scanning the ledger', () => {
118150
expect(() =>
119151
resolveUsageAnalyticsWindow({
@@ -162,7 +194,8 @@ describe('densifyUsageSeries', () => {
162194
const points = densifyUsageSeries(
163195
[{ bucketStart: '2026-08-02T00:00:00', cost: '1.50', events: 3 }],
164196
window,
165-
'day'
197+
'day',
198+
'UTC'
166199
)
167200
expect(points).toHaveLength(3)
168201
expect(points.map((p) => p.cost)).toEqual([0, 1.5, 0])
@@ -176,16 +209,74 @@ describe('densifyUsageSeries', () => {
176209
{ bucketStart: '2026-08-03T00:00:00', cost: '2.75', events: 2 },
177210
],
178211
window,
179-
'day'
212+
'day',
213+
'UTC'
180214
)
181215
expect(points.reduce((sum, p) => sum + p.cost, 0)).toBeCloseTo(4, 8)
182216
})
183217

184218
it('emits an empty series for a zero-length window instead of looping', () => {
185219
expect(
186-
densifyUsageSeries([], { kind: 'range', from: window.from, to: window.from }, 'day')
220+
densifyUsageSeries([], { kind: 'range', from: window.from, to: window.from }, 'day', 'UTC')
187221
).toEqual([])
188222
})
223+
224+
it('keys days by the viewer calendar the query grouped by, not UTC', () => {
225+
// Auckland is UTC+12, so the window's final instant is already the next local
226+
// day. Walking UTC dates dropped that bucket: its cost stayed in the headline
227+
// while its bar was never drawn.
228+
const points = densifyUsageSeries(
229+
[{ bucketStart: '2026-08-04T00:00:00', cost: '5.00', events: 1 }],
230+
window,
231+
'day',
232+
'Pacific/Auckland'
233+
)
234+
const keys = points.map((p) => p.timestamp.slice(0, 10))
235+
expect(keys).toEqual(['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04'])
236+
expect(points.at(-1)?.cost).toBe(5)
237+
})
238+
239+
it('aligns week buckets to Monday, as `date_trunc` does', () => {
240+
// A period starting mid-week previously produced keys Postgres never emits, so
241+
// every bar read zero while the total was correct. Reachable through an annual
242+
// enterprise period, which resolves to `week`.
243+
const points = densifyUsageSeries(
244+
[{ bucketStart: '2026-08-10T00:00:00', cost: '9.00', events: 4 }],
245+
// 2026-08-15 is a Saturday; its ISO week starts Monday 2026-08-10.
246+
{
247+
kind: 'range',
248+
from: new Date('2026-08-15T00:00:00.000Z'),
249+
to: new Date('2026-08-29T00:00:00.000Z'),
250+
},
251+
'week',
252+
'UTC'
253+
)
254+
expect(points.map((p) => p.timestamp.slice(0, 10))).toEqual([
255+
'2026-08-10',
256+
'2026-08-17',
257+
'2026-08-24',
258+
])
259+
expect(points[0].cost).toBe(9)
260+
})
261+
262+
it('aligns month buckets to the first, as `date_trunc` does', () => {
263+
const points = densifyUsageSeries(
264+
[{ bucketStart: '2026-09-01T00:00:00', cost: '3.00', events: 2 }],
265+
{
266+
kind: 'range',
267+
from: new Date('2026-08-15T00:00:00.000Z'),
268+
to: new Date('2026-10-15T00:00:00.000Z'),
269+
},
270+
'month',
271+
'UTC'
272+
)
273+
expect(points.map((p) => p.timestamp.slice(0, 10))).toEqual([
274+
'2026-08-01',
275+
'2026-09-01',
276+
'2026-10-01',
277+
])
278+
expect(points[1].cost).toBe(3)
279+
})
189280
})
190281

191282
describe('foldUsageBreakdown', () => {

apps/sim/lib/billing/core/usage-analytics.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,31 +206,83 @@ function toNumber(value: string | number | null | undefined): number {
206206
return Number.isFinite(parsed) ? parsed : 0
207207
}
208208

209+
/** The `YYYY-MM-DD` an instant falls on in the viewer's calendar — what `AT TIME ZONE` produced. */
210+
function localCalendarDate(instant: Date, timezone: string): string {
211+
return new Intl.DateTimeFormat('en-CA', {
212+
timeZone: timezone,
213+
year: 'numeric',
214+
month: '2-digit',
215+
day: '2-digit',
216+
}).format(instant)
217+
}
218+
219+
/**
220+
* Civil-date arithmetic on a `YYYY-MM-DD` key.
221+
*
222+
* UTC is used purely as a proleptic calendar here — these values are never converted
223+
* back to an instant, so no offset or DST transition can shift them. Doing the same
224+
* arithmetic on a real local instant is what would break across a DST boundary.
225+
*/
226+
function civilDate(key: string): Date {
227+
return new Date(`${key}T00:00:00.000Z`)
228+
}
229+
230+
function civilKey(date: Date): string {
231+
return date.toISOString().slice(0, 10)
232+
}
233+
234+
/**
235+
* Mirrors Postgres `date_trunc(bucket, …)`: an ISO week starts Monday, a month on
236+
* the 1st. The series keys have to land on the same boundaries the SQL emitted or
237+
* no lookup below will ever hit.
238+
*/
239+
function truncateToBucket(key: string, bucket: UsageBucket): string {
240+
const date = civilDate(key)
241+
if (bucket === 'week') date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7))
242+
else if (bucket === 'month') date.setUTCDate(1)
243+
return civilKey(date)
244+
}
245+
209246
/**
210247
* Fills every bucket in the window, because SQL only returns buckets that have rows.
211248
*
212249
* A period with no usage must render a flat zero line, not the chart's "No data"
213250
* branch — zero is information, "No data" reads as a failure.
251+
*
252+
* The keys are generated in the *same calendar the query grouped by*:
253+
* `readUsageTimeSeries` truncates `created_at AT TIME ZONE <timezone>`, so a viewer
254+
* east or west of UTC buckets rows by their own calendar date. Walking UTC dates
255+
* here instead dropped the edge buckets of every non-UTC window — their cost stayed
256+
* in the headline while their bar read zero. Week and month were worse than an edge
257+
* case: Postgres aligns those to Monday and the 1st, so a cursor stepping from an
258+
* arbitrary period start shared no key with the query at all and the whole chart
259+
* came back zeroed. That is reachable today through an annual enterprise period,
260+
* which resolves to `week`.
214261
*/
215262
export function densifyUsageSeries(
216263
rows: SparseBucketRow[],
217264
window: UsageAnalyticsWindow,
218-
bucket: UsageBucket
265+
bucket: UsageBucket,
266+
timezone: string
219267
): UsageSeriesPoint[] {
220268
const byBucket = new Map<string, SparseBucketRow>()
221269
for (const row of rows) {
222270
if (row.bucketStart) byBucket.set(row.bucketStart.slice(0, 10), row)
223271
}
224272

225273
const { start, end } = usageWindowBounds(window)
274+
const first = truncateToBucket(localCalendarDate(start, timezone), bucket)
275+
// The window is half-open, so the last bucket is the one holding its final instant.
276+
const last = truncateToBucket(localCalendarDate(new Date(end.getTime() - 1), timezone), bucket)
277+
226278
const points: UsageSeriesPoint[] = []
227-
const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()))
228-
const limit = end.getTime()
279+
const cursor = civilDate(first)
229280
let guard = 0
230281

231-
while (cursor.getTime() < limit && guard < 1000) {
282+
// `YYYY-MM-DD` sorts lexicographically in calendar order, so this compares dates.
283+
while (civilKey(cursor) <= last && guard < 1000) {
232284
guard += 1
233-
const key = cursor.toISOString().slice(0, 10)
285+
const key = civilKey(cursor)
234286
const row = byBucket.get(key)
235287
points.push({
236288
timestamp: `${key}T00:00:00`,

0 commit comments

Comments
 (0)