Skip to content

Commit b9b53b4

Browse files
committed
feat(billing): add auto-ban system for users with excessive disputes
- Add extensible ban-conditions module with dispute threshold checking - Handle charge.dispute.created webhook to evaluate ban conditions - Send friendly email to non-banned users offering to help resolve disputes - Update ban message to be clear and helpful (403 instead of cryptic 503) - Add unit tests for ban-conditions module - Fix SDK type issue exposed by User type changes
1 parent e6b7a87 commit b9b53b4

File tree

6 files changed

+691
-9
lines changed

6 files changed

+691
-9
lines changed

packages/internal/src/loops/client.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,40 @@ export async function sendBasicEmail(
199199
},
200200
})
201201
}
202+
203+
/**
204+
* Send a notification email when a user files a dispute.
205+
* Offers to work with them to resolve the issue.
206+
*/
207+
export async function sendDisputeNotificationEmail(params: {
208+
email: string
209+
firstName: string
210+
disputeAmount: string
211+
logger: Logger
212+
}): Promise<SendEmailResult> {
213+
const { email, firstName, disputeAmount, logger } = params
214+
215+
const subject = "We noticed a dispute on your account - let's resolve this together"
216+
const message = `Hi ${firstName},
217+
218+
We noticed that a dispute was filed for a ${disputeAmount} charge on your Codebuff account. We're sorry to hear you had an issue and we'd love the opportunity to make things right.
219+
220+
If there was a problem with your experience or a charge you didn't recognize, please reach out to us directly and we'll be happy to:
221+
222+
• Issue a full refund if appropriate
223+
• Help resolve any technical issues you experienced
224+
• Answer any questions about your billing
225+
226+
Working with us directly is often faster than going through your bank, and it helps us improve our service for everyone.
227+
228+
Please reply to this email or contact our support team - we're here to help!
229+
230+
Best regards,
231+
The Codebuff Team`
232+
233+
return sendBasicEmail({
234+
email,
235+
data: { subject, message },
236+
logger,
237+
})
238+
}

