Skip to content

Store TaxRate.rate as a whole percent (7.00) instead of a fraction (0.0700)

Date: 2026-06-19 Status: ๐ŸŸข Shipped

Problem / motivation

The unified tax_filings.TaxRate.rate was stored as a decimal fraction (0.0700 for 7%), with a rate_percent hybrid (rate * 100) bridging to the whole-percent value every operational consumer actually wants (line tax math, PO/order copy, item value-match). That split is the root of the "100ร— under-tax" trap documented in the unify plan ยง3: anything that read rate instead of rate_percent silently computed 0.07%.

The decision (see that plan's correctness traps) is to remove the split: store rate as the whole percent (7.00), so the stored column and the operational percent are the same number. rate_percent becomes an identity alias kept only for API/back-compat, and a symmetric rate_fraction (rate / 100) serves the fiscal/posting world that wants the fraction.

This also aligns TaxRate.rate with items.ITBMSTaxRate.rate, which has always been a whole percent โ€” the two halves of the in-flight unification now agree.

In scope

  • Flip the canonical convention of tax_filings.TaxRate.rate from fraction to whole percent (0.0700 โ†’ 7.0000).
  • Backfill existing rows (ร— 100) and update the column comment.
  • Keep rate_percent working (now identity) and add rate_fraction.
  • Update the GraphQL input/output contract (the rate field now means percent).
  • Update tests and the unify plan doc.

Out of scope

  • items.ITBMSTaxRate.rate โ€” already a whole percent; untouched.
  • The per-line InvoiceDetail.tax_rate / SupplierInvoiceDetail.tax_rate percent snapshots โ€” already a whole percent; untouched.
  • The EXPAND-phase FK/seed work and the CONTRACT phase โ€” see the unify plan.

What's being implemented

Data model

  • app/graphql/tax_filings/models/tax_rate.py
  • rate column comment โ†’ "Whole-percent rate, e.g. 7.00 for 7%".
  • rate_percent hybrid โ†’ returns self.rate (identity; kept for back-compat).
  • new rate_fraction hybrid โ†’ self.rate / 100 for fraction consumers.

Migration

  • alembic/versions/20260619_tax_rate_store_percent.py (revision = "tax_rate_store_percent", down_revision = "drop_employee_bank_account_id")
  • alter_column to update the tax_rates.rate comment.
  • idempotent data conversion UPDATE tax_rates SET rate = rate * 100 WHERE rate > 0 AND rate < 1 so both existing tenants and fresh tenants (whose rows the EXPAND migration seeds as 0.0700) converge on the percent convention.
  • downgrade reverses both (rate / 100 where rate >= 1).

One-off backfill script

  • scripts/backfill_tax_rate_percent.py โ€” the operational/verification tool: dry-run by default, --apply to commit, --tenant to scope. Uses the same idempotent guard (0 < rate < 1) as the migration, so running it before/after a deploy can neither double-apply nor fight the migration. Mirrors backfill_tax_rate_id_invoice_details.py.

GraphQL surface

  • tax_rate_input.py โ€” rate field description clarifies it is now a whole percent (7.00).
  • tax_rate_response.py โ€” rate now returns the percent; rate_percent returns the same value.

Consumer

  • purchase_order_factory.py โ€” rate_percent still returns the percent (now identity); only the now-stale bridge comment is corrected. No behavioral change.

Frontend contract (breaking)

The gated, accountant-only taxRates management surface changes meaning:

  • createTaxRate / updateTaxRate input rate now expects a whole percent (7 for 7%), not the fraction 0.07.
  • TaxRate.rate in responses now returns the whole percent (7.00). TaxRate.ratePercent is unchanged in meaning and now equals rate.

Callers that previously sent/read the fraction must switch to the percent. The item-picker and invoicing surfaces are unaffected (they already consume ratePercent / the per-line percent snapshot).

Migration / deploy order

  1. Deploy this revision โ€” the migration converts every tenant's rows and updates the comment automatically.
  2. (Optional) run scripts/backfill_tax_rate_percent.py first as a dry-run audit of what will change.

Future additions

  • When the unify CONTRACT phase lands, rate_fraction is the accessor the ledger/PAC posting math should read if it needs the fraction (the doc's Phase 2 posting work). Until a consumer needs it, it stays a thin helper.