Skip to content

Commit 01ee798

Browse files
committed
fix(billing): resolve recipients before claiming so opt-outs don't burn the dedup threshold
1 parent ea2a1b9 commit 01ee798

2 files changed

Lines changed: 93 additions & 57 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,16 +155,20 @@ describe('maybeSendLimitThresholdEmail', () => {
155155
expect(sendEmailSpy).not.toHaveBeenCalled()
156156
})
157157

158-
it('respects the per-user notifications toggle even after winning the claim', async () => {
158+
it('does not send OR burn the claim when the per-user toggle is off', async () => {
159159
mockSelectRows.mockReturnValue([{ enabled: false }])
160160
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })
161161
expect(sendEmailSpy).not.toHaveBeenCalled()
162+
// Recipient resolution gates the claim, so the threshold isn't advanced —
163+
// re-enabling notifications later still lets the email fire.
164+
expect(mockClaim).not.toHaveBeenCalled()
162165
})
163166

164-
it('respects unsubscribe preferences', async () => {
167+
it('does not send OR burn the claim when the recipient unsubscribed', async () => {
165168
getEmailPreferencesMock.mockResolvedValue({ unsubscribeNotifications: true })
166169
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })
167170
expect(sendEmailSpy).not.toHaveBeenCalled()
171+
expect(mockClaim).not.toHaveBeenCalled()
168172
})
169173