web/src/app/api/stripe/webhook/route.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ import {
66
import db from '@codebuff/internal/db'
77
import * as schema from '@codebuff/internal/db/schema'
88
import { env } from '@codebuff/internal/env'
9+
import { sendDisputeNotificationEmail } from '@codebuff/internal/loops'
910
import { stripeServer } from '@codebuff/internal/util/stripe'
1011
import { eq } from 'drizzle-orm'
1112
import { NextResponse } from 'next/server'
1213

1314
import type { NextRequest } from 'next/server'
1415
import type Stripe from 'stripe'
1516

17+
import {
18+
banUser,
19+
evaluateBanConditions,
20+
getUserByStripeCustomerId,
21+
} from '@/lib/ban-conditions'
1622
import { getStripeCustomerId } from '@/lib/stripe-utils'
1723
import { logger } from '@/util/logger'
1824

@@ -357,6 +363,111 @@ const webhookHandler = async (req: NextRequest): Promise<NextResponse> => {
357363
await handleSubscriptionEvent(event.data.object as Stripe.Subscription)
358364
break
359365
}
366+
case 'charge.dispute.created': {
367+
const dispute = event.data.object as Stripe.Dispute
368+
const chargeId =
369+
typeof dispute.charge === 'string'
370+
? dispute.charge
371+
: dispute.charge?.id
372+
373+
if (!chargeId) {
374+
logger.warn(
375+
{ disputeId: dispute.id },
376+
'Dispute received without charge ID',
377+
)
378+
break
379+
}
380+
381+
// Get the charge to find the customer
382+
const charge = await stripeServer.charges.retrieve(chargeId)
383+
if (!charge.customer) {
384+
logger.warn(
385+
{ disputeId: dispute.id, chargeId },
386+
'Dispute charge has no customer (guest payment)',
387+
)
388+
break
389+
}
390+
391+
const customerId = getStripeCustomerId(
392+
charge.customer as string | Stripe.Customer | Stripe.DeletedCustomer,
393+
)
394+
395+
if (!customerId) {
396+
logger.warn(
397+
{ disputeId: dispute.id, chargeId },
398+
'Dispute charge has no customer',
399+
)
400+
break
401+
}
402+
403+
// Look up the user
404+
const user = await getUserByStripeCustomerId(customerId)
405+
if (!user) {
406+
logger.info(
407+
{ disputeId: dispute.id, customerId },
408+
'Dispute received for unknown customer (may be an organization)',
409+
)
410+
break
411+
}
412+
413+
// Skip if already banned
414+
if (user.banned) {
415+
logger.debug(
416+
{ disputeId: dispute.id, userId: user.id },
417+
'Dispute received for already-banned user, skipping evaluation',
418+
)
419+
break
420+
}
421+
422+
// Evaluate ban conditions
423+
const banResult = await evaluateBanConditions({
424+
userId: user.id,
425+
stripeCustomerId: customerId,
426+
logger,
427+
})
428+
429+
if (banResult.shouldBan) {
430+
await banUser(user.id, banResult.reason, logger)
431+
logger.warn(
432+
{
433+
disputeId: dispute.id,
434+
userId: user.id,
435+
customerId,
436+
reason: banResult.reason,
437+
},
438+
'User auto-banned due to dispute threshold',
439+
)
440+
// Don't send email to banned users
441+
} else {
442+
// Send friendly dispute notification email to non-banned users
443+
const firstName = user.name?.split(' ')[0] || 'there'
444+
const disputeAmount = `$${(dispute.amount / 100).toFixed(2)}`
445+
const emailResult = await sendDisputeNotificationEmail({
446+
email: user.email,
447+
firstName,
448+
disputeAmount,
449+
logger,
450+
})
451+
452+
if (emailResult.success) {
453+
logger.info(
454+
{ disputeId: dispute.id, userId: user.id, email: user.email },
455+
'Sent dispute notification email to user',
456+
)
457+
} else {
458+
logger.warn(
459+
{
460+
disputeId: dispute.id,
461+
userId: user.id,
462+
email: user.email,
463+
error: emailResult.error,
464+
},
465+
'Failed to send dispute notification email',
466+
)
467+
}
468+
}
469+
break
470+
}
360471
case 'charge.refunded': {
361472
const charge = event.data.object as Stripe.Charge
362473
// Get the payment intent ID from the charge

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,8 @@ describe('/api/v1/chat/completions POST endpoint', () => {
354354
})
355355
})
356356

357-
describe('Blocked users', () => {
358-
it('returns 503 with cryptic error for blocked user IDs', async () => {
357+
describe('Banned users', () => {
358+
it('returns 403 with clear message for banned users', async () => {
359359
const req = new NextRequest(
360360
'http://localhost:3000/api/v1/chat/completions',
361361
{
@@ -380,11 +380,12 @@ describe('/api/v1/chat/completions POST endpoint', () => {
380380
loggerWithContext: mockLoggerWithContext,
381381
})
382382

383-
expect(response.status).toBe(503)
383+
expect(response.status).toBe(403)
384384
const body = await response.json()
385385
expect(body).toEqual({
386-
error: 'upstream_timeout',
387-
message: 'Overloaded. Request could not be processed',
386+
error: 'account_suspended',
387+
message:
388+
'Your account has been suspended due to billing issues. Please contact support@codebuff.com to resolve this.',
388389
})
389390
})
390391
})

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,14 +149,20 @@ export async function postChatCompletions(params: {
149149

150150
const userId = userInfo.id
151151

152-
// Check if user is banned. Return fake overloaded error to avoid revealing the block.
152+
// Check if user is banned.
153+
// We use a clear, helpful message rather than a cryptic error because:
154+
// 1. Legitimate users banned by mistake deserve to know what's happening
155+
// 2. Bad actors will figure out they're banned regardless of the message
156+
// 3. Clear messaging encourages resolution (matches our dispute notification email)
157+
// 4. 403 Forbidden is the correct HTTP status for "you're not allowed"
153158
if (userInfo.banned) {
154159
return NextResponse.json(
155160
{
156-
error: 'upstream_timeout',
157-
message: 'Overloaded. Request could not be processed',
161+
error: 'account_suspended',
162+
message:
163+
'Your account has been suspended due to billing issues. Please contact support@codebuff.com to resolve this.',
158164
},
159-
{ status: 503 },
165+
{ status: 403 },
160166
)
161167
}
162168

0 commit comments

Comments
 (0)