mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
+33
@@ -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;
|
||||
Reference in New Issue
Block a user