Skip to content

Commit 45cb959

Browse files
committed
feat(billing): suppress low credit warnings for auto top-up users
Add autoTopupEnabled field throughout the stack to improve UX for users with auto top-up enabled. These users no longer see threshold warnings at 1000/500/100 credits since their balance auto-replenishes. Warnings only appear when truly out of credits (≤ 0). - Add autoTopupEnabled to usage responses (API, websocket, CLI) - Update shouldAutoShowBanner to skip warnings for auto top-up users - Add comprehensive tests for new behavior - Fix naming consistency (autoTopupEnabled vs autoTopUpEnabled) - Remove unused test imports
1 parent 9d41657 commit 45cb959

File tree

13 files changed

+485
-8
lines changed

13 files changed

+485
-8
lines changed

backend/src/websockets/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ protec.use(async ({ action, clientSessionId, ws, userInfo, logger }) => {
402402
},
403403
})
404404

405-
// Check and trigger monthly reset if needed
405+
// Check and trigger monthly reset if needed (ignore the returned quotaResetDate since we use user.next_quota_reset)
406406
await triggerMonthlyResetAndGrant({ userId, logger })
407407

408408
// Check if we need to trigger auto top-up and get the amount added (if any)

backend/src/websockets/websocket-action.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export async function genUsageResponse(params: {
5151
where: eq(schema.user.id, userId),
5252
columns: {
5353
next_quota_reset: true,
54+
auto_topup_enabled: true,
5455
},
5556
})
5657

