feat(billing): update existing Polar subscription on plan change instead of creating a new checkout

A paid subscriber upgrading (pro -> scale) or downgrading (scale -> pro)
previously got a brand-new Polar checkout, ending up with two live Polar
subscriptions and double billing. Now an active paid subscription is
updated in place via Polar's subscription update API.

- store polarSubscriptionId/polarCustomerId/cancelAtPeriodEnd on
  subscriptions (recovered via externalCustomerId for legacy records)
- POST /billing/checkout returns a planChange preview for paid users;
  new POST /billing/change-plan executes it (uncancels a scheduled
  cancellation first, org-default proration, idempotent with webhooks)
- allow monthly<->yearly interval switches; keep ALREADY_ON_PLAN only
  for same plan + same interval; block custom plans (CONTACT_BILLING)
- map Polar 402/403/409 errors to actionable messages; run plan-change
  detection before cached checkout-session reuse
- checkout page shows a confirmation screen before applying the change;
  account page shows a success toast

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-06-12 20:09:00 +03:00
parent c846279d41
commit 164124d616
7 changed files with 495 additions and 36 deletions

View File

@@ -3,6 +3,8 @@ import { BillingService } from './billing.service'
import { AuthGuard } from 'src/auth/guards/auth.guard'
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'
import {
ChangePlanInputDTO,
ChangePlanResponseDTO,
CheckoutInputDTO,
CheckoutResponseDTO,
PlansResponseDTO,
@@ -48,6 +50,18 @@ export class BillingController {
})
}
@Post('change-plan')
@UseGuards(AuthGuard)
async changePlan(
@Body() payload: ChangePlanInputDTO,
@Request() req: any,
): Promise<ChangePlanResponseDTO> {
return this.billingService.changePlan({
user: req.user,
payload,
})
}
@Post('webhook/polar')
async handlePolarWebhook(@Body() data: any, @Request() req: any) {
const payload = await this.billingService.validatePolarWebhookPayload(
@@ -66,7 +80,8 @@ export class BillingController {
console.log('polar webhook event', payload.type)
console.log(payload)
await this.billingService.switchPlan({
userId: payload.data?.metadata?.userId as string,
userId: (payload.data?.metadata?.userId ||
payload.data?.customer?.externalId) as string,
newPlanPolarProductId: payload.data?.product?.id,
currentPeriodStart: payload.data?.currentPeriodStart,
currentPeriodEnd: payload.data?.currentPeriodEnd,
@@ -76,6 +91,9 @@ export class BillingController {
amount: payload.data?.amount,
currency: payload.data?.currency,
recurringInterval: payload.data?.recurringInterval,
polarSubscriptionId: payload.data?.id,
polarCustomerId: payload.data?.customerId,
cancelAtPeriodEnd: payload.data?.cancelAtPeriodEnd,
})
break
@@ -87,7 +105,8 @@ export class BillingController {
console.log('polar webhook event', payload.type)
console.log(payload)
await this.billingService.cancelSubscription({
userId: payload.data?.metadata?.userId as string,
userId: (payload.data?.metadata?.userId ||
payload.data?.customer?.externalId) as string,
polarProductId: payload.data?.product?.id,
})
break

View File

@@ -36,7 +36,48 @@ export class CheckoutInputDTO {
billingInterval?: 'monthly' | 'yearly'
}
export class CheckoutResponseDTO {
export class PlanChangePreviewDTO {
@ApiProperty({ type: String })
redirectUrl: string
currentPlan: string
@ApiProperty({ enum: ['monthly', 'yearly'] })
currentInterval: string
@ApiProperty({ type: String })
newPlan: string
@ApiProperty({ enum: ['monthly', 'yearly'] })
newInterval: string
@ApiProperty({ type: Boolean })
isUpgrade: boolean
@ApiProperty({ type: Boolean })
cancelAtPeriodEnd: boolean
}
export class CheckoutResponseDTO {
@ApiProperty({ type: String, required: false })
redirectUrl?: string
// returned instead of redirectUrl when the user already has an active paid
// Polar subscription, so the frontend shows a confirmation screen
@ApiProperty({ type: PlanChangePreviewDTO, required: false })
planChange?: PlanChangePreviewDTO
}
export class ChangePlanInputDTO {
@ApiProperty({ type: String, required: true })
planName: string
@ApiProperty({ enum: ['monthly', 'yearly'], required: false })
billingInterval?: 'monthly' | 'yearly'
}
export class ChangePlanResponseDTO {
@ApiProperty({ type: Boolean })
success: boolean
@ApiProperty({ type: String })
plan: string
}

View File

@@ -251,6 +251,37 @@ export class BillingService {
const billingInterval =
payload.billingInterval === 'yearly' ? 'yearly' : 'monthly'
// A user with an active paid Polar subscription must not get a new
// checkout (it would create a second subscription and double-bill them);
// their existing Polar subscription gets updated instead, after the
// frontend shows a confirmation screen
const planChange = await this.resolvePlanChange({
user,
planName: payload.planName,
billingInterval,
})
if (planChange.isPlanChange) {
const currentPlan = planChange.currentSubscription.plan as Plan
return {
planChange: {
currentPlan: currentPlan.name,
currentInterval:
this.normalizeBillingInterval(
planChange.currentSubscription.recurringInterval,
) ?? 'monthly',
newPlan: planChange.selectedPlan.name,
newInterval: billingInterval,
isUpgrade:
(planChange.selectedPlan.monthlyPrice ?? 0) >
(currentPlan.monthlyPrice ?? 0),
cancelAtPeriodEnd: !!planChange.polarSubscription.cancelAtPeriodEnd,
},
}
}
const selectedPlan = planChange.selectedPlan
const existingCheckoutSession = await this.checkoutSessionModel.findOne({
user: user._id,
expiresAt: { $gt: new Date() },
@@ -268,25 +299,6 @@ export class BillingService {
return { redirectUrl: existingCheckoutSession.checkoutUrl }
}
const selectedPlan = await this.planModel.findOne({
name: payload.planName,
})
const currentSubscription = await this.getCurrentSubscription(user)
if ((currentSubscription?.plan as Plan)?.name === payload.planName) {
throw new BadRequestException({
message: `You are already on ${payload.planName} plan, please contact billing@textbee.dev to get a custom plan`,
code: 'ALREADY_ON_PLAN',
})
}
if (
!selectedPlan?.polarMonthlyProductId &&
!selectedPlan?.polarYearlyProductId
) {
throw new BadRequestException('Plan cannot be purchased')
}
// const product = await this.polarApi.products.get(selectedPlan.polarProductId)
const discountId =
@@ -358,6 +370,261 @@ export class BillingService {
}
}
// Polar reports recurringInterval as 'month'/'year', checkout requests use
// 'monthly'/'yearly'
private normalizeBillingInterval(
interval?: string,
): 'monthly' | 'yearly' | undefined {
if (interval === 'month' || interval === 'monthly') return 'monthly'
if (interval === 'year' || interval === 'yearly') return 'yearly'
return undefined
}
// Decides whether a checkout request is actually a plan change on an
// existing paid Polar subscription. Throws for requests that are valid in
// neither path (same plan+interval, custom plans, payment issues).
private async resolvePlanChange({
user,
planName,
billingInterval,
}: {
user: any
planName: string
billingInterval: 'monthly' | 'yearly'
}): Promise<{
selectedPlan: PlanDocument
isPlanChange: boolean
currentSubscription?: SubscriptionDocument
polarSubscription?: any
targetProductId?: string
}> {
const selectedPlan = await this.planModel.findOne({ name: planName })
if (
!selectedPlan?.polarMonthlyProductId &&
!selectedPlan?.polarYearlyProductId
) {
throw new BadRequestException('Plan cannot be purchased')
}
const currentSubscription = await this.subscriptionModel
.findOne({ user: user._id, isActive: true })
.populate('plan')
const currentPlan = currentSubscription?.plan as Plan | undefined
const currentInterval = this.normalizeBillingInterval(
currentSubscription?.recurringInterval,
)
if (currentPlan?.name?.startsWith('custom')) {
throw new BadRequestException({
message:
'You are on a custom plan, please contact billing@textbee.dev to change your plan',
code: 'CONTACT_BILLING',
})
}
// Same plan with a different billing interval is a valid change
// (e.g. pro monthly -> pro yearly), each interval is a separate product
if (
currentPlan?.name === planName &&
(!currentInterval || currentInterval === billingInterval)
) {
throw new BadRequestException({
message: `You are already on ${planName} plan, please contact billing@textbee.dev to get a custom plan`,
code: 'ALREADY_ON_PLAN',
})
}
if (!currentPlan || currentPlan.name === 'free') {
return { selectedPlan, isPlanChange: false, currentSubscription }
}
let polarSubscription = null
if (currentSubscription.polarSubscriptionId) {
try {
polarSubscription = await this.polarApi.subscriptions.get({
id: currentSubscription.polarSubscriptionId,
})
} catch (error) {
console.error('failed to fetch polar subscription by stored id', error)
}
}
// Older subscriptions predate storing polarSubscriptionId; checkouts have
// always set externalCustomerId to the user id, so recover it from there
if (!polarSubscription || polarSubscription.status === 'canceled') {
try {
const page = await this.polarApi.subscriptions.list({
externalCustomerId: user._id.toString(),
active: true,
limit: 1,
})
polarSubscription = page?.result?.items?.[0] ?? null
if (polarSubscription) {
this.subscriptionModel
.updateOne(
{ _id: currentSubscription._id },
{
polarSubscriptionId: polarSubscription.id,
polarCustomerId: polarSubscription.customerId,
},
)
.catch((error) => {
console.error(error)
})
}
} catch (error) {
console.error('failed to list polar subscriptions', error)
}
}
if (!polarSubscription) {
// Paid subscription with no Polar record (e.g. manually granted):
// a regular checkout is the correct path for these users
console.warn(
`No active polar subscription found for user ${user._id} on paid plan ${currentPlan.name}, falling back to checkout`,
)
return { selectedPlan, isPlanChange: false, currentSubscription }
}
const targetProductId =
billingInterval === 'yearly'
? selectedPlan.polarYearlyProductId
: selectedPlan.polarMonthlyProductId
if (!targetProductId) {
throw new BadRequestException(
`Plan ${planName} cannot be purchased with ${billingInterval} billing`,
)
}
// Catches drift between our DB and Polar
if (polarSubscription.productId === targetProductId) {
throw new BadRequestException({
message: `You are already on ${planName} plan, please contact billing@textbee.dev to get a custom plan`,
code: 'ALREADY_ON_PLAN',
})
}
if (['past_due', 'incomplete', 'unpaid'].includes(polarSubscription.status)) {
throw new BadRequestException({
message:
'Your subscription has a payment issue. Please update your payment method in the customer portal before changing plans.',
code: 'PAYMENT_ISSUE',
})
}
return {
selectedPlan,
isPlanChange: true,
currentSubscription,
polarSubscription,
targetProductId,
}
}
async changePlan({ user, payload }: { user: any; payload: any }) {
const billingInterval =
payload.billingInterval === 'yearly' ? 'yearly' : 'monthly'
const { isPlanChange, selectedPlan, polarSubscription, targetProductId } =
await this.resolvePlanChange({
user,
planName: payload.planName,
billingInterval,
})
if (!isPlanChange) {
throw new BadRequestException({
message:
'No active paid subscription found to change, please use the checkout instead',
code: 'NO_ACTIVE_SUBSCRIPTION',
})
}
try {
// A product update on a subscription scheduled for cancellation is
// rejected by Polar; changing plans clearly signals intent to stay
if (polarSubscription.cancelAtPeriodEnd) {
await this.polarApi.subscriptions.update({
id: polarSubscription.id,
subscriptionUpdate: { cancelAtPeriodEnd: false },
})
}
// prorationBehavior omitted on purpose: use the Polar org default
const updated = await this.polarApi.subscriptions.update({
id: polarSubscription.id,
subscriptionUpdate: { productId: targetProductId },
})
// Update local state right away so the dashboard reflects the change;
// the subscription.updated webhook that follows is an idempotent no-op
await this.switchPlan({
userId: user._id.toString(),
newPlanPolarProductId: updated.productId ?? targetProductId,
currentPeriodStart: updated.currentPeriodStart,
currentPeriodEnd: updated.currentPeriodEnd,
subscriptionStartDate: updated.startedAt ?? updated.createdAt,
subscriptionEndDate: updated.canceledAt,
status: updated.status,
amount: updated.amount,
currency: updated.currency,
recurringInterval: updated.recurringInterval,
polarSubscriptionId: updated.id,
polarCustomerId: updated.customerId,
cancelAtPeriodEnd: updated.cancelAtPeriodEnd,
})
// An open cached checkout for the old plan must not be reusable anymore
this.checkoutSessionModel
.updateOne(
{ user: user._id, isCompleted: { $ne: true } },
{ isAbandoned: true },
)
.catch((error) => {
console.error(error)
})
return { success: true, plan: selectedPlan.name }
} catch (error) {
if (error instanceof HttpException) {
throw error
}
console.error('failed to change plan', error)
const statusCode = error?.statusCode
if (statusCode === 402) {
throw new BadRequestException({
message:
'The prorated charge failed. Please update your payment method in the customer portal and try again.',
code: 'PAYMENT_ISSUE',
})
}
if (statusCode === 403) {
throw new BadRequestException({
message:
'Your subscription is canceled or ending and cannot be changed. Please resume it in the customer portal or contact billing@textbee.dev.',
code: 'SUBSCRIPTION_ENDING',
})
}
if (statusCode === 409) {
throw new BadRequestException({
message:
'A plan change is already in progress for your subscription. Please try again later or contact billing@textbee.dev.',
code: 'PENDING_UPDATE',
})
}
throw new BadRequestException({
message:
'Failed to change plan, please try again or contact billing@textbee.dev',
code: 'PLAN_CHANGE_FAILED',
})
}
}
async getActiveSubscription(userId: string) {
const user = await this.userModel.findById(new Types.ObjectId(userId))
const plans = await this.planModel.find()
@@ -491,6 +758,9 @@ export class BillingService {
amount,
currency,
recurringInterval,
polarSubscriptionId,
polarCustomerId,
cancelAtPeriodEnd,
}: {
userId: string
newPlanName?: string
@@ -504,6 +774,9 @@ export class BillingService {
amount?: number
currency?: string
recurringInterval?: string
polarSubscriptionId?: string
polarCustomerId?: string
cancelAtPeriodEnd?: boolean
}) {
console.log(`Switching plan for user: ${userId}`)
@@ -548,6 +821,9 @@ export class BillingService {
amount,
currency,
recurringInterval,
polarSubscriptionId,
polarCustomerId,
cancelAtPeriodEnd,
},
{ upsert: true },
)

View File

@@ -23,8 +23,14 @@ export class Subscription {
@Prop({ type: Types.ObjectId, ref: Plan.name, required: true })
plan: Plan | Types.ObjectId
// @Prop()
// polarSubscriptionId?: string
@Prop({ type: String, index: true })
polarSubscriptionId?: string
@Prop({ type: String })
polarCustomerId?: string
@Prop({ type: Boolean })
cancelAtPeriodEnd?: boolean
@Prop({ type: String })
recurringInterval?: string

View File

@@ -2,33 +2,57 @@
import { useState, useEffect, useCallback } from 'react'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useSession } from 'next-auth/react'
import { redirect } from 'next/navigation'
import { Loader, CheckCircle } from 'lucide-react'
import { Loader, CheckCircle, ArrowRight } from 'lucide-react'
interface PlanChangePreview {
currentPlan: string
currentInterval: string
newPlan: string
newInterval: string
isUpgrade: boolean
cancelAtPeriodEnd: boolean
}
const formatPlan = (plan: string, interval: string) =>
`${plan.charAt(0).toUpperCase() + plan.slice(1)} (${interval})`
export default function CheckoutPage({ params }) {
const [error, setError] = useState<string | null>(null)
const [planChange, setPlanChange] = useState<PlanChangePreview | null>(null)
const [isConfirming, setIsConfirming] = useState(false)
const planName = params.planName as string
const { data: session } = useSession()
const getBillingInterval = () => {
// marketing site and pricing pages link here with ?billingInterval=monthly|yearly
// (legacy ?billing= fallback until the marketing site redeploys)
const searchParams = new URLSearchParams(window.location.search)
const billingInterval =
searchParams.get('billingInterval') ?? searchParams.get('billing')
return billingInterval === 'yearly' ? 'yearly' : 'monthly'
}
const initiateCheckout = useCallback(
async (retries = 2) => {
try {
// marketing site and pricing pages link here with ?billingInterval=monthly|yearly
// (legacy ?billing= fallback until the marketing site redeploys)
const searchParams = new URLSearchParams(window.location.search)
const billingInterval =
searchParams.get('billingInterval') ?? searchParams.get('billing')
const response = await httpBrowserClient.post('/billing/checkout', {
planName,
billingInterval: billingInterval === 'yearly' ? 'yearly' : 'monthly',
})
const response = await httpBrowserClient.post(
ApiEndpoints.billing.checkout(),
{
planName,
billingInterval: getBillingInterval(),
},
)
if (response.data?.redirectUrl) {
window.location.href = response.data?.redirectUrl
} else if (response.data?.planChange) {
// user already has a paid subscription: confirm before updating it
setPlanChange(response.data.planChange)
} else {
throw new Error('No redirect URL found')
}
@@ -44,6 +68,26 @@ export default function CheckoutPage({ params }) {
[planName]
)
const confirmPlanChange = async () => {
setIsConfirming(true)
try {
await httpBrowserClient.post(ApiEndpoints.billing.changePlan(), {
planName,
billingInterval: getBillingInterval(),
})
window.location.href = '/dashboard/account?plan-change-success=1'
} catch (error) {
// no auto-retry here: the request may have charged the card
setPlanChange(null)
setError(
error.response?.data?.message ||
'Failed to change your plan. Please try again or contact billing@textbee.dev.',
)
console.error(error.response?.data?.message)
setIsConfirming(false)
}
}
useEffect(() => {
initiateCheckout()
}, [initiateCheckout])
@@ -69,6 +113,57 @@ export default function CheckoutPage({ params }) {
)
}
if (planChange) {
return (
<div className='flex flex-col items-center justify-center min-h-[80vh] p-6'>
<div className='max-w-md w-full bg-white rounded-lg shadow-lg p-8'>
<h2 className='text-2xl font-bold text-gray-800 mb-4'>
Confirm your plan change
</h2>
<div className='flex items-center justify-center gap-3 mb-6 text-lg font-semibold text-gray-700'>
<span>
{formatPlan(planChange.currentPlan, planChange.currentInterval)}
</span>
<ArrowRight className='text-brand-500' size={20} />
<span>{formatPlan(planChange.newPlan, planChange.newInterval)}</span>
</div>
<p className='text-gray-600 mb-2'>
The change takes effect immediately. The price difference for the
remainder of your billing period is prorated by our payment
provider
{planChange.isUpgrade
? ' and may be charged to your payment method right away.'
: ' and credited towards your upcoming invoices.'}
</p>
{planChange.cancelAtPeriodEnd && (
<p className='text-amber-600 mb-2'>
Your subscription is currently scheduled to cancel at the end of
the billing period. Changing your plan will remove the scheduled
cancellation.
</p>
)}
<div className='flex gap-3 mt-6'>
<button
onClick={confirmPlanChange}
disabled={isConfirming}
className='flex-1 px-4 py-2 bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-60 flex items-center justify-center gap-2'
>
{isConfirming && <Loader className='animate-spin' size={16} />}
{isConfirming ? 'Updating...' : 'Confirm change'}
</button>
<button
onClick={() => (window.location.href = '/dashboard/account')}
disabled={isConfirming}
className='flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-60'
>
Cancel
</button>
</div>
</div>
</div>
)
}
return (
<div className='flex flex-col items-center justify-center min-h-[80vh] bg-gray-100 p-6 rounded-lg shadow-lg'>
<Loader className='animate-spin mb-4 text-brand-500' size={48} />

View File

@@ -1,5 +1,6 @@
'use client'
import { useEffect } from 'react'
import { Calendar, Check, Info } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
@@ -14,8 +15,28 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useToast } from '@/hooks/use-toast'
export default function SubscriptionInfo() {
const { toast } = useToast()
// the checkout page redirects here after an in-place plan change
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search)
if (searchParams.get('plan-change-success')) {
toast({
title: 'Plan updated',
description: 'Your subscription has been updated to the new plan.',
})
searchParams.delete('plan-change-success')
const query = searchParams.toString()
window.history.replaceState(
null,
'',
`${window.location.pathname}${query ? `?${query}` : ''}`,
)
}
}, [toast])
const {
data: currentSubscription,
isLoading: isLoadingSubscription,

View File

@@ -42,6 +42,7 @@ export const ApiEndpoints = {
billing: {
currentSubscription: () => '/billing/current-subscription',
checkout: () => '/billing/checkout',
changePlan: () => '/billing/change-plan',
plans: () => '/billing/plans',
},
support: {