mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Opening balances post to GL; unify all reports on the general ledger
- Opening balances now post a single "Opening Balances" journal entry to the GL (migration applied to prod), scoped to managed companies (imported-GL associations already carry theirs). Triggers on opening_balances / opening_balances_setup keep it in sync; Ashley Manor backfilled. - Balance Sheet: read balances from the GL only (drop the separate opening add, which also double-counted imported companies). - Trial Balance: compute balances from journal_entry_lines as of the report date (was accounts.balance). - General Ledger report: read from journal_entry_lines (was transactions); opening rolls in from the GL. All four reports now share one source. Verified Ashley Manor TB balances (debits = credits = $90,073.23) with opening cash (BOA +$47,304.31) flowing through. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -997,16 +997,9 @@ function buildBalanceSheet(d: any): StructuredReport {
|
|||||||
const fyStart = `${String(d.asOf ?? "").slice(0, 4)}-01-01`;
|
const fyStart = `${String(d.asOf ?? "").slice(0, 4)}-01-01`;
|
||||||
const isDebitNormal = (t: string) => t === "asset" || t === "expense";
|
const isDebitNormal = (t: string) => t === "asset" || t === "expense";
|
||||||
|
|
||||||
// Opening balance (natural) per account
|
// Opening balances are posted to the GL (acmacc_opening) for managed companies
|
||||||
const typeById = new Map(accounts.map((a) => [a.id, a.type]));
|
// and are already inside the imported GL for the rest — so the Balance Sheet
|
||||||
const openByAcct = new Map<string, number>();
|
// reads balances from the GL alone (no separate opening-balance add).
|
||||||
for (const o of (d.openingBalances ?? []) as any[]) {
|
|
||||||
const t = typeById.get(o.account_id);
|
|
||||||
if (!t) continue;
|
|
||||||
const nat = isDebitNormal(t) ? Number(o.debit || 0) - Number(o.credit || 0)
|
|
||||||
: Number(o.credit || 0) - Number(o.debit || 0);
|
|
||||||
openByAcct.set(o.account_id, (openByAcct.get(o.account_id) ?? 0) + nat);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cumulative GL (natural) per account, plus income/expense net split by FY
|
// Cumulative GL (natural) per account, plus income/expense net split by FY
|
||||||
const glByAcct = new Map<string, number>();
|
const glByAcct = new Map<string, number>();
|
||||||
@@ -1022,7 +1015,7 @@ function buildBalanceSheet(d: any): StructuredReport {
|
|||||||
else if (t === "expense") { expenseAll += debit - credit; if (isPrior) expensePrior += debit - credit; }
|
else if (t === "expense") { expenseAll += debit - credit; if (isPrior) expensePrior += debit - credit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const balOf = (a: any) => (openByAcct.get(a.id) ?? 0) + (glByAcct.get(a.id) ?? 0);
|
const balOf = (a: any) => (glByAcct.get(a.id) ?? 0);
|
||||||
const byType = (t: string) => accounts.filter((a) => a.type === t);
|
const byType = (t: string) => accounts.filter((a) => a.type === t);
|
||||||
const sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0);
|
const sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0);
|
||||||
|
|
||||||
|
|||||||
@@ -53,22 +53,21 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
queryFn: async () => (await accounting.from("accounts").select("id,name,code,type").eq("company_id", companyId).order("code")).data ?? [],
|
queryFn: async () => (await accounting.from("accounts").select("id,name,code,type").eq("company_id", companyId).order("code")).data ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// All transactions up to "to" — so we can compute opening balances from txns before "from"
|
// General-ledger lines up to "to" (the single source — opening balances are
|
||||||
const { data: allTxns = [] } = useQuery({
|
// posted to the GL too). Lines before "from" roll into each account's opening.
|
||||||
queryKey: ["gl-all-txns", companyId, to, basis],
|
const { data: glLines = [] } = useQuery({
|
||||||
|
queryKey: ["gl-lines", companyId, to],
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const q = accounting.from("transactions").select("id,date,description,reference,type,amount,account_id,category").eq("company_id", companyId).lte("date", to).order("date", { ascending: true });
|
const { data } = await accounting
|
||||||
return (await q).data ?? [];
|
.from("journal_entry_lines")
|
||||||
|
.select("id,debit,credit,description,account_id,journal_entries!inner(company_id,date,reference)")
|
||||||
|
.eq("journal_entries.company_id", companyId)
|
||||||
|
.lte("journal_entries.date", to);
|
||||||
|
return data ?? [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: openingBalances = [] } = useQuery({
|
|
||||||
queryKey: ["gl-opening", companyId],
|
|
||||||
enabled: !!companyId,
|
|
||||||
queryFn: async () => (await accounting.from("opening_balances").select("account_id,debit,credit").eq("company_id", companyId)).data ?? [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const accountMap = useMemo(() => {
|
const accountMap = useMemo(() => {
|
||||||
const m = new Map<string, any>();
|
const m = new Map<string, any>();
|
||||||
for (const a of accounts as any[]) m.set(a.id, a);
|
for (const a of accounts as any[]) m.set(a.id, a);
|
||||||
@@ -76,32 +75,35 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
}, [accounts]);
|
}, [accounts]);
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const obFor = new Map<string, number>();
|
|
||||||
for (const ob of openingBalances as any[]) {
|
|
||||||
const a = accountMap.get(ob.account_id);
|
|
||||||
if (!a) continue;
|
|
||||||
const naturalDebit = DEBIT_NATURAL.includes(a.type);
|
|
||||||
const opening = naturalDebit ? Number(ob.debit) - Number(ob.credit) : Number(ob.credit) - Number(ob.debit);
|
|
||||||
obFor.set(ob.account_id, (obFor.get(ob.account_id) ?? 0) + opening);
|
|
||||||
}
|
|
||||||
const filterAccts = selectedAccounts.length > 0;
|
const filterAccts = selectedAccounts.length > 0;
|
||||||
const map: Record<string, { account: any; opening: number; entries: Txn[]; closing: number; totalD: number; totalC: number }> = {};
|
const map: Record<string, { account: any; opening: number; entries: Txn[]; closing: number; totalD: number; totalC: number }> = {};
|
||||||
// initialize from selected accounts list (so empty accounts still show when selected)
|
// initialize from selected accounts list (so empty accounts still show when selected)
|
||||||
const accountIds = filterAccts ? selectedAccounts : (accounts as any[]).map((a) => a.id);
|
const accountIds = filterAccts ? selectedAccounts : (accounts as any[]).map((a) => a.id);
|
||||||
for (const id of accountIds) {
|
for (const id of accountIds) {
|
||||||
const a = accountMap.get(id); if (!a) continue;
|
const a = accountMap.get(id); if (!a) continue;
|
||||||
map[id] = { account: a, opening: obFor.get(id) ?? 0, entries: [], closing: 0, totalD: 0, totalC: 0 };
|
map[id] = { account: a, opening: 0, entries: [], closing: 0, totalD: 0, totalC: 0 };
|
||||||
}
|
}
|
||||||
const searchLower = search.trim().toLowerCase();
|
const searchLower = search.trim().toLowerCase();
|
||||||
for (const t of allTxns as any[]) {
|
const rows = (glLines as any[])
|
||||||
|
.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
date: l.journal_entries?.date as string,
|
||||||
|
description: l.description ?? null,
|
||||||
|
reference: l.journal_entries?.reference ?? null,
|
||||||
|
account_id: l.account_id,
|
||||||
|
type: (Number(l.debit || 0) >= Number(l.credit || 0) ? "debit" : "credit") as "debit" | "credit",
|
||||||
|
amount: Number(l.debit || 0) || Number(l.credit || 0),
|
||||||
|
category: null as string | null,
|
||||||
|
debit: Number(l.debit || 0),
|
||||||
|
credit: Number(l.credit || 0),
|
||||||
|
balance: 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.date ?? "").localeCompare(b.date ?? ""));
|
||||||
|
for (const t of rows) {
|
||||||
const g = map[t.account_id]; if (!g) continue;
|
const g = map[t.account_id]; if (!g) continue;
|
||||||
const a = g.account;
|
const a = g.account;
|
||||||
const naturalDebit = DEBIT_NATURAL.includes(a.type);
|
const naturalDebit = DEBIT_NATURAL.includes(a.type);
|
||||||
const amt = Number(t.amount || 0);
|
const delta = naturalDebit ? t.debit - t.credit : t.credit - t.debit;
|
||||||
const isDebit = t.type === "debit";
|
|
||||||
const debit = isDebit ? amt : 0;
|
|
||||||
const credit = !isDebit ? amt : 0;
|
|
||||||
const delta = naturalDebit ? debit - credit : credit - debit;
|
|
||||||
if (t.date < from) {
|
if (t.date < from) {
|
||||||
g.opening += delta;
|
g.opening += delta;
|
||||||
} else {
|
} else {
|
||||||
@@ -109,8 +111,8 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
const hay = `${t.description ?? ""} ${t.reference ?? ""}`.toLowerCase();
|
const hay = `${t.description ?? ""} ${t.reference ?? ""}`.toLowerCase();
|
||||||
if (!hay.includes(searchLower)) continue;
|
if (!hay.includes(searchLower)) continue;
|
||||||
}
|
}
|
||||||
g.entries.push({ ...t, debit, credit, balance: 0 });
|
g.entries.push({ ...t });
|
||||||
g.totalD += debit; g.totalC += credit;
|
g.totalD += t.debit; g.totalC += t.credit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const k of Object.keys(map)) {
|
for (const k of Object.keys(map)) {
|
||||||
@@ -127,7 +129,7 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
if (!filterAccts && g.entries.length === 0 && g.opening === 0) delete map[k];
|
if (!filterAccts && g.entries.length === 0 && g.opening === 0) delete map[k];
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [allTxns, accountMap, accounts, openingBalances, selectedAccounts, search, from]);
|
}, [glLines, accountMap, accounts, selectedAccounts, search, from]);
|
||||||
|
|
||||||
const accountList = useMemo(() => Object.values(groups).sort((a, b) =>
|
const accountList = useMemo(() => Object.values(groups).sort((a, b) =>
|
||||||
(a.account.code ?? "").localeCompare(b.account.code ?? "")
|
(a.account.code ?? "").localeCompare(b.account.code ?? "")
|
||||||
|
|||||||
@@ -56,19 +56,47 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
|
|||||||
const [showZero, setShowZero] = useState(false);
|
const [showZero, setShowZero] = useState(false);
|
||||||
const [typeFilter, setTypeFilter] = useState<"all" | Account["type"]>("all");
|
const [typeFilter, setTypeFilter] = useState<"all" | Account["type"]>("all");
|
||||||
|
|
||||||
const { data: accounts = [], isLoading } = useQuery({
|
const { data: acctMeta = [], isLoading } = useQuery({
|
||||||
queryKey: ["tb-accounts", companyId],
|
queryKey: ["tb-accounts", companyId],
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await accounting
|
const { data } = await accounting
|
||||||
.from("accounts")
|
.from("accounts")
|
||||||
.select("id,name,code,type,subtype,balance")
|
.select("id,name,code,type,subtype")
|
||||||
.eq("company_id", companyId)
|
.eq("company_id", companyId)
|
||||||
.order("code", { ascending: true });
|
.order("code", { ascending: true });
|
||||||
return (data ?? []) as Account[];
|
return (data ?? []) as Omit<Account, "balance">[];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Account balances come from the general ledger as of the report date — the
|
||||||
|
// single source shared with the P&L, Balance Sheet, and General Ledger report.
|
||||||
|
const { data: glLines = [] } = useQuery({
|
||||||
|
queryKey: ["tb-gl", companyId, asOf],
|
||||||
|
enabled: !!companyId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await accounting
|
||||||
|
.from("journal_entry_lines")
|
||||||
|
.select("debit,credit,account_id,journal_entries!inner(company_id,date)")
|
||||||
|
.eq("journal_entries.company_id", companyId)
|
||||||
|
.lte("journal_entries.date", asOf);
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accounts = useMemo<Account[]>(() => {
|
||||||
|
const net = new Map<string, number>(); // debit − credit
|
||||||
|
for (const l of glLines as any[]) {
|
||||||
|
net.set(l.account_id, (net.get(l.account_id) ?? 0) + Number(l.debit || 0) - Number(l.credit || 0));
|
||||||
|
}
|
||||||
|
return (acctMeta as any[]).map((a) => {
|
||||||
|
const n = net.get(a.id) ?? 0;
|
||||||
|
// store as the account's natural balance (positive on its normal side)
|
||||||
|
const balance = DEBIT_NATURAL.includes(a.type) ? n : -n;
|
||||||
|
return { ...a, balance } as Account;
|
||||||
|
});
|
||||||
|
}, [acctMeta, glLines]);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const filtered = accounts.filter((a) => typeFilter === "all" || a.type === typeFilter);
|
const filtered = accounts.filter((a) => typeFilter === "all" || a.type === typeFilter);
|
||||||
const map: Record<Account["type"], Account[]> = {
|
const map: Record<Account["type"], Account[]> = {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Post opening balances (accounting.opening_balances) to the general ledger as a
|
||||||
|
-- single "Opening Balances" journal entry, so they flow into every GL-based
|
||||||
|
-- report (Trial Balance, General Ledger, P&L, Balance Sheet) from one source.
|
||||||
|
--
|
||||||
|
-- Scoped by accounting.gl_managed(): only companies whose GL we generate (no
|
||||||
|
-- imported/foreign journal entries). Imported-GL companies (e.g. Bridgewater,
|
||||||
|
-- Bent Oak) already carry their opening balances inside the imported GL, so we
|
||||||
|
-- must NOT post theirs again. Keyed external_source='acmacc_opening',
|
||||||
|
-- external_id=company_id (one entry per company) — idempotent.
|
||||||
|
|
||||||
|
create or replace function accounting.post_opening_balance_gl(_company_id uuid) returns void
|
||||||
|
language plpgsql security definer set search_path to 'public','accounting' as $$
|
||||||
|
declare _dt date; _je uuid; _has boolean;
|
||||||
|
begin
|
||||||
|
perform accounting._gl_clear(_company_id, 'acmacc_opening', _company_id::text);
|
||||||
|
if not accounting.gl_managed(_company_id) then return; end if;
|
||||||
|
|
||||||
|
select exists (
|
||||||
|
select 1 from accounting.opening_balances
|
||||||
|
where company_id=_company_id and (coalesce(debit,0) <> 0 or coalesce(credit,0) <> 0)
|
||||||
|
) into _has;
|
||||||
|
if not _has then return; end if;
|
||||||
|
|
||||||
|
select as_of_date into _dt from accounting.opening_balances_setup where company_id=_company_id;
|
||||||
|
_dt := coalesce(_dt, date_trunc('year', now())::date);
|
||||||
|
|
||||||
|
insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id)
|
||||||
|
values (_company_id, _dt, 'Opening Balances', 'OPENING', 'acmacc_opening', _company_id::text)
|
||||||
|
returning id into _je;
|
||||||
|
|
||||||
|
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description)
|
||||||
|
select _je, ob.account_id, coalesce(ob.debit,0), coalesce(ob.credit,0), 'Opening balance'
|
||||||
|
from accounting.opening_balances ob
|
||||||
|
where ob.company_id=_company_id and (coalesce(ob.debit,0) <> 0 or coalesce(ob.credit,0) <> 0);
|
||||||
|
end$$;
|
||||||
|
|
||||||
|
create or replace function accounting.tg_opening_balance_gl() returns trigger
|
||||||
|
language plpgsql security definer set search_path to 'public','accounting' as $$
|
||||||
|
begin
|
||||||
|
begin
|
||||||
|
perform accounting.post_opening_balance_gl(coalesce(new.company_id, old.company_id));
|
||||||
|
exception when others then
|
||||||
|
raise warning 'accounting: opening-balance GL post failed for %: %', coalesce(new.company_id, old.company_id), sqlerrm;
|
||||||
|
end;
|
||||||
|
return coalesce(new, old);
|
||||||
|
end$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_acct_opening_gl on accounting.opening_balances;
|
||||||
|
create trigger trg_acct_opening_gl after insert or update or delete on accounting.opening_balances
|
||||||
|
for each row execute function accounting.tg_opening_balance_gl();
|
||||||
|
|
||||||
|
drop trigger if exists trg_acct_opening_setup_gl on accounting.opening_balances_setup;
|
||||||
|
create trigger trg_acct_opening_setup_gl after insert or update on accounting.opening_balances_setup
|
||||||
|
for each row execute function accounting.tg_opening_balance_gl();
|
||||||
Reference in New Issue
Block a user