|
|
|
|
@@ -178,6 +178,221 @@ export class ReportsService {
|
|
|
|
|
return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getCashFlowStatement(from: string, to: string) {
|
|
|
|
|
// Operating activities: income minus expenses from journal entries
|
|
|
|
|
const operating = await this.tenant.query(`
|
|
|
|
|
SELECT a.name, a.account_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
|
|
|
|
|
AND a.fund_type = 'operating'
|
|
|
|
|
GROUP BY a.id, a.name, a.account_type
|
|
|
|
|
HAVING ABS(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) > 0
|
|
|
|
|
ORDER BY a.account_type, a.name
|
|
|
|
|
`, [from, to]);
|
|
|
|
|
|
|
|
|
|
// Reserve fund activities
|
|
|
|
|
const reserve = await this.tenant.query(`
|
|
|
|
|
SELECT a.name,
|
|
|
|
|
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.is_active = true AND a.fund_type = 'reserve'
|
|
|
|
|
GROUP BY a.id, a.name, a.account_type
|
|
|
|
|
HAVING ABS(COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) > 0
|
|
|
|
|
ORDER BY a.name
|
|
|
|
|
`, [from, to]);
|
|
|
|
|
|
|
|
|
|
// Cash beginning and ending balances
|
|
|
|
|
const beginCash = await this.tenant.query(`
|
|
|
|
|
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
|
|
|
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
|
|
|
|
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
|
|
|
|
|
AND je.entry_date < $1
|
|
|
|
|
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
|
|
|
|
|
GROUP BY a.id
|
|
|
|
|
) sub
|
|
|
|
|
`, [from]);
|
|
|
|
|
|
|
|
|
|
const endCash = await this.tenant.query(`
|
|
|
|
|
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
|
|
|
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
|
|
|
|
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
|
|
|
|
|
AND je.entry_date <= $1
|
|
|
|
|
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
|
|
|
|
|
GROUP BY a.id
|
|
|
|
|
) sub
|
|
|
|
|
`, [to]);
|
|
|
|
|
|
|
|
|
|
const operatingItems = operating.map((r: any) => ({
|
|
|
|
|
name: r.name, type: r.account_type, amount: parseFloat(r.amount),
|
|
|
|
|
}));
|
|
|
|
|
const reserveItems = reserve.map((r: any) => ({
|
|
|
|
|
name: r.name, amount: parseFloat(r.amount),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
const endingBalance = parseFloat(endCash[0]?.balance || '0');
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
from, to,
|
|
|
|
|
operating_activities: operatingItems,
|
|
|
|
|
reserve_activities: reserveItems,
|
|
|
|
|
total_operating: totalOperating.toFixed(2),
|
|
|
|
|
total_reserve: totalReserve.toFixed(2),
|
|
|
|
|
net_cash_change: (totalOperating + totalReserve).toFixed(2),
|
|
|
|
|
beginning_cash: beginningBalance.toFixed(2),
|
|
|
|
|
ending_cash: endingBalance.toFixed(2),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getAgingReport() {
|
|
|
|
|
const sql = `
|
|
|
|
|
SELECT
|
|
|
|
|
u.id as unit_id, u.unit_number, u.owner_name,
|
|
|
|
|
i.id as invoice_id, i.invoice_number, i.description,
|
|
|
|
|
i.due_date, i.amount, i.amount_paid,
|
|
|
|
|
i.amount - i.amount_paid as balance_due,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN i.due_date >= CURRENT_DATE THEN 'current'
|
|
|
|
|
WHEN CURRENT_DATE - i.due_date BETWEEN 1 AND 30 THEN '1-30'
|
|
|
|
|
WHEN CURRENT_DATE - i.due_date BETWEEN 31 AND 60 THEN '31-60'
|
|
|
|
|
WHEN CURRENT_DATE - i.due_date BETWEEN 61 AND 90 THEN '61-90'
|
|
|
|
|
ELSE '90+'
|
|
|
|
|
END as aging_bucket
|
|
|
|
|
FROM invoices i
|
|
|
|
|
JOIN units u ON u.id = i.unit_id
|
|
|
|
|
WHERE i.status NOT IN ('paid', 'void', 'written_off')
|
|
|
|
|
AND i.amount - i.amount_paid > 0
|
|
|
|
|
ORDER BY u.unit_number, i.due_date
|
|
|
|
|
`;
|
|
|
|
|
const rows = await this.tenant.query(sql);
|
|
|
|
|
|
|
|
|
|
// Aggregate by unit
|
|
|
|
|
const unitMap = new Map<string, any>();
|
|
|
|
|
rows.forEach((r: any) => {
|
|
|
|
|
if (!unitMap.has(r.unit_id)) {
|
|
|
|
|
unitMap.set(r.unit_id, {
|
|
|
|
|
unit_id: r.unit_id, unit_number: r.unit_number, owner_name: r.owner_name,
|
|
|
|
|
current: 0, days_1_30: 0, days_31_60: 0, days_61_90: 0, days_90_plus: 0,
|
|
|
|
|
total: 0, invoices: [],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const unit = unitMap.get(r.unit_id);
|
|
|
|
|
const bal = parseFloat(r.balance_due);
|
|
|
|
|
unit.total += bal;
|
|
|
|
|
switch (r.aging_bucket) {
|
|
|
|
|
case 'current': unit.current += bal; break;
|
|
|
|
|
case '1-30': unit.days_1_30 += bal; break;
|
|
|
|
|
case '31-60': unit.days_31_60 += bal; break;
|
|
|
|
|
case '61-90': unit.days_61_90 += bal; break;
|
|
|
|
|
case '90+': unit.days_90_plus += bal; break;
|
|
|
|
|
}
|
|
|
|
|
unit.invoices.push({
|
|
|
|
|
invoice_id: r.invoice_id, invoice_number: r.invoice_number,
|
|
|
|
|
description: r.description, due_date: r.due_date,
|
|
|
|
|
amount: parseFloat(r.amount), amount_paid: parseFloat(r.amount_paid),
|
|
|
|
|
balance_due: bal, aging_bucket: r.aging_bucket,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const units = Array.from(unitMap.values());
|
|
|
|
|
const summary = {
|
|
|
|
|
current: units.reduce((s, u) => s + u.current, 0),
|
|
|
|
|
days_1_30: units.reduce((s, u) => s + u.days_1_30, 0),
|
|
|
|
|
days_31_60: units.reduce((s, u) => s + u.days_31_60, 0),
|
|
|
|
|
days_61_90: units.reduce((s, u) => s + u.days_61_90, 0),
|
|
|
|
|
days_90_plus: units.reduce((s, u) => s + u.days_90_plus, 0),
|
|
|
|
|
total: units.reduce((s, u) => s + u.total, 0),
|
|
|
|
|
unit_count: units.length,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return { units, summary };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getYearEndSummary(year: number) {
|
|
|
|
|
// Income statement for the full year
|
|
|
|
|
const from = `${year}-01-01`;
|
|
|
|
|
const to = `${year}-12-31`;
|
|
|
|
|
const incomeStmt = await this.getIncomeStatement(from, to);
|
|
|
|
|
const balanceSheet = await this.getBalanceSheet(to);
|
|
|
|
|
|
|
|
|
|
// 1099 vendor data
|
|
|
|
|
const vendors1099 = await this.tenant.query(`
|
|
|
|
|
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
|
|
|
|
|
COALESCE(SUM(p.amount), 0) as total_paid
|
|
|
|
|
FROM vendors v
|
|
|
|
|
JOIN (
|
|
|
|
|
SELECT vendor_id, amount FROM invoices
|
|
|
|
|
WHERE EXTRACT(YEAR FROM invoice_date) = $1
|
|
|
|
|
AND status IN ('paid', 'partial')
|
|
|
|
|
) p ON p.vendor_id = v.id
|
|
|
|
|
WHERE v.is_1099_eligible = true
|
|
|
|
|
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
|
|
|
|
|
HAVING COALESCE(SUM(p.amount), 0) >= 600
|
|
|
|
|
ORDER BY v.name
|
|
|
|
|
`, [year]);
|
|
|
|
|
|
|
|
|
|
// Collection stats
|
|
|
|
|
const collections = await this.tenant.query(`
|
|
|
|
|
SELECT
|
|
|
|
|
COUNT(*) as total_invoices,
|
|
|
|
|
COUNT(*) FILTER (WHERE status = 'paid') as paid_invoices,
|
|
|
|
|
COUNT(*) FILTER (WHERE status IN ('overdue', 'sent')) as outstanding_invoices,
|
|
|
|
|
COALESCE(SUM(amount), 0) as total_assessed,
|
|
|
|
|
COALESCE(SUM(amount_paid), 0) as total_collected,
|
|
|
|
|
COALESCE(SUM(amount - amount_paid) FILTER (WHERE status NOT IN ('paid', 'void', 'written_off')), 0) as total_outstanding
|
|
|
|
|
FROM invoices
|
|
|
|
|
WHERE EXTRACT(YEAR FROM invoice_date) = $1
|
|
|
|
|
`, [year]);
|
|
|
|
|
|
|
|
|
|
// Reserve fund status
|
|
|
|
|
const reserveStatus = await this.tenant.query(`
|
|
|
|
|
SELECT name, current_fund_balance, replacement_cost,
|
|
|
|
|
CASE WHEN replacement_cost > 0
|
|
|
|
|
THEN ROUND((current_fund_balance / replacement_cost * 100)::numeric, 1)
|
|
|
|
|
ELSE 0 END as percent_funded
|
|
|
|
|
FROM reserve_components
|
|
|
|
|
ORDER BY name
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
year,
|
|
|
|
|
income_statement: incomeStmt,
|
|
|
|
|
balance_sheet: balanceSheet,
|
|
|
|
|
vendors_1099: vendors1099,
|
|
|
|
|
collections: collections[0] || {},
|
|
|
|
|
reserve_status: reserveStatus,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getDashboardKPIs() {
|
|
|
|
|
// Total cash (all asset accounts with 'Cash' in name)
|
|
|
|
|
const cash = await this.tenant.query(`
|
|
|
|
|
|