Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion app/app/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<div className="mx-auto max-w-5xl space-y-6">
Expand All @@ -34,12 +63,29 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
<Badge variant={business.subscriptionStatus === 'ACTIVE' ? 'success' : 'outline'}>
{business.subscriptionStatus.toLowerCase()}
</Badge>
<Badge variant="outline">{usageTierLabel}</Badge>
<span className="text-muted-foreground">Usage: {usageSummary}</span>
{business.stripeCustomerId ? <span className="text-muted-foreground">Customer: {business.stripeCustomerId}</span> : null}
{business.stripeSubscriptionId ? <span className="text-muted-foreground">Subscription: {business.stripeSubscriptionId}</span> : null}
</CardContent>
</Card>

<div className="grid gap-6 md:grid-cols-2">
<Card className={automationBlockReason === 'none' ? 'border-accent/40 bg-accent/20' : 'border-destructive/30 bg-destructive/5'}>
<CardHeader>
<CardTitle>Automation Status</CardTitle>
<CardDescription>Why missed-call follow-up is running or paused.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>{automationStatusMessage}</p>
{automationBlockReason !== 'none' ? (
<Link href="#plan-options" className="inline-flex rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Upgrade Plan
</Link>
) : null}
</CardContent>
</Card>

<div id="plan-options" className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Starter</CardTitle>
Expand Down
51 changes: 47 additions & 4 deletions app/app/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[] | undefined> }) {
Expand All @@ -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: {
Expand All @@ -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 (
<div className="space-y-6">
Expand Down Expand Up @@ -64,8 +83,32 @@ export default async function LeadsPage({ searchParams }: { searchParams?: Recor
</div>
</div>

{!isSubscriptionActive(business.subscriptionStatus) && blockedCount > 0 ? <UpgradeBanner blockedCount={blockedCount} /> : null}
{error ? <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
{automationBlockReason !== 'none' && blockedCount > 0 ? (
<UpgradeBanner
blockedCount={blockedCount}
title="SMS follow-up is currently paused."
description={automationBlockMessage}
ctaLabel={automationBlockCta}
/>
) : null}

<Card>
<CardHeader>
<CardTitle>Automation Overview</CardTitle>
<CardDescription>Current plan tier and monthly conversation usage.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 text-sm sm:grid-cols-3">
<div className="space-y-1">
<p className="text-xs uppercase text-muted-foreground">Plan Tier</p>
<p className="font-medium">{usageTierLabel}</p>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs uppercase text-muted-foreground">Current Period Usage</p>
<p className="font-medium">{usageSummary}</p>
</div>
</CardContent>
</Card>

<Card>
<CardHeader>
Expand Down
27 changes: 20 additions & 7 deletions components/upgrade-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Card className="border-destructive/30 bg-destructive/5">
<CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">SMS follow-up is paused until billing is active.</p>
<p className="text-sm text-muted-foreground">
{blockedCount} lead{blockedCount === 1 ? '' : 's'} captured but not contacted automatically.
</p>
<p className="font-medium">{title}</p>
<p className="text-sm text-muted-foreground">{description ?? defaultDescription}</p>
</div>
<Link href="/app/billing">
<Button>Upgrade Now</Button>
<Link href={ctaHref}>
<Button>{ctaLabel}</Button>
</Link>
</CardContent>
</Card>
Expand Down
34 changes: 34 additions & 0 deletions docs/PRODUCTION_READINESS_GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
58 changes: 58 additions & 0 deletions lib/usage-visibility.ts
Original file line number Diff line number Diff line change
@@ -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<UsageTier, string> = {
free: 'Free',
starter: 'Starter',
pro: 'Pro',
};

export function formatUsageTierLabel(tier: UsageTier) {
return TIER_LABELS[tier];
}

export function formatUsageSummary(usage: Pick<ConversationUsage, 'used' | 'limit' | 'remaining'>) {
return `${usage.used}/${usage.limit} used (${usage.remaining} remaining)`;
}

export function resolveAutomationBlockReason(input: {
blockedCount: number;
subscriptionStatus: SubscriptionStatus | null | undefined;
usage?: Pick<ConversationUsage, 'used' | 'limit'> | 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<ConversationUsage, 'used' | 'limit'>;
} = {}
) {
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.';
}
87 changes: 87 additions & 0 deletions tests/usage-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -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.');
});