mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50: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 { parseCsv, pick, parseDateStr } from "./lib/csv";
|
||||||
import { usePlaidLink } from "react-plaid-link";
|
import { usePlaidLink } from "react-plaid-link";
|
||||||
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
|
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
|
||||||
import { applyPaymentToBill } from "./lib/autoBill";
|
import { applyPaymentToBill, matchOpenBills } from "./lib/autoBill";
|
||||||
|
|
||||||
type TxForm = {
|
type TxForm = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -223,11 +223,48 @@ export default function AccountingBankingPage() {
|
|||||||
const acc = (accounts as any[]).find((a) => a.id === accountId);
|
const acc = (accounts as any[]).find((a) => a.id === accountId);
|
||||||
if (!acc || selected.size === 0) return;
|
if (!acc || selected.size === 0) return;
|
||||||
const ids = [...selected];
|
const ids = [...selected];
|
||||||
const { error } = await accounting.from("transactions")
|
const rows = (register as any[]).filter((r) => selected.has(r.id));
|
||||||
.update({ coa_account_id: accountId, category: acc.name }).in("id", ids);
|
|
||||||
if (error) return toast.error(error.message);
|
// Accrual A/P: before categorizing a vendor debit to an expense account, check
|
||||||
toast.success(`Set category for ${ids.length} transaction${ids.length !== 1 ? "s" : ""}`);
|
// 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] });
|
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||||
|
if (billPayments.length) qc.invalidateQueries({ queryKey: ["bills", cid] });
|
||||||
|
setSelected(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulkSetDirection = async (type: "debit" | "credit") => {
|
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
|
// 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.
|
// entered (accrual), or — when no bill exists — when the payment is made.
|
||||||
// • Vendor has OPEN bill(s) → this payment clears Accounts Payable (coa null,
|
// • Debit matches an OPEN bill (matchOpenBills: same vendor, amount within
|
||||||
// vendor set → post_transaction_gl posts Dr A/P / Cr Bank); the expense was
|
// $0.01 or partial, date within ±30 days) → this payment clears Accounts
|
||||||
// already recognized on the bill. We then apply it to those bills (FIFO).
|
// Payable (coa null, vendor set → post_transaction_gl posts Dr A/P / Cr
|
||||||
// • No open bill → the payment IS the expense: keep the chosen expense account
|
// Bank); the expense was already recognized on the bill. We then apply it
|
||||||
// so it posts Dr Expense / Cr Bank on the payment date.
|
// 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.
|
// Customer deposits (credits) clear A/R via customer_id and are unchanged.
|
||||||
let openVendorBills: any[] = [];
|
const matchedBills = type === "debit" && vendor_id
|
||||||
if (type === "debit" && vendor_id) {
|
? await matchOpenBills({ companyId: cid, vendorId: vendor_id, amount, date })
|
||||||
const { data: vbills } = await accounting
|
: [];
|
||||||
.from("bills").select("id,number,total,paid_amount,issue_date,status")
|
const debitClearsAp = matchedBills.length > 0;
|
||||||
.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 payload: any = {
|
const payload: any = {
|
||||||
account_id, date, description, amount, type, category, reference: reference || null,
|
account_id, date, description, amount, type, category, reference: reference || null,
|
||||||
coa_account_id: debitClearsAp ? null : (coa_account_id || null),
|
coa_account_id: debitClearsAp ? null : (coa_account_id || null),
|
||||||
vendor_id: vendor_id || null,
|
vendor_id: vendor_id || null,
|
||||||
customer_id: customer_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) {
|
if (editId) {
|
||||||
@@ -306,14 +344,13 @@ export default function AccountingBankingPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the payment cleared A/P (vendor had open bills), apply it to those
|
// When the payment cleared A/P, apply it to the matched bill(s) oldest-first
|
||||||
// bills oldest-first so they show paid. The expense lives on the bill, so no
|
// so they show paid. The expense lives on the bill, so no expense is booked
|
||||||
// expense is booked here. With no open bill the payment already posted the
|
// here. With no matching bill the payment already posted the expense directly
|
||||||
// expense directly (above) — nothing further to do.
|
// (above) — nothing further to do.
|
||||||
if (debitClearsAp) {
|
if (debitClearsAp) {
|
||||||
let remaining = amount;
|
let remaining = amount;
|
||||||
const ordered = [...openVendorBills].sort((a, b) => String(a.issue_date ?? "").localeCompare(String(b.issue_date ?? "")));
|
for (const b of matchedBills) {
|
||||||
for (const b of ordered) {
|
|
||||||
if (remaining <= 0.005) break;
|
if (remaining <= 0.005) break;
|
||||||
const bal = Number(b.total) - Number(b.paid_amount ?? 0);
|
const bal = Number(b.total) - Number(b.paid_amount ?? 0);
|
||||||
const applied = Math.min(bal, remaining);
|
const applied = Math.min(bal, remaining);
|
||||||
|
|||||||
@@ -415,13 +415,14 @@ export default function AccountingBillsPage() {
|
|||||||
reference: refLabel,
|
reference: refLabel,
|
||||||
coa_account_id: null, // → posts against Accounts Payable (via vendor)
|
coa_account_id: null, // → posts against Accounts Payable (via vendor)
|
||||||
vendor_id: payBill.vendor_id ?? null, // required so the GL clears A/P
|
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
|
// 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);
|
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 #
|
// 4) If check + print: insert check record, print, mark printed, bump next #
|
||||||
if (payMethod === "check") {
|
if (payMethod === "check") {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { accounting } from "@/lib/accountingClient";
|
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 = {
|
export type AutoBillSettings = {
|
||||||
enabled: boolean;
|
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();
|
const { data: bill } = await accounting.from("bills").select("total, paid_amount").eq("id", billId).single();
|
||||||
if (!bill) return;
|
if (!bill) return;
|
||||||
const newPaid = Number(bill.paid_amount ?? 0) + amount;
|
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);
|
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 */
|
/** Orchestrator: returns either a match prompt, an auto-created bill, or skip */
|
||||||
export type HandleResult =
|
export type HandleResult =
|
||||||
| { kind: "skipped"; reason: string }
|
| { 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