diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 15045b9..268339d 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -997,16 +997,9 @@ function buildBalanceSheet(d: any): StructuredReport { const fyStart = `${String(d.asOf ?? "").slice(0, 4)}-01-01`; const isDebitNormal = (t: string) => t === "asset" || t === "expense"; - // Opening balance (natural) per account - const typeById = new Map(accounts.map((a) => [a.id, a.type])); - const openByAcct = new Map(); - 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); - } + // Opening balances are posted to the GL (acmacc_opening) for managed companies + // and are already inside the imported GL for the rest — so the Balance Sheet + // reads balances from the GL alone (no separate opening-balance add). // Cumulative GL (natural) per account, plus income/expense net split by FY const glByAcct = new Map(); @@ -1022,7 +1015,7 @@ function buildBalanceSheet(d: any): StructuredReport { 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 sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0); diff --git a/src/pages/accounting/components/GeneralLedgerReport.tsx b/src/pages/accounting/components/GeneralLedgerReport.tsx index 2850b3c..d540ab4 100644 --- a/src/pages/accounting/components/GeneralLedgerReport.tsx +++ b/src/pages/accounting/components/GeneralLedgerReport.tsx @@ -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 ?? [], }); - // All transactions up to "to" — so we can compute opening balances from txns before "from" - const { data: allTxns = [] } = useQuery({ - queryKey: ["gl-all-txns", companyId, to, basis], + // General-ledger lines up to "to" (the single source — opening balances are + // posted to the GL too). Lines before "from" roll into each account's opening. + const { data: glLines = [] } = useQuery({ + queryKey: ["gl-lines", companyId, to], enabled: !!companyId, 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 }); - return (await q).data ?? []; + const { data } = await accounting + .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 m = new Map(); for (const a of accounts as any[]) m.set(a.id, a); @@ -76,32 +75,35 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str }, [accounts]); const groups = useMemo(() => { - const obFor = new Map(); - 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 map: Record = {}; // initialize from selected accounts list (so empty accounts still show when selected) const accountIds = filterAccts ? selectedAccounts : (accounts as any[]).map((a) => a.id); for (const id of accountIds) { 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(); - 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 a = g.account; const naturalDebit = DEBIT_NATURAL.includes(a.type); - const amt = Number(t.amount || 0); - const isDebit = t.type === "debit"; - const debit = isDebit ? amt : 0; - const credit = !isDebit ? amt : 0; - const delta = naturalDebit ? debit - credit : credit - debit; + const delta = naturalDebit ? t.debit - t.credit : t.credit - t.debit; if (t.date < from) { g.opening += delta; } else { @@ -109,8 +111,8 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str const hay = `${t.description ?? ""} ${t.reference ?? ""}`.toLowerCase(); if (!hay.includes(searchLower)) continue; } - g.entries.push({ ...t, debit, credit, balance: 0 }); - g.totalD += debit; g.totalC += credit; + g.entries.push({ ...t }); + g.totalD += t.debit; g.totalC += t.credit; } } 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]; } return map; - }, [allTxns, accountMap, accounts, openingBalances, selectedAccounts, search, from]); + }, [glLines, accountMap, accounts, selectedAccounts, search, from]); const accountList = useMemo(() => Object.values(groups).sort((a, b) => (a.account.code ?? "").localeCompare(b.account.code ?? "") diff --git a/src/pages/accounting/components/TrialBalanceReport.tsx b/src/pages/accounting/components/TrialBalanceReport.tsx index 44a62e2..be51eb3 100644 --- a/src/pages/accounting/components/TrialBalanceReport.tsx +++ b/src/pages/accounting/components/TrialBalanceReport.tsx @@ -56,19 +56,47 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri const [showZero, setShowZero] = useState(false); const [typeFilter, setTypeFilter] = useState<"all" | Account["type"]>("all"); - const { data: accounts = [], isLoading } = useQuery({ + const { data: acctMeta = [], isLoading } = useQuery({ queryKey: ["tb-accounts", companyId], enabled: !!companyId, queryFn: async () => { const { data } = await accounting .from("accounts") - .select("id,name,code,type,subtype,balance") + .select("id,name,code,type,subtype") .eq("company_id", companyId) .order("code", { ascending: true }); - return (data ?? []) as Account[]; + return (data ?? []) as Omit[]; }, }); + // 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(() => { + const net = new Map(); // 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 filtered = accounts.filter((a) => typeFilter === "all" || a.type === typeFilter); const map: Record = { diff --git a/supabase/migrations/20260601160000_accounting_opening_balances_to_gl.sql b/supabase/migrations/20260601160000_accounting_opening_balances_to_gl.sql new file mode 100644 index 0000000..0272c18 --- /dev/null +++ b/supabase/migrations/20260601160000_accounting_opening_balances_to_gl.sql @@ -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();