mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
fix: stop the billing page asserting things it does not know
The status pill hardcoded a check mark and varied only its colour, so a past_due or canceled subscriber was shown a tick beside the bad news. It also fell back to the label "Active" when the payload carried no status at all, inventing a healthy state on a billing screen. The icon now comes from the status, and an absent status reads "Unknown". formatPrice returned "Free" whenever the currency was missing, even with a real amount. Since the badge only renders when amount > 0, the one case it could produce was a paying customer being shown "Free / monthly". Default the currency instead. The page also derived its usage numbers inline instead of calling deriveUsage, which exists precisely so billing and the dashboard cannot disagree. The inline copy skipped the helper's 0-100 clamp, so the invariant was already broken. Monthly is a rolling 30-day window server-side, not a calendar month, which the dashboard already labels correctly. Billing said "this month", so a user capping out late in the month would wait for a reset that never arrives. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Calendar, Check, Info, Sparkles } from 'lucide-react'
|
||||
import { Calendar, Info, Sparkles, type LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useCurrentUser, useSubscription } from '@/lib/api'
|
||||
import type { SubscriptionStatus } from '@/lib/api/types'
|
||||
import {
|
||||
formatLimit,
|
||||
formatPrice,
|
||||
@@ -12,7 +13,12 @@ import {
|
||||
getBillingInterval,
|
||||
titleCaseStatus,
|
||||
} from '@/lib/format'
|
||||
import { subscriptionStatusTone, usageMeterColor } from '@/lib/status'
|
||||
import {
|
||||
subscriptionStatusIcon,
|
||||
subscriptionStatusTone,
|
||||
usageMeterColor,
|
||||
} from '@/lib/status'
|
||||
import { deriveUsage } from '@/lib/usage'
|
||||
import { polarCustomerPortalRequestUrl } from '@/config/external-links'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
@@ -32,6 +38,29 @@ type Meter = {
|
||||
unlimited: boolean
|
||||
}
|
||||
|
||||
// Icon arrives as a prop rather than being selected into a local, matching how
|
||||
// EmptyState and the dashboard Stat take theirs. Both the icon and the tone
|
||||
// come from the status, so the pill can no longer show a reassuring tick next
|
||||
// to bad news the way the hardcoded check mark did.
|
||||
function StatusPill({
|
||||
status,
|
||||
icon: Icon,
|
||||
}: {
|
||||
status: SubscriptionStatus | null | undefined
|
||||
icon: LucideIcon
|
||||
}) {
|
||||
const tone = subscriptionStatusTone(status)
|
||||
return (
|
||||
<div className={cn('flex items-center px-2 py-0.5 rounded-full', tone.bg)}>
|
||||
<Icon className={cn('h-3 w-3 mr-1', tone.text)} />
|
||||
<span className={cn('text-xs font-medium', tone.text)}>
|
||||
{/* Never assert "Active" for a payload that carried no status. */}
|
||||
{titleCaseStatus(status) || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type LimitTileProps = {
|
||||
label: string
|
||||
effectiveValue: number | null | undefined
|
||||
@@ -187,6 +216,12 @@ export default function SubscriptionInfo() {
|
||||
const usage = currentSubscription?.usage
|
||||
const planName = plan?.name || 'Free'
|
||||
|
||||
// Shared with the dashboard usage cards. This page used to derive the same
|
||||
// numbers inline, which is exactly what deriveUsage exists to prevent: the
|
||||
// helper clamps percentage to 0-100 and the inline copy did not, so the two
|
||||
// screens could disagree about the same quota.
|
||||
const { daily, monthly } = deriveUsage(currentSubscription)
|
||||
|
||||
const isOverridden = (
|
||||
custom: number | null | undefined,
|
||||
planValue: number | null | undefined
|
||||
@@ -205,15 +240,18 @@ export default function SubscriptionInfo() {
|
||||
tooltipUnit: 'per day',
|
||||
unlimitedNote: 'Unlimited (within monthly limit)',
|
||||
meter: {
|
||||
used: usage?.processedSmsToday ?? 0,
|
||||
remaining: usage?.dailyRemaining ?? 0,
|
||||
percentage: usage?.dailyUsagePercentage ?? 0,
|
||||
used: daily.used,
|
||||
remaining: daily.remaining,
|
||||
percentage: daily.percentage,
|
||||
usedLabel: 'used today',
|
||||
unlimited: (usage?.dailyLimit ?? plan?.dailyLimit) === -1,
|
||||
unlimited: daily.unlimited,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Monthly',
|
||||
// "Last 30 days", not "Monthly": the backend counts from setMonth(-1),
|
||||
// a rolling window. Calling it monthly led users who capped out late in
|
||||
// the month to wait for a reset on the 1st that never comes.
|
||||
label: 'Last 30 days',
|
||||
effectiveValue: usage?.monthlyLimit ?? plan?.monthlyLimit,
|
||||
planValue: plan?.monthlyLimit,
|
||||
isOverridden: isOverridden(
|
||||
@@ -221,14 +259,14 @@ export default function SubscriptionInfo() {
|
||||
plan?.monthlyLimit
|
||||
),
|
||||
planName,
|
||||
tooltipUnit: 'per month',
|
||||
tooltipUnit: 'per rolling 30 days',
|
||||
unlimitedNote: 'Unlimited (within fair usage)',
|
||||
meter: {
|
||||
used: usage?.processedSmsLastMonth ?? 0,
|
||||
remaining: usage?.monthlyRemaining ?? 0,
|
||||
percentage: usage?.monthlyUsagePercentage ?? 0,
|
||||
usedLabel: 'used this month',
|
||||
unlimited: (usage?.monthlyLimit ?? plan?.monthlyLimit) === -1,
|
||||
used: monthly.used,
|
||||
remaining: monthly.remaining,
|
||||
percentage: monthly.percentage,
|
||||
usedLabel: 'used in the last 30 days',
|
||||
unlimited: monthly.unlimited,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -297,27 +335,10 @@ export default function SubscriptionInfo() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center px-2 py-0.5 rounded-full',
|
||||
subscriptionStatusTone(currentSubscription?.status).bg
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'h-3 w-3 mr-1',
|
||||
subscriptionStatusTone(currentSubscription?.status).text
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
subscriptionStatusTone(currentSubscription?.status).text
|
||||
)}
|
||||
>
|
||||
{titleCaseStatus(currentSubscription?.status) || 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
<StatusPill
|
||||
status={currentSubscription?.status}
|
||||
icon={subscriptionStatusIcon(currentSubscription?.status)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2.5 mb-4'>
|
||||
|
||||
@@ -24,9 +24,15 @@ describe('formatPrice', () => {
|
||||
it('formats cents into a currency string', () => {
|
||||
expect(formatPrice(1900, 'usd')).toBe('$19.00')
|
||||
})
|
||||
it('returns Free when amount or currency is missing', () => {
|
||||
it('returns Free only when there is no amount', () => {
|
||||
expect(formatPrice(null, null)).toBe('Free')
|
||||
expect(formatPrice(1900, null)).toBe('Free')
|
||||
})
|
||||
|
||||
// A real amount with a missing currency used to render as "Free", which put
|
||||
// the badge "Free / monthly" in front of paying customers.
|
||||
it('falls back to USD rather than claiming a paid plan is free', () => {
|
||||
expect(formatPrice(1900, null)).toBe('$19.00')
|
||||
expect(formatPrice(1900, '')).toBe('$19.00')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,10 +13,13 @@ export function formatPrice(
|
||||
amount: number | null | undefined,
|
||||
currency: string | null | undefined
|
||||
): string {
|
||||
if (amount == null || currency == null) return 'Free'
|
||||
// Only a missing amount means free. A missing currency used to fall through
|
||||
// to "Free" as well, so a paying customer whose payload omitted the currency
|
||||
// was shown the badge "Free / monthly" on their own billing page.
|
||||
if (amount == null) return 'Free'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase() || 'USD',
|
||||
currency: currency?.toUpperCase() || 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount / 100)
|
||||
}
|
||||
|
||||
44
web/lib/status.test.ts
Normal file
44
web/lib/status.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AlertTriangle, Check, CircleHelp, XCircle } from 'lucide-react'
|
||||
import {
|
||||
subscriptionStatusIcon,
|
||||
subscriptionStatusTone,
|
||||
usageMeterColor,
|
||||
} from './status'
|
||||
|
||||
// The billing card used to hardcode a check mark next to whatever the status
|
||||
// text said, so a past_due or canceled subscriber was reassured with a tick at
|
||||
// exactly the moment they needed a warning.
|
||||
describe('subscriptionStatusIcon', () => {
|
||||
it('only shows a check for an active subscription', () => {
|
||||
expect(subscriptionStatusIcon('active')).toBe(Check)
|
||||
})
|
||||
|
||||
it('warns rather than reassures when something is wrong', () => {
|
||||
expect(subscriptionStatusIcon('past_due')).toBe(AlertTriangle)
|
||||
expect(subscriptionStatusIcon('canceled')).toBe(XCircle)
|
||||
})
|
||||
|
||||
it('does not claim health for an unknown or missing status', () => {
|
||||
expect(subscriptionStatusIcon(undefined)).toBe(CircleHelp)
|
||||
expect(subscriptionStatusIcon(null)).toBe(CircleHelp)
|
||||
expect(subscriptionStatusIcon('something_new')).toBe(CircleHelp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscriptionStatusTone', () => {
|
||||
it('keeps tone and icon agreeing about severity', () => {
|
||||
expect(subscriptionStatusTone('active').text).toContain('green')
|
||||
expect(subscriptionStatusTone('past_due').text).toContain('amber')
|
||||
expect(subscriptionStatusTone(undefined).text).toBe('text-muted-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageMeterColor', () => {
|
||||
it('escalates with usage', () => {
|
||||
expect(usageMeterColor(0)).toBe('bg-green-500')
|
||||
expect(usageMeterColor(79)).toBe('bg-green-500')
|
||||
expect(usageMeterColor(80)).toBe('bg-amber-500')
|
||||
expect(usageMeterColor(100)).toBe('bg-red-500')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleHelp,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { SubscriptionStatus } from '@/lib/api/types'
|
||||
|
||||
// Subscription status colors, previously repeated across subscription-info's
|
||||
@@ -29,6 +36,29 @@ export function subscriptionStatusTone(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon for a subscription status.
|
||||
*
|
||||
* Picked from the status for the same reason the tone is: the billing card
|
||||
* used to hardcode a check mark, so a past_due or canceled subscriber was
|
||||
* shown a tick next to the bad news, at exactly the moment they needed a
|
||||
* warning instead.
|
||||
*/
|
||||
export function subscriptionStatusIcon(
|
||||
status: SubscriptionStatus | null | undefined
|
||||
): LucideIcon {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return Check
|
||||
case 'past_due':
|
||||
return AlertTriangle
|
||||
case 'canceled':
|
||||
return XCircle
|
||||
default:
|
||||
return CircleHelp
|
||||
}
|
||||
}
|
||||
|
||||
// Usage meter color by percentage: green under 80, amber 80-99, red at 100+.
|
||||
export function usageMeterColor(percentage: number): string {
|
||||
if (percentage >= 100) return 'bg-red-500'
|
||||
|
||||
Reference in New Issue
Block a user