170174
it('skips entirely when billing is disabled', async () => {

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

Lines changed: 87 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,63 @@ async function rearmThreshold(
9191
}
9292
}
9393

94+
interface LimitEmailRecipient {
95+
email: string
96+
name?: string
97+
}
98+
99+
/** Whether a recipient has unsubscribed from all or from notification emails. */
100+
async function isUnsubscribed(email: string): Promise<boolean> {
101+
const prefs = await getEmailPreferences(email)
102+
return Boolean(prefs?.unsubscribeAll || prefs?.unsubscribeNotifications)
103+
}
104+
105+
/**
106+
* Resolve the recipients that should receive a limit email, with all opt-outs
107+
* already applied (the per-user notifications toggle and unsubscribe prefs).
108+
* Returning an empty list means "nobody to notify" — the caller then skips the
109+
* claim so the dedup state isn't burned without an email going out.
110+
*/
111+
async function resolveRecipients(
112+
scope: 'user' | 'organization',
113+
params: { userId?: string; userEmail?: string; userName?: string; organizationId?: string }
114+
): Promise<LimitEmailRecipient[]> {
115+
if (scope === 'user') {
116+
if (!params.userId || !params.userEmail) return []
117+
const rows = await db
118+
.select({ enabled: settings.billingUsageNotificationsEnabled })
119+
.from(settings)
120+
.where(eq(settings.userId, params.userId))
121+
.limit(1)
122+
if (rows.length > 0 && rows[0].enabled === false) return []
123+
if (await isUnsubscribed(params.userEmail)) return []
124+
return [{ email: params.userEmail, name: params.userName }]
125+
}
126+
127+
if (!params.organizationId) return []
128+
const admins = await db
129+
.select({
130+
email: user.email,
131+
name: user.name,
132+
enabled: settings.billingUsageNotificationsEnabled,
133+
role: member.role,
134+
})
135+
.from(member)
136+
.innerJoin(user, eq(member.userId, user.id))
137+
.leftJoin(settings, eq(settings.userId, member.userId))
138+
.where(eq(member.organizationId, params.organizationId))
139+
140+
const recipients: LimitEmailRecipient[] = []
141+
for (const a of admins) {
142+
if (!isOrgAdminRole(a.role)) continue
143+
if (a.enabled === false) continue
144+
if (!a.email) continue
145+
if (await isUnsubscribed(a.email)) continue
146+
recipients.push({ email: a.email, name: a.name || undefined })
147+
}
148+
return recipients
149+
}
150+
94151
/**
95152
* Send a usage-limit threshold email (80% warning / 100% reached) for a
96153
* non-credit category, edge-triggered on the mutation that changed usage.
@@ -158,69 +215,44 @@ export async function maybeSendLimitThresholdEmail(params: {
158215
// Usage-decrease callers re-arm only — a drop is never a fresh crossing to email.
159216
if (params.rearmOnly || desired === 0) return
160217

218+
// Resolve eligible recipients (opt-outs filtered) BEFORE claiming, so the
219+
// dedup state only advances when an email will actually be sent — otherwise
220+
// an opted-out recipient would silently burn the threshold and suppress a
221+
// later email after notifications are re-enabled.
222+
const recipients = await resolveRecipients(scope, params)
223+
if (recipients.length === 0) return
224+
161225
if (!(await claimThreshold(scope, stateId, category, desired))) return
162226

163227
const kind = desired === REACH_THRESHOLD ? 'reached' : 'warning'
164228
const percentUsed = Math.min(100, Math.round(percent))
165229
const upgradeLink = `${getBaseUrl()}${buildUpgradeHref(params.workspaceId, category)}`
166230

167-
const sendTo = async (email: string, name?: string) => {
168-
const prefs = await getEmailPreferences(email)
169-
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
170-
171-
const html = await renderLimitThresholdEmail({
172-
kind,
173-
reason: category,
174-
userName: name,
175-
usageLabel: params.usageLabel,
176-
limitLabel: params.limitLabel,
177-
percentUsed,
178-
upgradeLink,
179-
})
180-
181-
await sendEmail({
182-
to: email,
183-
subject: getLimitEmailSubject(category, kind),
184-
html,
185-
emailType: 'notifications',
186-
})
187-
}
188-
189-
if (scope === 'user' && params.userId && params.userEmail) {
190-
const rows = await db
191-
.select({ enabled: settings.billingUsageNotificationsEnabled })
192-
.from(settings)
193-
.where(eq(settings.userId, params.userId))
194-
.limit(1)
195-
if (rows.length > 0 && rows[0].enabled === false) return
196-
await sendTo(params.userEmail, params.userName)
197-
} else if (scope === 'organization' && params.organizationId) {
198-
const admins = await db
199-
.select({
200-
email: user.email,
201-
name: user.name,
202-
enabled: settings.billingUsageNotificationsEnabled,
203-
role: member.role,
231+
for (const r of recipients) {
232+
// Isolate per-recipient failures so one bad send doesn't skip the rest.
233+
try {
234+
const html = await renderLimitThresholdEmail({
235+
kind,
236+
reason: category,
237+
userName: r.name,
238+
usageLabel: params.usageLabel,
239+
limitLabel: params.limitLabel,
240+
percentUsed,
241+
upgradeLink,
204242
})
205-
.from(member)
206-
.innerJoin(user, eq(member.userId, user.id))
207-
.leftJoin(settings, eq(settings.userId, member.userId))
208-
.where(eq(member.organizationId, params.organizationId))
209243

210-
for (const a of admins) {
211-
if (!isOrgAdminRole(a.role)) continue
212-
if (a.enabled === false) continue
213-
if (!a.email) continue
214-
// Isolate per-admin failures so one bad recipient doesn't skip the rest.
215-
try {
216-
await sendTo(a.email, a.name || undefined)
217-
} catch (sendError) {
218-
logger.error('Failed to send limit email to org admin', {
219-
category,
220-
email: a.email,
221-
error: sendError,
222-
})
223-
}
244+
await sendEmail({
245+
to: r.email,
246+
subject: getLimitEmailSubject(category, kind),
247+
html,
248+
emailType: 'notifications',
249+
})
250+
} catch (sendError) {
251+
logger.error('Failed to send limit email', {
252+
category,
253+
email: r.email,
254+
error: sendError,
255+
})
224256
}
225257
}
226258

0 commit comments

Comments
 (0)