Skip to content

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)

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_idoutput-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.ITBMSTaxRatetax_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:

  1. 20260615_retax_input_tax_account_type.py (revision="retax_input_tax_account_type") — if we introduce AccountType.TAX_RECEIVABLE: append the enum member (highest value, per account_type.py:36) + add a NominalCodeMapper code. (Skipped if we use OTHER_CURRENT_ASSET.)
  2. 20260615_rename_tax_rate_account_columns.py (revision="rename_tax_rate_account_columns") — only if Q1 = renameaccount_payable_idoutput_tax_account_id, account_receivable_idinput_tax_account_id. upgrade/downgrade both implemented.
  3. 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_idoutput_tax_account_id (sales/output ITBMS, liability, CR), account_receivable_idinput_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 to client.sales_taxes_account_id / supplier.tax_account_id so no tax line is ever dropped.
  • Q3 — Withholding → add the second account now. Replaced the single withholding_account_id with a directional pair: withholding_payable_account_id (agent/liability, CR) and withholding_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 (was TAX_PAYABLE), matching its DR/asset posting.

9. What shipped (file-by-file)

  • Migration20260615_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.
  • Modeltax_rate.py: renamed columns + two new ones, each with a comment=.
  • GraphQLtax_rate_input.py, tax_rate_response.py (4 account-id fields + 4 resolver fields: outputTaxAccount / inputTaxAccount / withholdingPayableAccount / withholdingAssetAccount), tax_rate_mutations.py.
  • Servicetax_rate_service.py: renamed params; injects AccountMutationRepository; _default_account() falls back to get_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 fixsupplier_invoice_service.py, supplier_tabular_service.py: purchase tax account → OTHER_CURRENT_ASSET.
  • Ledger postinginvoice_ledger_service.py: _resolve_output_tax_account (output ITBMS account) + _resolve_withholding_asset_account + the sales-side retention leg — when client.invoice_retention and invoice.codigo_retencion are set, the AR debit is netted by retention_rate × tax_amount and that amount is debited to the rate's withholding_asset_account_id (ITBMS retenido, an asset). supplier_invoice_ledger_service.py: _resolve_input_tax_account, eager-loads tax_rate_obj in _get_details.
  • Teststest_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 — TaxRate already lives under the tax_filings path. No new Path/Resource, no backfill.
  • Background tasks: none.
  • Frontend contract (breaking): the TaxRate type and TaxRateCreateInput / TaxRateUpdateInput fields changed:
  • accountPayableIdoutputTaxAccountId, accountReceivableIdinputTaxAccountId
  • withholdingAccountIdremoved; replaced by withholdingPayableAccountId + withholdingAssetAccountId
  • resolver fields accountPayable / accountReceivable / withholdingoutputTaxAccount / 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 no codigo_retencion and 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 because InvoiceBalance.retention_amount is hardcoded to 0 (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_amount to the first resolved account.
  • Credit-note retention/tax sourcing. credit_ledger_service still sources the sales-tax reversal from client.sales_taxes_account_id and posts no retention reversal — matches the invoice in the default single-account config; revisit if per-rate accounts or retention diverge.