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.ratefrom fraction to whole percent (0.0700โ7.0000). - Backfill existing rows (
ร 100) and update the column comment. - Keep
rate_percentworking (now identity) and addrate_fraction. - Update the GraphQL input/output contract (the
ratefield 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_ratepercent 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.pyratecolumncommentโ "Whole-percent rate, e.g. 7.00 for 7%".rate_percenthybrid โ returnsself.rate(identity; kept for back-compat).- new
rate_fractionhybrid โself.rate / 100for fraction consumers.
Migration¶
alembic/versions/20260619_tax_rate_store_percent.py(revision = "tax_rate_store_percent",down_revision = "drop_employee_bank_account_id")alter_columnto update thetax_rates.ratecomment.- idempotent data conversion
UPDATE tax_rates SET rate = rate * 100 WHERE rate > 0 AND rate < 1so both existing tenants and fresh tenants (whose rows the EXPAND migration seeds as0.0700) converge on the percent convention. downgradereverses both (rate / 100whererate >= 1).
One-off backfill script¶
scripts/backfill_tax_rate_percent.pyโ the operational/verification tool: dry-run by default,--applyto commit,--tenantto 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. Mirrorsbackfill_tax_rate_id_invoice_details.py.
GraphQL surface¶
tax_rate_input.pyโratefield description clarifies it is now a whole percent (7.00).tax_rate_response.pyโratenow returns the percent;rate_percentreturns the same value.
Consumer¶
purchase_order_factory.pyโrate_percentstill 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/updateTaxRateinputratenow expects a whole percent (7for 7%), not the fraction0.07.TaxRate.ratein responses now returns the whole percent (7.00).TaxRate.ratePercentis unchanged in meaning and now equalsrate.
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¶
- Deploy this revision โ the migration converts every tenant's rows and updates the comment automatically.
- (Optional) run
scripts/backfill_tax_rate_percent.pyfirst as a dry-run audit of what will change.
Future additions¶
- When the unify CONTRACT phase lands,
rate_fractionis 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.