From db8b52000982df958f76185df2f69215812ad284 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 18 Mar 2026 09:43:49 -0400 Subject: [PATCH] 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 --- .../src/modules/accounts/accounts.service.ts | 12 +- .../accounts/dto/create-account.dto.ts | 5 + .../src/modules/billing/billing.service.ts | 45 +++- .../onboarding/OnboardingWizard.tsx | 236 +++++------------- .../board-planning/BudgetPlanningPage.tsx | 36 ++- frontend/src/pages/settings/SettingsPage.tsx | 34 ++- 6 files changed, 164 insertions(+), 204 deletions(-) diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts index 8a0a5bd..911b457 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -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', diff --git a/backend/src/modules/accounts/dto/create-account.dto.ts b/backend/src/modules/accounts/dto/create-account.dto.ts index b2455f7..b69474d 100644 --- a/backend/src/modules/accounts/dto/create-account.dto.ts +++ b/backend/src/modules/accounts/dto/create-account.dto.ts @@ -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; diff --git a/backend/src/modules/billing/billing.service.ts b/backend/src/modules/billing/billing.service.ts index 89f9223..05185c4 100644 --- a/backend/src/modules/billing/billing.service.ts +++ b/backend/src/modules/billing/billing.service.ts @@ -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, }; } diff --git a/frontend/src/components/onboarding/OnboardingWizard.tsx b/frontend/src/components/onboarding/OnboardingWizard.tsx index 880fed9..37cad41 100644 --- a/frontend/src/components/onboarding/OnboardingWizard.tsx +++ b/frontend/src/components/onboarding/OnboardingWizard.tsx @@ -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[] { - 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 = {}; - 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(0); + const [balanceDate, setBalanceDate] = useState(new Date()); // ── Step 2: Assessment Group State ── const [groupCreated, setGroupCreated] = useState(false); const [groupName, setGroupName] = useState('Standard Assessment'); const [regularAssessment, setRegularAssessment] = useState(0); const [frequency, setFrequency] = useState('monthly'); + const [unitCount, setUnitCount] = useState(0); const [units, setUnits] = useState([]); const [unitsCreated, setUnitsCreated] = useState(false); - // ── Step 3: Budget State ── - const [budgetFile, setBudgetFile] = useState(null); - const [budgetUploaded, setBudgetUploaded] = useState(false); - const [budgetImportResult, setBudgetImportResult] = useState(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={} completedIcon={} /> - } - completedIcon={} - /> {error && ( @@ -343,6 +259,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {accountName} created successfully! Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()} + {balanceDate && ` as of ${balanceDate.toLocaleDateString()}`} ) : ( @@ -372,17 +289,26 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) autosize minRows={2} /> - + + + + - - - } - /> - - - - )} - - - )} - {/* ── Completion Screen ── */} - {active === 3 && ( + {active === 2 && ( You're All Set! - 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. @@ -605,12 +487,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) - - {budgetUploaded ? 'Done' : 'Skipped'} - + Up Next Budget + } color="blue" variant="light" mb="lg" ta="left"> + Set Up Your Budget + + Head to Budget Planning from the sidebar to download a CSV template, + fill in your monthly amounts, and upload your budget. You can do this at any time. + + - )} )} diff --git a/frontend/src/pages/board-planning/BudgetPlanningPage.tsx b/frontend/src/pages/board-planning/BudgetPlanningPage.tsx index d51cd00..2f9a479 100644 --- a/frontend/src/pages/board-planning/BudgetPlanningPage.tsx +++ b/frontend/src/pages/board-planning/BudgetPlanningPage.tsx @@ -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 && ( - No budget plan lines. + + + + + Get Started with Your {selectedYear} Budget + + 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. + + + + + + + Tip: The template includes all your active accounts. Fill in the monthly + dollar amounts for each line, save as CSV, then upload. + + )} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 049e7f0..50d7cf2 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -24,6 +24,7 @@ interface SubscriptionInfo { trialEndsAt: string | null; currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean; + hasStripeCustomer: boolean; } const statusColors: Record = { @@ -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,17 +169,23 @@ export function SettingsPage() { Invoice / ACH )} - + {subscription.hasStripeCustomer ? ( + + ) : subscription.status === 'trial' ? ( + + Billing portal will be available once you add a payment method. + + ) : null} ) : ( No active subscription