@@ -73,6 +74,7 @@ export async function genUsageResponse(params: {
7374
remainingBalance: balanceDetails.totalRemaining,
7475
balanceBreakdown: balanceDetails.breakdown,
7576
next_quota_reset: user.next_quota_reset,
77+
autoTopupEnabled: user.auto_topup_enabled ?? false,
7678
} satisfies UsageResponse
7779
} catch (error) {
7880
logger.error(

cli/src/hooks/use-usage-monitor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ export function useUsageMonitor() {
2929

3030
const authToken = getAuthToken()
3131
const remainingBalance = usageData?.remainingBalance ?? null
32+
const autoTopupEnabled = usageData?.autoTopupEnabled ?? false
3233

3334
const decision = shouldAutoShowBanner(
3435
isChainInProgress,
3536
!!authToken,
3637
remainingBalance,
3738
lastWarnedThresholdRef.current,
39+
autoTopupEnabled,
3840
)
3941

4042
// Update the last warned threshold

cli/src/hooks/use-usage-query.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface UsageResponse {
2020
paid: number
2121
}
2222
next_quota_reset: string | null
23+
autoTopupEnabled?: boolean
2324
}
2425

2526
interface FetchUsageParams {

cli/src/utils/__tests__/usage-banner-state.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,89 @@ describe('usage-banner-state', () => {
193193
})
194194
})
195195

196+
describe('auto top-up enabled behavior', () => {
197+
test('does not auto-show for auto-top-up users above 0 credits', () => {
198+
// Even at low credits, auto-top-up users shouldn't see warnings
199+
const result = shouldAutoShowBanner(false, true, 50, null, true)
200+
expect(result.shouldShow).toBe(false)
201+
expect(result.newWarningThreshold).toBe(null)
202+
})
203+
204+
test('does not auto-show for auto-top-up users at any positive threshold', () => {
205+
// At 500 credits - would normally warn
206+
let result = shouldAutoShowBanner(false, true, 499, null, true)
207+
expect(result.shouldShow).toBe(false)
208+
209+
// At 100 credits - would normally warn
210+
result = shouldAutoShowBanner(false, true, 99, null, true)
211+
expect(result.shouldShow).toBe(false)
212+
213+
// Even at 1 credit
214+
result = shouldAutoShowBanner(false, true, 1, null, true)
215+
expect(result.shouldShow).toBe(false)
216+
})
217+
218+
test('DOES auto-show for auto-top-up users when truly out (0 credits)', () => {
219+
const result = shouldAutoShowBanner(false, true, 0, null, true)
220+
expect(result.shouldShow).toBe(true)
221+
expect(result.newWarningThreshold).toBe(100)
222+
})
223+
224+
test('DOES auto-show for auto-top-up users when in debt (negative credits)', () => {
225+
const result = shouldAutoShowBanner(false, true, -50, null, true)
226+
expect(result.shouldShow).toBe(true)
227+
expect(result.newWarningThreshold).toBe(100)
228+
})
229+
230+
test('non-auto-top-up users still get warnings as normal', () => {
231+
// Without auto-top-up, should warn at low credits
232+
const result = shouldAutoShowBanner(false, true, 50, null, false)
233+
expect(result.shouldShow).toBe(true)
234+
expect(result.newWarningThreshold).toBe(100)
235+
})
236+
237+
test('defaults autoTopUpEnabled to false when omitted', () => {
238+
// When autoTopUpEnabled parameter is omitted, should behave like false
239+
const result = shouldAutoShowBanner(false, true, 50, null)
240+
expect(result.shouldShow).toBe(true)
241+
expect(result.newWarningThreshold).toBe(100)
242+
})
243+
})
244+
245+
describe('combined scenarios', () => {
246+
test('chain in progress takes precedence over auto-top-up status', () => {
247+
// Even with auto-top-up disabled and low credits, chain in progress blocks showing
248+
const result = shouldAutoShowBanner(true, true, 50, null, false)
249+
expect(result.shouldShow).toBe(false)
250+
})
251+
252+
test('unauthenticated takes precedence over auto-top-up status', () => {
253+
// Even with auto-top-up disabled and low credits, no auth token blocks showing
254+
const result = shouldAutoShowBanner(false, false, 50, null, false)
255+
expect(result.shouldShow).toBe(false)
256+
})
257+
258+
test('null balance takes precedence over auto-top-up status', () => {
259+
// Even with auto-top-up disabled, null balance blocks showing
260+
const result = shouldAutoShowBanner(false, true, null, null, false)
261+
expect(result.shouldShow).toBe(false)
262+
})
263+
264+
test('auto-top-up user with previous warning threshold and now at 0 credits', () => {
265+
// Auto-top-up user who was previously warned at 500, now at 0 - should show
266+
const result = shouldAutoShowBanner(false, true, 0, 500, true)
267+
expect(result.shouldShow).toBe(true)
268+
expect(result.newWarningThreshold).toBe(100)
269+
})
270+
271+
test('auto-top-up user with healthy balance clears warning state', () => {
272+
// Auto-top-up user who now has healthy balance should have cleared state
273+
const result = shouldAutoShowBanner(false, true, 1500, 100, true)
274+
expect(result.shouldShow).toBe(false)
275+
expect(result.newWarningThreshold).toBe(null)
276+
})
277+
})
278+
196279
describe('state reset behavior', () => {
197280
test('clears warning state when credits return to healthy', () => {
198281
const result = shouldAutoShowBanner(

cli/src/utils/usage-banner-state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,14 @@ export interface AutoShowDecision {
121121
* - User is authenticated (hasAuthToken = true)
122122
* - User has credit data available (remainingBalance !== null)
123123
* - User crosses a new threshold (1000, 500, or 100) that hasn't been warned about yet
124+
* - User does NOT have auto top-up enabled (unless truly out of credits <= 0)
124125
*/
125126
export function shouldAutoShowBanner(
126127
isChainInProgress: boolean,
127128
hasAuthToken: boolean,
128129
remainingBalance: number | null,
129130
lastWarnedThreshold: number | null,
131+
autoTopupEnabled: boolean = false,
130132
): AutoShowDecision {
131133
// Don't show during active chains
132134
if (isChainInProgress) {
@@ -143,6 +145,12 @@ export function shouldAutoShowBanner(
143145
return { shouldShow: false, newWarningThreshold: lastWarnedThreshold }
144146
}
145147

148+
// For users with auto top-up enabled, only show if truly out of credits (<= 0)
149+
// Auto top-up users want to "set and forget" - don't bother them with threshold warnings
150+
if (autoTopupEnabled && remainingBalance > 0) {
151+
return { shouldShow: false, newWarningThreshold: null }
152+
}
153+
146154
const currentThreshold = getThresholdTier(remainingBalance)
147155

148156
// Clear warning state if user is back above all thresholds

common/src/actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export type UsageResponse = {
154154
balanceBreakdown?: Record<GrantType, number>
155155
next_quota_reset: Date | null
156156
autoTopupAdded?: number
157+
autoTopupEnabled?: boolean
157158
}
158159

159160
export type MessageCostResponse = {

common/src/types/contracts/billing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type GetUserUsageDataFn = (params: {
1414
}
1515
nextQuotaReset: string
1616
autoTopupTriggered?: boolean
17+
autoTopupEnabled?: boolean
1718
}>
1819

1920
export type ConsumeCreditsWithFallbackFn = (params: {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import {
2+
clearMockedModules,
3+
mockModule,
4+
} from '@codebuff/common/testing/mock-modules'
5+
import { afterEach, describe, expect, it } from 'bun:test'
6+
7+
import { triggerMonthlyResetAndGrant } from '../grant-credits'
8+
9+
import type { Logger } from '@codebuff/common/types/contracts/logger'
10+
11+
const logger: Logger = {
12+
debug: () => {},
13+
error: () => {},
14+
info: () => {},
15+
warn: () => {},
16+
}
17+
18+
const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days from now
19+
const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days ago
20+
21+
const createDbMock = (options: {
22+
user: {
23+
next_quota_reset: Date | null
24+
auto_topup_enabled: boolean | null
25+
} | null
26+
}) => {
27+
const { user } = options
28+
29+
return {
30+
transaction: async (callback: (tx: any) => Promise<any>) => {
31+
const tx = {
32+
query: {
33+
user: {
34+
findFirst: async () => user,
35+
},
36+
},
37+
update: () => ({
38+
set: () => ({
39+
where: () => Promise.resolve(),
40+
}),
41+
}),
42+
insert: () => ({
43+
values: () => Promise.resolve(),
44+
}),
45+
select: () => ({
46+
from: () => ({
47+
where: () => ({
48+
orderBy: () => ({
49+
limit: () => [],
50+
}),
51+
}),
52+
then: (cb: any) => cb([]),
53+
}),
54+
}),
55+
}
56+
return callback(tx)
57+
},
58+
select: () => ({
59+
from: () => ({
60+
where: () => ({
61+
orderBy: () => ({
62+
limit: () => [],
63+
}),
64+
}),
65+
}),
66+
}),
67+
}
68+
}
69+
70+
describe('grant-credits', () => {
71+
afterEach(() => {
72+
clearMockedModules()
73+
})
74+
75+
describe('triggerMonthlyResetAndGrant', () => {
76+
describe('autoTopupEnabled return value', () => {
77+
it('should return autoTopupEnabled: true when user has auto_topup_enabled: true', async () => {
78+
await mockModule('@codebuff/internal/db', () => ({
79+
default: createDbMock({
80+
user: {
81+
next_quota_reset: futureDate,
82+
auto_topup_enabled: true,
83+
},
84+
}),
85+
}))
86+
87+
// Need to re-import after mocking
88+
const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits')
89+
90+
const result = await fn({
91+
userId: 'user-123',
92+
logger,
93+
})
94+
95+
expect(result.autoTopupEnabled).toBe(true)
96+
expect(result.quotaResetDate).toEqual(futureDate)
97+
})
98+
99+
it('should return autoTopupEnabled: false when user has auto_topup_enabled: false', async () => {
100+
await mockModule('@codebuff/internal/db', () => ({
101+
default: createDbMock({
102+
user: {
103+
next_quota_reset: futureDate,
104+
auto_topup_enabled: false,
105+
},
106+
}),
107+
}))
108+
109+
const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits')
110+
111+
const result = await fn({
112+
userId: 'user-123',
113+
logger,
114+
})
115+
116+
expect(result.autoTopupEnabled).toBe(false)
117+
})
118+
119+
it('should default autoTopupEnabled to false when user has auto_topup_enabled: null', async () => {
120+
await mockModule('@codebuff/internal/db', () => ({
121+
default: createDbMock({
122+
user: {
123+
next_quota_reset: futureDate,
124+
auto_topup_enabled: null,
125+
},
126+
}),
127+
}))
128+
129+
const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits')
130+
131+
const result = await fn({
132+
userId: 'user-123',
133+
logger,
134+
})
135+
136+
expect(result.autoTopupEnabled).toBe(false)
137+
})
138+
139+
it('should throw error when user is not found', async () => {
140+
await mockModule('@codebuff/internal/db', () => ({
141+
default: createDbMock({
142+
user: null,
143+
}),
144+
}))
145+
146+
const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits')
147+
148+
await expect(
149+
fn({
150+
userId: 'nonexistent-user',
151+
logger,
152+
}),
153+
).rejects.toThrow('User nonexistent-user not found')
154+
})
155+
})
156+
157+
describe('quota reset behavior', () => {
158+
it('should return existing reset date when it is in the future', async () => {
159+
await mockModule('@codebuff/internal/db', () => ({
160+
default: createDbMock({
161+
user: {
162+
next_quota_reset: futureDate,
163+
auto_topup_enabled: false,
164+
},
165+
}),
166+
}))
167+
168+
const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits')
169+
170+
const result = await fn({
171+
userId: 'user-123',
172+
logger,
173+
})
174+
175+
expect(result.quotaResetDate).toEqual(futureDate)
176+
})
177+
})
178+
})
179+
})

0 commit comments

Comments
 (0)