Files
textbee/web/lib/plans.ts
isra el 0bd5d4c900 fix(billing): send the plan name from route params under Next 16
/checkout/pro and /checkout/scale failed with "Plan cannot be purchased"
for every user since the Next 16 upgrade (ea1b00c).

The checkout page still typed `params` as a plain object and read
`params.planName` synchronously. Next 16 removed the sync compat shim, so
the value was undefined, axios dropped the key, and the API ran
findOne({ name: undefined }). Mongoose 9 returns null for that where
older versions stripped the key, so the guard threw a message about a
misconfigured plan for a request that never named one. Plan data and
Polar product ids were correct throughout.

- unwrap params with React's use() and drop the `as string` cast that
  suppressed the type error
- split the conflated guard into PLAN_NAME_REQUIRED, PLAN_NOT_FOUND and
  the existing unpurchasable case, each logged server side
- stop retrying a rejected checkout twice: a 400 is not transient
- mark the Polar product id indexes sparse, they have been failing to
  build with E11000 on every boot because most plans leave them unset

Checkout now defaults to yearly. A URL that names an interval still
redirects straight through, so the marketing funnel is unchanged; the
in-app CTAs that name none get an interval chooser with yearly
preselected instead of a silent monthly default.

Also redesigns the checkout states, which were the only place in the
dashboard hardcoding gray-100/white and so rendering a light slab in dark
mode, and the plan picker, which painted the accent on every tier.

Tests: a checkout e2e spec asserting the request body carries planName,
which is what would have caught this, plus API cases pinning the three
guard failures apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:47:17 +03:00

128 lines
4.0 KiB
TypeScript

/**
* Plan definitions for the dashboard, mirroring the marketing site's pricing
* section (textbee-marketing pricing-section.tsx).
*
* Deliberately static rather than fetched. The billing plans endpoint returns
* raw Plan documents with limits and cents, not the customer-facing copy, and
* an environment whose plans collection is empty left the onboarding step
* showing "plans could not be loaded" with nothing to choose. Pricing is
* marketing copy, so it comes from the same place the pricing page does.
*
* If the marketing pricing changes, change it here too. plans.test.ts pins the
* values so a silent drift shows up as a failing test rather than a wrong
* price in front of a customer.
*/
export type PlanTier = {
/** Matches the checkout route segment: /checkout/{id}. */
id: string
name: string
description: string
monthlyPrice: number
yearlyPrice?: number
features: string[]
/** The tier the picker highlights. */
isPopular?: boolean
}
export const PLAN_TIERS: PlanTier[] = [
{
id: 'free',
name: 'Free',
description: 'Get started with basic SMS gateway features',
monthlyPrice: 0,
features: [
'Send and receive SMS Messages',
'Register 1 active device',
'Max 50 messages per day',
'Up to 300 messages per month',
'Up to 50 recipients in bulk',
'Webhook notifications',
'Basic support',
],
},
{
id: 'pro',
name: 'Pro',
description: 'For growing projects that send every day',
monthlyPrice: 9.99,
yearlyPrice: 99.99,
isPopular: true,
features: [
'Everything in Free plan',
'Register up to 5 active devices',
'Unlimited daily messages',
'Up to 5,000 messages per month',
'No bulk SMS recipient limits',
'Message templates with variables',
'Priority support',
],
},
{
id: 'scale',
name: 'Scale',
description: 'For higher volume and more devices',
monthlyPrice: 29.99,
yearlyPrice: 299.99,
features: [
'Everything in Pro plan',
'Register up to 15 active devices',
'Unlimited daily messages',
'Up to 25,000 messages per month',
'No bulk SMS recipient limits',
'Message templates with variables',
'Priority support',
],
},
]
/**
* Prices are dollar amounts here, not the cents the API deals in.
*
* Free renders as "$0" rather than "Free" so it does not simply repeat the
* tier name directly above it, matching how the pricing page reads.
*/
export function formatPlanPrice(price: number): string {
if (price <= 0) return '$0'
return `$${price.toFixed(2)}`
}
export function findPlanTier(name: string | undefined | null) {
if (!name) return undefined
const key = name.trim().toLowerCase()
return PLAN_TIERS.find((tier) => tier.id === key)
}
/**
* What a yearly plan works out to per month, so the saving is legible without
* making the reader divide. Derived rather than stored: a hardcoded figure
* silently goes wrong the moment a price changes.
*/
export function monthlyEquivalent(tier: PlanTier): number | undefined {
if (!tier.yearlyPrice) return undefined
return tier.yearlyPrice / 12
}
/** Percentage saved by paying yearly, rounded to a whole number. */
export function yearlySavingPercent(tier: PlanTier): number | undefined {
if (!tier.yearlyPrice || tier.monthlyPrice <= 0) return undefined
const yearOfMonthly = tier.monthlyPrice * 12
return Math.round(((yearOfMonthly - tier.yearlyPrice) / yearOfMonthly) * 100)
}
/**
* The caption under a headline per-month figure, e.g. "billed yearly at
* $99.99, or $9.99 monthly". Assumes the per-month figure is already shown
* above it, so it does not repeat it.
*/
export function formatPriceCaption(tier: PlanTier): string {
const perMonth = monthlyEquivalent(tier)
if (perMonth !== undefined && tier.yearlyPrice !== undefined) {
return `billed yearly at ${formatPlanPrice(tier.yearlyPrice)}, or ${formatPlanPrice(
tier.monthlyPrice,
)} monthly`
}
if (tier.monthlyPrice <= 0) return 'no card required'
return `${formatPlanPrice(tier.monthlyPrice)} billed monthly`
}