Skip to content

Commit a7ae72d

Browse files
committed
docs(billing): move limit-notification rationale to TSDoc, correct tables warn-once behavior
1 parent c10f656 commit a7ae72d

8 files changed

Lines changed: 26 additions & 40 deletions

File tree

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

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -152,15 +152,22 @@ async function resolveRecipients(
152152
* Send a usage-limit threshold email (80% warning / 100% reached) for a
153153
* non-credit category, edge-triggered on the mutation that changed usage.
154154
*
155-
* Dedup + re-arm: the highest threshold already emailed is persisted per
156-
* category on `user_stats` / `organization`. The send is gated on an atomic
157-
* {@link claimThreshold}, so a threshold emails exactly once per crossing even
158-
* under concurrent calls; it re-arms once usage drops below {@link REARM_BELOW}.
159-
* Best-effort — callers fire-and-forget; failures never block the mutation.
155+
* Flow: bail when billing is off or the limit is non-positive; re-arm the
156+
* persisted threshold when current usage is back in the low band; then (for
157+
* increases) resolve eligible recipients and atomically claim the threshold
158+
* before sending. Re-arm and claim are mutually exclusive per call — re-arm only
159+
* fires when `desired === 0` — so the dedup stays a single atomic
160+
* {@link claimThreshold} with no re-arm/claim interleaving race. Recipients are
161+
* resolved with opt-outs applied BEFORE the claim, so an opted-out recipient
162+
* never burns the threshold (which would suppress a later email once
163+
* notifications are re-enabled). Per-recipient send failures are isolated.
160164
*
161-
* Mirrors the credits path in `maybeSendUsageThresholdEmail`: skips when billing
162-
* is disabled, respects the per-user notifications toggle and unsubscribe
163-
* preferences, and emails org admins for organization-scoped limits.
165+
* The highest threshold already emailed is persisted per category on
166+
* `user_stats` / `organization`; it re-arms once usage drops below
167+
* {@link REARM_BELOW}. Best-effort — callers fire-and-forget; failures never
168+
* block the mutation. Mirrors the credits path in `maybeSendUsageThresholdEmail`:
169+
* respects the per-user notifications toggle and unsubscribe preferences, and
170+
* emails org admins for organization-scoped limits.
164171
*/
165172
export async function maybeSendLimitThresholdEmail(params: {
166173
category: LimitCategory
@@ -185,8 +192,6 @@ export async function maybeSendLimitThresholdEmail(params: {
185192
}): Promise<void> {
186193
try {
187194
if (!isBillingEnabled) return
188-
// A non-positive limit can't yield a percentage; a zero/negative `currentUsage`
189-
// still needs to re-arm below, so it is handled by the `desired === 0` return.
190195
if (params.limit <= 0) return
191196

192197
const { category, scope } = params
@@ -196,20 +201,12 @@ export async function maybeSendLimitThresholdEmail(params: {
196201
const stateId = scope === 'user' ? params.userId : params.organizationId
197202
if (!stateId) return
198203

199-
// Re-arm when current usage is back in the low band, so a fresh climb re-notifies.
200-
// Re-arm and claim are mutually exclusive (re-arm only when desired === 0), which
201-
// keeps the dedup a single atomic claim with no re-arm/claim interleaving race.
202204
if (percent < REARM_BELOW) {
203205
await rearmThreshold(scope, stateId, category)
204206
}
205207

206-
// Usage-decrease callers re-arm only — a drop is never a fresh crossing to email.
207208
if (params.rearmOnly || desired === 0) return
208209

209-
// Resolve eligible recipients (opt-outs filtered) BEFORE claiming, so the
210-
// dedup state only advances when an email will actually be sent — otherwise
211-
// an opted-out recipient would silently burn the threshold and suppress a
212-
// later email after notifications are re-enabled.
213210
const recipients = await resolveRecipients(scope, params)
214211
if (recipients.length === 0) return
215212

@@ -221,7 +218,6 @@ export async function maybeSendLimitThresholdEmail(params: {
221218

222219
let sent = 0
223220
for (const r of recipients) {
224-
// Isolate per-recipient failures so one bad send doesn't skip the rest.
225221
try {
226222
const html = await renderLimitThresholdEmail({
227223
kind,

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -841,8 +841,6 @@ export async function maybeSendUsageThresholdEmail(params: {
841841
const baseUrl = getBaseUrl()
842842
const isFreeUser = params.planName === 'Free'
843843

844-
// Live deep-links. Without a workspaceId, fall back to `/workspace` (resolves
845-
// to the user's default workspace) rather than a dropped query param.
846844
const upgradeCreditsLink = params.workspaceId
847845
? `${baseUrl}${buildUpgradeHref(params.workspaceId, 'credits')}`
848846
: `${baseUrl}/workspace`

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ export async function incrementStorageUsage(
100100
throw error
101101
}
102102

103-
// Fire-and-forget (errors swallowed internally) so the email never adds upload latency.
104103
if (workspaceId) {
105104
void maybeNotifyStorageLimit(userId, workspaceId)
106105
}
@@ -153,7 +152,6 @@ export async function decrementStorageUsage(
153152
throw error
154153
}
155154

156-
// Re-arm only: usage dropped, so this never sends.
157155
if (workspaceId) {
158156
void maybeNotifyStorageLimit(userId, workspaceId, true)
159157
}

apps/sim/lib/table/billing.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,18 @@ async function maybeNotifyTableRowLimit(
4646

4747
/**
4848
* Fire-and-forget the table row-limit threshold email for an accepted insert,
49-
* gated so only near-limit writes pay the cost. Shared by every insert path
50-
* ({@link assertRowCapacity} and the transactional upsert/import branches that
51-
* check capacity with {@link wouldExceedRowLimit} instead). Pass the pre-insert
52-
* `currentRowCount` and `addedRows` so the projected count drives the notify gate.
49+
* gated (>= {@link TABLE_ROW_NOTIFY_PERCENT}) so only near-limit writes pay the
50+
* cost. Shared by every insert path ({@link assertRowCapacity} and the
51+
* transactional upsert/import branches that check capacity with
52+
* {@link wouldExceedRowLimit} instead). Pass the pre-insert `currentRowCount` and
53+
* `addedRows` so the projected count drives the gate.
5354
*
54-
* Re-arm (allowing a fresh warning) happens on any insert that lands the table
55-
* back below the re-arm band. Deleting straight below the band and then jumping
56-
* back over a threshold in a single insert (with no intermediate write) is a
57-
* best-effort gap — the 100% reached email and first-time crossings are
58-
* unaffected. This keeps the dedup a single atomic claim with no re-arm/claim race.
55+
* Because the gate only fires at/above the warn band, tables warn once per
56+
* threshold and do not re-arm: a table that hit a threshold then dropped (via
57+
* deletes, which have no notify hook) won't re-warn on a later climb. This is a
58+
* deliberate trade-off — re-arm is storage-only (via its decrement hook), which
59+
* avoids per-delete billing-table reads. The 100% reached and first-time
60+
* crossings are unaffected, and the dedup stays a single atomic claim (no race).
5961
*/
6062
export function notifyTableRowUsage(params: {
6163
workspaceId: string

apps/sim/lib/table/import-data.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ export async function importAppendRows(
179179
}
180180
return { inserted, table: working }
181181
})
182-
// Post-commit: a pre-commit notify would email/burn the claim for a rolled-back import.
183182
notifyTableRowUsage({
184183
workspaceId: ctx.workspaceId,
185184
currentRowCount: table.rowCount,
@@ -219,7 +218,6 @@ export async function importReplaceRows(
219218
requestId
220219
)
221220
})
222-
// Post-commit: footprint is the new set (prior rows deleted → prior count 0).
223221
notifyTableRowUsage({
224222
workspaceId: data.workspaceId,
225223
currentRowCount: 0,

apps/sim/lib/table/import-runner.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,6 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
217217
{ ...table, schema },
218218
requestId
219219
)
220-
// Post-commit (per batch): pre-batch count as prior, actual inserted as the delta.
221220
notifyTableRowUsage({
222221
workspaceId,
223222
currentRowCount: existingRowCount + inserted,

apps/sim/lib/table/rows/service.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ export async function insertRow(
144144
now,
145145
})
146146

147-
// Post-commit: a pre-commit notify would email/burn the dedup claim for a rolled-back insert.
148147
notifyTableRowUsage({
149148
workspaceId: table.workspaceId,
150149
currentRowCount: table.rowCount,
@@ -209,7 +208,6 @@ export async function batchInsertRows(
209208
})
210209

211210
const result = await db.transaction((trx) => batchInsertRowsWithTx(trx, data, table, requestId))
212-
// Post-commit: notify with the actual inserted count, so a rolled-back batch never emails.
213211
notifyTableRowUsage({
214212
workspaceId: table.workspaceId,
215213
currentRowCount: table.rowCount,
@@ -370,7 +368,6 @@ export async function replaceTableRows(
370368
addedRows: data.rows.length,
371369
})
372370
const result = await db.transaction((trx) => replaceTableRowsWithTx(trx, data, table, requestId))
373-
// Post-commit: footprint is the new set (prior rows deleted → prior count 0).
374371
notifyTableRowUsage({
375372
workspaceId: table.workspaceId,
376373
currentRowCount: 0,
@@ -697,7 +694,6 @@ export async function upsertRow(
697694
)
698695

699696
if (result.operation === 'insert') {
700-
// assertRowCapacity can't run inside the upsert tx, so notify here post-commit.
701697
notifyTableRowUsage({
702698
workspaceId: data.workspaceId,
703699
currentRowCount: table.rowCount,

apps/sim/lib/table/service.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,6 @@ export async function createTable(
370370
throw error
371371
}
372372

373-
// Post-commit: starter rows landed, so notify here (a rolled-back create never emails).
374373
if (initialRowCount > 0 && rowLimit !== undefined) {
375374
notifyTableRowUsage({
376375
workspaceId: data.workspaceId,

0 commit comments

Comments
 (0)