Skip to content

Commit 601f59c

Browse files
committed
Allow zero-credit API calls without balance
1 parent b7b2ddb commit 601f59c

4 files changed

Lines changed: 99 additions & 42 deletions

File tree

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
import { NextResponse } from 'next/server'
32

43
import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
@@ -40,7 +39,8 @@ export const parseJsonBody = async <T>(params: {
4039
validationErrorEvent: AnalyticsEvent
4140
userId?: string
4241
}): Promise<HandlerResult<T>> => {
43-
const { req, schema, logger, trackEvent, validationErrorEvent, userId } = params
42+
const { req, schema, logger, trackEvent, validationErrorEvent, userId } =
43+
params
4444
const trackingUserId = userId ?? 'unknown'
4545

4646
let json: unknown
@@ -151,7 +151,10 @@ export const checkCreditsAndCharge = async (params: {
151151
insufficientCreditsEvent: AnalyticsEvent
152152
getUserUsageData: GetUserUsageDataFn
153153
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn
154-
ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise<unknown>
154+
ensureSubscriberBlockGrant?: (params: {
155+
userId: string
156+
logger: Logger
157+
}) => Promise<unknown>
155158
}): Promise<HandlerResult<{ creditsUsed: number }>> => {
156159
const {
157160
userId,
@@ -167,6 +170,10 @@ export const checkCreditsAndCharge = async (params: {
167170
ensureSubscriberBlockGrant,
168171
} = params
169172

173+
if (creditsToCharge <= 0) {
174+
return { ok: true, data: { creditsUsed: 0 } }
175+
}
176+
170177
// Ensure subscription block grant exists before checking credits.
171178
// This creates the grant (if eligible) so its credits appear in the balance below.
172179
// When the function is provided, always include subscription credits in the balance:

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

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ describe('/api/v1/docs-search POST endpoint', () => {
8181
headers: { 'Content-Type': 'text/plain' },
8282
})
8383
}
84-
mockFetch = Object.assign(fetchImpl, { preconnect: () => {} }) as typeof fetch
84+
mockFetch = Object.assign(fetchImpl, {
85+
preconnect: () => {},
86+
}) as typeof fetch
8587
})
8688

8789
afterEach(() => {
@@ -106,7 +108,7 @@ describe('/api/v1/docs-search POST endpoint', () => {
106108
expect(res.status).toBe(401)
107109
})
108110

109-
test('402 when insufficient credits', async () => {
111+
test('200 when zero-credit docs search user has no credits', async () => {
110112
mockGetUserUsageData = mock(async () => ({
111113
usageThisCycle: 0,
112114
balance: {
@@ -133,7 +135,11 @@ describe('/api/v1/docs-search POST endpoint', () => {
133135
consumeCreditsWithFallback: mockConsumeCreditsWithFallback,
134136
fetch: mockFetch,
135137
})
136-
expect(res.status).toBe(402)
138+
expect(res.status).toBe(200)
139+
const body = await res.json()
140+
expect(body.creditsUsed).toBe(0)
141+
expect(mockGetUserUsageData).not.toHaveBeenCalled()
142+
expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled()
137143
})
138144

139145
test('200 on success', async () => {
@@ -155,26 +161,37 @@ describe('/api/v1/docs-search POST endpoint', () => {
155161
expect(res.status).toBe(200)
156162
const body = await res.json()
157163
expect(body.documentation).toContain('Some documentation text')
164+
expect(body.creditsUsed).toBe(0)
165+
expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled()
158166
})
159167

160168
test('200 for subscriber with 0 a-la-carte credits but active block grant', async () => {
161-
mockGetUserUsageData = mock(async ({ includeSubscriptionCredits }: { includeSubscriptionCredits?: boolean }) => ({
162-
usageThisCycle: 0,
163-
balance: {
164-
totalRemaining: includeSubscriptionCredits ? 350 : 0,
165-
totalDebt: 0,
166-
netBalance: includeSubscriptionCredits ? 350 : 0,
167-
breakdown: {},
168-
principals: {},
169-
},
170-
nextQuotaReset: 'soon',
171-
}))
169+
mockGetUserUsageData = mock(
170+
async ({
171+
includeSubscriptionCredits,
172+
}: {
173+
includeSubscriptionCredits?: boolean
174+
}) => ({
175+
usageThisCycle: 0,
176+
balance: {
177+
totalRemaining: includeSubscriptionCredits ? 350 : 0,
178+
totalDebt: 0,
179+
netBalance: includeSubscriptionCredits ? 350 : 0,
180+
breakdown: {},
181+
principals: {},
182+
},
183+
nextQuotaReset: 'soon',
184+
}),
185+
)
172186
const mockEnsureSubscriberBlockGrant = mock(async () => ({
173187
grantId: 'grant-1',
174188
credits: 350,
175189
expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000),
176190
isNew: true,
177-
})) as unknown as (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
191+
})) as unknown as (params: {
192+
userId: string
193+
logger: Logger
194+
}) => Promise<BlockGrantResult | null>
178195

179196
const req = new NextRequest('http://localhost:3000/api/v1/docs-search', {
180197
method: 'POST',
@@ -195,7 +212,7 @@ describe('/api/v1/docs-search POST endpoint', () => {
195212
expect(res.status).toBe(200)
196213
})
197214

198-
test('402 for non-subscriber with 0 credits and no block grant', async () => {
215+
test('200 for non-subscriber with 0 credits and no block grant', async () => {
199216
mockGetUserUsageData = mock(async () => ({
200217
usageThisCycle: 0,
201218
balance: {
@@ -207,7 +224,12 @@ describe('/api/v1/docs-search POST endpoint', () => {
207224
},
208225
nextQuotaReset: 'soon',
209226
}))
210-
const mockEnsureSubscriberBlockGrant = mock(async () => null) as unknown as (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
227+
const mockEnsureSubscriberBlockGrant = mock(
228+
async () => null,
229+
) as unknown as (params: {
230+
userId: string
231+
logger: Logger
232+
}) => Promise<BlockGrantResult | null>
211233

212234
const req = new NextRequest('http://localhost:3000/api/v1/docs-search', {
213235
method: 'POST',
@@ -225,6 +247,10 @@ describe('/api/v1/docs-search POST endpoint', () => {
225247
fetch: mockFetch,
226248
ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant,
227249
})
228-
expect(res.status).toBe(402)
250+
expect(res.status).toBe(200)
251+
const body = await res.json()
252+
expect(body.creditsUsed).toBe(0)
253+
expect(mockGetUserUsageData).not.toHaveBeenCalled()
254+
expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled()
229255
})
230256
})

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

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe('/api/v1/web-search POST endpoint', () => {
8989
expect(res.status).toBe(401)
9090
})
9191

92-
test('402 when insufficient credits', async () => {
92+
test('200 when zero-credit search user has no credits', async () => {
9393
mockGetUserUsageData = mock(async () => ({
9494
usageThisCycle: 0,
9595
balance: {
@@ -117,7 +117,11 @@ describe('/api/v1/web-search POST endpoint', () => {
117117
fetch: mockFetch,
118118
serverEnv: testServerEnv,
119119
})
120-
expect(res.status).toBe(402)
120+
expect(res.status).toBe(200)
121+
const body = await res.json()
122+
expect(body.creditsUsed).toBe(0)
123+
expect(mockGetUserUsageData).not.toHaveBeenCalled()
124+
expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled()
121125
})
122126

123127
test('200 on success', async () => {
@@ -140,26 +144,37 @@ describe('/api/v1/web-search POST endpoint', () => {
140144
expect(res.status).toBe(200)
141145
const body = await res.json()
142146
expect(body.result).toBeDefined()
147+
expect(body.creditsUsed).toBe(0)
148+
expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled()
143149
})
144150

145151
test('200 for subscriber with 0 a-la-carte credits but active block grant', async () => {
146-
mockGetUserUsageData = mock(async ({ includeSubscriptionCredits }: { includeSubscriptionCredits?: boolean }) => ({
147-
usageThisCycle: 0,
148-
balance: {
149-
totalRemaining: includeSubscriptionCredits ? 350 : 0,
150-
totalDebt: 0,
151-
netBalance: includeSubscriptionCredits ? 350 : 0,
152-
breakdown: {},
153-
principals: {},
154-
},
155-
nextQuotaReset: 'soon',
156-
}))
152+
mockGetUserUsageData = mock(
153+
async ({
154+
includeSubscriptionCredits,
155+
}: {
156+
includeSubscriptionCredits?: boolean
157+
}) => ({
158+
usageThisCycle: 0,
159+
balance: {
160+
totalRemaining: includeSubscriptionCredits ? 350 : 0,
161+
totalDebt: 0,
162+
netBalance: includeSubscriptionCredits ? 350 : 0,
163+
breakdown: {},
164+
principals: {},
165+
},
166+
nextQuotaReset: 'soon',
167+
}),
168+
)
157169
const mockEnsureSubscriberBlockGrant = mock(async () => ({
158170
grantId: 'grant-1',
159171
credits: 350,
160172
expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000),
161173
isNew: true,
162-
})) as unknown as (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
174+
})) as unknown as (params: {
175+
userId: string
176+
logger: Logger
177+
}) => Promise<BlockGrantResult | null>
163178

164179
const req = new NextRequest('http://localhost:3000/api/v1/web-search', {
165180
method: 'POST',
@@ -181,7 +196,7 @@ describe('/api/v1/web-search POST endpoint', () => {
181196
expect(res.status).toBe(200)
182197
})
183198

184-
test('402 for non-subscriber with 0 credits and no block grant', async () => {
199+
test('200 for non-subscriber with 0 credits and no block grant', async () => {
185200
mockGetUserUsageData = mock(async () => ({
186201
usageThisCycle: 0,
187202
balance: {
@@ -193,7 +208,12 @@ describe('/api/v1/web-search POST endpoint', () => {
193208
},
194209
nextQuotaReset: 'soon',
195210
}))
196-
const mockEnsureSubscriberBlockGrant = mock(async () => null) as unknown as (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
211+
const mockEnsureSubscriberBlockGrant = mock(
212+
async () => null,
213+
) as unknown as (params: {
214+
userId: string
215+
logger: Logger
216+
}) => Promise<BlockGrantResult | null>
197217

198218
const req = new NextRequest('http://localhost:3000/api/v1/web-search', {
199219
method: 'POST',
@@ -212,6 +232,10 @@ describe('/api/v1/web-search POST endpoint', () => {
212232
serverEnv: testServerEnv,
213233
ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant,
214234
})
215-
expect(res.status).toBe(402)
235+
expect(res.status).toBe(200)
236+
const body = await res.json()
237+
expect(body.creditsUsed).toBe(0)
238+
expect(mockGetUserUsageData).not.toHaveBeenCalled()
239+
expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled()
216240
})
217241
})

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ import type {
2424
import type { BlockGrantResult } from '@codebuff/billing/subscription'
2525
import type { NextRequest } from 'next/server'
2626

27-
28-
29-
3027
const bodySchema = z.object({
3128
query: z.string().min(1, 'query is required'),
3229
depth: z.enum(['standard', 'deep']).optional().default('standard'),
@@ -43,7 +40,10 @@ export async function postWebSearch(params: {
4340
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn
4441
fetch: typeof globalThis.fetch
4542
serverEnv: LinkupEnv
46-
ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
43+
ensureSubscriberBlockGrant?: (params: {
44+
userId: string
45+
logger: Logger
46+
}) => Promise<BlockGrantResult | null>
4747
}) {
4848
const {
4949
req,

0 commit comments

Comments
 (0)