diff --git a/app/app/billing/page.tsx b/app/app/billing/page.tsx
index f8eff27..164c77c 100644
--- a/app/app/billing/page.tsx
+++ b/app/app/billing/page.tsx
@@ -1,7 +1,18 @@
+import Link from 'next/link';
+
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { requireBusiness } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { getPortfolioDemoBlockedCount, isPortfolioDemoMode } from '@/lib/portfolio-demo';
+import { getConversationUsageForBusiness, resolveUsageTierFromSubscription } from '@/lib/usage';
+import {
+ describeAutomationBlockReason,
+ formatUsageSummary,
+ formatUsageTierLabel,
+ resolveAutomationBlockReason,
+} from '@/lib/usage-visibility';
function planPrice(priceId: string | undefined) {
return priceId ? 'Configured via Stripe Price ID' : 'Missing env var';
@@ -13,6 +24,24 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
const proPriceId = process.env.STRIPE_PRICE_PRO;
const error = typeof searchParams?.error === 'string' ? searchParams.error : undefined;
const checkout = typeof searchParams?.checkout === 'string' ? searchParams.checkout : undefined;
+ const demoMode = isPortfolioDemoMode();
+ const [blockedCount, usage] = demoMode
+ ? [getPortfolioDemoBlockedCount(), null]
+ : await Promise.all([
+ db.lead.count({ where: { businessId: business.id, billingRequired: true } }),
+ getConversationUsageForBusiness(business),
+ ]);
+ const usageTierLabel = formatUsageTierLabel(resolveUsageTierFromSubscription(business));
+ const usageSummary = usage ? formatUsageSummary(usage) : 'Unavailable in portfolio demo mode.';
+ const automationBlockReason = resolveAutomationBlockReason({
+ blockedCount,
+ subscriptionStatus: business.subscriptionStatus,
+ usage,
+ });
+ const automationStatusMessage = describeAutomationBlockReason(automationBlockReason, {
+ blockedCount,
+ usage: usage ?? undefined,
+ });
return (
@@ -34,12 +63,29 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
{business.subscriptionStatus.toLowerCase()}
+
{usageTierLabel}
+
Usage: {usageSummary}
{business.stripeCustomerId ?
Customer: {business.stripeCustomerId} : null}
{business.stripeSubscriptionId ?
Subscription: {business.stripeSubscriptionId} : null}
-
+
+
+ Automation Status
+ Why missed-call follow-up is running or paused.
+
+
+ {automationStatusMessage}
+ {automationBlockReason !== 'none' ? (
+
+ Upgrade Plan
+
+ ) : null}
+
+
+
+
Starter
diff --git a/app/app/leads/page.tsx b/app/app/leads/page.tsx
index 51c117c..179540b 100644
--- a/app/app/leads/page.tsx
+++ b/app/app/leads/page.tsx
@@ -9,7 +9,13 @@ import { db } from '@/lib/db';
import { formatPhoneForDisplay } from '@/lib/phone';
import { formatDateTime, leadStatusLabels, leadStatusOrder, smsStateLabels } from '@/lib/lead-presenters';
import { getPortfolioDemoBlockedCount, getPortfolioDemoLeads, isPortfolioDemoMode } from '@/lib/portfolio-demo';
-import { isSubscriptionActive } from '@/lib/subscription';
+import { getConversationUsageForBusiness, resolveUsageTierFromSubscription } from '@/lib/usage';
+import {
+ describeAutomationBlockReason,
+ formatUsageSummary,
+ formatUsageTierLabel,
+ resolveAutomationBlockReason,
+} from '@/lib/usage-visibility';
import { cn } from '@/lib/utils';
export default async function LeadsPage({ searchParams }: { searchParams?: Record }) {
@@ -19,8 +25,8 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Recor
const error = typeof searchParams?.error === 'string' ? searchParams.error : undefined;
const demoMode = isPortfolioDemoMode();
- const [leads, blockedCount] = demoMode
- ? [getPortfolioDemoLeads(statusFilter), getPortfolioDemoBlockedCount()]
+ const [leads, blockedCount, usage] = demoMode
+ ? [getPortfolioDemoLeads(statusFilter), getPortfolioDemoBlockedCount(), null]
: await Promise.all([
db.lead.findMany({
where: {
@@ -36,7 +42,20 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Recor
orderBy: [{ createdAt: 'desc' }],
}),
db.lead.count({ where: { businessId: business.id, billingRequired: true } }),
+ getConversationUsageForBusiness(business),
]);
+ const usageTierLabel = formatUsageTierLabel(resolveUsageTierFromSubscription(business));
+ const usageSummary = usage ? formatUsageSummary(usage) : 'Unavailable in portfolio demo mode.';
+ const automationBlockReason = resolveAutomationBlockReason({
+ blockedCount,
+ subscriptionStatus: business.subscriptionStatus,
+ usage,
+ });
+ const automationBlockMessage = describeAutomationBlockReason(automationBlockReason, {
+ blockedCount,
+ usage: usage ?? undefined,
+ });
+ const automationBlockCta = automationBlockReason === 'usage_limit_reached' ? 'Upgrade Plan' : 'Open Billing';
return (
@@ -64,8 +83,32 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Recor
- {!isSubscriptionActive(business.subscriptionStatus) && blockedCount > 0 ?
: null}
{error ?
{error}
: null}
+ {automationBlockReason !== 'none' && blockedCount > 0 ? (
+
+ ) : null}
+
+
+
+ Automation Overview
+ Current plan tier and monthly conversation usage.
+
+
+
+
Plan Tier
+
{usageTierLabel}
+
+
+
Current Period Usage
+
{usageSummary}
+
+
+
diff --git a/components/upgrade-banner.tsx b/components/upgrade-banner.tsx
index 7211c7a..690817c 100644
--- a/components/upgrade-banner.tsx
+++ b/components/upgrade-banner.tsx
@@ -3,18 +3,31 @@ import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
-export function UpgradeBanner({ blockedCount }: { blockedCount: number }) {
+type UpgradeBannerProps = {
+ blockedCount: number;
+ title?: string;
+ description?: string;
+ ctaLabel?: string;
+ ctaHref?: string;
+};
+
+export function UpgradeBanner({
+ blockedCount,
+ title = 'SMS follow-up is paused until billing is active.',
+ description,
+ ctaLabel = 'Upgrade Now',
+ ctaHref = '/app/billing',
+}: UpgradeBannerProps) {
+ const defaultDescription = `${blockedCount} lead${blockedCount === 1 ? '' : 's'} captured but not contacted automatically.`;
return (
-
SMS follow-up is paused until billing is active.
-
- {blockedCount} lead{blockedCount === 1 ? '' : 's'} captured but not contacted automatically.
-
+
{title}
+
{description ?? defaultDescription}
-
-
+
+
diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md
index 9989ecd..61b8cec 100644
--- a/docs/PRODUCTION_READINESS_GAPS.md
+++ b/docs/PRODUCTION_READINESS_GAPS.md
@@ -483,3 +483,37 @@ Dependencies: G4 (recommended)
- `npm run env:check` -> PASS
- Notes:
- No functional regressions observed in local validation gates.
+
+- 2026-03-02 - G13 (DONE)
+ - Branch: `hardening/g13-usage-visibility`
+ - What changed:
+ - Added usage-visibility presenter helpers in `lib/usage-visibility.ts` for:
+ - plan tier labels
+ - usage summary formatting
+ - automation block reason resolution (`billing_inactive`, `usage_limit_reached`, `billing_required`)
+ - user-facing blocked reason messages
+ - Updated leads dashboard UI (`app/app/leads/page.tsx`) to show:
+ - current plan tier
+ - current period usage (used/remaining)
+ - clear blocked-reason banner with billing/upgrade CTA when automation is paused
+ - Updated billing page UI (`app/app/billing/page.tsx`) to show:
+ - current plan tier
+ - current period usage
+ - explicit automation status messaging and upgrade CTA to plan options
+ - Extended `components/upgrade-banner.tsx` with configurable title/description/CTA so existing banner patterns are reused across blocked states.
+ - Added presenter/helper coverage in `tests/usage-visibility.test.ts`.
+ - Commands run + results:
+ - `npm test` -> PASS (38/38)
+ - `npm run lint` -> PASS
+ - `npm run build` -> PASS
+ - `npm run typecheck` -> PASS
+ - `npm run env:check` -> PASS
+ - Files touched:
+ - `lib/usage-visibility.ts`
+ - `app/app/leads/page.tsx`
+ - `app/app/billing/page.tsx`
+ - `components/upgrade-banner.tsx`
+ - `tests/usage-visibility.test.ts`
+ - `docs/PRODUCTION_READINESS_GAPS.md`
+ - Commit SHA:
+ - `92901ab`
diff --git a/lib/usage-visibility.ts b/lib/usage-visibility.ts
new file mode 100644
index 0000000..5c328a3
--- /dev/null
+++ b/lib/usage-visibility.ts
@@ -0,0 +1,58 @@
+import { type SubscriptionStatus } from '@prisma/client';
+
+import type { ConversationUsage, UsageTier } from './usage.ts';
+import { isConversationLimitReached } from './usage.ts';
+import { isSubscriptionActive } from './subscription.ts';
+
+export type AutomationBlockReason = 'none' | 'billing_inactive' | 'usage_limit_reached' | 'billing_required';
+
+const TIER_LABELS: Record = {
+ free: 'Free',
+ starter: 'Starter',
+ pro: 'Pro',
+};
+
+export function formatUsageTierLabel(tier: UsageTier) {
+ return TIER_LABELS[tier];
+}
+
+export function formatUsageSummary(usage: Pick) {
+ return `${usage.used}/${usage.limit} used (${usage.remaining} remaining)`;
+}
+
+export function resolveAutomationBlockReason(input: {
+ blockedCount: number;
+ subscriptionStatus: SubscriptionStatus | null | undefined;
+ usage?: Pick | null;
+}): AutomationBlockReason {
+ if (input.blockedCount <= 0) return 'none';
+ if (!isSubscriptionActive(input.subscriptionStatus)) return 'billing_inactive';
+ if (input.usage && isConversationLimitReached(input.usage)) return 'usage_limit_reached';
+ return 'billing_required';
+}
+
+export function describeAutomationBlockReason(
+ reason: AutomationBlockReason,
+ input: {
+ blockedCount?: number;
+ usage?: Pick;
+ } = {}
+) {
+ const blockedSuffix = typeof input.blockedCount === 'number' && input.blockedCount > 0
+ ? ` ${input.blockedCount} lead${input.blockedCount === 1 ? '' : 's'} currently blocked.`
+ : '';
+
+ if (reason === 'billing_inactive') {
+ return `Automation is paused because billing is inactive.${blockedSuffix}`;
+ }
+ if (reason === 'usage_limit_reached') {
+ if (input.usage) {
+ return `Automation is paused because your monthly conversation limit is reached (${input.usage.used}/${input.usage.limit}).${blockedSuffix}`;
+ }
+ return `Automation is paused because your monthly conversation limit is reached.${blockedSuffix}`;
+ }
+ if (reason === 'billing_required') {
+ return `Automation is paused for leads that require billing action.${blockedSuffix}`;
+ }
+ return 'Automation is active.';
+}
diff --git a/tests/usage-visibility.test.ts b/tests/usage-visibility.test.ts
new file mode 100644
index 0000000..829d52f
--- /dev/null
+++ b/tests/usage-visibility.test.ts
@@ -0,0 +1,87 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ describeAutomationBlockReason,
+ formatUsageSummary,
+ formatUsageTierLabel,
+ resolveAutomationBlockReason,
+} from '../lib/usage-visibility.ts';
+import { buildConversationUsage } from '../lib/usage.ts';
+
+test('formatUsageTierLabel returns readable tier names', () => {
+ assert.equal(formatUsageTierLabel('free'), 'Free');
+ assert.equal(formatUsageTierLabel('starter'), 'Starter');
+ assert.equal(formatUsageTierLabel('pro'), 'Pro');
+});
+
+test('formatUsageSummary returns used and remaining values', () => {
+ const usage = buildConversationUsage(
+ 'starter',
+ 42,
+ new Date('2026-03-01T05:00:00.000Z'),
+ new Date('2026-04-01T04:00:00.000Z')
+ );
+
+ assert.equal(formatUsageSummary(usage), '42/200 used (158 remaining)');
+});
+
+test('resolveAutomationBlockReason prioritizes inactive billing and usage limits', () => {
+ assert.equal(
+ resolveAutomationBlockReason({
+ blockedCount: 0,
+ subscriptionStatus: 'ACTIVE',
+ usage: { used: 10, limit: 200 },
+ }),
+ 'none'
+ );
+
+ assert.equal(
+ resolveAutomationBlockReason({
+ blockedCount: 2,
+ subscriptionStatus: 'INACTIVE',
+ usage: { used: 10, limit: 200 },
+ }),
+ 'billing_inactive'
+ );
+
+ assert.equal(
+ resolveAutomationBlockReason({
+ blockedCount: 2,
+ subscriptionStatus: 'ACTIVE',
+ usage: { used: 200, limit: 200 },
+ }),
+ 'usage_limit_reached'
+ );
+
+ assert.equal(
+ resolveAutomationBlockReason({
+ blockedCount: 2,
+ subscriptionStatus: 'ACTIVE',
+ usage: { used: 120, limit: 200 },
+ }),
+ 'billing_required'
+ );
+});
+
+test('describeAutomationBlockReason includes blocked counts and usage values', () => {
+ assert.equal(
+ describeAutomationBlockReason('billing_inactive', { blockedCount: 3 }),
+ 'Automation is paused because billing is inactive. 3 leads currently blocked.'
+ );
+
+ assert.equal(
+ describeAutomationBlockReason('usage_limit_reached', {
+ blockedCount: 1,
+ usage: { used: 200, limit: 200 },
+ }),
+ 'Automation is paused because your monthly conversation limit is reached (200/200). 1 lead currently blocked.'
+ );
+
+ assert.equal(
+ describeAutomationBlockReason('billing_required', { blockedCount: 2 }),
+ 'Automation is paused for leads that require billing action. 2 leads currently blocked.'
+ );
+
+ assert.equal(describeAutomationBlockReason('none'), 'Automation is active.');
+});