fix: billing portal error, onboarding wizard improvements, budget empty state

- Fix "Manage Billing" button error for trial orgs without Stripe customer;
  add fallback to retrieve customer from subscription, show helpful message
  for trial users, and surface real error messages in the UI
- Add "Balance As-Of Date" field to onboarding wizard so opening balance
  journal entries use the correct statement date instead of today
- Add "Total Unit Count" field to onboarding wizard assessment group step
  so cash flow projections work immediately
- Remove broken budget upload step from onboarding wizard (was using legacy
  budgets endpoint); replace with guidance to use Budget Planning page
- Replace bare "No budget plan lines" text with rich onboarding-style card
  featuring download template and upload CSV action buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 09:43:49 -04:00
parent e2d72223c8
commit db8b520009
6 changed files with 164 additions and 204 deletions

View File

@@ -74,9 +74,9 @@ export class AccountsService {
// Create opening balance journal entry if initialBalance is provided and non-zero // Create opening balance journal entry if initialBalance is provided and non-zero
if (dto.initialBalance && dto.initialBalance !== 0) { if (dto.initialBalance && dto.initialBalance !== 0) {
const now = new Date(); const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date();
const year = now.getFullYear(); const year = balanceDate.getFullYear();
const month = now.getMonth() + 1; const month = balanceDate.getMonth() + 1;
// Find the current fiscal period // Find the current fiscal period
const periods = await this.tenant.query( const periods = await this.tenant.query(
@@ -111,12 +111,14 @@ export class AccountsService {
); );
} }
// Create the journal entry // Create the journal entry (use provided balance date or today)
const entryDate = dto.initialBalanceDate || new Date().toISOString().split('T')[0];
const jeInsert = await this.tenant.query( const jeInsert = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) `INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3) VALUES ($1::date, $2, 'opening_balance', $3, true, NOW(), $4)
RETURNING id`, RETURNING id`,
[ [
entryDate,
`Opening balance for ${dto.name}`, `Opening balance for ${dto.name}`,
fiscalPeriodId, fiscalPeriodId,
'00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000',

View File

@@ -37,6 +37,11 @@ export class CreateAccountDto {
@IsOptional() @IsOptional()
initialBalance?: number; initialBalance?: number;
@ApiProperty({ required: false, description: 'ISO date string (YYYY-MM-DD) for when the initial balance was accurate' })
@IsString()
@IsOptional()
initialBalanceDate?: string;
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' }) @ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
@IsOptional() @IsOptional()
interestRate?: number; interestRate?: number;

View File

@@ -279,18 +279,49 @@ export class BillingService {
* Create a Stripe Customer Portal session for managing subscription. * Create a Stripe Customer Portal session for managing subscription.
*/ */
async createPortalSession(orgId: string): Promise<{ url: string }> { async createPortalSession(orgId: string): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured'); if (!this.stripe) throw new BadRequestException('Stripe is not configured');
const rows = await this.dataSource.query( const rows = await this.dataSource.query(
`SELECT stripe_customer_id FROM shared.organizations WHERE id = $1`, `SELECT stripe_customer_id, stripe_subscription_id, status
FROM shared.organizations WHERE id = $1`,
[orgId], [orgId],
); );
if (rows.length === 0 || !rows[0].stripe_customer_id) { if (rows.length === 0) {
throw new BadRequestException('No Stripe customer found for this organization'); throw new BadRequestException('Organization not found');
}
let customerId = rows[0].stripe_customer_id;
// Fallback: if customer ID is missing but subscription exists, retrieve customer from subscription
if (!customerId && rows[0].stripe_subscription_id) {
try {
const sub = await this.stripe.subscriptions.retrieve(rows[0].stripe_subscription_id) as Stripe.Subscription;
customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
if (customerId) {
// Backfill the customer ID for future calls
await this.dataSource.query(
`UPDATE shared.organizations SET stripe_customer_id = $1 WHERE id = $2`,
[customerId, orgId],
);
this.logger.log(`Backfilled stripe_customer_id=${customerId} for org=${orgId}`);
}
} catch (err) {
this.logger.warn(`Failed to retrieve customer from subscription: ${(err as Error).message}`);
}
}
if (!customerId) {
const status = rows[0].status;
if (status === 'trial') {
throw new BadRequestException(
'Billing portal is not available during your free trial. Add a payment method when your trial ends to manage your subscription.',
);
}
throw new BadRequestException('No Stripe customer found for this organization. Please contact support.');
} }
const session = await this.stripe.billingPortal.sessions.create({ const session = await this.stripe.billingPortal.sessions.create({
customer: rows[0].stripe_customer_id, customer: customerId,
return_url: `${this.getAppUrl()}/settings`, return_url: `${this.getAppUrl()}/settings`,
}); });
@@ -311,10 +342,11 @@ export class BillingService {
trialEndsAt: string | null; trialEndsAt: string | null;
currentPeriodEnd: string | null; currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean; cancelAtPeriodEnd: boolean;
hasStripeCustomer: boolean;
}> { }> {
const rows = await this.dataSource.query( const rows = await this.dataSource.query(
`SELECT plan_level, billing_interval, status, collection_method, `SELECT plan_level, billing_interval, status, collection_method,
trial_ends_at, stripe_subscription_id trial_ends_at, stripe_subscription_id, stripe_customer_id
FROM shared.organizations WHERE id = $1`, FROM shared.organizations WHERE id = $1`,
[orgId], [orgId],
); );
@@ -351,6 +383,7 @@ export class BillingService {
trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null, trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null,
currentPeriodEnd, currentPeriodEnd,
cancelAtPeriodEnd, cancelAtPeriodEnd,
hasStripeCustomer: !!org.stripe_customer_id,
}; };
} }

View File

@@ -1,14 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea, Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput, Select, Stack, Text, Title, Alert, ActionIcon, Table,
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box, Card, ThemeIcon, Divider, Badge, SimpleGrid, Box,
} from '@mantine/core'; } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { import {
IconBuildingBank, IconUsers, IconFileSpreadsheet, IconBuildingBank, IconUsers,
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket, IconPlus, IconTrash, IconCheck, IconRocket,
IconAlertCircle, IconAlertCircle, IconFileSpreadsheet,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import api from '../../services/api'; import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -24,27 +25,6 @@ interface UnitRow {
ownerEmail: string; ownerEmail: string;
} }
// ── CSV Parsing (reused from BudgetsPage pattern) ──
function parseCSV(text: string): Record<string, string>[] {
const lines = text.split('\n').filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
return lines.slice(1).map((line) => {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') { inQuotes = !inQuotes; }
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else { current += char; }
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, i) => { row[h] = values[i] || ''; });
return row;
});
}
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) { export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -57,22 +37,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
const [accountNumber, setAccountNumber] = useState('1000'); const [accountNumber, setAccountNumber] = useState('1000');
const [accountDescription, setAccountDescription] = useState(''); const [accountDescription, setAccountDescription] = useState('');
const [initialBalance, setInitialBalance] = useState<number | string>(0); const [initialBalance, setInitialBalance] = useState<number | string>(0);
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
// ── Step 2: Assessment Group State ── // ── Step 2: Assessment Group State ──
const [groupCreated, setGroupCreated] = useState(false); const [groupCreated, setGroupCreated] = useState(false);
const [groupName, setGroupName] = useState('Standard Assessment'); const [groupName, setGroupName] = useState('Standard Assessment');
const [regularAssessment, setRegularAssessment] = useState<number | string>(0); const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
const [frequency, setFrequency] = useState('monthly'); const [frequency, setFrequency] = useState('monthly');
const [unitCount, setUnitCount] = useState<number | string>(0);
const [units, setUnits] = useState<UnitRow[]>([]); const [units, setUnits] = useState<UnitRow[]>([]);
const [unitsCreated, setUnitsCreated] = useState(false); const [unitsCreated, setUnitsCreated] = useState(false);
// ── Step 3: Budget State ──
const [budgetFile, setBudgetFile] = useState<File | null>(null);
const [budgetUploaded, setBudgetUploaded] = useState(false);
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
const currentYear = new Date().getFullYear();
// ── Step 1: Create Account ── // ── Step 1: Create Account ──
const handleCreateAccount = async () => { const handleCreateAccount = async () => {
if (!accountName.trim()) { if (!accountName.trim()) {
@@ -99,6 +74,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
accountType: 'asset', accountType: 'asset',
fundType: 'operating', fundType: 'operating',
initialBalance: balance, initialBalance: balance,
initialBalanceDate: balanceDate ? balanceDate.toISOString().split('T')[0] : undefined,
}); });
setAccountCreated(true); setAccountCreated(true);
notifications.show({ notifications.show({
@@ -126,6 +102,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
return; return;
} }
const count = typeof unitCount === 'string' ? parseInt(unitCount) : unitCount;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
@@ -133,6 +111,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
name: groupName.trim(), name: groupName.trim(),
regularAssessment: assessment, regularAssessment: assessment,
frequency, frequency,
unitCount: isNaN(count) ? 0 : count,
isDefault: true, isDefault: true,
}); });
setGroupCreated(true); setGroupCreated(true);
@@ -175,62 +154,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
} }
}; };
// ── Step 3: Budget Import ──
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${currentYear}/template`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `budget_template_${currentYear}.csv`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch {
notifications.show({
title: 'Error',
message: 'Failed to download template',
color: 'red',
});
}
};
const handleUploadBudget = async () => {
if (!budgetFile) {
setError('Please select a CSV file');
return;
}
setLoading(true);
setError(null);
try {
const text = await budgetFile.text();
const rows = parseCSV(text);
if (rows.length === 0) {
setError('CSV file appears to be empty or invalid');
setLoading(false);
return;
}
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
setBudgetUploaded(true);
setBudgetImportResult(data);
notifications.show({
title: 'Budget Imported',
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
color: 'green',
});
} catch (err: any) {
const msg = err.response?.data?.message || 'Failed to import budget';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
// ── Finish Wizard ── // ── Finish Wizard ──
const handleFinish = async () => { const handleFinish = async () => {
setLoading(true); setLoading(true);
@@ -265,13 +188,12 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
const canGoNext = () => { const canGoNext = () => {
if (active === 0) return accountCreated; if (active === 0) return accountCreated;
if (active === 1) return groupCreated; if (active === 1) return groupCreated;
if (active === 2) return true; // Budget is optional
return false; return false;
}; };
const nextStep = () => { const nextStep = () => {
setError(null); setError(null);
if (active < 3) setActive(active + 1); if (active < 2) setActive(active + 1);
}; };
return ( return (
@@ -315,12 +237,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
icon={<IconUsers size={18} />} icon={<IconUsers size={18} />}
completedIcon={<IconCheck size={18} />} completedIcon={<IconCheck size={18} />}
/> />
<Stepper.Step
label="Budget"
description="Import your annual budget"
icon={<IconFileSpreadsheet size={18} />}
completedIcon={<IconCheck size={18} />}
/>
</Stepper> </Stepper>
{error && ( {error && (
@@ -343,6 +259,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<Text fw={500}>{accountName} created successfully!</Text> <Text fw={500}>{accountName} created successfully!</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()} Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
{balanceDate && ` as of ${balanceDate.toLocaleDateString()}`}
</Text> </Text>
</Alert> </Alert>
) : ( ) : (
@@ -372,6 +289,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
autosize autosize
minRows={2} minRows={2}
/> />
<SimpleGrid cols={2} mb="md">
<NumberInput <NumberInput
label="Current Balance" label="Current Balance"
description="Enter the current balance of this bank account" description="Enter the current balance of this bank account"
@@ -381,8 +299,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
thousandSeparator="," thousandSeparator=","
prefix="$" prefix="$"
decimalScale={2} decimalScale={2}
mb="md"
/> />
<DateInput
label="Balance As-Of Date"
description="Date this balance was accurate (e.g. last statement date)"
value={balanceDate}
onChange={setBalanceDate}
maxDate={new Date()}
clearable={false}
/>
</SimpleGrid>
<Button <Button
onClick={handleCreateAccount} onClick={handleCreateAccount}
loading={loading} loading={loading}
@@ -415,7 +341,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Alert> </Alert>
) : ( ) : (
<> <>
<SimpleGrid cols={3} mb="md"> <SimpleGrid cols={2} mb="md">
<TextInput <TextInput
label="Group Name" label="Group Name"
placeholder="e.g. Standard Assessment" placeholder="e.g. Standard Assessment"
@@ -423,6 +349,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
onChange={(e) => setGroupName(e.currentTarget.value)} onChange={(e) => setGroupName(e.currentTarget.value)}
required required
/> />
<NumberInput
label="Total Unit Count"
description="How many units/lots does your community have?"
placeholder="e.g. 50"
value={unitCount}
onChange={setUnitCount}
min={0}
required
/>
</SimpleGrid>
<SimpleGrid cols={2} mb="md">
<NumberInput <NumberInput
label="Assessment Amount" label="Assessment Amount"
placeholder="0.00" placeholder="0.00"
@@ -520,71 +457,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
</Stack> </Stack>
)} )}
{/* ── Step 3: Budget Upload ── */}
{active === 2 && (
<Stack gap="md">
<Card withBorder p="lg">
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
<Text size="sm" c="dimmed" mb="md">
Upload a CSV file with your annual budget. If you don&apos;t have one ready, you can download a template
or skip this step and set it up later from the Budgets page.
</Text>
{budgetUploaded ? (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
<Text fw={500}>Budget imported successfully!</Text>
{budgetImportResult && (
<Text size="sm" c="dimmed">
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
</Text>
)}
</Alert>
) : (
<>
<Group mb="md">
<Button
variant="light"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download CSV Template
</Button>
</Group>
<FileInput
label="Upload Budget CSV"
placeholder="Click to select a .csv file"
accept=".csv"
value={budgetFile}
onChange={setBudgetFile}
mb="md"
leftSection={<IconFileSpreadsheet size={16} />}
/>
<Button
onClick={handleUploadBudget}
loading={loading}
leftSection={<IconFileSpreadsheet size={16} />}
disabled={!budgetFile}
>
Import Budget
</Button>
</>
)}
</Card>
</Stack>
)}
{/* ── Completion Screen ── */} {/* ── Completion Screen ── */}
{active === 3 && ( {active === 2 && (
<Card withBorder p="xl" style={{ textAlign: 'center' }}> <Card withBorder p="xl" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md"> <ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
<IconCheck size={32} /> <IconCheck size={32} />
</ThemeIcon> </ThemeIcon>
<Title order={3} mb="xs">You&apos;re All Set!</Title> <Title order={3} mb="xs">You&apos;re All Set!</Title>
<Text c="dimmed" mb="lg" maw={400} mx="auto"> <Text c="dimmed" mb="lg" maw={400} mx="auto">
Your organization is configured and ready to go. You can always update your accounts, Your organization is configured and ready to go. You can always update your accounts
assessment groups, and budgets from the sidebar navigation. and assessment groups from the sidebar navigation.
</Text> </Text>
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto"> <SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
<Card withBorder p="sm" style={{ textAlign: 'center' }}> <Card withBorder p="sm" style={{ textAlign: 'center' }}>
@@ -605,12 +487,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}> <ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
<IconFileSpreadsheet size={16} /> <IconFileSpreadsheet size={16} />
</ThemeIcon> </ThemeIcon>
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm"> <Badge color="cyan" size="sm">Up Next</Badge>
{budgetUploaded ? 'Done' : 'Skipped'}
</Badge>
<Text size="xs" mt={4}>Budget</Text> <Text size="xs" mt={4}>Budget</Text>
</Card> </Card>
</SimpleGrid> </SimpleGrid>
<Alert icon={<IconFileSpreadsheet size={16} />} color="blue" variant="light" mb="lg" ta="left">
<Text size="sm" fw={500} mb={4}>Set Up Your Budget</Text>
<Text size="sm" c="dimmed">
Head to <Text span fw={600}>Budget Planning</Text> from the sidebar to download a CSV template,
fill in your monthly amounts, and upload your budget. You can do this at any time.
</Text>
</Alert>
<Button <Button
size="lg" size="lg"
onClick={handleFinish} onClick={handleFinish}
@@ -625,18 +512,13 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
)} )}
{/* ── Navigation Buttons ── */} {/* ── Navigation Buttons ── */}
{active < 3 && ( {active < 2 && (
<Group justify="flex-end" mt="xl"> <Group justify="flex-end" mt="xl">
{active === 2 && !budgetUploaded && (
<Button variant="subtle" onClick={nextStep}>
Skip for now
</Button>
)}
<Button <Button
onClick={nextStep} onClick={nextStep}
disabled={!canGoNext()} disabled={!canGoNext()}
> >
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'} Next Step
</Button> </Button>
</Group> </Group>
)} )}

View File

@@ -1,13 +1,13 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, NumberInput, Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card, Alert, Modal, Select, Loader, Center, Badge, Card, Alert, Modal, ThemeIcon,
} from '@mantine/core'; } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { import {
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
IconCheck, IconArrowBack, IconTrash, IconRefresh, IconCheck, IconArrowBack, IconTrash, IconRefresh,
IconUpload, IconDownload, IconUpload, IconDownload, IconFileSpreadsheet,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
@@ -659,7 +659,37 @@ export function BudgetPlanningPage() {
{lineData.length === 0 && ( {lineData.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={15}> <Table.Td colSpan={15}>
<Text ta="center" c="dimmed" py="lg">No budget plan lines.</Text> <Card withBorder p="xl" mx="auto" maw={600} my="lg" style={{ textAlign: 'center' }}>
<ThemeIcon size={60} radius="xl" variant="light" color="blue" mx="auto" mb="md">
<IconFileSpreadsheet size={28} />
</ThemeIcon>
<Title order={4} mb="xs">Get Started with Your {selectedYear} Budget</Title>
<Text c="dimmed" size="sm" mb="lg" maw={450} mx="auto">
Your budget plan is created but has no line items yet. Download the
CSV template pre-filled with your chart of accounts, fill in your
monthly amounts, then upload it here.
</Text>
<Group justify="center" gap="md">
<Button
variant="light"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download Budget Template
</Button>
<Button
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Upload Budget CSV
</Button>
</Group>
<Text size="xs" c="dimmed" mt="md">
Tip: The template includes all your active accounts. Fill in the monthly
dollar amounts for each line, save as CSV, then upload.
</Text>
</Card>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
)} )}

View File

@@ -24,6 +24,7 @@ interface SubscriptionInfo {
trialEndsAt: string | null; trialEndsAt: string | null;
currentPeriodEnd: string | null; currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean; cancelAtPeriodEnd: boolean;
hasStripeCustomer: boolean;
} }
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
@@ -68,8 +69,9 @@ export function SettingsPage() {
if (data.url) { if (data.url) {
window.location.href = data.url; window.location.href = data.url;
} }
} catch { } catch (err: any) {
notifications.show({ message: 'Unable to open billing portal', color: 'red' }); const msg = err.response?.data?.message || 'Unable to open billing portal';
notifications.show({ message: typeof msg === 'string' ? msg : 'Unable to open billing portal', color: 'red' });
} finally { } finally {
setPortalLoading(false); setPortalLoading(false);
} }
@@ -167,6 +169,7 @@ export function SettingsPage() {
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge> <Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
</Group> </Group>
)} )}
{subscription.hasStripeCustomer ? (
<Button <Button
variant="light" variant="light"
color="teal" color="teal"
@@ -178,6 +181,11 @@ export function SettingsPage() {
> >
Manage Billing Manage Billing
</Button> </Button>
) : subscription.status === 'trial' ? (
<Text size="xs" c="dimmed" mt="xs">
Billing portal will be available once you add a payment method.
</Text>
) : null}
</Stack> </Stack>
) : ( ) : (
<Text size="sm" c="dimmed">No active subscription</Text> <Text size="sm" c="dimmed">No active subscription</Text>