Commit Graph

357 Commits

Author SHA1 Message Date
Jakob Wennberg 5ac560ce41 fix: generate tax deadlines for the installed base + correct 2893 label carryover (#1029)
* fix(bookkeeping): refresh correction line description on account change

When editing an ändringsverifikation, CorrectionEntryDialog pre-filled each
line's description from the original entry but never re-derived it when the
user changed the account, so a description carried over from the old account
(e.g. 2393 "Lån från närstående personer, långfristig del") stayed stale on
the newly chosen account (e.g. 2893, the kortfristig account). The regular
JournalEntryForm already auto-fills on account change; this mirrors it.

The refresh is guarded: it only overwrites the description when it is empty or
still equals the previously selected account's name, so a memo the user typed
themselves is preserved. Logic is extracted into a pure, unit-tested helper.

Note: the wrong text on an already-posted correction cannot be repaired (line
descriptions of posted verifikat are immutable per BFL / migration 017); this
prevents recurrence on future corrections.

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

* fix(deadlines): generate tax deadlines for the installed base

Automatic tax deadlines only regenerated when a tax-relevant settings field
changed value (didTaxFieldsChange). Companies fill those fields once at
onboarding, so a later save changed nothing and generated nothing; the annual
cron was the only unconditional trigger. As a result only ~5 of ~776 real
companies had any system deadlines, and the /deadlines empty state told users
to "check the tax settings" that were already complete.

- Settings save now also regenerates when the company has zero system
  deadlines yet (safe first-time backfill; cannot reset is_completed/status).
  Decision extracted into shouldRegenerateTaxDeadlines() with tests.
- The empty-state banner gets a "Generera nu" action wired to the existing
  /api/tax-deadlines/generate route (previously it had no caller). New sv/en
  strings.
- generateNewYearDeadlines (annual cron) paginates company_settings via
  fetchAllRows: a plain .select() silently caps at 1000 rows, leaving
  companies beyond the cap without next-year deadlines.
- scripts/backfill-tax-deadlines.ts: one-off that reruns the real generator
  for non-sandbox companies with zero system deadlines.

Known gap (follow-up): moms_period='yearly' has no deadline config, so annual
VAT filers get no momsdeklaration deadline yet.

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

* fix(deadlines): address review feedback + fix settings-route test

- settings/route.ts: fail safe when the system-deadline count query errors.
  A null count on error was treated as 0, which would trigger a
  delete+regenerate and reset is_completed/status on a transient failure;
  now a count error keeps the self-heal off (CodeRabbit, Major).
- Update app/api/settings/__tests__/route.test.ts (added on main via the
  withRouteContext refactor) for the extra deadline-count query and the new
  shouldRegenerateTaxDeadlines export; add self-heal / no-regen cases.
- Soften the "no deadlines created" copy: zero generated rows can also mean
  no applicable obligations (or the moms_yearly gap), not just incomplete
  settings (CodeRabbit, Minor).

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-16 15:55:41 +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
Jakob Wennberg 4b51af3d80 feat(agent): 'Vad din agent vet' page rendering the ledger context (P2) (#935)
* feat(agent): 'Vad din agent vet' page rendering the ledger context (P2)

The human-facing surface for the openwiki ledger-context: a read-only page
that renders the exact payload the AI agent reads (Accounted://ledger/context
+ the briefing digest) as a legible profile of how this company books.

- Route app/(dashboard)/agent-knowledge (server component) calls the shared
  buildLedgerContext(supabase, companyId) directly: one payload, two
  renderers, no new API or data path.
- Sections mirror the payload 1:1: coverage/freshness strip, counterparty
  patterns (monochrome confidence bars + seen/agree evidence), supplier
  patterns, explicit rules shown as authoritative instructions distinct from
  observed patterns, account usage, VAT profile, conventions.
- Nav entry in the Analys group (icon Brain), ungated so it doubles as an
  upsell; flip requiredCapability to paywall.
- Design per .claude/rules/design.md (PageHeader, Card, Table, Badge,
  AccountNumber BAS tooltips); sv + en strings (agentKnowledge namespace).
  VAT/BAS labels stay Swedish in both locales per i18n rules.

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

* feat(agent): deep entity-resolved analysis + radial graph for the knowledge page

Reworks the 'Vad din agent vet' page from tables into a radial-hub graph
driven by a new full-history deep analysis, per founder feedback.

- fix(rpc): median_booking_lag_days now measures real posting promptness via
  committed_at, not entry_date (which the bank flow sets to the transaction
  date, giving a ~0 tautology: 151/152 on prod). migration 20260708120000.
- feat(rpc): get_ledger_deep_context (migration 20260708130000): full-history,
  deterministic, read-side. Merges counterparties by normalize_counterparty_key
  (e.g. Claude = 14 bookings across 12 name variants, weekly, 9 710 kr, always
  5420), mines booked verifikat for SEK spend (coalesce amount_sek), detects
  recurrence cadence, dominant account + share, plus supplier entities. Storno
  excluded, corrections kept; 19xx/26xx excluded from the dominant contra.
- LedgerGraph: radial SVG (company center, accounts inner ring, payees outer
  ring), hover/focus reveals variants + spend + cadence + account. Keyboard
  focusable nodes with per-node accessible names + a screen-reader data table.
- Page fetches the deep context alongside the light context; coverage strip
  gains tracked-payee / recurring / tracked-spend stats. sv + en strings.
- 14 pg tests (light + deep) green; both RPCs applied to prod + version-matched.

Reviewed by an adversarial multi-lens pass (accounting/SQL, frontend/a11y,
prod-fact verification); all four verified findings fixed (SEK currency,
storno-lag guard, keyboard a11y, spacing tokens).

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

* feat(agent): gentle mount fade-in for the radial map (reduced-motion safe)

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

* fix(agent): render the page for a rules-only company (empty-state edge case)

isEmpty ignored explicit_rules, so a company with configured mapping rules
but no posted transactions hit the 'hasn't learned anything' empty state and
lost its rules section. Rules are independent of bookings. (CodeRabbit)

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

* feat(agent): show Kompetens (skills) + Fakta (memory) on the knowledge page

The 'Vad din agent vet' page now shows the full picture of what the agent
knows: alongside the booking map, a compact read-only view of its Kompetens
(the Swedish accounting/tax knowledge atoms it ships with, grouped by
tier as chips with active/dormant state) and the Fakta it remembers (top
learned facts with kind + source), each linking to /settings/assistant for
full management. Server-rendered via a new buildAgentCompetence() that
mirrors GET /api/agent/skills + /api/agent/memory. Also renders in the
no-bookings case so a new company still sees its agent's competence.
sv + en strings.

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

* refactor(agent): restructure knowledge page - hero graph + tabbed detail

Declutters the page per feedback: the booking map is the always-visible hero,
and the supporting detail (Kompetens · Minne · Regler & profil) moves into
tabs so only one view shows at a time instead of a long card stack. Split
AgentCompetenceSections into standalone CompetenceCard + FactsCard for the
tabs; removed the top stat row on request. sv + en.

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

* feat(agent): Reconciliation Aurora rewrite of the ledger knowledge graph

Full rewrite of LedgerGraph: node area = sqrt(spend), colour = cadence,
shape = supplier/counterparty, confidence = depth-of-field; on-mount
descriptor-collapse animation with xN badge; cadence pulse veins;
deterministic seeded layout; framer-motion only (no new deps); keyboard
navigation, reduced-motion and sr-only support. Build-verified; 3-lens
adversarial review findings fixed.

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

* fix(agent): sample-size-honest confidence in the ledger knowledge graph

dominant_account_share was raw cnt/total, so a counterparty with a single
booking rendered as '100% säkerhet': fake certainty by construction (the
data_quality_master Item-C / P3 finding). New migration replaces
get_ledger_deep_context with a Laplace-smoothed share (cnt+1)/(total+2)
(1/1 -> 0.67, 3/3 -> 0.80) and exposes the raw evidence as
dominant_account_count / dominant_account_total. The detail card now shows
'Bokförd hit i k av n fall' under the confidence bar; the existing focus
buckets, stroke widths and percent labels inherit the honest value
unchanged. pg-real test updated to guard the n=1 case.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:25:34 +02:00
Jakob Wennberg 715c671b67 fix(app): stop transient login errors flashing the full-screen fallback (#1002)
* fix(app): stop transient login errors flashing the full-screen fallback

The BankID login landing (/auth/callback then the /select-company picker)
fires several Supabase auth/DB queries right as the session cookies are set,
so a transient failure there (most often a refresh-token rotation race, seen
in prod as "Invalid Refresh Token: Already Used/Not Found" on /middleware, or
a stale JS chunk after a deploy) threw during render. Only (dashboard) had an
error.tsx, so these escaped every boundary and hit app/global-error.tsx,
blanking the whole document with a bare "Nagot gick fel" screen for ~1s before
the next request repainted and logged the user in as usual.

Add an app-level error.tsx (AppErrorBoundary) that catches those segments and
their layouts, and harden global-error.tsx. Both recover via a single guarded
hard reload instead of React reset(): a reload re-runs middleware (fresh
rotated auth cookie) and fetches a fresh bundle (ChunkLoadError after a
deploy), matching the browser-navigation self-heal these transients already
relied on, whereas reset() re-renders against the same stale payload/bundle. A
per-path sessionStorage time-guard bounds it to one reload so a persistent
error shows the manual fallback instead of looping.

Reported via support: transient "Nagot gick fel" flash on BankID login.

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

* fix(app): make the error-boundary reload guard loop-proof (review)

CodeRabbit and the PR Agent both flagged that the 12s time-window guard could
still loop if a failing render takes longer than the window (e.g. a slow SSR
that eventually throws). Replace the time window with a per-path, per-tab-session
one-shot flag, so the auto-reload fires at most once per path regardless of
timing and a genuinely persistent error settles on the manual fallback.
sessionStorage is per-tab, so a fresh visit (or a different path) still gets a
fresh auto-recovery.

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

* fix(app): claim the reload flag atomically + add support escape hatch (review)

CodeRabbit round 2:
- Major: the one-shot flag was written in the effect but the reload fired even
  if the write threw (sessionStorage quota full), so it could reload forever
  without ever recording the attempt. Claim the flag inside decideInitialPhase
  instead, so entering the 'reloading' phase guarantees the flag persisted;
  any write failure falls through to the manual 'fallback' (no reload).
- Minor: give global-error.tsx a support escape hatch. It can't use SupportLink
  (no providers when the root layout fails), so use a dependency-free mailto to
  the hardcoded support address.

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-12 20:05:43 +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
Mattsson 7d7f604e00 Add/stripe invoice link (#998)
* feat(supplier-invoices): show registered invoices under "Att betala" with inline approve

Registered supplier invoices are already booked as debt (2440) but were
hidden from the "Att betala" tab until approved, which confused users.
The tab now shows registered invoices too, marked "Ej godkand" with a
compact inline approve button. Approval remains the gate for payment,
not visibility; status model and approve API untouched.

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

* feat(reports): add date range filter to huvudbok (kontoanalys)

Mounts the existing ReportDateRange control on /reports/huvudbok so the
ledger can be narrowed to any date range within the fiscal year, matching
Fortnox kontoanalys. Lines before the range roll into each account's
opening balance so running balances stay correct at the range start;
lines after the range are dropped. Applies to the XLSX export too.

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

* feat(invoices): add optional payment link on invoices (paste-link MVP)

The user pastes a payment link created in their PSP dashboard (e.g. a
Stripe Payment Link) onto an invoice. The recipient gets a "Betala
online" button in the invoice email and a QR code + clickable link in
the PDF payment box. No PSP integration server-side: this is the
demand probe; a future Stripe Connect integration would auto-fill the
same column.

- invoices.payment_link_url (migration 20260709090000), https-only +
  2048-char cap enforced in CreateInvoiceSchema; empty string
  normalises to undefined and build-invoice-write always writes a
  concrete value so clearing the field on a draft edit NULLs the column
- editor field (real invoices only) with one-link-per-invoice hint;
  strings in sv+en (messages landed via e0e11066)
- email button (customer.language, hidden for credit notes/proforma/
  delivery notes, URL escaped for the href attribute) + URL in the
  plain-text part
- PDF QR + link row following the Swish QR pattern; wired into send,
  download and preview routes
- derived documents (credit note, proforma convert, recurring) do NOT
  copy the link: it encodes one amount for one specific invoice
- MCP gnubok_create_invoice accepts payment_link_url (validated at
  staging and re-checked in the commit executor); v1 API exposes the
  column; tools/list token ceiling bumped 45K -> 45.5K (ledger entry
  in payload-size.bench.test.ts, headroom was <10 tokens)

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

* fix(invoices): show oresavrundning on editor/form totals, supplier list and invoice email

The rounding logic (getDisplayTotal) was correct but only applied on the
PDF, invoice list/detail and review dialog. The invoice editor summary,
the supplier invoice form totals and the supplier invoice list showed the
raw ore total right next to the toggle, and the invoice email said
"Att betala" with the unrounded invoice.total while the attached PDF
showed the rounded amount (and the email also ignored the ROT/RUT
deduction).

Extract the PDF's Att betala block into getAmountToPay
(lib/invoices/rounding.ts) and point PDF + email at it so they cannot
drift; behavior-identical refactor for the PDF. Booked amounts stay
ore-exact; display-only as designed.

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

* test(reports): adapt huvudbok date-range tests to the two-step entry-lines fetch

The date-range tests (0969168f) mocked the old single-query shape with the
parent entry embedded on each line; main's refactor (fetchEntryLines)
queries journal_entries first and reattaches. Queue entry rows like the
other tests so the merge of the two features is actually exercised.

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

* fix(invoices): fetch full invoice projection in v1 send so ROT/RUT deduction and payment link reach the PDF and email

The v1 send route's hand-rolled column list omitted deduction_total,
deduction_personnummer_last4, payment_link_url and the item-level
ROT/RUT fields, so invoices sent via the public API overstated
'Att betala' and dropped the deduction box. Reuse the shared
INVOICE_FULL_COLUMNS/INVOICE_ITEM_FULL_COLUMNS so the send row can
never drift from the GET shape again.

Also harden the supplier-invoice inline approve: a thrown fetch left
the button stuck spinning; failures now refetch the true server state.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 00:56:16 +02:00
Jakob Wennberg 0ef5593388 refactor(reports): restructure declaration pages around the filing pipeline (#992)
* refactor(reports): restructure declaration pages around the filing pipeline

Momsdeklaration (/reports/vat-declaration) becomes the four-step flow the
user actually runs: kontrollera, granska, bokfoer, laemna in.

- NEW VatChecksCard, mounted first and ungated: the local pre-flight
  checks and the RC-basis-gap worklist used to render inside
  SkatteverketPanel BELOW the filing CTAs, and vanished entirely for
  free-tier or not-connected users, exactly the manual filers who must
  not file a declaration the checks would have blocked.
- The gap worklist scales: compact DataList rows (first 8 + visa alla),
  visible shared classification selects, per-row overrides, bulk
  "Korrigera alla" behind a confirm dialog with serial progress, and an
  in-page declaration refetch replacing "Ladda om sidan". The list
  outlives the aggregate RC_BASIS_MISSING check so remaining rows never
  vanish after the first fix.
- Summary card: status Badge + font-display headline amount instead of a
  Badge carrying the number; sanctioned h3 section heads; Table
  primitive; NEW import block (rutor 50/60-62) and the utgaende sum now
  includes 60-62 so it matches ruta 49 arithmetic; drill-downs preserved.
- SkatteverketPanel stops being an API console: three forward buttons
  (Validera, Spara utkast, Laas och signera), six lookup/recovery actions
  in an overflow menu with two-line descriptions, destructive confirms on
  radera/koppla bort, one truthful notice slot (not-found is info, never
  green), visible disabled-reasons instead of title attrs, signing link
  as a real anchor plus auto re-check of inlaemning on tab refocus, and
  per-period state reset so a Q1 signing link can never show Q4's
  kvittens.
- VatCompositionChart deleted (decorative donut mixing in/out VAT);
  export menu keeps xlsx only, XML/PDF live in the Laemna in card.
- Sibling declaration pages adopt the same grammar: PS gets shadcn
  selects, auto-fetch with stale-discard, refresh button, envelope-parse
  fix (message_sv never existed); NE/INK2 auto-fetch on fiscal-year
  change (kills stale-year data), shared keyboard-accessible
  DeclarationRutaRow (fixes the expense sign bug), whole-krona amounts
  matching filed SRU values, neutral info notes instead of bg-primary/10,
  Skeleton loading, accessible download errors.

UI-only: no API, schema, or dependency changes. All strings hardcoded
Swedish (statutory surface). Adversarially reviewed (19-agent pass); all
10 confirmed findings fixed in this commit.

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

* fix(reports): address CodeRabbit review on #992

- formatWholeKronor truncates instead of rounds: NE/INK2 SRU generators
  drop oere with Math.trunc, and the UI must show the filed figures.
- SkatteverketPanel disconnect surfaces non-ok responses instead of
  silently stopping the spinner.
- VatChecksCard distinguishes a failed rc-basis-gaps fetch from a real
  zero-gap result: destructive note + retry instead of the benign
  'Inga verifikationer hittades'.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:22:52 +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 2774e01258 feat(vat): book the momsrapport as an editable settlement verifikat (#980) (#983)
* feat(vat): book the momsrapport as an editable settlement verifikat (#980)

Adds a "Bokfor momsrapporten" card under the VAT declaration that builds
an editable verifikat proposal from the report and books it through the
ordinary journal entry form:

- lib/reports/vat-settlement.ts: proposal builder. Clears each 26xx
  account at exact ore, books the net on 2650 (att betala) or 1650 (att
  aterfa) at the filed whole-krona amount (buildFiledAmounts, oretal
  faller bort per SFL 22 kap 1 par), balances the gap on 3740. Surfaces
  existing vat_settlement entries in the period so the UI can warn
  before a double booking.
- GET /api/reports/vat-declaration/settlement-proposal: same period
  params as the sibling report routes.
- VatBookingCard (reports view): fetches the proposal, warns when the
  period already has a posted settlement or draft, and opens the
  JournalEntryForm (bare, prefilled, source_type vat_settlement) in a
  dialog so every line is editable before committing. Booking uses the
  existing engine path: balance validation, period locks, voucher
  series per source type.
- vat_settlement entries are excluded from the declaration projection
  (calculateVatDeclaration via new shared fetchVatAccountTotals, and
  the MCP computeVatReport for parity): a pure-projection report would
  otherwise read zero, and a later Skatteverket submission would file
  zeros, the moment the settlement is booked.

No migration needed: the vat_settlement source type shipped in
20260708100000.

Closes #980

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

* fix(vat): block re-booking a settled period, fail loud on lookup errors (CodeRabbit)

The proposal is not delta-aware (it re-clears the FULL period), so a
second booking while a posted settlement exists would corrupt the 26xx
balances: disable "Skapa verifikat" until that verifikat is annulled
(storno restores the balances). And since the existing-settlement
lookup now gates that button, a swallowed query error would silently
re-enable it: throw instead.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:16:57 +02:00
Jakob Wennberg 5b505bb4a7 fix(entitlements): stop passing a component across the RSC boundary on the extension upsell page (#962)
The paywall branch in app/(dashboard)/e/[sector]/[slug]/page.tsx resolved
the extension icon server-side and passed the resulting forwardRef component
into the 'use client' EmptyState. React cannot serialize a component across
the server-to-client boundary, so non-payers opening a gated extension
(e.g. /e/general/invoice-inbox) got a 500 error page instead of the
upgrade CTA (digest 1621801304, 2026-07-08).

Fix: new client wrapper components/extensions/ExtensionUpsellState.tsx
accepts only plain string props (iconName, title, description, ctaLabel,
ctaHref) and resolves the icon client-side via resolveIcon, the same
pattern DashboardNav and the command palette already use. The server page
now passes definition.icon as a string. The two other resolveIcon call
sites in server pages render the icon inside the server component, which
is valid RSC, and are left untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:03:46 +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 0626bb6326 feat(app): prompt to reload when a newer deploy is live (#951)
Long-open tabs keep running the JS bundle they first loaded, so a shipped
change can look "missing" (e.g. a new settings field appearing only after a
full reload) until the whole app is reloaded. Add a small, unobtrusive prompt
that detects a newer deploy and offers a one-click reload.

- next.config: inline the deploy's commit SHA into the client bundle as
  NEXT_PUBLIC_BUILD_ID (empty in dev / self-hosted, which disables the check).
- /api/version: public, no-store route returning the running deployment's SHA
  at request time.
- DeployReloadPrompt: compares the two on load, on tab focus, and on a 30-min
  backstop; shows a bottom banner with "Ladda om" on mismatch. Mounted once in
  the root layout. No service worker, degrades to a no-op with no build id.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:14:37 +02:00
Jakob Wennberg 82c66739d7 fix(transactions): steer duplicate guard to matching for ledger-only vouchers (#958)
When the booking-time duplicate guard flags a ledger-only voucher
candidate (transaction_id null: a verifikat from an SIE import, a paid
invoice, a salary payout), the dialog's primary action is now "Matcha
mot verifikatet": it links the bank transaction to the existing voucher
via /api/reconciliation/bank/link (the same path MatchVoucherDialog
uses) instead of double-booking the same affarshandelse. "Bokfor anda"
stays available but demoted to an outline button. Sibling-transaction
candidates keep today's layout: matching a second bank line onto a
voucher that already has one is the N:1 edge case, not the default.

Wired through both call sites: the transactions page (runCategorize)
reuses handleVoucherLinked's refresh, and TransactionBookingDialog
(JournalEntryForm, /api/transactions/[id]/book path) reuses the booked
flow so in-dialog attached documents land on the matched verifikat and
the row leaves the list, with a "Bankhandelsen kopplad" toast instead
of "Bokford". No lib change: the candidate payload already carries the
transaction_id discriminator, covered by existing detection tests.

Closes #919.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:08:44 +02:00
Jakob Wennberg 2e7931b36b feat(customers): let users set a customer number shown on the invoice (#957)
Implements #914 (kundnummer on customers, printed on the invoice PDF).

- Migration: nullable text column customers.customer_number, no unique
  constraint in v1 so existing rows and imports keep working.
- API: CreateCustomerSchema/UpdateCustomerSchema accept an optional
  customer_number (trimmed, max 32 chars, nullable-then-optional so the
  OpenAPI registry sees it as not required); create/update routes
  persist it and normalize empty string to null so it can be cleared.
- v1 public API: customers create/detail/update round-trip the field
  (insert and update field lists, response projections, response
  schemas), and the invoices :send route fetches customer_number in its
  explicit customer join so the emailed PDF matches the downloaded one
  (the pdf route already selects customers(*)).
- UI: optional Kundnummer field in CustomerForm (next-intl keys in both
  sv and en), wired into the edit dialog's initialData; read-only
  Kundnummer row on the customer detail page's business-details card.
- Invoice PDF: renders "Kundnr:" / "Customer no.:" in the customer box
  when set; the PDF reads the live customers join, so no snapshot
  column is needed.
- Tests: route tests cover 400 validation, trimming, clearing with
  null/empty, and omit-leaves-untouched on POST and PATCH; v1 tests
  cover the create/update round-trip (insert/update payload + response
  projection) and the :send customer-join projection.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:27:03 +02:00
Jakob Wennberg 982fe77f72 fix(import): actionable hint when a bank CSV lands in the opening-balance importer (#953)
* fix(import): hint when a bank statement is uploaded as opening balances

Uploading a bank statement CSV to the opening-balance importer produced
the generic 'Inga konton med belopp hittades' error with no clue that
the file belongs in the bank-transactions importer (#918, users got
stuck together with #915).

When the opening-balance parse yields zero account rows, the parser now
runs the registered bank-file format detectors over the CSV content
(the generic CSV fallback never auto-detects, so any match is a real
bank format) and reports the matched format name as
detected_bank_format on the parse result. The upload step then shows an
actionable Swedish error naming the bank plus a button that routes to
the bank-transactions importer (/import?mode=bank).

Closes #918

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

* fix(import): use the standard bank-import CTA wording (CodeRabbit)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:10:09 +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 b1f85bc33e fix(inbox): stop the onboarding card rendering twice below xl (#944)
* fix(inbox): stop the onboarding card rendering twice below xl

The Dokumentinkorg workspace is a 3-pane master-detail grid that
collapses to a single stacked column below the xl breakpoint. With an
empty inbox the "Så funkar dokumentinkorgen" onboarding card rendered
in both the list pane (compact) and the main preview pane, because the
preview pane was never hidden when stacked. So on viewports under
1280px (narrow or split windows, smaller laptops) the same card showed
twice.

Hide the preview and fields panes below xl when the inbox is empty so
the list's compact card is the single onboarding surface. At xl+ the
3-pane layout is unchanged: quiet empty list plus one centered card.

Also align both loading skeletons (WorkspaceSkeleton and the
e/[sector]/[slug] route skeleton) to the same xl breakpoint; they
previously collapsed at md/lg, causing a 3-pane to 1-pane snap between
768 and 1280px while the workspace loaded.

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

* refactor(inbox): use existing hasAnyItem for empty-state checks

Address CodeRabbit review: the empty-inbox condition was re-derived as
`items.length === 0` in three spots (list pane, preview pane, fields
rail) while `hasAnyItem = items.length > 0` already exists. Reuse
`!hasAnyItem` in all three so "empty" has a single source of truth.
Behavior-preserving.

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 11:05:40 +02:00
Mattsson ae489cfdcb fix(client): update AWS credential handling to prefer BEDROCK_AWS_* environment variables (#937) 2026-07-08 18:09:34 +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 19cbb0094b fix(entitlements): gate the AI-only invoice-inbox for non-payers (#924)
The Dokumentinkorg (invoice-inbox) leaked past the paywall: visible in the
sidebar, command palette, and home "Att gora" list, its page directly
reachable, and every non-AI HTTP route open. Its whole value is AI field
extraction (Claude Sonnet 4.6 via Bedrock), already the paid chokepoint
elsewhere, so gate the whole surface on CAPABILITY.ai.

- EXTENSION_REQUIRED_CAPABILITY map + resolvers (keys.ts, sectors.ts) as the
  single source the nav item, the page, and the API dispatcher all read.
- Hide the sidebar item, command-palette entry, and home inbox row for
  non-payers; subtract inbox_document from the "Att gora" total via one shared
  visibleWorklistTotal helper (KPI tile + header cannot drift), clamped to >= 0.
- Block the /e/[sector]/[slug] page (fail-closed) with an upsell EmptyState.
- Enforce the capability in the extension API dispatcher (the single chokepoint
  that already enforces MFA), so every company-context inbox route 403s. The
  skipAuth /inbound webhook stays open (freeze-and-retain).
- FORCE_PAYWALL=true override so the real gate is exercisable in local dev.
- Tests: gating resolver, FORCE_PAYWALL, dispatcher 403/allow/webhook-exempt,
  visibleWorklistTotal, and enable-banking /connect + /sync 403.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:20:39 +02:00
Jakob Wennberg a566a42aec fix(entitlements): gate remaining paid-feature UI, drop stale beta pricing copy (#921)
* fix(entitlements): gate remaining paid-feature UI and drop stale beta pricing copy

Second post-cutover sweep. Non-payers still hit live controls that 403 at
the server gate, plus dead plan names in the inbox guide:

- document-inbox guide: remove "Gratis under beta för Open-användare /
  Pro-planen / Se priser" (plans that no longer exist); inbox stays free,
  AI extraction is pitched as part of the subscription
- payslip send button (RunProgressBar): disabled + tooltip without
  email_send; the free PDF download next to it is untouched
- skattekonto "Synkronisera nu": disabled + tooltip without skatteverket
- invoice post-create send-now dialog: skipped without email_send (the
  invoice page's SendInvoiceDialog carries the upsell)
- recurring-invoice auto_send checkbox: disabled + UpgradeNote without
  email_send, and force-unchecked so edit mode can't PATCH it back on
- AgentChat composer: replaced with UpgradeNote without ai, covering
  deep links to /chat/* and conversations opened before expiry
- /onboarding/agent server page: hasCapability(ai) redirect to
  /settings/billing before the gated composer stream starts

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

* fix(entitlements): address review findings on the gating sweep

- AgentChat: the mount effect auto-fired the first /api/agent/invoke on a
  fresh start regardless of capability; now returns early without ai, and
  Regenerate/Correction re-invokes are guarded too (real find by review bot)
- recurring auto_send upsell copy moved to i18n (auto_send_requires_subscription,
  sv+en) matching the rest of the dialog
- disabled-button tooltips wrapped in a span (payslip send + skattekonto
  sync): browsers suppress title on disabled elements

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 14:22:14 +02:00
Jakob Wennberg ed7a178ac6 fix(import): scope bank account picker to the active company (#922)
The chart_of_accounts query in BankFileConfirmStep had no company_id
filter, so RLS returned bank accounts from every company the user is a
member of, duplicating 1930/1940/etc. in the dropdown and making the
selected value ambiguous across companies.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 14:17:35 +02:00
Mattsson cbffcd7292 fix(invoices): accept empty self-billing fields on invoice create (#920)
#911 added self-billing fields to the shared CreateInvoiceSchema with
external_invoice_number: z.string().min(1), but the invoice form has
always sent that field (plus self_billing_agreement_ref and
received_date) as '' on every normal invoice. The empty string failed
min(1), so every invoice create returned 400.

Normalise the empty optional self-billing strings to undefined in the
schema (matching the existing optionalIsoDate / deduction_brf_org_number
patterns), and strip the unused empty carriers client-side before the
form POSTs. Required-when-self-billed is still enforced post-parse in the
v1 route, so the self-billed path is unaffected. Adds schema regression
tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:20:23 +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
Mattsson 3a88b53fd9 Add/api and invoice (#911)
* feat(salary): validate employee clearing/kontonummer at entry

Bank details on the "Anställda" form had no structural validation, so a
typo in clearing/kontonummer was saved silently and only surfaced at
Bankgirot LB generation (or never, on the SEPA path).

Adds a shared validator (lib/salary/payment/bank-account.ts) wired into
the create dialog, edit page, CreateEmployeeSchema, and the PATCH route:
4-digit clearing or 5-digit Swedbank (8xxxx), 5-11 digit account,
both-or-neither. Mirrors encodeReceiverAccount so entry-time validation
matches what the payout layer can encode. Update validates only when a
bank field actually changes, so legacy free-text data stays editable.
Includes a conservative clearing to bank-name hint (null for unknown
ranges). Per-bank mod10/mod11 checksum deferred to a soft-warning
follow-up.

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

* feat(chart-of-accounts): styled delete warnings and bulk select-all

Replace the native window.confirm() on single-account delete with the styled DestructiveConfirmDialog, and add to the prune dialog a master 'select all unused accounts' checkbox plus an explicit confirmation step before bulk deletion. New sv/en strings for the confirm titles and actions.

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

* fix(salary): encrypt personnummer on v1 employee create; tolerate legacy plaintext on read

The v1 REST create route stored personnummer unencrypted, which then threw ERR_CRYPTO_INVALID_AUTH_TAG on every decrypt-on-read path and 500'd the employees roster. Encrypt on write in v1 create, decrypt on read in the v1 list/detail/patch responses, and make decryptPersonnummer pass a raw 12-digit value through with a warn so a legacy plaintext row can't take the roster down. Encrypt seeded personnummer. Add a gated, idempotent backfill for existing rows.

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

* feat(bookkeeping): save a manual entry as a reusable template

Add a "Spara som mall" action to the manual journal-entry form next to the existing "Anvand mall" picker, so users can capture a booking pattern the moment they work it out. Opens the shared TemplateForm (create mode) pre-seeded from the current lines via deriveTemplateLinesFromBooking, and saves through the existing POST /api/settings/booking-templates. Rendered in both the mobile and desktop layouts and in create + edit modes.

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

* fix(pending): label all staged operation types

The Granskning list rendered the raw snake_case operation_type (e.g.
create_supplier_invoice_from_inbox) for any type missing from the label
map, which hogs the meta row and wraps awkwardly on mobile. Add short
sv/en labels for all operation types in OPERATION_RISK_TIERS, plus a
humanized fallback for future ones, and simplify the label map to a plain
operation_type -> i18n-key record (the icon/variant fields were dead).

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

* feat(reports): let users file moms without a Skatteverket connection

The momsdeklaration was never gated on the Skatteverket connection (it
renders from the bookkeeping), but the not-connected "Anslut med BankID"
card read as a wall. Make manual filing a first-class path:

- Add a "Lämna in din momsdeklaration" card under the report with a PDF
  download (SKV 4700 layout, hela kronor) and a skatteverket.se link.
- Add a momsdeklaration PDF route + template; buildManualFilingRows()
  rounds each ruta to whole kronor and recomputes ruta 49 per the SKV
  4700 formula so it ties out. The PDF is a read/record copy, not a
  submission file (moms has no upload channel).
- Offer PDF alongside Excel in the report's export menu.
- Reframe the not-connected SkatteverketPanel to "Skicka direkt till
  Skatteverket (valfritt)".

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

* feat(salary): compact new-employee dialog and warn on bad account check digit

Redesign NewEmployeeDialog into a compact layout: borderless sections split
by hairline dividers (no per-section cards), a fixed header + scrolling body
+ solid footer (fixes content showing through the old sticky bar), and denser
grids. EmployeeTaxCard gains a `flat` variant so the dialog can host it
without card chrome; the edit page keeps the boxed version.

Add non-blocking Swedish account check-digit validation
(lib/bankgiro/account-number.ts): mod10 (reuses luhn) + mod11, with a
clearing->method table from the Bankgirot "Bankernas kontonummeruppbyggnad"
spec, cross-checked against jop-io/kontonummer.js and verified against a real
account (Forex 9420/4172385). Surfaced as a soft warning in both employee
forms; unrecognised clearings return 'unknown' so we never warn on a valid
but unmapped account. Never blocks saving.

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

* feat(invoices): configurable send time + editing for recurring invoices

Re-register the accidentally-removed recurring cron (now hourly) and add a
per-schedule send hour (Europe/Stockholm, DST-aware). The cron never sends for
a past date, and the enabling migration pauses every existing schedule on
deploy so nothing auto-sends behind a user's back; users reactivate consciously
(with a confirm) or click "Skapa faktura nu" to send this month on demand.
Automatic sending now requires a customer email. Adds a full edit flow (row
click opens the prefilled form, PATCH), fixing the row-click 404.

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

* feat(invoices): configure självfaktura via the invoice API

Add an optional is_self_billed flag (plus external_invoice_number,
self_billing_agreement_ref, received_date) to the public invoice-create
endpoint so callers can register a received self-billing invoice
(mottagen självfaktura, ML 17 kap 15§) via the API. It was previously
only reachable from the internal dashboard route, so it was missing from
the API docs.

Extract the booking into a shared service (lib/invoices/self-billed-sale.ts)
and refactor the internal /api/invoices/self-billed route to a thin wrapper
over it, so the dashboard and the API cannot drift. Books as a sale
(Debit 1510 / Credit 30xx+26xx) with the counterparty's number; no own
number is consumed. Fields are plain optionals (no schema refine) so
UpdateInvoiceSchema.omit() keeps working; required-when-self-billed is
enforced in the route. Documented in the endpoint registry. No migration
(columns already exist).

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

* fix(settings): allow a partial voucher-series-per-source-type map

In Zod 4 an enum-keyed z.record is exhaustive (every source_type
required), so saving a default_voucher_series_per_source_type map that
omits a source type (e.g. the newly added result_appropriation) failed
with "expected string, received undefined". Use partialRecord so the map
can be sparse; the engine falls back to series 'A' for any unmapped key.

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

* refactor(salary): resolve employer name via getCompanyDisplayName

Payslip PDFs, the payslip email, AGI, KU10, and the BG/LB + SEPA payment
files now resolve the employer name through getCompanyDisplayName
(company_settings.company_name, falling back to companies.name), matching
how invoices already display it. Read-side coalesce, so no migration or
backfill: companies.name is write-once at onboarding and not authoritative
for these surfaces. The sidebar company switcher uses the same coalesce for
the non-active companies in the list.

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

* perf(kontoplan): index-only account usage counts + lighter reference load

Add a covering index on journal_entry_lines (journal_entry_id,
account_number) so get_account_usage_counts becomes an index-only scan
(prod worst case ~440ms). Slim /api/bookkeeping/accounts/reference to
return only the company's activation rows and merge against the
client-bundled BAS_REFERENCE instead of re-sending the full ~1,300-account
catalog every load, and defer the BAS catalog + usage counts off the
first-paint critical path in ChartOfAccountsManager.

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

* i18n(salary): add bank-account checksum warning string

sv/en strings for the employee bank-account (clearing/kontonummer) soft
checksum warning shown by the create/edit forms.

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

* docs: update decision log

Append the 2026-07-06/07 decision entries (salary employer-name coalesce,
sidebar switcher, employees API personnummer fix, kontoplan load
optimization, momsdeklaration manual filing, recurring invoices resend +
reactivation + editing, "spara som mall", voucher-series partial map, and
självfaktura via the invoice API).

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

* fix: address compliance-review findings on recurring invoices + moms filing

- recurring cron: close the double-send window with an atomic compare-and-set
  claim on last_run_at (release-on-failure) so two overlapping hourly runs
  can't both spawn from the same stale batch row
- recurring edit dialog: force auto_send=false whenever the effective customer
  has no email, so a disabled-but-checked box can't PATCH auto_send=true after
  the async customer load
- momsdeklaration manual-filing: truncate rutor to whole kronor (öretal faller
  bort per SFL 22 kap 1 §) instead of round-to-nearest, matching the SRU path

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:14:59 +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
Jonas Flodén 5149caebbf fix(transactions): use the resolved cash account name as bank line description (#899)
The booking dialog's bank line description was always the generic
"bank_line_description" translation regardless of which cash account
was actually resolved via resolveAccount(). This was accurate while
the bank line was hardcoded to 1930, but now that the dialog resolves
the real cash_account_id (#769) it can pick a different account whose
own name should show up on the line instead.

Signed-off-by: Jonas Flodén <jonas@floden.nu>
2026-07-06 12:14:02 +02:00
Jakob Wennberg ac1529e413 fix(ui): block illegal VAT rates, honest opening-balance state, surface account-save errors (#902)
* fix(ui): block illegal VAT rates, honest opening-balance state, surface account-save errors

- Supplier invoice form (#863 item 1): onSubmit now blocks any
  non-reverse-charge line whose VAT rate is outside the legal Swedish
  set {25, 12, 6, 0} with a destructive toast naming the line and the
  legal rates. Server-side schema tightening stays out of scope.
- Opening balance import (#837): the summary derives isBalanced from
  the running totals (0.01 epsilon) and renders the AlertCircle
  destructive pattern with the differens amount when unbalanced;
  canExecute includes isBalanced so the commit button is disabled
  instead of funneling users into a server-side rejection.
- EditAccountDialog (#838): a failed account save now shows a
  destructive toast with the server-provided message instead of being
  silently swallowed; the dialog stays open for retry.

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

* fix(ui): compare opening-balance totals in whole ore, map account-save errors to Swedish

Review findings on the first pass: the < 0.01 epsilon misclassified exact
1-ore imbalances as balanced (0.03 - 0.02 evaluates just under 0.01), and
the save-failure toast surfaced raw English server text instead of routing
through getErrorMessage like the sibling handlers in the same file.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:20:46 +02:00
Jakob Wennberg c43c4a076c feat(salary): allow recalling approval on a salary run (approved → review) (#894)
* feat(salary): allow recalling approval on a salary run (approved → review)

An approved run was a dead end: the only forward path was paid → booked,
so a wrong salary snapshot (e.g. stale employee monthly pay) could not be
fixed without paying and then storno-correcting. Approval is an internal
control point — nothing legally binding happens until payment, booking,
or AGI filing — so recalling it is allowed until the AGI reaches
Skatteverket.

- POST /api/salary/runs/[id]/unapprove: approved → review; clears
  approved_by/at and payment-file tracking; deletes generated-but-unfiled
  AGI declarations (stale XML must not stay exportable); 409 once the
  AGI is pending_signature/submitted/accepted — correction AGI (same
  specifikationsnummer) is the lawful path then.
- New salary_run.approval_reverted event for the audit trail.
- "Ångra godkännande" secondary action on the run page with a
  consequence-aware confirm (payment file possibly at the bank, sent
  payslips, generated AGI), sv + en.

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

* fix(salary): delete stale AGI after the unapprove transition, not before

Bot-review triage on #894: the declaration delete ran before the
optimistic status update, so a failed transition (concurrent flip,
transient error) would have destroyed the generated AGI while the run
stayed approved. Flip the run first; a delete failure afterwards is
harmless (agi_generated_at is already null, regeneration upserts over
the orphan). Also record the deleted declaration id in the
approval_reverted event payload, and warn in the confirm dialog that a
manually filed AGI requires a correction declaration instead.

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

* fix(salary): close the unapprove TOCTOU on concurrent AGI filing

Superagent P2 + compliance-bot round 2 on #894: AGI submission is
allowed from approved (also out-of-band via MCP/public API), so a
filing could land between the route's read and its update, and the
route would flip the run and delete a submitted declaration.

- Re-assert agi_submitted_at IS NULL inside the optimistic update
  filter, not just on the stale read.
- Guard the declaration delete with the same status filter so it
  no-ops if the declaration advanced since the read; log a miss.
- Zero-row update (PGRST116) now returns 409 "status har ändrats"
  instead of a generic 500.
- The approval_reverted event only reports deletedAgiDeclarationId
  when a row was actually deleted.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:44:22 +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 764348e99c feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement (#886)
* feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement

The final rung of the dimensions ladder
(dev_docs/dimensions_implementation_plan.md §7 row 10):

- custom dimensions: POST /api/dimensions creates registry dims (next free
  SIE number >= 20 when omitted; explicit numbers allowed — SIE import
  already mints reserved ones); register gets a 'Ny dimension' dialog with
  a quiet Avancerat disclosure for the #UNDERDIM parent; GET now carries
  parent_sie_dim_no (the column + SIE round-trip existed since PR1/PR5 —
  this exposes it)
- account_dimension_rules (migration 20260703120000): one rule per
  (account, dimension) — required / default / fixed, per-rule is_active,
  company-scoped RLS, composite FK to the registry, value-presence CHECK
- enforcement, opt-in BY CONSTRUCTION (zero rules = engine byte-identical;
  deliberately NO settings toggle — a rule that exists but is ignored is
  worse than either extreme): default/fixed apply onto line bags at draft
  creation (fixed overwrites, default fills); required asserts at
  commitEntry with a Swedish MANDATORY_DIMENSION_MISSING naming every
  account + dimension; the bulk-book route runs the same policy before its
  RPC; storno/correction paths never pass through commitEntry so history
  always reverses regardless of policy; rule fetches fail open incl.
  thrown exceptions
- chart of accounts: per-account Dimensionsregler section in
  EditAccountDialog (Krävs/Förval/Låst, value picker, pause switch),
  gated on the existing dimensions toggle, quiet when empty
- pickers: LineDimensionFields is registry-driven (one combobox per active
  dimension, cached fetch, hardcoded 1/6 fallback) — every existing mount
  lights up custom dims with zero changes
- agent briefing: per-dimension required_on_accounts/default_on_accounts
  so agents self-correct instead of bouncing off the policy error
- rules CRUD API with existence/active/company validation and qualified
  DTO ids; firm_id FK deferred until the firms table lands (per plan)

39 new tests (pure-fn rules, engine enforcement, both new API surfaces,
pg-real RLS/CHECK/cascade suite); full suite 6,791 green; migration
replayed on a fresh container.

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

* fix: renumber migration to 20260703200000 — version collision with prod

The concurrent session shipped pending_operations_add_link_document_to_voucher
as 20260703120000 today; the Supabase preview branch (cloned from prod)
rejected the duplicate version key.

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

* fix: review round — auto-pick retry on collision, fail-open warnings, query schema

- POST /api/dimensions retries once past a concurrent number claim when the
  number was auto-picked (explicit choices still 409)
- every fail-open skip of the dimension-rules policy now logs a structured
  warning (engine draft/commit paths + bulk-book) — deliberate fail-open,
  but observable
- GET /api/dimensions/rules validates its query through
  ListDimensionRulesQuerySchema instead of an inline regex

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:50:28 +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
Jakob Wennberg 9f7c842a33 feat(skatt): setup gates + auto-loading momsdeklaration across Skatt & bokslut tabs (#885)
* feat(skatt): setup gates + auto-loading momsdeklaration across Skatt & bokslut tabs

Every tab in the Skatt & bokslut nav group now tells an unconfigured user
what is missing and where to fix it, instead of dead-ending or rendering
zeros:

- Momsdeklaration: gates on vat_registered with a settings CTA; auto-fetches
  the configured period on load and on every period change (no more "Hämta"
  button); one period control (räkenskapsår picked inline for helårsmoms,
  shell selector + back link dropped via new ReportDescriptor.standalone);
  raw <select>s replaced with the Select primitive; banner when moms_period
  is missing; BankID connect returns to the page instead of the report
  library.
- Deadlines: callout (sv+en) when no system-generated tax deadlines exist —
  they are derived from tax settings, so point at /settings/tax rather than
  presenting an empty manual todo list.
- Årsbokslut: "no räkenskapsår yet" (CTA to bookkeeping settings) is now
  distinguished from "nothing to close yet".
- Skattekonto: non-auth fetch failures render an error card with retry
  instead of the misleading "inget saldo hämtat ännu" empty state.

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

* fix(skatt): address bot review — res.ok guard, blocking moms_period gate, panel hidden while räkenskapsår unresolved

- Auto-fetch treats non-2xx or unparsable responses as errors (with retry)
  instead of rendering undefined data.
- momsPeriodMissing now blocks the declaration (EmptyState + settings CTA)
  rather than fetching a guessed quarterly period behind a banner — a
  declaration submittable for the wrong period type is a hazard, not a
  convenience.
- SkatteverketPanel is not rendered while yearly mode awaits a fiscal
  period, so its actions can never target an unconfirmed period.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:12:51 +02:00
Jakob Wennberg 100a4d1291 feat(ux): create salary runs, employees & recurring schedules in modals (#883)
* feat(ux): create salary runs, employees & recurring schedules in modals

The last three full-page create flows move to ?new=1 URL-driven dialogs,
matching the verifikat/invoice pattern (#861):

- Salary run: 4-field form on /salary — creation was pure interruption
  before landing on the run-detail workspace.
- Employee: the last register entity still page-based after customers,
  suppliers, and articles.
- Recurring schedule: consistency with the invoice modal it feeds.

Old /new routes survive as redirects so bookmarks and agent intents keep
working. Dialogs close explicitly (header X / Avbryt) so half-typed forms
survive stray Escape or backdrop clicks.

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

* docs: move DECISIONS.md to repo root

dev_docs/ is gitignored, so the decision log was invisible to other
developers. Root matches the existing convention (CONTRIBUTING.md,
SECURITY.md). CLAUDE.md pointer updated.

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

* chore(lint): ignore Claude Code worktrees in eslint walk

.claude/worktrees/ holds full repo copies; without the ignore, local
npm run lint / check:lint walks them until the ratchet's 64 MB JSON
parse buffer overflows. CI is unaffected (no worktrees there).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:18:20 +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 ea236cbcdf fix(reconciliation): Bankavstämning phase 0 — correctness + feedback batch (+ nav IA regrouping) (#879)
* feat(nav): interaction-mode sidebar grouping — Arbeta/Analys/Data/Skatt & bokslut

Nav IA redesign phase 0 (dev_docs/nav_ia_redesign.md): same routes,
regrouped by what the user is doing. CLAUDE.md restructured around Hard
Rules (doc references updated); pending-page explainer removed.

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

* fix(reconciliation): correctness + feedback batch for Bankavstämning (phase 0)

Engine: fetchAllRows pagination on status/run/RPC fetches (silent 1000-row
cap corrupted totals), optimistic-lock guards on manualLink + apply,
unlink audit rows attributed to the acting user (was: company UUID),
selected_matches partial apply intersected with a fresh match run.

View: silent in-place refresh instead of a full-page skeleton per action,
checkbox-gated apply with confidence badges (fuzzy unticked) in chunks of
500, honest result toasts, dry-run errors surfaced, ranked per-row picker
candidates pinned to the applied date window, currency-correct amounts
(bank side in account currency, GL side SEK), voucher links, translated
source types, colored differens, dirty-date-filter guard.

Discovery: year-end preflight 404 href fixed (/reconciliation/bank never
existed), ⌘K palette entry, real links from the transactions page.

v1: status registry schema now matches the actual ReconciliationStatus
payload, errors documented as a count, false ~0.85-threshold pitfall
replaced, route test mocks the real shape.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:21:38 +02:00
Jakob Wennberg 28827c2613 feat(dimensions): voucher-level retro-tagging workbench — reversal pairs hidden by default (#874)
* feat(dimensions): voucher-level retro-tagging workbench, reversal pairs hidden by default

UX rework of the BulkTagWorkbench (follow-up to #867): the verifikat is now
the unit of work, matching how users think ('that invoice belongs to project
X') and how every Swedish bookkeeping tool presents entries.

- /api/dimensions/tagging/lines returns voucher-grouped results via a
  two-step query: filters select QUALIFYING vouchers (line-level predicates
  become 'voucher has such a line' through the inner join), then the
  complete line set for each — tagging a voucher always covers the whole
  verifikat, never the filtered subset of one
- reversal pairs are EXCLUDED by default: an annulled entry and its storno
  net to zero in every dimension bucket as long as both sides carry the
  same tag, so retro-tagging them is a no-op with an asymmetry foot-gun
  attached; 'Visa annullerade' opts them back in, and the blocking
  motverifikat confirmation survives only in that view (correction rebooks
  remain taggable — only the original+storno pair is hidden)
- voucher rows: label, description, date, line count, distinct tag chips +
  'Delvis taggad' state, single total amount (no debit/credit columns);
  chevron expands to per-line rows (signed amount, per-line checkboxes)
  for the mixed case (a voucher split across projects)
- selection stays line-id based under the hood (the retag RPC is per
  line); shift-click ranges operate on vouchers; apply groups by resulting
  map and now chunks to the apply route's 500-line cap; failed vouchers
  auto-expand with their Swedish RPC errors inline
- 'endast otaggade' now means 'vouchers with at least one untagged line';
  the cap counts vouchers (default 150, max 300)

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

* fix: pull counter-vouchers into the annulled view even outside filters (review)

Without this, a pair leg whose counter fell outside the date range would
show no motverifikat warning at all — one-sided tagging would slip
through silently, exactly the Srf U 14 skew the guard exists to prevent.
Also scope the line fetch explicitly through the parent company filter
(defense in depth).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:11:07 +02:00
Jakob Wennberg 755e0f7e47 feat(dimensions): PR7 producers — auto-tagged documents (invoices, supplier invoices, bulk-book, templates, MCP) (#868)
* feat(dimensions): PR7 producers — invoices/supplier invoices carry dims, generators propagate, BulkBook + templates + MCP bags

Source documents now carry dimension tags and every entry generator
propagates them onto journal lines (dev_docs/dimensions_implementation_plan.md PR7):

- invoices/supplier_invoices.default_dimensions + per-item dimensions
  (migration 20260702200000; jsonb DEFAULT '{}' + object CHECK)
- invoice-entries: issuance/payment/cash/credit propagate — item bags merge
  over the invoice default per revenue line (account+bag aggregation
  identity), payment vouchers re-propagate the linked invoice's bag onto
  every leg incl. FX result lines; ROT/RUT 1513 carries the item bag
- supplier-invoice-entries: registration/payment/cash/privately-paid/credit
  propagate with the same merge rules (expense buckets keyed account+bag)
- bulk_book_transactions RPC persists per-line bags + derives
  cost_center/project mirrors in SQL (migration 20260702201000; malformed
  bags rejected with BULK_BOOK_INVALID_DIMENSIONS); route merges the header
  default into template/manual lines
- counterparty templates: LinePatternEntry.dimensions learned from SIE
  voucher history (kept only when every occurrence agrees), applied to
  business lines on booking; QuickReviewDialog shows a dims badge
- categorize: staged dimensions bag tags business lines only (bank/VAT
  untagged); credit/convert/inbox copy paths carry bags forward
- propose-payment/send-lines stamp the invoice default so the editable
  payment grid books what the preview shows; mark-paid override lines
  accept dimensions
- UI: InvoiceEditor + NewSupplierInvoiceForm header KS/Projekt pair with
  per-row override; BulkBookDialog header default pair (both tabs)
- MCP: default_dimensions/items[].dimensions on create_invoice +
  create_supplier_invoice_from_inbox, dimensions on categorize_transaction,
  per-line bags on bulk_book_transactions — resolve-don't-select via the
  shared registry helpers, resolutions echoed

32 new propagation unit tests + 4 pg-real tests for the RPC migration.

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

* test: use roundOre in new dims rounding assertions (ratchet)

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

* fix: copy dimension bag per payment line, document dimensionsBagKey normalization contract (review)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:01 +02:00
Jakob Wennberg 816b1769c8 feat(dimensions): PR6 retro-tagging — audited retag carve-out, BulkTagWorkbench, staged MCP tool (#867)
* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool

Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.

Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).

retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).

Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.

UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).

MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).

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

* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence

- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
  P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
  guard) → 403, anything else → logged 500 with a generic message. No more
  substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
  naming the unselected counter-vouchers before apply (Srf U 14 gross
  reporting — one-legged retags silently skew project P&L; the banner alone
  was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
  the direct dialog/workbench path allows {} (human untags phantom codes,
  logged with reason), the MCP staged path rejects it (agents never
  bulk-clear history).

Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:02:34 +02:00
Jakob Wennberg fb3fe82a56 feat(dimensions): PR5 SIE round-trip — lossless dimension import + undo lockstep (#866)
* feat(dimensions): PR5 SIE round-trip — lossless dimension import, registry upsert, undo lockstep

SIE import previously parsed and silently DISCARDED all dimension data
(object lists at sie-parser.ts:651-654, #DIM/#OBJEKT in the ignore list at
:689). Import is now lossless — the dimensions plan PR5 milestone.

Parser: #TRANS object lists ({1 "KS01" 6 "P001"}) land on the line as an
SIE-dim-no → code map (canonical numeric keys, quoted codes, malformed
pairs warn); #DIM/#UNDERDIM/#OBJEKT parse into registry records. OIB/OUB
stay ignored (dimension reporting is P&L-only in v1).

Importer (lib/import/sie-dimensions.ts): upserts missing dimensions/
dimension_values rows — never renames existing ones (ON CONFLICT DO
NOTHING); undeclared reserved numbers synthesize their SIE-standard names
(mirroring the export's orphan synthesis); codes violating the registry
CHECK are skipped with a warning but survive verbatim on lines (documented
legacy-free-text exception). Bulk voucher insert now writes the dimensions
jsonb + cost_center/project mirrors via the sanctioned dual-write helpers
(no trigger suppression needed — the immutability trigger guards
UPDATE/DELETE, not INSERT). Import auto-enables dimensions_enabled with a
result-card notice (pre-authorized by the column comment). arcim-migration
provider syncs inherit all of it via the shared parser/importer.

Undo lockstep (migration 20260702154500): created_by_import_id provenance
on both registry tables (ON DELETE SET NULL); undo_sie_import deletes the
values/dimensions the undone import introduced when no remaining
posted/reversed line references them — user-created rows and rows other
bookkeeping references are untouched. The registry guard triggers act as
backstop. replace_sie_import deliberately skips the lockstep (re-import
re-upserts the same codes). Six pg-real tests cover the lockstep.

Round-trip pinned by test: parse → import state → export → parse preserves
declarations (#UNDERDIM parent links included), values, and per-line object
lists — including synthesis of referenced-but-undeclared values.

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

* fix(dimensions): restate function-local statement_timeout on undo_sie_import

CREATE OR REPLACE resets proconfig, so the 290s timeout from 20260629160100
was silently dropped — regressing service-client bulk deletes to the
authenticator role's 8s limit. Caught by sie-import.replace.pg.test.ts in CI.
Full pg-real suite green (483/483, TZ=UTC).

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

* fix(dimensions): surface OIB/OUB drops and dimension presence as parse-level info (#866 review)

Dropping object-balance records must never be silent — one info issue counts
the skipped #OIB/#OUB rows (object-level balances are P&L-out-of-scope in
v1), and a second announces dimension data before the user executes the
import (the preview step renders parse issues), so the auto-enable notice is
no longer purely post-hoc.

Triage notes for the remaining findings: the RPC's opening SELECT is the
company-ownership check the swarm asked for; registry writes are RLS-bound;
line-verbatim codes are the documented legacy-free-text exception; export
emits no #KSUMMA so there is nothing to recompute; SIE dims 3–5 are
"reserved for future use" with no standard names, so generic synthesis is
spec-correct; ON DELETE SET NULL is deliberate — provenance is operational
metadata for undo, not räkenskapsinformation (the guarded journal lines
are), and RESTRICT would block legitimate post-retention housekeeping.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:05:13 +02:00
Jakob Wennberg 01dbef4015 feat(dimensions): PR4 reports — dimension-filtered P&L + Resultat per projekt/kostnadsställe (#862)
* feat(dimensions): PR4 reports — dimension-filtered P&L everywhere + Resultat per projekt/kostnadsställe

The Project P&L milestone of the dimensions plan (dev_docs §7 PR4).

One choke point lights up everything: generateTrialBalance gains
options.dimensions (SIE dim → code map) pushed down as jsonb containment
(dimensions @>, served by idx_jel_dimensions_gin) on both line queries, with
company-wide opening balances dropped when filtered (they cannot be
dimension-scoped; P&L-safe by whitelist). Resultatrapport, resultaträkning,
huvudbok, monthly-breakdown and the TB drill-down inherit the filter; the
KPI route filters only its P&L-side inputs (income statement, months,
expense composition) — never cash/VAT.

New report lib/reports/dimension-pnl.ts — "Resultat per projekt/
kostnadsställe" (Fortnox Resultatrapport projekt): value-as-column matrix
over one dimension with an explicit "(Utan dimension)" bucket computed as
the residual against the same trial-balance pass resultatrapport uses, so
every row and the Totalt column reconcile with the unfiltered
resultatrapport by construction. Registered in REPORT_CATALOG (visible only
when dimensions_enabled), slug-routed view + xlsx export.

UI: DimensionFilter (dimension + value picker, persistent "Filtrerad — ej
fullständig rapport" chip) mounts in FocusedReport for catalog entries
flagged dimensions: true; huvudbok rows show line dim codes.

Statutory exclusion pinned by TEST, not convention:
lib/reports/__tests__/dimension-statutory-guard.test.ts fails if the filter
parser leaks into balance sheet, balansrapport, kassaflöde, VAT, SIE or
full-archive routes/generators, or if the catalog whitelist widens.

MCP: new gnubok_get_dimension_pnl (reports:read); dimensions filter arg on
get_trial_balance/get_income_statement/get_general_ledger with
resolve-don't-select (names → registry codes, resolution echoes);
query_journal totals fixed to aggregate the FULL match set (was silently
slice-scoped while claiming otherwise) with an honest totals_scope field,
plus group_by / group_by_dimension aggregation.

Also: voucher-detail dim-6 badge now uses the registry name instead of the
non-standard "PR" abbreviation (#859 review follow-up).

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

* fix(dimensions): address #862 review — export disclosure, prior-column suppression, period-label honesty, route hardening

- Filtered XLSX/PDF exports now carry the partial-view disclosure past the
  file boundary (BFNAR 2013:2): filename suffix (-dim6-p001), a
  "Filtrerad … — ej fullständig rapport" row on every sheet, and a header
  note/title line in the PDFs.
- Resultatrapport drops the prior-year column when a dimension filter is
  active — project codes are time-limited under K2/K3, so "this code last
  year" may be a different project (same rule as narrowed date ranges).
- dimension-pnl no longer accepts fromDate: the matrix is cumulative from
  period_start by design (closing-balance semantics), and the period label
  now states exactly that instead of echoing a lower bound that was never
  applied. Routes/MCP tool updated to toDate-only.
- dimension-pnl routes 404 on an unknown/foreign period id and cap dim_no
  to 4 digits (matching the MCP tool's PostgREST-path guard, which the
  generator now also enforces itself).
- Statutory-guard test's generateTrialBalance call-site scan is paren-aware
  instead of a 300-char window; added fully-untagged and injection-guard
  test cases.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:20:47 +02:00
Jakob Wennberg 86071334cb feat(invoices): create customer & supplier invoices in modals, matching the verifikat pattern (#861)
Invoice and supplier-invoice creation now open as pop-up dialogs on their
list pages instead of navigating to standalone form pages — the same UX as
NewJournalEntryDialog (capped-height scroll, explicit-close-only so a
half-typed invoice survives stray Escape/backdrop clicks).

- InvoiceEditor gains a `bare` variant (page chrome stripped, inline actions
  replacing the fixed mobile bar, live document-type title kept) hosted by
  the new NewInvoiceDialog (sm:max-w-5xl). Draft editing pages unchanged.
- The 2,057-line supplier form moves out of the route page into
  components/supplier-invoices/NewSupplierInvoiceForm.tsx with
  bare/inboxItemId/onCreated/onCancel props, hosted by
  NewSupplierInvoiceDialog (sm:max-w-4xl).
- Modals are URL-driven (?new=1): header buttons, empty states, command
  palette, and the reports CTA all open the same dialog; browser back
  closes it. /invoices/new and /supplier-invoices/new survive as redirects
  (bookmarks, agent intents, /expenses/new alias, inbox deep links).
- The invoice-inbox "Skapa leverantörsfaktura" action opens the modal in
  place and refreshes the inbox on success instead of navigating away.

No new i18n keys; dialog titles reuse existing strings.

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