diff --git a/api/src/billing/billing.controller.ts b/api/src/billing/billing.controller.ts index e73bdac..b89c27b 100644 --- a/api/src/billing/billing.controller.ts +++ b/api/src/billing/billing.controller.ts @@ -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 { + 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 diff --git a/api/src/billing/billing.dto.ts b/api/src/billing/billing.dto.ts index 6e55fad..3d7a0c0 100644 --- a/api/src/billing/billing.dto.ts +++ b/api/src/billing/billing.dto.ts @@ -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 } diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index e6098c1..10db2d7 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -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 }, ) diff --git a/api/src/billing/schemas/subscription.schema.ts b/api/src/billing/schemas/subscription.schema.ts index dd62f11..ac766bb 100644 --- a/api/src/billing/schemas/subscription.schema.ts +++ b/api/src/billing/schemas/subscription.schema.ts @@ -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 diff --git a/web/app/(app)/checkout/[planName]/page.tsx b/web/app/(app)/checkout/[planName]/page.tsx index da2eae4..aa42862 100644 --- a/web/app/(app)/checkout/[planName]/page.tsx +++ b/web/app/(app)/checkout/[planName]/page.tsx @@ -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(null) + const [planChange, setPlanChange] = useState(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 ( +
+
+

+ Confirm your plan change +

+
+ + {formatPlan(planChange.currentPlan, planChange.currentInterval)} + + + {formatPlan(planChange.newPlan, planChange.newInterval)} +
+

+ 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.'} +

+ {planChange.cancelAtPeriodEnd && ( +

+ Your subscription is currently scheduled to cancel at the end of + the billing period. Changing your plan will remove the scheduled + cancellation. +

+ )} +
+ + +
+
+
+ ) + } + return (
diff --git a/web/app/(app)/dashboard/(components)/subscription-info.tsx b/web/app/(app)/dashboard/(components)/subscription-info.tsx index ab8ef9d..f442629 100644 --- a/web/app/(app)/dashboard/(components)/subscription-info.tsx +++ b/web/app/(app)/dashboard/(components)/subscription-info.tsx @@ -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, diff --git a/web/config/api.ts b/web/config/api.ts index d6a74e3..619684a 100644 --- a/web/config/api.ts +++ b/web/config/api.ts @@ -42,6 +42,7 @@ export const ApiEndpoints = { billing: { currentSubscription: () => '/billing/current-subscription', checkout: () => '/billing/checkout', + changePlan: () => '/billing/change-plan', plans: () => '/billing/plans', }, support: {