Commit Graph

128 Commits

Author SHA1 Message Date
Jakob Wennberg 1a7152a7af feat(settings): skyline masthead on Abonnemang + AI works-with marks on API tab (#1241)
The Abonnemang tab gets a quiet decorative masthead: the marketing site's
halftone Stockholm skyline as a wide banner strip on the frame tint,
waterline pinned to the strip's bottom edge (same physics as the
onboarding backdrop). Shown in every billing state; purely decorative.

The API tab's "Anslut MCP-klient" group gets a works-with strip using the
site's monochrome halftone Claude and OpenAI marks (copied into
public/illustrations and registered in the shared manifest), with a
bilingual caption.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:41:22 +02:00
Jakob Wennberg 248d98bd7e feat(analytics): remove Recapt, PostHog is now the only analytics (#1238)
* feat(analytics): remove Recapt, PostHog is now the only analytics

Recapt shuts down in days. Everything it did is covered by the PostHog
integration in the previous commit, so the SDK, its five modules and its
CSP hosts come out.

Deleted: RecaptLoader, RecaptHideWidget, RecaptIdentify, lib/recapt.ts,
types/recapt.d.ts. Unmounted from app/layout.tsx (the <script> in <head>
and the widget-hider) and from app/(dashboard)/layout.tsx. Both logout
handlers already call resetAnalyticsIdentity() and now only that.

The CSP gets strictly narrower: connect-src loses api.recapt.app and
cdn.recapt.app, script-src loses cdn.recapt.app, and nothing is added in
their place, because PostHog runs through the same-origin /rl rewrite.
Verified against the built routes-manifest.

Behaviour change worth calling out: lib/support/submit-feedback.ts is now
single-channel. Recapt used to accept the message through its own SDK, so
a failing /api/support/contact still reported success to the user. Email
is now the only delivery path and its failure is visible. That is the
right outcome, silently "succeeding" while the message reached nobody was
worse, and the Resend path is solid. A non-blocking
posthog.capture('support_feedback_submitted') keeps the useful half of
the old dual-channel behaviour by putting the submission on the user's
timeline next to the session replay; it carries no message body, since
free text is user content and would be PII in an event property. The six
Recapt-specific test cases are replaced with the email-only contract plus
coverage of the breadcrumb, the self-hosted skip, and a throwing SDK not
breaking delivery.

Compliance, which Recapt never had: the privacy page sub-processor row is
replaced (not just deleted) with an accurate PostHog row, and .compliance/
ropa.yaml gains a product.analytics activity. The old row also claimed
Recapt loaded "endast for inloggade anvandare", which was never true,
RecaptLoader sat in the root <head> on every page including logged-out
ones. The new row describes what actually happens.

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

* fix(analytics): purge Recapt storage left on users' devices

Removing the Recapt <script> stops it writing anything new, but every
browser that already loaded the app keeps what it persisted. Observed on
production after #1237: localStorage still holds
`__recapt_record_engine`, and after this PR nothing would ever remove it,
because the helper that used to sweep on logout (lib/recapt.ts
clearRecaptIdentity) is deleted along with the SDK.

Inert data, but it is third-party storage from a processor the privacy
page now says we no longer use, and the whole point of the PostHog
config is that nothing is stored on the device. So clear it.

Matching is by substring rather than prefix on purpose: the old sweep
tested key.startsWith('recapt'), which never actually matched the real
key, since `__recapt_record_engine` starts with underscores. A test pins
that. The app's own keys (Accounted:chat-sidebar-collapsed,
gnubok.inbox.onboarding.dismissed) contain neither marker.

