df34cae9bf
* feat(packs): konteringspaket as validated data files, ported losslessly The 26 system booking templates lived inside migration 20260413160000. Under the never-modify-a-shipped-migration rule that froze them: correcting a wrong BAS account or a Swedish typo needed a whole new migration, and nothing checked that a seeded account existed in the chart or that a template balanced. #1321 was exactly that failure with seeded chart names. They are now one YAML file per pattern under packs/, with a Zod contract and a CI gate. A correction becomes a one-line edit plus a green run. The port is proven lossless, not asserted. The test fixture was read out of a Postgres with all 548 migrations applied, so it is the exact JSONB production holds; lib/packs/__tests__/port-is-lossless.test.ts asserts the YAML reproduces it by value. Phase 2b can swap the seeded rows for the loader as a no-op. The gate checks what makes a pack CORRECT, not just well-formed, because #1321 was structurally valid and still wrong: every account must exist in BAS 2026, and every pack must balance at five probe amounts through the real applyTemplate() rather than a reimplementation. Account numbers validate through lib/invariants, so a pack cannot disagree with the API or the SIE importer about what an account number is. Doing that immediately found four pre-existing breakages in the shipped templates: loneutbetalning debits total 1.42x the amount against a 1.0 credit: it can never post periodiseringsfond-avsattning-ab account 2113 is not in BAS 2026 and is not periodiseringsfond-aterforing-ab seeded into any company chart preliminar-f-skatt-ef account 2012, same problem These are quarantined in KNOWN_BROKEN, not fixed and not hidden: a quarantined pack's findings are warnings, any NEW finding fails the build, and the validator fails if a quarantined pack turns out to be clean, so the list may only shrink. Each is a Swedish accounting content change to a user-facing template, which deserves its own review rather than riding along inside a file-format change. Five shipped descriptions contain em dashes, preserved verbatim and pinned by a test: a lossless port must not silently rewrite user-visible strings. js-yaml is promoted from a transitive dependency to a declared one (MIT, already in node_modules), so the catalogue does not depend on it by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(deps): regenerate package-lock.json with npm 10 to match CI `npm ci` failed on every job with "Missing: @swc/helpers@0.5.23 from lock file". The lockfile was written by local npm 11.6.0; CI runs npm 10.8.2 on node 20, and npm 11 emits a tree npm 10 reads as out of sync. Regenerated with `npx npm@10 install --package-lock-only`, which cuts the diff from a sprawling rewrite down to the three entries this branch actually adds (js-yaml, @types/js-yaml, and the @swc/helpers entry npm 11 had dropped). Verified with `npx npm@10 ci --dry-run`. This is the documented gotcha for this repo: regenerate lockfiles with npx npm@10, never with a local npm 11. 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>
139 lines
5.0 KiB
TypeScript
139 lines
5.0 KiB
TypeScript
import { z } from 'zod'
|
|
import { accountNumberSchema } from '@/lib/invariants/zod'
|
|
|
|
/**
|
|
* The konteringspaket (pack) contract.
|
|
*
|
|
* ## What a pack is
|
|
*
|
|
* One reusable bookkeeping pattern, as data: the accounts it touches, which
|
|
* side each lands on, and how a total amount is split across them. A pack is
|
|
* pure data forever. It never carries executable code or DDL, which is the
|
|
* lock recorded in `dev_docs/niche_factory.md` and what makes a pack safe to
|
|
* accept from an author who is not us.
|
|
*
|
|
* ## Why the catalogue moved out of a migration
|
|
*
|
|
* The 26 system templates were seeded inside
|
|
* `supabase/migrations/20260413160000_booking_template_library.sql`. Under the
|
|
* never-modify-a-shipped-migration rule that froze them: correcting a wrong BAS
|
|
* account or a Swedish typo needed a whole new migration, and nothing checked
|
|
* that a seeded account existed in the chart or that a template balanced. PR
|
|
* #1321 was exactly that failure with seeded chart names. As data files with a
|
|
* validator, a correction is a one-line edit plus a green CI run.
|
|
*
|
|
* ## The line model
|
|
*
|
|
* `applyTemplate()` in `lib/bookkeeping/template-library.ts` turns a total
|
|
* amount into lines, and the three types are not decorative:
|
|
*
|
|
* - `vat`: amount is `total * vat_rate / (1 + vat_rate)`, so it carries
|
|
* `vat_rate` and never `ratio`.
|
|
* - `business` and `settlement`: amount is `total * ratio`, so they carry
|
|
* `ratio` and never `vat_rate`.
|
|
*
|
|
* That split is enforced below rather than left as a convention, because a
|
|
* `vat` line with a `ratio` silently computes the wrong amount.
|
|
*/
|
|
|
|
/** Categories a pack can be filed under. Mirrors the `booking_template_library.category` CHECK. */
|
|
export const PACK_CATEGORIES = [
|
|
'eu_trade',
|
|
'tax_account',
|
|
'private_transfer',
|
|
'salary',
|
|
'representation',
|
|
'year_end',
|
|
'vat',
|
|
'financial',
|
|
'other',
|
|
] as const
|
|
|
|
/** Which entity types a pack applies to. Mirrors the `entity_type` CHECK. */
|
|
export const PACK_ENTITY_TYPES = ['all', 'enskild_firma', 'aktiebolag'] as const
|
|
|
|
/** Line roles. Drives the amount maths in `applyTemplate()`. */
|
|
export const PACK_LINE_TYPES = ['business', 'vat', 'settlement'] as const
|
|
|
|
/**
|
|
* Slug: lowercase kebab-case. This is the **public lookup key**, used as the
|
|
* filename, in the docs URL, and by the assistant to name a pack. Renaming one
|
|
* breaks every reference, so treat it as an identifier, not a label.
|
|
*/
|
|
export const PACK_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
|
|
export const PackLineSchema = z
|
|
.object({
|
|
// From lib/invariants: the same BAS account rule the API, the MCP surface
|
|
// and the SIE importer use. A pack cannot disagree with the rest of the app
|
|
// about what an account number is.
|
|
account: accountNumberSchema,
|
|
label: z.string().min(1).max(200),
|
|
side: z.enum(['debit', 'credit']),
|
|
type: z.enum(PACK_LINE_TYPES),
|
|
ratio: z.number().min(0).max(10).optional(),
|
|
vat_rate: z.number().min(0).max(1).optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((line, ctx) => {
|
|
if (line.type === 'vat') {
|
|
if (line.vat_rate === undefined) {
|
|
ctx.addIssue({ code: 'custom', message: 'a vat line must carry vat_rate', path: ['vat_rate'] })
|
|
}
|
|
if (line.ratio !== undefined) {
|
|
ctx.addIssue({
|
|
code: 'custom',
|
|
message: 'a vat line must not carry ratio: its amount comes from vat_rate',
|
|
path: ['ratio'],
|
|
})
|
|
}
|
|
return
|
|
}
|
|
if (line.ratio === undefined) {
|
|
ctx.addIssue({ code: 'custom', message: `a ${line.type} line must carry ratio`, path: ['ratio'] })
|
|
}
|
|
if (line.vat_rate !== undefined) {
|
|
ctx.addIssue({
|
|
code: 'custom',
|
|
message: `a ${line.type} line must not carry vat_rate`,
|
|
path: ['vat_rate'],
|
|
})
|
|
}
|
|
})
|
|
|
|
export const PackMetaSchema = z
|
|
.object({
|
|
slug: z.string().regex(PACK_SLUG_RE, 'slug must be lowercase kebab-case'),
|
|
/**
|
|
* Single source of truth for display order, unique across the catalogue.
|
|
* The in-app gallery and the docs site both sort on it, so the two surfaces
|
|
* cannot disagree. Renumber deliberately.
|
|
*/
|
|
order: z.number().int().positive(),
|
|
name: z.string().min(1).max(200),
|
|
description: z.string().max(2000).default(''),
|
|
/**
|
|
* Optional Swedish note on the statutory rule behind the pattern, e.g. the
|
|
* 300 kr per person cap on representation VAT. Rendered next to the pack so
|
|
* a user knows *when* the template applies, not just what it posts.
|
|
* Stays Swedish in both locales: it is statutory content, per
|
|
* `.claude/rules/i18n.md`.
|
|
*/
|
|
legal_note: z.string().max(2000).optional(),
|
|
category: z.enum(PACK_CATEGORIES),
|
|
entity_type: z.enum(PACK_ENTITY_TYPES).default('all'),
|
|
})
|
|
.strict()
|
|
|
|
export const PackSchema = z
|
|
.object({
|
|
meta: PackMetaSchema,
|
|
// Two lines is the minimum that can balance.
|
|
lines: z.array(PackLineSchema).min(2).max(50),
|
|
})
|
|
.strict()
|
|
|
|
export type Pack = z.infer<typeof PackSchema>
|
|
export type PackLine = z.infer<typeof PackLineSchema>
|
|
export type PackMeta = z.infer<typeof PackMetaSchema>
|