Accounting: enforce accrual A/P on bill payments (match rule + guard + cleanup)

Under accrual, a vendor expense is recognized once — when the bill is entered
(Dr Expense / Cr A/P). Paying a bill must only relieve the liability
(Dr A/P / Cr Bank) and never re-hit the expense account. This hardens the
existing Pay Bills flow against re-recognition and double counting.

- Strict bill matching: extract a pure, dependency-free matcher into
  lib/billMatch.ts (debitMatchesBill/matchOpenBills) — same vendor, status
  open/overdue/partially_paid, amount within $0.01 of remaining (or <= remaining
  for partials), debit date within ±30 days of due/issue date. Unit-tested in
  billMatch.test.ts (covers the identical-recurring-charge regression).
- AccountingBankingPage.saveTx uses the strict rule (was "any open bill"), so a
  thrice-paid identical charge only clears the in-window bill.
- Bank-feed categorizer (bulkSetCategory) matches open bills before assigning an
  expense COA: single match clears A/P + links the bill; multi-match is skipped
  with a prompt to resolve in Pay Bills; no match categorizes as a direct expense.
- DB guard: add accounting.transactions.bill_id (FK -> bills) and CHECK
  chk_bill_payment_no_coa (bill_id IS NULL OR coa_account_id IS NULL) so a
  bill-linked payment can never carry an expense category. Both writers set
  bill_id on single-bill payments; partial payments now write partially_paid.
- One-time cleanup: clear coa_account_id on Ashley Manor's 8 double-counted bill
  payments ($2,198.98) so the GL reposts them as Dr A/P / Cr Bank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:30:44 -04:00
parent d82466f826
commit 7464d55b6c
7 changed files with 243 additions and 28 deletions
+62 -25
View File
@@ -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);
+3 -2
View File
@@ -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") {
+28 -1
View File
@@ -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 }
@@ -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<Parameters<typeof debitMatchesBill>[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);
});
});
+48
View File
@@ -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<BillMatchInput, "amount" | "date">): 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;
}
@@ -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);
@@ -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;