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>
123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { billingPriceLabel, deriveBillingState } from './billing'
|
|
|
|
// The exact shape billing.service.ts synthesises when findOne turns up no
|
|
// subscription: a plan and usage, no status, no amount, no dates.
|
|
const freePayload = {
|
|
plan: { name: 'free', dailyLimit: 50, monthlyLimit: 300 },
|
|
isActive: true,
|
|
usage: { processedSmsToday: 3 },
|
|
}
|
|
|
|
const proPayload = {
|
|
plan: { name: 'Pro' },
|
|
status: 'active',
|
|
amount: 999,
|
|
currency: 'usd',
|
|
recurringInterval: 'month',
|
|
subscriptionStartDate: '2026-06-01T00:00:00.000Z',
|
|
currentPeriodEnd: '2026-08-01T00:00:00.000Z',
|
|
}
|
|
|
|
describe('deriveBillingState', () => {
|
|
// The defect this helper exists for: a free user was shown "Unknown".
|
|
it('reads the no-subscription payload as Free, not as unknown', () => {
|
|
const state = deriveBillingState(freePayload)
|
|
|
|
expect(state.isFree).toBe(true)
|
|
expect(state.status).toBeUndefined()
|
|
expect(state.planName).toBe('Free')
|
|
})
|
|
|
|
it('treats a missing subscription entirely as Free', () => {
|
|
expect(deriveBillingState(undefined).isFree).toBe(true)
|
|
expect(deriveBillingState({}).isFree).toBe(true)
|
|
})
|
|
|
|
// Absence of a status means "no subscription". A status that is absent from
|
|
// a payload that clearly IS a paid plan is a different thing, and must not
|
|
// be quietly downgraded to Free.
|
|
it('does not flatten a paid plan with a missing status into Free', () => {
|
|
const state = deriveBillingState({ plan: { name: 'Pro' } })
|
|
|
|
expect(state.isFree).toBe(false)
|
|
expect(state.planName).toBe('Pro')
|
|
})
|
|
|
|
it('reads a real subscription without changing its status', () => {
|
|
const state = deriveBillingState(proPayload)
|
|
|
|
expect(state.isFree).toBe(false)
|
|
expect(state.status).toBe('active')
|
|
expect(state.planName).toBe('Pro')
|
|
})
|
|
|
|
it('uses the marketing casing rather than whatever the DB stored', () => {
|
|
expect(deriveBillingState(freePayload).planName).toBe('Free')
|
|
expect(deriveBillingState({ plan: { name: ' SCALE ' } }).planName).toBe(
|
|
'Scale'
|
|
)
|
|
})
|
|
|
|
describe('upgradeTier', () => {
|
|
it('sells Pro to a free user', () => {
|
|
expect(deriveBillingState(freePayload).upgradeTier?.id).toBe('pro')
|
|
})
|
|
|
|
it('sells Scale to a Pro user', () => {
|
|
expect(deriveBillingState(proPayload).upgradeTier?.id).toBe('scale')
|
|
})
|
|
|
|
it('sells nothing at the top of the ladder', () => {
|
|
const state = deriveBillingState({
|
|
plan: { name: 'Scale' },
|
|
status: 'active',
|
|
})
|
|
expect(state.upgradeTier).toBeUndefined()
|
|
})
|
|
|
|
// A bespoke arrangement is not on the self-serve ladder, so pushing it up
|
|
// one rung would be wrong in both directions.
|
|
it('sells nothing for an unrecognised plan', () => {
|
|
const state = deriveBillingState({
|
|
plan: { name: 'Enterprise' },
|
|
status: 'active',
|
|
})
|
|
expect(state.upgradeTier).toBeUndefined()
|
|
expect(state.planName).toBe('Enterprise')
|
|
})
|
|
})
|
|
|
|
describe('hasBillingDates', () => {
|
|
it('is false for a free account, which has no dates to show', () => {
|
|
expect(deriveBillingState(freePayload).hasBillingDates).toBe(false)
|
|
})
|
|
|
|
it('is true once a subscription carries a date', () => {
|
|
expect(deriveBillingState(proPayload).hasBillingDates).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('canManageBilling', () => {
|
|
it('is false for a free account with nothing to manage', () => {
|
|
expect(deriveBillingState(freePayload).canManageBilling).toBe(false)
|
|
})
|
|
|
|
it('is true for a subscriber', () => {
|
|
expect(deriveBillingState(proPayload).canManageBilling).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('billingPriceLabel', () => {
|
|
it('returns the amount for a paying customer', () => {
|
|
expect(billingPriceLabel(proPayload)).toBe(999)
|
|
})
|
|
|
|
it('returns nothing when there is no charge', () => {
|
|
expect(billingPriceLabel(freePayload)).toBeNull()
|
|
expect(billingPriceLabel({ amount: 0 })).toBeNull()
|
|
expect(billingPriceLabel(undefined)).toBeNull()
|
|
})
|
|
})
|