Commit Graph

8 Commits

Author SHA1 Message Date
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00
Mattsson e113e9c099 fix(vat,documents): EU reverse-charge packs feed ruta 20/21; daily reanchor cron for floating supplier-invoice underlag (#2095)
Two user reports (Anders, 2026-08-25 + 2026-08-29):

1. The seeded standardmallar "Inkop EU-varor/-tjanster, omvand moms 25%"
   booked the cost on 4010/6540, which no momsdeklaration ruta reads, so the
   fiktiv moms filled ruta 30/48 while ruta 20/21 (inkopsvarde) stayed 0;
   Skatteverket rejects that (FK004, ML 13 kap). The packs now book directly
   on the basis accounts 4515/4535 (ACCOUNT_RUTA -> ruta 20/21); the
   transaction-picker path already skips its own basis emission for basis
   debit accounts, so no double counting. Regression test pins every
   reverse-charge pack to a 44xx/45xx business debit. Prod rows update via
   the existing pack sync cron (upsert on pack_slug).

2. A kontantmetod payment verifikat stayed "Underlag saknas" although the
   invoice PDF was attached and eligible on every static condition: the
   inline anchorSupplierInvoiceDocument silently did nothing (prod case
   2026-08-28, verified in audit_log: no document_attachments update between
   the payment booking and the user's manual re-upload). The helper now
   verifies the guarded update actually matched a row instead of claiming
   success on zero rows, logs its silent bail branches, and a new daily cron
   (/api/documents/reanchor/cron) re-runs the anchor for any floating
   retained document with a posted verifikat, replacing the pattern of
   one-off repair migrations (20260727180000, 20260824150000). The sweep
   names the FK in its embed and is idempotent; locked/closed periods are
   skipped as before.


Claude-Session: https://claude.ai/code/session_01Jj6Rg1ViyFRej55gbxLVgj

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:16:07 +02:00
Jakob Wennberg fe1ff8649b fix(bookkeeping): drop non-standard account 2012, book EF F-skatt on 2013 (#1409) (#1489)
Primary-source check against bas.se (BAS 2026 v2): the official kontoplan
has no account 2012; the enskild firma equity block is 2010, 2011, 2013,
2017, 2018, 2019. 2012 'Avräkning för skatter och avgifter' is a program
convention (Visma, Bokio, Björn Lundén), not standard BAS, and a
non-standard account in BAS_REFERENCE leaks via the backfill into charts,
SIE export and SRU filing.

- remove 2012 from class-2-equity-liabilities.ts, with a tombstone comment
- migration retargets 'Preliminär F-skatt (EF)' lines 2012 -> 2013
  (system row plus any clones still carrying the seeded shape)
- pin 2012's absence in bas-ef-equity-accounts.test.ts (2113 precedent)
- correct the swedish-year-end-closing references that motivated #1388,
  regenerate atom seed migration

Companies whose charts already got 2012 backfilled keep it: existing
history stays valid; only future template use books 2013.

Closes #1409

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:04:08 +02:00
Mattsson 3ff12faa77 fix(bookkeeping): reverse-charge VAT in booking templates is 25% of the base, not 20% (#1494)
* fix(bookkeeping): reverse-charge VAT in booking templates is 25% of the base, not 20%

applyTemplate() extracted VAT out of the total (rate/(1+rate)) for every
vat line, including the fiktiv-moms pair of reverse-charge templates.
Under omvand skattskyldighet the supplier charges no VAT, so the total IS
the beskattningsunderlag: on 807.99 kr the seeded EU-purchase template
booked 161.60 kr (20%) on 2614/2645 instead of 202.00 kr (25%),
understating Ruta 30-32 and Ruta 48 on the momsdeklaration.

Fiktiv-moms lines (2614/2624/2634 output, 2615/2625/2635 import,
2645/2647 input) now compute amount x rate on top of the base.
deriveTemplateLinesFromBooking ("Spara som mall") gets the mirror fix:
RC legs no longer inflate the derived total (they net to zero), and RC
rates snap against the base, so a correct RC booking round-trips.

Counterparty/SIE learned patterns already strip RC legs and regenerate
them via generateReverseChargeLines with the gross base; those paths
were correct and are unchanged.

User-reported: "Er automatiska utrakning ar pa 20%, inte 25%".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): use roundOre for template VAT rounding (guard ratchet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(bookkeeping): clarify fiktiv-moms comment: total is the base, booked amount is the VAT

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:59:55 +02:00
Jakob Wennberg a3d8fe733c fix(packs): representation template named itself avdragsgill while booking 6072 (#1396)
Raised by the Swedish compliance bot on #1395. The template called itself
"Representation (avdragsgill, 25% moms)" and labelled its cost line
"Representation avdragsgill", but booked to 6072, which BAS 2026 defines as
Representation, EJ avdragsgill. The swedish-vat skill lists that exact
confusion under Representation errors: "Not separating avdragsgill/ej
avdragsgill on correct accounts (6071 vs 6072)".

The account was right and the words were wrong. Meal representation stopped
being income-tax deductible in 2017, so 6072 is where the cost belongs; only
enklare fortaring (max 60 kr per person) is still deductible, on 6071. Renamed
to "Representation, maltid (ej avdragsgill)" and relabelled to match.

VAT moves from 25% to the 12% restaurang-och-catering rate our own static
representation_external template in booking-templates.ts already used, so the
two template systems stop contradicting each other, with the paired net ratio
1/1.12. A legal_note now carries the 300 kr per person VAT cap, which this
format cannot compute because a template sees only a total and never a
participant count, plus the reminder that the rate follows the underlag rather
than the template.

Correcting the record on one point: the seeded ratio 0.8 was NOT a phantom 80%
deductibility rule, as the bot suggested and as I first repeated. 1/1.25 = 0.8,
so it was the net-of-VAT fraction pairing with the 25% rate, and the arithmetic
balanced. Only the naming and the missing cap were wrong. The ratio is now
commented in the file so the next reader does not make the same misreading.

Slug deliberately unchanged though it still reads "avdragsgill": a slug is an
identifier, not a label (packs/README.md). Renaming it would retire this
template and insert a new one, breaking every company's booking_template_usage
history.

The drift guard caught the rename, which exposed that the test joined packs to
seeded rows by NAME. That is the same fragility that made pack_slug the sync's
key, so INTENTIONAL_DIVERGENCES now records each entry's seededName explicitly.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 22:32:12 +02:00
Jakob Wennberg ff864ad3db feat(packs): sync system templates from packs/ instead of the frozen migration (#1390)
Phase 2b. The packs become the source of truth; the table stays the query
surface, so the existing read path (one RLS query returning system + company +
team templates) and every template id are untouched.

pack_slug is the stable upsert key (migration 20260803230000, backfilled onto
all 26 seeded rows). Matching on name instead would have meant that correcting a
Swedish label looks like a new template, and because
booking_template_usage.template_id is ON DELETE CASCADE, retiring the old row
would silently wipe every company's "recently used" history for it. For the same
reason a removed pack DEACTIVATES its row rather than deleting it: the read path
already filters is_active, so it leaves the picker while usage history survives.

The sync fails closed. A pack that will not parse or validate aborts the whole
run with zero writes, because a database left in a state no commit of the repo
describes is worse than a stale one. An empty catalogue is treated as a broken
deploy (packs/ not bundled) rather than an instruction to retire every system
template.

Runs as a daily cron rather than at boot: boot-time work would have every
serverless instance racing to write the same rows, and would re-apply a bad
catalogue continuously instead of once a day where it is visible. Idempotent, so
a database already matching the packs performs zero writes.

Three database guards, each covered by tests/pg/booking-template-pack-slug.pg.test.ts:
a partial unique index (one pack, one template), a format CHECK mirroring
PACK_SLUG_RE so the database refuses what the loader would, and a CHECK keeping
pack_slug off company templates, where it would shadow the pack it collides with.

Verified the upgrade path locally the way the pg-upgrade job will run it: base
schema, seeded fixture company with posted verifikat, an existing company
template, then this migration alone. 26 slugs backfilled, company template
intact, upgrade assertions pass. This is that job's first real migration.

Payloads are spelled out rather than spread so the phantom-column guard can
check them, and docker/crontab.* are regenerated for the new vercel.json entry.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:55:59 +02:00
Jakob Wennberg 5b9605d8e9 fix(packs): repair the four broken system templates the validator found (#1388)
Phase 2a quarantined four defects rather than guessing at Swedish accounting.
Each is now resolved against a domain source. KNOWN_BROKEN is empty.

Löneutbetalning could never post. It debited 2710 @0.3 + 2920 @0.12 + 7010 @1.0
against a single 1.0 credit, totalling 1.42x the amount, so the balance trigger
would reject every entry built from it. Rebuilt per the swedish-payroll skill:
Debit 7010 gross, Credit 2710 tax, Credit 1930 net. The 2920 semesterlöneskuld
line is gone because vacation accrual is its own verifikat (7290/2920), and a
legal_note now says the 30% split is schablon and must be adjusted to the actual
skatteavdrag.

Periodiseringsfond avsättning/återföring referenced account 2113. Per
swedish-year-end-closing the year-tagged block is 2120-2129 (2126 = tax year
2026), so 2113 was the fund for tax year 2013: long since reversed and absent
from BAS 2026. Both now use 2110 Periodiseringsfonder, which does not rot
annually, with a legal_note pointing at the year-tagged accounts for a company
that tracks funds per year.

Preliminär F-skatt (EF) turned out to be RIGHT, and the reference was wrong.
Account 2012 "Avräkning för skatter och avgifter" was simply missing from
lib/bookkeeping/bas-data (the file jumps 2011 -> 2013), while the
swedish-year-end-closing skill uses it in two places as an enskild firma equity
sub-account. That is not cosmetic: account-backfill.ts only seeds accounts
present in BAS_REFERENCE, so any entry touching 2012 failed with
AccountsNotInChartError. Added it with the equity SRU code its siblings share,
and a description separating it from 1630, which carries a confusingly similar
name on the asset side.

The port test now distinguishes deliberate divergence from accidental drift:
a pack not listed in INTENTIONAL_DIVERGENCES must still match the seeded JSONB
exactly, and a listed pack must actually differ, so neither an unnoticed edit
nor a stale entry can survive.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:46:55 +02:00
Jakob Wennberg df34cae9bf feat(packs): konteringspaket as validated data files (phase 2a) (#1386)
* 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>
2026-08-03 18:05:54 +02:00