From 2d216e24c9854877a51fc68d459270044c4ae1b4 Mon Sep 17 00:00:00 2001 From: renee-png Date: Mon, 1 Jun 2026 21:36:55 -0400 Subject: [PATCH] Sync owner ledger + payments into Accounting DB triggers on public.owner_ledger_entries (migration applied to prod): charges (debit) -> accounting.invoices; payments (credit) -> accounting.payments_received (deposited=false, Undeposited Funds). Customer balance recomputed authoritatively from the source ledger; ledger payments FIFO-applied to ledger invoices. Keyed external_source='acmacc_ledger'. Backfilled 6,756 invoices + 4,253 payments; balances reconcile exactly. Frontend: customer Ledger tab now renders real payments_received credits (true dates/amounts); Make Deposit page surfaces undeposited payments_received alongside Undeposited Funds transactions and deposits both. Co-Authored-By: Claude Opus 4.8 --- .../AccountingCustomerDetailPage.tsx | 37 +++- .../accounting/AccountingDepositsPage.tsx | 128 +++++++++---- ...601130000_accounting_sync_owner_ledger.sql | 179 ++++++++++++++++++ 3 files changed, 301 insertions(+), 43 deletions(-) create mode 100644 supabase/migrations/20260601130000_accounting_sync_owner_ledger.sql diff --git a/src/pages/accounting/AccountingCustomerDetailPage.tsx b/src/pages/accounting/AccountingCustomerDetailPage.tsx index 73d2544..99f3e7c 100644 --- a/src/pages/accounting/AccountingCustomerDetailPage.tsx +++ b/src/pages/accounting/AccountingCustomerDetailPage.tsx @@ -65,7 +65,7 @@ export default function AccountingCustomerDetailPage() { queryFn: async () => { const { data } = await accounting .from("invoices") - .select("id,number,issue_date,due_date,total,paid_amount,status,updated_at,notes") + .select("id,number,issue_date,due_date,total,paid_amount,status,updated_at,notes,external_source") .eq("company_id", cid) .eq("customer_id", id) .order("issue_date", { ascending: true }); @@ -73,6 +73,22 @@ export default function AccountingCustomerDetailPage() { }, }); + // Payments recorded against this homeowner (incl. those synced from the main + // app's owner ledger). Shown as real ledger credits so dates/amounts are exact. + const { data: payments = [] } = useQuery({ + queryKey: ["customer-payments", id], + enabled: !!id && !!cid, + queryFn: async () => { + const { data } = await accounting + .from("payments_received") + .select("id,payment_date,amount,method,reference,memo,deposited") + .eq("company_id", cid) + .eq("customer_id", id) + .order("payment_date", { ascending: true }); + return data ?? []; + }, + }); + const allRows = useMemo(() => { const rows: LedgerRow[] = []; for (const inv of invoices as any[]) { @@ -91,7 +107,10 @@ export default function AccountingCustomerDetailPage() { dueDate: inv.due_date, }); } - if (paid > 0) { + // Manual invoices track payment via paid_amount (no payments_received row). + // Synced ledger payments are rendered from payments_received below, so skip + // their paid_amount here to avoid double-counting. + if (paid > 0 && inv.external_source !== "acmacc_ledger") { rows.push({ date: (inv.updated_at ?? inv.issue_date).slice(0, 10), type: "Payment", @@ -104,9 +123,21 @@ export default function AccountingCustomerDetailPage() { }); } } + for (const p of payments as any[]) { + rows.push({ + date: p.payment_date, + type: "Payment", + ref: p.reference || "Payment", + description: [p.method, p.memo].filter(Boolean).join(" · ") || "Payment received", + debit: 0, + credit: Number(p.amount ?? 0), + sourceId: p.id, + sourceKind: "payment", + }); + } rows.sort((a, b) => a.date.localeCompare(b.date)); return rows; - }, [invoices]); + }, [invoices, payments]); const openingBalance = useMemo( () => allRows.filter((r) => r.date < from).reduce((s, r) => s + r.debit - r.credit, 0), diff --git a/src/pages/accounting/AccountingDepositsPage.tsx b/src/pages/accounting/AccountingDepositsPage.tsx index 249cf3d..cd742ce 100644 --- a/src/pages/accounting/AccountingDepositsPage.tsx +++ b/src/pages/accounting/AccountingDepositsPage.tsx @@ -40,8 +40,11 @@ export default function AccountingDepositsPage() { (await accounting.from("accounts").select("id,name,code,balance").eq("company_id", cid).eq("is_bank", true).order("code")).data ?? [], }); - const { data: pending = [] } = useQuery({ - queryKey: ["undeposited", cid, undepositedId], + // Two sources of "awaiting deposit": transactions parked on the Undeposited + // Funds account (banking flow) and payments_received not yet deposited (incl. + // payments synced from the main app's owner ledger). Both are unified below. + const { data: pendingTx = [] } = useQuery({ + queryKey: ["undeposited-tx", cid, undepositedId], enabled: !!cid && !!undepositedId, queryFn: async () => { const { data } = await accounting @@ -56,14 +59,45 @@ export default function AccountingDepositsPage() { }, }); + const { data: pendingPmt = [] } = useQuery({ + queryKey: ["undeposited-pmt", cid], + enabled: !!cid, + queryFn: async () => { + const { data } = await accounting + .from("payments_received") + .select("id,payment_date,amount,method,reference,memo,customer_id") + .eq("company_id", cid) + .eq("deposited", false) + .order("payment_date", { ascending: false }); + return data ?? []; + }, + }); + + type PendingRow = { key: string; kind: "tx" | "pmt"; id: string; date: string; description: string; reference: string | null; amount: number }; + + const pending = useMemo(() => { + const rows: PendingRow[] = [ + ...(pendingTx as any[]).map((t) => ({ + key: `tx:${t.id}`, kind: "tx" as const, id: t.id, date: t.date, + description: t.description, reference: t.reference ?? null, amount: Number(t.amount), + })), + ...(pendingPmt as any[]).map((p) => ({ + key: `pmt:${p.id}`, kind: "pmt" as const, id: p.id, date: p.payment_date, + description: [p.method, p.memo].filter(Boolean).join(" · ") || "Customer payment", + reference: p.reference ?? null, amount: Number(p.amount), + })), + ]; + return rows.sort((a, b) => b.date.localeCompare(a.date)); + }, [pendingTx, pendingPmt]); + const selectedTotal = useMemo( - () => (pending as any[]).filter((t) => selected.has(t.id)).reduce((s, t) => s + Number(t.amount), 0), + () => pending.filter((r) => selected.has(r.key)).reduce((s, r) => s + r.amount, 0), [pending, selected] ); const toggleAll = () => { - if (selected.size === (pending as any[]).length) setSelected(new Set()); - else setSelected(new Set((pending as any[]).map((t) => t.id))); + if (selected.size === pending.length) setSelected(new Set()); + else setSelected(new Set(pending.map((r) => r.key))); }; const submitDeposit = async () => { @@ -72,7 +106,11 @@ export default function AccountingDepositsPage() { setSaving(true); try { const bank = (bankAccounts as any[]).find((a) => a.id === bankAccountId); - const ids = Array.from(selected); + const chosen = pending.filter((r) => selected.has(r.key)); + const txIds = chosen.filter((r) => r.kind === "tx").map((r) => r.id); + const pmtIds = chosen.filter((r) => r.kind === "pmt").map((r) => r.id); + const txTotal = chosen.filter((r) => r.kind === "tx").reduce((s, r) => s + r.amount, 0); + const count = chosen.length; // 1) Create deposit record const { data: dep, error: depErr } = await accounting @@ -81,49 +119,59 @@ export default function AccountingDepositsPage() { .select() .single(); if (depErr || !dep) throw new Error(depErr?.message ?? "Failed to create deposit"); + const ref = `DEP-${dep.id.slice(0, 8).toUpperCase()}`; - // 2) Single debit on bank account + // 2) Single debit on bank account for the full deposit await accounting.from("transactions").insert({ company_id: cid, account_id: bankAccountId, date: depositDate, type: "debit", amount: selectedTotal, - description: `Deposit · ${ids.length} payment${ids.length > 1 ? "s" : ""}${memo ? " · " + memo : ""}`, + description: `Deposit · ${count} payment${count > 1 ? "s" : ""}${memo ? " · " + memo : ""}`, category: "Deposit", - reference: `DEP-${dep.id.slice(0, 8).toUpperCase()}`, + reference: ref, deposit_id: dep.id, }); - // 3) Single offsetting credit on Undeposited Funds (clears the holding balance) - await accounting.from("transactions").insert({ - company_id: cid, - account_id: undepositedId, - date: depositDate, - type: "credit", - amount: selectedTotal, - description: `Deposit cleared · ${ids.length} payment${ids.length > 1 ? "s" : ""}`, - category: "Deposit", - reference: `DEP-${dep.id.slice(0, 8).toUpperCase()}`, - deposit_id: dep.id, - }); + // 3) Offsetting credit on Undeposited Funds — only for the portion actually + // held there as transactions (payments_received aren't posted to it). + if (txTotal > 0) { + await accounting.from("transactions").insert({ + company_id: cid, + account_id: undepositedId, + date: depositDate, + type: "credit", + amount: txTotal, + description: `Deposit cleared · ${txIds.length} payment${txIds.length > 1 ? "s" : ""}`, + category: "Deposit", + reference: ref, + deposit_id: dep.id, + }); + await accounting.from("transactions").update({ deposit_id: dep.id }).in("id", txIds); + const { data: und } = await accounting.from("accounts").select("balance").eq("id", undepositedId).single(); + if (und) { + await accounting.from("accounts").update({ balance: Number(und.balance) - txTotal }).eq("id", undepositedId); + } + } - // 4) Tag selected pending payments with deposit_id so they disappear from the queue - await accounting.from("transactions").update({ deposit_id: dep.id }).in("id", ids); + // 4) Mark selected payments_received as deposited so they leave the queue + if (pmtIds.length) { + await accounting.from("payments_received") + .update({ deposited: true, deposit_id: dep.id, bank_account_id: bankAccountId }) + .in("id", pmtIds); + } - // 5) Update account balances + // 5) Bank balance reflects the full deposit if (bank) { await accounting.from("accounts").update({ balance: Number(bank.balance) + selectedTotal }).eq("id", bank.id); } - const { data: und } = await accounting.from("accounts").select("balance").eq("id", undepositedId).single(); - if (und) { - await accounting.from("accounts").update({ balance: Number(und.balance) - selectedTotal }).eq("id", undepositedId); - } toast.success(`Deposit of ${money(selectedTotal, cur)} recorded`); setSelected(new Set()); setMemo(""); - qc.invalidateQueries({ queryKey: ["undeposited", cid] }); + qc.invalidateQueries({ queryKey: ["undeposited-tx", cid] }); + qc.invalidateQueries({ queryKey: ["undeposited-pmt", cid] }); qc.invalidateQueries({ queryKey: ["bank-accounts", cid] }); qc.invalidateQueries({ queryKey: ["accounts", cid] }); qc.invalidateQueries({ queryKey: ["transactions", cid] }); @@ -178,7 +226,7 @@ export default function AccountingDepositsPage() { Payments awaiting deposit
- Selected: {money(selectedTotal, cur)} ({selected.size} of {(pending as any[]).length}) + Selected: {money(selectedTotal, cur)} ({selected.size} of {pending.length})
@@ -188,7 +236,7 @@ export default function AccountingDepositsPage() { 0 && selected.size === (pending as any[]).length} + checked={selected.size > 0 && selected.size === pending.length} onChange={toggleAll} /> @@ -199,20 +247,20 @@ export default function AccountingDepositsPage() { - {(pending as any[]).map((t) => ( - { + {pending.map((r) => ( + { const s = new Set(selected); - s.has(t.id) ? s.delete(t.id) : s.add(t.id); + s.has(r.key) ? s.delete(r.key) : s.add(r.key); setSelected(s); }}> - - {fmtDate(t.date)} - {t.description} - {t.reference ?? "—"} - {money(t.amount, cur)} + + {fmtDate(r.date)} + {r.description} + {r.reference ?? "—"} + {money(r.amount, cur)} ))} - {(pending as any[]).length === 0 && ( + {pending.length === 0 && ( 0) -> accounting.invoices (the customer's AR ledger) +-- * a PAYMENT (credit > 0) -> accounting.payments_received, deposited=false +-- (Undeposited Funds) so the funds flow into the "Make Deposit" page. +-- The accounting customer's balance is recomputed authoritatively from the +-- source ledger. Scoped to associations that have an accounting.companies row. +-- Keyed by external_source='acmacc_ledger', external_id=owner_ledger_entries.id. + +-- payments_received needs external linkage columns (invoices/bills/vendors already have them) +alter table accounting.payments_received add column if not exists external_source text; +alter table accounting.payments_received add column if not exists external_id text; +create unique index if not exists ux_payments_received_ext + on accounting.payments_received (company_id, external_source, external_id) + where external_id is not null; + +-- --------------------------------------------------------------------------- +-- Recompute a synced customer's balance from the source ledger and FIFO-apply +-- ledger payments across that customer's ledger invoices (so paid_amount/status +-- look right). Only touches acmacc_ledger rows — manual invoices/payments are +-- left alone. +-- --------------------------------------------------------------------------- +create or replace function accounting.recompute_customer_from_ledger( + _company_id uuid, _customer_id uuid, _unit_id uuid, _association_id uuid +) returns void +language plpgsql security definer set search_path to 'public','accounting' +as $$ +declare + _bal numeric; + _pool numeric; + _alloc numeric; + inv record; +begin + if _customer_id is null or _unit_id is null then return; end if; + + -- Authoritative AR balance = sum(debit - credit) of the unit's live ledger. + select coalesce(sum(debit - credit), 0) into _bal + from public.owner_ledger_entries + where unit_id = _unit_id and association_id = _association_id + and coalesce(is_archived, false) = false; + update accounting.customers set balance = _bal where id = _customer_id; + + -- FIFO-allocate ledger payments across ledger invoices. + select coalesce(sum(amount), 0) into _pool + from accounting.payments_received + where customer_id = _customer_id and external_source = 'acmacc_ledger'; + + for inv in + select id, total from accounting.invoices + where customer_id = _customer_id and external_source = 'acmacc_ledger' + order by issue_date asc, created_at asc + loop + _alloc := greatest(0, least(_pool, inv.total)); + update accounting.invoices set + paid_amount = _alloc, + status = case when _alloc >= inv.total and inv.total > 0 + then 'paid'::accounting.invoice_status + else 'sent'::accounting.invoice_status end, + updated_at = now() + where id = inv.id; + _pool := _pool - _alloc; + end loop; +end; +$$; + +-- --------------------------------------------------------------------------- +-- Sync one ledger entry into accounting (idempotent). +-- --------------------------------------------------------------------------- +create or replace function accounting.sync_owner_ledger_entry(_entry_id uuid) +returns void +language plpgsql security definer set search_path to 'public','accounting' +as $$ +declare + e public.owner_ledger_entries%rowtype; + _company_id uuid; + _customer_id uuid; +begin + select * into e from public.owner_ledger_entries where id = _entry_id; + if not found then return; end if; + + select id into _company_id from accounting.companies where association_id = e.association_id; + if _company_id is null then return; end if; + + select id into _customer_id from accounting.customers + where company_id = _company_id and external_source = 'acmacc_unit' + and external_id = e.unit_id::text; + if _customer_id is null then return; end if; -- unit not synced; nothing to attach to + + if coalesce(e.is_archived, false) then + delete from accounting.invoices where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text; + delete from accounting.payments_received where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text; + + elsif coalesce(e.debit, 0) > 0 then + -- charge -> invoice (drop any stale payment row for this entry) + delete from accounting.payments_received where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text; + insert into accounting.invoices + (company_id, customer_id, number, issue_date, due_date, status, subtotal, tax, total, notes, external_source, external_id) + values + (_company_id, _customer_id, + 'AR-' || to_char(e.date, 'YYYYMMDD') || '-' || left(replace(e.id::text, '-', ''), 6), + e.date, e.date, 'sent'::accounting.invoice_status, + e.debit, 0, e.debit, + coalesce(nullif(e.description, ''), initcap(e.transaction_type)), + 'acmacc_ledger', e.id::text) + on conflict (company_id, external_source, external_id) where external_id is not null + do update set customer_id=excluded.customer_id, issue_date=excluded.issue_date, + due_date=excluded.due_date, subtotal=excluded.subtotal, total=excluded.total, + notes=excluded.notes, number=excluded.number, updated_at=now(); + + elsif coalesce(e.credit, 0) > 0 then + -- payment -> payments_received (Undeposited Funds); never reset deposited/deposit_id + delete from accounting.invoices where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text; + insert into accounting.payments_received + (company_id, customer_id, payment_date, amount, method, reference, memo, deposited, external_source, external_id) + values + (_company_id, _customer_id, e.date, e.credit, + coalesce(nullif(e.transaction_type, ''), 'payment'), + e.reference_id, e.description, false, 'acmacc_ledger', e.id::text) + on conflict (company_id, external_source, external_id) where external_id is not null + do update set customer_id=excluded.customer_id, payment_date=excluded.payment_date, + amount=excluded.amount, method=excluded.method, reference=excluded.reference, + memo=excluded.memo, updated_at=now(); + + else + delete from accounting.invoices where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text; + delete from accounting.payments_received where company_id=_company_id and external_source='acmacc_ledger' and external_id=e.id::text; + end if; + + perform accounting.recompute_customer_from_ledger(_company_id, _customer_id, e.unit_id, e.association_id); +end; +$$; + +-- Remove synced rows for a deleted/moved ledger entry, then recompute. +create or replace function accounting.unsync_owner_ledger_entry( + _entry_id uuid, _association_id uuid, _unit_id uuid +) returns void +language plpgsql security definer set search_path to 'public','accounting' +as $$ +declare _company_id uuid; _customer_id uuid; +begin + select id into _company_id from accounting.companies where association_id=_association_id; + if _company_id is null then return; end if; + delete from accounting.invoices where company_id=_company_id and external_source='acmacc_ledger' and external_id=_entry_id::text; + delete from accounting.payments_received where company_id=_company_id and external_source='acmacc_ledger' and external_id=_entry_id::text; + select id into _customer_id from accounting.customers + where company_id=_company_id and external_source='acmacc_unit' and external_id=_unit_id::text; + perform accounting.recompute_customer_from_ledger(_company_id, _customer_id, _unit_id, _association_id); +end; +$$; + +-- Trigger glue (error-swallowed so a sync hiccup never blocks a ledger write). +create or replace function accounting.tg_owner_ledger_sync() +returns trigger +language plpgsql security definer set search_path to 'public','accounting' +as $$ +begin + begin + if tg_op = 'DELETE' then + perform accounting.unsync_owner_ledger_entry(old.id, old.association_id, old.unit_id); + return old; + end if; + if tg_op = 'UPDATE' + and (old.unit_id is distinct from new.unit_id + or old.association_id is distinct from new.association_id) then + perform accounting.unsync_owner_ledger_entry(old.id, old.association_id, old.unit_id); + end if; + perform accounting.sync_owner_ledger_entry(new.id); + return new; + exception when others then + raise warning 'accounting: owner_ledger sync failed for %: %', coalesce(new.id, old.id), sqlerrm; + return coalesce(new, old); + end; +end; +$$; + +drop trigger if exists trg_acct_sync_owner_ledger on public.owner_ledger_entries; +create trigger trg_acct_sync_owner_ledger + after insert or update or delete on public.owner_ledger_entries + for each row execute function accounting.tg_owner_ledger_sync();