Runs unconditionally from instrumentation-client.ts, before the
analytics gate, so a browser gets cleaned even on a build where PostHog
is switched off. Iterates backwards because removeItem() re-indexes the
store and a forward loop would skip entries; both covered by tests, along
with private-mode throws and the server no-op.

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 15:08:32 +02:00
Jakob Wennberg d4f82cafc4 feat(analytics): add PostHog (EU) behind a same-origin proxy (#1237)
Recapt shuts down in four days, taking product analytics and session
replay with it. This adds PostHog Cloud EU alongside it; the Recapt
removal follows separately so events can be confirmed landing first.

Wiring choices that are not the tutorial defaults:

- Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding
  PostHog hosts to the CSP. connect-src 'self' and script-src 'self'
  already cover it, tracking blockers have no third-party host to match,
  and the Recapt allowlist entries in next.config.ts get replaced by
  nothing at all when they go. Needs skipTrailingSlashRedirect, since
  PostHog sends trailing-slash API requests; verified that trailing-slash
  URLs on normal routes still resolve 200 rather than 404.

- /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE
  next.config rewrites, so without this updateSession() treats an
  ingestion POST as an unknown protected path and 307s it to /login.
  Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200
  from PostHog. This fails silently otherwise, because asset loads keep
  working through the rewrite while no events arrive.

- persistence: 'memory' so nothing is written to the device and no
  cookie-consent banner is required. Everything post-login is unaffected:
  AnalyticsIdentify re-identifies on each dashboard load.

- session_recording.maskTextSelector: '*'. PostHog masks inputs but not
  text by default, and this app renders org numbers (which for an
  enskild firma ARE the owner's personnummer), customer names and
  balances as ordinary text. Replays show where a user gets stuck, never
  what their books say. buildGroupProperties() also refuses to send
  org_number at all, with a test pinning it.

- Error tracking registers through the existing lib/observability sink
  rather than bypassing it, so every error-level createLogger() line is
  captured already redacted. instrumentation.ts onRequestError covers
  what escapes uncaught.

Analytics is hosted-only: isAnalyticsEnabled() short-circuits on
NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted
runs with zero third-party runtime code. Recapt got that outcome only by
accident, via a missing sentinel; here it is explicit and tested.

vitest.config.ts aliases 'server-only' to a stub: it is a build-time
guard whose real entry point always throws, which broke 48 test files the
moment a server-only module entered the graph. request-context.ts was
already carrying the same latent trap.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:30:49 +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 6d9846b1e7 feat(settings): Fönster redesign - flat rows, ? help, dirty save bar (#1193)
* feat(settings): Fönster redesign - flat rows, help behind ?, dirty save bar

Founder-approved concept (2026-07-25) applied to the whole settings
surface, modal and full-page variants alike:

- New primitives in components/settings/SettingsRows.tsx: section header
  (serif title + one-line intro), eyebrow groups, hairline label/control
  rows, flat inputs/selects/textareas, segmented control, animated
  reveal for gated settings, danger zone.
- Every static explanation paragraph moved behind a "?" popover
  (HelpPopover) at row or group level; dynamic status stays visible.
- Modal chrome: company kicker over serif title, fixed 920x680 window.
- SettingsFormWrapper: save is a sticky bar that appears only when the
  form is dirty; collapses to zero height when clean.
- All 11 sections converted (Konto, Abonnemang, Företag, Bokföring,
  Skatt, Löner, Fakturering, Mallar, Bank incl. Enable Banking-panel,
  Assistenten, API) with handlers, validation, role/entitlement/sandbox
  gates and i18n keys preserved; checkboxes became switches, cards
  dissolved into groups.
- Fix: Escape with an open help popover closed the whole settings
  modal; it now closes the popover first.
- New i18n keys: settings_intro.*, group labels, wrapper_unsaved
  (sv+en).

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

* fix(settings): founder feedback round 1 on the Fönster redesign

- Abonnemang paying state: status and manage split into two rows so the
  row no longer wraps awkwardly; the included-features list now shows
  for paying companies too.
- Logos where the counterpart has one: BankID mark on the security row
  and on the Koppla BankID button, Skatteverket mark on the connection
  rows.
- Buttons are unmistakably buttons: 27 text-labeled row actions went
  from ghost to outline pills; icon-only actions stay quiet.
- The agent-knowledge view (Regler & profil: Dina regler, Momsprofil,
  Konventioner) converted to the flat row language; it was the last
  old-style surface inside settings. Descriptions moved behind "?",
  rules render as hairline rows, the per-row "Regel" chip demoted to
  muted text.

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

* fix(settings): address review-bot findings on the Fönster redesign

- SettingsFormWrapper marks the form dirty on switch clicks too: Radix
  Switch is a button and fires no input event, so switch-only changes
  (f-skatt, KU, ROT/RUT, OSS...) never revealed the save bar.
- i18n: the migrated hardcoded strings got keys in both locales
  (fiscal-period start date/range/months, security set-password trio);
  dates in ApiKeysPanel/OAuthClientsPanel/CalendarFeedSettings now pass
  the active locale to formatDateLong.
- A11y: member remove/revoke buttons and the invite role select got
  correct accessible names; BankNameCombobox accepts aria-label wired
  from its row; the pinned-fact icon exposes role img.
- BankIdSettings: explicit Avbryt under the QR block so a cancelled
  BankID flow cannot strand isLinking.
- VoucherSeriesManager: clear the skeleton when no company is resolved.

Verified end to end in sandbox: switch-only dirty bar, PUT /api/settings
200 for text and switch saves, persistence across hard reload.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 23:55:08 +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 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 8a9162b948 polish(ui): loading feedback on sync/refresh buttons system-wide (#1156)
The "Synka bank nu" row in the transactions Importera split button fired
syncAll() with zero visual feedback. SplitButton now takes busy/busyLabel
per option: the primary face and the menu row swap to a spinning Loader2,
show the busy label and go inert until the action resolves. useBankSync
holds isBusy across the whole syncAll loop so the spinner does not
flicker between per-connection syncs.

Sweep of the rest of the system for async buttons missing the same
feedback (convention: disabled + Loader2 animate-spin + label swap):

- bokslut DigitalInlamning "Uppdatera status" (Bolagsverket poll): had no
  feedback at all; now disabled + spinner + "Uppdaterar ..." while polling
- Stripe settings "Synka nu": had disabled + label swap but a static icon
- Skatteverket "Verifiera": had disabled + label swap but no spinner
- AgentMemoryPanel "Dolj"/"Aterstall" row actions: static icons on async
  patch; now swap to spinner for the busy row

Checked and intentionally unchanged: Arcim migration "Synka igen" and
"Ateranslut" (the whole step flips to a spinner view synchronously on
click), skattekonto "Forsok igen" (page flips to loading view), AgentChat
"Generera om" (streaming indicator is the feedback).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:37:34 +02:00
Jakob Wennberg bb551d1d59 polish(ui): founder feedback batch - inset sidebar hairline, calmer dialogs, Discord mark, chat skeleton (#1158)
- Sidebar: the hairline above the user block is inset to the content
  edges instead of running edge-to-edge (concept language).
- User menu: the Discord community link renders the actual Discord mark
  (inlined simple-icons path, CC0) instead of lucide MessagesSquare.
- Dialogs: open/close animation toned down, zoom 98 instead of 95 and
  150ms instead of 200ms.
- Settings modal: switching tabs no longer remounts the intercepted
  route (and replayed the whole open animation on every click). The rail
  now swaps sections via history.replaceState, which Next syncs into
  usePathname(), so the dialog stays mounted and tab switches are
  instant. Verified via Playwright: dialog DOM node survives three tab
  switches, URL tracks the section, Esc still closes back.
- /chat loading: the shared dashboard skeleton stretched edge-to-edge in
  chat's full-bleed wrapper (chat's own layout is what suspends, so a
  chat/loading.tsx cannot catch it). The shared fallback is now route-
  aware and renders a two-pane chat silhouette for /chat.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:36:45 +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
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 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 b420f3e1d9 feat(domains): dual-domain cutover to app.accounted.se (#1087)
* feat(domains): dual-domain cutover to app.accounted.se

The user-facing app moves to app.accounted.se while app.gnubok.se stays
alive for machine traffic (MCP connectors, API keys, third-party OAuth
callbacks, webhooks, crons), so no third-party callback registration is
on the critical path.

- next.config: host redirect app.gnubok.se -> NEXT_PUBLIC_APP_URL for
  page traffic only (/api, /.well-known, /_next excluded). Arms itself
  only once NEXT_PUBLIC_APP_URL leaves the legacy host, so merging this
  is inert and the cutover is a pure env flip + redeploy.
- skatteverket: redirect_uri pinned via NEXT_PUBLIC_SKV_OAUTH_BASE_URL
  (Utvecklarportalen registration is slow to change); the OAuth callback
  now resolves the flow from the state token + stored oauth_user_id via
  the service client instead of session cookies, which no longer exist
  on the OAuth host. Legacy same-domain flows fall back to the session.
- popup listeners (SkatteverketConnectPanel, AGIPanel) accept postMessage
  from the pinned OAuth origin; event.source identity check unchanged.
- /.well-known discovery docs reflect the allowlisted request host so
  existing MCP connectors on app.gnubok.se keep a self-consistent
  issuer/resource after the flip; spoofed hosts fall back to canonical.

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

* docs: log dual-domain cutover decision

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

* fix(review): recency-bound SKV state lookup, exact localhost match in discovery allowlist

- The oauth_state lookup now only considers rows updated in the last 10
  minutes: bounds how long a leaked/phished authorize URL stays
  completable, keeps the row set far below PostgREST's 1000-row cap, and
  surfaces query errors instead of misreporting them as CSRF.
- resolveDiscoveryBaseUrl matches localhost/127.0.0.1 exactly; the
  prefix check reflected spoofed hosts like localhost.evil.example.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:39:42 +02:00
Mattsson 4e47335308 feat(year-end): administrative undo of executed year-end closing + skatteverket scope fixes (#1081)
* fix(skatteverket): request the ska scope for skattekonto v2

The skattekonto v2 API rejects skahmst-only tokens with 403 "The required
scopes are not authorized" (observed in prod 2026-07-20; no company has
synced since 2026-05-10). The requested `skattekonto` scope is silently
dropped from every grant, while `ska` appears in one real May grant, so
request it too: SKV grants the intersection, so this is harmless if wrong.

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

* fix(skatteverket): correct the skattekonto scope model around ska

Root cause of the May 10 skattekonto outage, confirmed via git history and
prod token data: the `ska` scope (the interactive skattekonto API's actual
scope, requested since the extension's first commit in March) was removed
by the "remove unused scopes" cleanup in the #431 series. Every token
issued after that hour lacks it and the API answers 403 "The required
scopes are not authorized"; no company has synced since. The May 15 repair
re-added skahmst, which per its tjanstebeskrivning is a different bulk
E-transport service and does not substitute; `skattekonto` is not a real
SKV scope name and is silently dropped from grants.

Follow-up to the ska re-request (cd8f7a30):
- document the confirmed scope model in oauth.ts so ska is never
  "cleaned up" again
- panel missing-scope warning and reconnect-button now gate on ska,
  not skahmst/skattekonto
- scope badge labels: ska takes the saldo & transaktioner label,
  skahmst relabeled as the E-transport file service
- consent-page note covers both terse scope names and says ska is
  required

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

* fix(year-end): warn on untaxed profit at verkstall, Swedish readiness messages, always-visible period selector

An aktiebolag could execute year-end with a profit and zero bolagsskatt
booked without any warning (support case: closing moved 592k to 2099
untaxed). The preview now computes bolagsskattMissing (AB + profit + no
89xx account among closed accounts, 8999 excluded) and both the preview
and execute steps render an advisory, bypassable warning.

validateYearEndReadiness messages are now Swedish (the bokslut wizard is
a stays-Swedish surface); the MCP year_end_readiness classifier matches
both the new Swedish strings and the legacy English ones.

The wizard period selector now always renders, keeps a selected-but-
ineligible period selectable, and resets a stale ?period= id from
another company instead of leaving the user stuck on the wrong year.

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

* feat(year-end): administrative undo of an executed year-end closing

Storno-only reset used when a bokslut was executed prematurely (e.g.
without bolagsskatt) and no arsredovisning exists yet: reverses the next
period's result_appropriation and opening_balance entries, reopens the
period, reverses the closing entry, and detaches closing_entry_id.
Resumable if interrupted midway; attribution per BFL 5 kap 6.

Migration 20260720140000 adds the trigger escape hatch: closing_entry_id
may only change once set when the old closing entry is reversed with a
posted storno chain (status flag alone is forgeable via PostgREST), and
a non-NULL replacement must be a posted year_end entry in the same
period. Covered by a pg-real test.

planResultAppropriation idempotency is now posted-only: a reversed
omforing no longer blocks the re-run from posting a fresh 2099 -> 2098
reclassification (it previously returned null silently, leaving the new
year's equity polluted).

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

* fix(review): address CodeRabbit, PR-Agent and compliance findings

- undo script: company_id filters on verify queries, period-scope the
  arsredovisning precondition checks, validate service-key format,
  escalate audit_log insert failure to a hard error (BFNAR 2013:2)
- detach migration: company-scope the storno chain EXISTS, replace the
  em dash in the new error message

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

* fix(review): address round-2 compliance swarm and Swedish review findings

- undo script: require --confirm-url with --commit so an env swap fails
  loud; retry the audit_log insert 3x and direct the operator to insert
  the behandlingshistorik row manually on final failure (BFNAR 2013:2)
- year-end preview: document why resultAccountSummary is a complete 89xx
  scan; warning text now also names periodiseringsfond and
  overavskrivningar as legitimate zero-tax reasons

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:17:43 +02:00
Jakob Wennberg bd816e190c feat(settings): add install-as-app section to account settings (#1079)
New section on /settings/account offering PWA installation. On Chromium
it captures beforeinstallprompt and shows a real install button; Safari
(macOS and iOS) gets platform-specific instructions; the section hides
entirely when the app already runs standalone or after installing.

Strings added to both sv.json and en.json.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:12:03 +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 90e7c7f47f feat(ux): company context in settings, Kundfakturor rename, compact verifikat view (#1071)
* feat(ux): company context in settings, Kundfakturor rename, compact verifikat view

Support feedback (2026-07-19): active company invisible in settings,
menu said Fakturor next to Leverantorsfakturor, no compact verifikat view.

- ActiveCompanyBadge chip in the settings modal header and the full-page
  settings header; the modal covers the sidebar CompanySwitcher
- nav + page title Fakturor -> Kundfakturor (sv), Invoices -> Customer
  invoices (en); command palette gets a Kundfakturor page entry
- verifikat list density toggle (comfortable/compact), persisted per
  company like the existing sort/page-size choices

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

* docs: decision log for scoped Kundfakturor rename

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

* fix(review): badge hover reveals full company name; pure density state updater

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:02:04 +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
Mattsson 0e9cca2750 Add/customer mcp (#1055)
* feat(mcp): kontoplan account tools + verifikat notes exposure

Two gaps reported by an MCP-driven user: no account management in the
API, and verifikat notes invisible to agents (they exist in the product
but MCP could neither read nor write them).

- add staged gnubok_create_account / gnubok_update_account (BAS 2026
  prefill for catalog numbers; rename/VAT-default/SRU/activate via
  update; both LOW risk reference data)
- add staged gnubok_set_voucher_note (notes-only annotation, legal on
  posted entries per the 20260608120000 trigger carve-out) and return
  entry_notes from gnubok_query_journal
- new pending_operations types create_account / update_account /
  set_voucher_note (CHECK migration + validate companion, applied to
  staging)
- tools/list payload ceiling 54K -> 56K (documented; wire contract,
  descriptions trimmed first)

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

* fix(skatteverket): unstick BankID connect flow and stale connection views

- respond to the OAuth callback immediately and run the post-connect
  refresh after the response (next/server after()): users no longer
  stare at Skatteverket's consumed consent page for up to 40s
- open the consent flow in a full tab instead of a 600x750 popup that
  hid the approve button below the fold
- disable connect buttons while the OAuth tab is open (parallel flows
  overwrote oauth_state + the PKCE verifier) and recover via a
  closed-tab watcher plus a delayed status refetch
- persist MISSING_SCOPE token health from the post-connect sync and
  show an actionable "approve all permissions" notice
- refetch connection state on tab visibility (settings connect panel,
  enable-banking panel, /skattekonto) so a connect completed in another
  tab or after a mobile app-switch shows up without a manual reload;
  fix /skattekonto never clearing its not-connected state

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

* feat(article-form): add article number field with validation to ArticleForm

* feat(account): enforce account type consistency with BAS class and add validation

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:13:53 +02:00
Jakob Wennberg bfd5b42eb1 feat(settings): open Assistenten on Kunskap with the konteringskarta first (#1044)
The Assistenten settings hub used to open on Minne, with the
konteringskarta buried two clicks away (Kunskap tab, below a second
nested tab row). Now /settings/assistant opens on Kunskap and the
LedgerGraph hero is the first thing on screen.

- Kunskap is the default view and first tab; Minne moves to ?view=memory
  (old ?view=knowledge links still resolve to the default)
- Drop the nested Kompetens/Minne/Regler & profil tab row inside the
  Kunskap view: Kompetens and Minne duplicated the top-level tabs one
  row above; Regler & profil now renders inline under the graph with a
  section header (KnowledgeTabs.tsx deleted)
- Restore vertical rhythm (space-y-8) between the hero, detail section
  and footer, lost when the view moved into the settings tabs
- Update redirects and memory deep links (/settings/agent-memory,
  AgentChat memory chips, FactsCard manage link) to ?view=memory
- Match the loading skeleton to the new layout

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:43:57 +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
Jakob Wennberg 1443235cec feat(invoices): registrera utan att bokföra + explicit Bokför-steg (#1040)
* feat(invoices): registrera utan att bokföra + explicit Bokför-steg

Companies where one person registers supplier invoices / sends customer
invoices while ekonomi does the actual bookkeeping had no way to split
the two: under faktureringsmetoden every registration/send booked the
journal entry inline.

- New company setting defer_invoice_booking (default off, accrual only):
  registering a supplier invoice or sending/marking-sent a customer
  invoice creates NO journal entry.
- New explicit booking routes POST /api/supplier-invoices/[id]/book and
  POST /api/invoices/[id]/book: create the registration/revenue entry
  afterwards, CAS-guarded against concurrent booking (a lost race
  cancels the just-posted voucher with a gap explanation), including
  periodisering schedules.
- Detail pages show "Ej bokförd ännu" + a Bokför button for unbooked
  accrual invoices; the settings toggle lives under Bokföringsmetod.
- mark-paid needs no changes: both payment flows already route on the
  journal-entry link, so an invoice still unbooked when paid gets the
  full cash-style entry.
- The mark-sent fail-closed rollback now keys on the same gate so
  deferred sends are not rolled back as booking failures.

Fixes #967

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

* fix(invoices): harden deferred booking after review

CodeRabbit round on #1040:
- CAS link guards also require a still-bookable status (and uncredited,
  customer side) so a concurrent mark-paid/credit cannot end up with a
  double-posting registration/revenue entry.
- Settings reads fail closed instead of defaulting to accrual rules.
- Detail pages surface the ACCRUAL_SCHEDULE_FAILED warning instead of
  showing plain success, and the customer page no longer stringifies
  structured errors into "[object Object]".
- The settings form normalizes defer_invoice_booking to false under
  kontantmetoden so a stale flag cannot re-activate on method switch.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:15:20 +02:00
Mattsson 072aedeaf9 Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
2026-07-15 15:53:15 +02:00
Mattsson b6332e9ff4 Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

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

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

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

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

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

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

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

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

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

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

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

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

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

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

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

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:33 +02:00
Jakob Wennberg e7e3c35f9e fix(billing): charge Swedish VAT on Stripe subscriptions (#1011)
The subscription price is net (tax_behavior=exclusive in Stripe), so the
Checkout session now enables Stripe Tax and collects what the rate needs:

- automatic_tax: 25% moms for SE customers, reverse charge for EU-B2B with a
  valid VAT number; it carries onto the subscription so renewals and the
  post-trial first charge stay taxed.
- tax_id_collection + billing_address_collection: capture the VAT number and
  address so Stripe issues a compliant momsfaktura.
- customer_update: persist name/address onto the pre-created customer.

BillingActions now shows "199 kr/man exkl. moms" plus the inkl. price.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:58:57 +02:00
Mattsson aab7e47c35 Bug/skv auth (#1010)
* fix(settings): open Skatteverket connect in a popup and fix the skahmst scope check

The full-page OAuth round-trip left Skatteverket's pages and the consumed
callback URL in browser history directly beneath /settings/tax, so closing
settings walked Back into a dead OAuth chain and re-prompted BankID auth.
The connect buttons now use the AGIPanel popup + postMessage pattern (the
callback already supports window.opener); the page never navigates and the
panel refetches status on success. Full-page navigation remains only as the
popup-blocked fallback.

Also fixes the reconnect-button condition: it checked for a scope literally
named 'skattekonto', but SKV grants 'skahmst', which kept "Anslut igen"
permanently visible on healthy connections.

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

* refactor(skatteverket): extract per-declaration AGI kvittens reconciliation

Behavior-identical extraction of the kvittens cron's core (fetch kvittens,
promote declaration to submitted, stamp salary_runs, clear cached submission,
complete deadline, notify) into lib/agi-kvittens-reconcile.ts so the upcoming
post-connect refresh can reuse it. Auth-error mapping (needs_reconsent, grant
revocation, APIGW config gaps) and run-level logging stay in the cron, which
is why SkatteverketAuthError propagates out of the helper on purpose.

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

* fix(skatteverket): auto-settle production-format AGI skattekonto draws

The settlement matcher only understood the SKV test environment's single
combined row ("Arbetsgivardeklaration YYYYMM"). Production books the draw as
two rows with Swedish month names: "Avdragen skatt maj 2026" (= total_tax)
and "Arbetsgivaravgift maj 2026" (= total_avgifter), so auto-settlement has
never fired against production data and the salary page kept showing paid
periods as unpaid.

parseAgiPeriod now also reads the month-name form (which additionally lets
match suggestions resolve the period on prod rows, including beslut rows).
Settlement classifies draws with start-anchored regexes so correction rows
like "Beslut 260703 arbetsgivaravgift mars 2026" can never qualify, and
settles a period on either the combined row matching the whole declared
amount or exactly one tax row + exactly one avgift row matching their
respective totals to the ore, dated to the later of the pair. Anything
non-exact (partial draws, duplicates) still falls back to the manual
mark-paid button.

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

* feat(skatteverket): refresh skattekonto + kvittenser right after OAuth consent

Nothing fetched Skatteverket data after a (re)connect: tokens were stored and
the user redirected, with the next fetch left to the nightly cron. SKV's
per-flow tokens (and refresh tokens) live ~65 minutes, so the crons usually
find them dead; right after consent is the one reliable window for a
personal-token fetch, which is why reconnecting never made stale numbers
recover.

The callback now awaits runPostConnectRefresh after storing tokens: a
skattekonto sync (upsert + auto-settlement + balance snapshot) plus a
kvittens re-check for this company's pending_signature AGI declarations.
Awaited on purpose so popup-close means the data is already fresh and UI
refetch listeners never race a background job; every step is best-effort and
a refresh failure can never fail the connect that just succeeded.

The callback's non-popup fallback also switches to window.location.replace
so the consumed callback URL (one-shot code + state) drops out of history
instead of re-running into a guaranteed CSRF error on Back.

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

* feat(salary): reload the salary dashboard after a Skatteverket reconnect

Listens for the BankID popup's skatteverket-oauth-success message and re-runs
load(). Because the OAuth callback awaits the skattekonto sync and AGI
auto-settlement before responding, the refetch already sees settled
tax-payment state: the "Skatt att betala" card flips to paid the moment the
popup closes, without leaving the page.

Also logs the three decisions behind this series in DECISIONS.md (awaited
post-connect refresh over after(), exact-pair settlement over per-period
summing, popup flow over in-place history repair).

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

* fix(skatteverket): verify OAuth popup source identity before trusting postMessage

The three 'skatteverket-oauth-success' listeners accepted any same-origin
message, letting any same-origin script trigger a data reload or a fake
success toast (OWASP ASVS V3.2, SOC 2 CC6.1). The two components that open
the popup now keep its handle in a ref and require event.source to be that
exact window; a window reference cannot be forged by same-origin scripts,
which is strictly stronger than a nonce threaded through the OAuth flow.

The salary dashboard never opens the popup, so its raw message listener is
replaced by a 'skatteverket-connection-updated' CustomEvent dispatched only
after a component has source-verified the popup (and after disconnect, so
connection-state consumers stay in sync).

Also extends the connect consent copy (sv + en) to disclose that connecting
immediately fetches skattekonto data and checks pending AGI receipts
(GDPR Art.5(1)(b) transparency).

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

* fix(skatteverket): CSP nonce and no-store on OAuth callback, deadline on post-connect refresh

Both callback HTML responses now carry a per-response nonce CSP
(default-src 'none'; script-src 'nonce-...'; base-uri 'none';
form-action 'none') so injected markup could never execute, plus
Cache-Control: no-store because the callback URL carries a one-shot
authorization code. The jsLiteral/appUrl injection invariants are
documented at the definition site.

The awaited post-connect refresh is bounded by a 30-second Promise.race
deadline so a hung SKV call cannot hold the OAuth callback open; on
timeout the refresh continues best-effort and the user still gets the
success response. Refresh failures and timeouts now log through the
structured logger with companyId + userId so they are attributable in
log aggregation (ASVS V16, ISO 27001 A.8.15, SOC 2 CC8.1).

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

* fix(skatteverket): tenant guard and redacting logs in kvittens reconcile, slim cron response

The agi_declarations update now also filters on company_id (ASVS V8.2.1),
matching the salary_runs pattern. The reconciler and the cron's error paths
log through the structured logger so third-party error strings pass
personnummer redaction; uuidKvittens is dropped from log context
(GDPR Art.5(1)(f) minimization, declarationId suffices).

response_data gains submittedAtEstimated so the signeradTid fallback can
never be mistaken for the legal filing time, and the submitted_by comment
now states explicitly that it records the technical submitter while
response_data.signeradAv is the authoritative legal signatory
(BFL 5 kap 6 par, BFNAR 2013:2 kap 8).

The cron HTTP response omits companyId per result row and sends
Cache-Control: no-store; the extension_data delete documents why the
period-scoped cache key needs no declaration-id guard. Cron tests observe
the logger via a mock without weakening any assertion.

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

* fix(skatteverket): diagnosable settlement refusals and signeradAv ROPA documentation

Settlement refusals (candidates present but amounts mismatch) now emit a
structured info log with declared-vs-drawn ore amounts per kind, so a
rounding divergence between stored declaration totals and SKV's actual
draw is diagnosable instead of silently falling back to the manual button.
No transaction texts are logged (they can carry personal data).

parseAgiPeriod documents the beslut-row audit: correction rows parse to
their period on purpose for match-suggestion boosting; settlement never
uses parseAgiPeriod (anchored classifiers + parseNumericAgiPeriod only)
and the only callers require an exact amount+side 1630 match first.

.compliance/ropa.yaml documents signeradAv (signer personnummer in the
SKV kvittens stored in agi_declarations.response_data): lawful basis
Art.6(1)(c) via BFL 5 kap 6 par / BFNAR 2013:2 kap 8, 7-year retention
per BFL 7 kap 2 par, access via company-membership RLS. DECISIONS.md
records the accepted-with-documentation calls from the compliance review.

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

* fix(skatteverket): add 'already_claimed' status to reconcile outcomes and enhance logging for pending lookups

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 01:40:03 +02:00
Jakob Wennberg dcd33997b7 feat(agent): move 'Vad din agent vet' into settings (Assistenten -> Kunskap) (#1008)
Relocates the ledger-knowledge surface off the top nav and into the
assistant settings hub as a third tab (Minne / Kompetens / Kunskap), per
the code's own "minne + kunskap under Assistenten" intent and the #935
flag that this was an easy call to change.

Because both settings surfaces (the full-page rail and the intercepting
settings modal) mount each section as a propless component via
SETTINGS_SECTIONS, the knowledge data must be fetched client-side rather
than passed as a server prop:

- New GET /api/agent/knowledge aggregates buildLedgerContext +
  buildDeepEntities + buildAgentCompetence + company name (read-only,
  company-scoped via withRouteContext).
- AgentKnowledgeView + AgentCompetenceSections converted from async
  server components to client components (getTranslations ->
  useTranslations; no other server-only usage).
- New AgentKnowledgePanel client wrapper lazy-fetches the payload when
  the Kunskap tab opens (Radix unmounts inactive tabs), with Skeleton and
  error states matching the memory/skills panels.
- Removed the Brain/agent-knowledge entry (and its now-unused import)
  from the Analys nav group.
- /agent-knowledge kept as a redirect to /settings/assistant?view=knowledge
  so old links/bookmarks resolve.

Tests: new route test (auth 401, no-company 400, happy-path aggregation).
i18n: load_error_* keys added to both locales (parity kept).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:21:08 +02:00
Mattsson 98d0c7f2d0 Add/stripe skv (#1004)
* fix(salary): align pain.001 salary file with the Swedish domestic bank dialect

Verified against the Swedish Common Interpretation of ISO 20022
(Bankforeningen, Common Payment Types in Sweden, Appendix 1 Example 4:
Salaries) and Nordea Corporate Access pain.001 examples v2.6 (2026-06-22),
and XSD-validated against the official pain.001.001.03 schema:

- drop SvcLvl SEPA (SEPA credit transfers are EUR-only; omitting SvcLvl
  gets the domestic NURG default)
- drop RmtInf (not allowed for SALA salary payments; the beneficiary
  statement text comes from the Dataclearing LON code)
- address employees domestically: clearing as CdtrAgt ClrSysMmbId SESBA,
  account WITHOUT clearing as CdtrAcct Othr with SchmeNm BBAN
- share the clearing/account split (Swedbank 5-digit shift, Nordea
  personkonto prefix dedup) between the LB and pain.001 generators via
  splitDomesticBankAccount, fixing pain.001 duplicating the personkonto
  clearing
- clamp MsgId/PmtInfId/InstrId/EndToEndId to Max35Text with the per-tx
  counter surviving truncation; carry the org number on Dbtr
- return 400 from the pain001 route on an invalid clearing instead of
  emitting a broken file

Also includes two unrelated decision-log lines from the parallel
revisor-review session (DECISIONS.md is a shared append-only log).

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

* feat(nav): surface the year-end chain in the sidebar

Add Periodiseringar, Arsredovisning (aktiebolag only) and
Inkomstdeklaration (INK2 for AB, NE-bilaga for EF) to the Skatt &
bokslut group, in workflow order. Entity gating via a new entityOnly
flag on NavItem; isActive carve-outs extended so exactly one row
lights up for the new routes. Driven by an external revisor review
that concluded these features did not exist because none of them
were reachable from the nav.

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

* feat(stripe): Stripe Connect integration behind config gate

Connect OAuth per company (only the acct_ id is stored), automatic
single-use Payment Links on invoice send, deterministic payment
settlement against 1686 (BAS moved acquirer receivables 1580 -> 1686),
payout booking with reverse-charge fees (6570 + 4535/4598 + 2645/2614),
and a 15-minute sync cron. Non-deterministic events land as
needs_review, never guessed at.

Fully dark without STRIPE_CONNECT_CLIENT_ID: connect returns 503, the
send hook and cron no-op, and the settings page shows 'Kommer snart'
(hosted) until the Connect platform is verified. Self-hosted keeps the
honest not-configured message.

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

* fix(deadlines): add shared completeTaxDeadline and fix dead AGI deadline auto-complete

generate-declaration.ts has updated non-existent columns (type/period/
status) since inception, so the arbetsgivardeklaration deadline was
never auto-completed. Replace with a shared helper targeting the real
schema (tax_deadline_type/tax_period/is_completed), also used by the
kvittens crons and moms handlers in the follow-up commit.

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

* feat(rot-rut): import Skatteverket beslutsfil and record decisions on payout requests

Parse the beslutsfil JSON from Skatteverkets rot/rut e-tjanst and record
godkant belopp on the matching begaran: matched by stored
skv_referensnummer first, then exact name among active undecided
requests; arenden by fakturanummer then personnummer, exactly-one or the
beslut errors (all-or-nothing). Never auto-settles: recording the beslut
and booking the payout are separate acts. Exposed as an API route and
the gnubok_import_rot_rut_beslut MCP tool.

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

* feat(skatteverket): system auth for background reads, one-click VAT submit, kvittens notifications

Hybrid auth program: system CCG (org certificate) for background reads
while personal BankID stays for interactive submissions, since SKV
per-flow refresh tokens live 65 min and crons structurally cannot run
on them. All system-auth code sits behind SKATTEVERKET_SYSTEM_AUTH_MODE
(default off) with a stub transport until the Expisoft cert and CCG
avtal land; auth resolution is centralized in resolve-auth.ts.

Also in this change:
- One-click VAT submit chaining kontrollera -> utkast -> las
  server-side with a stage discriminator; step-by-step buttons demoted
  to the overflow menu.
- Kvittens crons (AGI + new VAT schedule) with email-only
  notifications, deduped in notification_log under the new
  skv_kvittens type.
- Ombud grant probe + verification UI in the connect panel, and a
  dashboard promo card for unconnected companies.
- skatteverket_company_connections table with pg-real coverage.

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

* feat(salary): auto-settle AGI tax payment from skattekonto and surface SKV reconnect on the tax card

The "Skatt att betala" card only cleared via the manual mark-paid button
on the run detail page; the promised automatic flip from the Skattekonto
sync was never implemented, so paid periods stayed red.

- settleAgiTaxPayments: during every skattekonto sync, a booked
  "Arbetsgivardeklaration YYYYMM" debit row settles the matching
  agi_declarations.tax_paid_at, but only when the amount equals the
  declared total to the ore and the account is not in deficit
  (deterministic; drift or deficit falls back to manual).
- Salary overview card: reconnect hint when the SKV token needs
  re-consent (link to /settings/tax, silent when the extension is off),
  plus an inline "Markera som betald" button reusing the existing
  endpoint and salary_payments strings.

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

* Add cloud backup scheduling and alerting features

- Implement unit tests for scheduling logic in `schedule.test.ts`, covering various scenarios for determining if a backup schedule is due.
- Create a new module `backup-alert.ts` to handle failure alerts for cloud backup auto-sync, including email notifications for reauthentication and repeated failures.
- Introduce `schedule.ts` to manage scheduling logic, including handling local time zones and converting between local and UTC hours.
- Add CSV report generation functions in `archive-csv.ts` for trial balance, income statement, balance sheet, and general ledger, ensuring compatibility with Swedish Excel formats.
- Create a README generator for the archive structure in `archive-readme.ts`, providing clear documentation for users accessing backup files.
- Implement tests for CSV report generation in `archive-csv.test.ts`, ensuring correct formatting and content.
- Establish a full-archive coverage contract test in `full-archive-coverage.pg.test.ts` to ensure all company-scoped tables are properly classified for backup.

* fix(stripe): correct invoice clearing reference and improve type safety in sync logic

* fix(invoices): narrow accountingMethod before resolveInvoicePaymentSourceType

settleInvoicePayment takes accountingMethod as a raw settings string, but
resolveInvoicePaymentSourceType requires the 'accrual' | 'cash' union.
Normalize at the call site (anything but 'cash' books as accrual), matching
the existing useCashEntry semantics.

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

* fix: address CodeRabbit review findings and nitpicks on PR #1004

Review findings:
- backup settings redirect: always force view=export over incoming params
- AGI/VAT kvittens crons: isolate best-effort post-submit calls, check the
  signed-state persist error, guard recovery calls in catch blocks so one
  company cannot abort the rest; surface grant_revoked in the run summary
- kvittens notifications: atomic claim-first dedup with a partial unique
  index; map non-uuid reference keys to deterministic uuids
- grant probe: record the actual 2xx status; mTLS transport: handle
  response-stream errors
- stripe: amount-aware idempotency keys for payment links; emit
  stripe.disconnected on upstream revocations
- ROT/RUT beslut import: mutate in-memory request state after apply, move
  item + header writes into an atomic apply_rot_rut_beslut RPC, add
  rot_rut_payout to JournalEntrySourceTypeSchema
- migrations: use NOT VALID + VALIDATE CONSTRAINT for CHECK constraints on
  journal_entries, notification_log and rot_rut_payout_requests
- cloud backup: hour_utc-only schedule updates clear stale hour_local

Nitpicks:
- stripe sync: enforce the cron time budget inside per-connection event
  processing with idempotent cursor progress; maybeSingle for settings;
  honest partial-customer DTO shared with the settlement boundary
- shared applyPaymentLinkToInvoice helper for both invoice send routes,
  v1 docblock documents step 6b and PAYMENT_LINK_FAILED
- settings panel: drop redundant decodeURIComponent
- cloud backup: document worst-case archive memory headroom

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:14:12 +02:00
Jakob Wennberg 3a2c57a167 feat(billing): paywall conversion pass (deferred first charge, trial touchpoint, sell-view upgrade) (#991)
* feat(billing): paywall conversion pass: deferred first charge, trial touchpoint, sell-view upgrade

- checkout passes subscription_data.trial_end (trial grant expiry, 49h floor)
  so a mid-trial upgrade costs 0 kr today instead of double-billing days the
  company already has free; billing/status counts 'trialing' as paying
- trial countdown pill in the sidebar (CompanyContext.trialEndsAt via
  getCompanyEntitlements); hidden for sandbox, dev bypass, and once any
  non-trial grant is active
- sell view: what-happens-when timeline, free-vs-paid comparison table,
  risk-reversal copy + chevron CTA, post-checkout confirmation state

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

* fix(billing): review triage: fail-closed trial lookup, hourly countdown refresh, BFL retention note

- checkout returns 500 (no Stripe session) when the trial-grant lookup errors,
  instead of silently charging immediately after the UI promised 0 kr idag
- sidebar trial countdown recomputes hourly so a long-lived tab stays honest
- sell-view retention copy states BFL 7-year retention explicitly
  (compliance-bot suggestion)

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

* chore: retrigger CI (pull_request event delivery stuck)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:29:48 +02:00
Jakob Wennberg da859d7236 refactor(ui): design-system consistency pass over dense pages + i18n de-bloat (#961)
* refactor(ui): normalize dense pages to the locked design system

Sweep of the info-dense surfaces against .claude/rules/design.md; no
behavior changes, classNames and primitive adoption only.

- Replace hand-rolled h1s with PageHeader (import, suppliers, kpi,
  salary/employees, skattekonto, settings layout) and drop the one
  double title (SalarySettingsContent under the settings h1)
- Replace hand-rolled empty states with EmptyState (skattekonto,
  banking/api-keys/oauth/counterparty settings) and hand-rolled
  pulse divs with Skeleton (deadlines, report view loaders)
- Remove semantic colors used as chrome: amber/emerald banners in
  AGIPanel and SkatteverketPanel, success/warning tints in
  kassaflodesanalys, arsredovisning and import become neutral
  surfaces with the tint kept on the icon only
- Full-opacity borders everywhere (border-border/30-60,
  border-destructive/20-40, border-foreground/30, text-destructive/80)
- Snap off-scale spacing (p-5 to p-6, p-2.5 to p-3, gap/mt-x.5 to
  scale values); KPI metric tiles p-6 to p-4 per the tile rule
- Remove the mobile Select that duplicated the invoices status Tabs
  (TabsList already scrolls horizontally); single Tabs now serves
  both breakpoints
- supplier-invoices: shared formatCurrency instead of a local
  formatAmount helper; skattekonto: formatDate/formatDateLong/
  formatDateTime instead of raw dates and toLocaleString
- arsredovisning flerarsoversikt converted to the Table primitive
  with right-aligned tabular-nums cells
- Settings: CardTitle text-base on section cards, one heading idiom
  in AccountSettingsContent, h3 to h2 in CompanyProfileView

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

* refactor(i18n): trim text bloat and fix an untranslated sv string

- Fix invoice_credit.create_failed_fallback: sv catalog carried the
  English "Failed to create credit note"; now "Kunde inte skapa
  kreditfaktura". Translate new_user_checklist.step3_title in en
- Drop descriptions that paraphrase their own title (design.md
  forbidden pattern): invoice_detail.credited_description,
  invoice_credit.original_card_description, invoice_editor
  customer/notes card descriptions (keys deleted from both
  catalogs, zero remaining usages); the transaction booking
  DialogDescription becomes sr-only so screen readers keep it
- Trim redundant sentences from settings_salary.info_payroll_scope,
  settings_backup.intro, ext_cloud_backup_long_description,
  settings.name_description, salary_payments.open_payments_note and
  shorten invoice_credit.reason_card_description; statutory BFL/tax
  prose untouched
- Normalize toast punctuation (dimensions/self_billing
  created_description lose the trailing period like their siblings)
- common.delete "Radera" to "Ta bort" (zero live call sites; Radera
  stays reserved for irreversible account/company deletion)

Catalogs verified key-identical (4795 keys each) and JSON-parseable.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:56:30 +02:00
Jakob Wennberg c9d9c5fe99 fix(billing): block demo/sandbox accounts from Stripe checkout (#948)
* fix(billing): block demo/sandbox accounts from Stripe checkout

An anonymous demo user on a sandbox company reached POST /api/billing/checkout
and created a live Stripe customer. Neither the checkout nor the portal route
checked is_anonymous or is_sandbox, and withRouteContext lets anonymous users
through (they are authenticated, just anonymously).

Guard both routes on both conditions before any Stripe call: refuse anonymous
users (identity truth, cheap in-memory check) and sandbox companies (matches the
existing lib/sandbox/guard.ts "never charge a token" doctrine). Surface isDemo
on GET /api/billing/status so the client hides the upgrade CTA instead of
showing a button that 403s.

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

* docs(billing): redact tenant/customer IDs from incident note (CodeRabbit)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:16:25 +02:00
Jakob Wennberg c1ea0d9bf2 fix(salary): make pain.001 betalfil generatable (company IBAN + BIC) (#950)
The ISO 20022 pain.001 salary payment file could never be generated: the
route required company_settings.iban/bic, but no settings screen wrote
those columns, so every request returned 400. The specific reason was
also swallowed by getErrorMessage (isSwedishUserMessage did not know
"krävs"/"saknar"), surfacing only the generic "Förfrågan innehåller
ogiltiga uppgifter" (issue #945).

- Add IBAN + BIC inputs to Settings > Fakturering > Bankuppgifter. BIC
  auto-derives from the clearing number / bank already entered, so in
  practice only the IBAN is typed. Validated client- and server-side.
- Route requires the company IBAN (canonical debtor form every Swedish
  bank accepts) and derives the BIC, with clear actionable errors.
- Employees are unchanged: domestic clearing + account (BBAN), which is
  what Swedish payroll collects. Only the company (debtor) uses IBAN.
- getErrorMessage recognizes "krävs"/"saknar" so payment-file reasons
  surface instead of the generic 400.

Fixes #945

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:35:52 +02:00
Mattsson bacc5914af Fix/dependabot cus feedback (#946)
* feat(bookkeeping): per-account default VAT, oresavrundning momsfri

Add a per-account "Standard moms" setting to the chart of accounts and use
it to auto-fill the moms on a leverantorsfaktura-rad when that konto is
picked. Oresavrundning (3740) ships as "Ingen moms", so a rounding line no
longer inherits the 25 % rad-default and skews the moms.

- chart_of_accounts.default_vat_rate (0/0.06/0.12/0.25, CHECK-constrained)
- BEFORE INSERT trigger ships 3740 momsfri on every insert path; backfills
  existing 3740 rows
- kontoplan editor: dead free-text momskod replaced with a Standard moms select
- supplier-invoice rad auto-fills the rate from the konto default

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

* feat(supplier-invoices): configurable start number for the ankomstnummer series

Add a company_settings.next_arrival_number start floor so a company can continue its leverantorsfaktura numbering from a previous system (e.g. Fortnox) instead of restarting the ankomstnummer at 1. get_next_arrival_number now floors the series via GREATEST(MAX(arrival_number)+1, next_arrival_number), so the floor can never move the series backwards or collide with the (company_id, arrival_number) unique index.

The RPC is hardened while rewritten: SET search_path to empty, schema-qualified refs, and an auth.uid() membership check matching generate_invoice_number.

Includes the settings UI field, sv/en strings, migration, and pg-real coverage. The CompanySettings type and Zod schema field for this feature landed earlier in 1bf3b641 (swept into the per-account VAT commit).

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

* fix(dependabot): reduce open pull requests limit and group updates for better management

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:19:57 +02:00
Jakob Wennberg b10cf2ec23 fix(banking): harden PSD2 loading, error, and refresh states (#934)
Fixes loading/error/refresh-state gaps across the Enable Banking (PSD2) surfaces:

- Delete the dead post-OAuth bank_connected sync flow + 9 orphaned i18n keys.
- Surface fetch failures instead of empty/false-healthy states (settings panel error card, BankSelector non-OK guard, chip hidden on error).
- Stop the full-panel spinner flash on refetch; clear the spinner in finally.
- Client-side sync/backfill timeouts + live elapsed counter with a grace-period unlock so a slow bank can't trap the modal.
- Broadcast a bank-sync signal so the transactions chip refetches after a manual sync.
- Consolidate the import page onto the shared banking panel (also removes the core -> @/extensions import-rule violation) and standardize spinners.
- Surface previously silent chart/fiscal-year fetch failures in the account picker.
- Review fixes: cancellation guard on the deferred bank_error microtask; key the sync-progress dialog per attempt to reset its elapsed timer.
2026-07-08 16:55:29 +02:00
Mattsson abe9ac9d8c Fix/attributes config (#926)
* fix(git): pin LF on generated extension registry and vitest snapshots

setup:extensions and vitest write these files with LF; with
core.autocrlf=true git expects CRLF and flags them as phantom
modifications on every dev/build run.

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

* fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route

mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt.

Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }.

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

* fix(api): route transactions endpoints through withRouteContext

Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern.

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

* fix(api): route SIE import and bank reconciliation through withRouteContext

Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern.

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

* fix(api): route salary endpoints through withRouteContext

Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern.

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

* fix(api): route report endpoints through withRouteContext

Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern.

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

* fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext

Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated.

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

* fix(api): route documents, events, team and account endpoints through withRouteContext

Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated.

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

* fix(api): route settings and pending-operations endpoints through withRouteContext

Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern.

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

* chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration

Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md.

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

* feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil"

Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by
upload instead of typing every ruta into the form. Extract buildFiledAmounts()
as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so
the XML file and the manual-filing PDF can never disagree. Adds the /eskd API
route, an XML option in the report export menu, and the upload button on the
manual-filing card. Strings in sv + en.

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

* feat(vat): add 'vat_settlement' source type and update related components

* fix(booking): adjust search input layout and enable autofocus

* fix(vat): support 12-digit org numbers and adjust emission order for eSKD file

* fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:54:46 +02:00
Jakob Wennberg 27b88426e2 fix(entitlements): show paid features as gated upsells instead of dead ends (#913)
* fix(entitlements): show paid features as gated upsells instead of dead ends

Post-cutover, non-payers still saw fully interactive UI for paid features
(bank picker, agent-build hero, SKV VAT submission) that silently failed or
403'd on the server gate. Every surface now stays visible as a conversion
surface but is explicitly gated:

- new shared components/billing/UpgradeNote (lock icon + billing link)
- agent-build hero (dashboard + new-user checklist): routes to
  /settings/billing with upgrade copy when the ai capability is missing
- bank connect: BankingSettingsPanel and the import-page PSD2 wizard swap
  the bank list for an upgrade note; the import selection card swaps the
  "Rekommenderat" chip for "Kräver abonnemang"
- VAT report SkatteverketPanel: gated state renders before the
  connection check, so trial-connected companies see the upsell instead
  of action buttons that would 403; manual-filing framing kept
- SkatteverketConnectPanel: skahmst consent note hidden while gated
  (irrelevant until the consent page is reachable)

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

* test(ne-bilaga): fix time-of-day flake in SRU field-code assertion

The bare substrings '7310'/'7350' also match the #SKAPAD HHMMSS timestamp
when CI runs at 07:31/07:35, so the assertion now requires the full
'#UPPGIFT <code>' prefix.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 10:01:44 +02:00
Jakob Wennberg 573feea890 perf: cross-system snappiness batch (middleware, loading states, bundle) (#909)
* perf: cross-system snappiness batch (middleware, loading states, bundle)

Middleware: resolve the active company at most once per request and run
the user_preferences + first-membership queries in parallel, cutting 1-2
sequential DB round trips from every authenticated page load.

Loading states: add loading.tsx skeletons for the six highest-traffic
dashboard routes, render the real salary page header during load instead
of a full-page skeleton, replace the blank fallback={null} Suspense
flashes on customers/articles, and reshape the settings skeleton to
match the actual form layout.

Bundle: defer recharts chart components via next/dynamic on the KPI page
and report views, replace the import page's framer-motion marching-ants
border with a CSS keyframe, and enable optimizePackageImports for
recharts/date-fns/framer-motion.

Transactions: extract the potential-match lookups into a shared parallel
helper; a single-query PostgREST embed is blocked until the
potential_supplier_invoice_id FK exists in prod (see DECISIONS.md).

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

* fix(transactions): log potential-match query failures instead of dropping them

A DB error in the invoice/supplier-invoice hint lookups previously
surfaced as "no potential match"; log it so failures are diagnosable.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 16:16:33 +02:00
Mattsson 2c2743eb79 Check/salary bankid api (#892)
* fix(bankid): harden login/signup flow — polling, signup rollback, metadata merge, enrichment lookup

- middleware: read BankID enrichment from the bankid_enrichment table (the
  extension_data path has been dead since the multi-tenant refactor), so
  company-less BankID users land on /select-company instead of the manual wizard
- BankIdAuth: hard 6-min poll deadline; every failed poll counts toward the
  give-up limit; guard overlapping ticks so completion runs exactly once
  (a double /complete regenerated the magic link and invalidated the first,
  failing logins intermittently); retry clicks wait out the start cooldown
  instead of silently no-oping; Swedish messages for 429/unknown start errors
- bankid/complete: all-or-nothing signup — delete the created user when the
  identity insert, app_metadata update, or magic-link generation fails, so a
  retry starts clean instead of hitting account_exists with an unusable account
- bankid/unlink: read-merge-write app_metadata so has_password survives unlink
  (BankID-only users could otherwise strand themselves with no login method)
- login: BankID "create account" CTA now links to /register instead of
  dismissing the notice; sv.json: fix missing å/ä/ö in settings_bankid strings

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

* docs: move secondary guides into docs/, delete dead root files

Move DOCKER.md, SELF-HOSTING.md, WHITELABEL.md and extensions.md
(renamed EXTENSIONS.md) into a new docs/ folder and update all path
references (README, setup.sh, .dockerignore image rules, docker-publish
workflow comment, _example-branding, lib/branding/service.ts).

Delete two dead root files: customer.json (stray API-test payload) and
findings.md (point-in-time swarm audit export, criticals already filed).

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

* fix(api): security & correctness hardening + withRouteContext MFA migration across API routes

Audit of ~100 app/api routes. Highlights:

Security
- agent/conversations: list leaked colleagues' titles + message previews
  (company-scoped RLS, no user filter) -> user-scoped
- calendar/feed PUT: raw body into .update() allowed feed_token fixation on a
  public unauthenticated URL -> strict schema, content toggles only
- bokslutsdispositioner: unbounded schablonintaktRate could inflate the
  IL 30 kap 25% periodiseringsfond cap base -> bounded
- agent profile/composer/onboarding: viewers could rewrite the agent profile
  while sibling /verify blocked them -> role-gated

Correctness
- account-totals / listAssets: unbounded queries silently truncated at 1000
  rows (under-counted money; skipped assets at year-end depreciation) ->
  fetchAllRows with stable order (+3 more pagination fixes)
- voucher-gaps: swallowed detect_voucher_gaps RPC errors (BFNAR gap view could
  show "no gaps" when the check never ran) -> surfaced
- 5 phantom-success writes (OK on zero matched rows) fixed
- assets K3 component-sum validated against stale acquisition_cost -> fixed
- invite silent email-send failure -> response carries email_sent;
  deadlines/calendar cast-then-check JSON crashes -> Zod

Convention
- ~44 legacy routes converted to withRouteContext (MFA); added Zod validation,
  corrected status codes, console.* -> lib/logger

Response shapes preserved for existing callers. ~110 new tests.

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

* feat(bookkeeping): save a booking as a reusable template from Bokför direkt

Add a "Spara som mall" action to the manual booking dialog so users can
capture a kontering they just worked out as a booking template — right
where they figured out how something should be booked.

- derive amount-parameterised template lines from the concrete booking
  (settlement = the non-VAT leg nearest the total, 26xx = a VAT line with
  its rate snapped to the nearest standard rate, the rest = business
  ratios; line labels come from the loaded BAS chart)
- extract the shared TemplateForm out of BookingTemplatesPanel so the
  booking dialog reuses the same editor, live preview and convertibility
  hints instead of duplicating them
- save via the existing POST /api/settings/booking-templates endpoint

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

* fix(bokslut): render arsredovisning RR/BR at ÅRL post level — no kontonummer

Bolagsverket rejected a user's filed årsredovisning with "Balansräkning
och resultaträkning ska inte innehålla kontonummer": the PDF built every
statement row as per-account "1930 Företagskonto" lines while the iXBRL
filing path already aggregated to statutory posts, so the two artifacts
diverged.

The PDF statements now derive from the same K2 risbs mapping the iXBRL
document uses (mapTrialBalancesToK2), via a new statement-rows.ts that
emits post-level rows in uppställningsform order for both the K2 and K3
templates. Also fixed along the way:

- Jämförelseår column (ÅRL 3:5 §) — previous-year trial balances now load
  and render; the old PDF had no comparatives at all.
- mapping.warnings (unmapped accounts, RR ≠ 2099, obalans, reclass
  nudges) flow into ArsredovisningData.warnings so the wizard flags a
  non-fileable document before download.
- Flerårsöversikt current/previous year overridden with the mapper's
  strict-3000–3799 Nettoomsattning, mirroring build-input's
  duplicate-fact rule, so the FB table ties to the RR.
- FB eget kapital-table is post-level and drops obeskattade reserver
  (never eget kapital); K3 equity-changes statement uses real prior-year
  opening balances with derived utdelning/nyemission residuals that tie
  the roll-forward exactly to booked UB.
- build-input dedupes warnings now that the PDF path runs the same
  mapping.

Regression test asserts no RR/BR label ever contains a four-digit
account number again.

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

* fix(reports): diagnose untransferred prior-year results behind balance-sheet differens

Prod incident (97 kr): a multi-year SIE migration lacked one year's
omforing av arets resultat; the residual corrupted every later derived
opening balance and Balansrakningen showed a bare "Differens: 97 kr"
with no explanation. Continuity checking cannot catch this failure mode
(prior-year UB and derived IB match per-account by construction) - the
invariant that actually breaks is per-year P&L = 0 for all non-latest
years.

- lib/reports/imbalance-diagnosis.ts: shared detector
  (findUntransferredResults + buildImbalanceDiagnosis)
- Balansrakning/Balansrapport attach imbalance_diagnosis when unbalanced,
  naming the exact culprit years; rendered in web views + PDF; MCP
  gnubok_get_balance_sheet inherits the field via spread
- SIE import: parse-time warning when a completed year's vouchers leave
  a P&L residual, plus a post-import DB walk surfacing culprits as
  warnings and structured details.untransferredResults; the Arcim
  migration workspace previously dropped result.warnings entirely and
  now renders them
- opening-balance/correct: pre-flight the company lock date and return
  409 OB_COMPANY_LOCK_DATE (retryable: false, lock date interpolated in
  the client message) instead of the retryable 500 that invited blind
  retries; catch-path maps a raced trigger rejection to the same code

Diagnosis runs only on unbalanced paths (zero cost when healthy) and
never fails the report or the import. No migration, nothing persisted.

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

* fix: production error remediation — FX rates, deadlines, log levels, correction relink

Batch of fixes for recurring Vercel runtime errors:

- Riksbanken FX rates: persistent read-through cache (exchange_rates
  table), one retry honoring Retry-After on 429/5xx, bounded ingest
  concurrency, and an honest fallback — most recent cached observation
  or null, never a hardcoded rate silently booked into amount_sek.
  Unrated transactions stay repairable via refresh-exchange-rate.
- Tax deadline regeneration inserts replacement rows before deleting
  the superseded set, so a failed insert no longer wipes a company's
  deadlines (the 23502 user_id regression did exactly that). Migration
  makes deadlines.user_id nullable for system-generated rows.
- Route wrappers + errorResponse log 4xx outcomes at warn so only
  genuine 5xx reach Vercel's runtime-error clustering; client-supplied
  /api/log telemetry demoted to warn as well.
- application/json documents (raw PSD2 responses archived per BFL)
  validate as parseable JSON with object/array root instead of always
  failing the magic-byte check.
- correctEntry surfaces document-relink failures to callers, and the
  BFL document-immutability trigger now allows relinking underlag from
  a reversed entry to its correction (migration + pg test).
- Middleware clears stale session cookies on /api requests too, using
  scope 'local' so cleanup doesn't re-trigger the failed token refresh.

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

* fix(skatteverket): persist token health and stop retrying dead consents

Terminal auth errors (SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED) mark the token row needs_reconsent with the error code
and timestamp — SKV per-flow refresh tokens live 65 minutes, so once
expired nothing recovers without a fresh BankID consent. The AGI
kvittens and skattekonto sync crons skip flagged connections instead of
failing every night, and the settings panel prompts for re-consent
proactively. A successful reconnect resets the row to active.

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

* fix(banking): allocate distinct BAS ledger slots for PSD2 mirror accounts

A bank returning N same-currency accounts used to map them all onto the
currency default (1930/1932/1933/1934), tripping the UNIQUE
(company_id, ledger_account) constraint per-account — swallowed errors
left accounts silently unmirrored. allocatePsd2LedgerAccount now hands
out the currency default first, then free 1931–1959 sub-account slots,
skipping slots held by any existing row.

- Callback persists allocations to accounts_data so the picker pre-fills
  reality; reconnect reuses previously mirrored ledgers instead of
  re-deriving (a user remap to 1935 survives).
- Selection save resolves effective ledgers up front and rejects
  duplicates or cross-connection conflicts with a 400 instead of
  silently skipping the mirror.
- Bank error codes + psu_type are forwarded to the settings page for
  every OAuth error, keying the Handelsbanken corporate fullmakt
  guidance.

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

* fix(agent): stage exact journal lines on categorization previews

Categorization previews only carried debit/credit accounts, the GROSS
amount, and separate VAT rows — read together that looks like an
unbalanced 'gross on cost account + VAT debit' entry, and it misled
both users and agents into rejecting correct proposals. The MCP
preview and the pending-operation PATCH now materialize the exact
lines the commit executor will post (net cost line, VAT line, gross
bank line, SEK) via buildTransactionEntryLines, and PATCH re-derives
them from the new mapping instead of spreading stale staged lines.
ApprovalCard and /pending render the verifikat lines, falling back to
the legacy summary only for operations staged before this fix.

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

* feat(bookkeeping): prune unused imported accounts from the chart

SIE imports routinely bring in hundreds of accounts that were never
used and clutter the kontoplan. New account_usage_counts RPC (one
grouped query instead of a count per account) backs GET
/api/bookkeeping/accounts/usage, and POST /api/bookkeeping/accounts/prune
deletes zero-usage accounts — dry-run first, then an explicit account
list capped at 2000. Accounts with journal lines are skipped, never
deleted. The chart manager shows a usage column and a prune dialog
grouping custom accounts vs unused BAS-seeded ones.

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

* feat(api): carry dimensions through v1 invoice and supplier-invoice surfaces

Credit-note creation now copies default_dimensions and per-line
dimensions from the original, so the reversing journal entry nets
against the same dimension cells instead of dropping them. List/detail
responses expose the dimension fields, and the OpenAPI spec snapshot
follows.

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

* perf: batch serial Supabase round-trips on hot dashboard paths

Every dashboard render pays the layout's query chain, so serialized
awaits are direct wall-clock: the layout, chat conversation, invoice
detail, supplier detail, select-company, and agent-onboarding pages now
run their independent lookups in parallel batches, and
getCompanyCapabilities folds its disabled-config read into the same
round-trip. JournalEntryList hydrates the saved fiscal-year scope
optimistically instead of serializing the first entries fetch behind
the fiscal-periods request. The supplier detail page filters invoices
server-side via a new supplier_id query param instead of fetching the
whole company ledger, and the invoice editor (with its framer-motion
dependency) lazy-loads so it stops shipping with the invoice list
bundle.

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

* feat(salary): one-click runs, payslip delivery, payments settings, run cockpit

Salary P1 batch, driving the 20-click flow toward 3 clicks:

- One-click 'Starta lönekörning': POST /api/salary/runs accepts an
  empty body and resolves defaults server-side — period follows the
  latest non-corrected run, payment date from the new
  salary_pay_day setting, series from the per-source-type map. The
  separate /salary/runs/new page is gone.
- Run detail page rebuilt as a step-railed cockpit (progress rail,
  KPI cards, employee ledger, journal preview) on a deliberately
  wider canvas; components extracted to components/salary/run/.
- Payslip delivery: tokenized public payslip pages (/payslip/[token],
  backed by salary_payslip_links) plus per-employee email send with
  PDF — employees need no account, and the middleware exempts the
  route from auth redirects.
- Payments settings: salary pay day, default bank, and pain.001 vs
  Bankgirot Lön format with per-bank upload instructions and an LB
  sunset warning (banks retire LB during 2026).
- AGI panel: full submission status flows (stale drafts, signing
  links, kvittens polling, error reports); tax payment panel with
  skattekonto shortcut and mark-as-paid.
- Salary calendar bulk editing, employee benefits/tax-card polish,
  municipality tax-table lookup improvements.

messages/sv+en also carry the strings for the account-prune,
skatteverket-reconsent, and banking surfaces committed just before
this.

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

* chore: adopt Next 16 proxy.ts convention + repo housekeeping

- Rename middleware.ts to proxy.ts with the proxy() export (Next 16
  renamed the middleware convention; behavior unchanged).
- Exclude dev_docs/ from tsconfig so stray snippets in planning docs
  don't break the build type-check.
- Ratchet antipatterns-baseline down (raw-route-auth 165 → 119) to
  lock in the withRouteContext migration from 5cfd2b76.
- template-library uses roundOre() instead of inline rounding.
- database.md: drop account_balances from the key-tables list.

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

* fix(bookkeeping): robust service-role detection in correction document relink

relink_documents_to_correction() keyed its service-role branch on auth.role(),
which reads the singular request.jwt.claim.role GUC that PostgREST v10+ and the
pg-real harness no longer populate. Genuine service-role callers (pending-ops
executor / MCP approve) landed in the auth gate and could not relink underlag.
Read the role from the request.jwt.claims JSON directly, mirroring the canonical
link_voucher_rpcs_tenant_guard convention. Validated on staging.

Also: harden the salary run page's error paths (res.json().catch) against
non-JSON error bodies, and roll back the pg-real service-role case in finally so
an aborted transaction cannot poison a pooled connection for the next test.

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

* fix(documents): restore journal_entry_line_id link durability (BFL 7 kap)

Migration 20260704103000 rewrote enforce_document_journal_entry_immutability to
guard journal_entry_id but left journal_entry_line_id to the metadata trigger,
which exempts draft-linked docs -- and the entry-level trigger only fired on
UPDATE OF journal_entry_id, so a line-id-only UPDATE never invoked it at all.
That let a set journal_entry_line_id be cleared to NULL, breaking the "link
durable from first set" invariant (document-immutability.pg regression).

Widen the trigger to fire on journal_entry_line_id too and guard it with the
same uuid-durability rule as journal_entry_id (setting NULL -> uuid stays
allowed; clearing/re-pointing a set value is blocked, status-independent). The
correction-relink GUC path, which legitimately clears line_id when moving
underlag to the posted correction, stays exempt. Validated on staging.

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:05:09 +02:00
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob Wennberg 70e893b8d4 fix(skattekonto): stop conflating live-call auth failures with "inte anslutet" + surface SKV connection first in tax settings (#887)
The sync endpoint returns 401 for six distinct auth states (handleSkvError):
NOT_CONNECTED, SESSION_EXPIRED, REFRESH_EXHAUSTED, MISSING_SCOPE,
TOKEN_CORRUPTED, TOKEN_REVOKED. The page treated every 401 as "Skatteverket
är inte anslutet" — directly contradicting Inställningar, which reads the
stored token metadata and truthfully shows "Ansluten" for all but the first.

- syncNow now checks the structured error code: only NOT_CONNECTED flips to
  the not-connected empty state. Every other auth failure renders the
  server's actual Swedish message as a persistent banner with an "Anslut
  igen" CTA, keeping the stored saldo/transactions visible.
- SkatteverketConnectPanel moves to the top of /settings/tax — the
  skattekonto and momsdeklaration pages send users there specifically to
  (re)connect, and below the tax form it sat out of view.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:35:09 +02:00
Mattsson 237b77a366 feat: custom inbound mail domains, rot/rut payout file, invoice email texts, security hardening (#878)
* fix(security): guard MCP test keys, RLS role gate + voucher RPC guards, /api MFA gate, deps

- MCP: force dry-run / block writes for test-mode API keys in tools/call (extensions/general/mcp-server)
- DB: current_user_can_write role gate on write policies (40 tables) + tenant guards, SET search_path, REVOKE anon on commit_journal_entry / next_voucher_number / detect_voucher_gaps (migration 20260702093000)
- Middleware: MFA (AAL2) gate on cookie-authenticated /api routes via apiPathSkipsMfaGate
- Deps: npm audit fix clears mailparser/linkify-it/nodemailer/svix/uuid highs; xlsx -> SheetJS 0.20.3

Adds unit + pg-real tests. Does not touch in-progress ROT/RUT or invoice-email-texts work.

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

* feat(invoices): rot/rut begäran om utbetalning — HUS XML (V6), payout tracking + settlement, MCP tool

Generates Skatteverkets begäran-om-utbetalning file (schema V6) from paid
ROT/RUT invoices — no submission API exists, the file is uploaded manually
at skatteverket.se. Headless by design for now: API routes + MCP tool
(gnubok_generate_rot_rut_file), no UI surfaces.

- lib/invoices/rot-rut-file.ts: pure XML generator with deterministic
  per-invoice blockers (hours, work type, personnummer, property info,
  mixed rot+rut, XSD limits) + 31 January deadline warnings
- rot_rut_payout_requests(+items) tables: one active begäran per invoice
  (DB triggers incl. reactivation guard), RLS, audit, pg-real tests
- Settlement: POST /settle books debit 1930 / credit 1513 via the engine
  (source_type rot_rut_payout); partial payouts → partially_paid
- Work-type lists corrected against Begaran.xsd: IT-tjänster is rut-only,
  snöskottning/tillsyn/tvätt added (schablontjänster utfört-only)
- Fix: invoice-level fastighetsbeteckning was validated but never
  persisted — now stamped onto rot lines in build-invoice-write; API
  accepts bostadsrätt pair (lägenhetsnr + BRF orgnr, editor UI deferred)
- invoice_items.brf_org_number migration + MCP scope invoices:write

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

* feat(invoices): per-company editable invoice email texts

Add an "E-posttexter" section under Settings -> Fakturering where the
subject, greeting, body and sign-off of the standard invoice email can
be customized per company in Swedish and English. Fields pre-fill with
the standard texts and only diffs from the standard are stored
(company_settings.invoice_email_texts JSONB), so future improvements to
the stock wording still reach companies that have not customized. Each
field has a reset-to-standard button; cleared fields snap back.

Texts support a fixed placeholder set (invoice number, customer name,
first name, company, due date, amount) substituted at send time in a
single pass; unknown placeholders stay literal. Custom texts are
HTML-escaped after substitution, newlines become <br> in the HTML
variant, and subject lines are flattened to a single header line.
Overrides apply to standard invoices only - credit notes, proforma and
delivery notes keep the stock texts. All send paths (UI, v1 API, MCP
approval, recurring) pick the texts up via the existing settings row.

The Zod schema half of this change (InvoiceEmailTextsSchema in
lib/api/schemas.ts) was inadvertently included in 8291f745.

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

* fix(documents): accept PDFs with preamble before %PDF- header, surface content rejections as 400

detectFileMagic required the %PDF- signature at byte 0 (BOM aside),
rejecting genuine PDFs that carry a leading newline or junk bytes —
files every ISO 32000 reader opens fine. Now scan the first 1024 bytes
for the signature, matching real-reader behavior. Image types stay
strict at offset 0 to keep the anti-placeholder defense tight.

Magic-byte rejections were also mislabeled as DOC_UPLOAD_STORAGE_FAILED
(500 'Filen kunde inte sparas'), blaming storage for a client-side file
problem. Both upload routes now map them to a new
DOC_UPLOAD_INVALID_CONTENT (400) with an accurate message.

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

* feat(bookkeeping): full keyboard flow for manual journal entry

Enter now drives the whole verifikat flow: verifikationstext drops into
the first row missing an account, konto commits advance to debet, Enter
on an empty debet hops to kredit, and an entered amount jumps to the
next row. Once the voucher balances, Enter opens the review (unchanged
gate) and the auto-focused confirm posts it — including through the
no-underlag warning dialog. Escape in the inline review goes back to
the form.

Also fixes an Enter footgun in AccountCombobox: a bare Enter on a
freshly focused field no longer selects the first account in the list —
selection now requires typing or arrow navigation; otherwise Enter
re-commits the current value or bubbles to the form-level handler.

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

* feat: add custom inbound domains management for companies

- Implemented functionality to allow companies to claim and manage their own inbound email domains via Resend's API.
- Created a new table `company_inbound_domains` to store domain information, including status and DNS records.
- Added necessary RLS policies to restrict access based on user roles (owner/admin).
- Developed functions for domain normalization, validation, claiming, verification, and removal.
- Implemented webhook handling for domain status updates from Resend.
- Added comprehensive tests for RLS, constraints, and triggers related to the new domain management feature.

* fix: address PR #878 review findings and CI failures

- migrations: drop the ai_usage_tracking policy block from the role-gate
  migration — the table was removed by 20260504120000_remove_ai_subsystem
  and only lingers on staging as drift; a from-scratch chain (pg-real,
  Supabase preview) failed on it
- invoice-inbox: never flip a custom domain to verified off a domain.updated
  webhook alone — confirm the receiving capability with Resend first
  (fail-closed); normalize both sides of the orphan-adoption domain match
- rot/rut: block files where begärt belopp exceeds what the buyer paid
  (DEDUCTION_EXCEEDS_PAYMENT); tighten brf_org_number validation to real
  orgnr shapes; parameterize the settlement bank account (19xx, default 1930)
- rot/rut routes: log acting user on financial mutations, stop swallowing
  item mirror errors, narrow response projections (no customer ids through
  the invoice join); document the deliberate inline-XML decision
- documents: stop echoing raw storage-layer error messages to clients

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

* fix: round-2 CI + compliance findings on PR #878

- migrations: the role-gate migration targeted automation_webhooks, which
  20260515170000_webhooks_v2 renamed to webhooks on the canonical chain
  (staging kept the old name — drift); gate public.webhooks instead,
  dropping legacy schema-sync policy names defensively. Restore the
  20260623130000 owner fallback in next_voucher_number that the stale
  copied-verbatim body silently reverted (caught by engine.pg locally).
  Full migration chain verified from scratch against supabase/postgres:15.
- mcp: bump the tools/list payload ceiling 44K -> 45K — main's #877
  qualified-identifier schemas plus this branch's rot/rut tool crossed the
  ceiling only in combination; documented in the test's history log.
- rot/rut: refuse partial settlement before Skatteverkets beslut is
  recorded (would bypass the PATCH lifecycle and strand the request);
  block zero-kronor ärenden (ZERO_DEDUCTION); require sekelsiffra 16 on
  12-digit brf orgnr in both schema validation and normalizeBrfOrgNr

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

* fix: rename branch migrations off main's colliding versions

After the merge with main, two versions were shared by two files each
(20260702100000: rot_rut_payout_requests vs company_settings_dimensions_
enabled; 20260702130000: invoice_email_texts vs pending_operations_add_
create_dimension_value). psql-based CI applies by filename and doesn't
care, but Supabase branching records migrations by version (PK) — the
second file with the same version breaks the preview with a
schema_migrations_pkey duplicate. Neither branch migration is version-
recorded on staging or prod, so renaming to fresh 20260703 versions is
safe; nothing between the old and new positions depends on these objects.

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

* fix(security): scope the /api MFA-gate bypass to real Bearer-auth surfaces

Any Authorization header — attacker-controlled — used to skip the AAL2
gate for every /api route, so a stolen-password AAL1 cookie session could
reach cookie-authenticated routes (which ignore the header) by attaching
`Authorization: x`. The skip is now scoped to the surfaces whose auth
contract IS the header (/api/v1 API keys, the MCP endpoint's OAuth
tokens); pure Bearer callers elsewhere (cron secret, signed webhooks)
carry no cookie session and were never touched by the gate, which only
fires for cookie users. Superagent P2 on PR #878.

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

* test: normalize path separators in dimension statutory guard scan

The route scan compared walked file paths against a POSIX-path allowlist,
so the suite failed on Windows (backslash separators) while passing on
Linux CI. Normalize the scanned paths to forward slashes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:57:59 +02:00
Jakob Wennberg 8bb49c07a2 feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry (#858)
* feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry

Phase 2 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false (default) see zero change.

API:
- Dashboard CRUD: GET /api/dimensions (lazy-seeds system dims 1/6 via the
  ensure_company_dimensions RPC), PATCH /api/dimensions/[id] (is_system
  rename blocked), POST/PATCH/DELETE values (code immutable after creation;
  strict Fortnox code format ^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$ at the API layer;
  retention-trigger deletes surface the Swedish "arkivera istället" message
  as 409 DIMENSION_VALUE_REFERENCED).
- POST /api/dimensions/import-existing — scans journal_entry_lines.dimensions
  for unregistered codes and mints inactive placeholder registry rows.
- v1 public API: GET dimensions + POST values (Idempotency-Key, dry-run),
  registered in the OpenAPI spec (102→104 endpoints).
- dimensions_enabled boolean on company_settings (new migration,
  UI-visibility only, never correctness-bearing) exposed through the
  existing settings read/update path.

SIE export (lib/reports/sie-export.ts):
- Reads the new dimensions/dimension_values registry; legacy
  cost_centers/projects tables now have zero readers (drop migration next).
- Fixes the latent Visma-rejection bug: #OBJEKT now declared for INACTIVE
  values referenced by lines.
- Generic-N: #DIM/#UNDERDIM loop sorted by sie_dim_no; #TRANS object lists
  serialize from the line JSONB map (sorted, '01'→'1' collapse); orphan
  codes/dims synthesize declarations from the SIE reserved-number seed —
  every referenced (dim, code) pair is guaranteed declared.

UI:
- /dimensions register (Register-recipe): tabs per dimension, search,
  sortable table, value dialog (code immutable on edit, projekt dates on
  dim 6), archive-not-delete affordances.
- DimensionCombobox shipped (mounts in the tagging PR).
- Settings toggle "Aktivera kostnadsställen & projekt" — toggle-on runs the
  import-existing scan and links to the register.
- Nav row in redovisning, rendered only when dimensions_enabled (same
  mechanism as pays_salaries).
- dimensions.* i18n namespace (51 keys, sv/en parity).
- Sandbox seed: demo dims + values, revenue line tagged {"1":"BUTIK","6":"P001"}.

Verified: 6328/6328 unit tests, guard + coverage gate green, tsc parity with
main (210=210), production build passes.

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

* fix(dimensions): PR2 review round — atomic archived-create, UNDERDIM ordering, import robustness, date semantics

- POST values accepts is_active so "create as archived" is atomic; the UI's
  fragile create-then-PATCH fallback is deleted (PR Agent finding 1).
- DimensionCombobox blur revert reads the committed value/values through refs
  so a selection landing inside the 150ms window always wins (finding 2).
- import-existing sanitizes candidate codes like the PR1 backfill and upserts
  with ignoreDuplicates — one bad/duplicate code can no longer abort the
  batch; created counted from returned rows (finding 3).
- SIE export emits all root #DIM before any #UNDERDIM so a parent always
  precedes a lower-numbered child (SIE4 declaration order — Swedish review);
  synthesized placeholder declarations now log one structured warning
  (BFNAR 2013:2 behandlingshistorik) + defence-in-depth comment.
- Value dates rejected (400 DIMENSION_VALUE_DATES_NOT_ALLOWED) when the
  parent dimension is flow-period (resets_annually=true); explicit null
  still clears (Swedish review).
- Sandbox seed logs seeded dimension codes; GET /api/dimensions documents
  the deliberate absence of dimensions_enabled gating (UI-visibility flag,
  not a security boundary — compliance-swarm V8.2.1 rejected by design).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:26:42 +02:00
Mattsson f63d3e3100 Bug/open banking flow (#854)
* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

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

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

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

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

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

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

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

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

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

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:13:00 +02:00
Jakob Wennberg 2da9c71eb3 UI badge cleanup + /chart-of-accounts route + loop skills (#850)
Bundles three separable concerns:

- style(ui): badge audit + cleanup across 45 files — real-status chips use Badge variants (raw Tailwind colors dropped), non-status count/type/label chips demoted to muted text, clustered badges consolidated; 20 unused imports removed.
- feat(bookkeeping): Kontoplan moved to a dedicated /chart-of-accounts route (nav + command palette wired); /bookkeeping shows the journal list only.
- chore(skills): loop-* automation skills + design-scan workflow under .claude/.

fix(reports): restored the destructive count badge on blocking errors in the periodisk sammanställning (EC Sales List) — a genuine status cue the audit had wrongly flattened; flagged by the PR reviewer and Swedish compliance bot, now clean.

All CI green; compliance bots report no findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:29:23 +02:00
Jakob Wennberg b800dcd403 style(ui): system-wide UX/UI polish pass — design-system conformance + copy cleanup (#835)
* style(ui): system-wide UX/UI polish pass — design-system conformance + copy cleanup

Multi-agent scan of all 404 UI files against the locked design system, then
141 verified surgical fixes across 109 files (net -32 lines):

- Remove forbidden elevation/motion: shadow-* and rounded-xl on cards, active:scale
  bounce, hover:shadow on list items, transition-all -> transition-colors.
- Drop font-medium from single-weight Hedvig display headings/numerals.
- Replace raw rainbow Tailwind status colors with Badge variants / brand tokens /
  neutral surfaces (achromatic chrome, semantic colors stay data-only).
- Route raw dates through formatDate(), hand-rolled currency through formatCurrency(),
  add tabular-nums to financial figures; text-gray-* -> text-foreground tokens.
- Swap hand-rolled skeletons for the Skeleton primitive; off-scale spacing -> token scale.
- Fix copy: mislabeled "Leverantörsfakturor" -> "Utgifter" on bank-import outflow total,
  collapse no-op identical-branch ternaries, broken Swedish diacritics (mojibake),
  correct mismatch-password toast, correct supplier currency-field label.
- Remove PII-leaking debug console.log on register, stray console.logs.

Verified: tsc clean on all changed files, eslint clean, production build passes.

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

* fix(auth): sanitize residual error logs in register flow

Follow-up to PR review (compliance swarm V16 / GDPR Art.5(1)(f)): the
remaining console.error calls in the register flow passed raw error
objects, which Supabase may populate with PII (email) in nested fields.
Log only sanitized message strings instead.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:14:13 +02:00
Jakob Wennberg 0e8698f538 fix(entitlements): billing reachable in settings modal + conversion redesign (#821)
- Register BillingSettingsContent in SETTINGS_SECTIONS so the settings MODAL renders Abonnemang (it was falling back to Företag — billing was only a standalone page, never a registered section). Page is now a thin wrapper over the same component.
- Add GET /api/billing/status (isPaying / configured / trialEndsAt) so the client section gets state without server-only reads.
- Redesign for conversion: trial days-left urgency banner, reactive monthly/yearly price with a 'Spara 2 mån' badge, full-width price-bearing CTA, Stripe trust line, design-system-compliant chrome (flat Card, no shadow/rounded-xl, on-scale spacing, serif headline). Trialing companies now see the upgrade path (not the manage button).

The reported 'peach band' was not reproduced in code — no peach/salmon color exists in the app CSS and the only bottom drag-handle is in a md:hidden mobile sheet; most likely a macOS screenshot/desktop artifact.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:51:31 +02:00