mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
/billing/current-subscription answers in two shapes. A subscriber gets the
Subscription document, which always carries a status because the schema
requires one. A user with no subscription gets a synthesised
{ plan, isActive, usage } with no status, no amount and no dates.
The page read that second shape as a subscription with missing fields, so
every free user was told their subscription status was "Unknown" and shown
two "N/A" billing dates. Absence of a status is not an unknown status.
lib/billing.ts interprets the payload instead of guessing at it. A paid plan
arriving without a status is still genuinely unknown and is still reported
that way, so the earlier fix for fabricated "Active" is preserved.
The redesign splits one flat card into plan identity and usage. The old
inner panels were bg-card with shadow-sm sitting on a bg-card parent, so the
nesting was invisible; they are bordered muted panels now. Free accounts
drop the status pill, the always-N/A dates and the portal link they have
nothing to manage with.
CTAs are Buttons rather than hand-rolled Links with background utilities,
and the upgrade target comes from position on the plan ladder, so Scale
subscribers are no longer sold a tier above Scale and bespoke plans are not
pushed onto the self-serve ladder at all. Added a "Compare all plans" link
to textbee.dev/pricing, matching the label the onboarding plan picker
already uses for the same destination.
Also corrected the Pro fixture from 1900 to 999 cents, a price we do not
charge, and gave the portal link noopener noreferrer.
Verified: build clean, 0 lint errors (21 warnings, unchanged), 153 unit
tests (from 121), 78 e2e (from 75). The three free-account guards and the
pricing link guard were each confirmed to fail against the pre-change
component before being trusted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
2.8 KiB
TypeScript
78 lines
2.8 KiB
TypeScript
import type { Subscription, SubscriptionStatus } from '@/lib/api/types'
|
|
import { PLAN_TIERS, findPlanTier, type PlanTier } from '@/lib/plans'
|
|
|
|
export type BillingState = {
|
|
/** Display name for the current plan. Always populated. */
|
|
planName: string
|
|
/** The matching marketing tier, when the plan is one of ours. */
|
|
tier: PlanTier | undefined
|
|
/** True when the user has no subscription record at all. */
|
|
isFree: boolean
|
|
/** Present only for a real subscription. */
|
|
status: SubscriptionStatus | undefined
|
|
/** The tier to sell next, or undefined at the top of the ladder. */
|
|
upgradeTier: PlanTier | undefined
|
|
/** Whether a start/renewal date exists worth rendering. */
|
|
hasBillingDates: boolean
|
|
/** Whether the customer portal applies to this account. */
|
|
canManageBilling: boolean
|
|
}
|
|
|
|
/**
|
|
* Interpret a /billing/current-subscription payload.
|
|
*
|
|
* The endpoint answers in two different shapes. A subscriber gets the
|
|
* Subscription document, which always carries a `status` because the schema
|
|
* requires one. A user with no subscription gets a synthesised
|
|
* `{ plan, isActive, usage }` with no status, no amount and no dates.
|
|
*
|
|
* The billing page used to read that second shape as a subscription with
|
|
* missing fields, so every free user was told their status was "Unknown" and
|
|
* shown two "N/A" billing dates. Absence of a status is not an unknown status:
|
|
* it means there is nothing to have a status.
|
|
*
|
|
* A paid plan name arriving without a status is still genuinely unknown, and
|
|
* is reported that way rather than being flattened into Free.
|
|
*/
|
|
export function deriveBillingState(
|
|
subscription: Subscription | undefined
|
|
): BillingState {
|
|
const rawName = subscription?.plan?.name
|
|
const normalized = rawName?.trim().toLowerCase()
|
|
const tier = findPlanTier(rawName)
|
|
const status = subscription?.status
|
|
|
|
const isFree = !status && (!normalized || normalized === 'free')
|
|
|
|
// An unrecognised plan name (a bespoke or enterprise arrangement) has no
|
|
// place on the self-serve ladder, so it gets no upgrade CTA.
|
|
const currentIndex = tier ? PLAN_TIERS.indexOf(tier) : isFree ? 0 : -1
|
|
const upgradeTier =
|
|
currentIndex >= 0 ? PLAN_TIERS[currentIndex + 1] : undefined
|
|
|
|
return {
|
|
planName: tier?.name ?? rawName?.trim() ?? 'Free',
|
|
tier,
|
|
isFree,
|
|
status,
|
|
upgradeTier,
|
|
hasBillingDates: Boolean(
|
|
subscription?.subscriptionStartDate || subscription?.currentPeriodEnd
|
|
),
|
|
canManageBilling: !isFree,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Price badge text for the plan header, or null when there is no charge to
|
|
* show. Free accounts carry no amount, and rendering "Free" next to a plan
|
|
* already named Free just says the same thing twice.
|
|
*/
|
|
export function billingPriceLabel(
|
|
subscription: Subscription | undefined
|
|
): number | null {
|
|
const amount = subscription?.amount
|
|
if (amount == null || amount <= 0) return null
|
|
return amount
|
|
}
|