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:
2026-06-10 22:34:58 -04:00
parent cb8a29696f
commit 4f0ac97e83
4 changed files with 272 additions and 57 deletions
@@ -49,6 +49,7 @@ export default function AccountingChartOfAccountsPage() {
const [type, setType] = useState<string>("asset");
const [isBank, setIsBank] = useState(false);
const [isReserve, setIsReserve] = useState(false);
const [category, setCategory] = useState("");
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState<string>("");
@@ -63,6 +64,7 @@ export default function AccountingChartOfAccountsPage() {
parent_account_id: "no_change",
is_bank: "no_change",
is_reserve: "no_change",
category: "",
});
// ── Opening balances state ──
@@ -139,10 +141,16 @@ export default function AccountingChartOfAccountsPage() {
return out;
}, [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 ──
const resetForm = () => {
setEditId(null); setName(""); setCode(""); setType("asset");
setIsBank(false); setIsReserve(false); setDescription(""); setParentId("");
setIsBank(false); setIsReserve(false); setCategory(""); setDescription(""); setParentId("");
};
const openEdit = (a: any) => {
@@ -152,6 +160,7 @@ export default function AccountingChartOfAccountsPage() {
setType(a.type ?? "asset");
setIsBank(a.is_bank ?? false);
setIsReserve(a.is_reserve ?? false);
setCategory(a.category ?? "");
setDescription(a.description ?? "");
setParentId(a.parent_account_id ?? "");
setOpen(true);
@@ -165,6 +174,7 @@ export default function AccountingChartOfAccountsPage() {
type: type as any,
is_bank: isBank,
is_reserve: isReserve,
category: category.trim() || null,
description: description || null,
parent_account_id: parentId || null,
};
@@ -207,7 +217,7 @@ export default function AccountingChartOfAccountsPage() {
const clearSelection = () => setSelectedIds(new Set());
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);
};
@@ -221,6 +231,7 @@ export default function AccountingChartOfAccountsPage() {
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_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 (patch.parent_account_id && ids.includes(patch.parent_account_id))
@@ -407,6 +418,13 @@ export default function AccountingChartOfAccountsPage() {
</SelectContent>
</Select>
</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>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isBank} onChange={(e) => setIsBank(e.target.checked)} />
@@ -485,6 +503,10 @@ export default function AccountingChartOfAccountsPage() {
</SelectContent>
</Select>
</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>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkEditOpen(false)}>Cancel</Button>
+98 -49
View File
@@ -703,7 +703,34 @@ function isNum(n: number): string {
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 }: {
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,
queryFn: () => fetchAllGLLines(
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,
),
});
@@ -731,31 +758,26 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
const amt = type === "income" ? credit - debit : debit - credit; // both shown positive
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);
rec.by.set(key, (rec.by.get(key) ?? 0) + 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 income = live.filter((a) => a.type === "income").sort(byCode);
const expense = live.filter((a) => a.type === "expense").sort(byCode);
const sumRow = (rows: ISAcct[]) => {
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 };
};
const incTot = sumRow(income), expTot = sumRow(expense);
const net = {
const income = live.filter((a) => a.type === "income");
const expense = live.filter((a) => a.type === "expense");
const incomeGroups = isGroupAccts(income, periods);
const expenseGroups = isGroupAccts(expense, periods);
const incTot = isSumRows(income, periods), expTot = isSumRows(expense, periods);
const net: ISTotal = {
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,
};
return { income, expense, incTot, expTot, net };
return { incomeGroups, expenseGroups, incTot, expTot, net, hasRows: income.length > 0 || expense.length > 0 };
}, [glLines, periods, gran]);
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 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 body: any[] = [];
const boldRows = new Set<number>();
const sectionRows = new Set<number>();
const pushRow = (label: string, by: Map<string, number> | null, total: number | null, kind: "section" | "account" | "total") => {
const fillRows = new Set<number>(); // section headers (Income / Expense)
const boldRows = new Set<number>(); // group headers, subtotals, totals, net
const pushRow = (label: string, t: ISTotal | null, kind: "section" | "group" | "account" | "subtotal" | "total") => {
const asMoney = kind === "subtotal" || kind === "total";
const cells = [
label,
...periods.map((p) => (by ? (kind === "total" ? money(by.get(p.key) ?? 0, currency) : isNum(by.get(p.key) ?? 0)) : "")),
total == null ? "" : (kind === "total" ? money(total, currency) : isNum(total)),
...periods.map((p) => (t ? (asMoney ? money(t.by.get(p.key) ?? 0, currency) : isNum(t.by.get(p.key) ?? 0)) : "")),
t == null ? "" : (asMoney ? money(t.total, currency) : isNum(t.total)),
];
if (kind === "total") boldRows.add(body.length);
if (kind === "section") sectionRows.add(body.length);
if (kind === "section") fillRows.add(body.length);
if (kind !== "account") boldRows.add(body.length);
body.push(cells);
};
pushRow("Income", null, null, "section");
for (const a of model.income) pushRow(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total, "account");
pushRow("Total Income", model.incTot.by, model.incTot.total, "total");
pushRow("Expense", null, null, "section");
for (const a of model.expense) pushRow(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total, "account");
pushRow("Total Expense", model.expTot.by, model.expTot.total, "total");
pushRow("Net Income", model.net.by, model.net.total, "total");
const emitSection = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
pushRow(title, null, "section");
for (const g of groups) {
if (g.name) pushRow(` ${g.name}`, null, "group");
for (const a of g.accts) pushRow(`${g.name ? " " : " "}${a.code ? a.code + " " : ""}${a.name}`, a, "account");
if (g.name) pushRow(` Total for ${g.name}`, g, "subtotal");
}
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" };
autoTable(doc, {
startY, head, body,
@@ -797,7 +825,7 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
didParseCell: (data: any) => {
if (data.section !== "body") return;
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);
@@ -808,15 +836,20 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
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 row = (label: string, by: Map<string, number> | null, total: number | null) =>
lines.push([esc(label), ...periods.map((p) => (by ? f(by.get(p.key) ?? 0) : "")), total == null ? "" : f(total)].join(","));
row("Income", null, null);
for (const a of model.income) row(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total);
row("Total Income", model.incTot.by, model.incTot.total);
row("Expense", null, null);
for (const a of model.expense) row(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total);
row("Total Expense", model.expTot.by, model.expTot.total);
row("Net Income", model.net.by, model.net.total);
const row = (label: string, t: ISTotal | null) =>
lines.push([esc(label), ...periods.map((p) => (t ? f(t.by.get(p.key) ?? 0) : "")), t == null ? "" : f(t.total)].join(","));
const section = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
row(title, null);
for (const g of groups) {
if (g.name) row(g.name, null);
for (const a of g.accts) row(`${a.code ? a.code + " " : ""}${a.name}`, a);
if (g.name) row(`Total for ${g.name}`, g);
}
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 a = document.createElement("a");
a.href = URL.createObjectURL(blob);
@@ -868,8 +901,8 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
</tr>
</thead>
<tbody>
<ISSection title="Income" accts={model.income} periods={periods} totalLabel="Total Income" total={model.incTot} currency={currency} numCell={numCell} />
<ISSection title="Expense" accts={model.expense} periods={periods} totalLabel="Total Expense" total={model.expTot} 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" groups={model.expenseGroups} totalLabel="Total Expense" total={model.expTot} periods={periods} currency={currency} numCell={numCell} />
<tr className="border-t-2 border-primary font-bold">
<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>)}
@@ -884,25 +917,41 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
);
}
function ISSection({ title, accts, periods, totalLabel, total, currency, numCell }: {
title: string; accts: ISAcct[]; periods: { key: string; label: string }[];
totalLabel: string; total: { by: Map<string, number>; total: number }; currency: string; numCell: string;
function ISSection({ title, groups, periods, totalLabel, total, currency, numCell }: {
title: string; groups: ISGroup[]; periods: { key: string; label: string }[];
totalLabel: string; total: ISTotal; currency: string; numCell: string;
}) {
return (
<>
<tr className="bg-muted/40 font-semibold">
<td className="px-3 py-1.5" colSpan={periods.length + 2}>{title}</td>
</tr>
{accts.map((a) => (
{groups.map((g, gi) => (
<Fragment key={g.name ?? `__none-${gi}`}>
{g.name && (
<tr className="font-semibold text-[13px]">
<td className="px-3 pl-6 py-1.5" colSpan={periods.length + 2}>{g.name}</td>
</tr>
)}
{g.accts.map((a) => (
<tr key={a.id} className="border-b hover:bg-muted/20">
<td className="px-3 py-1.5">
<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>
))}
<tr className="border-b font-semibold">
{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-y-2 border-primary/40 font-semibold">
<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>)}
<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;