Skip to content

Commit fb99d84

Browse files
committed
fix(billing): re-arm limit-notification dedup on usage drops (prior-usage + decrement)
1 parent 40864ca commit fb99d84

5 files changed

Lines changed: 63 additions & 6 deletions

File tree

apps/sim/lib/billing/core/limit-notifications.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77
billingFlag,
88
mockClaim,
99
mockSelectRows,
10+
dbUpdateSpy,
1011
sendEmailSpy,
1112
getEmailPreferencesMock,
1213
renderMock,
@@ -16,6 +17,7 @@ const {
1617
billingFlag: { enabled: true },
1718
mockClaim: vi.fn<[], unknown[]>(() => [{ id: 'u1' }]),
1819
mockSelectRows: vi.fn<[], unknown[]>(() => []),
20+
dbUpdateSpy: vi.fn(),
1921
sendEmailSpy: vi.fn(() => Promise.resolve({ success: true })),
2022
getEmailPreferencesMock: vi.fn(() => Promise.resolve(null as unknown)),
2123
renderMock: vi.fn(() => Promise.resolve('<html></html>')),
@@ -42,7 +44,8 @@ vi.mock('@sim/db', () => {
4244
then: (f: (v: unknown) => unknown, r?: (e: unknown) => unknown) =>
4345
Promise.resolve(mockSelectRows()).then(f, r),
4446
}
45-
return { db: { update: () => updateBuilder, select: () => selectBuilder } }
47+
dbUpdateSpy.mockImplementation(() => updateBuilder)
48+
return { db: { update: dbUpdateSpy, select: () => selectBuilder } }
4649
})
4750

