Files
acmcc/supabase/migrations/20260604120000_transactions_bill_link_and_ap_guard.sql
admin 7464d55b6c 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>
2026-06-04 12:30:44 -04:00

23 lines
1.2 KiB
SQL

-- 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);