diff --git a/web/app/(app)/dashboard/(components)/subscription-info.tsx b/web/app/(app)/dashboard/(components)/subscription-info.tsx index 535ba4a..fe0bcfe 100644 --- a/web/app/(app)/dashboard/(components)/subscription-info.tsx +++ b/web/app/(app)/dashboard/(components)/subscription-info.tsx @@ -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 ( +
+ + + {/* Never assert "Active" for a payload that carried no status. */} + {titleCaseStatus(status) || 'Unknown'} + +
+ ) +} + 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() { )} -
- - - {titleCaseStatus(currentSubscription?.status) || 'Active'} - -
+
diff --git a/web/lib/format.test.ts b/web/lib/format.test.ts index f136845..af4e33a 100644 --- a/web/lib/format.test.ts +++ b/web/lib/format.test.ts @@ -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') }) }) diff --git a/web/lib/format.ts b/web/lib/format.ts index 76ee7d7..dfb9c8d 100644 --- a/web/lib/format.ts +++ b/web/lib/format.ts @@ -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) } diff --git a/web/lib/status.test.ts b/web/lib/status.test.ts new file mode 100644 index 0000000..4dcad57 --- /dev/null +++ b/web/lib/status.test.ts @@ -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') + }) +}) diff --git a/web/lib/status.ts b/web/lib/status.ts index 209b0bb..0e50860 100644 --- a/web/lib/status.ts +++ b/web/lib/status.ts @@ -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'