Skip to content

Commit f10f5c1

Browse files
committed
web endpoints: Create subscription credit block first before credits check
1 parent 18fc360 commit f10f5c1

File tree

13 files changed

+281
-26
lines changed

13 files changed

+281
-26
lines changed

common/src/types/contracts/billing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ErrorOr } from '../../util/error'
44
export type GetUserUsageDataFn = (params: {
55
userId: string
66
logger: Logger
7+
includeSubscriptionCredits?: boolean
78
}) => Promise<{
89
usageThisCycle: number
910
balance: {

packages/billing/src/__tests__/balance-calculator.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,69 @@ describe('Balance Calculator - calculateUsageAndBalance', () => {
209209
expect(result.usageThisCycle).toBe(500)
210210
})
211211

212+
it('should include subscription credits when isPersonalContext is true and includeSubscriptionCredits is true', async () => {
213+
const now = new Date()
214+
const quotaResetDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 7 days ago
215+
216+
const grants = [
217+
createMockGrant({
218+
operation_id: 'free-grant',
219+
balance: 500,
220+
principal: 1000,
221+
priority: 20,
222+
type: 'purchase',
223+
expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
224+
created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000),
225+
}),
226+
createMockGrant({
227+
operation_id: 'subscription-grant',
228+
balance: 2000,
229+
principal: 5000,
230+
priority: 10,
231+
type: 'subscription',
232+
expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
233+
created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000),
234+
}),
235+
]
236+
237+
await mockModule('@codebuff/internal/db', () => ({
238+
default: {
239+
select: () => ({
240+
from: () => ({
241+
where: () => ({
242+
orderBy: () => grants,
243+
}),
244+
}),
245+
}),
246+
},
247+
}))
248+
249+
await mockModule('@codebuff/common/analytics', () => ({
250+
trackEvent: () => {},
251+
}))
252+
253+
const { calculateUsageAndBalance } = await import(
254+
'@codebuff/billing/balance-calculator'
255+
)
256+
257+
const result = await calculateUsageAndBalance({
258+
userId: 'user-123',
259+
quotaResetDate,
260+
now,
261+
isPersonalContext: true,
262+
includeSubscriptionCredits: true,
263+
logger,
264+
})
265+
266+
// Should include both purchase (500) and subscription (2000) credits
267+
expect(result.balance.totalRemaining).toBe(2500)
268+
expect(result.balance.breakdown.purchase).toBe(500)
269+
expect(result.balance.breakdown.subscription).toBe(2000)
270+
271+
// Usage should include both: (1000 - 500) + (5000 - 2000) = 3500
272+
expect(result.usageThisCycle).toBe(3500)
273+
})
274+
212275
it('should include subscription credits when isPersonalContext is false', async () => {
213276
const now = new Date()
214277
const quotaResetDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 7 days ago

packages/billing/src/balance-calculator.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,18 +276,20 @@ export async function calculateUsageAndBalance(
276276
now: Date
277277
conn: DbConn
278278
isPersonalContext: boolean
279+
includeSubscriptionCredits: boolean
279280
logger: Logger
280281
} & ParamsOf<typeof getOrderedActiveGrants>,
281-
'now' | 'conn' | 'isPersonalContext'
282+
'now' | 'conn' | 'isPersonalContext' | 'includeSubscriptionCredits'
282283
>,
283284
): Promise<CreditUsageAndBalance> {
284285
const withDefaults = {
285286
now: new Date(),
286287
conn: db, // Add optional conn parameter to pass transaction
287288
isPersonalContext: false, // Add flag to exclude organization credits for personal usage
289+
includeSubscriptionCredits: false,
288290
...params,
289291
}
290-
const { userId, quotaResetDate, now, isPersonalContext, logger } =
292+
const { userId, quotaResetDate, now, isPersonalContext, includeSubscriptionCredits, logger } =
291293
withDefaults
292294

293295
// Get all relevant grants in one query, using the provided connection
@@ -326,9 +328,14 @@ export async function calculateUsageAndBalance(
326328
for (const grant of grants) {
327329
const grantType = grant.type as GrantType
328330

329-
// Skip organization and subscription credits for personal context
330-
// Subscription credits are shown separately in the CLI with progress bars
331-
if (isPersonalContext && (grantType === 'organization' || grantType === 'subscription')) {
331+
// Skip organization credits for personal context
332+
if (isPersonalContext && grantType === 'organization') {
333+
continue
334+
}
335+
// Skip subscription credits for personal context unless explicitly included
336+
// (subscription credits are shown separately in the CLI with progress bars,
337+
// but need to be included for credit gating after ensureSubscriberBlockGrant)
338+
if (isPersonalContext && grantType === 'subscription' && !includeSubscriptionCredits) {
332339
continue
333340
}
334341

packages/billing/src/usage-service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ export interface OrganizationUsageData {
5353
export async function getUserUsageData(params: {
5454
userId: string
5555
logger: Logger
56+
includeSubscriptionCredits?: boolean
5657
}): Promise<UserUsageData> {
57-
const { userId, logger } = params
58+
const { userId, logger, includeSubscriptionCredits } = params
5859
try {
5960
const now = new Date()
6061

@@ -79,10 +80,12 @@ export async function getUserUsageData(params: {
7980
// Use the canonical balance calculation function with the effective reset date
8081
// Pass isPersonalContext: true to exclude organization credits from personal usage
8182
const { usageThisCycle, balance } = await calculateUsageAndBalance({
82-
...params,
83+
userId,
84+
logger,
8385
quotaResetDate,
8486
now,
85-
isPersonalContext: true, // isPersonalContext: true to exclude organization credits
87+
isPersonalContext: true,
88+
includeSubscriptionCredits: includeSubscriptionCredits ?? false,
8689
})
8790

8891
// Check for active subscription

web/src/app/api/v1/_helpers.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export const checkCreditsAndCharge = async (params: {
152152
insufficientCreditsEvent: AnalyticsEvent
153153
getUserUsageData: GetUserUsageDataFn
154154
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn
155+
ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise<unknown>
155156
}): Promise<HandlerResult<{ creditsUsed: number }>> => {
156157
const {
157158
userId,
@@ -164,12 +165,30 @@ export const checkCreditsAndCharge = async (params: {
164165
insufficientCreditsEvent,
165166
getUserUsageData,
166167
consumeCreditsWithFallback,
168+
ensureSubscriberBlockGrant,
167169
} = params
168170

171+
// Ensure subscription block grant exists before checking credits.
172+
// This creates the grant (if eligible) so its credits appear in the balance below.
173+
// When the function is provided, always include subscription credits in the balance:
174+
// error/null results mean subscription grants have 0 balance, so including them is harmless.
175+
const includeSubscriptionCredits = !!ensureSubscriberBlockGrant
176+
if (ensureSubscriberBlockGrant) {
177+
try {
178+
await ensureSubscriberBlockGrant({ userId, logger })
179+
} catch (error) {
180+
logger.error(
181+
{ error, userId },
182+
'Error ensuring subscription block grant in credit check',
183+
)
184+
// Fail open: proceed with subscription credits included in balance check
185+
}
186+
}
187+
169188
const {
170189
balance: { totalRemaining },
171190
nextQuotaReset,
172-
} = await getUserUsageData({ userId, logger })
191+
} = await getUserUsageData({ userId, logger, includeSubscriptionCredits })
173192

174193
if (totalRemaining <= 0 || totalRemaining < creditsToCharge) {
175194
trackEvent({

web/src/app/api/v1/chat/completions/__tests__/completions.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,19 @@ describe('/api/v1/chat/completions POST endpoint', () => {
810810
}
811811
const mockEnsureSubscriberBlockGrant = mock(async () => blockGrant)
812812

813-
// Use the no-credits user (totalRemaining = 0)
813+
// Override mock: when subscription credits are included, simulate the block grant's credits
814+
mockGetUserUsageData = mock(async ({ includeSubscriptionCredits }: { includeSubscriptionCredits?: boolean }) => ({
815+
usageThisCycle: 0,
816+
balance: {
817+
totalRemaining: includeSubscriptionCredits ? 350 : 0,
818+
totalDebt: 0,
819+
netBalance: includeSubscriptionCredits ? 350 : 0,
820+
breakdown: {},
821+
},
822+
nextQuotaReset,
823+
}))
824+
825+
// Use the no-credits user (totalRemaining = 0 without subscription)
814826
const req = new NextRequest(
815827
'http://localhost:3000/api/v1/chat/completions',
816828
{

web/src/app/api/v1/chat/completions/_post.ts

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,6 @@ export async function postChatCompletions(params: {
204204
const costMode = typedBody.codebuff_metadata?.cost_mode
205205
const isFreeModeRequest = isFreeMode(costMode)
206206

207-
// Fetch user credit data early (actual credit check happens after subscription block grant logic)
208-
const {
209-
balance: { totalRemaining },
210-
nextQuotaReset,
211-
} = await getUserUsageData({ userId, logger })
212-
213207
// Extract and validate agent run ID
214208
const runIdFromBody = typedBody.codebuff_metadata?.run_id
215209
if (!runIdFromBody || typeof runIdFromBody !== 'string') {
@@ -270,7 +264,9 @@ export async function postChatCompletions(params: {
270264

271265
// For subscribers, ensure a block grant exists before processing the request.
272266
// This is done AFTER validation so malformed requests don't start a new 5-hour block.
273-
let subscriberHasAvailableCredits = false
267+
// When the function is provided, always include subscription credits in the balance:
268+
// error/null results mean subscription grants have 0 balance, so including them is harmless.
269+
const includeSubscriptionCredits = !!ensureSubscriberBlockGrant
274270
if (ensureSubscriberBlockGrant) {
275271
try {
276272
const blockGrantResult = await ensureSubscriberBlockGrant({ userId, logger })
@@ -311,24 +307,24 @@ export async function postChatCompletions(params: {
311307
{ userId, limitType: isWeeklyLimitError(blockGrantResult) ? 'weekly' : 'session' },
312308
'Subscriber hit limit, falling back to a-la-carte credits',
313309
)
314-
} else if (blockGrantResult) {
315-
subscriberHasAvailableCredits = true
316310
}
317311
} catch (error) {
318312
logger.error(
319313
{ error: getErrorObject(error), userId },
320314
'Error ensuring subscription block grant',
321315
)
322-
// Fail open: if we can't check the subscription status, allow the request to proceed.
323-
// Assume the user may be a subscriber so the credit check below doesn't reject them.
324-
subscriberHasAvailableCredits = true
316+
// Fail open: proceed with subscription credits included in balance check
325317
}
326318
}
327319

328-
// Credit check: reject if user has no a-la-carte credits AND is not covered by subscription.
329-
// Subscribers with available block grant credits bypass this check since their
330-
// subscription credits are excluded from totalRemaining (isPersonalContext: true).
331-
if (totalRemaining <= 0 && !isFreeModeRequest && !subscriberHasAvailableCredits) {
320+
// Fetch user credit data (includes subscription credits when block grant was ensured)
321+
const {
322+
balance: { totalRemaining },
323+
nextQuotaReset,
324+
} = await getUserUsageData({ userId, logger, includeSubscriptionCredits })
325+
326+
// Credit check
327+
if (totalRemaining <= 0 && !isFreeModeRequest) {
332328
trackEvent({
333329
event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS,
334330
userId,

web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
Logger,
1414
LoggerWithContextFn,
1515
} from '@codebuff/common/types/contracts/logger'
16+
import type { BlockGrantResult } from '@codebuff/billing/subscription'
1617

1718
describe('/api/v1/docs-search POST endpoint', () => {
1819
let mockLogger: Logger
@@ -153,4 +154,73 @@ describe('/api/v1/docs-search POST endpoint', () => {
153154
const body = await res.json()
154155
expect(body.documentation).toContain('Some documentation text')
155156
})
157+
158+
test('200 for subscriber with 0 a-la-carte credits but active block grant', async () => {
159+
mockGetUserUsageData = mock(async ({ includeSubscriptionCredits }: { includeSubscriptionCredits?: boolean }) => ({
160+
usageThisCycle: 0,
161+
balance: {
162+
totalRemaining: includeSubscriptionCredits ? 350 : 0,
163+
totalDebt: 0,
164+
netBalance: includeSubscriptionCredits ? 350 : 0,
165+
breakdown: {},
166+
},
167+
nextQuotaReset: 'soon',
168+
}))
169+
const mockEnsureSubscriberBlockGrant = mock(async () => ({
170+
grantId: 'grant-1',
171+
credits: 350,
172+
expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000),
173+
isNew: true,
174+
})) as unknown as (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
175+
176+
const req = new NextRequest('http://localhost:3000/api/v1/docs-search', {
177+
method: 'POST',
178+
headers: { Authorization: 'Bearer valid' },
179+
body: JSON.stringify({ libraryTitle: 'React' }),
180+
})
181+
const res = await postDocsSearch({
182+
req,
183+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
184+
logger: mockLogger,
185+
loggerWithContext: mockLoggerWithContext,
186+
trackEvent: mockTrackEvent,
187+
getUserUsageData: mockGetUserUsageData,
188+
consumeCreditsWithFallback: mockConsumeCreditsWithFallback,
189+
fetch: mockFetch,
190+
ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant,
191+
})
192+
expect(res.status).toBe(200)
193+
})
194+
195+
test('402 for non-subscriber with 0 credits and no block grant', async () => {
196+
mockGetUserUsageData = mock(async () => ({
197+
usageThisCycle: 0,
198+
balance: {
199+
totalRemaining: 0,
200+
totalDebt: 0,
201+
netBalance: 0,
202+
breakdown: {},
203+
},
204+
nextQuotaReset: 'soon',
205+
}))
206+
const mockEnsureSubscriberBlockGrant = mock(async () => null) as unknown as (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
207+
208+
const req = new NextRequest('http://localhost:3000/api/v1/docs-search', {
209+
method: 'POST',
210+
headers: { Authorization: 'Bearer valid' },
211+
body: JSON.stringify({ libraryTitle: 'React' }),
212+
})
213+
const res = await postDocsSearch({
214+
req,
215+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
216+
logger: mockLogger,
217+
loggerWithContext: mockLoggerWithContext,
218+
trackEvent: mockTrackEvent,
219+
getUserUsageData: mockGetUserUsageData,
220+
consumeCreditsWithFallback: mockConsumeCreditsWithFallback,
221+
fetch: mockFetch,
222+
ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant,
223+
})
224+
expect(res.status).toBe(402)
225+
})
156226
})

web/src/app/api/v1/docs-search/_post.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
Logger,
2020
LoggerWithContextFn,
2121
} from '@codebuff/common/types/contracts/logger'
22+
import type { BlockGrantResult } from '@codebuff/billing/subscription'
2223
import type { NextRequest } from 'next/server'
2324

2425

@@ -38,6 +39,7 @@ export async function postDocsSearch(params: {
3839
getUserUsageData: GetUserUsageDataFn
3940
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn
4041
fetch: typeof globalThis.fetch
42+
ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
4143
}) {
4244
const {
4345
req,
@@ -47,6 +49,7 @@ export async function postDocsSearch(params: {
4749
getUserUsageData,
4850
consumeCreditsWithFallback,
4951
fetch,
52+
ensureSubscriberBlockGrant,
5053
} = params
5154
const baseLogger = params.logger
5255

@@ -95,6 +98,7 @@ export async function postDocsSearch(params: {
9598
insufficientCreditsEvent: AnalyticsEvent.DOCS_SEARCH_INSUFFICIENT_CREDITS,
9699
getUserUsageData,
97100
consumeCreditsWithFallback,
101+
ensureSubscriberBlockGrant,
98102
})
99103
if (!credits.ok) return credits.response
100104

web/src/app/api/v1/docs-search/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { consumeCreditsWithFallback } from '@codebuff/billing/credit-delegation'
2+
import { ensureSubscriberBlockGrant } from '@codebuff/billing/subscription'
23
import { getUserUsageData } from '@codebuff/billing/usage-service'
34
import { trackEvent } from '@codebuff/common/analytics'
45

@@ -19,5 +20,6 @@ export async function POST(req: NextRequest) {
1920
getUserUsageData,
2021
consumeCreditsWithFallback,
2122
fetch,
23+
ensureSubscriberBlockGrant,
2224
})
2325
}

0 commit comments

Comments
 (0)