Skip to content

Commit a538a2f

Browse files
icecrasher321claude
andcommitted
fix(billing): bucket refresh by clamped day so stamped stragglers stay in the deduction
Refresh membership is now the entity/period stamps alone — identical to the ledger sums it offsets. A row written after the rollover but stamped to the elapsed period (attribution frozen at run start) is billed by the stamp-based close, so it must consume refresh too; created-at now only assigns the day bucket, clamped into the period, instead of excluding the row entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f907385 commit a538a2f

2 files changed

Lines changed: 39 additions & 18 deletions

File tree

apps/sim/lib/billing/credits/daily-refresh.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,28 @@ describe('computeDailyRefreshConsumed', () => {
136136
expect(drizzleOrmMock.inArray).not.toHaveBeenCalled()
137137
})
138138

139+
it('keeps straggler rows stamped to the period but written after its end', async () => {
140+
// A run that started before the rollover inserts rows stamped with the
141+
// elapsed period after it ended; the stamp-based close bills them, so the
142+
// deduction must include them too (clamped into the final day bucket).
143+
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 30, dayTotal: '0.30' }])
144+
const periodStart = new Date('2026-03-01')
145+
const periodEnd = new Date('2026-04-01')
146+
147+
const result = await computeDailyRefreshConsumed({
148+
billingEntity: { type: 'user', id: 'user-1' },
149+
periodStart,
150+
periodEnd,
151+
planDollars: 25,
152+
})
153+
154+
expect(result).toBe(0.25)
155+
// Membership is stamp-only: no created-at bound may exclude a row the
156+
// stamped ledger total includes.
157+
expect(drizzleOrmMock.lt).not.toHaveBeenCalledWith(schemaMock.usageLog.createdAt, periodEnd)
158+
expect(drizzleOrmMock.gte).not.toHaveBeenCalled()
159+
})
160+
139161
it('rejects windows beyond the supported annual bound', async () => {
140162
await expect(
141163
computeDailyRefreshConsumed({

apps/sim/lib/billing/credits/daily-refresh.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ interface BillingPeriodUsageWithDailyRefreshParams {
4545
*
4646
* The two aggregates intentionally keep different predicates. Ledger totals
4747
* use both captured period bounds (or a reporting-time window), while refresh
48-
* uses the captured period start plus a created-at day window.
48+
* membership is the captured period-start stamp alone — created-at only
49+
* buckets rows into days, clamped into the period (see
50+
* `computeDailyRefreshConsumed` for why).
4951
*/
5052
export async function computeBillingPeriodUsageWithDailyRefresh(
5153
params: BillingPeriodUsageWithDailyRefreshParams,
@@ -64,11 +66,7 @@ export async function computeBillingPeriodUsageWithDailyRefresh(
6466
const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats
6567
const refreshWindowActive = cap > refreshPeriodStart
6668
const refreshFilter = refreshWindowActive
67-
? and(
68-
eq(usageLog.billingPeriodStart, refreshPeriodStart),
69-
gte(usageLog.createdAt, refreshPeriodStart),
70-
lt(usageLog.createdAt, cap)
71-
)
69+
? eq(usageLog.billingPeriodStart, refreshPeriodStart)
7270
: sql<boolean>`false`
7371
const ledgerPeriodFilter =
7472
billingPeriod.source === 'reporting'
@@ -81,22 +79,18 @@ export async function computeBillingPeriodUsageWithDailyRefresh(
8179
const sameCapturedPeriodStart =
8280
billingPeriod.source !== 'reporting' &&
8381
billingPeriod.start.getTime() === refreshPeriodStart.getTime()
84-
const reportingWindowContainsRefresh =
85-
billingPeriod.source === 'reporting' &&
86-
refreshPeriodStart >= billingPeriod.start &&
87-
cap <= billingPeriod.end
8882
const scanFilter = !refreshWindowActive
8983
? ledgerPeriodFilter
9084
: sameCapturedPeriodStart
9185
? eq(usageLog.billingPeriodStart, billingPeriod.start)
92-
: reportingWindowContainsRefresh
93-
? ledgerPeriodFilter
94-
: or(ledgerPeriodFilter, refreshFilter)
86+
: or(ledgerPeriodFilter, refreshFilter)
9587

88+
const startEpoch = Math.floor(refreshPeriodStart.getTime() / 1000)
89+
const capEpoch = Math.floor(cap.getTime() / 1000)
9690
const rows = await executor
9791
.select({
9892
dayIndex:
99-
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(refreshPeriodStart.getTime() / 1000)}) / 86400)`.as(
93+
sql<number>`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 86400)`.as(
10094
'day_index'
10195
),
10296
ledgerTotal:
@@ -167,10 +161,17 @@ export async function computeDailyRefreshConsumed(
167161
throw new Error('Billing period exceeds the supported annual bound')
168162
}
169163

164+
// Membership mirrors the ledger sums exactly: the entity and period stamps
165+
// alone. Created-at only assigns the day bucket, clamped into the period —
166+
// a straggler row written after the rollover (billing attribution is frozen
167+
// at run start) is billed by the stamp-based close, so it must consume
168+
// refresh on the period's final day rather than fall out of the deduction.
169+
const startEpoch = Math.floor(periodStart.getTime() / 1000)
170+
const capEpoch = Math.floor(cap.getTime() / 1000)
170171
const rows = await executor
171172
.select({
172173
dayIndex:
173-
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as(
174+
sql<number>`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 86400)`.as(
174175
'day_index'
175176
),
176177
dayTotal: sum(usageLog.cost).as('day_total'),
@@ -180,9 +181,7 @@ export async function computeDailyRefreshConsumed(
180181
and(
181182
eq(usageLog.billingEntityType, billingEntity.type),
182183
eq(usageLog.billingEntityId, billingEntity.id),
183-
eq(usageLog.billingPeriodStart, periodStart),
184-
gte(usageLog.createdAt, periodStart),
185-
lt(usageLog.createdAt, cap)
184+
eq(usageLog.billingPeriodStart, periodStart)
186185
)
187186
)
188187
.groupBy(sql`day_index`)

0 commit comments

Comments
 (0)