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 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:36:55 -04:00
parent 8ac0edfbd9
commit 2d216e24c9
3 changed files with 301 additions and 43 deletions
@@ -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<LedgerRow[]>(() => {
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),
+88 -40
View File
@@ -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<PendingRow[]>(() => {
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() {
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Payments awaiting deposit</CardTitle>
<div className="text-sm">
Selected: <b>{money(selectedTotal, cur)}</b> ({selected.size} of {(pending as any[]).length})
Selected: <b>{money(selectedTotal, cur)}</b> ({selected.size} of {pending.length})
</div>
</CardHeader>
<CardContent>
@@ -188,7 +236,7 @@ export default function AccountingDepositsPage() {
<TableHead className="w-10">
<input
type="checkbox"
checked={selected.size > 0 && selected.size === (pending as any[]).length}
checked={selected.size > 0 && selected.size === pending.length}
onChange={toggleAll}
/>
</TableHead>
@@ -199,20 +247,20 @@ export default function AccountingDepositsPage() {
</TableRow>
</TableHeader>
<TableBody>
{(pending as any[]).map((t) => (
<TableRow key={t.id} className="cursor-pointer" onClick={() => {
{pending.map((r) => (
<TableRow key={r.key} className="cursor-pointer" onClick={() => {
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);
}}>
<TableCell><input type="checkbox" checked={selected.has(t.id)} readOnly /></TableCell>
<TableCell>{fmtDate(t.date)}</TableCell>
<TableCell>{t.description}</TableCell>
<TableCell className="text-muted-foreground">{t.reference ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums">{money(t.amount, cur)}</TableCell>
<TableCell><input type="checkbox" checked={selected.has(r.key)} readOnly /></TableCell>
<TableCell>{fmtDate(r.date)}</TableCell>
<TableCell>{r.description}</TableCell>
<TableCell className="text-muted-foreground">{r.reference ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums">{money(r.amount, cur)}</TableCell>
</TableRow>
))}
{(pending as any[]).length === 0 && (
{pending.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={5} className="p-0">
<EmptyState