diff --git a/src/pages/accounting/AccountingBankingPage.tsx b/src/pages/accounting/AccountingBankingPage.tsx index 9cb2030..57ac34c 100644 --- a/src/pages/accounting/AccountingBankingPage.tsx +++ b/src/pages/accounting/AccountingBankingPage.tsx @@ -20,7 +20,7 @@ import { generateCheckPDF } from "./lib/checkPdf"; import { parseCsv, pick, parseDateStr } from "./lib/csv"; import { usePlaidLink } from "react-plaid-link"; import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid"; -import { applyPaymentToBill } from "./lib/autoBill"; +import { applyPaymentToBill, matchOpenBills } from "./lib/autoBill"; type TxForm = { account_id: string; @@ -223,11 +223,48 @@ export default function AccountingBankingPage() { const acc = (accounts as any[]).find((a) => a.id === accountId); if (!acc || selected.size === 0) return; const ids = [...selected]; - const { error } = await accounting.from("transactions") - .update({ coa_account_id: accountId, category: acc.name }).in("id", ids); - if (error) return toast.error(error.message); - toast.success(`Set category for ${ids.length} transaction${ids.length !== 1 ? "s" : ""}`); + const rows = (register as any[]).filter((r) => selected.has(r.id)); + + // Accrual A/P: before categorizing a vendor debit to an expense account, check + // whether it actually settles an open bill (matchOpenBills). A debit that + // uniquely matches one is a bill payment — clear A/P (coa null, link the bill) + // instead of re-hitting the expense, which was already booked on the bill. + // • exactly one match → auto-apply to the bill + // • more than one match → leave for the user to resolve in Pay Bills (skip) + // • no match → categorize as a direct expense (below) + const billPayments: { id: string; bill: any; amount: number }[] = []; + let ambiguous = 0; + for (const r of rows) { + if (r.type !== "debit" || !r.vendor_id) continue; + const matches = await matchOpenBills({ companyId: cid, vendorId: r.vendor_id, amount: Number(r.amount), date: r.date }); + if (matches.length === 1) billPayments.push({ id: r.id, bill: matches[0], amount: Number(r.amount) }); + else if (matches.length > 1) ambiguous++; + } + const handled = new Set([...billPayments.map((p) => p.id)]); + const categorizeIds = ids.filter((id) => !handled.has(id)); + + if (categorizeIds.length) { + const { error } = await accounting.from("transactions") + .update({ coa_account_id: accountId, category: acc.name }).in("id", categorizeIds); + if (error) return toast.error(error.message); + } + for (const p of billPayments) { + const bal = Number(p.bill.total) - Number(p.bill.paid_amount ?? 0); + const { error } = await accounting.from("transactions") + .update({ coa_account_id: null, bill_id: p.bill.id, category: `Bill Payment · ${p.bill.number}` }) + .eq("id", p.id); + if (error) return toast.error(error.message); + await applyPaymentToBill(p.bill.id, Math.min(p.amount, bal)); + } + + const parts: string[] = []; + if (categorizeIds.length) parts.push(`categorized ${categorizeIds.length}`); + if (billPayments.length) parts.push(`applied ${billPayments.length} to open bills`); + toast.success(parts.length ? parts.join(", ") : "No changes"); + if (ambiguous) toast.warning(`${ambiguous} debit${ambiguous !== 1 ? "s" : ""} match multiple open bills — resolve in Pay Bills`); qc.invalidateQueries({ queryKey: ["transactions", cid] }); + if (billPayments.length) qc.invalidateQueries({ queryKey: ["bills", cid] }); + setSelected(new Set()); }; const bulkSetDirection = async (type: "debit" | "credit") => { @@ -259,27 +296,28 @@ export default function AccountingBankingPage() { // Vendor-payment recognition rule: count the expense for the bill when it is // entered (accrual), or — when no bill exists — when the payment is made. - // • Vendor has OPEN bill(s) → this payment clears Accounts Payable (coa null, - // vendor set → post_transaction_gl posts Dr A/P / Cr Bank); the expense was - // already recognized on the bill. We then apply it to those bills (FIFO). - // • No open bill → the payment IS the expense: keep the chosen expense account - // so it posts Dr Expense / Cr Bank on the payment date. + // • Debit matches an OPEN bill (matchOpenBills: same vendor, amount within + // $0.01 or partial, date within ±30 days) → this payment clears Accounts + // Payable (coa null, vendor set → post_transaction_gl posts Dr A/P / Cr + // Bank); the expense was already recognized on the bill. We then apply it + // to the matched bill(s). + // • No matching bill → the payment IS the expense: keep the chosen expense + // account so it posts Dr Expense / Cr Bank on the payment date. // Customer deposits (credits) clear A/R via customer_id and are unchanged. - let openVendorBills: any[] = []; - if (type === "debit" && vendor_id) { - const { data: vbills } = await accounting - .from("bills").select("id,number,total,paid_amount,issue_date,status") - .eq("company_id", cid).eq("vendor_id", vendor_id); - openVendorBills = (vbills ?? []).filter((b: any) => - !["void", "draft"].includes(b.status) && Number(b.total) - Number(b.paid_amount ?? 0) > 0.005); - } - const debitClearsAp = type === "debit" && openVendorBills.length > 0; + const matchedBills = type === "debit" && vendor_id + ? await matchOpenBills({ companyId: cid, vendorId: vendor_id, amount, date }) + : []; + const debitClearsAp = matchedBills.length > 0; const payload: any = { account_id, date, description, amount, type, category, reference: reference || null, coa_account_id: debitClearsAp ? null : (coa_account_id || null), vendor_id: vendor_id || null, customer_id: customer_id || null, + // Link the settled bill only when a single bill matches (the guard requires + // coa null on any bill-linked row, which holds here). Multi-bill payments + // stay unlinked but still clear A/P via the vendor branch. + bill_id: matchedBills.length === 1 ? matchedBills[0].id : null, }; if (editId) { @@ -306,14 +344,13 @@ export default function AccountingBankingPage() { }); } - // When the payment cleared A/P (vendor had open bills), apply it to those - // bills oldest-first so they show paid. The expense lives on the bill, so no - // expense is booked here. With no open bill the payment already posted the - // expense directly (above) — nothing further to do. + // When the payment cleared A/P, apply it to the matched bill(s) oldest-first + // so they show paid. The expense lives on the bill, so no expense is booked + // here. With no matching bill the payment already posted the expense directly + // (above) — nothing further to do. if (debitClearsAp) { let remaining = amount; - const ordered = [...openVendorBills].sort((a, b) => String(a.issue_date ?? "").localeCompare(String(b.issue_date ?? ""))); - for (const b of ordered) { + for (const b of matchedBills) { if (remaining <= 0.005) break; const bal = Number(b.total) - Number(b.paid_amount ?? 0); const applied = Math.min(bal, remaining); diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx index 69d9f71..e155349 100644 --- a/src/pages/accounting/AccountingBillsPage.tsx +++ b/src/pages/accounting/AccountingBillsPage.tsx @@ -415,13 +415,14 @@ export default function AccountingBillsPage() { reference: refLabel, coa_account_id: null, // → posts against Accounts Payable (via vendor) vendor_id: payBill.vendor_id ?? null, // required so the GL clears A/P + bill_id: payBill.id, // links the payment to the bill it settles }); // 2) Bank balance auto-updated by DB trigger trg_sync_account_balance - // 3) Update bill paid amount + // 3) Update bill paid amount (partial payments leave the bill partially_paid) const newPaid = Number(payBill.paid_amount ?? 0) + Number(payAmount); - await accounting.from("bills").update({ paid_amount: newPaid, status: newPaid >= Number(payBill.total) ? "paid" : "open" }).eq("id", payBill.id); + await accounting.from("bills").update({ paid_amount: newPaid, status: newPaid >= Number(payBill.total) - 0.005 ? "paid" : "partially_paid" }).eq("id", payBill.id); // 4) If check + print: insert check record, print, mark printed, bump next # if (payMethod === "check") { diff --git a/src/pages/accounting/lib/autoBill.ts b/src/pages/accounting/lib/autoBill.ts index 6d7ed39..3c130f2 100644 --- a/src/pages/accounting/lib/autoBill.ts +++ b/src/pages/accounting/lib/autoBill.ts @@ -1,4 +1,7 @@ import { accounting } from "@/lib/accountingClient"; +import { debitMatchesBill, SETTLEABLE_BILL_STATUSES, type BillMatchInput } from "./billMatch"; + +export { debitMatchesBill, BILL_MATCH_WINDOW_DAYS, type BillMatchInput } from "./billMatch"; export type AutoBillSettings = { enabled: boolean; @@ -127,10 +130,34 @@ export async function applyPaymentToBill(billId: string, amount: number) { const { data: bill } = await accounting.from("bills").select("total, paid_amount").eq("id", billId).single(); if (!bill) return; const newPaid = Number(bill.paid_amount ?? 0) + amount; - const status = newPaid >= Number(bill.total) ? "paid" : "open"; + const total = Number(bill.total); + // Fully paid → "paid"; some balance still owed after a payment → "partially_paid". + const status = newPaid >= total - 0.005 ? "paid" : "partially_paid"; await accounting.from("bills").update({ paid_amount: newPaid, status }).eq("id", billId); } +/** + * Find the open vendor bill(s) a bank debit settles, per the accrual matching rule: + * • same vendor, status not in (paid, void, draft) + * • debit date within ±30 days of the bill's due date (falling back to issue date) + * • remaining balance equals the debit within $0.01 (full settlement), or the + * debit is ≤ the remaining balance (partial payment) + * Returns candidates oldest-first. Empty → treat the debit as a direct expense. + * One candidate → apply automatically. More than one → caller should disambiguate. + */ +export async function matchOpenBills(input: BillMatchInput) { + if (!input.vendorId || !(input.amount > 0)) return []; + const { data } = await accounting + .from("bills") + .select("id, number, total, paid_amount, issue_date, due_date, status, vendor_id") + .eq("company_id", input.companyId) + .eq("vendor_id", input.vendorId) + .in("status", SETTLEABLE_BILL_STATUSES); + return (data ?? []) + .filter((b: any) => debitMatchesBill(b, input)) + .sort((a: any, b: any) => String(a.issue_date ?? "").localeCompare(String(b.issue_date ?? ""))); +} + /** Orchestrator: returns either a match prompt, an auto-created bill, or skip */ export type HandleResult = | { kind: "skipped"; reason: string } diff --git a/src/pages/accounting/lib/billMatch.test.ts b/src/pages/accounting/lib/billMatch.test.ts new file mode 100644 index 0000000..f6a7628 --- /dev/null +++ b/src/pages/accounting/lib/billMatch.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { debitMatchesBill } from "./billMatch"; + +// Accrual A/P matching rule: a bank debit settles a bill only when the amount and +// date line up. Mirrors the brief's acceptance tests 5 and 6. +describe("debitMatchesBill", () => { + const bill = (over: Partial[0]> = {}) => ({ + total: 100, paid_amount: 0, issue_date: "2026-04-15", due_date: "2026-04-15", ...over, + }); + + it("matches a same-amount debit within the ±30 day window (test 5)", () => { + expect(debitMatchesBill(bill(), { amount: 100, date: "2026-04-20" })).toBe(true); + }); + + it("treats $0.01 differences as a full match", () => { + expect(debitMatchesBill(bill(), { amount: 100.01, date: "2026-04-15" })).toBe(true); + expect(debitMatchesBill(bill(), { amount: 99.99, date: "2026-04-15" })).toBe(true); + }); + + it("rejects a debit outside the ±30 day window even if the amount is identical (test 6)", () => { + // FPL $307.67 billed once in April; identical payments in Jan/Feb must NOT match. + const b = bill({ total: 307.67, issue_date: "2026-04-15", due_date: "2026-04-15" }); + expect(debitMatchesBill(b, { amount: 307.67, date: "2026-01-26" })).toBe(false); + expect(debitMatchesBill(b, { amount: 307.67, date: "2026-02-24" })).toBe(false); + expect(debitMatchesBill(b, { amount: 307.67, date: "2026-04-15" })).toBe(true); + }); + + it("matches a partial payment (debit ≤ remaining balance)", () => { + expect(debitMatchesBill(bill(), { amount: 40, date: "2026-04-15" })).toBe(true); + }); + + it("respects remaining balance, not the original total", () => { + const partlyPaid = bill({ total: 100, paid_amount: 60 }); // $40 remaining + expect(debitMatchesBill(partlyPaid, { amount: 40, date: "2026-04-15" })).toBe(true); + expect(debitMatchesBill(partlyPaid, { amount: 100, date: "2026-04-15" })).toBe(false); // exceeds remaining + }); + + it("does not match a fully paid bill", () => { + expect(debitMatchesBill(bill({ total: 100, paid_amount: 100 }), { amount: 100, date: "2026-04-15" })).toBe(false); + }); + + it("uses issue_date when due_date is absent", () => { + expect(debitMatchesBill(bill({ due_date: null, issue_date: "2026-04-15" }), { amount: 100, date: "2026-05-10" })).toBe(true); + expect(debitMatchesBill(bill({ due_date: null, issue_date: "2026-04-15" }), { amount: 100, date: "2026-06-01" })).toBe(false); + }); +}); diff --git a/src/pages/accounting/lib/billMatch.ts b/src/pages/accounting/lib/billMatch.ts new file mode 100644 index 0000000..9931fdb --- /dev/null +++ b/src/pages/accounting/lib/billMatch.ts @@ -0,0 +1,48 @@ +// Pure accrual A/P matching logic — no DB/client imports so it stays unit-testable. +// +// A bank debit to a vendor either settles an existing bill (the expense was already +// booked when the bill was entered → the payment must only clear A/P) or it is a +// direct expense (no matching bill → book it now). + +export const BILL_MATCH_WINDOW_DAYS = 30; +export const BILL_MATCH_TOLERANCE = 0.01; +// Bill statuses a payment can still settle (excludes paid / void / draft). +export const SETTLEABLE_BILL_STATUSES = ["open", "overdue", "partially_paid"]; + +export type BillMatchInput = { + companyId: string; + vendorId: string | null; + amount: number; + date: string; // YYYY-MM-DD +}; + +export type MatchableBill = { + total: number | string; + paid_amount?: number | string | null; + issue_date?: string | null; + due_date?: string | null; +}; + +function shiftDays(date: string, days: number): Date { + const d = new Date(date + "T00:00:00"); + d.setDate(d.getDate() + days); + return d; +} + +/** + * Pure predicate: does a single bill settle this bank debit? + * • debit date within ±30 days of the bill's due date (falling back to issue date) + * • remaining balance equals the debit within $0.01 (full settlement), or the + * debit is ≤ the remaining balance (partial payment) + * Status/vendor filtering happens in the query; this only judges amount + date. + */ +export function debitMatchesBill(bill: MatchableBill, input: Pick): boolean { + if (!(input.amount > 0)) return false; + const remaining = Number(bill.total) - Number(bill.paid_amount ?? 0); + if (remaining <= BILL_MATCH_TOLERANCE) return false; + const ref = new Date(((bill.due_date ?? bill.issue_date) ?? input.date) + "T00:00:00"); + if (ref < shiftDays(input.date, -BILL_MATCH_WINDOW_DAYS) || ref > shiftDays(input.date, BILL_MATCH_WINDOW_DAYS)) return false; + const full = Math.abs(remaining - input.amount) <= BILL_MATCH_TOLERANCE; + const partial = input.amount <= remaining + BILL_MATCH_TOLERANCE; + return full || partial; +} diff --git a/supabase/migrations/20260604120000_transactions_bill_link_and_ap_guard.sql b/supabase/migrations/20260604120000_transactions_bill_link_and_ap_guard.sql new file mode 100644 index 0000000..040e6b5 --- /dev/null +++ b/supabase/migrations/20260604120000_transactions_bill_link_and_ap_guard.sql @@ -0,0 +1,22 @@ +-- Accrual A/P guard: link a bank transaction to the bill it settles, and enforce +-- that a bill-linked payment never carries an expense category. +-- +-- Under accrual, a vendor expense is recognized once — when the bill is entered +-- (Dr Expense / Cr A/P). Paying the bill must only relieve the liability +-- (Dr A/P / Cr Bank) via accounting.post_transaction_gl's vendor → A/P branch, +-- which only runs when coa_account_id IS NULL. So any transaction that settles a +-- bill must have coa_account_id NULL; otherwise it re-recognizes the expense. + +alter table accounting.transactions + add column if not exists bill_id uuid references accounting.bills(id) on delete set null; + +create index if not exists idx_transactions_bill_id + on accounting.transactions(bill_id) where bill_id is not null; + +-- Invariant: a transaction linked to a bill posts against Accounts Payable, never +-- an expense account. This makes the accrual rule enforceable, not just convention. +alter table accounting.transactions + drop constraint if exists chk_bill_payment_no_coa; +alter table accounting.transactions + add constraint chk_bill_payment_no_coa + check (bill_id is null or coa_account_id is null); diff --git a/supabase/migrations/20260604120500_cleanup_ashley_manor_double_counted_bill_payments.sql b/supabase/migrations/20260604120500_cleanup_ashley_manor_double_counted_bill_payments.sql new file mode 100644 index 0000000..efeac37 --- /dev/null +++ b/supabase/migrations/20260604120500_cleanup_ashley_manor_double_counted_bill_payments.sql @@ -0,0 +1,33 @@ +-- One-time remediation: Ashley Manor bill payments that double-counted the expense. +-- +-- These bank debits settled an existing bill but were written with an expense +-- coa_account_id, so the GL posted Dr Expense / Cr Bank — re-recognizing an expense +-- already booked when the bill was entered, and never relieving Accounts Payable. +-- Clearing coa_account_id makes accounting.post_transaction_gl repost them via the +-- vendor → A/P branch as Dr A/P / Cr Bank, correcting prior periods. The matched +-- bills are already marked paid, so paid_amount is intentionally left untouched. +-- +-- Match rule (matches the accrual A/P matching rule used by the app): same vendor, +-- bill not void/draft, amount within $0.01 of the bill total, debit date within +-- ±30 days of the bill's due (else issue) date. When a single bill matches, the +-- payment is linked via bill_id; ambiguous (multi-bill) matches clear coa only. +with targets as ( + select t.id as txn_id, + (select array_agg(b.id) from accounting.bills b + where b.company_id = t.company_id and b.vendor_id = t.vendor_id + and b.status <> 'void' and b.status <> 'draft' + and abs(b.total - t.amount) <= 0.01 + and t.date between (coalesce(b.due_date, b.issue_date) - interval '30 days') + and (coalesce(b.due_date, b.issue_date) + interval '30 days') + ) as bill_ids + from accounting.transactions t + join accounting.companies c on c.id = t.company_id + where c.name ilike 'Ashley Manor%' + and t.type = 'debit' and t.vendor_id is not null and t.coa_account_id is not null +) +update accounting.transactions t +set coa_account_id = null, + bill_id = case when array_length(targets.bill_ids, 1) = 1 then targets.bill_ids[1] else null end +from targets +where t.id = targets.txn_id + and targets.bill_ids is not null;