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>
This commit is contained in:
Jakob Wennberg
2026-08-03 16:30:14 +02:00
committed by GitHub
parent ef237351b6
commit 16fbcefbbc
24 changed files with 1291 additions and 81 deletions
+102
View File
@@ -33,6 +33,108 @@ jobs:
PG_GATE_BASE: origin/${{ github.base_ref }}
run: node scripts/check-pg-test-coverage.mjs
# Proves that an EXISTING database survives the PR's migrations, which the
# pg-real job below cannot: it applies every migration to an empty database,
# so a NOT NULL, a CHECK, a unique index or a backfill passes trivially
# against zero rows and can still fail (or silently corrupt) on production.
#
# Shape: schema at the merge-base -> seed real rows -> apply ONLY the new
# migrations -> assert the rows are intact. A PR that adds no migration skips
# straight past the apply step and costs one cheap no-op run.
pg-upgrade:
runs-on: ubuntu-latest
services:
postgres:
image: supabase/postgres:15.8.1.060
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
PGPASSWORD: postgres
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Full history so the merge-base with the PR base branch exists.
fetch-depth: 0
persist-credentials: false
- name: Install psql client
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
- name: Identify the migrations this PR adds
id: newmig
env:
BASE_REF: origin/${{ github.base_ref }}
run: |
set -euo pipefail
MERGE_BASE=$(git merge-base "$BASE_REF" HEAD)
echo "merge_base=$MERGE_BASE" >> "$GITHUB_OUTPUT"
echo "Merge base: $MERGE_BASE"
# Migrations present at HEAD but not at the merge base. Uses the git
# tree, not the filesystem, so a rebase or a merge commit cannot make
# an already-shipped migration look new.
git ls-tree -r --name-only HEAD -- supabase/migrations \
| grep '\.sql$' | sort > /tmp/head-migrations.txt
git ls-tree -r --name-only "$MERGE_BASE" -- supabase/migrations \
| grep '\.sql$' | sort > /tmp/base-migrations.txt
comm -23 /tmp/head-migrations.txt /tmp/base-migrations.txt > /tmp/new-migrations.txt
COUNT=$(wc -l < /tmp/new-migrations.txt | tr -d ' ')
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
echo "New migrations ($COUNT):"
cat /tmp/new-migrations.txt
- name: Bootstrap storage schema
if: steps.newmig.outputs.count != '0'
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f tests/pg/bootstrap.sql
- name: Apply the schema as it stands at the merge base
if: steps.newmig.outputs.count != '0'
env:
MERGE_BASE: ${{ steps.newmig.outputs.merge_base }}
run: |
set -euo pipefail
# Read each migration out of the merge-base tree rather than the
# working tree: a PR that EDITS a shipped migration (forbidden, but
# this job must not be the thing that hides it) still gets the
# original applied here, so the edit shows up as a failure below.
while read -r f; do
echo "Applying (base) $f"
git show "$MERGE_BASE:$f" | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q
done < /tmp/base-migrations.txt
- name: Seed a real company with posted verifikat
if: steps.newmig.outputs.count != '0'
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f tests/pg/upgrade/seed.sql
- name: Apply ONLY the new migrations, against existing data
if: steps.newmig.outputs.count != '0'
run: |
set -euo pipefail
while read -r f; do
echo "Applying (new) $f"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f "$f"
done < /tmp/new-migrations.txt
- name: Assert the existing data survived
if: steps.newmig.outputs.count != '0'
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f tests/pg/upgrade/assert.sql
- name: No migrations in this PR
if: steps.newmig.outputs.count == '0'
run: echo "This PR adds no migration; nothing to upgrade-test."
pg-real:
runs-on: ubuntu-latest
+4
View File
@@ -743,3 +743,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-02] Out-of-order SIE IB resync requires exact date adjacency: the nearest later fiscal period can sit beyond a missing middle year, and replacing its authoritative IB with a non-adjacent UB would make that later period temporarily wrong until the gap was imported.
[2026-08-03] Issue #1360 keeps EUR annual reports fail-closed at general eligibility rather than only digital filing: the ledger and annual-report model are SEK-denominated, so allowing a EUR profile to lock a paper version would mislabel SEK amounts; full EUR support requires a company accounting-currency model across the ledger, report builders, and iXBRL.
[2026-08-03] Issue #1267 localizes the latest-posted-voucher label in the web views while PDF and spreadsheet exports remain Swedish: translating one export label would create mixed-language files, so full export localization stays a separate surface-wide change.
[2026-08-03] Shared format contracts centralised in lib/invariants/ (org number, BAS account number, ISO date, fiscal year), each with its rationale recorded next to the rule. Trigger: four Skatteverket/Bolagsverket-bound export paths (KU10, AGI, SRU redovisare, iXBRL preflight) each had their own idea of a valid organisationsnummer, so a company stored with a space or in 12-digit form could file AGI all year and fail at the arsredovisning deadline. normalizeOrgNumber moved from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both old paths re-export. The iXBRL check-digit verdict is warn, not error: we do not block a statutory filing on a Luhn assumption unverified against a primary source. KU10 12-digit passthrough pinned by test, not changed (open domain question). ROT/RUT brf_org_number left alone: different documented contract. Ratchet guard 8 holds the remaining 114 inline copies.
[2026-08-03] CI gained a pg-upgrade job: apply the merge-base schema, seed real rows, apply ONLY the PR migrations, assert the data survived. Rationale: pg-real applies all 548 migrations to an EMPTY database, so a NOT NULL / CHECK / unique index / backfill passes against zero rows and can still break prod. Proven locally against supabase/postgres:15.8.1.060 with three bad migrations: a CHECK violating an ore-level row and a NOT NULL on a populated column both exit 0 on empty and exit 3 on seeded. Base migrations are read from the merge-base git tree, not the working tree, so a PR that edits a shipped migration still surfaces here.
+20 -17
View File
@@ -1,7 +1,13 @@
import { z } from 'zod'
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
import { normalizeVatNumber } from '@/lib/vat/vat-number'
import { isSaneDateString } from '@/lib/utils'
import {
accountNumberSchema,
isoDateSchema,
saneIsoDateSchema,
fiscalYearSchema,
} from '@/lib/invariants/zod'
import { ISO_DATE_RE, ISO_DATE_MESSAGE_SV } from '@/lib/invariants/iso-date'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
@@ -17,8 +23,13 @@ import type { AuditAction } from '@/types'
/** UUID v4 string */
const uuid = z.string().uuid()
/** ISO date string (YYYY-MM-DD) */
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
/**
* ISO date string (YYYY-MM-DD).
*
* Shape only. From `lib/invariants/iso-date.ts` so that every schema below, the
* v1 routes, and the MCP surface reject a malformed date the same way.
*/
const isoDate = isoDateSchema
/**
* ISO date that must also be a real, in-range calendar date: not just the
@@ -26,12 +37,10 @@ const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD dat
* transaction form) so a 6-digit year or impossible date can't slip through
* for user-entered dates. Use this over `isoDate` for free-text date input.
*/
const saneIsoDate = z
.string()
.refine(isSaneDateString, 'Invalid or out-of-range date (expected YYYY-MM-DD, year 1900-2100)')
const saneIsoDate = saneIsoDateSchema
/** BAS account number: always a string of 4 digits */
const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
const accountNumber = accountNumberSchema
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
@@ -1751,7 +1760,7 @@ export const UpdateSettingsSchema = z.object({
pays_salaries: z.boolean().optional(),
sector_slug: z.string().nullable().optional(),
// Bookkeeping lock
bookkeeping_locked_through: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Ogiltigt datumformat (YYYY-MM-DD)').nullable().optional(),
bookkeeping_locked_through: z.string().regex(ISO_DATE_RE, ISO_DATE_MESSAGE_SV).nullable().optional(),
auto_lock_period_days: z.number().int().positive().nullable().optional(),
// Voucher series
default_voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
@@ -1989,10 +1998,7 @@ export const BankLinkSchema = z.object({
// Settlement account being reconciled. The voucher must have a line on this
// account and the transaction must belong to it. Defaults to '1930' in the
// route for back-compat.
account_number: z
.string()
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
.optional(),
account_number: accountNumber.optional(),
})
export const BankUnlinkSchema = z.object({
@@ -2014,10 +2020,7 @@ export const RunReconciliationSchema = z.object({
date_to: isoDate.optional(),
// BAS settlement account to reconcile against (e.g. '1930', '1932'). Defaults
// to '1930' server-side so existing clients stay correct.
account_number: z
.string()
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
.optional(),
account_number: accountNumber.optional(),
dry_run: z.boolean().optional(),
// Pairs the user ticked in the dry-run preview. When present on an apply
// (dry_run false), only these pairs are committed: intersected server-side
@@ -2745,7 +2748,7 @@ const openingBalancesShape = {
ytd_net: z.number().min(0).default(0),
vacation_paid_days_remaining: z.number().min(0).max(40).default(0),
vacation_saved_days_by_year: z
.record(z.string().regex(/^\d{4}$/, 'Nyckel måste vara ett fyrsiffrigt år'), z.number().min(0).max(40))
.record(fiscalYearSchema, z.number().min(0).max(40))
.default({}),
opening_semester_liability: z.number().min(0).default(0),
opening_semester_liability_avgifter: z.number().min(0).default(0),
+1 -1
View File
@@ -135,7 +135,7 @@ export const PREVIOUS: TrialBalancePair = {
export function makeInput(): IxbrlArsredovisningInput {
const mapping = mapTrialBalancesToK2(CURRENT, PREVIOUS)
return {
company: { name: 'Testbolaget AB', orgNumber: '556999-9999', city: 'Sundsvall' },
company: { name: 'Testbolaget AB', orgNumber: '556999-9997', city: 'Sundsvall' },
period: { start: '2025-01-01', end: '2025-12-31' },
previousPeriod: { start: '2024-01-01', end: '2024-12-31' },
isFirstFiscalYear: false,
+30 -4
View File
@@ -14,6 +14,7 @@
*/
import type { IxbrlArsredovisningInput } from '../types'
import { isOrgNumberShaped, hasInvalidOrgNumberCheckDigit } from '@/lib/invariants/org-number'
export interface PreflightIssue {
/** Bolagsverket kontrollera code where one exists, else our own ACC-xxx. */
@@ -53,10 +54,35 @@ const RULES: Rule[] = [
input.company.name.trim().length === 0
? issue('1020', 'error', 'Företagsnamnet saknas i årsredovisningen.')
: null,
(input) =>
/^\d{6}-?\d{4}$/.test(input.company.orgNumber.trim())
? null
: issue('1035', 'error', `Organisationsnumret "${input.company.orgNumber}" är inte giltigt (förväntat format NNNNNN-NNNN).`),
// Shared rule (lib/invariants/org-number.ts), so this validator agrees with
// the AGI, KU10 and SRU export paths about what a valid org number is. The
// previous local regex rejected the 12-digit form outright and skipped the
// check digit, so a company stored in 12-digit form could file AGI all year
// and then fail here with a message that did not say why.
(input) => {
const orgNumber = input.company.orgNumber.trim()
if (!isOrgNumberShaped(orgNumber)) {
return issue(
'1035',
'error',
`Organisationsnumret "${input.company.orgNumber}" är inte giltigt (förväntat format NNNNNN-NNNN).`,
)
}
// Warn, not error, deliberately. A wrong check digit is almost certainly a
// typo and Bolagsverket will reject it, so the user should see it. 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' here blocks Skicka in. We do not block a statutory filing on an
// unverified assumption: surface it and let the user judge.
if (hasInvalidOrgNumberCheckDigit(orgNumber)) {
return issue(
'1035',
'warn',
`Organisationsnumret "${input.company.orgNumber}" har fel kontrollsiffra. Kontrollera sista siffran.`,
)
}
return null
},
(input) =>
input.forvaltningsberattelse.allmantOmVerksamheten.trim().length === 0
? issue('1051', 'error', 'Förvaltningsberättelsen saknas (Allmänt om verksamheten är tom).')
+6 -30
View File
@@ -1,33 +1,9 @@
import { luhnValidate } from '@/lib/bankgiro/luhn'
/**
* Normalize an org number to Accounted's canonical 10-digit storage form.
* Org-number normalization for the company-lookup boundary.
*
* Accepts hyphen/space-formatted input in either of the two shapes Swedish
* users commonly type:
* - 10 digits (5560125790 or 8001011231): stored as-is
* - 12 digits (198001011231): century prefix stripped
*
* Returns null for any other length, non-digit content, or invalid Luhn
* check digit (the structural rule Bolagsverket and personnummer share).
* Storing a structurally invalid org number would later be caught by
* Skatteverket SRU and any receiving SIE4 system: refusing at the boundary
* keeps Accounted's bookkeeping from accumulating under an unusable identifier.
*
* 10-digit storage matches the rest of the codebase: see
* `lib/skatteverket/format.ts`, which converts 10→12 at export time by
* prefixing with '16' (AB) or '19'/'20' (EF personnummer).
* The rule moved to `lib/invariants/org-number.ts`, where it sits with the
* other shared format contracts and with the Skatteverket-bound converters that
* have to agree with it. Re-exported here because this is where the lookup,
* TIC-refresh, VAT-number and årsredovisning callers import it from.
*/
export function normalizeOrgNumber(raw: string | null | undefined): string | null {
if (!raw) return null
const cleaned = raw.replace(/[\s-]/g, '')
let canonical: string
if (/^\d{10}$/.test(cleaned)) {
canonical = cleaned
} else if (/^\d{12}$/.test(cleaned)) {
canonical = cleaned.substring(2)
} else {
return null
}
return luhnValidate(canonical) ? canonical : null
}
export { normalizeOrgNumber } from '@/lib/invariants/org-number'
+51
View File
@@ -0,0 +1,51 @@
# `lib/invariants`
Shared **product contracts**: the formats and bounds that more than one consumer
must agree on, with the reason for each rule recorded next to it.
## What belongs here
A rule belongs here only when consumers **outside the owning module** need the
identical invariant to validate input or to generate compatible output.
Our consumers are the web app, the public `/api/v1` surface, the MCP server, the
SIE importer and exporter, and the Skatteverket-bound generators (AGI, KU10,
SRU, iXBRL). When two of those disagree about what a valid value looks like, the
disagreement is invisible until a customer's filing fails.
## What does not belong here
- **Rules specific to one module.** They stay in that module.
- **General helpers.** `lib/utils.ts`, `lib/money.ts`.
- **Database questions.** "Is this account in the company's chart?" is
`lib/bookkeeping/account-validation.ts`, not a format rule.
- **Business rules.** "Is this fiscal year open?" is a period question.
## Naming
The name must make the owning concept explicit. `isAccountNumber`, not
`isValidNumber`. Two rules in here share a byte-identical regex (`account-number`
and `fiscal-year` are both `/^\d{4}$/`) and mean entirely different things: the
names are the only thing keeping a call site honest, so they carry the weight.
## Layout
| File | Owns |
|---|---|
| `org-number.ts` | Swedish organisationsnummer / personnummer: canonical 10-digit form, Luhn, Skatteverket 12-digit conversion |
| `account-number.ts` | BAS account number format (4 digits, always a string) |
| `iso-date.ts` | `YYYY-MM-DD` shape, plus a real-calendar-date check |
| `fiscal-year.ts` | Four-digit räkenskapsår key |
| `zod.ts` | Zod primitives built from the rules above, for API schemas |
`zod.ts` is separate so that consumers which do not use Zod (the MCP server,
report generators) can import a rule without pulling the dependency in.
## Adding a rule
1. Write the rule and the **reason** in the docblock. A rule without a recorded
reason gets re-litigated or worked around within a quarter.
2. Export a pure validator (primitives in, boolean or `null` out).
3. Add the Zod primitive to `zod.ts` if an API surface needs it.
4. Replace the hand-rolled copies. `npm run check:guards` tracks how many remain
and fails if the count goes up.
+94
View File
@@ -0,0 +1,94 @@
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)
})
})
@@ -0,0 +1,194 @@
import { describe, it, expect } from 'vitest'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { generateKU10Xml } from '@/lib/salary/ku/ku10-generator'
import { generateAGIXml } from '@/lib/salary/agi/xml-generator'
import { runPreflightChecks } from '@/lib/bokslut/ixbrl/validate/rules'
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
/**
* The four Skatteverket- and Bolagsverket-bound export paths must agree about
* what a valid org number is.
*
* Before `lib/invariants/org-number.ts` they did not: the SRU converter stripped
* hyphens only and threw on a space, KU10 stripped the first hyphen only, AGI
* stripped every non-digit (so stray letters passed), and the årsredovisning
* validator rejected the 12-digit form and skipped the check digit. A company
* stored in one of the awkward forms could file AGI for a year and then fail at
* the årsredovisning deadline with a message that did not say why.
*
* This test is the guard on that agreement. If a future change makes one path
* accept an input the others reject, it fails here rather than at a customer's
* deadline.
*/
const AB_10 = '5560125790'
/** The input forms a Swedish user or a provider API actually produces. */
const EQUIVALENT_FORMS = ['5560125790', '556012-5790', '556012 5790', '165560125790']
/** The subset that is already 10 digits, ignoring separators. */
const TEN_DIGIT_FORMS = ['5560125790', '556012-5790', '556012 5790']
function ku10CompanyFixture(orgNumber: string) {
return {
orgNumber,
companyName: 'Testbolaget AB',
year: 2025,
contactName: 'Test Testsson',
contactPhone: '0700000000',
contactEmail: 'test@example.com',
}
}
function agiCompanyFixture(orgNumber: string) {
return {
orgNumber,
companyName: 'Testbolaget AB',
periodYear: 2025,
periodMonth: 3,
contactName: 'Test Testsson',
contactPhone: '0700000000',
contactEmail: 'test@example.com',
}
}
function ixbrlInputFixture(orgNumber: string): IxbrlArsredovisningInput {
// Only the org-number rule (code 1035) is asserted below. The remaining
// fields exist so the other preflight rules can run without throwing; their
// verdicts are filtered out.
return {
company: { name: 'Testbolaget AB', orgNumber },
period: { start: '2025-01-01', end: '2025-12-31' },
isFirstFiscalYear: false,
forvaltningsberattelse: {
allmantOmVerksamheten: 'Bolaget bedriver konsultverksamhet.',
resultatdisposition: { balanseratResultat: 0, aretsResultat: 0, summa: 0 },
},
faststallelseintyg: {
arsstammaDatum: '2026-05-15',
genereratDatum: '2026-05-20',
resultatdispositionDecision: 'enligt_forslag',
resultatdispositionOutcome: 'balanseras_i_ny_rakning',
signerFirstName: 'Test',
signerLastName: 'Testsson',
},
underskrifter: {
dateringsdatum: '2026-05-10',
signers: [{ firstName: 'Test', lastName: 'Testsson', role: 'Styrelseledamot' }],
},
totals: {
aretsResultat: { current: 0, previous: 0 },
egetKapitalSkulder: { current: 0, previous: 0 },
tillgangar: { current: 0, previous: 0 },
},
rr: [],
br: [],
warnings: [],
} as unknown as IxbrlArsredovisningInput
}
/** Does the årsredovisning preflight raise the org-number issue (code 1035)? */
function ixbrlRejects(orgNumber: string): boolean {
const result = runPreflightChecks(ixbrlInputFixture(orgNumber))
return result.issues.some((i) => i.code === '1035')
}
/** Does the SRU redovisare conversion throw? */
function redovisareRejects(orgNumber: string): boolean {
try {
formatRedovisare(orgNumber, 'aktiebolag')
return false
} catch {
return true
}
}
/** Does the AGI generator refuse to build for this org number? */
function agiRejects(orgNumber: string): boolean {
try {
generateAGIXml(
agiCompanyFixture(orgNumber),
[],
{ totalTax: 0, totalAvgifterBasis: 0, totalAvgifterAmount: 0, totalSjuklonekostnad: 0 } as never,
)
return false
} catch {
return true
}
}
describe('org number: the four export paths agree', () => {
it.each(EQUIVALENT_FORMS)('accepts %s everywhere', (form) => {
expect(redovisareRejects(form), 'SRU redovisare conversion').toBe(false)
expect(ixbrlRejects(form), 'årsredovisning preflight').toBe(false)
expect(agiRejects(form), 'AGI generator').toBe(false)
expect(() => generateKU10Xml(ku10CompanyFixture(form), []), 'KU10 generator').not.toThrow()
})
it('produces the same redovisare identity from every equivalent form', () => {
const identities = EQUIVALENT_FORMS.map((f) => formatRedovisare(f, 'aktiebolag'))
expect(new Set(identities).size, `got ${JSON.stringify(identities)}`).toBe(1)
expect(identities[0]).toBe('165560125790')
})
it('emits a separator-free identity into the KU10 file', () => {
for (const form of TEN_DIGIT_FORMS) {
const xml = generateKU10Xml(ku10CompanyFixture(form), [])
const match = xml.match(/<Organisationsnummer>([^<]*)<\/Organisationsnummer>/)
// Guard against a vacuous assertion: the element must actually be there.
expect(match, `input form ${form}: no <Organisationsnummer> in output`).not.toBeNull()
expect(match?.[1], `input form ${form}`).toBe(AB_10)
}
})
/**
* OPEN QUESTION, deliberately pinned rather than changed.
*
* KU10 strips separators but does not fold the 12-digit form down to the
* canonical 10 digits, so a company stored as `165560125790` files with 12
* digits. Whether Skatteverket's KU10 schema wants 10 or 12 here is a Swedish
* domain question that the `swedish-payroll` skill does not cover, and
* CLAUDE.md forbids answering it from training data.
*
* This is pre-existing behaviour (the old `replace('-', '')` did the same);
* this test pins it so the answer, when we get it, is a deliberate change with
* a failing test to update rather than a silent drift.
*/
it('PINNED: KU10 passes a 12-digit stored org number through unfolded', () => {
const xml = generateKU10Xml(ku10CompanyFixture('165560125790'), [])
const match = xml.match(/<Organisationsnummer>([^<]*)<\/Organisationsnummer>/)
expect(match?.[1]).toBe('165560125790')
})
it.each([
['5560125790x', 'stray characters'],
['55601', 'too short'],
])('rejects %s (%s) on every path that validates', (bad) => {
expect(redovisareRejects(bad), 'SRU redovisare conversion').toBe(true)
expect(ixbrlRejects(bad), 'årsredovisning preflight').toBe(true)
expect(agiRejects(bad), 'AGI generator').toBe(true)
})
it('surfaces a bad check digit without blocking the filing', () => {
const badCheckDigit = '5560125791'
const result = runPreflightChecks(ixbrlInputFixture(badCheckDigit))
const orgIssues = result.issues.filter((i) => i.code === '1035')
expect(orgIssues).toHaveLength(1)
// Warn, not error: a wrong check digit is surfaced to the user, but we do
// not block Skicka in on a domain assumption we have not verified against a
// primary source. See the rationale in validate/rules.ts.
expect(orgIssues[0].severity).toBe('warn')
// The org-number verdict must not be among the errors that block Skicka in.
// (`result.ok` is not asserted here: this fixture is minimal and trips other
// unrelated rules. The org-number rule's own severity is the contract.)
expect(result.errors.some((i) => i.code === '1035')).toBe(false)
// Export-time conversion stays permissive by design: see org-number.ts.
expect(redovisareRejects(badCheckDigit), 'SRU redovisare conversion').toBe(false)
})
it('the canonical form is what the display helper round-trips to', () => {
expect(formatRedovisare(AB_10, 'aktiebolag')).toBe('165560125790')
})
})
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest'
import {
normalizeOrgNumber,
isValidOrgNumber,
isOrgNumberShaped,
hasInvalidOrgNumberCheckDigit,
stripOrgNumberFormatting,
formatOrgNumberDisplay,
toRedovisare12,
} from '@/lib/invariants/org-number'
// Real-shaped numbers with correct Luhn check digits.
const AB_10 = '5560125790'
const EF_10 = '8001011231'
describe('stripOrgNumberFormatting', () => {
it('removes hyphens and spaces, nothing else', () => {
expect(stripOrgNumberFormatting('556012-5790')).toBe(AB_10)
expect(stripOrgNumberFormatting('556012 5790')).toBe(AB_10)
expect(stripOrgNumberFormatting(' 556012 - 5790 ')).toBe(AB_10)
// Letters are preserved so the caller's shape check can reject them,
// instead of a digit-strip silently making garbage look valid.
expect(stripOrgNumberFormatting('5560125790x')).toBe('5560125790x')
})
})
describe('normalizeOrgNumber', () => {
it('accepts the forms users actually type', () => {
expect(normalizeOrgNumber(AB_10)).toBe(AB_10)
expect(normalizeOrgNumber('556012-5790')).toBe(AB_10)
expect(normalizeOrgNumber('556012 5790')).toBe(AB_10)
})
it('strips the century prefix from the 12-digit form', () => {
expect(normalizeOrgNumber('165560125790')).toBe(AB_10)
expect(normalizeOrgNumber('198001011231')).toBe(EF_10)
expect(normalizeOrgNumber('19800101-1231')).toBe(EF_10)
})
it('rejects wrong length, non-digits and a bad check digit', () => {
expect(normalizeOrgNumber('55601257')).toBeNull()
expect(normalizeOrgNumber('5560125790x')).toBeNull()
expect(normalizeOrgNumber('5560125791')).toBeNull() // check digit off by one
expect(normalizeOrgNumber('')).toBeNull()
expect(normalizeOrgNumber(null)).toBeNull()
expect(normalizeOrgNumber(undefined)).toBeNull()
})
})
describe('shape versus check digit', () => {
it('separates "wrong format" from "wrong last digit"', () => {
expect(isOrgNumberShaped('5560125791')).toBe(true)
expect(isValidOrgNumber('5560125791')).toBe(false)
expect(hasInvalidOrgNumberCheckDigit('5560125791')).toBe(true)
// Wrong shape is not a check-digit problem.
expect(hasInvalidOrgNumberCheckDigit('55601')).toBe(false)
// A valid number is neither.
expect(hasInvalidOrgNumberCheckDigit(AB_10)).toBe(false)
})
})
describe('formatOrgNumberDisplay', () => {
it('renders NNNNNN-NNNN from any accepted input form', () => {
expect(formatOrgNumberDisplay(AB_10)).toBe('556012-5790')
expect(formatOrgNumberDisplay('556012 5790')).toBe('556012-5790')
expect(formatOrgNumberDisplay('165560125790')).toBe('556012-5790')
})
it('passes through anything that is not org-number shaped', () => {
expect(formatOrgNumberDisplay('nonsense')).toBe('nonsense')
expect(formatOrgNumberDisplay('')).toBe('')
})
})
describe('toRedovisare12', () => {
it('prefixes 16 for aktiebolag', () => {
expect(toRedovisare12(AB_10, 'aktiebolag')).toBe('165560125790')
expect(toRedovisare12('556012-5790', 'aktiebolag')).toBe('165560125790')
})
it('prefixes the century for enskild firma', () => {
// 80 is above the current two-digit year, so it belongs to the 1900s.
expect(toRedovisare12(EF_10, 'enskild_firma')).toBe('198001011231')
})
it('passes an already 12-digit value through untouched', () => {
expect(toRedovisare12('165560125790', 'aktiebolag')).toBe('165560125790')
})
it('accepts spaces, which the previous hyphen-only strip did not', () => {
expect(toRedovisare12('556012 5790', 'aktiebolag')).toBe('165560125790')
})
it('throws on a length it cannot interpret', () => {
expect(() => toRedovisare12('55601', 'aktiebolag')).toThrow(/Ogiltigt organisationsnummer/)
})
it('stays permissive about the check digit', () => {
// Export-time conversion must not start rejecting numbers that are already
// stored and filing: a failed export at a deadline is worse than letting
// Skatteverket reject it with its own message. See the module docblock.
expect(toRedovisare12('5560125791', 'aktiebolag')).toBe('165560125791')
})
})
+46
View File
@@ -0,0 +1,46 @@
/**
* BAS account number format.
*
* ## The rule
*
* Exactly four digits, carried as a **string**. `'1930'`, never `1930`.
*
* ## Why a string
*
* Account numbers are identifiers, not quantities. Arithmetic on one is always
* a bug, and a numeric type invites it (`1930 + 1` is not an account). The
* string form also keeps leading digits meaningful: the first digit is the BAS
* account class (1 assets, 2 equity and liabilities, 3 revenue, 4-7 costs,
* 8 financial), which reporting code reads positionally.
*
* ## Why the shared constant, not a literal
*
* `/^\d{4}$/` appeared at 20 sites, sometimes as `/^[0-9]{4}$/`, with error
* messages that differed per site. Worse, the identical literal also means
* "a four-digit year" elsewhere in the codebase (see `fiscal-year.ts`), so the
* regex alone does not say what is being validated. Naming the rule makes the
* two impossible to confuse at a call site.
*
* Format only. Whether an account *exists and is active* in a company's chart
* is a database question: see `lib/bookkeeping/account-validation.ts`.
*/
/** Exactly four digits. */
export const ACCOUNT_NUMBER_RE = /^\d{4}$/
/** Canonical validation error message (Swedish: user-facing surface). */
export const ACCOUNT_NUMBER_MESSAGE = 'Kontonummer måste vara 4 siffror'
/** True when the input is a syntactically valid BAS account number. */
export function isAccountNumber(raw: string | null | undefined): boolean {
return typeof raw === 'string' && ACCOUNT_NUMBER_RE.test(raw)
}
/**
* The BAS account class (leading digit) of an account number, or null when the
* input is not a valid account number.
*/
export function accountClass(raw: string | null | undefined): number | null {
if (!isAccountNumber(raw)) return null
return parseInt((raw as string).charAt(0), 10)
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Four-digit calendar year, as used for räkenskapsår keys.
*
* ## Why this exists as its own name
*
* The regex is byte-identical to {@link ACCOUNT_NUMBER_RE} in
* `account-number.ts`. That collision is the reason both are named: a reader
* (or a codemod) scanning for `/^\d{4}$/` cannot tell a BAS account from a
* year, and the two have opposite consequences when confused. `lib/api/schemas.ts`
* already carried both meanings under the same literal.
*
* The bound is deliberately loose. Accounted holds historical räkenskapsår from
* SIE imports going back decades and must accept future years for planning, so
* this validates the shape and a sane range, not a business rule. Whether a
* given year has an open fiscal period is a database question.
*/
/** Exactly four digits. Identical to the account-number shape by coincidence, not by meaning. */
export const FISCAL_YEAR_RE = /^\d{4}$/
/** Canonical validation error message (Swedish: user-facing surface). */
export const FISCAL_YEAR_MESSAGE = 'Nyckel måste vara ett fyrsiffrigt år'
/** Lower bound: earlier years are certainly a typo, not an imported räkenskapsår. */
export const FISCAL_YEAR_MIN = 1900
/** Upper bound: generous enough for forward planning, tight enough to catch a mistyped account number. */
export const FISCAL_YEAR_MAX = 2200
/** True when the input is four digits inside the accepted range. */
export function isFiscalYear(raw: string | number | null | undefined): boolean {
if (raw === null || raw === undefined) return false
const s = typeof raw === 'number' ? String(raw) : raw
if (!FISCAL_YEAR_RE.test(s)) return false
const n = parseInt(s, 10)
return n >= FISCAL_YEAR_MIN && n <= FISCAL_YEAR_MAX
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Shared product contracts: the formats and bounds more than one consumer must
* agree on. See `README.md` in this directory for what belongs here.
*
* Zod primitives are deliberately NOT re-exported from this barrel: import them
* from `@/lib/invariants/zod` so that non-Zod consumers keep a clean module
* graph.
*/
export {
ACCOUNT_NUMBER_RE,
ACCOUNT_NUMBER_MESSAGE,
isAccountNumber,
accountClass,
} from './account-number'
export {
ISO_DATE_RE,
ISO_DATE_MESSAGE,
ISO_DATE_MESSAGE_SV,
SANE_DATE_MESSAGE,
SANE_DATE_MIN_YEAR,
SANE_DATE_MAX_YEAR,
isIsoDateShaped,
isSaneDateString,
} from './iso-date'
export {
FISCAL_YEAR_RE,
FISCAL_YEAR_MESSAGE,
FISCAL_YEAR_MIN,
FISCAL_YEAR_MAX,
isFiscalYear,
} from './fiscal-year'
export {
ORG_NUMBER_LENGTH,
stripOrgNumberFormatting,
isOrgNumberShaped,
normalizeOrgNumber,
isValidOrgNumber,
hasInvalidOrgNumberCheckDigit,
formatOrgNumberDisplay,
toRedovisare12,
} from './org-number'
+69
View File
@@ -0,0 +1,69 @@
import { parseISO, isValid } from 'date-fns'
/**
* ISO calendar date format (`YYYY-MM-DD`).
*
* ## The rule
*
* Accounting dates are exchanged and stored as `YYYY-MM-DD`, never as a locale
* format and never as a timestamp. `lib/utils.ts` `formatDate()` renders this
* same shape for display, so what a user reads matches what the API accepts.
*
* ## Why it is centralised
*
* The literal `/^\d{4}-\d{2}-\d{2}$/` appeared at 68 sites outside tests, with
* at least three different error messages for the same failure ("Expected
* YYYY-MM-DD", "Expected YYYY-MM-DD date format", "Ogiltigt datumformat
* (YYYY-MM-DD)"). An API surface that rejects the same input three different
* ways is three surfaces to a client.
*
* ## Two rules, not one
*
* - {@link isIsoDateShaped} is a **shape** check. `2026-02-31` passes it.
* - {@link isSaneDateString} additionally requires the date to exist and to sit
* in a plausible year range. Use it for anything a human typed.
*
* `isSaneDateString` moved here from `lib/utils.ts`, where it was already
* documented as "the ONE authoritative date rule shared by the client form and
* the server-side CreateTransactionSchema". `lib/utils.ts` re-exports it so
* existing imports keep working; this module is now its home because the same
* rule is needed by consumers that must not import UI helpers.
*/
/** `YYYY-MM-DD` shape. Does not check that the date exists. */
export const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
/** Canonical validation error message. */
export const ISO_DATE_MESSAGE = 'Expected YYYY-MM-DD'
/** Swedish-facing variant, for surfaces that render errors to end users. */
export const ISO_DATE_MESSAGE_SV = 'Ogiltigt datumformat (YYYY-MM-DD)'
/** Message for the stricter {@link isSaneDateString} rule. */
export const SANE_DATE_MESSAGE = 'Invalid or out-of-range date (expected YYYY-MM-DD, year 1900-2100)'
/** Earliest year accepted for a user-entered date. */
export const SANE_DATE_MIN_YEAR = 1900
/** Latest year accepted for a user-entered date. */
export const SANE_DATE_MAX_YEAR = 2100
/** True when the input has `YYYY-MM-DD` shape. */
export function isIsoDateShaped(raw: string | null | undefined): boolean {
return typeof raw === 'string' && ISO_DATE_RE.test(raw)
}
/**
* Strict ISO date validation for user-entered dates: correct shape, a date that
* actually exists, and a plausible year.
*
* Rejects impossible dates (`2024-13-40`, `2026-02-31`) and absurd years. The
* year bound exists because a mistyped or misparsed date most often lands far
* outside it, and an accounting date in year 0202 silently creates a fiscal
* period nobody can close.
*/
export function isSaneDateString(s: string): boolean {
if (!ISO_DATE_RE.test(s)) return false
const d = parseISO(s)
return isValid(d) && d.getFullYear() >= SANE_DATE_MIN_YEAR && d.getFullYear() <= SANE_DATE_MAX_YEAR
}
+147
View File
@@ -0,0 +1,147 @@
import { luhnValidate } from '@/lib/bankgiro/luhn'
/**
* Swedish organisationsnummer / personnummer: the one place that decides what
* "a valid org number" means.
*
* ## The rule
*
* - **Canonical storage form is 10 digits, no separators** (`5560125790`).
* - Input may arrive as 10 or 12 digits, with spaces or hyphens, because that
* is what users type and what provider APIs return. Both forms normalize to
* the same 10 digits; the century prefix is dropped.
* - The last digit is a Luhn (mod-10) check digit, the structural rule
* Bolagsverket and personnummer share.
*
* ## Why this module exists
*
* Before it, seven call sites each had their own idea of the rule, and four of
* them fed Skatteverket-bound output that must agree:
*
* | Site | Old rule | Failure |
* |---|---|---|
* | `lib/skatteverket/format.ts` | strip `-` only | threw on any input containing a space |
* | `lib/salary/ku/ku10-generator.ts` | `replace('-', '')` | first hyphen only, no space handling |
* | `lib/salary/agi/xml-generator.ts` | strip non-digits | no check-digit validation |
* | `lib/bokslut/ixbrl/validate/rules.ts` | `/^\d{6}-?\d{4}$/` | rejected the 12-digit form outright |
*
* A company stored with a space or in 12-digit form could file AGI all year and
* then fail on the årsredovisning, with no way for the user to tell why. The
* rules only stay in agreement if there is exactly one of them.
*
* ## Deliberate asymmetry: normalize everywhere, Luhn only at the boundary
*
* `normalizeOrgNumber` (Luhn-checked) guards data coming *in*. The export-time
* converter `toRedovisare12` is structural only: it must not start rejecting
* numbers that are already stored and have been filing successfully, because a
* failed export at a deadline is worse than a number Skatteverket will reject
* with its own message. Tighten the intake, not the outflow.
*/
/** Digits-only canonical storage length. */
export const ORG_NUMBER_LENGTH = 10
/**
* Strip the separators Swedish users and provider APIs put in org numbers.
* Does not validate: use {@link isOrgNumberShaped} or {@link normalizeOrgNumber}.
*/
export function stripOrgNumberFormatting(raw: string): string {
return raw.replace(/[\s-]/g, '')
}
/**
* True when the input is structurally an org number (10 or 12 digits after
* separators are stripped), regardless of check digit.
*/
export function isOrgNumberShaped(raw: string | null | undefined): boolean {
if (!raw) return false
const cleaned = stripOrgNumberFormatting(raw)
return /^\d{10}$/.test(cleaned) || /^\d{12}$/.test(cleaned)
}
/**
* Normalize an org number to Accounted's canonical 10-digit storage form.
*
* Accepts hyphen/space-formatted input in either of the two shapes Swedish
* users commonly type:
* - 10 digits (5560125790 or 8001011231): stored as-is
* - 12 digits (198001011231): century prefix stripped
*
* Returns null for any other length, non-digit content, or invalid Luhn check
* digit. Storing a structurally invalid org number would later be caught by
* Skatteverket SRU and any receiving SIE4 system: refusing at the boundary
* keeps Accounted's bookkeeping from accumulating under an unusable identifier.
*/
export function normalizeOrgNumber(raw: string | null | undefined): string | null {
if (!raw) return null
const cleaned = stripOrgNumberFormatting(raw)
let canonical: string
if (/^\d{10}$/.test(cleaned)) {
canonical = cleaned
} else if (/^\d{12}$/.test(cleaned)) {
canonical = cleaned.substring(2)
} else {
return null
}
return luhnValidate(canonical) ? canonical : null
}
/** True when {@link normalizeOrgNumber} accepts the input. */
export function isValidOrgNumber(raw: string | null | undefined): boolean {
return normalizeOrgNumber(raw) !== null
}
/**
* True when the input is shaped like an org number but its check digit is
* wrong. Lets a validator tell the user *which* problem they have instead of
* one undifferentiated "ogiltigt organisationsnummer".
*/
export function hasInvalidOrgNumberCheckDigit(raw: string | null | undefined): boolean {
return isOrgNumberShaped(raw) && !isValidOrgNumber(raw)
}
/**
* Format a canonical org number for display: `NNNNNN-NNNN`.
* Returns the input unchanged when it is not org-number shaped.
*/
export function formatOrgNumberDisplay(raw: string | null | undefined): string {
if (!raw) return ''
const cleaned = stripOrgNumberFormatting(raw)
const ten = /^\d{12}$/.test(cleaned) ? cleaned.substring(2) : cleaned
if (!/^\d{10}$/.test(ten)) return raw
return `${ten.substring(0, 6)}-${ten.substring(6)}`
}
/**
* Convert an org number to Skatteverket's 12-digit "redovisare" format.
*
* - Organisationsnummer (aktiebolag): prefix `16` (5020000013 -> 165020000013)
* - Personnummer (enskild firma): prefix `19` or `20` by century
* - Input already in 12-digit form passes through untouched
*
* Structural only, no check-digit validation: see the module docblock for why
* the export path stays permissive.
*
* @throws when the input is not 10 or 12 digits after separators are stripped.
*/
export function toRedovisare12(
orgNumber: string,
entityType: 'enskild_firma' | 'aktiebolag',
): string {
const clean = stripOrgNumberFormatting(orgNumber)
if (/^\d{12}$/.test(clean)) return clean
if (!/^\d{10}$/.test(clean)) {
throw new Error(`Ogiltigt organisationsnummer: ${orgNumber} (förväntar 10 eller 12 siffror)`)
}
if (entityType === 'aktiebolag') return `16${clean}`
// Enskild firma: personnummer. A two-digit year above the current one must
// belong to the previous century (someone born in 98 is 1998, not 2098).
const yearDigits = parseInt(clean.substring(0, 2), 10)
const currentTwoDigitYear = new Date().getFullYear() % 100
const prefix = yearDigits > currentTwoDigitYear ? '19' : '20'
return `${prefix}${clean}`
}
+48
View File
@@ -0,0 +1,48 @@
import { z } from 'zod'
import { ACCOUNT_NUMBER_RE, ACCOUNT_NUMBER_MESSAGE } from './account-number'
import { ISO_DATE_RE, ISO_DATE_MESSAGE, SANE_DATE_MESSAGE, isSaneDateString } from './iso-date'
import { FISCAL_YEAR_RE, FISCAL_YEAR_MESSAGE } from './fiscal-year'
import { isValidOrgNumber, normalizeOrgNumber } from './org-number'
/**
* Zod primitives built from the shared rules.
*
* Kept in a separate file so consumers that do not use Zod (the MCP server,
* report generators, SIE import) can import a rule without pulling the
* dependency into their module graph.
*
* `lib/api/schemas.ts` builds its own local aliases on top of these, so the
* ~100 schemas there inherit any correction made in one place.
*/
/** BAS account number: always a string of exactly 4 digits. */
export const accountNumberSchema = z.string().regex(ACCOUNT_NUMBER_RE, ACCOUNT_NUMBER_MESSAGE)
/** ISO date shape (`YYYY-MM-DD`). Does not check the date exists. */
export const isoDateSchema = z.string().regex(ISO_DATE_RE, ISO_DATE_MESSAGE)
/**
* ISO date that must also be a real, in-range calendar date. Use this over
* {@link isoDateSchema} for anything a human typed.
*/
export const saneIsoDateSchema = z.string().refine(isSaneDateString, SANE_DATE_MESSAGE)
/** Four-digit räkenskapsår key. */
export const fiscalYearSchema = z.string().regex(FISCAL_YEAR_RE, FISCAL_YEAR_MESSAGE)
/**
* Swedish org number in any accepted input form (10 or 12 digits, spaces or
* hyphens), validated including its Luhn check digit.
*
* Does **not** transform: schemas that persist the value should call
* `normalizeOrgNumber` explicitly at the write site so the canonical form is
* visible in the calling code rather than hidden in a parser.
*/
export const orgNumberSchema = z
.string()
.refine(isValidOrgNumber, 'Ogiltigt organisationsnummer (10 eller 12 siffror, giltig kontrollsiffra)')
/** Org number that is normalized to the canonical 10-digit storage form on parse. */
export const normalizedOrgNumberSchema = orgNumberSchema.transform(
(v) => normalizeOrgNumber(v) as string,
)
+8 -4
View File
@@ -1,4 +1,5 @@
import { decryptPersonnummer } from '../personnummer'
import { isOrgNumberShaped } from '@/lib/invariants/org-number'
/**
* AGI XML generator: Arbetsgivardeklaration på individnivå.
@@ -197,11 +198,14 @@ export class AGIIncompleteDataError extends Error {
function assertRequiredCompanyData(company: AGICompanyData): void {
const missing: string[] = []
const orgNumberDigits = (company.orgNumber || '').replace(/\D/g, '')
// Skatteverket's IDENTITET type requires either 10 digits (AB orgnr, we prefix
// with "16") or 12 digits (personnummer for enskild firma). Any other length
// is a data-entry error that we cannot silently fix.
if (orgNumberDigits.length !== 10 && orgNumberDigits.length !== 12) missing.push('organisationsnummer')
// with "16") or 12 digits (personnummer for enskild firma). Any other shape is
// a data-entry error that we cannot silently fix.
//
// Shared rule (lib/invariants/org-number.ts), not a local digit-strip: the
// previous `replace(/\D/g, '')` also swallowed letters, so a field holding
// stray text still measured 10 digits and passed.
if (!isOrgNumberShaped(company.orgNumber)) missing.push('organisationsnummer')
if (!company.contactName.trim()) missing.push('kontaktperson (namn)')
if (!company.contactPhone.trim()) missing.push('telefon')
if (!company.contactEmail.trim()) missing.push('e-post')
+5 -1
View File
@@ -1,5 +1,6 @@
import { decryptPersonnummer } from '../personnummer'
import { getBranding } from '@/lib/branding/service'
import { stripOrgNumberFormatting } from '@/lib/invariants/org-number'
/**
* KU10 (Kontrolluppgift): Annual employee income statement.
@@ -49,7 +50,10 @@ export function generateKU10Xml(
employees: KU10EmployeeData[]
): string {
const lines: string[] = []
const orgNr = company.orgNumber.replace('-', '')
// Shared rule (lib/invariants/org-number.ts). The previous
// `replace('-', '')` removed only the FIRST hyphen and left spaces intact, so
// an org number entered as "556012 5790" reached Skatteverket with a space in it.
const orgNr = stripOrgNumberFormatting(company.orgNumber)
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<Skatteverket xmlns="http://xmls.skatteverket.se/se/skatteverket/ai/instans/infoForBeskworksgivku/1.0"')
+8 -16
View File
@@ -1,4 +1,5 @@
import type { VatPeriodType } from '@/types'
import { toRedovisare12 } from '@/lib/invariants/org-number'
/**
* Convert a Accounted org_number to Skatteverket's 12-digit "redovisare" format.
@@ -6,27 +7,18 @@ import type { VatPeriodType } from '@/types'
* Rules:
* - Organisationsnummer (10 digits, e.g. 5020000013): prefix with "16" → 165020000013
* - Personnummer (10 digits, e.g. 8501011234): prefix with "19" or "20" based on century
* - Strip any hyphens before processing
* - Separators (hyphens and spaces) are stripped before processing
*
* Delegates to `lib/invariants/org-number.ts` so that this converter, the AGI
* and KU10 generators, and the årsredovisning validator cannot drift apart.
* The previous local implementation stripped hyphens only and threw on any
* input containing a space.
*/
export function formatRedovisare(
orgNumber: string,
entityType: 'enskild_firma' | 'aktiebolag'
): string {
const clean = orgNumber.replace(/-/g, '')
if (clean.length === 12) return clean
if (clean.length !== 10) {
throw new Error(`Ogiltigt organisationsnummer: ${orgNumber} (förväntar 10 eller 12 siffror)`)
}
if (entityType === 'aktiebolag') return `16${clean}`
// Enskild firma: personnummer
const yearDigits = parseInt(clean.substring(0, 2), 10)
const currentTwoDigitYear = new Date().getFullYear() % 100
const prefix = yearDigits > currentTwoDigitYear ? '19' : '20'
return `${prefix}${clean}`
return toRedovisare12(orgNumber, entityType)
}
/**
+7 -7
View File
@@ -72,15 +72,15 @@ export function formatDate(date: Date | string): string {
*
* The shape check (4-digit year) is what stops the native <input type="date">
* 6-digit-year corruption ('202403-02-05'); the parse + range check also
* rejects impossible dates (2024-13-40) and absurd years. Exported as the ONE
* authoritative date rule shared by the client form and the server-side
* rejects impossible dates (2024-13-40) and absurd years. The ONE authoritative
* date rule shared by the client form and the server-side
* CreateTransactionSchema, so the two validation layers can never drift.
*
* Implementation lives in `lib/invariants/iso-date.ts` alongside the other
* shared format contracts; re-exported here because this is where callers have
* always imported it from.
*/
export function isSaneDateString(s: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false
const d = parseISO(s)
return isValid(d) && d.getFullYear() >= 1900 && d.getFullYear() <= 2100
}
export { isSaneDateString } from '@/lib/invariants/iso-date'
/**
* Date + time for audit / metadata displays: `2026-05-11 14:30`. ISO-ordered
@@ -9,6 +9,9 @@
"naiveOreRound": {
"count": 641
},
"handRolledInvariants": {
"count": 114
},
"ledgerScanningReports": {
"count": 4,
"files": [
+68 -1
View File
@@ -51,6 +51,13 @@
* extensionRegistry.get('<id>') so a disabled extension never exposes a
* live surface (allowlisted file-set, may only shrink). Implementation
* and rationale in extension-route-guards.mjs.
* 8. hand-rolled-invariant: a shared format rule (BAS account number, ISO
* date, four-digit fiscal year) spelled out inline instead of imported
* from lib/invariants/. The BAS account rule was written out at 20 sites
* and the ISO date rule at 68, with error messages that differed per site;
* the four Skatteverket-bound org-number paths disagreed outright about
* what "valid" meant, which is the kind of drift a customer only discovers
* when a filing fails at the deadline. Tracked as a count.
*
* Usage:
* node scripts/checks/no-new-antipatterns.mjs # check (CI)
@@ -83,6 +90,25 @@ const RAW_AUTH_RE = /\.auth\.getUser\(/
const GUARD_RE = /requireAuth\(|withRouteContext[<(]/
const NAIVE_ROUND_RE = /Math\.round\([^\n]*\*\s*100\s*\)\s*\/\s*100/
// 8. hand-rolled-invariant. Shared format contracts live in lib/invariants/
// (account number, ISO date, four-digit fiscal year, org number). Before that
// module the BAS account rule was written out at 20 sites and the ISO date rule
// at 68, with error messages that differed per site, and the four
// Skatteverket-bound org-number paths did not agree on what "valid" meant.
//
// Only the two unambiguous regex families are counted. An org-number
// digit-strip is too varied in shape to match reliably by regex; the
// cross-path test in lib/invariants/__tests__/org-number-cross-path.test.ts is
// the guard on that one instead.
const HAND_ROLLED_INVARIANT_RES = [
// /^\d{4}$/ or /^[0-9]{4}$/ → accountNumberSchema or fiscalYearSchema
/\/\^(?:\\d|\[0-9\])\{4\}\$\//,
// /^\d{4}-\d{2}-\d{2}$/ → isoDateSchema or ISO_DATE_RE
/\/\^(?:\\d|\[0-9\])\{4\}-(?:\\d|\[0-9\])\{2\}-(?:\\d|\[0-9\])\{2\}\$\//,
]
// The sanctioned home of these rules: must not count against itself.
const INVARIANT_EXEMPT_PREFIX = 'lib/invariants/'
function walk(dir, exts, out = []) {
let entries
try {
@@ -231,6 +257,31 @@ function countNaiveRound() {
return count
}
/**
* Occurrences of a shared format rule written out by hand instead of imported
* from lib/invariants/. Counted, not file-setted: the campaign lowers the
* number file by file and the count may only go down.
*/
function countHandRolledInvariants() {
const files = [
...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']),
]
let count = 0
for (const f of files) {
const relPath = rel(f)
if (relPath.startsWith(INVARIANT_EXEMPT_PREFIX)) continue
// Tests legitimately spell out the pattern they are asserting about.
if (relPath.includes('__tests__/') || relPath.endsWith('.test.ts')) continue
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
if (HAND_ROLLED_INVARIANT_RES.some((re) => re.test(line))) count++
}
}
return count
}
// Dependencies pinned to an EXACT version on purpose, because a bump broke prod
// and must not silently return via `npm update`, a dependabot bump, or a manual
// install. Any drift (in package.json OR the lockfile) fails CI. See DECISIONS.md.
@@ -563,6 +614,7 @@ function findRawUserErrors() {
const current = {
rawRouteAuth: findRawRouteAuth(),
naiveOreRound: countNaiveRound(),
handRolledInvariants: countHandRolledInvariants(),
ledgerScanningReports: findLedgerScanningReports(),
directJelInsert: findDirectJelInserts(),
pinnedDepViolations: findPinnedDepViolations(),
@@ -579,6 +631,7 @@ if (isUpdate) {
'Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).',
rawRouteAuth: { count: current.rawRouteAuth.length, files: current.rawRouteAuth },
naiveOreRound: { count: current.naiveOreRound },
handRolledInvariants: { count: current.handRolledInvariants },
ledgerScanningReports: {
count: current.ledgerScanningReports.length,
files: current.ledgerScanningReports,
@@ -675,6 +728,20 @@ if (current.sekLabelledAmounts.length) {
)
}
// 1e2. hand-rolled-invariant: counted, may only go down.
if (current.handRolledInvariants > (baseline.handRolledInvariants?.count ?? Infinity)) {
failed = true
console.error(
`\n✗ hand-rolled-invariant: ${current.handRolledInvariants} inline copies of a shared format rule ` +
`(baseline ${baseline.handRolledInvariants?.count}):`,
)
console.error(
' → import the rule instead: accountNumberSchema / isoDateSchema / saneIsoDateSchema /\n' +
' fiscalYearSchema from @/lib/invariants/zod, or the ACCOUNT_NUMBER_RE / ISO_DATE_RE\n' +
' constants from @/lib/invariants. See lib/invariants/README.md.',
)
}
// 1f. cross-extension-import: a physical extension route may only import its
// own extension. No baseline: the count is 0 today, any hit is a hard failure.
if (current.extensionRoutes.crossImports.length) {
@@ -772,5 +839,5 @@ if (failed) {
process.exit(1)
}
console.log(
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
)
+85
View File
@@ -0,0 +1,85 @@
-- Upgrade-path assertions: run AFTER the pull request's migrations have been
-- applied on top of the seeded database.
--
-- Every check raises an exception on failure, so `psql -v ON_ERROR_STOP=1`
-- fails the job. Keep the assertions about invariants that must hold for ANY
-- migration, not about the specifics of one change.
DO $$
DECLARE
v_entries INT;
v_lines INT;
v_debits NUMERIC;
v_credits NUMERIC;
v_ore NUMERIC;
v_company INT;
v_period INT;
v_max_voucher INT;
BEGIN
-- 1. The company, its membership and its fiscal period survived.
SELECT count(*) INTO v_company
FROM public.companies WHERE id = '22222222-2222-2222-2222-222222222222';
IF v_company <> 1 THEN
RAISE EXCEPTION 'upgrade: seeded company disappeared (found %)', v_company;
END IF;
SELECT count(*) INTO v_period
FROM public.fiscal_periods WHERE id = '33333333-3333-3333-3333-333333333333';
IF v_period <> 1 THEN
RAISE EXCEPTION 'upgrade: seeded fiscal period disappeared (found %)', v_period;
END IF;
-- 2. All three posted verifikat survived, still posted. A migration must
-- never silently drop or unpost a posted entry (BFL 5 kap).
SELECT count(*) INTO v_entries
FROM public.journal_entries
WHERE company_id = '22222222-2222-2222-2222-222222222222'
AND status = 'posted';
IF v_entries <> 3 THEN
RAISE EXCEPTION 'upgrade: expected 3 posted entries after migration, found %', v_entries;
END IF;
-- 3. Every line survived.
SELECT count(*) INTO v_lines
FROM public.journal_entry_lines l
JOIN public.journal_entries e ON e.id = l.journal_entry_id
WHERE e.company_id = '22222222-2222-2222-2222-222222222222';
IF v_lines <> 7 THEN
RAISE EXCEPTION 'upgrade: expected 7 journal entry lines after migration, found %', v_lines;
END IF;
-- 4. The ledger still balances. This is the assertion that catches a
-- migration which retypes, rescales or rounds a money column.
SELECT COALESCE(sum(l.debit_amount), 0), COALESCE(sum(l.credit_amount), 0)
INTO v_debits, v_credits
FROM public.journal_entry_lines l
JOIN public.journal_entries e ON e.id = l.journal_entry_id
WHERE e.company_id = '22222222-2222-2222-2222-222222222222';
IF v_debits <> v_credits THEN
RAISE EXCEPTION 'upgrade: ledger no longer balances after migration (debits %, credits %)',
v_debits, v_credits;
END IF;
IF v_debits <> 20623.45 THEN
RAISE EXCEPTION 'upgrade: total debits changed from 20623.45 to %', v_debits;
END IF;
-- 5. The öre survived exactly. Money is NUMERIC and must not drift.
SELECT l.debit_amount INTO v_ore
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = '44444444-4444-4444-4444-444444444403'
AND l.account_number = '6570';
IF v_ore IS DISTINCT FROM 123.45 THEN
RAISE EXCEPTION 'upgrade: öre-level amount drifted from 123.45 to %', v_ore;
END IF;
-- 6. Voucher numbers are intact and still sequential from 1.
SELECT max(voucher_number) INTO v_max_voucher
FROM public.journal_entries
WHERE company_id = '22222222-2222-2222-2222-222222222222';
IF v_max_voucher <> 3 THEN
RAISE EXCEPTION 'upgrade: highest voucher number changed to %, expected 3', v_max_voucher;
END IF;
RAISE NOTICE 'upgrade-path assertions passed: % entries, % lines, balanced at %',
v_entries, v_lines, v_debits;
END $$;
+108
View File
@@ -0,0 +1,108 @@
-- 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;