4851
vi.mock('@/lib/core/config/env-flags', () => ({
@@ -102,6 +105,31 @@ describe('maybeSendLimitThresholdEmail', () => {
102105
expect(sendEmailSpy).not.toHaveBeenCalled()
103106
})
104107

108+
it('re-arms then claims when a single jump crosses from below the band past 80% (priorUsage)', async () => {
109+
// prior 50% (re-arm band) → current 90%: re-arm (update) + claim (update) = 2 updates, then send.
110+
await maybeSendLimitThresholdEmail({
111+
...baseUserParams,
112+
currentUsage: 4.5,
113+
limit: 5,
114+
priorUsage: 2.5,
115+
})
116+
expect(dbUpdateSpy).toHaveBeenCalledTimes(2)
117+
expect(sendEmailSpy).toHaveBeenCalledTimes(1)
118+
expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' }))
119+
})
120+
121+
it('does not re-arm when prior usage was already in-band (no spurious reset)', async () => {
122+
// prior 85% and current 90%: both >= re-arm band → claim only, no re-arm update.
123+
await maybeSendLimitThresholdEmail({
124+
...baseUserParams,
125+
currentUsage: 4.5,
126+
limit: 5,
127+
priorUsage: 4.25,
128+
})
129+
expect(dbUpdateSpy).toHaveBeenCalledTimes(1)
130+
expect(sendEmailSpy).toHaveBeenCalledTimes(1)
131+
})
132+
105133
it('does not send in the dead band (70%–80%)', async () => {
106134
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 3.75, limit: 5 })
107135
expect(mockClaim).not.toHaveBeenCalled()

apps/sim/lib/billing/core/limit-notifications.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ export async function maybeSendLimitThresholdEmail(params: {
115115
usageLabel: string
116116
/** Pre-formatted limit for the email body, e.g. "5 GB", "10 seats". */
117117
limitLabel: string
118+
/**
119+
* Usage immediately BEFORE the mutation, when known (e.g. the pre-insert row
120+
* count). Lets a single large change that jumps from below the re-arm band
121+
* past a threshold still re-arm before claiming, so the re-warning isn't
122+
* suppressed. Omit when only the post-mutation usage is observable.
123+
*/
124+
priorUsage?: number
118125
userId?: string
119126
userEmail?: string
120127
userName?: string
@@ -126,14 +133,18 @@ export async function maybeSendLimitThresholdEmail(params: {
126133

127134
const { category, scope } = params
128135
const percent = (params.currentUsage / params.limit) * 100
136+
const priorPercent =
137+
params.priorUsage != null && params.priorUsage > 0
138+
? (params.priorUsage / params.limit) * 100
139+
: percent
129140
const desired = thresholdFor(percent)
130141

131142
const stateId = scope === 'user' ? params.userId : params.organizationId
132143
if (!stateId) return
133144

134-
if (percent < REARM_BELOW) {
145+
// Re-arm if usage is (or just was) back in the low band, so a fresh climb re-notifies.
146+
if (Math.min(percent, priorPercent) < REARM_BELOW) {
135147
await rearmThreshold(scope, stateId, category)
136-
return
137148
}
138149

139150
if (desired === 0) return
@@ -229,6 +240,8 @@ export async function maybeNotifyLimit(params: {
229240
limit: number
230241
usageLabel: string
231242
limitLabel: string
243+
/** Usage before the mutation, when known — see {@link maybeSendLimitThresholdEmail}. */
244+
priorUsage?: number
232245
}): Promise<void> {
233246
try {
234247
const sub = await getHighestPrioritySubscription(params.billedUserId)
@@ -254,6 +267,7 @@ export async function maybeNotifyLimit(params: {
254267
limit: params.limit,
255268
usageLabel: params.usageLabel,
256269
limitLabel: params.limitLabel,
270+
priorUsage: params.priorUsage,
257271
userId: params.billedUserId,
258272
userEmail,
259273
userName,

apps/sim/lib/billing/storage/tracking.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,17 @@ export async function incrementStorageUsage(
101101
/**
102102
* Decrement storage usage after file deletion
103103
* Only tracks if billing is enabled
104+
*
105+
* @param workspaceId - When provided, re-evaluates the storage threshold state
106+
* after the decrement. Usage only drops here, so this can only re-arm a
107+
* previously-sent threshold (it never sends), keeping the re-warning correct
108+
* after a shrink. Best-effort; never blocks the caller.
104109
*/
105-
export async function decrementStorageUsage(userId: string, bytes: number): Promise<void> {
110+
export async function decrementStorageUsage(
111+
userId: string,
112+
bytes: number,
113+
workspaceId?: string
114+
): Promise<void> {
106115
if (!isBillingEnabled) {
107116
logger.debug('Billing disabled, skipping storage decrement')
108117
return
@@ -135,4 +144,8 @@ export async function decrementStorageUsage(userId: string, bytes: number): Prom
135144
logger.error('Error decrementing storage usage:', error)
136145
throw error
137146
}
147+
148+
if (workspaceId) {
149+
void maybeNotifyStorageLimit(userId, workspaceId)
150+
}
138151
}

apps/sim/lib/table/billing.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const TABLE_ROW_NOTIFY_PERCENT = 80
2323
*/
2424
async function maybeNotifyTableRowLimit(
2525
workspaceId: string,
26+
currentRowCount: number,
2627
projectedRowCount: number,
2728
limit: number
2829
): Promise<void> {
@@ -38,6 +39,7 @@ async function maybeNotifyTableRowLimit(
3839
limit,
3940
usageLabel: `${projectedRowCount.toLocaleString('en-US')} rows`,
4041
limitLabel: `${limit.toLocaleString('en-US')} rows`,
42+
priorUsage: currentRowCount,
4143
})
4244
} catch (error) {
4345
logger.error('Error evaluating table row-limit notification:', error)
@@ -180,7 +182,7 @@ export async function assertRowCapacity(params: {
180182
if (limit > 0) {
181183
const projected = params.currentRowCount + params.addedRows
182184
if ((projected / limit) * 100 >= TABLE_ROW_NOTIFY_PERCENT) {
183-
void maybeNotifyTableRowLimit(params.workspaceId, projected, limit)
185+
void maybeNotifyTableRowLimit(params.workspaceId, params.currentRowCount, projected, limit)
184186
}
185187
}
186188
}

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,7 @@ export async function updateWorkspaceFileContent(
937937
if (sizeDiff > 0) {
938938
await incrementStorageUsage(userId, sizeDiff, workspaceId)
939939
} else {
940-
await decrementStorageUsage(userId, Math.abs(sizeDiff))
940+
await decrementStorageUsage(userId, Math.abs(sizeDiff), workspaceId)
941941
}
942942
} catch (storageError) {
943943
logger.error(`Failed to update storage tracking:`, storageError)

0 commit comments

Comments
 (0)