Files
accounted/tests/pg/upgrade/seed.sql
T
Jakob Wennberg 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

109 lines
4.5 KiB
PL/PgSQL

-- Upgrade-path fixture: a small but REAL company, seeded against the schema as
-- it stood BEFORE the pull request's migrations.
--
-- Why this exists
-- ---------------
-- The `pg-real` job applies all 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 (or
-- silently corrupt) on production, where the rows exist. This fixture gives the
-- new migrations something to break.
--
-- Deliberately narrow: only long-stable core tables, because this file has to
-- execute against an OLDER schema than the one in the working tree. Anything
-- newer than the merge-base does not exist yet. Adding a column reference here
-- to a recently-added column will make the job fail on every PR, not just the
-- bad ones. If a new table needs upgrade coverage, add it here only once it has
-- been in main long enough that the merge-base always has it.
--
-- CI FIXTURE ONLY. NEVER a template for real data.
-- ------------------------------------------------
-- The inserts below write `journal_entries` with status='posted' and their
-- lines directly, bypassing lib/bookkeeping/engine.ts and the atomic
-- commit_journal_entry RPC. That is legitimate here and ONLY here: this runs
-- against a throwaway CI database that is destroyed with the job, so there is
-- no verifikationsnummer sequence to keep gapless and no retention obligation
-- (BFL 5 kap 6-7 §). Seeding this way is the only way to hand the migrations
-- pre-existing posted rows to break.
--
-- Do not copy this pattern into a seed script, a migration, a repair script, or
-- anything that touches a real database. Every production journal write goes
-- through the engine (CLAUDE.md Hard Rule 2). If you need posted entries in a
-- real database, use the engine.
--
-- Fixed UUIDs so assert.sql can find the rows without threading state.
BEGIN;
INSERT INTO auth.users (id, email, instance_id)
VALUES (
'11111111-1111-1111-1111-111111111111',
'pg-upgrade@test.invalid',
'00000000-0000-0000-0000-000000000000'::uuid
);
INSERT INTO public.companies (id, name, entity_type, created_by)
VALUES (
'22222222-2222-2222-2222-222222222222',
'Uppgraderingsbolaget AB',
'aktiebolag',
'11111111-1111-1111-1111-111111111111'
);
INSERT INTO public.company_members (company_id, user_id, role)
VALUES (
'22222222-2222-2222-2222-222222222222',
'11111111-1111-1111-1111-111111111111',
'owner'
);
INSERT INTO public.fiscal_periods
(id, user_id, company_id, name, period_start, period_end, is_closed)
VALUES (
'33333333-3333-3333-3333-333333333333',
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'2026',
'2026-01-01',
'2026-12-31',
FALSE
);
-- Three posted verifikat with balanced lines. Posted (not draft) is the point:
-- these are the rows a careless migration corrupts, and the ones the
-- immutability triggers protect.
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
('44444444-4444-4444-4444-444444444401',
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
1, 'A', '2026-03-01', 'Försäljning', 'manual', 'posted'),
('44444444-4444-4444-4444-444444444402',
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
2, 'A', '2026-03-15', 'Lokalhyra', 'manual', 'posted'),
('44444444-4444-4444-4444-444444444403',
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
3, 'A', '2026-04-01', 'Bankavgift', 'manual', 'posted');
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES
('44444444-4444-4444-4444-444444444401', '1930', 12500.00, 0),
('44444444-4444-4444-4444-444444444401', '3001', 0, 10000.00),
('44444444-4444-4444-4444-444444444401', '2611', 0, 2500.00),
('44444444-4444-4444-4444-444444444402', '5010', 8000.00, 0),
('44444444-4444-4444-4444-444444444402', '1930', 0, 8000.00),
-- An öre-level amount, so a migration that rounds or retypes the money
-- columns shows up in the assertion instead of passing on round numbers.
('44444444-4444-4444-4444-444444444403', '6570', 123.45, 0),
('44444444-4444-4444-4444-444444444403', '1930', 0, 123.45);
COMMIT;