From c9138e215129feaf6a2731fb3813eef52ca487f4 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 23:57:12 -0500 Subject: [PATCH 1/2] hardening: g13 usage visibility and block reason UI --- app/app/billing/page.tsx | 48 ++++++++++++++++++- app/app/leads/page.tsx | 51 ++++++++++++++++++-- components/upgrade-banner.tsx | 27 ++++++++--- lib/usage-visibility.ts | 58 +++++++++++++++++++++++ tests/usage-visibility.test.ts | 87 ++++++++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 lib/usage-visibility.ts create mode 100644 tests/usage-visibility.test.ts 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/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.'); +}); From c3095c1393580c3c43d389a307b76430cad8f8e6 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Sun, 1 Mar 2026 23:57:31 -0500 Subject: [PATCH 2/2] docs: mark g13 done in production readiness log --- docs/PRODUCTION_READINESS_GAPS.md | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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`