From a65c1351229ab1691255b2eb0d36e74577f18f12 Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 16 Jun 2026 12:57:28 -0400 Subject: [PATCH 1/2] Accounting: auto-create record-only bills for no-bill vendor payments (import mode) Buildium often records vendor payments as direct checks (Dr Expense/Cr Bank) with no bill. For import-mode companies post_bill_gl no-ops, so a bill is a record only. New fn accounting.autocreate_nobill_vendor_bills() creates a paid record-only bill for each such buildium_gl payment (vendor resolved by payee, idempotent by external_id); buildium-gl-sync calls it per import-mode company after each pull. No GL impact, no double-count. Co-Authored-By: Claude Opus 4.8 --- supabase/functions/buildium-gl-sync/index.ts | 11 +++ ...6180000_autocreate_nobill_vendor_bills.sql | 67 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 supabase/migrations/20260616180000_autocreate_nobill_vendor_bills.sql diff --git a/supabase/functions/buildium-gl-sync/index.ts b/supabase/functions/buildium-gl-sync/index.ts index afa683b..c1f194e 100644 --- a/supabase/functions/buildium-gl-sync/index.ts +++ b/supabase/functions/buildium-gl-sync/index.ts @@ -526,6 +526,17 @@ Deno.serve(async (req) => { } else if (unmappedGl.size > 0) { companyResult.watermark_held = true; } + + // Import-mode: surface no-bill vendor payments (Buildium direct checks that + // hit an expense account with no A/P leg) as record-only bills. post_bill_gl + // no-ops for gl_auto_post=false companies, so this never touches the GL. + if (!dryRun && company.gl_auto_post === false) { + const { data: madeBills, error: billErr } = + await supabase.schema("accounting").rpc("autocreate_nobill_vendor_bills", { _company_id: company.id }); + if (billErr) companyResult.errors.push(`autocreate bills: ${billErr.message}`); + else if (madeBills) companyResult.autobills_created = madeBills; + } + companyResult.window = { since, until }; } catch (e: any) { companyResult.errors.push(e?.message || String(e)); diff --git a/supabase/migrations/20260616180000_autocreate_nobill_vendor_bills.sql b/supabase/migrations/20260616180000_autocreate_nobill_vendor_bills.sql new file mode 100644 index 0000000..1d2ab18 --- /dev/null +++ b/supabase/migrations/20260616180000_autocreate_nobill_vendor_bills.sql @@ -0,0 +1,67 @@ +-- Auto-create record-only bills for no-bill vendor payments in IMPORT-MODE companies. +-- +-- Buildium often records a vendor payment as a direct check (Dr Expense / Cr Bank) +-- with no Bill behind it. For import-mode companies (gl_auto_post=false) the GL is +-- the Buildium pull, and post_bill_gl no-ops, so a bill is a RECORD ONLY — creating +-- one for such a payment surfaces it on the Bills page WITHOUT touching the GL (the +-- expense is already booked once via the check). This makes A/P / vendor history +-- complete without any double-count. +-- +-- A "no-bill vendor payment" = a buildium_gl journal entry that: looks like a check +-- /payment (description), debits >=1 expense account, credits >=1 asset account (the +-- bank), and has NO accounts-payable line. Idempotent: skips entries that already +-- have an acmacc_autobill_nobill bill. Vendor is resolved from the payee (text after +-- the last "·" in the description) by exact name match; unmatched => bill with no +-- vendor (still correct on the GL, fixable later). Safe to call after every GL pull. + +create or replace function accounting.autocreate_nobill_vendor_bills(_company_id uuid) +returns integer +language plpgsql security definer set search_path to 'public','accounting' +as $function$ +declare _n int := 0; je record; _bill uuid; _vendor uuid; _payee text; +begin + -- Import-mode only: in gl_managed companies bills POST (post_bill_gl) and would + -- double-count; those use the direct-expense rule instead. + if accounting.gl_managed(_company_id) then return 0; end if; + + for je in + select j.id, j.date, j.description, + (select sum(l.debit) from accounting.journal_entry_lines l + join accounting.accounts a on a.id=l.account_id + where l.journal_entry_id=j.id and a.type='expense') as total + from accounting.journal_entries j + where j.company_id = _company_id + and j.external_source = 'buildium_gl' + and (j.description ilike 'Check%' or j.description ilike '%payment%') + and exists (select 1 from accounting.journal_entry_lines l join accounting.accounts a on a.id=l.account_id + where l.journal_entry_id=j.id and a.type='expense' and l.debit>0) + and exists (select 1 from accounting.journal_entry_lines l2 join accounting.accounts a2 on a2.id=l2.account_id + where l2.journal_entry_id=j.id and a2.type='asset' and l2.credit>0) + and not exists (select 1 from accounting.journal_entry_lines l3 join accounting.accounts a3 on a3.id=l3.account_id + where l3.journal_entry_id=j.id and a3.type='liability' and a3.name ilike '%payable%') + and not exists (select 1 from accounting.bills b + where b.company_id=_company_id and b.external_source='acmacc_autobill_nobill' and b.external_id=j.id::text) + loop + _payee := nullif(trim(regexp_replace(je.description, '^.*·\s*', '')), ''); + select id into _vendor from accounting.vendors + where company_id=_company_id and _payee is not null and lower(name)=lower(_payee) limit 1; + + insert into accounting.bills + (company_id, vendor_id, number, issue_date, due_date, status, subtotal, tax, total, paid_amount, + auto_created, external_source, external_id, notes) + values + (_company_id, _vendor, 'AUTOBILL-'||left(replace(je.id::text,'-',''),8), je.date, je.date, 'paid', + coalesce(je.total,0), 0, coalesce(je.total,0), coalesce(je.total,0), + true, 'acmacc_autobill_nobill', je.id::text, je.description) + returning id into _bill; + + insert into accounting.bill_items (bill_id, description, quantity, rate, amount, account_id) + select _bill, a.name, 1, l.debit, l.debit, l.account_id + from accounting.journal_entry_lines l join accounting.accounts a on a.id=l.account_id + where l.journal_entry_id=je.id and a.type='expense' and l.debit>0; + + _n := _n + 1; + end loop; + + return _n; +end$function$; From 8404f9b79d0dedb04955d7e6bc903d9690870530 Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 16 Jun 2026 13:15:34 -0400 Subject: [PATCH 2/2] Reports: keep archived accounts with a balance on the financial statements An archived COA account that still carried a GL balance was dropped from the Balance Sheet (fetchAllGLLines filtered is_archived=false), so the report went out of balance by exactly that amount. Now the financial-report GL fetches include archived accounts (those without activity never appear, since this queries journal lines), and buildBalanceSheet surfaces archived asset/liability /equity accounts that carry a balance as line items; archived income/expense flow into Net Income. Fixes the Village Woods $170.30 out-of-balance and the same latent issue in Bent Oak and Casuarina. Co-Authored-By: Claude Opus 4.8 --- .../accounting/AccountingReportsPage.tsx | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 095e535..36d777c 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -101,17 +101,20 @@ function shiftBack(from: string, to: string) { // falls past row 1000 silently read as $0). Order by a stable key (id) so the // pages don't overlap or skip rows. const GL_PAGE = 1000; -async function fetchAllGLLines(cid: string, to: string, select: string, from?: string): Promise { +async function fetchAllGLLines(cid: string, to: string, select: string, from?: string, includeArchived = false): Promise { const out: any[] = []; for (let offset = 0; ; offset += GL_PAGE) { let q = accounting .from("journal_entry_lines") .select(select) .eq("journal_entries.company_id", cid) - // Archived accounts stay off the financial statements (their history - // remains visible in the General Ledger report, which queries directly). - .eq("accounts.is_archived", false) .lte("journal_entries.date", to); + // Archived accounts are normally kept off the financial statements, BUT an + // archived account that still holds a balance MUST stay on them — otherwise + // its balance is silently dropped and the Balance Sheet goes out of balance + // by exactly that amount. Including archived accounts here is safe: this + // queries journal lines, so archived accounts with no activity never appear. + if (!includeArchived) q = q.eq("accounts.is_archived", false); if (from) q = q.gte("journal_entries.date", from); const { data, error } = await q.order("id", { ascending: true }).range(offset, offset + GL_PAGE - 1); if (error) throw error; @@ -140,9 +143,9 @@ export async function fetchReportData(cid: string, from: string, to: string) { // All bills (not date-filtered) for AP aging accounting.from("bills").select("id,vendor_id,total,paid_amount,status,due_date,issue_date,vendors(id,name)").eq("company_id", cid), // General-ledger lines in period — P&L is built from these, grouped by account - fetchAllGLLines(cid, to, "id,debit,credit,accounts!inner(id,name,code,type,parent_account_id),journal_entries!inner(company_id,date)", from), + fetchAllGLLines(cid, to, "id,debit,credit,accounts!inner(id,name,code,type,parent_account_id),journal_entries!inner(company_id,date)", from, true), // Cumulative GL through `to` — Balance Sheet is built from these (as-of balances) - fetchAllGLLines(cid, to, "id,debit,credit,account_id,accounts!inner(type),journal_entries!inner(company_id,date)"), + fetchAllGLLines(cid, to, "id,debit,credit,account_id,accounts!inner(id,name,code,type),journal_entries!inner(company_id,date)", undefined, true), // All invoices (not date-filtered) — Accounts Receivable = unpaid invoices accounting.from("invoices").select("total,paid_amount,status").eq("company_id", cid), // Whether the platform manages this company's GL (A/R-A/P sub-ledgers tie to the GL). @@ -1629,10 +1632,25 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep const accounts = (d.accounts ?? []) as any[]; const cur = bsBalances(d); const prev = useCompare && p ? bsBalances(p) : undefined; + // Surface accounts missing from the active COA list (i.e. archived) that still + // carry a balance, so their balance lands on the statement instead of being + // dropped (which would unbalance the Balance Sheet). Archived income/expense + // accounts already flow into Net Income via bsBalances; asset/liability/equity + // ones must appear as line items here. Zero-balance accounts are not added. + const knownIds = new Set(accounts.map((a) => a.id)); + const extraAccounts: any[] = []; + const seenExtra = new Set(); + for (const l of (d.glCumulative ?? []) as any[]) { + const id = l.account_id; const meta = l.accounts; + if (!meta || !id || knownIds.has(id) || seenExtra.has(id)) continue; + seenExtra.add(id); + if (Math.abs(cur.glByAcct.get(id) ?? 0) > 0.005) extraAccounts.push({ id, name: meta.name, code: meta.code, type: meta.type }); + } + const allAccounts = extraAccounts.length ? [...accounts, ...extraAccounts] : accounts; const balOf = (a: any) => (cur.glByAcct.get(a.id) ?? 0); const balOfP = (a: any) => (prev ? (prev.glByAcct.get(a.id) ?? 0) : undefined); const cmp = (v: number | undefined) => (prev ? v : undefined); - const byType = (t: string) => accounts.filter((a) => a.type === t); + const byType = (t: string) => allAccounts.filter((a) => a.type === t); const sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0); const sumBalP = (rows: any[]) => (prev ? rows.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined);