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 <noreply@anthropic.com>
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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<string> {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user