Files
HOA_Financial_Platform/frontend/src/pages/pricing/PricingPage.tsx
olsch01 a996208cb8 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>
2026-03-18 08:04:51 -04:00

285 lines
9.3 KiB
TypeScript

import { useState } from 'react';
import {
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
Button, List, ThemeIcon, TextInput, Center, Alert, SegmentedControl, Box,
} from '@mantine/core';
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import logoSrc from '../../assets/logo.png';
type BillingInterval = 'month' | 'year';
const plans = [
{
id: 'starter',
name: 'Starter',
monthlyPrice: 29,
annualPrice: 261, // 29 * 12 * 0.75
description: 'For small communities getting started',
icon: IconRocket,
color: 'blue',
features: [
{ text: 'Up to 50 units', included: true },
{ text: 'Chart of Accounts', included: true },
{ text: 'Assessment Tracking', included: true },
{ text: 'Basic Reports', included: true },
{ text: 'Board Planning', included: false },
{ text: 'AI Investment Advisor', included: false },
],
},
{
id: 'professional',
name: 'Professional',
monthlyPrice: 79,
annualPrice: 711, // 79 * 12 * 0.75
description: 'For growing HOAs that need full features',
icon: IconStar,
color: 'violet',
popular: true,
features: [
{ text: 'Up to 200 units', included: true },
{ text: 'Everything in Starter', included: true },
{ text: 'Board Planning & Scenarios', included: true },
{ text: 'AI Investment Advisor', included: true },
{ text: 'Advanced Reports', included: true },
{ text: 'Priority Support', included: false },
],
},
{
id: 'enterprise',
name: 'Enterprise',
monthlyPrice: 0,
annualPrice: 0,
description: 'For large communities and management firms',
icon: IconCrown,
color: 'orange',
externalUrl: 'https://www.hoaledgeriq.com/#preview-signup',
features: [
{ text: 'Unlimited units', included: true },
{ text: 'Everything in Professional', included: true },
{ text: 'Priority Support', included: true },
{ text: 'Custom Integrations', included: true },
{ text: 'Dedicated Account Manager', included: true },
{ text: 'SLA Guarantee', included: true },
],
},
];
function formatPrice(plan: typeof plans[0], interval: BillingInterval) {
if (plan.externalUrl) return { display: 'Custom', sub: '' };
if (interval === 'year') {
const monthly = (plan.annualPrice / 12).toFixed(2);
return {
display: `$${monthly}`,
sub: `/mo billed annually ($${plan.annualPrice}/yr)`,
};
}
return { display: `$${plan.monthlyPrice}`, sub: '/month' };
}
export function PricingPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState('');
const [email, setEmail] = useState('');
const [businessName, setBusinessName] = useState('');
const [billingInterval, setBillingInterval] = useState<BillingInterval>('month');
const handleStartTrial = async (planId: string) => {
if (!email.trim()) {
setError('Email address is required to start a trial');
return;
}
if (!businessName.trim()) {
setError('HOA / Business name is required to start a trial');
return;
}
setLoading(planId);
setError('');
try {
const { data } = await api.post('/billing/start-trial', {
planId,
billingInterval,
email: email.trim(),
businessName: businessName.trim(),
});
if (data.subscriptionId) {
// Navigate to pending page with subscription ID for polling
navigate(`/onboarding/pending?session_id=${data.subscriptionId}`);
} else {
setError('Unable to start trial');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to start trial');
} finally {
setLoading(null);
}
};
return (
<Container size="lg" py={60}>
<Stack align="center" mb={40}>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
<Title order={1} ta="center">
Simple, transparent pricing
</Title>
<Text size="lg" c="dimmed" ta="center" maw={500}>
Choose the plan that fits your community. All plans include a 14-day free trial.
</Text>
</Stack>
{/* Monthly / Annual Toggle */}
<Center mb="xl">
<Box pos="relative">
<SegmentedControl
value={billingInterval}
onChange={(val) => setBillingInterval(val as BillingInterval)}
data={[
{ label: 'Monthly', value: 'month' },
{ label: 'Annual', value: 'year' },
]}
size="md"
radius="xl"
/>
{billingInterval === 'year' && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: -10, right: -40 }}
>
Save 25%
</Badge>
)}
</Box>
</Center>
{/* Pre-capture fields (required for trial) */}
<Center mb="xl">
<Group>
<TextInput
placeholder="Email address *"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
style={{ width: 220 }}
required
/>
<TextInput
placeholder="HOA / Business name *"
value={businessName}
onChange={(e) => setBusinessName(e.currentTarget.value)}
style={{ width: 220 }}
required
/>
</Group>
</Center>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="lg" variant="light">
{error}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{plans.map((plan) => {
const price = formatPrice(plan, billingInterval);
return (
<Card
key={plan.id}
withBorder
shadow={plan.popular ? 'lg' : 'sm'}
radius="md"
p="xl"
style={plan.popular ? {
border: '2px solid var(--mantine-color-violet-5)',
position: 'relative',
} : undefined}
>
{plan.popular && (
<Badge
color="violet"
variant="filled"
style={{ position: 'absolute', top: -10, right: 20 }}
>
Most Popular
</Badge>
)}
<Stack gap="md">
<Group>
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
<plan.icon size={20} />
</ThemeIcon>
<div>
<Text fw={700} size="lg">{plan.name}</Text>
<Text size="xs" c="dimmed">{plan.description}</Text>
</div>
</Group>
<div>
<Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
{plan.externalUrl ? 'Request Quote' : price.display}
</Text>
</Group>
{price.sub && (
<Text size="xs" c="dimmed" mt={2}>
{price.sub}
</Text>
)}
{!plan.externalUrl && billingInterval === 'year' && (
<Text size="xs" c="dimmed" td="line-through" mt={2}>
${plan.monthlyPrice}/mo without annual discount
</Text>
)}
</div>
<List spacing="xs" size="sm" center>
{plan.features.map((f, i) => (
<List.Item
key={i}
icon={
<ThemeIcon
size={20}
radius="xl"
color={f.included ? 'teal' : 'gray'}
variant={f.included ? 'filled' : 'light'}
>
{f.included ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
}
>
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text>
</List.Item>
))}
</List>
<Button
fullWidth
size="md"
color={plan.color}
variant={plan.popular ? 'filled' : 'light'}
loading={!plan.externalUrl ? loading === plan.id : false}
onClick={() =>
plan.externalUrl
? window.open(plan.externalUrl, '_blank', 'noopener')
: handleStartTrial(plan.id)
}
>
{plan.externalUrl ? 'Request Quote' : 'Start Free Trial'}
</Button>
</Stack>
</Card>
);
})}
</SimpleGrid>
<Text ta="center" size="sm" c="dimmed" mt="xl">
All plans include a 14-day free trial. No credit card required.
</Text>
</Container>
);
}