fix(salary): apply percent brackets for monthly incomes above 80 000 kr (#1510)

* fix(salary): apply percent brackets for monthly incomes above 80 000 kr

Skatteverket's monthly tax tables switch from fixed krona amounts to
percent-of-income rows above 80 000 kr/month. The lookup only loaded the
krona ("30B") rows and clamped higher incomes to the last bracket,
under-withholding every salary above 80 000 kr (e.g. 100 000 kr, tabell
31 kolumn 1: 25 294 kr instead of 35 000 kr).

- fetch both 30B and 30% sections from the Skatteverket API; treat a
  missing section as API failure so the bundled fallback wins over
  incomplete data
- TaxTableRate is a discriminated union; percent brackets withhold
  percent of the whole monthly income, ore dropped per SFF 2011:1261
  22 kap. 1 (oretal bortfaller)
- fallback generator parses %-rows too; regenerated with 1 232 percent
  rows and a guard that every table carries both sections
- keep the old clamp only as a warn-logging last resort

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): fail loudly on incomplete tax table data (review findings)

Address CodeRabbit and Swedish accounting review findings on #1510:

- lookupTaxAmount throws TaxTableUnavailableError when loaded brackets
  contain a gap instead of silently withholding 0
- a failed or empty pagination page fails the whole API fetch so the
  bundled fallback serves complete data
- kolumn values are parsed strictly (decimal-aware, comma accepted);
  malformed values fail the fetch instead of becoming 0 kr / 0 %
- importer rejects malformed column values instead of emitting 0
  (regenerated fallback is byte-identical)
- close the bracket gap in the calculation-engine test fixture
- clarify the ore-truncation citation and use an absolute date in
  DECISIONS.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): validate income boundaries in tax table parsers

Round-2 CodeRabbit finding on #1510: income boundaries were still parsed
with parseInt, which accepts "100abc" and turns garbage into 0 or an
open-ended bracket. Both the importer and the API loader now require
digits-only boundaries; an empty upper bound is legal only on percent
rows (the open-ended top row). Malformed API data fails the fetch so the
bundled fallback runs; malformed TXT data fails the import. Regenerated
fallback is byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-11 01:27:16 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e45218bcc6
commit 614b7e60b9
7 changed files with 8449 additions and 6889 deletions
+2
View File
@@ -859,3 +859,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-10] Supplier pain.001 dialect corrected against a real Swedbank Validex run (MIG 1.0, eken.validex.net), which is stricter than the Bankforeningen appendix the generator was built from. Four generator changes: (1) MIG character set enforced by transliteration (a-acute to a, ampersand to plus, Swedish aao survive; leftovers become '?', matching LB padText), because rule 214 rejects e-acute in names outright; (2) InitgPty/Dbtr OrgId is now mandatory and the batch service refuses companies without organisationsnummer (rule 002), reading settings.org_number before the write-once companies.org_number; (3) BGNR creditors debit the company bankgiro when one exists, in their own PmtInf per (date, debit-form) group, because rule 219 demands BGNR-to-BGNR, with Cdtr PstlAdr/Ctry SE always present so IBAN-debited giro payments stay valid under rule 020 (v1 is domestic-only); (4) Strd carries RfrdDocAmt/RmtdAmt = the instructed amount (rule 217). Consequence accepted: the byte-identical re-download contract holds per generator version, not across dialect fixes; MsgId is unchanged so bank-side dedup still keys correctly. Validex account creation has a reCAPTCHA that was deliberately not circumvented; the founder registers and uploads.
[2026-08-10] Validex round 2 (1 fatal left): a present PstlAdr must carry TwnNm from November 2026 (rule 222), and the round-1 fix had added Ctry-only creditor addresses everywhere. Resolution follows Swedbank's own conditionality instead of stuffing TwnNm in unconditionally: BGNR-to-BGNR payments carry NO creditor address (rule 020 does not require one there), IBAN-debited payments carry the supplier's town (new nullable supplier_payment_batch_items.payee_city snapshot) plus Ctry SE, and the debtor gains PstlAdr TwnNm/Ctry from company_settings.city, which also clears the info-level rule 236. Rule 183 (BIC must be SWEDSESS) fires only because the sandbox debtor is a fake SEB account; real Swedbank customers resolve SWEDSESS from their own settings.
[2026-08-10] Validex round 3 falsified the round-2 reading: rule 237 (creditor PstlAdr mandatory) fires on the BGNR-to-BGNR path too, so rule 020's "required unless DbtrAcct is BGNR" only governs the Ctry element, not the address as a whole. Creditor PstlAdr is now emitted on every payment, TwnNm from the payee_city snapshot when known, and the preview warns (payee_city_missing) when the supplier register lacks a city, since 237 + 222 together make a town effectively mandatory at Swedbank and a Ctry-only address is rejected today, not from November.
[2026-08-10] Tax table percent rows (>80 000 kr/month) drop ore via Math.floor: whole-krona rule (oretal bortfaller) per SFF 2011:1261 22 kap. 1 § as applied by Skatteverket's tabellavdrag guidance (the statute governs stated amounts; Skatteverket's tables and guidance apply the same truncation to computed skatteavdrag); an API response missing either table section (30B or 30%) is treated as API failure so the bundled fallback serves complete data instead of clamping. Incomplete bracket data (gaps, failed pagination pages, malformed kolumn values) fails loudly rather than withholding 0.
[2026-08-10] Staging DB reconcile (metjnjrhvujscngnpzdv): the tracker had skipped everything from 20260721101500 to 2026-08-10 (105 local-only versions) while 25 rows existed only remotely. Renamed 10 remote rows to their repo versions (same name, MCP apply-time version drift: sandbox-cleanup consolidation, shopify, tax-depreciation, JEL index), deleted 7 superseded sandbox-iteration rows with no local file, and left 8 rows from unmerged branches (white-label brands/teams, vacation columns, agent-atom product tier) untouched since their content is deliberately live for the byra rigs. Older seed_agent_atom_bodies files register version-only: each seed is a full idempotent upsert with a version guard, so only the newest seed's content needs to run.