Skip to content

Commit f83289c

Browse files
committed
fix(billing): expand Stripe charge object to fix dispute counting bug
- Add expand: [data.charge] to disputes.list() to get full charge object - Without this, dispute.charge.customer is undefined and no disputes match - Use NEXT_PUBLIC_SUPPORT_EMAIL env var instead of hardcoded email - Add regression test to prevent this bug from recurring - Simplify dispute email CTA
1 parent b9b53b4 commit f83289c

File tree

5 files changed

+35
-11
lines changed

5 files changed

+35
-11
lines changed

packages/internal/src/loops/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ If there was a problem with your experience or a charge you didn't recognize, pl
225225
226226
Working with us directly is often faster than going through your bank, and it helps us improve our service for everyone.
227227
228-
Please reply to this email or contact our support team - we're here to help!
228+
Just reply to this email - we're here to help!
229229
230230
Best regards,
231231
The Codebuff Team`

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,9 @@ describe('/api/v1/chat/completions POST endpoint', () => {
382382

383383
expect(response.status).toBe(403)
384384
const body = await response.json()
385-
expect(body).toEqual({
386-
error: 'account_suspended',
387-
message:
388-
'Your account has been suspended due to billing issues. Please contact support@codebuff.com to resolve this.',
389-
})
385+
expect(body.error).toBe('account_suspended')
386+
expect(body.message).toContain('Your account has been suspended due to billing issues')
387+
expect(body.message).toContain('to resolve this')
390388
})
391389
})
392390

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,7 @@ export async function postChatCompletions(params: {
159159
return NextResponse.json(
160160
{
161161
error: 'account_suspended',
162-
message:
163-
'Your account has been suspended due to billing issues. Please contact support@codebuff.com to resolve this.',
162+
message: `Your account has been suspended due to billing issues. Please contact ${env.NEXT_PUBLIC_SUPPORT_EMAIL} to resolve this.`,
164163
},
165164
{ status: 403 },
166165
)

web/src/lib/__tests__/ban-conditions.test.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ mock.module('@codebuff/internal/db/schema', () => ({
4242
}))
4343

4444
// Mock Stripe server
45-
const mockDisputesList = mock(() =>
45+
const mockDisputesList = mock((): Promise<{ data: any[] }> =>
4646
Promise.resolve({
4747
data: [],
4848
}),
@@ -244,7 +244,7 @@ describe('ban-conditions', () => {
244244
expect(result.shouldBan).toBe(true)
245245
})
246246

247-
it('calls Stripe API with correct time window', async () => {
247+
it('calls Stripe API with correct time window and expand parameter', async () => {
248248
mockDisputesList.mockResolvedValueOnce({ data: [] })
249249

250250
const logger = createMockLogger()
@@ -259,8 +259,10 @@ describe('ban-conditions', () => {
259259
const afterCall = Math.floor(Date.now() / 1000)
260260

261261
expect(mockDisputesList).toHaveBeenCalledTimes(1)
262-
const callArgs = mockDisputesList.mock.calls[0][0]
262+
const callArgs = (mockDisputesList.mock.calls as any)[0]?.[0]
263263
expect(callArgs.limit).toBe(100)
264+
// Verify expand parameter is set to get full charge object
265+
expect(callArgs.expand).toEqual(['data.charge'])
264266

265267
// Verify the created.gte is within the expected window
266268
const expectedWindowStart = beforeCall - DISPUTE_WINDOW_DAYS * 24 * 60 * 60
@@ -269,6 +271,30 @@ describe('ban-conditions', () => {
269271
expect(callArgs.created.gte).toBeLessThanOrEqual(expectedWindowStart + windowTolerance)
270272
})
271273

274+
// REGRESSION TEST: Without expand: ['data.charge'], dispute.charge is a string ID,
275+
// not an object, so dispute.charge.customer is undefined and no disputes match.
276+
// This test ensures we always expand the charge object.
277+
it('REGRESSION: must expand data.charge to access customer field', async () => {
278+
mockDisputesList.mockResolvedValueOnce({ data: [] })
279+
280+
const logger = createMockLogger()
281+
const context: BanConditionContext = {
282+
userId: 'user-123',
283+
stripeCustomerId: 'cus_123',
284+
logger,
285+
}
286+
287+
await evaluateBanConditions(context)
288+
289+
const callArgs = (mockDisputesList.mock.calls as any)[0]?.[0]
290+
291+
// This is critical: without expand, dispute.charge is just a string ID like "ch_xxx"
292+
// and we cannot access dispute.charge.customer to filter by customer.
293+
// If this test fails, the ban condition will NEVER match any disputes.
294+
expect(callArgs.expand).toBeDefined()
295+
expect(callArgs.expand).toContain('data.charge')
296+
})
297+
272298
it('logs debug message after checking condition', async () => {
273299
mockDisputesList.mockResolvedValueOnce({ data: [] })
274300

web/src/lib/ban-conditions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async function disputeThresholdCondition(
5454
const disputes = await stripeServer.disputes.list({
5555
limit: 100,
5656
created: { gte: windowStart },
57+
expand: ['data.charge'],
5758
})
5859

5960
// Filter to only this customer's disputes

0 commit comments

Comments
 (0)