Files
accounted/lib/invariants/__tests__/formats.test.ts
T
16fbcefbbc feat(invariants): shared format contracts + upgrade-path CI (#1364)
* feat(invariants): centralise shared format contracts, reconcile the org-number paths

The same format rules were written out independently across the codebase, and
where they disagreed the disagreement was invisible until a filing failed.

Worst case, now fixed: four Skatteverket- and Bolagsverket-bound export paths
each had their own idea of a valid organisationsnummer.

  lib/skatteverket/format.ts      strip '-' only      threw on any input with a space
  lib/salary/ku/ku10-generator.ts replace('-', '')    first hyphen only, spaces survived
  lib/salary/agi/xml-generator.ts strip non-digits    stray letters passed the length check
  lib/bokslut/ixbrl/validate      /^\d{6}-?\d{4}$/    rejected the 12-digit form, no Luhn

A company stored with a space or in 12-digit form could file AGI all year and
then fail at the arsredovisning deadline with a message that did not say why.

lib/invariants/ now owns account number, ISO date, four-digit fiscal year and
org number, each with the rationale recorded next to the rule. normalizeOrgNumber
moves here from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both
old paths re-export, so no caller changes. lib/api/schemas.ts builds its
primitives on the module, so ~100 schemas inherit any correction.

The arsredovisning check-digit verdict is a warn, not an error: a wrong Luhn
digit is almost certainly a typo worth surfacing, but whether every org number
Bolagsverket accepts satisfies Luhn is a Swedish domain question we have not
verified against a primary source, and an error there blocks Skicka in. We do
not block a statutory filing on an unverified assumption.

KU10 still passes a 12-digit stored org number through unfolded. That is
pre-existing, and whether the KU10 schema wants 10 or 12 digits is not covered
by the swedish-payroll skill, so it is pinned by a test rather than changed
silently.

Guard 8 (hand-rolled-invariant) tracks the remaining 114 inline copies as a
ratchet that may only go down, same mechanism as the roundOre guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ci): add an upgrade-path job that applies new migrations against real data

The pg-real job applies all 548 migrations to an EMPTY database. Empty means
zero rows, so a migration that adds a NOT NULL, adds a CHECK, creates a unique
index or backfills passes trivially in CI and can still fail on production,
where the rows exist. CI proved that a fresh install works; nothing proved that
an existing install upgrades.

The new pg-upgrade job: apply the schema as it stands at the merge base, seed a
small real company (three posted verifikat, balanced lines, one ore-level
amount), then apply ONLY the migrations this PR adds, then assert the data
survived (entries still posted, lines intact, ledger still balances, ore
unchanged, voucher numbers sequential). A PR with no migration no-ops.

Verified locally against supabase/postgres:15.8.1.060 rather than assumed, with
three deliberately bad migrations:

  rescale money on posted lines   empty: would pass   seeded: ERROR (immutability trigger)
  CHECK violating the ore row     empty: exit 0       seeded: exit 3
  NOT NULL on a populated column  empty: exit 0       seeded: exit 3

Base migrations are read out of the merge-base git tree, not the working tree,
so a PR that edits an already-shipped migration still gets the original applied
and the edit surfaces as a failure here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the invariants and upgrade-CI decisions

Two entries covering what this PR changes and, more importantly, the calls that
are not obvious from the diff: why the arsredovisning check-digit verdict is a
warning rather than an error, why KU10's 12-digit passthrough is pinned instead
of fixed, and why the ROT/RUT brf org-number schemas stay on their own rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): mark the upgrade fixture as CI-only, never a production template

The fixture writes posted journal_entries and their lines directly, bypassing
the engine and the atomic commit RPC. That is the only way to hand a migration
pre-existing posted rows to break, and it is safe against a throwaway CI
database, but it reads like a sanctioned pattern to anyone who finds it later.

Says so explicitly, with the reason it is confined here (no voucher sequence to
keep gapless, no retention obligation on a database destroyed with the job) and
a pointer back to Hard Rule 2 for anything touching a real database.

Raised by the Swedish compliance review bot on #1364.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:30:14 +02:00

