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'