Skip to content

Commit 9184aa2

Browse files
committed
Web routes to cancel, change tier, create subscription, or get subscription info
1 parent 76f71c4 commit 9184aa2

File tree

9 files changed

+454
-3
lines changed

9 files changed

+454
-3
lines changed

common/src/constants/analytics-events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export enum AnalyticsEvent {
3838
SUBSCRIPTION_BLOCK_LIMIT_HIT = 'backend.subscription_block_limit_hit',
3939
SUBSCRIPTION_WEEKLY_LIMIT_HIT = 'backend.subscription_weekly_limit_hit',
4040
SUBSCRIPTION_CREDITS_MIGRATED = 'backend.subscription_credits_migrated',
41+
SUBSCRIPTION_TIER_CHANGED = 'backend.subscription_tier_changed',
4142

4243
// Web
4344
SIGNUP = 'web.signup',

packages/billing/src/subscription-webhooks.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ function getTierFromPriceId(priceId: string): SubscriptionTierPrice | null {
3737
return priceToTier[priceId] ?? null
3838
}
3939

40+
const tierToPrice = Object.fromEntries(
41+
Object.entries(priceToTier).map(([priceId, tier]) => [tier, priceId]),
42+
) as Partial<Record<SubscriptionTierPrice, string>>
43+
44+
export function getTierPriceId(tier: SubscriptionTierPrice): string | null {
45+
return tierToPrice[tier] ?? null
46+
}
47+
4048
// ---------------------------------------------------------------------------
4149
// invoice.paid
4250
// ---------------------------------------------------------------------------

packages/billing/src/subscription.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,41 @@ export async function checkRateLimit(params: {
485485
}
486486
}
487487

488+
// ---------------------------------------------------------------------------
489+
// Block grant expiration
490+
// ---------------------------------------------------------------------------
491+
492+
export async function expireActiveBlockGrants(params: {
493+
userId: string
494+
subscriptionId: string
495+
logger: Logger
496+
}): Promise<number> {
497+
const { userId, subscriptionId, logger } = params
498+
const now = new Date()
499+
500+
const expired = await db
501+
.update(schema.creditLedger)
502+
.set({ balance: 0, expires_at: now })
503+
.where(
504+
and(
505+
eq(schema.creditLedger.user_id, userId),
506+
eq(schema.creditLedger.type, 'subscription'),
507+
gt(schema.creditLedger.expires_at, now),
508+
gt(schema.creditLedger.balance, 0),
509+
),
510+
)
511+
.returning({ operation_id: schema.creditLedger.operation_id })
512+
513+
if (expired.length > 0) {
514+
logger.info(
515+
{ userId, subscriptionId, expiredCount: expired.length },
516+
'Expired active block grants for tier change',
517+
)
518+
}
519+
520+
return expired.length
521+
}
522+
488523
// ---------------------------------------------------------------------------
489524
// Subscription lookup
490525
// ---------------------------------------------------------------------------

packages/billing/src/usage-service.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,24 @@ import {
99
calculateOrganizationUsageAndBalance,
1010
syncOrganizationBillingCycle,
1111
} from './org-billing'
12+
import { getActiveSubscription } from './subscription'
1213

1314
import type { CreditBalance } from './balance-calculator'
1415
import type { Logger } from '@codebuff/common/types/contracts/logger'
1516

17+
export interface SubscriptionInfo {
18+
status: string
19+
billingPeriodEnd: string
20+
cancelAtPeriodEnd: boolean
21+
}
22+
1623
export interface UserUsageData {
1724
usageThisCycle: number
1825
balance: CreditBalance
1926
nextQuotaReset: string
2027
autoTopupTriggered?: boolean
2128
autoTopupEnabled?: boolean
29+
subscription?: SubscriptionInfo
2230
}
2331

2432
export interface OrganizationUsageData {
@@ -79,12 +87,24 @@ export async function getUserUsageData(params: {
7987
isPersonalContext: true, // isPersonalContext: true to exclude organization credits
8088
})
8189

90+
// Check for active subscription
91+
let subscription: SubscriptionInfo | undefined
92+
const activeSub = await getActiveSubscription({ userId, logger })
93+
if (activeSub) {
94+
subscription = {
95+
status: activeSub.status,
96+
billingPeriodEnd: activeSub.billing_period_end.toISOString(),
97+
cancelAtPeriodEnd: activeSub.cancel_at_period_end,
98+
}
99+
}
100+
82101
return {
83102
usageThisCycle,
84103
balance,
85104
nextQuotaReset: quotaResetDate.toISOString(),
86105
autoTopupTriggered,
87106
autoTopupEnabled,
107+
subscription,
88108
}
89109
} catch (error) {
90110
logger.error({ userId, error }, 'Error fetching user usage data')

packages/internal/src/env-schema.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ export const serverEnvSchema = clientEnvSchema.extend({
2121
STRIPE_WEBHOOK_SECRET_KEY: z.string().min(1),
2222
STRIPE_USAGE_PRICE_ID: z.string().min(1),
2323
STRIPE_TEAM_FEE_PRICE_ID: z.string().min(1),
24-
STRIPE_SUBSCRIPTION_100_PRICE_ID: z.string().min(1).optional(),
25-
STRIPE_SUBSCRIPTION_200_PRICE_ID: z.string().min(1).optional(),
26-
STRIPE_SUBSCRIPTION_500_PRICE_ID: z.string().min(1).optional(),
24+
STRIPE_SUBSCRIPTION_100_PRICE_ID: z.string().min(1),
25+
STRIPE_SUBSCRIPTION_200_PRICE_ID: z.string().min(1),
26+
STRIPE_SUBSCRIPTION_500_PRICE_ID: z.string().min(1),
2727
LOOPS_API_KEY: z.string().min(1),
2828
DISCORD_PUBLIC_KEY: z.string().min(1),
2929
DISCORD_BOT_TOKEN: z.string().min(1),
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { getActiveSubscription } from '@codebuff/billing'
2+
import db from '@codebuff/internal/db'
3+
import * as schema from '@codebuff/internal/db/schema'
4+
import { stripeServer } from '@codebuff/internal/util/stripe'
5+
import { eq } from 'drizzle-orm'
6+
import { NextResponse } from 'next/server'
7+
import { getServerSession } from 'next-auth'
8+
9+
import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
10+
import { logger } from '@/util/logger'
11+
12+
export async function POST() {
13+
const session = await getServerSession(authOptions)
14+
if (!session?.user?.id) {
15+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
16+
}
17+
18+
const userId = session.user.id
19+
20+
const subscription = await getActiveSubscription({ userId, logger })
21+
if (!subscription) {
22+
return NextResponse.json(
23+
{ error: 'No active subscription found.' },
24+
{ status: 404 },
25+
)
26+
}
27+
28+
try {
29+
await stripeServer.subscriptions.update(
30+
subscription.stripe_subscription_id,
31+
{ cancel_at_period_end: true },
32+
)
33+
34+
await db
35+
.update(schema.subscription)
36+
.set({ cancel_at_period_end: true, updated_at: new Date() })
37+
.where(
38+
eq(
39+
schema.subscription.stripe_subscription_id,
40+
subscription.stripe_subscription_id,
41+
),
42+
)
43+
44+
logger.info(
45+
{ userId, subscriptionId: subscription.stripe_subscription_id },
46+
'Subscription set to cancel at period end',
47+
)
48+
49+
return NextResponse.json({ success: true })
50+
} catch (error: unknown) {
51+
const message =
52+
(error as { raw?: { message?: string } })?.raw?.message ||
53+
'Internal server error canceling subscription.'
54+
logger.error(
55+
{ error: message, userId, subscriptionId: subscription.stripe_subscription_id },
56+
'Failed to cancel subscription',
57+
)
58+
return NextResponse.json({ error: message }, { status: 500 })
59+
}
60+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import {
2+
expireActiveBlockGrants,
3+
getActiveSubscription,
4+
getTierPriceId,
5+
} from '@codebuff/billing'
6+
import { trackEvent } from '@codebuff/common/analytics'
7+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
8+
import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans'
9+
import db from '@codebuff/internal/db'
10+
import * as schema from '@codebuff/internal/db/schema'
11+
import { stripeServer } from '@codebuff/internal/util/stripe'
12+
import { eq } from 'drizzle-orm'
13+
import { NextResponse } from 'next/server'
14+
import { getServerSession } from 'next-auth'
15+
16+
import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans'
17+
import type { NextRequest } from 'next/server'
18+
19+
import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
20+
import { logger } from '@/util/logger'
21+
22+
export async function POST(req: NextRequest) {
23+
const session = await getServerSession(authOptions)
24+
if (!session?.user?.id) {
25+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
26+
}
27+
28+
const userId = session.user.id
29+
30+
const user = await db.query.user.findFirst({
31+
where: eq(schema.user.id, userId),
32+
columns: { banned: true },
33+
})
34+
35+
if (user?.banned) {
36+
logger.warn({ userId }, 'Banned user attempted to change subscription tier')
37+
return NextResponse.json(
38+
{ error: 'Your account has been suspended. Please contact support.' },
39+
{ status: 403 },
40+
)
41+
}
42+
43+
const body = await req.json().catch(() => null)
44+
const rawTier = Number(body?.tier)
45+
if (!rawTier || !(rawTier in SUBSCRIPTION_TIERS)) {
46+
return NextResponse.json(
47+
{ error: 'Invalid tier. Must be 100, 200, or 500.' },
48+
{ status: 400 },
49+
)
50+
}
51+
const tier = rawTier as SubscriptionTierPrice
52+
53+
const subscription = await getActiveSubscription({ userId, logger })
54+
if (!subscription) {
55+
return NextResponse.json(
56+
{ error: 'No active subscription found.' },
57+
{ status: 404 },
58+
)
59+
}
60+
61+
const previousTier = subscription.tier
62+
if (previousTier === tier) {
63+
return NextResponse.json(
64+
{ error: 'Already on the requested tier.' },
65+
{ status: 400 },
66+
)
67+
}
68+
69+
const newPriceId = getTierPriceId(tier)
70+
if (!newPriceId) {
71+
return NextResponse.json(
72+
{ error: 'Subscription tier not available' },
73+
{ status: 503 },
74+
)
75+
}
76+
77+
try {
78+
const stripeSub = await stripeServer.subscriptions.retrieve(
79+
subscription.stripe_subscription_id,
80+
)
81+
const itemId = stripeSub.items.data[0]?.id
82+
if (!itemId) {
83+
logger.error(
84+
{ userId, subscriptionId: subscription.stripe_subscription_id },
85+
'Stripe subscription has no items',
86+
)
87+
return NextResponse.json(
88+
{ error: 'Subscription configuration error.' },
89+
{ status: 500 },
90+
)
91+
}
92+
93+
await stripeServer.subscriptions.update(
94+
subscription.stripe_subscription_id,
95+
{
96+
items: [{ id: itemId, price: newPriceId }],
97+
proration_behavior: 'create_prorations',
98+
},
99+
)
100+
101+
try {
102+
await Promise.all([
103+
db
104+
.update(schema.subscription)
105+
.set({ tier, stripe_price_id: newPriceId, updated_at: new Date() })
106+
.where(
107+
eq(
108+
schema.subscription.stripe_subscription_id,
109+
subscription.stripe_subscription_id,
110+
),
111+
),
112+
expireActiveBlockGrants({
113+
userId,
114+
subscriptionId: subscription.stripe_subscription_id,
115+
logger,
116+
}),
117+
])
118+
} catch (dbError) {
119+
logger.error(
120+
{ error: dbError, userId, subscriptionId: subscription.stripe_subscription_id },
121+
'DB update failed after Stripe tier change — webhook will reconcile',
122+
)
123+
}
124+
125+
trackEvent({
126+
event: AnalyticsEvent.SUBSCRIPTION_TIER_CHANGED,
127+
userId,
128+
properties: {
129+
subscriptionId: subscription.stripe_subscription_id,
130+
previousTier,
131+
newTier: tier,
132+
},
133+
logger,
134+
})
135+
136+
logger.info(
137+
{
138+
userId,
139+
subscriptionId: subscription.stripe_subscription_id,
140+
previousTier,
141+
newTier: tier,
142+
},
143+
'Subscription tier changed',
144+
)
145+
146+
return NextResponse.json({ success: true, previousTier, newTier: tier })
147+
} catch (error: unknown) {
148+
const message = error instanceof Error
149+
? error.message
150+
: 'Internal server error changing subscription tier.'
151+
logger.error(
152+
{
153+
error,
154+
userId,
155+
subscriptionId: subscription.stripe_subscription_id,
156+
},
157+
'Failed to change subscription tier',
158+
)
159+
return NextResponse.json({ error: message }, { status: 500 })
160+
}
161+
}

0 commit comments

Comments
 (0)