Tax-Rate GL-Account Wiring — make TaxRate's three account FKs functional¶
Date: 2026-06-15 Status: 🟢 Shipped — rename + directional withholding columns + seeding-bug fix + transaction-time ITBMS posting + sales-side retention ledger leg. Supplier-side retention (you as withholding agent) deferred — no data model exists (see §11). Related: Accounting Placeholder Audit finding #1; tax-filings.md; tax-filings-relations
1. Problem / motivation¶
TaxRate (the fiscal/declaration rate model under tax_filings) carries three GL-account foreign keys — account_payable_id, account_receivable_id, withholding_account_id — that are written by create/update and echoed over GraphQL, but never read by any posting, calculation, or report path (verified: placeholder-audit #1, and the two adversarial verifiers in the investigation run). They exist so tax journal lines could be derived from the rate, but that automation was never built. This feature decides their intent and makes them load-bearing — or, if intent is "drop them," removes them cleanly.
2. Critical context — there are two tax-rate models¶
Getting this wrong is the main risk, so it is stated up front:
| Model | Module | Purpose | Carries |
|---|---|---|---|
items.ITBMSTaxRate |
items/models/itbms_tax_rate.py | Operational — computes the ITBMS amount on each invoice line at transaction time | code (00/07/10/15), rate (e.g. 7.00) |
tax_filings.TaxRate |
tax_filings/models/tax_rate.py | Fiscal/declaration — classifies a line under a DGI rate code for the ITBMS worksheet; holds the 3 GL-account FKs | code (TaxRateCode), rate, valid_from/to, the 3 account FKs |
InvoiceDetail.tax_rate_id and SupplierInvoiceDetail.tax_rate_id already FK to tax_filings.TaxRate (comment: "powers per-rate ITBMS worksheet aggregation"). So the per-line link to use for posting already exists — the missing piece is reading the rate's GL account at posting time.
3. How tax posts today (verified against ledger code)¶
- Sales (customer invoice): output ITBMS is posted CR to
client.sales_taxes_account_id— invoice_ledger_service.py:85, and again in credit_ledger_service.py:79. That account is seededAccountType.TAX_PAYABLE(liability) — correct. - Purchases (supplier invoice): input ITBMS is posted DR to
supplier.tax_account_id— supplier_invoice_ledger_service.py:98. That account is also seededAccountType.TAX_PAYABLE(supplier_invoice_service.py:43, supplier_tabular_service.py:40) — bug: input tax is an asset (crédito fiscal), seeded as a liability but posted on the DR side. - Retention/withholding: computed only in the PAC e-invoicing layer; never posted to the ledger (no retention leg anywhere in
app/graphql/ledger).
4. The accounting model (Panama ITBMS / retenciones)¶
| Economic account | Nature | Touched by | Side |
|---|---|---|---|
| Output ITBMS — ITBMS por pagar (you collected on sales, owe DGI) | Liability (TAX_PAYABLE) |
Customer invoice / sales credit | CR |
| Input ITBMS — crédito fiscal (you paid on purchases, recoverable) | Asset (OTHER_CURRENT_ASSET, or a new TAX_RECEIVABLE) |
Supplier invoice | DR |
| Withholding/retención | Directional — agent → liability (TAX_PAYABLE, CR); withheld-from-you → asset (DR) |
Retention event | both |
A single nullable withholding_account_id cannot serve both retention directions — flagged by both verifiers.
5. ⚠️ The naming fork (blocking decision — see §8 Q1)¶
The two main FKs are named after the AR/AP document convention used elsewhere (supplier_invoice.account_payable_id is a purchase doc; invoice.account_receivable_id is a sales doc). But the in-repo placeholder audit #1 states the intended mapping is by tax nature:
account_payable_id/account_receivable_id/withholding_account_id→ output-tax liability, input-tax credit, and withholding payable
These two readings are opposite:
| Field | "Document-side" reading (matches invoice tables) | "Tax-nature" reading (matches audit-doc intent) |
|---|---|---|
account_payable_id |
tax account on purchase docs → input ITBMS (asset, DR) | output ITBMS (liability, CR) |
account_receivable_id |
tax account on sales docs → output ITBMS (liability, CR) | input ITBMS (asset, DR) |
Recommendation: adopt the audit-doc intent and rename the columns to output_tax_account_id / input_tax_account_id to remove the ambiguity permanently. This is the safest design but it is a breaking GraphQL/column change. Alternative: keep the names, pin the tax-nature semantics in column comment= and the docs.
6. In scope / out of scope¶
In scope (proposed first increment — confirm via §8):
- Fix the input-tax seeding bug (§3) so purchase ITBMS resolves to an asset account.
- Default the three accounts when a TaxRate is created/updated (via get_or_create_account), so they stop being null.
- Wire one consumption point (Q2) so the FKs are actually read.
- Migration(s), tests, docs, changelog, version bump.
Out of scope (explicitly deferred):
- Retention ledger posting (a separate feature — there is no retention leg at all today; audit #3/#16 territory).
- PDF persistence (declaration_file_id, pdf_file_id — audit #2/#3).
- items.ITBMSTaxRate ↔ tax_filings.TaxRate unification.
- OrderDetail-level tax_rate_id (only invoice + supplier-invoice details carry it today).
7. Data model changes (alembic — named revisions, tip = add_dates_to_items)¶
Depending on §8 answers, up to three single-concern migrations chained off add_dates_to_items:
20260615_retax_input_tax_account_type.py(revision="retax_input_tax_account_type") — if we introduceAccountType.TAX_RECEIVABLE: append the enum member (highest value, per account_type.py:36) + add aNominalCodeMappercode. (Skipped if we useOTHER_CURRENT_ASSET.)20260615_rename_tax_rate_account_columns.py(revision="rename_tax_rate_account_columns") — only if Q1 = rename →account_payable_id→output_tax_account_id,account_receivable_id→input_tax_account_id.upgrade/downgradeboth implemented.- No new columns needed for withholding if we defer it (Q3); if we add a second withholding account, that is its own migration
20260615_add_withholding_asset_account_to_tax_rates.py.
8. Resolved decisions (answered before coding, per methodology §1)¶
- Q1 — Semantics & naming → tax-nature + rename. Adopted the audit-doc intent and renamed the columns:
account_payable_id→output_tax_account_id(sales/output ITBMS, liability, CR),account_receivable_id→input_tax_account_id(purchase/input ITBMS, asset, DR). Ambiguity removed at the column level. - Q2 — Consumption point → transaction-time posting. Invoice + supplier-invoice ledger services source the tax line's GL account from the line's effective
TaxRate, falling back toclient.sales_taxes_account_id/supplier.tax_account_idso no tax line is ever dropped. - Q3 — Withholding → add the second account now. Replaced the single
withholding_account_idwith a directional pair:withholding_payable_account_id(agent/liability, CR) andwithholding_asset_account_id(withheld-from-you/asset, DR). The ledger leg that would consume them is deferred — see §11. - Q4 — Seeding-bug fix → bundled. The supplier input-tax account now seeds as
OTHER_CURRENT_ASSET(wasTAX_PAYABLE), matching its DR/asset posting.
9. What shipped (file-by-file)¶
- Migration — 20260615_rename_tax_rate_account_columns.py: renames the two columns, drops
withholding_account_id, adds the two directional withholding columns (+ FKs).down_revision = add_dates_to_items. Upgrade & downgrade both implemented; offline SQL verified both directions. - Model — tax_rate.py: renamed columns + two new ones, each with a
comment=. - GraphQL — tax_rate_input.py, tax_rate_response.py (4 account-id fields + 4 resolver fields:
outputTaxAccount/inputTaxAccount/withholdingPayableAccount/withholdingAssetAccount), tax_rate_mutations.py. - Service — tax_rate_service.py: renamed params; injects
AccountMutationRepository;_default_account()falls back toget_or_create_account(output/withholding-payable →TAX_PAYABLE, input/withholding-asset →OTHER_CURRENT_ASSET) on create;update()only overwrites an account when a non-null value is supplied (never nulls a configured account). - Seeding fix — supplier_invoice_service.py, supplier_tabular_service.py: purchase tax account →
OTHER_CURRENT_ASSET. - Ledger posting — invoice_ledger_service.py:
_resolve_output_tax_account(output ITBMS account) +_resolve_withholding_asset_account+ the sales-side retention leg — whenclient.invoice_retentionandinvoice.codigo_retencionare set, the AR debit is netted byretention_rate × tax_amountand that amount is debited to the rate'swithholding_asset_account_id(ITBMS retenido, an asset). supplier_invoice_ledger_service.py:_resolve_input_tax_account, eager-loadstax_rate_objin_get_details. - Tests — test_tax_account_sourcing.py (9, incl. a balanced retention entry: 100 + 7 ITBMS, 50% retained → AR 103.50 / ITBMS retenido 3.50, debits = credits = 107), test_tax_rate_service.py (2), updated test_relations.py. 21 passing.
Posting behaviour: the tax line stays a single aggregate (balance.tax_amount) — its account is now sourced from the first line that carries a tax rate with the relevant account set. In the default config this is the same per-tenant account get_or_create_account returns, so amounts are unchanged. Mixed-rate invoices (lines under genuinely different tax accounts) post the whole tax amount to the first resolved account — per-line tax splitting is a future refinement.
10. RBAC / background tasks / frontend contract¶
- RBAC: none new —
TaxRatealready lives under thetax_filingspath. No newPath/Resource, no backfill. - Background tasks: none.
- Frontend contract (breaking): the
TaxRatetype andTaxRateCreateInput/TaxRateUpdateInputfields changed: accountPayableId→outputTaxAccountId,accountReceivableId→inputTaxAccountIdwithholdingAccountId→ removed; replaced bywithholdingPayableAccountId+withholdingAssetAccountId- resolver fields
accountPayable/accountReceivable/withholding→outputTaxAccount/inputTaxAccount/withholdingPayableAccount/withholdingAssetAccount - All four account inputs are now optional; when omitted on create the backend defaults them. The frontend can drop any client-side defaulting.
11. Future additions¶
- Supplier-side retention (deferred — no data model). The shipped retention leg covers the sales side (the client withholds ITBMS from us →
withholding_asset_account_id, an asset). The purchase side — where we are the withholding agent and owe DGI (withholding_payable_account_id, a liability, CR) — has no trigger: supplier invoices carry nocodigo_retencionand no retention amount. The column + default are in place; wiring the leg needs a supplier-side retention data model first. - Retention amount is computed inline, not persisted. The sales leg mirrors the PAC e-invoice factory (
retention_rate × tax_amount) at posting time becauseInvoiceBalance.retention_amountis hardcoded to0(invoice_balance_repository.py:75,107). If retention is later persisted on the balance, the leg should read it instead of recomputing — single source of truth. - Per-line tax splitting. Post one tax line per distinct resolved tax account when an invoice mixes rates, instead of the whole
balance.tax_amountto the first resolved account. - Credit-note retention/tax sourcing.
credit_ledger_servicestill sources the sales-tax reversal fromclient.sales_taxes_account_idand posts no retention reversal — matches the invoice in the default single-account config; revisit if per-rate accounts or retention diverge.