Skip to content

Commit 164abc5

Browse files
committed
feat: add dedicated billing-portal endpoint for on-demand portal URL generation
1 parent c226108 commit 164abc5

File tree

2 files changed

+81
-9
lines changed

2 files changed

+81
-9
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { env } from '@codebuff/internal/env'
2+
import { stripeServer } from '@codebuff/internal/util/stripe'
3+
import { NextResponse } from 'next/server'
4+
import { getServerSession } from 'next-auth'
5+
6+
import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
7+
import { logger } from '@/util/logger'
8+
9+
export async function POST() {
10+
const session = await getServerSession(authOptions)
11+
if (!session?.user?.id) {
12+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13+
}
14+
15+
const stripeCustomerId = session.user.stripe_customer_id
16+
if (!stripeCustomerId) {
17+
return NextResponse.json(
18+
{ error: 'No Stripe customer ID found' },
19+
{ status: 400 }
20+
)
21+
}
22+
23+
try {
24+
const portalSession = await stripeServer.billingPortal.sessions.create({
25+
customer: stripeCustomerId,
26+
return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`,
27+
})
28+
29+
return NextResponse.json({ url: portalSession.url })
30+
} catch (error) {
31+
logger.error(
32+
{ userId: session.user.id, error },
33+
'Failed to create billing portal session'
34+
)
35+
return NextResponse.json(
36+
{ error: 'Failed to create billing portal session' },
37+
{ status: 500 }
38+
)
39+
}
40+
}

web/src/app/profile/components/subscription-section.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,33 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse;
5555
const { subscription, rateLimit, fallbackToALaCarte } = data
5656
const isCanceling = subscription.cancelAtPeriodEnd
5757
const fallbackPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}`
58-
const billingPortalUrl = data.billingPortalUrl ?? fallbackPortalUrl
5958
const queryClient = useQueryClient()
6059

60+
const billingPortalMutation = useMutation({
61+
mutationFn: async () => {
62+
const res = await fetch('/api/user/billing-portal', {
63+
method: 'POST',
64+
})
65+
if (!res.ok) {
66+
const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' }))
67+
throw new Error(error.error || 'Failed to open billing portal')
68+
}
69+
const data = await res.json()
70+
return data.url as string
71+
},
72+
onSuccess: (url) => {
73+
window.open(url, '_blank', 'noopener,noreferrer')
74+
},
75+
onError: (err: Error) => {
76+
// Fall back to the prefilled email portal URL on error
77+
window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer')
78+
toast({
79+
title: 'Note',
80+
description: 'Opened billing portal - you may need to sign in.',
81+
})
82+
},
83+
})
84+
6185
const updatePreferenceMutation = useMutation({
6286
mutationFn: async (newValue: boolean) => {
6387
const res = await fetch('/api/user/preferences', {
@@ -116,15 +140,23 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse;
116140
</span>
117141
)}
118142
</CardTitle>
119-
<a
120-
href={billingPortalUrl}
121-
target="_blank"
122-
rel="noopener noreferrer"
123-
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
143+
<button
144+
onClick={() => billingPortalMutation.mutate()}
145+
disabled={billingPortalMutation.isPending}
146+
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 disabled:opacity-50"
124147
>
125-
Manage
126-
<ExternalLink className="h-3.5 w-3.5" />
127-
</a>
148+
{billingPortalMutation.isPending ? (
149+
<>
150+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
151+
Opening...
152+
</>
153+
) : (
154+
<>
155+
Manage
156+
<ExternalLink className="h-3.5 w-3.5" />
157+
</>
158+
)}
159+
</button>
128160
</div>
129161
</CardHeader>
130162
<CardContent className="space-y-5">

0 commit comments

Comments
 (0)