---
name: evolution_gl_control_account_validation
description: "commitInvoice validates control accounts before posting; insertQuery echoes PDO errors instead of throwing, which silently dropped GL legs"
metadata: 
  node_type: memory
  type: project
  originSessionId: b5dc3603-f990-4b73-8fd0-cd0db278aa10
  modified: 2026-07-26T12:48:30.783Z
---

**`insertQuery()` (functions.php ~L1806) does `catch (PDOException $e) { echo $e->getMessage(); }`** — it neither throws nor returns a failure. Any legacy write that fails is *echoed* and execution continues. Two consequences, both hit for real on 2026-07-26 ([[tomago_exo_import]]):
1. A rejected row vanishes from the DB while the caller thinks it succeeded (invoice still flipped `commited=1`).
2. The echoed SQLSTATE text lands **mid-JSON** on an AJAX endpoint → `json_decode` fails → jQuery `.fail()` → a generic "request failed" that looks like a timeout. When you see that, suspect an echoed DB error before suspecting PHP limits.

**Root cause found:** `gledger.accname` is `varchar(55) NOT NULL`, and the GL posters resolved account names by scanning `getAccounts()` (functions.php:1078), which only returns `accounts.status='1'` rows whose `type` joins `accountcats.id`. So an inactive account, an orphaned `type`, or a control account still at `0` in `preferences` left the name variable **unset** → null → row rejected → leg silently missing. Because the AR debit is posted **last**, invoices ended up with income/GST/COGS legs and no receivable.

**Fixed (2026-07-26, branch `staging`):**
- `functions.php` — new **`glAccName($accounts, $accountid)`**: scans the passed chart, else looks the name up directly, else returns `"UNKNOWN ACCOUNT n"` / `"UNALLOCATED"`. **Never returns null.** Use it anywhere you're filling `gledger.accname`.
- **Both** `accounting.php` copies ([[evolution_two_accounting_files]]) — `commitInvoice()` now resolves + validates `getPref('accRec')` **before any GL write** and `throw`s if it's missing, so a misconfigured tenant refuses the commit instead of half-posting. Same for a tax code with a rate > 0 and no `collectedTax`.
- **Deliberately NOT fatal: `invoiceitems.taxid = 0`.** That's ordinary legacy data meaning "no tax code on the line" — Otter Fencing alone has ~14k such lines across 7,077 uncommitted invoices / $9.4M. Unresolvable or zero tax codes are treated as zero-rated and skipped (logged only if non-zero). My first version threw here and would have blocked their invoicing — **survey prod before making a shared path fail loud.**
- **Phantom-GST bug (all tenants):** the tax loop read the rate table into `$tax`, then assigned the per-group tax *amount* to that same `$tax` inside the loop. From group 2 on, `foreach($tax as $val)` iterated a float, the rate lookup no-opped, and the group inherited the **previous group's account and rate** — live proof: TIS invoice 2143 posted $9.31 GST on a $93.12 **FREE** line. Fixed by renaming the table to `$taxRates`, giving the amount its own variable, resetting per group, and skipping zero-rated groups entirely (a $0.00 row against `collectedTax = 0` was the actual source of the null `accname`). The AP/bill poster had already been fixed for this; the AR side was missed — **check both sides when you find a bug in one.**

**Entry-screen gate (2026-07-26, approved):** `controlAccountCheck($side)` + `controlAccountAlert($check,$side,$mode)` in functions.php. `invoiceadd.inc`/`billadd.inc` **block NEW documents only** when accRec/accPay is unresolvable (existing docs open with a `notice` banner; new docs also get a `notice` for unset bank/petty cash). Server-side halves gate `invoiceaddsave.php`'s `NewInvoice()` branch and `billaddsave.php`'s insert branch, so a direct POST can't bypass. Renders `text-start` — the content div centres text by default. Batched to 2 queries (one `preferences` row + one `accounts … id IN (…)`) because it runs on every load.

**Which globals are in the gate, and why (prod survey 2026-07-26, 25 active tenants):** accRec/accPay = **block** (5 tenants unset, all test/sandbox). defaultBankAccount = **warn** (0 tenants unset). defaultPettyCash = **warn** (3 unset). Per-tax-code `collectedTax`/`paidTax` = **deliberately excluded** — 20 of 25 tenants carry the seeded **WET 29% code with `paidTax=0`** and **zero** bill lines have ever used it, so a page warning would be permanent noise; commit-time validation already blocks on just the codes a document uses (`!= 0` rate test, so the -46% ABNW code is covered too). Only rate>0 code without an account that's actually reachable: `Tax 0.1%` in `85-1525752154`, never used on a line.

**Reverse-then-recommit hardening:** `commitInvoice`'s checks are factored into `accounts::invoiceCommitBlocker($id,$taxRates=null)` (returns null|reason, no writes). Called by `commitInvoice` (throws), and **before** `reverseInvoiceGl()` in `clearInvoiceAdjustment()` and `insertAlignmentAdjustment()` plus `ensureInvoiceCommitted()`'s match path — otherwise a throw lands between reverse and re-post and strands the invoice `commited=1` with no journal. `library/xero.php` `addPayment` now wraps `clearInvoiceAdjustment()` in `try/catch (\Throwable)` so one bad invoice can't abandon a tenant's payment batch (and its `lastPaymentSync` watermark).

**OPEN, not fixed (both accounting.php copies):** `commitInvoice`'s COGS block selects `sum(invoiceitems.total)` and uses it for BOTH the COGS debit and the inventory-asset credit — **cost of sales posts at sale value, not cost** (Otter inv 40015 item 6588: sale $326.20 vs cost $260.96, GL posted $326.20 both legs). Every stocked line shows zero gross profit; all tenants, all time. Also OPEN: `commitBill` has the AR side's old bugs — no accPay validation, no per-group tax reset, getAccounts-scan null names on AP/retention/purchase legs. Adding paidTax validation to `commitBill` would block nobody today (0 bill lines on an unmapped rate>0 code).

Docs: `docs/admin/control-accounts.php` (Administration → Control Accounts & Tax Accounts) — now also covers the entry gate. Prod release notes 143 (amended to cover the gate) / 144, both `status=0`.
