Accounting: Sales Receipts, COA sync to dashboard, vendor-expense recognition

- Add Sales Receipts page (dashboard/accounting/sales-receipts): records a
  cash sale (name, address, income account, price, qty) — deposits and books
  income in one step via a transaction. New accounting.sales_receipts table.
- Sync chart of accounts to the accounting dashboard: mirror accounting.accounts
  into public.chart_of_accounts for platform associations (one-way, same id) so
  Bill Approvals and every COA consumer use the dashboard's accounts. Legacy
  rows hidden; Bill Approvals made system-aware.
- Vendor-expense recognition: a vendor payment with no bill now books the
  expense directly (Dr Expense / Cr Bank) on the payment date instead of going
  to A/P; payments against open bills still clear A/P (applied FIFO). Backfill
  reclassifies unbilled payments stuck in A/P. Expense Summary report made
  GL-driven so it follows the same rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 10:01:18 -04:00
parent bd5caf5415
commit d82466f826
14 changed files with 688 additions and 24 deletions
+15 -11
View File
@@ -1294,18 +1294,22 @@ function buildFlat(id: ReportId, d: any, cur: string): Flat | null {
rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
};
case "expense-summary": {
const byCat: Record<string, number> = {};
// Direct expenses from expenses table
for (const e of d.expenses) byCat[e.category] = (byCat[e.category] ?? 0) + Number(e.amount);
// Bill expenses (accrual — total billed, not just paid)
for (const b of d.bills) {
if (b.status === "void" || b.status === "draft") continue;
const cat = b.vendors?.name ?? "Vendor Expenses";
byCat[cat] = (byCat[cat] ?? 0) + Number(b.total);
// GL-driven so it follows the same recognition rule as the P&L: a bill's
// expense counts on the bill date (Dr Expense / Cr A/P), and a vendor payment
// with no bill counts on the payment date (Dr Expense / Cr Bank). Reading the
// ledger avoids double-counting and never misses direct payments.
const byAcct: Record<string, number> = {};
for (const l of (d.glLines ?? []) as any[]) {
const acc = l.accounts;
if (acc?.type !== "expense") continue;
const amt = Number(l.debit) - Number(l.credit);
if (amt === 0) continue;
const name = acc.name ?? "Expense";
byAcct[name] = (byAcct[name] ?? 0) + amt;
}
const rows = Object.entries(byCat).sort((a, b) => b[1] - a[1]).map(([cat, amt]) => [cat, m(amt)]);
const total = Object.values(byCat).reduce((s, v) => s + v, 0);
return { title: "Expense Summary (Accrual)", columns: ["Category / Vendor", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
const rows = Object.entries(byAcct).sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]);
const total = Object.values(byAcct).reduce((s, v) => s + v, 0);
return { title: "Expense Summary (Accrual)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
}
case "vendor-balances": {
const byVendor: Record<string, number> = {};