Skip to content

Commit 5617bac

Browse files
committed
feat: use on-demand billing portal fetch for all Billing Portal buttons
1 parent 71c2641 commit 5617bac

File tree

6 files changed

+202
-50
lines changed

6 files changed

+202
-50
lines changed

common/src/types/subscription.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export interface ActiveSubscriptionResponse {
5454
subscription: SubscriptionInfo
5555
rateLimit: SubscriptionRateLimit
5656
limits: SubscriptionLimits
57-
billingPortalUrl?: string
57+
5858
/** Whether user prefers to fallback to a-la-carte credits when subscription limits are reached */
5959
fallbackToALaCarte: boolean
6060
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import db from '@codebuff/internal/db'
2+
import * as schema from '@codebuff/internal/db/schema'
3+
import { env } from '@codebuff/internal/env'
4+
import { stripeServer } from '@codebuff/internal/util/stripe'
5+
import { eq, and } from 'drizzle-orm'
6+
import { NextResponse } from 'next/server'
7+
import { getServerSession } from 'next-auth'
8+
9+
import type { NextRequest } from 'next/server'
10+
11+
import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
12+
import { ORG_BILLING_ENABLED } from '@/lib/billing-config'
13+
import { logger } from '@/util/logger'
14+
15+
interface RouteParams {
16+
params: Promise<{
17+
orgId: string
18+
}>
19+
}
20+
21+
export async function POST(req: NextRequest, { params }: RouteParams) {
22+
if (!ORG_BILLING_ENABLED) {
23+
return NextResponse.json(
24+
{ error: 'Organization billing is temporarily disabled' },
25+
{ status: 503 }
26+
)
27+
}
28+
29+
const session = await getServerSession(authOptions)
30+
if (!session?.user?.id) {
31+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
32+
}
33+
34+
const { orgId } = await params
35+
36+
try {
37+
// Check if user has access to this organization
38+
const membership = await db
39+
.select({
40+
role: schema.orgMember.role,
41+
organization: schema.org,
42+
})
43+
.from(schema.orgMember)
44+
.innerJoin(schema.org, eq(schema.orgMember.org_id, schema.org.id))
45+
.where(
46+
and(
47+
eq(schema.orgMember.org_id, orgId),
48+
eq(schema.orgMember.user_id, session.user.id),
49+
),
50+
)
51+
.limit(1)
52+
53+
if (membership.length === 0) {
54+
return NextResponse.json(
55+
{ error: 'Organization not found' },
56+
{ status: 404 },
57+
)
58+
}
59+
60+
const { role, organization } = membership[0]
61+
62+
// Check if user has permission to access billing
63+
if (role !== 'owner' && role !== 'admin') {
64+
return NextResponse.json(
65+
{ error: 'Insufficient permissions' },
66+
{ status: 403 },
67+
)
68+
}
69+
70+
if (!organization.stripe_customer_id) {
71+
return NextResponse.json(
72+
{ error: 'No Stripe customer ID found for organization' },
73+
{ status: 400 },
74+
)
75+
}
76+
77+
const portalSession = await stripeServer.billingPortal.sessions.create({
78+
customer: organization.stripe_customer_id,
79+
return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${organization.slug}/settings`,
80+
})
81+
82+
return NextResponse.json({ url: portalSession.url })
83+
} catch (error) {
84+
logger.error(
85+
{ userId: session.user.id, orgId, error },
86+
'Failed to create org billing portal session',
87+
)
88+
return NextResponse.json(
89+
{ error: 'Failed to create billing portal session' },
90+
{ status: 500 },
91+
)
92+
}
93+
}

web/src/app/api/orgs/[orgId]/billing/status/route.ts

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import db from '@codebuff/internal/db'
22
import * as schema from '@codebuff/internal/db/schema'
3-
import { env } from '@codebuff/internal/env'
43
import { stripeServer } from '@codebuff/internal/util/stripe'
54
import { eq, and, sql } from 'drizzle-orm'
65
import { NextResponse } from 'next/server'
@@ -74,32 +73,21 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
7473

7574
// Get subscription details if it exists
7675
let subscriptionDetails = null
77-
let billingPortalUrl = null
7876

79-
if (organization.stripe_customer_id) {
77+
if (organization.stripe_customer_id && organization.stripe_subscription_id) {
8078
try {
81-
// Create billing portal session
82-
const portalSession = await stripeServer.billingPortal.sessions.create({
83-
customer: organization.stripe_customer_id,
84-
return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${organization.slug}/settings`,
85-
})
86-
billingPortalUrl = portalSession.url
87-
88-
// Get subscription details if subscription exists
89-
if (organization.stripe_subscription_id) {
90-
const subscription = await stripeServer.subscriptions.retrieve(
91-
organization.stripe_subscription_id,
92-
)
93-
94-
subscriptionDetails = {
95-
status: subscription.status,
96-
current_period_start: subscription.current_period_start,
97-
current_period_end: subscription.current_period_end,
98-
cancel_at_period_end: subscription.cancel_at_period_end,
99-
}
79+
const subscription = await stripeServer.subscriptions.retrieve(
80+
organization.stripe_subscription_id,
81+
)
82+
83+
subscriptionDetails = {
84+
status: subscription.status,
85+
current_period_start: subscription.current_period_start,
86+
current_period_end: subscription.current_period_end,
87+
cancel_at_period_end: subscription.cancel_at_period_end,
10088
}
10189
} catch (error) {
102-
logger.warn({ orgId, error }, 'Failed to get Stripe billing details')
90+
logger.warn({ orgId, error }, 'Failed to get Stripe subscription details')
10391
}
10492
}
10593

@@ -112,7 +100,6 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
112100
totalMonthlyCost: seatCount * pricePerSeat,
113101
hasActiveSubscription: !!organization.stripe_subscription_id,
114102
subscriptionDetails,
115-
billingPortalUrl,
116103
organization: {
117104
id: organization.id,
118105
name: organization.name,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { toast } from '@/components/ui/use-toast'
1616

1717
const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => {
1818
const { data: session } = useSession()
19-
const email = encodeURIComponent(session?.user?.email || '')
19+
const email = session?.user?.email || ''
2020
const queryClient = useQueryClient()
2121
const [showConfetti, setShowConfetti] = useState(false)
2222
const [purchasedAmount, setPurchasedAmount] = useState(0)
@@ -84,7 +84,7 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => {
8484
isPurchasePending={buyCreditsMutation.isPending}
8585
showAutoTopup={true}
8686
isLoading={isLoading}
87-
billingPortalUrl={`${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${email}`}
87+
email={email}
8888
/>
8989
</div>
9090
</CardContent>

web/src/components/credits/CreditManagementSection.tsx

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
import { env } from '@codebuff/common/env'
2+
import { useMutation } from '@tanstack/react-query'
3+
import { ExternalLink, Loader2 } from 'lucide-react'
4+
15
import { CreditManagementSkeleton } from './CreditManagementSkeleton'
26
import { CreditPurchaseSection } from './CreditPurchaseSection'
37

48
import { AutoTopupSettings } from '@/components/auto-topup/AutoTopupSettings'
59
import { OrgAutoTopupSettings } from '@/components/auto-topup/OrgAutoTopupSettings'
10+
import { Button } from '@/components/ui/button'
11+
import { toast } from '@/components/ui/use-toast'
612

713
export interface CreditManagementSectionProps {
814
onPurchase: (credits: number) => void
@@ -13,7 +19,7 @@ export interface CreditManagementSectionProps {
1319
organizationId?: string
1420
isOrganization?: boolean // Keep for backward compatibility
1521
isLoading?: boolean
16-
billingPortalUrl?: string
22+
email?: string
1723
}
1824

1925
export { CreditManagementSkeleton }
@@ -27,11 +33,40 @@ export function CreditManagementSection({
2733
organizationId,
2834
isOrganization = false,
2935
isLoading = false,
30-
billingPortalUrl,
36+
email,
3137
}: CreditManagementSectionProps) {
3238
// Determine if we're in organization context
3339
const isOrgContext = context === 'organization' || isOrganization
3440

41+
const fallbackPortalUrl = email
42+
? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}`
43+
: env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL
44+
45+
const billingPortalMutation = useMutation({
46+
mutationFn: async () => {
47+
const res = await fetch('/api/user/billing-portal', {
48+
method: 'POST',
49+
})
50+
if (!res.ok) {
51+
const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' }))
52+
throw new Error(error.error || 'Failed to open billing portal')
53+
}
54+
const data = await res.json()
55+
return data.url as string
56+
},
57+
onSuccess: (url) => {
58+
window.open(url, '_blank', 'noopener,noreferrer')
59+
},
60+
onError: () => {
61+
// Fall back to the prefilled email portal URL on error
62+
window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer')
63+
toast({
64+
title: 'Note',
65+
description: 'Opened billing portal - you may need to sign in.',
66+
})
67+
},
68+
})
69+
3570
if (isLoading) {
3671
return <CreditManagementSkeleton />
3772
}
@@ -41,15 +76,24 @@ export function CreditManagementSection({
4176
<div className="space-y-8">
4277
<div className="flex items-center justify-between">
4378
<h3 className="text-2xl font-bold">Buy Credits</h3>
44-
{billingPortalUrl && (
45-
<a
46-
href={billingPortalUrl}
47-
target="_blank"
48-
rel="noopener noreferrer"
49-
className="text-sm text-primary underline underline-offset-4 hover:text-primary/90"
79+
{/* Only show billing portal button for user context - orgs have their own button */}
80+
{!isOrgContext && (
81+
<Button
82+
variant="link"
83+
size="sm"
84+
onClick={() => billingPortalMutation.mutate()}
85+
disabled={billingPortalMutation.isPending}
86+
className="text-sm text-primary underline underline-offset-4 hover:text-primary/90 p-0 h-auto"
5087
>
51-
Billing Portal →
52-
</a>
88+
{billingPortalMutation.isPending ? (
89+
<>
90+
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
91+
Opening...
92+
</>
93+
) : (
94+
<>Billing Portal <ExternalLink className="ml-1 h-3 w-3" /></>
95+
)}
96+
</Button>
5397
)}
5498
</div>
5599
<CreditPurchaseSection

web/src/components/organization/billing-status.tsx

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
'use client'
22

33
import { pluralize } from '@codebuff/common/util/string'
4-
import { useQuery } from '@tanstack/react-query'
4+
import { useQuery, useMutation } from '@tanstack/react-query'
55
import {
66
CreditCard,
77
Users,
88
ExternalLink,
99
AlertTriangle,
1010
CheckCircle,
11+
Loader2,
1112
} from 'lucide-react'
1213

1314

1415
import { Badge } from '@/components/ui/badge'
1516
import { Button } from '@/components/ui/button'
1617
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
1718
import { Skeleton } from '@/components/ui/skeleton'
19+
import { toast } from '@/components/ui/use-toast'
1820
import { useIsMobile } from '@/hooks/use-mobile'
1921
import { cn } from '@/lib/utils'
2022

@@ -29,7 +31,6 @@ interface BillingStatus {
2931
current_period_end: number
3032
cancel_at_period_end: boolean
3133
}
32-
billingPortalUrl?: string
3334
organization: {
3435
id: string
3536
name: string
@@ -58,6 +59,30 @@ export function BillingStatus({
5859
}: BillingStatusProps) {
5960
const isMobile = useIsMobile()
6061

62+
const billingPortalMutation = useMutation({
63+
mutationFn: async () => {
64+
const res = await fetch(`/api/orgs/${organizationId}/billing/portal`, {
65+
method: 'POST',
66+
})
67+
if (!res.ok) {
68+
const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' }))
69+
throw new Error(error.error || 'Failed to open billing portal')
70+
}
71+
const data = await res.json()
72+
return data.url as string
73+
},
74+
onSuccess: (url) => {
75+
window.open(url, '_blank', 'noopener,noreferrer')
76+
},
77+
onError: (err: Error) => {
78+
toast({
79+
title: 'Error',
80+
description: err.message || 'Failed to open billing portal',
81+
variant: 'destructive',
82+
})
83+
},
84+
})
85+
6186
const {
6287
data: billingStatus,
6388
isLoading,
@@ -233,23 +258,26 @@ export function BillingStatus({
233258
</div>
234259

235260
{/* Billing Portal Link */}
236-
{billingStatus.billingPortalUrl && (
261+
{billingStatus.organization && (
237262
<div className="flex flex-col sm:flex-row gap-2">
238263
<Button
239-
asChild
240264
variant="outline"
241265
size={isMobile ? 'sm' : 'default'}
242266
className="w-full sm:w-auto"
267+
onClick={() => billingPortalMutation.mutate()}
268+
disabled={billingPortalMutation.isPending}
243269
>
244-
<a
245-
href={billingStatus.billingPortalUrl}
246-
target="_blank"
247-
rel="noopener noreferrer"
248-
className="flex items-center justify-center"
249-
>
250-
<ExternalLink className="mr-2 h-4 w-4" />
251-
Manage Billing
252-
</a>
270+
{billingPortalMutation.isPending ? (
271+
<>
272+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
273+
Opening...
274+
</>
275+
) : (
276+
<>
277+
<ExternalLink className="mr-2 h-4 w-4" />
278+
Manage Billing
279+
</>
280+
)}
253281
</Button>
254282
</div>
255283
)}

0 commit comments

Comments
 (0)