Commit Graph

604 Commits

Author SHA1 Message Date
Mattsson 6458180bf5 fix(db): reconcile orphaned mail-hunt migrations applied to prod (#1447)
* fix(db): reconcile orphaned mail-hunt migrations applied to prod

Prod carries 20260807090000_mail_connections and
20260807090100_inbox_mail_hunt_source (applied 2026-08-07 morning) with
no files in supabase/migrations/, so the Supabase integration fails
every push to main with "Remote migration versions not found in local
migrations directory". This commits the byte-identical SQL recovered
from prod's supabase_migrations.schema_migrations statements under the
exact applied versions. No new DDL runs on prod: the versions already
exist there, this only reconciles the repo.

Also classifies mail_connections in full-archive-export.ts (excluded:
live OAuth refresh tokens, not portable), which the archive-completeness
test requires for any new company_id table.

Whoever owns the mail-hunt feature branch: these files now exist on
main; reuse them as-is instead of re-creating the versions.

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

* fix(db): annotate reconciled mail_connections migration as pg-test skip

The file is a byte-recovered reconciliation of DDL already applied to
prod; it never executes again, so pg-real coverage belongs to the
mail-hunt feature branch that owns the schema.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:45:11 +02:00
Mattsson 93f81f03e8 feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED (#1446)
* feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED

Adds WINT (wint.se) as a sixth migration provider, built against the
OpenAPI specs WINT's own API host serves publicly. Tier A scope: only the
partner-facing v1 endpoints are used; the general ledger is fetched as
vouchers/accounts and rendered as SIE 4E by our own sie-builder, with
opening balances for earlier years derived backward from the current-year
Ib anchor. Auth is the user's WINT login exchanged once for a JWT pair;
the password is never stored.

Ships dark: the wizard shows a disabled "Kommer snart" card, and the
server-side /connect gate rejects WINT until WINT_MIGRATION_ENABLED=true.
Live verification against a real WINT account is still outstanding.

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

* fix(providers): harden WINT provider per PR #1446 review findings

Addresses CodeRabbit and Swedish accounting review feedback in one pass:

- Ib anchor selection now uses WINT's unfiltered fiscal-year list, so an
  active year outside the allowed import window can never silently anchor
  the wrong year; the voucher chain is extended through the anchor and a
  per-year fetch failure fails that year loudly instead of sinking the
  whole migration.
- Auth token exchange is strict: only LoginState Success with a complete
  access+refresh pair mints a consent (a pair without a refresh token is
  unrefreshable and would break days later).
- WintApiError no longer retains full response bodies (bounded 300-char
  diagnostic; bodies can carry customer data and errors get logged).
- sie-builder refuses to render structurally invalid vouchers (missing
  account number or booking date) and documents deleted-voucher gaps in a
  #PROSA record per BFL 5 kap 6-7 §.
- Account classification: 20xx is equity, 83xx is financial income.
- SIE validator accepts EUBAS97 as BAS-based (standard kontoplanstyp; it
  previously produced a false non-BAS warning on every WINT/Bollbok file).
- New tests: resolveConsent WINT refresh flow, credential upsert payload
  (no mail/password persisted), WINT fetch failure path, EUBAS97 warning
  regression, builder invalid-data rejection, vi.clearAllMocks hygiene.

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

* fix(import): pin EUBAS97 acceptance to the exact SIE spec value

Review follow-up on PR #1446: match EUBAS97 exactly instead of any
EUBAS* prefix, so the non-BAS kontoplan warning stays pinned to the four
kontoplanstyp values the SIE 4B spec enumerates (BAS95, BAS96, EUBAS97,
NE2007) rather than silently accepting unknown future variants.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:07:14 +02:00
Jakob Wennberg c0a106e591 feat(ux): Bucket A defaults pass: remove choices the system already knows the answer to (#1443)
* feat(booking): batch VAT seeds from category default, period derives from entry date

BatchCategorySelector and BulkBookInboxDialog hardcoded standard_25 as the
initial VAT treatment, overriding the server's per-category derivation and
claiming 25% moms on VAT-exempt bank fees. Both now default to an explicit
'Enligt kategori' option that omits vat_treatment so the server derives it
(exempt bank/card fees, 12% representation). Reverse charge is never derived.

The embedded JournalEntryForm period Select is replaced by the same derived
read-only text the standalone variant already uses: the period is a total
function of the entry date, and the Select allowed picking a period that
disagreed with it.

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

* feat(booking): prefill cost account from counterparty history; period text in Bokfor direkt

BookDirectlyDialog and the supplier-invoice form left the cost account
deliberately blank even when the company's own confirmed history for the
counterparty (categorization_templates) or supplier.default_expense_account
knew the answer. Both now prefill from a counterparty-template hit (new
?counterparty= single-match mode on the settings route, same tiered matcher
as the booking flows), only into still-empty fields, only from expense-shaped
templates, with a provenance line. No generic fallback: a miss leaves the
field blank exactly as before.

Bokfor direkt's period Select is replaced by text derived from the entry
date; the silent periods[0] fallback becomes a blocking explanation, since
borrowing an arbitrary period could book into the wrong one.

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

* feat(ux): single-company login skips the picker; filing surfaces default to filable periods

/select-company auto-forwards when the user is a member of exactly one
company with nothing else to decide (no new TIC engagements, no pending
invite, enrichment fresh); the in-app 'Lagg till foretag' links pass
?choose=1 to keep the picker deliberately reachable. Byra/multi-company
users are untouched.

The VAT declaration now opens on the most recently ENDED month/quarter
(lib/vat/period-defaults, tested) instead of the current one, which can
never be filed and forced a step-back click on every filing visit; the
periodicity switch resets the same way. Helarsmoms FyPicker gains
preferLatestEnded and opens on the latest ended rakenskapsar instead of
the newest started one.

The 'momsperiod saknas' dead end now collects the answer inline through
the same PUT /api/settings validation instead of bouncing to settings:
until the period exists the deadline engine generates zero VAT deadlines,
silently, so every extra hop kept a compliance hole open.

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

* feat(granskning): approve pill commits directly for low and medium risk

The Godkann pill on /pending only opened a ConfirmationDialog demanding a
second Godkann, regardless of tier. The review row already states source,
title and risk and offers Detaljer, so for low/medium the pill now commits
directly; high risk keeps the dialog, whose warning sentence carries
information the row does not. Chat-side bulk approve is deferred: it needs
ApprovalCard's state lifted (assistant-redesign seam 8.8), see DECISIONS.md.

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

* fix(reports): map inline momsperiod save errors through getErrorMessage

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

* fix(review): repair the dead login auto-forward and nine review findings

The big one: setActiveCompany ends with a cookie write that throws during
Server Component render (sealed cookie store), so the /select-company
auto-forward silently never fired; the write is now best-effort since the
cookie is write-only compat and the DB write is already verified.

Also: supplier-switch un-plants history-prefilled accounts so the new
supplier's own default applies; prefill routes through handleAccountChange
so konto default moms rides along; batch 'Ingen moms' books exempt instead
of the derived 25%; monthly VAT default tracks the actual 12th/17th filing
deadline (over-40M stays M-1); inline momsperiod setup uses EmptyState,
gates on vat_number (the PUT would 400 without it), keeps keyboard focus
and announces errors; cost-account shape guard tightened to P&L accounts;
attn tone on the new warning lines.

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

* ci: retrigger workflows; the Actions outage swallowed the rebase push event

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

* ci: retrigger after outage (events dropped, not delayed)

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

* ci: retrigger after GitHub Actions recovery

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

* fix(review): address CodeRabbit and compliance-bot findings

Direct commit now prunes the op from the bulk selection (a stale id kept
inflating the bulk bar and rode into bulk-commit) and the detail-panel
Godkann gets the same risk gate as the row pill. The automatic account
fill in the supplier-invoice form is requested, not applied inline: the
applying effect waits for both the BAS chart and the request with fresh
closures, so a fill can no longer land before the chart and leave a
VAT-free konto on the 25% row default. Test dates use local-time
constructors (ISO strings parse as UTC midnight and shift a day in
negative-offset timezones). Stale ML 11 kap citation dropped from a
comment.

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

* ci: retrigger; push event dropped again

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 10:16:23 +02:00
Jakob Wennberg 63d520719a fix(import): treat a voucher-less SIE file as a no-op, not a failed migration (#1445)
* fix(import): treat a voucher-less SIE file as a no-op, not a failed migration

A Fortnox migration aborted with the generic "Något gick fel. Försök
igen." when the current fiscal year had nothing booked yet: Fortnox
exports an empty SIE file for such a year, the finalizer's 0-entry
safety net flipped it to 'failed', and the wizard stopped before the
customer/supplier/invoice phase ever ran.

Three layered fixes:

- finalizeImportRecord only downgrades a 0-entry run to 'failed' when
  the file actually contained vouchers (parsed count via the
  documentation object). A file with no vouchers completes as a no-op
  with an explanatory warning; the mapping-fix retry loop the downgrade
  exists for (Lookma case) is unchanged.
- The migration wizard no longer routes messages that are already
  user-facing Swedish (server envelopes, ImportResult.errors) through
  getErrorMessage's Swedish-pattern heuristic, which swallowed
  unrecognized sentences into the generic fallback.
- The heuristic itself learns the import-error family
  (verifikation/importera) so other surfaces rethrowing engine
  messages keep the real reason too.

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

* fix(import): cross-check raw #VER before accepting a 0-voucher file as empty

The parsed voucher count alone cannot prove a fiscal year was empty: a
field-separator or encoding mismatch can swallow every #VER block with
only a warning-severity parse issue, and executeSIEImport does not fail
on those. Only a raw content check proves the file never declared any
vouchers. Addresses the truncation/corruption finding from the Swedish
compliance review on #1445.

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

* docs: record the raw #VER safeguard in the empty-SIE-file decision entry

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 09:45:29 +02:00
Mattsson 707d597b2e feat(woocommerce): store order/refund feed extension (#1442)
* feat(woocommerce): store order/refund feed extension

Connect a WooCommerce store via the wc-auth key handshake (manual key
fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest,
and import paid orders and refunds into the transactions inbox as a
bank-style feed on the 1680 cash account. Feed-only: nothing auto-books,
gateway fees/payouts are out of scope (core wc/v3 does not expose them).

Sync is cursor-paginated on modified_after (offset pages only inside
same-second date_modified ties), terminates on an empty page, holds the
cursor below failed refund fetches / ingest errors / deadline-skipped work,
checks the time budget between refund fetches, and drops rows dated on or
before bookkeeping_locked_through on every run. Nightly cron gated on the
extension registry + new paid capability woocommerce_sync (backfilled to
existing bank_sync grant holders).

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

* fix(migrations): move woocommerce migrations past main's 20260806090000

origin/main gained 20260806090000_recurring_schedule_interval_months while
this branch was in flight; identical version timestamps abort the Supabase
apply, so the two new migrations move to 20260806170000/20260806170100.

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

* fix(woocommerce): resolve CodeRabbit review findings

- callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is
  unset: encryptCredential would otherwise throw after the probe and
  strand the pending row without error_message
- disconnect and upstream-revoke clear the encrypted consumer key/secret:
  nothing reads them after revoke and keeping decryptable dead
  credentials is unnecessary retention
- manual sync gets a 240s time budget and the panel reports a truncated
  run as 'partial, sync again' instead of a normal completion
- listOrderRefunds terminates on an empty batch (hosts may cap per_page),
  dedupes by id against hosts that ignore page, and caps total pages
- unparseable money strings count as errors and log instead of being
  silently identical to a zero total
- pg test uses per-run unique store URLs so committed rows cannot hit
  the store_url partial unique index across pg-real runs

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

* fix(woocommerce): resolve CodeRabbit cycle-2 findings

- listOrderRefunds throws when the page cap is exhausted with data still
  flowing, instead of returning a silently partial list the sync cursor
  would advance past; the error routes into the existing held-cursor
  refund-retry path
- partial sync results keep the row-error count, and the partial toast
  string surfaces it (ICU plural, hidden at zero) in both locales

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

* chore: retrigger CI after dropped push event

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:30:00 +02:00
Mattsson cd344b6dbb fix(db): enforce balance check on directly inserted posted journal entries (v2) (#1439)
* fix(db): enforce balance check on directly inserted posted journal entries

check_balance_on_post only fires on the draft-to-posted UPDATE transition,
so any code path that INSERTs a row with status 'posted' directly skipped
balance validation entirely. The invariant sum(debit) = sum(credit) on
every posted entry was DB-enforced only for the engine's commit lifecycle.

Add check_balance_on_posted_insert, a deferred constraint trigger on
AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing
check_journal_entry_balance() function, which already handles the
journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics
let an atomic transaction insert header and lines together; zero-line and
unbalanced posted inserts are rejected at constraint evaluation. All
existing checks stay intact; this only adds coverage.

The one first-party posted-INSERT path outside an RPC, the sandbox seed,
now books through the bookkeeping engine (createJournalEntry) instead of
raw inserts. SIE import already inserts header and lines in a single
transaction via its structured RPC and passes unchanged.

pg tests cover the new path (zero-line rejected, unbalanced rejected at
SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and
existing posted-entry fixtures move to a transactional
insertPostedJournalEntry helper so they stay valid setup.

Fixes #327

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

* fix(tests): insert list-filters pg fixtures in one transaction

The list-filters suite (landed via a sibling merge) inserted posted
headers with getPool().query, where each query autocommits: the deferred
check_balance_on_posted_insert constraint fired at the header's own
commit with zero lines and correctly rejected the fixture. Header and
balanced lines now share one BEGIN/COMMIT so the constraint evaluates
the complete entry, mirroring the insertPostedJournalEntry helper.

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

* fix(seed): insert journal headers as drafts, post after lines land

check_balance_on_posted_insert (renamed to apply-time version
20260806130000) rejects a posted header whose transaction has no lines.
PostgREST autocommits each request, so every seed path that inserted
posted headers first would die with "has zero total": the sandbox seed
(ledger history, invoice vouchers, salary vouchers), seed-demo-account
and seed-export-data. All now insert draft headers, insert lines, then
flip to posted so check_balance_on_post validates the finished
verifikat. The sandbox seed keeps its documented no-events design.

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

* fix(db): preserve a preset committed_at on draft-to-posted transition

set_committed_at() stamped now() unconditionally, so the seed flows that
post backdated drafts lost their historical booking timestamps and every
demo verifikat read as booked today (CodeRabbit finding on PR 1439).
Stamp only when committed_at is NULL: the engine path (drafts carry no
committed_at) behaves exactly as before and a posted entry still always
has a committed_at; an explicitly supplied value now survives posting.

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

* fix(db): preserve preset committed_at only for trusted roles

The IS NULL guard alone (20260806150000, never shipped; replaced by
20260806160000) let any RLS-permitted member backdate committed_at
through PostgREST by presetting it on a draft and posting, which the
Swedish accounting review flagged: committed_at is what the BFL 5 kap
timeliness checks and behandlingshistorik treat as the genuine
transition time. Preset values now survive posting only for
service_role/postgres/supabase_admin; authenticated and anon writers
always get the now() stamp. Consequence: the sandbox seed (runs as the
requesting user) gets committed_at = posting time, accepted and
documented in the route; the demo scripts run as service_role and keep
their backdated history. pg tests cover all four paths, with the upper
timestamp bound CodeRabbit asked for.

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

* fix(db): restore superseded migration so the preview tracker stays consistent

The preview branch had already applied 20260806150000 when the previous
commit deleted the file, orphaning the preview's migration tracker
("Remote migration versions not found in local migrations directory").
Restored with a header explaining it is superseded in the same deploy by
20260806160000, so the unguarded semantics are never live on their own.

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

* fix(db): decide committed_at trust by JWT claims, not current_user

The Swedish review found the current_user guard bypassable:
commit_journal_entry is SECURITY DEFINER and granted to authenticated,
so inside it current_user is the function owner and a member could
preset a backdated committed_at on a direct-inserted draft and launder
it through the RPC. The guard now reads the JWT claims role (same
primitive as the RPC's own tenant guard): preset values survive only
for service_role or claim-less backend connections; authenticated and
anon callers are always stamped now(), on both the direct UPDATE and
the RPC path (new pg test). Both migration files now carry the
identical final body so no unguarded intermediate exists as a
standalone applyable unit. Behandlingshistorik logging of trusted
overrides is follow-up #1444.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:00:05 +02:00
Jakob Wennberg 28b58aedc4 feat(auth): sign in with Google behind NEXT_PUBLIC_GOOGLE_AUTH_ENABLED (#1441)
Adds a 'Continue with Google' button to login and register, gated by
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED so it ships dark until the Google
provider is configured in Supabase. The OAuth round-trip reuses the
existing /auth/callback PKCE exchange, which already owns MFA routing,
invite acceptance and silent-team creation. A flow=oauth marker on the
redirect lets the callback tag failures (including provider consent
denials) so the login page shows Google-specific error copy instead of
the email-confirmation framing.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 21:26:09 +02:00
Jakob Wennberg 0bb0b89353 feat(auth): inline, specific error states on login and signup (#1440)
* feat(auth): inline, specific error states on login and signup

Auth failures now render inline next to the form instead of as a
top-right toast: a persistent alert with role=alert, aria-invalid
field highlighting, and focus returned to the offending field.

Login maps GoTrue error codes (invalid_credentials, email_not_confirmed,
rate limits, user_banned) to specific Swedish/English messages, with a
reset-password link embedded in the credentials error. The credentials
message stays 'wrong email or password' by design: GoTrue returns one
code for both cases to prevent account enumeration.

Signup gets a live password-requirements checklist, field-level errors
for weak/mismatched passwords, and inline handling of email-exists,
invalid-email and rate-limit responses with a sign-in link where that
is the recovery path.

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

* fix(auth): treat email_provider_disabled as signup-disabled with specific copy

Review follow-up: GoTrue signals disabled email/password signups with
email_provider_disabled as well as signup_disabled; classify both (plus
the message-string fallback for older GoTrue) and give the register form
a specific inline message instead of the generic fallback.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 21:24:36 +02:00
Mattsson a0ca692fed feat(invoices): quarterly, half-yearly and yearly recurring invoice schedules (#1438)
* fix(mcp): offer the link tool in the uncategorized-transactions VAT blocker

The gnubok_vat_close_check blocker hint only named categorize/auto-match,
both of which create new bookkeeping. For a transaction whose
affarshandelse is already booked on an existing verifikat, following the
hint would double-book, so agents dead-ended the case into "contact
support" (2026-08-06 support mail from Orto Engineering). The hint now
also names gnubok_link_transaction_to_journal_entry, is extracted as an
exported constant pinned by a test, and the tool joins the
categorize_month recommended loadout.

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

* feat(invoices): quarterly, half-yearly and yearly recurring schedules

User request: recurring invoice schedules only supported monthly cadence.
Adds interval_months (SMALLINT 1-12, default 1) to
recurring_invoice_schedules; the UI offers manadsvis/kvartalsvis/
halvarsvis/arsvis presets while API and MCP accept any 1-12.

The cron advances next_run_date by whole intervals from the due date, and
the new rollNextRunDateForward() helper rolls missed or edited interval
schedules on their own month grid so a quarterly Jan/Apr/Jul/Oct schedule
missed in an outage rolls Jan 15 to Apr 15, never Feb 15. Monthly
(interval 1) keeps its existing today-anchored recompute semantics
unchanged. Changing the interval alone never touches next_run_date: the
new cadence applies from the next run, so an edit can never pull a send
earlier.

Existing rows default to 1 and behave byte-identically. The MCP slice of
this feature (interval_months on the three recurring-schedule tools in
server.ts) was committed in d2600907f alongside the VAT-blocker hint fix
by a parallel session sharing this worktree.

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

* fix(invoices): address PR #1438 review findings

CodeRabbit round 1, all three findings:
- MCP descriptions now state the full accepted interval range (any integer
  1-12) instead of enumerating only the 1/3/6/12 presets, and qualify that
  changing ONLY interval_months leaves next_run_date untouched.
- assertValidCadence rejects fractional day_of_month.
- rollNextRunDateForward rejects calendar-invalid anchors that pass the
  shape regex (2026-13-05, 2026-02-31), with regression tests.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:43:54 +02:00
Jakob Wennberg 43386b4852 feat(agent): offer unmatched inbox receipts as confirmable underlag (#1436)
The originally reported scenario is still broken after #1425 and its
backfill-by-document_id: a user photographs a receipt into WhatsApp, answers
the bot's questions, then opens the app, clicks the bank transaction and asks
the assistant to book it, and is told "UNDERLAG: saknas" about a receipt we
are holding, then asked everything again.

WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id
(process-inbound.ts passes uploadAndExtract's matchedTransactionId as
undefined) nor transactions.document_id (that mirror is written by the manual
match route). Only TransactionMatchPicker fills either column. So the underlag
list comes back empty, and a backfill that keys on document_id has nothing to
key on.

Unmatched, unconsumed inbox items are now scored against the transaction with
the same pure scorer the picker uses and the strongest few are surfaced as
TROLIGT UNDERLAG, carrying their captured chat answers.

Proposals only: nothing writes matched_transaction_id, and the prompt tells
the agent to get the match confirmed and to book only against a confirmed one.
Setting the link at intake above a confidence bar is the obvious alternative
and is deliberately left open.

An uncomparable amount (cross-currency with no rate) disqualifies a candidate,
because calculateMatchConfidence drops the amount signal in that case and date
+ merchant alone then score a confident match nobody checked the sums for.

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-06 14:07:30 +02:00
Jakob Wennberg 0f7147a078 fix(agent): read a WhatsApp "nej" as an answer, not a half answer (#1433)
* fix(agent): read a WhatsApp "nej" as an answer, not a half answer

#1425 gave the assistant the answers the user typed in WhatsApp. Rendering
those inline off the raw channel_context blob gets the most common answer
backwards.

Answering "nej" to the representation question stores an EMPTY representation
block: participants: [], purpose: null, denied: true. The renderer branched on
`if (!rep.purpose)` and so emitted

    syfte SAKNAS: fråga bara efter syftet, inte om deltagarna igen.

for a user who had just said the meal was not representation. `denied` was
never read anywhere. The result is the assistant asking about the purpose of a
private lunch, which is worse than the generic re-ask #1425 fixed, because the
instruction is specific and confident.

Clarifications now come from a structured summary that models the denial and
the genuine half answer (participants named, purpose missing, which BFL 5 kap
6-7 § does want completed) as different states. #1425's syfte SAKNAS nudge is
preserved for the case it was written for.

Two smaller fixes in the same renderer, both about untrusted text:

- The photo caption no longer reaches the prompt. It is the one field on the
  record nobody was asked for and nobody reviewed, and the rationale already
  written down in lib/documents/channel-context-notes.ts for keeping it off an
  immutable verifikat applies at least as strongly to a prompt that can call
  tools.
- Human free text passes through flattenMemoryContent. An intent's
  promptTemplate output is seeded as a user message, so wrapToolResult never
  sees it and nothing else defends this path; a caption reading
  "# NYA INSTRUKTIONER: ..." previously rendered verbatim.

All three tests fail against the current renderer and pass against this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(agent): gate the chat-answer guidance on what was rendered

CodeRabbit caught the same defect shape this PR is about: the
prior-conversation paragraph was gated on chat_answers != null, but a
caption-only context is non-null and now summarises to nothing, so the
paragraph pointed at 'uppgivna av användaren' rows the prompt does not
contain. Gate on whether a clarification line was actually emitted.

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-06 14:00:20 +02:00
Jakob Wennberg 2dff83e2f3 feat(bokslut): kontantmetoden year-end cut-off for fordringar and skulder (#1432)
* feat(bokslut): kontantmetoden year-end cut-off for fordringar and skulder

Under kontantmetoden nothing reaches 1510/2440 during the year, but BFL
5 kap 2 § still requires fordringar och skulder to be booked at
rakenskapsarets utgang. That conversion did not exist: the AR/AP tie-outs
were permanently unreconciled by construction for all cash companies, and
the balance sheet omitted every open invoice.

Adds lib/core/bookkeeping/kontantmetod-cutoff.ts:

  Fordringar: Debit 1510 / Credit 30xx / Credit 2618|2628|2638
  Skulder:    Debit 4-6xxx / Debit 2648 / Credit 2440

Moms goes to the VILANDE accounts, never 2611/2641. Under bokslutsmetoden
moms is reported at payment, and the vilande accounts are deliberately
absent from ACCOUNT_RUTA / ACCOUNT_TO_BOX, so parking it there keeps it out
of the momsdeklaration until the invoice is actually paid. Booking it to
2641 would claim the deduction a period early.

Two aggregate verifikat, each reversed on day 1 of the next period, and no
invoices.journal_entry_id link: the payment flows route on that link, so
per-invoice linking would send every new-year payment down the accrual
clearing path against a receivable the vandning already removed. Leaving it
unset means a new-year payment still books the normal kontantmetoden cash
entry at the real payment date.

Outstanding is computed from payment DATES, not remaining_amount: an
invoice settled in January was still a fordran on 31 December, and reading
remaining_amount would shrink the cut-off every day the bokslut is delayed.

Surfaced as a bokslut wizard reminder (warning, not a blocker: promoting it
would newly block every cash company mid-bokslut, which is a separate call).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance review on the kontantmetoden cut-off

Three findings from the Swedish compliance review, all real:

1. BFL 5 kap 6-7 § traceability. The aggregate verifikat collected invoice
   references but never wrote them, so an examiner could not trace the
   1510/2440 posting back to the affarshandelser behind it. Invoice numbers
   now go into the entry `notes` via buildCutoffNote(), truncated past 50 so
   the note stays a pointer to the reskontra rather than a copy of it.

2. Non-atomic posting. The cut-off and its vandning were two sequential
   creates with no rollback: if the reversal threw, 1510/2440 stayed
   permanently inflated and every new-year payment would double-book, which
   is exactly what the module docstring warns about.

   postKontantmetodCutoff now asserts the target period exists, is open, and
   contains the reversal date BEFORE posting anything, so the common failures
   refuse without writing. If a reversal still fails after its cut-off
   committed, the cut-off is stornoed through reverseEntry() (BFL 5 kap 5 §:
   never edit or delete a posted entry) and the original error is rethrown.

3. Silent vat_treatment default. Missing vat_treatment fell back to 25 %,
   which would route a 12/6/undantagen invoice to the wrong vilande account
   AND the wrong revenue account. Such rows are now collected into
   CutoffCollection.unknownVatTreatment, excluded from the cut-off, refused
   by the posting step, and surfaced as their own wizard reminder.

Adds 11 cases for postKontantmetodCutoff, which had none: every refusal path
asserts nothing was posted, and the storno-compensation path is covered in
both the happy and the storno-also-failed direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bokslut): never split a reverse charge across the cut-off

Second compliance round. Verified the three data-dependent findings against
production before changing anything; two needed no change, one is hardened:

- Credit notes are NOT silently dropped: all 22 credit notes on prod carry
  document_type='invoice', so they are inside the collected set exactly as
  the comment claims. The filter only excludes proforma and delivery_note.
- Vilande account numbers verified against the BAS 2026 chart in
  lib/bookkeeping/bas-data: 2618/2628/2638 utgaende, 2648 ingaende. The
  suggested 2617/2627/2637 do not exist.
- Reverse charge: all 123 RC supplier invoices on prod carry vat_amount = 0,
  so no RC moms could reach 2648 today. That was an implicit data invariant,
  not an enforced one. CutoffPayable now carries reverseCharge and forces the
  cut-off moms to 0 for those rows, so a stray amount can never post a
  one-sided reverse charge into the single vilande bucket. The self-assessed
  output/input pair stays with the payment entry, after the vandning.

Also names the reskontra as the underlag in a truncated aggregate note, so
the verifikat points at its specification rather than implying the listed
subset is the whole of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bokslut): surface stray moms on momsfri invoices instead of absorbing it

Third compliance round, one legitimate new finding: moms on a treatment that
cannot carry Swedish output moms (export, omvand betalningsskyldighet,
undantagen) was folded into the revenue line with only a log.warn. That
balances the verifikat while silently swallowing a real invoicing error,
which is the netting the swedish-vat reference prohibits, and it was
inconsistent with how the same module already treats a missing
vat_treatment.

Those rows now travel the same path as a missing treatment: collected into
CutoffCollection.strayVatOnZeroRate, excluded from the cut-off, refused by
the posting step, and surfaced as their own wizard reminder.

buildCutoffLines keeps its balancing fallback for the case where such a row
reaches it directly: it is now a last resort rather than the normal path,
and it must still never invent a moms account nor unbalance the verifikat.

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-06 12:12:05 +02:00
Jakob Wennberg d73f288927 fix(supplier-invoices): reverse credit notes on paid kontantmetoden invoices (#1430)
Under kontantmetoden the credit flow skipped the reversing verifikat
entirely, gated on accounting_method === 'accrual' in all three surfaces
(dashboard route, v1 route, pending-operations commit).

That is right only while the original is still UNPAID: nothing reached the
ledger, so there is no entry to reverse and recognition waits for cash. But
a PAID original was already booked by its payment verifikat (expense +
2641 ingaende moms). Crediting it marked the invoice 'credited' with zero
accounting trace, leaving both the cost and the moms deduction overstated
and nothing to link a later refund back to.

Adds supplierCreditNoteNeedsJournalEntry(), the mirror of the customer
side's creditNoteNeedsJournalEntry(): reverse whenever the original
actually reached the ledger, whatever the configured method.
createSupplierCreditNoteEntry's existing shape already suits the cash case,
the 2440 debit leaves a claim on the supplier that the refund clears, just
as the customer side leaves a 1510 credit for a refund owed.

The v1 route's GDPR-minimised projection dropped exactly the booked-ness
columns this needs; they are restored with a comment explaining why, since
status alone misses a part-paid-but-booked original (rows predating #1413).

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-06 12:06:00 +02:00
Jakob Wennberg 5b0ca3d874 fix(copy): make K3 and year-end claims match what the code actually does (#1431)
* fix(copy): make K3, leasing and year-end claims match what the code does

Follow-up to the batch that removed the uppskjuten-skatt posting on
obeskattade reserver (K3 29.37 gross in juridisk person) and added the K2
asset-account gate. Six user-facing strings still described the old
behaviour or made claims the code cannot support.

1. Arsredovisning page: the K3 explainer promised an uppskjuten skatt-not
   and a materiella anlaggningstillgangar-not in every K3 document. Both
   are conditional (a 2240/8940 balance, assets in the register) and the
   first is now absent in the normal case. The kassaflodesanalys is
   dropped with a warning when it cannot be generated, so it is named
   only when the document actually carries one.

2. Regelverk settings: kassaflodesanalys was presented as following from
   K3. It follows from being ett storre foretag
   (swedish-year-end-closing/references/reporting-and-filing.md:10,
   legal-framework.md:42); the copy now says the product includes one and
   states the storre-foretag rule separately. Komponentavskrivning was
   presented as optional under K3; it is mandatory where component useful
   lives differ materially (k2-vs-k3.md:5, asset-accounting
   references/depreciation.md:33).

3. Note 1 and the Uppskjutna skatter-not no longer claim the 2240 balance
   is hanforlig till obeskattade reserver. deriveLatentTaxMovement reads
   the 2240/8940 balances only, and under K3 that account carries deferred
   tax on all temporary differences (k2-vs-k3.md:11-13).

4. The deferredTax 'unknown' branch emitted the gross-reserve statement,
   which is the denial phrased positively: the same affirmative claim
   about books that could not be read. It now emits no deferred-tax
   paragraph at all; build-data already warns on that path.

5. Capitalized-lease detection looked at 1260/1269 only. On the shipped
   BAS 2026 chart 1260 is a free inventarier account and 1269 is ack.
   avskrivningar pa datorer, so owned computers were reported as leased,
   while 1217/1227 (finansiellt leasade) were missed. Detection now reads
   the company's own account names in kontogrupp 12, which is where BAS
   keeps capitalized leases (leasing-and-disposal.md:28) and which owned
   inventarier on 1220 never matches. 1720 forutbetalda leasingavgifter
   stays out: that is the operational treatment.

6a. gnubok_year_end_readiness listed FX revaluation as a blocker (it is a
   warning) and omitted UNBOOKED_TRANSACTIONS, the common one. The
   description now names every actionable blocker kind, within the
   280-char budget, and a test pins it against YEAR_END_BLOCKER_KIND.

6b. companies.accounting_framework defaults to 'k2', so every enskild
   firma hit the K2 asset gate and was handed a BFNAR 2016:10 punkt 10.4
   citation plus a K3 remedy it cannot take: a sole trader prepares ett
   forenklat arsbokslut, not an arsredovisning (legal-framework.md:29,
   :48). entity_type now rides along on the companies read the routes
   already do, and non-AB entities get wording with no citation and no
   K3, keeping the 1090 remedy. The K1 counterpart of punkt 10.4 is not
   sourced in the repo skills, so nothing was invented in its place.

* fix(copy): close the review findings on the copy-truth sweep

Three follow-ups from the source and code reviews. (1) The K2/K3 help text had upgraded a vague sentence into a definite boundary claim ('gransen gar vid <trosklar>'), which excludes the other routes into mandatory K3 that are live right now for this control's audience: noterade vardepapper, and from fiscal years starting after 2025-12-31 also utlandsk filial, kryptotillgangar, aktierelaterade ersattningar and fastighetsbolag. An AB in one of those categories would have read the sentence and stayed on a regelverk it may no longer use. (2) hasCapitalizedLeaseAsset compared per-side cumulative totals, so a lease acquired earlier and disposed this year still claimed the balance sheet carries a leased asset; it now compares the net balance. (3) The K3 warning enumerated a kassaflodesanalys the document may not contain, contradicting the newly conditional page copy on the same screen.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:54:57 +02:00
Mattsson a5c10e441a chore(analytics): configure posthog session replay masking (#1428)
* chore(analytics): configure posthog session replay masking

Move session replay from the mask-everything default to pattern-based
masking in lib/analytics/replay-masking.ts: currency-shaped text,
person-/organisationsnummer (rendered and typed) and password inputs are
masked; other interface text and typed input is recorded for debugging.
data-ph-mask keeps force-masking tagged PII and data-ph-unmask is still
honored for chrome. Privacy policy, RoPA and decision log updated to
match.

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

* chore(analytics): address review comments on replay masking PR

Bump the privacy policy's visible last-updated date to 2026-08-06 and
add the conventional vi.clearAllMocks() beforeEach to the replay-masking
tests.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:40:54 +02:00
Jakob Wennberg ab712f829a fix(arsredovisning): truthful K3 policy note and GRANSKNINGSUTKAST watermark (#1423)
* fix(arsredovisning): truthful K3 policy note and GRANSKNINGSUTKAST watermark

The K3 redovisningsprinciper note asserted policies the engine does not
follow: full balansrakningsmetoden deferred tax and finance lease
capitalization. The note now states actual practice: deferred tax only on
obeskattade reserver at 20,6 percent, all leases expensed as operational.

Every K3 report is classified AR-K3-DRAFT-ONLY, so the K3 PDF now carries
a fixed rotated GRANSKNINGSUTKAST watermark on every page, and the
arsredovisning studio shows an always visible AttnLine for K3 explaining
that the document cannot be locked or filed via Accounted yet.

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

* fix(arsredovisning): stop the two K3 deferred-tax notes contradicting each other

The redovisningsprinciper note said deferred tax on obeskattade reserver is
not separately recognised in juridisk person, while the "Uppskjutna skatter"
note right after it disclosed exactly that split plus the 8940 movement. A
signed arsredovisning cannot say both.

Both notes are now driven by one derivation (deriveLatentTaxMovement in
build-data.ts, computed before note 1 is built): with no 2240/8940 activity
the policy paragraph states the gross treatment K3 29.37 permits and no
movement note is emitted; with a 2240 balance the paragraph discloses the
recognised liability and points at the movement note. Neither text claims a
measurement rate or an origin the builder cannot verify, since a balance can
come from the K3 bokslutsdisposition, a legacy posting or an SIE import
booked at 22 or 21,4 percent.

The leasing paragraph now states the basis for its blanket treatment: the
document is always a single juridisk person with no koncernredovisning, so
the K3 punkt 20.29 exemption applies. Without that qualifier the paragraph
asserted a treatment that is not the general K3 rule.

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

* fix(arsredovisning): never assert a K3 policy the books can contradict

Three cases where the principles note still made claims it could not back: a read failure on the 2240/8940 figures printed the affirmative denial anyway, the leasing paragraph asserted a fact about the entity's group obligations that a Koncernforhallanden note in the same document can contradict, and the blanket operational-lease claim survived even when the balance sheet carries leased assets on 1260/1269. Deferred tax becomes a tri-state (none/recognized/unknown) and leasing is driven by the same trial balance.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:05:33 +02:00
Jakob Wennberg 12c59399ee fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) (#1421)
* fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) and confirm K3 to K2 reversion

In juridisk person K3 29.37 keeps obeskattade reserver at gross; the 79.4/20.6 split belongs to koncernredovisning. The old disposition double-counted the tax portion (result charged twice, 2240 overstated on top of gross 21xx). Removes the proposal step, POST kind, UI case, K2-to-K3 account seeding and the interim framework gate; keeps LATENT_TAX_DEFAULT_RATE for analytical soliditet presentation. Also adds the K3-to-K2 consequence confirmation dialog in settings.

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

* docs(decisions): scope the batch log to shipped code and record the 29.37 election nuance

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

* fix(settings): stop promising deferred-tax accounting the engine no longer does

The framework help text and the K2-to-K3 confirmation both told the user that switching to K3 means uppskjuten skatt is recognised separately on 2240/8940 with a 79.4/20.6 split. This PR removes exactly that behaviour, so the copy would have promised something the product does not do, which is the defect class this batch exists to remove. Both now describe what actually happens: kassaflodesanalys, komponentavskrivning and a wider note set, with obeskattade reserver carried gross per K3 29.37.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:05:15 +02:00
Jakob Wennberg 8f38baca05 fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults (#1422)
* fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults

K2 companies (BFNAR 2016:10 punkt 10.4) may not capitalize internally
developed intangibles, but the asset register defaulted the immaterial
category onto 1010/1019 (Utvecklingsutgifter) for everyone and had no
framework gate beyond K3_REQUIRED_FOR_COMPONENTS.

- New K2_EXCLUDED_ACCOUNT gate (422) in POST /api/assets and PATCH
  /api/assets/[id]: when accounting_framework is not k3, reject any asset
  whose resolved asset or accumulated account is flagged k2_excluded in
  the BAS reference. Resolution mirrors the service defaults so category
  defaults cannot sneak onto 1010/1019; patches that leave category and
  accounts untouched skip the gate so legacy assets stay editable.
- Shared guard helper in lib/bokslut/assets/k2-account-guard.ts; code
  registered in structured-errors.ts with Swedish and English messages.
- CreateAssetDialog: non K3 companies now book immaterial assets on the
  purchased pair 1090/1099 with a quiet hint that egenupparbetad
  utveckling requires K3; K3 companies picking immaterial see a note
  about fond for utvecklingsutgifter (2089) per ARL 4 kap. 2 par.
- Route tests: K2 rejected on 1010 defaults and explicit overrides, K2
  accepted on purchased accounts, K3 accepted on 1010, PATCH equivalents
  and a gate skip regression test.

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

* fix(assets): cite punkt 10.4 only when the intangible group triggered the K2 gate

The K2 gate fires on ANY account the BAS chart flags k2_excluded, but the
rejection hardcoded an egenupparbetade immateriella / BFNAR 2016:10 punkt 10.4
citation. The flag also covers accounts excluded from K2 for unrelated reasons
(1370/2240/8940 uppskjuten skatt, 1518, 2089, 2092, 2096, 2448, 3940, 7940,
8290 to 8480), so those users got a factually wrong legal citation in a
compliance product. PATCH can reach them today: UpdateAssetSchema has no BAS
range refinement, so an explicit bas_asset_account override outside the
category range hits the gate before updateAsset() raises its range error.

- k2ExcludedAccountMessages() now picks the wording from what actually
  triggered the gate. The boundary is derived from the chart itself
  (k2_excluded + account_class 1 + kontogrupp 10), which is exactly the
  egenupparbetade set 1010, 1011, 1012, 1018, 1019, 1081; no magic list, so a
  flag change in bas-data moves the boundary with it. Other Ej K2 accounts get
  a generic message: the chart marks it Ej K2 and it requires K3, with no
  invented paragraph reference.
- Both messages are bilingual (message_sv / message_en, registry shape) and
  the routes now return message_en alongside message.
- The static K2_EXCLUDED_ACCOUNT registry entry drops the intangible citation
  too: it is the code level fallback for every k2_excluded account.
- Tests: route level distinction pinned in id.test.ts (1010/1081 cite 10.4,
  1370 must not), plus a guard unit test asserting the derived group and that
  no non group 10 Ej K2 account ever cites 10.4.

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

* fix(assets): let K2 companies register acquired intangibles, server side

The K2 gate blocked a lawful case. K2 forbids only EGENUPPARBETADE
immateriella tillgangar; acquired ones may be recognized (k2-vs-k3.md:24,
"Only acquired intangibles may be recognized"). But asset-service still
resolved category 'immaterial' to 1010/1019 for everyone, and only
CreateAssetDialog compensated with an explicit 1090/1099 override.
EditAssetDialog sends just the changed fields and has no account inputs, so a
K2 aktiebolag recategorizing a bought licence to "Immateriell tillgang" hit
the defaults, got a 422, and was told to switch the company to K3, which
would pull in komponentavskrivning and uppskjuten skatt and rewrite the whole
arsredovisning. The asset stayed on 1220/1229 and kept being presented as a
tangible asset.

- defaultAccountsForCategory(category, framework) is the single resolution
  point: immaterial resolves to the acquired pair 1090/1099 unless the
  framework is k3, every other category is unchanged. Both createAsset() and
  updateAsset()'s category realign go through resolveDefaultAccounts(), which
  reads companies.accounting_framework only for the intangible category and
  throws rather than guessing when that read fails. Explicit overrides and the
  realign-skip semantics are untouched.
- Both routes resolve gate accounts through the same function, so the check
  mirrors what the service will persist. A K2 company on the defaults now
  passes; a deliberate override onto 1010/1011/1012/1018/1019/1081 still 422s.
- CreateAssetDialog drops its now redundant client override so the two
  surfaces cannot drift; the hint text stays.
- The 422 no longer asserts the company's framework (the companies read
  behind it discards its error, so a transient failure would assert it against
  a K3 company) and no longer proposes a regelverk change. It states that the
  account is reserved for egenupparbetade utvecklingsutgifter, which require
  K3, and points at 1090 for an acquired intangible. Punkt 10.4 stays scoped
  to the kontogrupp 10 group, derived from the chart as before. sv and en.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:04:58 +02:00
Jakob Wennberg 78a37f6396 fix(year-end): typed preflight blocker codes so remediation links render (#1420)
* fix(year-end): typed preflight blocker codes so remediation links render

validateYearEndReadiness emits Swedish blocker strings but the wizard's
BlockerRow matched English phrases, so no remediation link ever rendered,
and the voucher-gap branch pointed at /bookkeeping/voucher-gaps which only
exists as an API route. Blockers now carry stable machine codes end to end
(YearEndBlockerCode on YearEndValidation.blockers, mirrored additively as
blockerItems on BokslutReadinessReport); errors stays the plain string
mirror so the v1 compliance check and MCP tool keep their exact shapes.
BlockerRow matches on code and links only to pages that exist; the
voucher-gap and dead-link branches are removed.

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

* fix(year-end): code the unbooked-transaction blockers #1414 added

#1414 landed two new blockers in validateYearEndReadiness using the old
errors.push style, which this branch had already renamed to a typed
blockers array. Merging main left them referencing a variable that no
longer exists.

Converted both to the typed scheme: UNBOOKED_TRANSACTIONS (the safety
guard that stops executeYearEndClosing from aborting at the step 7 lock
AFTER the closing entry posted at step 4) and UNBOOKED_CHECK_FAILED (the
fail-closed variant). Neither behaviour changes; both keep their Swedish
wording verbatim.

The MCP year_end_readiness classifier now routes on the stable
YearEndBlockerCode instead of regexing the Swedish message, with the
wording heuristic kept as a fallback for an unmapped or legacy English
message. The public `kind` values are unchanged, so MCP consumers see the
same output; both new codes map to 'unbooked_transactions' as before,
since an agent reacts to "we could not tell" the same way it reacts to a
real count.

UNBOOKED_TRANSACTIONS gets a /transactions remediation link in the
preflight step: that page is where a transaction is booked or marked
private, the two remedies the message names. UNBOOKED_CHECK_FAILED gets
none: the remedy is to re-run the check.

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:04:42 +02:00
Jakob Wennberg 31fba5e8f9 fix(accruals): carry origin dimensions onto dissolution entries (#1419)
* fix(accruals): carry origin dimensions onto dissolution entries

A project-tagged deferred invoice line lost its dimensions bag on every
monthly dissolution: the schedule never stored the bag, so dissolution
lines booked untagged and the per-project P&L drifted from the origin.

Persist the merged bag (invoice default_dimensions with the item bag on
top, same merge the origin generators use) on accrual_schedules and
attach it to BOTH dissolution lines: both origin generators tag the
interim 17xx/29xx line too, so per-dimension views of the interim
account keep netting to zero. Pre-existing schedules stay at '{}' and
keep today's untagged behavior; backfill is a separate follow-up.

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

* fix(accruals): let dissolutions post when the tagged dimension value is archived

Carrying the origin's dimensions bag onto dissolution lines pulled every
monthly periodisering into validateEntryDimensions. With dimensions_enabled,
a project archived after the origin invoice was booked made the validator
reject the dissolution: the accrual service caught the rejection, wrote
last_error and left the installment pending forever. The remaining months of
prepaid cost would never reach 5xxx/6xxx, the interim 17xx/29xx account would
stay overstated, and the trial balance still balances, so no year-end check
fires and the arsredovisning is filed with understated cost.

Add a second, narrow exempt set (DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES =
{'accrual'}) with an isDimensionValidationExemptSource() helper, and skip the
soft registry validation for it in createDraftEntry. A dissolution is the
mechanical continuation of an already-approved, already-posted decision: the
same category as a storno, which the engine already bypasses.

The tag is kept, never stripped: an archived value still exists in the
registry and the cost genuinely belongs to that project. The rules set
(required/default/fixed) stays untouched, and the other two
validateEntryDimensions call sites keep validating: updateDraftEntry is a
user edit of an editable draft, and replaceOpeningBalanceEntry rejects any
source_type other than opening_balance.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:04:20 +02:00
Jakob Wennberg d7320a6c87 feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices (#1426)
* feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices

A kassakvitto has no due date, no OCR reference, no invoice number and no
bankgiro: it is already paid. The rail rendered all four as empty boxes
anyway, so a perfectly extracted receipt looked like a failed extraction.
The date was mislabelled too: on a receipt the document date is the
purchase date, not a "Fakturadatum".

When extracted_data.documentKind is 'receipt' the rail now labels the date
"Inköpsdatum" and folds the five invoice-only fields behind a quiet "Visa
fakturafält (N)" link.

Two rules keep it safe when the classification is wrong: a field holding a
value is never hidden (a hybrid restaurangnota with an invoice number
still shows it), and the fold is one click from being undone.

Logic lives in lib/documents/inbox-field-visibility.ts rather than the
component: this repo has no jsdom or testing-library, so display rules are
only testable as a React-free module.

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

* fix(invoice-inbox): reset the invoice-field fold when switching documents

Review catch (PR Agent on #1426): showAllFields was component state with no
per-item reset, and EditableFieldsList stays mounted across selections in
the rail. Expanding "Visa fakturafält" on one receipt therefore left the
invoice-only fields open on the next document, which reads as if that one
carried them too.

Reset alongside drafts and edit provenance in the existing itemId effect,
and moved the declaration up next to the other state so it is defined
above its first use rather than relying on hoisting.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:26:16 +02:00
Jakob Wennberg 576ed4d290 fix(agent): let the assistant see answers the user gave in WhatsApp (#1425)
Two field findings from the first live receipts.

1. The assistant re-asked for information the user had already given.
   The user answered the representation question in WhatsApp ("Elias
   Karlsson från Canguro Media, Jakob Wennberg från Arcim"), the answer
   was stored correctly on invoice_inbox_items.channel_context, and then
   the in-app assistant said it could see no participant names and asked
   for them again. The intent's inbox query selected only document_id and
   extracted_data, and nothing under lib/agent/ read channel_context at
   all. It is now selected, threaded onto each underlag as chat_answers,
   and rendered into the prompt as "uppgivna av användaren" with an
   explicit instruction that human answers outrank anything read off the
   image and must never be re-asked. Also backfilled by document_id: a
   receipt can reach the intent through the document paths without its
   inbox row being matched to the transaction.

2. The representation question accepted half an answer in silence.
   Naming participants but no purpose stored purpose=null and replied
   "Tack!", leaving the deduction undocumented while looking complete.
   Skatteverket wants both (BFL 5 kap 6-7 §). It now asks once, for the
   missing half only, and keeps the question open so the reply routes
   back to the same receipt. Anti-loop: the follow-up fires only when no
   representation block exists yet, so a second incomplete answer is
   taken as-is rather than nagging.

Tests cover the prompt half and the query half separately: the earlier
prompt tests injected chat_answers directly and would have stayed green
with the column still missing from the select, which is precisely how the
bug shipped. Both mutation-checked.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:08:15 +02:00
Jakob Wennberg c0825e9bd2 fix(bokslut): surface unbooked transactions and AR/AP tie-outs in year-end preflight (#1414)
* fix(bokslut): surface unbooked transactions and AR/AP tie-outs in year-end preflight

Two gaps in the year-end readiness layer:

1. Unbooked bank transactions were enforced only by lockPeriod, which runs
   at step 7 of executeYearEndClosing, AFTER the closing entry has posted at
   step 4. A period with unbooked transactions reported ready: true from
   gnubok_year_end_readiness and the wizard, then aborted mid-flow, leaving
   a posted closing entry on an unlocked, unclosed period. The readiness
   check now runs the same counter as the lock guard
   (countUnbookedInPeriod, so the number reconciles with the "att bokföra"
   badge) as a blocking error, failing closed if the check cannot run. The
   lockPeriod guard stays as defense in depth. The MCP classifier tags the
   new blocker as kind unbooked_transactions.

2. The Phase-1 avstamningar (kundreskontra vs 1510, leverantörsreskontra vs
   2440) existed as reports (lib/reports/ar-reconciliation.ts,
   supplier-reconciliation.ts) but were wired only to the ledger report
   routes, never to the bokslut preflight. The readiness aggregator now runs
   both tie-outs and surfaces mismatches as warning-severity reminders with
   deep links, mirroring the bank-reconciliation reminder. Warnings only,
   never blockers: a difference can be legitimate (FX-settled partials).
   Skipped entirely for kontantmetod companies, where open invoices are
   deliberately not on 1510/2440 until the year-end conversion exists and
   the tie-out is permanently unreconciled by construction. Unconvertible-FX
   rows produce a "could not reconcile" message instead of a phantom
   difference.

YearEndValidation gains an optional unbookedTransactionCount field; the v1
compliance endpoint and MCP readiness tool pick the new blocker up
automatically since they share the same engine.

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

* fix(mcp): classify the next-period-IB readiness blocker instead of kind other

The blocker "Nästa räkenskapsperiod har redan ingående balanser bokförda"
was the only validateYearEndReadiness error with no classifier regex, so it
always surfaced as kind: 'other'.

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

* fix(bokslut): log swallowed AR/AP tie-out failures in the readiness aggregator

Compliance-review finding: a rejected tie-out produced no reminder and no
log entry, making a failed avstämning control indistinguishable from a
reconciled one. Still degrades to no reminder (advisory check), but the
rejection reason is now traceable, mirroring the unbooked-transaction
check's logging.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:05:38 +02:00
Jakob Wennberg 17d48ebb84 fix(invoices): guard cash-method partial payments across every payment path (#1413)
* fix(invoices): guard cash-method partial payments across every payment path

A never-booked kontantmetoden invoice can only be settled by the generated
cash entry (createInvoiceCashEntry / createSupplierInvoiceCashEntry), and
that entry always books the FULL invoice: it takes no payment amount. Three
payment surfaces still let partial payments through to it, corrupting books:

- settleInvoicePayment dropped the fully-paid term entirely, so a partial
  payment (Stripe sync, mark-paid) booked the entire invoice: over-recognized
  revenue, over-declared output VAT, and a bank debit that did not match the
  money received.
- The dashboard and agent match-transaction paths fell back to an
  accrual-style clearing entry against an EMPTY 1510: negative receivable,
  no revenue, no moms (ML 13 kap 8 § puts each installment's moms in its own
  receipt period). The comment claimed the credit "gets resolved on final
  payment", but the cash builder never touches 1510 and books the full
  total, so the final payment double-debited the bank instead.
- The supplier routes had no full-settlement term at all, so a partial
  payment booked the full expense + input VAT.

Fix: one shared predicate (cashPartialBlockReason in booking-mode.ts)
rejects generated cash entries unless the payment settles the invoice in
full from a fully unpaid state, wired into all six POST surfaces, the agent
commit paths, and the three preview routes (so dialogs cannot propose a
verifikat the POST refuses). New bilingual error codes
INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED / SI_CASH_PARTIAL_UNSUPPORTED.
Invoices booked at issue are unaffected: their partial payments keep the
normal 1510/2440 clearing path.

The v1 match-invoice route already had this guard (VALIDATION_ERROR); its
behavior is unchanged. Proper per-installment recognition (proportional
revenue + moms per receipt) is the follow-up feature that would lift this
restriction.

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

* docs(review): drop stale ML 13 kap 8 § cites for kontantmetoden VAT timing

Compliance-review finding: the section is the old ML 1994:200 numbering; in
ML 2023:200, 13 kap covers input-VAT deduction, not redovisningstidpunkt.
The substantive rule (bokslutsmetoden reports moms at payment, per
installment, except at year-end) is unchanged and stated without a section
cite until the current-law section is verified. Comments and cookbook prose
only; no behavior change. Also fixes the two pre-existing occurrences.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:05:11 +02:00
Jakob Wennberg 629069e281 feat(whatsapp-inbox): conversation layer with clarifying questions (#1340)
PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.

Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.

Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.

New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.

Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:27:00 +02:00
Jakob Wennberg d1c411ad6f feat(invoice-inbox): surface WhatsApp chat context in booking flows (#1339)
* feat(invoice-inbox): surface WhatsApp chat context in booking flows

The WhatsApp intake bot writes verified human answers (photo caption,
representation deltagare + syfte, sender note, open-question state) to
invoice_inbox_items.channel_context. This makes the in-app booking flows
READ it:

- New core renderer lib/documents/channel-context-notes.ts: deterministic
  compact Swedish line ("Representation: Anna Berg (Volvo), Jakob W ·
  Syfte: uppföljning av avtal"), capped at 220 chars by dropping whole
  participant names ("… och N till"), never mid-name. Representation
  first, then user_note; caption only when nothing else exists.
- FieldsRail "Från WhatsApp" block in InvoiceInboxWorkspace: caption,
  deltagare, syfte, anteckning rows plus an ochre AttnLine when a chat
  question expired unanswered (pending_question.status = moved_to_app).
- Notes threading: book-direct and convert default their notes to the
  rendered string server-side when the request carries none (a supplied
  value always wins); BookDirectlyDialog prefills its notes input with
  the same string so the user can edit it before it lands. Bulk-book
  (categorize-core) joins the shared batch note with the per-item
  rendered context so the representation trail survives batch booking.
- Inbox list: whatsapp rows get a chat icon and a quiet "Fråga obesvarad"
  badge for moved_to_app items. No worklist count change: unresolved
  whatsapp items are already counted by countInboxDocuments.
- sv/en strings for every new key; renderer + route + bulk tests.

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

* fix(invoice-inbox): honor cleared notes and keep unreviewed captions out of verifikat

Two adversarial-review findings on the WhatsApp surfacing flows, both about
text that lands on an immutable verifikat.

1. A cleared note was silently re-applied. BookDirectlyDialog prefills the
   rendered chat context, and the dialog sent `notes.trim() || undefined`
   while book-direct and convert defaulted from channel_context on any falsy
   value. A user who read the prefill, disagreed and deleted it therefore got
   it written back onto a posted entry, removable only through a formal
   rättelse. Both code comments claimed "an edited value always wins", which
   was false for exactly that edit. Now PRESENCE of the field decides: the
   dialog always submits `notes` (empty string included) and the routes only
   default when the field is absent from the request (MCP, older clients).
   The Zod `.optional()` carrying that distinction is documented at the
   schema so it is not "tidied" into a `.default('')` later.

2. The photo caption was auto-burned into verifikat text with no review.
   renderChannelContextNotes fell back to the raw caption, and bulk-book
   appended the result per item with no per-item notes field at all (the MCP
   approval preview deliberately shows no per-item PII either), so unreviewed
   chat text reached a WORM record nobody had seen. The renderer now takes
   { includeCaption } and leaves the caption out by DEFAULT: representation
   answers and user_note are replies to a question the bot asked, the caption
   is not. Only the Bokför direkt prefill opts in, where the user reads the
   string in an editable field before booking.

Tests: cleared-notes and whitespace-cleared on book-direct, cleared-notes on
convert, caption-never-defaulted on both routes, caption-not-threaded in
bulk-book, and the renderer's opt-in. The book-direct cleared-notes tests
were mutation-checked (restoring the truthiness fallback fails them).

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

* fix(reports): export the chat answers behind a verifikat in the full archive

The verifikat line caps the representation trail at 220 chars and drops whole
participant names ("… och N till"); the complete list (deltagare, syfte,
raw_answer) exists only in invoice_inbox_items.channel_context. That table was
in ARCHIVE_EXCLUDED_TABLES with a rationale predating channel_context ("inbox
workflow state"), so a company leaving Accounted and keeping the full-archive
export as its BFL 7-year record kept an incomplete deltagare documentation for
its representation deductions.

Dumped as a column PROJECTION, not the whole row: the new
MasterDataTableSpec.columns narrows the select to the underlag provenance
(document, matched transaction, created verifikat / leverantörsfaktura) plus
channel_context, so the answers are tied to what was booked from them while
the inbox workflow state (email bodies, OCR output, error messages) stays out
of the archive. The documents themselves remain in dokument/.

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

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:54:30 +02:00
Jakob Wennberg 398c734b93 feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token
compare); POST verifies X-Hub-Signature-256 over the RAW body before any
parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid
= dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers
media processing via the after() idiom. Rejected and rate-limited content
always acks 200 and lands as skipped/error rows, never a retryable status.

Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time
codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the
webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256-
GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp;
unknown senders get one throttled M1 greeting (1/h, 3/day) behind the
sender-quota RPC, with no media download and no content persistence.

Intake worker: atomic claim on the message row (the durable job record),
company resolution (default -> sole membership -> M6 fallback, no item),
per-company inbox quota (ack-and-drop, M17 once per 10 min per sender),
MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate
check, then the shared uploadAndExtract funnel (source 'whatsapp',
channel_context caption, whatsapp_message_id) and the M4 ack with extracted
merchant/total/date. Failures wrap to 'error' + error_message + one M18.

uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId;
email/upload paths behaviorally unchanged.

Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice
(M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM
call, sweep cron, retention cron.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:47:08 +02:00
Jakob Wennberg bd3592cb99 feat(db): WhatsApp channel schema (phone links, messages, inbox source) (#1337)
Foundation for WhatsApp receipt intake (plan 2026-08-01), no runtime
callers yet:

- whatsapp_phone_links + whatsapp_link_codes: verified phone -> user
  binding via single-use 10-min codes (invite-token pattern). Peppered
  HMAC lookup hash + AES-GCM encrypted number; one ACTIVE link per
  phone and per user (partial unique, revocation preserves history).
  User-scoped RLS (a binding belongs to a person, not a company).
- whatsapp_conversations + whatsapp_messages: deterministic state
  machine state and the message log, which doubles as the durable job
  record for persist-first webhook processing. Partial unique index on
  inbound wamid = the at-least-once dedupe key. Service-role only.
- whatsapp_sender_rate_counters + check_and_increment_whatsapp_sender_quota:
  the pre-binding limiter keyed by phone hash; EXECUTE granted to
  service_role only.
- invoice_inbox_items: source CHECK widened to 'whatsapp', plus
  whatsapp_message_id (one item per delivering message) and
  channel_context jsonb, kept separate from extracted_data so verified
  human answers never share a container with untrusted OCR output.
  document_attachments.upload_source CHECK gains 'whatsapp'.
- whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES
  (full-archive coverage contract).

pg-real on a fresh DB: 975/975 incl. the new whatsapp-channel suite
(RLS visibility, unique/rebinding semantics, wamid dedupe, CHECK
widenings, quota RPC caps + grant lockdown).

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:36:00 +02:00
Jakob Wennberg af9db54405 docs(self-hosting): AI runs on AWS Bedrock, not ANTHROPIC/OPENAI keys (#1407)
* docs(self-hosting): AI runs on AWS Bedrock, not ANTHROPIC/OPENAI keys

A self-hoster followed SELF-HOSTING.md, set ANTHROPIC_API_KEY and
OPENAI_API_KEY, and found document interpretation dead (with a 30s
extraction-poll hang per upload). Neither key has been read since the
ai-chat / receipt-ocr / ai-categorization extensions were removed in
PR #157: all AI (document extraction and the assistant) goes through
Claude on AWS Bedrock via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.

- SELF-HOSTING.md, DOCKER.md, .env.example: document the Bedrock
  credentials and model overrides; point plain-key support at #1406
- SELF-HOSTING.md: drop the receipts-bucket setup for the removed
  receipt-ocr extension
- EXTENSIONS.md: replace removed extensions with live ones in trees
  and examples; drop the AI-consent system claims (lib/extensions/
  ai-consent.ts no longer exists); services-pattern example now uses
  the real stripe/skatteverket services
- lib/init.ts: startup env warning now checks the AWS keys instead of
  the two dead vars, so a misconfigured self-host logs the truth

Direct Anthropic API key support and pluggable providers: #1406.

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

* docs: address review, dedupe tree entry and match provider-chain claim to code

extractInvoiceFields bails without both static AWS keys, so only the
assistant client actually falls back to the credential provider chain.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:17:25 +02:00
Jakob Wennberg 86c6af6976 feat(api): v1 REST company-settings write endpoint (PATCH) (#1405)
* feat(api): v1 REST company-settings write endpoint (PATCH)

Adds PATCH /api/v1/companies/{companyId}/settings, closing the gap where
the v1 REST surface had no company-settings write (only the staged MCP
tool gnubok_update_company_settings could change them).

- Field set is identical to the MCP tool: payment details (bank account,
  bankgiro, plusgiro, swish, iban, bic), invoice contact details (email,
  phone, website), contact_person (aliased onto default_our_reference,
  exactly as the MCP tool maps it), and invoice_email_texts.
- Validation reuses the shared UpdateCompanySettingsParamsSchema (Luhn
  bankgiro/plusgiro, invoice email placeholder whitelist), so REST and
  MCP can never drift apart on the Swedish-domain rules.
- Writes directly with an explicit .eq('company_id', ...) filter,
  following the v1 customers PATCH precedent: no staged operation, since
  REST callers are already gated by the companies:write scope.
- Dry-runnable, mandatory Idempotency-Key, registered in the endpoint
  catalogue, scope map, and load-routes; spec snapshot updated.
- The companies:write scope description now mentions the REST endpoint.

No GET endpoint yet (possible follow-up); reads stay on the MCP tool.

Fixes #1348

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

* test(v1): harden company-settings PATCH contract, align risk tier

Adversarial-review follow-up for the settings PATCH endpoint (#1348):

- Declare risk: 'medium' in registerEndpoint, matching the
  update_company_settings tier in lib/pending-operations/risk-tiers.ts
  (payment settings control where customers send money on future
  invoices). The spec snapshot does not pin the risk field, so no
  snapshot regeneration is needed.
- Pin the partial-PATCH contract: every column the caller did not
  supply must arrive as undefined in the update payload, never null.
  A future ?? null on the literal 13-column payload would silently
  clear every unsupplied column; the new test fails on exactly that
  regression (verified by mutation).
- Cover the body-parsing branches: invalid JSON and non-object JSON
  bodies (bare array, string, number, null) each return 400 with the
  handler's respective message and never reach the update call.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:46:13 +02:00
Jakob Wennberg 9f5a43310b fix(salary): recompute entitled_days on existing ledger rows and record pre-cutover taken days (#1403)
The vacation ledger sync carried entitled_days verbatim on existing open
rows while re-deriving accrued and taken, so a stale entitled value (for
example the flat 25 stored before Semesterlagen 7 § pro-rating existed)
survived every sync. The recompute loop now re-derives entitled the same
way the lazy-seed path does, with the opening-balance cutover still
outranking recomputation for the year containing cutover_date.

Opening balances could also not record paid vacation days already taken
in the cutover year under the previous payroll system. New additive
column employee_opening_balances.vacation_days_taken_this_year (NUMERIC
NOT NULL DEFAULT 0, CHECK 0..40) threaded through the shared service,
the Zod schema, the MCP staging tool (schema + mergeable fields), the
staged-operation executor, the v1 REST routes, and the employee editor
form. Ledger semantics for the cutover year, on both seed and recompute
paths: entitled = remaining + taken_this_year, taken = booked-run taken
+ taken_this_year, so remaining keeps meaning remaining and the seeded
value survives every subsequent sync.

Fixes #1347

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:35:02 +02:00
Jakob Wennberg 2296c0cd59 fix(auth): provision invitees server-side when signups are disabled (#1404)
* fix(auth): provision invitees server-side when signups are disabled

Self-hosted installations with GoTrue disable_signup broke the invite
flow silently: invitees without an account were routed to /register,
where supabase.auth.signUp fails with "Signups not allowed for this
instance", surfaced only as a generic toast.

New server-only env flag AUTH_SIGNUPS_DISABLED (documented in
.env.example) mirrors the GoTrue setting. When true, POST
/api/company/members/invite checks check_email_exists and, for invitees
without an account, provisions one via auth.admin.inviteUserByEmail
with a redirect back to /invite/<token>, before the Resend email and
before the invitation row is written so a provisioning failure leaves
nothing half-created and the admin can retry. The response now carries
user_provisioned alongside email_sent, and a provisioning failure
returns 502 with a Swedish message mapped through getErrorMessage
instead of a silently-successful invite.

/auth/callback now routes type=invite verifications to /reset-password
(the existing set-password surface) instead of dropping the
passwordless user on the dashboard, and preserves the invite token from
next=/invite/<token> as the pre-auth invite cookie so the existing
reset-password invite handoff accepts the membership right after the
password is saved.

getErrorMessage learns two GoTrue patterns: "Signups not allowed"
(account creation closed on this installation, contact your inviter or
administrator) so the /register dead end is explained even for flows
that bypass provisioning, and "Error sending ... email" (GoTrue SMTP
not configured) so the 502 above is actionable.

Hosted is untouched: the flag is unset there and every new code path is
gated on it.

Fixes #1335

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

* fix(auth): restore check_email_exists RPC and harden self-host invite config

Adversarial review of #1404 found that the check_email_exists function the
invite flow depends on does not exist anywhere: it shipped in PR #229 and
was lost in the #244 migration consolidation before ever reaching prod
(verified missing on the hosted production database directly). Today
app/api/team/accept destructures only { data } from the RPC call, so
alreadyHasAccount is silently null on every deployment and the invite page
routes even existing-account invitees toward /register.

- New migration 20260804140000 restores the function exactly as originally
  shipped: SECURITY DEFINER over auth.users, EXECUTE revoked from PUBLIC,
  anon and authenticated, granted to service_role only (prevents email
  enumeration). Fixes hosted prod behavior too once applied.
- New tests/pg/check-email-exists.pg.test.ts locks in existence,
  case-insensitive matching, false-for-unknown, and the role grants.
- .env.docker.example gains the AUTH_SIGNUPS_DISABLED block self-hosters
  actually use; both env templates now note that the GoTrue redirect URI
  allow-list must include /invite/* or the invite email redirect silently
  falls back to SITE_URL.
- Invite route test for the existsError branch: RPC failure logs a warning
  and provisioning proceeds anyway (GoTrue is authoritative).

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

* fix(auth): mask invitee email in provisioning-failure log (#1335)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:34:54 +02:00
Jakob Wennberg a9242551eb fix(reports): stop double-counting arets resultat on open years (#1401)
* fix(reports): stop double-counting arets resultat on open years

The balance sheet computed the synthetic Arets resultat section purely
from class 3-8 rows while 2099's posted balance already sat inside the
class 2 Eget kapital sections. When a resultatavslut was posted to 2099
on an open period with its counter-line outside class 3-8 (class 0/9 or
missing class), the result was counted twice and the report raised a
false imbalance whose differens equaled the 2099 balance.

The period-result filter now takes every row NOT in class 1-2, written
as a negated range so null/undefined account_class rows are included.
The invisible counter-line of a mangled resultatavslut then offsets
inside the period result and 2099 is never counted twice, while a
genuinely untransferred prior-year result still produces a real
differens and the existing diagnosis still fires.

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

* docs: record balance-sheet residual classification decision (#1333)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:34:18 +02:00
Jakob Wennberg 97bbba323f fix(salary): report F-skatt compensation on FK131 only, not FK011 too (#1402)
* fix(salary): report F-skatt compensation on FK131 only, not FK011 too

An F-skatt payee's cash compensation was passed through as grossSalary
unconditionally while also being routed to fSkattPayment, so the same
payment was double-reported in the AGI individuppgift as both FK011
(KontantErsattningUlagAG, underlag for arbetsgivaravgifter) and FK131
(KontantErsattningEjUlagSA). Per Skatteverket's AGI spec these fields
are mutually exclusive for the same payment stream: F-skatt payments
form no underlag for arbetsgivaravgifter.

Fix at the data layer: generateAgiDeclaration now zeroes grossSalary
for f_skatt payees so FK011 is never emitted for that payment, while
fSkattPayment keeps carrying it to FK131. A generator-side guard was
deliberately not used because an IU can legitimately carry both fields
for genuinely mixed payments.

The empty-IU filter already keeps f_skatt rows via fSkattPayment, so no
individuppgift is dropped; FK487/avgifter totals were already correct
(calculation engine sets avgifter_basis 0 for f_skatt) and are covered
by a regression test. Both the dashboard route and the v1 public route
call this helper, so both are fixed.

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

* fix(salary): exclude f-skatt rows from avgifter override aggregation (#315)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:33:36 +02:00
Mattsson 5ca64bde30 feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)
* feat(bokslut): IL 18 kap pooled tax depreciation with method election

Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25
as a company-level annual pool separate from per-asset book depreciation.
Method election persisted with immutable snapshots and book-conformity
confirmation for rakenskapsenlig.

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

* chore(db): move tax depreciation migrations to coordinated versions

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

* fix(bokslut): keep tax depreciation view loadable when a saved election goes stale

A predecessor's changed closing value can push a saved elected deduction
above the new statutory maximum; the view now falls back to the statutory
recomputation so the snapshot is flagged stale instead of crashing.
Ratchet naive-ore-round baseline down by 3.

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

* fix(bokslut): resolve tax-depreciation period selects statically

The no-phantom-columns guard counts every select it cannot resolve
toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo
4 over (364 > 360). Inline the literal column list at the four call
sites so the guard verifies these columns instead of skipping them.

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

* fix(bokslut): address review findings on tax depreciation election

- DepreciationPanel: gate the saving flag on a dedicated save sequence
  so a successful save (which refreshes the view and bumps the request
  version) no longer leaves the card permanently busy
- computeTaxDepreciation: refuse kompletteringsregel_20 with a positive
  basis and no acquisition cohorts instead of degenerating to a full
  write-off the cohort evidence does not support (IL 18 kap. 17 §)
- migration 227000: judge the asset-method guards on NEW.disposed_at so
  reversing a disposal cannot reactivate a grandfathered non-linear row
- migration 227200: require snapshot column completeness in the CHECK;
  SQL NULL semantics let partially populated snapshots pass the pure
  arithmetic comparisons
- depreciation route: use the string issue code 'custom' like the rest
  of the codebase instead of the Zod 3 compat enum

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:43:58 +02:00
Mattsson 00ae3540db feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration

Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical
DTOs, customer APIs (web + v1) and invoice send flows so contact person and
customer-level invoice CC/BCC addresses survive provider migrations. NULL
means unconfigured and empty means an explicit clear, so re-syncs enrich
legacy gaps without resurrecting deliberately removed values. Fortnox fixed
assets are split into a dedicated follow-up issue.

Fixes #1345

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

* chore(db): bump customer metadata migration past pack-slug version

Main already contains 20260803230000; keep new versions strictly newest so
Supabase branching applies them in order.

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

* fix(customers): complete Customer type consumers and make enrichment payload resolvable

The preview-pdf mock customer and the makeCustomer fixture now carry the
three new metadata fields, fixing the type-check failure in Build (zero
extensions) and Vercel.

The enrichment update in the migration orchestrator now spells its payload
as an object literal typed CustomerMetadataEnrichment (absent keys drop at
serialization), so the phantom-column guard resolves the columns instead of
counting another unresolvable dynamic payload past its ceiling. The cc/bcc
guards also verify element types instead of casting.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:00:03 +02:00
Mattsson cb3ef45f14 feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) (#1391)
* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse)

Disposal books depreciation to the disposal date, clears cost and
accumulated depreciation, books gain (3973) or loss (7973), applies
output VAT on third-party sales, honors the ML 5 kap. 38 §
verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning
server-side from tax years and original input VAT. The voucher, the
disposal-date depreciation schedule and the immutable register state
commit in one dedicated commit_asset_disposal RPC transaction that
delegates voucher numbering to commit_journal_entry.

Fixes #325

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

* fix(assets): harden disposal per review and pg-real findings

- commit_asset_disposal now uses the NULL-safe caller_is_company_member()
  guard (tenant-guard ratchet) and passes the allowed 'user_accept'
  commit_method instead of the unlisted 'asset_disposal' value
- disposal metadata invariants validated in the RPC (non-negative
  proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no
  proceeds) since the RPC is independently callable
- new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so
  the migration never blocks writes on the hot journal_entries table
- disposeAsset paginates fiscal periods and depreciation schedules with
  fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||)
- engine imports shared AssetDisposalType/AssetJamkningDirection/
  VatTreatment unions; post-commit reload retries once and logs before
  surfacing, so a transient read cannot masquerade as a failed disposal
- dispose page parses Swedish-formatted amounts (125 000,50) and blocks
  submission on unparseable proceeds
- assets pg tests write disposal attributes in the disposal transition
  itself and gain a regression test that the register is frozen after

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:59:52 +02:00
Mattsson 1c9d378df8 feat(auth): enforce session idle and absolute timeouts (#1387)
* feat(auth): enforce session idle and absolute timeouts

Hosted browser sessions now carry an HMAC-signed, HttpOnly cookie holding
session start, last activity and sign-in method, bound to the Supabase
session. Middleware enforces a 30 min idle and 12 h absolute limit
(reason-coded redirects to /login), a heartbeat route advances idle
activity from real user input, and a client controller warns 2 minutes
before expiry. BankID users are routed back to BankID on re-auth via a
short-lived method hint. API-key and MCP bearer surfaces are exempt;
self-hosted installs default off and can opt in via env vars.

Fixes #362

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

* fix(auth): derive session-timeout signing key via HKDF

The HMAC key is now HKDF-derived with a purpose-bound info string, so
the SUPABASE_SERVICE_ROLE_KEY fallback never uses the privileged
credential directly as a signing key. Addresses the security review
finding on PR #1387.

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

* fix(auth): back signature bytes with a plain ArrayBuffer

crypto.subtle.verify requires a BufferSource; Uint8Array.from is typed
over ArrayBufferLike, which the Vercel TypeScript build rejects. Decode
base64url into a Uint8Array constructed over a fresh ArrayBuffer.

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

* fix(auth): address session-timeout review findings

- signSessionTimeoutState returns null on signing failure instead of
  throwing, so a missing secret degrades the timeout feature in line
  with verifySessionTimeoutState rather than crashing authenticated
  requests; middleware and heartbeat skip the cookie write when null
- heartbeat initializes a fresh signed state for a missing or
  session-mismatched cookie, mirroring middleware, instead of
  returning SESSION_EXPIRED during normal initialization
- sessionStateMatchesUser treats an unresolved current session id as
  a mismatch for session-bound state so another session's cookie is
  never accepted on the userId fallback alone
- drop aria-live from the countdown DialogDescription so screen
  readers are not interrupted every second

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:59:42 +02:00
Jakob Wennberg f55bfc478b fix(bookkeeping): three static templates named accounts that break or mis-book (#1397)
Cross-checked all 86 booking templates (26 packs + 60 static) against BAS 2026.
The pack catalogue is clean; the static registry had three defects, none of
which any existing check could see.

Two named accounts that do not exist in BAS 2026, so account-backfill.ts cannot
seed them and EVERY booking through them failed with AccountsNotInChartError:

  vehicle_parking   5614 -> 5619  the BAS 561x run skips 5614; 5619 is the
                                  sibling "Ovriga kostnader for personbilar"
  it_cloud_hosting  5421 -> 5420  BAS has no 5421; both sibling SaaS templates
                                  already use 5420 Programvaror

One that posted successfully to the wrong account, which is worse because
nothing complained:

  travel_hotel      5820 -> 5830  5820 is Hyrbilskostnader. The Hotell template
                                  filed hotel nights under car hire, balanced,
                                  and looked fine. 5830 is "Kost och logi".

An existing test asserted the 5820 behaviour, so the bug was pinned as
expected output. That assertion now reads 5830 and carries the reason.

Adds template-accounts-exist.test.ts so the 5614 class cannot return: every
account any static template names must resolve in BAS 2026, account numbers
must be strings, and a VAT-bearing purchase may not debit equity or revenue.
That last check is deliberately narrow: class 1 is legitimate, equipment_capital
debits 1250 Inventarier and reclaims VAT, which is how capex is booked. Verified
the guard fails on 5614 and passes on 5619 rather than trusting it.

The pack catalogue already had this check via scripts/validate-packs.ts. The
static registry had nothing, which is how it drifted.

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:55:54 +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
Mattsson 281cb3989d fix(build): break Turbopack chunk-name hash collision from #1385's import edge (#1394)
Since the #1385 squash-merge every production build failed with 'Two or
more assets with different content were emitted to the same output path'
on [root-of-the-server]__1ge0sz5._.js: two distinct server chunk groups
(an AWS smithy helper chunk and the withRouteContext auth chunk) hash to
the same chunk name. The graph change that tipped the chunk layout into
the colliding state was entity-mapper.ts (arcim-migration extension
entry graph) importing lib/vat/supplier-invoice-line-checks, which
drags lib/money into the extension root.

Move normalizeVatRateToFraction into an import-free leaf module
(lib/vat/vat-rate-unit.ts), re-export it from
supplier-invoice-line-checks for all existing callers, and point
entity-mapper at the leaf. Behavior is unchanged (511 targeted tests
pass); the server chunk graph returns to the pre-#1385 shape that
builds cleanly. Verified: npm run build fails on ff864ad3d and passes
with this change.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 19:28:15 +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
Mattsson 0ef3c03904 fix(salary): book net deductions as settlement lines so salary entries balance (#1374)
* fix(salary): book net deductions as settlement lines so salary entries balance

Net deduction line items were skipped entirely in createSalaryEntry, so the
credit side (2710 tax + 1930 net) fell short of the gross debit by exactly
the deducted amount and the balance trigger rejected the voucher.

Net deductions now book on their mapped settlement account (1613 advance
repayment, 2794 union fee, 7385 benefit co-payment, 2799 other; explicit
account_number overrides), aggregated and undimensioned like the other
settlement legs. The default mapping in account-mapping.ts moves off 7210
so payslip lines, the booking preview and the voucher all agree.

Fixes #316

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

* fix(salary): address compliance review on net-deduction accounts

Label 7385 with its BAS 2026 name (Kostnader för fri bil) instead of the
benefit-generic Bilförmån, document why the single benefit-payment item
type defaults to 7385 with per-line override for other benefit kinds, and
add a repayment-direction test (positive net deduction books as debit).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:51:53 +02:00
Mattsson 8443062b1f fix(vat): enforce fraction unit for supplier invoice vat_rate writes (#1385)
Closes the remaining #310 write paths: credit-note item copies (web, v1,
pending-operations) and arcim-migration supplier imports now normalize
vat_rate to the decimal-fraction unit before storage, and a NOT VALID
CHECK constraint guards every new supplier_invoice_items row. Customer
invoice items deliberately stay percent; legacy supplier rows are left
untouched so posted-entry reversals reuse the exact original values.

Fixes #310

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:49:46 +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
Mattsson 8299ee9fb4 fix(bookkeeping): resolve settlement account in all categorization flows and ship mis-booking audit (#1383)
Completes the #985/#986/#987 caller sweep: categorize-core, v1
batch-categorize, pending-operation edits and the MCP categorize path now
resolve the settlement leg from the transaction's cash account instead of
inheriting a hardcoded or stale account. Extends the correct_entry preview
with currency, tax and dimension line metadata so staged corrections
preserve full line fidelity. Adds a read-only audit query and a runbook for
reviewing and correcting historical mis-bookings via staged storno with
explicit approval; no automated bulk mutation.

Fixes #1001

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:45:38 +02:00
Mattsson e40dc64485 feat(invoices): invoice list column sorting and row-level status styling (#1375)
* feat(invoices): add column sorting and row-level status styling to invoice list

Client-side sorting on number, customer, due date, amount and status with
Swedish collation, null-last ordering and stable date/id tie-breaks. Status
chips move to the shared RowStatus descriptor so normal states stay muted and
exceptions carry semantic color. Invoice fetch now pages past the PostgREST
1000-row cap via fetchAllRows so sorting covers the full list.

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

* test(invoices): make rounded-amount sort assertions non-degenerate

Distinct rounded totals now prove the comparator orders by displayed value;
the rounded tie case is kept as an explicit tie-breaker test since integer
rounding is monotonic and can only create ties, never reorder.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:43:30 +02:00
Mattsson a2f7132c94 fix(year-end): conservative historical repair for carried-forward 2099 (#1373)
* fix(year-end): conservative historical repair for carried-forward 2099

The steady-state year-end flow already reclassifies the opening 2099
(Arets resultat) to 2098 (Foregaende ars resultat) right after the
opening balance is generated. Periods opened before that fix still
carry the prior year's result on 2099.

Add lib/core/bookkeeping/result-appropriation-repair.ts: a pure
classifier plus assess/post helpers that auto-post the 2099 -> 2098
transfer only when it is unambiguous (open unlocked aktiebolag period,
posted explicit opening_balance entry, active 2099/2098 accounts, no
posted result_appropriation yet, current posted 2099 still equal to the
explicit opening amount, and no other entry touching 2099). Everything
else is skipped or listed for manual review; nothing is reconstructed
from cumulative history. All writes go through the bookkeeping engine.

Rework scripts/repair-result-appropriation.ts into a thin CLI over the
library: global/company/period dry-runs, and commit mode that requires
one exact --company-id, --period-id, and --user-id and re-assesses
immediately before posting.

Fixes #735

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

* fix(year-end): require company membership for repair attribution

Compliance review (ASVS V8.2.1): commit mode accepted any --user-id and
attributed the posted journal entry to it unvalidated. The service-role
client bypasses RLS, so nothing downstream would catch an outsider uuid.
postHistoricalResultRepair now verifies a company_members row for the
target company before posting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(year-end): reference the opening-balance underlag on the repair verifikat

Swedish compliance review (BFL 5 kap 6-7 §§): the historical repair
entry validated against a specific opening-balance entry but never
recorded it. Link it machine-readably via source_id and human-readably
in the entry note ("Underlag: ingående balans, verifikat A1 (<id>)").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(year-end): harden repair CLI arg parsing, pagination and exit code

CodeRabbit review on #1373:
- arg() rejects flag-shaped or missing values instead of silently
  consuming the next flag as an id
- global company and period scans paginate via fetchAllRows() so
  deployments past the PostgREST 1000-row cap are fully covered
- exit code is non-zero when any period failed to list, assess or post

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:41:54 +02:00
Mattsson 5d7952a01e feat(mcp): model-free document upload via signed URL (#1378)
* feat(mcp): model-free document upload via signed URL (#748)

Adds gnubok_create_document_upload + gnubok_complete_document_upload so
document bytes reach storage through a short-lived signed PUT URL and
never pass through the model context. Fixes silent base64 corruption on
real-size PDFs and the context blowup on batch uploads.

- pending/ staage keys with TTL cleanup; completion validates magic
  bytes + SHA-256, moves bytes to the WORM key and adopts the reserved
  UUID as document id, making retries and concurrent completions
  idempotent
- legacy gnubok_upload_document kept for clients without file access,
  description now points to the signed-URL pair; shared mime resolution
  and inbox-item creation extracted
- both new tools mapped in TOOL_SCOPE_MAP (transactions:write) and
  MCP_TOOL_CAPABILITY_MAP (ai) so the paywall and scope gates hold
- payload guard ceiling 58.5K to 59K after trimming the create tool's
  outputSchema to upload_id/upload_url/expires_at

Fixes #748

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

* fix(mcp): satisfy capability-map lock and phantom-column scanner

The exact-entries lock in capability-maps.test.ts now includes the
signed-URL pair as dispatch-only AI tools, and the inbox insert uses a
literal payload (explicit UUID instead of a conditional spread) so the
no-phantom-columns scanner can resolve every column.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:41:05 +02:00
Mattsson 00d4c8a49e feat(invoices): ROT/RUT payout file dialog and file guards (#1380)
* feat(invoices): ROT/RUT payout file dialog and file guards

Rebuild the UI for the existing headless HUS V6 payout-file flow
(demanded via #789): a dialog on the invoices page to pick eligible
paid ROT/RUT invoices, generate the XML, download it and track
request status. Adds file-level guards from the Skatteverket spec:
future payment dates blocked, one file per payment year, max 100
cases per file, with per-invoice blocker messages.

Submission stays manual (upload + sign in the SKV e-service);
no direct submission API exists.

Fixes #789

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

* fix(invoices): compute rot-rut gating date in Europe/Stockholm

The candidate and begäran date defaults used the UTC calendar day,
which near midnight Swedish time could wrongly block or admit an
invoice via FUTURE_PAYMENT_DATE and shift the 31 January deadline
warning. Use getSwedishLocalDate() like the bookkeeping engine.
Raised by the Swedish compliance review on PR #1380.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:16:46 +02:00