mirror of
https://github.com/vernu/textbee.git
synced 2026-06-15 11:10:13 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ApiEndpoints = {
|
||||
billing: {
|
||||
currentSubscription: () => '/billing/current-subscription',
|
||||
checkout: () => '/billing/checkout',
|
||||
changePlan: () => '/billing/change-plan',
|
||||
plans: () => '/billing/plans',
|
||||
},
|
||||
support: {
|
||||
|
||||
Reference in New Issue
Block a user