@@ -2,7 +2,7 @@ import { db } from '@sim/db'
22import { member , organization , settings , user , userStats } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { isOrgAdminRole } from '@sim/platform-authz/workspace'
5- import { and , eq , sql } from 'drizzle-orm'
5+ import { and , eq , type SQL , sql } from 'drizzle-orm'
66import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
77import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
88import type { BillingEntity } from '@/lib/billing/core/usage-log'
@@ -17,9 +17,6 @@ const logger = createLogger('LimitNotifications')
1717/** Limit categories that send per-category threshold emails (credits has its own path). */
1818export type LimitCategory = Extract < UpgradeReason , 'storage' | 'tables' | 'seats' >
1919
20- /** Every category whose emailed threshold is persisted, including credits. */
21- type ClaimCategory = LimitCategory | Extract < UpgradeReason , 'credits' >
22-
2320const WARN_THRESHOLD = 80
2421const REACH_THRESHOLD = 100
2522/** Usage must drop below this band before the same threshold can re-notify (hysteresis). */
@@ -35,6 +32,32 @@ function thresholdFor(percent: number): 0 | 80 | 100 {
3532 return 0
3633}
3734
35+ /**
36+ * Replace the account's `limitNotifications` with `next` when `condition` holds, returning
37+ * whether the row was updated.
38+ */
39+ async function writeLimitNotifications (
40+ scope : 'user' | 'organization' ,
41+ id : string ,
42+ next : SQL ,
43+ condition : SQL
44+ ) : Promise < boolean > {
45+ const written =
46+ scope === 'user'
47+ ? await db
48+ . update ( userStats )
49+ . set ( { limitNotifications : next } )
50+ . where ( and ( eq ( userStats . userId , id ) , condition ) )
51+ . returning ( { id : userStats . userId } )
52+ : await db
53+ . update ( organization )
54+ . set ( { limitNotifications : next } )
55+ . where ( and ( eq ( organization . id , id ) , condition ) )
56+ . returning ( { id : organization . id } )
57+
58+ return written . length > 0
59+ }
60+
3861/**
3962 * Atomically claim a threshold for a category: advance the stored value to
4063 * `threshold` only if it is currently lower, returning whether THIS call won the
@@ -44,7 +67,7 @@ function thresholdFor(percent: number): 0 | 80 | 100 {
4467async function claimThreshold (
4568 scope : 'user' | 'organization' ,
4669 id : string ,
47- category : ClaimCategory ,
70+ category : LimitCategory ,
4871 threshold : number
4972) : Promise < boolean > {
5073 const setExpr = sql `jsonb_set(coalesce(${ scope === 'user' ? userStats . limitNotifications : organization . limitNotifications } , '{}'::jsonb), ARRAY[${ category } ], to_jsonb(${ threshold } ::int))`
@@ -53,38 +76,37 @@ async function claimThreshold(
5376 ? sql `coalesce((${ userStats . limitNotifications } ->> ${ category } )::int, 0) < ${ threshold } `
5477 : sql `coalesce((${ organization . limitNotifications } ->> ${ category } )::int, 0) < ${ threshold } `
5578
56- const claimed =
57- scope === 'user'
58- ? await db
59- . update ( userStats )
60- . set ( { limitNotifications : setExpr } )
61- . where ( and ( eq ( userStats . userId , id ) , onlyIfLower ) )
62- . returning ( { id : userStats . userId } )
63- : await db
64- . update ( organization )
65- . set ( { limitNotifications : setExpr } )
66- . where ( and ( eq ( organization . id , id ) , onlyIfLower ) )
67- . returning ( { id : organization . id } )
68-
69- return claimed . length > 0
79+ return writeLimitNotifications ( scope , id , setExpr , onlyIfLower )
7080}
7181
7282const DAY_MS = 24 * 60 * 60 * 1000
7383
7484/**
75- * Claim a credits threshold (80 or 100) once per billing period, returning whether THIS call won
76- * it. The stored value is the period's start day followed by the threshold, so it only grows: a
77- * later period outranks every claim of an earlier one and re-arms both thresholds with no reset
78- * write, while within a period a claim of 100 also retires 80, and never the reverse.
85+ * Claim a credits threshold (80 or 100), returning whether THIS call won it. The claim is keyed
86+ * on the billing period and the limit: `credits` holds the highest threshold emailed while
87+ * `creditsPeriod` (the period's start day) and `creditsLimit` (the limit in cents) still match,
88+ * so a new period or a changed limit — in either direction — re-arms both thresholds with no
89+ * reset write, while within one a claim of 100 also retires 80, and never the reverse.
7990 */
80- export function claimCreditsThreshold (
81- scope : 'user' | 'organization' ,
82- id : string ,
83- periodStart : Date ,
91+ export function claimCreditsThreshold ( params : {
92+ scope : 'user' | 'organization'
93+ id : string
94+ periodStart : Date
95+ limit : number
8496 threshold : 80 | 100
85- ) : Promise < boolean > {
86- const periodDay = Math . floor ( periodStart . getTime ( ) / DAY_MS )
87- return claimThreshold ( scope , id , 'credits' , periodDay * 1000 + threshold )
97+ } ) : Promise < boolean > {
98+ const { scope, id, threshold } = params
99+ const periodDay = Math . floor ( params . periodStart . getTime ( ) / DAY_MS )
100+ const limitCents = Math . round ( params . limit * 100 )
101+ const column = scope === 'user' ? userStats . limitNotifications : organization . limitNotifications
102+ const next = sql `coalesce(${ column } , '{}'::jsonb) || jsonb_build_object('credits', ${ threshold } ::int, 'creditsPeriod', ${ periodDay } ::bigint, 'creditsLimit', ${ limitCents } ::bigint)`
103+ const unclaimed = sql `not (
104+ (${ column } ->> 'creditsPeriod')::bigint is not distinct from ${ periodDay } ::bigint
105+ and (${ column } ->> 'creditsLimit')::bigint is not distinct from ${ limitCents } ::bigint
106+ and coalesce((${ column } ->> 'credits')::int, 0) >= ${ threshold } ::int
107+ )`
108+
109+ return writeLimitNotifications ( scope , id , next , unclaimed )
88110}
89111
90112/** Re-arm a category (reset its stored threshold to 0) once usage falls back into the low band. */
@@ -129,7 +151,7 @@ async function isUnsubscribed(email: string): Promise<boolean> {
129151 * Returning an empty list means "nobody to notify" — the caller then skips the
130152 * claim so the dedup state isn't burned without an email going out.
131153 */
132- async function resolveRecipients (
154+ export async function resolveLimitEmailRecipients (
133155 scope : 'user' | 'organization' ,
134156 params : { userId ?: string ; userEmail ?: string ; userName ?: string ; organizationId ?: string }
135157) : Promise < LimitEmailRecipient [ ] > {
@@ -228,7 +250,7 @@ export async function maybeSendLimitThresholdEmail(params: {
228250
229251 if ( params . rearmOnly || desired === 0 ) return
230252
231- const recipients = await resolveRecipients ( scope , params )
253+ const recipients = await resolveLimitEmailRecipients ( scope , params )
232254 if ( recipients . length === 0 ) return
233255
234256 if ( ! ( await claimThreshold ( scope , stateId , category , desired ) ) ) return
0 commit comments