From 61e43212b92de89bf0b7e158cceb571fbe6cc67b Mon Sep 17 00:00:00 2001 From: olsch01 Date: Sat, 21 Feb 2026 14:24:00 -0500 Subject: [PATCH] Flexible budget import with auto-account creation and text-based account numbers Change account_number from INTEGER to VARCHAR(50) to support segmented codes like 30-3001-0000 used by real HOA accounting systems. Budget CSV import now: - Auto-creates income/expense accounts from CSV when they don't exist - Infers account_type and fund_type from account number prefix conventions - Parses currency-formatted values ($48,065.21, $(13,000.00), $-, etc.) - Reports created accounts back to the user Co-Authored-By: Claude Opus 4.6 --- backend/src/database/tenant-schema.service.ts | 2 +- .../src/modules/accounts/accounts.service.ts | 4 +- .../accounts/dto/create-account.dto.ts | 8 +- .../accounts/dto/update-account.dto.ts | 6 +- .../src/modules/budgets/budgets.service.ts | 129 +++++++++++++++--- .../investments/investments.service.ts | 2 +- .../src/modules/invoices/invoices.service.ts | 4 +- .../src/modules/payments/payments.service.ts | 4 +- frontend/src/pages/accounts/AccountsPage.tsx | 10 +- frontend/src/pages/budgets/BudgetsPage.tsx | 72 +++++++--- .../src/pages/reports/BalanceSheetPage.tsx | 2 +- .../src/pages/reports/BudgetVsActualPage.tsx | 2 +- .../src/pages/reports/IncomeStatementPage.tsx | 2 +- .../pages/transactions/TransactionsPage.tsx | 4 +- 14 files changed, 188 insertions(+), 63 deletions(-) diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index adef55e..d46a67e 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -35,7 +35,7 @@ export class TenantSchemaService { // Accounts (Chart of Accounts) `CREATE TABLE "${s}".accounts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - account_number INTEGER NOT NULL, + account_number VARCHAR(50) NOT NULL, name VARCHAR(255) NOT NULL, description TEXT, account_type VARCHAR(50) NOT NULL CHECK (account_type IN ('asset', 'liability', 'equity', 'income', 'expense')), diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts index 0a6d1b4..02901b6 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -92,7 +92,7 @@ export class AccountsService { const acctCredit = isDebitNormal ? 0 : absAmount; // Determine equity offset account based on fund type (auto-create if missing) - const equityAccountNumber = dto.fundType === 'reserve' ? 3100 : 3000; + const equityAccountNumber = dto.fundType === 'reserve' ? '3100' : '3000'; const equityName = dto.fundType === 'reserve' ? 'Reserve Fund Balance' : 'Operating Fund Balance'; let equityRows = await this.tenant.query( 'SELECT id FROM accounts WHERE account_number = $1', @@ -247,7 +247,7 @@ export class AccountsService { const fiscalPeriodId = periods[0].id; // Determine the equity offset account based on fund_type - const equityAccountNumber = account.fund_type === 'reserve' ? 3100 : 3000; + const equityAccountNumber = account.fund_type === 'reserve' ? '3100' : '3000'; const equityRows = await this.tenant.query( 'SELECT id, account_type FROM accounts WHERE account_number = $1', [equityAccountNumber], diff --git a/backend/src/modules/accounts/dto/create-account.dto.ts b/backend/src/modules/accounts/dto/create-account.dto.ts index 9682ae0..2ad013e 100644 --- a/backend/src/modules/accounts/dto/create-account.dto.ts +++ b/backend/src/modules/accounts/dto/create-account.dto.ts @@ -1,10 +1,10 @@ -import { IsString, IsInt, IsOptional, IsBoolean, IsIn, IsUUID } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsIn, IsUUID } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class CreateAccountDto { - @ApiProperty({ example: 6600 }) - @IsInt() - accountNumber: number; + @ApiProperty({ example: '6600' }) + @IsString() + accountNumber: string; @ApiProperty({ example: 'Equipment Repairs' }) @IsString() diff --git a/backend/src/modules/accounts/dto/update-account.dto.ts b/backend/src/modules/accounts/dto/update-account.dto.ts index d8b2999..1c3b8d7 100644 --- a/backend/src/modules/accounts/dto/update-account.dto.ts +++ b/backend/src/modules/accounts/dto/update-account.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsBoolean, IsIn, IsInt } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class UpdateAccountDto { @@ -28,9 +28,9 @@ export class UpdateAccountDto { isActive?: boolean; @ApiProperty({ required: false }) - @IsInt() + @IsString() @IsOptional() - accountNumber?: number; + accountNumber?: string; @ApiProperty({ required: false }) @IsIn(['operating', 'reserve']) diff --git a/backend/src/modules/budgets/budgets.service.ts b/backend/src/modules/budgets/budgets.service.ts index 6eb5430..c9a3823 100644 --- a/backend/src/modules/budgets/budgets.service.ts +++ b/backend/src/modules/budgets/budgets.service.ts @@ -2,6 +2,82 @@ import { Injectable } from '@nestjs/common'; import { TenantService } from '../../database/tenant.service'; import { UpsertBudgetDto } from './dto/upsert-budget.dto'; +/** + * Parse a currency-formatted string into a number. + * Handles: "$48,065.21", " $- ", "$(13,000.00)", "$500.00 ", etc. + */ +function parseCurrency(val: string | number | undefined | null): number { + if (val === undefined || val === null) return 0; + if (typeof val === 'number') return val; + + let s = String(val).trim(); + if (!s || s === '-' || s === '$-' || s === '$ -' || s === '$- ') return 0; + + // Detect negative: $(1,000.00) format + const isNegative = s.includes('(') && s.includes(')'); + + // Remove $, commas, spaces, parentheses + s = s.replace(/[$,\s()]/g, ''); + if (!s || s === '-') return 0; + + const num = parseFloat(s); + if (isNaN(num)) return 0; + return isNegative ? -num : num; +} + +/** + * Infer account_type and fund_type from an account number prefix. + * Follows common HOA chart-of-accounts conventions: + * 10-19xx or 1xxx = asset + * 20-29xx or 2xxx = liability + * 30-39xx or 3xxx = income (operating) -- common HOA: 30 prefix = revenue + * 40-49xx or 4-5xxx = expense (operating) + * 60-69xx or 6xxx = expense (operating) -- utilities + * 70-79xx or 7xxx = expense (reserve) + * 80-89xx or 8xxx = expense (operating) -- admin + * 90-99xx or 9xxx = income/expense (reserve) depending on name + */ +function inferAccountType(accountNumber: string, accountName: string): { accountType: string; fundType: string } { + const prefix = accountNumber.split('-')[0].trim(); + const prefixNum = parseInt(prefix, 10); + + if (isNaN(prefixNum)) { + // Can't infer, default to expense/operating + return { accountType: 'expense', fundType: 'operating' }; + } + + // Check for reserve keywords in name + const nameUpper = (accountName || '').toUpperCase(); + const isReserve = nameUpper.includes('RESERVE') || + nameUpper.includes('CAPITAL') || + prefixNum >= 90; + + if (prefixNum >= 10 && prefixNum <= 19) return { accountType: 'asset', fundType: 'operating' }; + if (prefixNum >= 20 && prefixNum <= 29) return { accountType: 'liability', fundType: 'operating' }; + if (prefixNum >= 30 && prefixNum <= 39) return { accountType: 'income', fundType: 'operating' }; + if (prefixNum >= 40 && prefixNum <= 69) return { accountType: 'expense', fundType: 'operating' }; + if (prefixNum >= 70 && prefixNum <= 79) return { accountType: 'expense', fundType: 'reserve' }; + if (prefixNum >= 80 && prefixNum <= 89) return { accountType: 'expense', fundType: 'operating' }; + if (prefixNum >= 90 && prefixNum <= 99) { + // 90-series: could be reserve income or reserve expense + // Look for disbursement/expense keywords + if (nameUpper.includes('DISBURSEMENT') || nameUpper.includes('EXPENSE') || nameUpper.includes('CAPITAL EXPENSE')) { + return { accountType: 'expense', fundType: 'reserve' }; + } + return { accountType: 'income', fundType: 'reserve' }; + } + + // For simple numeric codes (like existing 4000, 5000 series) + if (prefixNum >= 1000 && prefixNum < 2000) return { accountType: 'asset', fundType: 'operating' }; + if (prefixNum >= 2000 && prefixNum < 3000) return { accountType: 'liability', fundType: 'operating' }; + if (prefixNum >= 3000 && prefixNum < 4000) return { accountType: 'equity', fundType: prefixNum >= 3100 ? 'reserve' : 'operating' }; + if (prefixNum >= 4000 && prefixNum < 5000) return { accountType: 'income', fundType: 'operating' }; + if (prefixNum >= 5000 && prefixNum < 7000) return { accountType: 'expense', fundType: 'operating' }; + if (prefixNum >= 7000 && prefixNum < 8000) return { accountType: 'expense', fundType: 'reserve' }; + + return { accountType: 'expense', fundType: 'operating' }; +} + @Injectable() export class BudgetsService { constructor(private tenant: TenantService) {} @@ -9,23 +85,40 @@ export class BudgetsService { async importBudget(year: number, lines: any[]) { const results = []; const errors: string[] = []; + const created: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const accountNumber = String(line.accountNumber || line.account_number || '').trim(); + const accountName = String(line.accountName || line.account_name || '').trim(); if (!accountNumber) { errors.push(`Row ${i + 1}: missing account_number`); continue; } - // Look up account by account_number - const accounts = await this.tenant.query( - `SELECT id, fund_type FROM accounts WHERE account_number = $1 AND is_active = true`, - [parseInt(accountNumber, 10)], + // Look up account by account_number (text match) + let accounts = await this.tenant.query( + `SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`, + [accountNumber], ); + // Auto-create account if not found and we have a name + if ((!accounts || accounts.length === 0) && accountName) { + const { accountType, fundType } = inferAccountType(accountNumber, accountName); + await this.tenant.query( + `INSERT INTO accounts (account_number, name, account_type, fund_type, is_system) + VALUES ($1, $2, $3, $4, false)`, + [accountNumber, accountName, accountType, fundType], + ); + accounts = await this.tenant.query( + `SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`, + [accountNumber], + ); + created.push(`${accountNumber} - ${accountName} (${accountType}/${fundType})`); + } + if (!accounts || accounts.length === 0) { - errors.push(`Row ${i + 1}: account_number ${accountNumber} not found`); + errors.push(`Row ${i + 1}: account_number "${accountNumber}" not found and no account_name provided to auto-create`); continue; } @@ -42,25 +135,25 @@ export class BudgetsService { year, account.id, fundType, - parseFloat(line.jan) || 0, - parseFloat(line.feb) || 0, - parseFloat(line.mar) || 0, - parseFloat(line.apr) || 0, - parseFloat(line.may) || 0, - parseFloat(line.jun) || 0, - parseFloat(line.jul) || 0, - parseFloat(line.aug) || 0, - parseFloat(line.sep) || 0, - parseFloat(line.oct) || 0, - parseFloat(line.nov) || 0, - parseFloat(line.dec_amt || line.dec) || 0, + parseCurrency(line.jan), + parseCurrency(line.feb), + parseCurrency(line.mar), + parseCurrency(line.apr), + parseCurrency(line.may), + parseCurrency(line.jun), + parseCurrency(line.jul), + parseCurrency(line.aug), + parseCurrency(line.sep), + parseCurrency(line.oct), + parseCurrency(line.nov), + parseCurrency(line.dec_amt || line.dec), line.notes || null, ], ); results.push(rows[0]); } - return { imported: results.length, errors }; + return { imported: results.length, errors, created }; } async getTemplate(year: number): Promise { diff --git a/backend/src/modules/investments/investments.service.ts b/backend/src/modules/investments/investments.service.ts index 7a97968..5c698f2 100644 --- a/backend/src/modules/investments/investments.service.ts +++ b/backend/src/modules/investments/investments.service.ts @@ -58,7 +58,7 @@ export class InvestmentsService { ); if (primaryRows.length) { const primaryAccount = primaryRows[0]; - const equityAccountNumber = fundType === 'reserve' ? 3100 : 3000; + const equityAccountNumber = fundType === 'reserve' ? '3100' : '3000'; const equityRows = await this.tenant.query( 'SELECT id FROM accounts WHERE account_number = $1', [equityAccountNumber], diff --git a/backend/src/modules/invoices/invoices.service.ts b/backend/src/modules/invoices/invoices.service.ts index def3b97..1d703af 100644 --- a/backend/src/modules/invoices/invoices.service.ts +++ b/backend/src/modules/invoices/invoices.service.ts @@ -64,8 +64,8 @@ export class InvoicesService { ); // Create journal entry: DR Accounts Receivable, CR Assessment Income - const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1200`); - const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 4000`); + const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`); + const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`); if (arAccount.length && incomeAccount.length) { const je = await this.tenant.query( diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts index fafe670..2a8715c 100644 --- a/backend/src/modules/payments/payments.service.ts +++ b/backend/src/modules/payments/payments.service.ts @@ -58,8 +58,8 @@ export class PaymentsService { ); // Create journal entry: DR Cash, CR Accounts Receivable - const cashAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1000`); - const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1200`); + const cashAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1000'`); + const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`); if (cashAccount.length && arAccount.length) { const je = await this.tenant.query( diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index 9dfd23e..5e5d339 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -61,7 +61,7 @@ const ACCOUNT_TYPE_OPTIONS = [ interface Account { id: string; - account_number: number; + account_number: string; name: string; description: string; account_type: string; @@ -93,7 +93,7 @@ interface Investment { interface TrialBalanceEntry { id: string; - account_number: number; + account_number: string; name: string; account_type: string; fund_type: string; @@ -158,7 +158,7 @@ export function AccountsPage() { // ── Create / Edit form ── const form = useForm({ initialValues: { - accountNumber: 0, + accountNumber: '', name: '', description: '', accountType: 'expense', @@ -176,7 +176,7 @@ export function AccountsPage() { withdrawFromPrimary: true, }, validate: { - accountNumber: (v, values) => isInvestmentType(values.accountType) ? null : (v > 0 ? null : 'Required'), + accountNumber: (v, values) => isInvestmentType(values.accountType) ? null : (v.trim().length > 0 ? null : 'Required'), name: (v) => (v.length > 0 ? null : 'Required'), principal: (v, values) => isInvestmentType(values.accountType) ? (v > 0 ? null : 'Required') : null, }, @@ -609,7 +609,7 @@ export function AccountsPage() { {!isInvestmentType(form.values.accountType) && ( <> - + {editing && (