Bug & tweak sprint: fix financial calculations, add quarterly report, enhance dashboard

- Fix Accounts page: include investment accounts in Est. Monthly Interest calc,
  add Fund column to investment table, split summary cards into Operating/Reserve
- Fix Cash Flow: ending balance now respects includeInvestments toggle
- Fix Budget Manager: separate operating/reserve income in summary cards
- Fix Projects: default sort by planned_date instead of name
- Add Vendors: last_negotiated date field with migration, CSV import/export
- New Quarterly Financial Report: budget vs actuals, over-budget flagging, YTD
- Enhance Dashboard: separate Operating/Reserve fund cards, expanded Quick Stats
  with monthly interest, YTD interest earned, planned capital spend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:17:30 -05:00
parent 2fed5d6ce1
commit 0e82e238c1
13 changed files with 738 additions and 88 deletions

View File

@@ -202,6 +202,7 @@ export class TenantSchemaService {
default_account_id UUID REFERENCES "${s}".accounts(id),
is_active BOOLEAN DEFAULT TRUE,
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
last_negotiated DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,

View File

@@ -7,7 +7,7 @@ export class ProjectsService {
async findAll() {
const projects = await this.tenant.query(
'SELECT * FROM projects WHERE is_active = true ORDER BY name',
'SELECT * FROM projects WHERE is_active = true ORDER BY planned_date NULLS LAST, target_year NULLS LAST, target_month NULLS LAST, name',
);
return this.computeFunding(projects);
}

View File

@@ -66,4 +66,20 @@ export class ReportsController {
const mo = Math.min(parseInt(months || '') || 24, 48);
return this.reportsService.getCashFlowForecast(yr, mo);
}
@Get('quarterly')
getQuarterlyFinancial(
@Query('year') year?: string,
@Query('quarter') quarter?: string,
) {
const now = new Date();
const defaultYear = now.getFullYear();
// Default to last complete quarter
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4;
const defaultQYear = currentQuarter > 1 ? defaultYear : defaultYear - 1;
const yr = parseInt(year || '') || defaultQYear;
const q = Math.min(Math.max(parseInt(quarter || '') || defaultQuarter, 1), 4);
return this.reportsService.getQuarterlyFinancial(yr, q);
}
}

View File

@@ -273,7 +273,8 @@ export class ReportsService {
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance;
// Only include investment balances in ending balance when includeInvestments is toggled on
const endingBalance = parseFloat(endCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
return {
from, to,
@@ -444,24 +445,43 @@ export class ReportsService {
}
async getDashboardKPIs() {
// Total cash: ALL asset accounts (not just those named "Cash")
// Uses proper double-entry balance: debit - credit for assets
const cash = await this.tenant.query(`
// Operating cash (asset accounts, fund_type=operating)
const opCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.is_active = true
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
GROUP BY a.id
) sub
`);
// Also include investment account current_value in total cash
const investmentCash = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE is_active = true
// Reserve cash (asset accounts, fund_type=reserve)
const resCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
GROUP BY a.id
) sub
`);
const totalCash = parseFloat(cash[0]?.total || '0') + parseFloat(investmentCash[0]?.total || '0');
// Investment accounts split by fund type
const opInv = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
`);
const resInv = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
const operatingCash = parseFloat(opCash[0]?.total || '0');
const reserveCash = parseFloat(resCash[0]?.total || '0');
const operatingInvestments = parseFloat(opInv[0]?.total || '0');
const reserveInvestments = parseFloat(resInv[0]?.total || '0');
const totalCash = operatingCash + reserveCash + operatingInvestments + reserveInvestments;
// Receivables: sum of unpaid invoices
const ar = await this.tenant.query(`
@@ -469,9 +489,7 @@ export class ReportsService {
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
`);
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100)
// The equity accounts track the total reserve fund position via double-entry bookkeeping
// This is the standard HOA approach — every reserve contribution/expenditure flows through equity
// Reserve fund balance via equity accounts + reserve investments
const reserves = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
@@ -482,17 +500,43 @@ export class ReportsService {
GROUP BY a.id
) sub
`);
// Add reserve investment account values to the reserve fund total
const reserveInvestments = await this.tenant.query(`
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
`);
// Delinquent count (overdue invoices)
const delinquent = await this.tenant.query(`
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
`);
// Monthly interest estimate from accounts + investments with rates
const acctInterest = await this.tenant.query(`
SELECT COALESCE(SUM(
(COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) * (a.interest_rate / 100) / 12
), 0) as total
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.is_active = true AND a.interest_rate > 0
GROUP BY a.id
`);
const acctInterestTotal = (acctInterest || []).reduce((s: number, r: any) => s + parseFloat(r.total || '0'), 0);
const invInterest = await this.tenant.query(`
SELECT COALESCE(SUM(current_value * interest_rate / 100 / 12), 0) as total
FROM investment_accounts WHERE is_active = true AND interest_rate > 0
`);
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
// Interest earned YTD from investment accounts
const interestEarned = await this.tenant.query(`
SELECT COALESCE(SUM(interest_earned), 0) as total
FROM investment_accounts WHERE is_active = true
`);
// Planned capital spend for current year
const currentYear = new Date().getFullYear();
const capitalSpend = await this.tenant.query(`
SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
`, [currentYear]);
// Recent transactions
const recentTx = await this.tenant.query(`
SELECT je.id, je.entry_date, je.description, je.entry_type,
@@ -504,9 +548,17 @@ export class ReportsService {
return {
total_cash: totalCash.toFixed(2),
total_receivables: ar[0]?.total || '0.00',
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2),
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + reserveInvestments).toFixed(2),
delinquent_units: parseInt(delinquent[0]?.count || '0'),
recent_transactions: recentTx,
// Enhanced split data
operating_cash: operatingCash.toFixed(2),
reserve_cash: reserveCash.toFixed(2),
operating_investments: operatingInvestments.toFixed(2),
reserve_investments: reserveInvestments.toFixed(2),
est_monthly_interest: estMonthlyInterest.toFixed(2),
interest_earned_ytd: interestEarned[0]?.total || '0.00',
planned_capital_spend: capitalSpend[0]?.total || '0.00',
};
}
@@ -795,4 +847,168 @@ export class ReportsService {
datapoints,
};
}
/**
* Quarterly Financial Report: quarter income statement, YTD income statement,
* budget vs actuals for the quarter and YTD, and over-budget items.
*/
async getQuarterlyFinancial(year: number, quarter: number) {
// Quarter date ranges
const qStartMonths = [1, 4, 7, 10];
const qEndMonths = [3, 6, 9, 12];
const qStart = `${year}-${String(qStartMonths[quarter - 1]).padStart(2, '0')}-01`;
const qEndMonth = qEndMonths[quarter - 1];
const qEndDay = [31, 30, 30, 31][quarter - 1]; // Mar=31, Jun=30, Sep=30, Dec=31
const qEnd = `${year}-${String(qEndMonth).padStart(2, '0')}-${qEndDay}`;
const ytdStart = `${year}-01-01`;
// Quarter and YTD income statements (reuse existing method)
const quarterIS = await this.getIncomeStatement(qStart, qEnd);
const ytdIS = await this.getIncomeStatement(ytdStart, qEnd);
// Budget data for the quarter months
const budgetMonthCols = {
1: ['jan', 'feb', 'mar'],
2: ['apr', 'may', 'jun'],
3: ['jul', 'aug', 'sep'],
4: ['oct', 'nov', 'dec_amt'],
} as Record<number, string[]>;
const ytdMonthCols = {
1: ['jan', 'feb', 'mar'],
2: ['jan', 'feb', 'mar', 'apr', 'may', 'jun'],
3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep'],
4: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'],
} as Record<number, string[]>;
const qCols = budgetMonthCols[quarter];
const ytdCols = ytdMonthCols[quarter];
const budgetRows = await this.tenant.query(
`SELECT b.account_id, a.account_number, a.name, a.account_type, a.fund_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1`, [year],
);
// Actual amounts per account for the quarter and YTD
const quarterActuals = await this.tenant.query(`
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
`, [qStart, qEnd]);
const ytdActuals = await this.tenant.query(`
SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
`, [ytdStart, qEnd]);
// Build budget vs actual comparison
const actualsByIdQ = new Map<string, number>();
for (const a of quarterActuals) {
actualsByIdQ.set(a.account_id, parseFloat(a.amount) || 0);
}
const actualsByIdYTD = new Map<string, number>();
for (const a of ytdActuals) {
actualsByIdYTD.set(a.account_id, parseFloat(a.amount) || 0);
}
const budgetVsActual: any[] = [];
const overBudgetItems: any[] = [];
for (const b of budgetRows) {
const qBudget = qCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
const ytdBudget = ytdCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0);
const qActual = actualsByIdQ.get(b.account_id) || 0;
const ytdActual = actualsByIdYTD.get(b.account_id) || 0;
if (qBudget === 0 && ytdBudget === 0 && qActual === 0 && ytdActual === 0) continue;
const qVariance = qActual - qBudget;
const ytdVariance = ytdActual - ytdBudget;
const isExpense = b.account_type === 'expense';
const item = {
account_id: b.account_id,
account_number: b.account_number,
name: b.name,
account_type: b.account_type,
fund_type: b.fund_type,
quarter_budget: qBudget,
quarter_actual: qActual,
quarter_variance: qVariance,
ytd_budget: ytdBudget,
ytd_actual: ytdActual,
ytd_variance: ytdVariance,
};
budgetVsActual.push(item);
// Flag expenses over budget by more than 10%
if (isExpense && qBudget > 0 && qActual > qBudget * 1.1) {
overBudgetItems.push({
...item,
variance_pct: ((qActual / qBudget - 1) * 100).toFixed(1),
});
}
}
// Also include accounts with actuals but no budget
for (const a of quarterActuals) {
if (!budgetRows.find((b: any) => b.account_id === a.account_id)) {
const ytdActual = actualsByIdYTD.get(a.account_id) || 0;
budgetVsActual.push({
account_id: a.account_id,
account_number: a.account_number,
name: a.name,
account_type: a.account_type,
fund_type: a.fund_type,
quarter_budget: 0,
quarter_actual: parseFloat(a.amount) || 0,
quarter_variance: parseFloat(a.amount) || 0,
ytd_budget: 0,
ytd_actual: ytdActual,
ytd_variance: ytdActual,
});
}
}
// Sort: income first, then expenses, both by account number
budgetVsActual.sort((a: any, b: any) => {
if (a.account_type !== b.account_type) return a.account_type === 'income' ? -1 : 1;
return (a.account_number || '').localeCompare(b.account_number || '');
});
return {
year,
quarter,
quarter_label: `Q${quarter} ${year}`,
date_range: { from: qStart, to: qEnd },
quarter_income_statement: quarterIS,
ytd_income_statement: ytdIS,
budget_vs_actual: budgetVsActual,
over_budget_items: overBudgetItems,
};
}
}

