mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Income Statement: Buildium-style category subgroups
Group the multi-period Income Statement by account category (Operating Income, Administration, Utilities, Reserves Budget, …) with "Total for <category>" subtotals, matching the Buildium layout, in the on-screen table, PDF, and CSV. - New accounting.accounts.category column (nullable; null = ungrouped), seeded from the local chart_of_accounts parent hierarchy. - Editable in Chart of Accounts: single-edit (with datalist autocomplete) and bulk-edit (blank = no change, __clear__ to unset). - buildium-account-categories edge function pulls each account's parent-GL category from Buildium (matched by code, fallback name) and backfills accounting.accounts.category; idempotent and re-runnable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
const [type, setType] = useState<string>("asset");
|
const [type, setType] = useState<string>("asset");
|
||||||
const [isBank, setIsBank] = useState(false);
|
const [isBank, setIsBank] = useState(false);
|
||||||
const [isReserve, setIsReserve] = useState(false);
|
const [isReserve, setIsReserve] = useState(false);
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [parentId, setParentId] = useState<string>("");
|
const [parentId, setParentId] = useState<string>("");
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
parent_account_id: "no_change",
|
parent_account_id: "no_change",
|
||||||
is_bank: "no_change",
|
is_bank: "no_change",
|
||||||
is_reserve: "no_change",
|
is_reserve: "no_change",
|
||||||
|
category: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Opening balances state ──
|
// ── Opening balances state ──
|
||||||
@@ -139,10 +141,16 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
return out;
|
return out;
|
||||||
}, [accounts]);
|
}, [accounts]);
|
||||||
|
|
||||||
|
// Existing category names (for the Income Statement subgroups) — used as autocomplete suggestions.
|
||||||
|
const existingCategories = useMemo(
|
||||||
|
() => [...new Set((accounts as any[]).map((a) => a.category).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b)),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Accounts actions ──
|
// ── Accounts actions ──
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setEditId(null); setName(""); setCode(""); setType("asset");
|
setEditId(null); setName(""); setCode(""); setType("asset");
|
||||||
setIsBank(false); setIsReserve(false); setDescription(""); setParentId("");
|
setIsBank(false); setIsReserve(false); setCategory(""); setDescription(""); setParentId("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (a: any) => {
|
const openEdit = (a: any) => {
|
||||||
@@ -152,6 +160,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
setType(a.type ?? "asset");
|
setType(a.type ?? "asset");
|
||||||
setIsBank(a.is_bank ?? false);
|
setIsBank(a.is_bank ?? false);
|
||||||
setIsReserve(a.is_reserve ?? false);
|
setIsReserve(a.is_reserve ?? false);
|
||||||
|
setCategory(a.category ?? "");
|
||||||
setDescription(a.description ?? "");
|
setDescription(a.description ?? "");
|
||||||
setParentId(a.parent_account_id ?? "");
|
setParentId(a.parent_account_id ?? "");
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@@ -165,6 +174,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
type: type as any,
|
type: type as any,
|
||||||
is_bank: isBank,
|
is_bank: isBank,
|
||||||
is_reserve: isReserve,
|
is_reserve: isReserve,
|
||||||
|
category: category.trim() || null,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
parent_account_id: parentId || null,
|
parent_account_id: parentId || null,
|
||||||
};
|
};
|
||||||
@@ -207,7 +217,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
const clearSelection = () => setSelectedIds(new Set());
|
const clearSelection = () => setSelectedIds(new Set());
|
||||||
|
|
||||||
const openBulkEdit = () => {
|
const openBulkEdit = () => {
|
||||||
setBulkEdit({ type: "no_change", parent_account_id: "no_change", is_bank: "no_change", is_reserve: "no_change" });
|
setBulkEdit({ type: "no_change", parent_account_id: "no_change", is_bank: "no_change", is_reserve: "no_change", category: "" });
|
||||||
setBulkEditOpen(true);
|
setBulkEditOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +231,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
patch.parent_account_id = bulkEdit.parent_account_id === "none" ? null : bulkEdit.parent_account_id;
|
patch.parent_account_id = bulkEdit.parent_account_id === "none" ? null : bulkEdit.parent_account_id;
|
||||||
if (bulkEdit.is_bank !== "no_change") patch.is_bank = bulkEdit.is_bank === "true";
|
if (bulkEdit.is_bank !== "no_change") patch.is_bank = bulkEdit.is_bank === "true";
|
||||||
if (bulkEdit.is_reserve !== "no_change") patch.is_reserve = bulkEdit.is_reserve === "true";
|
if (bulkEdit.is_reserve !== "no_change") patch.is_reserve = bulkEdit.is_reserve === "true";
|
||||||
|
if (bulkEdit.category.trim()) patch.category = bulkEdit.category.trim() === "__clear__" ? null : bulkEdit.category.trim();
|
||||||
|
|
||||||
if (Object.keys(patch).length === 0) return toast.error("No changes selected");
|
if (Object.keys(patch).length === 0) return toast.error("No changes selected");
|
||||||
if (patch.parent_account_id && ids.includes(patch.parent_account_id))
|
if (patch.parent_account_id && ids.includes(patch.parent_account_id))
|
||||||
@@ -407,6 +418,13 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Category <span className="text-muted-foreground text-xs font-normal">(Income Statement subgroup — e.g. "Administration", "Utilities")</span></Label>
|
||||||
|
<Input list="coa-category-suggestions" maxLength={80} value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Ungrouped" />
|
||||||
|
<datalist id="coa-category-suggestions">
|
||||||
|
{existingCategories.map((c) => <option key={c} value={c} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
<div><Label>Description</Label><Textarea maxLength={500} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional notes about this account" /></div>
|
<div><Label>Description</Label><Textarea maxLength={500} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional notes about this account" /></div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={isBank} onChange={(e) => setIsBank(e.target.checked)} />
|
<input type="checkbox" checked={isBank} onChange={(e) => setIsBank(e.target.checked)} />
|
||||||
@@ -485,6 +503,10 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Category <span className="text-muted-foreground text-xs font-normal">(Income Statement subgroup)</span></Label>
|
||||||
|
<Input list="coa-category-suggestions" value={bulkEdit.category} onChange={(e) => setBulkEdit((p) => ({ ...p, category: e.target.value }))} placeholder="Blank = no change · type __clear__ to unset" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setBulkEditOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setBulkEditOpen(false)}>Cancel</Button>
|
||||||
|
|||||||
@@ -703,7 +703,34 @@ function isNum(n: number): string {
|
|||||||
return v < 0 ? `(${abs})` : abs;
|
return v < 0 ? `(${abs})` : abs;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ISAcct = { id: string; code: string | null; name: string; type: string; by: Map<string, number>; total: number };
|
type ISAcct = { id: string; code: string | null; name: string; type: string; category: string | null; by: Map<string, number>; total: number };
|
||||||
|
type ISTotal = { by: Map<string, number>; total: number };
|
||||||
|
type ISGroup = { name: string | null; accts: ISAcct[] } & ISTotal;
|
||||||
|
|
||||||
|
const isByCode = (a: ISAcct, b: ISAcct) => String(a.code ?? "").localeCompare(String(b.code ?? "")) || a.name.localeCompare(b.name);
|
||||||
|
|
||||||
|
function isSumRows(rows: { by: Map<string, number>; total: number }[], periods: { key: string }[]): ISTotal {
|
||||||
|
const by = new Map<string, number>(); let total = 0;
|
||||||
|
for (const p of periods) by.set(p.key, rows.reduce((s, r) => s + (r.by.get(p.key) ?? 0), 0));
|
||||||
|
for (const r of rows) total += r.total;
|
||||||
|
return { by, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group accounts by their `category` into Buildium-style subgroups; ungrouped (null) sorts last. */
|
||||||
|
function isGroupAccts(accts: ISAcct[], periods: { key: string }[]): ISGroup[] {
|
||||||
|
const map = new Map<string, ISAcct[]>();
|
||||||
|
for (const a of accts) {
|
||||||
|
const k = (a.category ?? "").trim();
|
||||||
|
(map.get(k) ?? map.set(k, []).get(k)!).push(a);
|
||||||
|
}
|
||||||
|
const groups: ISGroup[] = [];
|
||||||
|
for (const [name, list] of map) {
|
||||||
|
list.sort(isByCode);
|
||||||
|
groups.push({ name: name || null, accts: list, ...isSumRows(list, periods) });
|
||||||
|
}
|
||||||
|
groups.sort((a, b) => (a.name === null ? 1 : 0) - (b.name === null ? 1 : 0) || String(a.name).localeCompare(String(b.name)));
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
function IncomeStatementReport({ companyId, companyName, from, to, currency, logoUrl }: {
|
function IncomeStatementReport({ companyId, companyName, from, to, currency, logoUrl }: {
|
||||||
companyId: string; companyName: string; from: string; to: string; currency: string; logoUrl?: string | null;
|
companyId: string; companyName: string; from: string; to: string; currency: string; logoUrl?: string | null;
|
||||||
@@ -715,7 +742,7 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
|||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
queryFn: () => fetchAllGLLines(
|
queryFn: () => fetchAllGLLines(
|
||||||
companyId, to,
|
companyId, to,
|
||||||
"id,debit,credit,accounts!inner(id,name,code,type),journal_entries!inner(company_id,date)",
|
"id,debit,credit,accounts!inner(id,name,code,type,category),journal_entries!inner(company_id,date)",
|
||||||
from,
|
from,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -731,31 +758,26 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
|||||||
const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
|
const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
|
||||||
const amt = type === "income" ? credit - debit : debit - credit; // both shown positive
|
const amt = type === "income" ? credit - debit : debit - credit; // both shown positive
|
||||||
let rec = accts.get(a.id);
|
let rec = accts.get(a.id);
|
||||||
if (!rec) { rec = { id: a.id, code: a.code, name: a.name, type, by: new Map(), total: 0 }; accts.set(a.id, rec); }
|
if (!rec) { rec = { id: a.id, code: a.code, name: a.name, type, category: a.category ?? null, by: new Map(), total: 0 }; accts.set(a.id, rec); }
|
||||||
const key = isPeriodKey(date, gran);
|
const key = isPeriodKey(date, gran);
|
||||||
rec.by.set(key, (rec.by.get(key) ?? 0) + amt);
|
rec.by.set(key, (rec.by.get(key) ?? 0) + amt);
|
||||||
rec.total += amt;
|
rec.total += amt;
|
||||||
}
|
}
|
||||||
const byCode = (a: ISAcct, b: ISAcct) => String(a.code ?? "").localeCompare(String(b.code ?? "")) || a.name.localeCompare(b.name);
|
|
||||||
const live = [...accts.values()].filter((a) => Math.abs(a.total) > 0.005);
|
const live = [...accts.values()].filter((a) => Math.abs(a.total) > 0.005);
|
||||||
const income = live.filter((a) => a.type === "income").sort(byCode);
|
const income = live.filter((a) => a.type === "income");
|
||||||
const expense = live.filter((a) => a.type === "expense").sort(byCode);
|
const expense = live.filter((a) => a.type === "expense");
|
||||||
const sumRow = (rows: ISAcct[]) => {
|
const incomeGroups = isGroupAccts(income, periods);
|
||||||
const by = new Map<string, number>(); let total = 0;
|
const expenseGroups = isGroupAccts(expense, periods);
|
||||||
for (const p of periods) by.set(p.key, rows.reduce((s, r) => s + (r.by.get(p.key) ?? 0), 0));
|
const incTot = isSumRows(income, periods), expTot = isSumRows(expense, periods);
|
||||||
for (const r of rows) total += r.total;
|
const net: ISTotal = {
|
||||||
return { by, total };
|
|
||||||
};
|
|
||||||
const incTot = sumRow(income), expTot = sumRow(expense);
|
|
||||||
const net = {
|
|
||||||
by: new Map(periods.map((p) => [p.key, (incTot.by.get(p.key) ?? 0) - (expTot.by.get(p.key) ?? 0)])),
|
by: new Map(periods.map((p) => [p.key, (incTot.by.get(p.key) ?? 0) - (expTot.by.get(p.key) ?? 0)])),
|
||||||
total: incTot.total - expTot.total,
|
total: incTot.total - expTot.total,
|
||||||
};
|
};
|
||||||
return { income, expense, incTot, expTot, net };
|
return { incomeGroups, expenseGroups, incTot, expTot, net, hasRows: income.length > 0 || expense.length > 0 };
|
||||||
}, [glLines, periods, gran]);
|
}, [glLines, periods, gran]);
|
||||||
|
|
||||||
const subtitle = `${fmtDate(from)} – ${fmtDate(to)}, By ${gran[0].toUpperCase()}${gran.slice(1)}, Accrual basis`;
|
const subtitle = `${fmtDate(from)} – ${fmtDate(to)}, By ${gran[0].toUpperCase()}${gran.slice(1)}, Accrual basis`;
|
||||||
const hasRows = model.income.length > 0 || model.expense.length > 0;
|
const { hasRows } = model;
|
||||||
|
|
||||||
const exportPdf = async () => {
|
const exportPdf = async () => {
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
||||||
@@ -766,27 +788,33 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
|||||||
});
|
});
|
||||||
const head = [["Account", ...periods.map((p) => p.label), "Total"]];
|
const head = [["Account", ...periods.map((p) => p.label), "Total"]];
|
||||||
const body: any[] = [];
|
const body: any[] = [];
|
||||||
const boldRows = new Set<number>();
|
const fillRows = new Set<number>(); // section headers (Income / Expense)
|
||||||
const sectionRows = new Set<number>();
|
const boldRows = new Set<number>(); // group headers, subtotals, totals, net
|
||||||
const pushRow = (label: string, by: Map<string, number> | null, total: number | null, kind: "section" | "account" | "total") => {
|
const pushRow = (label: string, t: ISTotal | null, kind: "section" | "group" | "account" | "subtotal" | "total") => {
|
||||||
|
const asMoney = kind === "subtotal" || kind === "total";
|
||||||
const cells = [
|
const cells = [
|
||||||
label,
|
label,
|
||||||
...periods.map((p) => (by ? (kind === "total" ? money(by.get(p.key) ?? 0, currency) : isNum(by.get(p.key) ?? 0)) : "")),
|
...periods.map((p) => (t ? (asMoney ? money(t.by.get(p.key) ?? 0, currency) : isNum(t.by.get(p.key) ?? 0)) : "")),
|
||||||
total == null ? "" : (kind === "total" ? money(total, currency) : isNum(total)),
|
t == null ? "" : (asMoney ? money(t.total, currency) : isNum(t.total)),
|
||||||
];
|
];
|
||||||
if (kind === "total") boldRows.add(body.length);
|
if (kind === "section") fillRows.add(body.length);
|
||||||
if (kind === "section") sectionRows.add(body.length);
|
if (kind !== "account") boldRows.add(body.length);
|
||||||
body.push(cells);
|
body.push(cells);
|
||||||
};
|
};
|
||||||
pushRow("Income", null, null, "section");
|
const emitSection = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
|
||||||
for (const a of model.income) pushRow(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total, "account");
|
pushRow(title, null, "section");
|
||||||
pushRow("Total Income", model.incTot.by, model.incTot.total, "total");
|
for (const g of groups) {
|
||||||
pushRow("Expense", null, null, "section");
|
if (g.name) pushRow(` ${g.name}`, null, "group");
|
||||||
for (const a of model.expense) pushRow(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total, "account");
|
for (const a of g.accts) pushRow(`${g.name ? " " : " "}${a.code ? a.code + " " : ""}${a.name}`, a, "account");
|
||||||
pushRow("Total Expense", model.expTot.by, model.expTot.total, "total");
|
if (g.name) pushRow(` Total for ${g.name}`, g, "subtotal");
|
||||||
pushRow("Net Income", model.net.by, model.net.total, "total");
|
}
|
||||||
|
pushRow(totalLabel, total, "total");
|
||||||
|
};
|
||||||
|
emitSection("Income", model.incomeGroups, "Total Income", model.incTot);
|
||||||
|
emitSection("Expense", model.expenseGroups, "Total Expense", model.expTot);
|
||||||
|
pushRow("Net Income", model.net, "total");
|
||||||
|
|
||||||
const colStyles: Record<number, any> = { 0: { halign: "left", cellWidth: 150 } };
|
const colStyles: Record<number, any> = { 0: { halign: "left", cellWidth: 160 } };
|
||||||
for (let i = 1; i <= periods.length + 1; i++) colStyles[i] = { halign: "right" };
|
for (let i = 1; i <= periods.length + 1; i++) colStyles[i] = { halign: "right" };
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY, head, body,
|
startY, head, body,
|
||||||
@@ -797,7 +825,7 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
|||||||
didParseCell: (data: any) => {
|
didParseCell: (data: any) => {
|
||||||
if (data.section !== "body") return;
|
if (data.section !== "body") return;
|
||||||
if (boldRows.has(data.row.index)) data.cell.styles.fontStyle = "bold";
|
if (boldRows.has(data.row.index)) data.cell.styles.fontStyle = "bold";
|
||||||
if (sectionRows.has(data.row.index)) { data.cell.styles.fontStyle = "bold"; data.cell.styles.fillColor = [241, 245, 249]; }
|
if (fillRows.has(data.row.index)) data.cell.styles.fillColor = [241, 245, 249];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
drawBrandedFooter(doc);
|
drawBrandedFooter(doc);
|
||||||
@@ -808,15 +836,20 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
|||||||
const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
|
const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
|
||||||
const lines = [["Account", ...periods.map((p) => p.label), "Total"].map(esc).join(",")];
|
const lines = [["Account", ...periods.map((p) => p.label), "Total"].map(esc).join(",")];
|
||||||
const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
|
const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
|
||||||
const row = (label: string, by: Map<string, number> | null, total: number | null) =>
|
const row = (label: string, t: ISTotal | null) =>
|
||||||
lines.push([esc(label), ...periods.map((p) => (by ? f(by.get(p.key) ?? 0) : "")), total == null ? "" : f(total)].join(","));
|
lines.push([esc(label), ...periods.map((p) => (t ? f(t.by.get(p.key) ?? 0) : "")), t == null ? "" : f(t.total)].join(","));
|
||||||
row("Income", null, null);
|
const section = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
|
||||||
for (const a of model.income) row(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total);
|
row(title, null);
|
||||||
row("Total Income", model.incTot.by, model.incTot.total);
|
for (const g of groups) {
|
||||||
row("Expense", null, null);
|
if (g.name) row(g.name, null);
|
||||||
for (const a of model.expense) row(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total);
|
for (const a of g.accts) row(`${a.code ? a.code + " " : ""}${a.name}`, a);
|
||||||
row("Total Expense", model.expTot.by, model.expTot.total);
|
if (g.name) row(`Total for ${g.name}`, g);
|
||||||
row("Net Income", model.net.by, model.net.total);
|
}
|
||||||
|
row(totalLabel, total);
|
||||||
|
};
|
||||||
|
section("Income", model.incomeGroups, "Total Income", model.incTot);
|
||||||
|
section("Expense", model.expenseGroups, "Total Expense", model.expTot);
|
||||||
|
row("Net Income", model.net);
|
||||||
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
@@ -868,8 +901,8 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<ISSection title="Income" accts={model.income} periods={periods} totalLabel="Total Income" total={model.incTot} currency={currency} numCell={numCell} />
|
<ISSection title="Income" groups={model.incomeGroups} totalLabel="Total Income" total={model.incTot} periods={periods} currency={currency} numCell={numCell} />
|
||||||
<ISSection title="Expense" accts={model.expense} periods={periods} totalLabel="Total Expense" total={model.expTot} currency={currency} numCell={numCell} />
|
<ISSection title="Expense" groups={model.expenseGroups} totalLabel="Total Expense" total={model.expTot} periods={periods} currency={currency} numCell={numCell} />
|
||||||
<tr className="border-t-2 border-primary font-bold">
|
<tr className="border-t-2 border-primary font-bold">
|
||||||
<td className="px-3 py-2.5">Net Income</td>
|
<td className="px-3 py-2.5">Net Income</td>
|
||||||
{periods.map((p) => <td key={p.key} className={numCell}>{money(model.net.by.get(p.key) ?? 0, currency)}</td>)}
|
{periods.map((p) => <td key={p.key} className={numCell}>{money(model.net.by.get(p.key) ?? 0, currency)}</td>)}
|
||||||
@@ -884,25 +917,41 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ISSection({ title, accts, periods, totalLabel, total, currency, numCell }: {
|
function ISSection({ title, groups, periods, totalLabel, total, currency, numCell }: {
|
||||||
title: string; accts: ISAcct[]; periods: { key: string; label: string }[];
|
title: string; groups: ISGroup[]; periods: { key: string; label: string }[];
|
||||||
totalLabel: string; total: { by: Map<string, number>; total: number }; currency: string; numCell: string;
|
totalLabel: string; total: ISTotal; currency: string; numCell: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className="bg-muted/40 font-semibold">
|
<tr className="bg-muted/40 font-semibold">
|
||||||
<td className="px-3 py-1.5" colSpan={periods.length + 2}>{title}</td>
|
<td className="px-3 py-1.5" colSpan={periods.length + 2}>{title}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{accts.map((a) => (
|
{groups.map((g, gi) => (
|
||||||
<tr key={a.id} className="border-b hover:bg-muted/20">
|
<Fragment key={g.name ?? `__none-${gi}`}>
|
||||||
<td className="px-3 py-1.5">
|
{g.name && (
|
||||||
{a.code && <span className="font-mono text-xs text-muted-foreground mr-2">{a.code}</span>}{a.name}
|
<tr className="font-semibold text-[13px]">
|
||||||
</td>
|
<td className="px-3 pl-6 py-1.5" colSpan={periods.length + 2}>{g.name}</td>
|
||||||
{periods.map((p) => <td key={p.key} className={numCell}>{isNum(a.by.get(p.key) ?? 0)}</td>)}
|
</tr>
|
||||||
<td className={numCell + " font-medium"}>{isNum(a.total)}</td>
|
)}
|
||||||
</tr>
|
{g.accts.map((a) => (
|
||||||
|
<tr key={a.id} className="border-b hover:bg-muted/20">
|
||||||
|
<td className={`px-3 py-1.5 ${g.name ? "pl-10" : "pl-6"}`}>
|
||||||
|
{a.code && <span className="font-mono text-xs text-muted-foreground mr-2">{a.code}</span>}{a.name}
|
||||||
|
</td>
|
||||||
|
{periods.map((p) => <td key={p.key} className={numCell}>{isNum(a.by.get(p.key) ?? 0)}</td>)}
|
||||||
|
<td className={numCell + " font-medium"}>{isNum(a.total)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{g.name && (
|
||||||
|
<tr className="border-b font-medium text-muted-foreground">
|
||||||
|
<td className="px-3 pl-6 py-1.5">Total for {g.name}</td>
|
||||||
|
{periods.map((p) => <td key={p.key} className={numCell}>{money(g.by.get(p.key) ?? 0, currency)}</td>)}
|
||||||
|
<td className={numCell}>{money(g.total, currency)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
<tr className="border-b font-semibold">
|
<tr className="border-y-2 border-primary/40 font-semibold">
|
||||||
<td className="px-3 py-1.5">{totalLabel}</td>
|
<td className="px-3 py-1.5">{totalLabel}</td>
|
||||||
{periods.map((p) => <td key={p.key} className={numCell}>{money(total.by.get(p.key) ?? 0, currency)}</td>)}
|
{periods.map((p) => <td key={p.key} className={numCell}>{money(total.by.get(p.key) ?? 0, currency)}</td>)}
|
||||||
<td className={numCell}>{money(total.total, currency)}</td>
|
<td className={numCell}>{money(total.total, currency)}</td>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// buildium-account-categories
|
||||||
|
// Pulls each GL account's parent-category from Buildium's chart hierarchy and
|
||||||
|
// writes it onto accounting.accounts.category, which drives the Income Statement
|
||||||
|
// subgroups (Operating Income, Administration, Utilities, …). Matched by account
|
||||||
|
// code, falling back to name. Buildium's GL chart is shared across associations,
|
||||||
|
// so a single fetch maps code → category for every company.
|
||||||
|
//
|
||||||
|
// Body (all optional): { dryRun?: boolean, overwrite?: boolean }
|
||||||
|
// dryRun — compute and report, write nothing (default false)
|
||||||
|
// overwrite — replace existing categories too (default true); false only fills blanks
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
const BUILDIUM_BASE = "https://api.buildium.com";
|
||||||
|
|
||||||
|
function wait(ms: number) { return new Promise((r) => setTimeout(r, ms)); }
|
||||||
|
function norm(s: unknown) { return String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); }
|
||||||
|
function stripCode(name: unknown) { return String(name ?? "").replace(/^\s*\d+[\s.\-]*/, "").trim(); }
|
||||||
|
function json(body: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: Record<string, string>) {
|
||||||
|
const url = new URL(`${BUILDIUM_BASE}${path}`);
|
||||||
|
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "x-buildium-client-id": clientId, "x-buildium-client-secret": clientSecret, Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
const text = await res.text();
|
||||||
|
if ((res.status === 429 || res.status >= 500) && attempt < 3) { await wait(600 * Math.pow(2, attempt)); continue; }
|
||||||
|
throw new Error(`Buildium ${path} failed [${res.status}]: ${text}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Buildium ${path} failed after retries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildiumFetchAll(path: string, clientId: string, clientSecret: string) {
|
||||||
|
const all: any[] = [];
|
||||||
|
let offset = 0; const limit = 50;
|
||||||
|
while (true) {
|
||||||
|
const page = await buildiumFetch(path, clientId, clientSecret, { offset: String(offset), limit: String(limit) });
|
||||||
|
if (!Array.isArray(page) || page.length === 0) break;
|
||||||
|
all.push(...page);
|
||||||
|
if (page.length < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glCode(node: any): string {
|
||||||
|
return String(node?.AccountNumber ?? node?.Number ?? node?.GlNumber ?? node?.GLNumber ?? node?.Code ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||||
|
try {
|
||||||
|
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
||||||
|
const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
|
||||||
|
const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
|
||||||
|
if (!clientId || !clientSecret) return json({ error: "Buildium API credentials not configured" }, 500);
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({} as any));
|
||||||
|
const dryRun = body?.dryRun === true;
|
||||||
|
const overwrite = body?.overwrite !== false; // default true
|
||||||
|
|
||||||
|
// 1) Pull the GL chart (nested) and map each leaf → its immediate parent's name.
|
||||||
|
const roots = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
|
||||||
|
const byCode = new Map<string, string>();
|
||||||
|
const byName = new Map<string, string>();
|
||||||
|
const walk = (parentName: string | null, node: any) => {
|
||||||
|
const name = node?.Name ? String(node.Name) : "";
|
||||||
|
if (parentName) {
|
||||||
|
const code = glCode(node);
|
||||||
|
if (code) byCode.set(code, parentName);
|
||||||
|
if (name) byName.set(norm(name), parentName);
|
||||||
|
}
|
||||||
|
const subs = node?.SubAccounts;
|
||||||
|
if (Array.isArray(subs)) for (const s of subs) walk(name || parentName, s);
|
||||||
|
};
|
||||||
|
for (const r of roots) walk(null, r);
|
||||||
|
|
||||||
|
// 2) Apply to accounting.accounts (income/expense only).
|
||||||
|
const { data: accts, error } = await supabase.schema("accounting")
|
||||||
|
.from("accounts").select("id,code,name,type,category").in("type", ["income", "expense"]);
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
let matched = 0;
|
||||||
|
const updates: { id: string; category: string }[] = [];
|
||||||
|
for (const a of accts ?? []) {
|
||||||
|
const cat = (a.code && byCode.get(String(a.code).trim())) || byName.get(norm(stripCode(a.name)));
|
||||||
|
if (!cat) continue;
|
||||||
|
matched++;
|
||||||
|
if (!overwrite && a.category) continue;
|
||||||
|
if (a.category === cat) continue;
|
||||||
|
updates.push({ id: a.id, category: cat });
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
if (!dryRun) {
|
||||||
|
for (const u of updates) {
|
||||||
|
const { error: ue } = await supabase.schema("accounting").from("accounts").update({ category: u.category }).eq("id", u.id);
|
||||||
|
if (ue) throw ue;
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true, dryRun, overwrite,
|
||||||
|
glAccountsFetched: roots.length,
|
||||||
|
mappedCodes: byCode.size,
|
||||||
|
accountsScanned: (accts ?? []).length,
|
||||||
|
matched, toUpdate: updates.length, updated,
|
||||||
|
categories: [...new Set(updates.map((u) => u.category))].sort(),
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return json({ error: String(e?.message ?? e) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Income-statement subgroups (Buildium-style): each income/expense account can
|
||||||
|
-- carry a free-text category ("Operating Income", "Administration", "Utilities",
|
||||||
|
-- "Reserves Budget", …). The Income Statement groups accounts under it with a
|
||||||
|
-- "Total for <category>" subtotal. Sourced from Buildium's parent-GL hierarchy
|
||||||
|
-- and editable in the Chart of Accounts. Null = ungrouped.
|
||||||
|
alter table accounting.accounts
|
||||||
|
add column if not exists category text;
|
||||||
|
|
||||||
|
-- Seed from the local chart_of_accounts parent hierarchy where it exists
|
||||||
|
-- (a partial first pass — mostly a few income groups; the Buildium pull fills the rest).
|
||||||
|
update accounting.accounts a
|
||||||
|
set category = parent.account_name
|
||||||
|
from accounting.companies c
|
||||||
|
join public.chart_of_accounts coa
|
||||||
|
on coa.association_id = c.association_id
|
||||||
|
join public.chart_of_accounts parent
|
||||||
|
on parent.id = coa.parent_account_id
|
||||||
|
where a.company_id = c.id
|
||||||
|
and coa.account_number = a.code
|
||||||
|
and a.type in ('income','expense')
|
||||||
|
and a.category is null
|
||||||
|
and parent.account_name is not null;
|
||||||
Reference in New Issue
Block a user