Skip to content

Commit ea2a1b9

Browse files
committed
fix(billing): make storage-decrement notification re-arm only (never send on a shrink)
1 parent 10b9362 commit ea2a1b9

3 files changed

Lines changed: 38 additions & 6 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ describe('maybeSendLimitThresholdEmail', () => {
9999
expect(subjectMock).toHaveBeenCalledWith('storage', 'reached')
100100
})
101101

102+
it('never sends in rearmOnly mode, even when usage is above a threshold', async () => {
103+
// A storage shrink that still leaves usage at 90% must only re-arm, not send,
104+
// even if the stored threshold is 0 (claim would otherwise win).
105+
await maybeSendLimitThresholdEmail({
106+
...baseUserParams,
107+
currentUsage: 4.5,
108+
limit: 5,
109+
rearmOnly: true,
110+
})
111+
expect(mockClaim).not.toHaveBeenCalled()
112+
expect(sendEmailSpy).not.toHaveBeenCalled()
113+
})
114+
102115
it('does not send when the atomic claim is lost (already notified)', async () => {
103116
mockClaim.mockReturnValue([]) // someone else already advanced the threshold
104117
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ export async function maybeSendLimitThresholdEmail(params: {
122122
* suppressed. Omit when only the post-mutation usage is observable.
123123
*/
124124
priorUsage?: number
125+
/**
126+
* When true, only the re-arm is evaluated and no email is ever sent. Used by
127+
* usage-decrease paths (e.g. a storage shrink) where usage can still be above
128+
* a threshold but the change is a drop, not a fresh crossing.
129+
*/
130+
rearmOnly?: boolean
125131
userId?: string
126132
userEmail?: string
127133
userName?: string
@@ -149,7 +155,8 @@ export async function maybeSendLimitThresholdEmail(params: {
149155
await rearmThreshold(scope, stateId, category)
150156
}
151157

152-
if (desired === 0) return
158+
// Usage-decrease callers re-arm only — a drop is never a fresh crossing to email.
159+
if (params.rearmOnly || desired === 0) return
153160

154161
if (!(await claimThreshold(scope, stateId, category, desired))) return
155162

@@ -253,6 +260,8 @@ export async function maybeNotifyLimit(params: {
253260
limitLabel: string
254261
/** Usage before the mutation, when known — see {@link maybeSendLimitThresholdEmail}. */
255262
priorUsage?: number
263+
/** Re-arm only, never send — for usage-decrease callers. See {@link maybeSendLimitThresholdEmail}. */
264+
rearmOnly?: boolean
256265
}): Promise<void> {
257266
try {
258267
const sub = await getHighestPrioritySubscription(params.billedUserId)
@@ -279,6 +288,7 @@ export async function maybeNotifyLimit(params: {
279288
usageLabel: params.usageLabel,
280289
limitLabel: params.limitLabel,
281290
priorUsage: params.priorUsage,
291+
rearmOnly: params.rearmOnly,
282292
userId: params.billedUserId,
283293
userEmail,
284294
userName,

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@ function formatGb(bytes: number, decimals: number): string {
2121
}
2222

2323
/**
24-
* Best-effort storage threshold email after an increment. Re-reads the (now
25-
* updated) usage and plan limit, then delegates scope resolution + dedup + send
26-
* to {@link maybeNotifyLimit}. Never throws.
24+
* Best-effort storage threshold evaluation after a usage change. Re-reads the
25+
* (now updated) usage and plan limit, then delegates scope resolution + dedup +
26+
* send to {@link maybeNotifyLimit}. Never throws.
27+
*
28+
* @param rearmOnly - True on decrements, so a shrink that leaves usage above a
29+
* threshold re-arms but never sends (a drop is not a fresh crossing).
2730
*/
28-
async function maybeNotifyStorageLimit(userId: string, workspaceId: string): Promise<void> {
31+
async function maybeNotifyStorageLimit(
32+
userId: string,
33+
workspaceId: string,
34+
rearmOnly = false
35+
): Promise<void> {
2936
try {
3037
const [usage, limit] = await Promise.all([
3138
getUserStorageUsage(userId),
@@ -40,6 +47,7 @@ async function maybeNotifyStorageLimit(userId: string, workspaceId: string): Pro
4047
limit,
4148
usageLabel: formatGb(usage, 2),
4249
limitLabel: formatGb(limit, 0),
50+
rearmOnly,
4351
})
4452
} catch (error) {
4553
logger.error('Error evaluating storage limit notification:', error)
@@ -145,7 +153,8 @@ export async function decrementStorageUsage(
145153
throw error
146154
}
147155

156+
// Re-arm only: usage dropped, so this never sends.
148157
if (workspaceId) {
149-
void maybeNotifyStorageLimit(userId, workspaceId)
158+
void maybeNotifyStorageLimit(userId, workspaceId, true)
150159
}
151160
}

0 commit comments

Comments
 (0)