diff --git a/.github/workflows/test-pg-real.yml b/.github/workflows/test-pg-real.yml index 7e9b78aa..4a2fb211 100644 --- a/.github/workflows/test-pg-real.yml +++ b/.github/workflows/test-pg-real.yml @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index ee20fe31..afc6b919 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -743,3 +743,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index cbf8d7d8..8ee3c8c4 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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), diff --git a/lib/bokslut/ixbrl/__tests__/fixtures.ts b/lib/bokslut/ixbrl/__tests__/fixtures.ts index 1ff09bea..7e9606de 100644 --- a/lib/bokslut/ixbrl/__tests__/fixtures.ts +++ b/lib/bokslut/ixbrl/__tests__/fixtures.ts @@ -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, diff --git a/lib/bokslut/ixbrl/validate/rules.ts b/lib/bokslut/ixbrl/validate/rules.ts index b5cb96fb..c8980dec 100644 --- a/lib/bokslut/ixbrl/validate/rules.ts +++ b/lib/bokslut/ixbrl/validate/rules.ts @@ -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).') diff --git a/lib/company-lookup/normalize-org-number.ts b/lib/company-lookup/normalize-org-number.ts index d6d7e464..48c88a17 100644 --- a/lib/company-lookup/normalize-org-number.ts +++ b/lib/company-lookup/normalize-org-number.ts @@ -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' diff --git a/lib/invariants/README.md b/lib/invariants/README.md new file mode 100644 index 00000000..d3ce4646 --- /dev/null +++ b/lib/invariants/README.md @@ -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. diff --git a/lib/invariants/__tests__/formats.test.ts b/lib/invariants/__tests__/formats.test.ts new file mode 100644 index 00000000..8d71703a --- /dev/null +++ b/lib/invariants/__tests__/formats.test.ts @@ -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 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) + }) +}) diff --git a/lib/invariants/__tests__/org-number-cross-path.test.ts b/lib/invariants/__tests__/org-number-cross-path.test.ts new file mode 100644 index 00000000..b98e444a --- /dev/null +++ b/lib/invariants/__tests__/org-number-cross-path.test.ts @@ -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>/) + // Guard against a vacuous assertion: the element must actually be there. + expect(match, `input form ${form}: no 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>/) + 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') + }) +}) diff --git a/lib/invariants/__tests__/org-number.test.ts b/lib/invariants/__tests__/org-number.test.ts new file mode 100644 index 00000000..ba5799a6 --- /dev/null +++ b/lib/invariants/__tests__/org-number.test.ts @@ -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') + }) +}) diff --git a/lib/invariants/account-number.ts b/lib/invariants/account-number.ts new file mode 100644 index 00000000..7185b459 --- /dev/null +++ b/lib/invariants/account-number.ts @@ -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) +} diff --git a/lib/invariants/fiscal-year.ts b/lib/invariants/fiscal-year.ts new file mode 100644 index 00000000..7a71eba5 --- /dev/null +++ b/lib/invariants/fiscal-year.ts @@ -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 +} diff --git a/lib/invariants/index.ts b/lib/invariants/index.ts new file mode 100644 index 00000000..67807d78 --- /dev/null +++ b/lib/invariants/index.ts @@ -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' diff --git a/lib/invariants/iso-date.ts b/lib/invariants/iso-date.ts new file mode 100644 index 00000000..19a19e91 --- /dev/null +++ b/lib/invariants/iso-date.ts @@ -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 +} diff --git a/lib/invariants/org-number.ts b/lib/invariants/org-number.ts new file mode 100644 index 00000000..f2da068a --- /dev/null +++ b/lib/invariants/org-number.ts @@ -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}` +} diff --git a/lib/invariants/zod.ts b/lib/invariants/zod.ts new file mode 100644 index 00000000..3ad64714 --- /dev/null +++ b/lib/invariants/zod.ts @@ -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, +) diff --git a/lib/salary/agi/xml-generator.ts b/lib/salary/agi/xml-generator.ts index 9bd3a4f3..9b55abb6 100644 --- a/lib/salary/agi/xml-generator.ts +++ b/lib/salary/agi/xml-generator.ts @@ -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') diff --git a/lib/salary/ku/ku10-generator.ts b/lib/salary/ku/ku10-generator.ts index 73f78906..e91805bf 100644 --- a/lib/salary/ku/ku10-generator.ts +++ b/lib/salary/ku/ku10-generator.ts @@ -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('') lines.push(' currentTwoDigitYear ? '19' : '20' - return `${prefix}${clean}` + return toRedovisare12(orgNumber, entityType) } /** diff --git a/lib/utils.ts b/lib/utils.ts index 34ec3e31..a55cd7aa 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -72,15 +72,15 @@ export function formatDate(date: Date | string): string { * * The shape check (4-digit year) is what stops the native * 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 diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 097b81cd..3b1a43f1 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -9,6 +9,9 @@ "naiveOreRound": { "count": 641 }, + "handRolledInvariants": { + "count": 114 + }, "ledgerScanningReports": { "count": 4, "files": [ diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index 630dd962..f550a310 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -51,6 +51,13 @@ * extensionRegistry.get('') 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).`, ) diff --git a/tests/pg/upgrade/assert.sql b/tests/pg/upgrade/assert.sql new file mode 100644 index 00000000..7eafcf99 --- /dev/null +++ b/tests/pg/upgrade/assert.sql @@ -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 $$; diff --git a/tests/pg/upgrade/seed.sql b/tests/pg/upgrade/seed.sql new file mode 100644 index 00000000..b3b481a9 --- /dev/null +++ b/tests/pg/upgrade/seed.sql @@ -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;