Commit Graph

399 Commits

Author SHA1 Message Date
Jakob Wennberg 46c0b72ab0 feat(auth): surface duplicate-account traps around BankID login (#1234)
* feat(auth): surface duplicate-account traps around BankID login

Three escape hatches for the stale-duplicate-account trap (#1231, the
Chillen support case): a user whose BankID resolves to an abandoned
account got an empty app with no hint that their real bookkeeping
lives in another account.

- check-org-number: new exists_elsewhere signal (service role, reduced
  to one boolean) + a warn chip in the onboarding journey when the org
  number already exists in an account the user is not a member of.
- Hem: one AttnLine under the greeting when the whole account has zero
  journal entries but a same-orgnr company elsewhere has real
  bookkeeping, with a sign-out action. Common case costs one indexed
  existence probe.
- scripts/support/unlink-bankid.ts: dry-run-by-default support action
  that unlinks a BankID identity (delete + app_metadata clear +
  append-only SECURITY_EVENT audit_log row). Replaces the raw SQL used
  to resolve the original ticket.

Closes #1231

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

* fix(auth): harden unlink script and paginate hint queries per review

- other-account-hint: fetchAllRows() on both company listings (PostgREST
  1000-row cap; byrå users can hold many memberships); the journal probes
  stay limit(1) existence checks.
- unlink-bankid: audit_log row is written BEFORE the delete so a partial
  failure can never delete without a trace; context queries fail closed
  instead of rendering an unknown account as empty; stdout no longer
  prints the personnummer hash or ciphertext (the unsalted hash is
  brute-forceable over the personnummer space); record_id now carries the
  identity row id and the snapshot includes id + linked_at.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:00:15 +02:00
Mattsson fbd4b992f5 Add/db and speed (#1243)
* fix(privacy): make privacy policy page dark mode friendly

Replace the hardcoded light gradient background with bg-background and
add dark:prose-invert to the prose blocks so body text is readable on
dark cards.

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

* feat(cloud-backup): sync archives to Dropbox alongside Google Drive

Introduce a CloudStorageProvider interface so performSync builds the
archive set once and talks to storage only through it. Google Drive
keeps its existing behaviour; Dropbox is a second implementation, so
the compliance-relevant half (fingerprints, per-year layout, size
fallback, progressive persistence) cannot drift between targets.

Dropbox uses App folder access, matching the drive.file scope's "only
what the app created" guarantee. Uploads are single-shot under 8 MB and
chunked upload sessions above, every write verified against Dropbox's
content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so
Swedish file names survive the Dropbox-API-Arg header.

Each provider owns its extension_data keys, schedule, failure counter
and alert throttle, so a dead Dropbox token cannot pause a healthy
Drive backup. The google_drive_* keys and the /oauth/callback path are
untouched: both are wire format for already-connected companies.

isConfigured() gates /connect only. A deployment that loses its OAuth
credentials must not trap users with a connection they cannot remove
or a schedule they cannot switch off.

Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row
renders disabled without them. No migration: state is extension_data
JSON throughout.

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

* fix: remove merge-conflict markers committed in DECISIONS.md

The merge that brought main into this branch staged DECISIONS.md while
it still carried conflict markers, so cdc3a513 shipped an unresolved
hunk (compliance swarm ISO 27001 A.8.32).

DECISIONS.md is an append-only log, so both sides are kept: main's
systemdokumentation entry followed by this branch's Dropbox entries.
No decision was dropped.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:49:24 +02:00
Jakob Wennberg 1dce9227a4 feat(assistant): make the thumbs actually report something (#1236)
* feat(assistant): make the thumbs actually report something

The thumbs up/down under an assistant answer shipped wired to nothing. They lit
up, the vote died in component state, and the code said so in a comment nobody
reading the UI could see. An affordance that looks like it reports something and
does not is worse than no affordance: it spends the user's goodwill once, and
silently.

They now post to a new /api/agent/feedback, which emits the SAME agent.feedback
event the gnubok_feedback MCP tool emits, with actorType 'user'. The product
team already queries event_log for that type, so chat votes land in the backlog
they read rather than in a second place someone has to remember to look at.
event_log takes the payload as jsonb and already treats agent.* as telemetry, so
there is no migration.

The conversation id is caller-supplied, so it gets the same ownership check
/api/agent/invoke got: without it a member could file feedback against a
colleague's thread and the backlog would carry conversations the reporter never
saw. Mutation-checked, three tests fail when the guard is removed.

The pressed state is set only after the server accepts the vote, so the button
never claims a report that never arrived, and a vote does not toggle off: it is
append-only telemetry, and offering an undo we cannot honour would be a control
that lies. Changing your mind sends the other sentiment instead.

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

* fix(assistant): drop the unused free-text field from the feedback route

compliance-swarm flagged the `comment` field as an undocumented PII exposure:
free text embedded verbatim into the agent.feedback payload and written to
event_log under telemetry retention, with no data-classification decision, next
to a PostHog policy that treats the same class of data differently.

The finding is right, and the field was worse than it looked: no caller ever
sent one. The UI posts sentiment and a turn index. So this is dead API surface
whose only effect was to accept whatever a user might type, in an accounting
product, into a 180-day log: client names, personnummer, case details.

Removed rather than documented. A comment box is a reasonable thing to want,
but it needs its own classification and redaction decision made with the UI in
front of it, not inherited from an unused parameter.

The test now asserts the property instead of the field's absence: a caller that
posts a comment anyway must not get it stored anywhere in the payload.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:46:08 +02:00
Jakob Wennberg c62d00bcb3 feat(invoices): preview invoices and underlag in the browser instead of downloading (#1228)
Reviewing an invoice or a verifikat bilaga meant saving a file and opening it
from the Downloads folder (user request, christian@odinaero.se 2026-07-25).

- GET /api/invoices/[id]/pdf accepts ?disposition=inline and serves the PDF for
  in-browser review; anything else keeps the download behaviour every existing
  caller relies on. The filename still travels in the header, so the browser
  viewer's own save action produces the same name as the download button, and
  nosniff pins the content type.
- The invoice detail page gets a "Förhandsgranska" action next to "Ladda ner
  PDF". It resolves the document through the same resolveInvoicePdfSource path
  as the download, so preview cannot become the shortcut that presents a
  re-render as the invoice the customer received: the archived delivery wins,
  a re-render is shown with its caveat, and an unreadable delivery history
  still asks instead of guessing. The archive dialog now remembers whether the
  user asked to view or to save, and its fallback does that.
- DocumentViewButton (supplier-invoice underlag, staged agent previews) points
  at the existing /api/documents/:id/inline proxy, so bilagor render in the
  browser. Navigation now happens straight from the click, so the signed-URL
  fetch and its popup-blocker workaround are gone.
- The three re-render caveat strings and the two archive-dialog descriptions
  lose their "you downloaded" wording so they stay true for both actions;
  five new keys in sv + en.

Tests: route cases for the default, inline and unknown disposition values;
invoiceRerenderUrl cases for both modes and id encoding. npm test 11364
passed, lint 0 errors. Button row screenshotted against the design system
(pill outline, Eye icon) via a temporary sandbox route.

Closes #1190

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:59:33 +02:00
Jakob Wennberg df29817826 fix(supplier-invoices): make the 'overdue' label two-way and stop it locking an invoice (#1227)
* fix(supplier-invoices): make the 'overdue' label two-way and stop it locking an invoice

The daily cron flips unbooked payables past their due date to 'overdue' but
nothing ever flipped them back, so aging alone pushed an invoice out of every
workflow that gated on 'registered': it could not be edited (not even to extend
the due date that made it overdue) and it could not be attested. Deletion was
already unblocked in #1204; this closes the rest of #1206.

- update_overdue_supplier_invoices() gains the inverse branch: a payable whose
  due date is no longer in the past returns to its resting status. Because the
  flip collapses 'registered' and 'approved', the un-flip needs a separate
  attest marker: new supplier_invoices.approved_at, backfilled from updated_at
  for rows currently sitting in 'approved'.
- PUT /api/supplier-invoices/[id] accepts every unsettled status and recomputes
  the label from the due date it writes, in both directions, instead of leaving
  it up to a day stale. The update body carries metadata only (numbers, dates,
  reference, notes), never amounts or accounts, so a posted registration
  verifikat cannot be desynced by money.
- Approve (web route, v1 API, MCP staging tool, staged commit executor) keys off
  approved_at instead of status === 'registered', so an aged invoice can still
  be attested. A still-late invoice keeps the 'overdue' label after attest:
  approving is not a reason to hide that the money is late.
- One shared predicate in lib/supplier-invoices/lifecycle.ts for all five call
  sites, mirroring the SQL; new SI_EDIT_INVALID_STATUS replaces the raw Swedish
  string the edit gate used to return.

Tests: 12 pg-real cases on the cron (5 new, covering both directions and the
credit-note/fully-paid boundaries), plus route tests asserting the exact written
payload for PUT and approve, and unit tests pinning the shared predicate against
the SQL. npm test (11385), lint, check:guards clean.

Closes #1206

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

* docs(migration): mark backfilled approved_at values as derived, not audit facts

Compliance review on #1227 flagged that approved_at = updated_at could later be
mistaken for an observed attestation moment (BFNAR 2013:2 kap 8
behandlingshistorik). The column comment and the migration now state plainly
that pre-migration values are derived and that audit_log, written by the
audit_supplier_invoices trigger, remains the record of what happened.

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

* fix(supplier-invoices): guard the derived status writes with compare-and-swap

Review findings on #1227. The status these paths write is derived from facts
read a moment earlier, so an unconditional write could overwrite a concurrent
cron flip, edit or approval with a label computed from what those changed.

- PUT pins status, due_date and approved_at when (and only when) it derives a
  new status; zero matched rows is now a retryable 409 SI_EDIT_CONFLICT instead
  of a silently stale label. Metadata-only updates keep writing unconditionally:
  they never touch status, so they cannot clobber it.
- The web approve route and the staged-commit executor gain the same
  pre-approval guard the v1 route already had (status in registered/overdue,
  approved_at IS NULL) plus a !data race check, so two concurrent approvals can
  no longer both stamp approved_at and both emit supplier_invoice.approved.
- The v1 guard additionally pins due_date, since nextStatus is derived from it.
- The list page no longer invents status/approved_at when the approve response
  is incomplete: it re-reads instead. An operator about to pay must not be shown
  a fabricated lifecycle state.
- route.overdue.test.ts clears the module-level event bus like its sibling.

Tests: new conflict cases for both paths (409 on PUT, refusal without an event
emission on approve). npm test 11387 passed, lint 0 errors, check:guards clean,
12 pg-real cases green.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:49:22 +02:00
Jakob Wennberg 7dde8cac82 fix(security): resolve the CodeQL backlog, three fixes and three documented false positives (#1225)
Triage of all 9 CodeQL alerts surfaced on main by #1223. None were introduced by that PR.

Fixed: the compliance-review artifact now unpacks to runner.temp instead of over the trusted checkout (actions/artifact-poisoning, critical); MCP LIKE patterns escape backslash first, which was a real correctness bug returning wrong rows for any search containing a backslash (js/incomplete-sanitization, 2 sites); and the mcp-oauth consent form action is HTML-escaped (js/reflected-xss, not exploitable because WHATWG URL already percent-encodes " < >, but & is not in that encode set).

Dismissed as false positives with reasoning recorded at each site and in DECISIONS.md: sie-export escapeQuotes, where doubling backslashes would violate SIE 4B, corrupt files in conformant readers and skew #KSUMMA under BFL 7-year retention; hashApiKey, where SHA-256 is correct for a 256-bit CSPRNG token and changing it would invalidate every live gnubok_sk_ key; and the DuplicateBookingDialog href, which is a DB UUID behind a literal path prefix.

Regression tests cover both behavioural fixes, including the escape ordering.
2026-07-27 14:02:25 +02:00
Mattsson f3eacb436d Fix/articles (#1216)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

* fix(review): remediate the 2026-07-27 compliance and security review findings

- ROT/RUT deduction base is arbetskostnaden INKLUSIVE moms (HUSFL 2009:194
  6-9 par.): computeDeduction takes the line vat_rate, all five call sites
  pass it, and tests pin Skatteverkets worked example (18 000 kr excl =
  22 500 incl, ROT 6 750).
- Momsdeklaration: new SALES_OUTPUT_VAT_SHORTFALL warning catches output VAT
  short of the reported sales base (one-directional, never filing-blocking).
- SIE import: #RAR records validated for every year index (dates, ordering,
  18-month BFL cap as warn-and-keep).
- build-invoice-write: SEK invoices populate the *_sek twin columns (rate 1)
  so both creation paths produce the same row shape.
- CI: daily Trivy SCA scan of the npm lockfile (replaces removed Dependabot);
  compliance review fails loudly on empty review.md.
- arcim migration FX logging routed through the redacting structured logger.
- docs/security/: authorization policy for the SIE bulk-delete RPC pair and
  the observability redaction contract.
- Rewrote the swedish-payroll ob-overtime reference (was a byte-identical
  copy of sick-pay.md); skills:generate emitted the atom-body seed migration.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:54:42 +02:00
Mattsson f24b26a139 fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:34:56 +02:00
Jakob Wennberg 4de648fb5d fix(assistant): keep proposals, selections and picks intact across a resume (#1212)
* fix(assistant): keep proposals, selections and picks intact across a resume

PR3 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7):
resume fidelity. Four ways the chat lost state that the user had every reason to
think was still there.

Approval cards ride on streamed staged_operation events, which are never
persisted, so reopening a conversation rendered the tool trace and the answer
but silently dropped the card. The proposal then sat in Granskning for its full
30-day expiry with nothing in the thread pointing at it. run-turn already stamps
agent_metadata.conversation_id on every staged row, so both resume paths (the
sheet's history and the /chat page) now re-attach the still-pending ones to the
last assistant turn.

Regenerate abandoned whatever the discarded turn had staged: the card left the
screen, the operation stayed pending, and the regenerated turn usually staged a
second proposal for the same booking, leaving two live proposals for one action.
It now withdraws them through the same reject path the Avslå button uses, so the
audit trail records why they went away.

The sheet's remount key ignored intentArgs while some callers pass a CONSTANT
contextRef with varying args: bulk-book always uses 'inbox:bulk' and carries the
selected ids. Selecting A+B, collapsing, then selecting C+D reopened the A+B
conversation while the user believed C+D were being booked. The key now includes
a stable serialization of the args.

Picking conversation A (slow) then B (fast) let A's late response overwrite B,
leaving the user typing into a thread they did not choose. A sequence token now
means only the newest pick may write state.

Verified: 9540 unit tests pass (11 new), lint and tsc clean on every touched
file, guards pass.

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

* docs: record the resume-fidelity decisions

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

* fix(assistant): render hydrated proposals with the same preview as live ones

pending_operations.operation_type stores the bare action name
('categorize_transaction'), while the streamed card carries the MCP tool name
('gnubok_categorize_transaction') and ApprovalCard's PreviewBlock dispatches on
that. Hydrated cards therefore fell through to the flat generic preview instead
of the journal-line one, so a resumed proposal looked materially worse than the
same proposal did live: the opposite of what this PR is for.

Found by checking the query against prod rather than trusting the mock, which is
also how the stored value space was confirmed: categorize_transaction,
create_voucher and approve_supplier_invoice are what exist in the wild, and the
four operation types that have a specialized renderer all stage unprefixed.

The test fixture now uses the real stored shape so the mapping is actually
covered rather than assumed.

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

* fix(assistant): await proposal withdrawals, surface staged-query errors, share the type

Review follow-ups on the resume-fidelity batch. All four findings were valid.

The withdrawals were fire-and-forget and raced the replacement turn, so the new
turn could stage a second proposal before the old one was rejected: the exact
double-staging this change exists to prevent. They are now awaited, a 409 counts
as withdrawn (someone else resolved it, which is all we need), and if any
withdrawal genuinely fails the turn stays on screen with an error rather than
hiding a card whose operation is still pending.

Both staged-operation loaders ignored their error result, so a database or
policy failure rendered the conversation as successful with the proposals
silently missing: again the failure this query exists to prevent, reintroduced
through the error path. Both now propagate, matching the sibling message query.

StoredStagedOperation now lives in @/types: it is a persisted API contract that
crosses a server page and three components, not an AgentChat detail.

The unserializable-args fallback used a timestamp, which collides for two
objects created in the same millisecond and changes on every render tick for the
same object, remounting the sheet mid-session. A WeakMap gives each object one
stable id for its lifetime.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:56:50 +02:00
Jakob Wennberg f0f3050f54 fix(assistant): stop the chat loading in stages (#1210)
* fix(assistant): stop the chat loading in stages

PR2 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7).
No redesign; this is the "it loads in different stages" complaint, traced to
four separate staging points and one dead link.

Resumed conversations rendered a column of EMPTY bordered cards until the
markdown chunk arrived, then filled in all at once and reflowed the thread. The
chunk was deferred with a null fallback, which is invisible while a reply
streams (nobody reads that fast) but very visible on hydrate, where every
assistant bubble is already text. The chunk is now prefetched as soon as any
chat surface mounts, and until it resolves the raw text renders instead of
nothing, so a bubble is never blank.

Clicking the assistant launcher showed NOTHING until the sheet chunk loaded:
the dynamic import had no loading state at all. It now renders a skeleton in
the same geometry, and the chunk is warmed on idle so the click usually hits an
already-loaded module.

/chat's route skeleton drew a 320px sidebar while ChatSidebar mounts collapsed
as a 48px rail, so every load snapped one to the other. The skeleton now matches
what actually mounts, per breakpoint.

The first turn read agent_profiles twice: once in the route to build the intent's
prompt template, once again in run-turn for the system prompt. The route now
hands its result over. Ranked memory is deliberately NOT shared: the two queries
differ (the route's selects fewer columns and orders without is_pinned, and
run-turn needs ids to stamp last_accessed_at), so reusing it would silently
change both the prompt and memory touch.

Command palette's "Fråga Anna: ..." pointed at /chat?prompt=, but only /chat/new
reads ?prompt=, so the typed question was silently dropped and the user landed
on an empty state.

Verified: 9526 unit tests pass, lint clean and tsc clean on every touched file,
guards pass.

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

* fix(assistant): make the prefetches fail-safe and bounded

Review follow-ups on the staged-loading batch.

A rejected markdown import left the cached promise permanently rejected, so
every bubble for the rest of the session stayed on the plain-text fallback and
the rejection went unhandled. The cache is now cleared on failure so a later
surface retries, and the rejection is swallowed.

requestIdleCallback can defer indefinitely on a page that never goes idle; the
2s fallback only applied where the API is missing. The idle request now carries
a 2s timeout, and the warm import cannot produce an unhandled rejection either.

Adds the first-turn test for the profile-summary handover: it asserts the value
read for the prompt template is what reaches the turn, so a regression that
re-introduces the second read (or drops the template) fails.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:10:47 +02:00
Jakob Wennberg ee8ddb3849 fix(assistant): stop cross-user conversation access, bricked threads and lost sessions (#1209)
* fix(assistant): stop cross-user conversation access, bricked threads and lost sessions

Hotfix batch (PR1 of the assistant UI makeover, dev_docs/assistant_redesign_plan.md
section 7). No visual change; each of these is wrong today regardless of which
design lands, and three are unrecoverable per incident.

/api/agent/invoke never checked who owns a resumed conversation_id. RLS on
agent_conversations/agent_messages is company-scoped, not user-scoped
(20260517204000), so a member could post a colleague's conversation id, have
their history loaded into the prompt and read it back, while their own turns
were appended to that thread. The conversations list route filters on user_id
for exactly this reason. Also pins company and intent: resuming a thread from
another company would mix ledgers, and resuming under a different intent would
swap the tool whitelist under history the model has already seen.

A turn persists the assistant message carrying tool_use blocks before the tools
run, and their results only after the batch finishes. Dying in between (client
disconnect terminating the function, a deploy, a slow tool) left history ending
on an unanswered tool_use, which the Messages API rejects on replay: every later
turn 400s, and agent_messages is append-only for the BFL trail, so nothing could
repair it. History is now patched on read by synthesizing is_error tool_results,
leaving the stored trail untouched.

check_and_increment_agent_quota is SECURITY DEFINER in public with a
caller-chosen p_user_id, so any authenticated user could drain a colleague's
minute/day budget and lock them out of every agent endpoint. A plain REVOKE
would break the limiter (all three callers use the user's RLS client) and, as it
fails open, silently remove the spend cap: the function now refuses to act for
anyone but the caller, while service-role connections keep passing an explicit
id.

The single reject route re-read status and then wrote unguarded, so losing the
race with commit's atomic pending -> committing claim stamped `rejected` over an
operation that had already posted a verifikat, invisible to the committing-state
recovery sweep. Guarded on status like bulk-reject already is; a lost race is
now a 409.

The sheet's Escape handler listened on window with no defaultPrevented or target
check while the sheet is deliberately non-modal, so pressing Esc to dismiss the
reject-reason Select inside an approval card, the command palette or any dialog
unmounted the sheet and discarded the conversation, the streaming turn and the
un-actioned proposal. It now yields to open overlays and to focus outside the
sheet.

Verified: 9526 unit tests pass, lint clean on touched files, guards pass, and
the new pg-real test proves the quota guard against real Postgres (attacker
raises 42501, victim counters stay at 0). The four unrelated pg-real failures on
this machine reproduce identically with these changes stashed.

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

* fix(assistant): close anon path on the quota RPC, order the ownership check ahead of writes

Review follow-ups on the hotfix batch.

The caller guard used auth.uid() alone, which is NULL for the `anon` role just
as it is for backend roles, so an unauthenticated caller holding the public anon
key (it ships in the browser bundle) could still pick any p_user_id and drain
that user's quota. The guard now keys on the request role: anon and
authenticated may only ever spend their own quota, backend roles keep passing an
explicit id. The default PUBLIC execute grant is revoked as a second layer, with
execute granted only to authenticated and service_role. Covered by a new pg test
for the anon path.

The ownership check ran after the onboarding.intake stamp, so a request that was
about to be rejected could still write intake_completed_at. It now sits directly
after the capability gate, ahead of every side effect and ahead of the company
and profile reads, which also makes a rejected request cheaper.

The tool-result repair matched ids anywhere in the history, but the API needs
results in the message IMMEDIATELY after the tool_use. A result persisted after
an intervening turn (two turns racing on one conversation) left a shape that
still 400s. The repair is now positional, and orphaned or late-duplicate
tool_results are dropped, since an unmatched tool_result is rejected just as an
unanswered tool_use is.

The Escape guard matched the Radix popper wrapper, which stays mounted when a
popper is force-mounted; it now requires data-state="open" so a closed popper
cannot block Escape for the rest of the session.

Both new route errors are Swedish, per the user-facing error rule.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:48:18 +02:00
Jakob Wennberg 1270b6daeb fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion (#1204)
* fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion

A user who corrected a booking (storno + rättelse) and then discovered the
affärshändelse was already booked by another verifikat had no sanctioned way
out: reverseEntry refused source_type 'correction' alongside 'storno', and
correctEntry rightly rejects a zeroing rättelse (BFL 5 kap 5 §). The same
guard also broke uncategorize-after-rättelse, since bank transactions are
relinked to the correction entry.

- reverseEntry now blocks only 'storno' (storno-of-a-storno keeps the chain
  ambiguity problem); a correction entry is a regular live verifikat and can
  be stornoed, with correction_of_id keeping the chain traceable.
- CANNOT_REVERSE_STORNO copy narrowed to stornos + remediation hint.
- Supplier-invoice DELETE now allows unbooked, unpaid invoices in
  registered/approved/overdue: the daily overdue cron flipped unbooked
  invoices past due_date into a state where deletion was blocked forever.
  Orphan-safety checks (registration JE, payments, accrual schedule) are what
  actually protect the books. UI shows the delete button accordingly.
- LinkVoucherPicker showed customer-side copy (kundfordran/1510) in
  supplier-invoice mode; supplier mode now explains the 2440-debit
  requirement, including why a direct-cost verifikat cannot be linked.

Support case 2026-07-26 (marcus@).

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

* fix(supplier-invoices): review fixes: fail-closed orphan lookups, hide delete when payments loaded

- The payment and accrual-schedule lookups in DELETE now fail closed: a
  lookup error returns 500 instead of reading as "nothing linked" and
  letting the delete proceed unverified.
- The delete button also requires the loaded payment list to be empty,
  matching the server predicate.

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

* docs: authorize 'approved' in supplier-invoice delete allow-list (compliance-swarm V2.3)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:49:10 +02:00
Jakob Wennberg 968161b42b fix(documents): read attachments with service client so colleague uploads open (#1207)
The documents bucket SELECT policy only covers the uploader's own folder
(documents/{uid}/...), but document_attachments rows are company-scoped.
Every surface that touched storage with the user-bound client therefore
failed for attachments uploaded by another member of the same company
(colleague uploads, email-inbox ingest attributed to the company creator):

- GET /api/documents/:id 500ed with "Failed to create download URL", so
  viewing a bilaga on a verifikat or supplier invoice was broken for
  every member except the uploader (support case: Odin Aero, where all
  40 documents live in the owner's folder and the second member could
  open none of them).
- GET /api/documents/:id/integrity 500ed the same way.
- POST /api/documents/:id/verify failed the storage download.
- invoice-inbox retry-extraction could not download the attachment.
- cloud-backup user-triggered syncs silently dropped colleague-uploaded
  documents from the Drive archive (manifest rows flipped to 'error').

Fix: authorize on the user client (RLS + explicit company filter, plus
the membership check where present), then do the storage read with the
service-role client. This is the pattern the inline proxy route and the
v1 download route already use; these five call sites were left behind.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:34:11 +02:00
Mattsson d54b43f80f Bug/resend and invoices (#1192)
* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

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

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

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

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

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

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

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

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

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

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

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

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:56:17 +02:00
Jakob Wennberg b5e3c476a5 fix(articles): article delete failed for everyone on a phantom column (#1188)
* fix(articles): article delete failed for everyone on a phantom column

Support report (odinaero.se): three unused articles could not be
deleted. Root cause: the DELETE route's usage preflight filtered
invoice_items by company_id, a column that table does not have, so
PostgREST answered 42703 and the route mapped it to
ARTICLE_DELETE_FAILED on every delete since 8a9a930f. The queued
supabase mock swallows chained filters, which is why tests stayed
green; a source-pin regression test now guards the query shape.

Tenancy is unaffected: the preceding articles lookup already scopes
the id to the company, and invoice_items rows are only reachable via
that article's UUID.

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

* docs: decision log for the currency-honesty batch and the phantom-column lesson

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:11:02 +02:00
Jakob Wennberg 5afd031306 fix(invoices): keep the stored ROT/RUT personnummer when editing a draft (#1186)
Fixes #1175. The stored personnummer exists only as AES-256-GCM
ciphertext (+ last4), so the editor cannot rehydrate it and sent an
empty string; buildInvoiceWriteData then failed ROT/RUT validation and
every edit of a draft deduction invoice was blocked with "Personnummer
krävs för ROT/RUT-avdrag" unless the user re-entered the customer's
personnummer.

buildInvoiceWriteData accepts the stored ciphertext from the update
path: an empty field on an invoice that still has deduction lines
means keep, a typed value replaces, and removing every deduction line
clears as before. The editor hint shows the kept last4 in edit mode
(new i18n key, sv+en).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:59:50 +02:00
Jakob Wennberg 36a1df4f6b feat(import): detect and import the article register's Valuta column (#1183)
Fixes #1167. The register export gained a Valuta column in #1166 but
the importer ignored it, so re-imported non-SEK articles silently
became SEK, breaking the export -> edit -> re-import round-trip.

- Column detector recognizes valuta/valutakod/currency (claimed before
  generic columns; no keyword collision with Momskod).
- Parser normalizes to upper-case ISO shape, drops malformed codes
  with a file-level warning, and carries currency per row.
- Execute route validates codes lazily against the currencies table
  (FK stays the backstop when the reference read fails), imports valid
  codes, defaults absent to SEK, and in merge mode only overwrites
  when the file explicitly carries a valid currency.
- Edit step shows a muted currency marker next to non-SEK prices;
  manual column mapping offers Valuta.
- Export docblock caveat removed.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:59:23 +02:00
Jakob Wennberg 6d5a435ed9 fix(reports): drop the kr suffix from invoice-currency AR-ledger columns (#1180)
Fixes #1172. The Fakturor sheet's Totalt/Betalt/Utestående columns are
invoice-original currency (the sheet carries a Valuta column and a
separate SEK-converted Utestående (SEK) column), yet used the
kr-suffixed currency format, so a EUR invoice rendered "1 000,00 kr"
beside Valuta=EUR. They now use the suffix-free decimalColumn added in
#1166; the SEK column and the SEK-converted aging sheet keep kr. The
supplier-ledger xlsx was checked: its buckets are SEK-converted via
resolveSekAmount, so kr is correct there.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:59:05 +02:00
Jakob Wennberg e029376c08 fix(suppliers): per-currency stats and honest currency labels on the supplier page (#1177)
* fix(suppliers): per-currency stats and honest currency labels on the supplier page

Fixes #1169. The supplier detail page stamped "kr" on every invoice
amount although supplier_invoices carry their own currency, and the
stats endpoint summed remaining/paid amounts across currencies into
one number: a EUR + SEK supplier got a meaningless mixed total.

GET /api/suppliers/[id] now groups total_outstanding/total_paid per
invoice currency (öre-rounded per the money-math rule) and the page
renders one line per currency, "kr" only for SEK. Table cells and the
mobile Kvar line carry the invoice's currency the same way.

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

* fix(suppliers): use roundOre from lib/money (antipattern ratchet)

The naive Math.round(x * 100) / 100 form is blocked by check:guards
(subtly wrong on exact-half values); lib/money.roundOre is canonical.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:58:38 +02:00
Jakob Wennberg 17dc5f12f6 fix(articles): non-SEK price support + reinstated deactivate (support: odinaero.se) (#1166)
* fix(articles): stop losing and mislabeling non-SEK article prices

Support report (odinaero.se): EUR article prices did not stick and the
register showed every price in kr. Three concrete defects, one cause:
articles.currency existed in the DB and API but the UI dropped it.

- Edit dialog omitted currency from initialData, so ArticleForm fell
  back to SEK and every save silently reset an EUR article to SEK.
- Register list and detail page formatted prices without the article's
  currency, rendering EUR amounts as "kr".
- "Spara som artikel" in the invoice editor posted the line price
  without the invoice's currency, so lines from EUR invoices became
  SEK articles.
- The xlsx/csv register export stamped the kr-suffixed currency format
  on every price; prices now use a new suffix-free decimalColumn and a
  Valuta column carries the per-article code.

Follow-ups (not in this diff): the article importer does not detect a
Valuta column yet, and the MCP create/update_article staged schemas
have no currency param (agent-created articles stay SEK).

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

* fix(articles): reinstate deactivate/activate on the article detail page

Support report (odinaero.se): no button to set an article inactive.
Commit 8a9a930f turned DELETE into a hard delete and removed the
deactivate action, but hard delete is refused for articles referenced
by invoice lines (ARTICLE_IN_USE), leaving used articles with no
retire path even though the API, the list badge and the i18n keys for
deactivation all still exist.

Adds an Inaktivera/Aktivera button next to Redigera that PATCHes the
active flag (confirm dialog on deactivate, none on reactivate) and
stays on the page so the status badge reflects the change. Reuses the
orphaned deactivate_* keys; adds the three missing activate_* keys in
both locales.

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

* fix(customers): stop resetting customer language to Swedish on every edit

Same defect class as the article currency reset in this branch: the
customer edit dialog's initialData omits language, CustomerForm
defaults it to 'sv' and submits every field, and the PATCH route
applies it. Editing any detail on an English-language customer
silently flipped their invoice PDFs and emails back to Swedish.

Found by a repo-wide sweep for hand-picked initialData edit dialogs;
customers, suppliers and articles are the only three such call sites,
and suppliers passes every form field already.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 11:39:49 +02:00
Jakob Wennberg 98886e68d8 fix(vat): keep the RC-basis worklist visible until every voucher is fixed (#1164)
Correcting a single voucher cleared the momsdeklaration's RC_BASIS_MISSING
error and the whole per-voucher worklist with it: the check tested mere
presence of ruta 20-24 basis, the stepper re-derived its landing step and
yanked the user to Granska mid-work, and the remounted checks card never
refetched gaps once the aggregate check stopped firing. The declaration
then claimed "klart" while the remaining vouchers still under-reported
rutor 20-24 (FK004).

- Make RC_BASIS_MISSING/RC_OUTPUT_MISSING proportional: compare reported
  basis against the basis the per-rate output boxes imply (moms/sats),
  with a 0.5% + 1 kr tolerance for per-voucher ore rounding.
- Fetch the rc-basis-gaps worklist once per period, ungated from the
  aggregate check, so remaining rows survive remounts.
- Latch the automatic stepper landing once per period so a refetch after
  a korrigering cannot navigate the user off Kontrollera.
- Resolve rc-basis-gaps against the rakenskapsar (fiscal_period_id) for
  helarsmoms, matching the declaration totals; a calendar span hid gap
  vouchers in the tail of an extended first year.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 22:10:20 +02:00
Jakob Wennberg 8a7fd567bd fix(settings): validate share-capital pair before saving (#1137) (#1160)
* fix(settings): validate share-capital pair before saving (#1137)

Entering aktiekapital without antal aktier (or vice versa) died on the DB
pair constraint company_settings_share_capital_pair as a raw 500 with the
generic 'Vardet uppfyller inte de tillatna kraven' toast. The pair rule
(ARL 5 kap 14 $: the aktiekapital note needs both values) now surfaces as
a clear 400 in the PUT route, checked against effective body-or-stored
values so partial API updates are covered too. The form additionally marks
each field required when its sibling is filled, so the browser blocks a
one-sided submit before the request is sent.

Fixes #1137

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

* docs: decision-log entry for share-capital pair validation placement

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

* docs(settings): correct the share-capital note citation to ÅRL 5 kap 34 §

5 kap 14 § is ställda säkerheter; the antal aktier/kvotvärde note is 5 kap 34 §
(flagged by the Swedish compliance review bot, verified against the
swedish-financial-reporting skill).

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

* test(settings): assert the pair message on the one-sided-clear rejection

CodeRabbit review on #1160. Its second suggestion (assert the update
payload on the partial-update test) is skipped: createQueuedMockSupabase
proxies away builder args, so payloads are not recordable, same as every
other test in this suite.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:07:24 +02:00
Jakob Wennberg d0fb72dc63 feat(kpi): Nyckeltal as Instrumentbrädan — instrument panes, monthly bars, cost list (#1148)
* feat(kpi): Nyckeltal as Berattelsen (serif month hero + metric rail + quiet cost rows)

The founder-picked concept variant: the month's result as a serif hero
with a +/- delta sentence against the previous month, a single sage net
area chart (income/expenses ride in the hover tooltip), and a hairline
metric rail on the right still driven by the user's KPI preferences
(Anpassa, formula tooltips, all seven definitions supported). The cost
story renders as quiet bar rows: expense classes 4xxx-7xxx and top five
suppliers. Replaces the four-tile + three-Recharts-card layout;
KPIHeroCards, KPITrendChart, KPIExpenseMixChart and KPITopSuppliersChart
are deleted. FyPicker replaces FiscalYearSelector; help behind ?.
No API changes: everything derives from the existing KPIReport.

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

* feat(kpi): switch Nyckeltal to Instrumentbradan (founder pick v2)

Berattelsen replaced by the instrument-pane grid on founder review:
monthly result bars as plain SVG (muted months, latest in sage or
terracotta when negative, compact endpoint label, per-bar tooltips)
plus one bordered pane per visible preference KPI, with the
receivables pane carrying a two-segment not-due/overdue strip. The
cost story rows below are unchanged. Recharts leaves this page
entirely (KPIResultChart deleted). Anpassa, formula tooltips and all
seven KPI definitions still supported.

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

* feat(kpi): concept-true cost list and cash runway note

Founder review against the concept: the report now carries
topExpenseAccounts (top five BAS 4-7 accounts for the period, computed
from the trial-balance rows the route already holds) and the page
renders them as the full-width Storsta kostnaderna rows with account
numbers, exactly like the concept. The Kassa pane derives its 'Tacker
cirka N dagars utgifter' note from the period's daily burn so far.
Class-composition and supplier columns leave the UI (data stays on the
API). Route test extended for the new field.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:43:42 +02:00
Jakob Wennberg 9dfa6c6708 feat(home): first-run block as a numbered four-step thread with partner marks (#1149)
* feat(home): first-run block as a numbered three-step thread with partner marks

The founder-picked stepped shape for 'Hur vill du komma igang?':
1 Fa in din bokforing (primary Flytta bokforingen + Fortnox/Visma/Bokio
marks + '+ SIE'; Starta fran borjan as an inline alternative that just
checks the step off), 2 Koppla banken (Enable Banking mark only),
3 Bygg din bokforingsassistent (Beta chip, no vendor logo). Dots walk
number -> filled active -> sage check; the persisted state machine is
unchanged, but choosing a path no longer auto-completes the setup:
the block retires when all three steps are done (or via Dolj).
DashboardContent's build-assistant hero now waits until the checklist
is gone so the assistant is not pitched twice. initial_setup i18n
rewritten for the stepped copy (sv+en), unused selected-state keys
removed.

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

* feat(home): Skatteverket as step three, assistant last, ticked steps collapse

Founder feedback on #1149: the thread is now four steps: 1 Fa in din
bokforing, 2 Koppla banken, 3 Anslut Skatteverket (with the SKV mark,
BankID authorize link; skipped entirely in builds without the
skatteverket extension), 4 Bygg din bokforingsassistent. A completed
step drops its description and actions and keeps only the checked
muted title, so the fresh-start pitch never lingers after the books
are in. The heading counts honestly ({count} steg) and completion now
requires all four steps.

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

* polish(home): one-line checklist header

Founder feedback: the title and sub-line said the same thing twice;
only '4 steg sa ar bokforingen igang' remains (Dolj stays beside it).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:37:51 +02:00
Mattsson 63fd5311ed Bug/tic unlink (#1153)
* fix(tic): allow BankID link/unlink without a company context

/bankid/link and /bankid/unlink are user-level actions, but the extension
dispatcher resolved an active company for them, so a zero-company user
(fresh BankID signup, pre-onboarding) got a 500 'No company context' when
managing the connection from /settings/account. Mark both routes
skipCompanyContext and resolve the caller in-handler via requireAuth(),
which preserves the dispatcher's MFA/AAL2 enforcement.

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

* fix(tic): return 409 account_exists instead of 500 on BankID signup with taken email

The signup guard pre-checked profiles.email, but the authoritative store
is auth.users: anonymized account tombstones (and any profile drift) hold
the email in auth.users while profiles.email is NULL. The guard missed,
createUser failed with email_exists (422), and the route surfaced a
dead-end 500 'Kunde inte skapa kontot. Forsok igen.' where retrying can
never succeed.

Drop the profiles pre-check and let createUser's own uniqueness check be
the guard: map email_exists to the existing 409 account_exists response
(Swedish message), which the register page already handles with a toast
and a redirect to login. Also removes the TOCTOU window between the old
pre-check and createUser.

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

* fix(account): actually scrub auth.users metadata on account deletion

The delete route passed user_metadata: {} / app_metadata: {} to
auth.admin.updateUserById assuming replace semantics, but GoTrue MERGES
metadata maps, so the wipe was a silent no-op: the ~100-year tombstone
kept the user's full name in raw_user_meta_data (verified on production
2026-07-24).

Move the scrub into anonymize_user_account (migration 20260724150000):
raw_user_meta_data is cleared entirely, raw_app_meta_data drops the
app-specific keys (bankid_linked, has_password) while GoTrue's
provider/providers stay, and auth.users.email is still retained as the
documented legitimate-interest tombstone. The migration also repairs
existing tombstones (guarded by profiles.anonymized_at). The route keeps
only the ban, which the DB function cannot set.

Migration content already applied to staging; pg-real test extended.

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

* docs: log BankID signup guard decision

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

* fix(account): address PR review findings on anonymize scrub

- anonymize_user_account now rejects repeat invocations against an
  already-anonymized tombstone (SQLSTATE P0002) instead of re-churning
  the scrubbed row
- note that the tombstone repair UPDATE runs atomically inside the
  migration transaction
- tic signup failure log hashes the email (sha256 prefix, matching the
  pnrHashPrefix pattern) instead of logging the raw address
- pg-real test for the double-invocation guard

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

* fix(anonymization): ensure raw_app_meta_data is not null before scrubbing keys

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:36:35 +02:00
Mattsson 53e343ee92 Bug/invalid imports (#1146)
* feat: add Accounted MCP namespace

* fix(bookkeeping): stop flagging verifikat whose underlag lives on a referenced supplier invoice

The missing-underlag surfaces only accepted a document directly linked to
the entry, so payment verifikat for supplier invoices (doc on the
registration entry per design) and entries whose doc was pinned to the
bank transaction before matching were falsely flagged; opening the entry
showed the referenced doc and cleared the warning client-side, and it
came back on reload.

- verifikat_without_documents + transactions_without_documents now treat
  an entry as covered when a supplier invoice referencing it (registration
  or payment FK, or a supplier_invoice_payments row) carries a document
  anchored to a journal entry (BFL 5 kap 7 paragraf hänvisning till
  underlag; anchoring required because the WORM deletion guards key on
  document_attachments.journal_entry_id)
- match-supplier-invoice routes (dashboard + v1) propagate the
  transaction's pinned document onto the payment verifikat, mirroring the
  categorize route; migration backfills rows already written (open
  unlocked periods, company-guarded, never steals a linked doc)
- /api/documents/counts, the transactions-page badges, the bulk "Inget
  underlag krävs" count and the push-notification scheduler share the
  same reference-aware predicate, so every surface agrees with the RPC
- counts route validates journal_entry_ids as UUIDs (they are
  interpolated into a PostgREST or-filter)

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

* fix(transactions): align table columns flush with page edges

Collapse the checkbox gutter column to zero width and hang the
hover-revealed checkbox/expand chevron in the page margins, drop the
outer padding so DATUM sits flush left and STATUS flush right, and
tuck the overflow-menu dots under the middle of the STATUS header.
Applied to both the inbox and history tables so they stay identical.

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

* fix(arsredovisning): tie anlaggningstillgangar note to booked depreciation

The ARL 5:8 roll-forward note recomputed depreciation from its own
day-based linear formula (365.25/12 month length, non-inclusive day
count, linear only), drifting ~20 kr per year per asset from the
ledger-driven resultat- and balansrakning and misstating non-linear
methods entirely. Note figures now come from posted
depreciation_schedules rows (the same source disposeAsset reverses),
falling back to the engine's computeAnnualDepreciation when nothing is
posted; pre-onboarding opening balances iterate prior years through
the engine. Adds a note-vs-trial-balance tie-out warning (accounts
1000-1299, over 1 kr) surfaced before download.

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

* refactor(stripe): move connect and sync surface from settings to import page

Stripe's transaction feed is a continuous import source in the same
category as the PSD2 bank connection, so its connect/sync surface now
lives on the import page as a source card (mode=stripe), gated
"kommer snart" on hosted like before; self-hosted keeps the full panel.

- Import page: Stripe card after Koppla bank, renders the existing
  StripeSettingsPanel via the settings-panel registry
- OAuth callback and panel cleanup return to /import?mode=stripe
- Settings > Betalningar retired: nav item removed, route redirects,
  PaymentsSettingsContent deleted, legacy ?tab=payments mapped
- New import.stripe_* strings in sv+en; dead settings_nav.payments removed

Crons and sync logic unchanged; payment-link settings stay in the
invoicing section.

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

* fix(underlag): paginate missing-underlag cron and harden doc-surface queries

Resolve PR review findings on bug/invalid-imports:
- notification-scheduler: fetchAllRows on all 5 global reads; past 1000 rows
  the capped reads produced false "saknade underlag" notifications
- bulk-missing: LOOKUP_CHUNK 300->150 so the twice-embedded .or() id list
  stays under the PostgREST URL limit
- bulk-missing + transactions page: UUID-guard the .or()-interpolated id
  lists, matching documents/counts
- match-supplier-invoice (dashboard + v1): log documentId/journalEntryId on
  the non-fatal doc-link warning
- well-known/oauth-protected-resource: document the tool_namespace allow-list
- messages/en: reword stripe_description
- DECISIONS.md: record the asset ibAck tie-out and Tailwind !important calls

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

* fix(tic): convert registrationDate from Unix seconds to millisecond epoch in lookup and profile tests

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:03:50 +02:00
Mattsson d840257c0c Add/stripe connect transactions (#1139)
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login

Add chatgpt.com/connector/oauth/* (per-instance) and the legacy
chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth
redirect allowlist so ChatGPT MCP connectors can register and authorize.

Fix the login page dropping the ?next= destination: an OAuth-initiated
visit that required login previously ended on the dashboard and the
connection flow silently died. Login now resumes to the sanitized next
path (hard navigation, since the consent page is route-handler HTML),
carries it through the MFA step-up as returnTo, and /mfa/verify
hard-navigates for /api/ destinations.

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

* fix(transactions): dedup incoming feed rows against booked hand-entered twins

Users who bookkeep via MCP/chat first and connect their bank afterwards got
the same movement twice: the synced row's external_id lives in a different
namespace, the free-form manual title never text-bridges the bank's raw
string, and the cross-channel mirror deliberately excluded manual/mcp rows.

Extend the mirror with a booked-hand-entered track: an incoming feed row is
skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count-
symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be
booked (staged rows never consume an import), currencies must not contradict
(bucket key is date+ore only), the cash-account guard applies to the count
exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming
count so an already-stored row cannot inflate it. Consumption stamps the
batch cash_account_id onto an account-unbound hand row, so one hand row can
never consume feed rows on other accounts in later syncs.

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

* feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit)

Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style:
strike lines inside a posted verifikat with replacements in the same
voucher, and correct description/entry_date without an andringsverifikat.
Envelope: posted entries, open unlocked periods, company lock date,
same-period date moves, structural/FX/doc-linked lines excluded, and a
reconciliation guard preserving per-account net on bank/reskontra sides of
externally linked entries. Every rattelse writes an immutable who/when row
(journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and
struck originals render struck-through in the verifikat; list rows and the
detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the
swedish-accounting-compliance skill are amended to state the two-track
rule. Staging carries the DDL; prod gets it on merge.

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

* feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB

- Manual journal entry: saldo column now shows before -> after computed
  from the typed debit/credit amounts (direction feedback while booking)
- Resultatrapport: a narrowed date range now compares against the same
  window shifted one year back (#862), merged across fiscal periods for
  brutet rakenskapsar; P&L rows report window activity instead of
  rolled-forward YTD closing
- Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab,
  settings > assistant), sidebar entry unaffected; collapsed sessions keep
  their reopen handle

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

* feat(stripe): sync balance transactions as a bank feed on 1686

Import the connected Stripe balance into the transactions inbox, opt-in
per connection (transaction_sync_enabled on stripe_connections):

- Balance transactions map to feed rows with the two-row gross+fee split
  and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on
  created, bound to a provisioned "Stripe-saldo" cash account on 1686 so
  booking settles against the clearing account by construction.
- Double-booking protection: settled payment-link charges import
  pre-linked to their settlement entry; payout rows import pre-linked to
  the payout entry; processPayoutPaidEvent claims the payout's fee rows
  at booking time (linkPayoutFeedRows, idempotent from both directions).
- Cursor last_balance_txn_synced_at with 24h overlap; first run
  backfills 90 days floored at the day after the company lock date.
- Nightly cron /api/extensions/stripe/transactions/cron (03:30),
  transaction-sync toggle route, "Synka nu" covers both feeds, settings
  panel toggle with last-synced/backfill note, sv+en strings.
- Migration 20260723200000 (applied to staging).

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

* fix(transactions): offer match-to-voucher on unbooked history rows

Unbooked transactions with is_business already set (e.g. left behind when
a voucher was removed without a full uncategorize) land in the history
list instead of the inbox, where the match-against-existing-voucher
action did not exist, leaving them with no path back to voucher
matching. Add the same menu item to the history list for unbooked rows.

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

* feat(transactions): enhance ownership checks and error handling in journal entry routes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:16:20 +02:00
Jakob Wennberg 2175eccddd feat(salary): concept Löner landing + nav collapse animation (UI migration PR 9) (#1130)
* feat(salary): concept scene 22 landing + smoother nav collapse

Loner gets the concept header (quiet Anstallda link + Starta lonekorning
primary), the hero de-cards to a flat serif line + description + pill,
and the runs list becomes the scene 22 dry-table (Period, Status,
Anstallda, Bruttolon, Netto) where booked runs read as muted text and
in-flight runs carry a chip plus the payout date. Attention cards and the
hero state machine survive unchanged.

Nav polish: the collapse now animates as one movement (300ms decelerating
curve on both the aside width and the panel margin) and the rail/full
contents slide+fade in on swap; the Register/Bokslut folds match the
RowFoldout timing.

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

* refactor(salary): strip hero and attention cards from the landing

Founder direction: the Loner landing is header + lonekorningar only
(concept scene 22). The open run's table row is the entry point; AGI,
skatt, blockers and semester surfaces live on the run detail and the
employee register. Drops the hero state machine, the four cards, and all
their data plumbing (deadlines query, tax-payment chain, SKV status,
AGI submission hook).

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

* fix(salary): drop the Lonekorningar eyebrow

Founder feedback: the uppercase eyebrow read as a stray font above the
table, and with the landing reduced to the runs table alone the label is
redundant under the Loner page title.

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

* fix(salary): populate the Anstallda column via a count embed

The runs list selected bare salary_runs rows, so the landing's Anstallda
column was always empty (the employees array only ever existed on the
detail response). The list now embeds employee_count via
salary_run_employees(count) and the page reads the PostgREST count shape.

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

* fix(salary): quiet beige chip for in-flight run states

Founder feedback vs the concept: the Granskning chip rendered as the
bordered ochre warning badge and read noisy next to the concept's quiet
Utkast chip. Draft/review/approved now all wear the beige secondary chip;
the payout-date note carries the urgency.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:17:20 +02:00
Jakob Wennberg d59e4708cf feat(nav): concept sidebar with folds, collapse rail, and user menu (UI migration PR 2) (#1133)
* feat(nav): concept sidebar with folds, collapse rail, and user menu (UI migration PR 2)

The concept's navigation, exactly, with all current functionality kept:

- Groups restructured per concept: top (Hem, Assistenten), ARBETA
  (Bokforing, Underlag, Transaktioner, Granskning, Kundfakturor,
  Leverantorsfakturor, Loner), ANALYS, DATA (Register fold +
  Importera/exportera), SKATT & BOKSLUT (Moms, Skattekonto, Viktiga
  datum, Bokslut fold). Entity/capability/dimension gating unchanged.
- Register and Bokslut are animated folds (grid-rows 0fr/1fr), children
  text-indented behind a hairline; closed by default, forced open by an
  active child route; state persists per user.
- Sidebar collapses to a 64px icon rail (toggle top of rail); width is
  one inline --nav-w CSS variable on #dash-shell that aside and <main>
  both read, so the panel follows in lockstep. Server-rendered from
  ui_state so first paint is right.
- Sticky bottom user block (avatar, name, active company) opening an
  upward user menu: identity, company-switcher flyout (search + building
  glyphs + roles + check, real switch mechanism via shared
  lib/company/switch-client), Installningar, Medlemmar och roller,
  Abonnemang, Hjalp, support, terracotta logout. Trial touchpoint kept.
- CompanySwitcher removed from desktop top (lives in the user menu now);
  mobile bottom nav + sheet unchanged.
- New migration 20260723120000: user_preferences.ui_state jsonb bag
  (founder-approved) + POST /api/user/ui-state (requireAuth, strict zod,
  merge semantics) with route tests.
- i18n: fold/collapse/menu keys added sv+en; deadlines -> "Viktiga
  datum", year_end -> "Arsbokslut" per concept.

Discord-community row deferred: no invite URL exists in the repo.
Badges stay the current two (Transaktioner, Granskning); an Underlag
count is a follow-up with lib/worklist.

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

* fix(nav): brand-mark sidebar header + auto-hiding scrollbars

Concept alignment feedback: the sidebar gets a header row (brand mark
left, collapse toggle right) hanging from the same top line as the
panel, instead of a lone right-aligned toggle.

Scrollbars go overlay-style app-wide: transparent at rest, revealed only
while their container scrolls (ScrollbarReveal stamps .is-scrolling via
one capture-phase document listener), fading out after 700ms idle. The
gutter stays reserved so revealing never shifts layout.

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

* fix(nav): company flyout opens downward + Discord community row

The flyout was bottom-anchored to its row and grew upward over the menu;
founder feedback: top-align with the row and grow downward. Adds the
Discord community row to the user menu (external invite link).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:28:07 +02:00
Mattsson 288915c152 Fix/fdb fr usrs (#1125)
* fix(invoices): return attachment filename in delivery history summaries

The 20260723003000 hardening dropped attachment_filename from
list_invoice_delivery_summaries, so the delivery history UI always fell
back to the generic "faktura.pdf" label. Recreate the RPC with the
filename included: it is derived from company name, customer name,
invoice number, and date, all already visible to every company member,
so the minimization boundary is unchanged. Addresses stay masked and
message content, BCC, and checksums stay server-side.

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

* fix(reconciliation): surface own-account transfer legs in match-to-voucher by default

The second (incoming) leg of a transfer between two of the company's own
bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog
because the voucher counted as 'already matched' once its outgoing leg was
linked, even though the incoming account's line had no settling transaction.
Users read the empty default list as 'the app won't let me link this'.

get_account_gl_lines_for_matching now counts links per settlement account:
a transaction provably on another cash account no longer marks the voucher
as matched for the requested account, so the unsettled transfer leg surfaces
by default (and auto-selects on an exact match). Same-account N:1 stays
behind the 'Visa aven matchade verifikationer' opt-in, and transactions
without a resolvable cash account conservatively keep counting everywhere.
get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile).

Companion guard: mark_entry_as_opening_balance now refuses entries with
linked bank transactions, since half-settled transfer vouchers became
reachable in the reconciliation view's unmatched table where 'Mark som IB'
renders; re-tagging one would strand its transaction against a movement-
excluded entry. getReconciliationStatus counts unmatched GL lines with the
account-scoped RPC so the status card agrees with the table.

Fixes #1026

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

* perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs

Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of
requests over 300ms. Target: p95 under 300ms.

- requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of
  a second network getUser per request; getUser fallback keeps HS256
  self-hosted and existing test mocks working; middleware still
  revocation-checks every /api request
- resolve_active_company RPC (20260723161000): one round trip replaces
  2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall
  back to the legacy query path
- arsredovisning build-data: ~33 sequential round trips down to ~7,
  output byte-identical (snapshot-proven)
- currency rate route: stop bypassing the exchange_rates cache (missing
  supabase arg caused an external Riksbanken call on every request)
- document.get: parallelize row fetch, signed URL and audit event
- list_company_accounts RPC (20260723170000): accounts list in one round
  trip instead of paging past PostgREST's 1000-row cap
- vat-declaration route: drop a dead sequential company_settings query
- get_kpi_report_aggregates RPC (20260723180000): KPI report's three
  full-period line scans collapsed into one aggregate call; dimension-
  filtered path unchanged
- lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to
  warn, zero the eslint baseline ratchet

All four gates green: lint 0 errors, 9163 tests, check:guards, build.
Migrations applied idempotently to staging only; prod receives them via
Supabase branching on merge.

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

* fix(review): resolve PR review findings across auth, VAT declaration, and IB retag

- requireAuth getClaims fast path: pin iss (project URL) and aud
  ('authenticated'), log every fallback to getUser (ASVS V9.1 finding)
- remove the ignored accountingMethod parameter from calculateVatDeclaration
  and the dead company_settings.accounting_method reads in xlsx/pdf/eskd
  routes; v1 API keeps accepting the query param but documents it as a no-op
- close the mark_entry_as_opening_balance TOCTOU race with a transactions
  trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests;
  applied to staging and smoke-verified both directions
- re-add the 42501 tenant guard to branch-local migration 20260723160000
  (function body had silently reverted to the pre-20260619130100 definition)
- document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt
  opening balance per BFNAR 2012:1 ch.29)
- add KPI VAT-liability test covering reduced-rate output accounts 2621/2631

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

* fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard

The re-added tenant guard carried the pre-20260703180000 raw
NOT IN (SELECT user_company_ids()) pattern, which the
null-safe-tenant-guards ratchet blocks. Staging re-synced.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:16:55 +02:00
Mattsson 43f7ccab9e feat(invoices): allow BAS class 1-3 posting-account overrides and complete the aktiekapital note (#1121)
- invoice/article posting-account overrides accept active class 1-3 accounts;
  class 1-2 (balance-sheet) accounts are rejected on VAT-bearing lines so the
  ruta 05 tax base always books to a 3xxx account
- shared posting-account regex across server schemas, pending-operation
  re-validation, and client forms
- share-capital settings (aktiekapital/antal_aktier) feed the annual-report
  note; kvotvarde derived per ABL 1 kap 6 $; all-or-nothing pair constraint
- signed per-rate VAT breakdown on credit-note PDFs; U+2212 to ASCII hyphen

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:16:00 +02:00
Mattsson b0044bfe98 fix(arsredovisning): make the aktiekapital note completable via compa… (#1118)
* fix(arsredovisning): make the aktiekapital note completable via company settings

The annual report warned every AB that the aktiekapital note was missing
and pointed at Installningar -> Foretag, but the referenced columns
(aktiekapital, antal_aktier, kvotvarde) never existed and no settings UI
was ever built, so the warning was a dead end and no AB could produce a
complete note before Bolagsverket filing.

- migration 20260723103000: company_settings.aktiekapital (numeric) and
  antal_aktier (integer) with positive CHECKs; kvotvarde is intentionally
  not stored since ABL 1 kap 6 defines it as aktiekapital / antal aktier
- build-data.ts (K2 and K3 note paths): select only the two stored
  columns and derive kvotvarde with roundOre
- UpdateSettingsSchema: aktiekapital (positive), antal_aktier (positive
  integer), both nullable to allow clearing
- new ShareCapitalForm section on Installningar -> Foretag, rendered for
  aktiebolag only, with live derived kvotvarde display; wired through the
  existing CompanySettingsContent save path (empty string clears to null)
- sv/en strings; settings route tests (round-trip, clear, 400 on invalid);
  builder tests for derived kvotvarde and the empty-settings warning

Staging (metjnjrhvujscngnpzdv) already has the columns applied and the
note verified end-to-end against a rehearsal company.

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

* fix(arsredovisning): address PR review findings on the share-capital note

- enforce aktiekapital/antal_aktier as an all-or-nothing pair (DB CHECK,
  K2/K3 note guard now requires both, partial pair warns instead)
- numeric(15,2) column, .int() Zod constraint, maxFractionDigits 0 render
- guard numberOrNull against NaN; align kvotvarde preview with schema
- strengthen clearing test, add fractional and partial-pair tests

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:41:14 +02:00
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00
Mattsson 321e684523 Fix/usr fdbck ch (#1105)
* fix(privacy): mask voucher amounts in session replays

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
2026-07-22 18:49:57 +02:00
Jakob Wennberg 3e1ea29d02 fix(pending-ops): record posted ids and land failed_partial instead of clean rejected after partial commits (#842) (#1110)
Multi-step executors (match_transaction_invoice, credit_invoice) post an
irreversible voucher or persist a credit note and then run later fallible
steps. A failure there previously marked the whole op status=rejected,
hiding the posted entity and its id from operators.

- new migration 20260722134114: add failed_partial to the
  pending_operations status CHECK and treat it as terminal in both
  immutability triggers (immutable, undeletable, never re-claimable)
- PartialCommitError + ExecutorResult.partialPostedIds carry the posted
  ids; the dispatcher writes status=failed_partial with
  result_data.posted_ids and returns code=partial_commit
- instrument only the two named executors; hoist the read-only
  settlement-account resolution above the storno in the match executor
- consumer sweep: status union + query schema widened, failed_partial
  folds into the Avvisade tab with a badge and posted-ids detail line,
  bulk/reject routes and MCP tools message it explicitly, worklist and
  expiry sweep intentionally untouched (not pending work)
- tests: pg-real coverage for the new terminal semantics, dispatcher unit
  tests for both partial paths plus byte-for-byte regression guards

Fixes #842

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:33:49 +02:00
Jakob Wennberg 25a7261eda fix(pending-ops): recovery sweep for operations stuck in committing (#843) (#1108)
The commit dispatcher claims an op with an atomic pending -> committing
CAS; if the process dies after side-effects post but before the terminal
committed write (or that write fails, the PR #841 log line), the row sat
in status='committing' forever: the expire cron only sweeps 'pending'.

Add lib/pending-operations/recover-stuck-committing.ts, invoked from the
existing daily expire cron (no new vercel.json entry):

- Only rows whose updated_at (the claim timestamp: the CAS bumps it via
  the update_updated_at_column trigger) is older than 15 minutes, well
  past the 300s Vercel function ceiling, so in-flight executors are
  never raced.
- Positive evidence that side-effects posted finalizes the row to
  committed with result_data.recovered=true. Evidence exists only where
  params identify a target with an unambiguous posted state:
  categorize_transaction (is_transaction_booked RPC, skipped for
  allow_duplicate), link_transaction_journal_entry (exact tx+entry
  link), match_transaction_invoice (invoice_payments pair row).
- No evidence: terminal rejected with an explanatory result_data,
  never back to pending (re-execution could duplicate side-effects
  that posted without a trace). Reason 'stuck_committing' is distinct
  from 'expired' so the UI badge never claims these rows.
- Every terminal write is CAS-guarded on status='committing'; probe
  errors skip the row for the next run.
- One structured 'pending_op_recovery' warn per row (count by outcome);
  runbook comment added next to the #841 finalize-failure log line.

Tests: unit coverage for the decision logic and cron wiring (401, sweep
invoked, failure isolation), plus a pg-real test proving row selection,
the trustworthy updated_at anchor, committing -> terminal transitions
through the real immutability/input-frozen triggers, and the
is_transaction_booked evidence substrate.

Fixes #843

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:30:33 +02:00
Jakob Wennberg 4a0b524fbb fix(categorization): connect card descriptors to counterparty history (#1095)
* fix(categorization): connect card descriptors to counterparty history

suggest_categories returned no signal for recurring card merchants
(reported: Anthropic booked to 5420 fourteen times, zero suggestions).
Three compounding causes, all fixed:

- normalizeCounterpartyName() now reduces card-network descriptors to
  their merchant segment ("ANTHROPIC* CLAUDE SUB SAN FRANCISCO" ->
  "anthropic"; "PAYPAL *SPOTIFY" -> "spotify"), so monthly per-charge
  tails stop splintering one merchant into unmatchable variants. SQL
  mirror normalize_counterparty_key() updated in lockstep (migration
  20260721140000), keeping the ledger-context template join exact.
- New token_subset match tier bridges templates learned from manual
  bookings ("Claude Dec" -> "claude") to bank descriptors containing
  the token, and card-core descriptors to legacy splintered templates.
  Guarded by a distinctive-token filter so generic/geo words never
  match on their own.
- Merchant history falls back to description when merchant_name is
  null: card purchases never carry merchant_name, so the history path
  was structurally blind to exactly the transactions that need it.
  History keys now share the counterparty-template normalization and
  the 200-row window is ordered by recency.

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

* fix(categorization): guard single-token matches, anchor history on original_description

Review follow-ups (CodeRabbit on #1095):

- token_subset tier: a single shared distinctive token now also requires
  occurrence_count >= 3 on the template, so a template named after a
  common word or first name (one prior booking) cannot vacuum up
  unrelated transfers ("SWISH ANDERS JOHANSSON"). Multi-token agreement
  stays unrestricted; the Claude/Anthropic case (14 bookings) is
  unaffected.
- merchant history keys on original_description ?? description: the raw
  bank descriptor is immutable while description is a user-editable
  working title, so renaming a transaction no longer severs its history
  link for future recurring charges.

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

* chore(migrations): re-timestamp card-descriptor migration after prod moved past it

Prod applied 20260721144311 (#1101) through 20260721201747 (#1104) while
this PR was open; 20260721140000 would sort before them and risk being
skipped by out-of-order auto-apply at merge. Not yet applied to prod, so
renaming is safe; the preview branch re-applies idempotently
(CREATE OR REPLACE).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:58:45 +02:00
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00
Jakob Wennberg d860567976 feat(pending): bulk reject selected operations in granskning (#1085)
* feat(pending): bulk reject selected operations in granskning

The granskning queue could approve selected operations in bulk but
rejection was one row at a time. Adds:

- POST /api/pending-operations/bulk-reject: one guarded UPDATE
  (status='pending' filter) so rows resolved in a parallel session are
  reported as skipped instead of being flipped; optional
  rejection_category/rejection_reason applied to every rejected row so
  agents still learn from bulk 'no'. No high-risk skip server-side:
  rejecting posts nothing to the ledger.
- 'Avvisa valda' button next to 'Godkänn valda'; the existing reject
  dialog doubles as bulk confirmation (category + note apply to all).
- Route tests: 401/403/400/500, not-found, already-handled skip,
  read-write race, happy path.

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

* fix(pending): disable both bulk buttons while either bulk action is in flight

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:32:31 +02:00
Mattsson 87f0d5af48 fix: GH issues batch: deadlines opt-ins, SKV reconnect, narrative edit, payment-link gating (#1076)
* fix(errors): close remaining raw-message leaks after #1048 (#337)

Follow-up to PR #1048. No user-visible toast or response field can now
carry a raw engine or DB message; everything maps through getErrorMessage
or the structured-errors registry.

- get-error-message: only normalize a code-carrying Error instance into
  the structured path when the registry knows the code; unknown codes
  (Node system errors, stray third-party codes, Error-wrapped Postgres
  SQLSTATEs) fall through to pattern match, Swedish check, Postgres map
  and the status/context/generic fallbacks instead of returning the raw
  message. New Swedish-detection pattern for "ar last" phrases and a
  known-pattern row for "already has a journal entry".
- structured-errors: add CANNOT_EDIT_NON_DRAFT (409) and
  MANDATORY_DIMENSION_MISSING (400) rows, plus common Node network codes
  (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN, EPIPE) as
  retryable 503 transients with a Swedish message.
- pending-operations commit + bulk-commit routes: map executor error
  strings through getErrorMessage before responding (raw stays in logs);
  Swedish passes through, English falls to status-appropriate Swedish.
- pending page: toast via getErrorMessage, fixing raw English toasts and
  "[object Object]" for structured envelopes on commit/bulk/reject.
- transactions book + journal-entries routes: untyped catch and DB list
  errors no longer return err.message; mapped or static Swedish instead.
- invoice send + issue-credit-note: partial_failures reasons are now
  Swedish (raw provider/DB text logged, never returned).
- Tests: new unknown-code/Error-instance suite, registry rows asserted,
  route tests updated off the pinned raw-English expectations.

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

* fix(skatteverket): target the räkenskapsår for yearly VAT redovisningsperiod

A yearly filer with a broken fiscal year has a Skatteverket period ending
in its FY-end month, not December, and the panel's year state is never
maintained in yearly mode (the year picker is replaced by the
räkenskapsår selector), so calls targeted the wrong period even for
calendar-FY companies filing after year end. The selected fiscal period
now rides through the whole chain: panel query strings, draft/validate/
submit bodies, buildMomsuppgift (which resolves the FY bounds so the
period id and the figures describe the same räkenskapsår), and the
staged-commit path. MCP callers without a fiscal period keep the
calendar fallback.

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

* feat(deadlines): group same-day skattekonto deadlines into one card

Moms, AGI and preliminärskatt legally share the skattekonto date (den
12:e), so a small monthly-moms employer saw 2-3 near-identical rows per
month. Two or more pending system rows of the skattekonto family on the
same due date now render as one grouped card with the date block once
and each obligation as a sub-row keeping its own confirm-to-complete
flow. Presentation only: rows, statuses, ICS feed unchanged.

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

* feat(deadlines): KU + ROT/RUT + long-tail opt-in deadlines, rolling horizon

Follow-ups from the #1028 audit left out of the #1057-#1060 fix stack,
each with its own condition modeling:

- kontrolluppgifter (KU10/KU20/KU31), due 31 Jan (SFL 24 kap. 1 §):
  opt-in flag suggested from ledger signals (2898 utdelning, 2393/2893
  ägarlån; deliberately not 2091, see DECISIONS.md), AB only, mirroring
  the #1059 EU-sales suggest-and-confirm pattern.
- rot_rut_begaran, due 31 Jan after the payment year (Lag 2009:194
  8 §): rows generated only for years with actually PAID ROT/RUT
  invoices, resolved inside the generator; invoice-derived suggestion.
- Long tail, explicit opt-in ('Fler deadlines'): OSS quarterly and IOSS
  monthly with a skipBankingDayAdjustment config flag (EU-law dates
  stand on weekends), Intrastat (10th banking day of the following
  month), punktskatt (ordinary skattedeklaration schedule), and
  fyllnadsinbetalning (12th of 2nd month over 30k / 3rd of 5th month,
  SFL 62:8 + 65 kap.). Kvarskatt deferred: needs a slutskattebesked
  date the app does not hold.
- Rolling generation horizon: recurring types ~6 months ahead, annual
  12 months, mirrored in the backfill expectation keys so the nightly
  cron never thrashes; regeneration now preserves manual in_progress
  status; one-time cleanup migration removes existing far-future rows.

Migrations also applied to the staging branch, together with the
previously missing 20260717xxxxxx deadline migrations (staging had
drifted and lacked dismissed_at).

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

* fix(arsredovisning): keep narrative editable after year-end close

The narrative save endpoint refused writes whenever the fiscal period was
closed/locked, but Verkstall bokslut closes the period before the
arsredovisning text is ever written, so every legitimate save failed with
PERIOD_LOCKED and the PDF fell back to placeholder text.

The narrative is arsredovisning document text (ARL 6 kap.), not journal
rakenskapsinformation, so the bookkeeping period lock does not apply.
Saves are now refused only once a Bolagsverket submission for the period
is registrerad (ARSREDOVISNING_REGISTERED, 409); the filed artifact was
already frozen separately by the submissions immutability trigger.

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

* fix(skatteverket): surface dead SKV connections and nudge reconnect

Prod has ~70 companies that connected Skatteverket before the post-connect
sync fix (#1010) and silently never synced skattekonto: the only reconnect
prompt lived in the settings panel nobody revisits.

- transactions-page banner when the connection is needs_reconsent or
  expired without refresh, linking to /settings/tax
- pre-connect note in the connect panel: approve ALL behorigheter on
  Skatteverket's consent page (previously only shown after a failure)
- wire the inert skattekonto.connection.expired event to an email nudge
  to the token owner; one send per consent episode via claim-first dedup
  in notification_log (type skv_connection_expired, partial unique index
  in migration 20260720090000, applied to staging)

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

* fix(archive): per-year behandlingshistorik covers late-booked vouchers + Drive backup disclaimer

The per-fiscal-year archive filtered audit rows by created_at within the
period, dropping treatment history for bokslut entries, stornos and SIE
imports booked after year end (BFNAR 2013:2 kap 8). The year archive now
unions the date window with every audit row touching the period's journal
entries and lines, deduped by audit id; line rows (company_id NULL by
trigger design) are admitted via a scoped OR and reachable on the
service-role backup path. ARCHIVE_FORMAT_VERSION 2->3 forces a one-time
Drive re-upload so existing archives pick up the complete history. The
Drive card on /import Exportera and the LASMIG texts now state the Drive
copy is a convenience backup, not the BFL 7 kap legal archive.

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

* fix(decisions): clarify Arsredovisning narrative save behavior on submission status

* feat(invoices): gate payment links behind invoice settings opt-in

The payment-link section (manual URL field + Stripe auto-create toggle)
was visible on every invoice and auto-created Stripe links on send for
any connected company. It is now opt-in per company:

- new company_settings.invoice_payment_links_enabled, default false for
  everyone (no grandfathering of Stripe-connected companies)
- invoice editor hides the whole section unless enabled; a draft that
  already carries a link still shows it so old links stay clearable
- enforced server-side in maybeCreatePaymentLinkForInvoice (after the
  provider lookup, so the extension-free core build never queries), so
  dashboard, v1, MCP and recurring sends all obey it
- new toggle on Settings -> Invoicing, saves instantly; sv/en strings

Migration applied to the staging branch; prod gets it on merge.

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

* fix(tests): add invoice_payment_links_enabled to company settings fixture

The makeCompanySettings fixture missed the new required boolean, failing
the core-only build's type check of tests/helpers.ts. Default false,
matching the migration default.

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

* fix(review): address CodeRabbit, compliance and Swedish review findings

Round 2 of PR #1076 review feedback, one change per accepted finding:

- pending page: res.json() safe fallback in both commit paths so a
  non-JSON proxy response cannot surface a raw parser error
- bulk-commit: map operation status enums to Swedish display labels in
  the 'Redan hanterad' skip message
- payment-link settings: disable the toggle while a save is in flight
  to prevent out-of-order PUT responses
- deadlines group card: route all UI strings through next-intl
  (deadlines namespace, sv + en)
- archive export: scope the period audit entry lookup to
  posted/reversed, matching the rest of the export
- error tests: assert the exact registry English message for
  ECONNREFUSED to lock the no-leakage contract
- signal routes: log.warn when best-effort lookups swallow a Supabase
  error (forensics), keep fail-closed behavior
- narrative route: document that 'avslutad' submissions deliberately
  stay editable (never registered at Bolagsverket)
- VAT: yearly declarations without an explicit fiscalPeriodId now
  resolve the räkenskapsår ending in the target year from
  fiscal_periods instead of assuming a calendar FY (SFL 26 kap
  10-11 §§); calendar fallback only when no fiscal period exists
- deadlines: IOSS deadline no longer requires vat_registered
  (Art. 369s has no Swedish VAT registration prerequisite)

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-07-20 13:38:14 +02:00
Jakob Wennberg 30771b1619 feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion (#1075)
* feat(mcp): payroll e2e parity: staged salary-run booking + absence deletion

Close the last MCP-surface gaps for running payroll end-to-end via the
connector (the v1 REST API already had the full chain):

- gnubok_book_salary_run: stages a high-risk book operation; on approval
  the executor walks review -> approved -> paid -> booked via the new
  lib/salary/book-run.ts (extracted from the dashboard book route, which
  now calls the same core) and posts the immutable salary vouchers.
- gnubok_delete_absence: staged inverse of gnubok_register_absence,
  reusing deleteAbsenceRange with a dry-run day-count preview.
- Wire the missing payroll operation types into the Granskning label map
  (register_absence, update_payslip_line, employee ops, vacation_year_close
  had translations but fell back to humanized snake_case).
- Update stale 'booking happens in the web UI' prose in tool descriptions,
  the payroll-monthly skill, and the workflow hint; payload-size ceiling
  56K -> 57K per the documented bump protocol.

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

* fix(mcp): widen pending_operations op-type CHECK + roster typing for book_salary_run

The op-type audit (pg-real) caught the exact bug class it exists for:
book_salary_run and delete_absence were staged in code without the
constraint-expansion migration, so every real staging INSERT would have
failed with check_violation while dry_run previewed clean. Ships the
documented widen (NOT VALID) + validate migration pair. Also fixes the
strict-mode cast in book-run.ts that failed the production typecheck.

Verified locally against supabase/postgres 15.8.1.060 with all migrations
applied: op-type audit green, pg-real 692/693 (the one failure is the
pre-existing TZ-sensitive get_unlinked_1930_lines assertion, green under
TZ=UTC as in CI).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:00:53 +02:00
Mattsson 9c8e540338 fix(invoices): repair send dialog fiscal-period query + editable issu… (#1066)
* fix(invoices): repair send dialog fiscal-period query + editable issuance lines

The send/mark-sent dialog queried fiscal_periods with start_date/end_date
instead of period_start/period_end; the query always 400ed, and since PR
#1023 made that fatal the dialog closed instantly, blocking mark-as-sent
and email send for everyone.

Also lets accrual companies edit the proposed journal lines before booking
(both send and mark-sent), mirroring the mark-paid editor: untouched
proposals still book via the server generator; edited lines book verbatim
with balance validated at three layers. Credit notes and periodiserade
invoices keep the read-only preview. The dialog now also respects
defer_invoice_booking (#967).

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

* fix(invoices): harden custom issuance-line validation per review findings

Extract the custom-line parse + balance check into a shared validator so
the send and mark-sent routes cannot drift. Reject rows carrying both
debit and credit, and 29xx interim accounts (custom lines skip accrual
schedule creation, so a 29xx balance would never be dissolved). Validate
the payload only after the invoice ownership fetch, and emit structured
log events when user-edited lines are booked or deliberately ignored, so
manual overrides are visible in audit review.

Account existence needs no route-level check: the engine already resolves
every account against the company chart and throws AccountsNotInChartError.

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

* fix(invoices): address CodeRabbit findings on issuance line editing

Reject malformed JSON bodies with 400 instead of silently booking
generated lines; restrict line editing to SEK invoices (custom lines
cannot carry FX metadata); round each line before the client balance
check to match the server; stop claiming a voucher was created in the
mark-sent toast for deferred-booking companies; add programmatic labels
to the editor inputs and remove-row buttons.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:39:56 +02:00
Mattsson 46b8e2bfea Fix/fable design (#1063)
* fix(bokslut): make dispositions storno-safe and derive fond math from opening balances

A reversed year_end voucher kept its storno in the income statement while
the original was excluded (source_type asymmetry), inflating resultat fore
dispositioner by exactly the reversed amount, and the posted-only fond
balance produced a phantom negative 212X that leaked a bogus aterforing
proposal. Support case: a user double-booked periodiseringsfond, reversed
both correctly, and the dispositions page still showed wrong numbers.

- trial-balance excludeYearEndClosing now also excludes entries chained to
  reversed year_end entries via reverses_id/correction_of_id (grammar
  verified against staging PostgREST)
- listExistingPeriodiseringsfonder counts posted+reversed so storno pairs
  cancel, and returns opening balances per fond
- schablonintakt per IL 30 kap 6a: opening balance base, rate = SLR per
  closing year (1.96% FY2025, 2.55% FY2026), replacing the wrong SLR+1pp
  0.0355 constant
- avsattning 25% cap is year-total: already-provisioned current-cohort
  growth consumes headroom in both preview and commit, so re-running the
  flow can no longer double-book the fond
- SLP posts before avsattning (deductible, shrinks the cap base) and is
  posted-aware: no double proposal or double count on resumed runs
- sumPostedYearEndDispositions counts correction replacements of reversed
  year_end entries and exposes the SLP portion

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

* refactor(bokslut): use roundOre for new fond/disposition rounding

Satisfies the naive-ore-round ratchet that tightened on main; identical
arithmetic, pinned by the existing exact-value tests.

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

* fix(bokslut): address PR #1063 review findings

- computeProposal receives the already-validated period row: a transient
  DB failure can no longer silently skip a requested disposition (and two
  redundant per-item period fetches are gone)
- getSchablonintaktRate fails closed for unmapped years instead of
  falling back to the latest known rate: statutory rates are never
  guessed; POST rate override remains the escape hatch
- listExistingPeriodiseringsfonder is opening-balance-entry aware:
  a fond carried via the OB entry booked by year-end closing was counted
  twice (once from history, once from the OB entry); balances now derive
  from OB + current-period activity when an OB entry exists
- periodStart is validated as a real calendar date, not just a shape
- reversed year_end correction targets resolve company-wide in
  sumPostedYearEndDispositions, matching the trial balance exclusion

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:06:41 +02:00
Jakob Wennberg 425674ff35 chore(deadlines): legacy-type cleanup + ICS feed user-deadline fix (#1060)
* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal

The f_skatt deadline was gated on the F-skatt approval flag (DB default
true), giving nearly every company 12 monthly payment reminders for a tax
Skatteverket may not have debited at all (64% of all system deadline rows,
one lifetime completion). Approval carries no recurring obligation; the
monthly duty is payment of debiterad preliminarskatt and exists only while
the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.).

- Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already
  collected at onboarding, previously unread) and retitle it as a payment.
- Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.).
- Declare the prod-only preliminary_tax_monthly column in a migration so
  installs built purely from migrations stop failing tax-settings saves.
- Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses
  it durably (hard deletes were resurrected by the nightly backfill within
  24h); generator, backfill, and every read surface respect it.
- Prune upcoming f_skatt rows for companies with no debited amount.

Closes part of #1028.

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

* feat(deadlines): gate AGI on employer registration, stop completing AGI deadline at XML generation

The arbetsgivardeklaration deadline was gated on pays_salaries, which is
wrong in both directions: a registered employer must file AGI every month
including nil months (SFL 26 kap. 3 par.), and companies actively running
payroll with the flag off got no AGI reminders at all (each missed monthly
filing risks a forseningsavgift).

- New company_settings.employer_registered (nullable, no default) gates
  AGI and the storforetag skatteinbetalning row; pays_salaries remains a
  fallback for rows saved before the flag existed and keeps its UI meaning.
- Migration backfills employer_registered=true from pays_salaries=true and
  from actual payroll activity (salary_runs).
- New employer_seasonal flag: sasongsregistrerade file only for payment
  months plus a December nil declaration, so only the December-period row
  is generated.
- Settings UI: registration + seasonal checkboxes (sv/en strings).
- AGI XML generation no longer auto-completes the deadline as submitted:
  SFL 26 kap. deems the obligation satisfied only when the declaration has
  come in to Skatteverket. The Skatteverket extension's kvittens reconcile
  remains the confirming path; manual filers tick the deadline themselves.

Part of #1028.

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

* feat(deadlines): statutory arsstamma replaces bokslut, moms_yearly auto-complete, EU-sales suggestion

- Replace the non-statutory 'bokslut' deadline (3 months after FY end, no
  legal basis, off-by-one month math for broken FYs) with the statutory
  arsstamma deadline: within 6 months of FY end per ABL 7 kap. 10 par.,
  the corporate act that gates the arsredovisning filing chain. Migration
  deletes pending bokslut rows; the backfill cron generates arsstamma rows.
- Complete moms_yearly on Skatteverket submission/kvittens: the yearly
  branch previously returned null with a stale comment claiming annual VAT
  has no deadline type, leaving yearly filers with an eternally open row.
  The fiscal-year tax_period label is derived from company settings.
- Add /api/settings/eu-trade-signal + a tax-settings callout: companies
  with booked EU sales (3108/3308/3107, last 15 months) but EU-trade/PS
  flags off are prompted to confirm the periodisk sammanstallning
  obligation (SFL 35 kap., 1 250 kr late fee per report). Suggestion only,
  never auto-enables.

Part of #1028.

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

* chore(deadlines): clean up legacy deadline types, fix ICS feed hiding user deadlines

- Migration deletes pending rows of the retired bare 'moms' and
  'inkomstdeklaration' types (completed rows kept as history) and the
  sandbox seed route now inserts the current moms_quarterly /
  inkomstdeklaration_ef types so legacy rows stop reappearing.
- The calendar feed's include_tax_deadlines flag now hides only
  system-generated deadlines: user-created deadlines always appear. The
  old nesting skipped the entire deadlines fetch and dropped the user's
  own rows from the feed when the flag was off.

Part of #1028.

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

* fix(deadlines): include dismissed_at in DeadlineForm payload

The Deadline type gained the required dismissed_at field; the form's
submit payload literal must carry it for the Omit<Deadline, ...> shape.

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

* chore: retrigger Supabase preview check

The initial preview-branch creation failed transiently; the subsequent
migration run applied all four stack migrations (verified via
list_migrations on the preview project), leaving a stale failed check.

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

* fix(deadlines): make system-deadline dismissal atomic

Constrain the dismiss update to source='system' and verify a row was
actually updated: a concurrent regeneration can delete the row between
lookup and update, and the route must not report a phantom success.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:09:31 +02:00
Jakob Wennberg 05b954ac1d feat(deadlines): årsstämma replaces bokslut + moms_yearly auto-complete + EU-sales suggestion (#1059)
* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal

The f_skatt deadline was gated on the F-skatt approval flag (DB default
true), giving nearly every company 12 monthly payment reminders for a tax
Skatteverket may not have debited at all (64% of all system deadline rows,
one lifetime completion). Approval carries no recurring obligation; the
monthly duty is payment of debiterad preliminarskatt and exists only while
the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.).

- Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already
  collected at onboarding, previously unread) and retitle it as a payment.
- Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.).
- Declare the prod-only preliminary_tax_monthly column in a migration so
  installs built purely from migrations stop failing tax-settings saves.
- Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses
  it durably (hard deletes were resurrected by the nightly backfill within
  24h); generator, backfill, and every read surface respect it.
- Prune upcoming f_skatt rows for companies with no debited amount.

Closes part of #1028.

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

* feat(deadlines): gate AGI on employer registration, stop completing AGI deadline at XML generation

The arbetsgivardeklaration deadline was gated on pays_salaries, which is
wrong in both directions: a registered employer must file AGI every month
including nil months (SFL 26 kap. 3 par.), and companies actively running
payroll with the flag off got no AGI reminders at all (each missed monthly
filing risks a forseningsavgift).

- New company_settings.employer_registered (nullable, no default) gates
  AGI and the storforetag skatteinbetalning row; pays_salaries remains a
  fallback for rows saved before the flag existed and keeps its UI meaning.
- Migration backfills employer_registered=true from pays_salaries=true and
  from actual payroll activity (salary_runs).
- New employer_seasonal flag: sasongsregistrerade file only for payment
  months plus a December nil declaration, so only the December-period row
  is generated.
- Settings UI: registration + seasonal checkboxes (sv/en strings).
- AGI XML generation no longer auto-completes the deadline as submitted:
  SFL 26 kap. deems the obligation satisfied only when the declaration has
  come in to Skatteverket. The Skatteverket extension's kvittens reconcile
  remains the confirming path; manual filers tick the deadline themselves.

Part of #1028.

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

* feat(deadlines): statutory arsstamma replaces bokslut, moms_yearly auto-complete, EU-sales suggestion

- Replace the non-statutory 'bokslut' deadline (3 months after FY end, no
  legal basis, off-by-one month math for broken FYs) with the statutory
  arsstamma deadline: within 6 months of FY end per ABL 7 kap. 10 par.,
  the corporate act that gates the arsredovisning filing chain. Migration
  deletes pending bokslut rows; the backfill cron generates arsstamma rows.
- Complete moms_yearly on Skatteverket submission/kvittens: the yearly
  branch previously returned null with a stale comment claiming annual VAT
  has no deadline type, leaving yearly filers with an eternally open row.
  The fiscal-year tax_period label is derived from company settings.
- Add /api/settings/eu-trade-signal + a tax-settings callout: companies
  with booked EU sales (3108/3308/3107, last 15 months) but EU-trade/PS
  flags off are prompted to confirm the periodisk sammanstallning
  obligation (SFL 35 kap., 1 250 kr late fee per report). Suggestion only,
  never auto-enables.

Part of #1028.

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

* fix(deadlines): include dismissed_at in DeadlineForm payload

The Deadline type gained the required dismissed_at field; the form's
submit payload literal must carry it for the Omit<Deadline, ...> shape.

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

* fix(deadlines): make system-deadline dismissal atomic

Constrain the dismiss update to source='system' and verify a row was
actually updated: a concurrent regeneration can delete the row between
lookup and update, and the route must not report a phantom success.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:02:49 +02:00
Jakob Wennberg da4d5a39ae feat(deadlines): gate AGI on employer registration + stop completing AGI at XML generation (#1062)
* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal

The f_skatt deadline was gated on the F-skatt approval flag (DB default
true), giving nearly every company 12 monthly payment reminders for a tax
Skatteverket may not have debited at all (64% of all system deadline rows,
one lifetime completion). Approval carries no recurring obligation; the
monthly duty is payment of debiterad preliminarskatt and exists only while
the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.).

- Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already
  collected at onboarding, previously unread) and retitle it as a payment.
- Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.).
- Declare the prod-only preliminary_tax_monthly column in a migration so
  installs built purely from migrations stop failing tax-settings saves.
- Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses
  it durably (hard deletes were resurrected by the nightly backfill within
  24h); generator, backfill, and every read surface respect it.
- Prune upcoming f_skatt rows for companies with no debited amount.

Closes part of #1028.

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

* feat(deadlines): gate AGI on employer registration, stop completing AGI deadline at XML generation

The arbetsgivardeklaration deadline was gated on pays_salaries, which is
wrong in both directions: a registered employer must file AGI every month
including nil months (SFL 26 kap. 3 par.), and companies actively running
payroll with the flag off got no AGI reminders at all (each missed monthly
filing risks a forseningsavgift).

- New company_settings.employer_registered (nullable, no default) gates
  AGI and the storforetag skatteinbetalning row; pays_salaries remains a
  fallback for rows saved before the flag existed and keeps its UI meaning.
- Migration backfills employer_registered=true from pays_salaries=true and
  from actual payroll activity (salary_runs).
- New employer_seasonal flag: sasongsregistrerade file only for payment
  months plus a December nil declaration, so only the December-period row
  is generated.
- Settings UI: registration + seasonal checkboxes (sv/en strings).
- AGI XML generation no longer auto-completes the deadline as submitted:
  SFL 26 kap. deems the obligation satisfied only when the declaration has
  come in to Skatteverket. The Skatteverket extension's kvittens reconcile
  remains the confirming path; manual filers tick the deadline themselves.

Part of #1028.

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

* fix(deadlines): include dismissed_at in DeadlineForm payload

The Deadline type gained the required dismissed_at field; the form's
submit payload literal must carry it for the Omit<Deadline, ...> shape.

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

* fix(deadlines): make system-deadline dismissal atomic

Constrain the dismiss update to source='system' and verify a row was
actually updated: a concurrent regeneration can delete the row between
lookup and update, and the route must not report a phantom success.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:04:05 +02:00
Jakob Wennberg 3c0bf3f584 feat(deadlines): gate F-skatt reminders on debited preliminary tax + durable dismissal (#1057)
* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal

The f_skatt deadline was gated on the F-skatt approval flag (DB default
true), giving nearly every company 12 monthly payment reminders for a tax
Skatteverket may not have debited at all (64% of all system deadline rows,
one lifetime completion). Approval carries no recurring obligation; the
monthly duty is payment of debiterad preliminarskatt and exists only while
the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.).

- Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already
  collected at onboarding, previously unread) and retitle it as a payment.
- Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.).
- Declare the prod-only preliminary_tax_monthly column in a migration so
  installs built purely from migrations stop failing tax-settings saves.
- Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses
  it durably (hard deletes were resurrected by the nightly backfill within
  24h); generator, backfill, and every read surface respect it.
- Prune upcoming f_skatt rows for companies with no debited amount.

Closes part of #1028.

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

* fix(deadlines): include dismissed_at in DeadlineForm payload

The Deadline type gained the required dismissed_at field; the form's
submit payload literal must carry it for the Omit<Deadline, ...> shape.

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

* fix(deadlines): make system-deadline dismissal atomic

Constrain the dismiss update to source='system' and verify a row was
actually updated: a concurrent regeneration can delete the row between
lookup and update, and the route must not report a phantom success.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:48:25 +02:00
Jakob Wennberg 5b8e3fa130 fix(vat): enforce decimal vat_rate on supplier invoice items and normalize MCP percent extraction (#1049)
Supplier invoice items store vat_rate as a decimal fraction (0.25) while
customer invoices use integer percent (25). The shared Zod schema accepted
0-100, so a percent-shaped vat_rate silently booked 2500 % VAT via
line_total * vat_rate, and the MCP inbox-conversion path staged the AI
extraction's percent-integer vatRate straight into the decimal column with
per-line vat_amount 0. Part of #310.

- CreateSupplierInvoiceItemSchema.vat_rate is now a literal union of the
  statutory decimal set (0, 0.06, 0.12, 0.25) with a unit-hint error,
  covering the cookie route, the invoice-inbox convert route, and /api/v1
  (whose runtime ALLOWED_SV_VAT_RATES guard stays as defense in depth).
- New shared normalizeVatRateToDecimal() in lib/vat: percent-shaped values
  (25, 12, 6) divide by 100, results snap to the legal Swedish set, and
  anything else (foreign 19/20, non-finite) maps to 0.
- gnubok_create_supplier_invoice_from_inbox normalizes vatRate at the
  extraction boundary and derives per-line vat_amount when the extraction
  carries none, so the staged header vat_amount is honest.
- The pending-operation executor normalizes staged vat_rate on insert, so
  rows staged before this fix cannot book percent-scaled VAT.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:28:36 +02:00
Jakob Wennberg 88f53350de fix(errors): translate typed engine errors instead of leaking raw messages as journal_entry_error (#1048)
Typed bookkeeping Error instances passed to getErrorMessage() matched the
bare-envelope branch (any object with string code + message) and returned
their raw English message verbatim, so the categorize and match-invoice
routes surfaced strings like DB check-constraint violations directly in the
user's toast (issue #337).

- get-error-message.ts: when the bare-envelope shape is an Error instance,
  normalize it into the structured envelope ({ error: { code, message,
  account_numbers, details } }) so the existing per-code Swedish branches
  own the translation; plain forwarded envelopes keep the passthrough.
- get-error-message.ts: structured-path final fallback now prefers the
  registry's message_sv for known codes whose message is not Swedish, so
  typed codes without a dynamic branch (e.g. CANNOT_REVERSE_STORNO) cannot
  surface English either.
- categorize + match-invoice routes: always map the caught error through
  getErrorMessage (the raw error is already logged); untyped errors fall to
  the Swedish context fallback instead of leaking err.message.
- Tests: new instance-translation suite in lib/errors, typed-error case in
  the categorize route suite, and deliberate updates of the two tests that
  pinned raw 'Period locked' passthrough.

Fixes #337

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:51:07 +02:00
Mattsson a5e37d3510 Fix/build (#1041)
* fix(bookkeeping): harden correction account changes

* feat(tax): enhance tax deadline generation with new settings and filing methods

- Added new company settings: tax_turnover_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, and periodisk_sammanstallning_filing_method.
- Updated deadline generation logic to accommodate new settings affecting VAT and employer declaration deadlines.
- Implemented tests for new functionality, ensuring that completed obligations are preserved and not replaced by new pending rows.
- Introduced a cron job to backfill missing tax deadlines for companies with settings but no upcoming deadlines.
- Updated API routes for generating tax deadlines and handling cron jobs.
- Modified database schema to include new columns for tax filing profiles and constraints for filing methods.

* fix(invoices): record credit note reconciliation guard

* fix(tax): correct automatic deadline settings

* fix(tax): key AGI deadline to VAT taxable base and add storforetag payment deadline

The 26th filing day for the skattedeklaration (AGI and VAT together) hinges
on one statutory measure, a VAT taxable base above SEK 40 million (SFL 26
kap.), not a separate employer turnover. Drop employer_turnover_over_40m and
derive the AGI schedule from vat_registered plus vat_taxable_base_over_40m,
so a non-VAT-reporting employer is never shown the 26th when its binding
date is the 12th.

Also:
- add a skatteinbetalning deadline row (12th, 17 January) for storforetag,
  whose deducted tax and employer contributions are due before the 26th
  filing date
- normalize legally incoherent over-40m flag combinations to the earlier
  small-company schedule in a follow-up migration
- replace hardcoded 27 December dates with the banking-day adjustment
- extend the 40m help text to cover the SKV-decided early filing election
  and the payment-still-on-the-12th rule
- document the regeneration race repaired by the daily backfill cron

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

* feat(migrations): add AGI and VAT filing logic with employer column removal

* feat(settings): implement VAT registration logic and update related flags; enhance deadline handling

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:52:57 +02:00