View File

@@ -17,10 +17,10 @@ export class VendorsService {
async create(dto: any) {
const rows = await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id, last_negotiated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code,
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null],
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null, dto.last_negotiated || null],
);
return rows[0];
}
@@ -32,24 +32,25 @@ export class VendorsService {
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code),
tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible),
default_account_id = COALESCE($12, default_account_id), updated_at = NOW()
default_account_id = COALESCE($12, default_account_id), last_negotiated = $13, updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id],
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id, dto.last_negotiated || null],
);
return rows[0];
}
async exportCSV(): Promise<string> {
const rows = await this.tenant.query(
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated
FROM vendors WHERE is_active = true ORDER BY name`,
);
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible'];
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible', 'last_negotiated'];
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(headers.map((h) => {
const v = r[h] ?? '';
let v = r[h] ?? '';
if (v instanceof Date) v = v.toISOString().split('T')[0];
const s = String(v);
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
}).join(','));
@@ -80,20 +81,22 @@ export class VendorsService {
zip_code = COALESCE(NULLIF($8, ''), zip_code),
tax_id = COALESCE(NULLIF($9, ''), tax_id),
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
updated_at = NOW()
WHERE id = $1`,
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible],
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible, row.last_negotiated],
);
updated++;
} else {
await this.tenant.query(
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[name, row.contact_name || null, row.email || null, row.phone || null,
row.address_line1 || null, row.city || null, row.state || null,
row.zip_code || null, row.tax_id || null,
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false],
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false,
row.last_negotiated || null],
);
created++;
}