95 lines
3.7 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { isAccountNumber, accountClass, ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { isIsoDateShaped, isSaneDateString, ISO_DATE_RE } from '@/lib/invariants/iso-date'
import { isFiscalYear, FISCAL_YEAR_RE } from '@/lib/invariants/fiscal-year'
import { isSaneDateString as isSaneDateStringFromUtils } from '@/lib/utils'
describe('account number', () => {
it('accepts exactly four digits, as a string', () => {
expect(isAccountNumber('1930')).toBe(true)
expect(isAccountNumber('0000')).toBe(true)
})
it('rejects anything else', () => {
expect(isAccountNumber('193')).toBe(false)
expect(isAccountNumber('19300')).toBe(false)
expect(isAccountNumber('19a0')).toBe(false)
expect(isAccountNumber(' 1930')).toBe(false)
expect(isAccountNumber('')).toBe(false)
expect(isAccountNumber(null)).toBe(false)
expect(isAccountNumber(undefined)).toBe(false)
})
it('reads the BAS account class off the leading digit', () => {
expect(accountClass('1930')).toBe(1)
expect(accountClass('2440')).toBe(2)
expect(accountClass('3001')).toBe(3)
expect(accountClass('8910')).toBe(8)
expect(accountClass('nope')).toBeNull()
})
})
describe('iso date', () => {
it('shape check accepts the right shape, including impossible dates', () => {
expect(isIsoDateShaped('2026-01-31')).toBe(true)
// Shape only: this is the documented difference from isSaneDateString.
expect(isIsoDateShaped('2026-02-31')).toBe(true)
expect(isIsoDateShaped('2026-1-31')).toBe(false)
expect(isIsoDateShaped('31/01/2026')).toBe(false)
expect(isIsoDateShaped(null)).toBe(false)
})
it('sane check rejects impossible and out-of-range dates', () => {
expect(isSaneDateString('2026-01-31')).toBe(true)
expect(isSaneDateString('2026-02-31')).toBe(false)
expect(isSaneDateString('2024-13-40')).toBe(false)
// The native <input type="date"> 6-digit-year corruption.
expect(isSaneDateString('202403-02-05')).toBe(false)
expect(isSaneDateString('1899-12-31')).toBe(false)
expect(isSaneDateString('2101-01-01')).toBe(false)
})
it('is the same function utils re-exports, not a second copy', () => {
expect(isSaneDateStringFromUtils).toBe(isSaneDateString)
})
})
describe('fiscal year', () => {
it('accepts a four-digit year in range', () => {
expect(isFiscalYear('2026')).toBe(true)
expect(isFiscalYear(2026)).toBe(true)
expect(isFiscalYear('1900')).toBe(true)
})
it('rejects out-of-range and wrong shapes', () => {
expect(isFiscalYear('1899')).toBe(false)
expect(isFiscalYear('2201')).toBe(false)
expect(isFiscalYear('26')).toBe(false)
expect(isFiscalYear('')).toBe(false)
expect(isFiscalYear(null)).toBe(false)
})
})
describe('the deliberate regex collision', () => {
/**
* `account-number` and `fiscal-year` share a byte-identical regex and mean
* entirely different things. This test exists so that anyone tempted to
* "deduplicate" them has to read why they are separate first.
*/
it('account number and fiscal year share a pattern but not a rule', () => {
expect(ACCOUNT_NUMBER_RE.source).toBe(FISCAL_YEAR_RE.source)
// '1930' is a real bank account and an absurd fiscal year in our range,
// '2026' is a real fiscal year and a valid BAS account number. Only the
// named rules can tell a caller which one it is holding.
expect(isAccountNumber('1930')).toBe(true)
expect(isFiscalYear('1930')).toBe(true)
expect(isFiscalYear('0000')).toBe(false)
expect(isAccountNumber('0000')).toBe(true)
})
it('the iso-date pattern is not the four-digit pattern', () => {
expect(ISO_DATE_RE.source).not.toBe(ACCOUNT_NUMBER_RE.source)
})
})