feat: add annual billing, free trial, upgrade/downgrade, and ACH invoice support

- Add monthly/annual billing toggle with 25% annual discount on pricing page
- Implement 14-day no-card free trial (server-side Stripe subscription creation)
- Enable upgrade/downgrade via Stripe Customer Portal
- Add admin-initiated ACH/invoice billing for enterprise customers
- Add billing card to Settings page with plan info and Manage Billing button
- Handle past_due status with read-only grace period access
- Add trial ending and trial expired email templates
- Add DB migration for billing_interval and collection_method columns
- Update ONBOARDING-AND-AUTH.md documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 08:04:51 -04:00
parent 5845334454
commit a996208cb8
12 changed files with 1241 additions and 507 deletions

View File

@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
Tabs, Button, Switch,
Tabs, Button, Switch, Loader,
} from '@mantine/core';
import {
IconBuilding, IconUser, IconSettings, IconShieldLock,
IconFingerprint, IconLink, IconLogout,
IconFingerprint, IconLink, IconLogout, IconCreditCard,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useAuthStore } from '../../stores/authStore';
@@ -15,10 +15,39 @@ import { PasskeySettings } from './PasskeySettings';
import { LinkedAccounts } from './LinkedAccounts';
import api from '../../services/api';
interface SubscriptionInfo {
plan: string;
planName: string;
billingInterval: string;
status: string;
collectionMethod: string;
trialEndsAt: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
}
const statusColors: Record<string, string> = {
active: 'green',
trial: 'blue',
past_due: 'orange',
archived: 'red',
suspended: 'red',
};
export function SettingsPage() {
const { user, currentOrg } = useAuthStore();
const { compactView, toggleCompactView } = usePreferencesStore();
const [loggingOutAll, setLoggingOutAll] = useState(false);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
const [subLoading, setSubLoading] = useState(true);
const [portalLoading, setPortalLoading] = useState(false);
useEffect(() => {
api.get('/billing/subscription')
.then(({ data }) => setSubscription(data))
.catch(() => { /* billing not configured or no subscription */ })
.finally(() => setSubLoading(false));
}, []);
const handleLogoutEverywhere = async () => {
setLoggingOutAll(true);
@@ -32,6 +61,31 @@ export function SettingsPage() {
}
};
const handleManageBilling = async () => {
setPortalLoading(true);
try {
const { data } = await api.post('/billing/portal');
if (data.url) {
window.location.href = data.url;
}
} catch {
notifications.show({ message: 'Unable to open billing portal', color: 'red' });
} finally {
setPortalLoading(false);
}
};
const formatInterval = (interval: string) => {
return interval === 'year' ? 'Annual' : 'Monthly';
};
const formatDate = (iso: string | null) => {
if (!iso) return null;
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
});
};
return (
<Stack>
<div>
@@ -63,6 +117,73 @@ export function SettingsPage() {
</Stack>
</Card>
{/* Billing / Subscription */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="teal" variant="light" size={40} radius="md">
<IconCreditCard size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Billing</Text>
<Text c="dimmed" size="sm">Subscription and payment</Text>
</div>
</Group>
{subLoading ? (
<Group justify="center" py="md"><Loader size="sm" /></Group>
) : subscription ? (
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Plan</Text>
<Group gap={4}>
<Badge variant="light">{subscription.planName}</Badge>
<Badge variant="light" color="gray" size="sm">{formatInterval(subscription.billingInterval)}</Badge>
</Group>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Status</Text>
<Badge
color={statusColors[subscription.status] || 'gray'}
variant="light"
>
{subscription.status === 'past_due' ? 'Past Due' : subscription.status}
{subscription.cancelAtPeriodEnd ? ' (Canceling)' : ''}
</Badge>
</Group>
{subscription.trialEndsAt && subscription.status === 'trial' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Trial Ends</Text>
<Text size="sm" fw={500}>{formatDate(subscription.trialEndsAt)}</Text>
</Group>
)}
{subscription.currentPeriodEnd && subscription.status !== 'trial' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Current Period Ends</Text>
<Text size="sm" fw={500}>{formatDate(subscription.currentPeriodEnd)}</Text>
</Group>
)}
{subscription.collectionMethod === 'send_invoice' && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Payment</Text>
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
</Group>
)}
<Button
variant="light"
color="teal"
size="sm"
leftSection={<IconCreditCard size={16} />}
onClick={handleManageBilling}
loading={portalLoading}
mt="xs"
>
Manage Billing
</Button>
</Stack>
) : (
<Text size="sm" c="dimmed">No active subscription</Text>
)}
</Card>
{/* User Profile */}
<Card withBorder padding="lg">
<Group mb="md">
@@ -108,7 +229,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.03.17</Badge>
<Badge variant="light">2026.03.18</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>