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:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
ef237351b6
commit
16fbcefbbc
+20
-17
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user