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:
@@ -74,9 +74,9 @@ export class AccountsService {
|
||||
|
||||
// Create opening balance journal entry if initialBalance is provided and non-zero
|
||||
if (dto.initialBalance && dto.initialBalance !== 0) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date();
|
||||
const year = balanceDate.getFullYear();
|
||||
const month = balanceDate.getMonth() + 1;
|
||||
|
||||
// Find the current fiscal period
|
||||
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(
|
||||
`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`,
|
||||
[
|
||||
entryDate,
|
||||
`Opening balance for ${dto.name}`,
|
||||
fiscalPeriodId,
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
|
||||
@@ -37,6 +37,11 @@ export class CreateAccountDto {
|
||||
@IsOptional()
|
||||
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' })
|
||||
@IsOptional()
|
||||
interestRate?: number;
|
||||
|
||||
@@ -279,18 +279,49 @@ export class BillingService {
|
||||
* Create a Stripe Customer Portal session for managing subscription.
|
||||
*/
|
||||
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(
|
||||
`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],
|
||||
);
|
||||
if (rows.length === 0 || !rows[0].stripe_customer_id) {
|
||||
throw new BadRequestException('No Stripe customer found for this organization');
|
||||
if (rows.length === 0) {
|
||||
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({
|
||||
customer: rows[0].stripe_customer_id,
|
||||
customer: customerId,
|
||||
return_url: `${this.getAppUrl()}/settings`,
|
||||
});
|
||||
|
||||
@@ -311,10 +342,11 @@ export class BillingService {
|
||||
trialEndsAt: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
hasStripeCustomer: boolean;
|
||||
}> {
|
||||
const rows = await this.dataSource.query(
|
||||
`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`,
|
||||
[orgId],
|
||||
);
|
||||
@@ -351,6 +383,7 @@ export class BillingService {
|
||||
trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd,
|
||||
hasStripeCustomer: !!org.stripe_customer_id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
|
||||
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
|
||||
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
|
||||
Select, Stack, Text, Title, Alert, ActionIcon, Table,
|
||||
Card, ThemeIcon, Divider, Badge, SimpleGrid, Box,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBuildingBank, IconUsers, IconFileSpreadsheet,
|
||||
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
|
||||
IconAlertCircle,
|
||||
IconBuildingBank, IconUsers,
|
||||
IconPlus, IconTrash, IconCheck, IconRocket,
|
||||
IconAlertCircle, IconFileSpreadsheet,
|
||||
} from '@tabler/icons-react';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
@@ -24,27 +25,6 @@ interface UnitRow {
|
||||
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) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -57,22 +37,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
const [accountNumber, setAccountNumber] = useState('1000');
|
||||
const [accountDescription, setAccountDescription] = useState('');
|
||||
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
||||
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
|
||||
|
||||
// ── Step 2: Assessment Group State ──
|
||||
const [groupCreated, setGroupCreated] = useState(false);
|
||||
const [groupName, setGroupName] = useState('Standard Assessment');
|
||||
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
||||
const [frequency, setFrequency] = useState('monthly');
|
||||
const [unitCount, setUnitCount] = useState<number | string>(0);
|
||||
const [units, setUnits] = useState<UnitRow[]>([]);
|
||||
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 ──
|
||||
const handleCreateAccount = async () => {
|
||||
if (!accountName.trim()) {
|
||||
@@ -99,6 +74,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
accountType: 'asset',
|
||||
fundType: 'operating',
|
||||
initialBalance: balance,
|
||||
initialBalanceDate: balanceDate ? balanceDate.toISOString().split('T')[0] : undefined,
|
||||
});
|
||||
setAccountCreated(true);
|
||||
notifications.show({
|
||||
@@ -126,6 +102,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const count = typeof unitCount === 'string' ? parseInt(unitCount) : unitCount;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -133,6 +111,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
name: groupName.trim(),
|
||||
regularAssessment: assessment,
|
||||
frequency,
|
||||
unitCount: isNaN(count) ? 0 : count,
|
||||
isDefault: 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 ──
|
||||
const handleFinish = async () => {
|
||||
setLoading(true);
|
||||
@@ -265,13 +188,12 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
const canGoNext = () => {
|
||||
if (active === 0) return accountCreated;
|
||||
if (active === 1) return groupCreated;
|
||||
if (active === 2) return true; // Budget is optional
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
setError(null);
|
||||
if (active < 3) setActive(active + 1);
|
||||
if (active < 2) setActive(active + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -315,12 +237,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
icon={<IconUsers size={18} />}
|
||||
completedIcon={<IconCheck size={18} />}
|
||||
/>
|
||||
<Stepper.Step
|
||||
label="Budget"
|
||||
description="Import your annual budget"
|
||||
icon={<IconFileSpreadsheet size={18} />}
|
||||
completedIcon={<IconCheck size={18} />}
|
||||
/>
|
||||
</Stepper>
|
||||
|
||||
{error && (
|
||||
@@ -343,6 +259,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
<Text fw={500}>{accountName} created successfully!</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
|
||||
{balanceDate && ` as of ${balanceDate.toLocaleDateString()}`}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -372,6 +289,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<SimpleGrid cols={2} mb="md">
|
||||
<NumberInput
|
||||
label="Current Balance"
|
||||
description="Enter the current balance of this bank account"
|
||||
@@ -381,8 +299,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
thousandSeparator=","
|
||||
prefix="$"
|
||||
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
|
||||
onClick={handleCreateAccount}
|
||||
loading={loading}
|
||||
@@ -415,7 +341,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={3} mb="md">
|
||||
<SimpleGrid cols={2} mb="md">
|
||||
<TextInput
|
||||
label="Group Name"
|
||||
placeholder="e.g. Standard Assessment"
|
||||
@@ -423,6 +349,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
onChange={(e) => setGroupName(e.currentTarget.value)}
|
||||
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
|
||||
label="Assessment Amount"
|
||||
placeholder="0.00"
|
||||
@@ -520,71 +457,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
</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'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 ── */}
|
||||
{active === 3 && (
|
||||
{active === 2 && (
|
||||
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
||||
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
||||
<IconCheck size={32} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} mb="xs">You're All Set!</Title>
|
||||
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
||||
Your organization is configured and ready to go. You can always update your accounts,
|
||||
assessment groups, and budgets from the sidebar navigation.
|
||||
Your organization is configured and ready to go. You can always update your accounts
|
||||
and assessment groups from the sidebar navigation.
|
||||
</Text>
|
||||
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
||||
<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}>
|
||||
<IconFileSpreadsheet size={16} />
|
||||
</ThemeIcon>
|
||||
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
|
||||
{budgetUploaded ? 'Done' : 'Skipped'}
|
||||
</Badge>
|
||||
<Badge color="cyan" size="sm">Up Next</Badge>
|
||||
<Text size="xs" mt={4}>Budget</Text>
|
||||
</Card>
|
||||
</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
|
||||
size="lg"
|
||||
onClick={handleFinish}
|
||||
@@ -625,18 +512,13 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
||||
)}
|
||||
|
||||
{/* ── Navigation Buttons ── */}
|
||||
{active < 3 && (
|
||||
{active < 2 && (
|
||||
<Group justify="flex-end" mt="xl">
|
||||
{active === 2 && !budgetUploaded && (
|
||||
<Button variant="subtle" onClick={nextStep}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={!canGoNext()}
|
||||
>
|
||||
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
|
||||
Next Step
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
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';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
|
||||
IconCheck, IconArrowBack, IconTrash, IconRefresh,
|
||||
IconUpload, IconDownload,
|
||||
IconUpload, IconDownload, IconFileSpreadsheet,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
@@ -659,7 +659,37 @@ export function BudgetPlanningPage() {
|
||||
{lineData.length === 0 && (
|
||||
<Table.Tr>
|
||||
<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.Tr>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface SubscriptionInfo {
|
||||
trialEndsAt: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
hasStripeCustomer: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
@@ -68,8 +69,9 @@ export function SettingsPage() {
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch {
|
||||
notifications.show({ message: 'Unable to open billing portal', color: 'red' });
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
setPortalLoading(false);
|
||||
}
|
||||
@@ -167,6 +169,7 @@ export function SettingsPage() {
|
||||
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
|
||||
</Group>
|
||||
)}
|
||||
{subscription.hasStripeCustomer ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
@@ -178,6 +181,11 @@ export function SettingsPage() {
|
||||
>
|
||||
Manage Billing
|
||||
</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>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">No active subscription</Text>
|
||||
|
||||
Reference in New Issue
Block a user