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:
@@ -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