diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml
index a10779af..b30c2dc0 100644
--- a/.compliance/ropa.yaml
+++ b/.compliance/ropa.yaml
@@ -364,15 +364,96 @@ processing_activities:
- error_message_truncated_500_chars
- no_tool_args_or_results_persisted
+ - id: mcp.company_customer_tools
+ name: MCP-verktyg för företagsinställningar och kunder
+ purpose: >-
+ Låta en uttryckligen behörig användare läsa nödvändiga företags- och
+ kunduppgifter samt förbereda ändringar för användarens godkännande.
+ Verktygssökningen gnubok_search_tools returnerar endast verktygsmetadata
+ och JSON-schema, aldrig företags- eller kundposter. Läsresultat begränsas
+ till det aktiva företaget och till verktygets dokumenterade fält.
+ lawful_basis: art_6_1_b
+ special_category_basis: null
+ controller: gnubok-tenant
+ processor: supabase
+ data_subjects:
+ - business_owner
+ - company_member
+ - customer
+ data_categories:
+ - user.name
+ - user.contact
+ - user.financial.bank_account
+ recipients:
+ - name: Supabase
+ country: EU
+ role: processor
+ international_transfers:
+ applicable: false
+ mechanism: null
+ note: EU-only processor.
+ retention:
+ duration: pending_operation_lifecycle
+ basis: contract_and_user_approval
+ stored_in:
+ - pending_operations
+ security_measures:
+ - api_key_scope_companies_read_or_write
+ - api_key_scope_customers_read_or_write
+ - active_company_filter_on_every_query
+ - rls_company_scoped
+ - search_returns_tool_metadata_only
+ - writes_staged_for_explicit_user_approval
+ - plaintext_personnummer_blocked_from_staging
+
+ - id: arsredovisning.signature_evidence
+ name: Underskriftsbevis för årsredovisning
+ purpose: >-
+ Bevara vem som undertecknade en låst årsredovisningsversion, i vilken
+ roll, med vilken metod och på vilket datum. Bevisreferensen är endast en
+ ogenomskinlig identifierare med prefix archive:, document: eller receipt:.
+ Den får inte innehålla namn, e-post, personnummer eller fritext.
+ lawful_basis: art_6_1_c
+ special_category_basis: null
+ controller: gnubok-tenant
+ processor: supabase
+ data_subjects:
+ - company_signatory
+ data_categories:
+ - user.name
+ - user.professional_role
+ - user.activity_timestamp
+ recipients:
+ - name: Supabase
+ country: EU
+ role: processor
+ international_transfers:
+ applicable: false
+ mechanism: null
+ note: EU-only processor.
+ retention:
+ duration: 7y
+ basis: bfl_7_kap_and_arl_signing_evidence
+ stored_in:
+ - arsredovisning_signature_requests
+ - annual_report_versions
+ security_measures:
+ - rls_company_scoped
+ - service_role_only_signature_transitions
+ - version_company_period_and_state_validation
+ - signed_rows_immutable
+ - structured_opaque_evidence_reference
+ - future_and_pre_finalization_dates_rejected
+
- id: arsredovisning.bolagsverket.submit
name: Digital inlämning av årsredovisning till Bolagsverket
purpose: >-
Lämna in årsredovisningen (iXBRL) digitalt till Bolagsverkets eget
utrymme (ÅRL 8 kap.). Avsändarens och undertecknarens personnummer
krävs av Bolagsverkets API (skapa-inlamningtoken, lamna-in) och
- används transient i anropen; i databasen sparas endast
- företagssaltade SHA-256-hashar. Fastställelseintyget signeras med
- e-legitimation hos Bolagsverket, aldrig i appen.
+ används endast transient i anropen. Varken personnummer eller hash av
+ personnummer sparas i inlämningsposten. Fastställelseintyget signeras
+ med e-legitimation hos Bolagsverket, aldrig i appen.
lawful_basis: art_6_1_c # legal obligation (ÅRL filing duty)
special_category_basis: null
controller: gnubok-tenant
@@ -380,7 +461,7 @@ processing_activities:
data_subjects:
- business_owner # avsändare/undertecknare (styrelseledamot, VD)
data_categories:
- - user.government_id # personnummer (transient; at rest only salted SHA-256 hash)
+ - user.government_id # personnummer, endast transient till Bolagsverket
- user.name # undertecknarens namn
- user.contact # undertecknarens/kvittens e-post
- user.financial # årsredovisningens finansiella innehåll
@@ -396,13 +477,13 @@ processing_activities:
duration: 7y
basis: bfl_7_kap # filed .xhtml archived as räkenskapsinformation
stored_in:
- - arsredovisning_submissions # status, idnummer, checksums, PNR hashes only
+ - arsredovisning_submissions # status, idnummer and checksums, no PNR values or hashes
- document_attachments # the exact filed iXBRL bytes (WORM)
security_measures:
- mtls_client_certificate_to_bolagsverket
- mtls_key_env_only_injected_from_secret_manager # never in settings/DB; see .env.example custody note
- pinned_endpoint_allowlist_per_environment # HOSTS map in client.ts; not env-configurable
- - personnummer_never_persisted_plaintext_salted_sha256_only
+ - personnummer_and_hash_never_persisted
- personnummer_never_logged
- webhook_secret_constant_time_validation
- rls_company_scoped
diff --git a/.env.example b/.env.example
index 21ed066c..478989f3 100644
--- a/.env.example
+++ b/.env.example
@@ -46,3 +46,9 @@ CRON_SECRET=generate-a-random-secret
# BOLAGSVERKET_CLIENT_CERT=
# BOLAGSVERKET_CLIENT_KEY=
# BOLAGSVERKET_CA=
+# Safety gate: enable only after agreement, certificate, test-bank fixtures,
+# acceptance testing, and production runbook approval are complete.
+# BOLAGSVERKET_FILING_ENABLED=false
+# NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED=false
+# BOLAGSVERKET_ARELLE_VALIDATOR_URL=
+# BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN=
diff --git a/.reuse/dep5 b/.reuse/dep5
new file mode 100644
index 00000000..c45842a3
--- /dev/null
+++ b/.reuse/dep5
@@ -0,0 +1,13 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: Adobe Source Fonts
+Source: https://github.com/adobe-fonts
+
+Files: public/fonts/invoice/SourceSans3-*.ttf
+ public/fonts/invoice/LICENSE-Source-Sans-3.md
+Copyright: 2010-2024 Adobe
+License: OFL-1.1
+
+Files: public/fonts/invoice/SourceSerif4-*.ttf
+ public/fonts/invoice/LICENSE-Source-Serif-4.md
+Copyright: 2014-2023 Adobe
+License: OFL-1.1
diff --git a/AGENTS.md b/AGENTS.md
index 9c8c3f73..7120b1f0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,11 +21,15 @@ The accounting rules are Swedish law, enforced by DB triggers. Code that violate
General prohibitions:
- **Never modify an existing migration**: schemas already shipped; create a new migration. Never touch the enforcement triggers (migration 017); they are legally required.
+- **Apply migrations only to the `erpbase` Supabase project's `staging` branch.** Never apply migrations to a local database or a locally hosted Supabase instance.
+- **Never write to the `erpbase` Supabase production database without Emil's explicit approval for the specific write.** Production reads are allowed, including fetching data for a requested account, but no INSERT, UPDATE, DELETE, DDL, migration, mutating RPC, repair, seed, or other state-changing operation may run until Emil has clearly said okay. Do not infer approval from a request to investigate, diagnose, fix code, or fetch data.
+- **Never write directly to the `main` branch without Emil's explicit approval.** Do not commit, push, merge, or otherwise update `main`; use a feature branch unless Emil clearly approves the specific main-branch write.
- **Never leave a remote DB ahead of the repo.** If you `apply_migration` (or run any DDL) against prod, staging, or a preview branch, write the byte-identical SQL into `supabase/migrations/` under the exact applied version in the same change. An applied version with no committed file is an orphan: Supabase branching aborts the next merge to `main` with "Remote migration versions not found in local migrations directory" and blocks every pending migration behind it. The PR preview passes anyway (preview branches fork from prod's history, which already has the orphan), so this only surfaces at merge.
- **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`).
- **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited.
- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites.
- **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation.
+- **Never open, start, or run Docker locally.** Do not run Docker commands or commands that start Docker-managed services.
- **Keep the diff scoped to the request.** No drive-by refactors of untouched code.
- **Never use em dashes (—) or en dashes (–)** in code, comments, commit messages, or docs. Use a colon, comma, semicolon, or plain hyphen instead, whichever fits the sentence. Exception: a dash character that is the literal subject being parsed, matched, or documented (e.g. mojibake byte-mapping tables, a date-range separator regex) stays as-is; don't launder those into a colon.
- Never create a NUL/nul file: `\Accounted\NUL`.
diff --git a/DECISIONS.md b/DECISIONS.md
index c30370e3..8a820a5c 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -244,5 +244,28 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-07-20] Onboarding backdrop reuses marketing-site halftone webp assets copied into public/illustrations/ (not hotlinked, not regenerated): keeps app self-contained and signup->app visually continuous; decorative art uses plain (physics sizes by %, next/image adds nothing for 1-35KB webp).
[2026-07-20] Removed Dependabot entirely (.github/dependabot.yml deleted, open PRs #1083/#1082/#1012 closed) on Emil's request: weekly grouped bumps were noise and the #884 bedrock-sdk incident showed the risk profile. Dependency bumps are now manual/deliberate; the bedrock-sdk 0.29.1 exact pin stays enforced by scripts/checks/no-new-antipatterns.mjs.
[2026-07-20] Bulk reject (/pending) reuses the exact bulk-approve selection set: high-risk and locked-period ops stay one-by-one for reject too, keeping one selection model instead of per-action eligibility. Server-side bulk-reject has NO high-risk skip (rejecting posts nothing), so the API stays permissive; the UI is the gate.
+[2026-07-21] Keep Skatteverket audit inserts service-role-only and fix the writer instead of adding an authenticated INSERT policy: regulator audit rows must not be forgeable by a user session.
+[2026-07-21] Treat an empty year-end period as a structured conflict instead of creating a zero-value voucher: the journal engine correctly forbids meaningless zero entries, while the API now returns an actionable domain outcome and logs it as a warning.
+[2026-07-21] Store kvarskatt payment dates exactly from the tax notice instead of calculating a 90-day offset: Skatteverket makes the notice date authoritative and reassessments can have shorter payment windows.
+[2026-07-21] Persist Oasis first-value setup per company and keep it optional inside the dashboard: existing companies are backfilled as completed, and Skatteverket plus assistant setup never block access.
+[2026-07-21] Enforce zero raw user-facing engine and database errors with an AST guard: aliases are traced while logger-only values remain allowed.
+[2026-07-21] Persist year-end tax adjustments separately from journal entries and reuse them for tax provision plus INK2: detected 6992/8423 balances remain tax-only, a posted current-year periodiseringsfond is treated as a completed choice instead of new headroom, and a mismatched posted tax requires storno/correction rather than a duplicate voucher.
+[2026-07-21] Share dashboard auth and active-company resolution with request-local React cache: nested layouts and pages now reuse one verified context while cross-request data remains uncached.
+[2026-07-21] Progressively render large customer, supplier, article, and invoice lists in batches of 100: complete datasets remain available for filtering, sorting, statistics, and export while initial browser work stays bounded.
+[2026-07-21] Give company-settings MCP writes a dedicated companies:write scope and medium risk: bank details redirect future invoice payments, so reusing a low-risk customer or generic bookkeeping scope would hide the payment-routing permission boundary. Keep the three new specialized settings and customer tools search-only in the default MCP catalog: gnubok_search_tools still returns and authorizes them while tools/list stays inside its locked context budget.
+[2026-07-21] Cloud backup audit scaling keeps API-key security and configuration changes in behandlingshistorik but suppresses request-counter-only audit rows, and excludes non-portable extension_data from the Grunddata watermark: existing history stays immutable while telemetry growth and the backup's own state can no longer recreate the timeout.
+[2026-07-21] Miles staging reproduction copies exact effective ledger balances plus the active periodiseringsfond through the bookkeeping engine, not production documents or obsolete closing audit chains: this preserves the disposition calculation while minimizing personal data and leaving A44/A1/A2 absent for a clean test.
+[2026-07-21] Keep Bolagsverket connected filing disabled behind explicit public and server release gates: migrations, validator service, agreement, certificate, test-bank acceptance, and remote migration reconciliation must be complete before activation.
+[2026-07-21] Treat signer rosters as mutable only while unbound and immutable once attached to an annual-report version: corrected versions either clone the latest roster or bind a newly supplied roster, while database serialization advances the version only after the final signature.
+[2026-07-21] Keep Bolagsverket idnummer server-side for webhook correlation and troubleshooting but omit it from dashboard and MCP responses: the v2.1 contract marks it as a technical identifier that must not be shown to users.
+[2026-07-21] Store retention_expires_at as the first legally permitted deletion date, 1 January of the eighth following calendar year: BFL 7 kap. 2 § requires preservation through the end of the seventh following calendar year, so period_end plus seven years ends too early for non-calendar fiscal years.
+[2026-07-21] Restrict locked annual-report creation to the server service role and preserve the profile, disclosure, and eligibility snapshots with the validation result: a caller-controlled browser RPC cannot be allowed to assert its own compliance result, and the version must remain independently auditable after the live profile changes.
+[2026-07-21] Keep the current K3 renderer available only as a review draft and fail closed before version locking or paper-filing readiness: its present note builder is not a complete applicability-driven K3 disclosure matrix, so claiming general K3 compliance would be misleading.
+[2026-07-21] Bundle Source Sans 3 and Source Serif 4 under OFL-1.1 and store company TTF/WOFF files in a dedicated private bucket: invoice rendering embeds fonts server-side without a new runtime dependency, while tenant-scoped paths, parse validation, size limits, and Helvetica fallback keep uploaded fonts private and reliable.
+[2026-07-21] Prepared issue #1064's end-to-end fixture on Supabase staging in the existing disposable ZZ Undo-YE Rehearsal 3 AB, renamed ZZ AR Narrative E2E AB: avoided the realistic Box 7 AB and preserved posted history through storno; left FY2025 open with balanced synthetic K2 entries and no closing entry.
+[2026-07-21] Did not bulk-push the missing 20260721160000 annual-report migration while diagnosing the staging 500: staging has later applied versions plus other local-only gaps, and the 44 KB schema change exceeds fixture-seeding authority until explicitly approved for targeted application.
+[2026-07-21] Applied only migration 20260721160000 to Supabase staging after explicit approval: used an isolated CLI workdir hydrated from staging history because the configured Supabase MCP targets production and a repository-wide push would include unrelated migration gaps; source and applied copy had identical SHA-256.
[2026-07-21] Domain cutover is dual-domain, not full migration: app.gnubok.se stays serving /api + /.well-known forever (MCP connectors, API keys, SKV callback registered in Utvecklarportalen); only page traffic redirects to app.accounted.se, gated on NEXT_PUBLIC_APP_URL so the merge is inert. SKV OAuth callback rewritten cookie-free (state + stored oauth_user_id) because sessions no longer exist on the OAuth host; discovery docs host-reflect (allowlisted) for RFC 8414/9728 self-consistency.
[2026-07-21] Fortnox/SIE atomic import timeout fixed by function-scoped statement_timeout (290s) on import_sie_journal_entries, not by chunking the RPC: chunking would reintroduce the partial-import states the atomic RPC exists to eliminate (20260712150000), and the 20260629160100 delete-path precedent already uses the same bound; pg-real ratchet pins the config on all three SIE RPCs because CREATE OR REPLACE silently drops ALTER FUNCTION settings.
+[2026-07-21] Keep live annual-report narrative editable after a version is locked: immutable version snapshots preserve signing and filing content, while edits must remain possible to create a corrected superseding version.
+[2026-07-21] Restrict annual-report signature evidence transitions to the server service role and structured opaque references: browser RLS may manage only unbound pending roster rows, so route validation cannot be bypassed and evidence references cannot carry free-text personal data.
diff --git a/LICENSES/OFL-1.1.txt b/LICENSES/OFL-1.1.txt
new file mode 100644
index 00000000..815c90bf
--- /dev/null
+++ b/LICENSES/OFL-1.1.txt
@@ -0,0 +1,84 @@
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/(auth)/loading.tsx b/app/(auth)/loading.tsx
new file mode 100644
index 00000000..3bae78a3
--- /dev/null
+++ b/app/(auth)/loading.tsx
@@ -0,0 +1,5 @@
+import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
+
+export default function AuthLoading() {
+ return
+}
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
index e1146fd9..17b43763 100644
--- a/app/(auth)/login/page.tsx
+++ b/app/(auth)/login/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { Suspense, useState, useEffect } from 'react'
+import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
@@ -13,18 +14,23 @@ import { Loader2, Mail, ArrowLeft, KeyRound, ExternalLink } from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
-import { BankIdAuth } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
+import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
const branding = getBranding()
import type { BankIdResult } from '@/components/auth/BankIdAuth'
+const BankIdAuth = dynamic(
+ () => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth),
+ { ssr: false },
+)
+
// Wrapping in Suspense is required because useSearchParams() forces
// dynamic rendering in Next.js 16; static prerender bails out otherwise.
export default function LoginPage() {
return (
-
+ }>
)
@@ -157,7 +163,7 @@ function LoginPageContent() {
if (error) {
toast({
title: tAuth('login_failed_title'),
- description: error.message === 'Invalid login credentials'
+ description: getErrorMessage(error) === 'Invalid login credentials'
? tAuth('login_invalid_credentials')
: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
diff --git a/app/(auth)/mfa/enroll/page.tsx b/app/(auth)/mfa/enroll/page.tsx
index b66dc5cf..b4c15ff4 100644
--- a/app/(auth)/mfa/enroll/page.tsx
+++ b/app/(auth)/mfa/enroll/page.tsx
@@ -11,6 +11,7 @@ import { Loader2, ShieldCheck, Copy, Check, ArrowLeft } from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { userHasPassword } from '@/lib/auth/has-password'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export default function MfaEnrollPage() {
return (
@@ -83,7 +84,7 @@ function MfaEnrollContent() {
if (error) {
toast({
title: 'Kunde inte aktivera 2FA',
- description: error.message,
+ description: getUserErrorMessage(error),
variant: 'destructive',
})
setIsEnrolling(false)
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
index 36f3fe1f..051b32c2 100644
--- a/app/(auth)/register/page.tsx
+++ b/app/(auth)/register/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
+import dynamic from 'next/dynamic'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
@@ -13,20 +14,21 @@ import { Loader2, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
-import { BankIdAuth } from '@/components/auth/BankIdAuth'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
+import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
const branding = getBranding()
+const BankIdAuth = dynamic(
+ () => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth),
+ { ssr: false },
+)
+
export default function RegisterPage() {
return (
-
-
-
- }>
+ }>
)
diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx
index 56270a30..cdae3efd 100644
--- a/app/(dashboard)/articles/page.tsx
+++ b/app/(dashboard)/articles/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react'
+import dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -21,7 +22,6 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
-import ArticleForm from '@/components/articles/ArticleForm'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import {
useSubmitWithAccountActivation,
@@ -37,6 +37,19 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Article, ArticleType, CreateArticleInput } from '@/types'
+const ArticleForm = dynamic(
+ () => import('@/components/articles/ArticleForm'),
+ {
+ loading: () => (
+
+
+
+
+
+ ),
+ },
+)
+
const ARTICLE_TYPE_LABEL_KEYS: Record = {
vara: 'type_vara',
tjanst: 'type_tjanst',
@@ -53,6 +66,7 @@ const SORTABLE_COLUMNS: ReadonlyArray = [
'price_excl_vat',
'vat_rate',
]
+const INITIAL_VISIBLE_ROWS = 100
function compareStrings(a: string, b: string): number {
return a.localeCompare(b, 'sv', { sensitivity: 'base' })
@@ -64,11 +78,13 @@ function ArticlesPageInner() {
const [articles, setArticles] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
+ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('articles')
+ const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale
const router = useRouter()
@@ -85,6 +101,7 @@ function ArticlesPageInner() {
const updateSort = useCallback(
(column: SortColumn) => {
+ setVisibleCount(INITIAL_VISIBLE_ROWS)
const params = new URLSearchParams(searchParams.toString())
let nextDir: SortDir = 'asc'
if (column === sortColumn) {
@@ -208,6 +225,7 @@ function ArticlesPageInner() {
})
return arr
}, [filteredArticles, sortColumn, sortDir])
+ const visibleArticles = sortedArticles.slice(0, visibleCount)
function SortableHeader({
column,
@@ -281,7 +299,10 @@ function ArticlesPageInner() {
setSearchTerm(e.target.value)}
+ onChange={(e) => {
+ setSearchTerm(e.target.value)
+ setVisibleCount(INITIAL_VISIBLE_ROWS)
+ }}
className="pl-10"
/>
@@ -358,7 +379,7 @@ function ArticlesPageInner() {
- {sortedArticles.map((article) => (
+ {visibleArticles.map((article) => (
- {sortedArticles.map((article) => (
+ {visibleArticles.map((article) => (
@@ -430,6 +451,17 @@ function ArticlesPageInner() {
))}
+ {visibleCount < sortedArticles.length && (
+
+ setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
+ >
+ {tCommon('load_more')}
+
+
+ )}
>
)}
diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx
index abc52c58..8fb5762e 100644
--- a/app/(dashboard)/bookkeeping/page.tsx
+++ b/app/(dashboard)/bookkeeping/page.tsx
@@ -1,12 +1,14 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
+import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
import { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
-import NewJournalEntryDialog, { type CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
+import type { CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
+import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { useToast } from '@/components/ui/use-toast'
import { Plus } from 'lucide-react'
@@ -14,6 +16,11 @@ import { PageHeader } from '@/components/ui/page-header'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry, JournalEntryLine } from '@/types'
+const NewJournalEntryDialog = dynamic(
+ () => import('@/components/bookkeeping/NewJournalEntryDialog'),
+ { loading: DialogLoadingSkeleton },
+)
+
interface NextVoucher {
next: number
series: string
@@ -153,20 +160,22 @@ export default function BookkeepingPage() {
- {
- setShowNewEntry(o)
- if (!o) setCopyPrefill(null)
- }}
- onCreated={() => {
- setRefreshKey((k) => k + 1)
- setShowNewEntry(false)
- setCopyPrefill(null)
- }}
- copyPrefill={copyPrefill}
- isLoading={isLoadingCopy}
- />
+ {showNewEntry && (
+ {
+ setShowNewEntry(o)
+ if (!o) setCopyPrefill(null)
+ }}
+ onCreated={() => {
+ setRefreshKey((k) => k + 1)
+ setShowNewEntry(false)
+ setCopyPrefill(null)
+ }}
+ copyPrefill={copyPrefill}
+ isLoading={isLoadingCopy}
+ />
+ )}
)
}
diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
index 7502bf33..0e8aeacc 100644
--- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
+++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -10,21 +11,29 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
+import { Checkbox } from '@/components/ui/checkbox'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { PageHeader } from '@/components/ui/page-header'
-import { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2 } from 'lucide-react'
+import { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2, Trash2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { DigitalInlamning, INLAMNING_COMING_SOON } from '@/components/bokslut/DigitalInlamning'
+import { AnnualReportStudio } from '@/components/bokslut/AnnualReportStudio'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
import type { SignatureRequest } from '@/lib/bokslut/arsredovisning/signature-service'
+import type { AnnualReportVersionSummary } from '@/lib/bokslut/arsredovisning/compliance-types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+
+const SIGNATURE_EVIDENCE_REFERENCE_PATTERN =
+ /^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$/
export default function ArsredovisningPage() {
const router = useRouter()
const searchParams = useSearchParams()
const periodId = searchParams.get('period')
const { toast } = useToast()
+ const tStudio = useTranslations('annualReportStudio')
const [data, setData] = useState(null)
const [signatures, setSignatures] = useState([])
@@ -37,11 +46,19 @@ export default function ArsredovisningPage() {
const [description, setDescription] = useState('')
const [importantEvents, setImportantEvents] = useState('')
const [resultatdisposition, setResultatdisposition] = useState('')
+ const [proposedDividend, setProposedDividend] = useState('')
const [savedDescription, setSavedDescription] = useState('')
const [savedImportantEvents, setSavedImportantEvents] = useState('')
const [savedResultatdisposition, setSavedResultatdisposition] = useState('')
+ const [savedProposedDividend, setSavedProposedDividend] = useState('')
const [agmDate, setAgmDate] = useState('')
const [savedAgmDate, setSavedAgmDate] = useState('')
+ const [agmDispositionOutcome, setAgmDispositionOutcome] = useState<
+ '' | 'proposal_approved' | 'alternative_decision'
+ >('')
+ const [savedAgmDispositionOutcome, setSavedAgmDispositionOutcome] = useState('')
+ const [agmDispositionDecision, setAgmDispositionDecision] = useState('')
+ const [savedAgmDispositionDecision, setSavedAgmDispositionDecision] = useState('')
// Disclosure fields per ÅRL 5:13-15 § + BFNAR koncernförhållanden.
// Persisted via the same POST endpoint as the förvaltningsberättelse text.
const [longTermDebt, setLongTermDebt] = useState('')
@@ -56,12 +73,37 @@ export default function ArsredovisningPage() {
const [savedParentOrgNr, setSavedParentOrgNr] = useState('')
const [parentCity, setParentCity] = useState('')
const [savedParentCity, setSavedParentCity] = useState('')
+ const [longTermDebtConfirmed, setLongTermDebtConfirmed] = useState(false)
+ const [savedLongTermDebtConfirmed, setSavedLongTermDebtConfirmed] = useState(false)
+ const [securitiesPledgedConfirmed, setSecuritiesPledgedConfirmed] = useState(false)
+ const [savedSecuritiesPledgedConfirmed, setSavedSecuritiesPledgedConfirmed] = useState(false)
+ const [contingentLiabilitiesConfirmed, setContingentLiabilitiesConfirmed] = useState(false)
+ const [savedContingentLiabilitiesConfirmed, setSavedContingentLiabilitiesConfirmed] = useState(false)
+ const [parentCompanyConfirmed, setParentCompanyConfirmed] = useState(false)
+ const [savedParentCompanyConfirmed, setSavedParentCompanyConfirmed] = useState(false)
const [savingNarrative, setSavingNarrative] = useState(false)
- const [savedAt, setSavedAt] = useState(null)
+ const [narrativeRevision, setNarrativeRevision] = useState(null)
// Add-signer form
const [signerName, setSignerName] = useState('')
const [signerRole, setSignerRole] = useState('Styrelseledamot')
+ const [versions, setVersions] = useState([])
+ const [selectedSignatureVersionId, setSelectedSignatureVersionId] = useState('')
+ const [signingMethod, setSigningMethod] = useState<
+ 'paper_original' | 'advanced_e_signature' | 'bankid'
+ >('paper_original')
+ const [signatureEvidence, setSignatureEvidence] = useState('')
+ const [signatureDate, setSignatureDate] = useState(() => new Date().toISOString().slice(0, 10))
+
+ const handleVersionsChanged = useCallback((nextVersions: AnnualReportVersionSummary[]) => {
+ setVersions(nextVersions)
+ setSelectedSignatureVersionId((current) => {
+ if (nextVersions.some((version) => version.id === current && version.status === 'ready_for_signature')) {
+ return current
+ }
+ return nextVersions.find((version) => version.status === 'ready_for_signature')?.id ?? ''
+ })
+ }, [])
useEffect(() => {
if (!periodId) return
@@ -75,7 +117,7 @@ export default function ArsredovisningPage() {
.then(([arBody, sigBody]) => {
if (cancelled) return
if (arBody?.error) {
- setError(arBody.error.message ?? 'Kunde inte hämta årsredovisning')
+ setError(getUserErrorMessage(arBody.error) ?? 'Kunde inte hämta årsredovisning')
return
}
const d = arBody.data as ArsredovisningData
@@ -87,11 +129,18 @@ export default function ArsredovisningPage() {
setDescription(d.forvaltningsberattelse.description)
setImportantEvents(d.forvaltningsberattelse.important_events)
setResultatdisposition(d.forvaltningsberattelse.resultatdisposition)
+ const dividend = String(d.forvaltningsberattelse.proposed_dividend || '')
+ setProposedDividend(dividend)
setAgmDate(d.forvaltningsberattelse.agm_date ?? '')
setSavedDescription(d.forvaltningsberattelse.description)
setSavedImportantEvents(d.forvaltningsberattelse.important_events)
setSavedResultatdisposition(d.forvaltningsberattelse.resultatdisposition)
+ setSavedProposedDividend(dividend)
setSavedAgmDate(d.forvaltningsberattelse.agm_date ?? '')
+ setAgmDispositionOutcome(d.forvaltningsberattelse.agm_disposition_outcome ?? '')
+ setSavedAgmDispositionOutcome(d.forvaltningsberattelse.agm_disposition_outcome ?? '')
+ setAgmDispositionDecision(d.forvaltningsberattelse.agm_disposition_decision ?? '')
+ setSavedAgmDispositionDecision(d.forvaltningsberattelse.agm_disposition_decision ?? '')
const ltd = d.disclosures.long_term_debt_over_five_years
const ltdStr = ltd != null ? String(ltd) : ''
setLongTermDebt(ltdStr)
@@ -106,6 +155,14 @@ export default function ArsredovisningPage() {
setSavedParentOrgNr(d.disclosures.parent_company_org_number ?? '')
setParentCity(d.disclosures.parent_company_city ?? '')
setSavedParentCity(d.disclosures.parent_company_city ?? '')
+ setLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years)
+ setSavedLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years)
+ setSecuritiesPledgedConfirmed(d.disclosures.confirmations.securities_pledged)
+ setSavedSecuritiesPledgedConfirmed(d.disclosures.confirmations.securities_pledged)
+ setContingentLiabilitiesConfirmed(d.disclosures.confirmations.contingent_liabilities)
+ setSavedContingentLiabilitiesConfirmed(d.disclosures.confirmations.contingent_liabilities)
+ setParentCompanyConfirmed(d.disclosures.confirmations.parent_company)
+ setSavedParentCompanyConfirmed(d.disclosures.confirmations.parent_company)
setSignatures((sigBody.data ?? []) as SignatureRequest[])
})
.catch(() => {
@@ -123,13 +180,20 @@ export default function ArsredovisningPage() {
description !== savedDescription ||
importantEvents !== savedImportantEvents ||
resultatdisposition !== savedResultatdisposition ||
+ proposedDividend !== savedProposedDividend ||
agmDate !== savedAgmDate ||
+ agmDispositionOutcome !== savedAgmDispositionOutcome ||
+ agmDispositionDecision !== savedAgmDispositionDecision ||
longTermDebt !== savedLongTermDebt ||
securitiesPledged !== savedSecuritiesPledged ||
contingentLiabilities !== savedContingentLiabilities ||
parentName !== savedParentName ||
parentOrgNr !== savedParentOrgNr ||
- parentCity !== savedParentCity
+ parentCity !== savedParentCity ||
+ longTermDebtConfirmed !== savedLongTermDebtConfirmed ||
+ securitiesPledgedConfirmed !== savedSecuritiesPledgedConfirmed ||
+ contingentLiabilitiesConfirmed !== savedContingentLiabilitiesConfirmed ||
+ parentCompanyConfirmed !== savedParentCompanyConfirmed
const handleSaveNarrative = useCallback(async () => {
if (!periodId) return
@@ -150,6 +214,19 @@ export default function ArsredovisningPage() {
}
longTermDebtParsed = parsed
}
+ let proposedDividendParsed = 0
+ if (proposedDividend.trim()) {
+ const parsed = Number(proposedDividend.replace(/\s/g, '').replace(',', '.'))
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ toast({
+ title: 'Ogiltigt belopp',
+ description: 'Föreslagen utdelning måste vara noll eller ett positivt belopp.',
+ variant: 'destructive',
+ })
+ return
+ }
+ proposedDividendParsed = Math.round(parsed * 100) / 100
+ }
setSavingNarrative(true)
try {
const res = await fetch(
@@ -161,13 +238,23 @@ export default function ArsredovisningPage() {
description,
important_events: importantEvents,
resultatdisposition,
+ proposed_dividend: proposedDividendParsed,
agm_date: agmDate || null,
+ agm_disposition_outcome: agmDispositionOutcome || null,
+ agm_disposition_decision:
+ agmDispositionOutcome === 'alternative_decision'
+ ? agmDispositionDecision.trim() || null
+ : null,
long_term_debt_over_five_years: longTermDebtParsed,
securities_pledged: securitiesPledged.trim() || null,
contingent_liabilities: contingentLiabilities.trim() || null,
parent_company_name: parentName.trim() || null,
parent_company_org_number: parentOrgNr.trim() || null,
parent_company_city: parentCity.trim() || null,
+ long_term_debt_over_five_years_confirmed: longTermDebtConfirmed,
+ securities_pledged_confirmed: securitiesPledgedConfirmed,
+ contingent_liabilities_confirmed: contingentLiabilitiesConfirmed,
+ parent_company_confirmed: parentCompanyConfirmed,
}),
},
)
@@ -175,7 +262,7 @@ export default function ArsredovisningPage() {
if (!res.ok) {
toast({
title: 'Kunde inte spara texten',
- description: body?.error?.message ?? '',
+ description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive',
})
return
@@ -183,18 +270,27 @@ export default function ArsredovisningPage() {
setSavedDescription(description)
setSavedImportantEvents(importantEvents)
setSavedResultatdisposition(resultatdisposition)
+ setSavedProposedDividend(proposedDividend)
setSavedAgmDate(agmDate)
+ setSavedAgmDispositionOutcome(agmDispositionOutcome)
+ setSavedAgmDispositionDecision(agmDispositionDecision)
setSavedLongTermDebt(longTermDebt)
setSavedSecuritiesPledged(securitiesPledged)
setSavedContingentLiabilities(contingentLiabilities)
setSavedParentName(parentName)
setSavedParentOrgNr(parentOrgNr)
setSavedParentCity(parentCity)
- setSavedAt(Date.now())
+ setSavedLongTermDebtConfirmed(longTermDebtConfirmed)
+ setSavedSecuritiesPledgedConfirmed(securitiesPledgedConfirmed)
+ setSavedContingentLiabilitiesConfirmed(contingentLiabilitiesConfirmed)
+ setSavedParentCompanyConfirmed(parentCompanyConfirmed)
+ setNarrativeRevision(
+ typeof body.data?.updated_at === 'string' ? body.data.updated_at : null,
+ )
} catch (err) {
toast({
title: 'Kunde inte spara texten',
- description: err instanceof Error ? err.message : 'Okänt fel',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
} finally {
@@ -205,33 +301,59 @@ export default function ArsredovisningPage() {
description,
importantEvents,
resultatdisposition,
+ proposedDividend,
agmDate,
+ agmDispositionOutcome,
+ agmDispositionDecision,
longTermDebt,
securitiesPledged,
contingentLiabilities,
parentName,
parentOrgNr,
parentCity,
+ longTermDebtConfirmed,
+ securitiesPledgedConfirmed,
+ contingentLiabilitiesConfirmed,
+ parentCompanyConfirmed,
toast,
])
const handleMarkSigned = useCallback(
async (signatureId: string) => {
if (!periodId) return
+ if (
+ !selectedSignatureVersionId ||
+ !SIGNATURE_EVIDENCE_REFERENCE_PATTERN.test(signatureEvidence.trim()) ||
+ !signatureDate
+ ) {
+ toast({
+ title: 'Underskriftsbevis saknas',
+ description:
+ 'Välj en låst version och ange datum samt en strukturerad referens, till exempel archive:AR-2026-001.',
+ variant: 'destructive',
+ })
+ return
+ }
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ status: 'signed' }),
+ body: JSON.stringify({
+ status: 'signed',
+ annual_report_version_id: selectedSignatureVersionId,
+ signing_method: signingMethod,
+ evidence_reference: signatureEvidence.trim(),
+ signed_at: new Date(`${signatureDate}T12:00:00`).toISOString(),
+ }),
},
)
const body = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte markera som signerad',
- description: body?.error?.message ?? '',
+ description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive',
})
return
@@ -239,16 +361,31 @@ export default function ArsredovisningPage() {
setSignatures((prev) =>
prev.map((s) => (s.id === signatureId ? (body.data as SignatureRequest) : s)),
)
+ const versionsResponse = await fetch(
+ `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/versions`,
+ )
+ if (versionsResponse.ok) {
+ const versionsBody = await versionsResponse.json()
+ handleVersionsChanged((versionsBody.data ?? []) as AnnualReportVersionSummary[])
+ }
toast({ title: 'Underskrift registrerad' })
} catch (err) {
toast({
title: 'Kunde inte markera som signerad',
- description: err instanceof Error ? err.message : 'Okänt fel',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
},
- [periodId, toast],
+ [
+ handleVersionsChanged,
+ periodId,
+ selectedSignatureVersionId,
+ signatureDate,
+ signatureEvidence,
+ signingMethod,
+ toast,
+ ],
)
const handleAddSigner = useCallback(async () => {
@@ -266,7 +403,7 @@ export default function ArsredovisningPage() {
if (!res.ok) {
toast({
title: 'Kunde inte lägga till undertecknare',
- description: body?.error?.message ?? '',
+ description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive',
})
return
@@ -277,12 +414,37 @@ export default function ArsredovisningPage() {
} catch (err) {
toast({
title: 'Kunde inte lägga till undertecknare',
- description: err instanceof Error ? err.message : 'Okänt fel',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
}, [periodId, signerName, signerRole, toast])
+ const handleRemoveSigner = useCallback(
+ async (signatureId: string) => {
+ if (!periodId) return
+ try {
+ const response = await fetch(
+ `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`,
+ { method: 'DELETE' },
+ )
+ if (!response.ok) {
+ const body = await response.json()
+ throw new Error(getUserErrorMessage(body?.error))
+ }
+ setSignatures((current) => current.filter((signature) => signature.id !== signatureId))
+ toast({ title: tStudio('signer_removed') })
+ } catch (err) {
+ toast({
+ title: tStudio('signer_remove_error'),
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
+ variant: 'destructive',
+ })
+ }
+ },
+ [periodId, tStudio, toast],
+ )
+
if (!periodId) {
return (
@@ -381,6 +543,16 @@ export default function ArsredovisningPage() {
)}
+
+
Förvaltningsberättelse: narrativ
@@ -417,6 +589,20 @@ export default function ArsredovisningPage() {
rows={3}
/>
+
+
Föreslagen utdelning (kr)
+
setProposedDividend(event.target.value)}
+ placeholder="0"
+ className="min-h-11 max-w-[220px] tabular-nums"
+ />
+
+ Beloppet används i resultatdispositionen i samma version av PDF och iXBRL.
+
+
Datum för årsstämma
+
+ Årsstämmans beslut om resultatdisposition
+
+ setAgmDispositionOutcome(
+ event.target.value as
+ | ''
+ | 'proposal_approved'
+ | 'alternative_decision',
+ )
+ }
+ >
+ Välj efter genomförd årsstämma
+ Styrelsens förslag godkändes
+ Årsstämman fattade ett annat beslut
+
+
+ {agmDispositionOutcome === 'alternative_decision' && (
+
+ Årsstämmans beslut
+
+ )}
@@ -458,6 +675,14 @@ export default function ArsredovisningPage() {
ÅRL 5:13 §. Lämna tomt om inga skulder förfaller senare än fem år.
+
+ setLongTermDebtConfirmed(Boolean(checked))}
+ />
+ Jag har kontrollerat uppgiften, även om beloppet är noll.
+
Ställda säkerheter
@@ -469,6 +694,14 @@ export default function ArsredovisningPage() {
placeholder="t.ex. Företagsinteckning 500 000 kr som säkerhet för bankkredit."
/>
ÅRL 5:14 §.
+
+ setSecuritiesPledgedConfirmed(Boolean(checked))}
+ />
+ Jag har kontrollerat ställda säkerheter, även om svaret är inga.
+
Eventualförpliktelser
@@ -480,6 +713,14 @@ export default function ArsredovisningPage() {
placeholder="t.ex. Borgensåtagande för dotterbolags krediter 200 000 kr."
/>
ÅRL 5:15 §.
+
+ setContingentLiabilitiesConfirmed(Boolean(checked))}
+ />
+ Jag har kontrollerat eventualförpliktelser, även om svaret är inga.
+
@@ -516,13 +757,21 @@ export default function ArsredovisningPage() {
/>
+
+ setParentCompanyConfirmed(Boolean(checked))}
+ />
+ Jag har kontrollerat koncernförhållandet, även om bolaget saknar moderföretag.
+
{hasUnsavedNarrative ? (
Ändringar sparas inte automatiskt.
- ) : savedAt ? (
+ ) : narrativeRevision ? (
Sparat
@@ -588,12 +837,78 @@ export default function ArsredovisningPage() {
Underskrifter
- Lägg till varje styrelseledamot + VD som ska skriva under. BankID-signering
- kommer i en kommande version: för nu visas slottar och status här, och
- själva underskriften görs på pappret.
+ Lägg till varje styrelseledamot och eventuell VD. Lås först en version i
+ arbetsflödet ovan. När originalet eller en extern e-signatur är klar registrerar
+ du datum och bevisreferens mot exakt den version som skrevs under.
+
+
+ Låst version
+ setSelectedSignatureVersionId(event.target.value)}
+ >
+ Välj version
+ {versions
+ .filter((version) => version.status === 'ready_for_signature')
+ .map((version) => (
+
+ Version {version.version_number}: {version.content_hash.slice(0, 12)}
+
+ ))}
+
+
+
+ Underskriftsmetod
+
+ setSigningMethod(
+ event.target.value as
+ | 'paper_original'
+ | 'advanced_e_signature'
+ | 'bankid',
+ )
+ }
+ >
+ Undertecknat original på papper
+ Avancerad e-signatur
+ BankID via extern signeringstjänst
+
+
+
+ Underskriftsdatum
+ setSignatureDate(event.target.value)}
+ />
+
+
+ Bevisreferens
+ setSignatureEvidence(event.target.value)}
+ placeholder="archive:AR-2026-001"
+ maxLength={128}
+ autoComplete="off"
+ />
+
+
+ Accounted registrerar beviset men skapar inte själva underskriften. Spara
+ originalet eller signeringskvittot enligt bolagets dokumenthantering.
+
+
{signatures.length === 0 && (
Inga undertecknare tillagda än.
@@ -602,13 +917,13 @@ export default function ArsredovisningPage() {
{signatures.map((sig) => (
{sig.signer_name}
{sig.role}
-
+
{sig.status === 'signed' ? (
Signerad
) : sig.status === 'declined' ? (
@@ -616,10 +931,27 @@ export default function ArsredovisningPage() {
) : (
<>
Väntar på underskrift
+ {sig.annual_report_version_id === null && (
+ void handleRemoveSigner(sig.id)}
+ >
+
+
+ )}
void handleMarkSigned(sig.id)}
+ disabled={
+ !selectedSignatureVersionId ||
+ !SIGNATURE_EVIDENCE_REFERENCE_PATTERN.test(signatureEvidence.trim()) ||
+ !signatureDate
+ }
>
Markera som signerad
@@ -628,14 +960,14 @@ export default function ArsredovisningPage() {
))}
-
+
Roll
setSignerRole(e.target.value)}
>
@@ -654,10 +986,10 @@ export default function ArsredovisningPage() {
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
placeholder="t.ex. Anna Andersson"
- className="h-9"
+ className="min-h-11"
/>
-
+
Lägg till
@@ -666,15 +998,17 @@ export default function ArsredovisningPage() {
- Ladda ner & lämna in
+ PDF för pappersinlämning
- Ladda ner PDF-utkastet, granska, skriv ut och låt undertecknarna signera
- fastställelseintyget. Ladda sedan upp PDF:en till Bolagsverkets e-tjänst.
+ Ladda ner PDF-utkastet och granska det. För pappersinlämning ska
+ årsredovisningens original skrivas under av samtliga styrelseledamöter och
+ eventuell VD. En bestyrkt kopia med fastställelseintyg skickas sedan per post
+ till Bolagsverket. PDF-filen kan inte laddas upp som digital årsredovisning.
-
+
Ladda ner PDF (utkast)
@@ -690,13 +1024,13 @@ export default function ArsredovisningPage() {
: undefined
}
>
-
+
- Bolagsverket Mina Sidor
+ Bolagsverket om årsredovisning
@@ -721,11 +1055,10 @@ export default function ArsredovisningPage() {
)}
- Notis om digital inlämning: Digital inlämning (iXBRL) av
- årsredovisning föreslås bli obligatorisk för K2/K3-aktiebolag för
- räkenskapsår som inleds efter 2025-12-31. Använd avsnittet{' '}
- Digital inlämning nedan för att granska, validera och lämna
- in årsredovisningen som iXBRL. PDF:en ovan är ett läsexemplar.
+ Digital inlämning är frivillig. Den görs som iXBRL genom en
+ ansluten programvara. Accounteds direktinlämning förblir stängd tills avtal,
+ certifikat och Bolagsverkets acceptanstest är klara. PDF-flödet ovan är den
+ separata vägen för pappersinlämning.
diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx
index 4a3574e0..f2005445 100644
--- a/app/(dashboard)/bookkeeping/year-end/page.tsx
+++ b/app/(dashboard)/bookkeeping/year-end/page.tsx
@@ -148,7 +148,7 @@ export default function YearEndPage() {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
- setReportError(body?.error?.message ?? 'Kunde inte ladda bokslutskontroll')
+ setReportError(getErrorMessage(body?.error) ?? 'Kunde inte ladda bokslutskontroll')
return
}
setReport(body.data as BokslutReadinessReport)
@@ -174,7 +174,7 @@ export default function YearEndPage() {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`)
const body = await res.json()
if (!res.ok) {
- setPreviewError(body?.error?.message ?? 'Kunde inte hämta förhandsgranskning')
+ setPreviewError(getErrorMessage(body?.error) ?? 'Kunde inte hämta förhandsgranskning')
return
}
setPreview(body.data.preview as YearEndPreview)
@@ -198,7 +198,7 @@ export default function YearEndPage() {
// body.error.message is the localized Swedish message picked by
// the structured-error registry. Do NOT interpolate raw details
// here: they can contain DB-sourced strings (V2.3 finding).
- setExecuteError(body?.error?.message ?? 'Bokslutet kunde inte verkställas')
+ setExecuteError(getErrorMessage(body?.error) ?? 'Bokslutet kunde inte verkställas')
return
}
setResult(body.data as YearEndResult)
diff --git a/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx b/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx
index 19e59d63..cad82aa4 100644
--- a/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx
+++ b/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx
@@ -26,6 +26,7 @@ import type {
PeriodiseringConfidence,
} from '@/lib/bokslut/accruals/auto-detect'
import type { FiscalPeriod } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review'
@@ -160,7 +161,7 @@ export default function PeriodiseringWizardPage() {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
- setLoadError(body?.error?.message ?? 'Kunde inte ladda periodiseringar')
+ setLoadError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda periodiseringar')
return
}
const data = body.data as ProposalResponse
@@ -329,7 +330,7 @@ export default function PeriodiseringWizardPage() {
})
const body = await res.json()
if (!res.ok) {
- setPostError(body?.error?.message ?? 'Kunde inte bokföra periodiseringarna')
+ setPostError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra periodiseringarna')
return
}
const created = body.data?.created?.length ?? 0
@@ -340,7 +341,7 @@ export default function PeriodiseringWizardPage() {
description: skipped > 0 ? `${skipped} hoppades över (redan postade).` : undefined,
})
} catch (err) {
- setPostError(err instanceof Error ? err.message : 'Okänt fel')
+ setPostError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
diff --git a/app/(dashboard)/chat/[id]/page.tsx b/app/(dashboard)/chat/[id]/page.tsx
index d65b8232..0972aa1d 100644
--- a/app/(dashboard)/chat/[id]/page.tsx
+++ b/app/(dashboard)/chat/[id]/page.tsx
@@ -1,7 +1,6 @@
-import { createClient } from '@/lib/supabase/server'
import { notFound, redirect } from 'next/navigation'
-import { getActiveCompanyId } from '@/lib/company/context'
import ChatConversationView from '@/components/agent/ChatConversationView'
+import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -15,12 +14,11 @@ interface PageProps {
// up via /api/agent/invoke with conversation_id supplied.
export default async function ChatConversationPage({ params }: PageProps) {
const { id } = await params
- const supabase = await createClient()
-
- const { data: { user } } = await supabase.auth.getUser()
+ const [{ supabase, user }, companyId] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ ])
if (!user) redirect('/login')
-
- const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
// Both queries key on the route id, so they run in parallel. The tenant
diff --git a/app/(dashboard)/chat/intake/page.tsx b/app/(dashboard)/chat/intake/page.tsx
index 536b940c..27d0ddab 100644
--- a/app/(dashboard)/chat/intake/page.tsx
+++ b/app/(dashboard)/chat/intake/page.tsx
@@ -1,7 +1,6 @@
-import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
-import { getActiveCompanyId } from '@/lib/company/context'
import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter'
+import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -13,11 +12,11 @@ export const dynamic = 'force-dynamic'
//
// Plan ref: dev_docs/specialized-agent-plan.md §7 Phase C.
export default async function ChatIntakePage() {
- const supabase = await createClient()
- const { data: { user } } = await supabase.auth.getUser()
+ const [{ user }, companyId] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ ])
if (!user) redirect('/login')
-
- const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
return
diff --git a/app/(dashboard)/chat/layout.tsx b/app/(dashboard)/chat/layout.tsx
index f6846e96..1cd43b81 100644
--- a/app/(dashboard)/chat/layout.tsx
+++ b/app/(dashboard)/chat/layout.tsx
@@ -1,8 +1,10 @@
-import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
-import { getActiveCompanyId } from '@/lib/company/context'
-import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import ChatSidebar from '@/components/agent/ChatSidebar'
+import {
+ getDashboardAuthContext,
+ getDashboardCompanyId,
+ getResolvedDashboardAgentProfile,
+} from '../request-context'
export const dynamic = 'force-dynamic'
@@ -10,11 +12,12 @@ export const dynamic = 'force-dynamic'
// conversation (or empty state) in the main panel. Both /chat and /chat/[id]
// share this layout so the sidebar doesn't unmount on conversation switches.
export default async function ChatLayout({ children }: { children: React.ReactNode }) {
- const supabase = await createClient()
- const { data: { user } } = await supabase.auth.getUser()
+ const [{ supabase, user }, companyId, agent] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ getResolvedDashboardAgentProfile(),
+ ])
if (!user) redirect('/login')
-
- const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
// Block the chat surface until the agent is built. Without this a user
@@ -22,31 +25,6 @@ export default async function ChatLayout({ children }: { children: React.ReactNo
// empty conversations list with no Anna to talk to. The home route at /
// renders NewUserChecklist for the same state, so we forward there
// instead of duplicating the welcome screen here.
- let { data: agent } = await supabase
- .from('agent_profiles')
- .select('verified_at')
- .eq('company_id', companyId)
- .maybeSingle()
-
- // Sandbox sessions get a pre-built assistant: backfill if a pre-seed
- // session is missing it so /chat doesn't bounce back to / in a loop.
- if (!agent?.verified_at) {
- const { data: settings } = await supabase
- .from('company_settings')
- .select('is_sandbox')
- .eq('company_id', companyId)
- .maybeSingle()
- if (settings?.is_sandbox) {
- await ensureSandboxAgentProfile(supabase, companyId)
- const refresh = await supabase
- .from('agent_profiles')
- .select('verified_at')
- .eq('company_id', companyId)
- .maybeSingle()
- agent = refresh.data
- }
- }
-
if (!agent?.verified_at) redirect('/')
const { data: conversations } = await supabase
diff --git a/app/(dashboard)/chat/new/page.tsx b/app/(dashboard)/chat/new/page.tsx
index fef59597..c27f78ec 100644
--- a/app/(dashboard)/chat/new/page.tsx
+++ b/app/(dashboard)/chat/new/page.tsx
@@ -1,8 +1,7 @@
-import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
-import { getActiveCompanyId } from '@/lib/company/context'
import ChatNewStarter from '@/components/agent/ChatNewStarter'
import { getIntent } from '@/lib/agent/intents/registry'
+import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -17,11 +16,11 @@ interface PageProps {
// caller-chosen intent/seed, so suggestion chips and ⌘K can route here
// inline instead of opening the slide-in sheet.
export default async function ChatNewPage({ searchParams }: PageProps) {
- const supabase = await createClient()
- const { data: { user } } = await supabase.auth.getUser()
+ const [{ user }, companyId] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ ])
if (!user) redirect('/login')
-
- const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
const sp = await searchParams
diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx
index 976acf66..177797e9 100644
--- a/app/(dashboard)/customers/page.tsx
+++ b/app/(dashboard)/customers/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
+import dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -21,7 +22,6 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
-import CustomerForm from '@/components/customers/CustomerForm'
import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
import { PageHeader } from '@/components/ui/page-header'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
@@ -31,6 +31,19 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
+const CustomerForm = dynamic(
+ () => import('@/components/customers/CustomerForm'),
+ {
+ loading: () => (
+
+
+
+
+
+ ),
+ },
+)
+
const CUSTOMER_TYPE_LABEL_KEYS: Record
= {
individual: 'type_individual',
swedish_business: 'type_swedish_business',
@@ -49,6 +62,7 @@ const SORTABLE_COLUMNS: ReadonlyArray = [
'city',
'created_at',
]
+const INITIAL_VISIBLE_ROWS = 100
function getInitials(name: string): string {
return name
@@ -73,11 +87,13 @@ function CustomersPageInner() {
const [customers, setCustomers] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
+ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('customers')
+ const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale
const router = useRouter()
@@ -93,6 +109,7 @@ function CustomersPageInner() {
const updateSort = useCallback(
(column: SortColumn) => {
+ setVisibleCount(INITIAL_VISIBLE_ROWS)
const params = new URLSearchParams(searchParams.toString())
let nextDir: SortDir = 'asc'
if (column === sortColumn) {
@@ -211,6 +228,7 @@ function CustomersPageInner() {
})
return arr
}, [filteredCustomers, sortColumn, sortDir])
+ const visibleCustomers = sortedCustomers.slice(0, visibleCount)
function SortableHeader({
column,
@@ -284,7 +302,10 @@ function CustomersPageInner() {
setSearchTerm(e.target.value)}
+ onChange={(e) => {
+ setSearchTerm(e.target.value)
+ setVisibleCount(INITIAL_VISIBLE_ROWS)
+ }}
className="pl-10"
/>
@@ -350,7 +371,7 @@ function CustomersPageInner() {
- {sortedCustomers.map((customer) => {
+ {visibleCustomers.map((customer) => {
const identifier = getIdentifier(customer)
return (
- {sortedCustomers.map((customer) => (
+ {visibleCustomers.map((customer) => (
@@ -441,6 +462,17 @@ function CustomersPageInner() {
))}
+ {visibleCount < sortedCustomers.length && (
+
+ setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
+ >
+ {tCommon('load_more')}
+
+
+ )}
>
)}
diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx
index 23b5eb98..c7d6aadd 100644
--- a/app/(dashboard)/deadlines/page.tsx
+++ b/app/(dashboard)/deadlines/page.tsx
@@ -15,6 +15,7 @@ import { AlertTriangle, ArrowRight, CalendarClock, Loader2 } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils'
import type { Deadline } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const supabase = createClient()
@@ -123,7 +124,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('generate_failed_title'),
- description: error instanceof Error ? error.message : t('retry'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
} finally {
@@ -155,7 +156,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('create_failed_title'),
- description: error instanceof Error ? error.message : t('retry'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
throw error
@@ -208,7 +209,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('toggle_failed_title'),
- description: error instanceof Error ? error.message : t('retry'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
}
@@ -236,7 +237,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('update_failed_title'),
- description: error instanceof Error ? error.message : t('retry'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
}
@@ -258,7 +259,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('delete_failed_title'),
- description: error instanceof Error ? error.message : t('retry'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
}
diff --git a/app/(dashboard)/e/[sector]/[slug]/page.tsx b/app/(dashboard)/e/[sector]/[slug]/page.tsx
index bc347264..f0cb9069 100644
--- a/app/(dashboard)/e/[sector]/[slug]/page.tsx
+++ b/app/(dashboard)/e/[sector]/[slug]/page.tsx
@@ -1,12 +1,11 @@
-import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader'
-import { getActiveCompanyId } from '@/lib/company/context'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
import { ExtensionUpsellState } from '@/components/extensions/ExtensionUpsellState'
+import { getDashboardAuthContext, getDashboardCompanyId } from '../../../request-context'
export default async function ExtensionWorkspacePage({
params,
@@ -14,8 +13,10 @@ export default async function ExtensionWorkspacePage({
params: Promise<{ sector: string; slug: string }>
}) {
const { sector, slug } = await params
- const supabase = await createClient()
- const { data: { user } } = await supabase.auth.getUser()
+ const [{ supabase, user }, companyId] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ ])
if (!user) redirect('/login')
@@ -29,7 +30,6 @@ export default async function ExtensionWorkspacePage({
// Fail closed: no resolvable company or the capability absent, both block.
const requiredCapability = requiredCapabilityForExtension(sector, slug)
if (requiredCapability) {
- const companyId = await getActiveCompanyId(supabase, user.id)
const allowed = companyId
? await hasCapability(supabase, companyId, requiredCapability)
: false
diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx
index c074d4c7..723fada7 100644
--- a/app/(dashboard)/import/page.tsx
+++ b/app/(dashboard)/import/page.tsx
@@ -19,28 +19,11 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
-// Bank file import components
-import BankFileUploadStep from '@/components/import/BankFileUploadStep'
-import BankFilePreviewStep from '@/components/import/BankFilePreviewStep'
-import BankFileColumnMappingStep from '@/components/import/BankFileColumnMappingStep'
-import BankFileConfirmStep from '@/components/import/BankFileConfirmStep'
-import BankFileResultStep from '@/components/import/BankFileResultStep'
-
-// Opening balance import components
-import OpeningBalanceUploadStep from '@/components/import/OpeningBalanceUploadStep'
-import OpeningBalanceColumnMappingStep from '@/components/import/OpeningBalanceColumnMappingStep'
-import OpeningBalanceEditStep from '@/components/import/OpeningBalanceEditStep'
-import OpeningBalancePeriodStep from '@/components/import/OpeningBalancePeriodStep'
-import OpeningBalanceResultStep from '@/components/import/OpeningBalanceResultStep'
import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types'
// Register import (customers/suppliers) components
-import RegisterUploadStep from '@/components/import/RegisterUploadStep'
import RegisterColumnMappingStep, { type RegisterColumnSpec } from '@/components/import/RegisterColumnMappingStep'
-import CustomersEditStep from '@/components/import/CustomersEditStep'
-import SuppliersEditStep from '@/components/import/SuppliersEditStep'
-import ArticlesEditStep from '@/components/import/ArticlesEditStep'
-import RegisterResultStep, { type RegisterResult } from '@/components/import/RegisterResultStep'
+import type { RegisterResult } from '@/components/import/RegisterResultStep'
import type {
CustomerImportParseResult,
AnnotatedCustomerRow,
@@ -57,12 +40,7 @@ import type {
DetectedArticleColumns,
} from '@/lib/import/articles/types'
-// SIE import components
-import SIEUploadStep from '@/components/import/SIEUploadStep'
-import SIEPreviewStep from '@/components/import/SIEPreviewStep'
-import AccountMappingStep from '@/components/import/AccountMappingStep'
-import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep'
-import ImportResultStep from '@/components/import/ImportResultStep'
+import type { ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import { applyMappingOverride } from '@/lib/import/account-mapper'
import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { IngestResult } from '@/lib/transactions/ingest'
@@ -86,6 +64,35 @@ const MigrationWizard = dynamic(
{ ssr: false, loading: () => Laddar migreringsverktyg...
}
)
+function ImportStepLoading() {
+ return (
+
+
+
+ )
+}
+
+const BankFileUploadStep = dynamic(() => import('@/components/import/BankFileUploadStep'), { loading: ImportStepLoading })
+const BankFilePreviewStep = dynamic(() => import('@/components/import/BankFilePreviewStep'), { loading: ImportStepLoading })
+const BankFileColumnMappingStep = dynamic(() => import('@/components/import/BankFileColumnMappingStep'), { loading: ImportStepLoading })
+const BankFileConfirmStep = dynamic(() => import('@/components/import/BankFileConfirmStep'), { loading: ImportStepLoading })
+const BankFileResultStep = dynamic(() => import('@/components/import/BankFileResultStep'), { loading: ImportStepLoading })
+const OpeningBalanceUploadStep = dynamic(() => import('@/components/import/OpeningBalanceUploadStep'), { loading: ImportStepLoading })
+const OpeningBalanceColumnMappingStep = dynamic(() => import('@/components/import/OpeningBalanceColumnMappingStep'), { loading: ImportStepLoading })
+const OpeningBalanceEditStep = dynamic(() => import('@/components/import/OpeningBalanceEditStep'), { loading: ImportStepLoading })
+const OpeningBalancePeriodStep = dynamic(() => import('@/components/import/OpeningBalancePeriodStep'), { loading: ImportStepLoading })
+const OpeningBalanceResultStep = dynamic(() => import('@/components/import/OpeningBalanceResultStep'), { loading: ImportStepLoading })
+const RegisterUploadStep = dynamic(() => import('@/components/import/RegisterUploadStep'), { loading: ImportStepLoading })
+const CustomersEditStep = dynamic(() => import('@/components/import/CustomersEditStep'), { loading: ImportStepLoading })
+const SuppliersEditStep = dynamic(() => import('@/components/import/SuppliersEditStep'), { loading: ImportStepLoading })
+const ArticlesEditStep = dynamic(() => import('@/components/import/ArticlesEditStep'), { loading: ImportStepLoading })
+const RegisterResultStep = dynamic(() => import('@/components/import/RegisterResultStep'), { loading: ImportStepLoading })
+const SIEUploadStep = dynamic(() => import('@/components/import/SIEUploadStep'), { loading: ImportStepLoading })
+const SIEPreviewStep = dynamic(() => import('@/components/import/SIEPreviewStep'), { loading: ImportStepLoading })
+const AccountMappingStep = dynamic(() => import('@/components/import/AccountMappingStep'), { loading: ImportStepLoading })
+const ImportReviewStep = dynamic(() => import('@/components/import/ImportReviewStep'), { loading: ImportStepLoading })
+const ImportResultStep = dynamic(() => import('@/components/import/ImportResultStep'), { loading: ImportStepLoading })
+
// ============================================================
// Bank File Import Wizard Steps
// ============================================================
@@ -185,7 +192,7 @@ function BankFileImportWizard() {
'Exportera en ny fil från banken om du vill lägga till fler transaktioner.'
)
} else {
- setBankError(err.message || 'Kunde inte läsa filen')
+ setBankError(getErrorMessage(err) || 'Kunde inte läsa filen')
}
} else {
setBankError(typeof err === 'string' ? err : 'Kunde inte läsa filen')
@@ -219,7 +226,7 @@ function BankFileImportWizard() {
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
}
} catch (err) {
- setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setBankError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setBankIsLoading(false)
}
@@ -267,7 +274,7 @@ function BankFileImportWizard() {
description: `${data.data.imported} transaktioner importerades`,
})
} catch (err) {
- setBankError(err instanceof Error ? err.message : 'Importen misslyckades')
+ setBankError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setBankIsLoading(false)
}
@@ -524,7 +531,7 @@ function SIEImportWizard() {
const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError'))
const message = isNetworkError
? 'Kunde inte nå servern. Kontrollera din internetanslutning och försök igen.'
- : err instanceof Error ? err.message : 'Ett oväntat fel uppstod.'
+ : getErrorMessage(err)
setErrorType(isNetworkError ? 'network' : 'parse')
setError(message)
toast({ title: isNetworkError ? 'Anslutningsfel' : 'Ett fel uppstod', description: message, variant: 'destructive' })
@@ -677,7 +684,7 @@ function SIEImportWizard() {
setBasAccounts(accountsData.data || [])
}
} catch (err) {
- toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive' })
+ toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? getErrorMessage(err) : 'Försök igen.', variant: 'destructive' })
} finally {
setIsCreatingAccounts(false)
}
@@ -739,7 +746,7 @@ function SIEImportWizard() {
const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError'))
const msg = isNetworkError
? 'Tappade anslutningen till servern under importen. Kontrollera din internetanslutning och se om importen genomfördes under Bokföring.'
- : err instanceof Error ? err.message : 'Ett oväntat fel uppstod.'
+ : getErrorMessage(err)
setError(msg)
toast({ title: 'Import avbröts', description: msg, variant: 'destructive' })
} finally {
@@ -886,7 +893,7 @@ function OpeningBalanceFlow() {
setObStep('edit')
}
} catch (err) {
- setObError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setObError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setObIsLoading(false)
}
@@ -918,7 +925,7 @@ function OpeningBalanceFlow() {
setParseResult(data.data)
setObStep('edit')
} catch (err) {
- setObError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setObError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setObIsLoading(false)
}
@@ -979,7 +986,7 @@ function OpeningBalanceFlow() {
})
}
} catch (err) {
- setObError(err instanceof Error ? err.message : 'Importen misslyckades')
+ setObError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setObIsLoading(false)
}
@@ -1156,7 +1163,7 @@ function CustomersFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
@@ -1175,7 +1182,7 @@ function CustomersFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1217,14 +1224,14 @@ function CustomersFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen med de valda kolumnerna')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen med de valda kolumnerna')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1249,7 +1256,7 @@ function CustomersFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
@@ -1263,7 +1270,7 @@ function CustomersFlow() {
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
- setError(err instanceof Error ? err.message : 'Importen misslyckades')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
@@ -1407,7 +1414,7 @@ function SuppliersFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
@@ -1426,7 +1433,7 @@ function SuppliersFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1474,14 +1481,14 @@ function SuppliersFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1506,7 +1513,7 @@ function SuppliersFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
@@ -1520,7 +1527,7 @@ function SuppliersFlow() {
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
- setError(err instanceof Error ? err.message : 'Importen misslyckades')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
@@ -1657,7 +1664,7 @@ function ArticlesFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
@@ -1676,7 +1683,7 @@ function ArticlesFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1717,14 +1724,14 @@ function ArticlesFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen med de valda kolumnerna')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen med de valda kolumnerna')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1749,7 +1756,7 @@ function ArticlesFlow() {
const data = await res.json()
if (!res.ok) {
- setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades')
+ setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
@@ -1763,7 +1770,7 @@ function ArticlesFlow() {
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
- setError(err instanceof Error ? err.message : 'Importen misslyckades')
+ setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx
index 5abba896..50424d9d 100644
--- a/app/(dashboard)/invoices/[id]/credit/page.tsx
+++ b/app/(dashboard)/invoices/[id]/credit/page.tsx
@@ -20,6 +20,7 @@ import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import type { Invoice, InvoiceItem, Customer } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InvoiceWithRelations extends Invoice {
customer: Customer
@@ -136,7 +137,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
} catch (error) {
toast({
title: t('create_failed_title'),
- description: error instanceof Error ? error.message : t('try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('try_again'),
variant: 'destructive',
})
}
diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx
index 9579a60f..f7b84ffa 100644
--- a/app/(dashboard)/invoices/[id]/page.tsx
+++ b/app/(dashboard)/invoices/[id]/page.tsx
@@ -53,6 +53,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const statusVariantMap: Record = {
draft: 'secondary',
@@ -83,7 +84,7 @@ interface InvoiceWithRelations extends Invoice {
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
- const { isSandbox } = useCompany()
+ const { company, isSandbox } = useCompany()
const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params)
const router = useRouter()
@@ -139,6 +140,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
async function fetchInvoice() {
setIsLoading(true)
+ // Settings depend only on the active company, so start them with the main
+ // invoice batch instead of waiting for the invoice row first.
+ const settingsPromise = company?.id
+ ? supabase
+ .from('company_settings')
+ .select('ore_rounding, vat_registered, accounting_method, defer_invoice_booking, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
+ .eq('company_id', company.id)
+ .maybeSingle()
+ : Promise.resolve(null)
+
// Invoice, reminders, and payments all key on the route id — one
// parallel batch. Only the follow-ups below need the invoice row.
const [{ data, error }, { data: reminderData }, { data: paymentData }] =
@@ -213,19 +224,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)
}
- // Follow-ups that need the invoice row: company settings (öresavrundning
- // + VAT registration so the detail view matches the PDF — see
- // pdf-template.tsx:792 and :876), the credit note, the original invoice,
- // and the proforma source. Independent of each other → parallel.
- const [settingsRes, creditNoteRes, originalRes, convertedRes] =
- await Promise.all([
- data.company_id
- ? supabase
- .from('company_settings')
- .select('ore_rounding, vat_registered, accounting_method, defer_invoice_booking, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
- .eq('company_id', data.company_id)
- .maybeSingle()
- : Promise.resolve(null),
+ const settingsRes = await settingsPromise
+ if (settingsRes) {
+ const settings = settingsRes.data
+ setOreRounding(settings?.ore_rounding ?? true)
+ if (typeof settings?.vat_registered === 'boolean') {
+ setVatRegistered(settings.vat_registered)
+ }
+ setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
+ setDeferInvoiceBooking(!!settings?.defer_invoice_booking)
+ setReminderDays([
+ settings?.reminder_days_level_1 ?? 15,
+ settings?.reminder_days_level_2 ?? 30,
+ settings?.reminder_days_level_3 ?? 45,
+ ])
+ }
+
+ // Related documents need the invoice row but do not gate the main detail
+ // view. Resolve them together after first paint and fill their links in.
+ setIsLoading(false)
+ void Promise.all([
!data.credited_invoice_id &&
['sent', 'paid', 'overdue', 'credited'].includes(data.status)
? supabase
@@ -249,31 +267,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.eq('id', data.converted_from_id)
.single()
: Promise.resolve(null),
- ])
-
- if (settingsRes) {
- const settings = settingsRes.data
- setOreRounding(settings?.ore_rounding ?? true)
- if (typeof settings?.vat_registered === 'boolean') {
- setVatRegistered(settings.vat_registered)
- }
- setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
- setDeferInvoiceBooking(!!settings?.defer_invoice_booking)
- setReminderDays([
- settings?.reminder_days_level_1 ?? 15,
- settings?.reminder_days_level_2 ?? 30,
- settings?.reminder_days_level_3 ?? 45,
- ])
- }
- setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
- if (originalRes?.data) {
- setOriginalInvoice(originalRes.data as Invoice)
- }
- if (convertedRes?.data) {
- setConvertedFromInvoice(convertedRes.data as Invoice)
- }
-
- setIsLoading(false)
+ ]).then(([creditNoteRes, originalRes, convertedRes]) => {
+ setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
+ if (originalRes?.data) {
+ setOriginalInvoice(originalRes.data as Invoice)
+ }
+ if (convertedRes?.data) {
+ setConvertedFromInvoice(convertedRes.data as Invoice)
+ }
+ })
}
// #967: deferred booking: create the revenue verifikat afterwards.
@@ -304,7 +306,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('book_failed_title'),
- description: error instanceof Error ? error.message : t('fallback_try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
} finally {
@@ -357,7 +359,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('status_update_failed_title'),
- description: error instanceof Error ? error.message : t('fallback_try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -394,7 +396,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('convert_failed_title'),
- description: error instanceof Error ? error.message : t('fallback_try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -433,7 +435,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('pdf_download_failed_title'),
- description: error instanceof Error ? error.message : t('fallback_try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -495,7 +497,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('finalize_failed_title'),
- description: error instanceof Error ? error.message : t('fallback_try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
} finally {
@@ -540,7 +542,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('cancel_failed_title'),
- description: error instanceof Error ? error.message : t('fallback_try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx
index 7f8a2654..6cbb890b 100644
--- a/app/(dashboard)/invoices/page.tsx
+++ b/app/(dashboard)/invoices/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
+import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -11,6 +12,7 @@ import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { Skeleton } from '@/components/ui/skeleton'
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
DataList,
DataListRow,
@@ -26,13 +28,35 @@ import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
-import NewInvoiceDialog from '@/components/invoices/NewInvoiceDialog'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Invoice, InvoiceStatus } from '@/types'
+function NewInvoiceDialogLoading() {
+ const t = useTranslations('invoices')
+ return (
+
+
+ {t('new_invoice')}
+
+
+
+
+
+
+
+ )
+}
+
+const NewInvoiceDialog = dynamic(
+ () => import('@/components/invoices/NewInvoiceDialog'),
+ { loading: NewInvoiceDialogLoading },
+)
+
type InvoiceStatusVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
+const INITIAL_VISIBLE_ROWS = 100
+
const STATUS_CONFIG: Record = {
draft: { labelKey: 'status_draft', variant: 'secondary' },
sent: { labelKey: 'status_sent', variant: 'default' },
@@ -77,9 +101,11 @@ export default function InvoicesPage() {
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('all')
+ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoices')
+ const tCommon = useTranslations('common')
const getRelativeTimeLabel = useRelativeTimeLabel()
// The "Ny faktura" modal is driven by the URL (?new=1) so every entry point
@@ -144,6 +170,7 @@ export default function InvoicesPage() {
return matchesSearch && matchesTab
})
+ const visibleInvoices = filteredInvoices.slice(0, visibleCount)
const isOutstandingReceivable = (i: Invoice) =>
['sent', 'overdue'].includes(i.status) && !i.credited_invoice_id
@@ -218,11 +245,21 @@ export default function InvoicesPage() {
setSearchTerm(e.target.value)}
+ onChange={(e) => {
+ setSearchTerm(e.target.value)
+ setVisibleCount(INITIAL_VISIBLE_ROWS)
+ }}
className="pl-10"
/>
-
+ {
+ setActiveTab(value)
+ setVisibleCount(INITIAL_VISIBLE_ROWS)
+ }}
+ className="min-w-0"
+ >
{t('tab_all')}
{t('tab_unpaid')}
@@ -264,7 +301,7 @@ export default function InvoicesPage() {
/>
)
) : (
- filteredInvoices.map((invoice) => {
+ visibleInvoices.map((invoice) => {
const status = STATUS_CONFIG[invoice.status]
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
@@ -378,13 +415,27 @@ export default function InvoicesPage() {
)}
- {
- if (!open) closeNewInvoice()
- }}
- />
+ {!isLoading && visibleCount < filteredInvoices.length && (
+
+ setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
+ >
+ {tCommon('load_more')}
+
+
+ )}
+
+ {showNewInvoice && (
+ {
+ if (!open) closeNewInvoice()
+ }}
+ />
+ )}
)
}
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index 9222e863..98817a67 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -1,4 +1,3 @@
-import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
@@ -12,11 +11,15 @@ import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
-import { getActiveCompanyId } from '@/lib/company/context'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service'
-import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import type { EntityType, CompanyRole, Team } from '@/types'
+import {
+ getDashboardAuthContext,
+ getDashboardCompanyId,
+ getDashboardSettings,
+ getResolvedDashboardAgentProfile,
+} from './request-context'
/**
* Routes inside the dashboard group that must remain reachable when the
@@ -34,9 +37,7 @@ export default async function DashboardLayout({
// current page on in-app navigation to /settings/*; null otherwise.
settingsModal: React.ReactNode
}) {
- const supabase = await createClient()
-
- const { data: { user } } = await supabase.auth.getUser()
+ const { supabase, user } = await getDashboardAuthContext()
if (!user) {
redirect('/login')
@@ -51,7 +52,7 @@ export default async function DashboardLayout({
// so it resolves in parallel, this layout is on the critical path of
// every dashboard page, so sequential round-trips are wall-clock time.
const [companyId, headerStore, { data: teamMembership }] = await Promise.all([
- getActiveCompanyId(supabase, user.id),
+ getDashboardCompanyId(),
// Read the pathname forwarded by middleware so we can branch on it.
headers(),
supabase
@@ -128,7 +129,7 @@ export default async function DashboardLayout({
{ data: memberRow },
{ data: allMemberships },
{ data: settings },
- { data: agentProfileIdentity },
+ agentProfileIdentity,
{ data: userProfile },
entitlements,
{ data: allSettingsNames },
@@ -136,22 +137,14 @@ export default async function DashboardLayout({
supabase.from('companies').select('*').eq('id', companyId).single(),
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
- supabase
- .from('company_settings')
- .select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
- .eq('company_id', companyId)
- .single(),
+ getDashboardSettings(),
// Nav badge counts (unbooked transactions, pending operations) are NOT
// fetched here anymore: DashboardNav loads them client-side after mount
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
// first paint on every dashboard navigation.
// Agent identity, name + avatar, surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
- supabase
- .from('agent_profiles')
- .select('display_name, avatar_id, verified_at')
- .eq('company_id', companyId)
- .maybeSingle(),
+ getResolvedDashboardAgentProfile(),
// The signed-in user's profile, shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
@@ -242,23 +235,6 @@ export default async function DashboardLayout({
const isSandbox = settings?.is_sandbox === true
- // Backfill a verified agent_profile for sandbox sessions that pre-date the
- // seed change. Without this an old anonymous session shows the "Bygg din
- // bokföringsassistent" CTA in three places (dashboard hero, NewUserChecklist
- // step 4, /chat layout redirect) and the user can still kick off a build
- // flow that the server now 403s. Best-effort; doesn't block the layout
- // even if the insert fails.
- let resolvedAgentIdentity = agentProfileIdentity
- if (isSandbox && !agentProfileIdentity?.verified_at) {
- await ensureSandboxAgentProfile(supabase, companyId)
- const { data: refreshed } = await supabase
- .from('agent_profiles')
- .select('display_name, avatar_id, verified_at')
- .eq('company_id', companyId)
- .maybeSingle()
- resolvedAgentIdentity = refreshed ?? agentProfileIdentity
- }
-
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
@@ -283,9 +259,9 @@ export default async function DashboardLayout({
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index d2634d46..56e3747f 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -1,50 +1,31 @@
-import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
-import { cookies } from 'next/headers'
import DashboardContent from '@/components/dashboard/DashboardContent'
-import WelcomeGate from '@/components/onboarding/WelcomeGate'
-import { getActiveCompanyId } from '@/lib/company/context'
import { getDisplayTotal } from '@/lib/invoices/rounding'
-import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
import type { Deadline, OnboardingProgress } from '@/types'
+import {
+ getDashboardAuthContext,
+ getDashboardCompanyId,
+ getDashboardSettings,
+ getResolvedDashboardAgentProfile,
+} from './request-context'
export const dynamic = 'force-dynamic'
// Home route = Översikt (DashboardContent). The agent chat has its own nav
-// entry at /chat, so / no longer forwards there. New users who haven't built
-// their assistant yet get WelcomeGate (the build-agent checklist) instead of
-// the dashboard; once the agent is verified, / renders the normal Översikt.
+// entry at /chat, so / no longer forwards there. Initial setup is an optional,
+// persisted surface inside the dashboard and never replaces the overview.
export default async function DashboardPage() {
- const supabase = await createClient()
-
- const { data: { user } } = await supabase.auth.getUser()
+ const [{ supabase, user }, companyId] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ ])
if (!user) {
redirect('/login')
}
- const cookieStore = await cookies()
- const rawCompanyId = cookieStore.get('gnubok-company-id')?.value
- ?? await getActiveCompanyId(supabase, user.id)
-
- // Validate the cookie/preference points to a company the user can access.
- // Only a positive "no membership row" clears it: a FAILED query means the
- // membership is unknown, and treating that as absent bounced onboarded
- // users to the wizard on transient failures (issue #1053). RLS still
- // guards every downstream query if the cookie is stale.
- let companyId = rawCompanyId
- if (companyId) {
- const { data: membership, error: membershipError } = await supabase
- .from('company_members')
- .select('company_id')
- .eq('company_id', companyId)
- .eq('user_id', user.id)
- .maybeSingle()
- if (!membership && !membershipError) companyId = null
- }
-
if (!companyId) {
redirect('/onboarding')
}
@@ -61,7 +42,6 @@ export default async function DashboardPage() {
settingsRes,
{ count: customerCount },
{ count: invoiceCount },
- { count: receiptCount },
{ count: transactionCount },
{ data: journalLines },
{ data: unpaidInvoices },
@@ -70,15 +50,13 @@ export default async function DashboardPage() {
{ count: sieImportCount },
{ count: staleUncategorizedCount },
{ count: skatteverketTokenCount },
- { data: agentProfile },
- { count: postedEntriesCount },
+ agentProfile,
worklist,
suggestedMatches,
] = await Promise.all([
- supabase.from('company_settings').select('*').eq('company_id', companyId).maybeSingle(),
+ getDashboardSettings(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
- supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status, company_id)')
@@ -95,9 +73,7 @@ export default async function DashboardPage() {
// carry the active company_id; either filter would work: we use user_id
// because that's what the token-store reads/writes against.
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
- supabase.from('agent_profiles').select('verified_at').eq('company_id', companyId).maybeSingle(),
- // Any posted entry counts as "company has been used" for the hasData gate.
- supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted'),
+ getResolvedDashboardAgentProfile(),
// Pending-work counts + suggested matches come from lib/worklist: the
// same source as the sidebar badges, so the numbers can never diverge.
getWorklistCounts(supabase, companyId),
@@ -118,53 +94,12 @@ export default async function DashboardPage() {
redirect('/onboarding')
}
- // Sandbox sessions that pre-date the agent_profile seeding step would
- // otherwise still see the "Bygg din bokföringsassistent" hero + the
- // NewUserChecklist's agent step lit up. Backfill here so the next render
- // sees a verified profile and treats the sandbox as fully set up.
- let effectiveAgentVerified = agentProfile?.verified_at ?? null
- if (settings?.is_sandbox === true && !effectiveAgentVerified) {
- await ensureSandboxAgentProfile(supabase, companyId)
- const { data: refreshed } = await supabase
- .from('agent_profiles')
- .select('verified_at')
- .eq('company_id', companyId)
- .maybeSingle()
- effectiveAgentVerified = refreshed?.verified_at ?? null
- }
-
- const agentBuilt = Boolean(effectiveAgentVerified)
-
- // "Has the company already been used?" Any real business data means we must
- // NOT hijack the dashboard with the full-screen onboarding gate: existing
- // and migrated users get the normal Översikt with a build-assistant prompt
- // in the hero slot (see DashboardContent's agentBuilt branch) instead.
- const hasData =
- (transactionCount || 0) > 0 ||
- (sieImportCount || 0) > 0 ||
- (invoiceCount || 0) > 0 ||
- (receiptCount || 0) > 0 ||
- (customerCount || 0) > 0 ||
- (postedEntriesCount || 0) > 0
-
- // Only a genuinely empty company without an assistant sees the full
- // onboarding checklist (where building the assistant is the last step).
- // Everyone else falls through to the dashboard below.
- if (!agentBuilt && !hasData) {
- return (
- 0}
- hasBankConnected={(transactionCount || 0) > 0}
- hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0}
- />
- )
- }
+ const agentBuilt = Boolean(agentProfile?.verified_at)
const onboardingProgress: OnboardingProgress = {
hasCustomers: (customerCount || 0) > 0,
hasInvoices: (invoiceCount || 0) > 0,
- hasBankConnected: (transactionCount || 0) > 0,
+ hasBankConnected: (bankConnections?.length || 0) > 0 || (transactionCount || 0) > 0,
hasSIEImport: (sieImportCount || 0) > 0,
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
}
@@ -266,6 +201,11 @@ export default async function DashboardPage() {
worklist={worklist}
suggestedMatches={suggestedMatches}
onboardingProgress={onboardingProgress}
+ initialSetup={{
+ path: settings.initial_setup_path ?? null,
+ completedAt: settings.initial_setup_completed_at ?? null,
+ dismissedAt: settings.initial_setup_dismissed_at ?? null,
+ }}
/>
)
}
diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx
index 6828b148..77447e6f 100644
--- a/app/(dashboard)/pending/page.tsx
+++ b/app/(dashboard)/pending/page.tsx
@@ -688,45 +688,26 @@ export default function PendingOperationsPage() {
const res = await fetch(`/api/pending-operations?status=${activeTab}`)
const json = await res.json()
setOperations(json.data ?? [])
- setCounts((prev) => ({ ...prev, [activeTab]: json.count ?? json.data?.length ?? 0 }))
+ setCounts((prev) => ({
+ ...prev,
+ ...(json.counts ?? {}),
+ [activeTab]: json.count ?? json.data?.length ?? 0,
+ }))
} catch {
toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' })
}
setIsLoading(false)
}, [activeTab, toast])
- const fetchAllCounts = useCallback(async () => {
- const statuses: TabStatus[] = ['pending', 'committed', 'rejected']
- try {
- const results = await Promise.all(
- statuses.map((s) =>
- fetch(`/api/pending-operations?status=${s}&limit=1`).then((r) => r.json())
- )
- )
- setCounts({
- pending: results[0]?.count ?? 0,
- committed: results[1]?.count ?? 0,
- rejected: results[2]?.count ?? 0,
- })
- } catch {
- // Counts are best-effort; the active tab's count will still update via fetchOperations
- }
- }, [])
-
useEffect(() => {
fetchOperations()
}, [fetchOperations])
- useEffect(() => {
- fetchAllCounts()
- }, [fetchAllCounts])
-
// Realtime subscription: refetch when ANY pending_operations row changes for
// this company. RLS scopes the channel automatically: we don't see other
// tenants' events. We refetch the whole list (rather than patching state
// in-place) so server-side filtering, sorting, and computed fields stay in
- // sync with whatever the API route returned. The counts endpoint isn't
- // pushed by the same trigger, so we also refresh counts on every change.
+ // sync with whatever the API route returned, including all tab counts.
// Trailing debounce: bulk actions emit one event per row, which previously
// stampeded 4 requests per event (list + 3 counts); the burst now collapses
// into a single refetch after the last event.
@@ -742,7 +723,6 @@ export default function PendingOperationsPage() {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
fetchOperations()
- fetchAllCounts()
}, 400)
}
)
@@ -751,7 +731,7 @@ export default function PendingOperationsPage() {
if (debounce) clearTimeout(debounce)
void supabase.removeChannel(channel)
}
- }, [fetchOperations, fetchAllCounts])
+ }, [fetchOperations])
// Clear selection when filters/tab change
useEffect(() => {
@@ -772,11 +752,10 @@ export default function PendingOperationsPage() {
setShowCommitDialog(false)
setSelectedOp(null)
fetchOperations()
- fetchAllCounts()
} catch (err) {
toast({
title: 'Misslyckades',
- description: err instanceof Error ? err.message : 'Okänt fel',
+ description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
@@ -818,11 +797,10 @@ export default function PendingOperationsPage() {
setShowBulkDialog(false)
setSelectedIds(new Set())
fetchOperations()
- fetchAllCounts()
} catch (err) {
toast({
title: 'Misslyckades',
- description: err instanceof Error ? err.message : 'Okänt fel',
+ description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
@@ -887,11 +865,10 @@ export default function PendingOperationsPage() {
setRejectTarget(null)
fetchOperations()
- fetchAllCounts()
} catch (err) {
toast({
title: 'Kunde inte avvisa',
- description: err instanceof Error ? err.message : 'Okänt fel',
+ description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
diff --git a/app/(dashboard)/reports/[slug]/page.tsx b/app/(dashboard)/reports/[slug]/page.tsx
index abead7fb..00464d26 100644
--- a/app/(dashboard)/reports/[slug]/page.tsx
+++ b/app/(dashboard)/reports/[slug]/page.tsx
@@ -1,6 +1,8 @@
import { notFound, redirect } from 'next/navigation'
import { getReport } from '@/lib/reports/catalog'
import { FocusedReport } from '@/components/reports/FocusedReport'
+import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
+import type { FiscalPeriod } from '@/types'
/**
* Focused single-report route. Unknown slugs 404; reports that own a dedicated
@@ -16,5 +18,25 @@ export default async function ReportSlugPage({
const report = getReport(slug)
if (!report) notFound()
if (report.route) redirect(report.route)
- return
+
+ const [{ supabase }, companyId] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ ])
+ const needsFiscalPeriod = report.params !== 'calendar' && report.params !== 'none'
+ const { data: periods } = companyId && needsFiscalPeriod
+ ? await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('company_id', companyId)
+ .order('period_start', { ascending: false })
+ : { data: [] }
+
+ return (
+
+ )
}
diff --git a/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx b/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx
index 168c9781..f246992e 100644
--- a/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx
+++ b/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx
@@ -17,6 +17,7 @@ import {
import { ArrowLeft, Download, FileSpreadsheet, AlertTriangle, CheckCircle2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import type { KassaflodesanalysReport } from '@/lib/reports/kassaflodesanalys'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function formatAmount(n: number): string {
return n.toLocaleString('sv-SE', {
@@ -72,7 +73,7 @@ export function KassaflodesanalysClient() {
const { data } = await res.json()
setReport(data)
} catch (err) {
- setError(err instanceof Error ? err.message : 'Okänt fel')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
setReport(null)
} finally {
setIsLoadingReport(false)
diff --git a/app/(dashboard)/request-context.ts b/app/(dashboard)/request-context.ts
new file mode 100644
index 00000000..7743fadd
--- /dev/null
+++ b/app/(dashboard)/request-context.ts
@@ -0,0 +1,79 @@
+import 'server-only'
+
+import { cache } from 'react'
+import { createClient } from '@/lib/supabase/server'
+import { getActiveCompanyId } from '@/lib/company/context'
+import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
+
+/**
+ * Request-local dashboard auth context. React cache shares the Supabase client
+ * and auth lookup between the dashboard layout and every nested server page.
+ */
+export const getDashboardAuthContext = cache(async () => {
+ const supabase = await createClient()
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+
+ return { supabase, user }
+})
+
+/**
+ * Request-local active company resolution. Nested layouts and pages commonly
+ * need the same value, so resolving it once removes repeated preference and
+ * membership round trips without caching anything across requests.
+ */
+export const getDashboardCompanyId = cache(async () => {
+ const { supabase, user } = await getDashboardAuthContext()
+ return user ? getActiveCompanyId(supabase, user.id) : null
+})
+
+export const getDashboardSettings = cache(async () => {
+ const [{ supabase }, companyId] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ ])
+ if (!companyId) return { data: null, error: null }
+
+ return supabase
+ .from('company_settings')
+ .select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled, ore_rounding, initial_setup_path, initial_setup_completed_at, initial_setup_dismissed_at')
+ .eq('company_id', companyId)
+ .maybeSingle()
+})
+
+const getDashboardAgentProfile = cache(async () => {
+ const [{ supabase }, companyId] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ ])
+ if (!companyId) return { data: null, error: null }
+
+ return supabase
+ .from('agent_profiles')
+ .select('display_name, avatar_id, verified_at')
+ .eq('company_id', companyId)
+ .maybeSingle()
+})
+
+export const getResolvedDashboardAgentProfile = cache(async () => {
+ const [{ supabase }, companyId, settingsResult, profileResult] = await Promise.all([
+ getDashboardAuthContext(),
+ getDashboardCompanyId(),
+ getDashboardSettings(),
+ getDashboardAgentProfile(),
+ ])
+
+ let profile = profileResult.data
+ if (companyId && settingsResult.data?.is_sandbox === true && !profile?.verified_at) {
+ await ensureSandboxAgentProfile(supabase, companyId)
+ const refreshed = await supabase
+ .from('agent_profiles')
+ .select('display_name, avatar_id, verified_at')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ profile = refreshed.data ?? profile
+ }
+
+ return profile
+})
diff --git a/app/(dashboard)/salary/employees/page.tsx b/app/(dashboard)/salary/employees/page.tsx
index 986aa8a0..22d566d9 100644
--- a/app/(dashboard)/salary/employees/page.tsx
+++ b/app/(dashboard)/salary/employees/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
+import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -13,9 +14,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Plus, ArrowLeft, UserCircle } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
-import NewEmployeeDialog from '@/components/salary/NewEmployeeDialog'
+import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import type { Employee } from '@/types'
+const NewEmployeeDialog = dynamic(
+ () => import('@/components/salary/NewEmployeeDialog'),
+ { loading: DialogLoadingSkeleton },
+)
+
const EMPLOYMENT_LABEL_KEYS: Record = {
employee: 'employment_employee',
company_owner: 'employment_company_owner',
@@ -142,16 +148,18 @@ export default function EmployeesPage() {
)}
- {
- if (!open) closeNewEmployee()
- }}
- onCreated={() => {
- closeNewEmployee()
- setRefreshKey((k) => k + 1)
- }}
- />
+ {showNewEmployee && (
+ {
+ if (!open) closeNewEmployee()
+ }}
+ onCreated={() => {
+ closeNewEmployee()
+ setRefreshKey((k) => k + 1)
+ }}
+ />
+ )}
)
}
diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx
index 87801f2f..0ca4ecf7 100644
--- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx
+++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx
@@ -10,6 +10,7 @@ import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** Translation keys in the `salary_run_employee` namespace. */
const LINE_ITEM_TYPE_KEYS: Record = {
@@ -86,7 +87,7 @@ export default function SalaryRunEmployeeDetailPage({
if (!sreRes.ok) throw new Error(sreJson.error || t('error_load_employee'))
setData({ run: runJson.data, runEmployee: sreJson.data })
} catch (e) {
- setError(e instanceof Error ? e.message : t('unknown_error'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setLoading(false)
}
@@ -108,7 +109,7 @@ export default function SalaryRunEmployeeDetailPage({
}
await load()
} catch (e) {
- setError(e instanceof Error ? e.message : t('unknown_error'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setCalculating(false)
}
diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx
index 41a140ef..86adfe01 100644
--- a/app/(dashboard)/salary/runs/[id]/page.tsx
+++ b/app/(dashboard)/salary/runs/[id]/page.tsx
@@ -61,6 +61,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
tax_payment_file_generated_at: string | null
tax_paid_at: string | null
} | null>(null)
+ const [taxPaymentLoading, setTaxPaymentLoading] = useState(false)
// Skatteverket's per-period AGI submission record: drives the AGI step on
// the progress rail and the panel's state machine (underlag submitted /
@@ -79,11 +80,14 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
setRun(data)
if (data?.period_year && data?.period_month) {
const period = `${data.period_year}-${String(data.period_month).padStart(2, '0')}`
- const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
- if (txRes.ok) {
- const tx = await txRes.json()
- setTaxPayment(tx.data)
- }
+ setTaxPaymentLoading(true)
+ void fetch(`/api/skatteverket/tax-payments/${period}`)
+ .then(async (txRes) => (txRes.ok ? txRes.json() : null))
+ .then((tx) => {
+ if (tx) setTaxPayment(tx.data)
+ })
+ .catch(() => setTaxPayment(null))
+ .finally(() => setTaxPaymentLoading(false))
}
}
}
@@ -463,7 +467,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
} catch (err) {
toast({
title: t('toast_zip_failed'),
- description: err instanceof Error ? err.message : t('unknown_error'),
+ description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
@@ -650,15 +654,24 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
{/* Tax payment (skatt + arbetsgivaravgifter): once AGI has been generated */}
{run.status === 'booked' && run.agi_generated_at && (
-
+ taxPaymentLoading ? (
+
+
+
+
+
+
+ ) : (
+
+ )
)}
{/* AGI (Arbetsgivardeklaration): available once the run is booked */}
diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx
index 43507ea5..ed1f6c88 100644
--- a/app/(dashboard)/skattekonto/page.tsx
+++ b/app/(dashboard)/skattekonto/page.tsx
@@ -41,6 +41,7 @@ import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/extensions/general/skatteverket/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface SaldoEnvelope {
data: SkatteverketSaldoResponse | null
@@ -194,7 +195,7 @@ export default function SkattekontoPage() {
} catch (err) {
toast({
title: 'Synk misslyckades',
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
@@ -222,7 +223,7 @@ export default function SkattekontoPage() {
} catch (err) {
toast({
title: 'Kunde inte bokföra',
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
@@ -246,7 +247,7 @@ export default function SkattekontoPage() {
} catch (err) {
toast({
title: 'Kunde inte hämta kandidater',
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
setMatchOpenFor(null)
@@ -278,7 +279,7 @@ export default function SkattekontoPage() {
} catch (err) {
toast({
title: 'Kunde inte koppla transaktionen',
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx
index cf38d5bc..64036be9 100644
--- a/app/(dashboard)/supplier-invoices/[id]/page.tsx
+++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx
@@ -91,6 +91,7 @@ export default function SupplierInvoiceDetailPage() {
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
const [paymentAccount, setPaymentAccount] = useState('1930')
const [accounts, setAccounts] = useState([])
+ const [areAccountsLoading, setAreAccountsLoading] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [duplicateCandidates, setDuplicateCandidates] = useState<
Array<{
@@ -245,31 +246,27 @@ export default function SupplierInvoiceDetailPage() {
}
}, [isPayDialogOpen, invoice, payAmount, paymentAccount])
- // Load chart of accounts and remember the last picked payment account so the
- // dialog defaults to the user's previous choice instead of re-defaulting to
- // 1930 every time.
+ // The chart of accounts is only needed by the payment dialog. Defer the
+ // request until the user opens it instead of blocking the detail page.
useEffect(() => {
+ if (!isPayDialogOpen || accounts.length > 0) return
+
let cancelled = false
;(async () => {
- const [accountsRes, settingsRes] = await Promise.all([
- fetch('/api/bookkeeping/accounts'),
- fetch('/api/settings'),
- ])
- if (cancelled) return
- if (accountsRes.ok) {
- const { data } = await accountsRes.json()
+ setAreAccountsLoading(true)
+ try {
+ const response = await fetch('/api/bookkeeping/accounts')
+ if (cancelled || !response.ok) return
+ const { data } = await response.json()
if (Array.isArray(data)) setAccounts(data as BASAccount[])
- }
- if (settingsRes.ok) {
- const { data } = await settingsRes.json()
- const last = (data as { last_supplier_payment_account?: string | null } | null)?.last_supplier_payment_account
- if (last) setPaymentAccount(last)
+ } finally {
+ if (!cancelled) setAreAccountsLoading(false)
}
})()
return () => {
cancelled = true
}
- }, [])
+ }, [accounts.length, isPayDialogOpen])
async function handleApprove() {
setIsProcessing(true)
@@ -898,6 +895,9 @@ export default function SupplierInvoiceDetailPage() {
open={isPayDialogOpen}
onOpenChange={(open) => {
setIsPayDialogOpen(open)
+ if (open && companySettings?.last_supplier_payment_account) {
+ setPaymentAccount(companySettings.last_supplier_payment_account)
+ }
if (!open) setPayTab('new')
}}
>
@@ -938,11 +938,15 @@ export default function SupplierInvoiceDetailPage() {
Betalkonto
-
+ {areAccountsLoading ? (
+
+ ) : (
+
+ )}
T.ex. 1930 bankkonto, 1940 övrigt bankkonto, 2018 egna uttag (EF), 2893 ägarlån (AB).
diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx
index 85d0e100..6625e288 100644
--- a/app/(dashboard)/supplier-invoices/page.tsx
+++ b/app/(dashboard)/supplier-invoices/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
+import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Skeleton } from "@/components/ui/skeleton"
@@ -13,7 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Plus, FileInput, Lock } from 'lucide-react'
import Link from 'next/link'
import { PageHeader } from '@/components/ui/page-header'
-import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
+import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -21,6 +22,11 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { SupplierInvoice } from '@/types'
+const NewSupplierInvoiceDialog = dynamic(
+ () => import('@/components/supplier-invoices/NewSupplierInvoiceDialog'),
+ { loading: DialogLoadingSkeleton },
+)
+
const STATUS_VARIANTS: Record
= {
registered: 'secondary',
approved: 'default',
@@ -267,14 +273,16 @@ export default function SupplierInvoicesPage() {
- {
- if (!open) closeNewInvoice()
- }}
- inboxItemId={inboxItemId}
- onCreated={handleCreated}
- />
+ {showNewInvoice && (
+ {
+ if (!open) closeNewInvoice()
+ }}
+ inboxItemId={inboxItemId}
+ onCreated={handleCreated}
+ />
+ )}
)
}
diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx
index d17df4b4..95f20d9c 100644
--- a/app/(dashboard)/suppliers/page.tsx
+++ b/app/(dashboard)/suppliers/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
+import dynamic from 'next/dynamic'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
@@ -13,17 +14,30 @@ import { PageHeader } from '@/components/ui/page-header'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Building2, Lock } from 'lucide-react'
-import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
+const SupplierForm = dynamic(
+ () => import('@/components/suppliers/SupplierForm'),
+ {
+ loading: () => (
+
+
+
+
+
+ ),
+ },
+)
+
const SUPPLIER_TYPE_KEYS: Record = {
swedish_business: 'type_swedish_business',
eu_business: 'type_eu_business',
non_eu_business: 'type_non_eu_business',
}
+const INITIAL_VISIBLE_ROWS = 100
function getPaymentInfo(supplier: Supplier, t: (key: string) => string): { label: string; value: string } | null {
if (supplier.bankgiro) return { label: t('label_bg'), value: supplier.bankgiro }
@@ -46,10 +60,12 @@ export default function SuppliersPage() {
const [suppliers, setSuppliers] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
+ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
+ const tCommon = useTranslations('common')
async function fetchSuppliers() {
if (!company) return
@@ -111,6 +127,7 @@ export default function SuppliersPage() {
s.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.org_number?.includes(searchTerm)
)
+ const visibleSuppliers = filteredSuppliers.slice(0, visibleCount)
return (
@@ -159,7 +176,10 @@ export default function SuppliersPage() {
setSearchTerm(e.target.value)}
+ onChange={(e) => {
+ setSearchTerm(e.target.value)
+ setVisibleCount(INITIAL_VISIBLE_ROWS)
+ }}
className="pl-10"
/>
@@ -200,8 +220,9 @@ export default function SuppliersPage() {
) : (
-
- {filteredSuppliers.map((supplier) => {
+ <>
+
+ {visibleSuppliers.map((supplier) => {
const payment = getPaymentInfo(supplier, t)
const location = formatLocation(supplier)
return (
@@ -251,7 +272,19 @@ export default function SuppliersPage() {
)
})}
-
+
+ {visibleCount < filteredSuppliers.length && (
+
+ setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
+ >
+ {tCommon('load_more')}
+
+
+ )}
+ >
)}
)
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index e532887f..e11503c5 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import type { SupabaseClient } from '@supabase/supabase-js'
+import dynamic from 'next/dynamic'
import Link from 'next/link'
import { AnimatePresence } from 'framer-motion'
import { useSearchParams } from 'next/navigation'
@@ -23,8 +24,6 @@ import {
DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu'
import { ChevronDown, EyeOff, Layers, Search, ShieldAlert, Trash2, X } from 'lucide-react'
-import TransactionForm from '@/components/transactions/TransactionForm'
-import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
@@ -33,21 +32,9 @@ import TransactionInboxCard from '@/components/transactions/TransactionInboxCard
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
import SkattekontoInboxCard from '@/components/transactions/SkattekontoInboxCard'
-import { SkattekontoMatchDialog } from '@/components/skattekonto/SkattekontoMatchDialog'
-import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
-import { MatchVoucherDialog } from '@/components/transactions/MatchVoucherDialog'
-import InvoicePicker from '@/components/transactions/InvoicePicker'
-import SupplierInvoicePicker from '@/components/transactions/SupplierInvoicePicker'
-import MatchAllocationDialog from '@/components/transactions/MatchAllocationDialog'
-import BulkBookDialog from '@/components/transactions/BulkBookDialog'
-import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
-import TransactionAttachDocumentDialog from '@/components/transactions/TransactionAttachDocumentDialog'
-import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
-import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog'
-import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
-import TemplatePicker from '@/components/transactions/TemplatePicker'
+import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
@@ -67,6 +54,47 @@ import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status'
+function InlineDialogContentLoading() {
+ return (
+
+
+
+
+
+ )
+}
+
+const TransactionForm = dynamic(() => import('@/components/transactions/TransactionForm'), { loading: InlineDialogContentLoading })
+const BatchCategorySelector = dynamic(() => import('@/components/transactions/BatchCategorySelector'), { loading: DialogLoadingSkeleton })
+const InvoiceMatchDialog = dynamic(() => import('@/components/transactions/InvoiceMatchDialog'), { loading: DialogLoadingSkeleton })
+const MatchVoucherDialog = dynamic(
+ () => import('@/components/transactions/MatchVoucherDialog').then((module) => module.MatchVoucherDialog),
+ { loading: DialogLoadingSkeleton },
+)
+const InvoicePicker = dynamic(() => import('@/components/transactions/InvoicePicker'), { loading: InlineDialogContentLoading })
+const SupplierInvoicePicker = dynamic(() => import('@/components/transactions/SupplierInvoicePicker'), { loading: InlineDialogContentLoading })
+const MatchAllocationDialog = dynamic(() => import('@/components/transactions/MatchAllocationDialog'), { loading: DialogLoadingSkeleton })
+const BulkBookDialog = dynamic(() => import('@/components/transactions/BulkBookDialog'), { loading: DialogLoadingSkeleton })
+const TransactionBookingDialog = dynamic(() => import('@/components/transactions/TransactionBookingDialog'), { loading: DialogLoadingSkeleton })
+const TransactionAttachDocumentDialog = dynamic(
+ () => import('@/components/transactions/TransactionAttachDocumentDialog'),
+ { loading: DialogLoadingSkeleton },
+)
+const QuickReviewDialog = dynamic(() => import('@/components/transactions/QuickReviewDialog'), { loading: DialogLoadingSkeleton })
+const EditTransactionTitleDialog = dynamic(
+ () => import('@/components/transactions/EditTransactionTitleDialog'),
+ { loading: DialogLoadingSkeleton },
+)
+const SkattekontoMatchDialog = dynamic(
+ () => import('@/components/skattekonto/SkattekontoMatchDialog').then((module) => module.SkattekontoMatchDialog),
+ { loading: DialogLoadingSkeleton },
+)
+const DuplicateBookingDialog = dynamic(
+ () => import('@/components/transactions/DuplicateBookingDialog'),
+ { loading: DialogLoadingSkeleton },
+)
+const TemplatePicker = dynamic(() => import('@/components/transactions/TemplatePicker'), { loading: InlineDialogContentLoading })
+
type InvoiceWithCustomer = Invoice & { customer?: Customer }
type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
@@ -249,8 +277,8 @@ export default function TransactionsPage() {
} | null>(null)
const [duplicateProcessing, setDuplicateProcessing] = useState(false)
- // Entity type for tooltip context
- const [entityType, setEntityType] = useState('enskild_firma')
+ // Dashboard layout already resolved the effective entity type from settings.
+ const entityType = company?.entity_type ?? 'enskild_firma'
// Pagination
const [hasMore, setHasMore] = useState(false)
@@ -294,14 +322,17 @@ export default function TransactionsPage() {
const refreshTransactionsQueuedRef = useRef(false)
// Computed lists
- const uncategorizedTransactions = transactions
- .filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id))
- .sort((a, b) => {
- const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0
- const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0
- if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
- return b.date.localeCompare(a.date)
- })
+ const uncategorizedTransactions = useMemo(
+ () => transactions
+ .filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id))
+ .sort((a, b) => {
+ const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0
+ const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0
+ if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
+ return b.date.localeCompare(a.date)
+ }),
+ [exitingIds, transactions],
+ )
// Merged inbox: bank tx + SKV rows interleaved by date. Source filter
// narrows to one side. SKV rows always go after bank rows on the same
@@ -311,14 +342,24 @@ export default function TransactionsPage() {
| { source: 'bank'; date: string; data: TransactionWithInvoice }
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
- const skvUnmatched = skvRows.filter(r => !r.journal_entry_id)
+ const skvUnmatched = useMemo(
+ () => skvRows.filter((row) => !row.journal_entry_id),
+ [skvRows],
+ )
- const bankToSkvHints = findBankSkvCounterparts({
- bankRows: uncategorizedTransactions.map(t => ({ id: t.id, date: t.date, amount: t.amount })),
- skvRows: skvUnmatched,
- })
+ const bankToSkvHints = useMemo(
+ () => findBankSkvCounterparts({
+ bankRows: uncategorizedTransactions.map((transaction) => ({
+ id: transaction.id,
+ date: transaction.date,
+ amount: transaction.amount,
+ })),
+ skvRows: skvUnmatched,
+ }),
+ [skvUnmatched, uncategorizedTransactions],
+ )
- const inboxItems: InboxItem[] = (() => {
+ const inboxItems = useMemo(() => {
const items: InboxItem[] = []
const query = searchTerm.trim().toLowerCase()
if (sourceFilter !== 'skatteverket') {
@@ -356,11 +397,14 @@ export default function TransactionsPage() {
if (a.source !== b.source) return a.source === 'bank' ? -1 : 1
return 0
})
- })()
- const transactionsWithMatches = transactions.filter(
- (t) =>
- (t.potential_invoice && !t.invoice_id) ||
- (t.potential_supplier_invoice && !t.supplier_invoice_id),
+ }, [exitingIds, searchTerm, skvRows, sourceFilter, uncategorizedTransactions])
+ const transactionsWithMatches = useMemo(
+ () => transactions.filter(
+ (transaction) =>
+ (transaction.potential_invoice && !transaction.invoice_id) ||
+ (transaction.potential_supplier_invoice && !transaction.supplier_invoice_id),
+ ),
+ [transactions],
)
const PAGE_SIZE = 200
@@ -413,6 +457,7 @@ export default function TransactionsPage() {
const fetchTransactions = useCallback(async (showLoading = false, includeSkvRows = false) => {
if (!companyId) return
if (showLoading) setIsLoading(true)
+ if (includeSkvRows) void loadSkvRows()
try {
const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([
supabase
@@ -451,13 +496,6 @@ export default function TransactionsPage() {
setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore(rows.length >= PAGE_SIZE)
- // Fire-and-forget: load SKV rows in parallel with the rest of the
- // page. We don't block on this: if the extension is disabled or the
- // user isn't connected the response is 503/401 and we just leave the
- // SKV section empty.
- if (includeSkvRows) {
- void loadSkvRows()
- }
} finally {
if (showLoading) setIsLoading(false)
}
@@ -608,27 +646,9 @@ export default function TransactionsPage() {
}
}
- // Fetch transactions and entity type in parallel on mount, then suggestions
+ // Fetch the initial page. Entity type is already available from CompanyContext.
useEffect(() => {
- let cancelled = false
-
- async function loadAll() {
- // Fetch transactions and entity type in parallel
- const [, entityRes] = await Promise.all([
- fetchTransactions(true, true),
- fetch('/api/settings').then(r => r.json()).catch(() => null),
- ])
-
- if (cancelled) return
-
- if (entityRes?.data?.entity_type) {
- setEntityType(entityRes.data.entity_type)
- }
- }
-
- loadAll()
-
- return () => { cancelled = true }
+ void fetchTransactions(true, true)
}, [fetchTransactions])
useEffect(() => {
@@ -1647,7 +1667,7 @@ export default function TransactionsPage() {
} catch (err) {
toast({
title: 'Kunde inte bokföra',
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
@@ -2349,68 +2369,82 @@ export default function TransactionsPage() {
)}
{/* Dialogs */}
-
+ {showBatchSelector && (
+
+ )}
-
+ {matchDialogOpen && (
+
+ )}
- { if (!o) setMatchVoucherTx(null) }}
- transaction={matchVoucherTx}
- onLinked={handleVoucherLinked}
- />
+ {matchVoucherTx && (
+ { if (!o) setMatchVoucherTx(null) }}
+ transaction={matchVoucherTx}
+ onLinked={handleVoucherLinked}
+ />
+ )}
- {
- setSplitMatchOpen(o)
- if (!o) setSplitMatchTransaction(null)
- }}
- transaction={splitMatchTransaction}
- onSuccess={handleSplitMatchSuccess}
- />
+ {splitMatchOpen && (
+ {
+ setSplitMatchOpen(o)
+ if (!o) setSplitMatchTransaction(null)
+ }}
+ transaction={splitMatchTransaction}
+ onSuccess={handleSplitMatchSuccess}
+ />
+ )}
-
+ {bulkBookOpen && (
+
+ )}
- {
- setBookingDialogOpen(o)
- if (!o) setBookingDialogTemplate(null)
- }}
- transaction={bookingDialogTransaction}
- preselectedTemplate={bookingDialogTemplate}
- onBooked={handleTransactionBooked}
- />
+ {bookingDialogOpen && (
+ {
+ setBookingDialogOpen(o)
+ if (!o) setBookingDialogTemplate(null)
+ }}
+ transaction={bookingDialogTransaction}
+ preselectedTemplate={bookingDialogTemplate}
+ onBooked={handleTransactionBooked}
+ />
+ )}
- {
- if (!o) setAttachDocTx(null)
- }}
- transaction={attachDocTx}
- onAttached={handleDocumentAttached}
- />
+ {attachDocTx && (
+ {
+ if (!o) setAttachDocTx(null)
+ }}
+ transaction={attachDocTx}
+ onAttached={handleDocumentAttached}
+ />
+ )}
-
+ {templatePickerOpen &&
Bokför transaktion
@@ -2471,10 +2505,10 @@ export default function TransactionsPage() {
onPickLibraryTemplate={handlePickLibraryTemplate}
/>
-
+ }
- {
if (isMatchingFromPicker) return
setInvoicePickerOpen(open)
@@ -2501,10 +2535,10 @@ export default function TransactionsPage() {
>
)}
-
+ }
- {
if (isMatchingSupplierFromPicker) return
setSupplierInvoicePickerOpen(open)
@@ -2531,67 +2565,73 @@ export default function TransactionsPage() {
>
)}
-
+ }
-
+ {quickReviewOpen && (
+
+ )}
-
+ {isDialogOpen &&
{t('dialog_add_transaction')}
-
+ }
- {
- if (!v) setEditTitleTarget(null)
- }}
- currentTitle={editTitleTarget?.description ?? ''}
- originalTitle={editTitleTarget?.original_description ?? null}
- onSave={handleSaveTitle}
- />
+ {editTitleTarget && (
+ {
+ if (!v) setEditTitleTarget(null)
+ }}
+ currentTitle={editTitleTarget.description ?? ''}
+ originalTitle={editTitleTarget.original_description ?? null}
+ onSave={handleSaveTitle}
+ />
+ )}
- setSkvMatchTarget(null)}
- onMatched={handleSkvMatched}
- />
+ {skvMatchTarget && (
+ setSkvMatchTarget(null)}
+ onMatched={handleSkvMatched}
+ />
+ )}
{/* Prong B: match-against-supplier-invoice suggestion */}
- {
if (!open) setSiMatchSuggestion(null)
}}
@@ -2645,11 +2685,11 @@ export default function TransactionsPage() {
-
+ }
{/* Prong B (customer side): match-against-customer-invoice suggestion */}
- {
if (!open) setCiMatchSuggestion(null)
}}
@@ -2708,10 +2748,10 @@ export default function TransactionsPage() {
-
+ }
- setDuplicateWarning(null)}
// Ledger-only candidate (transaction_id null, e.g. a verifikat from an
@@ -2719,11 +2759,9 @@ export default function TransactionsPage() {
// voucher instead of double-booking it. Success refreshes the same
// state a MatchVoucherDialog link does.
matchTransaction={
- duplicateWarning
- ? transactions.find((tx) => tx.id === duplicateWarning.transactionId) ?? {
- id: duplicateWarning.transactionId,
- }
- : null
+ transactions.find((tx) => tx.id === duplicateWarning.transactionId) ?? {
+ id: duplicateWarning.transactionId,
+ }
}
onMatched={(transactionId, journalEntryId, voucherLabel) => {
setDuplicateWarning(null)
@@ -2739,7 +2777,7 @@ export default function TransactionsPage() {
setDuplicateProcessing(false)
}
}}
- />
+ />}
)
diff --git a/app/(onboarding)/loading.tsx b/app/(onboarding)/loading.tsx
new file mode 100644
index 00000000..0d25c41b
--- /dev/null
+++ b/app/(onboarding)/loading.tsx
@@ -0,0 +1,18 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function OnboardingLoading() {
+ return (
+
+ )
+}
diff --git a/app/api/account/password/__tests__/route.test.ts b/app/api/account/password/__tests__/route.test.ts
index aa985069..5ed3f9a5 100644
--- a/app/api/account/password/__tests__/route.test.ts
+++ b/app/api/account/password/__tests__/route.test.ts
@@ -194,7 +194,7 @@ describe('POST /api/account/password', () => {
)
expect(status).toBe(400)
- expect(body.error).toContain('Password too weak')
+ expect(body.error).toBe('Något gick fel. Försök igen.')
expect(updateUser).not.toHaveBeenCalled()
expect(flagFlipCall(updateUserById)).toBeUndefined()
})
@@ -273,7 +273,7 @@ describe('POST /api/account/password', () => {
)
expect(status).toBe(400)
- expect(body.error).toContain('Password too similar')
+ expect(body.error).toBe('Något gick fel. Försök igen.')
expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
expect(flagFlipCall(updateUserById)).toBeUndefined()
})
diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts
index 62a3d599..302220e0 100644
--- a/app/api/account/password/route.ts
+++ b/app/api/account/password/route.ts
@@ -4,6 +4,7 @@ import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
import { validateBody } from '@/lib/api/validate'
import { createLogger } from '@/lib/logger'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const log = createLogger('api/account/password')
@@ -85,7 +86,7 @@ export async function POST(request: Request) {
return NextResponse.json(
{
error:
- updateError.message ||
+ getUserErrorMessage(updateError) ||
'Kunde inte uppdatera lösenord. Försök igen.',
},
{ status: 400 },
diff --git a/app/api/agent/invoke/route.ts b/app/api/agent/invoke/route.ts
index b85300a1..a590aa04 100644
--- a/app/api/agent/invoke/route.ts
+++ b/app/api/agent/invoke/route.ts
@@ -10,6 +10,7 @@ import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Make sure extensions are loaded: the chat loop dispatches against the
// agent tool registry which is populated by the mcp-server extension at load.
@@ -87,7 +88,7 @@ export async function POST(request: Request) {
body = BodySchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Invalid body' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 },
)
}
@@ -164,7 +165,7 @@ export async function POST(request: Request) {
.single()
if (convErr || !newConv) {
return NextResponse.json(
- { error: convErr?.message ?? 'Failed to create conversation' },
+ { error: getUserErrorMessage(convErr) ?? 'Failed to create conversation' },
{ status: 500 },
)
}
@@ -202,7 +203,7 @@ export async function POST(request: Request) {
{
error:
err instanceof Error
- ? `Capture failed: ${err.message}`
+ ? `Capture failed: ${getUserErrorMessage(err)}`
: 'Capture failed',
},
{ status: 500 },
diff --git a/app/api/agent/onboarding/stream/route.ts b/app/api/agent/onboarding/stream/route.ts
index 943115bb..b74d04b3 100644
--- a/app/api/agent/onboarding/stream/route.ts
+++ b/app/api/agent/onboarding/stream/route.ts
@@ -15,6 +15,7 @@ import { preWarmAtomCache } from '@/lib/agent/composer/prewarm'
import { OPUS_MODEL } from '@/lib/agent/composer/client'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
import type { AtomSelection } from '@/lib/agent/composer/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
@@ -82,7 +83,7 @@ export async function POST(request: Request) {
body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Invalid body' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 },
)
}
@@ -229,7 +230,7 @@ export async function POST(request: Request) {
{ onConflict: 'company_id' },
)
if (upsertErr) {
- send({ step: 'error', status: 'error', message: upsertErr.message })
+ send({ step: 'error', status: 'error', message: getUserErrorMessage(upsertErr) })
return
}
@@ -273,7 +274,7 @@ export async function POST(request: Request) {
send({
step: 'error',
status: 'error',
- message: err instanceof Error ? err.message : 'Composer pipeline failed',
+ message: err instanceof Error ? getUserErrorMessage(err) : 'Composer pipeline failed',
})
} finally {
try {
diff --git a/app/api/articles/[id]/route.ts b/app/api/articles/[id]/route.ts
index 98a87095..895026fc 100644
--- a/app/api/articles/[id]/route.ts
+++ b/app/api/articles/[id]/route.ts
@@ -8,6 +8,7 @@ import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Article } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -32,7 +33,7 @@ export const GET = withRouteContext(
opLog.error('article fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
@@ -97,7 +98,7 @@ export const PATCH = withRouteContext(
opLog.error('article update failed', error)
return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
@@ -135,7 +136,7 @@ export const DELETE = withRouteContext(
opLog.error('article lookup before delete failed', articleError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
- details: { reason: articleError.message },
+ details: { reason: getUserErrorMessage(articleError) },
})
}
@@ -149,7 +150,7 @@ export const DELETE = withRouteContext(
opLog.error('article usage check failed', usageError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
- details: { reason: usageError.message },
+ details: { reason: getUserErrorMessage(usageError) },
})
}
@@ -170,7 +171,7 @@ export const DELETE = withRouteContext(
opLog.error('article delete failed', deleteError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
- details: { reason: deleteError.message },
+ details: { reason: getUserErrorMessage(deleteError) },
})
}
diff --git a/app/api/articles/route.ts b/app/api/articles/route.ts
index 0c423358..63575ddb 100644
--- a/app/api/articles/route.ts
+++ b/app/api/articles/route.ts
@@ -10,6 +10,7 @@ import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookk
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { Article } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -99,7 +100,7 @@ export const POST = withRouteContext(
log.error('article insert failed', error)
return errorResponseFromCode('ARTICLE_CREATE_FAILED', log, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/bookkeeping/account-balances/__tests__/route.test.ts b/app/api/bookkeeping/account-balances/__tests__/route.test.ts
new file mode 100644
index 00000000..7598e050
--- /dev/null
+++ b/app/api/bookkeeping/account-balances/__tests__/route.test.ts
@@ -0,0 +1,106 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
+
+vi.mock('@/lib/reports/opening-balances', () => ({
+ getOpeningBalances: vi.fn(),
+}))
+
+const requireAuthMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+import { getOpeningBalances } from '@/lib/reports/opening-balances'
+import { GET } from '../route'
+
+function request(searchParams: Record) {
+ return createMockRequest('/api/bookkeeping/account-balances', { searchParams })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(getOpeningBalances).mockResolvedValue({
+ balances: new Map(),
+ obEntryId: null,
+ })
+})
+
+describe('GET /api/bookkeeping/account-balances', () => {
+ it('returns 401 when not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+
+ expect((await GET(request({ accounts: '1930', as_of: '2026-06-30' }), {
+ params: Promise.resolve({}),
+ })).status).toBe(401)
+ })
+
+ it('returns 400 for an invalid account number', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
+
+ expect((await GET(request({ accounts: 'not-an-account', as_of: '2026-06-30' }), {
+ params: Promise.resolve({}),
+ })).status).toBe(400)
+ })
+
+ it('returns zero balances when no fiscal period contains the date', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: null })
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
+
+ const { status, body } = await parseJsonResponse<{
+ data: Array<{ account_number: string; balance: number }>
+ }>(await GET(request({ accounts: '1930', as_of: '2026-06-30' }), {
+ params: Promise.resolve({}),
+ }))
+
+ expect(status).toBe(200)
+ expect(body.data).toEqual([{ account_number: '1930', balance: 0 }])
+ })
+
+ it('combines opening balances with aggregated period activity', async () => {
+ const { supabase, enqueueMany } = createQueuedMockSupabase()
+ enqueueMany([
+ {
+ data: {
+ id: 'period-1',
+ period_start: '2026-01-01',
+ period_end: '2026-12-31',
+ opening_balance_entry_id: 'opening-1',
+ },
+ },
+ { data: [{ account_number: '1930', account_class: 1 }] },
+ { data: [{ account_number: '1930', debit: 250, credit: 50 }] },
+ ])
+ vi.mocked(getOpeningBalances).mockResolvedValue({
+ balances: new Map([['1930', { debit: 1_000, credit: 0 }]]),
+ obEntryId: 'opening-1',
+ })
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
+
+ const { status, body } = await parseJsonResponse<{
+ data: Array<{ account_number: string; balance: number }>
+ }>(await GET(request({ accounts: '1930', as_of: '2026-06-30' }), {
+ params: Promise.resolve({}),
+ }))
+
+ expect(status).toBe(200)
+ expect(body.data).toEqual([{ account_number: '1930', balance: 1_200 }])
+ expect(supabase.rpc).toHaveBeenCalledWith('get_account_period_activity', {
+ p_company_id: 'company-1',
+ p_start: '2026-01-01',
+ p_end: '2026-06-30',
+ p_accounts: ['1930'],
+ p_exclude_journal_entry_id: 'opening-1',
+ })
+ })
+})
diff --git a/app/api/bookkeeping/account-balances/route.ts b/app/api/bookkeeping/account-balances/route.ts
index fba4bfd1..aad307d5 100644
--- a/app/api/bookkeeping/account-balances/route.ts
+++ b/app/api/bookkeeping/account-balances/route.ts
@@ -3,7 +3,6 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { AccountBalancesQuerySchema } from '@/lib/api/schemas'
import { getOpeningBalances } from '@/lib/reports/opening-balances'
-import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Per-account saldo as of a date. Used by the journal-entry form to show
@@ -90,46 +89,32 @@ export const GET = withRouteContext('bookkeeping.account_balances', async (reque
// Sum activity from period_start through as_of, excluding the OB entry
// (its lines are already in openingBalances).
- let lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
- try {
- lines = await fetchAllRows<{
- account_number: string
- debit_amount: number
- credit_amount: number
- }>(({ from, to }) => {
- let query = supabase
- .from('journal_entry_lines')
- .select(
- 'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)'
- )
- .eq('journal_entries.company_id', companyId)
- .in('account_number', accounts)
- .in('journal_entries.status', ['posted', 'reversed'])
- .gte('journal_entries.entry_date', period.period_start)
- .lte('journal_entries.entry_date', as_of)
+ const { data: activityRows, error: activityError } = await supabase.rpc(
+ 'get_account_period_activity',
+ {
+ p_company_id: companyId,
+ p_start: period.period_start,
+ p_end: as_of,
+ p_accounts: accounts,
+ p_exclude_journal_entry_id: obEntryId,
+ },
+ )
- if (obEntryId) {
- query = query.neq('journal_entry_id', obEntryId)
- }
-
- // Stable total order for correct paging (see fetch-all.ts).
- return query.order('id', { ascending: true }).range(from, to)
- })
- } catch (err) {
+ if (activityError) {
log.error('period activity lookup failed', {
companyId,
period_id: period.id,
- error: err instanceof Error ? err.message : String(err),
+ error: activityError.message,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
const periodActivity = new Map()
- for (const line of lines) {
- const existing = periodActivity.get(line.account_number) || { debit: 0, credit: 0 }
- existing.debit += Number(line.debit_amount) || 0
- existing.credit += Number(line.credit_amount) || 0
- periodActivity.set(line.account_number, existing)
+ for (const row of activityRows ?? []) {
+ periodActivity.set(row.account_number, {
+ debit: Number(row.debit) || 0,
+ credit: Number(row.credit) || 0,
+ })
}
return NextResponse.json({
diff --git a/app/api/bookkeeping/accounts/[number]/route.ts b/app/api/bookkeeping/accounts/[number]/route.ts
index d6b38446..58266eb7 100644
--- a/app/api/bookkeeping/accounts/[number]/route.ts
+++ b/app/api/bookkeeping/accounts/[number]/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// DELETE hard-deletes an unused, non-system account; accounts referenced by
// this company's journal entries must be deactivated instead (PUT is_active).
@@ -57,7 +58,7 @@ export const DELETE = withRouteContext(
.eq('company_id', companyId)
if (deleteError) {
- return NextResponse.json({ error: deleteError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 })
}
return NextResponse.json({ success: true })
@@ -95,7 +96,7 @@ export const PUT = withRouteContext(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
diff --git a/app/api/bookkeeping/accounts/activate/route.ts b/app/api/bookkeeping/accounts/activate/route.ts
index 98fe494e..fc2811ef 100644
--- a/app/api/bookkeeping/accounts/activate/route.ts
+++ b/app/api/bookkeeping/accounts/activate/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/bookkeeping/accounts/activate
@@ -41,7 +42,7 @@ export const POST = withRouteContext(
.in('account_number', uniqueNumbers)
if (fetchError) {
- return NextResponse.json({ error: fetchError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
const existingByNumber = new Map(
@@ -79,7 +80,7 @@ export const POST = withRouteContext(
.in('account_number', toReactivate)
.select('account_number')
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
reactivatedRows = data || []
}
@@ -91,7 +92,7 @@ export const POST = withRouteContext(
.insert(toInsert)
.select('account_number')
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
insertedRows = data || []
}
diff --git a/app/api/bookkeeping/accounts/prune/route.ts b/app/api/bookkeeping/accounts/prune/route.ts
index fd913589..a20aab01 100644
--- a/app/api/bookkeeping/accounts/prune/route.ts
+++ b/app/api/bookkeeping/accounts/prune/route.ts
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { PruneAccountsSchema } from '@/lib/api/schemas'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// POST /api/bookkeeping/accounts/prune — bulk cleanup of unused accounts
// ("Rensa oanvända konton"), for charts bloated by an import from an old
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
{ p_company_id: companyId },
)
if (usageError) {
- return NextResponse.json({ error: usageError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(usageError) }, { status: 500 })
}
const usageByAccount = new Map(
@@ -134,7 +135,7 @@ export const POST = withRouteContext(
// Report what was already deleted so the UI can refresh honestly.
return NextResponse.json(
{
- error: deleteError.message,
+ error: getUserErrorMessage(deleteError),
data: { deleted: toDelete.slice(0, i), skipped, not_found: notFound },
},
{ status: 500 },
@@ -153,7 +154,7 @@ export const POST = withRouteContext(
})
} catch (error) {
return NextResponse.json(
- { error: error instanceof Error ? error.message : 'Failed to prune accounts' },
+ { error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to prune accounts' },
{ status: 500 },
)
}
diff --git a/app/api/bookkeeping/accounts/reference/route.ts b/app/api/bookkeeping/accounts/reference/route.ts
index c5b138af..d4198af4 100644
--- a/app/api/bookkeeping/accounts/reference/route.ts
+++ b/app/api/bookkeeping/accounts/reference/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/bookkeeping/accounts/reference
@@ -33,6 +34,6 @@ export const GET = withRouteContext('bookkeeping.accounts.reference', async (_re
return NextResponse.json({ data: userAccounts })
} catch (error) {
- return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
+ return NextResponse.json({ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to fetch accounts' }, { status: 500 })
}
})
diff --git a/app/api/bookkeeping/accounts/route.ts b/app/api/bookkeeping/accounts/route.ts
index d51671bb..9eca7c02 100644
--- a/app/api/bookkeeping/accounts/route.ts
+++ b/app/api/bookkeeping/accounts/route.ts
@@ -4,6 +4,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ data }` / `{ error: string }` — several pages
// (import, supplier-invoices, article form) consume the list directly.
@@ -46,7 +47,7 @@ export const GET = withRouteContext('bookkeeping.accounts.list', async (request,
return NextResponse.json({ data })
} catch (error) {
return NextResponse.json(
- { error: error instanceof Error ? error.message : 'Failed to fetch accounts' },
+ { error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to fetch accounts' },
{ status: 500 },
)
}
@@ -93,7 +94,7 @@ export const POST = withRouteContext(
{ status: 409 },
)
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
diff --git a/app/api/bookkeeping/accounts/usage/route.ts b/app/api/bookkeeping/accounts/usage/route.ts
index b9144ff7..6ecdba10 100644
--- a/app/api/bookkeeping/accounts/usage/route.ts
+++ b/app/api/bookkeeping/accounts/usage/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// GET /api/bookkeeping/accounts/usage — per-account posting counts for the
// active company, from the get_account_usage_counts RPC. Accounts that have
@@ -17,7 +18,7 @@ export const GET = withRouteContext('bookkeeping.accounts.usage', async (_reques
})
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: data ?? [] })
diff --git a/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts b/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts
index 01056ae9..d367bef6 100644
--- a/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts
+++ b/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts
@@ -106,6 +106,6 @@ describe('POST /api/bookkeeping/accruals/[id]/dissolve', () => {
expect(status).toBe(400)
expect(body.error.code).toBe('ACCRUAL_DISSOLVE_FAILED')
- expect(body.error.details.reason).toMatch(/Ingen öppen räkenskapsperiod/)
+ expect(body.error.details.reason).toBe('Något gick fel. Försök igen.')
})
})
diff --git a/app/api/bookkeeping/accruals/[id]/dissolve/route.ts b/app/api/bookkeeping/accruals/[id]/dissolve/route.ts
index d3d8be95..4b8d4d3e 100644
--- a/app/api/bookkeeping/accruals/[id]/dissolve/route.ts
+++ b/app/api/bookkeeping/accruals/[id]/dissolve/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { dissolveScheduleNow } from '@/lib/bookkeeping/accruals/service'
import {
@@ -39,7 +40,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
return NextResponse.json({ data: result })
} catch (err) {
- const reason = err instanceof Error ? err.message : 'unknown'
// Typed domain errors carry a stable code: never match Swedish prose.
if (isAccrualError(err)) {
switch (err.code) {
@@ -57,7 +57,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
log.error('accrual dissolve failed', err as Error, { entityId: id })
return errorResponseFromCode('ACCRUAL_DISSOLVE_FAILED', log, {
requestId,
- details: { reason },
+ details: { reason: getErrorMessage(err) },
})
}
},
diff --git a/app/api/bookkeeping/accruals/post-due/cron/route.ts b/app/api/bookkeeping/accruals/post-due/cron/route.ts
index 37a04fce..bc7c74da 100644
--- a/app/api/bookkeeping/accruals/post-due/cron/route.ts
+++ b/app/api/bookkeeping/accruals/post-due/cron/route.ts
@@ -5,6 +5,7 @@ import { createServiceClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { postDueInstallments } from '@/lib/bookkeeping/accruals/service'
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -42,7 +43,7 @@ export const GET = withCronContext('cron.accrual_postings', async (_request, ctx
} catch (error) {
ctx.log.error('failed to load due accrual installments', error as Error)
return NextResponse.json(
- { success: false, error: error instanceof Error ? error.message : 'unknown' },
+ { success: false, error: error instanceof Error ? getUserErrorMessage(error) : 'unknown' },
{ status: 500 },
)
}
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/compliance/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/compliance/__tests__/route.test.ts
new file mode 100644
index 00000000..9b09bc75
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/compliance/__tests__/route.test.ts
@@ -0,0 +1,144 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
+
+const requireAuthMock = vi.fn()
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+vi.mock('@/lib/bokslut/arsredovisning/model', () => ({
+ buildCanonicalAnnualReport: vi.fn(),
+}))
+vi.mock('@/lib/bokslut/arsredovisning/profile-service', () => ({
+ upsertAnnualReportProfile: vi.fn(),
+}))
+
+import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
+import { upsertAnnualReportProfile } from '@/lib/bokslut/arsredovisning/profile-service'
+import { GET, PATCH } from '../route'
+
+const params = { params: Promise.resolve({ id: 'period-1' }) }
+const model = {
+ report: {
+ accounting_framework: 'k2',
+ forvaltningsberattelse: {
+ proposed_dividend: 0,
+ resultatdisposition_amounts: { total: 100 },
+ },
+ },
+ profile: { company_id: 'company-1', fiscal_period_id: 'period-1' },
+ disclosures: {},
+ eligibility: {
+ k2_eligible: true,
+ digital_filing_eligible: true,
+ size_classification: 'smaller',
+ k2_relief_rule: 'eligible',
+ issues: [],
+ digital_issues: [],
+ },
+ validation: { stage: 'draft', ok: true, error_count: 0, warning_count: 0, issues: [] },
+}
+
+function setup() {
+ const mock = createQueuedMockSupabase()
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase: mock.supabase,
+ error: null,
+ })
+ vi.mocked(buildCanonicalAnnualReport).mockResolvedValue(model as never)
+ vi.mocked(upsertAnnualReportProfile).mockResolvedValue(model.profile as never)
+ return mock
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ requireWriteMock.mockResolvedValue({ ok: true })
+})
+
+describe('annual report compliance route', () => {
+ it('returns 401 without authentication', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ expect((await GET(createMockRequest('/x'), params)).status).toBe(401)
+ })
+
+ it('returns 400 for an empty patch', async () => {
+ setup()
+ const response = await PATCH(
+ createMockRequest('/x', { method: 'PATCH', body: {} }),
+ params,
+ )
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 404 for another company period', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: null })
+ expect((await GET(createMockRequest('/x'), params)).status).toBe(404)
+ })
+
+ it('returns the canonical compliance result', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'period-1' } })
+ const { status, body } = await parseJsonResponse<{ data: typeof model }>(
+ await GET(createMockRequest('/x'), params),
+ )
+ expect(status).toBe(200)
+ expect(body.data.validation.ok).toBe(true)
+ })
+
+ it('persists legal facts and confirmation timestamps', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'period-1' } })
+ const response = await PATCH(
+ createMockRequest('/x', {
+ method: 'PATCH',
+ body: { is_public_limited_company: false, k2_assessment_confirmed: true },
+ }),
+ params,
+ )
+ expect(response.status).toBe(200)
+ expect(upsertAnnualReportProfile).toHaveBeenCalledWith(
+ expect.anything(),
+ 'company-1',
+ 'user-1',
+ 'period-1',
+ expect.objectContaining({
+ is_public_limited_company: false,
+ k2_assessment_confirmed_at: expect.any(String),
+ }),
+ )
+ })
+
+ it('records explicit representative roster confirmation', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'period-1' } })
+ const response = await PATCH(
+ createMockRequest('/x', {
+ method: 'PATCH',
+ body: { signer_roster_confirmed: true },
+ }),
+ params,
+ )
+ expect(response.status).toBe(200)
+ expect(upsertAnnualReportProfile).toHaveBeenCalledWith(
+ expect.anything(),
+ 'company-1',
+ 'user-1',
+ 'period-1',
+ expect.objectContaining({ signer_roster_confirmed_at: expect.any(String) }),
+ )
+ })
+})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/compliance/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/compliance/route.ts
new file mode 100644
index 00000000..e3f7a672
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/compliance/route.ts
@@ -0,0 +1,127 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { validateBody } from '@/lib/api/validate'
+import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
+import { getAnnualReportCapabilities } from '@/lib/bokslut/arsredovisning/capabilities'
+import { upsertAnnualReportProfile } from '@/lib/bokslut/arsredovisning/profile-service'
+
+const NullableBoolean = z.boolean().nullable()
+
+const PatchSchema = z
+ .object({
+ is_public_limited_company: NullableBoolean.optional(),
+ is_in_liquidation: NullableBoolean.optional(),
+ securities_traded_on_regulated_market: NullableBoolean.optional(),
+ is_parent_company: NullableBoolean.optional(),
+ parent_group_size: z.enum(['none', 'small', 'large']).nullable().optional(),
+ prepares_consolidated_accounts: NullableBoolean.optional(),
+ has_foreign_branch: NullableBoolean.optional(),
+ has_crypto_assets: NullableBoolean.optional(),
+ has_share_based_payments: NullableBoolean.optional(),
+ has_convertible_debt: NullableBoolean.optional(),
+ building_revenue_share_pct: z.number().min(0).max(100).nullable().optional(),
+ has_material_deferred_tax: NullableBoolean.optional(),
+ reporting_currency: z.enum(['SEK', 'EUR']).optional(),
+ auditor_report_required: NullableBoolean.optional(),
+ auditor_report_included: z.boolean().optional(),
+ dividend_prudence_confirmed: NullableBoolean.optional(),
+ narrative_confirmed: z.boolean().optional(),
+ k2_assessment_confirmed: z.boolean().optional(),
+ signer_roster_confirmed: z.boolean().optional(),
+ })
+ .strict()
+ .refine((value) => Object.keys(value).length > 0, { message: 'At least one field is required' })
+
+async function periodExists(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+): Promise {
+ const { data } = await supabase
+ .from('fiscal_periods')
+ .select('id')
+ .eq('id', fiscalPeriodId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+ return Boolean(data)
+}
+
+function responseData(model: Awaited>) {
+ return {
+ profile: model.profile,
+ disclosures: model.disclosures,
+ eligibility: model.eligibility,
+ validation: model.validation,
+ capabilities: getAnnualReportCapabilities(model.report.accounting_framework, model.eligibility),
+ report_summary: {
+ proposed_dividend: model.report.forvaltningsberattelse.proposed_dividend,
+ distributable_equity:
+ model.report.forvaltningsberattelse.resultatdisposition_amounts.total,
+ },
+ }
+}
+
+export const GET = withRouteContext(
+ 'period.arsredovisning_compliance_get',
+ async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { supabase, companyId, log, requestId } = ctx
+ try {
+ if (!(await periodExists(supabase, companyId, id))) {
+ return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
+ }
+ const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
+ stage: 'draft',
+ includeIxbrl: false,
+ })
+ return NextResponse.json({ data: responseData(model) })
+ } catch (err) {
+ return errorResponse(err, log, { requestId })
+ }
+ },
+)
+
+export const PATCH = withRouteContext(
+ 'period.arsredovisning_compliance_patch',
+ async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { user, supabase, companyId, log, requestId } = ctx
+ const validation = await validateBody(request, PatchSchema)
+ if (!validation.success) return validation.response
+ try {
+ if (!(await periodExists(supabase, companyId, id))) {
+ return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
+ }
+ const {
+ narrative_confirmed,
+ k2_assessment_confirmed,
+ signer_roster_confirmed,
+ ...profileFields
+ } = validation.data
+ const now = new Date().toISOString()
+ await upsertAnnualReportProfile(supabase, companyId, user.id, id, {
+ ...profileFields,
+ ...(narrative_confirmed !== undefined
+ ? { narrative_confirmed_at: narrative_confirmed ? now : null }
+ : {}),
+ ...(k2_assessment_confirmed !== undefined
+ ? { k2_assessment_confirmed_at: k2_assessment_confirmed ? now : null }
+ : {}),
+ ...(signer_roster_confirmed !== undefined
+ ? { signer_roster_confirmed_at: signer_roster_confirmed ? now : null }
+ : {}),
+ })
+ const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
+ stage: 'draft',
+ includeIxbrl: false,
+ })
+ return NextResponse.json({ data: responseData(model) })
+ } catch (err) {
+ return errorResponse(err, log, { requestId })
+ }
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/route.ts
index 80c38b49..b03c1ee4 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/route.ts
@@ -2,14 +2,17 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
+import { getVersionIxbrlInput } from '@/lib/bokslut/arsredovisning/version-ixbrl'
/**
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl
*
* Generates the iXBRL (XHTML) årsredovisning for the period. The document IS
* the presentation (per TILLAMPNINGSANVISNING): the wizard renders it in an
- * iframe as the authoritative preview, and `?download=1` hands the same bytes
- * to the user for manual filing at bolagsverket.se (the self-hosted path).
+ * iframe as the authoritative preview, and `?download=1` exports the same
+ * bytes for validation and the company's archive. The XHTML file is not a
+ * standalone manual filing path: digital filing goes through connected
+ * software and the paper fallback is a certified copy sent by post.
*
* Query params:
* - download=1 → Content-Disposition: attachment
@@ -23,12 +26,19 @@ export const GET = withRouteContext(
try {
const url = new URL(request.url)
const download = url.searchParams.get('download') === '1'
+ const versionId = url.searchParams.get('version')
const utdelningRaw = url.searchParams.get('utdelning')
- const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
+ const proposedDividend = utdelningRaw ? Number(utdelningRaw) : undefined
- const input = await buildIxbrlInput(supabase, companyId, id, {
- proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
- })
+ const input = versionId
+ ? await getVersionIxbrlInput(supabase, companyId, id, versionId)
+ : await buildIxbrlInput(supabase, companyId, id, {
+ proposedDividend:
+ proposedDividend !== undefined && Number.isFinite(proposedDividend)
+ ? proposedDividend
+ : undefined,
+ })
+ if (!input) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const { xhtml, warnings } = generateK2IxbrlDocument(input)
const safePeriodEnd = input.period.end.replace(/[^\w.-]/g, '_')
@@ -43,6 +53,7 @@ export const GET = withRouteContext(
Pragma: 'no-cache',
// Generation warnings surfaced without disturbing the body.
'X-Ixbrl-Warning-Count': String(warnings.length),
+ ...(versionId ? { 'X-Annual-Report-Version': versionId } : {}),
},
})
} catch (err) {
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate/route.ts
index 0eb9a181..75d0eafb 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate/route.ts
@@ -4,6 +4,8 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks, type PreflightIssue } from '@/lib/bokslut/ixbrl/validate/rules'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+import { getVersionIxbrlInput } from '@/lib/bokslut/arsredovisning/version-ixbrl'
/**
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl/validate
@@ -21,12 +23,19 @@ export const GET = withRouteContext(
const { supabase, companyId, log, requestId } = ctx
try {
const url = new URL(request.url)
+ const versionId = url.searchParams.get('version')
const utdelningRaw = url.searchParams.get('utdelning')
- const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
+ const proposedDividend = utdelningRaw ? Number(utdelningRaw) : undefined
- const input = await buildIxbrlInput(supabase, companyId, id, {
- proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
- })
+ const input = versionId
+ ? await getVersionIxbrlInput(supabase, companyId, id, versionId)
+ : await buildIxbrlInput(supabase, companyId, id, {
+ proposedDividend:
+ proposedDividend !== undefined && Number.isFinite(proposedDividend)
+ ? proposedDividend
+ : undefined,
+ })
+ if (!input) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const result = runPreflightChecks(input)
// Generation dry-run: a document that cannot even be generated must
@@ -47,7 +56,7 @@ export const GET = withRouteContext(
issues.push({
code: 'ACC-GEN',
severity: 'error',
- message: `iXBRL-dokumentet kunde inte genereras: ${genErr instanceof Error ? genErr.message : 'okänt fel'}`,
+ message: `iXBRL-dokumentet kunde inte genereras: ${genErr instanceof Error ? getUserErrorMessage(genErr) : 'okänt fel'}`,
})
}
@@ -61,6 +70,7 @@ export const GET = withRouteContext(
generated_bytes: generatedBytes,
entry_point: input.entryPointId,
period: input.period,
+ annual_report_version_id: versionId,
},
})
} catch (err) {
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts
index 4ebb982c..44ac317a 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/__tests__/route.test.ts
@@ -121,6 +121,15 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
expect(res.status).toBe(400)
})
+ it('returns 400 when the payload contains an unknown field', async () => {
+ setupSupabase()
+ const res = await POST(
+ postReq({ description: 'x', annual_report_version_id: 'forged-version' }),
+ idParams,
+ )
+ expect(res.status).toBe(400)
+ })
+
it('returns 404 when the period does not belong to the company', async () => {
const { enqueue } = setupSupabase()
enqueue({ data: null }) // fiscal_periods ownership check
@@ -135,6 +144,7 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check
enqueue({ data: null }) // no registrerad submission
enqueue({ data: narrativeRow }) // upsert
+ enqueue({ data: null }) // clear narrative confirmation
const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>(
await POST(postReq({ description: 'Bolaget bedriver konsultverksamhet.' }), idParams),
)
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
index 69022f65..49d3c63f 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
@@ -30,6 +30,15 @@ const PostSchema = z.object({
description: sanitizedText(4000).nullable().optional(),
important_events: sanitizedText(4000).nullable().optional(),
resultatdisposition: sanitizedText(2000).nullable().optional(),
+ proposed_dividend: z
+ .number()
+ .min(0)
+ .max(1_000_000_000_000)
+ .nullable()
+ .optional()
+ .transform((value) =>
+ value === null || value === undefined ? value : Math.round(value * 100) / 100,
+ ),
// ISO YYYY-MM-DD per the DATE column; null clears it. Validate as a
// real calendar date (not just regex) so '2024-13-99' returns 400 from
// the API instead of bubbling up as a Postgres 500.
@@ -75,6 +84,26 @@ const PostSchema = z.object({
.nullable()
.optional(),
parent_company_city: sanitizedText(100).nullable().optional(),
+ long_term_debt_over_five_years_confirmed: z.boolean().optional(),
+ securities_pledged_confirmed: z.boolean().optional(),
+ contingent_liabilities_confirmed: z.boolean().optional(),
+ parent_company_confirmed: z.boolean().optional(),
+ agm_disposition_outcome: z
+ .enum(['proposal_approved', 'alternative_decision'])
+ .nullable()
+ .optional(),
+ agm_disposition_decision: sanitizedText(2000).nullable().optional(),
+}).strict().superRefine((value, ctx) => {
+ if (
+ value.agm_disposition_outcome === 'alternative_decision' &&
+ !value.agm_disposition_decision?.trim()
+ ) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['agm_disposition_decision'],
+ message: 'Årsstämmans alternativa beslut måste beskrivas.',
+ })
+ }
})
export const GET = withRouteContext(
@@ -150,6 +179,14 @@ export const POST = withRouteContext(
return errorResponseFromCode('ARSREDOVISNING_REGISTERED', log, { requestId })
}
const data = await upsertNarrative(supabase, companyId, user.id, id, validation.data)
+ const { error: confirmationError } = await supabase
+ .from('annual_report_profiles')
+ .update({ narrative_confirmed_at: null })
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', id)
+ if (confirmationError) {
+ throw new Error(`Failed to clear narrative confirmation: ${confirmationError.message}`)
+ }
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts
index a2de02c2..8440c92d 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts
@@ -1,20 +1,53 @@
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
-import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
+import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
+import { getAnnualReportVersion } from '@/lib/bokslut/arsredovisning/version-service'
import { ArsredovisningPDF } from '@/lib/bokslut/arsredovisning/arsredovisning-pdf'
import { ArsredovisningK3PDF } from '@/lib/bokslut/arsredovisning/arsredovisning-k3-pdf'
+import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
export const GET = withRouteContext(
'period.arsredovisning_pdf',
- async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
// Narrative edits come from arsredovisning_narratives now, loaded
// inside buildArsredovisningData. The URL stays clean, no narrative
// text in query params, access logs, or browser history.
- const data = await buildArsredovisningData(supabase, companyId, id)
+ const versionId = new URL(request.url).searchParams.get('version')
+ let versionStatus: string | null = null
+ let data: ArsredovisningData
+ if (versionId) {
+ const version = await getAnnualReportVersion(supabase, companyId, id, versionId)
+ if (!version) {
+ return errorResponseFromCode('NOT_FOUND', log, { requestId })
+ }
+ versionStatus = version.summary.status
+ data = structuredClone(version.report_data)
+ const { data: signatureRows, error: signatureError } = await supabase
+ .from('arsredovisning_signature_requests')
+ .select('role, signer_name, signed_at')
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', id)
+ .eq('annual_report_version_id', versionId)
+ .order('created_at', { ascending: true })
+ if (signatureError) {
+ throw new Error(`Failed to load version signatures: ${signatureError.message}`)
+ }
+ data.signatures = (signatureRows ?? []).map((signature) => ({
+ role: signature.role,
+ name: signature.signer_name,
+ signed_at: signature.signed_at,
+ }))
+ } else {
+ const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
+ stage: 'draft',
+ includeIxbrl: false,
+ })
+ data = model.report
+ }
// Dispatch on the framework recorded in the data. K3 documents need
// the additional kassaflöde + equity-changes pages + richer noter
// that ArsredovisningK3PDF renders. K2 (the default) keeps the
@@ -29,7 +62,10 @@ export const GET = withRouteContext(
// Sanitize the dynamic segment so a stray quote / newline in the date
// (defensive, unlikely to ever happen) can't break the header.
const safePeriodEnd = data.fiscal_period.period_end.replace(/[^\w.-]/g, '_')
- const filename = `arsredovisning-${safePeriodEnd}-utkast.pdf`
+ const suffix = versionStatus && ['signed', 'filed', 'registered'].includes(versionStatus)
+ ? 'papperskopia'
+ : 'utkast'
+ const filename = `arsredovisning-${safePeriodEnd}-${suffix}.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
@@ -38,6 +74,7 @@ export const GET = withRouteContext(
// intermediary cache the document.
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
+ ...(versionId ? { 'X-Annual-Report-Version': versionId } : {}),
},
})
} catch (err) {
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/route.ts
index 5716b04b..2eff3b06 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/route.ts
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
-import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
+import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
+import { getAnnualReportCapabilities } from '@/lib/bokslut/arsredovisning/capabilities'
export const GET = withRouteContext(
'period.arsredovisning_data',
@@ -9,8 +10,23 @@ export const GET = withRouteContext(
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
- const data = await buildArsredovisningData(supabase, companyId, id)
- return NextResponse.json({ data })
+ const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
+ stage: 'draft',
+ includeIxbrl: false,
+ })
+ return NextResponse.json({
+ data: model.report,
+ compliance: {
+ profile: model.profile,
+ disclosures: model.disclosures,
+ eligibility: model.eligibility,
+ validation: model.validation,
+ capabilities: getAnnualReportCapabilities(
+ model.report.accounting_framework,
+ model.eligibility,
+ ),
+ },
+ })
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/__tests__/route.test.ts
new file mode 100644
index 00000000..1fa5f417
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/__tests__/route.test.ts
@@ -0,0 +1,197 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
+
+const requireAuthMock = vi.fn()
+const requireWriteMock = vi.fn()
+const createServiceClientMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+vi.mock('@/lib/supabase/server', () => ({
+ createServiceClient: () => createServiceClientMock(),
+}))
+
+import { DELETE, PATCH } from '../route'
+
+const versionId = '123e4567-e89b-12d3-a456-426614174000'
+const params = {
+ params: Promise.resolve({ id: 'period-1', signatureId: 'signature-1' }),
+}
+const signedBody = {
+ status: 'signed',
+ annual_report_version_id: versionId,
+ signing_method: 'paper_original',
+ evidence_reference: 'archive:A-1',
+ signed_at: '2026-03-01T10:00:00.000Z',
+}
+
+function setup() {
+ const mock = createQueuedMockSupabase()
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase: mock.supabase,
+ error: null,
+ })
+ createServiceClientMock.mockReturnValue(mock.supabase)
+ return mock
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ requireWriteMock.mockResolvedValue({ ok: true })
+})
+
+describe('annual report signature transition', () => {
+ it('returns 401 without authentication', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ expect(
+ (
+ await PATCH(
+ createMockRequest('/x', { method: 'PATCH', body: signedBody }),
+ params,
+ )
+ ).status,
+ ).toBe(401)
+ })
+
+ it('returns 400 when signed evidence is missing', async () => {
+ setup()
+ const response = await PATCH(
+ createMockRequest('/x', { method: 'PATCH', body: { status: 'signed' } }),
+ params,
+ )
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 400 for a free-text evidence reference', async () => {
+ setup()
+ const response = await PATCH(
+ createMockRequest('/x', {
+ method: 'PATCH',
+ body: { ...signedBody, evidence_reference: 'Original i arkiv A-1' },
+ }),
+ params,
+ )
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 409 when the version is not ready for signature', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: null })
+ const response = await PATCH(
+ createMockRequest('/x', { method: 'PATCH', body: signedBody }),
+ params,
+ )
+ expect(response.status).toBe(409)
+ })
+
+ it('stores version-bound signature evidence', async () => {
+ const { enqueue } = setup()
+ enqueue({
+ data: {
+ id: versionId,
+ status: 'ready_for_signature',
+ finalized_at: '2026-03-01T08:00:00.000Z',
+ },
+ })
+ enqueue({ data: { id: 'signature-1', annual_report_version_id: versionId, status: 'pending' } })
+ enqueue({
+ data: {
+ id: 'signature-1',
+ company_id: 'company-1',
+ fiscal_period_id: 'period-1',
+ status: 'signed',
+ signing_method: 'paper_original',
+ evidence_reference: 'archive:A-1',
+ },
+ })
+ const { status, body } = await parseJsonResponse<{
+ data: { status: string; evidence_reference: string }
+ }>(
+ await PATCH(
+ createMockRequest('/x', { method: 'PATCH', body: signedBody }),
+ params,
+ ),
+ )
+ expect(status).toBe(200)
+ expect(body.data.status).toBe('signed')
+ expect(body.data.evidence_reference).toBe('archive:A-1')
+ expect(createServiceClientMock).toHaveBeenCalledOnce()
+ })
+
+ it('rejects a signature date before the version was finalized', async () => {
+ const { enqueue } = setup()
+ enqueue({
+ data: {
+ id: versionId,
+ status: 'ready_for_signature',
+ finalized_at: '2026-03-02T08:00:00.000Z',
+ },
+ })
+ enqueue({ data: { id: 'signature-1', annual_report_version_id: versionId, status: 'pending' } })
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
+ await PATCH(
+ createMockRequest('/x', { method: 'PATCH', body: signedBody }),
+ params,
+ ),
+ )
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('ARSREDOVISNING_SIGNATURE_DATE_INVALID')
+ expect(createServiceClientMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects a future signature date', async () => {
+ const { enqueue } = setup()
+ enqueue({
+ data: {
+ id: versionId,
+ status: 'ready_for_signature',
+ finalized_at: '2026-03-01T08:00:00.000Z',
+ },
+ })
+ enqueue({ data: { id: 'signature-1', annual_report_version_id: versionId, status: 'pending' } })
+ const response = await PATCH(
+ createMockRequest('/x', {
+ method: 'PATCH',
+ body: { ...signedBody, signed_at: '2999-03-01T10:00:00.000Z' },
+ }),
+ params,
+ )
+ expect(response.status).toBe(400)
+ })
+})
+
+describe('annual report draft signer removal', () => {
+ it('returns 401 without authentication', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ expect((await DELETE(createMockRequest('/x', { method: 'DELETE' }), params)).status).toBe(401)
+ })
+
+ it('returns 409 when the signer is already bound to a version', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: null, error: null })
+ expect((await DELETE(createMockRequest('/x', { method: 'DELETE' }), params)).status).toBe(409)
+ })
+
+ it('removes an unbound pending signer', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'signature-1' }, error: null })
+ expect((await DELETE(createMockRequest('/x', { method: 'DELETE' }), params)).status).toBe(204)
+ })
+})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts
index cfd1f812..97d61e87 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts
@@ -1,8 +1,10 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
-import { errorResponse } from '@/lib/errors/get-structured-error'
+import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
+import { markSignatureSigned } from '@/lib/bokslut/arsredovisning/signature-service'
+import { createServiceClient } from '@/lib/supabase/server'
// PATCH transitions: pending → signed (manual entry for the paper / outside-
// BankID flow) or pending → declined. Real BankID wiring lands in a future
@@ -14,9 +16,39 @@ import { validateBody } from '@/lib/api/validate'
// /periods/A/signatures/SIG_FROM_B can't bypass the path scope
// - .eq('status', 'pending'): state-machine guard so a signed or declined
// row can't be flipped back
-const PatchSchema = z.object({
- status: z.enum(['signed', 'declined']),
-})
+const EvidenceReferenceSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .max(128)
+ .regex(/^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$/)
+
+const PatchSchema = z.discriminatedUnion('status', [
+ z.object({
+ status: z.literal('signed'),
+ annual_report_version_id: z.string().uuid(),
+ signing_method: z.enum(['paper_original', 'advanced_e_signature', 'bankid']),
+ evidence_reference: EvidenceReferenceSchema,
+ signed_at: z.string().datetime().optional(),
+ }).strict(),
+ z.object({ status: z.literal('declined') }).strict(),
+])
+
+function stockholmDate(instant: string | Date): string {
+ return new Intl.DateTimeFormat('sv-SE', {
+ timeZone: 'Europe/Stockholm',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).format(typeof instant === 'string' ? new Date(instant) : instant)
+}
+
+function isSignatureDateAllowed(signedAt: string, finalizedAt: string): boolean {
+ const signedDate = stockholmDate(signedAt)
+ const finalizedDate = stockholmDate(finalizedAt)
+ const today = stockholmDate(new Date())
+ return signedDate >= finalizedDate && signedDate <= today
+}
export const PATCH = withRouteContext(
'period.arsredovisning_signature_patch',
@@ -30,15 +62,65 @@ export const PATCH = withRouteContext(
const validation = await validateBody(request, PatchSchema)
if (!validation.success) return validation.response
- const update =
- validation.data.status === 'signed'
- ? { status: 'signed' as const, signed_at: new Date().toISOString() }
- : { status: 'declined' as const }
-
try {
- const { data, error } = await supabase
+ if (validation.data.status === 'signed') {
+ const { data: version, error: versionError } = await supabase
+ .from('annual_report_versions')
+ .select('id, status, finalized_at')
+ .eq('id', validation.data.annual_report_version_id)
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .eq('status', 'ready_for_signature')
+ .maybeSingle()
+ if (versionError) {
+ throw new Error(`Failed to load annual report version: ${versionError.message}`)
+ }
+ if (!version?.finalized_at) {
+ return errorResponseFromCode('ARSREDOVISNING_VERSION_NOT_SIGNABLE', log, {
+ requestId,
+ })
+ }
+ const { data: requestRow, error: requestError } = await supabase
+ .from('arsredovisning_signature_requests')
+ .select('id, annual_report_version_id, status')
+ .eq('id', signatureId)
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .eq('status', 'pending')
+ .maybeSingle()
+ if (requestError) {
+ throw new Error(`Failed to load annual report signature: ${requestError.message}`)
+ }
+ if (
+ !requestRow ||
+ (requestRow.annual_report_version_id !== null &&
+ requestRow.annual_report_version_id !== validation.data.annual_report_version_id)
+ ) {
+ return NextResponse.json(
+ { error: { code: 'SIGNATURE_INVALID_TRANSITION' } },
+ { status: 409 },
+ )
+ }
+ const signedAt = validation.data.signed_at ?? new Date().toISOString()
+ if (!isSignatureDateAllowed(signedAt, version.finalized_at)) {
+ return errorResponseFromCode('ARSREDOVISNING_SIGNATURE_DATE_INVALID', log, {
+ requestId,
+ })
+ }
+ const data = await markSignatureSigned(createServiceClient(), companyId, signatureId, {
+ fiscalPeriodId,
+ annualReportVersionId: validation.data.annual_report_version_id,
+ signingMethod: validation.data.signing_method,
+ evidenceReference: validation.data.evidence_reference,
+ evidenceRecordedBy: ctx.user.id,
+ signedAt,
+ })
+ return NextResponse.json({ data })
+ }
+
+ const { data, error } = await createServiceClient()
.from('arsredovisning_signature_requests')
- .update(update)
+ .update({ status: 'declined' as const })
.eq('id', signatureId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
@@ -65,3 +147,38 @@ export const PATCH = withRouteContext(
},
{ requireWrite: true },
)
+
+export const DELETE = withRouteContext(
+ 'period.arsredovisning_signature_delete',
+ async (
+ _request,
+ ctx,
+ { params }: { params: Promise<{ id: string; signatureId: string }> },
+ ) => {
+ const { id: fiscalPeriodId, signatureId } = await params
+ const { supabase, companyId, log, requestId } = ctx
+ try {
+ const { data, error } = await supabase
+ .from('arsredovisning_signature_requests')
+ .delete()
+ .eq('id', signatureId)
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .eq('status', 'pending')
+ .is('annual_report_version_id', null)
+ .select('id')
+ .maybeSingle()
+ if (error) throw new Error(`Failed to remove annual report signer: ${error.message}`)
+ if (!data) {
+ return NextResponse.json(
+ { error: { code: 'ARSREDOVISNING_SIGNER_ROSTER_LOCKED' } },
+ { status: 409 },
+ )
+ }
+ return new NextResponse(null, { status: 204 })
+ } catch (err) {
+ return errorResponse(err, log, { requestId })
+ }
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/__tests__/route.test.ts
new file mode 100644
index 00000000..d9cc4c95
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/__tests__/route.test.ts
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
+
+const requireAuthMock = vi.fn()
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+
+import { GET, POST } from '../route'
+
+const params = { params: Promise.resolve({ id: 'period-1' }) }
+const validBody = { role: 'Styrelseledamot', signer_name: 'Anna Andersson' }
+
+function setup() {
+ const mock = createQueuedMockSupabase()
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase: mock.supabase,
+ error: null,
+ })
+ return mock
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ requireWriteMock.mockResolvedValue({ ok: true })
+})
+
+describe('annual report signer roster route', () => {
+ it('returns 401 without authentication', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ expect((await GET(createMockRequest('/x'), params)).status).toBe(401)
+ })
+
+ it('returns 400 for an invalid signer', async () => {
+ setup()
+ const response = await POST(
+ createMockRequest('/x', { method: 'POST', body: { role: 'Administrator' } }),
+ params,
+ )
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 404 for another company period', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: null, error: null })
+ const response = await POST(
+ createMockRequest('/x', { method: 'POST', body: validBody }),
+ params,
+ )
+ expect(response.status).toBe(404)
+ })
+
+ it('returns 409 for a duplicate unbound signer', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'period-1' }, error: null })
+ enqueue({ data: { id: 'signature-existing' }, error: null })
+ const response = await POST(
+ createMockRequest('/x', { method: 'POST', body: validBody }),
+ params,
+ )
+ expect(response.status).toBe(409)
+ })
+
+ it('creates a new unbound signer roster slot', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'period-1' }, error: null })
+ enqueue({ data: null, error: null })
+ enqueue({
+ data: {
+ id: 'signature-1',
+ company_id: 'company-1',
+ fiscal_period_id: 'period-1',
+ role: 'Styrelseledamot',
+ signer_name: 'Anna Andersson',
+ status: 'pending',
+ annual_report_version_id: null,
+ },
+ error: null,
+ })
+ const { status, body } = await parseJsonResponse<{
+ data: { id: string; annual_report_version_id: null }
+ }>(
+ await POST(createMockRequest('/x', { method: 'POST', body: validBody }), params),
+ )
+ expect(status).toBe(200)
+ expect(body.data).toMatchObject({ id: 'signature-1', annual_report_version_id: null })
+ })
+})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/route.ts
index 5b74d199..159f6fab 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/route.ts
@@ -14,7 +14,7 @@ import {
// (Art.25.2): Swedish names are well under that; bound is a defense.
const CreateSchema = z.object({
role: z.enum(['Styrelseledamot', 'Styrelseordförande', 'VD', 'Verkställande direktör']),
- signer_name: z.string().min(1).max(200),
+ signer_name: z.string().trim().min(1).max(200),
})
export const GET = withRouteContext(
@@ -43,15 +43,35 @@ export const POST = withRouteContext(
// authenticated company before writing. RLS would catch a cross-tenant
// insert anyway, but rejecting at the route layer gives a cleaner
// 404 + avoids the noisy RLS error in the structured-error envelope.
- const { data: period } = await supabase
+ const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
+ if (periodError) throw new Error(`Failed to load fiscal period: ${periodError.message}`)
if (!period) {
return NextResponse.json({ error: { code: 'PERIOD_NOT_FOUND' } }, { status: 404 })
}
+ const { data: duplicate, error: duplicateError } = await supabase
+ .from('arsredovisning_signature_requests')
+ .select('id')
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', id)
+ .eq('status', 'pending')
+ .is('annual_report_version_id', null)
+ .ilike('role', validation.data.role.trim())
+ .ilike('signer_name', validation.data.signer_name.trim())
+ .maybeSingle()
+ if (duplicateError) {
+ throw new Error(`Failed to check annual report signer roster: ${duplicateError.message}`)
+ }
+ if (duplicate) {
+ return NextResponse.json(
+ { error: { code: 'ARSREDOVISNING_SIGNER_ALREADY_EXISTS' } },
+ { status: 409 },
+ )
+ }
const data = await createSignatureRequest(supabase, companyId, user.id, id, validation.data)
return NextResponse.json({ data })
} catch (err) {
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/__tests__/route.test.ts
new file mode 100644
index 00000000..19c9633e
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/__tests__/route.test.ts
@@ -0,0 +1,164 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
+
+const requireAuthMock = vi.fn()
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+vi.mock('@/lib/supabase/server', () => ({
+ createServiceClient: vi.fn(() => ({ rpc: vi.fn() })),
+}))
+vi.mock('@/lib/bokslut/arsredovisning/model', () => ({
+ buildCanonicalAnnualReport: vi.fn(),
+}))
+vi.mock('@/lib/bokslut/arsredovisning/version-service', () => ({
+ createAnnualReportVersion: vi.fn(),
+ listAnnualReportVersions: vi.fn(),
+}))
+
+import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
+import {
+ createAnnualReportVersion,
+ listAnnualReportVersions,
+} from '@/lib/bokslut/arsredovisning/version-service'
+import { GET, POST } from '../route'
+
+const params = { params: Promise.resolve({ id: 'period-1' }) }
+const version = {
+ id: 'version-1',
+ version_number: 1,
+ status: 'draft',
+ framework: 'k2',
+ content_hash: 'a'.repeat(64),
+ taxonomy_version: '2024-09-12',
+ entry_point: 'k2-ab-risbs-2024-09-12',
+ finalized_at: null,
+ created_at: '2026-07-21T10:00:00Z',
+}
+
+function setup() {
+ const mock = createQueuedMockSupabase()
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase: mock.supabase,
+ error: null,
+ })
+ vi.mocked(listAnnualReportVersions).mockResolvedValue([version] as never)
+ vi.mocked(createAnnualReportVersion).mockResolvedValue(version as never)
+ vi.mocked(buildCanonicalAnnualReport).mockResolvedValue({
+ validation: { ok: true },
+ } as never)
+ return mock
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ requireWriteMock.mockResolvedValue({ ok: true })
+})
+
+describe('annual report versions route', () => {
+ it('returns 401 without authentication', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ expect((await GET(createMockRequest('/x'), params)).status).toBe(401)
+ })
+
+ it('returns 400 for an invalid action', async () => {
+ setup()
+ const response = await POST(
+ createMockRequest('/x', { method: 'POST', body: { action: 'delete' } }),
+ params,
+ )
+ expect(response.status).toBe(400)
+ })
+
+ it('rejects a client-supplied dividend override', async () => {
+ setup()
+ const response = await POST(
+ createMockRequest('/x', {
+ method: 'POST',
+ body: { action: 'finalize', proposed_dividend: 100 },
+ }),
+ params,
+ )
+ expect(response.status).toBe(400)
+ expect(buildCanonicalAnnualReport).not.toHaveBeenCalled()
+ })
+
+ it('returns 404 for another company period', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: null })
+ expect((await GET(createMockRequest('/x'), params)).status).toBe(404)
+ })
+
+ it('lists immutable versions', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'period-1' } })
+ const { body } = await parseJsonResponse<{ data: Array }>(
+ await GET(createMockRequest('/x'), params),
+ )
+ expect(body.data[0].content_hash).toHaveLength(64)
+ })
+
+ it('creates a finalized version through the canonical model', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'period-1' } })
+ const response = await POST(
+ createMockRequest('/x', { method: 'POST', body: { action: 'finalize' } }),
+ params,
+ )
+ expect(response.status).toBe(201)
+ expect(buildCanonicalAnnualReport).toHaveBeenCalledWith(
+ expect.anything(),
+ 'company-1',
+ 'period-1',
+ expect.objectContaining({ stage: 'signing' }),
+ )
+ expect(createAnnualReportVersion).toHaveBeenCalledWith(
+ expect.anything(),
+ 'user-1',
+ expect.anything(),
+ true,
+ )
+ })
+
+ it('accepts a VD as the fastställelseintyg signer', async () => {
+ const { enqueue } = setup()
+ enqueue({ data: { id: 'period-1' } })
+ const response = await POST(
+ createMockRequest('/x', {
+ method: 'POST',
+ body: {
+ action: 'finalize',
+ certificate_signer: {
+ first_name: 'Anna',
+ last_name: 'Andersson',
+ role: 'VD',
+ },
+ },
+ }),
+ params,
+ )
+ expect(response.status).toBe(201)
+ expect(buildCanonicalAnnualReport).toHaveBeenCalledWith(
+ expect.anything(),
+ 'company-1',
+ 'period-1',
+ expect.objectContaining({
+ undertecknare: expect.objectContaining({ role: 'VD' }),
+ }),
+ )
+ })
+})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/route.ts
new file mode 100644
index 00000000..cb688b07
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/route.ts
@@ -0,0 +1,109 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { validateBody } from '@/lib/api/validate'
+import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { createServiceClient } from '@/lib/supabase/server'
+import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
+import {
+ createAnnualReportVersion,
+ listAnnualReportVersions,
+} from '@/lib/bokslut/arsredovisning/version-service'
+
+const PostSchema = z
+ .object({
+ action: z.enum(['snapshot', 'finalize']),
+ certificate_signer: z
+ .object({
+ first_name: z.string().min(1).max(100),
+ last_name: z.string().min(1).max(100),
+ role: z.enum([
+ 'Styrelseledamot',
+ 'Styrelseordförande',
+ 'VD',
+ 'Verkställande direktör',
+ ]),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+
+async function ownsPeriod(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+): Promise {
+ const { data } = await supabase
+ .from('fiscal_periods')
+ .select('id')
+ .eq('id', fiscalPeriodId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+ return Boolean(data)
+}
+
+export const GET = withRouteContext(
+ 'period.arsredovisning_versions_list',
+ async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { supabase, companyId, log, requestId } = ctx
+ try {
+ if (!(await ownsPeriod(supabase, companyId, id))) {
+ return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
+ }
+ const data = await listAnnualReportVersions(supabase, companyId, id)
+ return NextResponse.json({ data })
+ } catch (err) {
+ return errorResponse(err, log, { requestId })
+ }
+ },
+)
+
+export const POST = withRouteContext(
+ 'period.arsredovisning_versions_create',
+ async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { user, supabase, companyId, log, requestId } = ctx
+ const validation = await validateBody(request, PostSchema)
+ if (!validation.success) return validation.response
+ try {
+ if (!(await ownsPeriod(supabase, companyId, id))) {
+ return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
+ }
+ const signer = validation.data.certificate_signer
+ const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
+ stage: validation.data.action === 'finalize' ? 'signing' : 'draft',
+ undertecknare: signer
+ ? {
+ firstName: signer.first_name,
+ lastName: signer.last_name,
+ role: signer.role,
+ }
+ : undefined,
+ })
+ if (validation.data.action === 'finalize' && !model.validation.ok) {
+ return NextResponse.json(
+ {
+ error: {
+ code: 'ARSREDOVISNING_INCOMPLETE',
+ details: model.validation,
+ },
+ },
+ { status: 409 },
+ )
+ }
+ const data = await createAnnualReportVersion(
+ validation.data.action === 'finalize' ? createServiceClient() : supabase,
+ user.id,
+ model,
+ validation.data.action === 'finalize',
+ )
+ return NextResponse.json({ data }, { status: 201 })
+ } catch (err) {
+ return errorResponse(err, log, { requestId })
+ }
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslut-readiness/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslut-readiness/route.ts
index 23efeb85..897d50c1 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/bokslut-readiness/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslut-readiness/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildBokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
@@ -27,7 +28,7 @@ export const GET = withRouteContext(
}
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, {
requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err) },
})
}
},
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts
index e335c5d9..184673f1 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts
@@ -8,6 +8,37 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
+vi.mock('@/lib/bokslut/dispositions-proposal-builder', () => ({
+ buildDispositionsProposal: vi.fn(),
+ buildLatentTaxProposal: vi.fn(),
+}))
+
+vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
+ loadTaxAdjustmentSnapshot: vi.fn(),
+ saveTaxAdjustments: vi.fn(),
+}))
+
+vi.mock('@/lib/bokslut/tax-provision/bolagsskatt-calculator', () => ({
+ calculateBolagsskatt: vi.fn(),
+ getBookedBolagsskatt: vi.fn(),
+ sumPostedYearEndDispositions: vi.fn(),
+}))
+
+vi.mock('@/lib/reports/income-statement', () => ({
+ generateIncomeStatement: vi.fn(),
+}))
+
+vi.mock('@/lib/bokslut/reserves/periodiseringsfond-service', async (importOriginal) => {
+ const actual = await importOriginal<
+ typeof import('@/lib/bokslut/reserves/periodiseringsfond-service')
+ >()
+ return { ...actual, listExistingPeriodiseringsfonder: vi.fn() }
+})
+
+vi.mock('@/lib/bookkeeping/engine', () => ({
+ createJournalEntry: vi.fn(),
+}))
+
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
@@ -23,7 +54,20 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
-import { POST } from '../route'
+import { buildDispositionsProposal } from '@/lib/bokslut/dispositions-proposal-builder'
+import {
+ loadTaxAdjustmentSnapshot,
+ saveTaxAdjustments,
+} from '@/lib/bokslut/tax-provision/tax-adjustment-service'
+import {
+ calculateBolagsskatt,
+ getBookedBolagsskatt,
+ sumPostedYearEndDispositions,
+} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
+import { generateIncomeStatement } from '@/lib/reports/income-statement'
+import { listExistingPeriodiseringsfonder } from '@/lib/bokslut/reserves/periodiseringsfond-service'
+import { createJournalEntry } from '@/lib/bookkeeping/engine'
+import { POST, PUT } from '../route'
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
@@ -37,10 +81,152 @@ function post(body: unknown) {
)
}
+function put(body: unknown) {
+ return PUT(
+ createMockRequest('/api/bookkeeping/fiscal-periods/period-1/bokslutsdispositioner', {
+ method: 'PUT',
+ body,
+ }),
+ idParams,
+ )
+}
+
+function periodClient(period: unknown, error: unknown = null) {
+ const builder = {
+ select: vi.fn(),
+ eq: vi.fn(),
+ single: vi.fn().mockResolvedValue({ data: period, error }),
+ }
+ builder.select.mockReturnValue(builder)
+ builder.eq.mockReturnValue(builder)
+ return { from: vi.fn().mockReturnValue(builder) }
+}
+
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
+ vi.mocked(buildDispositionsProposal).mockResolvedValue({
+ entityType: 'aktiebolag',
+ fiscalPeriod: {
+ id: 'period-1',
+ name: '2025',
+ period_start: '2024-10-07',
+ period_end: '2025-12-31',
+ },
+ netResultBefore: 592_722.21,
+ proposals: [],
+ })
+ vi.mocked(saveTaxAdjustments).mockResolvedValue()
+ vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue({
+ items: [],
+ nonDeductibleExpenses: 5_244,
+ nonTaxableIncome: 0,
+ })
+ vi.mocked(generateIncomeStatement).mockResolvedValue({
+ net_result: 592_722.21,
+ } as Awaited>)
+ vi.mocked(sumPostedYearEndDispositions).mockResolvedValue({
+ total: 0,
+ slpPortion: 0,
+ taxProvisionPortion: 0,
+ })
+ vi.mocked(getBookedBolagsskatt).mockResolvedValue(0)
+ vi.mocked(listExistingPeriodiseringsfonder).mockResolvedValue([])
+ vi.mocked(calculateBolagsskatt).mockResolvedValue({
+ kind: 'bolagsskatt',
+ label: 'Bolagsskatt 20,6 %',
+ description: 'Skatt på årets skattemässiga resultat.',
+ amount: 123_180,
+ lines: [
+ { account_number: '8910', debit_amount: 123_180, credit_amount: 0 },
+ { account_number: '2512', debit_amount: 0, credit_amount: 123_180 },
+ ],
+ warnings: [],
+ })
+ vi.mocked(createJournalEntry).mockResolvedValue({ id: 'entry-tax' } as Awaited<
+ ReturnType
+ >)
+})
+
+describe('PUT /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => {
+ const validBody = {
+ manualAdjustments: { nonDeductibleExpenses: 0, nonTaxableIncome: 0 },
+ detectedAccounts: { '6992': true, '8423': true },
+ }
+
+ it('returns 401 when not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+ expect((await put(validBody)).status).toBe(401)
+ })
+
+ it('returns 400 for a negative manual adjustment', async () => {
+ const res = await put({
+ ...validBody,
+ manualAdjustments: { nonDeductibleExpenses: -1, nonTaxableIncome: 0 },
+ })
+ expect(res.status).toBe(400)
+ })
+
+ it('returns 404 when the fiscal period is missing', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase: periodClient(null, { message: 'not found' }),
+ error: null,
+ })
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
+ await put(validBody),
+ )
+ expect(status).toBe(404)
+ expect(body.error.code).toBe('PERIOD_NOT_FOUND')
+ })
+
+ it('returns PERIOD_LOCKED when the fiscal period is locked', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase: periodClient({
+ id: 'period-1',
+ is_closed: false,
+ locked_at: '2026-07-21T08:00:00Z',
+ closing_entry_id: null,
+ }),
+ error: null,
+ })
+ const { body } = await parseJsonResponse<{ error: { code: string } }>(await put(validBody))
+ expect(body.error.code).toBe('PERIOD_LOCKED')
+ })
+
+ it('saves the adjustments and returns the recalculated proposal', async () => {
+ const supabase = periodClient({
+ id: 'period-1',
+ is_closed: false,
+ locked_at: null,
+ closing_entry_id: null,
+ })
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase,
+ error: null,
+ })
+
+ const { status, body } = await parseJsonResponse<{ data: { netResultBefore: number } }>(
+ await put(validBody),
+ )
+
+ expect(status).toBe(200)
+ expect(saveTaxAdjustments).toHaveBeenCalledWith(
+ supabase,
+ 'company-1',
+ 'period-1',
+ 'user-1',
+ validBody,
+ )
+ expect(body.data.netResultBefore).toBe(592_722.21)
+ })
})
describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => {
@@ -85,4 +271,103 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () =
const { status } = await parseJsonResponse(await post({ items: [] }))
expect(status).toBe(400)
})
+
+ it('rejects legacy client-supplied tax adjustments with 400', async () => {
+ const { status } = await parseJsonResponse(
+ await post({
+ items: [{
+ kind: 'bolagsskatt',
+ manualAdjustments: { nonDeductibleExpenses: 999_999 },
+ }],
+ }),
+ )
+ expect(status).toBe(400)
+ })
+
+ it('posts the calculated tax through the bookkeeping engine', async () => {
+ const supabase = periodClient({
+ id: 'period-1',
+ name: '2025',
+ period_start: '2024-10-07',
+ period_end: '2025-12-31',
+ opening_balance_entry_id: null,
+ is_closed: false,
+ locked_at: null,
+ closing_entry_id: null,
+ })
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase,
+ error: null,
+ })
+
+ const { status, body } = await parseJsonResponse<{
+ data: { created: Array<{ kind: string }> }
+ }>(await post({ items: [{ kind: 'bolagsskatt' }] }))
+
+ expect(status).toBe(200)
+ expect(body.data.created).toHaveLength(1)
+ expect(createJournalEntry).toHaveBeenCalledOnce()
+ expect(createJournalEntry).toHaveBeenCalledWith(
+ supabase,
+ 'company-1',
+ 'user-1',
+ expect.objectContaining({ source_id: 'period-1' }),
+ )
+ })
+
+ it('does not post a duplicate when the same tax is already booked', async () => {
+ const supabase = periodClient({
+ id: 'period-1',
+ name: '2025',
+ period_start: '2024-10-07',
+ period_end: '2025-12-31',
+ opening_balance_entry_id: null,
+ is_closed: false,
+ locked_at: null,
+ closing_entry_id: null,
+ })
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase,
+ error: null,
+ })
+ vi.mocked(getBookedBolagsskatt).mockResolvedValue(123_180)
+
+ const { status, body } = await parseJsonResponse<{
+ data: { created: Array<{ kind: string }> }
+ }>(await post({ items: [{ kind: 'bolagsskatt' }] }))
+
+ expect(status).toBe(200)
+ expect(body.data.created).toEqual([])
+ expect(createJournalEntry).not.toHaveBeenCalled()
+ })
+
+ it('returns 409 instead of posting over a different booked tax amount', async () => {
+ const supabase = periodClient({
+ id: 'period-1',
+ name: '2025',
+ period_start: '2024-10-07',
+ period_end: '2025-12-31',
+ opening_balance_entry_id: null,
+ is_closed: false,
+ locked_at: null,
+ closing_entry_id: null,
+ })
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase,
+ error: null,
+ })
+ vi.mocked(getBookedBolagsskatt).mockResolvedValue(123_181)
+
+ const { status, body } = await parseJsonResponse<{
+ error: { code: string; details: { bookedAmount: number; expectedAmount: number } }
+ }>(await post({ items: [{ kind: 'bolagsskatt' }] }))
+
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('CONFLICT')
+ expect(body.error.details).toEqual({ bookedAmount: 123_181, expectedAmount: 123_180 })
+ expect(createJournalEntry).not.toHaveBeenCalled()
+ })
})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts
index 0e769895..c64dafe7 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts
@@ -4,10 +4,16 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
+import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
import {
calculateBolagsskatt,
+ getBookedBolagsskatt,
sumPostedYearEndDispositions,
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
+import {
+ loadTaxAdjustmentSnapshot,
+ saveTaxAdjustments,
+} from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import { calculateSarskildLoneskatt } from '@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator'
import {
getPeriodiseringsfondCohortAccount,
@@ -76,21 +82,68 @@ export const GET = withRouteContext(
},
)
+const PutBodySchema = z.object({
+ manualAdjustments: z.object({
+ nonDeductibleExpenses: z.number().nonnegative().max(1_000_000_000_000),
+ nonTaxableIncome: z.number().nonnegative().max(1_000_000_000_000),
+ }),
+ detectedAccounts: z.object({
+ '6992': z.boolean(),
+ '8423': z.boolean(),
+ }),
+})
+
+export const PUT = withRouteContext(
+ 'period.bokslutsdispositioner_adjustments',
+ async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { user, supabase, companyId, log, requestId } = ctx
+ const opLog = log.child({ periodId: id })
+ const validation = await validateBody(request, PutBodySchema)
+ if (!validation.success) return validation.response
+
+ try {
+ const { data: period, error: periodError } = await supabase
+ .from('fiscal_periods')
+ .select('id, is_closed, locked_at, closing_entry_id')
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .single()
+
+ if (periodError || !period) {
+ return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
+ }
+ if (period.is_closed || period.locked_at || period.closing_entry_id) {
+ return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
+ }
+
+ await saveTaxAdjustments(
+ supabase,
+ companyId,
+ id,
+ user.id,
+ validation.data,
+ )
+ const data = await buildDispositionsProposal(supabase, companyId, id)
+ return NextResponse.json({ data })
+ } catch (err) {
+ if (err instanceof Error && /locked for tax adjustments/i.test(err.message)) {
+ return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
+ }
+ opLog.error('bokslutsdispositioner adjustments failed', err as Error)
+ return errorResponse(err, opLog, { requestId })
+ }
+ },
+ { requireWrite: true },
+)
+
// ============================================================
// POST: commit a list of dispositions chosen by the user
// ============================================================
const ItemSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('bolagsskatt'),
- manualAdjustments: z
- .object({
- nonDeductibleExpenses: z.number().optional(),
- nonTaxableIncome: z.number().optional(),
- schablonintaktPeriodiseringsfond: z.number().optional(),
- other: z.number().optional(),
- })
- .optional(),
- }),
+ }).strict(),
z.object({
kind: z.literal('sarskild_loneskatt'),
manualAdjustment: z.number().optional(),
@@ -181,6 +234,7 @@ export const POST = withRouteContext(
entry_date: period.period_end,
description: `Bokslutsdisposition: ${proposal.label}`,
source_type: 'year_end',
+ source_id: item.kind === 'bolagsskatt' ? id : undefined,
voucher_series: 'A',
lines: proposal.lines,
})
@@ -189,6 +243,30 @@ export const POST = withRouteContext(
return NextResponse.json({ data: { created } })
} catch (err) {
+ if (err instanceof TaxProvisionConflictError) {
+ return errorResponseFromCode('CONFLICT', opLog, {
+ requestId,
+ messageSv:
+ 'Bolagsskatt finns redan bokförd med ett annat belopp. Rätta den befintliga verifikationen med en ändringsverifikation innan du fortsätter.',
+ messageEn:
+ 'Corporate tax is already posted with a different amount. Correct the existing voucher before continuing.',
+ details: {
+ bookedAmount: err.bookedAmount,
+ expectedAmount: err.expectedAmount,
+ },
+ })
+ }
+ if (
+ err instanceof BookkeepingDatabaseError
+ && err.operation === 'create_draft_entry'
+ && err.cause?.includes('uq_year_end_corporate_tax_per_period')
+ ) {
+ return errorResponseFromCode('CONFLICT', opLog, {
+ requestId,
+ messageSv: 'Bolagsskatten bokförs redan. Ladda om sidan innan du fortsätter.',
+ messageEn: 'Corporate tax is already being posted. Reload the page before continuing.',
+ })
+ }
opLog.error('bokslutsdispositioner post failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
@@ -208,6 +286,15 @@ interface ValidatedPeriod {
opening_balance_entry_id: string | null
}
+class TaxProvisionConflictError extends Error {
+ constructor(
+ readonly bookedAmount: number,
+ readonly expectedAmount: number,
+ ) {
+ super('Booked corporate tax differs from the current calculation')
+ }
+}
+
async function computeProposal(
item: PostItem,
supabase: Parameters[0],
@@ -224,16 +311,42 @@ async function computeProposal(
// SLP −, överavskrivningar −); bolagsskatt is sorted LAST so they are
// committed by now. Without this the booked tax ignores the avsättning
// (the original customer bug, too-high tax, ÅR/INK2 mismatch).
- const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
- const dispositionsEffect = await sumPostedYearEndDispositions(
- supabase,
- companyId,
- fiscalPeriodId,
+ const [incomeStatement, dispositionsEffect, taxAdjustments, bookedTax, existingFonder] =
+ await Promise.all([
+ generateIncomeStatement(supabase, companyId, fiscalPeriodId),
+ sumPostedYearEndDispositions(supabase, companyId, fiscalPeriodId),
+ loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId),
+ getBookedBolagsskatt(supabase, companyId, fiscalPeriodId),
+ listExistingPeriodiseringsfonder(
+ supabase,
+ companyId,
+ period.period_end,
+ period.period_start,
+ period.opening_balance_entry_id,
+ ),
+ ])
+ const schablonintakt = existingFonder.reduce(
+ (sum, fund) =>
+ sum + Math.max(0, fund.opening_balance) * getSchablonintaktRate(fiscalYear),
+ 0,
)
- return calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
- resultBeforeTaxOverride: incomeStatement.net_result + dispositionsEffect.total,
- manualAdjustments: item.manualAdjustments,
+ const manuallyBookedTax = Math.max(
+ 0,
+ bookedTax - dispositionsEffect.taxProvisionPortion,
+ )
+ const proposal = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
+ resultBeforeTaxOverride:
+ incomeStatement.net_result + dispositionsEffect.total + manuallyBookedTax,
+ manualAdjustments: {
+ nonDeductibleExpenses: taxAdjustments.nonDeductibleExpenses,
+ nonTaxableIncome: taxAdjustments.nonTaxableIncome,
+ schablonintaktPeriodiseringsfond: Math.round(schablonintakt),
+ },
})
+ const expectedTax = proposal?.amount ?? 0
+ if (bookedTax > 0 && bookedTax === expectedTax) return null
+ if (bookedTax > 0) throw new TaxProvisionConflictError(bookedTax, expectedTax)
+ return proposal && proposal.amount > 0 ? proposal : null
}
case 'sarskild_loneskatt': {
// Already posted in this period (resumed run / duplicate POST): the
@@ -278,6 +391,10 @@ async function computeProposal(
const alreadyProvisioned = currentCohort
? Math.max(0, currentCohort.balance - Math.max(0, currentCohort.opening_balance))
: 0
+ // A posted current-year allocation is a completed user decision. New
+ // tax adjustments may increase the legal ceiling, but must never cause
+ // the page to propose an unsolicited incremental allocation on reload.
+ if (alreadyProvisioned > 0) return null
// Dispositions posted earlier in this batch (återföring, över-
// avskrivningar, SLP: all sorted before avsättning) are year_end-typed
// and thus invisible in net_result, yet they move the cap base. Add
@@ -289,9 +406,15 @@ async function computeProposal(
companyId,
fiscalPeriodId,
)
+ const taxAdjustments = await loadTaxAdjustmentSnapshot(
+ supabase,
+ companyId,
+ fiscalPeriodId,
+ )
const base =
incomeStatement.net_result + postedEffect.total + alreadyProvisioned
+ Math.round(schablonintakt)
+ + taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
return proposeAvsattning({
skattemassigtResultatBeforeAvsattning: base,
desiredAmount: item.desiredAmount,
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/close/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/close/__tests__/route.test.ts
index c9d35610..182d6591 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/close/__tests__/route.test.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/close/__tests__/route.test.ts
@@ -57,13 +57,13 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/close', () => {
expect(mockClosePeriod).not.toHaveBeenCalled()
})
- it('maps a service refusal to 400 with the message', async () => {
+ it('maps a service refusal to 400 with a safe message', async () => {
mockClosePeriod.mockRejectedValue(new Error('Period contains draft entries'))
const { status, body } = await parseJsonResponse<{ error: string }>(
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
)
expect(status).toBe(400)
- expect(body.error).toBe('Period contains draft entries')
+ expect(body.error).toBe('Något gick fel. Försök igen.')
})
it('closes the period on the happy path', async () => {
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts
index 0cb7aaec..1ce4baad 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ error: string }` — kept for the year-end UI.
// closePeriod throws plain Errors for every refusal (period not found, drafts
@@ -16,7 +17,7 @@ export const POST = withRouteContext(
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Failed to close period' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to close period' },
{ status: 400 }
)
}
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts
index 1fcf83e3..640a7476 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts
@@ -5,6 +5,7 @@ import {
} from '@/lib/bookkeeping/currency-revaluation'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** GET: preview currency revaluation for a fiscal period. */
export const GET = withRouteContext(
@@ -76,7 +77,7 @@ export const POST = withRouteContext(
if (fallback.status === 500) {
return errorResponseFromCode('FX_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
return fallback
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts
index a5452761..22efd210 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'period.entry_count',
@@ -26,7 +27,7 @@ export const GET = withRouteContext(
.in('status', ['posted', 'reversed'])
if (countError) {
- return NextResponse.json({ error: countError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(countError) }, { status: 500 })
}
return NextResponse.json({ data: { posted_count: count ?? 0 } })
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts
index 49d241e6..a2869064 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
export const POST = withRouteContext(
@@ -27,7 +28,7 @@ export const POST = withRouteContext(
if (/draft/i.test(message)) {
return errorResponseFromCode('PERIOD_LOCK_HAS_DRAFTS', opLog, {
requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err) },
})
}
// lockPeriod() refuses to lock a period that still has uncategorized
@@ -36,7 +37,7 @@ export const POST = withRouteContext(
if (/saknar bokföring|okategoriserade affärstransaktion/i.test(message)) {
return errorResponseFromCode('PERIOD_HAS_UNBOOKED_TRANSACTIONS', opLog, {
requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err) },
})
}
return errorResponse(err, opLog, { requestId })
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/year-end/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/year-end/__tests__/route.test.ts
new file mode 100644
index 00000000..02f60aae
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/year-end/__tests__/route.test.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
+
+vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
+ validateYearEndReadiness: vi.fn(),
+ previewYearEndClosing: vi.fn(),
+ executeYearEndClosing: vi.fn(),
+}))
+
+const requireAuthMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+import { executeYearEndClosing } from '@/lib/core/bookkeeping/year-end-service'
+import { POST } from '../route'
+
+const params = createMockRouteParams({ id: 'period-1' })
+const request = () => createMockRequest('/api/bookkeeping/fiscal-periods/period-1/year-end', {
+ method: 'POST',
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase: {},
+ error: null,
+ })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ vi.mocked(executeYearEndClosing).mockResolvedValue({
+ closingEntry: { id: 'closing-1' },
+ nextPeriod: { id: 'period-2' },
+ } as never)
+})
+
+describe('POST /api/bookkeeping/fiscal-periods/[id]/year-end', () => {
+ it('returns 401 when not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase: {},
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+
+ expect((await POST(request(), params)).status).toBe(401)
+ })
+
+ it('returns a specific 409 for a period without posted result activity', async () => {
+ vi.mocked(executeYearEndClosing).mockRejectedValue(
+ new Error('No result accounts to close: period has no activity'),
+ )
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
+ await POST(request(), params),
+ )
+
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('YEAR_END_NO_ACTIVITY')
+ })
+
+ it('returns 404 when the fiscal period is missing', async () => {
+ vi.mocked(executeYearEndClosing).mockRejectedValue(new Error('Fiscal period not found'))
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
+ await POST(request(), params),
+ )
+
+ expect(status).toBe(404)
+ expect(body.error.code).toBe('PERIOD_NOT_FOUND')
+ })
+
+ it('returns the completed year-end result', async () => {
+ const { status, body } = await parseJsonResponse<{ data: { closingEntry: { id: string } } }>(
+ await POST(request(), params),
+ )
+
+ expect(status).toBe(200)
+ expect(body.data.closingEntry.id).toBe('closing-1')
+ expect(executeYearEndClosing).toHaveBeenCalledWith(
+ {},
+ 'company-1',
+ 'user-1',
+ 'period-1',
+ )
+ })
+})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts
index 7ec286d5..f3387592 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts
@@ -44,7 +44,6 @@ export const POST = withRouteContext(
const result = await executeYearEndClosing(supabase, companyId!, user.id, id)
return NextResponse.json({ data: result })
} catch (err) {
- opLog.error('year-end execution failed', err as Error)
const message = err instanceof Error ? err.message : ''
// The downstream errors below are matched on stable English keywords
// emitted by year-end-service. Do NOT include the raw message in
@@ -53,6 +52,12 @@ export const POST = withRouteContext(
if (/Next fiscal period already has opening balance/i.test(message)) {
return errorResponseFromCode('YEAR_END_NEXT_PERIOD_HAS_IB', opLog, { requestId })
}
+ if (/No result accounts to close: period has no activity/i.test(message)) {
+ return errorResponseFromCode('YEAR_END_NO_ACTIVITY', opLog, {
+ requestId,
+ reason: 'year-end blocked because the period has no posted result activity',
+ })
+ }
if (/prior.*open/i.test(message)) {
return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, { requestId })
}
@@ -62,12 +67,10 @@ export const POST = withRouteContext(
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
- // Fall through bookkeeping/Zod/etc to errorResponse, but cap to YEAR_END_FAILED.
- const fallback = errorResponse(err, opLog, { requestId })
- if (fallback.status === 500) {
- return errorResponseFromCode('YEAR_END_FAILED', opLog, { requestId })
- }
- return fallback
+ // Unknown failures stay 5xx and retain the original Error object and
+ // stack in the log. Expected domain outcomes above are structured 4xx
+ // warnings, so Vercel runtime-error clusters remain actionable.
+ return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
diff --git a/app/api/bookkeeping/fiscal-periods/period-status/route.ts b/app/api/bookkeeping/fiscal-periods/period-status/route.ts
index 3f9059d7..a3ec3dd3 100644
--- a/app/api/bookkeeping/fiscal-periods/period-status/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/period-status/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/bookkeeping/fiscal-periods/period-status?date=YYYY-MM-DD
@@ -45,7 +46,7 @@ export const GET = withRouteContext('period.status_for_date', async (request, ct
{
error: {
code: 'PERIOD_STATUS_ERROR',
- message: err instanceof Error ? err.message : 'Kunde inte hämta periodstatus',
+ message: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta periodstatus',
},
},
{ status: 500 }
diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts
index 50a7b895..169ac6f0 100644
--- a/app/api/bookkeeping/fiscal-periods/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/route.ts
@@ -4,6 +4,7 @@ import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-durati
import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ error: string }` (plus one envelope code for
// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI.
@@ -18,7 +19,7 @@ export const GET = withRouteContext('period.list', async (_request, ctx) => {
.order('period_start', { ascending: false })
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -202,7 +203,7 @@ export const POST = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Keep the continuity chain intact for the period that now follows the new one:
diff --git a/app/api/bookkeeping/fix-cash-mismatch/route.ts b/app/api/bookkeeping/fix-cash-mismatch/route.ts
index 1a191a07..c3834d75 100644
--- a/app/api/bookkeeping/fix-cash-mismatch/route.ts
+++ b/app/api/bookkeeping/fix-cash-mismatch/route.ts
@@ -32,6 +32,7 @@ import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -237,7 +238,7 @@ export const POST = withRouteContext(
payment_id: t.payment_id,
ok: false,
old_journal_entry_id: t.payment_journal_entry_id,
- error: err instanceof Error ? err.message : 'Unknown error',
+ error: err instanceof Error ? getUserErrorMessage(err) : 'Unknown error',
})
}
}
diff --git a/app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts b/app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts
index 2cbc17e3..15ef62b7 100644
--- a/app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts
+++ b/app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SetNoDocSchema = z.object({
reason: z.string().trim().max(200).nullable().optional(),
@@ -39,7 +40,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 400 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
return NextResponse.json({ data: { exempted: true } })
@@ -64,7 +65,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 400 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
return NextResponse.json({ data: { exempted: false } })
diff --git a/app/api/bookkeeping/journal-entries/[id]/notes/route.ts b/app/api/bookkeeping/journal-entries/[id]/notes/route.ts
index e61770a1..d026ca8b 100644
--- a/app/api/bookkeeping/journal-entries/[id]/notes/route.ts
+++ b/app/api/bookkeeping/journal-entries/[id]/notes/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const UpdateNotesSchema = z.object({
notes: z.string().max(2000).nullable(),
@@ -27,7 +28,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
.maybeSingle()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 400 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
// Zero rows = the entry doesn't exist in this company — report it instead
// of a phantom success.
diff --git a/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/route.ts b/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/route.ts
index c2f76465..fe263ae3 100644
--- a/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/route.ts
+++ b/app/api/bookkeeping/journal-entry-lines/[lineId]/retag/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { RetagLineDimensionsSchema } from '@/lib/api/schemas'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag
@@ -39,10 +40,10 @@ export const POST = withRouteContext<{ params: Promise<{ lineId: string }> }>(
// Anything else is unexpected infrastructure failure → 500 + log.
const message = error.message ?? 'Kunde inte ändra dimensioner'
if (error.code === 'P0001') {
- return NextResponse.json({ error: message }, { status: 409 })
+ return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
}
if (error.code === '42501') {
- return NextResponse.json({ error: message }, { status: 403 })
+ return NextResponse.json({ error: getErrorMessage(error) }, { status: 403 })
}
log.error('retag_line_dimensions failed', new Error(message), { lineId })
return NextResponse.json({ error: 'Kunde inte ändra dimensioner' }, { status: 500 })
diff --git a/app/api/bookkeeping/mapping-rules/evaluate/route.ts b/app/api/bookkeeping/mapping-rules/evaluate/route.ts
index c53a87d1..ad9bf5f7 100644
--- a/app/api/bookkeeping/mapping-rules/evaluate/route.ts
+++ b/app/api/bookkeeping/mapping-rules/evaluate/route.ts
@@ -4,6 +4,7 @@ import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
import type { Transaction } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const POST = withRouteContext('mapping_rules.evaluate', async (request, ctx) => {
const { supabase, companyId, log } = ctx
@@ -42,7 +43,7 @@ export const POST = withRouteContext('mapping_rules.evaluate', async (request, c
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Evaluation failed' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Evaluation failed' },
{ status: 500 }
)
}
diff --git a/app/api/bookkeeping/mapping-rules/route.ts b/app/api/bookkeeping/mapping-rules/route.ts
index e94475a2..96a1ba46 100644
--- a/app/api/bookkeeping/mapping-rules/route.ts
+++ b/app/api/bookkeeping/mapping-rules/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('mapping_rules.list', async (_request, ctx) => {
const { supabase, companyId } = ctx
@@ -14,7 +15,7 @@ export const GET = withRouteContext('mapping_rules.list', async (_request, ctx)
.order('priority')
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -59,7 +60,7 @@ export const POST = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
diff --git a/app/api/bookkeeping/no-doc-required/batch/route.ts b/app/api/bookkeeping/no-doc-required/batch/route.ts
index b46f18f0..8a48692e 100644
--- a/app/api/bookkeeping/no-doc-required/batch/route.ts
+++ b/app/api/bookkeeping/no-doc-required/batch/route.ts
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BatchNoDocSchema = z.object({
journal_entry_ids: z.array(z.string().uuid()).min(1).max(500),
@@ -40,7 +41,7 @@ export const POST = withRouteContext(
.in('id', chunk)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 400 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
ownedIds.push(...(data ?? []).map((r) => r.id))
}
diff --git a/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts b/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts
index 1feb2694..4690780e 100644
--- a/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts
+++ b/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts
@@ -6,6 +6,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// A real calendar date in YYYY-MM-DD form. Rejects shaped-but-invalid values
// (e.g. 9999-99-99 or 2026-02-30) that a bare /^\d{4}-\d{2}-\d{2}$/ regex would
@@ -106,10 +107,10 @@ export const POST = withRouteContext(
.in('journal_entry_id', chunk),
])
if (docRes.error) {
- return NextResponse.json({ error: docRes.error.message }, { status: 400 })
+ return NextResponse.json({ error: getUserErrorMessage(docRes.error) }, { status: 400 })
}
if (exemptRes.error) {
- return NextResponse.json({ error: exemptRes.error.message }, { status: 400 })
+ return NextResponse.json({ error: getUserErrorMessage(exemptRes.error) }, { status: 400 })
}
for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
withDoc.add(r.journal_entry_id)
diff --git a/app/api/bookkeeping/voucher-gaps/route.ts b/app/api/bookkeeping/voucher-gaps/route.ts
index 8d81a830..1720b2f8 100644
--- a/app/api/bookkeeping/voucher-gaps/route.ts
+++ b/app/api/bookkeeping/voucher-gaps/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Voucher gap detection + explanations (BFNAR 2013:2 — gaps in voucher
// sequences must be documented). Response shapes are legacy `{ data }` /
@@ -31,7 +32,7 @@ export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) =>
const { data: seriesRows, error: seriesError } = await seriesQuery
if (seriesError) {
log.error('voucher series lookup failed', seriesError)
- return NextResponse.json({ error: seriesError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(seriesError) }, { status: 500 })
}
if (!seriesRows || seriesRows.length === 0) {
@@ -59,7 +60,7 @@ export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) =>
// render "no gaps" on a compliance view when the check didn't run.
if (gapsError) {
log.error('detect_voucher_gaps failed', gapsError, { series: row.voucher_series })
- return NextResponse.json({ error: gapsError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(gapsError) }, { status: 500 })
}
for (const gap of (gaps ?? []) as Array<{ gap_start: number; gap_end: number }>) {
@@ -148,7 +149,7 @@ export const POST = withRouteContext(
{ status: 403 }
)
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
diff --git a/app/api/calendar/feed/route.ts b/app/api/calendar/feed/route.ts
index ccab3b68..bb181763 100644
--- a/app/api/calendar/feed/route.ts
+++ b/app/api/calendar/feed/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Only the two content toggles are user-settable. Strict: the previous
// implementation passed the raw JSON body into .update(), which would have
@@ -41,7 +42,7 @@ export const GET = withRouteContext('calendar_feed.get', async (_request, ctx) =
if (error && error.code !== 'PGRST116') {
// PGRST116 = no rows returned, which is fine
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (feed) {
@@ -90,7 +91,7 @@ export const POST = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
@@ -123,7 +124,7 @@ export const PUT = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
@@ -155,7 +156,7 @@ export const DELETE = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
diff --git a/app/api/company/check-org-number/route.ts b/app/api/company/check-org-number/route.ts
index 968678dc..d3bcc3c8 100644
--- a/app/api/company/check-org-number/route.ts
+++ b/app/api/company/check-org-number/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/company/check-org-number?org_number=XXXXXXXXXX
@@ -48,7 +49,7 @@ export async function GET(request: Request) {
.is('archived_at', null)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const companies = (data ?? []).map((c: { id: string; name: string }) => ({
diff --git a/app/api/company/current/route.ts b/app/api/company/current/route.ts
index 5776127d..24079e92 100644
--- a/app/api/company/current/route.ts
+++ b/app/api/company/current/route.ts
@@ -7,6 +7,7 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { createLogger } from '@/lib/logger'
import { NextResponse } from 'next/server'
import { z } from 'zod'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const log = createLogger('api/company/current')
@@ -124,7 +125,7 @@ export const PATCH = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// When opting in to K3, ensure the two latent-tax (uppskjuten skatt)
diff --git a/app/api/company/route.ts b/app/api/company/route.ts
index bd86ee21..9cf08486 100644
--- a/app/api/company/route.ts
+++ b/app/api/company/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/company?owned=true&archived=false
@@ -32,7 +33,7 @@ export async function GET(request: Request) {
const { data, error } = await query
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const companies = (data ?? []).map((row) => {
diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts
index 81359277..7e15dbf0 100644
--- a/app/api/customers/[id]/route.ts
+++ b/app/api/customers/[id]/route.ts
@@ -5,6 +5,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'customer.get',
@@ -27,7 +28,7 @@ export const GET = withRouteContext(
opLog.error('customer fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
@@ -120,7 +121,7 @@ export const PATCH = withRouteContext(
opLog.error('customer update failed', error)
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
@@ -181,7 +182,7 @@ export const DELETE = withRouteContext(
opLog.error('customer delete failed', error)
return errorResponseFromCode('CUSTOMER_DELETE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts
index 87b0e8b8..9cf07b2f 100644
--- a/app/api/customers/route.ts
+++ b/app/api/customers/route.ts
@@ -8,6 +8,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
log.error('customer insert failed', error)
return errorResponseFromCode('CUSTOMER_CREATE_FAILED', log, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/deadlines/[id]/complete/route.ts b/app/api/deadlines/[id]/complete/route.ts
index cd554cbd..115b13e6 100644
--- a/app/api/deadlines/[id]/complete/route.ts
+++ b/app/api/deadlines/[id]/complete/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/deadlines/[id]/complete
@@ -23,7 +24,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
- return NextResponse.json({ error: fetchError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
// Toggle completion
@@ -40,7 +41,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
diff --git a/app/api/deadlines/[id]/route.ts b/app/api/deadlines/[id]/route.ts
index b53afee3..3a1dfb43 100644
--- a/app/api/deadlines/[id]/route.ts
+++ b/app/api/deadlines/[id]/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Sparse update: every Create field, optional. Validated — the previous
// implementation type-asserted the raw JSON, so malformed values reached
@@ -29,7 +30,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -80,7 +81,7 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -112,7 +113,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.maybeSingle()
if (fetchError) {
- return NextResponse.json({ error: fetchError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
if (!existing) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
@@ -128,7 +129,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.select('id')
if (dismissError) {
- return NextResponse.json({ error: dismissError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(dismissError) }, { status: 500 })
}
// The row can vanish between lookup and update (generator cleanup
// during a concurrent regeneration); report 404 rather than a
@@ -146,7 +147,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Zero rows = wrong id / another company's deadline — not a success.
if (count === 0) {
diff --git a/app/api/deadlines/route.ts b/app/api/deadlines/route.ts
index 5d7cb84d..a0cf4c44 100644
--- a/app/api/deadlines/route.ts
+++ b/app/api/deadlines/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/deadlines
@@ -52,7 +53,7 @@ export const GET = withRouteContext('deadline.list', async (request, ctx) => {
const { data, error } = await query.order('due_date', { ascending: true })
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -89,7 +90,7 @@ export const POST = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
diff --git a/app/api/dimensions/[id]/route.ts b/app/api/dimensions/[id]/route.ts
index 963a230c..73ec1908 100644
--- a/app/api/dimensions/[id]/route.ts
+++ b/app/api/dimensions/[id]/route.ts
@@ -14,6 +14,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateDimensionSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -68,7 +69,7 @@ export const PATCH = withRouteContext(
opLog.error('dimension update failed', error)
return errorResponseFromCode('DIMENSION_UPDATE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/dimensions/[id]/values/[valueId]/route.ts b/app/api/dimensions/[id]/values/[valueId]/route.ts
index 66dd5a86..f45f2317 100644
--- a/app/api/dimensions/[id]/values/[valueId]/route.ts
+++ b/app/api/dimensions/[id]/values/[valueId]/route.ts
@@ -16,6 +16,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateDimensionValueSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -82,7 +83,7 @@ export const PATCH = withRouteContext(
opLog.error('dimension value update failed', error)
return errorResponseFromCode('DIMENSION_VALUE_UPDATE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
@@ -118,7 +119,7 @@ export const DELETE = withRouteContext(
opLog.error('dimension value delete failed', error)
return errorResponseFromCode('DIMENSION_VALUE_DELETE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/dimensions/[id]/values/route.ts b/app/api/dimensions/[id]/values/route.ts
index d2014716..1a112a8e 100644
--- a/app/api/dimensions/[id]/values/route.ts
+++ b/app/api/dimensions/[id]/values/route.ts
@@ -14,6 +14,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateDimensionValueSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -81,7 +82,7 @@ export const POST = withRouteContext(
opLog.error('dimension value insert failed', error)
return errorResponseFromCode('DIMENSION_VALUE_CREATE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/dimensions/import-existing/route.ts b/app/api/dimensions/import-existing/route.ts
index 6c036222..9b315ec1 100644
Binary files a/app/api/dimensions/import-existing/route.ts and b/app/api/dimensions/import-existing/route.ts differ
diff --git a/app/api/dimensions/tagging/apply/route.ts b/app/api/dimensions/tagging/apply/route.ts
index 33c866da..c613946b 100644
--- a/app/api/dimensions/tagging/apply/route.ts
+++ b/app/api/dimensions/tagging/apply/route.ts
@@ -21,6 +21,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { DimensionTaggingApplySchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -53,7 +54,7 @@ export const POST = withRouteContext(
})
if (error) {
- failed.push({ line_id: lineId, error: error.message })
+ failed.push({ line_id: lineId, error: getUserErrorMessage(error) })
continue
}
diff --git a/app/api/documents/[id]/extraction-status/route.ts b/app/api/documents/[id]/extraction-status/route.ts
index 8849c517..be4be0e8 100644
--- a/app/api/documents/[id]/extraction-status/route.ts
+++ b/app/api/documents/[id]/extraction-status/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// GET /api/documents/:id/extraction-status
//
@@ -29,7 +30,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
.maybeSingle()
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const extractedAt = data.extracted_at as string | null
diff --git a/app/api/documents/[id]/inline/route.ts b/app/api/documents/[id]/inline/route.ts
index d90baa56..4d9e466d 100644
--- a/app/api/documents/[id]/inline/route.ts
+++ b/app/api/documents/[id]/inline/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { contentDisposition } from '@/lib/api/content-disposition'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/documents/:id/inline
@@ -83,7 +84,7 @@ export async function GET(
if (downloadError || !blob) {
return NextResponse.json(
- { error: `Failed to download document: ${downloadError?.message ?? 'unknown error'}` },
+ { error: `Failed to download document: ${getUserErrorMessage(downloadError) ?? 'unknown error'}` },
{ status: 500 }
)
}
diff --git a/app/api/documents/[id]/link/route.ts b/app/api/documents/[id]/link/route.ts
index 8403391f..8b8ed2ca 100644
--- a/app/api/documents/[id]/link/route.ts
+++ b/app/api/documents/[id]/link/route.ts
@@ -4,6 +4,7 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { LinkDocumentSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -73,7 +74,7 @@ export const POST = withRouteContext(
// Non-fatal: the verifikat ↔ underlag link already succeeded.
opLog.warn('inbox item stamp after link failed', {
inboxItemId: body.inbox_item_id,
- reason: inboxError.message,
+ reason: getUserErrorMessage(inboxError),
})
} else if (!stamped || stamped.length === 0) {
// Zero rows updated means the supplied inbox_item_id / document_id
@@ -100,7 +101,7 @@ export const POST = withRouteContext(
// pin is row-level UX on the /transactions list.
opLog.warn('transaction pin after link failed', {
transactionId: body.transaction_id,
- reason: pinError.message,
+ reason: getUserErrorMessage(pinError),
})
}
}
@@ -125,7 +126,7 @@ export const POST = withRouteContext(
}
return errorResponseFromCode('DOC_LINK_FAILED', opLog, {
requestId,
- details: { reason: message || 'unknown' },
+ details: { reason: getUserErrorMessage(err) },
})
}
},
diff --git a/app/api/documents/[id]/route.ts b/app/api/documents/[id]/route.ts
index d3067773..8e16adfd 100644
--- a/app/api/documents/[id]/route.ts
+++ b/app/api/documents/[id]/route.ts
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { deleteDocument } from '@/lib/core/documents/document-service'
import { eventBus } from '@/lib/events'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -34,7 +35,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
if (signError) {
return NextResponse.json(
- { error: `Failed to create download URL: ${signError.message}` },
+ { error: `Failed to create download URL: ${getUserErrorMessage(signError)}` },
{ status: 500 }
)
}
@@ -80,7 +81,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (error) {
console.error('[documents/DELETE] Failed to delete document:', error)
return NextResponse.json(
- { error: error instanceof Error ? error.message : 'Failed to delete document' },
+ { error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to delete document' },
{ status: 500 }
)
}
diff --git a/app/api/documents/[id]/verify/route.ts b/app/api/documents/[id]/verify/route.ts
index e1da1c3e..8c80572e 100644
--- a/app/api/documents/[id]/verify/route.ts
+++ b/app/api/documents/[id]/verify/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { verifyIntegrity } from '@/lib/core/documents/document-service'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -21,7 +22,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (error) {
console.error('[documents/verify/POST] Verification failed:', error)
return NextResponse.json(
- { error: error instanceof Error ? error.message : 'Verification failed' },
+ { error: error instanceof Error ? getUserErrorMessage(error) : 'Verification failed' },
{ status: 500 }
)
}
diff --git a/app/api/documents/[id]/versions/route.ts b/app/api/documents/[id]/versions/route.ts
index ab3bdeab..fb8c0293 100644
--- a/app/api/documents/[id]/versions/route.ts
+++ b/app/api/documents/[id]/versions/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createNewVersion, validateDocumentFile } from '@/lib/core/documents/document-service'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -42,7 +43,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (error) {
console.error('[documents/versions/POST] Version creation failed:', error)
return NextResponse.json(
- { error: error instanceof Error ? error.message : 'Version creation failed' },
+ { error: error instanceof Error ? getUserErrorMessage(error) : 'Version creation failed' },
{ status: 500 }
)
}
@@ -83,7 +84,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.order('version', { ascending: true })
if (versionsError) {
- return NextResponse.json({ error: versionsError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(versionsError) }, { status: 500 })
}
return NextResponse.json({ data: versions })
diff --git a/app/api/documents/counts/route.ts b/app/api/documents/counts/route.ts
index 952cd374..6d070d85 100644
--- a/app/api/documents/counts/route.ts
+++ b/app/api/documents/counts/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
@@ -34,7 +35,7 @@ export const GET = withRouteContext('document.counts', async (request, ctx) => {
.in('journal_entry_id', ids)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Group and count by journal_entry_id
diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts
index 446c593a..77e57ee8 100644
--- a/app/api/documents/route.ts
+++ b/app/api/documents/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { DocumentUploadSource } from '@/types'
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(message)) {
return errorResponseFromCode('DOC_UPLOAD_PERIOD_LOCKED', opLog, {
requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err) },
})
}
// Magic-byte validation rejections (validateDocumentMagicBytes) are a
@@ -88,7 +89,7 @@ export const POST = withRouteContext(
opLog.warn('document upload rejected by content validation', { reason: message })
return errorResponseFromCode('DOC_UPLOAD_INVALID_CONTENT', opLog, {
requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err) },
})
}
// Full error is logged above; the raw message can leak storage-layer
diff --git a/app/api/events/route.ts b/app/api/events/route.ts
index f7b1fd97..8f7628b7 100644
--- a/app/api/events/route.ts
+++ b/app/api/events/route.ts
@@ -5,6 +5,7 @@ import { validateQuery } from '@/lib/api/validate'
import { EventsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { SupabaseClient } from '@supabase/supabase-js'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/events
@@ -80,7 +81,7 @@ export async function GET(request: Request) {
const { data, error } = await query
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const events = data ?? []
diff --git a/app/api/extensions/[sector]/[slug]/data/route.ts b/app/api/extensions/[sector]/[slug]/data/route.ts
index 3c05fd7f..ceb2601a 100644
--- a/app/api/extensions/[sector]/[slug]/data/route.ts
+++ b/app/api/extensions/[sector]/[slug]/data/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>(
'extension.data.get',
@@ -28,7 +29,7 @@ export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: st
const { data, error } = await query
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -65,7 +66,7 @@ export const POST = withRouteContext<{ params: Promise<{ sector: string; slug: s
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -95,7 +96,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ sector: string; slug:
.eq('key', key)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ success: true })
diff --git a/app/api/extensions/[sector]/[slug]/settings/route.ts b/app/api/extensions/[sector]/[slug]/settings/route.ts
index fcf30146..2c63bb2f 100644
--- a/app/api/extensions/[sector]/[slug]/settings/route.ts
+++ b/app/api/extensions/[sector]/[slug]/settings/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>(
'extension.settings.get',
@@ -55,7 +56,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ sector: string; slug:
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: data.value })
diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts
index 314a0cad..5d84f57a 100644
--- a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts
+++ b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts
@@ -270,7 +270,7 @@ describe('cloud-backup auto-sync cron', () => {
companyId: 'c-1',
kind: 'repeated_failures',
consecutiveFailures: 3,
- errorMessage: 'Drive upload failed: 500',
+ errorMessage: 'Något gick fel. Försök igen.',
})
)
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
@@ -330,7 +330,7 @@ describe('cloud-backup auto-sync cron', () => {
)
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
expect((value as any).last_auto_sync_status).toBe('error')
- expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded')
+ expect((value as any).last_auto_sync_error).toBe('Något gick fel. Försök igen.')
expect((value as any).consecutive_failures).toBe(3)
})
diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts
index 3fca910b..e496d6a9 100644
--- a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts
+++ b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts
@@ -2,6 +2,7 @@ import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import {
performSync,
CONNECTION_KEY,
@@ -210,6 +211,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
const consecutiveFailures = syncResult.ok
? 0
: (schedule.consecutive_failures ?? 0) + 1
+ const safeSyncError = syncResult.ok ? null : getErrorMessage(syncResult.message)
let lastAlertAt = schedule.last_alert_at ?? null
if (!syncResult.ok) {
lastAlertAt = await maybeAlert({
@@ -217,7 +219,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
userId,
kind: syncResult.reason === 'needs_reauth' ? 'needs_reauth' : 'repeated_failures',
consecutiveFailures,
- errorMessage: syncResult.message,
+ errorMessage: safeSyncError,
lastAlertAt,
})
}
@@ -226,7 +228,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
...schedule,
last_auto_sync_at: new Date().toISOString(),
last_auto_sync_status: syncResult.ok ? 'success' : 'error',
- last_auto_sync_error: syncResult.ok ? null : syncResult.message,
+ last_auto_sync_error: safeSyncError,
consecutive_failures: consecutiveFailures,
last_alert_at: lastAlertAt,
}
@@ -235,10 +237,10 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
results.push({
companyId,
status: syncResult.ok ? 'success' : 'error',
- error: syncResult.ok ? undefined : syncResult.message,
+ error: safeSyncError ?? undefined,
})
} catch (err) {
- const message = err instanceof Error ? err.message : 'Unknown error'
+ const safeMessage = getErrorMessage(err)
ctx.log.error('cloud backup sync failed for company', err as Error, {
companyId,
})
@@ -249,7 +251,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
userId,
kind: 'repeated_failures',
consecutiveFailures,
- errorMessage: message.slice(0, 200),
+ errorMessage: safeMessage.slice(0, 200),
lastAlertAt: schedule.last_alert_at,
})
@@ -257,7 +259,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
...schedule,
last_auto_sync_at: new Date().toISOString(),
last_auto_sync_status: 'error',
- last_auto_sync_error: message.slice(0, 200),
+ last_auto_sync_error: safeMessage.slice(0, 200),
consecutive_failures: consecutiveFailures,
last_alert_at: lastAlertAt,
}
@@ -267,7 +269,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
},
)
- results.push({ companyId, status: 'error', error: message })
+ results.push({ companyId, status: 'error', error: safeMessage })
}
}
diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts
index 96bb9c77..94b3c371 100644
--- a/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts
+++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts
@@ -459,7 +459,10 @@ describe('AGI kvittenser cron', () => {
const body = await res.json()
expect(body.errors).toBe(1)
- expect(body.results[0]).toMatchObject({ status: 'error', error: 'fetch failed' })
+ expect(body.results[0]).toMatchObject({
+ status: 'error',
+ error: 'Något gick fel. Försök igen.',
+ })
expect(errorRecorder).toHaveBeenCalledTimes(1)
expect(String(errorRecorder.mock.calls[0][0])).toContain('Reconciliation failed')
})
diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts
index 9e60e4cf..e1520d95 100644
--- a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts
+++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts
@@ -11,6 +11,7 @@ import { reconcileAgiDeclaration } from '@/extensions/general/skatteverket/lib/a
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -204,7 +205,7 @@ export async function GET(request: Request) {
}
log.error('Reconciliation failed', { declarationId, companyId, period, message })
- results.push({ declarationId, period, status: 'error', error: message })
+ results.push({ declarationId, period, status: 'error', error: getErrorMessage(err) })
}
}
diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts
index 6f82e870..1da7373f 100644
--- a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts
+++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts
@@ -12,6 +12,7 @@ import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
import { listVerifiedCompanies, markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { currentSkvEnvironment, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth'
ensureInitialized()
@@ -261,7 +262,7 @@ export async function GET(request: Request) {
message,
felkod,
})
- results.push({ userId, companyId, source, status: 'error', error: message })
+ results.push({ userId, companyId, source, status: 'error', error: getErrorMessage(err) })
}
}
diff --git a/app/api/extensions/skatteverket/vat/kvittenser/cron/__tests__/route.test.ts b/app/api/extensions/skatteverket/vat/kvittenser/cron/__tests__/route.test.ts
index 6cbbefea..282108e1 100644
--- a/app/api/extensions/skatteverket/vat/kvittenser/cron/__tests__/route.test.ts
+++ b/app/api/extensions/skatteverket/vat/kvittenser/cron/__tests__/route.test.ts
@@ -298,7 +298,10 @@ describe('VAT kvittenser cron', () => {
const body = await res.json()
expect(body.errors).toBe(1)
- expect(body.results[0]).toMatchObject({ status: 'error', error: 'fetch failed' })
+ expect(body.results[0]).toMatchObject({
+ status: 'error',
+ error: 'Något gick fel. Försök igen.',
+ })
expect(errorSpy).toHaveBeenCalledTimes(1)
})
diff --git a/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts
index eb88a076..e4da210d 100644
--- a/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts
+++ b/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts
@@ -12,6 +12,7 @@ import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { SkatteverketInlamnatResponse } from '@/extensions/general/skatteverket/types'
import type { VatPeriodType } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -183,7 +184,7 @@ export async function GET(request: Request) {
companyId,
period,
status: 'error',
- error: `Failed to persist signed state: ${updateError.message}`,
+ error: `Failed to persist signed state: ${getUserErrorMessage(updateError)}`,
})
continue
}
@@ -283,7 +284,7 @@ export async function GET(request: Request) {
}
console.error('[vat-kvittenser-cron] Reconciliation failed', { companyId, period, message })
- results.push({ companyId, period, status: 'error', error: message })
+ results.push({ companyId, period, status: 'error', error: getUserErrorMessage(err) })
}
}
diff --git a/app/api/import/articles/execute/route.ts b/app/api/import/articles/execute/route.ts
index 51de3076..32e0dcca 100644
--- a/app/api/import/articles/execute/route.ts
+++ b/app/api/import/articles/execute/route.ts
@@ -10,6 +10,7 @@ import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { checkRevenueAccount, type RevenueAccountStatus } from '@/lib/articles/validate-revenue-account'
import type { Article } from '@/types'
import type { ArticleImportExecuteResult } from '@/lib/import/articles/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -140,7 +141,7 @@ export const POST = withRouteContext(
.single()
if (error) {
- errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
+ errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) updated.push(data as Article)
@@ -176,7 +177,7 @@ export const POST = withRouteContext(
skipped++
continue
}
- errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
+ errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
@@ -225,7 +226,7 @@ export const POST = withRouteContext(
opLog.error('article import execute failed', err as Error)
return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/articles/parse/route.ts b/app/api/import/articles/parse/route.ts
index 8fb52df4..c2ed0785 100644
--- a/app/api/import/articles/parse/route.ts
+++ b/app/api/import/articles/parse/route.ts
@@ -9,6 +9,7 @@ import type {
ArticleImportParseResult,
DetectedArticleColumns,
} from '@/lib/import/articles/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
@@ -127,7 +128,7 @@ export const POST = withRouteContext(
opLog.error('article import parse failed', err as Error)
return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts
index 061b4f60..ae3f9c0d 100644
--- a/app/api/import/bank-file/execute/route.ts
+++ b/app/api/import/bank-file/execute/route.ts
@@ -9,6 +9,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -87,7 +88,7 @@ export const POST = withRouteContext(
opLog.error('failed to create bank_file_imports record', importError)
return errorResponseFromCode('BANK_FILE_IMPORT_RECORD_FAILED', opLog, {
requestId,
- details: { reason: importError.message },
+ details: { reason: getUserErrorMessage(importError) },
})
}
@@ -164,7 +165,7 @@ export const POST = withRouteContext(
opLog.error('bank file execute failed', err as Error)
return errorResponseFromCode('BANK_FILE_EXECUTE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/bank-file/parse/route.ts b/app/api/import/bank-file/parse/route.ts
index 3fe4a7ef..9d8953dc 100644
--- a/app/api/import/bank-file/parse/route.ts
+++ b/app/api/import/bank-file/parse/route.ts
@@ -4,6 +4,7 @@ import { decodeFileContent } from '@/lib/import/shared/encoding'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/import/bank-file/parse
@@ -91,7 +92,7 @@ export const POST = withRouteContext(
opLog.error('bank file parse failed', err as Error)
return errorResponseFromCode('BANK_FILE_PARSE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/customers/execute/route.ts b/app/api/import/customers/execute/route.ts
index 0e2b3b17..8bf939fb 100644
--- a/app/api/import/customers/execute/route.ts
+++ b/app/api/import/customers/execute/route.ts
@@ -9,6 +9,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types'
import type { CustomerImportExecuteResult } from '@/lib/import/customers/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -126,7 +127,7 @@ export const POST = withRouteContext(
.single()
if (error) {
- errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
+ errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) updated.push(data as Customer)
@@ -162,7 +163,7 @@ export const POST = withRouteContext(
skipped++
continue
}
- errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
+ errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) {
@@ -200,7 +201,7 @@ export const POST = withRouteContext(
opLog.error('customer import execute failed', err as Error)
return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/customers/parse/route.ts b/app/api/import/customers/parse/route.ts
index 8ee58651..5d37f6b0 100644
--- a/app/api/import/customers/parse/route.ts
+++ b/app/api/import/customers/parse/route.ts
@@ -9,6 +9,7 @@ import type {
CustomerImportParseResult,
DetectedCustomerColumns,
} from '@/lib/import/customers/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
@@ -113,7 +114,7 @@ export const POST = withRouteContext(
opLog.error('customer import parse failed', err as Error)
return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/opening-balance/correct/route.ts b/app/api/import/opening-balance/correct/route.ts
index 192efe1b..f390a5f4 100644
--- a/app/api/import/opening-balance/correct/route.ts
+++ b/app/api/import/opening-balance/correct/route.ts
@@ -11,6 +11,7 @@ import {
} from '@/lib/import/opening-balance/execute-helpers'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -244,7 +245,11 @@ export const POST = withRouteContext(
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
- details: { reason, newEntryId: newEntry.id, oldEntryId },
+ details: {
+ reason: getUserErrorMessage(seqErr),
+ newEntryId: newEntry.id,
+ oldEntryId,
+ },
})
}
@@ -267,7 +272,7 @@ export const POST = withRouteContext(
if (/Bokföringen är låst/i.test(message)) {
return errorResponseFromCode('OB_COMPANY_LOCK_DATE', opLog, {
requestId,
- details: { reason: message },
+ details: { reason: getUserErrorMessage(err) },
})
}
if (isBookkeepingError(err)) {
@@ -276,7 +281,7 @@ export const POST = withRouteContext(
opLog.error('opening balance correct failed', err as Error)
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/opening-balance/execute/route.ts b/app/api/import/opening-balance/execute/route.ts
index 79461701..6df900e5 100644
--- a/app/api/import/opening-balance/execute/route.ts
+++ b/app/api/import/opening-balance/execute/route.ts
@@ -11,6 +11,7 @@ import {
} from '@/lib/import/opening-balance/execute-helpers'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -129,7 +130,7 @@ export const POST = withRouteContext(
opLog.error('opening balance execute failed', err as Error)
return errorResponseFromCode('OB_EXECUTE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/opening-balance/parse/route.ts b/app/api/import/opening-balance/parse/route.ts
index a53c28ab..13875f9b 100644
--- a/app/api/import/opening-balance/parse/route.ts
+++ b/app/api/import/opening-balance/parse/route.ts
@@ -3,6 +3,7 @@ import { parseOpeningBalanceFile } from '@/lib/import/opening-balance/parser'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { DetectedColumns } from '@/lib/import/opening-balance/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
@@ -60,7 +61,7 @@ export const POST = withRouteContext(
opLog.error('opening balance parse failed', err as Error)
return errorResponseFromCode('OB_PARSE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/sie/[id]/route.ts b/app/api/import/sie/[id]/route.ts
index e00bd5e0..2af3081d 100644
--- a/app/api/import/sie/[id]/route.ts
+++ b/app/api/import/sie/[id]/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/import/sie/[id]
@@ -18,7 +19,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (!data) {
@@ -68,7 +69,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ success: true })
diff --git a/app/api/import/sie/create-accounts/route.ts b/app/api/import/sie/create-accounts/route.ts
index de7f77ad..84f9b7cb 100644
--- a/app/api/import/sie/create-accounts/route.ts
+++ b/app/api/import/sie/create-accounts/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import type { SIEAccount } from '@/lib/import/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* Determine account type based on account class (first digit)
@@ -106,7 +107,7 @@ export const POST = withRouteContext(
if (error) {
console.error('Error upserting accounts batch:', error)
return NextResponse.json({
- error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${error.message}. ${totalCreated} konton skapades innan felet.`,
+ error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${getUserErrorMessage(error)}. ${totalCreated} konton skapades innan felet.`,
created: totalCreated,
}, { status: 500 })
}
@@ -123,7 +124,7 @@ export const POST = withRouteContext(
} catch (error) {
console.error('Create accounts error:', error)
return NextResponse.json(
- { error: `Kunde inte skapa konton: ${error instanceof Error ? error.message : 'Okänt fel'}. Försök igen.` },
+ { error: `Kunde inte skapa konton: ${error instanceof Error ? getUserErrorMessage(error) : 'Okänt fel'}. Försök igen.` },
{ status: 500 }
)
}
diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts
index 8a112a33..4d7bc10f 100644
--- a/app/api/import/sie/execute/route.ts
+++ b/app/api/import/sie/execute/route.ts
@@ -6,6 +6,7 @@ import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// SIE imports with many vouchers need extended execution time
export const maxDuration = 300
@@ -125,7 +126,7 @@ export const POST = withRouteContext(
opLog.error('sie execute unexpected error', err as Error)
return errorResponseFromCode('SIE_IMPORT_UNEXPECTED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/sie/mappings/route.ts b/app/api/import/sie/mappings/route.ts
index 4a38401a..32093fb4 100644
--- a/app/api/import/sie/mappings/route.ts
+++ b/app/api/import/sie/mappings/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { saveMappings } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/import/sie/mappings
@@ -17,7 +18,7 @@ export const GET = withRouteContext(
.order('source_account')
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -43,7 +44,7 @@ export const POST = withRouteContext(
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json(
- { error: error instanceof Error ? error.message : 'Failed to save mappings' },
+ { error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to save mappings' },
{ status: 500 }
)
}
@@ -84,7 +85,7 @@ export const PUT = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -111,7 +112,7 @@ export const DELETE = withRouteContext(
.eq('source_account', sourceAccount)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
} else {
// Delete all mappings
@@ -121,7 +122,7 @@ export const DELETE = withRouteContext(
.eq('company_id', companyId)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
}
diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts
index 8b7cc951..8d50aebd 100644
--- a/app/api/import/sie/parse/route.ts
+++ b/app/api/import/sie/parse/route.ts
@@ -12,6 +12,7 @@ import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { SIEAccountMappingRecord } from '@/lib/import/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/import/sie/parse
@@ -143,7 +144,7 @@ export const POST = withRouteContext(
opLog.error('sie parse failed', err as Error)
return errorResponseFromCode('SIE_PARSE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/sie/route.ts b/app/api/import/sie/route.ts
index 3fe1d965..b2853bc7 100644
--- a/app/api/import/sie/route.ts
+++ b/app/api/import/sie/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/import/sie
@@ -28,7 +29,7 @@ export const GET = withRouteContext(
const { data, error, count } = await query
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
diff --git a/app/api/import/suppliers/execute/route.ts b/app/api/import/suppliers/execute/route.ts
index f659e914..09a74f0c 100644
--- a/app/api/import/suppliers/execute/route.ts
+++ b/app/api/import/suppliers/execute/route.ts
@@ -9,6 +9,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Supplier } from '@/types'
import type { SupplierImportExecuteResult } from '@/lib/import/suppliers/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -110,7 +111,7 @@ export const POST = withRouteContext(
.single()
if (error) {
- errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
+ errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) updated.push(data as Supplier)
@@ -150,7 +151,7 @@ export const POST = withRouteContext(
skipped++
continue
}
- errors.push({ row_index: row.row_index, name: row.name, reason: error.message })
+ errors.push({ row_index: row.row_index, name: row.name, reason: getUserErrorMessage(error) })
continue
}
if (data) {
@@ -185,7 +186,7 @@ export const POST = withRouteContext(
opLog.error('supplier import execute failed', err as Error)
return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/import/suppliers/parse/route.ts b/app/api/import/suppliers/parse/route.ts
index c3c10576..e9ac4425 100644
--- a/app/api/import/suppliers/parse/route.ts
+++ b/app/api/import/suppliers/parse/route.ts
@@ -9,6 +9,7 @@ import type {
SupplierImportParseResult,
DetectedSupplierColumns,
} from '@/lib/import/suppliers/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
@@ -106,7 +107,7 @@ export const POST = withRouteContext(
opLog.error('supplier import parse failed', err as Error)
return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/invoices/[id]/convert/__tests__/route.test.ts b/app/api/invoices/[id]/convert/__tests__/route.test.ts
index 29bed643..dd3d8b99 100644
--- a/app/api/invoices/[id]/convert/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/convert/__tests__/route.test.ts
@@ -160,7 +160,7 @@ describe('POST /api/invoices/[id]/convert', () => {
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
- expect(body.error).toContain('cancel failed')
+ expect(body.error).toBe('Något gick fel. Försök igen.')
// Counter must not have been touched and orphan invoice must have been
// deleted (5 enqueued calls all consumed).
expect(mockSupabase.rpc).not.toHaveBeenCalled()
diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts
index eff07f0e..f00e2aff 100644
--- a/app/api/invoices/[id]/convert/route.ts
+++ b/app/api/invoices/[id]/convert/route.ts
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import type { Invoice } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -82,7 +83,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (invoiceError) {
- return NextResponse.json({ error: invoiceError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(invoiceError) }, { status: 500 })
}
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; dimensions?: Record }) => ({
@@ -104,7 +105,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
- return NextResponse.json({ error: itemsError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(itemsError) }, { status: 500 })
}
}
@@ -119,7 +120,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (cancelError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
- return NextResponse.json({ error: cancelError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(cancelError) }, { status: 500 })
}
// Allocate the F-series number last. If allocation fails, restore the
@@ -134,7 +135,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('id', id)
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Failed to assign invoice number' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to assign invoice number' },
{ status: 500 }
)
}
diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts
index 05f47dd0..ae2d07e0 100644
--- a/app/api/invoices/[id]/pdf/route.ts
+++ b/app/api/invoices/[id]/pdf/route.ts
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.pdf',
@@ -95,7 +96,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (error) {
console.error('PDF generation error:', error)
return NextResponse.json(
- { error: error instanceof Error ? error.message : 'PDF generation failed' },
+ { error: error instanceof Error ? getUserErrorMessage(error) : 'PDF generation failed' },
{ status: 500 }
)
}
diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts
index a2fd3b1b..ec8f65c8 100644
--- a/app/api/invoices/[id]/route.ts
+++ b/app/api/invoices/[id]/route.ts
@@ -8,6 +8,7 @@ import { UpdateInvoiceSchema } from '@/lib/api/schemas'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import type { InvoiceDocumentType } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized() // Module-level: wires the audit-log handler for invoice.draft_deleted.
@@ -220,7 +221,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
ctxLog.error('invoice update failed', updateError, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', ctxLog, {
requestId,
- details: { pgCode: updateError.code, pgMessage: updateError.message },
+ details: { pgCode: updateError.code, pgMessage: getUserErrorMessage(updateError) },
})
}
if (!updated || updated.length === 0) {
@@ -239,7 +240,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
ctxLog.error('invoice items delete failed on update', deleteItemsError, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
requestId,
- details: { pgCode: deleteItemsError.code, pgMessage: deleteItemsError.message },
+ details: { pgCode: deleteItemsError.code, pgMessage: getUserErrorMessage(deleteItemsError) },
})
}
@@ -250,7 +251,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
ctxLog.error('invoice items insert failed on update', itemsError, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
requestId,
- details: { pgCode: itemsError.code, pgMessage: itemsError.message },
+ details: { pgCode: itemsError.code, pgMessage: getUserErrorMessage(itemsError) },
})
}
diff --git a/app/api/invoices/recurring/cron/route.ts b/app/api/invoices/recurring/cron/route.ts
index c9aba280..6b04bd59 100644
--- a/app/api/invoices/recurring/cron/route.ts
+++ b/app/api/invoices/recurring/cron/route.ts
@@ -13,6 +13,7 @@ import type {
RecurringInvoiceSchedule,
RecurringInvoiceScheduleItem,
} from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -57,7 +58,7 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
if (error) {
ctx.log.error('failed to load due recurring schedules', error)
return NextResponse.json(
- { success: false, error: error.message },
+ { success: false, error: getUserErrorMessage(error) },
{ status: 500 },
)
}
diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts
index c81f96dc..6aba6106 100644
--- a/app/api/invoices/route.ts
+++ b/app/api/invoices/route.ts
@@ -10,6 +10,7 @@ import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Logger } from '@/lib/logger'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -151,7 +152,7 @@ export const POST = withRouteContext(
log.error('invoice insert failed', invoiceError)
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
- details: { pgCode: invoiceError.code, pgMessage: invoiceError.message },
+ details: { pgCode: invoiceError.code, pgMessage: getUserErrorMessage(invoiceError) },
})
}
@@ -167,7 +168,7 @@ export const POST = withRouteContext(
})
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
requestId,
- details: { pgCode: itemsError.code, pgMessage: itemsError.message },
+ details: { pgCode: itemsError.code, pgMessage: getUserErrorMessage(itemsError) },
})
}
@@ -391,7 +392,7 @@ async function createCreditNote(
log.error('credit note insert failed', creditNoteError)
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
- details: { pgCode: creditNoteError.code, pgMessage: creditNoteError.message },
+ details: { pgCode: creditNoteError.code, pgMessage: getUserErrorMessage(creditNoteError) },
})
}
@@ -418,7 +419,7 @@ async function createCreditNote(
})
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
requestId,
- details: { pgCode: itemsError.code, pgMessage: itemsError.message },
+ details: { pgCode: itemsError.code, pgMessage: getUserErrorMessage(itemsError) },
})
}
diff --git a/app/api/kpi/preferences/route.ts b/app/api/kpi/preferences/route.ts
index 5936bf6f..8aaa0dcd 100644
--- a/app/api/kpi/preferences/route.ts
+++ b/app/api/kpi/preferences/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import type { KPIPreferences } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const EXTENSION_ID = 'core/kpi'
const KEY = 'preferences'
@@ -69,7 +70,7 @@ export const PUT = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: data.value })
diff --git a/app/api/onboarding/state/__tests__/route.test.ts b/app/api/onboarding/state/__tests__/route.test.ts
new file mode 100644
index 00000000..d087ec71
--- /dev/null
+++ b/app/api/onboarding/state/__tests__/route.test.ts
@@ -0,0 +1,112 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import {
+ createMockRequest,
+ createQueuedMockSupabase,
+ parseJsonResponse,
+} from '@/tests/helpers'
+
+const { supabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
+const requireAuthMock = vi.fn()
+const requireWriteMock = vi.fn()
+
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+
+import { GET, PATCH } from '../route'
+
+describe('/api/onboarding/state', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase,
+ error: null,
+ })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ })
+
+ it('returns 401 when the user is not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase,
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+
+ const response = await GET(createMockRequest('/api/onboarding/state'), {})
+ expect(response.status).toBe(401)
+ })
+
+ it('returns the persisted company setup state', async () => {
+ enqueue({
+ data: {
+ initial_setup_path: 'migration',
+ initial_setup_completed_at: null,
+ initial_setup_dismissed_at: null,
+ },
+ })
+
+ const { status, body } = await parseJsonResponse<{
+ data: { path: string; completedAt: string | null; dismissedAt: string | null }
+ }>(await GET(createMockRequest('/api/onboarding/state'), {}))
+
+ expect(status).toBe(200)
+ expect(body.data).toEqual({ path: 'migration', completedAt: null, dismissedAt: null })
+ })
+
+ it('returns 400 for an empty update', async () => {
+ const response = await PATCH(createMockRequest('/api/onboarding/state', {
+ method: 'PATCH',
+ body: {},
+ }), {})
+
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 404 when company settings do not exist', async () => {
+ enqueue({ data: null, error: null })
+ const response = await PATCH(createMockRequest('/api/onboarding/state', {
+ method: 'PATCH',
+ body: { path: 'bank' },
+ }), {})
+
+ expect(response.status).toBe(404)
+ })
+
+ it('persists a selected path and clears dismissal', async () => {
+ enqueueMany([
+ {
+ data: {
+ initial_setup_path: null,
+ initial_setup_completed_at: null,
+ initial_setup_dismissed_at: null,
+ },
+ },
+ {
+ data: {
+ initial_setup_path: 'bank',
+ initial_setup_completed_at: null,
+ initial_setup_dismissed_at: null,
+ },
+ },
+ ])
+
+ const { status, body } = await parseJsonResponse<{
+ data: { path: string; completedAt: string | null; dismissedAt: string | null }
+ }>(await PATCH(createMockRequest('/api/onboarding/state', {
+ method: 'PATCH',
+ body: { path: 'bank' },
+ }), {}))
+
+ expect(status).toBe(200)
+ expect(body.data).toEqual({ path: 'bank', completedAt: null, dismissedAt: null })
+ })
+})
diff --git a/app/api/onboarding/state/route.ts b/app/api/onboarding/state/route.ts
new file mode 100644
index 00000000..4fe6db68
--- /dev/null
+++ b/app/api/onboarding/state/route.ts
@@ -0,0 +1,118 @@
+import { NextResponse } from 'next/server'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { validateBody } from '@/lib/api/validate'
+import { UpdateInitialSetupStateSchema } from '@/lib/api/schemas'
+import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+
+const INITIAL_SETUP_SELECT =
+ 'initial_setup_path, initial_setup_completed_at, initial_setup_dismissed_at' as const
+
+function toResponse(data: {
+ initial_setup_path: string | null
+ initial_setup_completed_at: string | null
+ initial_setup_dismissed_at: string | null
+}) {
+ return {
+ path: data.initial_setup_path,
+ completedAt: data.initial_setup_completed_at,
+ dismissedAt: data.initial_setup_dismissed_at,
+ }
+}
+
+export const GET = withRouteContext(
+ 'onboarding-state.get',
+ async (_request, { supabase, companyId, log, requestId }) => {
+ const { data, error } = await supabase
+ .from('company_settings')
+ .select(INITIAL_SETUP_SELECT)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (error) {
+ log.error('initial setup state lookup failed', error)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(error) },
+ })
+ }
+ if (!data) return errorResponseFromCode('NOT_FOUND', log, { requestId })
+
+ return NextResponse.json({ data: toResponse(data) })
+ },
+)
+
+export const PATCH = withRouteContext(
+ 'onboarding-state.update',
+ async (request, { supabase, companyId, log, requestId }) => {
+ const validation = await validateBody(request, UpdateInitialSetupStateSchema, {
+ log,
+ operation: 'onboarding-state.update',
+ })
+ if (!validation.success) return validation.response
+ const body = validation.data
+
+ const { data: existing, error: lookupError } = await supabase
+ .from('company_settings')
+ .select(INITIAL_SETUP_SELECT)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (lookupError) {
+ log.error('initial setup state lookup failed', lookupError)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(lookupError) },
+ })
+ }
+ if (!existing) return errorResponseFromCode('NOT_FOUND', log, { requestId })
+
+ const effectivePath = body.path !== undefined ? body.path : existing.initial_setup_path
+ if (body.completed === true && !effectivePath) {
+ return NextResponse.json(
+ {
+ error: 'Validation failed',
+ type: 'validation_error',
+ errors: [{
+ field: 'completed',
+ message: 'Välj först hur du vill komma igång',
+ code: 'custom',
+ }],
+ },
+ { status: 400 },
+ )
+ }
+
+ const now = new Date().toISOString()
+ const update: Record = {}
+ if (body.path !== undefined) {
+ update.initial_setup_path = body.path
+ update.initial_setup_completed_at = body.path === 'fresh' ? now : null
+ update.initial_setup_dismissed_at = null
+ }
+ if (body.completed !== undefined) {
+ update.initial_setup_completed_at = body.completed ? now : null
+ }
+ if (body.dismissed !== undefined) {
+ update.initial_setup_dismissed_at = body.dismissed ? now : null
+ }
+
+ const { data, error } = await supabase
+ .from('company_settings')
+ .update(update)
+ .eq('company_id', companyId)
+ .select(INITIAL_SETUP_SELECT)
+ .single()
+
+ if (error) {
+ log.error('initial setup state update failed', error)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(error) },
+ })
+ }
+
+ return NextResponse.json({ data: toResponse(data) })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/pending-operations/[id]/__tests__/route.test.ts b/app/api/pending-operations/[id]/__tests__/route.test.ts
index 9d068e02..3dafdb35 100644
--- a/app/api/pending-operations/[id]/__tests__/route.test.ts
+++ b/app/api/pending-operations/[id]/__tests__/route.test.ts
@@ -416,7 +416,7 @@ describe('PATCH /api/pending-operations/[id]', () => {
)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
- expect(body.error).toMatch(/exceeds the maximum/)
+ expect(body.error).toBe('Något gick fel. Försök igen.')
})
it('returns 400 when mapping yields no accounts', async () => {
diff --git a/app/api/pending-operations/[id]/reject/route.ts b/app/api/pending-operations/[id]/reject/route.ts
index b509860f..ac742f9e 100644
--- a/app/api/pending-operations/[id]/reject/route.ts
+++ b/app/api/pending-operations/[id]/reject/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const RejectBodySchema = z.object({
rejection_category: z
@@ -89,7 +90,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('id', id)
if (updateError) {
- return NextResponse.json({ error: updateError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(updateError) }, { status: 500 })
}
return NextResponse.json({ data: { id, status: 'rejected' } })
diff --git a/app/api/pending-operations/[id]/route.ts b/app/api/pending-operations/[id]/route.ts
index 5612d85b..4b69d09c 100644
--- a/app/api/pending-operations/[id]/route.ts
+++ b/app/api/pending-operations/[id]/route.ts
@@ -6,6 +6,7 @@ import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib
import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entries'
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// PATCH /api/pending-operations/[id]
//
@@ -56,7 +57,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
body = PatchSchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Invalid body' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 },
)
}
@@ -162,7 +163,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
)
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Ogiltig momsjustering' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Ogiltig momsjustering' },
{ status: 400 },
)
}
@@ -210,7 +211,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
.select('id, params, preview_data, title, status')
.single()
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data: updated })
},
diff --git a/app/api/pending-operations/__tests__/route.test.ts b/app/api/pending-operations/__tests__/route.test.ts
index 889e9d82..8c128251 100644
--- a/app/api/pending-operations/__tests__/route.test.ts
+++ b/app/api/pending-operations/__tests__/route.test.ts
@@ -1,101 +1,94 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
import {
createMockRequest,
- parseJsonResponse,
createQueuedMockSupabase,
+ parseJsonResponse,
} from '@/tests/helpers'
-const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
-vi.mock('@/lib/supabase/server', () => ({
- createClient: () => Promise.resolve(mockSupabase),
+const { supabase, enqueue, reset } = createQueuedMockSupabase()
+const requireAuthMock = vi.fn()
+
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
- requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
+}))
+
+vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
+
import { GET } from '../route'
describe('GET /api/pending-operations', () => {
- const mockUser = { id: 'user-1', email: 'test@test.se' }
-
- const sampleOps = [
- {
- id: 'op-1',
- user_id: 'user-1',
- operation_type: 'categorize_transaction',
- status: 'pending',
- title: 'Kategorisera: CLAS OHLSON -523 SEK',
- params: { transaction_id: 'tx-1', category: 'expense_office' },
- preview_data: { debit_account: '6100', credit_account: '1930', amount: 523 },
- result_data: null,
- created_at: '2026-03-25T10:00:00Z',
- },
- {
- id: 'op-2',
- user_id: 'user-1',
- operation_type: 'create_customer',
- status: 'pending',
- title: 'Ny kund: Acme AB',
- params: { name: 'Acme AB', customer_type: 'swedish_business' },
- preview_data: { name: 'Acme AB', customer_type: 'swedish_business' },
- result_data: null,
- created_at: '2026-03-25T10:01:00Z',
- },
- ]
-
beforeEach(() => {
vi.clearAllMocks()
reset()
- mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
it('returns 401 when not authenticated', async () => {
- mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
+ requireAuthMock.mockResolvedValue({
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
- const request = createMockRequest('/api/pending-operations')
- const response = await GET(request)
- const { status, body } = await parseJsonResponse(response)
+ const response = await GET(
+ createMockRequest('/api/pending-operations'),
+ { params: Promise.resolve({}) },
+ )
- expect(status).toBe(401)
- expect(body).toEqual({ error: 'Unauthorized' })
+ expect(response.status).toBe(401)
})
- it('returns pending operations', async () => {
- enqueue({ data: sampleOps, count: 2 })
+ it('returns 400 for an invalid status', async () => {
+ const response = await GET(
+ createMockRequest('/api/pending-operations', {
+ searchParams: { status: 'unknown' },
+ }),
+ { params: Promise.resolve({}) },
+ )
- const request = createMockRequest('/api/pending-operations')
- const response = await GET(request)
+ expect(response.status).toBe(400)
+ expect(supabase.from).not.toHaveBeenCalled()
+ })
+
+ it('returns the active list and all tab counts in one response', async () => {
+ enqueue({ data: [{ id: 'operation-1', status: 'pending' }], count: 12 })
+ enqueue({ count: 3 })
+ enqueue({ count: 4 })
+
+ const response = await GET(
+ createMockRequest('/api/pending-operations'),
+ { params: Promise.resolve({}) },
+ )
const { status, body } = await parseJsonResponse<{
- data: typeof sampleOps
+ data: Array<{ id: string }>
count: number
+ counts: { pending: number; committed: number; rejected: number }
}>(response)
expect(status).toBe(200)
- expect(body.data).toHaveLength(2)
- expect(body.count).toBe(2)
+ expect(body.data).toEqual([{ id: 'operation-1', status: 'pending' }])
+ expect(body.count).toBe(12)
+ expect(body.counts).toEqual({ pending: 12, committed: 3, rejected: 4 })
+ expect(supabase.from).toHaveBeenCalledTimes(3)
})
- it('filters by status parameter', async () => {
- enqueue({ data: [], count: 0 })
+ it('returns 500 when the list query fails', async () => {
+ enqueue({ error: { message: 'database unavailable' } })
+ enqueue({ count: 0 })
+ enqueue({ count: 0 })
- const request = createMockRequest('/api/pending-operations', {
- searchParams: { status: 'committed' },
- })
- const response = await GET(request)
- const { status } = await parseJsonResponse(response)
+ const response = await GET(
+ createMockRequest('/api/pending-operations'),
+ { params: Promise.resolve({}) },
+ )
- expect(status).toBe(200)
- })
-
- it('rejects invalid status parameter', async () => {
- const request = createMockRequest('/api/pending-operations', {
- searchParams: { status: 'invalid' },
- })
- const response = await GET(request)
- const { status } = await parseJsonResponse(response)
-
- expect(status).toBe(400)
+ expect(response.status).toBe(500)
})
})
diff --git a/app/api/pending-operations/route.ts b/app/api/pending-operations/route.ts
index 688e4fd0..e7cd60c2 100644
--- a/app/api/pending-operations/route.ts
+++ b/app/api/pending-operations/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { PendingOperationsQuerySchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/pending-operations
@@ -22,7 +23,7 @@ export const GET = withRouteContext(
// newer rejections and the "Utgick automatiskt" context would never be seen.
const orderColumn = status === 'pending' ? 'created_at' : 'resolved_at'
- const { data, error, count } = await supabase
+ const listPromise = supabase
.from('pending_operations')
.select('*', { count: 'exact' })
.eq('company_id', companyId)
@@ -30,10 +31,38 @@ export const GET = withRouteContext(
.order(orderColumn, { ascending: false, nullsFirst: false })
.range(offset, offset + limit - 1)
+ // Return every tab count with the active list. The browser previously
+ // called this authenticated route four times, repeating auth and company
+ // resolution for one list plus three counters. The active list already
+ // supplies its own exact count, so only the other two lightweight head
+ // queries are needed here.
+ const statuses = ['pending', 'committed', 'rejected'] as const
+ const otherStatuses = statuses.filter((candidate) => candidate !== status)
+ const [listResult, ...otherCountResults] = await Promise.all([
+ listPromise,
+ ...otherStatuses.map((candidate) =>
+ supabase
+ .from('pending_operations')
+ .select('id', { count: 'exact', head: true })
+ .eq('company_id', companyId)
+ .eq('status', candidate),
+ ),
+ ])
+
+ const { data, error, count } = listResult
+
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
- return NextResponse.json({ data: data ?? [], count })
+ const counts: Partial> = {
+ [status]: count ?? 0,
+ }
+ otherStatuses.forEach((candidate, index) => {
+ const result = otherCountResults[index]
+ if (!result.error) counts[candidate] = result.count ?? 0
+ })
+
+ return NextResponse.json({ data: data ?? [], count, counts })
},
)
diff --git a/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts b/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts
index c2ae075e..64da2846 100644
--- a/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts
+++ b/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/reports/ar-ledger/customer/[customerId]/invoices
@@ -56,7 +57,7 @@ export const GET = withRouteContext<{ params: Promise<{ customerId: string }> }>
.limit(PAGE_LIMIT)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// For each invoice, find the registration journal entry (source_type =
diff --git a/app/api/reports/ar-ledger/pdf/route.ts b/app/api/reports/ar-ledger/pdf/route.ts
index 37aabf22..941cb816 100644
--- a/app/api/reports/ar-ledger/pdf/route.ts
+++ b/app/api/reports/ar-ledger/pdf/route.ts
@@ -5,6 +5,7 @@ import { ReskontraPDF, type ReskontraInvoiceRow } from '@/lib/reports/reskontra-
import { withRouteContext } from '@/lib/api/with-route-context'
import { slugifyCompanyName } from '@/lib/reports/xlsx-export'
import type { CompanySettings } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.ar_ledger.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -87,7 +88,7 @@ export const GET = withRouteContext('report.ar_ledger.pdf', async (request, { su
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera kundreskontra' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera kundreskontra' },
{ status: 500 }
)
}
diff --git a/app/api/reports/ar-ledger/xlsx/route.ts b/app/api/reports/ar-ledger/xlsx/route.ts
index 6df52578..1052ae38 100644
--- a/app/api/reports/ar-ledger/xlsx/route.ts
+++ b/app/api/reports/ar-ledger/xlsx/route.ts
@@ -9,6 +9,7 @@ import {
integerColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AgingRow {
customer_name: string
@@ -146,7 +147,7 @@ export const GET = withRouteContext('report.ar_ledger.xlsx', async (request, { s
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera kundreskontra' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera kundreskontra' },
{ status: 500 }
)
}
diff --git a/app/api/reports/audit-trail/route.ts b/app/api/reports/audit-trail/route.ts
index 905b1fa7..99c496de 100644
--- a/app/api/reports/audit-trail/route.ts
+++ b/app/api/reports/audit-trail/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import type { AuditLogEntry, AuditAction } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const CSV_HEADERS = 'timestamp,action,table_name,record_id,description,old_state,new_state'
@@ -80,7 +81,7 @@ export const GET = withRouteContext('report.audit_trail', async (request, { supa
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Failed to generate audit trail report' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to generate audit trail report' },
{ status: 500 }
)
}
diff --git a/app/api/reports/avgifter-basis/route.ts b/app/api/reports/avgifter-basis/route.ts
index 04bd0847..531ea88f 100644
--- a/app/api/reports/avgifter-basis/route.ts
+++ b/app/api/reports/avgifter-basis/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { generateAvgifterBasis } from '@/lib/reports/avgifter-basis'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* Arbetsgivaravgiftsunderlag report.
@@ -15,7 +16,6 @@ export const GET = withRouteContext('report.avgifter_basis', async (request, { s
const report = await generateAvgifterBasis(supabase, companyId, year)
return NextResponse.json({ data: report })
} catch (err) {
- const message = err instanceof Error ? err.message : 'Kunde inte generera avgiftsunderlag'
- return NextResponse.json({ error: message }, { status: 500 })
+ return NextResponse.json({ error: getErrorMessage(err) }, { status: 500 })
}
})
diff --git a/app/api/reports/balance-sheet/pdf/route.ts b/app/api/reports/balance-sheet/pdf/route.ts
index 32a8bcdb..e79734ab 100644
--- a/app/api/reports/balance-sheet/pdf/route.ts
+++ b/app/api/reports/balance-sheet/pdf/route.ts
@@ -5,6 +5,7 @@ import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-tem
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.balance_sheet.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -107,7 +108,7 @@ export const GET = withRouteContext('report.balance_sheet.pdf', async (request,
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera balansräkning' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera balansräkning' },
{ status: 500 }
)
}
diff --git a/app/api/reports/balance-sheet/route.ts b/app/api/reports/balance-sheet/route.ts
index 9d8a7c46..0cb0f7e3 100644
--- a/app/api/reports/balance-sheet/route.ts
+++ b/app/api/reports/balance-sheet/route.ts
@@ -3,6 +3,7 @@ import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseReportDateRange } from '@/lib/reports/date-range'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'report.balance_sheet',
@@ -49,7 +50,7 @@ export const GET = withRouteContext(
opLog.error('balance sheet generation failed', err as Error)
return errorResponseFromCode('REPORT_GENERATION_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/balance-sheet/xlsx/route.ts b/app/api/reports/balance-sheet/xlsx/route.ts
index d46d877d..da9f20f8 100644
--- a/app/api/reports/balance-sheet/xlsx/route.ts
+++ b/app/api/reports/balance-sheet/xlsx/route.ts
@@ -8,6 +8,7 @@ import {
currencyColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
section: string
@@ -144,7 +145,7 @@ export const GET = withRouteContext('report.balance_sheet.xlsx', async (request,
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera balansräkning' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera balansräkning' },
{ status: 500 }
)
}
diff --git a/app/api/reports/balansrapport/pdf/route.ts b/app/api/reports/balansrapport/pdf/route.ts
index 2d8d89c9..e8660229 100644
--- a/app/api/reports/balansrapport/pdf/route.ts
+++ b/app/api/reports/balansrapport/pdf/route.ts
@@ -5,6 +5,7 @@ import { BalansrapportPDF } from '@/lib/reports/operational-report-pdf-template'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.balansrapport.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -68,7 +69,7 @@ export const GET = withRouteContext('report.balansrapport.pdf', async (request,
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera balansrapport' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera balansrapport' },
{ status: 500 }
)
}
diff --git a/app/api/reports/balansrapport/route.ts b/app/api/reports/balansrapport/route.ts
index 64f13e16..be1446ff 100644
--- a/app/api/reports/balansrapport/route.ts
+++ b/app/api/reports/balansrapport/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { generateBalansrapport } from '@/lib/reports/balansrapport'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseReportDateRange } from '@/lib/reports/date-range'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.balansrapport', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -32,7 +33,7 @@ export const GET = withRouteContext('report.balansrapport', async (request, { su
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Failed to generate balansrapport' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to generate balansrapport' },
{ status: 500 }
)
}
diff --git a/app/api/reports/balansrapport/xlsx/route.ts b/app/api/reports/balansrapport/xlsx/route.ts
index ef38e223..1118bd39 100644
--- a/app/api/reports/balansrapport/xlsx/route.ts
+++ b/app/api/reports/balansrapport/xlsx/route.ts
@@ -8,6 +8,7 @@ import {
currencyColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
group: string
@@ -118,7 +119,7 @@ export const GET = withRouteContext('report.balansrapport.xlsx', async (request,
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera balansrapport' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera balansrapport' },
{ status: 500 }
)
}
diff --git a/app/api/reports/continuity-check/route.ts b/app/api/reports/continuity-check/route.ts
index 4f1548f3..6a2df231 100644
--- a/app/api/reports/continuity-check/route.ts
+++ b/app/api/reports/continuity-check/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET: Validate IB/UB continuity for a fiscal period.
@@ -19,7 +20,7 @@ export const GET = withRouteContext('report.continuity_check', async (request, {
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Failed to validate continuity' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to validate continuity' },
{ status: 400 }
)
}
diff --git a/app/api/reports/dimension-pnl/route.ts b/app/api/reports/dimension-pnl/route.ts
index cbbe6b36..d3cb3662 100644
--- a/app/api/reports/dimension-pnl/route.ts
+++ b/app/api/reports/dimension-pnl/route.ts
@@ -3,6 +3,7 @@ import { generateDimensionPnl } from '@/lib/reports/dimension-pnl'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseReportDateRange } from '@/lib/reports/date-range'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Resultat per projekt/kostnadsställe: value-as-column P&L matrix over one
// SIE dimension. ?dim_no picks the dimension (default 6, projekt).
@@ -49,7 +50,7 @@ export const GET = withRouteContext(
log.error('dimension pnl generation failed', err as Error, { periodId, dimNo })
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/dimension-pnl/xlsx/route.ts b/app/api/reports/dimension-pnl/xlsx/route.ts
index 8b282a02..e467b9a1 100644
--- a/app/api/reports/dimension-pnl/xlsx/route.ts
+++ b/app/api/reports/dimension-pnl/xlsx/route.ts
@@ -10,6 +10,7 @@ import {
xlsxFilename,
} from '@/lib/reports/xlsx-export'
import type { DimensionPnlReport } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// One row per account; the dimension values are dynamic columns, exactly as
// the on-screen matrix renders. Column labels stay Swedish (report surface).
@@ -125,7 +126,7 @@ export const GET = withRouteContext(
log.error('dimension pnl xlsx failed', err as Error, { periodId, dimNo })
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/full-archive/__tests__/route.test.ts b/app/api/reports/full-archive/__tests__/route.test.ts
index c69c1ef6..f07f62c8 100644
--- a/app/api/reports/full-archive/__tests__/route.test.ts
+++ b/app/api/reports/full-archive/__tests__/route.test.ts
@@ -201,6 +201,6 @@ describe('GET /api/reports/full-archive', () => {
)
)
expect(status).toBe(404)
- expect(body).toEqual({ error: 'Fiscal period not found' })
+ expect(body).toEqual({ error: 'Något gick fel. Försök igen.' })
})
})
diff --git a/app/api/reports/full-archive/route.ts b/app/api/reports/full-archive/route.ts
index d8e1a60e..bd40a125 100644
--- a/app/api/reports/full-archive/route.ts
+++ b/app/api/reports/full-archive/route.ts
@@ -5,6 +5,7 @@ import {
type ArchiveScope,
} from '@/lib/reports/full-archive-export'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
export const runtime = 'nodejs'
export const maxDuration = 300
@@ -81,7 +82,7 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to generate archive'
const status = message.includes('not found') ? 404 : 500
- return NextResponse.json({ error: message }, { status })
+ return NextResponse.json({ error: getErrorMessage(err) }, { status })
}
})
diff --git a/app/api/reports/general-ledger/xlsx/route.ts b/app/api/reports/general-ledger/xlsx/route.ts
index 370a4259..d447aac9 100644
--- a/app/api/reports/general-ledger/xlsx/route.ts
+++ b/app/api/reports/general-ledger/xlsx/route.ts
@@ -10,6 +10,7 @@ import {
dateColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
account_number: string
@@ -173,7 +174,7 @@ export const GET = withRouteContext('report.general_ledger.xlsx', async (request
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera huvudbok' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera huvudbok' },
{ status: 500 }
)
}
diff --git a/app/api/reports/income-statement/pdf/route.ts b/app/api/reports/income-statement/pdf/route.ts
index 1adbf673..9115f23a 100644
--- a/app/api/reports/income-statement/pdf/route.ts
+++ b/app/api/reports/income-statement/pdf/route.ts
@@ -6,6 +6,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// K2/K3 uppställningsform (ÅRL bilaga 2, kostnadsslagsindelad) splits class 8
// into three named blocks with subtotals:
@@ -219,7 +220,7 @@ export const GET = withRouteContext('report.income_statement.pdf', async (reques
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera resultaträkning' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera resultaträkning' },
{ status: 500 }
)
}
diff --git a/app/api/reports/income-statement/route.ts b/app/api/reports/income-statement/route.ts
index c5b020e5..f4966277 100644
--- a/app/api/reports/income-statement/route.ts
+++ b/app/api/reports/income-statement/route.ts
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseReportDateRange } from '@/lib/reports/date-range'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'report.income_statement',
@@ -58,7 +59,7 @@ export const GET = withRouteContext(
opLog.error('income statement generation failed', err as Error)
return errorResponseFromCode('REPORT_GENERATION_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/income-statement/xlsx/route.ts b/app/api/reports/income-statement/xlsx/route.ts
index edd54240..bad56511 100644
--- a/app/api/reports/income-statement/xlsx/route.ts
+++ b/app/api/reports/income-statement/xlsx/route.ts
@@ -10,6 +10,7 @@ import {
} from '@/lib/reports/xlsx-export'
import type { IncomeStatementSection } from '@/types'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
section: string
@@ -171,7 +172,7 @@ export const GET = withRouteContext('report.income_statement.xlsx', async (reque
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera resultaträkning' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera resultaträkning' },
{ status: 500 }
)
}
diff --git a/app/api/reports/ink2/route.ts b/app/api/reports/ink2/route.ts
index d056aabf..a3f04bb3 100644
--- a/app/api/reports/ink2/route.ts
+++ b/app/api/reports/ink2/route.ts
@@ -8,6 +8,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encodeISO88591 } from '@/lib/reports/sru-encoding'
import JSZip from 'jszip'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/reports/ink2
@@ -63,7 +64,7 @@ export const GET = withRouteContext(
opLog.error('ink2 declaration generation failed', err as Error)
return errorResponseFromCode('TAX_DECL_GENERATION_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/journal-register/xlsx/route.ts b/app/api/reports/journal-register/xlsx/route.ts
index 633f1e43..13b51e6d 100644
--- a/app/api/reports/journal-register/xlsx/route.ts
+++ b/app/api/reports/journal-register/xlsx/route.ts
@@ -8,6 +8,7 @@ import {
dateColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
voucher: string
@@ -102,7 +103,7 @@ export const GET = withRouteContext('report.journal_register.xlsx', async (reque
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera grundbok' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera grundbok' },
{ status: 500 }
)
}
diff --git a/app/api/reports/kassaflodesanalys/pdf/route.ts b/app/api/reports/kassaflodesanalys/pdf/route.ts
index f6068b0c..c1f2a9f1 100644
--- a/app/api/reports/kassaflodesanalys/pdf/route.ts
+++ b/app/api/reports/kassaflodesanalys/pdf/route.ts
@@ -4,6 +4,7 @@ import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
import { KassaflodesanalysPDF } from '@/lib/reports/kassaflodesanalys-pdf-template'
import { withRouteContext } from '@/lib/api/with-route-context'
import type { CompanySettings } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.kassaflodesanalys.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -63,7 +64,7 @@ export const GET = withRouteContext('report.kassaflodesanalys.pdf', async (reque
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera kassaflödesanalys' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera kassaflödesanalys' },
{ status: 500 }
)
}
diff --git a/app/api/reports/kassaflodesanalys/route.ts b/app/api/reports/kassaflodesanalys/route.ts
index cc9134b2..4f224834 100644
--- a/app/api/reports/kassaflodesanalys/route.ts
+++ b/app/api/reports/kassaflodesanalys/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.kassaflodesanalys', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -15,7 +16,7 @@ export const GET = withRouteContext('report.kassaflodesanalys', async (request,
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Failed to generate kassaflödesanalys' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to generate kassaflödesanalys' },
{ status: 500 }
)
}
diff --git a/app/api/reports/kpi/xlsx/route.ts b/app/api/reports/kpi/xlsx/route.ts
index f07f041f..befcad40 100644
--- a/app/api/reports/kpi/xlsx/route.ts
+++ b/app/api/reports/kpi/xlsx/route.ts
@@ -19,6 +19,7 @@ import {
integerColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface KpiKv {
label: string
@@ -231,7 +232,7 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera nyckeltalsrapport' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera nyckeltalsrapport' },
{ status: 500 }
)
}
diff --git a/app/api/reports/monthly-breakdown/xlsx/route.ts b/app/api/reports/monthly-breakdown/xlsx/route.ts
index 048a451b..08604a6c 100644
--- a/app/api/reports/monthly-breakdown/xlsx/route.ts
+++ b/app/api/reports/monthly-breakdown/xlsx/route.ts
@@ -7,6 +7,7 @@ import {
currencyColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.monthly_breakdown.xlsx', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -60,7 +61,7 @@ export const GET = withRouteContext('report.monthly_breakdown.xlsx', async (requ
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera månadsbrytning' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera månadsbrytning' },
{ status: 500 }
)
}
diff --git a/app/api/reports/ne-bilaga/route.ts b/app/api/reports/ne-bilaga/route.ts
index 67981b65..ec78f47c 100644
--- a/app/api/reports/ne-bilaga/route.ts
+++ b/app/api/reports/ne-bilaga/route.ts
@@ -8,6 +8,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encodeISO88591 } from '@/lib/reports/sru-encoding'
import JSZip from 'jszip'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/reports/ne-bilaga
@@ -63,7 +64,7 @@ export const GET = withRouteContext(
opLog.error('ne-bilaga declaration generation failed', err as Error)
return errorResponseFromCode('TAX_DECL_GENERATION_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/periodisk-sammanstallning/csv/route.ts b/app/api/reports/periodisk-sammanstallning/csv/route.ts
index d8a8f4a3..e43a2ff9 100644
--- a/app/api/reports/periodisk-sammanstallning/csv/route.ts
+++ b/app/api/reports/periodisk-sammanstallning/csv/route.ts
@@ -8,6 +8,7 @@ import {
} from '@/lib/reports/periodisk-sammanstallning-csv'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/reports/periodisk-sammanstallning/csv
@@ -85,12 +86,12 @@ export const GET = withRouteContext(
if (err instanceof PsCsvBuildError) {
if (err.reason === 'BLOCKING_WARNINGS') {
return errorResponseFromCode('PS_REPORT_CSV_BLOCKED_BY_ERRORS', log, {
- requestId, details: { message: err.message },
+ requestId, details: { message: getUserErrorMessage(err) },
})
}
if (err.reason === 'MISSING_FILER_INFO') {
return errorResponseFromCode('PS_REPORT_MISSING_FILER_INFO', log, {
- requestId, details: { message: err.message },
+ requestId, details: { message: getUserErrorMessage(err) },
})
}
}
@@ -99,7 +100,7 @@ export const GET = withRouteContext(
})
return errorResponseFromCode('PS_REPORT_GENERATION_FAILED', log, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/periodisk-sammanstallning/route.ts b/app/api/reports/periodisk-sammanstallning/route.ts
index 97fc24ad..6596bb1c 100644
--- a/app/api/reports/periodisk-sammanstallning/route.ts
+++ b/app/api/reports/periodisk-sammanstallning/route.ts
@@ -7,6 +7,7 @@ import {
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { MomsPeriod } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/reports/periodisk-sammanstallning
@@ -88,7 +89,7 @@ export const GET = withRouteContext(
})
return errorResponseFromCode('PS_REPORT_GENERATION_FAILED', log, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/resultatrapport/pdf/route.ts b/app/api/reports/resultatrapport/pdf/route.ts
index 5db9a08d..5870725a 100644
--- a/app/api/reports/resultatrapport/pdf/route.ts
+++ b/app/api/reports/resultatrapport/pdf/route.ts
@@ -6,6 +6,7 @@ import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-templat
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.resultatrapport.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -77,7 +78,7 @@ export const GET = withRouteContext('report.resultatrapport.pdf', async (request
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera resultatrapport' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera resultatrapport' },
{ status: 500 }
)
}
diff --git a/app/api/reports/resultatrapport/route.ts b/app/api/reports/resultatrapport/route.ts
index ab345fe8..1d1bcccb 100644
--- a/app/api/reports/resultatrapport/route.ts
+++ b/app/api/reports/resultatrapport/route.ts
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { generateResultatrapport } from '@/lib/reports/resultatrapport'
import { parseReportDateRange } from '@/lib/reports/date-range'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.resultatrapport', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -41,7 +42,7 @@ export const GET = withRouteContext('report.resultatrapport', async (request, {
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Failed to generate resultatrapport' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to generate resultatrapport' },
{ status: 500 }
)
}
diff --git a/app/api/reports/resultatrapport/xlsx/route.ts b/app/api/reports/resultatrapport/xlsx/route.ts
index ad35aac0..7fd221c3 100644
--- a/app/api/reports/resultatrapport/xlsx/route.ts
+++ b/app/api/reports/resultatrapport/xlsx/route.ts
@@ -9,6 +9,7 @@ import {
currencyColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface FlatRow {
group: string
@@ -134,7 +135,7 @@ export const GET = withRouteContext('report.resultatrapport.xlsx', async (reques
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera resultatrapport' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera resultatrapport' },
{ status: 500 }
)
}
diff --git a/app/api/reports/salary-journal/route.ts b/app/api/reports/salary-journal/route.ts
index 0f71c23c..044d9b5a 100644
--- a/app/api/reports/salary-journal/route.ts
+++ b/app/api/reports/salary-journal/route.ts
@@ -1,6 +1,7 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import { generateSalaryJournal } from '@/lib/reports/salary-journal'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* Lönejournal report, per BFNAR 2013:2 behandlingshistorik requirement.
@@ -16,7 +17,6 @@ export const GET = withRouteContext('report.salary_journal', async (request, { s
const report = await generateSalaryJournal(supabase, companyId, year, monthFrom, monthTo)
return NextResponse.json({ data: report })
} catch (err) {
- const message = err instanceof Error ? err.message : 'Kunde inte generera lönejournal'
- return NextResponse.json({ error: message }, { status: 500 })
+ return NextResponse.json({ error: getErrorMessage(err) }, { status: 500 })
}
})
diff --git a/app/api/reports/salary-journal/xlsx/route.ts b/app/api/reports/salary-journal/xlsx/route.ts
index 5f5802e7..b681c2ff 100644
--- a/app/api/reports/salary-journal/xlsx/route.ts
+++ b/app/api/reports/salary-journal/xlsx/route.ts
@@ -9,6 +9,7 @@ import {
integerColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function toDate(s: string): Date | null {
if (!s) return null
@@ -96,7 +97,7 @@ export const GET = withRouteContext('report.salary_journal.xlsx', async (request
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera lönejournal' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera lönejournal' },
{ status: 500 }
)
}
diff --git a/app/api/reports/sie-export/route.ts b/app/api/reports/sie-export/route.ts
index c81f0f57..419a2fc9 100644
--- a/app/api/reports/sie-export/route.ts
+++ b/app/api/reports/sie-export/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { generateSIEExport, encodeSIEToCP437 } from '@/lib/reports/sie-export'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'report.sie_export',
@@ -53,7 +54,7 @@ export const GET = withRouteContext(
opLog.error('sie export generation failed', err as Error)
return errorResponseFromCode('SIE_EXPORT_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/supplier-ledger/pdf/route.ts b/app/api/reports/supplier-ledger/pdf/route.ts
index ee65b5a0..98004d12 100644
--- a/app/api/reports/supplier-ledger/pdf/route.ts
+++ b/app/api/reports/supplier-ledger/pdf/route.ts
@@ -5,6 +5,7 @@ import { ReskontraPDF } from '@/lib/reports/reskontra-pdf-template'
import { withRouteContext } from '@/lib/api/with-route-context'
import { slugifyCompanyName } from '@/lib/reports/xlsx-export'
import type { CompanySettings } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.supplier_ledger.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -70,7 +71,7 @@ export const GET = withRouteContext('report.supplier_ledger.pdf', async (request
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera leverantörsreskontra' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera leverantörsreskontra' },
{ status: 500 }
)
}
diff --git a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts
index 31de9d52..4270629f 100644
--- a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts
+++ b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts
@@ -2,6 +2,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices
@@ -51,7 +52,7 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>
.limit(PAGE_LIMIT)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/app/api/reports/supplier-ledger/xlsx/route.ts b/app/api/reports/supplier-ledger/xlsx/route.ts
index b3c572e0..5f49f08e 100644
--- a/app/api/reports/supplier-ledger/xlsx/route.ts
+++ b/app/api/reports/supplier-ledger/xlsx/route.ts
@@ -7,6 +7,7 @@ import {
currencyColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AgingRow {
supplier_name: string
@@ -79,7 +80,7 @@ export const GET = withRouteContext('report.supplier_ledger.xlsx', async (reques
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera leverantörsreskontra' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera leverantörsreskontra' },
{ status: 500 }
)
}
diff --git a/app/api/reports/trial-balance/route.ts b/app/api/reports/trial-balance/route.ts
index 02d2b377..9b616503 100644
--- a/app/api/reports/trial-balance/route.ts
+++ b/app/api/reports/trial-balance/route.ts
@@ -1,6 +1,7 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.trial_balance', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -15,7 +16,7 @@ export const GET = withRouteContext('report.trial_balance', async (request, { su
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Failed to generate trial balance' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to generate trial balance' },
{ status: 500 }
)
}
diff --git a/app/api/reports/trial-balance/xlsx/route.ts b/app/api/reports/trial-balance/xlsx/route.ts
index c1581764..fe01acf1 100644
--- a/app/api/reports/trial-balance/xlsx/route.ts
+++ b/app/api/reports/trial-balance/xlsx/route.ts
@@ -9,6 +9,7 @@ import {
xlsxFilename,
} from '@/lib/reports/xlsx-export'
import type { TrialBalanceRow } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('report.trial_balance.xlsx', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -77,7 +78,7 @@ export const GET = withRouteContext('report.trial_balance.xlsx', async (request,
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera saldobalans' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera saldobalans' },
{ status: 500 }
)
}
diff --git a/app/api/reports/vacation-liability/route.ts b/app/api/reports/vacation-liability/route.ts
index 72011893..80fd4db4 100644
--- a/app/api/reports/vacation-liability/route.ts
+++ b/app/api/reports/vacation-liability/route.ts
@@ -1,6 +1,7 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* Semesterlöneskuld report, per BFNAR 2016:10 kap 16.
@@ -15,7 +16,6 @@ export const GET = withRouteContext('report.vacation_liability', async (request,
const report = await generateVacationLiability(supabase, companyId, year)
return NextResponse.json({ data: report })
} catch (err) {
- const message = err instanceof Error ? err.message : 'Kunde inte generera semesterlöneskuld'
- return NextResponse.json({ error: message }, { status: 500 })
+ return NextResponse.json({ error: getErrorMessage(err) }, { status: 500 })
}
})
diff --git a/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts b/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts
index 2a4d5781..a130eff7 100644
--- a/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts
+++ b/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts
@@ -5,6 +5,7 @@ import { validateBody } from '@/lib/api/validate'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import type { CreateJournalEntryLineInput, JournalEntryLine } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/reports/vat-declaration/rc-basis-gaps/fix
@@ -175,7 +176,7 @@ export const POST = withRouteContext(
log.error('rc-basis-gap fix failed', err as Error, { entryId })
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/vat-declaration/rc-basis-gaps/route.ts b/app/api/reports/vat-declaration/rc-basis-gaps/route.ts
index 6a8b400a..9fe33213 100644
--- a/app/api/reports/vat-declaration/rc-basis-gaps/route.ts
+++ b/app/api/reports/vat-declaration/rc-basis-gaps/route.ts
@@ -3,6 +3,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps'
import type { VatPeriodType } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'report.vat_declaration.rc_basis_gaps',
@@ -40,7 +41,7 @@ export const GET = withRouteContext(
log.error('rc-basis-gaps detection failed', err as Error, { periodType, year, period })
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/vat-declaration/route.ts b/app/api/reports/vat-declaration/route.ts
index c5172236..76af6858 100644
--- a/app/api/reports/vat-declaration/route.ts
+++ b/app/api/reports/vat-declaration/route.ts
@@ -6,6 +6,7 @@ import {
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { VatPeriodType, AccountingMethod } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/reports/vat-declaration
@@ -106,7 +107,7 @@ export const GET = withRouteContext(
})
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts
index 14dd71c5..76090156 100644
--- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts
+++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts
@@ -16,12 +16,14 @@ import { GET } from '../route'
interface SupabaseShape {
from: ReturnType
+ rpc: ReturnType
}
function buildSupabase(
linesResult: { data: unknown; error: unknown }
): SupabaseShape {
return {
+ rpc: vi.fn().mockResolvedValue(linesResult),
from: vi.fn().mockImplementation(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
@@ -32,7 +34,6 @@ function buildSupabase(
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
- // journal_entry_lines terminates on `.range()` (fetchAllRows).
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
})),
@@ -88,18 +89,14 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
it('happy path: returns mapped lines for ruta10', async () => {
const linesData = [
{
- account_number: '2611',
+ line_id: 'line-1',
+ journal_entry_id: 'je-1',
+ voucher_number: 12,
+ voucher_series: 'A',
+ entry_date: '2026-05-12',
+ description: 'Faktura 1001',
debit_amount: 0,
credit_amount: 250,
- journal_entries: {
- id: 'je-1',
- voucher_number: 12,
- voucher_series: 'A',
- entry_date: '2026-05-12',
- description: 'Faktura 1001',
- status: 'posted',
- company_id: 'company-1',
- },
},
]
authOk(buildSupabase({ data: linesData, error: null }))
@@ -143,52 +140,37 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
expect(res.status).toBe(400)
})
- it('sorts lines by entry_date ASC then voucher_number ASC regardless of DB return order', async () => {
- // Regression: this endpoint relied on `.order({ foreignTable })`, which
- // sorts the embedded resource (not the parent) so lines came back in
- // arbitrary order and the drill-down showed "different rows on reload".
+ it('preserves the stable chronological order returned by the paged RPC', async () => {
const linesData = [
{
- account_number: '2611',
- debit_amount: 0,
- credit_amount: 500,
- journal_entries: {
- id: 'je-late',
- voucher_number: 30,
- voucher_series: 'A',
- entry_date: '2026-05-20',
- description: 'Late',
- status: 'posted',
- company_id: 'company-1',
- },
- },
- {
- account_number: '2611',
+ line_id: 'line-early',
+ journal_entry_id: 'je-early',
+ voucher_number: 4,
+ voucher_series: 'A',
+ entry_date: '2026-05-02',
+ description: 'Early',
debit_amount: 0,
credit_amount: 100,
- journal_entries: {
- id: 'je-early',
- voucher_number: 4,
- voucher_series: 'A',
- entry_date: '2026-05-02',
- description: 'Early',
- status: 'posted',
- company_id: 'company-1',
- },
},
{
- account_number: '2611',
+ line_id: 'line-mid',
+ journal_entry_id: 'je-mid',
+ voucher_number: 18,
+ voucher_series: 'A',
+ entry_date: '2026-05-11',
+ description: 'Mid',
debit_amount: 0,
credit_amount: 250,
- journal_entries: {
- id: 'je-mid',
- voucher_number: 18,
- voucher_series: 'A',
- entry_date: '2026-05-11',
- description: 'Mid',
- status: 'posted',
- company_id: 'company-1',
- },
+ },
+ {
+ line_id: 'line-late',
+ journal_entry_id: 'je-late',
+ voucher_number: 30,
+ voucher_series: 'A',
+ entry_date: '2026-05-20',
+ description: 'Late',
+ debit_amount: 0,
+ credit_amount: 500,
},
]
authOk(buildSupabase({ data: linesData, error: null }))
diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts
index 5593e768..6714711b 100644
--- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts
+++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts
@@ -1,6 +1,5 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
-import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
ACCOUNT_RUTA,
calculatePeriodDates,
@@ -22,6 +21,7 @@ import type { VatDeclarationRutor, VatPeriodType } from '@/types'
* The periodType form mirrors the way the main VAT report is fetched.
*/
const PAGE_LIMIT = 500
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
'report.vat_declaration.ruta_sources',
@@ -84,96 +84,84 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>(
end = dates.end
}
- // Parse the optional cursor up front (format: |).
- // Pagination is applied in JS after a full, deterministically-ordered fetch.
+ // New cursors include entry and line IDs so multiple rows with the same
+ // date and voucher number are paged without gaps. Two-part legacy cursors
+ // remain accepted during rolling deployments.
let cursorDate: string | null = null
- let cursorVoucherNum = 0
+ let cursorVoucherNum: number | null = null
+ let cursorEntryId: string | null = null
+ let cursorLineId: string | null = null
if (cursor) {
- const [cd, cv] = cursor.split('|')
+ const parts = cursor.split('|')
+ const [cd, cv, ce, cl] = parts
cursorVoucherNum = parseInt(cv, 10)
- // The cursor is applied in JS (string compare); structurally validating the
- // date component here is defense-in-depth against malformed/injection cursors.
- if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) {
+ const validShape = parts.length === 2 || parts.length === 4
+ const validIds = parts.length === 2 || (UUID_PATTERN.test(ce) && UUID_PATTERN.test(cl))
+ if (
+ !validShape ||
+ !validIds ||
+ !cd ||
+ !/^\d{4}-\d{2}-\d{2}$/.test(cd) ||
+ isNaN(cursorVoucherNum)
+ ) {
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
}
cursorDate = cd
+ cursorEntryId = ce ?? null
+ cursorLineId = cl ?? null
}
- // Pull ALL contributing lines, then sort + paginate in JS.
- //
- // Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST
- // sorts the *embedded* resource's rows, not the parent result set, so it
- // cannot give us a chronological parent order. Without a stable parent order
- // a raw `.limit()` returns an arbitrary subset that varies between identical
- // requests, making the drill-down show "different rows on every reload". We
- // page on the line PK (`id`) for a stable total order (see fetch-all.ts) and
- // do the chronological sort here, mirroring `generateGeneralLedger` and the
- // trial-balance sources route.
- const rows = await fetchAllRows<{
- id: string
+ // The RPC orders and limits at the database. The old PostgREST join loaded
+ // every matching line before slicing 500 in JavaScript, which timed out for
+ // companies with long VAT histories.
+ const { data: rows, error } = await supabase.rpc('get_vat_ruta_source_lines', {
+ p_company_id: companyId,
+ p_start: start,
+ p_end: end,
+ p_accounts: accountsForRuta,
+ p_cursor_date: cursorDate,
+ p_cursor_voucher_number: cursorVoucherNum,
+ p_cursor_entry_id: cursorEntryId,
+ p_cursor_line_id: cursorLineId,
+ p_limit: PAGE_LIMIT + 1,
+ })
+
+ if (error) {
+ throw Object.assign(new Error(`Failed to fetch VAT source lines: ${error.message}`), {
+ code: error.code,
+ })
+ }
+
+ const pageRows = (rows ?? []).slice(0, PAGE_LIMIT) as Array<{
+ line_id: string
+ journal_entry_id: string
+ voucher_number: number
+ voucher_series: string | null
+ entry_date: string
+ description: string | null
debit_amount: number
credit_amount: number
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- journal_entries: any
- }>(({ from, to }) =>
- supabase
- .from('journal_entry_lines')
- .select(`
- id,
- account_number,
- debit_amount,
- credit_amount,
- journal_entries!inner(
- id,
- voucher_number,
- voucher_series,
- entry_date,
- description,
- status,
- company_id
- )
- `)
- .in('account_number', accountsForRuta)
- .eq('journal_entries.company_id', companyId)
- .in('journal_entries.status', ['posted', 'reversed'])
- .gte('journal_entries.entry_date', start)
- .lte('journal_entries.entry_date', end)
- .order('id', { ascending: true })
- .range(from, to), { dedupeBy: (r) => r.id })
+ }>
- // Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
- // a final deterministic tiebreak).
- const allMapped: ReportSourceLine[] = rows.map((row) => ({
- journal_entry_id: row.journal_entries.id,
- voucher_number: row.journal_entries.voucher_number,
- voucher_series: row.journal_entries.voucher_series || 'A',
- date: row.journal_entries.entry_date,
- description: row.journal_entries.description || '',
+ const lines: ReportSourceLine[] = pageRows.map((row) => ({
+ journal_entry_id: row.journal_entry_id,
+ voucher_number: row.voucher_number,
+ voucher_series: row.voucher_series || 'A',
+ date: row.entry_date,
+ description: row.description || '',
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
}))
- allMapped.sort((a, b) => {
- const dateComp = a.date.localeCompare(b.date)
- if (dateComp !== 0) return dateComp
- if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number
- return a.journal_entry_id.localeCompare(b.journal_entry_id)
- })
-
- // Apply the cursor in JS: keep rows strictly after (date, voucher_number).
- const afterCursor = cursorDate
- ? allMapped.filter(
- (l) =>
- l.date > cursorDate! ||
- (l.date === cursorDate! && l.voucher_number > cursorVoucherNum)
- )
- : allMapped
-
- const lines = afterCursor.slice(0, PAGE_LIMIT)
let next_cursor: string | null = null
- if (afterCursor.length > PAGE_LIMIT && lines.length > 0) {
- const last = lines[lines.length - 1]
- next_cursor = `${last.date}|${last.voucher_number}`
+ if ((rows?.length ?? 0) > PAGE_LIMIT && pageRows.length > 0) {
+ const last = pageRows[pageRows.length - 1]
+ next_cursor = [
+ last.entry_date,
+ last.voucher_number,
+ last.journal_entry_id,
+ last.line_id,
+ ].join('|')
}
return NextResponse.json({
diff --git a/app/api/reports/vat-declaration/settlement-proposal/route.ts b/app/api/reports/vat-declaration/settlement-proposal/route.ts
index 1846d60c..4d4d8122 100644
--- a/app/api/reports/vat-declaration/settlement-proposal/route.ts
+++ b/app/api/reports/vat-declaration/settlement-proposal/route.ts
@@ -3,6 +3,7 @@ import { buildVatSettlementProposal } from '@/lib/reports/vat-settlement'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { VatPeriodType } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/reports/vat-declaration/settlement-proposal
@@ -76,7 +77,7 @@ export const GET = withRouteContext(
})
return errorResponseFromCode('VAT_REPORT_GENERATION_FAILED', log, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/reports/vat-declaration/xlsx/route.ts b/app/api/reports/vat-declaration/xlsx/route.ts
index 20d790c0..2ac2ecdf 100644
--- a/app/api/reports/vat-declaration/xlsx/route.ts
+++ b/app/api/reports/vat-declaration/xlsx/route.ts
@@ -16,6 +16,7 @@ import {
type VatDeclarationRutor,
type AccountingMethod,
} from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface RutaRow {
ruta: string
@@ -102,7 +103,7 @@ export const GET = withRouteContext('report.vat_declaration.xlsx', async (reques
})
} catch (err) {
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte generera momsdeklaration' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte generera momsdeklaration' },
{ status: 500 }
)
}
diff --git a/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts b/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts
index 71b50e89..2e01c977 100644
--- a/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts
+++ b/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateEmployeeBenefitSchema } from '@/lib/api/schemas'
import { calculateBikeBenefit } from '@/lib/salary/benefits'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -79,7 +80,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; benefitId
.eq('employee_id', id)
.eq('company_id', companyId)
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data: { id: benefitId, deleted: true } })
},
diff --git a/app/api/salary/employees/[id]/benefits/route.ts b/app/api/salary/employees/[id]/benefits/route.ts
index 4e56479d..af5994a6 100644
--- a/app/api/salary/employees/[id]/benefits/route.ts
+++ b/app/api/salary/employees/[id]/benefits/route.ts
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateEmployeeBenefitSchema } from '@/lib/api/schemas'
import { calculateBikeBenefit } from '@/lib/salary/benefits'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -19,7 +20,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
.order('valid_from', { ascending: false })
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data })
},
@@ -75,7 +76,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.select()
.single()
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data }, { status: 201 })
},
diff --git a/app/api/salary/employees/[id]/route.ts b/app/api/salary/employees/[id]/route.ts
index 90213b55..af5041e8 100644
--- a/app/api/salary/employees/[id]/route.ts
+++ b/app/api/salary/employees/[id]/route.ts
@@ -7,6 +7,7 @@ import { getCompanyEntityType } from '@/lib/company/context'
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -124,7 +125,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
if (error.code === '23505') {
return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 })
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
diff --git a/app/api/salary/employees/[id]/worked-hours/batch/route.ts b/app/api/salary/employees/[id]/worked-hours/batch/route.ts
index d248dcbe..04bf674c 100644
--- a/app/api/salary/employees/[id]/worked-hours/batch/route.ts
+++ b/app/api/salary/employees/[id]/worked-hours/batch/route.ts
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { BatchUpsertWorkedDaysSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -44,7 +45,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.in('work_date', uniqueDates)
if (deleteError) {
- return NextResponse.json({ error: deleteError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 })
}
// Per-row insert so we can isolate trigger failures (24h cap on a date with
@@ -68,11 +69,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish
// message starting with "Total tid". Other failures are unexpected.
if (error.message?.includes('Total tid') || error.code === '23514') {
- conflicts.push({ date, reason: error.message })
+ conflicts.push({ date, reason: getUserErrorMessage(error) })
continue
}
return NextResponse.json(
- { error: error.message, inserted, conflicts },
+ { error: getUserErrorMessage(error), inserted, conflicts },
{ status: 500 },
)
}
diff --git a/app/api/salary/employees/[id]/worked-hours/route.ts b/app/api/salary/employees/[id]/worked-hours/route.ts
index 6e827a40..d361d2ba 100644
--- a/app/api/salary/employees/[id]/worked-hours/route.ts
+++ b/app/api/salary/employees/[id]/worked-hours/route.ts
@@ -8,6 +8,7 @@ import {
UpsertWorkedDaySchema,
WorkedHoursRangeQuerySchema,
} from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
@@ -50,7 +51,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.order('work_date', { ascending: true })
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const totalHours = (data ?? []).reduce(
@@ -87,7 +88,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('work_date', body.work_date)
if (deleteError) {
- return NextResponse.json({ error: deleteError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 })
}
const { data, error } = await supabase
@@ -107,9 +108,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// The 24h cap trigger raises check_violation when worked + absence > 24h
// for the same date. Surface a clean 409 with a Swedish message.
if (error.message?.includes('Total tid') || error.code === '23514') {
- return NextResponse.json({ error: error.message }, { status: 409 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 409 })
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
@@ -161,7 +162,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
const { error } = await q
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: { ok: true } })
diff --git a/app/api/salary/employees/route.ts b/app/api/salary/employees/route.ts
index 97ed4d57..90267578 100644
--- a/app/api/salary/employees/route.ts
+++ b/app/api/salary/employees/route.ts
@@ -6,6 +6,7 @@ import { CreateEmployeeSchema } from '@/lib/api/schemas'
import { getCompanyEntityType } from '@/lib/company/context'
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -25,7 +26,7 @@ export const GET = withRouteContext('salary.employees.list', async (request, { s
const { data, error } = await query.order('last_name')
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Mask personnummer: show birthdate, hide the 4-digit suffix
@@ -110,7 +111,7 @@ export const POST = withRouteContext('salary.employees.create', async (request,
if (error.code === '23505') {
return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 })
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
diff --git a/app/api/salary/ku/[year]/route.ts b/app/api/salary/ku/[year]/route.ts
index 5ff302b1..db406e8a 100644
--- a/app/api/salary/ku/[year]/route.ts
+++ b/app/api/salary/ku/[year]/route.ts
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { generateKU10Xml } from '@/lib/salary/ku/ku10-generator'
import type { KU10EmployeeData, KU10CompanyData } from '@/lib/salary/ku/ku10-generator'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -57,7 +58,7 @@ export const GET = withRouteContext<{ params: Promise<{ year: string }> }>(
`)
.eq('company_id', companyId)
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
// Filter to booked runs for the year
const bookedForYear = (runEmployees || []).filter(sre => {
diff --git a/app/api/salary/runs/[id]/agi/submit/route.ts b/app/api/salary/runs/[id]/agi/submit/route.ts
index 2481e82b..33618eb4 100644
--- a/app/api/salary/runs/[id]/agi/submit/route.ts
+++ b/app/api/salary/runs/[id]/agi/submit/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { eventBus } from '@/lib/events'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -137,7 +138,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (err) {
log.error('[salary/agi/submit] Error', err as Error)
return NextResponse.json(
- { error: err instanceof Error ? err.message : 'Kunde inte skicka AGI till Skatteverket' },
+ { error: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte skicka AGI till Skatteverket' },
{ status: 500 }
)
}
diff --git a/app/api/salary/runs/[id]/book/route.ts b/app/api/salary/runs/[id]/book/route.ts
index fb475517..628b2852 100644
--- a/app/api/salary/runs/[id]/book/route.ts
+++ b/app/api/salary/runs/[id]/book/route.ts
@@ -4,6 +4,7 @@ import { bookPaidSalaryRun } from '@/lib/salary/book-run'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -40,7 +41,7 @@ export const POST = withRouteContext(
opLog.error('salary booking failed', err as Error)
return errorResponseFromCode('SALARY_RUN_BOOK_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
diff --git a/app/api/salary/runs/[id]/correct/route.ts b/app/api/salary/runs/[id]/correct/route.ts
index c3514b06..a40828ff 100644
--- a/app/api/salary/runs/[id]/correct/route.ts
+++ b/app/api/salary/runs/[id]/correct/route.ts
@@ -5,6 +5,7 @@ import { reverseEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors'
import { revokeLinksForRun } from '@/lib/salary/payslips/links'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -58,8 +59,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (err instanceof EntryAlreadyReversedError) continue
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
- const msg = err instanceof Error ? err.message : ''
- return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 })
+ return NextResponse.json(
+ { error: getUserErrorMessage(err, { context: 'salary' }) },
+ { status: 500 },
+ )
}
}
@@ -107,7 +110,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
error: 'Det finns redan en aktiv lönekörning för denna period. Ta bort den först.',
}, { status: 409 })
}
- return NextResponse.json({ error: createError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(createError) }, { status: 500 })
}
// Copy employees from original run to correction run (with snapshots)
diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts
index 382a56ba..8060dc7b 100644
--- a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts
+++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts
@@ -6,6 +6,7 @@ import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
import { getErrorEntry } from '@/lib/errors/structured-errors'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -25,7 +26,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string; employeeId:
.maybeSingle()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (!data) {
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
@@ -118,7 +119,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; employeeId
.select('id, employment_degree, salary_type, monthly_salary')
.maybeSingle()
- if (sreErr) return NextResponse.json({ error: sreErr.message }, { status: 400 })
+ if (sreErr) return NextResponse.json({ error: getUserErrorMessage(sreErr) }, { status: 400 })
if (!sre) {
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
}
@@ -173,7 +174,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; employeeId
.maybeSingle()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 400 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
if (!data) {
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
diff --git a/app/api/salary/runs/[id]/payment/bg-lb/route.ts b/app/api/salary/runs/[id]/payment/bg-lb/route.ts
index 7c5a4272..8369545c 100644
--- a/app/api/salary/runs/[id]/payment/bg-lb/route.ts
+++ b/app/api/salary/runs/[id]/payment/bg-lb/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { generateBgLb } from '@/lib/salary/payment/bg-lb-generator'
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
@@ -130,8 +131,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
periodLabel,
})
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Kunde inte generera Bankgirot LB-fil'
- return NextResponse.json({ error: msg }, { status: 400 })
+ return NextResponse.json({ error: getErrorMessage(err, { context: 'salary' }) }, { status: 400 })
}
await supabase
diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts
index efe759cc..ee5fb282 100644
--- a/app/api/salary/runs/[id]/payment/pain001/route.ts
+++ b/app/api/salary/runs/[id]/payment/pain001/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { generatePain001 } from '@/lib/salary/payment/pain001-generator'
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
import { normalizeBankNumber, lookupBicByClearing, lookupBicByBankName } from '@/lib/salary/payment/bank-account'
@@ -141,8 +142,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
periodLabel,
})
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Kunde inte generera betalfil'
- return NextResponse.json({ error: msg }, { status: 400 })
+ return NextResponse.json({ error: getErrorMessage(err, { context: 'salary' }) }, { status: 400 })
}
await supabase
diff --git a/app/api/salary/runs/[id]/route.ts b/app/api/salary/runs/[id]/route.ts
index 7bfe460b..3d81a532 100644
--- a/app/api/salary/runs/[id]/route.ts
+++ b/app/api/salary/runs/[id]/route.ts
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -114,7 +115,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
const { data: employees, error: employeesError } = employeesResult
if (employeesError) {
return NextResponse.json(
- { error: `Kunde inte läsa anställda för lönekörningen: ${employeesError.message}` },
+ { error: `Kunde inte läsa anställda för lönekörningen: ${getUserErrorMessage(employeesError)}` },
{ status: 500 },
)
}
@@ -206,7 +207,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: updated })
@@ -254,7 +255,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: { id, deleted: true } })
diff --git a/app/api/salary/runs/route.ts b/app/api/salary/runs/route.ts
index 85d42440..4fc58efc 100644
--- a/app/api/salary/runs/route.ts
+++ b/app/api/salary/runs/route.ts
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateSalaryRunWithDefaultsSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createSalaryRunWithEmployees } from '@/lib/salary/create-run'
import { runSalaryCalculation } from '@/lib/salary/run-calculation'
@@ -155,7 +156,7 @@ export const POST = withRouteContext(
log.error('salary run create failed', err as Error)
return errorResponseFromCode('SALARY_RUN_CREATE_FAILED', log, {
requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err, { context: 'salary' }) },
})
}
diff --git a/app/api/salary/tax-tables/lookup/route.ts b/app/api/salary/tax-tables/lookup/route.ts
index b6c14fd4..a89a8e83 100644
--- a/app/api/salary/tax-tables/lookup/route.ts
+++ b/app/api/salary/tax-tables/lookup/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { lookupTaxFromApi, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('salary.tax_tables.lookup', async (request) => {
const { searchParams } = new URL(request.url)
@@ -27,7 +28,7 @@ export const GET = withRouteContext('salary.tax_tables.lookup', async (request)
})
} catch (err) {
if (err instanceof TaxTableUnavailableError) {
- return NextResponse.json({ error: err.message }, { status: 503 })
+ return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 503 })
}
throw err
}
diff --git a/app/api/salary/tax-tables/status/route.ts b/app/api/salary/tax-tables/status/route.ts
index 150385e0..7da0d0df 100644
--- a/app/api/salary/tax-tables/status/route.ts
+++ b/app/api/salary/tax-tables/status/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchTaxTableRates, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* Probe Skatteverket's open data API to confirm tax tables are reachable.
@@ -28,7 +29,7 @@ export const GET = withRouteContext('salary.tax_tables.status', async (request)
source: 'unavailable' as const,
reachable: false,
checkedAt: new Date().toISOString(),
- message: err.message,
+ message: getUserErrorMessage(err),
},
})
}
diff --git a/app/api/salary/vacation-balances/route.ts b/app/api/salary/vacation-balances/route.ts
index b8b213e0..5a5a1cf6 100644
--- a/app/api/salary/vacation-balances/route.ts
+++ b/app/api/salary/vacation-balances/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { roundOre } from '@/lib/money'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -21,7 +22,7 @@ export const GET = withRouteContext(
.order('vacation_year_start', { ascending: false })
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const rows = ((data ?? []) as Array>)
diff --git a/app/api/sandbox/seed/__tests__/customers.test.ts b/app/api/sandbox/seed/__tests__/customers.test.ts
new file mode 100644
index 00000000..2306fcb6
--- /dev/null
+++ b/app/api/sandbox/seed/__tests__/customers.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+import { buildSandboxCustomers } from '../customers'
+
+describe('sandbox customer seed data', () => {
+ it('sets the required VAT-validation state for every customer', () => {
+ const customers = buildSandboxCustomers('user-1', 'company-1')
+
+ expect(customers).toHaveLength(3)
+ expect(customers.every((customer) => typeof customer.vat_number_validated === 'boolean')).toBe(
+ true,
+ )
+ expect(customers.find((customer) => customer.customer_type === 'individual')).toMatchObject({
+ name: 'Anna Lindström',
+ vat_number_validated: false,
+ })
+ })
+})
diff --git a/app/api/sandbox/seed/customers.ts b/app/api/sandbox/seed/customers.ts
new file mode 100644
index 00000000..ae768f77
--- /dev/null
+++ b/app/api/sandbox/seed/customers.ts
@@ -0,0 +1,47 @@
+export function buildSandboxCustomers(userId: string, companyId: string) {
+ return [
+ {
+ user_id: userId,
+ company_id: companyId,
+ name: 'Björk & Partner AB',
+ customer_type: 'swedish_business',
+ email: 'faktura@bjorkpartner.se',
+ org_number: '5566778899',
+ vat_number: 'SE556677889901',
+ vat_number_validated: true,
+ address_line1: 'Storgatan 10',
+ postal_code: '111 44',
+ city: 'Stockholm',
+ country: 'SE',
+ default_payment_terms: 30,
+ },
+ {
+ user_id: userId,
+ company_id: companyId,
+ name: 'Schmidt GmbH',
+ customer_type: 'eu_business',
+ email: 'billing@schmidt.de',
+ org_number: 'HRB 12345',
+ vat_number: 'DE123456789',
+ vat_number_validated: true,
+ address_line1: 'Hauptstraße 5',
+ postal_code: '10115',
+ city: 'Berlin',
+ country: 'DE',
+ default_payment_terms: 30,
+ },
+ {
+ user_id: userId,
+ company_id: companyId,
+ name: 'Anna Lindström',
+ customer_type: 'individual',
+ email: 'anna.lindstrom@example.com',
+ vat_number_validated: false,
+ address_line1: 'Lillgatan 3',
+ postal_code: '222 33',
+ city: 'Malmö',
+ country: 'SE',
+ default_payment_terms: 30,
+ },
+ ]
+}
diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts
index b0a93e6f..51c6c8ee 100644
--- a/app/api/sandbox/seed/route.ts
+++ b/app/api/sandbox/seed/route.ts
@@ -7,6 +7,7 @@ import { createLogger } from '@/lib/logger'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { truncateIp } from '@/lib/api/v1/with-api-v1'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
+import { buildSandboxCustomers } from './customers'
// Anonymous sign-in is enabled in all environments so visitors can try the
// product; a per-/24 cap on the seed endpoint keeps a single network from
@@ -145,6 +146,9 @@ export async function POST(request: Request) {
invoice_default_days: 30,
onboarding_step: 6,
onboarding_complete: true,
+ initial_setup_path: 'fresh',
+ initial_setup_completed_at: new Date().toISOString(),
+ initial_setup_dismissed_at: new Date().toISOString(),
is_sandbox: true,
// Dimensions demo: the register/pickers render out of the box.
dimensions_enabled: true,
@@ -211,50 +215,7 @@ export async function POST(request: Request) {
// 5. Create customers
const { data: customers, error: custError } = await supabase
.from('customers')
- .insert([
- {
- user_id: userId,
- company_id: companyId,
- name: 'Björk & Partner AB',
- customer_type: 'swedish_business',
- email: 'faktura@bjorkpartner.se',
- org_number: '5566778899',
- vat_number: 'SE556677889901',
- vat_number_validated: true,
- address_line1: 'Storgatan 10',
- postal_code: '111 44',
- city: 'Stockholm',
- country: 'SE',
- default_payment_terms: 30,
- },
- {
- user_id: userId,
- company_id: companyId,
- name: 'Schmidt GmbH',
- customer_type: 'eu_business',
- email: 'billing@schmidt.de',
- org_number: 'HRB 12345',
- vat_number: 'DE123456789',
- vat_number_validated: true,
- address_line1: 'Hauptstraße 5',
- postal_code: '10115',
- city: 'Berlin',
- country: 'DE',
- default_payment_terms: 30,
- },
- {
- user_id: userId,
- company_id: companyId,
- name: 'Anna Lindström',
- customer_type: 'individual',
- email: 'anna.lindstrom@example.com',
- address_line1: 'Lillgatan 3',
- postal_code: '222 33',
- city: 'Malmö',
- country: 'SE',
- default_payment_terms: 30,
- },
- ])
+ .insert(buildSandboxCustomers(userId, companyId))
.select('id, name')
if (custError) throw custError
diff --git a/app/api/settings/api-keys/[id]/route.ts b/app/api/settings/api-keys/[id]/route.ts
index eb477876..62a0b07c 100644
--- a/app/api/settings/api-keys/[id]/route.ts
+++ b/app/api/settings/api-keys/[id]/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* DELETE /api/settings/api-keys/[id]: Revoke an API key (soft delete)
@@ -18,7 +19,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.is('revoked_at', null)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ success: true })
diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts
index e0b2547b..8269b9d7 100644
--- a/app/api/settings/api-keys/route.ts
+++ b/app/api/settings/api-keys/route.ts
@@ -8,6 +8,7 @@ import {
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { ApiKeyMode, ApiKeyScope } from '@/lib/auth/api-keys'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** GET /api/settings/api-keys: list the company's API keys (key value never returned). */
export const GET = withRouteContext(
@@ -123,7 +124,7 @@ export const POST = withRouteContext(
log.error('api_key insert failed', error)
return errorResponseFromCode('API_KEY_CREATE_FAILED', log, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/settings/booking-templates/[id]/route.ts b/app/api/settings/booking-templates/[id]/route.ts
index 17eb079a..3abad843 100644
--- a/app/api/settings/booking-templates/[id]/route.ts
+++ b/app/api/settings/booking-templates/[id]/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BookingTemplateLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
@@ -46,7 +47,7 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
.select()
.single()
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 })
return NextResponse.json({ data })
diff --git a/app/api/settings/booking-templates/[id]/touch/route.ts b/app/api/settings/booking-templates/[id]/touch/route.ts
index 6f50cd5f..863e2fdd 100644
--- a/app/api/settings/booking-templates/[id]/touch/route.ts
+++ b/app/api/settings/booking-templates/[id]/touch/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/settings/booking-templates/[id]/touch
@@ -28,7 +29,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: { success: true } })
diff --git a/app/api/settings/booking-templates/export/route.ts b/app/api/settings/booking-templates/export/route.ts
index c0f84fb3..8b20c737 100644
--- a/app/api/settings/booking-templates/export/route.ts
+++ b/app/api/settings/booking-templates/export/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/settings/booking-templates/export
@@ -20,7 +21,7 @@ export const GET = withRouteContext(
.order('category')
.order('name')
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), {
headers: {
diff --git a/app/api/settings/booking-templates/import/route.ts b/app/api/settings/booking-templates/import/route.ts
index 53f81c0c..b18cf51c 100644
--- a/app/api/settings/booking-templates/import/route.ts
+++ b/app/api/settings/booking-templates/import/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ImportLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
@@ -70,7 +71,7 @@ export const POST = withRouteContext(
.insert(rows)
.select()
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 })
},
diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts
index 25b6229a..cd941079 100644
--- a/app/api/settings/booking-templates/route.ts
+++ b/app/api/settings/booking-templates/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// The GET scope below builds a PostgREST .or() filter by string interpolation.
// Guard every interpolated id against a strict UUID shape so a tainted value
@@ -87,7 +88,7 @@ export const GET = withRouteContext(
])
if (templatesRes.error) {
- return NextResponse.json({ error: templatesRes.error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(templatesRes.error) }, { status: 500 })
}
// usage lookup failing is non-fatal: we just fall back to default ordering
const usageByTemplate = new Map()
@@ -155,7 +156,7 @@ export const POST = withRouteContext(
.select()
.single()
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data }, { status: 201 })
},
@@ -186,7 +187,7 @@ export const DELETE = withRouteContext(
.update({ is_active: false })
.eq('id', id)
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data: { success: true } })
},
diff --git a/app/api/settings/counterparty-templates/route.ts b/app/api/settings/counterparty-templates/route.ts
index 8c6d9d32..d61a94e5 100644
--- a/app/api/settings/counterparty-templates/route.ts
+++ b/app/api/settings/counterparty-templates/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'counterparty_template.list',
@@ -11,7 +12,7 @@ export const GET = withRouteContext(
.eq('is_active', true)
.order('occurrence_count', { ascending: false })
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data })
},
@@ -35,7 +36,7 @@ export const DELETE = withRouteContext(
.eq('id', id)
.eq('company_id', companyId)
- if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data: { success: true } })
},
diff --git a/app/api/settings/invoice-font/__tests__/route.test.ts b/app/api/settings/invoice-font/__tests__/route.test.ts
new file mode 100644
index 00000000..8f795ae5
--- /dev/null
+++ b/app/api/settings/invoice-font/__tests__/route.test.ts
@@ -0,0 +1,175 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
+
+const { supabase, enqueue, reset } = createQueuedMockSupabase()
+
+const requireAuthMock = vi.fn()
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+const requireWriteMock = vi.fn()
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+
+const storageBucket = {
+ upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
+ list: vi.fn().mockResolvedValue({ data: [], error: null }),
+ remove: vi.fn().mockResolvedValue({ data: [], error: null }),
+}
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(),
+ createServiceClient: () => ({
+ storage: { from: vi.fn().mockReturnValue(storageBucket) },
+ }),
+}))
+
+import { DELETE, POST } from '../route'
+
+function makeFontRequest(
+ size = 8,
+ signature: number[] = [0x00, 0x01, 0x00, 0x00],
+ name = 'brand.ttf',
+): Request {
+ const bytes = new Uint8Array(size)
+ bytes.set(signature.slice(0, size))
+ const formData = new FormData()
+ formData.append('file', new File([bytes], name, { type: 'font/ttf' }))
+ return new Request('http://localhost/api/settings/invoice-font', {
+ method: 'POST',
+ body: formData,
+ })
+}
+
+describe('POST /api/settings/invoice-font', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ })
+
+ it('returns 401 when not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase,
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+
+ const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
+
+ expect(response.status).toBe(401)
+ })
+
+ it('returns 403 for a viewer without write permission', async () => {
+ requireWriteMock.mockResolvedValue({
+ ok: false,
+ response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
+ })
+
+ const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
+
+ expect(response.status).toBe(403)
+ })
+
+ it('returns 400 for a file with an invalid font signature', async () => {
+ const response = await POST(
+ makeFontRequest(8, [0x25, 0x50, 0x44, 0x46]),
+ { params: Promise.resolve({}) },
+ )
+
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 400 when the font exceeds 5 MB', async () => {
+ const response = await POST(
+ makeFontRequest(5 * 1024 * 1024 + 1),
+ { params: Promise.resolve({}) },
+ )
+ const { body } = await parseJsonResponse<{ error: string }>(response)
+
+ expect(response.status).toBe(400)
+ expect(body.error).toContain('5 MB')
+ })
+
+ it('returns 404 when company settings do not exist', async () => {
+ enqueue({ error: { code: 'PGRST116', message: 'No rows returned' } })
+
+ const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
+
+ expect(response.status).toBe(404)
+ expect(storageBucket.remove).toHaveBeenCalled()
+ })
+
+ it('uploads a valid TTF and selects it for invoice PDFs', async () => {
+ enqueue({ error: null })
+
+ const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
+ const { body } = await parseJsonResponse<{
+ data: { invoice_font_family: string; invoice_custom_font_name: string }
+ }>(response)
+
+ expect(response.status).toBe(200)
+ expect(body.data).toMatchObject({
+ invoice_font_family: 'Custom',
+ invoice_custom_font_name: 'brand.ttf',
+ })
+ expect(storageBucket.upload).toHaveBeenCalledWith(
+ expect.stringMatching(/^company-1\/invoice-font-\d+\.ttf$/),
+ expect.any(Buffer),
+ expect.objectContaining({ contentType: 'font/ttf' }),
+ )
+ })
+
+ it('uploads a valid WOFF with its normalized content type', async () => {
+ enqueue({ error: null })
+
+ const response = await POST(
+ makeFontRequest(8, [0x77, 0x4f, 0x46, 0x46], 'brand.woff'),
+ { params: Promise.resolve({}) },
+ )
+
+ expect(response.status).toBe(200)
+ expect(storageBucket.upload).toHaveBeenCalledWith(
+ expect.stringMatching(/^company-1\/invoice-font-\d+\.woff$/),
+ expect.any(Buffer),
+ expect.objectContaining({ contentType: 'font/woff' }),
+ )
+ })
+})
+
+describe('DELETE /api/settings/invoice-font', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ })
+
+ it('removes the custom font and restores Helvetica', async () => {
+ enqueue({ error: null })
+
+ const response = await DELETE(
+ new Request('http://localhost/api/settings/invoice-font', { method: 'DELETE' }),
+ { params: Promise.resolve({}) },
+ )
+ const { body } = await parseJsonResponse<{
+ data: { invoice_font_family: string; invoice_custom_font_path: null }
+ }>(response)
+
+ expect(response.status).toBe(200)
+ expect(body.data).toEqual({
+ invoice_font_family: 'Helvetica',
+ invoice_custom_font_path: null,
+ invoice_custom_font_name: null,
+ })
+ })
+})
diff --git a/app/api/settings/invoice-font/route.ts b/app/api/settings/invoice-font/route.ts
new file mode 100644
index 00000000..978cdefe
--- /dev/null
+++ b/app/api/settings/invoice-font/route.ts
@@ -0,0 +1,151 @@
+import { NextResponse } from 'next/server'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { createServiceClient } from '@/lib/supabase/server'
+import {
+ INVOICE_FONT_UPLOAD_MAX_BYTES,
+ INVOICE_FONT_UPLOAD_MAX_MB,
+} from '@/lib/invoices/branding-constants'
+import {
+ detectInvoiceFontFileFormat,
+ getInvoiceFontContentType,
+} from '@/lib/invoices/font-files'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+
+const ALLOWED_EXTENSIONS = new Set(['ttf', 'woff'])
+
+function safeDisplayName(fileName: string, extension: string): string {
+ const baseName = fileName.split(/[\\/]/).pop() ?? `invoice-font.${extension}`
+ const cleaned = baseName.replace(/[\u0000-\u001f\u007f]/g, '').trim()
+ return (cleaned || `invoice-font.${extension}`).slice(0, 200)
+}
+
+export const POST = withRouteContext(
+ 'settings.invoice-font.upload',
+ async (request, { supabase, companyId }) => {
+ const formData = await request.formData()
+ const file = formData.get('file')
+ if (!(file instanceof File)) {
+ return NextResponse.json({ error: 'Ingen typsnittsfil angiven.' }, { status: 400 })
+ }
+
+ const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
+ if (!ALLOWED_EXTENSIONS.has(extension)) {
+ return NextResponse.json(
+ { error: 'Otillåten filtyp. Tillåtna format är TTF och WOFF.' },
+ { status: 400 },
+ )
+ }
+
+ if (file.size > INVOICE_FONT_UPLOAD_MAX_BYTES) {
+ return NextResponse.json(
+ { error: `Filen är för stor (max ${INVOICE_FONT_UPLOAD_MAX_MB} MB).` },
+ { status: 400 },
+ )
+ }
+
+ const bytes = Buffer.from(await file.arrayBuffer())
+ const format = detectInvoiceFontFileFormat(bytes)
+ if (!format || format !== extension) {
+ return NextResponse.json(
+ { error: 'Filen är inte ett giltigt TTF- eller WOFF-typsnitt.' },
+ { status: 400 },
+ )
+ }
+
+ const fileName = `invoice-font-${Date.now()}.${format}`
+ const storagePath = `${companyId}/${fileName}`
+ const serviceClient = createServiceClient()
+ const bucket = serviceClient.storage.from('invoice-fonts')
+
+ const { error: uploadError } = await bucket.upload(storagePath, bytes, {
+ contentType: getInvoiceFontContentType(format),
+ upsert: false,
+ })
+ if (uploadError) {
+ return NextResponse.json(
+ { error: `Uppladdning misslyckades: ${getUserErrorMessage(uploadError)}` },
+ { status: 500 },
+ )
+ }
+
+ const displayName = safeDisplayName(file.name, format)
+ const { error: updateError } = await supabase
+ .from('company_settings')
+ .update({
+ invoice_font_family: 'Custom',
+ invoice_custom_font_path: storagePath,
+ invoice_custom_font_name: displayName,
+ })
+ .eq('company_id', companyId)
+ .select('company_id')
+ .single()
+
+ if (updateError) {
+ await bucket.remove([storagePath])
+ if (updateError.code === 'PGRST116') {
+ return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
+ }
+ return NextResponse.json(
+ { error: getUserErrorMessage(updateError) },
+ { status: 500 },
+ )
+ }
+
+ const { data: existing } = await bucket.list(companyId)
+ const obsoletePaths = (existing ?? [])
+ .filter((stored) => stored.name !== fileName)
+ .map((stored) => `${companyId}/${stored.name}`)
+ if (obsoletePaths.length > 0) await bucket.remove(obsoletePaths)
+
+ return NextResponse.json({
+ data: {
+ invoice_font_family: 'Custom',
+ invoice_custom_font_path: storagePath,
+ invoice_custom_font_name: displayName,
+ },
+ })
+ },
+ { requireWrite: true },
+)
+
+export const DELETE = withRouteContext(
+ 'settings.invoice-font.delete',
+ async (_request, { supabase, companyId }) => {
+ const { error: updateError } = await supabase
+ .from('company_settings')
+ .update({
+ invoice_font_family: 'Helvetica',
+ invoice_custom_font_path: null,
+ invoice_custom_font_name: null,
+ })
+ .eq('company_id', companyId)
+ .select('company_id')
+ .single()
+
+ if (updateError) {
+ if (updateError.code === 'PGRST116') {
+ return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
+ }
+ return NextResponse.json(
+ { error: getUserErrorMessage(updateError) },
+ { status: 500 },
+ )
+ }
+
+ const serviceClient = createServiceClient()
+ const bucket = serviceClient.storage.from('invoice-fonts')
+ const { data: existing } = await bucket.list(companyId)
+ if (existing && existing.length > 0) {
+ await bucket.remove(existing.map((stored) => `${companyId}/${stored.name}`))
+ }
+
+ return NextResponse.json({
+ data: {
+ invoice_font_family: 'Helvetica',
+ invoice_custom_font_path: null,
+ invoice_custom_font_name: null,
+ },
+ })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/settings/logo/__tests__/route.test.ts b/app/api/settings/logo/__tests__/route.test.ts
index e9df07f3..0b45468a 100644
--- a/app/api/settings/logo/__tests__/route.test.ts
+++ b/app/api/settings/logo/__tests__/route.test.ts
@@ -34,9 +34,13 @@ vi.mock('@/lib/supabase/server', () => ({
import { POST } from '../route'
-function makeFormRequest(): Request {
+function makeFormRequest(
+ size = 3,
+ type = 'image/png',
+ name = 'logo.png',
+): Request {
const fd = new FormData()
- fd.append('file', new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' }))
+ fd.append('file', new File([new Uint8Array(size)], name, { type }))
return new Request('http://localhost/api/settings/logo', { method: 'POST', body: fd })
}
@@ -73,6 +77,39 @@ describe('POST /api/settings/logo', () => {
expect(status).toBe(403)
})
+ it('returns 400 for an unsupported file type', async () => {
+ const response = await POST(
+ makeFormRequest(3, 'application/pdf', 'logo.pdf'),
+ { params: Promise.resolve({}) },
+ )
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(400)
+ })
+
+ it('returns 400 when the logo exceeds 10 MB', async () => {
+ const response = await POST(
+ makeFormRequest(10 * 1024 * 1024 + 1),
+ { params: Promise.resolve({}) },
+ )
+ const { status, body } = await parseJsonResponse<{ error: string }>(response)
+
+ expect(status).toBe(400)
+ expect(body.error).toContain('10 MB')
+ })
+
+ it('accepts a logo larger than the previous 2 MB limit', async () => {
+ enqueue({ error: null }) // company_settings update
+
+ const response = await POST(
+ makeFormRequest(2 * 1024 * 1024 + 1),
+ { params: Promise.resolve({}) },
+ )
+ const { status } = await parseJsonResponse(response)
+
+ expect(status).toBe(200)
+ })
+
it('uploads the logo and returns its public url on the happy path', async () => {
enqueue({ error: null }) // company_settings update
diff --git a/app/api/settings/logo/route.ts b/app/api/settings/logo/route.ts
index 8bbe8ec5..8df10380 100644
--- a/app/api/settings/logo/route.ts
+++ b/app/api/settings/logo/route.ts
@@ -1,8 +1,9 @@
import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+import { LOGO_UPLOAD_MAX_BYTES, LOGO_UPLOAD_MAX_MB } from '@/lib/invoices/branding-constants'
-const MAX_SIZE = 2 * 1024 * 1024 // 2MB
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
export const POST = withRouteContext(
@@ -19,8 +20,8 @@ export const POST = withRouteContext(
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
}
- if (file.size > MAX_SIZE) {
- return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 })
+ if (file.size > LOGO_UPLOAD_MAX_BYTES) {
+ return NextResponse.json({ error: `Filen är för stor (max ${LOGO_UPLOAD_MAX_MB} MB).` }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
@@ -53,7 +54,7 @@ export const POST = withRouteContext(
})
if (uploadError) {
- return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
+ return NextResponse.json({ error: `Uppladdning misslyckades: ${getUserErrorMessage(uploadError)}` }, { status: 500 })
}
const { data: urlData } = serviceClient.storage
diff --git a/app/api/settings/oauth-clients/[id]/route.ts b/app/api/settings/oauth-clients/[id]/route.ts
index 90cd0cab..a46045dc 100644
--- a/app/api/settings/oauth-clients/[id]/route.ts
+++ b/app/api/settings/oauth-clients/[id]/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { appendProcessingHistory } from '@/lib/processing-history/append'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* DELETE /api/settings/oauth-clients/[id]: revoke a redirect URI
@@ -26,7 +27,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.select('id, redirect_uri, client_name')
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (!rows || rows.length === 0) {
diff --git a/app/api/settings/oauth-clients/route.ts b/app/api/settings/oauth-clients/route.ts
index ae5881ca..a549c006 100644
--- a/app/api/settings/oauth-clients/route.ts
+++ b/app/api/settings/oauth-clients/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/settings/oauth-clients: list the current user's registered
@@ -38,7 +39,7 @@ export const GET = withRouteContext(
.order('created_at', { ascending: false })
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -80,7 +81,7 @@ export const POST = withRouteContext(
{ status: 409 }
)
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index a12377e9..75d7590c 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -9,6 +9,7 @@ import {
} from '@/lib/tax/deadline-generator'
import { validateBody } from '@/lib/api/validate'
import { UpdateSettingsSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'settings.get',
@@ -20,7 +21,7 @@ export const GET = withRouteContext(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Fall back to companies.entity_type if company_settings.entity_type is null
@@ -99,7 +100,7 @@ export const PUT = withRouteContext(
// Fail closed: a failed check must not let the basis change through
// and orphan open vacation-ledger rows.
if (openRowsError) {
- return NextResponse.json({ error: openRowsError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(openRowsError) }, { status: 500 })
}
if ((openRows ?? 0) > 0) {
return NextResponse.json(
@@ -182,7 +183,7 @@ export const PUT = withRouteContext(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
}
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Regenerate when the save touches tax-relevant fields: the statutory
diff --git a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts
index 214fc65f..fbb3a513 100644
--- a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts
+++ b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -47,7 +48,7 @@ export const POST = withRouteContext<{ params: Promise<{ period: string }> }>(
.eq('id', agi.id)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: { ok: true } })
diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts
index 8e5a3926..bec19724 100644
--- a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts
+++ b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
import { generateBankgiroPaymentBgLb } from '@/lib/salary/payment/bg-lb-generator'
import { generateSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '@/lib/skatteverket/skattekonto-ocr'
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
@@ -95,8 +96,7 @@ export const GET = withRouteContext<{ params: Promise<{ period: string }> }>(
try {
ocr = generateSkattekontoOcr(company.org_number)
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Kunde inte generera OCR-nummer'
- return NextResponse.json({ error: msg }, { status: 400 })
+ return NextResponse.json({ error: getErrorMessage(err) }, { status: 400 })
}
// Payment date = AGI deadline, which is the 12th of the following month
@@ -116,8 +116,7 @@ export const GET = withRouteContext<{ params: Promise<{ period: string }> }>(
{ paymentDate, periodLabel: period }
)
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Kunde inte generera betalfil'
- return NextResponse.json({ error: msg }, { status: 400 })
+ return NextResponse.json({ error: getErrorMessage(err) }, { status: 400 })
}
await supabase
diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts
index c764ac0b..1a3c3c0f 100644
--- a/app/api/supplier-invoices/[id]/credit/route.ts
+++ b/app/api/supplier-invoices/[id]/credit/route.ts
@@ -7,6 +7,7 @@ import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -70,7 +71,7 @@ export const POST = withRouteContext(
opLog.error('credit note insert failed', creditError as Error)
return errorResponseFromCode('SI_CREDIT_FAILED', opLog, {
requestId,
- details: { reason: creditError?.message || 'unknown' },
+ details: { reason: getUserErrorMessage(creditError) || 'unknown' },
})
}
@@ -139,7 +140,7 @@ export const POST = withRouteContext(
return errorResponseFromCode('SI_CREDIT_FAILED', opLog, {
requestId,
details: {
- reason: err instanceof Error ? err.message : 'unknown',
+ reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown',
step: 'credit_note_journal_entry',
},
})
diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts
index 9fd485b6..e7e5d10b 100644
--- a/app/api/supplier-invoices/[id]/mark-paid/route.ts
+++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts
@@ -19,6 +19,7 @@ import {
escapeLikePattern,
} from '@/lib/invoices/duplicate-payment-guard'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -203,7 +204,7 @@ export const POST = withRouteContext(
opLog.error('failed to create payment journal entry', err as Error)
return errorResponseFromCode('SI_PAID_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
@@ -325,7 +326,7 @@ export const POST = withRouteContext(
opLog.warn('failed to link supplier invoice document to cash payment JE', {
documentId: invoiceDocumentId,
journalEntryId,
- error: linkErr instanceof Error ? linkErr.message : String(linkErr),
+ error: linkErr instanceof Error ? getUserErrorMessage(linkErr) : getUserErrorMessage(linkErr),
})
}
}
diff --git a/app/api/supplier-invoices/[id]/route.ts b/app/api/supplier-invoices/[id]/route.ts
index a49a8967..6d15b249 100644
--- a/app/api/supplier-invoices/[id]/route.ts
+++ b/app/api/supplier-invoices/[id]/route.ts
@@ -3,6 +3,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'supplier_invoice.get',
@@ -63,7 +64,7 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -146,7 +147,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ success: true })
diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts
index e332d6cc..84f097d8 100644
--- a/app/api/supplier-invoices/route.ts
+++ b/app/api/supplier-invoices/route.ts
@@ -15,6 +15,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -187,7 +188,7 @@ export const POST = withRouteContext(
log.error('arrival number generation failed', arrivalError)
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
- details: { reason: arrivalError.message, step: 'arrival_number' },
+ details: { reason: getUserErrorMessage(arrivalError), step: 'arrival_number' },
})
}
@@ -355,7 +356,7 @@ export const POST = withRouteContext(
log.error('supplier invoice insert failed', invoiceError)
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
- details: { reason: invoiceError?.message || 'unknown' },
+ details: { reason: getUserErrorMessage(invoiceError) || 'unknown' },
})
}
@@ -377,7 +378,7 @@ export const POST = withRouteContext(
})
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
- details: { reason: itemsError.message, step: 'items_insert' },
+ details: { reason: getUserErrorMessage(itemsError), step: 'items_insert' },
})
}
@@ -459,7 +460,7 @@ export const POST = withRouteContext(
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
details: {
- reason: err instanceof Error ? err.message : 'unknown',
+ reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown',
step: 'privately_paid_journal_entry',
},
})
@@ -537,7 +538,7 @@ export const POST = withRouteContext(
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
details: {
- reason: err instanceof Error ? err.message : 'unknown',
+ reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown',
step: 'registration_journal_entry',
},
})
diff --git a/app/api/suppliers/[id]/route.ts b/app/api/suppliers/[id]/route.ts
index d6bdfc2b..7b74cd62 100644
--- a/app/api/suppliers/[id]/route.ts
+++ b/app/api/suppliers/[id]/route.ts
@@ -3,6 +3,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateSupplierSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'supplier.get',
@@ -101,7 +102,7 @@ export const PUT = withRouteContext(
opLog.error('supplier update failed', error)
return errorResponseFromCode('SUPPLIER_UPDATE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
@@ -140,7 +141,7 @@ export const DELETE = withRouteContext(
opLog.error('supplier delete failed', error)
return errorResponseFromCode('SUPPLIER_DELETE_FAILED', opLog, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts
index 6db9eb9d..f8f0bd1b 100644
--- a/app/api/suppliers/route.ts
+++ b/app/api/suppliers/route.ts
@@ -6,6 +6,7 @@ import { CreateSupplierSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Supplier } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -80,7 +81,7 @@ export const POST = withRouteContext(
log.error('supplier insert failed', error)
return errorResponseFromCode('SUPPLIER_CREATE_FAILED', log, {
requestId,
- details: { reason: error.message },
+ details: { reason: getUserErrorMessage(error) },
})
}
diff --git a/app/api/tax-assessment-notices/[id]/route.ts b/app/api/tax-assessment-notices/[id]/route.ts
new file mode 100644
index 00000000..bb6eaf7b
--- /dev/null
+++ b/app/api/tax-assessment-notices/[id]/route.ts
@@ -0,0 +1,146 @@
+import { NextResponse } from 'next/server'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { validateBody } from '@/lib/api/validate'
+import { UpdateTaxAssessmentNoticeSchema } from '@/lib/api/schemas'
+import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import {
+ DEADLINE_SETTINGS_SELECT,
+ regenerateTaxDeadlinesForUser,
+ toDeadlineSettings,
+} from '@/lib/tax/deadline-generator'
+
+export const PATCH = withRouteContext(
+ 'tax-assessment-notice.update',
+ async (request, { supabase, companyId, log, requestId }, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const validation = await validateBody(request, UpdateTaxAssessmentNoticeSchema, {
+ log,
+ operation: 'tax-assessment-notice.update',
+ })
+ if (!validation.success) return validation.response
+ const body = validation.data
+
+ const { data: existing, error: lookupError } = await supabase
+ .from('tax_assessment_notices')
+ .select('*, fiscal_period:fiscal_periods(name)')
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (lookupError) {
+ log.error('tax assessment notice lookup failed', lookupError)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(lookupError) },
+ })
+ }
+ if (!existing) return errorResponseFromCode('NOT_FOUND', log, { requestId })
+
+ const fiscalPeriodId = body.fiscal_period_id ?? existing.fiscal_period_id
+ if (body.fiscal_period_id) {
+ const { data: fiscalPeriod, error: fiscalPeriodError } = await supabase
+ .from('fiscal_periods')
+ .select('id, name')
+ .eq('id', fiscalPeriodId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+ if (fiscalPeriodError) {
+ log.error('fiscal period lookup failed', fiscalPeriodError)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(fiscalPeriodError) },
+ })
+ }
+ if (!fiscalPeriod) {
+ return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
+ }
+ }
+
+ const decisionDate = body.decision_date ?? existing.decision_date
+ const paymentDueDate = body.payment_due_date ?? existing.payment_due_date
+ if (paymentDueDate < decisionDate) {
+ return NextResponse.json(
+ {
+ error: 'Validation failed',
+ type: 'validation_error',
+ errors: [{
+ field: 'payment_due_date',
+ message: 'Förfallodagen får inte vara tidigare än beslutsdagen',
+ code: 'custom',
+ }],
+ },
+ { status: 400 },
+ )
+ }
+
+ const update: Record = {
+ fiscal_period_id: fiscalPeriodId,
+ decision_type: body.decision_type ?? existing.decision_type,
+ decision_date: decisionDate,
+ payment_due_date: paymentDueDate,
+ }
+ if (body.archived !== undefined) {
+ update.archived_at = body.archived ? new Date().toISOString() : null
+ }
+
+ const { data, error } = await supabase
+ .from('tax_assessment_notices')
+ .update(update)
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .select('*, fiscal_period:fiscal_periods(id, name, period_start, period_end)')
+ .single()
+
+ if (error) {
+ log.error('tax assessment notice update failed', error)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(error) },
+ })
+ }
+
+ const fiscalPeriod = Array.isArray(data.fiscal_period)
+ ? data.fiscal_period[0]
+ : data.fiscal_period
+ const noticeKind = data.decision_type === 'reassessment' ? 'omprövning' : 'slutskattebesked'
+ const deadlineUpdate: Record = {
+ title: `Kvarskatt ${noticeKind}, ${fiscalPeriod?.name ?? ''}`.trimEnd(),
+ due_date: data.payment_due_date,
+ }
+ if (body.archived !== undefined) {
+ deadlineUpdate.dismissed_at = body.archived ? new Date().toISOString() : null
+ }
+
+ const { error: deadlineUpdateError } = await supabase
+ .from('deadlines')
+ .update(deadlineUpdate)
+ .eq('company_id', companyId)
+ .eq('tax_assessment_notice_id', id)
+ .eq('is_completed', false)
+ if (deadlineUpdateError) {
+ log.error('linked deadline update failed', deadlineUpdateError)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(deadlineUpdateError) },
+ })
+ }
+
+ const { data: settings, error: settingsError } = await supabase
+ .from('company_settings')
+ .select(DEADLINE_SETTINGS_SELECT)
+ .eq('company_id', companyId)
+ .single()
+ if (settingsError) {
+ log.error('deadline settings lookup failed', settingsError)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(settingsError) },
+ })
+ }
+
+ await regenerateTaxDeadlinesForUser(supabase, companyId, toDeadlineSettings(settings))
+ return NextResponse.json({ data })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/tax-assessment-notices/__tests__/route.test.ts b/app/api/tax-assessment-notices/__tests__/route.test.ts
new file mode 100644
index 00000000..48f34bbe
--- /dev/null
+++ b/app/api/tax-assessment-notices/__tests__/route.test.ts
@@ -0,0 +1,164 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NextResponse } from 'next/server'
+import {
+ createMockRequest,
+ createQueuedMockSupabase,
+ parseJsonResponse,
+} from '@/tests/helpers'
+
+const { supabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
+const requireAuthMock = vi.fn()
+const requireWriteMock = vi.fn()
+const deadlineMocks = vi.hoisted(() => ({
+ regenerate: vi.fn().mockResolvedValue({ created: 1, deleted: 0 }),
+ normalize: vi.fn((settings: unknown) => settings),
+}))
+
+vi.mock('@/lib/auth/require-auth', () => ({
+ requireAuth: (...args: unknown[]) => requireAuthMock(...args),
+}))
+vi.mock('@/lib/company/context', () => ({
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
+}))
+vi.mock('@/lib/tax/deadline-generator', () => ({
+ DEADLINE_SETTINGS_SELECT: 'company_id, entity_type',
+ regenerateTaxDeadlinesForUser: deadlineMocks.regenerate,
+ toDeadlineSettings: deadlineMocks.normalize,
+}))
+
+import { GET, POST } from '../route'
+import { PATCH } from '../[id]/route'
+
+const notice = {
+ id: '11111111-1111-4111-8111-111111111111',
+ company_id: 'company-1',
+ user_id: 'user-1',
+ fiscal_period_id: '22222222-2222-4222-8222-222222222222',
+ decision_type: 'final',
+ decision_date: '2026-07-01',
+ payment_due_date: '2026-10-12',
+ archived_at: null,
+}
+
+describe('/api/tax-assessment-notices', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ requireAuthMock.mockResolvedValue({
+ user: { id: 'user-1' },
+ supabase,
+ error: null,
+ })
+ requireWriteMock.mockResolvedValue({ ok: true })
+ deadlineMocks.regenerate.mockResolvedValue({ created: 1, deleted: 0 })
+ })
+
+ it('returns 401 when the user is not authenticated', async () => {
+ requireAuthMock.mockResolvedValue({
+ user: null,
+ supabase,
+ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
+ })
+
+ const response = await GET(createMockRequest('/api/tax-assessment-notices'), {})
+ expect(response.status).toBe(401)
+ })
+
+ it('lists active notices', async () => {
+ enqueue({ data: [notice], error: null })
+ const { status, body } = await parseJsonResponse<{ data: Array }>(
+ await GET(createMockRequest('/api/tax-assessment-notices'), {}),
+ )
+
+ expect(status).toBe(200)
+ expect(body.data).toEqual([notice])
+ })
+
+ it('returns 400 when the due date precedes the decision', async () => {
+ const response = await POST(createMockRequest('/api/tax-assessment-notices', {
+ method: 'POST',
+ body: {
+ fiscal_period_id: notice.fiscal_period_id,
+ decision_type: 'final',
+ decision_date: '2026-07-01',
+ payment_due_date: '2026-06-30',
+ },
+ }), {})
+
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 404 when the fiscal period belongs to another company', async () => {
+ enqueue({ data: null, error: null })
+ const response = await POST(createMockRequest('/api/tax-assessment-notices', {
+ method: 'POST',
+ body: {
+ fiscal_period_id: notice.fiscal_period_id,
+ decision_type: 'final',
+ decision_date: notice.decision_date,
+ payment_due_date: notice.payment_due_date,
+ },
+ }), {})
+
+ expect(response.status).toBe(404)
+ })
+
+ it('upserts a notice and regenerates company deadlines', async () => {
+ enqueueMany([
+ { data: { id: notice.fiscal_period_id }, error: null },
+ { data: notice, error: null },
+ { data: null, error: null },
+ { data: { company_id: 'company-1', entity_type: 'aktiebolag' }, error: null },
+ ])
+
+ const { status, body } = await parseJsonResponse<{ data: typeof notice }>(
+ await POST(createMockRequest('/api/tax-assessment-notices', {
+ method: 'POST',
+ body: {
+ fiscal_period_id: notice.fiscal_period_id,
+ decision_type: 'final',
+ decision_date: notice.decision_date,
+ payment_due_date: notice.payment_due_date,
+ },
+ }), {}),
+ )
+
+ expect(status).toBe(201)
+ expect(body.data.id).toBe(notice.id)
+ expect(deadlineMocks.regenerate).toHaveBeenCalledWith(
+ supabase,
+ 'company-1',
+ expect.objectContaining({ company_id: 'company-1' }),
+ )
+ })
+
+ it('returns 404 when updating a notice outside the company', async () => {
+ enqueue({ data: null, error: null })
+ const response = await PATCH(createMockRequest(`/api/tax-assessment-notices/${notice.id}`, {
+ method: 'PATCH',
+ body: { archived: true },
+ }), { params: Promise.resolve({ id: notice.id }) })
+
+ expect(response.status).toBe(404)
+ })
+
+ it('archives a notice and dismisses its pending deadline', async () => {
+ enqueueMany([
+ { data: notice, error: null },
+ { data: { ...notice, archived_at: '2026-07-21T10:00:00.000Z' }, error: null },
+ { data: null, error: null },
+ { data: { company_id: 'company-1', entity_type: 'aktiebolag' }, error: null },
+ ])
+
+ const response = await PATCH(createMockRequest(`/api/tax-assessment-notices/${notice.id}`, {
+ method: 'PATCH',
+ body: { archived: true },
+ }), { params: Promise.resolve({ id: notice.id }) })
+
+ expect(response.status).toBe(200)
+ expect(deadlineMocks.regenerate).toHaveBeenCalledOnce()
+ })
+})
diff --git a/app/api/tax-assessment-notices/route.ts b/app/api/tax-assessment-notices/route.ts
new file mode 100644
index 00000000..8da79ad3
--- /dev/null
+++ b/app/api/tax-assessment-notices/route.ts
@@ -0,0 +1,126 @@
+import { NextResponse } from 'next/server'
+import { withRouteContext } from '@/lib/api/with-route-context'
+import { validateBody } from '@/lib/api/validate'
+import { CreateTaxAssessmentNoticeSchema } from '@/lib/api/schemas'
+import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import {
+ DEADLINE_SETTINGS_SELECT,
+ regenerateTaxDeadlinesForUser,
+ toDeadlineSettings,
+} from '@/lib/tax/deadline-generator'
+
+export const GET = withRouteContext(
+ 'tax-assessment-notice.list',
+ async (_request, { supabase, companyId, log, requestId }) => {
+ const { data, error } = await supabase
+ .from('tax_assessment_notices')
+ .select('*, fiscal_period:fiscal_periods(id, name, period_start, period_end)')
+ .eq('company_id', companyId)
+ .is('archived_at', null)
+ .order('payment_due_date', { ascending: true })
+
+ if (error) {
+ log.error('tax assessment notice list failed', error)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(error) },
+ })
+ }
+
+ return NextResponse.json({ data: data ?? [] })
+ },
+)
+
+export const POST = withRouteContext(
+ 'tax-assessment-notice.create',
+ async (request, { supabase, companyId, user, log, requestId }) => {
+ const validation = await validateBody(request, CreateTaxAssessmentNoticeSchema, {
+ log,
+ operation: 'tax-assessment-notice.create',
+ })
+ if (!validation.success) return validation.response
+ const body = validation.data
+
+ const { data: fiscalPeriod, error: fiscalPeriodError } = await supabase
+ .from('fiscal_periods')
+ .select('id, name')
+ .eq('id', body.fiscal_period_id)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (fiscalPeriodError) {
+ log.error('fiscal period lookup failed', fiscalPeriodError)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(fiscalPeriodError) },
+ })
+ }
+ if (!fiscalPeriod) {
+ return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
+ }
+
+ const { data, error } = await supabase
+ .from('tax_assessment_notices')
+ .upsert(
+ {
+ company_id: companyId,
+ user_id: user.id,
+ fiscal_period_id: body.fiscal_period_id,
+ decision_type: body.decision_type,
+ decision_date: body.decision_date,
+ payment_due_date: body.payment_due_date,
+ archived_at: null,
+ },
+ { onConflict: 'company_id,fiscal_period_id,decision_type,decision_date' },
+ )
+ .select('*, fiscal_period:fiscal_periods(id, name, period_start, period_end)')
+ .single()
+
+ if (error) {
+ log.error('tax assessment notice upsert failed', error)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(error) },
+ })
+ }
+
+ const noticeKind = body.decision_type === 'reassessment' ? 'omprövning' : 'slutskattebesked'
+ const { error: deadlineUpdateError } = await supabase
+ .from('deadlines')
+ .update({
+ title: `Kvarskatt ${noticeKind}, ${fiscalPeriod.name}`,
+ due_date: body.payment_due_date,
+ dismissed_at: null,
+ })
+ .eq('company_id', companyId)
+ .eq('tax_assessment_notice_id', data.id)
+ .eq('is_completed', false)
+
+ if (deadlineUpdateError) {
+ log.error('linked deadline reactivation failed', deadlineUpdateError)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(deadlineUpdateError) },
+ })
+ }
+
+ const { data: settings, error: settingsError } = await supabase
+ .from('company_settings')
+ .select(DEADLINE_SETTINGS_SELECT)
+ .eq('company_id', companyId)
+ .single()
+
+ if (settingsError) {
+ log.error('deadline settings lookup failed', settingsError)
+ return errorResponseFromCode('INTERNAL_ERROR', log, {
+ requestId,
+ details: { reason: getErrorMessage(settingsError) },
+ })
+ }
+
+ await regenerateTaxDeadlinesForUser(supabase, companyId, toDeadlineSettings(settings))
+ return NextResponse.json({ data }, { status: 201 })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/transactions/[id]/ignore/__tests__/route.test.ts b/app/api/transactions/[id]/ignore/__tests__/route.test.ts
index 1d7776e0..888e4a2e 100644
--- a/app/api/transactions/[id]/ignore/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/ignore/__tests__/route.test.ts
@@ -121,7 +121,7 @@ describe('POST /api/transactions/[id]/ignore', () => {
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(500)
- expect(body).toEqual({ error: 'db down' })
+ expect(body).toEqual({ error: 'Något gick fel. Försök igen.' })
})
})
@@ -174,6 +174,6 @@ describe('DELETE /api/transactions/[id]/ignore', () => {
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(500)
- expect(body).toEqual({ error: 'db down' })
+ expect(body).toEqual({ error: 'Något gick fel. Försök igen.' })
})
})
diff --git a/app/api/transactions/[id]/ignore/route.ts b/app/api/transactions/[id]/ignore/route.ts
index 93b469cd..44a9d5fe 100644
--- a/app/api/transactions/[id]/ignore/route.ts
+++ b/app/api/transactions/[id]/ignore/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/transactions/[id]/ignore
@@ -47,7 +48,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (updateError) {
- return NextResponse.json({ error: updateError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(updateError) }, { status: 500 })
}
return NextResponse.json({ success: true })
@@ -74,7 +75,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (updateError) {
- return NextResponse.json({ error: updateError.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(updateError) }, { status: 500 })
}
return NextResponse.json({ success: true })
diff --git a/app/api/transactions/[id]/match-batch/route.ts b/app/api/transactions/[id]/match-batch/route.ts
index cc487cbd..99d46a4b 100644
--- a/app/api/transactions/[id]/match-batch/route.ts
+++ b/app/api/transactions/[id]/match-batch/route.ts
@@ -6,6 +6,7 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { Invoice, SupplierInvoice, Transaction } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -81,7 +82,7 @@ export const POST = withRouteContext(
txLog.error('match_batch_allocate RPC error', error)
return errorResponseFromCode('BATCH_RPC_FAILED', txLog, {
requestId,
- details: { message: error.message },
+ details: { message: getUserErrorMessage(error) },
})
}
diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts
index 242df20f..24911d64 100644
--- a/app/api/transactions/[id]/match-supplier-invoice/route.ts
+++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts
@@ -17,6 +17,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -320,7 +321,7 @@ export const POST = withRouteContext(
}
return errorResponseFromCode('MATCH_SI_JE_FAILED', txLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
diff --git a/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts b/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts
index da7bad4e..596aff82 100644
--- a/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts
@@ -121,7 +121,7 @@ describe('POST /api/transactions/[id]/uncategorize', () => {
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(500)
- expect(body).toEqual({ error: 'Period is locked' })
+ expect(body).toEqual({ error: 'Kunde inte hantera transaktionen. Försök igen.' })
})
it('returns 200 and reverses entry on success', async () => {
diff --git a/app/api/transactions/[id]/uncategorize/route.ts b/app/api/transactions/[id]/uncategorize/route.ts
index e8b02665..f4ca91c0 100644
--- a/app/api/transactions/[id]/uncategorize/route.ts
+++ b/app/api/transactions/[id]/uncategorize/route.ts
@@ -3,6 +3,7 @@ import { reverseEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -49,8 +50,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
- const message = err instanceof Error ? err.message : 'Reversal failed'
- return NextResponse.json({ error: message }, { status: 500 })
+ return NextResponse.json(
+ { error: getErrorMessage(err, { context: 'transaction' }) },
+ { status: 500 },
+ )
}
// Reset transaction categorization
diff --git a/app/api/transactions/__tests__/route.test.ts b/app/api/transactions/__tests__/route.test.ts
index ebf7ad81..f4178cb0 100644
--- a/app/api/transactions/__tests__/route.test.ts
+++ b/app/api/transactions/__tests__/route.test.ts
@@ -187,6 +187,6 @@ describe('GET /api/transactions', () => {
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
- expect(body.error).toBe('boom')
+ expect(body.error).toBe('Något gick fel. Försök igen.')
})
})
diff --git a/app/api/transactions/bulk-book/route.ts b/app/api/transactions/bulk-book/route.ts
index b93f2043..2394d775 100644
--- a/app/api/transactions/bulk-book/route.ts
+++ b/app/api/transactions/bulk-book/route.ts
@@ -14,6 +14,7 @@ import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -108,7 +109,7 @@ export const POST = withRouteContext(
opLog.error('chart_of_accounts lookup failed', accountsError)
return errorResponseFromCode('BULK_BOOK_RPC_FAILED', opLog, {
requestId,
- details: { message: accountsError.message },
+ details: { message: getUserErrorMessage(accountsError) },
})
}
const validSet = new Set(
@@ -283,7 +284,7 @@ export const POST = withRouteContext(
opLog.error('bulk_book_transactions RPC error', error)
return errorResponseFromCode('BULK_BOOK_RPC_FAILED', opLog, {
requestId,
- details: { message: error.message },
+ details: { message: getUserErrorMessage(error) },
})
}
diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts
index fede8b3d..e56781a1 100644
--- a/app/api/transactions/route.ts
+++ b/app/api/transactions/route.ts
@@ -5,6 +5,7 @@ import { scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconcilia
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateTransactionSchema } from '@/lib/api/schemas'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const MAX_ROWS = 500
@@ -98,7 +99,7 @@ export async function GET(request: Request) {
const { data, error } = await query
if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 })
+ return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const rows = data || []
@@ -147,7 +148,7 @@ export const POST = withRouteContext(
// is invalid input, not a server fault: surface it as 400 with the PG
// code so the client maps it to a friendly message.
return NextResponse.json(
- { error: error.message, code: error.code, type: 'database_error' },
+ { error: getUserErrorMessage(error), code: error.code, type: 'database_error' },
{ status: 400 },
)
}
diff --git a/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts b/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts
index 897e50a5..2fc9a562 100644
--- a/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts
+++ b/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts
@@ -17,6 +17,7 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { eventBus } from '@/lib/events'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const DocumentDownloadResponse = z.object({
id: z.string().uuid(),
@@ -122,7 +123,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
ctx.log.error('createSignedUrl failed', signErr as Error, { documentId })
return v1ErrorResponseFromCode('DOC_DOWNLOAD_FAILED', ctx.log, {
requestId: ctx.requestId,
- details: { reason: signErr?.message ?? 'unknown' },
+ details: { reason: getUserErrorMessage(signErr) ?? 'unknown' },
})
}
diff --git a/app/api/v1/companies/[companyId]/documents/route.ts b/app/api/v1/companies/[companyId]/documents/route.ts
index 0d6d3620..bd863613 100644
--- a/app/api/v1/companies/[companyId]/documents/route.ts
+++ b/app/api/v1/companies/[companyId]/documents/route.ts
@@ -41,6 +41,7 @@ import {
ALLOWED_DOCUMENT_TYPES,
} from '@/lib/core/documents/document-service'
import type { DocumentUploadSource } from '@/types'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
const DocumentUploaded = z.object({
id: z.string().uuid(),
@@ -297,7 +298,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
opLog.warn('document upload rejected by content validation', { reason: message })
return v1ErrorResponseFromCode('DOC_UPLOAD_INVALID_CONTENT', opLog, {
requestId: ctx.requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err) },
})
}
// Full error is logged above; the raw message can leak storage-layer
diff --git a/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts
index 4ae98378..dc8ff1fb 100644
--- a/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts
+++ b/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts
@@ -23,6 +23,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const OpeningBalancesResponse = z.object({
employee_opening_balances_id: z.string().uuid().nullable(),
@@ -200,7 +201,7 @@ export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }
const itemError = result.itemErrors?.[0]
return v1ErrorResponseFromCode(itemError?.code ?? result.code, ctx.log, {
requestId: ctx.requestId,
- details: itemError ? { message: itemError.message } : result.details,
+ details: itemError ? { message: getUserErrorMessage(itemError) } : result.details,
})
}
diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts
index 07d46090..f59e15c2 100644
--- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts
+++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts
@@ -15,6 +15,7 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
const PeriodClosedResponse = z.object({
id: z.string().uuid(),
@@ -128,7 +129,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
- requestId: ctx.requestId, details: { reason: msg },
+ requestId: ctx.requestId, details: { reason: getErrorMessage(err, { locale: 'en' }) },
})
}
},
diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts
index 84671639..a62e0bd2 100644
--- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts
+++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts
@@ -18,6 +18,7 @@ import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
import { startOperation, completeOperation, failOperation } from '@/lib/api/v1/operations'
import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
const Body = z
.object({ as_of_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() })
@@ -177,7 +178,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
id: operationId,
error: {
code: msg.includes('already exists') ? 'CURRENCY_REVALUATION_ALREADY_EXISTS' : 'CURRENCY_REVALUATION_FAILED',
- message: msg,
+ message: getErrorMessage(err, { locale: 'en' }),
},
},
ctx.log,
diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts
index 2b84a740..185f7cac 100644
--- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts
+++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts
@@ -12,6 +12,7 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
const PeriodLockedResponse = z.object({
id: z.string().uuid(),
@@ -83,7 +84,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (msg.includes('already closed') || msg.includes('already locked')) {
return v1ErrorResponseFromCode('CONFLICT', ctx.log, {
requestId: ctx.requestId,
- details: { reason: msg },
+ details: { reason: getErrorMessage(err, { locale: 'en' }) },
})
}
// lockPeriod's uncategorised-transactions error message is in Swedish
@@ -94,13 +95,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (msg.includes('saknar bokföring') || msg.toLowerCase().includes('uncategorised')) {
return v1ErrorResponseFromCode('PERIOD_HAS_UNBOOKED_TRANSACTIONS', ctx.log, {
requestId: ctx.requestId,
- details: { reason: msg },
+ details: { reason: getErrorMessage(err, { locale: 'en' }) },
})
}
ctx.log.error('fiscal-periods.lock unexpected error', err as Error, { fiscalPeriodId: idParse.data })
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
- details: { reason: msg },
+ details: { reason: getErrorMessage(err, { locale: 'en' }) },
})
}
},
diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts
index 37d15db3..dbbae0a9 100644
--- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts
+++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts
@@ -17,6 +17,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
import { generateOpeningBalances } from '@/lib/core/bookkeeping/year-end-service'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
const Body = z.object({ next_period_id: z.string().uuid() }).strict()
@@ -153,7 +154,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
return v1ErrorResponseFromCode('BOOKKEEPING_DATABASE_ERROR', ctx.log, {
- requestId: ctx.requestId, details: { reason: msg, step: 'opening_balances' },
+ requestId: ctx.requestId,
+ details: { reason: getErrorMessage(err, { locale: 'en' }), step: 'opening_balances' },
})
}
},
diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts
index 749e598a..98ba006a 100644
--- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts
+++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts
@@ -21,6 +21,7 @@ import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
import { startOperation, completeOperation, failOperation } from '@/lib/api/v1/operations'
import { executeYearEndClosing } from '@/lib/core/bookkeeping/year-end-service'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
const YearEndAcceptedResponse = z.object({
operation_id: z.string().uuid(),
@@ -133,13 +134,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
)
return accepted(operationId, 'fiscal_periods.year_end', { requestId: ctx.requestId })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'unknown'
ctx.log.error('fiscal-periods.year-end failed', err as Error, { fiscalPeriodId, operationId })
await failOperation(
ctx.supabase,
{
id: operationId,
- error: { code: 'YEAR_END_FAILED', message: msg },
+ error: { code: 'YEAR_END_FAILED', message: getErrorMessage(err, { locale: 'en' }) },
},
ctx.log,
)
diff --git a/app/api/v1/companies/[companyId]/imports/bank/route.ts b/app/api/v1/companies/[companyId]/imports/bank/route.ts
index f9604fe3..75baea01 100644
--- a/app/api/v1/companies/[companyId]/imports/bank/route.ts
+++ b/app/api/v1/companies/[companyId]/imports/bank/route.ts
@@ -37,6 +37,7 @@ import {
} from '@/lib/import/bank-file/parser'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BankImportAccepted = z.object({
operation_id: z.string().uuid(),
@@ -292,14 +293,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
id: op.id,
error: {
code: 'BANK_IMPORT_FAILED',
- message: err instanceof Error ? err.message : 'Unknown failure during bank import.',
+ message: err instanceof Error ? getUserErrorMessage(err) : 'Unknown failure during bank import.',
},
},
ctx.log,
)
return v1ErrorResponseFromCode('BANK_IMPORT_FAILED', ctx.log, {
requestId: ctx.requestId,
- details: { operation_id: op.id, reason: err instanceof Error ? err.message : 'unknown' },
+ details: { operation_id: op.id, reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
diff --git a/app/api/v1/companies/[companyId]/imports/sie/route.ts b/app/api/v1/companies/[companyId]/imports/sie/route.ts
index 8edf5b1a..a689424a 100644
--- a/app/api/v1/companies/[companyId]/imports/sie/route.ts
+++ b/app/api/v1/companies/[companyId]/imports/sie/route.ts
@@ -45,6 +45,7 @@ import {
import { suggestMappings } from '@/lib/import/account-mapper'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { SIEAccountMappingRecord } from '@/lib/import/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SieImportAccepted = z.object({
operation_id: z.string().uuid(),
@@ -141,7 +142,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
requestId: ctx.requestId,
details: {
field: 'options',
- message: `options must be a valid JSON string: ${err instanceof Error ? err.message : 'parse error'}`,
+ message: `options must be a valid JSON string: ${err instanceof Error ? getUserErrorMessage(err) : 'parse error'}`,
},
})
}
@@ -205,7 +206,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
ctx.log.error('SIE parse failed', err as Error)
return v1ErrorResponseFromCode('SIE_PARSE_FAILED', ctx.log, {
requestId: ctx.requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
@@ -309,14 +310,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
id: op.id,
error: {
code: 'SIE_IMPORT_FAILED',
- message: err instanceof Error ? err.message : 'Unknown failure during SIE import.',
+ message: err instanceof Error ? getUserErrorMessage(err) : 'Unknown failure during SIE import.',
},
},
ctx.log,
)
return v1ErrorResponseFromCode('SIE_IMPORT_FAILED', ctx.log, {
requestId: ctx.requestId,
- details: { operation_id: op.id, reason: err instanceof Error ? err.message : 'unknown' },
+ details: { operation_id: op.id, reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts
index 4f771a8b..40635789 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts
@@ -38,7 +38,7 @@ import {
createInvoicePaymentJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
-import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
@@ -451,14 +451,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
invoiceId,
companyId: ctx.companyId,
})
- const message = isBookkeepingError(err)
- ? getErrorMessage(err, { context: 'invoice' })
- : err instanceof Error
- ? err.message
- : 'Unknown error'
return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', ctx.log, {
requestId: ctx.requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err, { context: 'invoice' }) },
})
}
}
diff --git a/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts b/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts
index d45489aa..f851f29e 100644
--- a/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts
+++ b/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts
@@ -25,6 +25,7 @@ import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { createDraftEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import type { Logger } from '@/lib/logger'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BulkRequest = z.object({
journal_entries: z.array(CreateJournalEntrySchema).min(1).max(50),
@@ -140,7 +141,7 @@ async function createOne(
request_index: index,
error: {
code: e.code ?? 'BOOKKEEPING_DATABASE_ERROR',
- message: e.message ?? 'Engine error',
+ message: getUserErrorMessage(e) ?? 'Engine error',
details: e.details,
},
}
diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts
index d8643a53..5ca20dcb 100644
--- a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts
+++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts
@@ -37,6 +37,7 @@ import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SalaryRunBooked = z.object({
id: z.string().uuid(),
@@ -272,7 +273,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
return v1ErrorResponseFromCode('SALARY_RUN_BOOK_FAILED', ctx.log, {
requestId: ctx.requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts
index 600e79ad..cad4e2e7 100644
--- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts
+++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts
@@ -30,6 +30,7 @@ import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookke
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SI_PAID_RESPONSE_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, status, currency, total, paid_amount, remaining_amount, paid_at, payment_journal_entry_id'
@@ -409,7 +410,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
return v1ErrorResponseFromCode('SI_PAID_FAILED', ctx.log, {
requestId: ctx.requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts
index 19293cc2..432aac10 100644
--- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts
@@ -32,7 +32,7 @@ import {
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
-import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
@@ -453,14 +453,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
}
txLog.error('match-invoice: payment JE creation failed: aborting before state mutation', err as Error)
- const message = isBookkeepingError(err)
- ? getErrorMessage(err, { context: 'invoice' })
- : err instanceof Error
- ? err.message
- : 'Unknown error'
return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', txLog, {
requestId: ctx.requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err, { context: 'invoice' }) },
})
}
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts
index e1f69460..3b655a14 100644
--- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts
@@ -18,7 +18,7 @@ import {
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
-import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import { findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -365,14 +365,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (err instanceof AccountsNotInChartError) {
return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
}
- const message = isBookkeepingError(err)
- ? getErrorMessage(err, { context: 'supplier_invoice' })
- : err instanceof Error
- ? err.message
- : 'Unknown error'
return v1ErrorResponseFromCode('MATCH_SI_RECORD_PAYMENT_FAILED', txLog, {
requestId: ctx.requestId,
- details: { reason: message },
+ details: { reason: getErrorMessage(err, { context: 'supplier_invoice' }) },
})
}
diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts
index b56b170f..1b7606ed 100644
--- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts
+++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts
@@ -346,7 +346,7 @@ async function categorizeOne(
ok: false,
request_index: index,
transaction_id: transactionId,
- error: { code: 'INTERNAL_ERROR', message: updateErr.message },
+ error: { code: 'INTERNAL_ERROR', message: getErrorMessage(updateErr) },
}
}
if ((!updated || updated.length === 0) && journalEntryId) {
diff --git a/app/companies/loading.tsx b/app/companies/loading.tsx
new file mode 100644
index 00000000..87a11e93
--- /dev/null
+++ b/app/companies/loading.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function CompaniesLoading() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/invite/[token]/loading.tsx b/app/invite/[token]/loading.tsx
new file mode 100644
index 00000000..a1318361
--- /dev/null
+++ b/app/invite/[token]/loading.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function InviteLoading() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/payslip/[token]/loading.tsx b/app/payslip/[token]/loading.tsx
new file mode 100644
index 00000000..9ad9f684
--- /dev/null
+++ b/app/payslip/[token]/loading.tsx
@@ -0,0 +1,20 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export default function PayslipLoading() {
+ return (
+
+
+
+ )
+}
diff --git a/app/sandbox/page.tsx b/app/sandbox/page.tsx
index cb0e8416..fcd931dc 100644
--- a/app/sandbox/page.tsx
+++ b/app/sandbox/page.tsx
@@ -9,6 +9,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const branding = getBranding()
@@ -33,7 +34,7 @@ export default function SandboxPage() {
if (error) {
toast({
title: 'Kunde inte starta sandlådan',
- description: error.message,
+ description: getUserErrorMessage(error),
variant: 'destructive',
})
setIsLoading(false)
diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx
index 9f5fd6f2..0c82fa3d 100644
--- a/components/agent/AgentChat.tsx
+++ b/components/agent/AgentChat.tsx
@@ -18,6 +18,7 @@ import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import ApprovalCard from './ApprovalCard'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Markdown parser loads on the first assistant message instead of with the
// chat surface itself; react-markdown + remark-gfm pull in the whole
@@ -259,7 +260,7 @@ export default function AgentChat({
})
} catch (err) {
if (signal.aborted) return
- setErrorMessage(err instanceof Error ? err.message : 'Kunde inte nå assistenten.')
+ setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte nå assistenten.')
setStreaming(false)
activeControllerRef.current = null
return
@@ -332,7 +333,7 @@ export default function AgentChat({
}
} catch (err) {
if (!signal.aborted) {
- setErrorMessage(err instanceof Error ? err.message : 'Streamen avbröts.')
+ setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Streamen avbröts.')
}
} finally {
try {
diff --git a/components/agent/AgentSheetProvider.tsx b/components/agent/AgentSheetProvider.tsx
index bf43dffb..88c35097 100644
--- a/components/agent/AgentSheetProvider.tsx
+++ b/components/agent/AgentSheetProvider.tsx
@@ -1,7 +1,9 @@
'use client'
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
-import AgentSheet from './AgentSheet'
+import dynamic from 'next/dynamic'
+
+const AgentSheet = dynamic(() => import('./AgentSheet'))
export interface AgentIdentity {
displayName: string | null
diff --git a/components/agent/ApprovalCard.tsx b/components/agent/ApprovalCard.tsx
index 50b4eddf..dc8dda87 100644
--- a/components/agent/ApprovalCard.tsx
+++ b/components/agent/ApprovalCard.tsx
@@ -11,6 +11,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import type { PendingOperationRejectionCategory } from '@/types'
import { cn } from '@/lib/utils'
import { formatCurrency } from '@/lib/utils'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Inline approval card for an agent-staged pending_operation.
//
@@ -144,7 +145,7 @@ export default function ApprovalCard({
setState('committed')
} catch (err) {
setState('error')
- setErrorMessage(err instanceof Error ? err.message : 'Kunde inte godkänna.')
+ setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte godkänna.')
}
}
@@ -169,7 +170,7 @@ export default function ApprovalCard({
await handleCommit()
} catch (err) {
setState('error')
- setErrorMessage(err instanceof Error ? err.message : 'Kunde inte aktivera kontona.')
+ setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte aktivera kontona.')
}
}
@@ -210,7 +211,7 @@ export default function ApprovalCard({
}
} catch (err) {
setState('error')
- setErrorMessage(err instanceof Error ? err.message : 'Kunde inte avslå.')
+ setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte avslå.')
}
}
diff --git a/components/auth/AuthPageSkeleton.tsx b/components/auth/AuthPageSkeleton.tsx
new file mode 100644
index 00000000..96906ff8
--- /dev/null
+++ b/components/auth/AuthPageSkeleton.tsx
@@ -0,0 +1,19 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+export function AuthPageSkeleton() {
+ return (
+
+
+
+ )
+}
diff --git a/components/bokslut/AnnualReportStudio.tsx b/components/bokslut/AnnualReportStudio.tsx
new file mode 100644
index 00000000..163ce190
--- /dev/null
+++ b/components/bokslut/AnnualReportStudio.tsx
@@ -0,0 +1,589 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { AlertCircle, CheckCircle2, FileClock, Loader2, LockKeyhole, Save } from 'lucide-react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { useToast } from '@/components/ui/use-toast'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+import type {
+ AnnualReportComplianceIssue,
+ AnnualReportProfile,
+ AnnualReportValidationResult,
+ AnnualReportVersionSummary,
+} from '@/lib/bokslut/arsredovisning/compliance-types'
+
+interface ComplianceResponse {
+ profile: AnnualReportProfile
+ eligibility: {
+ k2_eligible: boolean
+ digital_filing_eligible: boolean
+ size_classification: 'smaller' | 'larger' | 'unknown'
+ issues: AnnualReportComplianceIssue[]
+ digital_issues: AnnualReportComplianceIssue[]
+ }
+ validation: AnnualReportValidationResult
+ report_summary: {
+ proposed_dividend: number
+ distributable_equity: number
+ }
+}
+
+interface AnnualReportStudioProps {
+ periodId: string
+ periodStart: string
+ periodEnd: string
+ framework: 'k2' | 'k3'
+ hasUnsavedNarrative: boolean
+ narrativeRevision: string | null
+ onVersionsChanged?: (versions: AnnualReportVersionSummary[]) => void
+}
+
+type NullableBoolean = boolean | null
+
+function BooleanQuestion({
+ id,
+ label,
+ value,
+ onChange,
+}: {
+ id: string
+ label: string
+ value: NullableBoolean
+ onChange: (value: NullableBoolean) => void
+}) {
+ const t = useTranslations('annualReportStudio')
+ return (
+
+ {label}
+
+ onChange(event.target.value === '' ? null : event.target.value === 'yes')
+ }
+ >
+ {t('choose')}
+ {t('no')}
+ {t('yes')}
+
+
+ )
+}
+
+export function AnnualReportStudio({
+ periodId,
+ periodStart,
+ periodEnd,
+ framework,
+ hasUnsavedNarrative,
+ narrativeRevision,
+ onVersionsChanged,
+}: AnnualReportStudioProps) {
+ const t = useTranslations('annualReportStudio')
+ const { toast } = useToast()
+ const [compliance, setCompliance] = useState(null)
+ const [profile, setProfile] = useState(null)
+ const [versions, setVersions] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [creatingVersion, setCreatingVersion] = useState<'snapshot' | 'finalize' | null>(null)
+
+ const complianceUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/compliance`
+ const versionsUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/versions`
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const [complianceResponse, versionsResponse] = await Promise.all([
+ fetch(complianceUrl),
+ fetch(versionsUrl),
+ ])
+ const [complianceBody, versionsBody] = await Promise.all([
+ complianceResponse.json(),
+ versionsResponse.json(),
+ ])
+ if (!complianceResponse.ok) throw new Error(getUserErrorMessage(complianceBody.error))
+ if (!versionsResponse.ok) throw new Error(getUserErrorMessage(versionsBody.error))
+ setCompliance(complianceBody.data as ComplianceResponse)
+ setProfile((complianceBody.data as ComplianceResponse).profile)
+ const nextVersions = (versionsBody.data ?? []) as AnnualReportVersionSummary[]
+ setVersions(nextVersions)
+ onVersionsChanged?.(nextVersions)
+ } catch (err) {
+ toast({
+ title: t('load_error'),
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
+ variant: 'destructive',
+ })
+ } finally {
+ setLoading(false)
+ }
+ }, [complianceUrl, versionsUrl, onVersionsChanged, t, toast])
+
+ useEffect(() => {
+ void load()
+ }, [load, narrativeRevision])
+
+ const showNewK2Questions =
+ periodStart > '2025-12-31' || (periodStart > '2025-06-30' && periodEnd >= '2026-12-31')
+ const blockingIssues = useMemo(
+ () => compliance?.validation.issues.filter((issue) => issue.severity === 'error') ?? [],
+ [compliance],
+ )
+ const digitalOnlyIssues = useMemo(() => {
+ const generalCodes = new Set(compliance?.validation.issues.map((issue) => issue.code) ?? [])
+ return (
+ compliance?.eligibility.digital_issues.filter((issue) => !generalCodes.has(issue.code)) ?? []
+ )
+ }, [compliance])
+
+ const updateProfile = (
+ key: K,
+ value: AnnualReportProfile[K],
+ ) => {
+ setProfile((current) => (current ? { ...current, [key]: value } : current))
+ }
+
+ const saveProfile = async () => {
+ if (!profile) return
+ setSaving(true)
+ try {
+ const response = await fetch(complianceUrl, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ is_public_limited_company: profile.is_public_limited_company,
+ is_in_liquidation: profile.is_in_liquidation,
+ securities_traded_on_regulated_market:
+ profile.securities_traded_on_regulated_market,
+ is_parent_company: profile.is_parent_company,
+ parent_group_size: profile.is_parent_company ? profile.parent_group_size : null,
+ prepares_consolidated_accounts: profile.is_parent_company
+ ? profile.prepares_consolidated_accounts
+ : null,
+ has_foreign_branch: profile.has_foreign_branch,
+ has_crypto_assets: profile.has_crypto_assets,
+ has_share_based_payments: profile.has_share_based_payments,
+ has_convertible_debt: profile.has_convertible_debt,
+ building_revenue_share_pct: profile.building_revenue_share_pct,
+ has_material_deferred_tax: profile.has_material_deferred_tax,
+ reporting_currency: profile.reporting_currency,
+ auditor_report_required: profile.auditor_report_required,
+ auditor_report_included: profile.auditor_report_included,
+ dividend_prudence_confirmed: profile.dividend_prudence_confirmed,
+ k2_assessment_confirmed: true,
+ }),
+ })
+ const body = await response.json()
+ if (!response.ok) throw new Error(getUserErrorMessage(body.error))
+ const next = body.data as ComplianceResponse
+ setCompliance(next)
+ setProfile(next.profile)
+ toast({ title: t('scope_saved') })
+ } catch (err) {
+ toast({
+ title: t('save_error'),
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
+ variant: 'destructive',
+ })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const confirmNarrative = async () => {
+ if (hasUnsavedNarrative) {
+ toast({ title: t('save_content_first'), variant: 'destructive' })
+ return
+ }
+ setSaving(true)
+ try {
+ const response = await fetch(complianceUrl, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ narrative_confirmed: true }),
+ })
+ const body = await response.json()
+ if (!response.ok) throw new Error(getUserErrorMessage(body.error))
+ const next = body.data as ComplianceResponse
+ setCompliance(next)
+ setProfile(next.profile)
+ toast({ title: t('content_confirmed') })
+ } catch (err) {
+ toast({
+ title: t('save_error'),
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
+ variant: 'destructive',
+ })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const confirmSignerRoster = async () => {
+ setSaving(true)
+ try {
+ const response = await fetch(complianceUrl, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ signer_roster_confirmed: true }),
+ })
+ const body = await response.json()
+ if (!response.ok) throw new Error(getUserErrorMessage(body.error))
+ const next = body.data as ComplianceResponse
+ setCompliance(next)
+ setProfile(next.profile)
+ toast({ title: t('signer_roster_confirmed') })
+ } catch (err) {
+ toast({
+ title: t('save_error'),
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
+ variant: 'destructive',
+ })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const createVersion = async (action: 'snapshot' | 'finalize') => {
+ if (hasUnsavedNarrative) {
+ toast({ title: t('save_content_first'), variant: 'destructive' })
+ return
+ }
+ setCreatingVersion(action)
+ try {
+ const response = await fetch(versionsUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action }),
+ })
+ const body = await response.json()
+ if (!response.ok) {
+ if (body.error?.details?.issues) {
+ setCompliance((current) =>
+ current
+ ? { ...current, validation: body.error.details as AnnualReportValidationResult }
+ : current,
+ )
+ }
+ throw new Error(getUserErrorMessage(body.error))
+ }
+ toast({ title: action === 'finalize' ? t('version_locked') : t('snapshot_created') })
+ await load()
+ } catch (err) {
+ toast({
+ title: action === 'finalize' ? t('lock_error') : t('snapshot_error'),
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
+ variant: 'destructive',
+ })
+ } finally {
+ setCreatingVersion(null)
+ }
+ }
+
+ if (loading || !profile || !compliance) {
+ return (
+
+
+ {t('loading')}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
{t('title')}
+
{t('description')}
+
+
+ {blockingIssues.length === 0
+ ? t('no_blockers')
+ : t('blocker_count', { count: blockingIssues.length })}
+
+
+
+ {[
+ [
+ t('step_scope'),
+ compliance.eligibility.issues.every((issue) => issue.severity !== 'error'),
+ ],
+ [t('step_content'), Boolean(profile.narrative_confirmed_at)],
+ [t('step_signatures'), versions.some((version) => version.status === 'signed')],
+ [t('step_filing'), versions.some((version) => ['filed', 'registered'].includes(version.status))],
+ ].map(([label, complete], index) => (
+
+ {complete ? (
+
+ ) : (
+
+ {index + 1}
+
+ )}
+ {label}
+
+ ))}
+
+
+
+
+
+
+ {t('scope_title')}
+ {t('scope_description')}
+
+
+
+
updateProfile('is_public_limited_company', value)}
+ />
+ updateProfile('is_in_liquidation', value)}
+ />
+ updateProfile('securities_traded_on_regulated_market', value)}
+ />
+ updateProfile('is_parent_company', value)}
+ />
+ updateProfile('auditor_report_required', value)}
+ />
+
+ {t('reporting_currency')}
+
+ updateProfile(
+ 'reporting_currency',
+ event.target.value as AnnualReportProfile['reporting_currency'],
+ )
+ }
+ >
+ SEK
+ EUR
+
+
+ {profile.auditor_report_required && (
+ updateProfile('auditor_report_included', Boolean(value))}
+ />
+ )}
+
+
+ {profile.is_parent_company && (
+
+
+ {t('group_size')}
+
+ updateProfile(
+ 'parent_group_size',
+ (event.target.value || null) as AnnualReportProfile['parent_group_size'],
+ )
+ }
+ >
+ {t('choose')}
+ {t('group_small')}
+ {t('group_large')}
+
+
+
updateProfile('prepares_consolidated_accounts', value)}
+ />
+
+ )}
+
+ {framework === 'k2' && showNewK2Questions && (
+
+
{t('k2_2026_title')}
+
+
+ )}
+
+ {compliance.report_summary.proposed_dividend > 0 && (
+
+
updateProfile('dividend_prudence_confirmed', value)}
+ />
+
+ {t('dividend_prudence_description', {
+ dividend: compliance.report_summary.proposed_dividend,
+ equity: compliance.report_summary.distributable_equity,
+ })}
+
+
+ )}
+
+
+ void saveProfile()} disabled={saving}>
+ {saving ? : }
+ {t('save_scope')}
+
+
+
+
+
+
+
+ {t('checks_title')}
+
+
+ {blockingIssues.length === 0 ? (
+
+ {t('no_blockers')}
+
+ ) : (
+
+ {blockingIssues.map((issue) => (
+
+
+
+
{issue.message}
+ {issue.remediation &&
{issue.remediation}
}
+
+
+ ))}
+
+ )}
+ {digitalOnlyIssues.length > 0 && (
+
+
{t('digital_checks_title')}
+
+ {digitalOnlyIssues.map((issue) => (
+
+ {issue.message}
+
+ ))}
+
+
+ )}
+
+ void confirmSignerRoster()}
+ disabled={saving || Boolean(profile.signer_roster_confirmed_at)}
+ >
+
+ {profile.signer_roster_confirmed_at
+ ? t('signer_roster_confirmed')
+ : t('confirm_signer_roster')}
+
+ void confirmNarrative()}
+ disabled={saving || hasUnsavedNarrative || Boolean(profile.narrative_confirmed_at)}
+ >
+
+ {profile.narrative_confirmed_at ? t('content_confirmed') : t('confirm_content')}
+
+ void createVersion('snapshot')} disabled={creatingVersion !== null}>
+ {creatingVersion === 'snapshot' ? : }
+ {t('create_snapshot')}
+
+ void createVersion('finalize')} disabled={creatingVersion !== null || blockingIssues.length > 0 || hasUnsavedNarrative}>
+ {creatingVersion === 'finalize' ? : }
+ {t('lock_version')}
+
+
+
+
+
+
+
+ {t('versions_title')}
+
+
+ {versions.length === 0 ? (
+ {t('versions_empty')}
+ ) : (
+
+ {versions.map((version) => (
+
+
+
{t('version_label', { number: version.version_number })}
+
{version.content_hash.slice(0, 12)}
+
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/components/bokslut/DigitalInlamning.tsx b/components/bokslut/DigitalInlamning.tsx
index 0ea6cf7f..37d76a9e 100644
--- a/components/bokslut/DigitalInlamning.tsx
+++ b/components/bokslut/DigitalInlamning.tsx
@@ -6,7 +6,7 @@
* Three steps below the year-end ÅR editors:
* 1. Granska: the generated iXBRL rendered in an iframe (the XHTML *is*
* the filed presentation) + pre-flight validation results + download
- * for manual filing (the self-hosted/no-extension path).
+ * for technical inspection and support diagnostics.
* 2. Skicka in: only when the bolagsverket extension responds: avtalstext
* acceptance → kontrollera-utfall → upload till eget utrymme → kvittens
* with "signera hos Bolagsverket"-link. The fastställelseintyg is signed
@@ -17,6 +17,7 @@
*/
import { useCallback, useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -33,13 +34,16 @@ import {
Send,
ShieldCheck,
} from 'lucide-react'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+import { CONNECTED_FILING_PUBLIC_RELEASED } from '@/lib/bokslut/arsredovisning/capabilities'
+import type { AnnualReportVersionSummary } from '@/lib/bokslut/arsredovisning/compliance-types'
/** Inlämningen till Bolagsverket väntar på avtal + organisationscertifikat
* (M0). Tills dess visas hela digital inlämning-sektionen blurrad med en
* "Kommer snart"-skylt: endast PDF-nedladdningen på ÅR-sidan är användbar.
* Flippa till false när integrationen är godkänd. Importeras också av
* ÅR-sidan som blurrar sina Bolagsverket-delar med samma flagga. */
-export const INLAMNING_COMING_SOON = true
+export const INLAMNING_COMING_SOON = !CONNECTED_FILING_PUBLIC_RELEASED
interface PreflightIssue {
code: string
@@ -66,7 +70,6 @@ interface SubmissionRow {
id: string
status: string
environment: string
- idnummer: string | null
kontrollsumma: string | null
sha256_checksumma: string | null
bolagsverket_url: string | null
@@ -76,13 +79,33 @@ interface SubmissionRow {
uploaded_at: string | null
registered_at: string | null
created_at: string
+ annual_report_version_id: string | null
+ archive_status: 'pending' | 'stored' | 'failed'
+}
+
+interface RegistryInformation {
+ namn: string
+ status: Array<{ kod?: string; text?: string }>
+ rakenskapsperioder: Array<{
+ from: string
+ tom: string
+ kravPaRevisionsberattelse: 'ja' | 'nej' | 'uppgift_saknas'
+ revisorsplikt: 'ja' | 'nej' | 'uppgift_saknas'
+ }>
+}
+
+interface RegistryCaseStatus {
+ typ: string
+ arendenummer: string | null
+ rakenskapsperiod: { from: string; tom: string } | null
}
type SubmitOutcome =
| { outcome: 'avtal_required'; avtalstext: string; avtalstextAndrad: string }
| { outcome: 'preflight_failed'; issues: PreflightIssue[] }
| { outcome: 'kontrollera_stopped'; submissionId: string; utfall: KontrolleraUtfall[] }
- | { outcome: 'uploaded'; submissionId: string; idnummer: string; url: string; utfall: KontrolleraUtfall[] }
+ | { outcome: 'uploaded'; submissionId: string; url: string; utfall: KontrolleraUtfall[] }
+ | { outcome: 'state_unknown'; submissionId: string; url: string | null; message: string }
/**
* Normalize a Swedish personnummer to the 12-digit ÅÅÅÅMMDDNNNN form the
@@ -108,7 +131,9 @@ function normalizePnr(raw: string): string | null {
const STATUS_BADGES: Record = {
draft: { label: 'Utkast', variant: 'outline' },
kontrollerad: { label: 'Kontrollerad', variant: 'secondary' },
+ sending: { label: 'Skickar: avvakta', variant: 'warning' },
uploaded: { label: 'Uppladdad: väntar på signering', variant: 'warning' },
+ unknown: { label: 'Okänd extern status: skicka inte igen', variant: 'destructive' },
inkommen: { label: 'Inkommen till Bolagsverket', variant: 'secondary' },
forelagd: { label: 'Föreläggande: åtgärd krävs', variant: 'destructive' },
komplettering: { label: 'Komplettering inlämnad', variant: 'secondary' },
@@ -119,6 +144,7 @@ const STATUS_BADGES: Record(null)
+ const [filingEnabled, setFilingEnabled] = useState(false)
const [environment, setEnvironment] = useState('test')
// Submission form
@@ -139,23 +166,32 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
const [submitting, setSubmitting] = useState(false)
const [avtal, setAvtal] = useState<{ text: string; andrad: string; accepted: boolean } | null>(null)
const [utfall, setUtfall] = useState(null)
- const [kvittens, setKvittens] = useState<{ idnummer: string; url: string } | null>(null)
+ const [kvittens, setKvittens] = useState<{ url: string } | null>(null)
- // Proposed dividend (utdelning) for the resultatdisposition. There is no
- // persisted dividend proposal in the year-end flow yet, so the value is
- // entered here and forwarded to the preview, the download and the
- // submission so all three render the same disposition.
- const [utdelning, setUtdelning] = useState('')
- const parsedUtdelning = Math.round(Number(utdelning.replace(/\s/g, '').replace(',', '.')))
- const utdelningValue = Number.isFinite(parsedUtdelning) && parsedUtdelning > 0 ? parsedUtdelning : 0
- const previewUrl = utdelningValue > 0 ? `${ixbrlUrl}?utdelning=${utdelningValue}` : ixbrlUrl
- const downloadUrl =
- utdelningValue > 0 ? `${ixbrlUrl}?download=1&utdelning=${utdelningValue}` : `${ixbrlUrl}?download=1`
+ const [versions, setVersions] = useState([])
+ const [selectedVersionId, setSelectedVersionId] = useState('')
+ const [registryInformation, setRegistryInformation] = useState(null)
+ const [registryCase, setRegistryCase] = useState(null)
+
+ const versionQuery = selectedVersionId
+ ? `version=${encodeURIComponent(selectedVersionId)}`
+ : ''
+ const previewUrl = versionQuery ? `${ixbrlUrl}?${versionQuery}` : ixbrlUrl
+ const downloadUrl = `${ixbrlUrl}?download=1${versionQuery ? `&${versionQuery}` : ''}`
const [submissions, setSubmissions] = useState([])
const [loadingSubmissions, setLoadingSubmissions] = useState(false)
const [submissionsError, setSubmissionsError] = useState(null)
+ useEffect(() => {
+ const signer = versions.find((version) => version.id === selectedVersionId)
+ ?.certificate_signer
+ if (!signer) return
+ setFornamn(signer.first_name)
+ setEfternamn(signer.last_name)
+ setRoll(signer.role)
+ }, [selectedVersionId, versions])
+
const loadSubmissions = useCallback(async () => {
setLoadingSubmissions(true)
try {
@@ -179,6 +215,25 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
}
}, [periodId])
+ const loadVersions = useCallback(async () => {
+ try {
+ const res = await fetch(
+ `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/versions`,
+ )
+ if (!res.ok) return
+ const body = await res.json()
+ const rows = (body.data ?? []) as AnnualReportVersionSummary[]
+ setVersions(rows)
+ const latestSigned = rows.find(
+ (version) => version.status === 'signed' && version.digital_filing_eligible,
+ )
+ if (latestSigned) setSelectedVersionId((current) => current || latestSigned.id)
+ } catch {
+ // The version list is also visible in the studio. Submission stays
+ // disabled here until a signed version can be selected.
+ }
+ }, [periodId])
+
useEffect(() => {
let cancelled = false
fetch('/api/extensions/ext/bolagsverket/status')
@@ -190,26 +245,43 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
}
const body = await res.json()
setExtensionActive(true)
+ setFilingEnabled(body.data?.filing_enabled === true)
setEnvironment(body.data?.environment ?? 'test')
void loadSubmissions()
+ void Promise.all([
+ fetch('/api/extensions/ext/bolagsverket/grunduppgifter'),
+ fetch('/api/extensions/ext/bolagsverket/arendestatus'),
+ ])
+ .then(async ([informationResponse, caseResponse]) => {
+ if (informationResponse.ok) {
+ const informationBody = await informationResponse.json()
+ if (!cancelled) setRegistryInformation(informationBody.data as RegistryInformation)
+ }
+ if (caseResponse.ok) {
+ const caseBody = await caseResponse.json()
+ if (!cancelled) setRegistryCase(caseBody.data as RegistryCaseStatus)
+ }
+ })
+ .catch(() => undefined)
})
.catch(() => {
if (!cancelled) setExtensionActive(false)
})
+ void loadVersions()
return () => {
cancelled = true
}
- }, [loadSubmissions])
+ }, [loadSubmissions, loadVersions])
const handleValidate = async () => {
setValidating(true)
try {
const res = await fetch(
- `${ixbrlUrl}/validate${utdelningValue > 0 ? `?utdelning=${utdelningValue}` : ''}`,
+ `${ixbrlUrl}/validate${versionQuery ? `?${versionQuery}` : ''}`,
)
const body = await res.json()
if (body?.error) {
- toast({ title: 'Kunde inte validera', description: body.error.message, variant: 'destructive' })
+ toast({ title: 'Kunde inte validera', description: getUserErrorMessage(body.error), variant: 'destructive' })
return
}
setValidation(body.data as ValidateResponse)
@@ -233,6 +305,10 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
toast({ title: 'Fyll i undertecknarens namn och e-post', variant: 'destructive' })
return
}
+ if (!selectedVersionId) {
+ toast({ title: 'Lås och underteckna en version före inlämning', variant: 'destructive' })
+ return
+ }
setSubmitting(true)
setUtfall(null)
try {
@@ -241,6 +317,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fiscal_period_id: periodId,
+ annual_report_version_id: selectedVersionId,
avsandare_pnr: normalizedAvsandare,
undertecknare: {
pnr: normalizedPnr,
@@ -249,14 +326,13 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
roll,
epost: epost.trim(),
},
- ...(utdelningValue > 0 ? { utdelning: utdelningValue } : {}),
...(avtal?.accepted ? { accepted_avtalstext_andrad: avtal.andrad } : {}),
...(opts.ignoreWarnings ? { ignore_warnings: true } : {}),
}),
})
const body = await res.json()
if (body?.error) {
- toast({ title: 'Inlämningen misslyckades', description: body.error.message, variant: 'destructive' })
+ toast({ title: 'Inlämningen misslyckades', description: getUserErrorMessage(body.error), variant: 'destructive' })
return
}
const result = body.data as SubmitOutcome
@@ -285,7 +361,17 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
void loadSubmissions()
return
}
- setKvittens({ idnummer: result.idnummer, url: result.url })
+ if (result.outcome === 'state_unknown') {
+ setKvittens(result.url ? { url: result.url } : null)
+ void loadSubmissions()
+ toast({
+ title: 'Okänd status hos Bolagsverket',
+ description: result.message,
+ variant: 'destructive',
+ })
+ return
+ }
+ setKvittens({ url: result.url })
setUtfall(result.utfall.length > 0 ? result.utfall : null)
setAvtal(null)
void loadSubmissions()
@@ -306,7 +392,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
})
const body = await res.json()
if (body?.error) {
- toast({ title: 'Kunde inte hämta händelser', description: body.error.message, variant: 'destructive' })
+ toast({ title: 'Kunde inte hämta händelser', description: getUserErrorMessage(body.error), variant: 'destructive' })
return
}
void loadSubmissions()
@@ -340,31 +426,16 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
-
-
Föreslagen utdelning (kr)
-
setUtdelning(event.target.value)}
- />
-
- Ingår i resultatdispositionen i dokumentet: 0 betyder att allt
- balanseras i ny räkning. Beloppet följer med förhandsgranskning,
- nedladdning och inlämning.
-
-
-
setShowPreview((value) => !value)}>
+ setShowPreview((value) => !value)}>
{showPreview ? 'Dölj förhandsgranskning' : 'Förhandsgranska iXBRL'}
-
+
- Ladda ner iXBRL (.xhtml)
+ Ladda ner tekniskt iXBRL-underlag
- void handleValidate()} disabled={validating}>
+ void handleValidate()} disabled={validating}>
{validating ? (
) : (
@@ -436,13 +507,13 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
Bolagsverket-integrationen är inte aktiverad i den här installationen.
- Ladda ner iXBRL-filen ovan och lämna in den manuellt via Bolagsverkets
- e-tjänst, eller aktivera integrationen (kräver avtal med Bolagsverket och
- organisationscertifikat).
+ Digital inlämning kräver en ansluten programvara. Använd PDF-flödet för
+ pappersinlämning per post, eller aktivera integrationen efter avtal,
+ organisationscertifikat och godkänt acceptanstest.
-
+
@@ -452,7 +523,14 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
)}
- {extensionActive === true && (
+ {extensionActive === true && !filingEnabled && (
+
+ Anslutningen finns installerad men säkerhetsgrinden för inlämning är stängd.
+ Den öppnas först efter genomförd acceptanstest och produktionsgodkännande.
+
+ )}
+
+ {extensionActive === true && filingEnabled && (
<>
{environment !== 'prod' && (
@@ -460,11 +538,58 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
)}
+
+
Undertecknad årsredovisningsversion
+
{
+ const versionId = event.target.value
+ setSelectedVersionId(versionId)
+ const signer = versions.find((version) => version.id === versionId)
+ ?.certificate_signer
+ if (signer) {
+ setFornamn(signer.first_name)
+ setEfternamn(signer.last_name)
+ setRoll(signer.role)
+ }
+ }}
+ >
+ Välj undertecknad version
+ {versions
+ .filter(
+ (version) =>
+ version.status === 'signed' && version.digital_filing_eligible,
+ )
+ .map((version) => (
+
+ Version {version.version_number}: skapad {formatDate(version.created_at)}
+
+ ))}
+
+ {versions.every(
+ (version) =>
+ version.status !== 'signed' || !version.digital_filing_eligible,
+ ) && (
+
+ {tStudio('no_digital_version')}
+
+ )}
+ {selectedVersionId && (
+
+ Namn och roll för fastställelseintyget kommer från den låsta versionen och
+ måste vara oförändrade vid inlämning.
+
+ )}
+
+
Ditt personnummer (avsändare)
Undertecknarens personnummer
Undertecknarens förnamn
- setFornamn(event.target.value)} />
+ setFornamn(event.target.value)}
+ readOnly={Boolean(selectedVersionId)}
+ />
Undertecknarens efternamn
- setEfternamn(event.target.value)} />
+ setEfternamn(event.target.value)}
+ readOnly={Boolean(selectedVersionId)}
+ />
Roll
setRoll(event.target.value)}
+ disabled={Boolean(selectedVersionId)}
>
Styrelseledamot
Styrelseordförande
@@ -506,6 +645,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
Undertecknarens e-post
Personnumren skickas till Bolagsverket för att skapa eget utrymme och bjuda
- in undertecknaren. De sparas inte i Accounted: endast en teknisk
- referens (hash) lagras.
+ in undertecknaren. De sparas inte i Accounted och skrivs inte till loggar.
+ {registryInformation && (
+
+
Grunduppgifter från Bolagsverket
+
+ {registryInformation.namn}
+ {registryInformation.status[0]?.text
+ ? `: ${registryInformation.status[0].text}`
+ : ''}
+
+ {registryInformation.rakenskapsperioder[0] && (
+
+ Senaste period: {registryInformation.rakenskapsperioder[0].from} till{' '}
+ {registryInformation.rakenskapsperioder[0].tom}. Krav på
+ revisionsberättelse:{' '}
+ {registryInformation.rakenskapsperioder[0].kravPaRevisionsberattelse}.
+
+ )}
+ {registryCase && (
+
+ Senaste ärendestatus: {registryCase.typ}
+ {registryCase.arendenummer ? `, ärende ${registryCase.arendenummer}` : ''}.
+
+ )}
+
+ )}
+
{avtal && (
Villkor för eget utrymme hos Bolagsverket
{avtal.text}
-
+
void handleSubmit()}
- disabled={submitting || blockingErrors || (avtal !== null && !avtal.accepted)}
+ disabled={
+ submitting ||
+ blockingErrors ||
+ !selectedVersionId ||
+ (avtal !== null && !avtal.accepted)
+ }
>
{submitting ? (
@@ -580,6 +751,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
{utfall && utfall.length > 0 && !utfallHasErrors && (
void handleSubmit({ ignoreWarnings: true })}
disabled={submitting}
@@ -596,11 +768,10 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
Uppladdad till eget utrymme
- Dokument-id: {kvittens.idnummer} .
Undertecknaren har fått e-post från Bolagsverket och signerar
fastställelseintyget där. Ärendet startar först efter signering.
-
+
Signera hos Bolagsverket
@@ -623,7 +794,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
- void handlePollEvents()}>
+ void handlePollEvents()}>
Uppdatera status
@@ -653,14 +824,13 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
return (
{badge.label}
{envLabel ? `${envLabel} · ` : ''}
{formatDate(submission.created_at)}
- {submission.idnummer ? ` · id ${submission.idnummer}` : ''}
{submission.undertecknare_namn ? ` · ${submission.undertecknare_namn}` : ''}
{submission.status === 'forelagd' && (
@@ -674,7 +844,7 @@ export function DigitalInlamning({ periodId }: { periodId: string }) {
)}
{submission.bolagsverket_url && submission.status === 'uploaded' && (
-
+
Signera
diff --git a/components/bookkeeping/AddAccountDialog.tsx b/components/bookkeeping/AddAccountDialog.tsx
index ff42e280..b0aea607 100644
--- a/components/bookkeeping/AddAccountDialog.tsx
+++ b/components/bookkeeping/AddAccountDialog.tsx
@@ -18,6 +18,7 @@ import { Loader2, AlertTriangle } from 'lucide-react'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import type { BASAccount } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AddAccountDialogProps {
open: boolean
@@ -107,7 +108,7 @@ export function AddAccountDialog({
onCreated(createdAccount)
onOpenChange(false)
} catch (err) {
- setError(err instanceof Error ? err.message : 'Något gick fel')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Något gick fel')
} finally {
setIsSaving(false)
}
diff --git a/components/bookkeeping/ChartOfAccountsManager.tsx b/components/bookkeeping/ChartOfAccountsManager.tsx
index 0ea24a55..0c55efe0 100644
--- a/components/bookkeeping/ChartOfAccountsManager.tsx
+++ b/components/bookkeeping/ChartOfAccountsManager.tsx
@@ -29,6 +29,7 @@ import {
} from 'lucide-react'
import type { BASAccount } from '@/types'
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ---------------------------------------------------------------------------
// Types
@@ -243,7 +244,7 @@ export default function ChartOfAccountsManager() {
await refreshAll()
} catch (err) {
toast({
- title: err instanceof Error ? err.message : t('toast_delete_failed'),
+ title: err instanceof Error ? getUserErrorMessage(err) : t('toast_delete_failed'),
variant: 'destructive',
})
} finally {
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx
index 053f405a..f1c9446a 100644
--- a/components/bookkeeping/JournalEntryList.tsx
+++ b/components/bookkeeping/JournalEntryList.tsx
@@ -1233,7 +1233,7 @@ export default function JournalEntryList() {
)}
{entry.description}
-
+
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
diff --git a/components/bookkeeping/PruneAccountsDialog.tsx b/components/bookkeeping/PruneAccountsDialog.tsx
index 96727494..dc3fc110 100644
--- a/components/bookkeeping/PruneAccountsDialog.tsx
+++ b/components/bookkeeping/PruneAccountsDialog.tsx
@@ -19,6 +19,7 @@ import {
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { Loader2, Search, Trash2 } from 'lucide-react'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface PruneCandidate {
account_number: string
@@ -188,7 +189,7 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
onOpenChange(false)
} catch (err) {
toast({
- title: err instanceof Error && err.message ? err.message : t('toast_prune_failed'),
+ title: err instanceof Error && getUserErrorMessage(err) ? getUserErrorMessage(err) : t('toast_prune_failed'),
variant: 'destructive',
})
} finally {
diff --git a/components/bookkeeping/assets/CreateAssetDialog.tsx b/components/bookkeeping/assets/CreateAssetDialog.tsx
index d6d95d5a..2a81ca84 100644
--- a/components/bookkeeping/assets/CreateAssetDialog.tsx
+++ b/components/bookkeeping/assets/CreateAssetDialog.tsx
@@ -23,6 +23,7 @@ import { useToast } from '@/components/ui/use-toast'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils'
import type { AssetCategory, DepreciationMethod, K3Component } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface CreateAssetDialogProps {
open: boolean
@@ -247,7 +248,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
})
const body = await res.json()
if (!res.ok) {
- setError(body?.error?.message ?? 'Kunde inte spara tillgången')
+ setError(getUserErrorMessage(body?.error) ?? 'Kunde inte spara tillgången')
return
}
toast({ title: 'Tillgång sparad', description: name.trim() })
@@ -260,7 +261,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
setComponentRows([])
onCreated()
} catch (err) {
- setError(err instanceof Error ? err.message : 'Okänt fel')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setSubmitting(false)
}
diff --git a/components/bookkeeping/year-end/AccrualsStep.tsx b/components/bookkeeping/year-end/AccrualsStep.tsx
index 09d92928..bffc1ee9 100644
--- a/components/bookkeeping/year-end/AccrualsStep.tsx
+++ b/components/bookkeeping/year-end/AccrualsStep.tsx
@@ -12,6 +12,7 @@ import { ArrowRight, Loader2, Plus, Trash2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import type { AccrualsProposal } from '@/lib/bokslut/accruals/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AccrualsStepProps {
periodId: string
@@ -56,7 +57,7 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
const body = await res.json()
if (cancelled) return
if (!res.ok) {
- setError(body?.error?.message ?? 'Kunde inte ladda periodiseringar')
+ setError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda periodiseringar')
return
}
setProposal(body.data as AccrualsProposal)
@@ -140,7 +141,7 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
})
const body = await res.json()
if (!res.ok) {
- setError(body?.error?.message ?? 'Kunde inte bokföra periodiseringarna')
+ setError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra periodiseringarna')
return
}
const created = body.data?.created?.length ?? 0
@@ -152,7 +153,7 @@ export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps
})
onContinue()
} catch (err) {
- setError(err instanceof Error ? err.message : 'Okänt fel')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
diff --git a/components/bookkeeping/year-end/DepreciationPanel.tsx b/components/bookkeeping/year-end/DepreciationPanel.tsx
index 63c4ffa7..d90ce8e9 100644
--- a/components/bookkeeping/year-end/DepreciationPanel.tsx
+++ b/components/bookkeeping/year-end/DepreciationPanel.tsx
@@ -18,6 +18,7 @@ import {
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import type { Asset } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface ProposalItem {
asset: Asset
@@ -55,12 +56,12 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`)
const body = await res.json()
if (!res.ok) {
- setError(body?.error?.message ?? 'Kunde inte ladda avskrivningar')
+ setError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda avskrivningar')
return
}
setProposal(body.data as Proposal)
} catch (err) {
- setError(err instanceof Error ? err.message : 'Okänt fel')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setLoading(false)
}
@@ -80,7 +81,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
})
const body = await res.json()
if (!res.ok) {
- setError(body?.error?.message ?? 'Kunde inte bokföra avskrivningar')
+ setError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra avskrivningar')
return
}
const posted = body.data?.posted?.length ?? 0
@@ -92,7 +93,7 @@ export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps
onPosted()
await load()
} catch (err) {
- setError(err instanceof Error ? err.message : 'Okänt fel')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
diff --git a/components/bookkeeping/year-end/DispositionsStep.tsx b/components/bookkeeping/year-end/DispositionsStep.tsx
index aa2d623b..1a3071d8 100644
--- a/components/bookkeeping/year-end/DispositionsStep.tsx
+++ b/components/bookkeeping/year-end/DispositionsStep.tsx
@@ -8,7 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { ArrowRight, AlertTriangle, Loader2 } from 'lucide-react'
+import { ArrowRight, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { DepreciationPanel } from './DepreciationPanel'
@@ -17,7 +17,9 @@ import type {
DispositionsProposal,
ProposedDisposition,
DispositionKind,
+ TaxAdjustmentSnapshot,
} from '@/lib/bokslut/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface DispositionsStepProps {
periodId: string
@@ -30,6 +32,12 @@ interface UiState {
selections: Record
}
+interface TaxAdjustmentDraft {
+ nonDeductibleExpenses: string
+ nonTaxableIncome: string
+ detectedAccounts: { '6992': boolean; '8423': boolean }
+}
+
/**
* Phase 2 bokslutsdispositioner step. Fetches proposals from the dispositions
* API, lets the user adjust amounts (or skip) per proposal, then POSTs the
@@ -47,6 +55,9 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
const [ui, setUi] = useState({ selections: {} })
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState(null)
+ const [savingAdjustments, setSavingAdjustments] = useState(false)
+ const [adjustmentError, setAdjustmentError] = useState(null)
+ const [taxAdjustmentDraft, setTaxAdjustmentDraft] = useState(emptyTaxDraft)
// ---- Fetch proposals ----
const loadProposals = useCallback(async () => {
@@ -58,24 +69,13 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
)
const body = await res.json()
if (!res.ok) {
- setFetchError(body?.error?.message ?? 'Kunde inte ladda dispositioner')
+ setFetchError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda dispositioner')
return
}
const data = body.data as DispositionsProposal
setProposal(data)
- const selections: UiState['selections'] = {}
- data.proposals.forEach((p, index) => {
- // Key must match the render loop and buildPostItems, which both pass
- // the array index: omitting it here defaulted every non-ateforing
- // key to ":0", so only the first proposal card ever rendered.
- const key = proposalKey(p, index)
- selections[key] = {
- accept: true,
- overrideAmount: p.amount,
- lockedSkip: Boolean(p.required),
- }
- })
- setUi({ selections })
+ setUi(createUiState(data))
+ setTaxAdjustmentDraft(createTaxAdjustmentDraft(data.taxAdjustments))
} catch {
setFetchError('Kunde inte ladda dispositioner')
} finally {
@@ -83,6 +83,52 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
}
}, [periodId])
+ const handleSaveAdjustments = useCallback(async () => {
+ setSavingAdjustments(true)
+ setAdjustmentError(null)
+ try {
+ const res = await fetch(
+ `/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`,
+ {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ manualAdjustments: {
+ nonDeductibleExpenses: parseNonNegativeAmount(
+ taxAdjustmentDraft.nonDeductibleExpenses,
+ ),
+ nonTaxableIncome: parseNonNegativeAmount(taxAdjustmentDraft.nonTaxableIncome),
+ },
+ detectedAccounts: taxAdjustmentDraft.detectedAccounts,
+ }),
+ },
+ )
+ const body = await res.json()
+ if (!res.ok) {
+ setAdjustmentError(
+ getUserErrorMessage(body?.error) ?? 'Kunde inte spara skattemässiga justeringar',
+ )
+ return
+ }
+ const data = body.data as DispositionsProposal
+ setProposal(data)
+ setUi(createUiState(data))
+ setTaxAdjustmentDraft(createTaxAdjustmentDraft(data.taxAdjustments))
+ toast({
+ title: 'Skatteunderlaget är uppdaterat',
+ description: 'Bolagsskatten har räknats om utan att ändra periodiseringsfonden.',
+ })
+ } catch (err) {
+ setAdjustmentError(
+ err instanceof Error
+ ? getUserErrorMessage(err)
+ : 'Kunde inte spara skattemässiga justeringar',
+ )
+ } finally {
+ setSavingAdjustments(false)
+ }
+ }, [periodId, taxAdjustmentDraft, toast])
+
useEffect(() => {
void loadProposals()
}, [loadProposals])
@@ -90,6 +136,10 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
// ---- POST accepted dispositions ----
const handleCommit = useCallback(async () => {
if (!proposal) return
+ if (proposal.completedDispositions?.some((item) => item.status === 'needs_correction')) {
+ setPostError('Rätta den bokförda bolagsskatten och ladda om sidan innan du fortsätter.')
+ return
+ }
setPosting(true)
setPostError(null)
try {
@@ -106,7 +156,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
})
const body = await res.json()
if (!res.ok) {
- setPostError(body?.error?.message ?? 'Kunde inte bokföra dispositioner')
+ setPostError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra dispositioner')
return
}
const created = body.data?.created ?? []
@@ -118,7 +168,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
})
onContinue()
} catch (err) {
- setPostError(err instanceof Error ? err.message : 'Okänt fel')
+ setPostError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
@@ -149,6 +199,10 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
if (!proposal) return null
+ const hasCorrectionRequired = proposal.completedDispositions?.some(
+ (item) => item.status === 'needs_correction',
+ ) ?? false
+
// EF: depreciation can apply (skattemässig hanteras separat); replace the
// AB-only dispositioner with a NE-bilaga declaration section.
if (proposal.entityType !== 'aktiebolag') {
@@ -171,29 +225,6 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
)
}
- if (proposal.proposals.length === 0) {
- return (
-
-
void loadProposals()} />
-
-
- Inga dispositioner föreslagna
-
-
- Bolaget har varken pensionskostnader, periodiseringsfonder att hantera, vinst att skatta
- på, eller skattemässiga avskrivningar att boka.
-
-
-
-
Tillbaka
-
- Fortsätt
-
-
-
- )
- }
-
return (
void loadProposals()} />
@@ -210,6 +241,60 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
+ {proposal.taxAdjustments && (
+ item.kind === 'bolagsskatt')}
+ saving={savingAdjustments}
+ error={adjustmentError}
+ onChange={setTaxAdjustmentDraft}
+ onSave={() => void handleSaveAdjustments()}
+ />
+ )}
+
+ {(proposal.completedDispositions ?? []).map((completed) => (
+
+
+
+
+
{completed.label}
+
+ {completed.status === 'booked' ? (
+
+ ) : (
+
+ )}
+ {completed.status === 'booked' ? 'Redan bokförd' : 'Behöver rättas'}
+
+
+
+ {formatCurrency(completed.amount)}
+
+
+
+ {completed.warnings.length > 0 && (
+
+ {completed.warnings.map((warning) => (
+ {warning}
+ ))}
+
+ )}
+
+ ))}
+
+ {proposal.proposals.length === 0
+ && (proposal.completedDispositions ?? []).length === 0 && (
+
+
+ Inga bokslutsdispositioner behöver bokföras utifrån det aktuella underlaget.
+
+
+ )}
+
{proposal.proposals.map((p, i) => {
const key = proposalKey(p, i)
const sel = ui.selections[key]
@@ -240,7 +325,7 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
Tillbaka
-
+
{posting ? (
<>
Bokför…
@@ -256,6 +341,136 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
)
}
+function TaxAdjustmentsCard({
+ snapshot,
+ draft,
+ taxProposal,
+ saving,
+ error,
+ onChange,
+ onSave,
+}: {
+ snapshot: TaxAdjustmentSnapshot
+ draft: TaxAdjustmentDraft
+ taxProposal?: ProposedDisposition
+ saving: boolean
+ error: string | null
+ onChange: (draft: TaxAdjustmentDraft) => void
+ onSave: () => void
+}) {
+ const detected = snapshot.items.filter(
+ (item) => item.source === 'detected' && item.accountNumber && item.amount > 0,
+ )
+ const computation = taxProposal?.computation
+
+ return (
+
+
+ Skattemässiga justeringar
+
+ Återläggningar påverkar skatten och INK2, men skapar ingen egen verifikation och ändrar
+ inte en redan bokförd periodiseringsfond.
+
+
+
+ {detected.length > 0 && (
+
+
Upptäckt i bokföringen
+ {detected.map((item) => {
+ const account = item.accountNumber as '6992' | '8423'
+ return (
+
+
+
+ onChange({
+ ...draft,
+ detectedAccounts: {
+ ...draft.detectedAccounts,
+ [account]: Boolean(checked),
+ },
+ })
+ }
+ />
+
+ Konto {account}: {item.description}
+
+
+
{formatCurrency(item.amount)}
+
+ )
+ })}
+
+ )}
+
+
+
+ {computation && (
+
+
+
+
+
+
+
+ )}
+
+ {error && {error}
}
+
+ {saving && }
+ Spara och räkna om
+
+
+
+ )
+}
+
+function CalculationRow({
+ label,
+ amount,
+ prefix = '',
+ strong = false,
+}: {
+ label: string
+ amount: number
+ prefix?: string
+ strong?: boolean
+}) {
+ return (
+
+ {label}
+ {prefix}{formatCurrency(amount)}
+
+ )
+}
+
function ProposalCard({
proposal,
accept,
@@ -372,7 +587,7 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[]
switch (p.kind) {
case 'bolagsskatt':
- items.push({ kind: 'bolagsskatt', manualAdjustments: {} })
+ items.push({ kind: 'bolagsskatt' })
break
case 'sarskild_loneskatt':
items.push({ kind: 'sarskild_loneskatt' })
@@ -405,3 +620,53 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[]
}
return items
}
+
+const emptyTaxDraft: TaxAdjustmentDraft = {
+ nonDeductibleExpenses: '0',
+ nonTaxableIncome: '0',
+ detectedAccounts: { '6992': false, '8423': false },
+}
+
+function createUiState(proposal: DispositionsProposal): UiState {
+ const selections: UiState['selections'] = {}
+ proposal.proposals.forEach((item, index) => {
+ const key = proposalKey(item, index)
+ selections[key] = {
+ accept: true,
+ overrideAmount: item.amount,
+ lockedSkip: Boolean(item.required),
+ }
+ })
+ return { selections }
+}
+
+function createTaxAdjustmentDraft(
+ snapshot: TaxAdjustmentSnapshot | undefined,
+): TaxAdjustmentDraft {
+ if (!snapshot) return emptyTaxDraft
+ const manualNonDeductible = snapshot.items.find(
+ (item) => item.sourceKey === 'manual:non_deductible_expenses',
+ )
+ const manualNonTaxable = snapshot.items.find(
+ (item) => item.sourceKey === 'manual:non_taxable_income',
+ )
+ const account6992 = snapshot.items.find((item) => item.sourceKey === 'account:6992')
+ const account8423 = snapshot.items.find((item) => item.sourceKey === 'account:8423')
+ return {
+ nonDeductibleExpenses: String(manualNonDeductible?.amount ?? 0),
+ nonTaxableIncome: String(manualNonTaxable?.amount ?? 0),
+ detectedAccounts: {
+ '6992': Boolean(account6992?.included),
+ '8423': Boolean(account8423?.included),
+ },
+ }
+}
+
+function parseNonNegativeAmount(value: string): number {
+ const parsed = Number(value.replace(',', '.'))
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
+}
+
+function numberValue(value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
+}
diff --git a/components/bookkeeping/year-end/EfDeclarationSection.tsx b/components/bookkeeping/year-end/EfDeclarationSection.tsx
index 707b2d76..86fd0e18 100644
--- a/components/bookkeeping/year-end/EfDeclarationSection.tsx
+++ b/components/bookkeeping/year-end/EfDeclarationSection.tsx
@@ -11,6 +11,7 @@ import { AlertTriangle, FileDown, Info } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type { EgenavgiftCategory } from '@/lib/bokslut/enskild-firma/egenavgifter-calculator'
import type { EfDeclarationItem } from '@/lib/bokslut/enskild-firma/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface EfDeclarationSectionProps {
fiscalPeriodId: string
@@ -129,7 +130,7 @@ export function EfDeclarationSection({
)
const body = await res.json()
if (!res.ok) {
- setError(body?.error?.message ?? 'Kunde inte ladda EF-deklaration')
+ setError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda EF-deklaration')
setPreview(null)
return
}
diff --git a/components/common/FiscalYearSelector.tsx b/components/common/FiscalYearSelector.tsx
index b7282c76..c7d36194 100644
--- a/components/common/FiscalYearSelector.tsx
+++ b/components/common/FiscalYearSelector.tsx
@@ -51,6 +51,16 @@ interface Props {
*/
onReady?: () => void
className?: string
+ /** Server-loaded periods for the first render, scoped to initialCompanyId. */
+ initialPeriods?: FiscalPeriod[]
+ initialCompanyId?: string | null
+}
+
+function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] {
+ const today = new Date().toISOString().split('T')[0]
+ return periods
+ .filter((period) => !hideFuturePeriods || period.period_start <= today)
+ .sort((a, b) => b.period_start.localeCompare(a.period_start))
}
/**
@@ -71,11 +81,16 @@ export function FiscalYearSelector({
hideFuturePeriods = false,
onReady,
className,
+ initialPeriods,
+ initialCompanyId,
}: Props) {
const { company } = useCompany()
const t = useTranslations('fiscal_year')
- const [periods, setPeriods] = useState([])
- const [loaded, setLoaded] = useState(false)
+ const canUseInitialPeriods = initialCompanyId === company?.id && initialPeriods !== undefined
+ const [periods, setPeriods] = useState(() =>
+ canUseInitialPeriods ? preparePeriods(initialPeriods, hideFuturePeriods) : [],
+ )
+ const [loaded, setLoaded] = useState(canUseInitialPeriods)
const effectiveLabel = label === null ? null : (label ?? t('label'))
useEffect(() => {
@@ -87,24 +102,23 @@ export function FiscalYearSelector({
}
let cancelled = false
;(async () => {
- const res = await fetch('/api/bookkeeping/fiscal-periods')
- if (!res.ok) {
- if (!cancelled) {
- setLoaded(true)
- onReady?.()
+ let fetched: FiscalPeriod[]
+ if (initialCompanyId === company.id && initialPeriods !== undefined) {
+ fetched = preparePeriods(initialPeriods, hideFuturePeriods)
+ } else {
+ const res = await fetch('/api/bookkeeping/fiscal-periods')
+ if (!res.ok) {
+ if (!cancelled) {
+ setLoaded(true)
+ onReady?.()
+ }
+ return
}
- return
+ const { data } = await res.json()
+ fetched = preparePeriods(data || [], hideFuturePeriods)
}
- const { data } = await res.json()
if (cancelled) return
- let fetched: FiscalPeriod[] = data || []
- if (hideFuturePeriods) {
- const today = new Date().toISOString().split('T')[0]
- fetched = fetched.filter((p) => p.period_start <= today)
- }
- // Newest first: most migrations list recent years at the top
- fetched.sort((a, b) => b.period_start.localeCompare(a.period_start))
setPeriods(fetched)
setLoaded(true)
@@ -131,7 +145,7 @@ export function FiscalYearSelector({
// onReady is intentionally excluded from deps: it's a lifecycle callback that
// should fire once per load, not re-trigger if the parent re-creates it.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [company?.id, hideFuturePeriods, includeAllOption])
+ }, [company?.id, hideFuturePeriods, includeAllOption, initialCompanyId, initialPeriods])
const handleChange = (next: string) => {
const nextPeriodId = next === ALL_YEARS_VALUE ? null : next
diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx
index 2f76bc05..643e17e1 100644
--- a/components/dashboard/DashboardContent.tsx
+++ b/components/dashboard/DashboardContent.tsx
@@ -1,6 +1,5 @@
'use client'
-import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
@@ -20,12 +19,10 @@ import {
ArrowRight,
MessageCircle,
} from 'lucide-react'
-import type { Deadline, OnboardingProgress } from '@/types'
+import type { Deadline, InitialSetupState, OnboardingProgress } from '@/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import { visibleWorklistTotalFrom } from '@/lib/worklist/visible-total'
-const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
-
interface DashboardContentProps {
companyId: string
summary: {
@@ -45,6 +42,7 @@ interface DashboardContentProps {
/** High-confidence transaction↔invoice matches for inline one-click confirm. */
suggestedMatches: SuggestedMatch[]
onboardingProgress?: OnboardingProgress
+ initialSetup: InitialSetupState
/**
* False until the company has a verified agent_profile. When false the hero
* slot shows a build-assistant prompt instead of the next-best-action card,
@@ -54,56 +52,10 @@ interface DashboardContentProps {
agentBuilt?: boolean
}
-export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, agentBuilt = true }: DashboardContentProps) {
+export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, initialSetup, agentBuilt = true }: DashboardContentProps) {
const t = useTranslations('dashboard')
const hasAi = useCapability(CAPABILITY.ai)
- // The setup gate exists to nudge brand-new users into a data-import step
- // before they hit the dashboard. Once the assistant is built we treat the
- // user as past that phase (they've already committed to using the tool)
- // and let the dashboard render normally. This also keeps the sandbox
- // (which ships with a pre-built assistant + seeded data but no bank
- // connection / SIE import) from showing a checklist that re-links to
- // /onboarding/agent.
- const needsSetup =
- !agentBuilt &&
- onboardingProgress &&
- !onboardingProgress.hasBankConnected &&
- !onboardingProgress.hasSIEImport
- const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
-
- useEffect(() => {
- if (!needsSetup) {
- setSetupGateActive(false)
- return
- }
- const scopedKey = setupFreshStartKey(companyId)
- const freshStart = localStorage.getItem(scopedKey) === 'true'
- const legacyFreshStart = localStorage.getItem('erp_setup_fresh_start') === 'true'
- const legacyDismissed = localStorage.getItem('erp_checklist_dismissed') === 'true'
- if (freshStart || legacyFreshStart || legacyDismissed) {
- if (!freshStart) {
- localStorage.setItem(scopedKey, 'true')
- }
- setSetupGateActive(false)
- }
- }, [needsSetup, companyId])
-
- if (setupGateActive) {
- return (
- {
- localStorage.setItem(setupFreshStartKey(companyId), 'true')
- setSetupGateActive(false)
- }}
- />
- )
- }
-
const formatLargeNumber = (amount: number) => {
return new Intl.NumberFormat('sv-SE', {
style: 'decimal',
@@ -124,6 +76,13 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
return (
+
{/* Build-assistant hero: shown only until the company has a verified
agent_profile, so existing/migrated users get a clear prompt instead
of a full-screen onboarding takeover. Once the assistant is built the
diff --git a/components/deadlines/DeadlineForm.tsx b/components/deadlines/DeadlineForm.tsx
index dbfa76cf..2210d4b0 100644
--- a/components/deadlines/DeadlineForm.tsx
+++ b/components/deadlines/DeadlineForm.tsx
@@ -120,6 +120,7 @@ export function DeadlineForm({
linked_report_type: null,
linked_report_period: null,
dismissed_at: null,
+ tax_assessment_notice_id: null,
})
} finally {
setIsLoading(false)
diff --git a/components/deadlines/TaxTodoWidget.tsx b/components/deadlines/TaxTodoWidget.tsx
index 14680686..80f81898 100644
--- a/components/deadlines/TaxTodoWidget.tsx
+++ b/components/deadlines/TaxTodoWidget.tsx
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Deadline, DeadlineStatus, TAX_DEADLINE_TYPE_LABELS, DEADLINE_STATUS_LABELS } from '@/types'
import { getReportUrl } from '@/lib/tax/deadline-config'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Use the labels from types
const STATUS_LABELS = DEADLINE_STATUS_LABELS
@@ -95,7 +96,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
- title: error instanceof Error ? error.message : t('toast_status_update_failed'),
+ title: error instanceof Error ? getUserErrorMessage(error) : t('toast_status_update_failed'),
variant: 'destructive',
})
} finally {
diff --git a/components/deadlines/UpcomingDeadlinesWidget.tsx b/components/deadlines/UpcomingDeadlinesWidget.tsx
index 3b2c0b93..ebb0a01c 100644
--- a/components/deadlines/UpcomingDeadlinesWidget.tsx
+++ b/components/deadlines/UpcomingDeadlinesWidget.tsx
@@ -13,6 +13,7 @@ import {
STATUS_LABELS,
} from '@/lib/calendar/utils'
import { Calendar, ChevronRight, AlertTriangle, Clock, Check, Send, Loader2 } from 'lucide-react'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface UpcomingDeadlinesWidgetProps {
deadlines: Deadline[]
@@ -85,7 +86,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
- title: error instanceof Error ? error.message : t('toast_status_update_failed'),
+ title: error instanceof Error ? getUserErrorMessage(error) : t('toast_status_update_failed'),
variant: 'destructive',
})
} finally {
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx
index 919d04de..313d50be 100644
--- a/components/extensions/general/ArcimMigrationWorkspace.tsx
+++ b/components/extensions/general/ArcimMigrationWorkspace.tsx
@@ -13,6 +13,7 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import Link from 'next/link'
import { FallbackPrompt } from '@/components/ui/fallback-prompt'
import { getBranding } from '@/lib/branding/service'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const branding = getBranding()
import {
@@ -1827,7 +1828,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
}
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte hämta förhandsgranskning')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta förhandsgranskning')
} finally {
setIsLoading(false)
}
@@ -1866,7 +1867,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
}
// Token-based providers stay on connect step for credential input
} catch (err) {
- setError(err instanceof Error ? err.message : 'Anslutning misslyckades')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Anslutning misslyckades')
} finally {
setIsLoading(false)
}
@@ -1925,7 +1926,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setStep('connect')
}
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte återansluta')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte återansluta')
setAuthExpired(true)
} finally {
setIsLoading(false)
@@ -1947,7 +1948,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' })
await fetchStatus()
} catch (err) {
- toast({ title: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' })
+ toast({ title: err instanceof Error ? getUserErrorMessage(err) : 'Något gick fel', variant: 'destructive' })
}
}, [toast, fetchStatus])
@@ -1978,7 +1979,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
// Token stored: consent is now accepted, proceed to preview
await loadPreview(consentId)
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte ansluta')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte ansluta')
} finally {
setIsLoading(false)
}
@@ -2079,7 +2080,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setStep('options')
}
} catch (err) {
- setError(err instanceof Error ? err.message : 'Kunde inte hämta SIE-data')
+ setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta SIE-data')
} finally {
setIsLoading(false)
}
@@ -2229,7 +2230,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
description: 'Din bokföringsdata har importerats.',
})
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Migrering misslyckades'
+ const msg = getUserErrorMessage(err)
setError(msg)
setStep('result')
}
diff --git a/components/extensions/general/BulkBookInboxDialog.tsx b/components/extensions/general/BulkBookInboxDialog.tsx
index a578e3d9..09b66475 100644
--- a/components/extensions/general/BulkBookInboxDialog.tsx
+++ b/components/extensions/general/BulkBookInboxDialog.tsx
@@ -17,6 +17,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type { InvoiceExtractionResult, VatTreatment } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Minimal shape the dialog needs from the workspace's inbox items.
interface BulkBookInboxItem {
@@ -145,7 +146,7 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
} catch (err) {
toast({
title: 'Bokföringen misslyckades',
- description: err instanceof Error ? err.message : 'Okänt fel',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
} finally {
diff --git a/components/extensions/general/InboxCustomDomainDialog.tsx b/components/extensions/general/InboxCustomDomainDialog.tsx
index e8cbbffe..654a3571 100644
--- a/components/extensions/general/InboxCustomDomainDialog.tsx
+++ b/components/extensions/general/InboxCustomDomainDialog.tsx
@@ -16,6 +16,7 @@ import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, Check, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react'
import { formatDateLong } from '@/lib/utils'
import type { CompanyInboundDomain, InboundDomainDnsRecord } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BASE = '/api/extensions/ext/invoice-inbox/inbox/domain'
@@ -83,7 +84,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
} catch (err) {
toast({
title: 'Kunde inte lägga till domänen',
- description: err instanceof Error ? err.message : 'Försök igen.',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -106,7 +107,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
} catch (err) {
toast({
title: 'Kontrollen misslyckades',
- description: err instanceof Error ? err.message : 'Försök igen.',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -127,7 +128,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
} catch (err) {
toast({
title: 'Borttagningen misslyckades',
- description: err instanceof Error ? err.message : 'Försök igen.',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx
index 51c33ed1..6866dd3a 100644
--- a/components/extensions/general/InvoiceInboxWorkspace.tsx
+++ b/components/extensions/general/InvoiceInboxWorkspace.tsx
@@ -48,6 +48,7 @@ import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDi
// INBOX_CUSTOM_DOMAINS_ENABLED in extensions/general/invoice-inbox/index.ts.
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type AccountingMethod = 'accrual' | 'cash'
@@ -458,7 +459,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
} catch (err) {
toast({
title: 'Kunde inte ladda dokumentet',
- description: err instanceof Error ? err.message : 'Försök igen.',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
}
@@ -534,7 +535,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}
toast({
title: 'Uppladdning misslyckades',
- description: err instanceof Error ? err.message : 'Försök igen.',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -597,7 +598,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
} catch (err) {
toast({
title: 'Kunde inte ta bort',
- description: err instanceof Error ? err.message : 'Försök igen.',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -697,7 +698,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
} catch (err) {
toast({
title: 'Rotation misslyckades',
- description: err instanceof Error ? err.message : 'Försök igen.',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -2049,7 +2050,7 @@ export function EditableFieldsList({
toast({
variant: 'destructive',
title: 'Nätverksfel',
- description: err instanceof Error ? err.message : 'Kunde inte spara',
+ description: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte spara',
})
setDrafts((prev) => ({ ...prev, [key]: readField(data, key) }))
}
diff --git a/components/extensions/shared/MockDataImportDialog.tsx b/components/extensions/shared/MockDataImportDialog.tsx
index 8f135acf..f41bf36c 100644
--- a/components/extensions/shared/MockDataImportDialog.tsx
+++ b/components/extensions/shared/MockDataImportDialog.tsx
@@ -16,6 +16,7 @@ import {
Upload, FileJson, FileSpreadsheet, Download, AlertCircle, Check,
} from 'lucide-react'
import { cn } from '@/lib/utils'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ── Types ─────────────────────────────────────────────────────
@@ -192,7 +193,7 @@ export default function MockDataImportDialog
({
await onImport(report, { source: 'csv', fileName, rowCount: mappedRows.length })
handleOpenChange(false)
} catch (e) {
- setError(e instanceof Error ? e.message : 'Import misslyckades')
+ setError(e instanceof Error ? getUserErrorMessage(e) : 'Import misslyckades')
setStep('map-csv')
}
}, [csvRows, csvHeaders, mappings, parseCsvRows, onImport, fileName, handleOpenChange])
@@ -206,7 +207,7 @@ export default function MockDataImportDialog({
await onImport(jsonReport, { source: 'json', fileName, rowCount: 0 })
handleOpenChange(false)
} catch (e) {
- setError(e instanceof Error ? e.message : 'Import misslyckades')
+ setError(e instanceof Error ? getUserErrorMessage(e) : 'Import misslyckades')
setStep('preview-json')
}
}, [jsonReport, onImport, fileName, handleOpenChange])
diff --git a/components/inbox/TransactionMatchPicker.tsx b/components/inbox/TransactionMatchPicker.tsx
index 5335b746..a83d49ba 100644
--- a/components/inbox/TransactionMatchPicker.tsx
+++ b/components/inbox/TransactionMatchPicker.tsx
@@ -23,6 +23,7 @@ import {
} from '@/lib/documents/core-receipt-matcher'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { InvoiceExtractionResult } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// TransactionMatchPicker
//
@@ -227,7 +228,7 @@ export default function TransactionMatchPicker({
if (error) {
toast({
title: 'Kunde inte hämta transaktioner',
- description: error.message,
+ description: getUserErrorMessage(error),
variant: 'destructive',
})
setRawRows([])
diff --git a/components/invoices/BankDetailsSetupDialog.tsx b/components/invoices/BankDetailsSetupDialog.tsx
index f4587245..2ada21dc 100644
--- a/components/invoices/BankDetailsSetupDialog.tsx
+++ b/components/invoices/BankDetailsSetupDialog.tsx
@@ -14,6 +14,7 @@ import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface BankDetailsSetupDialogProps {
open: boolean
@@ -148,7 +149,7 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
} catch (error) {
toast({
title: t('save_failed_title'),
- description: error instanceof Error ? error.message : t('save_failed_fallback'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('save_failed_fallback'),
variant: 'destructive',
})
} finally {
diff --git a/components/invoices/NewRecurringScheduleDialog.tsx b/components/invoices/NewRecurringScheduleDialog.tsx
index 6ae9942c..a51219cb 100644
--- a/components/invoices/NewRecurringScheduleDialog.tsx
+++ b/components/invoices/NewRecurringScheduleDialog.tsx
@@ -31,6 +31,7 @@ import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { Plus, Trash2 } from 'lucide-react'
import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types'
import { formatCurrency } from '@/lib/utils'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
@@ -202,7 +203,7 @@ function NewRecurringScheduleForm({
} catch (err) {
toast({
title: schedule ? t('update_failed_title') : t('create_failed_title'),
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx
index a797f559..a1f1c956 100644
--- a/components/invoices/PaymentBookingDialog.tsx
+++ b/components/invoices/PaymentBookingDialog.tsx
@@ -168,7 +168,7 @@ export default function PaymentBookingDialog({
if (cancelled) return
toast({
title: t('load_dialog_failed_title'),
- description: err instanceof Error ? err.message : t('try_again'),
+ description: err instanceof Error ? getErrorMessage(err) : t('try_again'),
variant: 'destructive',
})
onOpenChange(false)
diff --git a/components/invoices/SendInvoiceDialog.tsx b/components/invoices/SendInvoiceDialog.tsx
index d00f23f1..113fa47f 100644
--- a/components/invoices/SendInvoiceDialog.tsx
+++ b/components/invoices/SendInvoiceDialog.tsx
@@ -29,6 +29,7 @@ import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InvoiceWithRelations extends Invoice {
customer: Customer
@@ -151,7 +152,7 @@ export default function SendInvoiceDialog({
if (cancelled) return
toast({
title: t('load_failed_title'),
- description: err instanceof Error ? err.message : t('try_again'),
+ description: err instanceof Error ? getUserErrorMessage(err) : t('try_again'),
variant: 'destructive',
})
onOpenChange(false)
@@ -345,7 +346,7 @@ export default function SendInvoiceDialog({
} catch (error) {
toast({
title: t(isCreditNote ? 'credit_send_failed_title' : 'send_failed_title'),
- description: error instanceof Error ? error.message : t('try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('try_again'),
variant: 'destructive',
})
}
diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx
index 8bfe2942..ae2f7ae0 100644
--- a/components/onboarding/NewUserChecklist.tsx
+++ b/components/onboarding/NewUserChecklist.tsx
@@ -1,365 +1,191 @@
'use client'
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
ArrowRight,
- CheckCircle2,
- FileCheck,
- FileText,
- Landmark,
ArrowRightLeft,
+ FileCheck,
+ Landmark,
MessageCircle,
- ShieldCheck,
+ Sparkles,
} from 'lucide-react'
-import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
+import { useErrorToast } from '@/lib/hooks/use-error-toast'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
-import { getBranding } from '@/lib/branding/service'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
-
-const branding = getBranding()
+import type { InitialSetupPath, InitialSetupState } from '@/types'
interface NewUserChecklistProps {
- onFreshStart: () => void
+ initialState: InitialSetupState
className?: string
- /**
- * Per-step completion flags. The render flips each step from CTA to a
- * compact "done" card when its corresponding flag is true so the user
- * sees their progress without having to remember what they finished.
- */
hasBookkeepingImported?: boolean
hasBankConnected?: boolean
hasSkatteverketConnected?: boolean
hasAgentBuilt?: boolean
}
+const pathIcons = {
+ migration: ArrowRightLeft,
+ bank: Landmark,
+ fresh: Sparkles,
+} as const
+
export default function NewUserChecklist({
- onFreshStart,
+ initialState,
className,
- hasBookkeepingImported,
- hasBankConnected,
- hasSkatteverketConnected,
- hasAgentBuilt,
+ hasBookkeepingImported = false,
+ hasBankConnected = false,
+ hasSkatteverketConnected = false,
+ hasAgentBuilt = false,
}: NewUserChecklistProps) {
- const t = useTranslations('new_user_checklist')
+ const t = useTranslations('initial_setup')
+ const router = useRouter()
+ const showError = useErrorToast()
+ const hasAi = useCapability(CAPABILITY.ai)
+ const [state, setState] = useState(initialState)
+ const [saving, setSaving] = useState(null)
+
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
- const hasAi = useCapability(CAPABILITY.ai)
+
+ const persist = async (
+ body: Record,
+ pending: InitialSetupPath | 'dismiss' | 'complete',
+ ): Promise => {
+ setSaving(pending)
+ try {
+ const response = await fetch('/api/onboarding/state', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (!response.ok) {
+ await showError(response, { context: 'settings' })
+ return null
+ }
+ const payload = await response.json() as { data: InitialSetupState }
+ setState(payload.data)
+ return payload.data
+ } catch (error) {
+ await showError(error, { context: 'settings' })
+ return null
+ } finally {
+ setSaving(null)
+ }
+ }
+
+ useEffect(() => {
+ const selectedPathComplete =
+ (state.path === 'migration' && hasBookkeepingImported) ||
+ (state.path === 'bank' && hasBankConnected)
+ if (!state.completedAt && selectedPathComplete && saving === null) {
+ void persist({ completed: true }, 'complete')
+ }
+ // persist intentionally stays out: its identity follows the toast hook and
+ // would retrigger this completion sync after every render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hasBankConnected, hasBookkeepingImported, saving, state.completedAt, state.path])
+
+ if (state.dismissedAt || state.completedAt) return null
+
+ const choosePath = async (path: InitialSetupPath) => {
+ const updated = await persist({ path }, path)
+ if (!updated || path === 'fresh') return
+ if (path === 'migration') {
+ router.push(hasMigration ? '/import?mode=migration' : '/import?mode=sie')
+ } else {
+ router.push(hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
+ }
+ }
+
+ if (!state.path) {
+ return (
+
+
+ {t('title')}
+ {t('description')}
+
+
+ {(['migration', 'bank', 'fresh'] as const).map((path) => {
+ const Icon = pathIcons[path]
+ return (
+ void choosePath(path)}
+ className="group min-h-28 rounded-lg border border-border p-4 text-left transition-colors hover:border-foreground/40 hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
+ >
+
+ {t(`${path}_title`)}
+
+ {t(`${path}_description`)}
+
+
+ )
+ })}
+
+
+ )
+ }
+
+ const primaryHref = state.path === 'migration'
+ ? (hasMigration ? '/import?mode=migration' : '/import?mode=sie')
+ : (hasBanking ? '/import?mode=psd2' : '/import?mode=bank')
+ const PrimaryIcon = pathIcons[state.path]
return (
-
-
- {/* Header: centered welcome. Data-import steps lead; building the
- assistant is the last step so a user coming from another system
- brings their books in first. */}
-
-
- {t('welcome', { appName: branding.appName.toLowerCase() })}
-
-
- {t('intro')}
-
+
+
+
+
+ {t(`${state.path}_selected_title`)}
+ {t(`${state.path}_selected_description`)}
+
+
void persist({ dismissed: true }, 'dismiss')}
+ >
+ {t('dismiss')}
+
-
- {/* Step 1: Migrate bookkeeping */}
-
-
-
- {hasBookkeepingImported ? : '1'}
-
-
- {t('step1_title')}
-
-
-
- {hasBookkeepingImported ? (
-
-
{t('step1_done_title')}
-
{t('step1_done_description')}
-
- ) : (
-
- {hasMigration && (
-
-
-
-
-
- {t('migrate_title')}
-
-
- {t('migrate_description')}
-
-
- {([
- { name: 'Fortnox', logo: '/logos/fortnox.svg' },
- { name: 'Visma', logo: '/logos/visma.jpeg' },
- { name: 'Bokio', logo: '/logos/bokio.png' },
- { name: 'Björn Lundén', logo: '/logos/bjornlunden.png' },
- { name: 'Briox', logo: '/logos/Briox_logo.png' },
- { name: 'SIE4-fil', logo: null },
- ] as const).map(provider => (
-
- {provider.logo ? (
-
- ) : (
-
- )}
-
{provider.name}
-
- ))}
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
- {t('sie_title')}
-
-
- {t('sie_description')}
-
-
-
-
+
+
+
+
+
+ {t(`${state.path}_action`)}
+
+
+
+ {hasSkatteverket && !hasSkatteverketConnected && (
+ // The authorize endpoint redirects off-site to Skatteverket.
+ // eslint-disable-next-line @next/next/no-html-link-for-pages
+
+
+ {t('optional_skatteverket')}
+
+ )}
+ {!hasAgentBuilt && (
+
+
+ {t('optional_assistant')}
-
)}
-
- {/* Step 2: Connect bank */}
-
-
-
- {hasBankConnected ? : '2'}
-
-
- {t('step2_title')}
-
-
-
-
- {hasBankConnected ? (
-
-
Bank kopplad
-
Transaktioner synkas automatiskt.
-
- ) : (
-
-
-
-
-
-
-
- {t('bank_title')}
-
-
- {hasBanking
- ? t('bank_description_psd2')
- : t('bank_description_file')}
-
-
-
-
-
- )}
-
-
-
- {/* Step 3: Connect Skatteverket, only when the extension is enabled.
- Optional: connecting here lets Accounted submit moms + AGI and read
- skattekonto saldo, but the user can skip and do it later from
- /settings/skatteverket. The OAuth flow returns to the dashboard
- via return_to=/, which clears the gate via the same path the
- user would take naturally. */}
- {hasSkatteverket && (
-
-
-
- {hasSkatteverketConnected
- ?
- : '3'}
-
-
- {t('step3_title')}
-
- {t('optional_suffix')}
-
-
-
-
- )}
-
- {/* Build the assistant: always the last step, so a user migrating
- from another system brings their books in first. */}
-
-
-
- {hasAgentBuilt ? : (hasSkatteverket ? '4' : '3')}
-
-
- Skapa din assistent
-
-
-
-
- {hasAgentBuilt ? (
-
-
-
-
-
-
-
- Assistenten är klar
-
-
- Du kan börja chatta direkt. Justera tonalitet och kunskap i Inställningar > Assistentens minne.
-
-
-
-
- ) : (
-
-
-
-
-
-
-
-
- Bygg din bokföringsassistent
-
-
- {hasAi ? 'Beta' : 'Abonnemang'}
-
-
-
- {hasAi
- ? 'Några frågor om din verksamhet kalibrerar tonalitet, signatur och vad assistenten kan. Ju mer du delar, desto bättre förstår den dig.'
- : 'Ingår i abonnemanget: en assistent som föreslår bokföring åt dig. Uppgradera för att komma igång.'}
-
-
-
-
-
- )}
-
-
-
- {/* Escape hatch: a visible secondary action, not a muted ghost link,
- so users with nothing to import can clearly choose to start fresh. */}
-
-
-
-
{t('or_separator')}
-
-
-
-
-
- {t('fresh_start')}
-
-
-
-
-
-
-
- {t('security_note')}
-
-
-
-
-
+
+
)
}
diff --git a/components/onboarding/WelcomeGate.tsx b/components/onboarding/WelcomeGate.tsx
deleted file mode 100644
index 8c96be15..00000000
--- a/components/onboarding/WelcomeGate.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-'use client'
-
-import { useRouter } from 'next/navigation'
-import NewUserChecklist from './NewUserChecklist'
-
-interface Props {
- companyId: string
- hasBookkeepingImported: boolean
- hasBankConnected: boolean
- hasSkatteverketConnected: boolean
-}
-
-// Thin client wrapper around NewUserChecklist, shown only to a genuinely empty
-// company (no data, no assistant). The data-import steps lead and building the
-// assistant is the last step; the "I'm starting fresh" escape hatch forwards
-// straight to /onboarding/agent for users with no books to bring in.
-export default function WelcomeGate({
- companyId: _companyId,
- hasBookkeepingImported,
- hasBankConnected,
- hasSkatteverketConnected,
-}: Props) {
- const router = useRouter()
-
- return (
-
router.push('/onboarding/agent')}
- />
- )
-}
diff --git a/components/onboarding/agent/ReviewCard.tsx b/components/onboarding/agent/ReviewCard.tsx
index 57b4366d..e760471e 100644
--- a/components/onboarding/agent/ReviewCard.tsx
+++ b/components/onboarding/agent/ReviewCard.tsx
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { AVATAR_OPTIONS } from '@/components/agent/avatars'
import AgentAvatar from '@/components/agent/AgentAvatar'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InitialFields {
entity_type_label: string
@@ -177,7 +178,7 @@ export default function ReviewCard({
onVerified()
} catch (err) {
- setVerifyError(err instanceof Error ? err.message : 'Kunde inte verifiera.')
+ setVerifyError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte verifiera.')
} finally {
setVerifying(false)
}
diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx
index 4fa42c84..73e1d286 100644
--- a/components/reports/BankReconciliationView.tsx
+++ b/components/reports/BankReconciliationView.tsx
@@ -31,6 +31,7 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import type { CashAccount } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -761,7 +762,7 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
toast({
variant: 'destructive',
title: 'Kunde inte bokföra transaktionen',
- description: result.error?.message || (typeof result.error === 'string' ? result.error : undefined),
+ description: getUserErrorMessage(result.error) || (typeof result.error === 'string' ? result.error : undefined),
})
return
}
diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx
index 4657e07e..dc1c39e9 100644
--- a/components/reports/FocusedReport.tsx
+++ b/components/reports/FocusedReport.tsx
@@ -1,6 +1,7 @@
'use client'
import { Suspense, useState } from 'react'
+import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -14,23 +15,46 @@ import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { DimensionFilter, type DimensionFilterValue } from '@/components/reports/DimensionFilter'
import { DATE_RANGE_SLUGS, DIMENSION_FILTER_SLUGS, getReport } from '@/lib/reports/catalog'
-import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
-import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
-import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
-import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
-import {
- TrialBalanceView,
- IncomeStatementView,
- BalanceSheetView,
- ResultatrapportView,
- BalansrapportView,
- VatDeclarationView,
- SupplierLedgerView,
- GeneralLedgerView,
- JournalRegisterView,
- ARLedgerView,
- DimensionPnlView,
-} from '@/components/reports/views'
+import type { FiscalPeriod } from '@/types'
+
+function ReportViewLoading() {
+ return (
+
+
+
+
+
+
+ )
+}
+
+const TrialBalanceView = dynamic(() => import('./lazy-views/TrialBalanceView'), { loading: ReportViewLoading })
+const IncomeStatementView = dynamic(() => import('./lazy-views/IncomeStatementView'), { loading: ReportViewLoading })
+const BalanceSheetView = dynamic(() => import('./lazy-views/BalanceSheetView'), { loading: ReportViewLoading })
+const ResultatrapportView = dynamic(() => import('./lazy-views/ResultatrapportView'), { loading: ReportViewLoading })
+const BalansrapportView = dynamic(() => import('./lazy-views/BalansrapportView'), { loading: ReportViewLoading })
+const VatDeclarationView = dynamic(() => import('./lazy-views/VatDeclarationView'), { loading: ReportViewLoading })
+const SupplierLedgerView = dynamic(() => import('./lazy-views/SupplierLedgerView'), { loading: ReportViewLoading })
+const GeneralLedgerView = dynamic(() => import('./lazy-views/GeneralLedgerView'), { loading: ReportViewLoading })
+const JournalRegisterView = dynamic(() => import('./lazy-views/JournalRegisterView'), { loading: ReportViewLoading })
+const ARLedgerView = dynamic(() => import('./lazy-views/ARLedgerView'), { loading: ReportViewLoading })
+const DimensionPnlView = dynamic(() => import('./lazy-views/DimensionPnlView'), { loading: ReportViewLoading })
+const NEDeclarationView = dynamic(() =>
+ import('./NEDeclarationView').then((module) => ({ default: module.NEDeclarationView })),
+ { loading: ReportViewLoading },
+)
+const PeriodiskSammanstallningView = dynamic(() =>
+ import('./PeriodiskSammanstallningView').then((module) => ({ default: module.PeriodiskSammanstallningView })),
+ { loading: ReportViewLoading },
+)
+const INK2DeclarationView = dynamic(() =>
+ import('./INK2DeclarationView').then((module) => ({ default: module.INK2DeclarationView })),
+ { loading: ReportViewLoading },
+)
+const BankReconciliationView = dynamic(() =>
+ import('./BankReconciliationView').then((module) => ({ default: module.BankReconciliationView })),
+ { loading: ReportViewLoading },
+)
/**
* The focused single-report experience at /reports/[slug]. Carries one report:
@@ -39,7 +63,15 @@ import {
* optional date-range control, and the report body. Drilling into an account
* navigates to /reports/huvudbok?account=…: drill state lives in the URL.
*/
-function FocusedReportInner({ slug }: { slug: string }) {
+function FocusedReportInner({
+ slug,
+ initialPeriods,
+ initialCompanyId,
+}: {
+ slug: string
+ initialPeriods: FiscalPeriod[]
+ initialCompanyId: string | null
+}) {
const router = useRouter()
const searchParams = useSearchParams()
const { company } = useCompany()
@@ -97,6 +129,8 @@ function FocusedReportInner({ slug }: { slug: string }) {
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsReady(true)}
+ initialPeriods={initialPeriods}
+ initialCompanyId={initialCompanyId}
/>
)
}
@@ -203,10 +237,22 @@ function FocusedView({
}
}
-export function FocusedReport({ slug }: { slug: string }) {
+export function FocusedReport({
+ slug,
+ initialPeriods,
+ initialCompanyId,
+}: {
+ slug: string
+ initialPeriods: FiscalPeriod[]
+ initialCompanyId: string | null
+}) {
return (
}>
-
+
)
}
diff --git a/components/reports/INK2DeclarationView.tsx b/components/reports/INK2DeclarationView.tsx
index 090ee9e4..c1dc989d 100644
--- a/components/reports/INK2DeclarationView.tsx
+++ b/components/reports/INK2DeclarationView.tsx
@@ -324,8 +324,8 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
- Grundläggande justeringar beräknas automatiskt. Manuella justeringar
- (periodiseringsfonder, koncernbidrag m.m.) hanteras av din
+ Grundläggande justeringar och de återläggningar som sparats i bokslutet
+ beräknas automatiskt. Kontrollera övriga deklarationsjusteringar med din
redovisningskonsult.
@@ -356,6 +356,16 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
label="Skatt på årets resultat (ej avdragsgill)"
amount={data.ink2s['7651']}
/>
+
+
diff --git a/components/reports/lazy-views/ARLedgerView.tsx b/components/reports/lazy-views/ARLedgerView.tsx
new file mode 100644
index 00000000..d200a748
--- /dev/null
+++ b/components/reports/lazy-views/ARLedgerView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { ARLedgerView as default } from '../views'
diff --git a/components/reports/lazy-views/BalanceSheetView.tsx b/components/reports/lazy-views/BalanceSheetView.tsx
new file mode 100644
index 00000000..487a8e34
--- /dev/null
+++ b/components/reports/lazy-views/BalanceSheetView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { BalanceSheetView as default } from '../views'
diff --git a/components/reports/lazy-views/BalansrapportView.tsx b/components/reports/lazy-views/BalansrapportView.tsx
new file mode 100644
index 00000000..f6dcd09d
--- /dev/null
+++ b/components/reports/lazy-views/BalansrapportView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { BalansrapportView as default } from '../views'
diff --git a/components/reports/lazy-views/DimensionPnlView.tsx b/components/reports/lazy-views/DimensionPnlView.tsx
new file mode 100644
index 00000000..fbd453b6
--- /dev/null
+++ b/components/reports/lazy-views/DimensionPnlView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { DimensionPnlView as default } from '../views'
diff --git a/components/reports/lazy-views/GeneralLedgerView.tsx b/components/reports/lazy-views/GeneralLedgerView.tsx
new file mode 100644
index 00000000..bc0630c3
--- /dev/null
+++ b/components/reports/lazy-views/GeneralLedgerView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { GeneralLedgerView as default } from '../views'
diff --git a/components/reports/lazy-views/IncomeStatementView.tsx b/components/reports/lazy-views/IncomeStatementView.tsx
new file mode 100644
index 00000000..c65dc236
--- /dev/null
+++ b/components/reports/lazy-views/IncomeStatementView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { IncomeStatementView as default } from '../views'
diff --git a/components/reports/lazy-views/JournalRegisterView.tsx b/components/reports/lazy-views/JournalRegisterView.tsx
new file mode 100644
index 00000000..ee67323a
--- /dev/null
+++ b/components/reports/lazy-views/JournalRegisterView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { JournalRegisterView as default } from '../views'
diff --git a/components/reports/lazy-views/ResultatrapportView.tsx b/components/reports/lazy-views/ResultatrapportView.tsx
new file mode 100644
index 00000000..0e1bdd56
--- /dev/null
+++ b/components/reports/lazy-views/ResultatrapportView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { ResultatrapportView as default } from '../views'
diff --git a/components/reports/lazy-views/SupplierLedgerView.tsx b/components/reports/lazy-views/SupplierLedgerView.tsx
new file mode 100644
index 00000000..e54308d6
--- /dev/null
+++ b/components/reports/lazy-views/SupplierLedgerView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { SupplierLedgerView as default } from '../views'
diff --git a/components/reports/lazy-views/TrialBalanceView.tsx b/components/reports/lazy-views/TrialBalanceView.tsx
new file mode 100644
index 00000000..84251d79
--- /dev/null
+++ b/components/reports/lazy-views/TrialBalanceView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { TrialBalanceView as default } from '../views'
diff --git a/components/reports/lazy-views/VatDeclarationView.tsx b/components/reports/lazy-views/VatDeclarationView.tsx
new file mode 100644
index 00000000..d020133f
--- /dev/null
+++ b/components/reports/lazy-views/VatDeclarationView.tsx
@@ -0,0 +1,3 @@
+'use client'
+
+export { VatDeclarationView as default } from '../views'
diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx
index 93577645..0b88b708 100644
--- a/components/salary/AGIPanel.tsx
+++ b/components/salary/AGIPanel.tsx
@@ -26,6 +26,7 @@ import { useCapability } from '@/contexts/CompanyContext'
import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface AGIPanelProps {
salaryRunId: string
@@ -433,7 +434,7 @@ export function AGIPanel(props: AGIPanelProps) {
await fetchStatus()
onRefreshSubmission()
} catch (e) {
- setError(e instanceof Error ? e.message : t('disconnect_failed'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('disconnect_failed'))
} finally {
setActionLoading(null)
}
@@ -677,7 +678,7 @@ export function AGIPanel(props: AGIPanelProps) {
signingTab?.close()
setChain(prev => (prev ? { ...prev, failed: true } : prev))
if (!(e instanceof ChainFailed)) {
- setError(e instanceof Error ? e.message : t('submit_failed'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('submit_failed'))
}
onRefreshSubmission()
} finally {
@@ -708,7 +709,7 @@ export function AGIPanel(props: AGIPanelProps) {
URL.revokeObjectURL(url)
onChange?.()
} catch (e) {
- setError(e instanceof Error ? e.message : t('xml_download_failed'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('xml_download_failed'))
} finally {
setActionLoading(null)
}
@@ -726,7 +727,7 @@ export function AGIPanel(props: AGIPanelProps) {
onRefreshSubmission()
onChange?.()
} catch (e) {
- setError(e instanceof Error ? e.message : t('submit_failed'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('submit_failed'))
} finally {
setActionLoading(null)
}
@@ -742,7 +743,7 @@ export function AGIPanel(props: AGIPanelProps) {
if (link !== null) setSuccess(t('signing_link_ready'))
onRefreshSubmission()
} catch (e) {
- setError(e instanceof Error ? e.message : t('signing_link_failed'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('signing_link_failed'))
} finally {
setActionLoading(null)
}
@@ -765,7 +766,7 @@ export function AGIPanel(props: AGIPanelProps) {
setSuccess(t('unlock_success'))
onRefreshSubmission()
} catch (e) {
- setError(e instanceof Error ? e.message : t('unlock_failed'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unlock_failed'))
} finally {
setActionLoading(null)
}
@@ -799,7 +800,7 @@ export function AGIPanel(props: AGIPanelProps) {
onRefreshSubmission()
onChange?.()
} catch (e) {
- setError(e instanceof Error ? e.message : t('check_status_failed'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('check_status_failed'))
} finally {
setActionLoading(null)
}
diff --git a/components/salary/SalaryCalendar.tsx b/components/salary/SalaryCalendar.tsx
index 8dacf346..b257cef2 100644
--- a/components/salary/SalaryCalendar.tsx
+++ b/components/salary/SalaryCalendar.tsx
@@ -44,6 +44,7 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import type { SalaryType } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ─── Types ─────────────────────────────────────────────────────────
@@ -169,7 +170,7 @@ export function SalaryCalendar({
setWorked(wJson.data ?? [])
}
} catch (e) {
- setError(e instanceof Error ? e.message : t('unknown_error'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setLoading(false)
}
@@ -318,7 +319,7 @@ export function SalaryCalendar({
await load()
onChange?.()
} catch (e) {
- setError(e instanceof Error ? e.message : t('unknown_error'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setDeleting(false)
}
@@ -645,7 +646,7 @@ function BulkWorkedDialog({
if (!res.ok) throw new Error(json.error || t('error_save_hours'))
onSaved([])
} catch (e) {
- setError(e instanceof Error ? e.message : t('unknown_error'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setSubmitting(false)
}
@@ -791,7 +792,7 @@ function BulkAbsenceDialog({
}
onSaved([])
} catch (e) {
- setError(e instanceof Error ? e.message : t('unknown_error'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setSubmitting(false)
}
@@ -918,7 +919,7 @@ function DayInspectorDialog({
onChanged()
onClose()
} catch (e) {
- setError(e instanceof Error ? e.message : t('unknown_error'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setBusy(null)
}
@@ -940,7 +941,7 @@ function DayInspectorDialog({
// Stay open if there's other content; close if this was the last entry.
if (absences.length === 1 && !worked) onClose()
} catch (e) {
- setError(e instanceof Error ? e.message : t('unknown_error'))
+ setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setBusy(null)
}
diff --git a/components/salary/SalaryOverridePanel.tsx b/components/salary/SalaryOverridePanel.tsx
index 9600818e..796b103d 100644
--- a/components/salary/SalaryOverridePanel.tsx
+++ b/components/salary/SalaryOverridePanel.tsx
@@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/badge'
import { Settings2, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface SalaryOverridePanelProps {
runId: string
@@ -84,7 +85,7 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
} catch (err) {
toast({
title: t('save_failed'),
- description: err instanceof Error ? err.message : t('unknown_error'),
+ description: err instanceof Error ? getUserErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
diff --git a/components/settings/AccountDangerZone.tsx b/components/settings/AccountDangerZone.tsx
index 529fd72c..02bd9a43 100644
--- a/components/settings/AccountDangerZone.tsx
+++ b/components/settings/AccountDangerZone.tsx
@@ -19,6 +19,7 @@ import {
import { RetentionNotice } from '@/components/ui/retention-notice'
import { ExternalLink, Loader2 } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface Blocker {
id: string
@@ -92,7 +93,7 @@ export function AccountDangerZone() {
router.push('/login')
} catch (err) {
- setError(err instanceof Error ? err.message : t('delete_failed_default'))
+ setError(err instanceof Error ? getUserErrorMessage(err) : t('delete_failed_default'))
setIsDeleting(false)
}
}
diff --git a/components/settings/BackupDownloadForm.tsx b/components/settings/BackupDownloadForm.tsx
index 1ecf9391..4b65e2b3 100644
--- a/components/settings/BackupDownloadForm.tsx
+++ b/components/settings/BackupDownloadForm.tsx
@@ -12,6 +12,7 @@ import { Cloud, Download, Info, Loader2 } from 'lucide-react'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import type { FiscalPeriod } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const CloudBackupPanel = getSettingsPanel('cloud-backup')
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
@@ -170,7 +171,7 @@ export function BackupDownloadForm() {
} catch (err) {
toast({
title: t('toast_backup_failed'),
- description: err instanceof Error ? err.message : t('toast_try_again'),
+ description: err instanceof Error ? getUserErrorMessage(err) : t('toast_try_again'),
variant: 'destructive',
})
} finally {
diff --git a/components/settings/BillingActions.tsx b/components/settings/BillingActions.tsx
index 684417f7..b9b5232a 100644
--- a/components/settings/BillingActions.tsx
+++ b/components/settings/BillingActions.tsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { formatDateLong } from '@/lib/utils'
import type { BillingPlan } from '@/lib/stripe/client'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const PRICE: Record = {
monthly: {
@@ -62,7 +63,7 @@ export function BillingActions({
} catch (e) {
toast({
title: 'Kunde inte öppna betalningen',
- description: e instanceof Error ? e.message : undefined,
+ description: e instanceof Error ? getUserErrorMessage(e) : undefined,
variant: 'destructive',
})
setLoading(false)
diff --git a/components/settings/CompanyDangerZone.tsx b/components/settings/CompanyDangerZone.tsx
index 827957de..ccd04154 100644
--- a/components/settings/CompanyDangerZone.tsx
+++ b/components/settings/CompanyDangerZone.tsx
@@ -19,6 +19,7 @@ import { RetentionNotice } from '@/components/ui/retention-notice'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getBranding } from '@/lib/branding/service'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const branding = getBranding()
@@ -72,7 +73,7 @@ export function CompanyDangerZone() {
} catch (err) {
toast({
title: t('danger_delete_failed_title'),
- description: err instanceof Error ? err.message : t('danger_try_again'),
+ description: err instanceof Error ? getUserErrorMessage(err) : t('danger_try_again'),
variant: 'destructive',
})
setIsDeleting(false)
diff --git a/components/settings/FiscalPeriodEditor.tsx b/components/settings/FiscalPeriodEditor.tsx
index a57d4839..be08472a 100644
--- a/components/settings/FiscalPeriodEditor.tsx
+++ b/components/settings/FiscalPeriodEditor.tsx
@@ -18,6 +18,7 @@ import {
validateFirstPeriod,
} from '@/components/bookkeeping/FiscalPeriodDateFields'
import type { FiscalPeriod } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function formatSwedishDate(dateStr: string): string {
const months = [
@@ -84,7 +85,7 @@ export function FiscalPeriodEditor() {
setEndDate(first.period_end)
} catch (err) {
if (!cancelled) {
- setLoadError(err instanceof Error ? err.message : t('fp_load_error_unknown'))
+ setLoadError(err instanceof Error ? getUserErrorMessage(err) : t('fp_load_error_unknown'))
}
} finally {
if (!cancelled) setIsLoading(false)
@@ -158,7 +159,7 @@ export function FiscalPeriodEditor() {
} catch (err) {
toast({
title: t('fp_update_failed_title'),
- description: err instanceof Error ? err.message : t('fp_try_again'),
+ description: err instanceof Error ? getUserErrorMessage(err) : t('fp_try_again'),
variant: 'destructive',
})
} finally {
diff --git a/components/settings/FiscalYearsManager.tsx b/components/settings/FiscalYearsManager.tsx
index 5cd5a9f6..cb9b93b7 100644
--- a/components/settings/FiscalYearsManager.tsx
+++ b/components/settings/FiscalYearsManager.tsx
@@ -16,6 +16,7 @@ import { formatDate } from '@/lib/utils'
import type { FiscalPeriod } from '@/types'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** Status of a fiscal period, in legal precedence: closed > locked > open. */
function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' {
@@ -81,7 +82,7 @@ export function FiscalYearsManager() {
} catch (err) {
toast({
title: t('fy_action_error'),
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
diff --git a/components/settings/LogoUpload.tsx b/components/settings/LogoUpload.tsx
index 721bdcdb..b026e781 100644
--- a/components/settings/LogoUpload.tsx
+++ b/components/settings/LogoUpload.tsx
@@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Upload, Trash2 } from 'lucide-react'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
interface LogoUploadProps {
logoUrl: string | null
@@ -28,7 +30,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
toast({ title: t('logo_disallowed_type_title'), description: t('logo_disallowed_type_description'), variant: 'destructive' })
return
}
- if (file.size > 2 * 1024 * 1024) {
+ if (file.size > LOGO_UPLOAD_MAX_BYTES) {
toast({ title: t('logo_too_large'), variant: 'destructive' })
return
}
@@ -58,7 +60,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
} catch (error) {
toast({
title: t('logo_upload_failed_title'),
- description: error instanceof Error ? error.message : t('logo_try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('logo_try_again'),
variant: 'destructive',
})
}
diff --git a/components/settings/PdfPrintSettings.tsx b/components/settings/PdfPrintSettings.tsx
index 0123bb00..cfa4d51a 100644
--- a/components/settings/PdfPrintSettings.tsx
+++ b/components/settings/PdfPrintSettings.tsx
@@ -1,12 +1,17 @@
'use client'
import { useTranslations } from 'next-intl'
-import { useState, useCallback } from 'react'
+import { useState, useCallback, useRef, type ChangeEvent } from 'react'
+import { Loader2, Trash2, Upload } from 'lucide-react'
+import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
-import type { CompanySettings } from '@/types'
+import { INVOICE_FONT_FAMILIES } from '@/lib/invoices/branding-constants'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
+import type { CompanySettings, InvoiceFontFamily } from '@/types'
interface PdfPrintSettingsProps {
settings: CompanySettings
@@ -18,6 +23,10 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
+ const [isSavingFont, setIsSavingFont] = useState(false)
+ const [isUploadingFont, setIsUploadingFont] = useState(false)
+ const [isDeletingFont, setIsDeletingFont] = useState(false)
+ const fontInputRef = useRef(null)
const saveToggle = useCallback(async (field: string, value: boolean) => {
try {
@@ -61,12 +70,162 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
}
}, [onUpdate, toast, t])
+ const saveFont = useCallback(async (fontFamily: InvoiceFontFamily) => {
+ setIsSavingFont(true)
+ try {
+ const response = await fetch('/api/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ invoice_font_family: fontFamily }),
+ })
+ const result = await response.json()
+ if (!response.ok) throw new Error(result.error || t('toast_save_failed'))
+ onUpdate({ invoice_font_family: fontFamily })
+ } catch (error) {
+ toast({
+ title: t('toast_save_failed'),
+ description: error instanceof Error ? getUserErrorMessage(error) : undefined,
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSavingFont(false)
+ }
+ }, [onUpdate, toast, t])
+
+ async function uploadFont(file: File) {
+ setIsUploadingFont(true)
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+ const response = await fetch('/api/settings/invoice-font', {
+ method: 'POST',
+ body: formData,
+ })
+ const result = await response.json()
+ if (!response.ok) throw new Error(result.error || t('font_upload_failed'))
+ onUpdate(result.data as Partial)
+ } catch (error) {
+ toast({
+ title: t('font_upload_failed'),
+ description: error instanceof Error ? getUserErrorMessage(error) : undefined,
+ variant: 'destructive',
+ })
+ } finally {
+ setIsUploadingFont(false)
+ if (fontInputRef.current) fontInputRef.current.value = ''
+ }
+ }
+
+ async function deleteFont() {
+ setIsDeletingFont(true)
+ try {
+ const response = await fetch('/api/settings/invoice-font', { method: 'DELETE' })
+ const result = await response.json()
+ if (!response.ok) throw new Error(result.error || t('font_delete_failed'))
+ onUpdate(result.data as Partial)
+ } catch (error) {
+ toast({
+ title: t('font_delete_failed'),
+ description: error instanceof Error ? getUserErrorMessage(error) : undefined,
+ variant: 'destructive',
+ })
+ } finally {
+ setIsDeletingFont(false)
+ }
+ }
+
+ function handleFontChange(event: ChangeEvent) {
+ const file = event.target.files?.[0]
+ if (file) void uploadFont(file)
+ }
+
+ const fontLabels: Record = {
+ Helvetica: t('font_helvetica'),
+ 'Times-Roman': t('font_times'),
+ Courier: t('font_courier'),
+ 'Source Sans 3': t('font_source_sans'),
+ 'Source Serif 4': t('font_source_serif'),
+ Custom: t('font_custom'),
+ }
+
return (
{t('heading')}
+
+
+
{t('font_label')}
+
{t('font_help')}
+
+
{
+ if (value) void saveFont(value as InvoiceFontFamily)
+ }}
+ disabled={isSavingFont || isUploadingFont || isDeletingFont}
+ >
+
+
+
+
+ {INVOICE_FONT_FAMILIES
+ .filter((family) => family !== 'Custom' || settings.invoice_custom_font_path)
+ .map((family) => (
+
+ {fontLabels[family]}
+
+ ))}
+
+
+
+
+ fontInputRef.current?.click()}
+ disabled={isUploadingFont || isDeletingFont}
+ >
+ {isUploadingFont ? (
+
+ ) : (
+
+ )}
+ {settings.invoice_custom_font_path ? t('font_replace') : t('font_upload')}
+
+ {settings.invoice_custom_font_path && (
+ void deleteFont()}
+ disabled={isUploadingFont || isDeletingFont}
+ className="text-muted-foreground hover:text-destructive"
+ >
+ {isDeletingFont ? (
+
+ ) : (
+
+ )}
+ {t('font_remove')}
+
+ )}
+
+ {settings.invoice_custom_font_name && (
+
+ {t('font_uploaded_name', { name: settings.invoice_custom_font_name })}
+
+ )}
+
{t('font_file_help')}
+
+
+
diff --git a/components/settings/SecuritySettings.tsx b/components/settings/SecuritySettings.tsx
index eb9e283c..aca66012 100644
--- a/components/settings/SecuritySettings.tsx
+++ b/components/settings/SecuritySettings.tsx
@@ -14,6 +14,7 @@ import { isMfaRequired } from '@/lib/auth/mfa'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdSettings } from '@/components/settings/BankIdSettings'
import { userHasPassword } from '@/lib/auth/has-password'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
const mfaRequired = isMfaRequired()
@@ -141,7 +142,7 @@ export function SecuritySettings() {
}
toast({
title: t('toast_unenroll_failed_title'),
- description: error.message,
+ description: getUserErrorMessage(error),
variant: 'destructive',
})
return
diff --git a/components/settings/SettingsFormWrapper.tsx b/components/settings/SettingsFormWrapper.tsx
index 2e274e20..39dbad1d 100644
--- a/components/settings/SettingsFormWrapper.tsx
+++ b/components/settings/SettingsFormWrapper.tsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Loader2, Check, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type SaveResult =
| Record
@@ -82,7 +83,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
} catch (error) {
toast({
title: t('wrapper_save_failed_title'),
- description: error instanceof Error ? error.message : t('wrapper_try_again'),
+ description: error instanceof Error ? getUserErrorMessage(error) : t('wrapper_try_again'),
variant: 'destructive',
})
}
diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx
index fc84b654..6dabb6a7 100644
--- a/components/settings/SkatteverketConnectPanel.tsx
+++ b/components/settings/SkatteverketConnectPanel.tsx
@@ -11,6 +11,7 @@ import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { CheckCircle2, ExternalLink, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type Environment = 'test' | 'prod'
@@ -227,7 +228,7 @@ function SkatteverketPersonalConnectionCard() {
} catch (err) {
toast({
title: t('toast_disconnect_failed'),
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
diff --git a/components/settings/TaxAssessmentNoticesPanel.tsx b/components/settings/TaxAssessmentNoticesPanel.tsx
new file mode 100644
index 00000000..5da6dc2e
--- /dev/null
+++ b/components/settings/TaxAssessmentNoticesPanel.tsx
@@ -0,0 +1,243 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { CalendarClock, Pencil, Trash2 } from 'lucide-react'
+import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { useToast } from '@/components/ui/use-toast'
+import { useErrorToast } from '@/lib/hooks/use-error-toast'
+import type {
+ TaxAssessmentDecisionType,
+ TaxAssessmentNotice,
+} from '@/types'
+
+function todayLocalIso(): string {
+ const today = new Date()
+ const year = today.getFullYear()
+ const month = String(today.getMonth() + 1).padStart(2, '0')
+ const day = String(today.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+export function TaxAssessmentNoticesPanel() {
+ const t = useTranslations('tax_assessment_notices')
+ const { toast } = useToast()
+ const showError = useErrorToast()
+ const [notices, setNotices] = useState([])
+ const [selectedPeriodId, setSelectedPeriodId] = useState(null)
+ const [decisionType, setDecisionType] = useState('final')
+ const [decisionDate, setDecisionDate] = useState(todayLocalIso)
+ const [paymentDueDate, setPaymentDueDate] = useState('')
+ const [editingId, setEditingId] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ void (async () => {
+ try {
+ const response = await fetch('/api/tax-assessment-notices')
+ if (!response.ok) {
+ if (!cancelled) await showError(response, { context: 'settings' })
+ return
+ }
+ const payload = await response.json() as { data: TaxAssessmentNotice[] }
+ if (!cancelled) setNotices(payload.data)
+ } catch (error) {
+ if (!cancelled) await showError(error, { context: 'settings' })
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ // The error helper is recreated with the toast hook. This load should run
+ // once per panel mount, not after every render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const resetForm = () => {
+ setEditingId(null)
+ setDecisionType('final')
+ setDecisionDate(todayLocalIso())
+ setPaymentDueDate('')
+ }
+
+ const saveNotice = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!selectedPeriodId || !paymentDueDate) return
+ setSaving(true)
+ try {
+ const response = await fetch(
+ editingId ? `/api/tax-assessment-notices/${editingId}` : '/api/tax-assessment-notices',
+ {
+ method: editingId ? 'PATCH' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ fiscal_period_id: selectedPeriodId,
+ decision_type: decisionType,
+ decision_date: decisionDate,
+ payment_due_date: paymentDueDate,
+ }),
+ },
+ )
+ if (!response.ok) {
+ await showError(response, { context: 'settings' })
+ return
+ }
+ const payload = await response.json() as { data: TaxAssessmentNotice }
+ setNotices((current) => {
+ const withoutSaved = current.filter((notice) => notice.id !== payload.data.id)
+ return [...withoutSaved, payload.data]
+ .sort((a, b) => a.payment_due_date.localeCompare(b.payment_due_date))
+ })
+ toast({ title: t(editingId ? 'updated' : 'saved') })
+ resetForm()
+ } catch (error) {
+ await showError(error, { context: 'settings' })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const editNotice = (notice: TaxAssessmentNotice) => {
+ setEditingId(notice.id)
+ setSelectedPeriodId(notice.fiscal_period_id)
+ setDecisionType(notice.decision_type)
+ setDecisionDate(notice.decision_date)
+ setPaymentDueDate(notice.payment_due_date)
+ }
+
+ const archiveNotice = async (notice: TaxAssessmentNotice) => {
+ setSaving(true)
+ try {
+ const response = await fetch(`/api/tax-assessment-notices/${notice.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ archived: true }),
+ })
+ if (!response.ok) {
+ await showError(response, { context: 'settings' })
+ return
+ }
+ setNotices((current) => current.filter((item) => item.id !== notice.id))
+ if (editingId === notice.id) resetForm()
+ toast({ title: t('archived') })
+ } catch (error) {
+ await showError(error, { context: 'settings' })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {t('title')}
+
+
+ {t('description')}
+
+
+
+
+
+
+ {!loading && notices.length > 0 && (
+
+ {notices.map((notice) => (
+
+
+
+ {notice.decision_type === 'final' ? t('decision_final') : t('decision_reassessment')}
+ {notice.fiscal_period?.name ? `: ${notice.fiscal_period.name}` : ''}
+
+
+ {t('due_summary', { date: notice.payment_due_date })}
+
+
+
+
editNotice(notice)} disabled={saving}>
+
+ {t('edit')}
+
+
void archiveNotice(notice)} disabled={saving}>
+
+ {t('archive')}
+
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx
index b90e7e40..52e1d587 100644
--- a/components/settings/sections/InvoicingSettingsContent.tsx
+++ b/components/settings/sections/InvoicingSettingsContent.tsx
@@ -15,6 +15,7 @@ import { useToast } from '@/components/ui/use-toast'
import { normaliseSwish } from '@/lib/payments/swish'
import { formatPlusgiroNumber } from '@/lib/bankgiro/luhn'
import type { CompanySettings } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export function InvoicingSettingsContent() {
const t = useTranslations('settings_invoicing')
@@ -29,7 +30,7 @@ export function InvoicingSettingsContent() {
if (bankErrors.length > 0) {
toast({
title: t('bank_validation_title'),
- description: bankErrors.map(e => e.message).join(', '),
+ description: bankErrors.map(e => getUserErrorMessage(e)).join(', '),
variant: 'destructive',
})
return {}
diff --git a/components/settings/sections/TaxSettingsContent.tsx b/components/settings/sections/TaxSettingsContent.tsx
index 4a3d1a4c..d2d6c3aa 100644
--- a/components/settings/sections/TaxSettingsContent.tsx
+++ b/components/settings/sections/TaxSettingsContent.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
+import { TaxAssessmentNoticesPanel } from '@/components/settings/TaxAssessmentNoticesPanel'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
@@ -165,6 +166,8 @@ export function TaxSettingsContent() {
rotRutSignalDetected={rotRutSignalDetected}
/>
+
+
)
}
diff --git a/components/settings/sections/index.ts b/components/settings/sections/index.ts
index 7fe9e0e2..ac72de81 100644
--- a/components/settings/sections/index.ts
+++ b/components/settings/sections/index.ts
@@ -1,16 +1,55 @@
import type { ComponentType } from 'react'
-import { AccountSettingsContent } from './AccountSettingsContent'
-import { CompanySettingsContent } from './CompanySettingsContent'
-import { BookkeepingSettingsContent } from './BookkeepingSettingsContent'
-import { TaxSettingsContent } from './TaxSettingsContent'
-import { SalarySettingsContent } from './SalarySettingsContent'
-import { InvoicingSettingsContent } from './InvoicingSettingsContent'
-import { TemplatesSettingsContent } from './TemplatesSettingsContent'
-import { PaymentsSettingsContent } from './PaymentsSettingsContent'
-import { BankingSettingsContent } from './BankingSettingsContent'
-import { AssistantSettingsContent } from './AssistantSettingsContent'
-import { ApiSettingsContent } from './ApiSettingsContent'
-import { BillingSettingsContent } from './BillingSettingsContent'
+import dynamic from 'next/dynamic'
+import { SettingsLoadingSkeleton } from '../SettingsLoadingSkeleton'
+
+const AccountSettingsContent = dynamic(() =>
+ import('./AccountSettingsContent').then((module) => ({ default: module.AccountSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const CompanySettingsContent = dynamic(() =>
+ import('./CompanySettingsContent').then((module) => ({ default: module.CompanySettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const BookkeepingSettingsContent = dynamic(() =>
+ import('./BookkeepingSettingsContent').then((module) => ({ default: module.BookkeepingSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const TaxSettingsContent = dynamic(() =>
+ import('./TaxSettingsContent').then((module) => ({ default: module.TaxSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const SalarySettingsContent = dynamic(() =>
+ import('./SalarySettingsContent').then((module) => ({ default: module.SalarySettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const InvoicingSettingsContent = dynamic(() =>
+ import('./InvoicingSettingsContent').then((module) => ({ default: module.InvoicingSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const TemplatesSettingsContent = dynamic(() =>
+ import('./TemplatesSettingsContent').then((module) => ({ default: module.TemplatesSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const PaymentsSettingsContent = dynamic(() =>
+ import('./PaymentsSettingsContent').then((module) => ({ default: module.PaymentsSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const BankingSettingsContent = dynamic(() =>
+ import('./BankingSettingsContent').then((module) => ({ default: module.BankingSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const AssistantSettingsContent = dynamic(() =>
+ import('./AssistantSettingsContent').then((module) => ({ default: module.AssistantSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const ApiSettingsContent = dynamic(() =>
+ import('./ApiSettingsContent').then((module) => ({ default: module.ApiSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
+const BillingSettingsContent = dynamic(() =>
+ import('./BillingSettingsContent').then((module) => ({ default: module.BillingSettingsContent })),
+ { loading: SettingsLoadingSkeleton },
+)
/**
* Single source of truth mapping a settings section id to the component that
diff --git a/components/skattekonto/SkattekontoMatchDialog.tsx b/components/skattekonto/SkattekontoMatchDialog.tsx
index 621337cd..0cecd695 100644
--- a/components/skattekonto/SkattekontoMatchDialog.tsx
+++ b/components/skattekonto/SkattekontoMatchDialog.tsx
@@ -24,6 +24,7 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { StoredSkattekontoTransaction } from '@/types/skatteverket'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface MatchCandidate {
journal_entry_id: string
@@ -84,7 +85,7 @@ export function SkattekontoMatchDialog({
if (cancelled) return
toast({
title: t('fetch_candidates_failed_title'),
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
onClose()
@@ -119,7 +120,7 @@ export function SkattekontoMatchDialog({
} catch (err) {
toast({
title: t('match_failed_title'),
- description: err instanceof Error ? err.message : undefined,
+ description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx
index b81bd9b1..a8dc685f 100644
--- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx
+++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx
@@ -580,7 +580,7 @@ export default function NewSupplierInvoiceForm({
if (cancelled) return
toast({
title: t('inbox_load_failed_title'),
- description: err instanceof Error ? err.message : t('unknown_error'),
+ description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
diff --git a/components/transactions/BankSyncNowButton.tsx b/components/transactions/BankSyncNowButton.tsx
index 68a897b2..1c95c0a4 100644
--- a/components/transactions/BankSyncNowButton.tsx
+++ b/components/transactions/BankSyncNowButton.tsx
@@ -17,6 +17,7 @@ import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface BankConn {
id: string
@@ -86,7 +87,7 @@ export default function BankSyncNowButton() {
} catch (error) {
toast({
title: t('bank_reconnect'),
- description: error instanceof Error ? error.message : 'Reconnect failed',
+ description: error instanceof Error ? getUserErrorMessage(error) : 'Reconnect failed',
variant: 'destructive',
})
setBusyId(null)
@@ -137,7 +138,7 @@ export default function BankSyncNowButton() {
} catch (error) {
toast({
title: t('bank_sync_button_now'),
- description: error instanceof Error ? error.message : 'Sync failed',
+ description: error instanceof Error ? getUserErrorMessage(error) : 'Sync failed',
variant: 'destructive',
})
} finally {
diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx
index 140cb84b..2bb55e3a 100644
--- a/components/transactions/QuickReviewDialog.tsx
+++ b/components/transactions/QuickReviewDialog.tsx
@@ -21,6 +21,7 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
+import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface QuickReviewDialogProps {
open: boolean
@@ -133,7 +134,7 @@ export default function QuickReviewDialog({
const json = await res.json()
if (cancelled) return
if (!res.ok) {
- setRateError(json?.error?.message || t('exchange_rate_fetch_failed'))
+ setRateError(getUserErrorMessage(json?.error) || t('exchange_rate_fetch_failed'))
return
}
if (json?.data) {
diff --git a/components/ui/dialog-loading-skeleton.tsx b/components/ui/dialog-loading-skeleton.tsx
new file mode 100644
index 00000000..0d157d96
--- /dev/null
+++ b/components/ui/dialog-loading-skeleton.tsx
@@ -0,0 +1,23 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
+import { Skeleton } from '@/components/ui/skeleton'
+
+export function DialogLoadingSkeleton() {
+ const t = useTranslations('common')
+
+ return (
+
+
+ {t('loading')}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/extensions/general/bolagsverket/__tests__/index.test.ts b/extensions/general/bolagsverket/__tests__/index.test.ts
index ff2127d6..49eda501 100644
--- a/extensions/general/bolagsverket/__tests__/index.test.ts
+++ b/extensions/general/bolagsverket/__tests__/index.test.ts
@@ -38,9 +38,10 @@ function makeCtx(supabase: unknown): ExtensionContext {
const validBody = {
fiscal_period_id: '123e4567-e89b-12d3-a456-426614174000',
- avsandare_pnr: '198001019876',
+ annual_report_version_id: '123e4567-e89b-12d3-a456-426614174001',
+ avsandare_pnr: '198001019879',
undertecknare: {
- pnr: '198001019876',
+ pnr: '198001019879',
fornamn: 'Anna',
efternamn: 'Svensson',
roll: 'VD',
@@ -62,10 +63,12 @@ interface ErrorEnvelope {
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
const ORIGINAL_BV_ENV = process.env.BOLAGSVERKET_ENV
+const ORIGINAL_FILING_ENABLED = process.env.BOLAGSVERKET_FILING_ENABLED
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
+ process.env.BOLAGSVERKET_FILING_ENABLED = 'true'
delete process.env.BOLAGSVERKET_ENV
})
@@ -74,6 +77,8 @@ afterEach(() => {
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
if (ORIGINAL_BV_ENV === undefined) delete process.env.BOLAGSVERKET_ENV
else process.env.BOLAGSVERKET_ENV = ORIGINAL_BV_ENV
+ if (ORIGINAL_FILING_ENABLED === undefined) delete process.env.BOLAGSVERKET_FILING_ENABLED
+ else process.env.BOLAGSVERKET_FILING_ENABLED = ORIGINAL_FILING_ENABLED
})
describe('POST /submissions: write-role enforcement', () => {
@@ -146,9 +151,12 @@ describe('POST /submissions: environment validation + ceiling', () => {
})
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
- const { status, body } = await parseJsonResponse<{ data: { outcome: string } }>(res as Response)
+ const { status, body } = await parseJsonResponse<{
+ data: { outcome: string; idnummer?: string }
+ }>(res as Response)
expect(status).toBe(200)
expect(body.data.outcome).toBe('uploaded')
+ expect(body.data.idnummer).toBeUndefined()
// The service got a client pinned to the validated environment.
const deps = vi.mocked(submitArsredovisning).mock.calls[0][0]
expect(deps.client.environment).toBe('test')
@@ -196,6 +204,23 @@ describe('POST /submissions: config + error mapping', () => {
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
})
+
+ it('rejects an invalid personnummer before calling the filing service', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { role: 'member' }, error: null })
+
+ const res = await route('POST', '/submissions').handler(
+ makePost('/submissions', {
+ ...validBody,
+ avsandare_pnr: '198002309879',
+ }),
+ makeCtx(supabase),
+ )
+ const { status, body } = await parseJsonResponse
(res as Response)
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('VALIDATION_ERROR')
+ expect(submitArsredovisning).not.toHaveBeenCalled()
+ })
})
describe('GET /status', () => {
diff --git a/extensions/general/bolagsverket/__tests__/submission-service.test.ts b/extensions/general/bolagsverket/__tests__/submission-service.test.ts
index 56b4a6f6..b843909b 100644
--- a/extensions/general/bolagsverket/__tests__/submission-service.test.ts
+++ b/extensions/general/bolagsverket/__tests__/submission-service.test.ts
@@ -10,11 +10,20 @@ vi.mock('@/lib/bokslut/ixbrl/build-input', () => ({
}))
vi.mock('@/lib/bokslut/ixbrl/document/k2-document', () => ({
generateK2IxbrlDocument: vi.fn(() => ({ xhtml: '' })),
- embedKontrollsumma: vi.fn((xhtml: string) => xhtml),
+ embedKontrollsumma: vi.fn(
+ (xhtml: string, checksum: string) => `${xhtml}`,
+ ),
}))
vi.mock('@/lib/bokslut/ixbrl/validate/rules', () => ({
runPreflightChecks: vi.fn(() => ({ ok: true, issues: [] })),
}))
+vi.mock('@/lib/bokslut/ixbrl/validate/arelle-client', () => ({
+ validateIxbrlWithArelle: vi.fn(async () => ({
+ status: 'passed',
+ validator_version: 'test',
+ issues: [],
+ })),
+}))
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: vi.fn(async () => ({ id: 'doc-1' })),
}))
@@ -25,6 +34,7 @@ vi.mock('@/lib/auth/api-keys', () => ({
}))
import { uploadDocument } from '@/lib/core/documents/document-service'
+import { validateIxbrlWithArelle } from '@/lib/bokslut/ixbrl/validate/arelle-client'
import {
applyHandelse,
BolagsverketSubmissionError,
@@ -34,6 +44,7 @@ import {
submitArsredovisning,
} from '../lib/submission-service'
import type { HandelseMeddelande } from '../types'
+import { makeInput } from '@/lib/bokslut/ixbrl/__tests__/fixtures'
function makeLog() {
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
@@ -96,16 +107,22 @@ const submitParams = {
companyId: 'company-1',
userId: 'user-1',
fiscalPeriodId: 'period-1',
- avsandarePnr: '198001019876',
+ annualReportVersionId: 'version-1',
+ avsandarePnr: '198001019879',
undertecknare: {
- pnr: '198001019876',
- fornamn: 'Anna',
- efternamn: 'Svensson',
- roll: 'VD',
+ pnr: '198001019879',
+ fornamn: 'Karl',
+ efternamn: 'Karlsson',
+ roll: 'Styrelseledamot',
epost: 'anna@example.com',
},
}
+const eligibleValidation = {
+ digital_filing_eligible: true,
+ digital_issues: [],
+}
+
function message(overrides: Partial = {}): HandelseMeddelande {
return {
typ: 'AR-v2',
@@ -129,12 +146,12 @@ describe('normalizeOrgnr / hashPnr', () => {
})
it('hashes personnummer with company salt: never the raw value', () => {
- const hash = hashPnr('company-1', '19830101-9876')
+ const hash = hashPnr('company-1', '19830101-9876', 'test-secret')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
expect(hash).not.toContain('9876')
- expect(hashPnr('company-2', '198301019876')).not.toBe(hash)
+ expect(hashPnr('company-2', '198301019876', 'test-secret')).not.toBe(hash)
// Same pnr + company → stable.
- expect(hashPnr('company-1', '198301019876')).toBe(hash)
+ expect(hashPnr('company-1', '198301019876', 'test-secret')).toBe(hash)
})
})
@@ -328,6 +345,15 @@ describe('submitArsredovisning', () => {
it('refuses when an active submission already exists for the fiscal period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { org_number: '556000-1111' }, error: null }) // company_settings
+ enqueue({
+ data: {
+ id: 'version-1',
+ status: 'signed',
+ ixbrl_data: makeInput(),
+ validation_summary: eligibleValidation,
+ },
+ error: null,
+ })
enqueue({ data: [{ id: 'sub-0', status: 'uploaded' }], error: null }) // active submissions
await expect(
@@ -341,14 +367,104 @@ describe('submitArsredovisning', () => {
})
})
- it('marks the submission row as error and rethrows when inlämning fails', async () => {
+ it('blocks a signed version that is not eligible for connected filing', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { org_number: '556000-1111' }, error: null })
+ enqueue({
+ data: {
+ id: 'version-1',
+ status: 'signed',
+ ixbrl_data: makeInput(),
+ validation_summary: {
+ digital_filing_eligible: false,
+ digital_issues: [{ code: 'AR-DIGITAL-AUDIT' }],
+ },
+ },
+ error: null,
+ })
+
+ await expect(
+ submitArsredovisning(
+ {
+ supabase: supabase as never,
+ client: makeClientMock(),
+ appUrl: 'https://app.test',
+ log: makeLog(),
+ },
+ submitParams,
+ ),
+ ).rejects.toMatchObject({ code: 'BOLAGSVERKET_DIGITAL_INELIGIBLE' })
+ })
+
+ it('requires the certificate signer to match the immutable version', async () => {
+ const { supabase } = makeRecordingSupabase([
+ { data: { org_number: '5560001111' } },
+ {
+ data: {
+ id: 'version-1',
+ status: 'signed',
+ ixbrl_data: makeInput(),
+ taxonomy_version: '2024-09-12',
+ validation_summary: eligibleValidation,
+ },
+ },
+ { data: [] },
+ { data: { id: 'acc-1' } },
+ {
+ data: [
+ {
+ signer_name: 'Anna Svensson',
+ role: 'VD',
+ signed_at: '2026-03-01T10:00:00Z',
+ status: 'signed',
+ signing_method: 'paper_original',
+ evidence_reference: 'Arkiv A-1',
+ },
+ ],
+ },
+ ])
+
+ await expect(
+ submitArsredovisning(
+ {
+ supabase,
+ client: makeClientMock(),
+ appUrl: 'https://app.test',
+ log: makeLog(),
+ },
+ {
+ ...submitParams,
+ undertecknare: { ...submitParams.undertecknare, fornamn: 'Någon annan' },
+ },
+ ),
+ ).rejects.toMatchObject({
+ code: 'BOLAGSVERKET_CERTIFICATE_SIGNER_MISMATCH',
+ })
+ })
+
+ it('marks the submission as unknown and blocks an automatic retry when upload is uncertain', async () => {
const { supabase, updates } = makeRecordingSupabase([
{ data: { org_number: '5560001111' } }, // getOrgnr
+ {
+ data: {
+ id: 'version-1',
+ status: 'signed',
+ ixbrl_data: makeInput(),
+ taxonomy_version: '2024-09-12',
+ validation_summary: eligibleValidation,
+ },
+ },
{ data: [] }, // no active submission
{ data: { id: 'acc-1' } }, // avtal acceptance exists
+ { data: [{ signer_name: 'Anna Svensson', role: 'VD', signed_at: '2026-03-01T10:00:00Z', status: 'signed', signing_method: 'paper_original', evidence_reference: 'Arkiv A-1' }] },
+ {}, // local validation run
+ {}, // Arelle validation run
+ { data: null }, // no exact request
{ data: { id: 'sub-1' } }, // insert submission row
{}, // update → kontrollerad
- {}, // markSubmissionError update
+ {}, // archive link update
+ {}, // update → sending
+ {}, // update → unknown
])
const client = makeClientMock({
lamnaIn: vi.fn(async () => {
@@ -357,52 +473,110 @@ describe('submitArsredovisning', () => {
})
const log = makeLog()
- await expect(
- submitArsredovisning({ supabase, client, appUrl: 'https://app.test', log }, submitParams),
- ).rejects.toThrow('inlamning exploded')
+ const result = await submitArsredovisning(
+ { supabase, client, appUrl: 'https://app.test', log },
+ submitParams,
+ )
- const errorUpdate = updates.find((u) => u.payload.status === 'error')
- expect(errorUpdate).toBeDefined()
- expect(errorUpdate!.table).toBe('arsredovisning_submissions')
- expect(errorUpdate!.payload.error_message).toContain('inlamning exploded')
+ expect(result.outcome).toBe('state_unknown')
+ const unknownUpdate = updates.find((u) => u.payload.status === 'unknown')
+ expect(unknownUpdate).toBeDefined()
+ expect(unknownUpdate!.payload.error_message).toContain('inlamning exploded')
})
- it('logs and persists a document-archive failure without blocking the filing', async () => {
+ it('blocks filing when the exact uploaded bytes cannot be archived first', async () => {
vi.mocked(uploadDocument).mockRejectedValueOnce(new Error('magic bytes rejected'))
const { supabase, updates } = makeRecordingSupabase([
{ data: { org_number: '5560001111' } }, // getOrgnr
+ {
+ data: {
+ id: 'version-1',
+ status: 'signed',
+ ixbrl_data: makeInput(),
+ taxonomy_version: '2024-09-12',
+ validation_summary: eligibleValidation,
+ },
+ },
{ data: [] }, // no active submission
{ data: { id: 'acc-1' } }, // avtal acceptance exists
+ { data: [{ signer_name: 'Anna Svensson', role: 'VD', signed_at: '2026-03-01T10:00:00Z', status: 'signed', signing_method: 'paper_original', evidence_reference: 'Arkiv A-1' }] },
+ {}, // local validation run
+ {}, // Arelle validation run
+ { data: null }, // no exact request
{ data: { id: 'sub-1' } }, // insert submission row
{}, // update → kontrollerad
- {}, // error_message update (doc failure)
- {}, // update → uploaded
- // ensureSubscription throws on the empty appUrl before any query.
+ {}, // archive failure update
+ {}, // mark submission error
])
const log = makeLog()
- const result = await submitArsredovisning(
- { supabase, client: makeClientMock(), appUrl: '', log },
- submitParams,
- )
- expect(result.outcome).toBe('uploaded')
+ await expect(
+ submitArsredovisning(
+ { supabase, client: makeClientMock(), appUrl: 'https://app.test', log },
+ submitParams,
+ ),
+ ).rejects.toThrow('Dokumentarkivering misslyckades')
// The failure is logged AND visible on the row.
- expect(log.error).toHaveBeenCalledTimes(1)
- expect(log.error.mock.calls[0][0]).toMatch(/archive/)
const docFailureUpdate = updates.find(
(u) => typeof u.payload.error_message === 'string' && !u.payload.status,
)
expect(docFailureUpdate).toBeDefined()
expect(docFailureUpdate!.payload.error_message).toContain('magic bytes rejected')
- // The filing itself still went through with dokument_id null.
- const uploadedUpdate = updates.find((u) => u.payload.status === 'uploaded')
- expect(uploadedUpdate).toBeDefined()
- expect(uploadedUpdate!.payload.dokument_id).toBeNull()
+ expect(updates.some((update) => update.payload.status === 'sending')).toBe(false)
+ })
- // Subscription failure (invalid appUrl) is logged, not swallowed.
- expect(log.warn.mock.calls.some(([msg]) => /prenumeration/.test(String(msg)))).toBe(true)
+ it('reuses a stopped kontrollera row when the user explicitly accepts warnings', async () => {
+ const { supabase, updates } = makeRecordingSupabase([
+ { data: { org_number: '5560001111' } },
+ {
+ data: {
+ id: 'version-1',
+ status: 'signed',
+ ixbrl_data: makeInput(),
+ taxonomy_version: '2024-09-12',
+ validation_summary: eligibleValidation,
+ },
+ },
+ { data: [] },
+ { data: { id: 'acc-1' } },
+ {
+ data: [
+ {
+ signer_name: 'Anna Svensson',
+ role: 'VD',
+ signed_at: '2026-03-01T10:00:00Z',
+ status: 'signed',
+ signing_method: 'paper_original',
+ evidence_reference: 'Arkiv A-1',
+ },
+ ],
+ },
+ {},
+ {},
+ { data: { id: 'sub-1', status: 'kontrollerad' } },
+ { data: { id: 'sub-1' } },
+ {},
+ {},
+ {},
+ {},
+ {},
+ ])
+
+ const result = await submitArsredovisning(
+ { supabase, client: makeClientMock(), appUrl: '', log: makeLog() },
+ { ...submitParams, ignoreWarnings: true },
+ )
+
+ expect(result.outcome).toBe('uploaded')
+ expect(updates.some((update) => update.payload.status === 'draft')).toBe(true)
+ expect(updates.some((update) => update.payload.status === 'uploaded')).toBe(true)
+ expect(validateIxbrlWithArelle).toHaveBeenCalledWith(
+ expect.stringContaining('checksum:ksum'),
+ )
+ const archivedFile = vi.mocked(uploadDocument).mock.calls[0][3]
+ expect(Buffer.from(archivedFile.buffer).toString('utf8')).toContain('checksum:ksum')
})
it('exports BolagsverketSubmissionError with a stable code', () => {
diff --git a/extensions/general/bolagsverket/index.ts b/extensions/general/bolagsverket/index.ts
index 75ef4a94..0cdc4629 100644
--- a/extensions/general/bolagsverket/index.ts
+++ b/extensions/general/bolagsverket/index.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
+import { validateSwedishPersonalNumber } from '@/lib/extensions/validation'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -63,6 +64,10 @@ function environmentCeiling(): BolagsverketEnvironment {
return isBolagsverketEnvironment(raw) ? raw : 'test'
}
+function filingReleaseEnabled(): boolean {
+ return process.env.BOLAGSVERKET_FILING_ENABLED === 'true'
+}
+
/**
* Resolve the effective Bolagsverket environment for a company.
*
@@ -77,13 +82,16 @@ function environmentCeiling(): BolagsverketEnvironment {
* able to point a hosted tenant at prod and ride the platform certificate).
*/
async function resolveEnvironment(ctx: ExtensionContext): Promise {
- const { data } = await ctx.supabase
+ const { data, error } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'general/bolagsverket')
.eq('key', 'settings')
.maybeSingle()
+ if (error) {
+ throw new Error(`Failed to resolve Bolagsverket environment: ${error.message}`)
+ }
const configured = (data?.value as { environment?: unknown } | null)?.environment
const ceiling = environmentCeiling()
if (configured === undefined || configured === null || configured === '') {
@@ -120,11 +128,12 @@ async function clientFor(ctx: ExtensionContext): Promise {
}
async function companyOrgnr(ctx: ExtensionContext): Promise {
- const { data } = await ctx.supabase
+ const { data, error } = await ctx.supabase
.from('company_settings')
.select('org_number')
.eq('company_id', ctx.companyId)
.maybeSingle()
+ if (error) throw new Error(`Kunde inte läsa organisationsnummer: ${error.message}`)
const orgNumber = (data as { org_number?: string } | null)?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas i företagsinställningarna.')
return normalizeOrgnr(orgNumber)
@@ -186,18 +195,34 @@ function apiErrorResponse(err: unknown, ctx: ExtensionContext): NextResponse {
const noContextResponse = () =>
NextResponse.json({ error: { code: 'NO_CONTEXT', message: 'Saknar kontext' } }, { status: 500 })
+const PersonnummerSchema = z
+ .string()
+ .regex(/^\d{12}$/, 'Personnummer ska normaliseras till 12 siffror')
+ .refine((value) => validateSwedishPersonalNumber(value) === null, 'Ogiltigt personnummer')
+ .refine((value) => {
+ const year = Number(value.slice(0, 4))
+ const month = Number(value.slice(4, 6))
+ const day = Number(value.slice(6, 8))
+ const date = new Date(Date.UTC(year, month - 1, day))
+ return (
+ date.getUTCFullYear() === year &&
+ date.getUTCMonth() === month - 1 &&
+ date.getUTCDate() === day
+ )
+ }, 'Ogiltigt födelsedatum')
+
const SubmitSchema = z.object({
fiscal_period_id: z.string().uuid(),
- avsandare_pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10-12 siffror'),
+ annual_report_version_id: z.string().uuid(),
+ avsandare_pnr: PersonnummerSchema,
undertecknare: z.object({
- pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10-12 siffror'),
+ pnr: PersonnummerSchema,
fornamn: z.string().min(1).max(100),
efternamn: z.string().min(1).max(100),
roll: z.string().min(1).max(100),
epost: z.string().email(),
}),
kvittens_epost: z.array(z.string().email()).max(5).optional(),
- utdelning: z.number().min(0).optional(),
accepted_avtalstext_andrad: z.string().optional(),
ignore_warnings: z.boolean().optional(),
})
@@ -226,6 +251,7 @@ export const bolagsverketExtension: Extension = {
environment_ceiling: environmentCeiling(),
// Certificate material is env-only; settings can never carry it.
has_certificate: Boolean(config.clientCertPem && config.clientKeyPem),
+ filing_enabled: filingReleaseEnabled(),
},
})
} catch (err) {
@@ -273,7 +299,7 @@ export const bolagsverketExtension: Extension = {
let query = ctx.supabase
.from('arsredovisning_submissions')
.select(
- 'id, fiscal_period_id, handling_typ, taxonomy_version, entry_point, environment, status, undertecknare_namn, undertecknare_epost, idnummer, sha256_checksumma, kontrollsumma, bolagsverket_url, kontrollera_utfall, error_message, uploaded_at, registered_at, created_at, updated_at',
+ 'id, fiscal_period_id, annual_report_version_id, handling_typ, taxonomy_version, entry_point, environment, status, archive_status, undertecknare_namn, undertecknare_epost, sha256_checksumma, kontrollsumma, bolagsverket_url, kontrollera_utfall, error_message, uploaded_at, registered_at, created_at, updated_at',
)
.eq('company_id', ctx.companyId)
.order('created_at', { ascending: false })
@@ -294,6 +320,11 @@ export const bolagsverketExtension: Extension = {
path: '/submissions',
handler: async (request, ctx) => {
if (!ctx) return noContextResponse()
+ if (!filingReleaseEnabled()) {
+ return errorResponseFromCode('BOLAGSVERKET_NOT_RELEASED', ctx.log, {
+ requestId: ctx.requestId,
+ })
+ }
const forbidden = await requireWriteRole(ctx)
if (forbidden) return forbidden
let parsed: z.infer
@@ -326,14 +357,21 @@ export const bolagsverketExtension: Extension = {
companyId: ctx.companyId,
userId: ctx.userId,
fiscalPeriodId: parsed.fiscal_period_id,
+ annualReportVersionId: parsed.annual_report_version_id,
avsandarePnr: parsed.avsandare_pnr,
undertecknare: parsed.undertecknare,
kvittensEpost: parsed.kvittens_epost,
- proposedDividend: parsed.utdelning,
acceptedAvtalstextAndrad: parsed.accepted_avtalstext_andrad,
ignoreWarnings: parsed.ignore_warnings,
},
)
+ // Bolagsverket marks idnummer as a technical correlation value that
+ // must not be shown to end users. Keep it in the server-side filing
+ // record for webhook matching, but omit it from browser responses.
+ if (result.outcome === 'uploaded' || result.outcome === 'state_unknown') {
+ const { idnummer: _idnummer, ...publicResult } = result
+ return NextResponse.json({ data: publicResult })
+ }
return NextResponse.json({ data: result })
} catch (err) {
ctx.log.error('bolagsverket submission failed', err)
diff --git a/extensions/general/bolagsverket/lib/submission-service.ts b/extensions/general/bolagsverket/lib/submission-service.ts
index 5dc00be2..5208883c 100644
--- a/extensions/general/bolagsverket/lib/submission-service.ts
+++ b/extensions/general/bolagsverket/lib/submission-service.ts
@@ -8,18 +8,18 @@
* there: uploaded → inkommen → (förelagd ↔ komplettering)* →
* registrerad | avslutad.
*
- * Personnummer are transient: used for the API calls, persisted only as
- * company-salted SHA-256 hashes.
+ * Personnummer are transient: used for the API calls and never persisted.
*/
-import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
+import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { uploadDocument } from '@/lib/core/documents/document-service'
-import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument, embedKontrollsumma } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks } from '@/lib/bokslut/ixbrl/validate/rules'
+import { validateIxbrlWithArelle } from '@/lib/bokslut/ixbrl/validate/arelle-client'
+import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
import { BolagsverketClient } from './client'
import type { ExtensionLogger } from '@/lib/extensions/types'
import type {
@@ -47,14 +47,18 @@ export class BolagsverketSubmissionError extends Error {
/** Statuses that mean "Bolagsverket currently holds an open filing for this period". */
export const ACTIVE_SUBMISSION_STATUSES = [
+ 'sending',
'uploaded',
+ 'unknown',
'inkommen',
'forelagd',
'komplettering',
] as const
-export function hashPnr(companyId: string, pnr: string): string {
- return createHash('sha256').update(`${companyId}:${pnr.replace(/\D/g, '')}`).digest('hex')
+export function hashPnr(companyId: string, pnr: string, secret: string): string {
+ return createHmac('sha256', secret)
+ .update(`${companyId}:${pnr.replace(/\D/g, '')}`)
+ .digest('hex')
}
/** Normalize to the 10-digit orgnr the API expects (no dash, no century). */
@@ -67,13 +71,15 @@ export function normalizeOrgnr(orgNumber: string): string {
* SECURITY: `avsandarePnr` and `undertecknare.pnr` are plaintext personnummer,
* needed only for the Bolagsverket API calls. They must NEVER reach a log sink:
* log structured fields (companyId, fiscalPeriodId, submissionId) and never
- * the params object itself. At rest only company-salted SHA-256 hashes are
- * stored (see hashPnr).
+ * the params object itself. No personnummer-derived value is needed for the
+ * filing audit trail; hashPnr exists only for integrations that need a keyed,
+ * non-enumerable correlation reference.
*/
export interface SubmitParams {
companyId: string
userId: string
fiscalPeriodId: string
+ annualReportVersionId: string
/** Avsändarens personnummer (12 siffror): required by skapa-inlamningtoken. */
avsandarePnr: string
/** Undertecknare of fastställelseintyget. */
@@ -85,7 +91,6 @@ export interface SubmitParams {
epost: string
}
kvittensEpost?: string[]
- proposedDividend?: number
/** User accepted the current avtalstext (avtalstextAndrad value). */
acceptedAvtalstextAndrad?: string
/** Upload even when kontrollera returns warn-level utfall (GUIDE §4.2.2). */
@@ -108,6 +113,13 @@ export type SubmitResult =
url: string
utfall: KontrolleraUtfall[]
}
+ | {
+ outcome: 'state_unknown'
+ submissionId: string
+ idnummer: string | null
+ url: string | null
+ message: string
+ }
interface ServiceDeps {
supabase: SupabaseClient
@@ -124,11 +136,15 @@ async function markSubmissionError(
log: ExtensionLogger,
submissionId: string,
err: unknown,
+ remoteUploadStarted = false,
): Promise {
const message = err instanceof Error ? err.message : String(err)
const { error } = await supabase
.from('arsredovisning_submissions')
- .update({ status: 'error', error_message: message.slice(0, 2_000) })
+ .update({
+ status: remoteUploadStarted ? 'unknown' : 'error',
+ error_message: message.slice(0, 2_000),
+ })
.eq('id', submissionId)
if (error) {
log.error('could not mark submission as error', { submissionId, dbError: error.message })
@@ -136,11 +152,12 @@ async function markSubmissionError(
}
async function getOrgnr(supabase: SupabaseClient, companyId: string): Promise {
- const { data } = await supabase
+ const { data, error } = await supabase
.from('company_settings')
.select('org_number')
.eq('company_id', companyId)
.maybeSingle()
+ if (error) throw new Error(`Kunde inte läsa organisationsnummer: ${error.message}`)
const orgNumber = (data as { org_number?: string } | null)?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas i företagsinställningarna.')
return normalizeOrgnr(orgNumber)
@@ -153,18 +170,55 @@ export async function submitArsredovisning(
const { supabase, client, log } = deps
const orgnr = await getOrgnr(supabase, params.companyId)
+ const { data: versionRow, error: versionError } = await supabase
+ .from('annual_report_versions')
+ .select(
+ 'id, status, content_hash, ixbrl_data, entry_point, taxonomy_version, validation_summary',
+ )
+ .eq('id', params.annualReportVersionId)
+ .eq('company_id', params.companyId)
+ .eq('fiscal_period_id', params.fiscalPeriodId)
+ .maybeSingle()
+ if (versionError || !versionRow) {
+ throw new BolagsverketSubmissionError(
+ 'BOLAGSVERKET_VERSION_NOT_FOUND',
+ 'The annual report version does not exist for this company and period.',
+ )
+ }
+ if (versionRow.status !== 'signed' || !versionRow.ixbrl_data) {
+ throw new BolagsverketSubmissionError(
+ 'BOLAGSVERKET_VERSION_NOT_SIGNED',
+ 'The annual report version must be signed and contain iXBRL before submission.',
+ { annual_report_version_id: versionRow.id, status: versionRow.status },
+ )
+ }
+ const validationSnapshot = versionRow.validation_summary as {
+ digital_filing_eligible?: boolean
+ digital_issues?: unknown[]
+ }
+ if (validationSnapshot.digital_filing_eligible !== true) {
+ throw new BolagsverketSubmissionError(
+ 'BOLAGSVERKET_DIGITAL_INELIGIBLE',
+ 'The immutable annual report version is not eligible for connected filing.',
+ { issues: validationSnapshot.digital_issues ?? [] },
+ )
+ }
+
// 0. Double-submission guard: once an upload reached Bolagsverket, a retry
// would file a second handling (and store a second audit document).
// Refuse while a submission for this period is still open with the
// authority. Rows in draft/kontrollerad/error/registrerad/avslutad do
- // not block: retries after failure create a fresh row.
- const { data: activeRows } = await supabase
+ // not block. A pre-upload retry reuses its idempotency row.
+ const { data: activeRows, error: activeSubmissionError } = await supabase
.from('arsredovisning_submissions')
.select('id, status')
.eq('company_id', params.companyId)
.eq('fiscal_period_id', params.fiscalPeriodId)
.in('status', [...ACTIVE_SUBMISSION_STATUSES])
.limit(1)
+ if (activeSubmissionError) {
+ throw new Error(`Kunde inte kontrollera tidigare inlämningar: ${activeSubmissionError.message}`)
+ }
const active = (activeRows as Array<{ id: string; status: string }> | null)?.[0]
if (active) {
throw new BolagsverketSubmissionError(
@@ -179,13 +233,16 @@ export async function submitArsredovisning(
// 2. Avtalstext gate (GUIDE §4.2): the user must have accepted THIS version
// of the text for THIS company before kontrollera/inlämning may run.
- const { data: acceptance } = await supabase
+ const { data: acceptance, error: acceptanceError } = await supabase
.from('bolagsverket_avtal_acceptances')
.select('id')
.eq('company_id', params.companyId)
.eq('user_id', params.userId)
.eq('avtalstext_andrad', token.avtalstextAndrad)
.maybeSingle()
+ if (acceptanceError) {
+ throw new Error(`Kunde inte kontrollera avtalsgodkännandet: ${acceptanceError.message}`)
+ }
const acceptedNow = params.acceptedAvtalstextAndrad === token.avtalstextAndrad
if (!acceptance && !acceptedNow) {
return {
@@ -195,23 +252,85 @@ export async function submitArsredovisning(
}
}
if (!acceptance && acceptedNow) {
- await supabase.from('bolagsverket_avtal_acceptances').insert({
+ const { error: acceptanceInsertError } = await supabase.from('bolagsverket_avtal_acceptances').insert({
company_id: params.companyId,
user_id: params.userId,
avtalstext_andrad: token.avtalstextAndrad,
})
+ if (acceptanceInsertError) {
+ throw new Error(`Kunde inte spara avtalsgodkännandet: ${acceptanceInsertError.message}`)
+ }
}
- // 3. Generate the iXBRL + local pre-flight (layer 1): cheaper than a
- // kontrollera round-trip and catches data problems with better messages.
- const input = await buildIxbrlInput(supabase, params.companyId, params.fiscalPeriodId, {
- proposedDividend: params.proposedDividend,
- undertecknare: {
- firstName: params.undertecknare.fornamn,
- lastName: params.undertecknare.efternamn,
- role: params.undertecknare.roll,
- },
+ // 3. Render from the immutable, signed version. Signature evidence is
+ // stored separately from the content snapshot and overlaid here.
+ const input = structuredClone(versionRow.ixbrl_data) as IxbrlArsredovisningInput
+ const { data: signatureRows, error: signatureError } = await supabase
+ .from('arsredovisning_signature_requests')
+ .select('signer_name, role, signed_at, status, signing_method, evidence_reference')
+ .eq('company_id', params.companyId)
+ .eq('fiscal_period_id', params.fiscalPeriodId)
+ .eq('annual_report_version_id', params.annualReportVersionId)
+ if (signatureError) {
+ throw new Error(`Failed to load signature evidence: ${signatureError.message}`)
+ }
+ const signatures = (signatureRows ?? []) as Array<{
+ signer_name: string
+ role: string
+ signed_at: string | null
+ status: string
+ signing_method: string | null
+ evidence_reference: string | null
+ }>
+ if (
+ signatures.length === 0 ||
+ signatures.some(
+ (signature) =>
+ signature.status !== 'signed' ||
+ !signature.signed_at ||
+ !signature.signing_method ||
+ !signature.evidence_reference,
+ )
+ ) {
+ throw new BolagsverketSubmissionError(
+ 'BOLAGSVERKET_SIGNATURE_EVIDENCE_INCOMPLETE',
+ 'Every required signer must have version-bound signature evidence.',
+ { annual_report_version_id: params.annualReportVersionId },
+ )
+ }
+ input.underskrifter.signers = signatures.map((signature) => {
+ const parts = signature.signer_name.trim().split(/\s+/)
+ return {
+ firstName: parts.length > 1 ? parts.slice(0, -1).join(' ') : parts[0],
+ lastName: parts.length > 1 ? parts.at(-1) ?? parts[0] : parts[0],
+ role: signature.role,
+ signedDate: signature.signed_at?.slice(0, 10) ?? null,
+ }
})
+ input.underskrifter.harVd = input.underskrifter.signers.some((signer) =>
+ /verkställande direktör|^vd$/i.test(signer.role ?? ''),
+ )
+ input.underskrifter.dateringsdatum = input.underskrifter.signers
+ .map((signer) => signer.signedDate)
+ .filter((date): date is string => date !== null)
+ .sort()
+ .at(-1) ?? null
+ const normalizeSignerText = (value: string) => value.trim().replace(/\s+/g, ' ').toLocaleLowerCase('sv')
+ if (
+ normalizeSignerText(input.faststallelseintyg.signerFirstName) !==
+ normalizeSignerText(params.undertecknare.fornamn) ||
+ normalizeSignerText(input.faststallelseintyg.signerLastName) !==
+ normalizeSignerText(params.undertecknare.efternamn) ||
+ normalizeSignerText(input.faststallelseintyg.signerRole) !==
+ normalizeSignerText(params.undertecknare.roll)
+ ) {
+ throw new BolagsverketSubmissionError(
+ 'BOLAGSVERKET_CERTIFICATE_SIGNER_MISMATCH',
+ 'The certificate signer must match the signer locked into the annual report version.',
+ { annual_report_version_id: params.annualReportVersionId },
+ )
+ }
+ input.faststallelseintyg.genereratDatum = new Date().toISOString().slice(0, 10)
const preflight = runPreflightChecks(input)
if (!preflight.ok) {
return { outcome: 'preflight_failed', issues: preflight.issues }
@@ -238,47 +357,148 @@ export async function submitArsredovisning(
})
}
- const fileBase64 = Buffer.from(xhtml, 'utf8').toString('base64')
-
- // 5. Create the submission row (draft) before talking to Bolagsverket so
- // every attempt is traceable.
- const { data: submissionRow, error: insertError } = await supabase
- .from('arsredovisning_submissions')
+ // Validate the final bytes after the optional checksum was embedded. The
+ // recorded artifact hash must identify the same XHTML that is archived and
+ // uploaded, otherwise the validation evidence cannot prove what was filed.
+ const artifactHash = createHash('sha256').update(xhtml).digest('hex')
+ const { error: localValidationError } = await supabase
+ .from('annual_report_validation_runs')
.insert({
+ company_id: params.companyId,
+ fiscal_period_id: params.fiscalPeriodId,
+ version_id: params.annualReportVersionId,
+ user_id: params.userId,
+ validation_layer: 'local',
+ status: 'passed',
+ validator_version: 'accounted-preflight-1',
+ artifact_hash: artifactHash,
+ issues: preflight.issues,
+ })
+ if (localValidationError) {
+ throw new Error(`Kunde inte spara lokal validering: ${localValidationError.message}`)
+ }
+
+ const arelle = await validateIxbrlWithArelle(xhtml)
+ const { error: arelleValidationError } = await supabase
+ .from('annual_report_validation_runs')
+ .insert({
+ company_id: params.companyId,
+ fiscal_period_id: params.fiscalPeriodId,
+ version_id: params.annualReportVersionId,
+ user_id: params.userId,
+ validation_layer: 'arelle',
+ status: arelle.status,
+ validator_version: arelle.validator_version,
+ artifact_hash: artifactHash,
+ issues: arelle.issues,
+ })
+ if (arelleValidationError) {
+ throw new Error(`Kunde inte spara Arelle-validering: ${arelleValidationError.message}`)
+ }
+ if (arelle.status === 'unavailable') {
+ throw new BolagsverketSubmissionError(
+ 'BOLAGSVERKET_ARELLE_UNAVAILABLE',
+ 'Arelle validation is unavailable. Connected filing is blocked.',
+ { issues: arelle.issues },
+ )
+ }
+ if (arelle.status === 'failed') {
+ throw new BolagsverketSubmissionError(
+ 'BOLAGSVERKET_ARELLE_FAILED',
+ 'Arelle found blocking taxonomy validation errors.',
+ { issues: arelle.issues },
+ )
+ }
+
+ const fileBase64 = Buffer.from(xhtml, 'utf8').toString('base64')
+ const requestKey = createHash('sha256')
+ .update(
+ `${params.companyId}:${params.annualReportVersionId}:${client.environment}:arsredovisning_komplett`,
+ )
+ .digest('hex')
+ const { data: existingRequest, error: existingRequestError } = await supabase
+ .from('arsredovisning_submissions')
+ .select('id, status')
+ .eq('company_id', params.companyId)
+ .eq('environment', client.environment)
+ .eq('request_key', requestKey)
+ .maybeSingle()
+ if (existingRequestError) {
+ throw new Error(`Kunde inte kontrollera inlämningens idempotensnyckel: ${existingRequestError.message}`)
+ }
+ const submissionPayload = {
company_id: params.companyId,
user_id: params.userId,
fiscal_period_id: params.fiscalPeriodId,
+ annual_report_version_id: params.annualReportVersionId,
+ request_key: requestKey,
handling_typ: 'arsredovisning_komplett',
- taxonomy_version: '2024-09-12',
+ taxonomy_version: versionRow.taxonomy_version,
entry_point: input.entryPointId,
environment: client.environment,
status: 'draft',
undertecknare_namn: `${params.undertecknare.fornamn} ${params.undertecknare.efternamn}`,
undertecknare_epost: params.undertecknare.epost,
- undertecknare_pnr_hash: hashPnr(params.companyId, params.undertecknare.pnr),
- avsandare_pnr_hash: hashPnr(params.companyId, params.avsandarePnr),
+ undertecknare_pnr_hash: null,
+ avsandare_pnr_hash: null,
kontrollsumma,
- })
- .select('id')
- .single()
- if (insertError || !submissionRow) {
- throw new Error(`Kunde inte spara inlämningsförsöket: ${insertError?.message ?? 'okänt fel'}`)
- }
- const submissionId = (submissionRow as { id: string }).id
+ error_message: null,
+ }
- // Steps 6-8 talk to Bolagsverket with a persisted row in play. Any failure
- // here must flip the row to status='error' with the message: otherwise it
- // sits in draft/kontrollerad forever and the failed attempt is invisible.
+ // 5. Persist before talking to Bolagsverket. A stopped kontrollera or a
+ // pre-upload error reuses the same idempotency row, which makes the
+ // explicit "continue despite warnings" action work without creating a
+ // second filing attempt for identical content.
+ let submissionRow: { id: string } | null = null
+ if (existingRequest) {
+ if (!['draft', 'kontrollerad', 'error'].includes(existingRequest.status)) {
+ throw new BolagsverketSubmissionError(
+ 'BOLAGSVERKET_SUBMISSION_EXISTS',
+ 'This exact annual report version already has a submission attempt.',
+ { submission_id: existingRequest.id, status: existingRequest.status },
+ )
+ }
+ const { data, error } = await supabase
+ .from('arsredovisning_submissions')
+ .update({ ...submissionPayload, status: 'draft' })
+ .eq('id', existingRequest.id)
+ .eq('company_id', params.companyId)
+ .select('id')
+ .single()
+ if (error || !data) {
+ throw new Error(`Kunde inte återuppta inlämningsförsöket: ${error?.message ?? 'okänt fel'}`)
+ }
+ submissionRow = data as { id: string }
+ } else {
+ const { data, error } = await supabase
+ .from('arsredovisning_submissions')
+ .insert({ ...submissionPayload, status: 'draft' })
+ .select('id')
+ .single()
+ if (error || !data) {
+ throw new Error(`Kunde inte spara inlämningsförsöket: ${error?.message ?? 'okänt fel'}`)
+ }
+ submissionRow = data as { id: string }
+ }
+ const submissionId = submissionRow.id
+
+ // Steps 6-8 talk to Bolagsverket with a persisted row in play. Failures
+ // before the external upload become error. Once upload starts, uncertain
+ // outcomes become unknown and retries are blocked until reconciled.
let svar: Awaited>
let utfall: KontrolleraUtfall[]
+ let remoteUploadStarted = false
try {
// 6. Kontrollera (layer 3): always run; surface utfall to the user.
const kontrollSvar = await client.kontrollera(token.token, fileBase64, 'arsredovisning_komplett')
utfall = kontrollSvar.utfall ?? []
- await supabase
+ const { error: kontrollUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({ status: 'kontrollerad', kontrollera_utfall: utfall })
.eq('id', submissionId)
+ if (kontrollUpdateError) {
+ throw new Error(`Kunde inte spara Bolagsverkets kontrollresultat: ${kontrollUpdateError.message}`)
+ }
const hasBlocking = utfall.some((item) => item.typ?.toLowerCase() === 'error')
if (utfall.length > 0 && (hasBlocking || !params.ignoreWarnings)) {
return { outcome: 'kontrollera_stopped', submissionId, utfall }
@@ -286,10 +506,10 @@ export async function submitArsredovisning(
// 7. Store the exact uploaded bytes as räkenskapsinformation (7-year
// retention, Accounting Guard Rail #7) BEFORE upload.
- let dokumentId: string | null = null
+ const buffer = Buffer.from(xhtml, 'utf8')
+ let doc: Awaited>
try {
- const buffer = Buffer.from(xhtml, 'utf8')
- const doc = await uploadDocument(
+ doc = await uploadDocument(
supabase,
params.userId,
params.companyId,
@@ -300,33 +520,69 @@ export async function submitArsredovisning(
},
{ upload_source: 'system' },
)
- dokumentId = doc.id
} catch (err) {
- // Storage failure must not block the filing, but it MUST be visible:
- // without the stored bytes the legally-filed document is not
- // reproducible from our archive (Guard Rail #7).
- dokumentId = null
const message = err instanceof Error ? err.message : String(err)
- log.error('failed to archive the filed .xhtml as räkenskapsinformation', {
+ await supabase
+ .from('arsredovisning_submissions')
+ .update({ archive_status: 'failed', error_message: message.slice(0, 2_000) })
+ .eq('id', submissionId)
+ throw new Error(`Dokumentarkivering misslyckades: ${message}`)
+ }
+ const dokumentId = doc.id
+ const { error: archiveUpdateError } = await supabase
+ .from('arsredovisning_submissions')
+ .update({ dokument_id: dokumentId, archive_status: 'stored' })
+ .eq('id', submissionId)
+ if (archiveUpdateError) {
+ throw new Error(`Kunde inte koppla det arkiverade dokumentet: ${archiveUpdateError.message}`)
+ }
+
+ // 8. Lämna in till eget utrymme.
+ const uploadStartedAt = new Date().toISOString()
+ const { error: sendingUpdateError } = await supabase
+ .from('arsredovisning_submissions')
+ .update({ status: 'sending', upload_started_at: uploadStartedAt })
+ .eq('id', submissionId)
+ if (sendingUpdateError) {
+ throw new Error(`Kunde inte låsa inlämningsförsöket före uppladdning: ${sendingUpdateError.message}`)
+ }
+ remoteUploadStarted = true
+ try {
+ svar = await client.lamnaIn(token.token, {
+ undertecknare: params.undertecknare.pnr,
+ epostadresser: [params.undertecknare.epost],
+ kvittensepostadresser: params.kvittensEpost,
+ fileBase64,
+ typ: 'arsredovisning_komplett',
+ })
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ await supabase
+ .from('arsredovisning_submissions')
+ .update({ status: 'unknown', error_message: message.slice(0, 2_000) })
+ .eq('id', submissionId)
+ log.error('Bolagsverket upload outcome is unknown: retry blocked', {
submissionId,
companyId: params.companyId,
error: message,
})
- await supabase
- .from('arsredovisning_submissions')
- .update({ error_message: `Dokumentarkivering misslyckades: ${message}`.slice(0, 2_000) })
- .eq('id', submissionId)
+ return {
+ outcome: 'state_unknown',
+ submissionId,
+ idnummer: null,
+ url: null,
+ message:
+ 'Bolagsverkets mottagande kunde inte bekräftas. Försök inte igen innan status har stämts av.',
+ }
}
- // 8. Lämna in till eget utrymme.
- svar = await client.lamnaIn(token.token, {
- undertecknare: params.undertecknare.pnr,
- epostadresser: [params.undertecknare.epost],
- kvittensepostadresser: params.kvittensEpost,
- fileBase64,
- typ: 'arsredovisning_komplett',
- })
-
+ const uploadedAt = new Date().toISOString()
+ const externalReceipt = {
+ idnummer: svar.handlingsinfo.idnummer,
+ sha256_checksumma: svar.handlingsinfo.sha256checksumma,
+ url: svar.url,
+ received_at: uploadedAt,
+ }
const { error: uploadUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({
@@ -335,18 +591,60 @@ export async function submitArsredovisning(
sha256_checksumma: svar.handlingsinfo.sha256checksumma,
bolagsverket_url: svar.url,
dokument_id: dokumentId,
- uploaded_at: new Date().toISOString(),
+ external_receipt: externalReceipt,
+ uploaded_at: uploadedAt,
})
.eq('id', submissionId)
if (uploadUpdateError) {
- log.error('failed to persist uploaded state after successful inlämning', {
+ log.error('failed to persist uploaded state after successful inlämning: marking unknown', {
submissionId,
idnummer: svar.handlingsinfo.idnummer,
dbError: uploadUpdateError.message,
})
+ const serviceClient = createServiceClientNoCookies()
+ const { error: recoveryError } = await serviceClient
+ .from('arsredovisning_submissions')
+ .update({
+ status: 'unknown',
+ idnummer: svar.handlingsinfo.idnummer,
+ sha256_checksumma: svar.handlingsinfo.sha256checksumma,
+ bolagsverket_url: svar.url,
+ dokument_id: dokumentId,
+ external_receipt: externalReceipt,
+ uploaded_at: uploadedAt,
+ error_message: `Uppladdningen lyckades men lokal status kunde inte bekräftas: ${uploadUpdateError.message}`.slice(0, 2_000),
+ })
+ .eq('id', submissionId)
+ if (recoveryError) {
+ log.error('could not persist unknown state after successful remote upload', {
+ submissionId,
+ dbError: recoveryError.message,
+ })
+ }
+ return {
+ outcome: 'state_unknown',
+ submissionId,
+ idnummer: svar.handlingsinfo.idnummer,
+ url: svar.url,
+ message:
+ 'Bolagsverket tog emot dokumentet men Accounted kunde inte bekräfta lokal status. Skicka inte igen.',
+ }
+ }
+ const { error: versionStatusError } = await supabase
+ .from('annual_report_versions')
+ .update({ status: 'filed' })
+ .eq('id', params.annualReportVersionId)
+ .eq('company_id', params.companyId)
+ .eq('status', 'signed')
+ if (versionStatusError) {
+ log.error('failed to mark annual report version as filed', {
+ submissionId,
+ annualReportVersionId: params.annualReportVersionId,
+ dbError: versionStatusError.message,
+ })
}
} catch (err) {
- await markSubmissionError(supabase, log, submissionId, err)
+ await markSubmissionError(supabase, log, submissionId, err, remoteUploadStarted)
throw err // preserved for the route's error mapping (5xx / upstream status)
}
@@ -490,16 +788,21 @@ export async function applyHandelse(
// submission for the company.
const base = serviceClient
.from('arsredovisning_submissions')
- .select('id, status, fiscal_period_id, user_id, company_id')
+ .select('id, status, fiscal_period_id, user_id, company_id, annual_report_version_id')
.eq('company_id', companyId)
const filtered =
idnummerList.length > 0
? base.in('idnummer', idnummerList)
- : base.in('status', ['uploaded', 'inkommen', 'forelagd', 'komplettering'])
+ : base.in('status', ['unknown', 'uploaded', 'inkommen', 'forelagd', 'komplettering'])
const { data: rows } = await filtered.order('created_at', { ascending: false }).limit(1)
const submission = (rows as Pick<
ArsredovisningSubmission,
- 'id' | 'status' | 'fiscal_period_id' | 'user_id' | 'company_id'
+ | 'id'
+ | 'status'
+ | 'fiscal_period_id'
+ | 'user_id'
+ | 'company_id'
+ | 'annual_report_version_id'
>[] | null)?.[0]
if (!submission) continue
if (submission.status === mapped) continue
@@ -538,6 +841,21 @@ export async function applyHandelse(
},
})
if (mapped === 'registrerad') {
+ if (submission.annual_report_version_id) {
+ const { error: versionError } = await serviceClient
+ .from('annual_report_versions')
+ .update({ status: 'registered' })
+ .eq('id', submission.annual_report_version_id)
+ .eq('company_id', companyId)
+ .eq('status', 'filed')
+ if (versionError) {
+ log?.warn('registered submission did not update annual report version', {
+ submissionId: submission.id,
+ versionId: submission.annual_report_version_id,
+ dbError: versionError.message,
+ })
+ }
+ }
await eventBus.emit({
type: 'arsredovisning.registered',
payload: {
diff --git a/extensions/general/bolagsverket/manifest.json b/extensions/general/bolagsverket/manifest.json
index f50712b9..50de64e5 100644
--- a/extensions/general/bolagsverket/manifest.json
+++ b/extensions/general/bolagsverket/manifest.json
@@ -9,7 +9,10 @@
"BOLAGSVERKET_ENV",
"BOLAGSVERKET_CLIENT_CERT",
"BOLAGSVERKET_CLIENT_KEY",
- "BOLAGSVERKET_CA"
+ "BOLAGSVERKET_CA",
+ "BOLAGSVERKET_FILING_ENABLED",
+ "BOLAGSVERKET_ARELLE_VALIDATOR_URL",
+ "BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN"
],
"npmDependencies": [],
"definition": {
diff --git a/extensions/general/bolagsverket/types.ts b/extensions/general/bolagsverket/types.ts
index fda1f092..0327493a 100644
--- a/extensions/general/bolagsverket/types.ts
+++ b/extensions/general/bolagsverket/types.ts
@@ -13,7 +13,7 @@ export type BolagsverketEnvironment = 'test' | 'accept' | 'prod'
export type HandlingTyp =
| 'arsredovisning_komplett'
- | 'arsredovisning'
+ | 'arsredovisning_kompletteras'
| 'revisionsberattelse'
// ---- hamta-arsredovisningsinformation/v1.4 ---------------------------------
@@ -147,7 +147,9 @@ export interface HamtaHandelserSvar {
export type SubmissionStatus =
| 'draft'
| 'kontrollerad'
+ | 'sending'
| 'uploaded'
+ | 'unknown'
| 'inkommen'
| 'forelagd'
| 'komplettering'
@@ -160,6 +162,7 @@ export interface ArsredovisningSubmission {
company_id: string
user_id: string
fiscal_period_id: string
+ annual_report_version_id: string | null
handling_typ: HandlingTyp
taxonomy_version: string
entry_point: string
diff --git a/extensions/general/cloud-backup/lib/__tests__/sync.test.ts b/extensions/general/cloud-backup/lib/__tests__/sync.test.ts
index 73fbdd66..87048bff 100644
--- a/extensions/general/cloud-backup/lib/__tests__/sync.test.ts
+++ b/extensions/general/cloud-backup/lib/__tests__/sync.test.ts
@@ -153,7 +153,7 @@ function makeSupabase(data: MockData) {
return resolve({ data: [], error: null })
},
}
- const passthrough = ['select', 'eq', 'in', 'order', 'range', 'limit']
+ const passthrough = ['select', 'eq', 'neq', 'in', 'order', 'range', 'limit']
for (const method of passthrough) {
chain[method] = vi.fn().mockImplementation((col?: string, val?: string) => {
if (method === 'eq' && col === 'key') key = val ?? null
@@ -162,7 +162,7 @@ function makeSupabase(data: MockData) {
}
return chain
})
- return { supabase: { from } as any, upsert }
+ return { supabase: { from } as any, from, upsert }
}
function syncParams(supabase: any, overrides: Record = {}) {
@@ -310,6 +310,22 @@ describe('performSync needs_reauth handling', () => {
})
describe('performSync per-fiscal-year layout', () => {
+ it('ignores extension runtime state when fingerprinting Grunddata', async () => {
+ const { supabase, from } = makeSupabase({
+ connection: makeConnection(),
+ lastSync: upToDateLastSync(),
+ })
+
+ await performSync(syncParams(supabase))
+
+ const auditCallIndex = from.mock.calls.findIndex(([table]) => table === 'audit_log')
+ expect(auditCallIndex).toBeGreaterThanOrEqual(0)
+ const auditQuery = from.mock.results[auditCallIndex].value
+ expect(auditQuery.neq).toHaveBeenCalledWith('table_name', 'extension_data')
+ expect(auditQuery.order).toHaveBeenNthCalledWith(1, 'created_at', { ascending: false })
+ expect(auditQuery.order).toHaveBeenNthCalledWith(2, 'id', { ascending: false })
+ })
+
it('uploads one archive per period plus Grunddata and the folder README on first sync', async () => {
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
diff --git a/extensions/general/cloud-backup/lib/sync.ts b/extensions/general/cloud-backup/lib/sync.ts
index a8a8fa93..3c70d755 100644
--- a/extensions/general/cloud-backup/lib/sync.ts
+++ b/extensions/general/cloud-backup/lib/sync.ts
@@ -494,12 +494,18 @@ async function fetchLatestAuditAt(
supabase: SupabaseClient,
companyId: string
): Promise {
- const { data } = await supabase
+ const { data, error } = await supabase
.from('audit_log')
.select('created_at')
.eq('company_id', companyId)
+ // extension_data is non-portable runtime state and includes this sync's
+ // own progress snapshots. Letting those rows advance the watermark makes
+ // every completed backup invalidate Grunddata for the next run.
+ .neq('table_name', 'extension_data')
.order('created_at', { ascending: false })
+ .order('id', { ascending: false })
.limit(1)
+ if (error) throw new Error(`Failed to fetch backup audit watermark: ${error.message}`)
const rows = (data as { created_at: string }[] | null) ?? []
return rows[0]?.created_at ?? ''
}
diff --git a/extensions/general/mcp-server/README.md b/extensions/general/mcp-server/README.md
index e98b2790..63e9d6ee 100644
--- a/extensions/general/mcp-server/README.md
+++ b/extensions/general/mcp-server/README.md
@@ -21,7 +21,7 @@ For internal Anthropic API usage (today only `extensions/general/invoice-inbox/l
## Payload-size watchdog
-`payload-size.bench.test.ts` enforces a `tools/list` JSON payload ceiling (currently 36,000 tokens, bumped from 32,000 when top-level `Tool.title` landed on all tools for Claude Connectors Directory readiness). If the test fires, the right answer is rarely "raise the ceiling". Instead, trim descriptions or leverage `gnubok_search_tools` (already deployed; tool definitions can defer to it for discovery rather than enumerating in `tools/list`).
+`payload-size.bench.test.ts` enforces a `tools/list` JSON payload ceiling. If the test fires, the right answer is rarely "raise the ceiling". Instead, trim descriptions or set specialized wide tools to `catalogVisibility: 'search'`. Those tools remain discoverable with full schemas through `gnubok_search_tools` and callable through `tools/call` without bloating the default catalog.
## Where things live
diff --git a/extensions/general/mcp-server/__tests__/annual-report-tools.test.ts b/extensions/general/mcp-server/__tests__/annual-report-tools.test.ts
new file mode 100644
index 00000000..9443df83
--- /dev/null
+++ b/extensions/general/mcp-server/__tests__/annual-report-tools.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
+import { listAnnualReportVersions } from '@/lib/bokslut/arsredovisning/version-service'
+import { tools } from '../server'
+
+vi.mock('@/lib/bokslut/arsredovisning/model', () => ({
+ buildCanonicalAnnualReport: vi.fn(),
+}))
+
+vi.mock('@/lib/bokslut/arsredovisning/version-service', () => ({
+ listAnnualReportVersions: vi.fn(),
+}))
+
+const annualReportToolNames = [
+ 'gnubok_preview_arsredovisning',
+ 'gnubok_validate_arsredovisning',
+ 'gnubok_list_arsredovisning_versions',
+ 'gnubok_get_arsredovisning_filing_status',
+] as const
+
+function tool(name: (typeof annualReportToolNames)[number]) {
+ const found = tools.find((candidate) => candidate.name === name)
+ if (!found) throw new Error(`Missing MCP tool ${name}`)
+ return found
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('annual report MCP tools', () => {
+ it('registers every annual report tool as read-only and scope protected', () => {
+ for (const name of annualReportToolNames) {
+ const registered = tool(name)
+ expect(registered.annotations).toMatchObject({
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ })
+ expect(TOOL_SCOPE_MAP[name]).toBe('reports:read')
+ }
+ })
+
+ it('uses signing validation before a report version is locked', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ vi.mocked(buildCanonicalAnnualReport).mockResolvedValue({
+ report: { accounting_framework: 'k2' },
+ profile: {},
+ eligibility: { k2_eligible: true },
+ validation: { ok: true, issues: [] },
+ } as never)
+
+ const result = await tool('gnubok_validate_arsredovisning').execute(
+ { fiscal_period_id: 'period-1', stage: 'signing' },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ )
+
+ expect(buildCanonicalAnnualReport).toHaveBeenCalledWith(
+ supabase,
+ 'company-1',
+ 'period-1',
+ { stage: 'signing', includeIxbrl: false },
+ )
+ expect(result).toMatchObject({ fiscal_period_id: 'period-1', framework: 'k2' })
+ })
+
+ it('lists immutable versions through the canonical version service', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ vi.mocked(listAnnualReportVersions).mockResolvedValue([
+ { id: 'version-1', version_number: 1, status: 'signed' },
+ ] as never)
+
+ const result = await tool('gnubok_list_arsredovisning_versions').execute(
+ { fiscal_period_id: 'period-1' },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ )
+
+ expect(listAnnualReportVersions).toHaveBeenCalledWith(
+ supabase,
+ 'company-1',
+ 'period-1',
+ )
+ expect(result).toMatchObject({
+ fiscal_period_id: 'period-1',
+ versions: [{ id: 'version-1', status: 'signed' }],
+ })
+ })
+
+ it('keeps Bolagsverket technical document ids out of filing-status output', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({
+ data: [
+ {
+ id: 'submission-1',
+ status: 'uploaded',
+ idnummer: 'technical-id',
+ bolagsverket_url: 'https://example.test/sign',
+ },
+ ],
+ error: null,
+ })
+
+ const result = await tool('gnubok_get_arsredovisning_filing_status').execute(
+ { fiscal_period_id: 'period-1' },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ ) as { submissions: Array> }
+
+ expect(result.submissions).toEqual([
+ {
+ id: 'submission-1',
+ status: 'uploaded',
+ bolagsverket_url: 'https://example.test/sign',
+ },
+ ])
+ })
+})
diff --git a/extensions/general/mcp-server/__tests__/company-settings-tools.test.ts b/extensions/general/mcp-server/__tests__/company-settings-tools.test.ts
new file mode 100644
index 00000000..355ce720
--- /dev/null
+++ b/extensions/general/mcp-server/__tests__/company-settings-tools.test.ts
@@ -0,0 +1,203 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
+import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
+import { tools } from '../server'
+
+const getTool = () => tools.find((tool) => tool.name === 'gnubok_get_company_settings')!
+const updateTool = () => tools.find((tool) => tool.name === 'gnubok_update_company_settings')!
+
+describe('company settings MCP tools: registration', () => {
+ it('registers the read and staged write tools with company scopes', () => {
+ expect(getTool()).toBeDefined()
+ expect(updateTool()).toBeDefined()
+ expect(getTool().annotations.readOnlyHint).toBe(true)
+ expect(getTool().catalogVisibility).toBe('search')
+ expect(updateTool().annotations.readOnlyHint).toBe(false)
+ expect(updateTool().annotations.idempotentHint).toBe(true)
+ expect(updateTool().catalogVisibility).toBe('search')
+ expect(TOOL_SCOPE_MAP.gnubok_get_company_settings).toBe('companies:read')
+ expect(TOOL_SCOPE_MAP.gnubok_update_company_settings).toBe('companies:write')
+ })
+
+ it('classifies payment-routing changes as medium risk', () => {
+ expect(OPERATION_RISK_TIERS.update_company_settings).toBe('medium')
+ })
+
+ it('uses strict top-level input schemas', () => {
+ expect(getTool().inputSchema.additionalProperties).toBe(false)
+ expect(updateTool().inputSchema.additionalProperties).toBe(false)
+ })
+
+ it('keeps both settings schemas discoverable through tool search', async () => {
+ const search = tools.find((tool) => tool.name === 'gnubok_search_tools')!
+ const readResult = (await search.execute(
+ {
+ query: 'get company settings',
+ detail: 'full',
+ __keyScopes: ['companies:read'],
+ },
+ 'company-1',
+ 'user-1',
+ {} as never,
+ )) as { tools: Array<{ name: string; inputSchema?: Record }> }
+ const writeResult = (await search.execute(
+ {
+ query: 'update company settings',
+ detail: 'full',
+ __keyScopes: ['companies:write'],
+ },
+ 'company-1',
+ 'user-1',
+ {} as never,
+ )) as { tools: Array<{ name: string; inputSchema?: Record }> }
+
+ expect(readResult.tools).toEqual([
+ expect.objectContaining({
+ name: 'gnubok_get_company_settings',
+ inputSchema: expect.any(Object),
+ }),
+ ])
+ expect(writeResult.tools).toEqual([
+ expect.objectContaining({
+ name: 'gnubok_update_company_settings',
+ inputSchema: expect.any(Object),
+ }),
+ ])
+ })
+})
+
+describe('gnubok_get_company_settings', () => {
+ it('returns payment details and maps the default reference to contact_person', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({
+ data: {
+ bank_name: 'Testbanken',
+ clearing_number: '1234',
+ account_number: '1234567',
+ bankgiro: '5050-1055',
+ plusgiro: null,
+ swish: '1231231231',
+ iban: null,
+ bic: null,
+ default_our_reference: 'Test Contact',
+ },
+ })
+
+ const result = await getTool().execute({}, 'company-1', 'user-1', supabase as never)
+
+ expect(result).toMatchObject({
+ company_id: 'company-1',
+ bankgiro: '5050-1055',
+ contact_person: 'Test Contact',
+ })
+ expect(supabase.from).toHaveBeenCalledWith('company_settings')
+ })
+
+ it('fails when the company has no settings row', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: null, error: null })
+
+ await expect(
+ getTool().execute({}, 'company-1', 'user-1', supabase as never),
+ ).rejects.toThrow(/not found/i)
+ })
+})
+
+describe('gnubok_update_company_settings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('rejects an empty change set before querying the database', async () => {
+ const { supabase } = createQueuedMockSupabase()
+
+ await expect(
+ updateTool().execute({ dry_run: true }, 'company-1', 'user-1', supabase as never),
+ ).rejects.toThrow(/at least one/i)
+ expect(supabase.from).not.toHaveBeenCalled()
+ })
+
+ it('rejects a Bankgiro number with an invalid check digit', async () => {
+ const { supabase } = createQueuedMockSupabase()
+
+ await expect(
+ updateTool().execute(
+ { bankgiro: '1234567', dry_run: true },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ ),
+ ).rejects.toThrow(/bankgiro/i)
+ expect(supabase.from).not.toHaveBeenCalled()
+ })
+
+ it('returns a merged dry-run preview without staging', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({
+ data: {
+ bank_name: 'Old Bank',
+ clearing_number: '1234',
+ account_number: '1234567',
+ bankgiro: null,
+ plusgiro: null,
+ swish: null,
+ iban: null,
+ bic: null,
+ default_our_reference: 'Old Contact',
+ },
+ })
+
+ const result = (await updateTool().execute(
+ { bankgiro: '5050-1055', contact_person: 'New Contact', dry_run: true },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ )) as {
+ staged: boolean
+ dry_run?: boolean
+ preview: { proposed?: Record }
+ }
+
+ expect(result.staged).toBe(false)
+ expect(result.dry_run).toBe(true)
+ expect(result.preview.proposed).toMatchObject({
+ bank_name: 'Old Bank',
+ bankgiro: '5050-1055',
+ contact_person: 'New Contact',
+ })
+ expect(supabase.from).toHaveBeenCalledTimes(1)
+ })
+
+ it('stages a validated update for approval', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({
+ data: {
+ bank_name: null,
+ clearing_number: null,
+ account_number: null,
+ bankgiro: null,
+ plusgiro: null,
+ swish: null,
+ iban: null,
+ bic: null,
+ default_our_reference: null,
+ },
+ })
+ enqueue({ data: { id: 'op-settings-1' } })
+
+ const result = (await updateTool().execute(
+ { contact_person: 'Test Contact' },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ )) as { staged: boolean; operation_id?: string; risk_level: string }
+
+ expect(result).toMatchObject({
+ staged: true,
+ operation_id: 'op-settings-1',
+ risk_level: 'medium',
+ })
+ expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
+ })
+})
diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
index a48a1203..df4f153f 100644
--- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
+++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { tools, deriveToolMeta } from '../server'
+import { tools, deriveToolMeta, isDefaultCatalogTool } from '../server'
import { projectToolInputSchema } from '../company-routing'
describe('tools/list payload size guard', () => {
@@ -7,7 +7,7 @@ describe('tools/list payload size guard', () => {
// Mirror the real tools/list serializer, including the derived staging
// _meta (requires_approval / approve_tool / preflight) merged over any
// literal _meta: otherwise the guard under-measures the wire payload.
- const projection = tools.map((t) => {
+ const projection = tools.filter(isDefaultCatalogTool).map((t) => {
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
return {
name: t.name,
diff --git a/extensions/general/mcp-server/__tests__/update-customer.test.ts b/extensions/general/mcp-server/__tests__/update-customer.test.ts
new file mode 100644
index 00000000..1fb06c09
--- /dev/null
+++ b/extensions/general/mcp-server/__tests__/update-customer.test.ts
@@ -0,0 +1,168 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
+import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
+import { tools } from '../server'
+
+const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
+const tool = () => tools.find((candidate) => candidate.name === 'gnubok_update_customer')!
+
+function currentCustomer(overrides: Record = {}) {
+ return {
+ id: CUSTOMER_ID,
+ name: 'Test Customer AB',
+ customer_type: 'swedish_business',
+ customer_number: '1001',
+ email: 'billing@example.test',
+ phone: '',
+ address_line1: 'Testgatan 1',
+ address_line2: null,
+ postal_code: '12345',
+ city: 'Teststad',
+ country: 'Sweden',
+ org_number: '556000-0000',
+ vat_number: null,
+ vat_number_validated: false,
+ language: 'sv',
+ default_payment_terms: 30,
+ notes: null,
+ ...overrides,
+ }
+}
+
+describe('gnubok_update_customer: registration', () => {
+ it('is a strict, staged customers:write tool at low risk', () => {
+ expect(tool()).toBeDefined()
+ expect(tool().inputSchema.additionalProperties).toBe(false)
+ expect(tool().annotations.readOnlyHint).toBe(false)
+ expect(tool().annotations.idempotentHint).toBe(true)
+ expect(tool().catalogVisibility).toBe('search')
+ expect(TOOL_SCOPE_MAP.gnubok_update_customer).toBe('customers:write')
+ expect(OPERATION_RISK_TIERS.update_customer).toBe('low')
+ })
+
+ it('does not expose personal_number as an input', () => {
+ const properties = tool().inputSchema.properties as Record
+ expect(properties).not.toHaveProperty('personal_number')
+ })
+
+ it('keeps the wide write schema discoverable through tool search', async () => {
+ const search = tools.find((candidate) => candidate.name === 'gnubok_search_tools')!
+ const result = (await search.execute(
+ {
+ query: 'update customer',
+ detail: 'full',
+ __keyScopes: ['customers:write'],
+ },
+ 'company-1',
+ 'user-1',
+ {} as never,
+ )) as { tools: Array<{ name: string; inputSchema?: Record }> }
+
+ expect(result.tools).toEqual([
+ expect.objectContaining({
+ name: 'gnubok_update_customer',
+ inputSchema: expect.any(Object),
+ }),
+ ])
+ })
+})
+
+describe('gnubok_update_customer: validation and staging', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('requires at least one changed field', async () => {
+ const { supabase } = createQueuedMockSupabase()
+
+ await expect(
+ tool().execute(
+ { customer_id: CUSTOMER_ID, dry_run: true },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ ),
+ ).rejects.toThrow(/at least one/i)
+ expect(supabase.from).not.toHaveBeenCalled()
+ })
+
+ it('rejects malformed email before querying the database', async () => {
+ const { supabase } = createQueuedMockSupabase()
+
+ await expect(
+ tool().execute(
+ { customer_id: CUSTOMER_ID, email: 'not-an-email', dry_run: true },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ ),
+ ).rejects.toThrow(/email/i)
+ expect(supabase.from).not.toHaveBeenCalled()
+ })
+
+ it('fails when the customer is outside the selected company', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: null })
+
+ await expect(
+ tool().execute(
+ { customer_id: CUSTOMER_ID, city: 'New City', dry_run: true },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ ),
+ ).rejects.toThrow(/not found/i)
+ })
+
+ it('returns a merged dry-run preview without staging', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: currentCustomer() })
+
+ const result = (await tool().execute(
+ {
+ customer_id: CUSTOMER_ID,
+ city: 'New City',
+ default_payment_terms: 14,
+ dry_run: true,
+ },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ )) as {
+ staged: boolean
+ dry_run?: boolean
+ preview: { proposed?: Record }
+ }
+
+ expect(result.staged).toBe(false)
+ expect(result.dry_run).toBe(true)
+ expect(result.preview.proposed).toMatchObject({
+ customer_id: CUSTOMER_ID,
+ name: 'Test Customer AB',
+ city: 'New City',
+ default_payment_terms: 14,
+ })
+ expect(supabase.from).toHaveBeenCalledTimes(1)
+ })
+
+ it('stages the partial update for approval', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: currentCustomer() })
+ enqueue({ data: { id: 'op-customer-1' } })
+
+ const result = (await tool().execute(
+ { customer_id: CUSTOMER_ID, phone: '0701234567' },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ )) as { staged: boolean; operation_id?: string; risk_level: string }
+
+ expect(result).toMatchObject({
+ staged: true,
+ operation_id: 'op-customer-1',
+ risk_level: 'low',
+ })
+ expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
+ })
+})
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index 1aa491cc..2aeb512e 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -43,6 +43,8 @@ import { accountClassTypeConflict } from '@/lib/pending-operations/schemas/accou
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions'
+import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
+import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
import {
ensureCompanyDimensions,
fetchDimensionRegistry,
@@ -185,6 +187,8 @@ interface McpTool {
inputSchema: Record
outputSchema?: Record
annotations: McpToolAnnotations
+ /** Wide or specialized tools discoverable through gnubok_search_tools only. */
+ catalogVisibility?: 'default' | 'search'
_meta?: { ui: { resourceUri: string } }
// Result-level UI hint: when set, a call passing render_ui=true gets a
// _meta.ui.resourceUri on the RESULT, so the host renders the widget only when
@@ -897,6 +901,10 @@ export function deriveToolMeta(t: { name: string; outputSchema?: Record = { type: 'object' }) {
return {
type: 'object',
@@ -1680,12 +1688,12 @@ export const tools: McpTool[] = [
{
name: 'gnubok_search_tools',
title: 'Search MCP Tools',
- description: 'Search Accounted MCP tools by keyword and return their schemas at a chosen detail level. Call this first when looking for a capability: avoids loading every tool schema upfront.',
+ description: 'Search available tools by keyword and choose the returned schema detail level.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
- query: { type: 'string', description: 'Keywords matched against tool name + description (e.g. "vat", "invoice", "categorize"). Empty string returns all tools.' },
+ query: { type: 'string', description: 'Keywords matched against tool names and descriptions. Empty returns all tools.' },
detail: { type: 'string', enum: ['name', 'summary', 'full'], description: 'Detail level. name: just names. summary: name + description + scope (default). full: complete schema including inputSchema and outputSchema.' },
scope: { type: 'string', description: 'Optional filter: only tools requiring this API key scope (e.g. "invoices:write").' },
limit: { type: 'number', description: 'Max results, 1-50 (default 20).' },
@@ -1901,6 +1909,179 @@ export const tools: McpTool[] = [
},
},
+ {
+ name: 'gnubok_get_company_settings',
+ title: 'Get Company Settings',
+ description: 'Get invoice payment details and the default company contact person. Use before creating invoices or staging a settings update.',
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {},
+ },
+ outputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ company_id: { type: 'string' },
+ bank_name: { type: ['string', 'null'] },
+ clearing_number: { type: ['string', 'null'] },
+ account_number: { type: ['string', 'null'] },
+ bankgiro: { type: ['string', 'null'] },
+ plusgiro: { type: ['string', 'null'] },
+ swish: { type: ['string', 'null'] },
+ iban: { type: ['string', 'null'] },
+ bic: { type: ['string', 'null'] },
+ contact_person: { type: ['string', 'null'], description: 'Default Our reference value on new invoices.' },
+ },
+ required: [
+ 'company_id',
+ 'bank_name',
+ 'clearing_number',
+ 'account_number',
+ 'bankgiro',
+ 'plusgiro',
+ 'swish',
+ 'iban',
+ 'bic',
+ 'contact_person',
+ ],
+ },
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ catalogVisibility: 'search',
+ async execute(_args, companyId, _userId, supabase) {
+ const { data, error } = await supabase
+ .from('company_settings')
+ .select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference')
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (error) throw new Error(`Database error: ${error.message}`)
+ if (!data) throw new Error('Company settings not found.')
+
+ return {
+ company_id: companyId,
+ bank_name: data.bank_name ?? null,
+ clearing_number: data.clearing_number ?? null,
+ account_number: data.account_number ?? null,
+ bankgiro: data.bankgiro ?? null,
+ plusgiro: data.plusgiro ?? null,
+ swish: data.swish ?? null,
+ iban: data.iban ?? null,
+ bic: data.bic ?? null,
+ contact_person: data.default_our_reference ?? null,
+ }
+ },
+ },
+
+ {
+ name: 'gnubok_update_company_settings',
+ title: 'Update Company Settings',
+ description: 'Stage changes to invoice payment details or the default company contact person. Requires approval before company settings are updated.',
+ outputSchema: STAGED_OPERATION_SCHEMA,
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ bank_name: { type: 'string', maxLength: 100 },
+ clearing_number: { type: 'string', description: '4-5 digits. Empty string clears the value.' },
+ account_number: { type: 'string', description: '6-12 digits. Empty string clears the value.' },
+ bankgiro: { type: ['string', 'null'], description: 'Valid 7-8 digit Bankgiro with Luhn check digit. Null or empty string clears it.' },
+ plusgiro: { type: ['string', 'null'], description: 'Valid Plusgiro with hyphen and Luhn check digit. Null or empty string clears it.' },
+ swish: { type: ['string', 'null'], description: 'Swedish business or mobile Swish number. Null clears it.' },
+ iban: { type: ['string', 'null'], description: 'Swedish IBAN: SE followed by 22 digits. Null or empty string clears it.' },
+ bic: { type: ['string', 'null'], description: '8 or 11 character BIC/SWIFT. Null or empty string clears it.' },
+ contact_person: { type: ['string', 'null'], maxLength: 200, description: 'Default Our reference value on new invoices. Null clears it.' },
+ dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' },
+ idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' },
+ },
+ },
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ catalogVisibility: 'search',
+ async execute(args, companyId, userId, supabase, actor) {
+ const rawChanges: Record = {}
+ for (const key of [
+ 'bank_name',
+ 'clearing_number',
+ 'account_number',
+ 'bankgiro',
+ 'plusgiro',
+ 'swish',
+ 'iban',
+ 'bic',
+ ]) {
+ if (args[key] !== undefined) rawChanges[key] = args[key]
+ }
+ if (args.contact_person !== undefined) {
+ rawChanges.default_our_reference = args.contact_person
+ }
+
+ const parsed = UpdateCompanySettingsParamsSchema.safeParse({ changes: rawChanges })
+ if (!parsed.success) {
+ const issue = parsed.error.issues[0]
+ throw new Error(`Invalid company settings: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`)
+ }
+
+ const { data: current, error } = await supabase
+ .from('company_settings')
+ .select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference')
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (error) throw new Error(`Database error: ${error.message}`)
+ if (!current) throw new Error('Company settings not found.')
+
+ const currentPreview = {
+ company_id: companyId,
+ bank_name: current.bank_name ?? null,
+ clearing_number: current.clearing_number ?? null,
+ account_number: current.account_number ?? null,
+ bankgiro: current.bankgiro ?? null,
+ plusgiro: current.plusgiro ?? null,
+ swish: current.swish ?? null,
+ iban: current.iban ?? null,
+ bic: current.bic ?? null,
+ contact_person: current.default_our_reference ?? null,
+ }
+ const previewChanges = {
+ ...parsed.data.changes,
+ ...(parsed.data.changes.default_our_reference !== undefined
+ ? { contact_person: parsed.data.changes.default_our_reference }
+ : {}),
+ }
+ delete (previewChanges as Record).default_our_reference
+
+ return stagePendingOperation(
+ supabase,
+ companyId,
+ userId,
+ 'update_company_settings',
+ 'Uppdatera företagsinställningar',
+ parsed.data,
+ {
+ current: currentPreview,
+ changes: previewChanges,
+ proposed: { ...currentPreview, ...previewChanges },
+ },
+ actor,
+ undefined,
+ {
+ dryRun: Boolean(args.dry_run),
+ idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
+ },
+ )
+ },
+ },
+
{
name: 'gnubok_list_skills',
title: 'List Domain Skills',
@@ -3471,6 +3652,130 @@ export const tools: McpTool[] = [
},
},
+ {
+ name: 'gnubok_update_customer',
+ title: 'Update Customer',
+ description: 'Stage a partial update to an existing customer. Find customer_id with gnubok_list_customers. Requires approval before customer data is changed.',
+ outputSchema: STAGED_OPERATION_SCHEMA,
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ customer_id: { type: 'string', description: 'UUID from gnubok_list_customers.' },
+ name: { type: 'string', minLength: 1 },
+ customer_type: {
+ type: 'string',
+ enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'],
+ description: 'Changing an individual customer to a business type clears its stored personal number.',
+ },
+ customer_number: { type: ['string', 'null'], maxLength: 32, description: 'Null or empty string clears the customer number.' },
+ email: { type: 'string', format: 'email' },
+ phone: { type: 'string' },
+ address_line1: { type: 'string' },
+ address_line2: { type: 'string' },
+ postal_code: { type: 'string' },
+ city: { type: 'string' },
+ country: { type: 'string' },
+ org_number: { type: 'string' },
+ vat_number: { type: 'string', description: 'EU VAT numbers are revalidated with VIES when the update is approved.' },
+ language: { type: 'string', enum: ['sv', 'en'] },
+ default_payment_terms: { type: 'integer', minimum: 1 },
+ notes: { type: 'string' },
+ dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' },
+ idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' },
+ },
+ required: ['customer_id'],
+ },
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ catalogVisibility: 'search',
+ async execute(args, companyId, userId, supabase, actor) {
+ const changes: Record = {}
+ for (const key of [
+ 'name',
+ 'customer_type',
+ 'customer_number',
+ 'email',
+ 'phone',
+ 'address_line1',
+ 'address_line2',
+ 'postal_code',
+ 'city',
+ 'country',
+ 'org_number',
+ 'vat_number',
+ 'language',
+ 'default_payment_terms',
+ 'notes',
+ ]) {
+ if (args[key] !== undefined) changes[key] = args[key]
+ }
+
+ const parsed = UpdateCustomerParamsSchema.safeParse({
+ customer_id: args.customer_id,
+ changes,
+ })
+ if (!parsed.success) {
+ const issue = parsed.error.issues[0]
+ throw new Error(`Invalid customer update: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`)
+ }
+
+ const { data: current, error } = await supabase
+ .from('customers')
+ .select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes')
+ .eq('id', parsed.data.customer_id)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (error) throw new Error(`Database error: ${error.message}`)
+ if (!current) throw new Error('Customer not found.')
+
+ const currentPreview = {
+ customer_id: current.id,
+ name: current.name,
+ customer_type: current.customer_type,
+ customer_number: current.customer_number ?? null,
+ email: current.email ?? null,
+ phone: current.phone ?? null,
+ address_line1: current.address_line1 ?? null,
+ address_line2: current.address_line2 ?? null,
+ postal_code: current.postal_code ?? null,
+ city: current.city ?? null,
+ country: current.country,
+ org_number: current.org_number ?? null,
+ vat_number: current.vat_number ?? null,
+ vat_number_validated: current.vat_number_validated ?? false,
+ language: current.language ?? 'sv',
+ default_payment_terms: current.default_payment_terms,
+ notes: current.notes ?? null,
+ }
+
+ return stagePendingOperation(
+ supabase,
+ companyId,
+ userId,
+ 'update_customer',
+ `Uppdatera kund: ${current.name}`,
+ parsed.data,
+ {
+ current: currentPreview,
+ changes: parsed.data.changes,
+ proposed: { ...currentPreview, ...parsed.data.changes },
+ },
+ actor,
+ undefined,
+ {
+ dryRun: Boolean(args.dry_run),
+ idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
+ },
+ )
+ },
+ },
+
// ── Article tools (artikelregister) ──────────────────────────
{
@@ -12621,7 +12926,7 @@ export const tools: McpTool[] = [
name: 'gnubok_preview_arsredovisning',
title: 'Preview Annual Report (Årsredovisning)',
description:
- 'Read-only K2 årsredovisning preview for a fiscal period. Returns flerårsöversikt, eget-kapital-förändring, RR, BR, K2 noter, signature slots. PDF download is via UI.',
+ 'Read-only annual report preview from the canonical model. Returns report content, eligibility, compliance blockers, and capabilities. PDF and immutable versions are available in the UI.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -12635,8 +12940,144 @@ export const tools: McpTool[] = [
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
- const { buildArsredovisningData } = await import('@/lib/bokslut/arsredovisning/build-data')
- return buildArsredovisningData(supabase, companyId, fiscalPeriodId)
+ const { buildCanonicalAnnualReport } = await import('@/lib/bokslut/arsredovisning/model')
+ const { getAnnualReportCapabilities } = await import(
+ '@/lib/bokslut/arsredovisning/capabilities'
+ )
+ const model = await buildCanonicalAnnualReport(supabase, companyId, fiscalPeriodId, {
+ stage: 'draft',
+ includeIxbrl: false,
+ })
+ return {
+ schema_version: model.schema_version,
+ generated_at: model.generated_at,
+ report: model.report,
+ profile: model.profile,
+ disclosures: model.disclosures,
+ eligibility: model.eligibility,
+ validation: model.validation,
+ capabilities: getAnnualReportCapabilities(
+ model.report.accounting_framework,
+ model.eligibility,
+ ),
+ }
+ },
+ },
+
+ {
+ name: 'gnubok_validate_arsredovisning',
+ title: 'Validate Annual Report (Årsredovisning)',
+ description:
+ 'Read-only compliance validation for an annual report. Use draft while editing, signing before locking a version, and filing before a Bolagsverket submission.',
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
+ stage: {
+ type: 'string',
+ enum: ['draft', 'signing', 'filing'],
+ description: 'Validation strictness. Default: draft',
+ },
+ },
+ required: ['fiscal_period_id'],
+ },
+ outputSchema: { type: 'object', additionalProperties: true },
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+ async execute(args, companyId, _userId, supabase, _actor) {
+ const fiscalPeriodId = args.fiscal_period_id as string
+ if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
+ const stage = (args.stage as 'draft' | 'signing' | 'filing' | undefined) ?? 'draft'
+ const { buildCanonicalAnnualReport } = await import('@/lib/bokslut/arsredovisning/model')
+ const model = await buildCanonicalAnnualReport(supabase, companyId, fiscalPeriodId, {
+ stage,
+ includeIxbrl: stage === 'filing',
+ })
+ return {
+ fiscal_period_id: fiscalPeriodId,
+ framework: model.report.accounting_framework,
+ profile: model.profile,
+ eligibility: model.eligibility,
+ validation: model.validation,
+ }
+ },
+ },
+
+ {
+ name: 'gnubok_list_arsredovisning_versions',
+ title: 'List Annual Report Versions',
+ description:
+ 'Read-only list of immutable annual report versions with content hashes, taxonomy versions, and signing or filing status.',
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
+ },
+ required: ['fiscal_period_id'],
+ },
+ outputSchema: { type: 'object', additionalProperties: true },
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+ async execute(args, companyId, _userId, supabase, _actor) {
+ const fiscalPeriodId = args.fiscal_period_id as string
+ if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
+ const { listAnnualReportVersions } = await import(
+ '@/lib/bokslut/arsredovisning/version-service'
+ )
+ const versions = await listAnnualReportVersions(supabase, companyId, fiscalPeriodId)
+ return { fiscal_period_id: fiscalPeriodId, versions }
+ },
+ },
+
+ {
+ name: 'gnubok_get_arsredovisning_filing_status',
+ title: 'Get Annual Report Filing Status',
+ description:
+ 'Read-only filing history for a fiscal period, including uncertain upload states that must be reconciled before retrying.',
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
+ },
+ required: ['fiscal_period_id'],
+ },
+ outputSchema: { type: 'object', additionalProperties: true },
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+ async execute(args, companyId, _userId, supabase, _actor) {
+ const fiscalPeriodId = args.fiscal_period_id as string
+ if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
+ const { data, error } = await supabase
+ .from('arsredovisning_submissions')
+ .select(
+ 'id, annual_report_version_id, handling_typ, environment, status, archive_status, bolagsverket_url, error_message, uploaded_at, registered_at, created_at',
+ )
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .order('created_at', { ascending: false })
+ .limit(50)
+ if (error) throw new Error(`Failed to list annual report filings: ${error.message}`)
+ const publicFields = [
+ 'id',
+ 'annual_report_version_id',
+ 'handling_typ',
+ 'environment',
+ 'status',
+ 'archive_status',
+ 'bolagsverket_url',
+ 'error_message',
+ 'uploaded_at',
+ 'registered_at',
+ 'created_at',
+ ] as const
+ const submissions = (data ?? []).map((submission) =>
+ Object.fromEntries(
+ publicFields.flatMap((field) =>
+ field in submission ? [[field, submission[field]]] : [],
+ ),
+ ),
+ )
+ return { fiscal_period_id: fiscalPeriodId, submissions }
},
},
@@ -13507,7 +13948,7 @@ export async function handleMcpRequest(request: Request): Promise {
'Accounted: Swedish double-entry bookkeeping via conversation.',
'',
'Discovery:',
- '• tools/list returns the full schema for every tool. To narrow a large catalog, call gnubok_search_tools(query="…"): it ranks tools by relevance; pass detail="name"|"summary"|"full" to control payload size.',
+ '• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.',
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
@@ -13545,6 +13986,7 @@ export async function handleMcpRequest(request: Request): Promise {
case 'tools/list': {
const listStartedAt = Date.now()
const allowedTools = tools.filter((t) => {
+ if (!isDefaultCatalogTool(t)) return false
const required = TOOL_SCOPE_MAP[t.name]
return !required || hasScope(keyScopes, required)
})
diff --git a/extensions/general/skatteverket/__tests__/audit.test.ts b/extensions/general/skatteverket/__tests__/audit.test.ts
new file mode 100644
index 00000000..7202c4c8
--- /dev/null
+++ b/extensions/general/skatteverket/__tests__/audit.test.ts
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const createServiceClientMock = vi.fn()
+
+vi.mock('@/lib/supabase/server', () => ({
+ createServiceClient: () => createServiceClientMock(),
+}))
+
+import { writeSkatteverketAudit } from '../lib/audit'
+
+describe('writeSkatteverketAudit', () => {
+ const insert = vi.fn()
+ const from = vi.fn(() => ({ insert }))
+ const userFrom = vi.fn()
+ const log = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ insert.mockResolvedValue({ error: null })
+ createServiceClientMock.mockReturnValue({ from })
+ })
+
+ it('writes through the service-role client while preserving tenant metadata', async () => {
+ await writeSkatteverketAudit(
+ {
+ companyId: 'company-1',
+ userId: 'user-1',
+ supabase: { from: userFrom },
+ log,
+ } as never,
+ {
+ endpoint: '/momsdeklarationer',
+ outcome: 'ok',
+ responseStatus: 200,
+ correlationId: 'corr-1',
+ },
+ )
+
+ expect(createServiceClientMock).toHaveBeenCalledOnce()
+ expect(userFrom).not.toHaveBeenCalled()
+ expect(from).toHaveBeenCalledWith('skatteverket_api_audit_log')
+ expect(insert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ company_id: 'company-1',
+ user_id: 'user-1',
+ endpoint: '/momsdeklarationer',
+ outcome: 'ok',
+ correlation_id: 'corr-1',
+ }),
+ )
+ expect(log.error).not.toHaveBeenCalled()
+ })
+
+ it('keeps the primary regulator flow alive and logs insert failures with correlation data', async () => {
+ insert.mockResolvedValue({ error: { message: 'database unavailable' } })
+
+ await writeSkatteverketAudit(
+ {
+ companyId: 'company-1',
+ userId: 'user-1',
+ supabase: { from: userFrom },
+ log,
+ } as never,
+ {
+ endpoint: '/momsdeklarationer',
+ outcome: 'internal_error',
+ correlationId: 'corr-2',
+ },
+ )
+
+ expect(log.error).toHaveBeenCalledWith(
+ 'skatteverket_api_audit_log insert failed',
+ expect.objectContaining({
+ endpoint: '/momsdeklarationer',
+ outcome: 'internal_error',
+ correlationId: 'corr-2',
+ error: 'database unavailable',
+ }),
+ )
+ })
+})
diff --git a/extensions/general/skatteverket/lib/audit.ts b/extensions/general/skatteverket/lib/audit.ts
index bfa18161..e494d253 100644
--- a/extensions/general/skatteverket/lib/audit.ts
+++ b/extensions/general/skatteverket/lib/audit.ts
@@ -1,4 +1,5 @@
import type { ExtensionContext } from '@/lib/extensions/types'
+import { createServiceClient } from '@/lib/supabase/server'
/**
* Append an immutable row to skatteverket_api_audit_log. Errors are
@@ -28,7 +29,11 @@ export async function writeSkatteverketAudit(
},
): Promise {
try {
- const { error } = await ctx.supabase
+ // This table intentionally has no authenticated INSERT policy. Use the
+ // server-only service client so callers cannot fabricate regulator audit
+ // rows through their user session.
+ const auditClient = createServiceClient()
+ const { error } = await auditClient
.from('skatteverket_api_audit_log')
.insert({
company_id: ctx.companyId,
@@ -47,12 +52,14 @@ export async function writeSkatteverketAudit(
ctx.log.error('skatteverket_api_audit_log insert failed', {
endpoint: fields.endpoint,
outcome: fields.outcome,
+ correlationId: fields.correlationId ?? null,
error: error.message,
})
}
} catch (err) {
ctx.log.error('skatteverket_api_audit_log insert threw', {
endpoint: fields.endpoint,
+ correlationId: fields.correlationId ?? null,
error: err instanceof Error ? err.message : String(err),
})
}
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index c2834864..d6e398e6 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -262,8 +262,52 @@ export const TaxDeadlineTypeSchema = z.enum([
'arsredovisning',
'arsstamma',
'periodisk_sammanstallning',
+ 'kvarskatt',
])
+export const TaxAssessmentDecisionTypeSchema = z.enum(['final', 'reassessment'])
+
+export const CreateTaxAssessmentNoticeSchema = z
+ .object({
+ fiscal_period_id: uuid,
+ decision_type: TaxAssessmentDecisionTypeSchema,
+ decision_date: saneIsoDate,
+ payment_due_date: saneIsoDate,
+ })
+ .refine((data) => data.payment_due_date >= data.decision_date, {
+ message: 'Förfallodagen får inte vara tidigare än beslutsdagen',
+ path: ['payment_due_date'],
+ })
+
+export const UpdateTaxAssessmentNoticeSchema = z
+ .object({
+ fiscal_period_id: uuid.optional(),
+ decision_type: TaxAssessmentDecisionTypeSchema.optional(),
+ decision_date: saneIsoDate.optional(),
+ payment_due_date: saneIsoDate.optional(),
+ archived: z.boolean().optional(),
+ })
+ .refine((data) => Object.values(data).some((value) => value !== undefined), {
+ message: 'Minst ett fält måste anges',
+ })
+ .refine(
+ (data) => !data.decision_date || !data.payment_due_date || data.payment_due_date >= data.decision_date,
+ {
+ message: 'Förfallodagen får inte vara tidigare än beslutsdagen',
+ path: ['payment_due_date'],
+ },
+ )
+
+export const UpdateInitialSetupStateSchema = z
+ .object({
+ path: z.enum(['migration', 'bank', 'fresh']).nullable().optional(),
+ completed: z.boolean().optional(),
+ dismissed: z.boolean().optional(),
+ })
+ .refine((data) => Object.values(data).some((value) => value !== undefined), {
+ message: 'Minst ett fält måste anges',
+ })
+
export const DeadlineSourceSchema = z.enum(['system', 'user'])
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
@@ -1574,7 +1618,9 @@ export const UpdateSettingsSchema = z.object({
.string()
.regex(/^#[0-9A-Fa-f]{6}$/, 'Ange en giltig hex-färg (#RRGGBB)')
.optional(),
- invoice_font_family: z.enum(['Helvetica', 'Times-Roman', 'Courier']).optional(),
+ invoice_font_family: z
+ .enum(['Helvetica', 'Times-Roman', 'Courier', 'Source Sans 3', 'Source Serif 4', 'Custom'])
+ .optional(),
invoice_header_text: z.string().max(200).nullable().optional(),
invoice_footer_text: z.string().max(500).nullable().optional(),
// Automation
diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts
index d37e0fb6..0074140d 100644
--- a/lib/auth/api-keys.ts
+++ b/lib/auth/api-keys.ts
@@ -10,7 +10,7 @@ export const API_KEY_SCOPES = {
'transactions:read': { label: 'Transaktioner: läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' },
'transactions:write': { label: 'Transaktioner: skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' },
'customers:read': { label: 'Kunder: läs', description: 'Lista kunder (1 verktyg)' },
- 'customers:write': { label: 'Kunder: skriv', description: 'Skapa kunder (1 verktyg)' },
+ 'customers:write': { label: 'Kunder: skriv', description: 'Skapa och uppdatera kunder (2 verktyg)' },
'articles:read': { label: 'Artiklar: läs', description: 'Lista artiklar i artikelregistret (1 verktyg)' },
'articles:write': { label: 'Artiklar: skriv', description: 'Skapa och uppdatera artiklar (2 verktyg)' },
'invoices:read': { label: 'Fakturor: läs', description: 'Lista fakturor (1 verktyg)' },
@@ -23,6 +23,7 @@ export const API_KEY_SCOPES = {
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
// v1 REST API: added Phase 1
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
+ 'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg' },
'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
@@ -119,6 +120,7 @@ export const STAGING_SCOPES: ApiKeyScope[] = [
'bookkeeping:write',
'payroll:write',
'documents:write',
+ 'companies:write',
// Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a
// key holding both this and pending_operations:approve is a SoD conflict:
// findStageApproveConflict picks it up automatically from this list.
@@ -143,6 +145,7 @@ export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | n
/** Scope domain groups for UI rendering */
export const SCOPE_GROUPS = [
+ { domain: 'companies', label: 'Företag', read: 'companies:read' as const, write: 'companies:write' as const },
{ domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const },
{ domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const },
{ domain: 'articles', label: 'Artiklar', read: 'articles:read' as const, write: 'articles:write' as const },
@@ -160,6 +163,8 @@ export const SCOPE_GROUPS = [
export const TOOL_SCOPE_MAP: Record = {
// Companies
gnubok_list_companies: 'companies:read',
+ gnubok_get_company_settings: 'companies:read',
+ gnubok_update_company_settings: 'companies:write',
// Transactions
gnubok_list_uncategorized_transactions: 'transactions:read',
gnubok_list_transactions_without_documents: 'transactions:read',
@@ -177,6 +182,7 @@ export const TOOL_SCOPE_MAP: Record = {
// Customers
gnubok_list_customers: 'customers:read',
gnubok_create_customer: 'customers:write',
+ gnubok_update_customer: 'customers:write',
// Articles (artikelregister)
gnubok_list_articles: 'articles:read',
gnubok_create_article: 'articles:write',
@@ -320,6 +326,9 @@ export const TOOL_SCOPE_MAP: Record = {
gnubok_propose_accruals: 'reports:read',
gnubok_propose_annual_depreciation: 'reports:read',
gnubok_preview_arsredovisning: 'reports:read',
+ gnubok_validate_arsredovisning: 'reports:read',
+ gnubok_list_arsredovisning_versions: 'reports:read',
+ gnubok_get_arsredovisning_filing_status: 'reports:read',
gnubok_preview_ef_declaration: 'reports:read',
// Deliberately UNSCOPED (available to any authenticated key):
// gnubok_search_tools, gnubok_list_skills, gnubok_load_skill,
diff --git a/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts b/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts
index 5b5d94e2..4e6a602c 100644
--- a/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts
+++ b/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts
@@ -107,6 +107,16 @@ describe('calculateBolagsskatt', () => {
expect(generateIncomeStatement).not.toHaveBeenCalled()
})
+ it('calculates Miles tax after both non-deductible account addbacks', async () => {
+ const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
+ resultBeforeTaxOverride: 592_722.21,
+ manualAdjustments: { nonDeductibleExpenses: 2_994 + 2_250 },
+ })
+
+ expect(result?.computation?.taxableResultClamped).toBe(597_960)
+ expect(result?.amount).toBe(123_180)
+ })
+
/** Table-keyed FIFO client: consumption order per table mirrors the
* two-step entry-lines fetch plus the reversed-ids lookup. */
function makeQueuedClient(queues: Record) {
@@ -150,6 +160,7 @@ describe('calculateBolagsskatt', () => {
const effect = await sumPostedYearEndDispositions(client, 'co', 'fp')
expect(effect.total).toBe(-174_000) // -150k + 20k - 39k - 5k
expect(effect.slpPortion).toBe(-5_000)
+ expect(effect.taxProvisionPortion).toBe(123_600)
})
it('sumPostedYearEndDispositions counts the replacement of a corrected year_end entry', async () => {
@@ -177,16 +188,17 @@ describe('calculateBolagsskatt', () => {
const effect = await sumPostedYearEndDispositions(client, 'co', 'fp')
expect(effect.total).toBe(-90_000)
expect(effect.slpPortion).toBe(0)
+ expect(effect.taxProvisionPortion).toBe(0)
})
- it('truncates taxable result to whole krona before applying tax', async () => {
+ it('rounds taxable result down to a whole 10 SEK before applying tax', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_999.99,
} as Awaited>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
- // floor(100_999.99) = 100_999, × 0.206 = 20805.794 → round = 20_806
- expect(result!.amount).toBe(20_806)
+ expect(result?.computation?.taxableResultClamped).toBe(100_990)
+ expect(result!.amount).toBe(20_804)
})
})
diff --git a/lib/bokslut/__tests__/tax-adjustment-service.test.ts b/lib/bokslut/__tests__/tax-adjustment-service.test.ts
new file mode 100644
index 00000000..cc57a0c5
--- /dev/null
+++ b/lib/bokslut/__tests__/tax-adjustment-service.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/reports/trial-balance', () => ({
+ generateTrialBalance: vi.fn(),
+}))
+
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import { loadTaxAdjustmentSnapshot } from '../tax-provision/tax-adjustment-service'
+
+function makeClient(rows: unknown[] = []) {
+ const result = { data: rows, error: null }
+ const handler: ProxyHandler = {
+ get(_target, property) {
+ if (property === 'then') {
+ return (resolve: (value: unknown) => void) => resolve(result)
+ }
+ return () => new Proxy({}, handler)
+ },
+ }
+ return {
+ from: () => new Proxy({}, handler),
+ } as unknown as Parameters[0]
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ {
+ account_number: '6992',
+ closing_debit: 2_994,
+ closing_credit: 0,
+ },
+ {
+ account_number: '8423',
+ closing_debit: 2_250,
+ closing_credit: 0,
+ },
+ ],
+ totalDebit: 5_244,
+ totalCredit: 0,
+ isBalanced: false,
+ } as Awaited>)
+})
+
+describe('loadTaxAdjustmentSnapshot', () => {
+ it('detects Miles account balances as non-deductible expenses', async () => {
+ const snapshot = await loadTaxAdjustmentSnapshot(makeClient(), 'company-1', 'period-1')
+
+ expect(snapshot.nonDeductibleExpenses).toBe(5_244)
+ expect(snapshot.nonTaxableIncome).toBe(0)
+ expect(snapshot.items.find((item) => item.accountNumber === '6992')).toMatchObject({
+ amount: 2_994,
+ included: true,
+ })
+ expect(snapshot.items.find((item) => item.accountNumber === '8423')).toMatchObject({
+ amount: 2_250,
+ included: true,
+ })
+ })
+
+ it('honors saved exclusions and includes manual adjustments', async () => {
+ const snapshot = await loadTaxAdjustmentSnapshot(
+ makeClient([
+ {
+ source_key: 'account:8423',
+ adjustment_type: 'non_deductible_expense',
+ source: 'detected',
+ description: 'Räntekostnader för skatter och avgifter',
+ account_number: '8423',
+ amount: 2_250,
+ included: false,
+ },
+ {
+ source_key: 'manual:non_deductible_expenses',
+ adjustment_type: 'non_deductible_expense',
+ source: 'manual',
+ description: 'Ytterligare ej avdragsgilla kostnader',
+ account_number: null,
+ amount: 100,
+ included: true,
+ },
+ ]),
+ 'company-1',
+ 'period-1',
+ )
+
+ expect(snapshot.nonDeductibleExpenses).toBe(3_094)
+ expect(snapshot.items.find((item) => item.accountNumber === '8423')?.included).toBe(false)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
index 4cf6c912..c60042a0 100644
--- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
+++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
@@ -21,6 +21,7 @@ function makeMinimalK3Data(): ArsredovisningData {
company: {
name: 'Testbolaget AB',
org_number: '556677-8899',
+ entity_type: 'aktiebolag',
city: 'Stockholm',
},
fiscal_period: {
@@ -43,7 +44,18 @@ function makeMinimalK3Data(): ArsredovisningData {
{ label: 'Årets resultat', amount: 300_000 },
],
resultatdisposition: 'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
+ proposed_dividend: 0,
+ resultatdisposition_amounts: {
+ retained_earnings: 0,
+ share_premium_reserve: 0,
+ current_year_result: 0,
+ total: 0,
+ proposed_dividend: 0,
+ carried_forward: 0,
+ },
agm_date: '2026-06-15',
+ agm_disposition_outcome: 'proposal_approved',
+ agm_disposition_decision: null,
},
resultatrakning: [
{
@@ -147,6 +159,12 @@ function makeMinimalK3Data(): ArsredovisningData {
parent_company_name: null,
parent_company_org_number: null,
parent_company_city: null,
+ confirmations: {
+ long_term_debt_over_five_years: true,
+ securities_pledged: true,
+ contingent_liabilities: true,
+ parent_company: true,
+ },
},
}
}
diff --git a/lib/bokslut/arsredovisning/__tests__/capabilities.test.ts b/lib/bokslut/arsredovisning/__tests__/capabilities.test.ts
new file mode 100644
index 00000000..db10c6c5
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/capabilities.test.ts
@@ -0,0 +1,40 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { getAnnualReportCapabilities } from '../capabilities'
+
+afterEach(() => vi.unstubAllEnvs())
+
+describe('getAnnualReportCapabilities', () => {
+ it('keeps direct filing closed unless the release gate is explicit', () => {
+ vi.stubEnv('NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED', '')
+ const result = getAnnualReportCapabilities('k2', {
+ k2_eligible: true,
+ digital_filing_eligible: true,
+ digital_issues: [],
+ size_classification: 'smaller',
+ k2_relief_rule: 'eligible',
+ issues: [],
+ })
+ expect(result.paper.enabled).toBe(true)
+ expect(result.ixbrl_preview.enabled).toBe(true)
+ expect(result.connected_filing.enabled).toBe(false)
+ })
+
+ it('opens connected filing only when release and eligibility gates pass', () => {
+ vi.stubEnv('NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED', 'true')
+ const result = getAnnualReportCapabilities('k2', {
+ k2_eligible: true,
+ digital_filing_eligible: true,
+ digital_issues: [],
+ size_classification: 'smaller',
+ k2_relief_rule: 'eligible',
+ issues: [],
+ })
+ expect(result.connected_filing.enabled).toBe(true)
+ })
+
+ it('does not present the current K3 draft as paper-filing ready', () => {
+ const result = getAnnualReportCapabilities('k3')
+ expect(result.paper.enabled).toBe(false)
+ expect(result.paper.reason).toMatch(/granskningsutkast/i)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/__tests__/completeness.test.ts b/lib/bokslut/arsredovisning/__tests__/completeness.test.ts
new file mode 100644
index 00000000..48cfcf1c
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/completeness.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, it } from 'vitest'
+import type { ArsredovisningData } from '../types'
+import {
+ emptyAnnualReportProfile,
+ type AnnualReportEligibilityResult,
+ type AnnualReportProfile,
+} from '../compliance-types'
+import { validateAnnualReportCompleteness } from '../completeness'
+
+const eligibility: AnnualReportEligibilityResult = {
+ k2_eligible: true,
+ digital_filing_eligible: true,
+ size_classification: 'smaller',
+ k2_relief_rule: 'eligible',
+ issues: [],
+ digital_issues: [],
+}
+
+function report(): ArsredovisningData {
+ return {
+ accounting_framework: 'k2',
+ company: { name: 'Test AB', org_number: '556012-5790', city: 'Stockholm' },
+ fiscal_period: {
+ id: 'period-1',
+ name: '2025',
+ period_start: '2025-01-01',
+ period_end: '2025-12-31',
+ },
+ previous_period: null,
+ forvaltningsberattelse: {
+ description: 'Bolaget bedriver konsultverksamhet.',
+ important_events: 'Inga väsentliga händelser.',
+ resultatdisposition: 'Resultatet balanseras i ny räkning.',
+ proposed_dividend: 0,
+ resultatdisposition_amounts: {
+ retained_earnings: 80,
+ share_premium_reserve: 0,
+ current_year_result: 20,
+ total: 100,
+ proposed_dividend: 0,
+ carried_forward: 100,
+ },
+ agm_date: '2026-03-15',
+ },
+ balansrakning: {
+ total_assets: 100,
+ total_equity_liabilities: 100,
+ total_assets_previous: null,
+ total_equity_liabilities_previous: null,
+ assets: [{ label: 'Bank', amount: 100 }],
+ },
+ resultatrakning: [{ label: 'Nettoomsättning', amount: 100 }],
+ noter: [{ number: 1, title: 'Principer', body: 'K2' }],
+ signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: '2026-03-01' }],
+ warnings: [],
+ } as unknown as ArsredovisningData
+}
+
+function input(stage: 'draft' | 'signing' | 'filing') {
+ const profile: AnnualReportProfile = {
+ ...emptyAnnualReportProfile('company-1', 'period-1'),
+ k2_assessment_confirmed_at: '2026-02-01T10:00:00Z',
+ narrative_confirmed_at: '2026-02-01T10:00:00Z',
+ signer_roster_confirmed_at: '2026-02-01T10:00:00Z',
+ is_in_liquidation: false,
+ auditor_report_required: false,
+ }
+ return {
+ report: report(),
+ profile,
+ eligibility,
+ stage,
+ todayIso: '2026-03-20',
+ disclosures: {
+ long_term_debt_over_five_years_confirmed: true,
+ securities_pledged_confirmed: true,
+ contingent_liabilities_confirmed: true,
+ parent_company_confirmed: true,
+ agm_disposition_outcome: 'proposal_approved' as
+ | 'proposal_approved'
+ | 'alternative_decision'
+ | null,
+ agm_disposition_decision: null,
+ },
+ }
+}
+
+describe('validateAnnualReportCompleteness', () => {
+ it('accepts a complete filing model', () => {
+ const result = validateAnnualReportCompleteness(input('filing'))
+ expect(result.ok).toBe(true)
+ expect(result.error_count).toBe(0)
+ })
+
+ it('does not infer an unanswered disclosure confirmation', () => {
+ const value = input('draft')
+ value.disclosures.securities_pledged_confirmed = false
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.ok).toBe(false)
+ expect(result.issues.some((issue) => issue.code === 'AR-NOTE-SECURITIES-UNCONFIRMED')).toBe(
+ true,
+ )
+ })
+
+ it('requires signers before a version can be locked', () => {
+ const value = input('signing')
+ value.report.signatures = []
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.issues.some((issue) => issue.code === 'AR-SIGNERS-MISSING')).toBe(true)
+ })
+
+ it('validates the organisation number check digit', () => {
+ const value = input('draft')
+ value.report.company.org_number = '556012-5791'
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.issues.some((issue) => issue.code === 'AR-COMPANY-ORGNR')).toBe(true)
+ })
+
+ it('requires confirmation that the signer roster matches Bolagsverket', () => {
+ const value = input('signing')
+ value.profile.signer_roster_confirmed_at = null
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.issues.some((issue) => issue.code === 'AR-SIGNER-ROSTER-UNCONFIRMED')).toBe(true)
+ })
+
+ it('blocks impossible signature and AGM chronology', () => {
+ const value = input('filing')
+ value.report.signatures[0].signed_at = '2025-12-30'
+ value.report.forvaltningsberattelse.agm_date = '2025-12-29'
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.issues.map((issue) => issue.code)).toEqual(
+ expect.arrayContaining(['AR-SIGNATURE-BEFORE-PERIOD-END', 'AR-AGM-BEFORE-PERIOD-END']),
+ )
+ })
+
+ it('keeps K3 output draft-only until the complete K3 disclosure matrix exists', () => {
+ const value = input('signing')
+ value.report.accounting_framework = 'k3'
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.issues.some((issue) => issue.code === 'AR-K3-DRAFT-ONLY')).toBe(true)
+ })
+
+ it('requires the AGM decision and evidence dates at filing stage', () => {
+ const value = input('filing')
+ value.report.signatures[0].signed_at = null
+ value.disclosures.agm_disposition_outcome = null
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.issues.map((issue) => issue.code)).toEqual(
+ expect.arrayContaining(['AR-SIGNATURES-INCOMPLETE', 'AR-AGM-DISPOSITION-OUTCOME']),
+ )
+ })
+
+ it('blocks a proposed dividend above distributable equity', () => {
+ const value = input('draft')
+ value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 101
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.issues.some((issue) => issue.code === 'AR-DIVIDEND-EXCEEDS-EQUITY')).toBe(true)
+ })
+
+ it('requires a documented prudence assessment for a positive dividend', () => {
+ const value = input('draft')
+ value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 50
+ const result = validateAnnualReportCompleteness(value)
+ expect(
+ result.issues.some((issue) => issue.code === 'AR-DIVIDEND-PRUDENCE-UNCONFIRMED'),
+ ).toBe(true)
+
+ value.profile.dividend_prudence_confirmed = true
+ const confirmed = validateAnnualReportCompleteness(value)
+ expect(
+ confirmed.issues.some((issue) => issue.code === 'AR-DIVIDEND-PRUDENCE-UNCONFIRMED'),
+ ).toBe(false)
+ })
+
+ it('does not report a dividend error for zero dividend and negative equity', () => {
+ const value = input('draft')
+ value.report.forvaltningsberattelse.resultatdisposition_amounts.total = -100
+ value.report.forvaltningsberattelse.resultatdisposition_amounts.carried_forward = -100
+ const result = validateAnnualReportCompleteness(value)
+ expect(result.issues.some((issue) => issue.code.startsWith('AR-DIVIDEND-'))).toBe(false)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/__tests__/eligibility.test.ts b/lib/bokslut/arsredovisning/__tests__/eligibility.test.ts
new file mode 100644
index 00000000..02974127
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/eligibility.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from 'vitest'
+import { emptyAnnualReportProfile } from '../compliance-types'
+import { evaluateAnnualReportEligibility } from '../eligibility'
+
+const metrics = {
+ current: { employees: 2, balance_sheet_total: 1_000_000, net_revenue: 2_000_000 },
+ previous: { employees: 2, balance_sheet_total: 900_000, net_revenue: 1_800_000 },
+}
+
+function completeProfile() {
+ return {
+ ...emptyAnnualReportProfile('company-1', 'period-1'),
+ is_public_limited_company: false,
+ is_in_liquidation: false,
+ securities_traded_on_regulated_market: false,
+ is_parent_company: false,
+ has_foreign_branch: false,
+ has_crypto_assets: false,
+ has_share_based_payments: false,
+ has_convertible_debt: false,
+ building_revenue_share_pct: 0,
+ has_material_deferred_tax: false,
+ auditor_report_required: false,
+ }
+}
+
+describe('evaluateAnnualReportEligibility', () => {
+ it('accepts a confirmed smaller private K2 company without auditor report', () => {
+ const result = evaluateAnnualReportEligibility({
+ entityType: 'aktiebolag',
+ framework: 'k2',
+ periodStart: '2026-01-01',
+ periodEnd: '2026-12-31',
+ profile: completeProfile(),
+ metrics,
+ })
+ expect(result.k2_eligible).toBe(true)
+ expect(result.digital_filing_eligible).toBe(true)
+ expect(result.issues).toEqual([])
+ })
+
+ it('does not treat unanswered legal facts as false', () => {
+ const result = evaluateAnnualReportEligibility({
+ entityType: 'aktiebolag',
+ framework: 'k2',
+ periodStart: '2026-01-01',
+ periodEnd: '2026-12-31',
+ profile: emptyAnnualReportProfile('company-1', 'period-1'),
+ metrics,
+ })
+ expect(result.k2_eligible).toBe(false)
+ expect(result.issues.some((item) => item.code === 'AR-K2-PUBLIC-UNKNOWN')).toBe(true)
+ expect(result.digital_issues.some((item) => item.code === 'AR-DIGITAL-AUDIT-UNKNOWN')).toBe(
+ true,
+ )
+ })
+
+ it('blocks a larger company after two consecutive threshold years', () => {
+ const result = evaluateAnnualReportEligibility({
+ entityType: 'aktiebolag',
+ framework: 'k2',
+ periodStart: '2025-01-01',
+ periodEnd: '2025-12-31',
+ profile: completeProfile(),
+ metrics: {
+ current: { employees: 51, balance_sheet_total: 41_000_000, net_revenue: 10_000_000 },
+ previous: { employees: 52, balance_sheet_total: 42_000_000, net_revenue: 10_000_000 },
+ },
+ })
+ expect(result.size_classification).toBe('larger')
+ expect(result.issues.some((item) => item.code === 'AR-K2-LARGE')).toBe(true)
+ })
+
+ it('blocks liquidation reports because they need a separate measurement model', () => {
+ const profile = completeProfile()
+ profile.is_in_liquidation = true
+ const result = evaluateAnnualReportEligibility({
+ entityType: 'aktiebolag',
+ framework: 'k2',
+ periodStart: '2026-01-01',
+ periodEnd: '2026-12-31',
+ profile,
+ metrics,
+ })
+ expect(result.issues.some((item) => item.code === 'AR-SCOPE-LIQUIDATION')).toBe(true)
+ })
+
+ it('applies the new K2 exclusions to financial years starting in 2026', () => {
+ const profile = completeProfile()
+ profile.has_crypto_assets = true
+ const result = evaluateAnnualReportEligibility({
+ entityType: 'aktiebolag',
+ framework: 'k2',
+ periodStart: '2026-01-01',
+ periodEnd: '2026-12-31',
+ profile,
+ metrics,
+ })
+ expect(result.issues.some((item) => item.code === 'AR-K2-CRYPTO')).toBe(true)
+ })
+
+ it('keeps K3 available for paper but does not claim digital K3 support', () => {
+ const result = evaluateAnnualReportEligibility({
+ entityType: 'aktiebolag',
+ framework: 'k3',
+ periodStart: '2026-01-01',
+ periodEnd: '2026-12-31',
+ profile: completeProfile(),
+ metrics,
+ })
+ expect(result.k2_eligible).toBe(false)
+ expect(result.digital_filing_eligible).toBe(false)
+ expect(result.digital_issues.some((item) => item.code === 'AR-DIGITAL-FRAMEWORK')).toBe(true)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/__tests__/version-service.test.ts b/lib/bokslut/arsredovisning/__tests__/version-service.test.ts
new file mode 100644
index 00000000..51a3b388
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/version-service.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { CanonicalAnnualReport } from '../compliance-types'
+import { annualReportContentHash, createAnnualReportVersion } from '../version-service'
+
+function model(signedAt: string | null): CanonicalAnnualReport {
+ return {
+ schema_version: '1.0',
+ generated_at: '2026-07-21T10:00:00Z',
+ company_id: 'company-1',
+ fiscal_period_id: 'period-1',
+ entity_type: 'aktiebolag',
+ report: {
+ accounting_framework: 'k2',
+ signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: signedAt }],
+ },
+ profile: { reporting_currency: 'SEK' },
+ disclosures: {},
+ eligibility: {
+ digital_filing_eligible: true,
+ digital_issues: [],
+ },
+ validation: { ok: true },
+ ixbrl: {
+ entryPointId: 'k2-ab-risbs-2024-09-12',
+ underskrifter: {
+ dateringsdatum: signedAt,
+ signers: [
+ {
+ firstName: 'Anna',
+ lastName: 'Andersson',
+ role: 'Styrelseledamot',
+ signedDate: signedAt,
+ },
+ ],
+ },
+ faststallelseintyg: {
+ signerFirstName: 'Anna',
+ signerLastName: 'Andersson',
+ signerRole: 'Styrelseledamot',
+ genereratDatum: '2026-07-21',
+ },
+ },
+ } as unknown as CanonicalAnnualReport
+}
+
+describe('annualReportContentHash', () => {
+ it('does not change when evidence dates are overlaid after locking', () => {
+ expect(annualReportContentHash(model(null))).toBe(
+ annualReportContentHash(model('2026-03-01T10:00:00Z')),
+ )
+ })
+
+ it('changes when signed content changes', () => {
+ const original = model(null)
+ const changed = model(null)
+ changed.report.signatures[0].name = 'Bertil Andersson'
+ expect(annualReportContentHash(changed)).not.toBe(annualReportContentHash(original))
+ })
+
+ it('locks the digital eligibility decision into the validation snapshot', async () => {
+ const rpc = vi.fn().mockResolvedValue({
+ data: [{ id: 'version-1', version_number: 1, status: 'draft' }],
+ error: null,
+ })
+ await createAnnualReportVersion({ rpc } as never, 'user-1', model(null), false)
+ expect(rpc).toHaveBeenCalledWith(
+ 'create_annual_report_version',
+ expect.objectContaining({
+ p_validation_summary: expect.objectContaining({
+ digital_filing_eligible: true,
+ digital_issues: [],
+ profile: expect.objectContaining({ reporting_currency: 'SEK' }),
+ disclosures: {},
+ eligibility: expect.objectContaining({ digital_filing_eligible: true }),
+ }),
+ }),
+ )
+ })
+})
diff --git a/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx b/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx
index 6f073f03..24d3576b 100644
--- a/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx
+++ b/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx
@@ -213,6 +213,17 @@ function PageChrome({
}
export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
+ const reportSignatureDate = data.signatures
+ .map((signature) => signature.signed_at?.slice(0, 10) ?? null)
+ .filter((date): date is string => date !== null)
+ .sort()
+ .at(-1)
+ const agmDispositionDecision =
+ data.forvaltningsberattelse.agm_disposition_outcome === 'proposal_approved'
+ ? `Årsstämman beslutade att godkänna styrelsens förslag: ${data.forvaltningsberattelse.resultatdisposition}`
+ : data.forvaltningsberattelse.agm_disposition_outcome === 'alternative_decision'
+ ? data.forvaltningsberattelse.agm_disposition_decision
+ : null
return (
{/* Cover */}
@@ -272,6 +283,22 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
Förslag till resultatdisposition
{data.forvaltningsberattelse.resultatdisposition}
+ {[
+ ['Balanserat resultat', data.forvaltningsberattelse.resultatdisposition_amounts.retained_earnings],
+ ['Fri överkursfond', data.forvaltningsberattelse.resultatdisposition_amounts.share_premium_reserve],
+ ['Årets resultat', data.forvaltningsberattelse.resultatdisposition_amounts.current_year_result],
+ ['Summa till årsstämmans förfogande', data.forvaltningsberattelse.resultatdisposition_amounts.total],
+ ['Föreslagen utdelning', -data.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend],
+ ['Balanseras i ny räkning', data.forvaltningsberattelse.resultatdisposition_amounts.carried_forward],
+ ].map(([label, amount], index) => (
+
+ {label}
+ {fmt(Number(amount))}
+
+ ))}
{/* Resultaträkning — ÅRL post level, no account numbers. */}
@@ -502,7 +529,7 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
Underskrifter
{data.company.city ? `${data.company.city}, ` : ''}
- {data.fiscal_period.period_end}
+ {reportSignatureDate ?? '____________________'}
{(data.signatures.length > 0
? data.signatures
@@ -543,7 +570,7 @@ export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
Stämmans beslut om resultatdisposition
- {data.forvaltningsberattelse.resultatdisposition}
+ {agmDispositionDecision ?? 'Årsstämmans beslut har ännu inte registrerats.'}
diff --git a/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx b/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx
index a78d8fcd..ee114596 100644
--- a/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx
+++ b/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx
@@ -171,6 +171,17 @@ function PageChrome({
}
export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
+ const reportSignatureDate = data.signatures
+ .map((signature) => signature.signed_at?.slice(0, 10) ?? null)
+ .filter((date): date is string => date !== null)
+ .sort()
+ .at(-1)
+ const agmDispositionDecision =
+ data.forvaltningsberattelse.agm_disposition_outcome === 'proposal_approved'
+ ? `Årsstämman beslutade att godkänna styrelsens förslag: ${data.forvaltningsberattelse.resultatdisposition}`
+ : data.forvaltningsberattelse.agm_disposition_outcome === 'alternative_decision'
+ ? data.forvaltningsberattelse.agm_disposition_decision
+ : null
return (
{/* Cover */}
@@ -237,6 +248,22 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
Förslag till resultatdisposition
{data.forvaltningsberattelse.resultatdisposition}
+ {[
+ ['Balanserat resultat', data.forvaltningsberattelse.resultatdisposition_amounts.retained_earnings],
+ ['Fri överkursfond', data.forvaltningsberattelse.resultatdisposition_amounts.share_premium_reserve],
+ ['Årets resultat', data.forvaltningsberattelse.resultatdisposition_amounts.current_year_result],
+ ['Summa till årsstämmans förfogande', data.forvaltningsberattelse.resultatdisposition_amounts.total],
+ ['Föreslagen utdelning', -data.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend],
+ ['Balanseras i ny räkning', data.forvaltningsberattelse.resultatdisposition_amounts.carried_forward],
+ ].map(([label, amount], index) => (
+
+ {label}
+ {fmt(Number(amount))}
+
+ ))}
{/* Resultaträkning — ÅRL post level, no account numbers. */}
@@ -292,7 +319,7 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
Underskrifter
{data.company.city ? `${data.company.city}, ` : ''}
- {data.fiscal_period.period_end}
+ {reportSignatureDate ?? '____________________'}
{(data.signatures.length > 0
? data.signatures
@@ -345,7 +372,7 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
Stämmans beslut om resultatdisposition
- {data.forvaltningsberattelse.resultatdisposition}
+ {agmDispositionDecision ?? 'Årsstämmans beslut har ännu inte registrerats.'}
diff --git a/lib/bokslut/arsredovisning/build-data.ts b/lib/bokslut/arsredovisning/build-data.ts
index ce390cf1..d686f6da 100644
--- a/lib/bokslut/arsredovisning/build-data.ts
+++ b/lib/bokslut/arsredovisning/build-data.ts
@@ -173,6 +173,11 @@ export async function buildArsredovisningData(
)
const egen_kapital_changes = buildEquityChanges(mapping)
+ const proposedDividend = narrative?.proposed_dividend ?? 0
+ const retainedEarnings = mapping.br['BalanseratResultat']?.current ?? 0
+ const sharePremiumReserve = mapping.br['Overkursfond']?.current ?? 0
+ const currentYearResult = mapping.br['AretsResultatEgetKapital']?.current ?? 0
+ const distributableEquity = mapping.totals.frittEgetKapital.current
// Duplicate-value consistency with the RR (mirrors build-input.ts): the
// flerårsöversikt is computed from the income statement (ALL class-3
@@ -325,6 +330,7 @@ export async function buildArsredovisningData(
company: {
name: companyName,
org_number: orgNumber,
+ entity_type: entityType,
city,
},
fiscal_period: {
@@ -351,7 +357,18 @@ export async function buildArsredovisningData(
overrides.resultatdisposition ??
persistedRd ??
'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
+ proposed_dividend: proposedDividend,
+ resultatdisposition_amounts: {
+ retained_earnings: retainedEarnings,
+ share_premium_reserve: sharePremiumReserve,
+ current_year_result: currentYearResult,
+ total: distributableEquity,
+ proposed_dividend: proposedDividend,
+ carried_forward: distributableEquity - proposedDividend,
+ },
agm_date: persistedAgmDate,
+ agm_disposition_outcome: narrative?.agm_disposition_outcome ?? null,
+ agm_disposition_decision: narrative?.agm_disposition_decision ?? null,
},
resultatrakning,
warnings,
@@ -367,6 +384,13 @@ export async function buildArsredovisningData(
parent_company_name: narrative?.parent_company_name ?? null,
parent_company_org_number: narrative?.parent_company_org_number ?? null,
parent_company_city: narrative?.parent_company_city ?? null,
+ confirmations: {
+ long_term_debt_over_five_years:
+ narrative?.long_term_debt_over_five_years_confirmed ?? false,
+ securities_pledged: narrative?.securities_pledged_confirmed ?? false,
+ contingent_liabilities: narrative?.contingent_liabilities_confirmed ?? false,
+ parent_company: narrative?.parent_company_confirmed ?? false,
+ },
},
}
}
diff --git a/lib/bokslut/arsredovisning/capabilities.ts b/lib/bokslut/arsredovisning/capabilities.ts
new file mode 100644
index 00000000..0ca138ac
--- /dev/null
+++ b/lib/bokslut/arsredovisning/capabilities.ts
@@ -0,0 +1,55 @@
+import type { AnnualReportEligibilityResult, AnnualReportFramework } from './compliance-types'
+
+export interface AnnualReportCapabilities {
+ paper: {
+ enabled: boolean
+ delivery: 'post'
+ reason: string | null
+ }
+ ixbrl_preview: {
+ enabled: boolean
+ reason: string | null
+ }
+ connected_filing: {
+ enabled: boolean
+ release_gate_open: boolean
+ reason: string | null
+ }
+}
+
+export const CONNECTED_FILING_PUBLIC_RELEASED =
+ process.env.NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED === 'true'
+
+export function getAnnualReportCapabilities(
+ framework: AnnualReportFramework,
+ eligibility?: AnnualReportEligibilityResult,
+): AnnualReportCapabilities {
+ const releaseGateOpen = process.env.NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED === 'true'
+ const ixbrlEnabled = framework === 'k2'
+ const eligible = eligibility?.digital_filing_eligible ?? false
+ return {
+ paper: {
+ enabled: framework === 'k2',
+ delivery: 'post',
+ reason:
+ framework === 'k2'
+ ? null
+ : 'K3-dokumentet är endast ett granskningsutkast tills hela upplysningsmatrisen är implementerad och granskad.',
+ },
+ ixbrl_preview: {
+ enabled: ixbrlEnabled,
+ reason: ixbrlEnabled ? null : 'iXBRL-generering stöds ännu endast för K2.',
+ },
+ connected_filing: {
+ enabled: releaseGateOpen && ixbrlEnabled && eligible,
+ release_gate_open: releaseGateOpen,
+ reason: !releaseGateOpen
+ ? 'Direktinlämning öppnas först efter avtal, certifikat och godkänd acceptanstest.'
+ : !ixbrlEnabled
+ ? 'Direktinlämning stöds ännu endast för K2.'
+ : !eligible
+ ? 'Årsredovisningen uppfyller inte alla behörighets- och fullständighetskrav.'
+ : null,
+ },
+ }
+}
diff --git a/lib/bokslut/arsredovisning/completeness.ts b/lib/bokslut/arsredovisning/completeness.ts
new file mode 100644
index 00000000..5eab613d
--- /dev/null
+++ b/lib/bokslut/arsredovisning/completeness.ts
@@ -0,0 +1,352 @@
+import type { ArsredovisningData } from './types'
+import type {
+ AnnualReportComplianceIssue,
+ AnnualReportDisclosureState,
+ AnnualReportEligibilityResult,
+ AnnualReportProfile,
+ AnnualReportValidationResult,
+ AnnualReportValidationStage,
+} from './compliance-types'
+import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
+
+const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
+
+function parseIsoDate(value: string | null | undefined): Date | null {
+ if (!value) return null
+ const datePart = value.slice(0, 10)
+ if (!ISO_DATE.test(datePart)) return null
+ const parsed = new Date(`${datePart}T00:00:00.000Z`)
+ return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== datePart
+ ? null
+ : parsed
+}
+
+function addCalendarMonths(date: Date, months: number): Date {
+ const year = date.getUTCFullYear()
+ const month = date.getUTCMonth() + months
+ const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
+ return new Date(Date.UTC(year, month, Math.min(date.getUTCDate(), lastDay)))
+}
+
+function push(
+ issues: AnnualReportComplianceIssue[],
+ code: string,
+ severity: AnnualReportComplianceIssue['severity'],
+ section: AnnualReportComplianceIssue['section'],
+ message: string,
+ remediation?: string,
+): void {
+ issues.push({ code, severity, section, message, remediation })
+}
+
+export interface ValidateAnnualReportInput {
+ report: ArsredovisningData
+ profile: AnnualReportProfile
+ disclosures: AnnualReportDisclosureState
+ eligibility: AnnualReportEligibilityResult
+ stage: AnnualReportValidationStage
+ todayIso?: string
+}
+
+export function validateAnnualReportCompleteness(
+ input: ValidateAnnualReportInput,
+): AnnualReportValidationResult {
+ const { report, profile, disclosures, eligibility, stage } = input
+ const issues: AnnualReportComplianceIssue[] = [...eligibility.issues]
+ const today = parseIsoDate(input.todayIso ?? new Date().toISOString())
+ const periodStart = parseIsoDate(report.fiscal_period.period_start)
+ const periodEnd = parseIsoDate(report.fiscal_period.period_end)
+
+ if (!periodStart || !periodEnd || periodStart > periodEnd) {
+ push(
+ issues,
+ 'AR-FISCAL-PERIOD-INVALID',
+ 'error',
+ 'company',
+ 'Räkenskapsårets start- och slutdatum är ogiltiga.',
+ 'Korrigera räkenskapsåret i företagsinställningarna.',
+ )
+ } else if (periodEnd >= addCalendarMonths(periodStart, 18)) {
+ push(
+ issues,
+ 'AR-FISCAL-PERIOD-TOO-LONG',
+ 'error',
+ 'company',
+ 'Räkenskapsåret är längre än 18 månader.',
+ 'Korrigera räkenskapsåret innan årsredovisningen upprättas.',
+ )
+ }
+
+ if (report.accounting_framework === 'k2' && !profile.k2_assessment_confirmed_at) {
+ push(
+ issues,
+ 'AR-K2-ASSESSMENT-UNCONFIRMED',
+ 'error',
+ 'scope',
+ 'Bedömningen att bolaget får använda K2 är inte uttryckligen bekräftad.',
+ 'Besvara frågorna om omfattning och regelverk och spara bedömningen.',
+ )
+ }
+
+ if (!report.company.name.trim()) {
+ push(issues, 'AR-COMPANY-NAME', 'error', 'company', 'Företagsnamn saknas.')
+ }
+ if (!normalizeOrgNumber(report.company.org_number)) {
+ push(
+ issues,
+ 'AR-COMPANY-ORGNR',
+ 'error',
+ 'company',
+ 'Ett giltigt organisationsnummer saknas.',
+ 'Komplettera företagsinställningarna.',
+ )
+ }
+ if (!report.company.city?.trim()) {
+ push(
+ issues,
+ 'AR-COMPANY-CITY',
+ 'error',
+ 'company',
+ 'Bolagets registrerade säte saknas.',
+ 'Komplettera företagsinställningarna eller hämta grunduppgifter från Bolagsverket.',
+ )
+ }
+ if (!report.forvaltningsberattelse.description.trim()) {
+ push(issues, 'AR-MANAGEMENT-DESCRIPTION', 'error', 'management_report', 'Verksamhetsbeskrivning saknas.')
+ }
+ if (!report.forvaltningsberattelse.important_events.trim()) {
+ push(issues, 'AR-MANAGEMENT-EVENTS', 'error', 'management_report', 'Uppgift om väsentliga händelser saknas.')
+ }
+ if (!report.forvaltningsberattelse.resultatdisposition.trim()) {
+ push(issues, 'AR-MANAGEMENT-DISPOSITION', 'error', 'management_report', 'Styrelsens förslag till resultatdisposition saknas.')
+ }
+ const disposition = report.forvaltningsberattelse.resultatdisposition_amounts
+ const proposedDividendCents = Math.round(disposition.proposed_dividend * 100)
+ const distributableEquityCents = Math.round(disposition.total * 100)
+ if (proposedDividendCents > 0 && proposedDividendCents > distributableEquityCents) {
+ push(
+ issues,
+ 'AR-DIVIDEND-EXCEEDS-EQUITY',
+ 'error',
+ 'management_report',
+ 'Föreslagen utdelning överstiger det fria egna kapitalet.',
+ 'Sänk utdelningen och kontrollera resultatdispositionen.',
+ )
+ }
+ if (disposition.proposed_dividend > 0 && profile.dividend_prudence_confirmed !== true) {
+ push(
+ issues,
+ 'AR-DIVIDEND-PRUDENCE-UNCONFIRMED',
+ 'error',
+ 'management_report',
+ 'Försiktighetsregeln för föreslagen utdelning är inte bekräftad.',
+ 'Bedöm bolagets kapitalbehov, likviditet, ställning och risker enligt ABL 17 kap. 3 §.',
+ )
+ }
+ if (!profile.narrative_confirmed_at) {
+ push(
+ issues,
+ 'AR-MANAGEMENT-UNCONFIRMED',
+ 'error',
+ 'management_report',
+ 'Förvaltningsberättelsens texter är inte uttryckligen granskade.',
+ 'Granska texterna och markera dem som bekräftade.',
+ )
+ }
+
+ if (
+ Math.round(report.balansrakning.total_assets * 100) !==
+ Math.round(report.balansrakning.total_equity_liabilities * 100)
+ ) {
+ push(
+ issues,
+ 'AR-BALANCE-MISMATCH',
+ 'error',
+ 'statements',
+ 'Balansräkningen balanserar inte i årsredovisningen.',
+ )
+ }
+ if (
+ report.previous_period &&
+ (report.balansrakning.total_assets_previous === null ||
+ report.balansrakning.total_equity_liabilities_previous === null)
+ ) {
+ push(
+ issues,
+ 'AR-COMPARATIVE-MISSING',
+ 'error',
+ 'statements',
+ 'Jämförelsetal saknas trots att ett föregående räkenskapsår finns.',
+ )
+ }
+ if (report.resultatrakning.length === 0 || report.balansrakning.assets.length === 0) {
+ push(issues, 'AR-STATEMENTS-EMPTY', 'error', 'statements', 'Resultat- eller balansräkningen saknar rader.')
+ }
+ if (report.noter.length === 0) {
+ push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
+ }
+
+ const disclosureChecks: Array<[boolean, string, string]> = [
+ [
+ disclosures.long_term_debt_over_five_years_confirmed,
+ 'AR-NOTE-LONG-DEBT-UNCONFIRMED',
+ 'Uppgiften om långfristiga skulder som förfaller efter mer än fem år är inte bekräftad.',
+ ],
+ [
+ disclosures.securities_pledged_confirmed,
+ 'AR-NOTE-SECURITIES-UNCONFIRMED',
+ 'Uppgiften om ställda säkerheter är inte bekräftad.',
+ ],
+ [
+ disclosures.contingent_liabilities_confirmed,
+ 'AR-NOTE-CONTINGENT-UNCONFIRMED',
+ 'Uppgiften om eventualförpliktelser är inte bekräftad.',
+ ],
+ [
+ disclosures.parent_company_confirmed,
+ 'AR-NOTE-PARENT-UNCONFIRMED',
+ 'Uppgiften om koncern- och moderföretagsförhållanden är inte bekräftad.',
+ ],
+ ]
+ for (const [confirmed, code, message] of disclosureChecks) {
+ if (!confirmed) push(issues, code, 'error', 'notes', message)
+ }
+
+ if (report.accounting_framework === 'k3') {
+ push(
+ issues,
+ 'AR-K3-DRAFT-ONLY',
+ 'error',
+ 'scope',
+ 'Accounteds K3-dokument är ännu ett granskningsutkast och kan inte låsas som en komplett K3-årsredovisning.',
+ 'Slutför K3:s upplysningsmatris och oberoende regelverksgranskning innan versionen låses.',
+ )
+ }
+
+ if (stage !== 'draft') {
+ if (report.signatures.length === 0) {
+ push(
+ issues,
+ 'AR-SIGNERS-MISSING',
+ 'error',
+ 'signatures',
+ 'Styrelsens och eventuell VD:s undertecknare är inte registrerade.',
+ )
+ }
+ if (!profile.signer_roster_confirmed_at) {
+ push(
+ issues,
+ 'AR-SIGNER-ROSTER-UNCONFIRMED',
+ 'error',
+ 'signatures',
+ 'Det är inte bekräftat att samtliga aktuella styrelseledamöter och eventuell VD finns med.',
+ 'Kontrollera namn och roller mot Bolagsverket och bekräfta undertecknarlistan.',
+ )
+ }
+ }
+ if (stage === 'filing') {
+ if (report.signatures.some((signature) => !signature.signed_at)) {
+ push(
+ issues,
+ 'AR-SIGNATURES-INCOMPLETE',
+ 'error',
+ 'signatures',
+ 'Alla registrerade undertecknare har inte ett verifierat underskriftsdatum.',
+ )
+ }
+ if (!report.forvaltningsberattelse.agm_date) {
+ push(issues, 'AR-AGM-DATE', 'error', 'agm', 'Datum för årsstämman saknas.')
+ }
+ const signedDates = report.signatures
+ .map((signature) => parseIsoDate(signature.signed_at))
+ .filter((date): date is Date => date !== null)
+ if (report.signatures.some((signature) => signature.signed_at && !parseIsoDate(signature.signed_at))) {
+ push(issues, 'AR-SIGNATURE-DATE-INVALID', 'error', 'signatures', 'Ett underskriftsdatum är ogiltigt.')
+ }
+ if (periodEnd && signedDates.some((date) => date <= periodEnd)) {
+ push(
+ issues,
+ 'AR-SIGNATURE-BEFORE-PERIOD-END',
+ 'error',
+ 'signatures',
+ 'Årsredovisningen har undertecknats innan räkenskapsåret avslutades.',
+ )
+ }
+ if (today && signedDates.some((date) => date > today)) {
+ push(issues, 'AR-SIGNATURE-IN-FUTURE', 'error', 'signatures', 'Ett underskriftsdatum ligger i framtiden.')
+ }
+ const agmDate = parseIsoDate(report.forvaltningsberattelse.agm_date)
+ if (report.forvaltningsberattelse.agm_date && !agmDate) {
+ push(issues, 'AR-AGM-DATE-INVALID', 'error', 'agm', 'Datumet för årsstämman är ogiltigt.')
+ }
+ if (agmDate && periodEnd && agmDate <= periodEnd) {
+ push(issues, 'AR-AGM-BEFORE-PERIOD-END', 'error', 'agm', 'Årsstämman ligger före räkenskapsårets slut.')
+ }
+ if (agmDate && today && agmDate > today) {
+ push(issues, 'AR-AGM-IN-FUTURE', 'error', 'agm', 'Datumet för årsstämman ligger i framtiden.')
+ }
+ if (agmDate && signedDates.some((date) => date > agmDate)) {
+ push(
+ issues,
+ 'AR-AGM-BEFORE-SIGNATURES',
+ 'error',
+ 'agm',
+ 'Årsstämman har registrerats före den senaste underskriften av årsredovisningen.',
+ )
+ }
+ if (agmDate && periodEnd && agmDate > addCalendarMonths(periodEnd, 6)) {
+ push(
+ issues,
+ 'AR-AGM-DEADLINE-PASSED',
+ 'warning',
+ 'agm',
+ 'Årsstämman hölls senare än sex månader efter räkenskapsårets slut.',
+ 'Dokumentera förseningen. En sen årsredovisning ska ändå lämnas in så snart som möjligt.',
+ )
+ }
+ if (!disclosures.agm_disposition_outcome) {
+ push(
+ issues,
+ 'AR-AGM-DISPOSITION-OUTCOME',
+ 'error',
+ 'agm',
+ 'Årsstämmans beslut om resultatdisposition är inte registrerat.',
+ )
+ }
+ if (
+ disclosures.agm_disposition_outcome === 'alternative_decision' &&
+ !disclosures.agm_disposition_decision?.trim()
+ ) {
+ push(
+ issues,
+ 'AR-AGM-ALTERNATIVE-MISSING',
+ 'error',
+ 'agm',
+ 'Årsstämmans alternativa beslut om resultatdisposition saknar text.',
+ )
+ }
+ if (profile.auditor_report_required && !profile.auditor_report_included) {
+ push(
+ issues,
+ 'AR-AUDITOR-REPORT-MISSING',
+ 'error',
+ 'filing',
+ 'Revisionsberättelse krävs men är inte markerad som inkluderad i inlämningspaketet.',
+ )
+ }
+ }
+
+ for (const warning of report.warnings) {
+ push(issues, 'AR-SOURCE-WARNING', 'warning', 'statements', warning)
+ }
+
+ const errorCount = issues.filter((item) => item.severity === 'error').length
+ const warningCount = issues.filter((item) => item.severity === 'warning').length
+ return {
+ stage,
+ ok: errorCount === 0,
+ error_count: errorCount,
+ warning_count: warningCount,
+ issues,
+ }
+}
diff --git a/lib/bokslut/arsredovisning/compliance-types.ts b/lib/bokslut/arsredovisning/compliance-types.ts
new file mode 100644
index 00000000..0e67bc2d
--- /dev/null
+++ b/lib/bokslut/arsredovisning/compliance-types.ts
@@ -0,0 +1,176 @@
+import type { ArsredovisningData } from './types'
+import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
+
+export const ANNUAL_REPORT_SCHEMA_VERSION = '1.0' as const
+
+export type AnnualReportFramework = 'k2' | 'k3'
+export type AnnualReportFilingMode = 'paper' | 'digital'
+export type AnnualReportValidationStage = 'draft' | 'signing' | 'filing'
+
+export type ParentGroupSize = 'none' | 'small' | 'large'
+
+/**
+ * Facts that determine whether the selected framework may be used. Nullable
+ * booleans are intentional: an unanswered eligibility question must never be
+ * interpreted as a legal assertion that the condition does not apply.
+ */
+export interface AnnualReportProfile {
+ id: string | null
+ company_id: string
+ fiscal_period_id: string
+ is_public_limited_company: boolean | null
+ is_in_liquidation: boolean | null
+ securities_traded_on_regulated_market: boolean | null
+ is_parent_company: boolean | null
+ parent_group_size: ParentGroupSize | null
+ prepares_consolidated_accounts: boolean | null
+ has_foreign_branch: boolean | null
+ has_crypto_assets: boolean | null
+ has_share_based_payments: boolean | null
+ has_convertible_debt: boolean | null
+ building_revenue_share_pct: number | null
+ has_material_deferred_tax: boolean | null
+ reporting_currency: 'SEK' | 'EUR'
+ auditor_report_required: boolean | null
+ auditor_report_included: boolean
+ dividend_prudence_confirmed: boolean | null
+ narrative_confirmed_at: string | null
+ k2_assessment_confirmed_at: string | null
+ signer_roster_confirmed_at: string | null
+ updated_at: string | null
+}
+
+export interface AnnualReportDisclosureState {
+ long_term_debt_over_five_years_confirmed: boolean
+ securities_pledged_confirmed: boolean
+ contingent_liabilities_confirmed: boolean
+ parent_company_confirmed: boolean
+ agm_disposition_outcome: 'proposal_approved' | 'alternative_decision' | null
+ agm_disposition_decision: string | null
+}
+
+export interface AnnualReportMetricsYear {
+ employees: number | null
+ balance_sheet_total: number | null
+ net_revenue: number | null
+}
+
+export interface AnnualReportSizeMetrics {
+ current: AnnualReportMetricsYear
+ previous: AnnualReportMetricsYear | null
+}
+
+export type AnnualReportIssueSeverity = 'error' | 'warning' | 'info'
+
+export interface AnnualReportComplianceIssue {
+ code: string
+ severity: AnnualReportIssueSeverity
+ section:
+ | 'scope'
+ | 'company'
+ | 'management_report'
+ | 'statements'
+ | 'notes'
+ | 'signatures'
+ | 'agm'
+ | 'filing'
+ message: string
+ remediation?: string
+}
+
+export interface AnnualReportEligibilityResult {
+ k2_eligible: boolean
+ digital_filing_eligible: boolean
+ size_classification: 'smaller' | 'larger' | 'unknown'
+ k2_relief_rule: 'eligible' | 'not_eligible' | 'unknown'
+ issues: AnnualReportComplianceIssue[]
+ digital_issues: AnnualReportComplianceIssue[]
+}
+
+export interface AnnualReportValidationResult {
+ stage: AnnualReportValidationStage
+ ok: boolean
+ error_count: number
+ warning_count: number
+ issues: AnnualReportComplianceIssue[]
+}
+
+export interface AnnualReportVersionValidationSnapshot extends AnnualReportValidationResult {
+ digital_filing_eligible: boolean
+ digital_issues: AnnualReportComplianceIssue[]
+ profile: AnnualReportProfile
+ disclosures: AnnualReportDisclosureState
+ eligibility: AnnualReportEligibilityResult
+}
+
+export interface CanonicalAnnualReport {
+ schema_version: typeof ANNUAL_REPORT_SCHEMA_VERSION
+ generated_at: string
+ company_id: string
+ fiscal_period_id: string
+ entity_type: string
+ report: ArsredovisningData
+ profile: AnnualReportProfile
+ disclosures: AnnualReportDisclosureState
+ eligibility: AnnualReportEligibilityResult
+ validation: AnnualReportValidationResult
+ ixbrl: IxbrlArsredovisningInput | null
+}
+
+export interface AnnualReportVersionSummary {
+ id: string
+ version_number: number
+ status: 'draft' | 'ready_for_signature' | 'signed' | 'filed' | 'registered' | 'superseded'
+ framework: AnnualReportFramework
+ content_hash: string
+ taxonomy_version: string | null
+ entry_point: string | null
+ finalized_at: string | null
+ created_at: string
+ digital_filing_eligible?: boolean
+ certificate_signer?: {
+ first_name: string
+ last_name: string
+ role: string
+ } | null
+}
+
+export function emptyAnnualReportProfile(
+ companyId: string,
+ fiscalPeriodId: string,
+): AnnualReportProfile {
+ return {
+ id: null,
+ company_id: companyId,
+ fiscal_period_id: fiscalPeriodId,
+ is_public_limited_company: null,
+ is_in_liquidation: null,
+ securities_traded_on_regulated_market: null,
+ is_parent_company: null,
+ parent_group_size: null,
+ prepares_consolidated_accounts: null,
+ has_foreign_branch: null,
+ has_crypto_assets: null,
+ has_share_based_payments: null,
+ has_convertible_debt: null,
+ building_revenue_share_pct: null,
+ has_material_deferred_tax: null,
+ reporting_currency: 'SEK',
+ auditor_report_required: null,
+ auditor_report_included: false,
+ dividend_prudence_confirmed: null,
+ narrative_confirmed_at: null,
+ k2_assessment_confirmed_at: null,
+ signer_roster_confirmed_at: null,
+ updated_at: null,
+ }
+}
+
+export const EMPTY_DISCLOSURE_STATE: AnnualReportDisclosureState = {
+ long_term_debt_over_five_years_confirmed: false,
+ securities_pledged_confirmed: false,
+ contingent_liabilities_confirmed: false,
+ parent_company_confirmed: false,
+ agm_disposition_outcome: null,
+ agm_disposition_decision: null,
+}
diff --git a/lib/bokslut/arsredovisning/eligibility.ts b/lib/bokslut/arsredovisning/eligibility.ts
new file mode 100644
index 00000000..0255067e
--- /dev/null
+++ b/lib/bokslut/arsredovisning/eligibility.ts
@@ -0,0 +1,329 @@
+import type {
+ AnnualReportComplianceIssue,
+ AnnualReportEligibilityResult,
+ AnnualReportProfile,
+ AnnualReportSizeMetrics,
+} from './compliance-types'
+
+const LARGE_COMPANY_THRESHOLDS = {
+ employees: 50,
+ balanceSheetTotal: 40_000_000,
+ netRevenue: 80_000_000,
+} as const
+
+const K2_RELIEF_THRESHOLDS = {
+ employees: 3,
+ balanceSheetTotal: 1_500_000,
+ netRevenue: 3_000_000,
+} as const
+
+function issue(
+ code: string,
+ message: string,
+ remediation?: string,
+): AnnualReportComplianceIssue {
+ return { code, severity: 'error', section: 'scope', message, remediation }
+}
+
+function exceededCount(
+ metrics: AnnualReportSizeMetrics['current'],
+ thresholds: {
+ employees: number
+ balanceSheetTotal: number
+ netRevenue: number
+ },
+): number | null {
+ if (
+ metrics.employees === null ||
+ metrics.balance_sheet_total === null ||
+ metrics.net_revenue === null
+ ) {
+ return null
+ }
+ return [
+ metrics.employees > thresholds.employees,
+ metrics.balance_sheet_total > thresholds.balanceSheetTotal,
+ metrics.net_revenue > thresholds.netRevenue,
+ ].filter(Boolean).length
+}
+
+function sizeClassification(
+ metrics: AnnualReportSizeMetrics,
+): AnnualReportEligibilityResult['size_classification'] {
+ if (!metrics.previous) return 'smaller'
+ const current = exceededCount(metrics.current, LARGE_COMPANY_THRESHOLDS)
+ const previous = exceededCount(metrics.previous, LARGE_COMPANY_THRESHOLDS)
+ if (current === null || previous === null) return 'unknown'
+ return current > 1 && previous > 1 ? 'larger' : 'smaller'
+}
+
+function reliefClassification(
+ metrics: AnnualReportSizeMetrics,
+): AnnualReportEligibilityResult['k2_relief_rule'] {
+ if (!metrics.previous) return 'eligible'
+ const current = exceededCount(metrics.current, K2_RELIEF_THRESHOLDS)
+ const previous = exceededCount(metrics.previous, K2_RELIEF_THRESHOLDS)
+ if (current === null || previous === null) return 'unknown'
+ return current <= 1 || previous <= 1 ? 'eligible' : 'not_eligible'
+}
+
+function requireAnswer(
+ issues: AnnualReportComplianceIssue[],
+ value: boolean | null,
+ code: string,
+ label: string,
+): void {
+ if (value === null) {
+ issues.push(
+ issue(
+ code,
+ `${label} är inte besvarad.`,
+ 'Besvara frågan i avsnittet Omfattning och regelverk.',
+ ),
+ )
+ }
+}
+
+function usesNewK2Restrictions(periodStart: string, periodEnd: string): boolean {
+ if (periodStart > '2025-12-31') return true
+ return periodStart > '2025-06-30' && periodEnd >= '2026-12-31'
+}
+
+export interface EvaluateAnnualReportEligibilityInput {
+ entityType: string
+ framework: 'k2' | 'k3'
+ periodStart: string
+ periodEnd: string
+ profile: AnnualReportProfile
+ metrics: AnnualReportSizeMetrics
+}
+
+/**
+ * Evaluate framework and current Accounted filing support. The function is
+ * deliberately conservative: missing legal facts produce blockers instead
+ * of being treated as false.
+ */
+export function evaluateAnnualReportEligibility(
+ input: EvaluateAnnualReportEligibilityInput,
+): AnnualReportEligibilityResult {
+ const issues: AnnualReportComplianceIssue[] = []
+ const profile = input.profile
+ const size = sizeClassification(input.metrics)
+ const relief = reliefClassification(input.metrics)
+
+ if (input.entityType !== 'aktiebolag') {
+ issues.push(
+ issue(
+ 'AR-SCOPE-ENTITY',
+ 'Accounteds årsredovisningsflöde stöder för närvarande aktiebolag.',
+ 'Använd rätt årsboksluts- eller deklarationsflöde för företagsformen.',
+ ),
+ )
+ }
+
+ requireAnswer(
+ issues,
+ profile.is_in_liquidation,
+ 'AR-SCOPE-LIQUIDATION-UNKNOWN',
+ 'Om bolaget är i likvidation',
+ )
+ if (profile.is_in_liquidation) {
+ issues.push(
+ issue(
+ 'AR-SCOPE-LIQUIDATION',
+ 'Årsredovisningar under likvidation kräver särskilda värderings- och uppställningsregler som Accounted ännu inte stöder.',
+ 'Upprätta årsredovisningen med hjälp av redovisningskonsult eller revisor.',
+ ),
+ )
+ }
+
+ if (input.framework === 'k2') {
+ requireAnswer(
+ issues,
+ profile.is_public_limited_company,
+ 'AR-K2-PUBLIC-UNKNOWN',
+ 'Om bolaget är publikt',
+ )
+ requireAnswer(
+ issues,
+ profile.securities_traded_on_regulated_market,
+ 'AR-K2-LISTED-UNKNOWN',
+ 'Om värdepapper är upptagna till handel på en reglerad marknad',
+ )
+ requireAnswer(
+ issues,
+ profile.is_parent_company,
+ 'AR-K2-PARENT-UNKNOWN',
+ 'Om bolaget är moderföretag',
+ )
+
+ if (profile.is_public_limited_company) {
+ issues.push(issue('AR-K2-PUBLIC', 'Publika aktiebolag får inte tillämpa K2.', 'Välj K3.'))
+ }
+ if (profile.securities_traded_on_regulated_market) {
+ issues.push(
+ issue('AR-K2-LISTED', 'Noterade företag är större företag och får inte tillämpa K2.', 'Välj K3.'),
+ )
+ }
+ if (size === 'larger') {
+ issues.push(issue('AR-K2-LARGE', 'Bolaget klassificeras som ett större företag enligt ÅRL.', 'Välj K3.'))
+ }
+ if (size === 'unknown') {
+ issues.push(
+ issue(
+ 'AR-K2-SIZE-UNKNOWN',
+ 'Storleksklassificeringen kan inte avgöras eftersom ett eller flera jämförelsetal saknas.',
+ 'Kontrollera jämförelseårets balansomslutning, nettoomsättning och medelantal anställda.',
+ ),
+ )
+ }
+ if (profile.is_parent_company) {
+ if (profile.parent_group_size === null) {
+ issues.push(issue('AR-K2-GROUP-SIZE-UNKNOWN', 'Koncernens storlek är inte angiven.'))
+ }
+ if (profile.prepares_consolidated_accounts === null) {
+ issues.push(issue('AR-K2-CONSOLIDATION-UNKNOWN', 'Det är inte angivet om koncernredovisning upprättas.'))
+ }
+ if (profile.parent_group_size === 'large') {
+ issues.push(issue('AR-K2-LARGE-GROUP', 'Moderföretag i en större koncern får inte tillämpa K2.', 'Välj K3.'))
+ }
+ if (profile.prepares_consolidated_accounts) {
+ issues.push(
+ issue(
+ 'AR-K2-CONSOLIDATED',
+ 'Ett moderföretag som upprättar koncernredovisning får inte upprätta årsredovisningen enligt K2.',
+ 'Välj K3.',
+ ),
+ )
+ }
+ }
+
+ if (usesNewK2Restrictions(input.periodStart, input.periodEnd)) {
+ const restrictedFacts: Array<{
+ value: boolean | null
+ unknownCode: string
+ blockedCode: string
+ label: string
+ }> = [
+ {
+ value: profile.has_foreign_branch,
+ unknownCode: 'AR-K2-FOREIGN-BRANCH-UNKNOWN',
+ blockedCode: 'AR-K2-FOREIGN-BRANCH',
+ label: 'filial i utlandet',
+ },
+ {
+ value: profile.has_crypto_assets,
+ unknownCode: 'AR-K2-CRYPTO-UNKNOWN',
+ blockedCode: 'AR-K2-CRYPTO',
+ label: 'kryptotillgångar som omfattas av K2-begränsningen',
+ },
+ {
+ value: profile.has_share_based_payments,
+ unknownCode: 'AR-K2-SHARE-PAYMENT-UNKNOWN',
+ blockedCode: 'AR-K2-SHARE-PAYMENT',
+ label: 'aktierelaterade ersättningar',
+ },
+ {
+ value: profile.has_convertible_debt,
+ unknownCode: 'AR-K2-CONVERTIBLE-UNKNOWN',
+ blockedCode: 'AR-K2-CONVERTIBLE',
+ label: 'emitterade sammansatta finansiella instrument',
+ },
+ ]
+ for (const fact of restrictedFacts) {
+ requireAnswer(issues, fact.value, fact.unknownCode, `Om bolaget har ${fact.label}`)
+ if (fact.value) {
+ issues.push(
+ issue(
+ fact.blockedCode,
+ `Bolaget har ${fact.label} och får därför inte tillämpa K2 för det här räkenskapsåret.`,
+ 'Välj K3.',
+ ),
+ )
+ }
+ }
+
+ if (relief !== 'eligible') {
+ if (profile.building_revenue_share_pct === null) {
+ issues.push(
+ issue(
+ 'AR-K2-PROPERTY-UNKNOWN',
+ 'Andelen nettoomsättning från byggnader är inte angiven.',
+ ),
+ )
+ } else if (profile.building_revenue_share_pct >= 75) {
+ issues.push(
+ issue(
+ 'AR-K2-PROPERTY',
+ 'Minst 75 procent av nettoomsättningen kommer normalt från byggnader och bolaget omfattas inte av lättnadsregeln.',
+ 'Välj K3.',
+ ),
+ )
+ }
+ requireAnswer(
+ issues,
+ profile.has_material_deferred_tax,
+ 'AR-K2-DEFERRED-TAX-UNKNOWN',
+ 'Om bolaget normalt har en väsentlig uppskjuten skatteskuld',
+ )
+ if (profile.has_material_deferred_tax) {
+ issues.push(
+ issue(
+ 'AR-K2-DEFERRED-TAX',
+ 'Bolaget har normalt en väsentlig uppskjuten skatteskuld och omfattas inte av lättnadsregeln.',
+ 'Välj K3.',
+ ),
+ )
+ }
+ }
+ }
+ }
+
+ const k2Eligible = input.framework === 'k2' && issues.every((item) => item.severity !== 'error')
+ const digitalIssues = [...issues]
+ if (input.framework !== 'k2') {
+ digitalIssues.push(
+ issue(
+ 'AR-DIGITAL-FRAMEWORK',
+ 'Accounteds digitala Bolagsverket-flöde stöder ännu endast K2 för aktiebolag.',
+ 'Använd pappersflödet tills K3-taxonomin är implementerad och godkänd.',
+ ),
+ )
+ }
+ if (profile.reporting_currency !== 'SEK') {
+ digitalIssues.push(
+ issue(
+ 'AR-DIGITAL-CURRENCY',
+ 'Digital inlämning stöds ännu endast för årsredovisningar i SEK.',
+ ),
+ )
+ }
+ if (profile.auditor_report_required === null) {
+ digitalIssues.push(
+ issue(
+ 'AR-DIGITAL-AUDIT-UNKNOWN',
+ 'Det är inte bekräftat om en revisionsberättelse krävs.',
+ 'Hämta grunduppgifter från Bolagsverket eller bekräfta uppgiften manuellt.',
+ ),
+ )
+ } else if (profile.auditor_report_required) {
+ digitalIssues.push(
+ issue(
+ 'AR-DIGITAL-AUDIT',
+ 'Det här bolaget kräver revisionsberättelse, vilket Accounteds digitala paket ännu inte skickar.',
+ 'Använd pappersflödet eller vänta tills separat revisionsberättelse stöds.',
+ ),
+ )
+ }
+
+ return {
+ k2_eligible: k2Eligible,
+ digital_filing_eligible: digitalIssues.every((item) => item.severity !== 'error'),
+ size_classification: size,
+ k2_relief_rule: relief,
+ issues,
+ digital_issues: digitalIssues,
+ }
+}
+
+export { K2_RELIEF_THRESHOLDS, LARGE_COMPANY_THRESHOLDS, usesNewK2Restrictions }
diff --git a/lib/bokslut/arsredovisning/model.ts b/lib/bokslut/arsredovisning/model.ts
new file mode 100644
index 00000000..1aaa9ccc
--- /dev/null
+++ b/lib/bokslut/arsredovisning/model.ts
@@ -0,0 +1,149 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { buildArsredovisningData } from './build-data'
+import { listSignatureRequests } from './signature-service'
+import { getAnnualReportProfile } from './profile-service'
+import { evaluateAnnualReportEligibility } from './eligibility'
+import { validateAnnualReportCompleteness } from './completeness'
+import {
+ ANNUAL_REPORT_SCHEMA_VERSION,
+ type AnnualReportDisclosureState,
+ type AnnualReportSizeMetrics,
+ type AnnualReportValidationStage,
+ type CanonicalAnnualReport,
+} from './compliance-types'
+import { buildIxbrlInput, type BuildIxbrlOptions } from '@/lib/bokslut/ixbrl/build-input'
+import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
+
+export interface BuildCanonicalAnnualReportOptions extends BuildIxbrlOptions {
+ stage?: AnnualReportValidationStage
+ generatedAt?: string
+ includeIxbrl?: boolean
+}
+
+interface EmployeeRow {
+ employment_start: string
+ employment_end: string | null
+ employment_degree: number
+}
+
+function reportMetrics(
+ report: Awaited>,
+ employees: EmployeeRow[],
+): AnnualReportSizeMetrics {
+ const currentOverview = report.forvaltningsberattelse.flerarsoversikt.find(
+ (row) => row.year === report.fiscal_period.name,
+ )
+ const previousOverview = report.previous_period
+ ? report.forvaltningsberattelse.flerarsoversikt.find(
+ (row) => row.year === report.previous_period?.name,
+ )
+ : null
+ return {
+ current: {
+ employees: computeMedelantalAnstallda(
+ employees,
+ report.fiscal_period.period_start,
+ report.fiscal_period.period_end,
+ ),
+ balance_sheet_total: report.balansrakning.total_assets,
+ net_revenue: currentOverview?.net_revenue ?? null,
+ },
+ previous: report.previous_period
+ ? {
+ employees: computeMedelantalAnstallda(
+ employees,
+ report.previous_period.period_start,
+ report.previous_period.period_end,
+ ),
+ balance_sheet_total: report.balansrakning.total_assets_previous,
+ net_revenue: previousOverview?.net_revenue ?? null,
+ }
+ : null,
+ }
+}
+
+function disclosureState(
+ report: Awaited>,
+): AnnualReportDisclosureState {
+ return {
+ long_term_debt_over_five_years_confirmed:
+ report.disclosures.confirmations.long_term_debt_over_five_years,
+ securities_pledged_confirmed: report.disclosures.confirmations.securities_pledged,
+ contingent_liabilities_confirmed:
+ report.disclosures.confirmations.contingent_liabilities,
+ parent_company_confirmed: report.disclosures.confirmations.parent_company,
+ agm_disposition_outcome: report.forvaltningsberattelse.agm_disposition_outcome,
+ agm_disposition_decision: report.forvaltningsberattelse.agm_disposition_decision,
+ }
+}
+
+export async function buildCanonicalAnnualReport(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+ options: BuildCanonicalAnnualReportOptions = {},
+): Promise {
+ const [report, profile, signatures, employeesResult] = await Promise.all([
+ buildArsredovisningData(supabase, companyId, fiscalPeriodId),
+ getAnnualReportProfile(supabase, companyId, fiscalPeriodId),
+ listSignatureRequests(supabase, companyId, fiscalPeriodId),
+ supabase
+ .from('employees')
+ .select('employment_start, employment_end, employment_degree')
+ .eq('company_id', companyId),
+ ])
+
+ if (employeesResult.error) {
+ throw new Error(`Failed to load annual report employee metrics: ${employeesResult.error.message}`)
+ }
+ const activeSignatures = signatures.filter((signature) => signature.status !== 'declined')
+ report.signatures = activeSignatures.map((signature) => ({
+ role: signature.role,
+ name: signature.signer_name,
+ signed_at: signature.signed_at,
+ }))
+
+ const metrics = reportMetrics(report, (employeesResult.data ?? []) as EmployeeRow[])
+ const eligibility = evaluateAnnualReportEligibility({
+ entityType: report.company.entity_type,
+ framework: report.accounting_framework,
+ periodStart: report.fiscal_period.period_start,
+ periodEnd: report.fiscal_period.period_end,
+ profile,
+ metrics,
+ })
+ const disclosures = disclosureState(report)
+ const validation = validateAnnualReportCompleteness({
+ report,
+ profile,
+ disclosures,
+ eligibility,
+ stage: options.stage ?? 'draft',
+ todayIso: options.todayIso,
+ })
+
+ let ixbrl = null
+ if (report.accounting_framework === 'k2' && options.includeIxbrl !== false) {
+ ixbrl = await buildIxbrlInput(supabase, companyId, fiscalPeriodId, {
+ undertecknare: options.undertecknare,
+ proposedDividend: options.proposedDividend,
+ todayIso: options.todayIso,
+ reportData: report,
+ signatureRequests: activeSignatures,
+ })
+ }
+
+ return {
+ schema_version: ANNUAL_REPORT_SCHEMA_VERSION,
+ generated_at: options.generatedAt ?? new Date().toISOString(),
+ company_id: companyId,
+ fiscal_period_id: fiscalPeriodId,
+ entity_type: report.company.entity_type,
+ report,
+ profile,
+ disclosures,
+ eligibility,
+ validation,
+ ixbrl,
+ }
+}
diff --git a/lib/bokslut/arsredovisning/narrative-service.ts b/lib/bokslut/arsredovisning/narrative-service.ts
index b519579c..e5988e6a 100644
--- a/lib/bokslut/arsredovisning/narrative-service.ts
+++ b/lib/bokslut/arsredovisning/narrative-service.ts
@@ -4,6 +4,7 @@ export interface NarrativeOverrides {
description: string | null
important_events: string | null
resultatdisposition: string | null
+ proposed_dividend: number | null
/** ISO date of the AGM (årsstämma) where the årsredovisning was adopted.
* Populates the fastställelseintyg date blank: without it the PDF
* cannot be filed at Bolagsverket without manual pen-and-ink edit. */
@@ -24,6 +25,12 @@ export interface NarrativeOverrides {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
+ long_term_debt_over_five_years_confirmed: boolean
+ securities_pledged_confirmed: boolean
+ contingent_liabilities_confirmed: boolean
+ parent_company_confirmed: boolean
+ agm_disposition_outcome: 'proposal_approved' | 'alternative_decision' | null
+ agm_disposition_decision: string | null
}
/**
@@ -38,6 +45,7 @@ export interface NarrativeRow {
description: string | null
important_events: string | null
resultatdisposition: string | null
+ proposed_dividend: number | null
agm_date: string | null
long_term_debt_over_five_years: number | null
securities_pledged: string | null
@@ -45,6 +53,12 @@ export interface NarrativeRow {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
+ long_term_debt_over_five_years_confirmed: boolean
+ securities_pledged_confirmed: boolean
+ contingent_liabilities_confirmed: boolean
+ parent_company_confirmed: boolean
+ agm_disposition_outcome: 'proposal_approved' | 'alternative_decision' | null
+ agm_disposition_decision: string | null
updated_at: string
}
@@ -54,7 +68,7 @@ const TABLE = 'arsredovisning_narratives'
// of API responses. GDPR Art.25.2 / ISO A.8.3 data-minimization: callers
// only need the narrative content + last-updated timestamp.
const NARRATIVE_API_COLUMNS =
- 'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, updated_at'
+ 'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, proposed_dividend, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, long_term_debt_over_five_years_confirmed, securities_pledged_confirmed, contingent_liabilities_confirmed, parent_company_confirmed, agm_disposition_outcome, agm_disposition_decision, updated_at'
/**
* Load persisted narrative overrides for a fiscal period. Returns null when
@@ -90,20 +104,13 @@ export async function upsertNarrative(
fiscalPeriodId: string,
input: Partial,
): Promise {
- const payload = {
+ const payload: Record = {
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
- description: input.description ?? null,
- important_events: input.important_events ?? null,
- resultatdisposition: input.resultatdisposition ?? null,
- agm_date: input.agm_date ?? null,
- long_term_debt_over_five_years: input.long_term_debt_over_five_years ?? null,
- securities_pledged: input.securities_pledged ?? null,
- contingent_liabilities: input.contingent_liabilities ?? null,
- parent_company_name: input.parent_company_name ?? null,
- parent_company_org_number: input.parent_company_org_number ?? null,
- parent_company_city: input.parent_company_city ?? null,
+ }
+ for (const [key, value] of Object.entries(input)) {
+ payload[key] = value ?? null
}
const { data, error } = await supabase
.from(TABLE)
diff --git a/lib/bokslut/arsredovisning/profile-service.ts b/lib/bokslut/arsredovisning/profile-service.ts
new file mode 100644
index 00000000..61dbf2e4
--- /dev/null
+++ b/lib/bokslut/arsredovisning/profile-service.ts
@@ -0,0 +1,57 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import {
+ emptyAnnualReportProfile,
+ type AnnualReportProfile,
+} from './compliance-types'
+
+const PROFILE_COLUMNS =
+ 'id, company_id, fiscal_period_id, is_public_limited_company, is_in_liquidation, securities_traded_on_regulated_market, is_parent_company, parent_group_size, prepares_consolidated_accounts, has_foreign_branch, has_crypto_assets, has_share_based_payments, has_convertible_debt, building_revenue_share_pct, has_material_deferred_tax, reporting_currency, auditor_report_required, auditor_report_included, dividend_prudence_confirmed, narrative_confirmed_at, k2_assessment_confirmed_at, signer_roster_confirmed_at, updated_at'
+
+export type AnnualReportProfileUpdate = Partial<
+ Omit
+>
+
+export async function getAnnualReportProfile(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+): Promise {
+ const { data, error } = await supabase
+ .from('annual_report_profiles')
+ .select(PROFILE_COLUMNS)
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .maybeSingle()
+ if (error) throw new Error(`Failed to load annual report profile: ${error.message}`)
+ return data
+ ? (data as AnnualReportProfile)
+ : emptyAnnualReportProfile(companyId, fiscalPeriodId)
+}
+
+export async function upsertAnnualReportProfile(
+ supabase: SupabaseClient,
+ companyId: string,
+ userId: string,
+ fiscalPeriodId: string,
+ input: AnnualReportProfileUpdate,
+): Promise {
+ const payload: Record = {
+ company_id: companyId,
+ fiscal_period_id: fiscalPeriodId,
+ user_id: userId,
+ ...input,
+ }
+ if (input.is_parent_company === false) {
+ payload.parent_group_size = null
+ payload.prepares_consolidated_accounts = null
+ }
+ const { data, error } = await supabase
+ .from('annual_report_profiles')
+ .upsert(payload, { onConflict: 'company_id,fiscal_period_id' })
+ .select(PROFILE_COLUMNS)
+ .single()
+ if (error || !data) {
+ throw new Error(`Failed to save annual report profile: ${error?.message ?? 'unknown'}`)
+ }
+ return data as AnnualReportProfile
+}
diff --git a/lib/bokslut/arsredovisning/signature-service.ts b/lib/bokslut/arsredovisning/signature-service.ts
index d87dd67c..b1aef372 100644
--- a/lib/bokslut/arsredovisning/signature-service.ts
+++ b/lib/bokslut/arsredovisning/signature-service.ts
@@ -2,7 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export interface SignatureRequest {
id: string
- user_id: string
+ user_id: string | null
company_id: string
fiscal_period_id: string
role: string
@@ -11,6 +11,10 @@ export interface SignatureRequest {
signed_at: string | null
created_at: string
updated_at: string
+ annual_report_version_id: string | null
+ signing_method: 'paper_original' | 'advanced_e_signature' | 'bankid' | 'bolagsverket' | null
+ evidence_reference: string | null
+ evidence_recorded_at: string | null
}
export interface CreateSignatureRequestInput {
@@ -28,12 +32,24 @@ export async function listSignatureRequests(
): Promise {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
- .select('id, user_id, company_id, fiscal_period_id, role, signer_name, status, signed_at, created_at, updated_at')
+ .select('id, user_id, company_id, fiscal_period_id, role, signer_name, status, signed_at, created_at, updated_at, annual_report_version_id, signing_method, evidence_reference, evidence_recorded_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.order('created_at', { ascending: true })
if (error) throw new Error(`Failed to list signature requests: ${error.message}`)
- return (data ?? []) as SignatureRequest[]
+ const rows = (data ?? []) as SignatureRequest[]
+ const unbound = rows.filter(
+ (request) => request.annual_report_version_id === null && request.status === 'pending',
+ )
+ if (unbound.length > 0) return unbound
+ const latestBound = [...rows]
+ .filter((request) => request.annual_report_version_id !== null)
+ .sort((left, right) => right.created_at.localeCompare(left.created_at))[0]
+ return latestBound
+ ? rows.filter(
+ (request) => request.annual_report_version_id === latestBound.annual_report_version_id,
+ )
+ : rows
}
/**
@@ -76,17 +92,35 @@ export async function markSignatureSigned(
supabase: SupabaseClient,
companyId: string,
requestId: string,
- options: { bankidSignatureData?: Record } = {},
+ options: {
+ fiscalPeriodId: string
+ annualReportVersionId: string
+ signingMethod: SignatureRequest['signing_method']
+ evidenceReference: string
+ evidenceRecordedBy: string
+ signedAt?: string
+ bankidSignatureData?: Record
+ },
): Promise {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.update({
status: 'signed',
- signed_at: new Date().toISOString(),
+ signed_at: options.signedAt ?? new Date().toISOString(),
+ annual_report_version_id: options.annualReportVersionId,
+ signing_method: options.signingMethod,
+ evidence_reference: options.evidenceReference,
+ evidence_recorded_by: options.evidenceRecordedBy,
+ evidence_recorded_at: new Date().toISOString(),
bankid_signature_data: options.bankidSignatureData ?? null,
})
.eq('id', requestId)
.eq('company_id', companyId)
+ .eq('fiscal_period_id', options.fiscalPeriodId)
+ .eq('status', 'pending')
+ .or(
+ `annual_report_version_id.is.null,annual_report_version_id.eq.${options.annualReportVersionId}`,
+ )
.select('*')
.single()
if (error || !data) {
diff --git a/lib/bokslut/arsredovisning/types.ts b/lib/bokslut/arsredovisning/types.ts
index ad4ad29e..438ee072 100644
--- a/lib/bokslut/arsredovisning/types.ts
+++ b/lib/bokslut/arsredovisning/types.ts
@@ -55,6 +55,7 @@ export interface ArsredovisningData {
company: {
name: string
org_number: string
+ entity_type: string
/** Företagets säte (Bolagsverket-registered registered office city).
* Used in the underskrifter "Stad, datum" line and the fastställelseintyg. */
city: string | null
@@ -90,10 +91,22 @@ export interface ArsredovisningData {
egen_kapital_changes: EgenKapitalRow[]
/** Styrelsens förslag till resultatdisposition (manual input). */
resultatdisposition: string
+ proposed_dividend: number
+ resultatdisposition_amounts: {
+ retained_earnings: number
+ share_premium_reserve: number
+ current_year_result: number
+ total: number
+ proposed_dividend: number
+ carried_forward: number
+ }
/** ISO date of the årsstämma where the årsredovisning was adopted.
* Populates the fastställelseintyg date blank. Null means "not yet
* recorded": PDF then leaves the blank. */
agm_date: string | null
+ /** What the AGM actually decided, distinct from the board's proposal. */
+ agm_disposition_outcome: 'proposal_approved' | 'alternative_decision' | null
+ agm_disposition_decision: string | null
}
resultatrakning: StatementRow[]
balansrakning: {
@@ -138,6 +151,12 @@ export interface ArsredovisningData {
parent_company_name: string | null
parent_company_org_number: string | null
parent_company_city: string | null
+ confirmations: {
+ long_term_debt_over_five_years: boolean
+ securities_pledged: boolean
+ contingent_liabilities: boolean
+ parent_company: boolean
+ }
}
}
diff --git a/lib/bokslut/arsredovisning/version-ixbrl.ts b/lib/bokslut/arsredovisning/version-ixbrl.ts
new file mode 100644
index 00000000..0ee4238a
--- /dev/null
+++ b/lib/bokslut/arsredovisning/version-ixbrl.ts
@@ -0,0 +1,44 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
+import { getAnnualReportVersion } from './version-service'
+
+/** Load immutable version data and overlay only its separately stored signatures. */
+export async function getVersionIxbrlInput(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+ versionId: string,
+): Promise {
+ const version = await getAnnualReportVersion(supabase, companyId, fiscalPeriodId, versionId)
+ if (!version?.ixbrl_data) return null
+ const input = structuredClone(version.ixbrl_data)
+ const { data, error } = await supabase
+ .from('arsredovisning_signature_requests')
+ .select('signer_name, role, signed_at')
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .eq('annual_report_version_id', versionId)
+ .order('created_at', { ascending: true })
+ if (error) throw new Error(`Failed to load version signatures: ${error.message}`)
+ if ((data ?? []).length === 0) return input
+
+ input.underskrifter.signers = (data ?? []).map((signature) => {
+ const parts = signature.signer_name.trim().split(/\s+/)
+ return {
+ firstName: parts.length > 1 ? parts.slice(0, -1).join(' ') : parts[0],
+ lastName: parts.length > 1 ? parts.at(-1) ?? parts[0] : parts[0],
+ role: signature.role,
+ signedDate: signature.signed_at?.slice(0, 10) ?? null,
+ }
+ })
+ input.underskrifter.harVd = input.underskrifter.signers.some((signer) =>
+ /verkställande direktör|^vd$/i.test(signer.role ?? ''),
+ )
+ input.underskrifter.dateringsdatum =
+ input.underskrifter.signers
+ .map((signer) => signer.signedDate)
+ .filter((date): date is string => date !== null)
+ .sort()
+ .at(-1) ?? null
+ return input
+}
diff --git a/lib/bokslut/arsredovisning/version-service.ts b/lib/bokslut/arsredovisning/version-service.ts
new file mode 100644
index 00000000..3aa07b95
--- /dev/null
+++ b/lib/bokslut/arsredovisning/version-service.ts
@@ -0,0 +1,182 @@
+import { createHash } from 'node:crypto'
+import type { SupabaseClient } from '@supabase/supabase-js'
+import type {
+ AnnualReportVersionValidationSnapshot,
+ AnnualReportVersionSummary,
+ CanonicalAnnualReport,
+} from './compliance-types'
+import { getEntryPoint } from '@/lib/bokslut/ixbrl/taxonomy/entry-points'
+
+function stableValue(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(stableValue)
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value as Record)
+ .filter(([, child]) => child !== undefined)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, child]) => [key, stableValue(child)]),
+ )
+ }
+ return value
+}
+
+export function annualReportContentHash(model: CanonicalAnnualReport): string {
+ const reportForSigning = {
+ ...model.report,
+ signatures: model.report.signatures.map((signature) => ({
+ role: signature.role,
+ name: signature.name,
+ signed_at: null,
+ })),
+ }
+ const ixbrlForSigning = model.ixbrl
+ ? {
+ ...model.ixbrl,
+ underskrifter: {
+ ...model.ixbrl.underskrifter,
+ dateringsdatum: null,
+ signers: model.ixbrl.underskrifter.signers.map((signer) => ({
+ ...signer,
+ signedDate: null,
+ })),
+ },
+ faststallelseintyg: {
+ ...model.ixbrl.faststallelseintyg,
+ genereratDatum: null,
+ },
+ }
+ : null
+ const content = {
+ schema_version: model.schema_version,
+ company_id: model.company_id,
+ fiscal_period_id: model.fiscal_period_id,
+ entity_type: model.entity_type,
+ report: reportForSigning,
+ profile: model.profile,
+ disclosures: model.disclosures,
+ ixbrl: ixbrlForSigning,
+ }
+ return createHash('sha256').update(JSON.stringify(stableValue(content))).digest('hex')
+}
+
+export async function listAnnualReportVersions(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+): Promise {
+ const { data, error } = await supabase
+ .from('annual_report_versions')
+ .select(
+ 'id, version_number, status, framework, content_hash, taxonomy_version, entry_point, finalized_at, created_at, ixbrl_data, validation_summary',
+ )
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .order('version_number', { ascending: false })
+ if (error) throw new Error(`Failed to list annual report versions: ${error.message}`)
+ return (data ?? []).map((row) => {
+ const version = row as AnnualReportVersionSummary & {
+ ixbrl_data?: CanonicalAnnualReport['ixbrl']
+ validation_summary?: AnnualReportVersionValidationSnapshot
+ }
+ const signer = version.ixbrl_data?.faststallelseintyg
+ return {
+ id: version.id,
+ version_number: version.version_number,
+ status: version.status,
+ framework: version.framework,
+ content_hash: version.content_hash,
+ taxonomy_version: version.taxonomy_version,
+ entry_point: version.entry_point,
+ finalized_at: version.finalized_at,
+ created_at: version.created_at,
+ digital_filing_eligible:
+ version.validation_summary?.digital_filing_eligible === true,
+ certificate_signer: signer
+ ? {
+ first_name: signer.signerFirstName,
+ last_name: signer.signerLastName,
+ role: signer.signerRole,
+ }
+ : null,
+ }
+ })
+}
+
+export async function getAnnualReportVersion(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+ versionId: string,
+): Promise<{
+ summary: AnnualReportVersionSummary
+ report_data: CanonicalAnnualReport['report']
+ ixbrl_data: CanonicalAnnualReport['ixbrl']
+ validation_summary: AnnualReportVersionValidationSnapshot
+} | null> {
+ const { data, error } = await supabase
+ .from('annual_report_versions')
+ .select(
+ 'id, version_number, status, framework, content_hash, taxonomy_version, entry_point, finalized_at, created_at, report_data, ixbrl_data, validation_summary',
+ )
+ .eq('id', versionId)
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .maybeSingle()
+ if (error) throw new Error(`Failed to load annual report version: ${error.message}`)
+ if (!data) return null
+ const row = data as AnnualReportVersionSummary & {
+ report_data: CanonicalAnnualReport['report']
+ ixbrl_data: CanonicalAnnualReport['ixbrl']
+ validation_summary: AnnualReportVersionValidationSnapshot
+ }
+ return {
+ summary: row,
+ report_data: row.report_data,
+ ixbrl_data: row.ixbrl_data,
+ validation_summary: row.validation_summary,
+ }
+}
+
+export async function createAnnualReportVersion(
+ supabase: SupabaseClient,
+ userId: string,
+ model: CanonicalAnnualReport,
+ finalize: boolean,
+): Promise {
+ if (finalize && !model.validation.ok) {
+ throw new Error('Annual report has blocking validation errors and cannot be finalized')
+ }
+ const hash = annualReportContentHash(model)
+ const entryPoint = model.ixbrl?.entryPointId ?? null
+ const taxonomyVersion = entryPoint ? getEntryPoint(entryPoint).taxonomyVersion : null
+ const rpcName = finalize
+ ? 'create_annual_report_version_with_signatures'
+ : 'create_annual_report_version'
+ const { data, error } = await supabase.rpc(rpcName, {
+ p_company_id: model.company_id,
+ p_fiscal_period_id: model.fiscal_period_id,
+ p_schema_version: model.schema_version,
+ p_framework: model.report.accounting_framework,
+ p_status: finalize ? 'ready_for_signature' : 'draft',
+ p_report_data: model.report,
+ p_ixbrl_data: model.ixbrl,
+ p_content_hash: hash,
+ p_taxonomy_version: taxonomyVersion,
+ p_entry_point: entryPoint,
+ p_validation_summary: {
+ ...model.validation,
+ digital_filing_eligible: model.eligibility.digital_filing_eligible,
+ digital_issues: model.eligibility.digital_issues,
+ profile: model.profile,
+ disclosures: model.disclosures,
+ eligibility: model.eligibility,
+ } satisfies AnnualReportVersionValidationSnapshot,
+ p_user_id: userId,
+ })
+ if (error || !data) {
+ throw new Error(`Failed to create annual report version: ${error?.message ?? 'unknown'}`)
+ }
+ const row = Array.isArray(data) ? data[0] : data
+ const version = row as AnnualReportVersionSummary
+ return version
+}
diff --git a/lib/bokslut/dispositions-proposal-builder.ts b/lib/bokslut/dispositions-proposal-builder.ts
index 75eed00a..17aef7af 100644
--- a/lib/bokslut/dispositions-proposal-builder.ts
+++ b/lib/bokslut/dispositions-proposal-builder.ts
@@ -3,8 +3,10 @@ import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import {
calculateBolagsskatt,
+ getBookedBolagsskatt,
sumPostedYearEndDispositions,
} from './tax-provision/bolagsskatt-calculator'
+import { loadTaxAdjustmentSnapshot } from './tax-provision/tax-adjustment-service'
import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator'
import {
computeLatentTax,
@@ -19,7 +21,7 @@ import {
proposeAvsattning,
proposeAteforing,
} from './reserves/periodiseringsfond-service'
-import type { DispositionsProposal, ProposedDisposition } from './types'
+import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types'
import type { AccountingFramework } from '@/types'
/**
@@ -86,6 +88,7 @@ export async function buildDispositionsProposal(
const resultBeforeTax = incomeStatement.net_result
const proposals: ProposedDisposition[] = []
+ const completedDispositions: CompletedDisposition[] = []
// Dispositions already POSTED in this period (a partially completed
// bokslut run) are excluded from resultBeforeTax like all year_end
@@ -97,6 +100,15 @@ export async function buildDispositionsProposal(
companyId,
fiscalPeriodId,
)
+ const [taxAdjustments, bookedTax] = await Promise.all([
+ loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId),
+ getBookedBolagsskatt(supabase, companyId, fiscalPeriodId),
+ ])
+ // Income statement excludes tax posted by this year-end flow, but includes
+ // manually posted 8910. Add back only the latter to get a stable pre-tax
+ // result on reload.
+ const manuallyBookedTax = Math.max(0, bookedTax - postedEffect.taxProvisionPortion)
+ const normalizedResultBeforeTax = resultBeforeTax + manuallyBookedTax
const existingFonder = await listExistingPeriodiseringsfonder(
supabase,
@@ -137,14 +149,25 @@ export async function buildDispositionsProposal(
// headroom effect is alreadyProvisioned, not a base reduction), plus
// proposed återföringar and schablonintäkt, minus deductible SLP.
const taxableBeforeAvsattning =
- resultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal
+ normalizedResultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal
+ ateforing.schablonintaktAmount - (slp?.amount ?? 0)
- const avsattning = proposeAvsattning({
- skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
- fiscalYear,
- alreadyProvisioned,
- })
+ + taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
+ const avsattning = alreadyProvisioned > 0
+ ? null
+ : proposeAvsattning({
+ skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
+ fiscalYear,
+ })
if (avsattning) proposals.push(avsattning)
+ if (alreadyProvisioned > 0) {
+ completedDispositions.push({
+ kind: 'periodiseringsfond_avsattning',
+ label: 'Avsättning till periodiseringsfond',
+ amount: alreadyProvisioned,
+ status: 'booked',
+ warnings: [],
+ })
+ }
if (slp) proposals.push(slp)
@@ -158,16 +181,34 @@ export async function buildDispositionsProposal(
// Without this, the previewed tax ignores the avsättning (tax too high) and
// diverges from what the sequential commit books and from ÅR/INK2.
const resultAfterDispositions =
- resultBeforeTax + postedEffect.total + ateforingTotal
+ normalizedResultBeforeTax + postedEffect.total + ateforingTotal
- (avsattning?.amount ?? 0) - (slp?.amount ?? 0)
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
resultBeforeTaxOverride: resultAfterDispositions,
manualAdjustments: {
+ nonDeductibleExpenses: taxAdjustments.nonDeductibleExpenses,
+ nonTaxableIncome: taxAdjustments.nonTaxableIncome,
schablonintaktPeriodiseringsfond: ateforing.schablonintaktAmount,
},
})
- if (bolagsskatt) proposals.push(bolagsskatt)
+ if (bookedTax > 0) {
+ const expectedTax = bolagsskatt?.amount ?? 0
+ const matches = bookedTax === expectedTax
+ completedDispositions.push({
+ kind: 'bolagsskatt',
+ label: 'Bolagsskatt 20,6 %',
+ amount: bookedTax,
+ status: matches ? 'booked' : 'needs_correction',
+ warnings: matches
+ ? []
+ : [
+ `Bokförd skatt är ${bookedTax} kr, men aktuellt underlag ger ${expectedTax} kr. Rätta den bokförda skatten innan bokslutet verkställs.`,
+ ],
+ })
+ } else if (bolagsskatt && bolagsskatt.amount > 0) {
+ proposals.push(bolagsskatt)
+ }
// K3 only: split obeskattade reserver into the 79.4 % equity portion and
// the 20.6 % uppskjuten skatteskuld. We sum the projected 21xx balance
@@ -187,8 +228,10 @@ export async function buildDispositionsProposal(
return {
entityType,
fiscalPeriod: period,
- netResultBefore: resultBeforeTax,
+ netResultBefore: normalizedResultBeforeTax,
proposals,
+ taxAdjustments,
+ completedDispositions,
}
}
diff --git a/lib/bokslut/ixbrl/__tests__/fixtures.ts b/lib/bokslut/ixbrl/__tests__/fixtures.ts
index da02f566..78759c17 100644
--- a/lib/bokslut/ixbrl/__tests__/fixtures.ts
+++ b/lib/bokslut/ixbrl/__tests__/fixtures.ts
@@ -204,7 +204,20 @@ export function makeInput(): IxbrlArsredovisningInput {
},
{ number: 2, title: 'Medelantal anställda', body: 'Medelantalet anställda har uppgått till 2.' },
{ number: 3, title: 'Långfristiga skulder', body: 'Inga skulder förfaller senare än fem år efter balansdagen.' },
+ { number: 4, title: 'Ställda säkerheter', body: 'Inga.' },
+ { number: 5, title: 'Eventualförpliktelser', body: 'Inga.' },
+ {
+ number: 6,
+ title: 'Koncernförhållanden',
+ body: 'Moderföretag: Moderbolaget AB.',
+ },
],
+ disclosures: {
+ longTermDebtOverFiveYears: 0,
+ securitiesPledged: 'Inga.',
+ contingentLiabilities: 'Inga.',
+ parentCompany: 'Moderföretag: Moderbolaget AB.',
+ },
medelantalAnstallda: { current: 2, previous: 1 },
underskrifter: {
ort: 'Sundsvall',
@@ -222,6 +235,8 @@ export function makeInput(): IxbrlArsredovisningInput {
},
faststallelseintyg: {
arsstammaDatum: '2026-03-15',
+ resultatdispositionOutcome: 'proposal_approved',
+ resultatdispositionDecision: null,
signerFirstName: 'Karl',
signerLastName: 'Karlsson',
signerRole: 'Styrelseledamot',
diff --git a/lib/bokslut/ixbrl/__tests__/k2-document.test.ts b/lib/bokslut/ixbrl/__tests__/k2-document.test.ts
index b7eea1b8..3252ef8f 100644
--- a/lib/bokslut/ixbrl/__tests__/k2-document.test.ts
+++ b/lib/bokslut/ixbrl/__tests__/k2-document.test.ts
@@ -107,6 +107,15 @@ describe('generateK2IxbrlDocument', () => {
expect(xhtml).toContain('se-k2-type:AntalAnstallda ')
})
+ it('tags the long-term debt disclosure instead of leaving the note as plain XHTML', () => {
+ expect(xhtml).toMatch(
+ /contextRef="balans0" name="se-gen-base:LangfristigaSkulderForfallerSenare5Ar"[^>]*>0<\/ix:nonFraction>/,
+ )
+ expect(xhtml).toContain('name="se-gen-base:NotStalldaSakerheter"')
+ expect(xhtml).toContain('name="se-gen-base:NotEventualforpliktelser"')
+ expect(xhtml).toContain('name="se-gen-base:NotUpplysningModerforetag"')
+ })
+
it('tags resultatdisposition (BÖR: förslag) consistently with BR', () => {
expect(xhtml).toMatch(/name="se-gen-base:ForslagDispositionBalanserasINyRakning"[^>]*>220 000/)
expect(xhtml).toMatch(/name="se-gen-base:ForslagDisposition"[^>]*>220 000/)
diff --git a/lib/bokslut/ixbrl/build-input.ts b/lib/bokslut/ixbrl/build-input.ts
index 2df467dd..842a40b4 100644
--- a/lib/bokslut/ixbrl/build-input.ts
+++ b/lib/bokslut/ixbrl/build-input.ts
@@ -22,6 +22,7 @@ import type {
IxbrlSigner,
Resultatdisposition,
} from './types'
+import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
/** TA §4.3.4-4.3.5: " - ", version ".". */
export const PROGRAMVARA_NAMN = 'Accounted - Accounted'
@@ -35,6 +36,10 @@ export interface BuildIxbrlOptions {
proposedDividend?: number
/** Override "today" for deterministic tests (ISO date). */
todayIso?: string
+ /** Reuse a canonical report instance so PDF and iXBRL cannot diverge. */
+ reportData?: ArsredovisningData
+ /** Reuse the version-bound signer roster when a canonical model is built. */
+ signatureRequests?: Awaited>
}
function splitName(fullName: string): { firstName: string; lastName: string } {
@@ -59,7 +64,7 @@ export async function buildIxbrlInput(
// without it the BR would not tie.
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
await Promise.all([
- buildArsredovisningData(supabase, companyId, fiscalPeriodId),
+ options.reportData ?? buildArsredovisningData(supabase, companyId, fiscalPeriodId),
supabase
.from('fiscal_periods')
.select('id, period_start, period_end, previous_period_id')
@@ -68,7 +73,7 @@ export async function buildIxbrlInput(
.single(),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
- listSignatureRequests(supabase, companyId, fiscalPeriodId),
+ options.signatureRequests ?? listSignatureRequests(supabase, companyId, fiscalPeriodId),
])
if (periodRow.error || !periodRow.data) throw new Error('Fiscal period not found')
@@ -226,11 +231,17 @@ export async function buildIxbrlInput(
// same context: the disposition row must carry the identical value
// (TA §2.7.3), so fri överkursfond (2097) is its own row tagged with the
// separate Overkursfond concept instead of being folded into balanserat.
- const proposedDividend = Math.max(0, Math.round(options.proposedDividend ?? 0))
- const dispBalanserat = br['BalanseratResultat']?.current ?? 0
- const dispOverkursfond = br['Overkursfond']?.current ?? 0
- const dispArets = br['AretsResultatEgetKapital']?.current ?? 0
- const dispSumma = mapping.totals.frittEgetKapital.current
+ const proposedDividend = Math.max(
+ 0,
+ Math.round(
+ options.proposedDividend ?? pdfData.forvaltningsberattelse.proposed_dividend,
+ ),
+ )
+ const dispositionAmounts = pdfData.forvaltningsberattelse.resultatdisposition_amounts
+ const dispBalanserat = dispositionAmounts.retained_earnings
+ const dispOverkursfond = dispositionAmounts.share_premium_reserve
+ const dispArets = dispositionAmounts.current_year_result
+ const dispSumma = dispositionAmounts.total
if (proposedDividend > dispSumma) {
warnings.push(
`Föreslagen utdelning (${proposedDividend} kr) överstiger fritt eget kapital (${dispSumma} kr).`,
@@ -247,14 +258,17 @@ export async function buildIxbrlInput(
}
// ---- underskrifter ---------------------------------------------------------
- // Every signature request becomes a signer row (the board must appear in
- // the document), but ONLY actually-signed requests get a date: an unsigned
+ // Every active signature request becomes a signer row (the board must appear
+ // in the document), but ONLY actually-signed requests get a date: an unsigned
// request keeps signedDate null. Legal dates are never fabricated: the
// missing date renders as an omitted fact in the preview and preflight 1214
// blocks the submission path until everyone has signed.
- const signedRequests = signatureRequests.filter((request) => request.status === 'signed')
+ const activeSignatureRequests = signatureRequests.filter(
+ (request) => request.status !== 'declined',
+ )
+ const signedRequests = activeSignatureRequests.filter((request) => request.status === 'signed')
const today = options.todayIso ?? new Date().toISOString().slice(0, 10)
- const signers: IxbrlSigner[] = signatureRequests.map((request) => {
+ const signers: IxbrlSigner[] = activeSignatureRequests.map((request) => {
const { firstName, lastName } = splitName(request.signer_name)
return {
firstName,
@@ -268,7 +282,7 @@ export async function buildIxbrlInput(
'Inga underskrifter är registrerade: årsredovisningen måste skrivas under av styrelsen (och ev. VD) innan inlämning (kontrollera-kod 1107/1201).',
)
}
- if (signedRequests.length !== signatureRequests.length) {
+ if (signedRequests.length !== activeSignatureRequests.length) {
warnings.push('Alla underskriftsförfrågningar är inte signerade ännu.')
}
const harVd = signers.some((signer) => /verkställande direktör|^vd$/i.test(signer.role ?? ''))
@@ -290,6 +304,11 @@ export async function buildIxbrlInput(
'Datum för årsstämma saknas: fastställelseintyget kan inte fyllas i (kontrollera-kod 1103).',
)
}
+ const agmDispositionOutcome = pdfData.forvaltningsberattelse.agm_disposition_outcome
+ const agmDispositionDecision = pdfData.forvaltningsberattelse.agm_disposition_decision
+ if (!agmDispositionOutcome) {
+ warnings.push('Årsstämmans beslut om resultatdisposition saknas i fastställelseintyget.')
+ }
const fallbackSigner = signers[0] ?? { firstName: '', lastName: '', role: null }
const undertecknare = options.undertecknare ?? {
firstName: fallbackSigner.firstName,
@@ -352,6 +371,26 @@ export async function buildIxbrlInput(
resultatdisposition,
},
noter: pdfData.noter.map((note) => ({ number: note.number, title: note.title, body: note.body })),
+ disclosures: {
+ longTermDebtOverFiveYears:
+ pdfData.disclosures.long_term_debt_over_five_years ?? 0,
+ securitiesPledged: pdfData.disclosures.securities_pledged?.trim() || 'Inga.',
+ contingentLiabilities:
+ pdfData.disclosures.contingent_liabilities?.trim() || 'Inga.',
+ parentCompany: pdfData.disclosures.parent_company_name
+ ? [
+ `Moderföretag: ${pdfData.disclosures.parent_company_name}.`,
+ pdfData.disclosures.parent_company_org_number
+ ? `Organisationsnummer: ${pdfData.disclosures.parent_company_org_number}.`
+ : '',
+ pdfData.disclosures.parent_company_city
+ ? `Säte: ${pdfData.disclosures.parent_company_city}.`
+ : '',
+ ]
+ .filter(Boolean)
+ .join(' ')
+ : null,
+ },
medelantalAnstallda,
underskrifter: {
ort: pdfData.company.city ?? '',
@@ -361,6 +400,8 @@ export async function buildIxbrlInput(
},
faststallelseintyg: {
arsstammaDatum: agmDate ?? null,
+ resultatdispositionOutcome: agmDispositionOutcome,
+ resultatdispositionDecision: agmDispositionDecision,
signerFirstName: undertecknare.firstName,
signerLastName: undertecknare.lastName,
signerRole: undertecknare.role,
diff --git a/lib/bokslut/ixbrl/document/k2-document.ts b/lib/bokslut/ixbrl/document/k2-document.ts
index 0f03048d..f680ccae 100644
--- a/lib/bokslut/ixbrl/document/k2-document.ts
+++ b/lib/bokslut/ixbrl/document/k2-document.ts
@@ -175,6 +175,26 @@ export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): Genera
fb.arsstammaDatum !== null
? writer.date('Arsstamma', 'balans0', fb.arsstammaDatum)
: escapeText('[datum för årsstämma saknas]')
+ const dispositionFact =
+ fb.resultatdispositionOutcome === 'proposal_approved'
+ ? writer.textPlain(
+ 'ArsstammaResultatDispositionGodkannaStyrelsensForslag',
+ 'balans0',
+ 'Årsstämman beslöt att godkänna styrelsens förslag till resultatdisposition.',
+ )
+ : fb.resultatdispositionOutcome === 'alternative_decision'
+ ? writer.textPlain(
+ 'ArsstammaResultatDispositionInteGodkannaStyrelsensForslag',
+ 'balans0',
+ 'Årsstämman beslöt att inte godkänna styrelsens förslag till resultatdisposition.',
+ ) +
+ ' ' +
+ writer.textPlain(
+ 'ArsstammaResultatDispositionBeslutstext',
+ 'balans0',
+ fb.resultatdispositionDecision ?? '[årsstämmans beslut saknas]',
+ )
+ : escapeText('[årsstämmans beslut om resultatdisposition saknas]')
const fi = el(
'div',
{ class: 'ar-cert', id: 'id-innehall-faststallelseintyg' },
@@ -194,11 +214,7 @@ export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): Genera
' ' +
arsstammaFact +
'. ' +
- writer.textPlain(
- 'ArsstammaResultatDispositionGodkannaStyrelsensForslag',
- 'balans0',
- 'Årsstämman beslöt att godkänna styrelsens förslag till resultatdisposition.',
- ),
+ dispositionFact,
{ continuedAt: 'intygande_forts' },
),
),
@@ -1080,6 +1096,10 @@ export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): Genera
)
const isPrinciples = /redovisnings.*principer/i.test(note.title)
const isMedelantal = /medelantal.*anst/i.test(note.title)
+ const isLongTermDebt = /långfristiga skulder/i.test(note.title)
+ const isSecurities = /ställda säkerheter/i.test(note.title)
+ const isContingencies = /eventualförpliktelser/i.test(note.title)
+ const isParentCompany = /koncernförhållanden/i.test(note.title)
if (isPrinciples) {
parts.push(
writer.textHtml('RedovisningsVarderingsprinciper', 'period0', paragraphs(note.body)),
@@ -1121,6 +1141,41 @@ export function generateK2IxbrlDocument(input: IxbrlArsredovisningInput): Genera
),
),
)
+ } else if (isLongTermDebt) {
+ parts.push(
+ paragraphs(note.body) +
+ el(
+ 'p',
+ { class: 'note-fact' },
+ 'Belopp som förfaller senare än fem år efter balansdagen: ' +
+ writer.money(
+ 'LangfristigaSkulderForfallerSenare5Ar',
+ 'balans0',
+ input.disclosures.longTermDebtOverFiveYears,
+ ) +
+ ' kr.',
+ ),
+ )
+ } else if (isSecurities) {
+ parts.push(
+ writer.textPlain(
+ 'NotStalldaSakerheter',
+ 'balans0',
+ input.disclosures.securitiesPledged,
+ ),
+ )
+ } else if (isContingencies) {
+ parts.push(
+ writer.textPlain(
+ 'NotEventualforpliktelser',
+ 'balans0',
+ input.disclosures.contingentLiabilities,
+ ),
+ )
+ } else if (isParentCompany && input.disclosures.parentCompany) {
+ parts.push(
+ writer.textPlain('NotUpplysningModerforetag', 'period0', input.disclosures.parentCompany),
+ )
} else {
parts.push(paragraphs(note.body))
}
diff --git a/lib/bokslut/ixbrl/types.ts b/lib/bokslut/ixbrl/types.ts
index 73edcce8..c37f5b69 100644
--- a/lib/bokslut/ixbrl/types.ts
+++ b/lib/bokslut/ixbrl/types.ts
@@ -109,6 +109,12 @@ export interface IxbrlArsredovisningInput {
}
noter: IxbrlNote[]
+ disclosures: {
+ longTermDebtOverFiveYears: number
+ securitiesPledged: string
+ contingentLiabilities: string
+ parentCompany: string | null
+ }
/** Medelantal anställda (FTE) for current + previous year. */
medelantalAnstallda: { current: number; previous: number | null }
@@ -129,6 +135,8 @@ export interface IxbrlArsredovisningInput {
* placeholder instead of a fabricated date and preflight 1103 blocks
* filing (Bolagsverket kontrollera 1103 semantics). */
arsstammaDatum: string | null
+ resultatdispositionOutcome: 'proposal_approved' | 'alternative_decision' | null
+ resultatdispositionDecision: string | null
/** The företrädare who will sign at Bolagsverket. */
signerFirstName: string
signerLastName: string
diff --git a/lib/bokslut/ixbrl/validate/__tests__/arelle-client.test.ts b/lib/bokslut/ixbrl/validate/__tests__/arelle-client.test.ts
new file mode 100644
index 00000000..a54b702a
--- /dev/null
+++ b/lib/bokslut/ixbrl/validate/__tests__/arelle-client.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { validateIxbrlWithArelle } from '../arelle-client'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('validateIxbrlWithArelle', () => {
+ it('returns unavailable when no validator is configured', async () => {
+ const result = await validateIxbrlWithArelle(' ', { url: '' })
+ expect(result.status).toBe('unavailable')
+ expect(result.issues[0].code).toBe('ARELLE-NOT-CONFIGURED')
+ })
+
+ it('sends the exact artifact and accepts a clean validation', async () => {
+ const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
+ const body = JSON.parse(String(init.body)) as { content_base64: string }
+ expect(Buffer.from(body.content_base64, 'base64').toString('utf8')).toBe(' ')
+ return new Response(JSON.stringify({ ok: true, validator_version: '2.37.50', issues: [] }))
+ })
+ vi.stubGlobal('fetch', fetchMock)
+
+ const result = await validateIxbrlWithArelle(' ', {
+ url: 'https://validator.example/validate',
+ token: 'secret',
+ })
+ expect(result.status).toBe('passed')
+ expect(result.validator_version).toBe('2.37.50')
+ expect(fetchMock).toHaveBeenCalledOnce()
+ })
+
+ it('normalizes Arelle errors into a blocking result', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ new Response(
+ JSON.stringify({
+ ok: false,
+ issues: [{ code: 'xbrl.5.2.5.2', severity: 'error', message: 'Invalid fact' }],
+ }),
+ ),
+ ),
+ )
+ const result = await validateIxbrlWithArelle(' ', {
+ url: 'https://validator.example/validate',
+ })
+ expect(result.status).toBe('failed')
+ expect(result.issues).toEqual([
+ { code: 'xbrl.5.2.5.2', severity: 'error', message: 'Invalid fact' },
+ ])
+ })
+})
diff --git a/lib/bokslut/ixbrl/validate/arelle-client.ts b/lib/bokslut/ixbrl/validate/arelle-client.ts
new file mode 100644
index 00000000..19027d3f
--- /dev/null
+++ b/lib/bokslut/ixbrl/validate/arelle-client.ts
@@ -0,0 +1,107 @@
+export interface ArelleValidationIssue {
+ code: string
+ severity: 'error' | 'warning' | 'info'
+ message: string
+}
+
+export interface ArelleValidationResult {
+ status: 'passed' | 'warnings' | 'failed' | 'unavailable'
+ validator_version: string | null
+ issues: ArelleValidationIssue[]
+}
+
+interface ArelleValidatorResponse {
+ ok?: boolean
+ validator_version?: string
+ issues?: Array<{
+ code?: string
+ severity?: string
+ message?: string
+ }>
+}
+
+/**
+ * Validate exact iXBRL bytes through the configured Arelle service. The
+ * service boundary keeps the Vercel application free from a Python runtime
+ * while still making taxonomy validation mandatory for connected filing.
+ */
+export async function validateIxbrlWithArelle(
+ xhtml: string,
+ options: {
+ url?: string
+ token?: string
+ timeoutMs?: number
+ } = {},
+): Promise {
+ const url = options.url ?? process.env.BOLAGSVERKET_ARELLE_VALIDATOR_URL
+ if (!url) {
+ return {
+ status: 'unavailable',
+ validator_version: null,
+ issues: [
+ {
+ code: 'ARELLE-NOT-CONFIGURED',
+ severity: 'error',
+ message: 'Arelle validator service is not configured.',
+ },
+ ],
+ }
+ }
+
+ try {
+ const token = options.token ?? process.env.BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({
+ filename: 'arsredovisning.xhtml',
+ content_base64: Buffer.from(xhtml, 'utf8').toString('base64'),
+ }),
+ signal: AbortSignal.timeout(options.timeoutMs ?? 60_000),
+ })
+ if (!response.ok) {
+ return {
+ status: 'unavailable',
+ validator_version: null,
+ issues: [
+ {
+ code: `ARELLE-HTTP-${response.status}`,
+ severity: 'error',
+ message: `Arelle validator returned HTTP ${response.status}.`,
+ },
+ ],
+ }
+ }
+
+ const body = (await response.json()) as ArelleValidatorResponse
+ const issues: ArelleValidationIssue[] = (body.issues ?? []).map((item) => ({
+ code: item.code?.trim() || 'ARELLE',
+ severity:
+ item.severity === 'warning' || item.severity === 'info'
+ ? item.severity
+ : 'error',
+ message: item.message?.trim() || 'Arelle reported a validation issue.',
+ }))
+ const hasErrors = body.ok === false || issues.some((item) => item.severity === 'error')
+ return {
+ status: hasErrors ? 'failed' : issues.length > 0 ? 'warnings' : 'passed',
+ validator_version: body.validator_version ?? null,
+ issues,
+ }
+ } catch (error) {
+ return {
+ status: 'unavailable',
+ validator_version: null,
+ issues: [
+ {
+ code: 'ARELLE-UNAVAILABLE',
+ severity: 'error',
+ message: error instanceof Error ? error.message : 'Arelle validator is unavailable.',
+ },
+ ],
+ }
+ }
+}
diff --git a/lib/bokslut/ixbrl/validate/rules.ts b/lib/bokslut/ixbrl/validate/rules.ts
index 1d06e441..b5cb96fb 100644
--- a/lib/bokslut/ixbrl/validate/rules.ts
+++ b/lib/bokslut/ixbrl/validate/rules.ts
@@ -98,6 +98,19 @@ const RULES: Rule[] = [
input.faststallelseintyg.arsstammaDatum
? null
: issue('1103', 'error', 'Datum för årsstämman saknas i fastställelseintyget.'),
+ (input) =>
+ input.faststallelseintyg.resultatdispositionOutcome
+ ? null
+ : issue('ACC-AGM-DISP', 'error', 'Årsstämmans beslut om resultatdisposition saknas.'),
+ (input) =>
+ input.faststallelseintyg.resultatdispositionOutcome === 'alternative_decision' &&
+ !input.faststallelseintyg.resultatdispositionDecision?.trim()
+ ? issue(
+ 'ACC-AGM-DISP-TEXT',
+ 'error',
+ 'Årsstämmans alternativa resultatdisposition saknar beslutstext.',
+ )
+ : null,
// ---- date ordering ------------------------------------------------------
(input, today) =>
diff --git a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts
index a69ea23f..ee08ff36 100644
--- a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts
+++ b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts
@@ -3,6 +3,7 @@ import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-l
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { roundOre } from '@/lib/money'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
import type { ProposedDisposition } from '../types'
/** Bolagsskatt rate. 20.6 % since 2021 (gäller räkenskapsår påbörjat efter 31 dec 2020). */
@@ -45,7 +46,7 @@ export interface BolagsskattComputation {
schablonintaktPeriodiseringsfond: number
otherAdjustments: number
taxableResult: number
- /** Taxable result before tax: equals max(taxableResult, 0). */
+ /** Taxable result before tax, floored to a whole 10 SEK and clamped at zero. */
taxableResultClamped: number
taxRate: number
taxAmount: number
@@ -59,6 +60,9 @@ export interface PostedDispositionsEffect {
* detect an already-posted SLP so it is neither re-proposed nor
* double-counted on a resumed bokslut run. Negative when SLP is posted. */
slpPortion: number
+ /** Tax provision booked by the year-end flow. This is excluded from
+ * `total`, but lets callers distinguish it from manually posted 8910. */
+ taxProvisionPortion: number
}
/**
@@ -148,26 +152,47 @@ export async function sumPostedYearEndDispositions(
}
let effect = 0
let slp = 0
+ let taxProvision = 0
for (const row of data) {
const acc = row.account_number
- if (!(acc.startsWith('88') || acc === '7533')) continue
const delta = (Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
- effect += delta
- if (acc === '7533') slp += delta
+ if (acc.startsWith('88') || acc === '7533') {
+ effect += delta
+ if (acc === '7533') slp += delta
+ }
+ if (acc === '8910') taxProvision += -delta
}
return {
total: roundOre(effect),
slpPortion: roundOre(slp),
+ taxProvisionPortion: roundOre(taxProvision),
}
}
+/**
+ * Return the effective tax expense on 8910 for an open fiscal period.
+ * Trial balance already nets storno and correction entries, which makes this
+ * suitable as the idempotency check before a new tax voucher is posted.
+ */
+export async function getBookedBolagsskatt(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+): Promise {
+ const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
+ const amount = trialBalance.rows
+ .filter((row) => row.account_number === '8910')
+ .reduce((sum, row) => sum + row.closing_debit - row.closing_credit, 0)
+ return roundOre(Math.max(0, amount))
+}
+
/**
* Compute bolagsskatt 20.6 % on the company's taxable result.
*
* Reads income-statement result before tax and adds the manual adjustments
* the user provided (non-deductible expenses, schablonintäkt, etc.). The
- * resulting taxable result is rounded down to nearest whole krona before
- * applying the tax rate, per SFL 22 kap 1 §.
+ * resulting taxable result is rounded down to the nearest whole 10 SEK before
+ * applying the tax rate, per IL 1 kap 7 §.
*
* If the period shows a loss, no tax is proposed: Swedish AB accumulate
* inrullat underskott for future offset, but that bookkeeping is handled
@@ -199,9 +224,9 @@ export async function calculateBolagsskatt(
schablonintaktPeriodiseringsfond +
otherAdjustments
- // Truncate to whole krona before applying rate. Negative taxable result =
- // no tax provision (handled as inrullat underskott in INK2, not here).
- const taxableResultClamped = Math.max(0, Math.floor(taxableResult))
+ // Round down to a whole 10 SEK before applying the rate. Negative taxable
+ // result means no tax provision (handled as inrullat underskott in INK2).
+ const taxableResultClamped = Math.floor(Math.max(0, taxableResult) / 10) * 10
const taxAmount = Math.round(taxableResultClamped * BOLAGSSKATT_RATE)
const computation: BolagsskattComputation = {
diff --git a/lib/bokslut/tax-provision/tax-adjustment-service.ts b/lib/bokslut/tax-provision/tax-adjustment-service.ts
new file mode 100644
index 00000000..79370380
--- /dev/null
+++ b/lib/bokslut/tax-provision/tax-adjustment-service.ts
@@ -0,0 +1,195 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { roundOre } from '@/lib/money'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import type {
+ TaxAdjustmentItem,
+ TaxAdjustmentSnapshot,
+ TaxAdjustmentType,
+} from '../types'
+
+export const DETECTED_TAX_ADJUSTMENT_ACCOUNTS = [
+ {
+ accountNumber: '6992',
+ sourceKey: 'account:6992',
+ adjustmentType: 'non_deductible_expense' as const,
+ description: 'Övriga externa kostnader, ej avdragsgilla',
+ },
+ {
+ accountNumber: '8423',
+ sourceKey: 'account:8423',
+ adjustmentType: 'non_deductible_expense' as const,
+ description: 'Räntekostnader för skatter och avgifter',
+ },
+] as const
+
+const MANUAL_ADJUSTMENTS = [
+ {
+ sourceKey: 'manual:non_deductible_expenses',
+ adjustmentType: 'non_deductible_expense' as const,
+ description: 'Ytterligare ej avdragsgilla kostnader',
+ },
+ {
+ sourceKey: 'manual:non_taxable_income',
+ adjustmentType: 'non_taxable_income' as const,
+ description: 'Ej skattepliktiga intäkter',
+ },
+] as const
+
+interface PersistedAdjustmentRow {
+ source_key: string
+ adjustment_type: TaxAdjustmentType
+ source: 'detected' | 'manual'
+ description: string
+ account_number: string | null
+ amount: number | string
+ included: boolean
+}
+
+export interface SaveTaxAdjustmentsInput {
+ manualAdjustments: {
+ nonDeductibleExpenses: number
+ nonTaxableIncome: number
+ }
+ detectedAccounts: Record<(typeof DETECTED_TAX_ADJUSTMENT_ACCOUNTS)[number]['accountNumber'], boolean>
+}
+
+export async function loadTaxAdjustmentSnapshot(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+): Promise {
+ const [trialBalance, persistedResult] = await Promise.all([
+ generateTrialBalance(supabase, companyId, fiscalPeriodId, {
+ excludeYearEndClosing: true,
+ }),
+ supabase
+ .from('fiscal_period_tax_adjustments')
+ .select('source_key, adjustment_type, source, description, account_number, amount, included')
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', fiscalPeriodId),
+ ])
+
+ if (persistedResult.error) {
+ throw new Error(`Failed to load tax adjustments: ${persistedResult.error.message}`)
+ }
+
+ const persistedByKey = new Map(
+ ((persistedResult.data ?? []) as PersistedAdjustmentRow[]).map((row) => [row.source_key, row]),
+ )
+ const trialBalanceByAccount = new Map(
+ trialBalance.rows.map((row) => [row.account_number, row]),
+ )
+
+ const detectedItems: TaxAdjustmentItem[] = DETECTED_TAX_ADJUSTMENT_ACCOUNTS.map((config) => {
+ const row = trialBalanceByAccount.get(config.accountNumber)
+ const amount = roundOre(Math.max(0, (row?.closing_debit ?? 0) - (row?.closing_credit ?? 0)))
+ const persisted = persistedByKey.get(config.sourceKey)
+ return {
+ sourceKey: config.sourceKey,
+ source: 'detected',
+ adjustmentType: config.adjustmentType,
+ description: config.description,
+ accountNumber: config.accountNumber,
+ amount,
+ included: persisted?.included ?? amount > 0,
+ }
+ })
+
+ const manualItems: TaxAdjustmentItem[] = MANUAL_ADJUSTMENTS.map((config) => {
+ const persisted = persistedByKey.get(config.sourceKey)
+ const amount = roundOre(Math.max(0, Number(persisted?.amount) || 0))
+ return {
+ sourceKey: config.sourceKey,
+ source: 'manual',
+ adjustmentType: config.adjustmentType,
+ description: config.description,
+ accountNumber: null,
+ amount,
+ included: amount > 0,
+ }
+ })
+
+ return summarizeTaxAdjustments([...detectedItems, ...manualItems])
+}
+
+export async function saveTaxAdjustments(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+ userId: string,
+ input: SaveTaxAdjustmentsInput,
+): Promise {
+ const current = await loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId)
+ const detectedAmounts = new Map(
+ current.items
+ .filter((item) => item.source === 'detected')
+ .map((item) => [item.sourceKey, item.amount]),
+ )
+
+ const rows = [
+ ...DETECTED_TAX_ADJUSTMENT_ACCOUNTS.map((config) => ({
+ company_id: companyId,
+ user_id: userId,
+ fiscal_period_id: fiscalPeriodId,
+ adjustment_type: config.adjustmentType,
+ source: 'detected',
+ source_key: config.sourceKey,
+ description: config.description,
+ account_number: config.accountNumber,
+ amount: detectedAmounts.get(config.sourceKey) ?? 0,
+ included: input.detectedAccounts[config.accountNumber],
+ })),
+ {
+ company_id: companyId,
+ user_id: userId,
+ fiscal_period_id: fiscalPeriodId,
+ adjustment_type: 'non_deductible_expense',
+ source: 'manual',
+ source_key: 'manual:non_deductible_expenses',
+ description: 'Ytterligare ej avdragsgilla kostnader',
+ account_number: null,
+ amount: roundOre(input.manualAdjustments.nonDeductibleExpenses),
+ included: input.manualAdjustments.nonDeductibleExpenses > 0,
+ },
+ {
+ company_id: companyId,
+ user_id: userId,
+ fiscal_period_id: fiscalPeriodId,
+ adjustment_type: 'non_taxable_income',
+ source: 'manual',
+ source_key: 'manual:non_taxable_income',
+ description: 'Ej skattepliktiga intäkter',
+ account_number: null,
+ amount: roundOre(input.manualAdjustments.nonTaxableIncome),
+ included: input.manualAdjustments.nonTaxableIncome > 0,
+ },
+ ]
+
+ const { error } = await supabase
+ .from('fiscal_period_tax_adjustments')
+ .upsert(rows, { onConflict: 'company_id,fiscal_period_id,source_key' })
+
+ if (error) {
+ throw new Error(`Failed to save tax adjustments: ${error.message}`)
+ }
+}
+
+function summarizeTaxAdjustments(items: TaxAdjustmentItem[]): TaxAdjustmentSnapshot {
+ let nonDeductibleExpenses = 0
+ let nonTaxableIncome = 0
+
+ for (const item of items) {
+ if (!item.included) continue
+ if (item.adjustmentType === 'non_deductible_expense') {
+ nonDeductibleExpenses += item.amount
+ } else {
+ nonTaxableIncome += item.amount
+ }
+ }
+
+ return {
+ items,
+ nonDeductibleExpenses: roundOre(nonDeductibleExpenses),
+ nonTaxableIncome: roundOre(nonTaxableIncome),
+ }
+}
diff --git a/lib/bokslut/types.ts b/lib/bokslut/types.ts
index 25a06872..0de1b331 100644
--- a/lib/bokslut/types.ts
+++ b/lib/bokslut/types.ts
@@ -34,6 +34,33 @@ export interface ProposedDisposition {
required?: boolean
}
+export type TaxAdjustmentType = 'non_deductible_expense' | 'non_taxable_income'
+export type TaxAdjustmentSource = 'detected' | 'manual'
+
+export interface TaxAdjustmentItem {
+ sourceKey: string
+ source: TaxAdjustmentSource
+ adjustmentType: TaxAdjustmentType
+ description: string
+ accountNumber: string | null
+ amount: number
+ included: boolean
+}
+
+export interface TaxAdjustmentSnapshot {
+ items: TaxAdjustmentItem[]
+ nonDeductibleExpenses: number
+ nonTaxableIncome: number
+}
+
+export interface CompletedDisposition {
+ kind: DispositionKind
+ label: string
+ amount: number
+ status: 'booked' | 'needs_correction'
+ warnings: string[]
+}
+
/**
* Snapshot of all proposed dispositions for a fiscal period, returned by the
* dispositions API. Order is the suggested user-flow order: p-fond återföring
@@ -52,4 +79,6 @@ export interface DispositionsProposal {
/** Result before any new dispositions, from the income statement (positive = profit). */
netResultBefore: number
proposals: ProposedDisposition[]
+ taxAdjustments?: TaxAdjustmentSnapshot
+ completedDispositions?: CompletedDisposition[]
}
diff --git a/lib/company/actions.ts b/lib/company/actions.ts
index 3cf4a120..c3e326be 100644
--- a/lib/company/actions.ts
+++ b/lib/company/actions.ts
@@ -11,6 +11,7 @@ import {
} from '@/lib/tax/deadline-generator'
import type { CompanySettingsForDeadlines } from '@/lib/tax/deadline-config'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* Switch the active company. Returns an error *code* (translated by the
@@ -76,8 +77,7 @@ export async function createCompanyFromOnboarding(params: {
// and a redacted message in prod. Logging the full error here gives
// us a server-side trace and returns a localized fallback to the UI.
console.error('[createCompanyFromOnboarding] unexpected error', err)
- const message = err instanceof Error ? err.message : String(err)
- return { error: message || 'Något gick fel när företaget skulle skapas. Försök igen.' }
+ return { error: getErrorMessage(err, { context: 'settings' }) }
}
}
diff --git a/lib/core/audit/__tests__/audit-service.test.ts b/lib/core/audit/__tests__/audit-service.test.ts
new file mode 100644
index 00000000..a27ee2e5
--- /dev/null
+++ b/lib/core/audit/__tests__/audit-service.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it, vi } from 'vitest'
+import { getAuditLog } from '../audit-service'
+
+function createQuery(result: { data: unknown[]; error: unknown; count: number | null }) {
+ const query = {
+ select: vi.fn(),
+ eq: vi.fn(),
+ order: vi.fn(),
+ range: vi.fn(),
+ }
+ query.select.mockReturnValue(query)
+ query.eq.mockReturnValue(query)
+ query.order.mockReturnValue(query)
+ query.range.mockResolvedValue(result)
+ return query
+}
+
+describe('getAuditLog', () => {
+ it('requests an exact count for interactive pagination', async () => {
+ const query = createQuery({ data: [], error: null, count: 12 })
+ const supabase = { from: vi.fn(() => query) }
+
+ const result = await getAuditLog(supabase as never, 'company-1')
+
+ expect(query.select).toHaveBeenCalledWith('*', { count: 'exact' })
+ expect(result.count).toBe(12)
+ })
+
+ it('skips the exact count for full archive exports', async () => {
+ const query = createQuery({ data: [], error: null, count: null })
+ const supabase = { from: vi.fn(() => query) }
+
+ const result = await getAuditLog(supabase as never, 'company-1', {
+ includeCount: false,
+ pageSize: 500,
+ })
+
+ expect(query.select).toHaveBeenCalledWith('*')
+ expect(query.order).toHaveBeenNthCalledWith(1, 'created_at', { ascending: false })
+ expect(query.order).toHaveBeenNthCalledWith(2, 'id', { ascending: false })
+ expect(result.count).toBe(0)
+ })
+})
diff --git a/lib/core/audit/audit-service.ts b/lib/core/audit/audit-service.ts
index f554ed72..81cf1fa2 100644
--- a/lib/core/audit/audit-service.ts
+++ b/lib/core/audit/audit-service.ts
@@ -16,6 +16,8 @@ export interface AuditLogFilters {
to_date?: string
page?: number
pageSize?: number
+ /** Full exports can skip the expensive exact count and stop on a short page. */
+ includeCount?: boolean
}
/**
@@ -28,13 +30,16 @@ export async function getAuditLog(
): Promise<{ data: AuditLogEntry[]; count: number }> {
const page = filters.page ?? 1
const pageSize = filters.pageSize ?? 50
+ const includeCount = filters.includeCount ?? true
const offset = (page - 1) * pageSize
- let query = supabase
- .from('audit_log')
- .select('*', { count: 'exact' })
+ const auditTable = supabase.from('audit_log')
+ let query = (includeCount
+ ? auditTable.select('*', { count: 'exact' })
+ : auditTable.select('*'))
.eq('company_id', companyId)
.order('created_at', { ascending: false })
+ .order('id', { ascending: false })
.range(offset, offset + pageSize - 1)
if (filters.action) {
@@ -61,7 +66,7 @@ export async function getAuditLog(
return {
data: (data as AuditLogEntry[]) || [],
- count: count ?? 0,
+ count: includeCount ? count ?? 0 : 0,
}
}
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index dada78fe..38783edc 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -1181,6 +1181,14 @@ const YEAR_END: Record = {
message_sv: 'Nästa räkenskapsperiod har redan ingående balanser bokförda. Storno dem innan du kör om bokslutet.',
message_en: 'Next fiscal period already has opening balances posted; reverse them before re-running year-end.',
},
+ YEAR_END_NO_ACTIVITY: {
+ httpStatus: 409,
+ message_sv:
+ 'Räkenskapsperioden saknar bokförd aktivitet och kan därför inte skapa en bokslutsverifikation. Bokför eller importera periodens affärshändelser innan du kör bokslutet.',
+ message_en:
+ 'The fiscal period has no posted activity, so no year-end voucher can be created. Post or import the period activity before running year-end closing.',
+ retryable: false,
+ },
}
const OPENING_BAL: Record = {
@@ -2775,6 +2783,98 @@ const BOLAGSVERKET: Record = {
message_en:
'No Bolagsverket event subscription exists for this company yet. One is created on the first submission.',
},
+ BOLAGSVERKET_NOT_RELEASED: {
+ httpStatus: 503,
+ message_sv:
+ 'Direktinlämning till Bolagsverket är inte öppnad i den här installationen. Använd pappersflödet tills anslutningen är godkänd.',
+ message_en:
+ 'Connected filing to Bolagsverket is not enabled for this installation. Use the paper flow until acceptance is complete.',
+ retryable: false,
+ },
+ BOLAGSVERKET_VERSION_NOT_FOUND: {
+ httpStatus: 404,
+ message_sv: 'Den valda versionen av årsredovisningen finns inte.',
+ message_en: 'The selected annual report version was not found.',
+ retryable: false,
+ },
+ BOLAGSVERKET_VERSION_NOT_SIGNED: {
+ httpStatus: 409,
+ message_sv: 'Årsredovisningsversionen måste vara låst och undertecknad före inlämning.',
+ message_en: 'The annual report version must be finalized and signed before submission.',
+ retryable: false,
+ },
+ BOLAGSVERKET_DIGITAL_INELIGIBLE: {
+ httpStatus: 409,
+ message_sv:
+ 'Den låsta årsredovisningsversionen är inte godkänd för digital inlämning. Använd pappersflödet och följ kontrollpunkterna i årsredovisningsstudion.',
+ message_en:
+ 'The locked annual report version is not eligible for connected filing. Use the paper workflow and review the Annual Report Studio checks.',
+ retryable: false,
+ },
+ BOLAGSVERKET_SIGNATURE_EVIDENCE_INCOMPLETE: {
+ httpStatus: 409,
+ message_sv: 'Verifierbart underskriftsunderlag saknas för en eller flera undertecknare.',
+ message_en: 'Verifiable signature evidence is missing for one or more required signers.',
+ retryable: false,
+ },
+ BOLAGSVERKET_CERTIFICATE_SIGNER_MISMATCH: {
+ httpStatus: 409,
+ message_sv:
+ 'Undertecknaren av fastställelseintyget stämmer inte med den person som låstes i årsredovisningsversionen.',
+ message_en:
+ 'The certificate signer does not match the person locked into the annual report version.',
+ retryable: false,
+ },
+ BOLAGSVERKET_ARELLE_UNAVAILABLE: {
+ httpStatus: 503,
+ message_sv:
+ 'Taxonomivalideringen med Arelle är inte tillgänglig. Inlämningen har stoppats innan något skickades.',
+ message_en:
+ 'Arelle taxonomy validation is unavailable. Filing was stopped before anything was sent.',
+ retryable: true,
+ },
+ BOLAGSVERKET_ARELLE_FAILED: {
+ httpStatus: 409,
+ message_sv:
+ 'Arelle hittade blockerande fel i iXBRL-dokumentet. Rätta felen och skapa en ny version.',
+ message_en:
+ 'Arelle found blocking errors in the iXBRL document. Correct them and create a new version.',
+ retryable: false,
+ },
+ ARSREDOVISNING_INCOMPLETE: {
+ httpStatus: 409,
+ message_sv: 'Årsredovisningen har blockerande kontrollfel och kan inte låsas ännu.',
+ message_en: 'The annual report has blocking validation errors and cannot be finalized yet.',
+ retryable: false,
+ },
+ ARSREDOVISNING_VERSION_NOT_SIGNABLE: {
+ httpStatus: 409,
+ message_sv: 'Den valda årsredovisningsversionen är inte öppen för underskrift.',
+ message_en: 'The selected annual report version is not open for signing.',
+ retryable: false,
+ },
+ ARSREDOVISNING_SIGNATURE_DATE_INVALID: {
+ httpStatus: 400,
+ message_sv:
+ 'Underskriftsdatumet måste vara samma dag som eller senare än versionens låsdatum och får inte ligga i framtiden.',
+ message_en:
+ 'The signature date must be on or after the version finalization date and cannot be in the future.',
+ retryable: false,
+ },
+ ARSREDOVISNING_SIGNER_ROSTER_LOCKED: {
+ httpStatus: 409,
+ message_sv:
+ 'Undertecknarlistan är låst eftersom en årsredovisningsversion redan väntar på underskrift.',
+ message_en:
+ 'The signer roster is locked because an annual report version is already awaiting signatures.',
+ retryable: false,
+ },
+ ARSREDOVISNING_SIGNER_ALREADY_EXISTS: {
+ httpStatus: 409,
+ message_sv: 'Undertecknaren finns redan i den aktuella undertecknarlistan.',
+ message_en: 'The signer is already present in the current signer roster.',
+ retryable: false,
+ },
ARSREDOVISNING_REGISTERED: {
httpStatus: 409,
message_sv:
diff --git a/lib/invoices/__tests__/pdf-render-helpers.test.ts b/lib/invoices/__tests__/pdf-render-helpers.test.ts
index a1cc3a3d..219cc480 100644
--- a/lib/invoices/__tests__/pdf-render-helpers.test.ts
+++ b/lib/invoices/__tests__/pdf-render-helpers.test.ts
@@ -12,10 +12,21 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
import sharp from 'sharp'
import { prepareInvoicePdfRender, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { makeCompanySettings, makeInvoice } from '@/tests/helpers'
+const fontDownloadMock = vi.hoisted(() => vi.fn())
+vi.mock('@/lib/supabase/server', () => ({
+ createServiceClient: () => ({
+ storage: {
+ from: () => ({ download: fontDownloadMock }),
+ },
+ }),
+}))
+
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
const SVG_LOGO = Buffer.from(
@@ -47,9 +58,11 @@ async function expectValidEmbeddedPng(logoUrl: string | null | undefined) {
describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
beforeEach(() => {
vi.unstubAllGlobals()
+ fontDownloadMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
+ vi.unstubAllEnvs()
vi.restoreAllMocks()
})
@@ -128,7 +141,7 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
ok: true,
headers: {
get: (h: string) =>
- h.toLowerCase() === 'content-length' ? String(6 * 1024 * 1024) : 'image/png',
+ h.toLowerCase() === 'content-length' ? String(11 * 1024 * 1024) : 'image/png',
},
arrayBuffer: async () => new ArrayBuffer(0),
})
@@ -178,6 +191,77 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
})
})
+describe('prepareInvoicePdfRender: invoice fonts', () => {
+ beforeEach(() => {
+ vi.unstubAllGlobals()
+ fontDownloadMock.mockReset()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.unstubAllEnvs()
+ vi.restoreAllMocks()
+ })
+
+ it('registers a bundled font before returning it to the PDF template', async () => {
+ const company = makeCompanySettings({ invoice_font_family: 'Source Sans 3' })
+
+ const { branding } = await prepareInvoicePdfRender(company)
+
+ expect(branding.fontFamily).toBe('Source Sans 3')
+ })
+
+ it('registers a valid stored custom font under an isolated render family', async () => {
+ const bytes = await readFile(
+ join(process.cwd(), 'public', 'fonts', 'invoice', 'SourceSans3-Regular.ttf'),
+ )
+ const arrayBuffer = bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength,
+ )
+ fontDownloadMock.mockResolvedValue({
+ data: new Blob([arrayBuffer]),
+ error: null,
+ })
+ const company = makeCompanySettings({
+ invoice_font_family: 'Custom',
+ invoice_custom_font_path: 'company-1/invoice-font-1.ttf',
+ })
+
+ const { branding } = await prepareInvoicePdfRender(company)
+
+ expect(branding.fontFamily).toMatch(/^InvoiceCustom-[0-9a-f]{12}$/)
+ })
+
+ it('falls back to Helvetica when a custom font cannot be parsed', async () => {
+ const corruptedFont = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0xff, 0xff])
+ fontDownloadMock.mockResolvedValue({
+ data: new Blob([corruptedFont]),
+ error: null,
+ })
+ const company = makeCompanySettings({
+ invoice_font_family: 'Custom',
+ invoice_custom_font_path: 'company-1/invoice-font-2.ttf',
+ })
+
+ const { branding } = await prepareInvoicePdfRender(company)
+
+ expect(branding.fontFamily).toBe('Helvetica')
+ })
+
+ it('does not download a custom font from another company path', async () => {
+ const company = makeCompanySettings({
+ invoice_font_family: 'Custom',
+ invoice_custom_font_path: 'company-2/invoice-font-3.ttf',
+ })
+
+ const { branding } = await prepareInvoicePdfRender(company)
+
+ expect(fontDownloadMock).not.toHaveBeenCalled()
+ expect(branding.fontFamily).toBe('Helvetica')
+ })
+})
+
describe('buildPaymentLinkQrDataUrl', () => {
it('encodes the payment link as a PNG QR data URL for a real invoice', async () => {
const invoice = makeInvoice({ payment_link_url: 'https://buy.stripe.com/test_abc123' })
diff --git a/lib/invoices/branding-constants.ts b/lib/invoices/branding-constants.ts
new file mode 100644
index 00000000..e6cfc217
--- /dev/null
+++ b/lib/invoices/branding-constants.ts
@@ -0,0 +1,31 @@
+import type { InvoiceFontFamily } from '@/types'
+
+export const LOGO_UPLOAD_MAX_MB = 10
+export const LOGO_UPLOAD_MAX_BYTES = LOGO_UPLOAD_MAX_MB * 1024 * 1024
+
+export const INVOICE_LOGO_MAX_WIDTH_PT = 240
+export const INVOICE_LOGO_MAX_HEIGHT_PT = 80
+
+export const INVOICE_FONT_FAMILIES = [
+ 'Helvetica',
+ 'Times-Roman',
+ 'Courier',
+ 'Source Sans 3',
+ 'Source Serif 4',
+ 'Custom',
+] as const satisfies readonly InvoiceFontFamily[]
+
+export const STANDARD_PDF_FONT_FAMILIES = [
+ 'Helvetica',
+ 'Times-Roman',
+ 'Courier',
+] as const satisfies readonly InvoiceFontFamily[]
+
+export const BUNDLED_INVOICE_FONT_FAMILIES = [
+ 'Source Sans 3',
+ 'Source Serif 4',
+] as const satisfies readonly InvoiceFontFamily[]
+
+export const CUSTOM_INVOICE_FONT_FAMILY = 'Custom' as const
+export const INVOICE_FONT_UPLOAD_MAX_MB = 5
+export const INVOICE_FONT_UPLOAD_MAX_BYTES = INVOICE_FONT_UPLOAD_MAX_MB * 1024 * 1024
diff --git a/lib/invoices/font-files.ts b/lib/invoices/font-files.ts
new file mode 100644
index 00000000..06c163e7
--- /dev/null
+++ b/lib/invoices/font-files.ts
@@ -0,0 +1,32 @@
+export type InvoiceFontFileFormat = 'ttf' | 'woff'
+
+export function detectInvoiceFontFileFormat(
+ bytes: Uint8Array,
+): InvoiceFontFileFormat | null {
+ if (bytes.byteLength < 4) return null
+
+ if (
+ bytes[0] === 0x00 &&
+ bytes[1] === 0x01 &&
+ bytes[2] === 0x00 &&
+ bytes[3] === 0x00
+ ) {
+ return 'ttf'
+ }
+
+ const signature = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3])
+ if (signature === 'true') return 'ttf'
+ if (signature === 'wOFF') return 'woff'
+ return null
+}
+
+export function getInvoiceFontContentType(format: InvoiceFontFileFormat): string {
+ return format === 'ttf' ? 'font/ttf' : 'font/woff'
+}
+
+export function toInvoiceFontDataUrl(
+ bytes: Uint8Array,
+ format: InvoiceFontFileFormat,
+): string {
+ return `data:${getInvoiceFontContentType(format)};base64,${Buffer.from(bytes).toString('base64')}`
+}
diff --git a/lib/invoices/pdf-fonts.ts b/lib/invoices/pdf-fonts.ts
new file mode 100644
index 00000000..efc5d915
--- /dev/null
+++ b/lib/invoices/pdf-fonts.ts
@@ -0,0 +1,220 @@
+import { createHash } from 'node:crypto'
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { Font } from '@react-pdf/renderer'
+import type { CompanySettings, InvoiceFontFamily } from '@/types'
+import type { InvoiceBranding } from '@/lib/invoices/pdf-template'
+import {
+ BUNDLED_INVOICE_FONT_FAMILIES,
+ CUSTOM_INVOICE_FONT_FAMILY,
+ INVOICE_FONT_UPLOAD_MAX_BYTES,
+ STANDARD_PDF_FONT_FAMILIES,
+} from '@/lib/invoices/branding-constants'
+import {
+ detectInvoiceFontFileFormat,
+ toInvoiceFontDataUrl,
+} from '@/lib/invoices/font-files'
+import { createLogger } from '@/lib/logger'
+import { createServiceClient } from '@/lib/supabase/server'
+
+export const CUSTOM_INVOICE_FONT_RENDER_PREFIX = 'InvoiceCustom-'
+
+type BundledInvoiceFontFamily = (typeof BUNDLED_INVOICE_FONT_FAMILIES)[number]
+
+interface FontVariantFiles {
+ regular: string
+ bold: string
+ italic: string
+ boldItalic: string
+}
+
+const BUNDLED_FONT_FILES: Record = {
+ 'Source Sans 3': {
+ regular: 'SourceSans3-Regular.ttf',
+ bold: 'SourceSans3-Bold.ttf',
+ italic: 'SourceSans3-Italic.ttf',
+ boldItalic: 'SourceSans3-BoldItalic.ttf',
+ },
+ 'Source Serif 4': {
+ regular: 'SourceSerif4-Regular.ttf',
+ bold: 'SourceSerif4-Bold.ttf',
+ italic: 'SourceSerif4-Italic.ttf',
+ boldItalic: 'SourceSerif4-BoldItalic.ttf',
+ },
+}
+
+const log = createLogger('invoice.pdf-fonts')
+const registeredFamilies = new Set()
+const bundledRegistration = new Map>()
+const customRegistration = new Map>()
+const invalidCustomFontPaths = new Set()
+
+function isStandardPdfFont(
+ family: InvoiceFontFamily,
+): family is (typeof STANDARD_PDF_FONT_FAMILIES)[number] {
+ return STANDARD_PDF_FONT_FAMILIES.includes(
+ family as (typeof STANDARD_PDF_FONT_FAMILIES)[number],
+ )
+}
+
+function isBundledInvoiceFont(
+ family: InvoiceFontFamily,
+): family is BundledInvoiceFontFamily {
+ return BUNDLED_INVOICE_FONT_FAMILIES.includes(
+ family as BundledInvoiceFontFamily,
+ )
+}
+
+async function readBundledFont(fileName: string): Promise {
+ const bytes = await readFile(join(process.cwd(), 'public', 'fonts', 'invoice', fileName))
+ const format = detectInvoiceFontFileFormat(bytes)
+ if (!format) throw new Error(`Invalid bundled invoice font: ${fileName}`)
+ return toInvoiceFontDataUrl(bytes, format)
+}
+
+async function registerBundledFont(family: BundledInvoiceFontFamily): Promise {
+ if (registeredFamilies.has(family)) return
+
+ let registration = bundledRegistration.get(family)
+ if (!registration) {
+ registration = (async () => {
+ const files = BUNDLED_FONT_FILES[family]
+ const [regular, bold, italic, boldItalic] = await Promise.all([
+ readBundledFont(files.regular),
+ readBundledFont(files.bold),
+ readBundledFont(files.italic),
+ readBundledFont(files.boldItalic),
+ ])
+
+ Font.register({
+ family,
+ fonts: [
+ { src: regular, fontWeight: 400, fontStyle: 'normal' },
+ { src: bold, fontWeight: 700, fontStyle: 'normal' },
+ { src: italic, fontWeight: 400, fontStyle: 'italic' },
+ { src: boldItalic, fontWeight: 700, fontStyle: 'italic' },
+ ],
+ })
+ await Promise.all([
+ Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'normal' }),
+ Font.load({ fontFamily: family, fontWeight: 700, fontStyle: 'normal' }),
+ Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'italic' }),
+ Font.load({ fontFamily: family, fontWeight: 700, fontStyle: 'italic' }),
+ ])
+ registeredFamilies.add(family)
+ })()
+ bundledRegistration.set(family, registration)
+ }
+
+ try {
+ await registration
+ } finally {
+ bundledRegistration.delete(family)
+ }
+}
+
+async function downloadCustomFontDataUrl(
+ companyId: string,
+ storagePath: string,
+): Promise {
+ const pathPrefix = `${companyId}/`
+ const fileName = storagePath.slice(pathPrefix.length)
+ if (
+ !storagePath.startsWith(pathPrefix) ||
+ !/^invoice-font-\d+\.(ttf|woff)$/.test(fileName)
+ ) {
+ return null
+ }
+
+ try {
+ const serviceClient = createServiceClient()
+ const { data, error } = await serviceClient.storage
+ .from('invoice-fonts')
+ .download(storagePath)
+ if (error || !data || data.size > INVOICE_FONT_UPLOAD_MAX_BYTES) return null
+
+ const bytes = new Uint8Array(await data.arrayBuffer())
+ if (bytes.byteLength > INVOICE_FONT_UPLOAD_MAX_BYTES) return null
+ const format = detectInvoiceFontFileFormat(bytes)
+ return format ? toInvoiceFontDataUrl(bytes, format) : null
+ } catch {
+ return null
+ }
+}
+
+async function registerCustomFont(company: CompanySettings): Promise {
+ const storagePath = company.invoice_custom_font_path
+ if (!storagePath || invalidCustomFontPaths.has(storagePath)) return null
+
+ const digest = createHash('sha256').update(storagePath).digest('hex').slice(0, 12)
+ const family = `${CUSTOM_INVOICE_FONT_RENDER_PREFIX}${digest}`
+ if (registeredFamilies.has(family)) return family
+
+ let registration = customRegistration.get(storagePath)
+ if (!registration) {
+ registration = (async () => {
+ const src = await downloadCustomFontDataUrl(company.company_id, storagePath)
+ if (!src) return null
+
+ Font.register({
+ family,
+ fonts: [
+ { src, fontWeight: 400, fontStyle: 'normal' },
+ { src, fontWeight: 700, fontStyle: 'normal' },
+ { src, fontWeight: 400, fontStyle: 'italic' },
+ { src, fontWeight: 700, fontStyle: 'italic' },
+ ],
+ })
+ try {
+ await Font.load({ fontFamily: family, fontWeight: 400, fontStyle: 'normal' })
+ } catch {
+ invalidCustomFontPaths.add(storagePath)
+ return null
+ }
+ registeredFamilies.add(family)
+ return family
+ })()
+ customRegistration.set(storagePath, registration)
+ }
+
+ try {
+ return await registration
+ } finally {
+ customRegistration.delete(storagePath)
+ }
+}
+
+function withHelveticaFallback(branding: InvoiceBranding): InvoiceBranding {
+ return { ...branding, fontFamily: 'Helvetica' }
+}
+
+export async function prepareInvoiceFont(
+ company: CompanySettings,
+ branding: InvoiceBranding,
+): Promise {
+ const family = branding.fontFamily ?? 'Helvetica'
+ if (isStandardPdfFont(family as InvoiceFontFamily)) return branding
+
+ if (isBundledInvoiceFont(family as InvoiceFontFamily)) {
+ try {
+ await registerBundledFont(family as BundledInvoiceFontFamily)
+ return branding
+ } catch (error) {
+ log.warn('bundled invoice font registration failed', {
+ family,
+ error: error instanceof Error ? error.message : String(error),
+ })
+ return withHelveticaFallback(branding)
+ }
+ }
+
+ if (family === CUSTOM_INVOICE_FONT_FAMILY) {
+ const registeredFamily = await registerCustomFont(company)
+ if (registeredFamily) return { ...branding, fontFamily: registeredFamily }
+ log.warn('custom invoice font unavailable, using Helvetica', {
+ companyId: company.company_id,
+ })
+ }
+
+ return withHelveticaFallback(branding)
+}
diff --git a/lib/invoices/pdf-render-helpers.ts b/lib/invoices/pdf-render-helpers.ts
index 814803a9..b340bae4 100644
--- a/lib/invoices/pdf-render-helpers.ts
+++ b/lib/invoices/pdf-render-helpers.ts
@@ -25,6 +25,8 @@ import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBrandin
import { buildSwishQrPayload } from '@/lib/payments/swish'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { createLogger } from '@/lib/logger'
+import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
+import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
const log = createLogger('invoice.swish-qr')
const paymentLinkLog = createLogger('invoice.payment-link-qr')
@@ -50,9 +52,8 @@ const LOGO_CACHE_TTL_MS = 5 * 60 * 1000
const LOGO_CACHE_MAX = 50
const logoDataUrlCache = new Map()
-// The invoice draws the logo at maxWidth 150pt / maxHeight 40pt (~200px at
-// print resolution), so 600px keeps it crisp while bounding the embedded
-// base64 payload.
+// The invoice draws the logo at up to 240pt by 80pt, so 600px keeps it crisp
+// while bounding the embedded base64 payload.
const LOGO_MAX_PX = 600
// Bound the logo fetch so a slow or oversized response can't hang or balloon an
@@ -60,7 +61,6 @@ const LOGO_MAX_PX = 600
// URL (set only by the upload route), so SSRF is not reachable today: these
// caps are defense-in-depth for that invariant plus plain robustness.
const LOGO_FETCH_TIMEOUT_MS = 5_000
-const LOGO_MAX_BYTES = 5 * 1024 * 1024 // 5 MB: generous for a logo, bounds memory
// Coalesce concurrent renders of the same logo (preflight + final on a send, and
// every invoice in a recurring/batch loop) onto one in-flight fetch+encode
@@ -103,9 +103,9 @@ async function encodeLogo(logoUrl: string): Promise {
// Reject oversized payloads up front when the server declares a length, and
// again after reading in case the header lied or was absent.
const declared = Number(res.headers.get('content-length') ?? '')
- if (Number.isFinite(declared) && declared > LOGO_MAX_BYTES) return null
+ if (Number.isFinite(declared) && declared > LOGO_UPLOAD_MAX_BYTES) return null
const input = Buffer.from(await res.arrayBuffer())
- if (input.byteLength > LOGO_MAX_BYTES) return null
+ if (input.byteLength > LOGO_UPLOAD_MAX_BYTES) return null
// SVGs must be rasterized at a higher density or sharp renders them at
// their intrinsic (often tiny) pixel size and the result looks blurry.
@@ -145,7 +145,10 @@ async function encodeLogo(logoUrl: string): Promise {
export async function prepareInvoicePdfRender(
company: CompanySettings,
): Promise {
- const branding = brandingFromCompanySettings(company)
+ const branding = await prepareInvoiceFont(
+ company,
+ brandingFromCompanySettings(company),
+ )
if (!company.logo_url) return { branding, company }
const dataUrl = await resolveLogoDataUrl(company.logo_url)
diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx
index b311ca4e..6cda93bb 100644
--- a/lib/invoices/pdf-template.tsx
+++ b/lib/invoices/pdf-template.tsx
@@ -9,6 +9,13 @@ import {
} from '@react-pdf/renderer'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { generateOcrReference } from '@/lib/bankgiro/luhn'
+import {
+ BUNDLED_INVOICE_FONT_FAMILIES,
+ INVOICE_LOGO_MAX_HEIGHT_PT,
+ INVOICE_LOGO_MAX_WIDTH_PT,
+ STANDARD_PDF_FONT_FAMILIES,
+} from '@/lib/invoices/branding-constants'
+import { CUSTOM_INVOICE_FONT_RENDER_PREFIX } from '@/lib/invoices/pdf-fonts'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { isTextLikeLine } from '@/lib/invoices/display'
@@ -188,8 +195,7 @@ export interface InvoiceBranding {
/** Accent color: used for muted labels and section headings.
* Default '#666666' (the existing hardcoded value). */
accentColor?: string
- /** Font family: must be one of react-pdf's built-in PostScript fonts.
- * Default 'Helvetica'. */
+ /** Registered react-pdf font family. Default 'Helvetica'. */
fontFamily?: string
/** Optional banner text rendered above the document title. */
headerText?: string | null
@@ -203,10 +209,10 @@ interface ResolvedBranding {
fontFamily: string
}
-// react-pdf only ships these three PostScript fonts. Anything else would
-// require registerFont() with a binary file, out of scope for AGPL-clean
-// branding and a fingerprinting risk besides.
-const ALLOWED_FONTS = new Set(['Helvetica', 'Times-Roman', 'Courier'])
+const ALLOWED_FONTS = new Set([
+ ...STANDARD_PDF_FONT_FAMILIES,
+ ...BUNDLED_INVOICE_FONT_FAMILIES,
+])
/**
* Extract the InvoicePDF branding shape from a CompanySettings row. Tolerates
@@ -238,7 +244,9 @@ const DEFAULT_BRANDING: ResolvedBranding = {
function resolveBranding(branding: InvoiceBranding | undefined): ResolvedBranding {
if (!branding) return DEFAULT_BRANDING
const fontFamily =
- branding.fontFamily && ALLOWED_FONTS.has(branding.fontFamily)
+ branding.fontFamily &&
+ (ALLOWED_FONTS.has(branding.fontFamily) ||
+ branding.fontFamily.startsWith(CUSTOM_INVOICE_FONT_RENDER_PREFIX))
? branding.fontFamily
: DEFAULT_BRANDING.fontFamily
return {
@@ -730,7 +738,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{company.logo_url && (company.invoice_show_logo ?? true) && (
-
+
)}
{(company.invoice_show_company_name ?? true) &&
(company.invoice_company_name_position ?? 'header') === 'header' && (
diff --git a/lib/pending-operations/__tests__/company-settings-executor.test.ts b/lib/pending-operations/__tests__/company-settings-executor.test.ts
new file mode 100644
index 00000000..fe0cdef3
--- /dev/null
+++ b/lib/pending-operations/__tests__/company-settings-executor.test.ts
@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PendingOperation } from '@/types'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+import { commitPendingOperation } from '../commit'
+
+function makePendingOp(params: Record): PendingOperation {
+ return {
+ id: 'op-settings-1',
+ user_id: 'user-1',
+ company_id: 'company-1',
+ operation_type: 'update_company_settings',
+ status: 'pending',
+ title: 'Update company settings',
+ params,
+ preview_data: {},
+ result_data: null,
+ actor_type: 'api_key',
+ actor_id: 'key-1',
+ actor_label: 'Test key',
+ risk_level: 'medium',
+ agent_metadata: null,
+ rejection_category: null,
+ rejection_reason: null,
+ created_at: '2026-07-21T00:00:00Z',
+ resolved_at: null,
+ updated_at: '2026-07-21T00:00:00Z',
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('commitPendingOperation: update_company_settings', () => {
+ it('updates only validated settings for the selected company', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-settings-1' } })
+ enqueue({
+ data: {
+ bank_name: 'Testbanken',
+ clearing_number: '1234',
+ account_number: '1234567',
+ bankgiro: '5050-1055',
+ plusgiro: null,
+ swish: null,
+ iban: null,
+ bic: null,
+ default_our_reference: 'Test Contact',
+ },
+ })
+ enqueue({ data: null })
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ makePendingOp({
+ changes: {
+ bank_name: 'Testbanken',
+ bankgiro: '5050-1055',
+ default_our_reference: 'Test Contact',
+ },
+ }),
+ )
+
+ expect(result.status).toBe('committed')
+ expect(result.data).toMatchObject({
+ company_id: 'company-1',
+ bankgiro: '5050-1055',
+ contact_person: 'Test Contact',
+ })
+ expect(supabase.from).toHaveBeenNthCalledWith(2, 'company_settings')
+ })
+
+ it('rejects tampered staged fields at the commit boundary', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-settings-1' } })
+ enqueue({ data: null })
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ makePendingOp({
+ changes: {
+ company_id: 'other-company',
+ bankgiro: '5050-1055',
+ },
+ }),
+ )
+
+ expect(result.status).toBe('failed')
+ expect(result.http_status).toBe(400)
+ expect(result.error).toMatch(/unrecognized key/i)
+ expect(supabase.from).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/lib/pending-operations/__tests__/customer-executor.test.ts b/lib/pending-operations/__tests__/customer-executor.test.ts
new file mode 100644
index 00000000..cd1f7905
--- /dev/null
+++ b/lib/pending-operations/__tests__/customer-executor.test.ts
@@ -0,0 +1,252 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PendingOperation } from '@/types'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+import { commitPendingOperation } from '../commit'
+import { validateVatNumber } from '@/lib/vat/vies-client'
+
+vi.mock('@/lib/vat/vies-client', () => ({
+ validateVatNumber: vi.fn(),
+}))
+
+const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
+
+function makePendingOp(params: Record): PendingOperation {
+ return {
+ id: 'op-customer-1',
+ user_id: 'user-1',
+ company_id: 'company-1',
+ operation_type: 'update_customer',
+ status: 'pending',
+ title: 'Update customer',
+ params,
+ preview_data: {},
+ result_data: null,
+ actor_type: 'api_key',
+ actor_id: 'key-1',
+ actor_label: 'Test key',
+ risk_level: 'low',
+ agent_metadata: null,
+ rejection_category: null,
+ rejection_reason: null,
+ created_at: '2026-07-21T00:00:00Z',
+ resolved_at: null,
+ updated_at: '2026-07-21T00:00:00Z',
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('commitPendingOperation: update_customer', () => {
+ it('updates the selected customer and returns the qualified id', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-customer-1' } })
+ enqueue({ data: { customer_type: 'swedish_business' } })
+ enqueue({
+ data: {
+ id: CUSTOMER_ID,
+ name: 'Test Customer AB',
+ customer_type: 'swedish_business',
+ customer_number: '1001',
+ email: 'billing@example.test',
+ phone: '0701234567',
+ address_line1: 'Testgatan 1',
+ address_line2: null,
+ postal_code: '12345',
+ city: 'New City',
+ country: 'Sweden',
+ org_number: '556000-0000',
+ vat_number: null,
+ vat_number_validated: false,
+ language: 'sv',
+ default_payment_terms: 14,
+ notes: null,
+ },
+ })
+ enqueue({ data: null })
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ makePendingOp({
+ customer_id: CUSTOMER_ID,
+ changes: { city: 'New City', default_payment_terms: 14 },
+ }),
+ )
+
+ expect(result.status).toBe('committed')
+ expect(result.data).toMatchObject({
+ customer_id: CUSTOMER_ID,
+ city: 'New City',
+ default_payment_terms: 14,
+ })
+ expect(supabase.from).toHaveBeenNthCalledWith(2, 'customers')
+ expect(supabase.from).toHaveBeenNthCalledWith(3, 'customers')
+ })
+
+ it('auto-rejects when the customer no longer exists', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-customer-1' } })
+ enqueue({ data: null })
+ enqueue({ data: null })
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ makePendingOp({
+ customer_id: CUSTOMER_ID,
+ changes: { city: 'New City' },
+ }),
+ )
+
+ expect(result.status).toBe('rejected')
+ expect(result.auto_rejected).toBe(true)
+ expect(result.http_status).toBe(404)
+ })
+
+ it('revalidates an updated EU VAT number at commit time', async () => {
+ vi.mocked(validateVatNumber).mockResolvedValueOnce({
+ valid: true,
+ name: 'Test Customer GmbH',
+ address: 'Teststrasse 1',
+ country_code: 'DE',
+ vat_number: 'DE123456789',
+ })
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-customer-1' } })
+ enqueue({ data: { customer_type: 'eu_business' } })
+ enqueue({
+ data: {
+ id: CUSTOMER_ID,
+ name: 'Test Customer GmbH',
+ customer_type: 'eu_business',
+ customer_number: null,
+ email: null,
+ phone: null,
+ address_line1: 'Teststrasse 1',
+ address_line2: null,
+ postal_code: '10115',
+ city: 'Berlin',
+ country: 'Germany',
+ org_number: null,
+ vat_number: 'DE123456789',
+ vat_number_validated: true,
+ language: 'en',
+ default_payment_terms: 30,
+ notes: null,
+ },
+ })
+ enqueue({ data: null })
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ makePendingOp({
+ customer_id: CUSTOMER_ID,
+ changes: { vat_number: 'DE123456789' },
+ }),
+ )
+
+ expect(result.status).toBe('committed')
+ expect(result.data).toMatchObject({
+ customer_id: CUSTOMER_ID,
+ vat_number: 'DE123456789',
+ vat_number_validated: true,
+ })
+ expect(validateVatNumber).toHaveBeenCalledWith('DE123456789')
+ })
+
+ it('clears the personal number when an individual becomes a business', async () => {
+ const results = [
+ { data: { id: 'op-customer-1' }, error: null },
+ { data: { customer_type: 'individual' }, error: null },
+ {
+ data: {
+ id: CUSTOMER_ID,
+ name: 'Test Customer AB',
+ customer_type: 'swedish_business',
+ customer_number: null,
+ email: null,
+ phone: null,
+ address_line1: null,
+ address_line2: null,
+ postal_code: null,
+ city: null,
+ country: 'Sweden',
+ org_number: null,
+ vat_number: null,
+ vat_number_validated: false,
+ language: 'sv',
+ default_payment_terms: 30,
+ notes: null,
+ },
+ error: null,
+ },
+ { data: null, error: null },
+ ]
+ const customerUpdates: Record[] = []
+ const supabase = {
+ from: vi.fn((table: string) => {
+ const result = results.shift() ?? { data: null, error: null }
+ const chain: Record = new Proxy({}, {
+ get(_target, prop) {
+ if (prop === 'then') {
+ return (resolve: (value: unknown) => void) => resolve(result)
+ }
+ if (prop === 'update') {
+ return (payload: Record) => {
+ if (table === 'customers') customerUpdates.push(payload)
+ return chain
+ }
+ }
+ return () => chain
+ },
+ })
+ return chain
+ }),
+ }
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ makePendingOp({
+ customer_id: CUSTOMER_ID,
+ changes: { customer_type: 'swedish_business' },
+ }),
+ )
+
+ expect(result.status).toBe('committed')
+ expect(customerUpdates).toEqual([
+ expect.objectContaining({
+ customer_type: 'swedish_business',
+ personal_number: null,
+ }),
+ ])
+ })
+
+ it('rejects tampered fields before reading the customer', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-customer-1' } })
+ enqueue({ data: null })
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ makePendingOp({
+ customer_id: CUSTOMER_ID,
+ changes: { company_id: 'other-company', city: 'New City' },
+ }),
+ )
+
+ expect(result.status).toBe('failed')
+ expect(result.http_status).toBe(400)
+ expect(result.error).toMatch(/unrecognized key/i)
+ expect(supabase.from).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index bb088cd5..83854755 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -81,6 +81,8 @@ import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schem
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
import { CreateAccountParamsSchema, UpdateAccountParamsSchema } from '@/lib/pending-operations/schemas/account'
import { SetVoucherNoteParamsSchema } from '@/lib/pending-operations/schemas/voucher-note'
+import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
+import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
import { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
@@ -279,6 +281,154 @@ async function commitCreateCustomer(
return { data: { customer_id: data.id } }
}
+async function commitUpdateCustomer(
+ supabase: SupabaseClient,
+ companyId: string,
+ params: Record,
+): Promise {
+ let validated
+ try {
+ validated = UpdateCustomerParamsSchema.parse(params)
+ } catch (err) {
+ if (err instanceof z.ZodError) {
+ const issue = err.issues[0]
+ return {
+ error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`,
+ status: 400,
+ }
+ }
+ throw err
+ }
+
+ const { customer_id: customerId, changes } = validated
+ const { data: current, error: currentError } = await supabase
+ .from('customers')
+ .select('customer_type')
+ .eq('id', customerId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (currentError) return { error: currentError.message, status: 500 }
+ if (!current) return { error: 'Customer not found', status: 404 }
+
+ const updateData: Record = { ...changes }
+ if (changes.customer_number !== undefined) {
+ updateData.customer_number = changes.customer_number || null
+ }
+ const effectiveType = changes.customer_type ?? current.customer_type
+ if (changes.customer_type !== undefined && effectiveType !== 'individual') {
+ updateData.personal_number = null
+ }
+
+ if (changes.vat_number !== undefined) {
+ if (effectiveType === 'eu_business') {
+ if (changes.vat_number) {
+ try {
+ const vatResult = await validateVatNumber(changes.vat_number)
+ updateData.vat_number_validated = vatResult.valid
+ updateData.vat_number_validated_at = vatResult.valid
+ ? new Date().toISOString()
+ : null
+ } catch (err) {
+ log.warn('Auto-VIES validation failed on staged customer update:', err)
+ updateData.vat_number_validated = false
+ updateData.vat_number_validated_at = null
+ }
+ } else {
+ updateData.vat_number_validated = false
+ updateData.vat_number_validated_at = null
+ }
+ }
+ }
+
+ const { data, error } = await supabase
+ .from('customers')
+ .update(updateData)
+ .eq('id', customerId)
+ .eq('company_id', companyId)
+ .select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes')
+ .maybeSingle()
+
+ if (error) {
+ if (error.code === '23505') {
+ return { error: 'A customer with this organization number already exists', status: 409 }
+ }
+ return { error: error.message, status: 500 }
+ }
+ if (!data) return { error: 'Customer not found', status: 404 }
+
+ return {
+ data: {
+ customer_id: data.id,
+ name: data.name,
+ customer_type: data.customer_type,
+ customer_number: data.customer_number ?? null,
+ email: data.email ?? null,
+ phone: data.phone ?? null,
+ address_line1: data.address_line1 ?? null,
+ address_line2: data.address_line2 ?? null,
+ postal_code: data.postal_code ?? null,
+ city: data.city ?? null,
+ country: data.country,
+ org_number: data.org_number ?? null,
+ vat_number: data.vat_number ?? null,
+ vat_number_validated: data.vat_number_validated ?? false,
+ language: data.language ?? 'sv',
+ default_payment_terms: data.default_payment_terms,
+ notes: data.notes ?? null,
+ },
+ }
+}
+
+async function commitUpdateCompanySettings(
+ supabase: SupabaseClient,
+ companyId: string,
+ params: Record,
+): Promise {
+ let validated
+ try {
+ validated = UpdateCompanySettingsParamsSchema.parse(params)
+ } catch (err) {
+ if (err instanceof z.ZodError) {
+ const issue = err.issues[0]
+ return {
+ error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`,
+ status: 400,
+ }
+ }
+ throw err
+ }
+
+ const { data, error } = await supabase
+ .from('company_settings')
+ .update(validated.changes)
+ .eq('company_id', companyId)
+ .select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference')
+ .single()
+
+ if (error) {
+ if (error.code === 'PGRST116') {
+ return { error: 'Company settings not found', status: 404 }
+ }
+ return { error: error.message, status: 500 }
+ }
+
+ return {
+ data: {
+ company_id: companyId,
+ bank_name: data.bank_name ?? null,
+ clearing_number: data.clearing_number ?? null,
+ account_number: data.account_number ?? null,
+ bankgiro: data.bankgiro ?? null,
+ plusgiro: data.plusgiro ?? null,
+ swish: data.swish ?? null,
+ iban: data.iban ?? null,
+ bic: data.bic ?? null,
+ contact_person: data.default_our_reference ?? null,
+ },
+ }
+}
+
async function commitCreateArticle(
supabase: SupabaseClient,
userId: string,
@@ -4292,6 +4442,12 @@ async function commitPendingOperationInner(
case 'create_customer':
result = await commitCreateCustomer(supabase, userId, companyId, pendingOp.params)
break
+ case 'update_customer':
+ result = await commitUpdateCustomer(supabase, companyId, pendingOp.params)
+ break
+ case 'update_company_settings':
+ result = await commitUpdateCompanySettings(supabase, companyId, pendingOp.params)
+ break
case 'create_article':
result = await commitCreateArticle(supabase, userId, companyId, pendingOp.params)
break
diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts
index c5deeacb..ead8f79f 100644
--- a/lib/pending-operations/risk-tiers.ts
+++ b/lib/pending-operations/risk-tiers.ts
@@ -21,6 +21,7 @@ export type RiskLevel = 'low' | 'medium' | 'high'
export const OPERATION_RISK_TIERS: Record = {
// ── Low: pure data, no booking impact ─────────────────────────────
create_customer: 'low',
+ update_customer: 'low',
// Article catalog (artikelregister) is app-level master data: no journal
// impact, no external side-effect. Unlike create_supplier it carries no
// payment-routing fields, so there's no BEC/fraud surface; both create and
@@ -66,6 +67,10 @@ export const OPERATION_RISK_TIERS: Record = {
// (silently rerouting payment), so always require explicit human approval
// rather than auto-commit.
create_supplier: 'medium',
+ // Company payment settings control where customers send money on future
+ // invoices. Treat changes like supplier payment-routing data: reversible,
+ // but never eligible for silent low-risk auto-commit.
+ update_company_settings: 'medium',
// Pinning a doc to a tx is reversible while pre-categorization, but the link
// becomes part of the verifikation underlag (BFL 5 kap 6 §) once categorize
// propagates it. A wrong attachment requires a rättelse, so require human
diff --git a/lib/pending-operations/schemas/company-settings.ts b/lib/pending-operations/schemas/company-settings.ts
new file mode 100644
index 00000000..5c54b800
--- /dev/null
+++ b/lib/pending-operations/schemas/company-settings.ts
@@ -0,0 +1,54 @@
+import { z } from 'zod'
+import { UpdateSettingsSchema } from '@/lib/api/schemas'
+import {
+ validateBankgiroNumber,
+ validatePlusgiroNumber,
+} from '@/lib/bankgiro/luhn'
+
+const CompanySettingsChangesSchema = z
+ .object({
+ bank_name: UpdateSettingsSchema.shape.bank_name,
+ clearing_number: UpdateSettingsSchema.shape.clearing_number,
+ account_number: UpdateSettingsSchema.shape.account_number,
+ bankgiro: UpdateSettingsSchema.shape.bankgiro,
+ plusgiro: UpdateSettingsSchema.shape.plusgiro,
+ swish: UpdateSettingsSchema.shape.swish,
+ iban: UpdateSettingsSchema.shape.iban,
+ bic: UpdateSettingsSchema.shape.bic,
+ default_our_reference: UpdateSettingsSchema.shape.default_our_reference,
+ })
+ .strict()
+ .superRefine((changes, ctx) => {
+ if (Object.keys(changes).length === 0) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'At least one company setting must be supplied',
+ })
+ }
+
+ if (changes.bankgiro && !validateBankgiroNumber(changes.bankgiro)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['bankgiro'],
+ message: 'Invalid Bankgiro number',
+ })
+ }
+
+ if (changes.plusgiro && !validatePlusgiroNumber(changes.plusgiro)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['plusgiro'],
+ message: 'Invalid Plusgiro number',
+ })
+ }
+ })
+
+export const UpdateCompanySettingsParamsSchema = z
+ .object({
+ changes: CompanySettingsChangesSchema,
+ })
+ .strict()
+
+export type UpdateCompanySettingsParams = z.infer<
+ typeof UpdateCompanySettingsParamsSchema
+>
diff --git a/lib/pending-operations/schemas/customer.ts b/lib/pending-operations/schemas/customer.ts
new file mode 100644
index 00000000..a804cdfd
--- /dev/null
+++ b/lib/pending-operations/schemas/customer.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod'
+import { UpdateCustomerSchema } from '@/lib/api/schemas'
+
+const CustomerChangesSchema = z
+ .object({
+ name: UpdateCustomerSchema.shape.name,
+ customer_type: UpdateCustomerSchema.shape.customer_type,
+ customer_number: UpdateCustomerSchema.shape.customer_number,
+ email: UpdateCustomerSchema.shape.email,
+ phone: UpdateCustomerSchema.shape.phone,
+ address_line1: UpdateCustomerSchema.shape.address_line1,
+ address_line2: UpdateCustomerSchema.shape.address_line2,
+ postal_code: UpdateCustomerSchema.shape.postal_code,
+ city: UpdateCustomerSchema.shape.city,
+ country: UpdateCustomerSchema.shape.country,
+ org_number: UpdateCustomerSchema.shape.org_number,
+ vat_number: UpdateCustomerSchema.shape.vat_number,
+ language: UpdateCustomerSchema.shape.language,
+ default_payment_terms: UpdateCustomerSchema.shape.default_payment_terms,
+ notes: UpdateCustomerSchema.shape.notes,
+ })
+ .strict()
+ .superRefine((changes, ctx) => {
+ if (Object.keys(changes).length === 0) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'At least one customer field must be supplied',
+ })
+ }
+ })
+
+export const UpdateCustomerParamsSchema = z
+ .object({
+ customer_id: z.string().uuid(),
+ changes: CustomerChangesSchema,
+ })
+ .strict()
+
+export type UpdateCustomerParams = z.infer
diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts
index ec4bf478..61f822e0 100644
--- a/lib/reports/full-archive-export.ts
+++ b/lib/reports/full-archive-export.ts
@@ -846,7 +846,13 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
{ name: 'rot_rut_payout_request_items', file: 'rot_rut_payout_request_items.json', via: { parent: 'rot_rut_payout_requests', fk: 'request_id' } },
+ { name: 'fiscal_period_tax_adjustments', file: 'fiscal_period_tax_adjustments.json', orderBy: 'created_at' },
+ { name: 'tax_assessment_notices', file: 'tax_assessment_notices.json', orderBy: 'created_at' },
{ name: 'arsredovisning_narratives', file: 'arsredovisning_narratives.json' },
+ { name: 'annual_report_profiles', file: 'annual_report_profiles.json', orderBy: 'created_at' },
+ { name: 'annual_report_versions', file: 'annual_report_versions.json', orderBy: 'created_at' },
+ { name: 'annual_report_validation_runs', file: 'annual_report_validation_runs.json', orderBy: 'created_at' },
+ { name: 'arsredovisning_signature_requests', file: 'arsredovisning_signature_requests.json', orderBy: 'created_at' },
{ name: 'arsredovisning_submissions', file: 'arsredovisning_submissions.json' },
// Settings
{ name: 'company_settings', file: 'company_settings.json' },
@@ -877,7 +883,6 @@ export const ARCHIVE_EXCLUDED_TABLES: Record = {
agent_memory: 'AI assistant state, not räkenskapsinformation',
agent_profiles: 'AI assistant state, not räkenskapsinformation',
api_keys: 'secrets',
- arsredovisning_signature_requests: 'signing workflow state',
bank_connections: 'PSD2 connection state and tokens, not portable',
bolagsverket_avtal_acceptances: 'service agreement acceptance state',
bolagsverket_subscriptions: 'integration subscription state',
@@ -1131,9 +1136,14 @@ async function fetchAllAuditEntries(
const pageSize = 500
while (true) {
- const result = await getAuditLog(supabase, companyId, { ...filters, page, pageSize })
+ const result = await getAuditLog(supabase, companyId, {
+ ...filters,
+ page,
+ pageSize,
+ includeCount: false,
+ })
all.push(...result.data)
- if (all.length >= result.count || result.data.length < pageSize) {
+ if (result.data.length < pageSize) {
break
}
page++
@@ -1189,11 +1199,18 @@ async function buildSystemDoc(
rls_aktiv: true,
},
arkivering: {
- lagringstid_ar: 7,
+ lagringsregel: 'Till och med utgången av det sjunde kalenderåret efter det kalenderår då räkenskapsåret avslutades',
+ gallring_tidigare_an: '1 januari det åttonde efterföljande kalenderåret',
format: 'WORM (Write Once, Read Many)',
integritetskontroll: 'SHA-256 hashning vid uppladdning, regelbunden verifiering',
lagringsplats: 'Supabase Storage (krypterad)',
},
+ arsredovisning: {
+ versionering: 'Låsta versioner är oföränderliga och SHA-256-hashade',
+ kontrollunderlag: 'Regelverksprofil, upplysningsbekräftelser och valideringsresultat sparas med versionen',
+ underskrifter: 'Undertecknarlista, metod, datum och bevisreferens binds till exakt version',
+ inlamning: 'Exakt skickad iXBRL-fil och Bolagsverkets kvittens arkiveras före och efter överföring',
+ },
integrationer: {
bank: 'Enable Banking (PSD2)',
email: 'Resend',
diff --git a/lib/reports/ink2/__tests__/sru-generator.test.ts b/lib/reports/ink2/__tests__/sru-generator.test.ts
index 22197872..de6b2954 100644
--- a/lib/reports/ink2/__tests__/sru-generator.test.ts
+++ b/lib/reports/ink2/__tests__/sru-generator.test.ts
@@ -49,6 +49,8 @@ function makeDeclaration(overrides?: Partial): INK2Declaration
'7650': 302000,
'7750': 0,
'7651': 0,
+ '7653': 0,
+ '7754': 0,
'8020': 302000,
'8021': 0,
},
@@ -248,6 +250,22 @@ describe('INK2 SRU Generator', () => {
expect(ink2sBlock).not.toContain('#UPPGIFT 8021')
})
+ it('includes saved non-deductible and non-taxable adjustments in INK2S', () => {
+ const base = makeDeclaration()
+ const declaration = makeDeclaration({
+ ink2s: {
+ ...base.ink2s,
+ '7653': 5_244,
+ '7754': 1_000,
+ },
+ })
+ const submission = generateSRUSubmission(declaration)
+ const ink2sBlock = extractBlock(submission.blanketterSru, 'INK2S')
+
+ expect(ink2sBlock).toContain('#UPPGIFT 7653 5244')
+ expect(ink2sBlock).toContain('#UPPGIFT 7754 1000')
+ })
+
it('INK2 block includes överskott', () => {
const declaration = makeDeclaration()
const submission = generateSRUSubmission(declaration)
diff --git a/lib/reports/ink2/ink2-engine.ts b/lib/reports/ink2/ink2-engine.ts
index 3494ce93..2ae7b51f 100644
--- a/lib/reports/ink2/ink2-engine.ts
+++ b/lib/reports/ink2/ink2-engine.ts
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
+import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import type {
FiscalPeriod,
JournalEntry,
@@ -727,6 +728,12 @@ export async function generateINK2Declaration(
throw new Error('INK2 declaration is only for aktiebolag (limited company)')
}
+ const taxAdjustments = await loadTaxAdjustmentSnapshot(
+ supabase,
+ companyId,
+ fiscalPeriodId,
+ )
+
// Fetch all posted journal entries with lines for this period.
// Paginated: a period can exceed PostgREST's 1000-row cap, and a silent
// truncation here would under-report the INK2 tax declaration. PostgREST
@@ -890,10 +897,17 @@ export async function generateINK2Declaration(
const fyEnd = (period.period_end as string).replace(/-/g, '')
// Build INK2 (huvudblankett)
- // Auto-derive from INK2S result (simplified: result + non-deductible tax)
+ // Auto-derive from INK2S result and the saved tax-only adjustments.
// 7528 is already positive per Skatteverket convention
const taxAmount = ink2r['7528']
- const taxableResult = resultAfterFinancial + taxAmount
+ // INK2/SRU amounts are declared in whole kronor with ören omitted. Use the
+ // same whole-krona values in both the adjustment fields and the tax result
+ // so the worksheet remains internally consistent.
+ const nonDeductibleExpenses = Math.trunc(taxAdjustments.nonDeductibleExpenses)
+ const nonTaxableIncome = Math.trunc(taxAdjustments.nonTaxableIncome)
+ const taxableResult =
+ resultAfterFinancial + taxAmount
+ + nonDeductibleExpenses - nonTaxableIncome
const ink2: INK2Rutor = {
'7011': fyStart,
@@ -909,6 +923,8 @@ export async function generateINK2Declaration(
'7650': resultAfterFinancial >= 0 ? resultAfterFinancial : 0,
'7750': resultAfterFinancial < 0 ? Math.abs(resultAfterFinancial) : 0,
'7651': taxAmount, // Skatt (ej avdragsgill)
+ '7653': nonDeductibleExpenses,
+ '7754': nonTaxableIncome,
'8020': taxableResult >= 0 ? taxableResult : 0,
'8021': taxableResult < 0 ? Math.abs(taxableResult) : 0,
}
diff --git a/lib/reports/ink2/sru-generator.ts b/lib/reports/ink2/sru-generator.ts
index 9984dc90..fb568606 100644
--- a/lib/reports/ink2/sru-generator.ts
+++ b/lib/reports/ink2/sru-generator.ts
@@ -200,7 +200,15 @@ function generateBlanketterSru(declaration: INK2Declaration, now: Date): string
lines.push(`#UPPGIFT 7012 ${declaration.ink2s['7012']}`)
// INK2S numeric fields: emit non-zero values only
- const ink2sNumericFields: (keyof INK2SRutor)[] = ['7650', '7750', '7651', '8020', '8021']
+ const ink2sNumericFields: (keyof INK2SRutor)[] = [
+ '7650',
+ '7750',
+ '7651',
+ '7653',
+ '7754',
+ '8020',
+ '8021',
+ ]
for (const code of ink2sNumericFields) {
const value = declaration.ink2s[code]
if (typeof value === 'number' && value !== 0) {
diff --git a/lib/reports/ink2/types.ts b/lib/reports/ink2/types.ts
index 7d97254a..32d46cb4 100644
--- a/lib/reports/ink2/types.ts
+++ b/lib/reports/ink2/types.ts
@@ -101,7 +101,7 @@ export interface INK2Rutor {
/**
* INK2S: Skattemässiga justeringar (page 4)
- * Auto-derived fields only. Manual tax adjustments are handled by the accountant.
+ * Auto-derived fields plus the tax adjustments saved in the year-end flow.
*/
export interface INK2SRutor {
'7011': string // Räkenskapsår fr.o.m. (YYYYMMDD)
@@ -109,6 +109,8 @@ export interface INK2SRutor {
'7650': number // 4.1 Årets resultat, vinst
'7750': number // 4.2 Årets resultat, förlust
'7651': number // 4.3a Skatt på årets resultat (ej avdragsgill)
+ '7653': number // 4.3c Andra ej avdragsgilla kostnader
+ '7754': number // 4.5c Andra ej skattepliktiga intäkter
'8020': number // 4.15 Överskott → punkt 1.1
'8021': number // 4.16 Underskott → punkt 1.2
}
diff --git a/lib/tax/__tests__/deadline-config.test.ts b/lib/tax/__tests__/deadline-config.test.ts
index 0e3d439d..c8a1e757 100644
--- a/lib/tax/__tests__/deadline-config.test.ts
+++ b/lib/tax/__tests__/deadline-config.test.ts
@@ -30,6 +30,7 @@ function makeSettings(overrides: Partial = {}): Com
intrastat_enabled: false,
punktskatt_enabled: false,
fyllnadsinbetalning_enabled: false,
+ tax_assessment_notices: [],
...overrides,
}
}
@@ -276,6 +277,29 @@ describe('rot_rut_begaran: 31 January after the payment year (Lag 2009:194 8 §)
})
describe('long-tail opt-in deadlines', () => {
+ it('kvarskatt copies the exact notice date and never applies a banking-day shift', () => {
+ const config = getConfig('kvarskatt')
+ const settings = makeSettings({
+ tax_assessment_notices: [{
+ id: 'notice-1',
+ fiscalPeriodName: '2029',
+ decisionType: 'final',
+ paymentDueDate: '2030-03-31',
+ }],
+ })
+
+ expect(config.condition(settings)).toBe(true)
+ expect(config.skipBankingDayAdjustment).toBe(true)
+ expect(config.generateDates(2030, settings)).toEqual([{
+ day: 31,
+ month: 2,
+ year: 2030,
+ period: 'notice:notice-1',
+ periodLabel: 'slutskattebesked, 2029',
+ taxAssessmentNoticeId: 'notice-1',
+ }])
+ })
+
it('OSS: quarterly, last day of the month after the quarter, opt-in, no banking-day shift', () => {
const config = getConfig('oss_quarterly')
expect(config.condition(makeSettings())).toBe(false)
diff --git a/lib/tax/__tests__/deadline-generator.test.ts b/lib/tax/__tests__/deadline-generator.test.ts
index 423aaf1b..face34a2 100644
--- a/lib/tax/__tests__/deadline-generator.test.ts
+++ b/lib/tax/__tests__/deadline-generator.test.ts
@@ -33,6 +33,7 @@ const SETTINGS: CompanySettingsForDeadlines = {
intrastat_enabled: false,
punktskatt_enabled: false,
fyllnadsinbetalning_enabled: false,
+ tax_assessment_notices: [],
}
// Current + next year (the generator's own default): with the rolling
@@ -230,6 +231,34 @@ describe('generateTaxDeadlinesForUser', () => {
)
expect(other?.status).not.toBe('in_progress')
})
+
+ it('links a kvarskatt deadline to the notice that supplied its exact date', async () => {
+ const due = new Date()
+ due.setDate(due.getDate() + 30)
+ const paymentDueDate = [
+ due.getFullYear(),
+ String(due.getMonth() + 1).padStart(2, '0'),
+ String(due.getDate()).padStart(2, '0'),
+ ].join('-')
+ const { supabase, getInsertPayload } = makeRecordingSupabase()
+
+ await generateTaxDeadlinesForUser(supabase, 'company-1', {
+ ...SETTINGS,
+ tax_assessment_notices: [{
+ id: 'notice-1',
+ fiscalPeriodName: '2025',
+ decisionType: 'reassessment',
+ paymentDueDate,
+ }],
+ }, GEN_YEARS)
+
+ expect(getInsertPayload()).toContainEqual(expect.objectContaining({
+ tax_deadline_type: 'kvarskatt',
+ tax_period: 'notice:notice-1',
+ due_date: paymentDueDate,
+ tax_assessment_notice_id: 'notice-1',
+ }))
+ })
})
describe('getExpectedUpcomingDeadlineKeys: banking-day handling', () => {
@@ -253,6 +282,21 @@ describe('getExpectedUpcomingDeadlineKeys: banking-day handling', () => {
expect(keys.has('f_skatt:2030-05:2030-05-13')).toBe(true)
expect(keys.has('f_skatt:2030-05:2030-05-12')).toBe(false)
})
+
+ it('keeps an exact kvarskatt notice date on a Sunday', () => {
+ const keys = getExpectedUpcomingDeadlineKeys({
+ ...SETTINGS,
+ tax_assessment_notices: [{
+ id: 'notice-1',
+ fiscalPeriodName: '2029',
+ decisionType: 'final',
+ paymentDueDate: '2030-03-31',
+ }],
+ }, [2030], new Date(2030, 0, 1))
+
+ expect(keys.has('kvarskatt:notice:notice-1:2030-03-31')).toBe(true)
+ expect(keys.has('kvarskatt:notice:notice-1:2030-04-01')).toBe(false)
+ })
})
describe('findSettingsMissingUpcomingDeadlines', () => {
diff --git a/lib/tax/deadline-config.ts b/lib/tax/deadline-config.ts
index 3da9973e..faff064f 100644
--- a/lib/tax/deadline-config.ts
+++ b/lib/tax/deadline-config.ts
@@ -9,6 +9,13 @@ import { isBankingDay } from './swedish-holidays'
// Condition function type for determining if a deadline applies
export type DeadlineCondition = (settings: CompanySettingsForDeadlines) => boolean
+export interface TaxAssessmentNoticeForDeadline {
+ id: string
+ fiscalPeriodName: string
+ decisionType: 'final' | 'reassessment'
+ paymentDueDate: string
+}
+
// Subset of company settings needed for deadline generation
export interface CompanySettingsForDeadlines {
entity_type: EntityType
@@ -43,6 +50,8 @@ export interface CompanySettingsForDeadlines {
* detection), where rot_rut_begaran rows are simply never expected.
*/
rot_rut_payment_years?: number[]
+ /** Derived from active tax_assessment_notices rows by the generator. */
+ tax_assessment_notices?: TaxAssessmentNoticeForDeadline[]
}
// Configuration for a single tax deadline type
@@ -71,6 +80,7 @@ export interface DeadlineInstance {
year: number
period: string // e.g., "2025-Q1", "2025-01", "2025"
periodLabel: string // Human-readable, e.g., "Q1 2025", "januari 2025"
+ taxAssessmentNoticeId?: string
}
/**
@@ -524,6 +534,31 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
},
},
+ // Kvarskatt: the payment date is copied exactly from the final tax notice
+ // or reassessment decision. It must not be estimated or moved to a banking
+ // day because Skatteverket has already determined the statutory due date.
+ {
+ type: 'kvarskatt',
+ titleTemplate: 'Kvarskatt {periodLabel}',
+ description: 'Kvarskatt enligt slutskattebesked eller omprövningsbeslut',
+ condition: (s) => (s.tax_assessment_notices?.length ?? 0) > 0,
+ priority: 'critical',
+ linkedReportType: null,
+ skipBankingDayAdjustment: true,
+ generateDates: (year, settings) => (settings.tax_assessment_notices ?? [])
+ .filter((notice) => Number(notice.paymentDueDate.slice(0, 4)) === year)
+ .map((notice) => ({
+ day: Number(notice.paymentDueDate.slice(8, 10)),
+ month: Number(notice.paymentDueDate.slice(5, 7)) - 1,
+ year,
+ period: `notice:${notice.id}`,
+ periodLabel: notice.decisionType === 'reassessment'
+ ? `omprövning, ${notice.fiscalPeriodName}`
+ : `slutskattebesked, ${notice.fiscalPeriodName}`,
+ taxAssessmentNoticeId: notice.id,
+ })),
+ },
+
// Kontrolluppgifter (KU10/KU20/KU31): annual income statements to
// Skatteverket, due 31 January after the income year (SFL 24 kap. 1 §).
// KU31 (utdelning) is never covered by the monthly AGI, so a fåmansbolag
diff --git a/lib/tax/deadline-generator.ts b/lib/tax/deadline-generator.ts
index a457fc6f..bc6f358c 100644
--- a/lib/tax/deadline-generator.ts
+++ b/lib/tax/deadline-generator.ts
@@ -12,6 +12,7 @@ import {
getApplicableDeadlineConfigs,
type CompanySettingsForDeadlines,
type DeadlineInstance,
+ type TaxAssessmentNoticeForDeadline,
} from './deadline-config'
import { adjustDeadlineToNextBankingDay } from './swedish-holidays'
@@ -136,9 +137,64 @@ export function toDeadlineSettings(
intrastat_enabled: settings.intrastat_enabled ?? false,
punktskatt_enabled: settings.punktskatt_enabled ?? false,
fyllnadsinbetalning_enabled: settings.fyllnadsinbetalning_enabled ?? false,
+ tax_assessment_notices: settings.tax_assessment_notices,
}
}
+interface TaxAssessmentNoticeRow {
+ id: string
+ company_id: string
+ decision_type: 'final' | 'reassessment'
+ payment_due_date: string
+ fiscal_periods: { name: string } | Array<{ name: string }> | null
+}
+
+async function fetchActiveTaxAssessmentNotices(
+ supabase: SupabaseClient,
+ companyId?: string,
+): Promise {
+ return fetchAllRows(({ from, to }) => {
+ let query = supabase
+ .from('tax_assessment_notices')
+ .select('id, company_id, decision_type, payment_due_date, fiscal_periods(name)')
+ .is('archived_at', null)
+ .order('id', { ascending: true })
+ .range(from, to)
+
+ if (companyId) query = query.eq('company_id', companyId)
+ return query
+ })
+}
+
+function toDeadlineNotice(row: TaxAssessmentNoticeRow): TaxAssessmentNoticeForDeadline {
+ const fiscalPeriod = Array.isArray(row.fiscal_periods)
+ ? row.fiscal_periods[0]
+ : row.fiscal_periods
+ return {
+ id: row.id,
+ fiscalPeriodName: fiscalPeriod?.name ?? '',
+ decisionType: row.decision_type,
+ paymentDueDate: row.payment_due_date,
+ }
+}
+
+async function hydrateTaxAssessmentNotices(
+ supabase: SupabaseClient,
+ settingsRows: DeadlineSettingsRow[],
+): Promise {
+ const notices = await fetchActiveTaxAssessmentNotices(supabase)
+ const byCompany = new Map()
+ for (const notice of notices) {
+ const current = byCompany.get(notice.company_id) ?? []
+ current.push(toDeadlineNotice(notice))
+ byCompany.set(notice.company_id, current)
+ }
+ return settingsRows.map((settings) => ({
+ ...settings,
+ tax_assessment_notices: byCompany.get(settings.company_id) ?? [],
+ }))
+}
+
/**
* Decide whether a settings save should (re)generate tax deadlines.
*
@@ -175,10 +231,23 @@ export async function generateTaxDeadlinesForUser(
settings: CompanySettingsForDeadlines,
years: number[] = []
): Promise<{ created: number; deleted: number }> {
- // Default to current and next year if not specified
+ if (settings.tax_assessment_notices === undefined) {
+ const notices = await fetchActiveTaxAssessmentNotices(supabase, companyId)
+ settings = {
+ ...settings,
+ tax_assessment_notices: notices.map(toDeadlineNotice),
+ }
+ }
+
+ // Recurring deadlines use the current rolling window. Explicit tax notices
+ // also include their own due-date years so a newly entered overdue notice is
+ // represented instead of disappearing only because its exact date has passed.
if (years.length === 0) {
const currentYear = new Date().getFullYear()
- years = [currentYear, currentYear + 1]
+ const noticeYears = (settings.tax_assessment_notices ?? [])
+ .map((notice) => Number(notice.paymentDueDate.slice(0, 4)))
+ .filter(Number.isInteger)
+ years = Array.from(new Set([currentYear, currentYear + 1, ...noticeYears]))
}
// The ROT/RUT begäran deadline is data-dependent: a row for year Y only
@@ -289,6 +358,7 @@ export async function generateTaxDeadlinesForUser(
linked_report_period: Record | null
reminder_offsets: number[]
is_auto_generated: boolean
+ tax_assessment_notice_id: string | null
}> = []
for (const config of applicableConfigs) {
@@ -308,7 +378,7 @@ export async function generateTaxDeadlinesForUser(
const dueDate = formatDateISO(adjustedDate)
// Skip if the deadline is in the past
- if (adjustedDate < today) {
+ if (adjustedDate < today && !instance.taxAssessmentNoticeId) {
continue
}
@@ -351,6 +421,7 @@ export async function generateTaxDeadlinesForUser(
linked_report_period: linkedReportPeriod,
reminder_offsets: [14, 7, 1, 0],
is_auto_generated: true,
+ tax_assessment_notice_id: instance.taxAssessmentNoticeId ?? null,
})
}
}
@@ -598,7 +669,10 @@ export async function generateNewYearDeadlines(
supabase: SupabaseClient
): Promise<{ usersProcessed: number; totalCreated: number }> {
const newYear = new Date().getFullYear()
- const allSettings = await fetchAllDeadlineSettings(supabase)
+ const allSettings = await hydrateTaxAssessmentNotices(
+ supabase,
+ await fetchAllDeadlineSettings(supabase),
+ )
let usersProcessed = 0
let totalCreated = 0
@@ -634,7 +708,7 @@ export async function backfillMissingTaxDeadlines(
// refuses to recreate a filed obligation. Matches the generator's own
// completed-row floor.
const pastFloor = `${new Date().getFullYear() - 1}-01-01`
- const [allSettings, upcomingDeadlineRows] = await Promise.all([
+ const [rawSettings, upcomingDeadlineRows] = await Promise.all([
fetchAllDeadlineSettings(supabase),
fetchAllRows(({ from, to }) =>
supabase
@@ -647,6 +721,7 @@ export async function backfillMissingTaxDeadlines(
.range(from, to),
),
])
+ const allSettings = await hydrateTaxAssessmentNotices(supabase, rawSettings)
const missingSettings = findSettingsMissingUpcomingDeadlines(allSettings, upcomingDeadlineRows)
let companiesRepaired = 0
diff --git a/messages/en.json b/messages/en.json
index 3174e634..07ab9b67 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -17,6 +17,7 @@
"search": "Search",
"filter": "Filter",
"loading": "Loading...",
+ "load_more": "Load more",
"retry": "Try again",
"load_error": "Could not load data",
"confirm": "Confirm",
@@ -1066,6 +1067,49 @@
"fresh_start": "I'm starting a new business with no prior bookkeeping",
"security_note": "Your data is encrypted and stored securely in Sweden"
},
+ "initial_setup": {
+ "title": "How would you like to get started?",
+ "description": "Choose the path that gets you to value fastest. You can switch or do the rest later.",
+ "migration_title": "Move existing books",
+ "migration_description": "Import from another system or upload a SIE file.",
+ "bank_title": "Connect your bank",
+ "bank_description": "Fetch transactions automatically or import a bank statement.",
+ "fresh_title": "Start from scratch",
+ "fresh_description": "Continue to the overview without prior bookkeeping.",
+ "migration_selected_title": "Move your books",
+ "migration_selected_description": "Import your history first. The overview stays available while you work.",
+ "migration_action": "Open import",
+ "bank_selected_title": "Connect your bank",
+ "bank_selected_description": "Fetch transactions so you can start reconciling and bookkeeping.",
+ "bank_action": "Connect or import bank",
+ "fresh_selected_title": "You are ready",
+ "fresh_selected_description": "Start working directly from the overview.",
+ "fresh_action": "Continue",
+ "dismiss": "Hide",
+ "optional_skatteverket": "Connect Skatteverket, optional",
+ "optional_assistant": "Create assistant, optional"
+ },
+ "tax_assessment_notices": {
+ "title": "Final tax notices and remaining tax",
+ "description": "Enter the exact payment date printed on the final tax notice or reassessment decision. The date is copied to your deadlines without calculation or adjustment.",
+ "fiscal_period": "Fiscal year",
+ "decision_type": "Decision type",
+ "decision_final": "Final tax notice",
+ "decision_reassessment": "Reassessment decision",
+ "decision_date": "Decision date",
+ "payment_due_date": "Remaining tax payment date",
+ "payment_due_date_help": "Copy the date exactly from the notice. It cannot be derived safely from the decision date.",
+ "save_action": "Add deadline",
+ "update_action": "Save changes",
+ "saving": "Saving...",
+ "cancel": "Cancel",
+ "saved": "Remaining tax date added",
+ "updated": "Remaining tax date updated",
+ "archived": "Remaining tax date archived",
+ "due_summary": "Payment due {date}",
+ "edit": "Edit",
+ "archive": "Archive"
+ },
"onboarding": {
"greeting_night": "Good night",
"greeting_morning": "Good morning",
@@ -1310,10 +1354,10 @@
"invitations_pending_title": "Pending invitations",
"invitations_expires": "Expires {date}",
"logo_heading": "Logo",
- "logo_help": "Shown in the header of your invoices. Max 2 MB, PNG/JPG/SVG.",
+ "logo_help": "Shown in the header of your invoices. Max 10 MB, PNG/JPG/SVG/WebP.",
"logo_disallowed_type_title": "File type not allowed",
"logo_disallowed_type_description": "PNG, JPG, SVG or WebP.",
- "logo_too_large": "File is too large (max 2 MB)",
+ "logo_too_large": "File is too large (max 10 MB)",
"logo_upload_failed_title": "Could not upload",
"logo_upload_failed_default": "Upload failed",
"logo_try_again": "Please try again.",
@@ -1598,6 +1642,21 @@
"coming_soon": "Coming soon",
"heading": "Print & PDF",
"toast_save_failed": "Could not save",
+ "font_label": "Invoice font",
+ "font_help": "Choose a bundled font or upload your company's own font.",
+ "font_helvetica": "Helvetica",
+ "font_times": "Times",
+ "font_courier": "Courier",
+ "font_source_sans": "Source Sans 3",
+ "font_source_serif": "Source Serif 4",
+ "font_custom": "Custom font",
+ "font_upload": "Upload custom font",
+ "font_replace": "Replace custom font",
+ "font_remove": "Remove custom font",
+ "font_uploaded_name": "Uploaded: {name}",
+ "font_file_help": "TTF or WOFF, max 5 MB. Helvetica is used automatically if the file cannot be read.",
+ "font_upload_failed": "Could not upload the font",
+ "font_delete_failed": "Could not remove the font",
"ore_rounding_label": "Öre rounding",
"ore_rounding_help": "Round invoice total to whole krona",
"show_ocr_label": "Show OCR reference",
@@ -5580,6 +5639,73 @@
"export_sie_exclude_closing_label": "Exclude year-end closing voucher (recommended for eDeklarera and systems that do their own year-end closing)",
"export_cloud_title": "Cloud backup to Google Drive"
},
+ "annualReportStudio": {
+ "choose": "Välj",
+ "no": "Nej",
+ "yes": "Ja",
+ "loading": "Kontrollerar årsredovisningen...",
+ "load_error": "Kunde inte läsa årsredovisningskontrollen",
+ "save_error": "Kunde inte spara bedömningen",
+ "scope_saved": "Bedömningen har sparats",
+ "save_content_first": "Spara alla textändringar först",
+ "content_confirmed": "Innehållet är granskat",
+ "snapshot_created": "Ett versionsutkast har skapats",
+ "version_locked": "Versionen är låst för underskrift",
+ "snapshot_error": "Kunde inte skapa versionsutkast",
+ "lock_error": "Kunde inte låsa versionen",
+ "title": "Arbetsflöde för årsredovisning",
+ "description": "Kontrollera regelverk, fullständighet, version, underskrifter och inlämning i rätt ordning.",
+ "no_blockers": "Inga blockerande fel",
+ "blocker_count": "{count, plural, one {# blockerande fel} other {# blockerande fel}}",
+ "step_scope": "Omfattning",
+ "step_content": "Innehåll",
+ "step_signatures": "Underskrifter",
+ "step_filing": "Inlämning",
+ "scope_title": "Omfattning och regelverk",
+ "scope_description": "Svar som påverkar om K2 får användas och om Accounted kan skapa ett digitalt inlämningspaket.",
+ "public_company": "Är bolaget ett publikt aktiebolag?",
+ "in_liquidation": "Är bolaget i likvidation?",
+ "listed_securities": "Har bolaget värdepapper på en reglerad marknad?",
+ "parent_company": "Är bolaget moderföretag?",
+ "audit_required": "Krävs revisionsberättelse för året?",
+ "auditor_report_included": "Är revisionsberättelsen inkluderad i inlämningspaketet?",
+ "reporting_currency": "Redovisningsvaluta",
+ "group_size": "Koncernens storlek",
+ "group_small": "Mindre koncern",
+ "group_large": "Större koncern",
+ "consolidated_accounts": "Upprättar bolaget koncernredovisning?",
+ "k2_2026_title": "Ytterligare K2-frågor för räkenskapsår som omfattas av de nya reglerna",
+ "foreign_branch": "Har bolaget filial i utlandet?",
+ "crypto_assets": "Har bolaget kryptotillgångar som omfattas av K2-begränsningen?",
+ "share_payments": "Har bolaget aktierelaterade ersättningar?",
+ "convertible_debt": "Har bolaget emitterat sammansatta finansiella instrument?",
+ "material_deferred_tax": "Har bolaget normalt en väsentlig uppskjuten skatteskuld?",
+ "building_revenue": "Andel nettoomsättning från byggnader, procent",
+ "dividend_prudence": "Har styrelsen dokumenterat försiktighetsbedömningen för den föreslagna utdelningen?",
+ "dividend_prudence_description": "Föreslagen utdelning är {dividend, number} kr och fritt eget kapital är {equity, number} kr. Bedömningen ska omfatta verksamhetens art, omfattning och risker samt bolagets likviditet och ställning enligt aktiebolagslagen 17 kap. 3 §.",
+ "save_scope": "Spara bedömning",
+ "checks_title": "Fullständighetskontroll",
+ "digital_checks_title": "Begränsningar för digital inlämning",
+ "confirm_content": "Bekräfta granskat innehåll",
+ "confirm_signer_roster": "Bekräfta styrelse och VD",
+ "signer_roster_confirmed": "Styrelse och VD är kontrollerade mot Bolagsverket",
+ "create_snapshot": "Skapa versionsutkast",
+ "lock_version": "Lås version för underskrift",
+ "versions_title": "Versioner och dokument",
+ "versions_empty": "Inga versionsutkast har skapats ännu.",
+ "version_label": "Version {number}",
+ "status_draft": "Utkast",
+ "status_ready_for_signature": "Klar för underskrift",
+ "status_signed": "Undertecknad",
+ "status_filed": "Inlämnad",
+ "status_registered": "Registrerad",
+ "status_superseded": "Ersatt",
+ "open_pdf": "Öppna PDF",
+ "remove_signer": "Ta bort undertecknare",
+ "signer_removed": "Undertecknaren har tagits bort",
+ "signer_remove_error": "Kunde inte ta bort undertecknaren",
+ "no_digital_version": "Ingen undertecknad version är godkänd för digital inlämning. Kontrollera versionens blockerare i årsredovisningsstudion eller använd pappersflödet."
+ },
"empty": {
"invoices_title": "No invoices yet",
"invoices_description": "Create your first invoice to get started.",
diff --git a/messages/sv.json b/messages/sv.json
index 94fc3465..07ced225 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -17,6 +17,7 @@
"search": "Sök",
"filter": "Filtrera",
"loading": "Laddar...",
+ "load_more": "Ladda fler",
"retry": "Försök igen",
"load_error": "Kunde inte ladda data",
"confirm": "Bekräfta",
@@ -1066,6 +1067,49 @@
"fresh_start": "Jag startar en ny verksamhet utan tidigare bokföring",
"security_note": "Din data är krypterad och lagras säkert i Sverige"
},
+ "initial_setup": {
+ "title": "Hur vill du komma igång?",
+ "description": "Välj den väg som ger dig första nyttan snabbast. Du kan alltid byta eller göra resten senare.",
+ "migration_title": "Flytta befintlig bokföring",
+ "migration_description": "Importera från ett annat system eller ladda upp en SIE-fil.",
+ "bank_title": "Koppla banken",
+ "bank_description": "Hämta transaktioner automatiskt eller importera ett kontoutdrag.",
+ "fresh_title": "Starta från början",
+ "fresh_description": "Fortsätt direkt till översikten utan tidigare bokföring.",
+ "migration_selected_title": "Flytta in din bokföring",
+ "migration_selected_description": "Importera historiken först. Översikten är tillgänglig under tiden.",
+ "migration_action": "Öppna importen",
+ "bank_selected_title": "Koppla din bank",
+ "bank_selected_description": "Hämta transaktionerna så att du kan börja stämma av och bokföra.",
+ "bank_action": "Koppla eller importera bank",
+ "fresh_selected_title": "Du är redo",
+ "fresh_selected_description": "Börja arbeta direkt i översikten.",
+ "fresh_action": "Fortsätt",
+ "dismiss": "Dölj",
+ "optional_skatteverket": "Anslut Skatteverket, valfritt",
+ "optional_assistant": "Skapa assistent, valfritt"
+ },
+ "tax_assessment_notices": {
+ "title": "Slutskattebesked och kvarskatt",
+ "description": "Lägg in det exakta betalningsdatum som står på slutskattebeskedet eller omprövningsbeslutet. Datumet kopieras till dina deadlines utan att beräknas eller flyttas.",
+ "fiscal_period": "Räkenskapsår",
+ "decision_type": "Typ av beslut",
+ "decision_final": "Slutskattebesked",
+ "decision_reassessment": "Omprövningsbeslut",
+ "decision_date": "Beslutsdatum",
+ "payment_due_date": "Betalningsdatum för kvarskatt",
+ "payment_due_date_help": "Kopiera datumet exakt från beskedet. Det kan inte räknas fram säkert från beslutsdatumet.",
+ "save_action": "Lägg till deadline",
+ "update_action": "Spara ändringar",
+ "saving": "Sparar...",
+ "cancel": "Avbryt",
+ "saved": "Kvarskattedatum tillagt",
+ "updated": "Kvarskattedatum uppdaterat",
+ "archived": "Kvarskattedatum arkiverat",
+ "due_summary": "Betalas senast {date}",
+ "edit": "Redigera",
+ "archive": "Arkivera"
+ },
"onboarding": {
"greeting_night": "God natt",
"greeting_morning": "Godmorgon",
@@ -1310,10 +1354,10 @@
"invitations_pending_title": "Väntande inbjudningar",
"invitations_expires": "Går ut {date}",
"logo_heading": "Logotyp",
- "logo_help": "Visas i sidhuvudet på dina fakturor. Max 2 MB, PNG/JPG/SVG.",
+ "logo_help": "Visas i sidhuvudet på dina fakturor. Max 10 MB, PNG/JPG/SVG/WebP.",
"logo_disallowed_type_title": "Otillåten filtyp",
"logo_disallowed_type_description": "PNG, JPG, SVG eller WebP.",
- "logo_too_large": "Filen är för stor (max 2 MB)",
+ "logo_too_large": "Filen är för stor (max 10 MB)",
"logo_upload_failed_title": "Kunde inte ladda upp",
"logo_upload_failed_default": "Uppladdning misslyckades",
"logo_try_again": "Försök igen.",
@@ -1598,6 +1642,21 @@
"coming_soon": "Kommer snart",
"heading": "Utskrift & PDF",
"toast_save_failed": "Kunde inte spara",
+ "font_label": "Typsnitt på fakturan",
+ "font_help": "Välj ett medföljande typsnitt eller ladda upp företagets eget.",
+ "font_helvetica": "Helvetica",
+ "font_times": "Times",
+ "font_courier": "Courier",
+ "font_source_sans": "Source Sans 3",
+ "font_source_serif": "Source Serif 4",
+ "font_custom": "Eget typsnitt",
+ "font_upload": "Ladda upp eget typsnitt",
+ "font_replace": "Byt eget typsnitt",
+ "font_remove": "Ta bort eget typsnitt",
+ "font_uploaded_name": "Uppladdat: {name}",
+ "font_file_help": "TTF eller WOFF, max 5 MB. Om filen inte kan läsas används Helvetica automatiskt.",
+ "font_upload_failed": "Kunde inte ladda upp typsnittet",
+ "font_delete_failed": "Kunde inte ta bort typsnittet",
"ore_rounding_label": "Öresavrundning",
"ore_rounding_help": "Avrunda fakturatotal till hel krona",
"show_ocr_label": "Visa OCR-referens",
@@ -5580,6 +5639,73 @@
"export_sie_exclude_closing_label": "Exkludera bokslutsverifikat (rekommenderas för eDeklarera och system som gör eget bokslut)",
"export_cloud_title": "Säkerhetskopia till Google Drive"
},
+ "annualReportStudio": {
+ "choose": "Välj",
+ "no": "Nej",
+ "yes": "Ja",
+ "loading": "Kontrollerar årsredovisningen...",
+ "load_error": "Kunde inte läsa årsredovisningskontrollen",
+ "save_error": "Kunde inte spara bedömningen",
+ "scope_saved": "Bedömningen har sparats",
+ "save_content_first": "Spara alla textändringar först",
+ "content_confirmed": "Innehållet är granskat",
+ "snapshot_created": "Ett versionsutkast har skapats",
+ "version_locked": "Versionen är låst för underskrift",
+ "snapshot_error": "Kunde inte skapa versionsutkast",
+ "lock_error": "Kunde inte låsa versionen",
+ "title": "Arbetsflöde för årsredovisning",
+ "description": "Kontrollera regelverk, fullständighet, version, underskrifter och inlämning i rätt ordning.",
+ "no_blockers": "Inga blockerande fel",
+ "blocker_count": "{count, plural, one {# blockerande fel} other {# blockerande fel}}",
+ "step_scope": "Omfattning",
+ "step_content": "Innehåll",
+ "step_signatures": "Underskrifter",
+ "step_filing": "Inlämning",
+ "scope_title": "Omfattning och regelverk",
+ "scope_description": "Svar som påverkar om K2 får användas och om Accounted kan skapa ett digitalt inlämningspaket.",
+ "public_company": "Är bolaget ett publikt aktiebolag?",
+ "in_liquidation": "Är bolaget i likvidation?",
+ "listed_securities": "Har bolaget värdepapper på en reglerad marknad?",
+ "parent_company": "Är bolaget moderföretag?",
+ "audit_required": "Krävs revisionsberättelse för året?",
+ "auditor_report_included": "Är revisionsberättelsen inkluderad i inlämningspaketet?",
+ "reporting_currency": "Redovisningsvaluta",
+ "group_size": "Koncernens storlek",
+ "group_small": "Mindre koncern",
+ "group_large": "Större koncern",
+ "consolidated_accounts": "Upprättar bolaget koncernredovisning?",
+ "k2_2026_title": "Ytterligare K2-frågor för räkenskapsår som omfattas av de nya reglerna",
+ "foreign_branch": "Har bolaget filial i utlandet?",
+ "crypto_assets": "Har bolaget kryptotillgångar som omfattas av K2-begränsningen?",
+ "share_payments": "Har bolaget aktierelaterade ersättningar?",
+ "convertible_debt": "Har bolaget emitterat sammansatta finansiella instrument?",
+ "material_deferred_tax": "Har bolaget normalt en väsentlig uppskjuten skatteskuld?",
+ "building_revenue": "Andel nettoomsättning från byggnader, procent",
+ "dividend_prudence": "Har styrelsen dokumenterat försiktighetsbedömningen för den föreslagna utdelningen?",
+ "dividend_prudence_description": "Föreslagen utdelning är {dividend, number} kr och fritt eget kapital är {equity, number} kr. Bedömningen ska omfatta verksamhetens art, omfattning och risker samt bolagets likviditet och ställning enligt aktiebolagslagen 17 kap. 3 §.",
+ "save_scope": "Spara bedömning",
+ "checks_title": "Fullständighetskontroll",
+ "digital_checks_title": "Begränsningar för digital inlämning",
+ "confirm_content": "Bekräfta granskat innehåll",
+ "confirm_signer_roster": "Bekräfta styrelse och VD",
+ "signer_roster_confirmed": "Styrelse och VD är kontrollerade mot Bolagsverket",
+ "create_snapshot": "Skapa versionsutkast",
+ "lock_version": "Lås version för underskrift",
+ "versions_title": "Versioner och dokument",
+ "versions_empty": "Inga versionsutkast har skapats ännu.",
+ "version_label": "Version {number}",
+ "status_draft": "Utkast",
+ "status_ready_for_signature": "Klar för underskrift",
+ "status_signed": "Undertecknad",
+ "status_filed": "Inlämnad",
+ "status_registered": "Registrerad",
+ "status_superseded": "Ersatt",
+ "open_pdf": "Öppna PDF",
+ "remove_signer": "Ta bort undertecknare",
+ "signer_removed": "Undertecknaren har tagits bort",
+ "signer_remove_error": "Kunde inte ta bort undertecknaren",
+ "no_digital_version": "Ingen undertecknad version är godkänd för digital inlämning. Kontrollera versionens blockerare i årsredovisningsstudion eller använd pappersflödet."
+ },
"empty": {
"invoices_title": "Inga fakturor ännu",
"invoices_description": "Skapa din första faktura för att komma igång.",
diff --git a/prototypes/invoice-create-redesign.html b/prototypes/invoice-create-redesign.html
new file mode 100644
index 00000000..735635e7
--- /dev/null
+++ b/prototypes/invoice-create-redesign.html
@@ -0,0 +1,305 @@
+
+
+
+
+
+PROTOTYP — Kundfaktura redesign (round 9: back to clean editorial, ALL fields)
+
+
+
+
+
+
+
+
+
+
+
Redesign · rond 9 · ren + alla fält
+
+
+
+
+ Tom
+ Ifylld
+ Fullt
+
+
+
+
+
+
+
diff --git a/prototypes/invoice-editor-redesign.prototype.html b/prototypes/invoice-editor-redesign.prototype.html
new file mode 100644
index 00000000..7a924b91
--- /dev/null
+++ b/prototypes/invoice-editor-redesign.prototype.html
@@ -0,0 +1,281 @@
+
+
+
+
+
+PROTOTYPE — R4 finish (F1–F4)
+
+
+
+
+
+
+
+
R4 finish — rubrikrad + radåtgärder kropp låst · header med Beskrivning · lägg-till & ⋮ varieras (2×2)
+
+
+
+
Finish ⠿
+
+
+
← / → för att byta variant
+
+
+
+
+
diff --git a/public/fonts/invoice/LICENSE-Source-Sans-3.md b/public/fonts/invoice/LICENSE-Source-Sans-3.md
new file mode 100644
index 00000000..69fa3e45
--- /dev/null
+++ b/public/fonts/invoice/LICENSE-Source-Sans-3.md
@@ -0,0 +1,93 @@
+Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+
+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/fonts/invoice/LICENSE-Source-Serif-4.md b/public/fonts/invoice/LICENSE-Source-Serif-4.md
new file mode 100644
index 00000000..5871e1f3
--- /dev/null
+++ b/public/fonts/invoice/LICENSE-Source-Serif-4.md
@@ -0,0 +1,93 @@
+Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+
+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/fonts/invoice/SourceSans3-Bold.ttf b/public/fonts/invoice/SourceSans3-Bold.ttf
new file mode 100644
index 00000000..85693ccc
Binary files /dev/null and b/public/fonts/invoice/SourceSans3-Bold.ttf differ
diff --git a/public/fonts/invoice/SourceSans3-BoldItalic.ttf b/public/fonts/invoice/SourceSans3-BoldItalic.ttf
new file mode 100644
index 00000000..1a83e3fb
Binary files /dev/null and b/public/fonts/invoice/SourceSans3-BoldItalic.ttf differ
diff --git a/public/fonts/invoice/SourceSans3-Italic.ttf b/public/fonts/invoice/SourceSans3-Italic.ttf
new file mode 100644
index 00000000..8e0576f2
Binary files /dev/null and b/public/fonts/invoice/SourceSans3-Italic.ttf differ
diff --git a/public/fonts/invoice/SourceSans3-Regular.ttf b/public/fonts/invoice/SourceSans3-Regular.ttf
new file mode 100644
index 00000000..c5dd0b88
Binary files /dev/null and b/public/fonts/invoice/SourceSans3-Regular.ttf differ
diff --git a/public/fonts/invoice/SourceSerif4-Bold.ttf b/public/fonts/invoice/SourceSerif4-Bold.ttf
new file mode 100644
index 00000000..c4159ff2
Binary files /dev/null and b/public/fonts/invoice/SourceSerif4-Bold.ttf differ
diff --git a/public/fonts/invoice/SourceSerif4-BoldItalic.ttf b/public/fonts/invoice/SourceSerif4-BoldItalic.ttf
new file mode 100644
index 00000000..40f3863f
Binary files /dev/null and b/public/fonts/invoice/SourceSerif4-BoldItalic.ttf differ
diff --git a/public/fonts/invoice/SourceSerif4-Italic.ttf b/public/fonts/invoice/SourceSerif4-Italic.ttf
new file mode 100644
index 00000000..e818087d
Binary files /dev/null and b/public/fonts/invoice/SourceSerif4-Italic.ttf differ
diff --git a/public/fonts/invoice/SourceSerif4-Regular.ttf b/public/fonts/invoice/SourceSerif4-Regular.ttf
new file mode 100644
index 00000000..964a2bf8
Binary files /dev/null and b/public/fonts/invoice/SourceSerif4-Regular.ttf differ
diff --git a/scripts/checks/eslint-baseline.json b/scripts/checks/eslint-baseline.json
index fa9a6bd7..4ff899b6 100644
--- a/scripts/checks/eslint-baseline.json
+++ b/scripts/checks/eslint-baseline.json
@@ -1,12 +1,12 @@
{
- "totalErrors": 59,
+ "totalErrors": 53,
"perRule": {
"@next/next/no-assign-module-variable": 1,
"@typescript-eslint/no-explicit-any": 14,
- "prefer-const": 3,
- "react-hooks/preserve-manual-memoization": 6,
+ "prefer-const": 1,
+ "react-hooks/preserve-manual-memoization": 5,
"react-hooks/purity": 1,
- "react-hooks/set-state-in-effect": 28,
+ "react-hooks/set-state-in-effect": 25,
"react-hooks/static-components": 6
}
}
diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs
index 2ffe0b3c..ed6769df 100644
--- a/scripts/checks/no-new-antipatterns.mjs
+++ b/scripts/checks/no-new-antipatterns.mjs
@@ -26,6 +26,9 @@
* whose package.json spec or locked version drifted from the pin. Guards
* against a repeat of the @anthropic-ai/bedrock-sdk 0.32.0 prod outage
* (empty Bedrock stream). No baseline: any drift is a hard failure.
+ * 5. raw-user-error: raw caught-error messages passed to API response fields,
+ * client error state, or toast fields. Engine, database, and upstream
+ * messages must pass through getErrorMessage() or errorResponse().
*
* Usage:
* node scripts/checks/no-new-antipatterns.mjs # check (CI)
@@ -36,6 +39,7 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
+import ts from 'typescript'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const BASELINE_PATH = path.join(ROOT, 'scripts', 'checks', 'antipatterns-baseline.json')
@@ -167,11 +171,285 @@ function findPinnedDepViolations() {
return out
}
+const USER_ERROR_FIELD_NAMES = new Set([
+ 'description',
+ 'detail',
+ 'details',
+ 'error',
+ 'message',
+ 'reason',
+ 'title',
+])
+
+function propertyNameText(name) {
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
+ return name.text
+ }
+ return null
+}
+
+function propertyPath(node) {
+ const parts = []
+ let current = node
+ while (ts.isPropertyAccessExpression(current)) {
+ parts.unshift(current.name.text)
+ current = current.expression
+ }
+ if (ts.isIdentifier(current)) parts.unshift(current.text)
+ return parts
+}
+
+function isRawErrorMessage(node) {
+ if (!ts.isPropertyAccessExpression(node) || node.name.text !== 'message') return false
+ const parts = propertyPath(node)
+ if (parts.length < 2) return false
+ const root = parts[0]
+ return (
+ /^(?:e|err|error|cause)$/i.test(root) ||
+ /(?:Error|Err)$/.test(root) ||
+ parts.slice(0, -1).some((part) => /^(?:error|first_error)$/i.test(part))
+ )
+}
+
+function isErrorLikeIdentifier(node) {
+ return ts.isIdentifier(node) && (
+ /^(?:e|err|error|cause)$/i.test(node.text) || /(?:Error|Err)$/.test(node.text)
+ )
+}
+
+function isRawErrorString(node) {
+ return (
+ ts.isCallExpression(node) &&
+ ts.isIdentifier(node.expression) &&
+ node.expression.text === 'String' &&
+ node.arguments.length === 1 &&
+ isErrorLikeIdentifier(node.arguments[0])
+ )
+}
+
+function containsRawErrorMessage(node) {
+ let found = false
+ const visit = (child) => {
+ if (found) return
+ if (isRawErrorMessage(child) || isRawErrorString(child)) {
+ found = true
+ return
+ }
+ ts.forEachChild(child, visit)
+ }
+ visit(node)
+ return found
+}
+
+function enclosingCatch(node) {
+ let current = node.parent
+ while (current) {
+ if (ts.isCatchClause(current)) return current
+ if (ts.isFunctionLike(current)) return null
+ current = current.parent
+ }
+ return null
+}
+
+const taintedCatchNames = new WeakMap()
+
+function getTaintedNames(catchClause) {
+ const cached = taintedCatchNames.get(catchClause)
+ if (cached) return cached
+
+ const declarations = []
+ const collect = (node) => {
+ if (
+ ts.isVariableDeclaration(node) &&
+ ts.isIdentifier(node.name) &&
+ node.initializer
+ ) {
+ declarations.push(node)
+ }
+ ts.forEachChild(node, collect)
+ }
+ collect(catchClause.block)
+
+ const names = new Set()
+
+ const isTaintedValue = (node) => {
+ if (isRawErrorMessage(node) || isRawErrorString(node)) return true
+ if (ts.isCallExpression(node) && callName(node) === 'getErrorMessage') return false
+ if (ts.isConditionalExpression(node)) {
+ return isTaintedValue(node.whenTrue) || isTaintedValue(node.whenFalse)
+ }
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind !== ts.SyntaxKind.PlusToken) {
+ return false
+ }
+ let tainted = false
+ const visit = (child) => {
+ if (tainted) return
+ if (isRawErrorMessage(child) || isRawErrorString(child)) {
+ tainted = true
+ return
+ }
+ if (ts.isIdentifier(child) && names.has(child.text)) {
+ tainted = true
+ return
+ }
+ if (ts.isCallExpression(child) && callName(child) === 'getErrorMessage') return
+ ts.forEachChild(child, visit)
+ }
+ ts.forEachChild(node, visit)
+ return tainted
+ }
+
+ let changed = true
+ while (changed) {
+ changed = false
+ for (const declaration of declarations) {
+ if (names.has(declaration.name.text)) continue
+ const tainted = isTaintedValue(declaration.initializer)
+ if (tainted) {
+ names.add(declaration.name.text)
+ changed = true
+ }
+ }
+ }
+
+ taintedCatchNames.set(catchClause, names)
+ return names
+}
+
+function containsRawOrTaintedError(node) {
+ if (containsRawErrorMessage(node)) return true
+ const catchClause = enclosingCatch(node)
+ if (!catchClause) return false
+ const names = getTaintedNames(catchClause)
+ let found = false
+ const visit = (child) => {
+ if (found) return
+ if (ts.isIdentifier(child) && names.has(child.text)) {
+ found = true
+ return
+ }
+ if (ts.isPropertyAssignment(child)) {
+ visit(child.initializer)
+ return
+ }
+ ts.forEachChild(child, visit)
+ }
+ visit(node)
+ return found
+}
+
+function callName(call) {
+ const expression = call.expression
+ if (ts.isIdentifier(expression)) return expression.text
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text
+ return ''
+}
+
+function isLoggingCall(call) {
+ const expression = call.expression
+ if (!ts.isPropertyAccessExpression(expression)) return false
+ const owner = expression.expression.getText()
+ return (
+ owner === 'console' ||
+ /(?:^|\.)log$/.test(owner) ||
+ /Log$/.test(owner) ||
+ owner.endsWith('Logger')
+ )
+}
+
+function ancestorCall(node, predicate = () => true) {
+ let current = node.parent
+ while (current) {
+ if (ts.isCallExpression(current) && predicate(current)) return current
+ if (ts.isFunctionLike(current)) return null
+ current = current.parent
+ }
+ return null
+}
+
+function isApiResponseCall(call) {
+ const name = callName(call)
+ if (/^(?:errorResponse|errorResponseFromCode|getErrorMessage)$/.test(name)) return false
+ if (/^(?:json|v1ErrorResponse|v1ErrorResponseFromCode)$/.test(name)) return true
+ if (ts.isPropertyAccessExpression(call.expression)) {
+ return call.expression.name.text === 'json'
+ }
+ return false
+}
+
+function isClientErrorSetter(call) {
+ const name = callName(call)
+ return name === 'toast' || /^set[A-Z].*(?:Error|Message)$/.test(name) || name === 'setError'
+}
+
+/**
+ * Raw caught-error messages in user-visible sinks. This is deliberately an
+ * AST check: line regexes cannot distinguish a logger payload from a JSON
+ * response, nor a Zod issue message from err.message.
+ */
+function findRawUserErrors() {
+ const files = [
+ ...walk(path.join(ROOT, 'app', 'api'), ['route.ts']),
+ ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']).filter((f) => !rel(f).startsWith('app/api/')),
+ ...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']),
+ ]
+ const findings = []
+
+ for (const file of files) {
+ const sourceText = fs.readFileSync(file, 'utf8')
+ const source = ts.createSourceFile(
+ file,
+ sourceText,
+ ts.ScriptTarget.Latest,
+ true,
+ file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
+ )
+ const isApi = rel(file).startsWith('app/api/')
+
+ const add = (node) => {
+ const pos = source.getLineAndCharacterOfPosition(node.getStart(source))
+ findings.push(`${rel(file)}:${pos.line + 1}`)
+ }
+
+ const visit = (node) => {
+ if (ts.isPropertyAssignment(node)) {
+ const field = propertyNameText(node.name)
+ if (
+ field &&
+ USER_ERROR_FIELD_NAMES.has(field) &&
+ !ts.isObjectLiteralExpression(node.initializer) &&
+ containsRawOrTaintedError(node.initializer)
+ ) {
+ const loggingCall = ancestorCall(node, isLoggingCall)
+ const clientSink = ancestorCall(node, isClientErrorSetter)
+ if (!loggingCall) {
+ if (isApi || clientSink) add(node)
+ }
+ }
+ }
+
+ if (ts.isCallExpression(node) && node.arguments.some(containsRawOrTaintedError)) {
+ if (!isLoggingCall(node)) {
+ if ((isApi && isApiResponseCall(node)) || (!isApi && isClientErrorSetter(node))) {
+ add(node)
+ }
+ }
+ }
+
+ ts.forEachChild(node, visit)
+ }
+ visit(source)
+ }
+
+ return [...new Set(findings)].sort()
+}
+
const current = {
rawRouteAuth: findRawRouteAuth(),
naiveOreRound: countNaiveRound(),
directJelInsert: findDirectJelInserts(),
pinnedDepViolations: findPinnedDepViolations(),
+ rawUserErrors: findRawUserErrors(),
}
const isUpdate = process.argv.includes('--update')
@@ -244,6 +522,19 @@ if (current.pinnedDepViolations.length) {
)
}
+// 1d. raw-user-error: user-facing sinks must never receive err.message.
+if (current.rawUserErrors.length) {
+ failed = true
+ console.error(
+ `\n✗ raw-user-error: ${current.rawUserErrors.length} user-visible sink(s) expose a raw caught-error message:`,
+ )
+ current.rawUserErrors.forEach((finding) => console.error(` ${finding}`))
+ console.error(
+ ' → map the error through getErrorMessage(), or throw it inside withRouteContext so\n' +
+ ' errorResponse() produces the canonical structured envelope.',
+ )
+}
+
// 2. naive-ore-round: count may not increase.
if (current.naiveOreRound > baseline.naiveOreRound.count) {
failed = true
@@ -268,5 +559,5 @@ if (failed) {
process.exit(1)
}
console.log(
- `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, direct-jel-insert: 0, pinned-dep: 0).`,
+ `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0).`,
)
diff --git a/scripts/checks/no-new-lint-errors.mjs b/scripts/checks/no-new-lint-errors.mjs
index a387d0a3..b8485cdb 100644
--- a/scripts/checks/no-new-lint-errors.mjs
+++ b/scripts/checks/no-new-lint-errors.mjs
@@ -31,15 +31,17 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.
const BASELINE_PATH = path.join(ROOT, 'scripts', 'checks', 'eslint-baseline.json')
function runEslint() {
+ const eslintBin = path.join(ROOT, 'node_modules', 'eslint', 'bin', 'eslint.js')
const result = spawnSync(
- 'npx',
- ['eslint', '.', '--quiet', '-f', 'json'],
+ process.execPath,
+ [eslintBin, '.', '--quiet', '-f', 'json'],
{ cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
)
// ESLint exits 1 when errors exist: that's expected; only treat a missing/
// unparsable report as fatal.
if (!result.stdout) {
console.error('no-new-lint-errors: eslint produced no JSON output')
+ if (result.error) console.error(result.error)
console.error(result.stderr ?? '')
process.exit(2)
}
diff --git a/supabase/migrations/20260721081951_add_tax_notices_and_initial_setup_state.sql b/supabase/migrations/20260721081951_add_tax_notices_and_initial_setup_state.sql
new file mode 100644
index 00000000..bcc967b5
--- /dev/null
+++ b/supabase/migrations/20260721081951_add_tax_notices_and_initial_setup_state.sql
@@ -0,0 +1,107 @@
+-- Exact kvarskatt deadlines and persisted post-company onboarding state.
+--
+-- Kvarskatt is not calculated from a guessed 90-day offset. Skatteverket
+-- states that the exact payment due date is printed on the final tax notice,
+-- and reassessment decisions can have a materially shorter payment window.
+-- Store the notice date and its exact due date, then let the deadline
+-- generator mirror that date without banking-day adjustment.
+--
+-- Oasis onboarding state belongs to the company, not localStorage. Existing
+-- onboarded companies are backfilled as completed and dismissed so only newly
+-- created companies see the new optional setup surface.
+
+-- =============================================================================
+-- 1. Persisted initial setup state
+-- =============================================================================
+ALTER TABLE public.company_settings
+ ADD COLUMN IF NOT EXISTS initial_setup_path text NULL,
+ ADD COLUMN IF NOT EXISTS initial_setup_completed_at timestamptz NULL,
+ ADD COLUMN IF NOT EXISTS initial_setup_dismissed_at timestamptz NULL;
+
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_initial_setup_path_check;
+
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_initial_setup_path_check
+ CHECK (initial_setup_path IS NULL OR initial_setup_path IN ('migration', 'bank', 'fresh'));
+
+UPDATE public.company_settings
+SET initial_setup_completed_at = COALESCE(initial_setup_completed_at, now()),
+ initial_setup_dismissed_at = COALESCE(initial_setup_dismissed_at, now())
+WHERE onboarding_complete = true
+ AND initial_setup_completed_at IS NULL;
+
+COMMENT ON COLUMN public.company_settings.initial_setup_path IS
+ 'Optional first-value path selected after company creation: migration, bank, or fresh.';
+COMMENT ON COLUMN public.company_settings.initial_setup_completed_at IS
+ 'When the selected first-value setup path was completed.';
+COMMENT ON COLUMN public.company_settings.initial_setup_dismissed_at IS
+ 'When the optional dashboard setup checklist was dismissed.';
+
+-- =============================================================================
+-- 2. Final tax notices and reassessment decisions
+-- =============================================================================
+CREATE TABLE public.tax_assessment_notices (
+ id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
+ company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT,
+ decision_type text NOT NULL CHECK (decision_type IN ('final', 'reassessment')),
+ decision_date date NOT NULL,
+ payment_due_date date NOT NULL,
+ archived_at timestamptz NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT tax_assessment_notices_due_after_decision
+ CHECK (payment_due_date >= decision_date),
+ CONSTRAINT tax_assessment_notices_company_period_type_key
+ UNIQUE (company_id, fiscal_period_id, decision_type)
+);
+
+ALTER TABLE public.tax_assessment_notices ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "view own-company tax_assessment_notices"
+ ON public.tax_assessment_notices FOR SELECT
+ USING (company_id IN (SELECT user_company_ids()));
+CREATE POLICY "insert own-company tax_assessment_notices"
+ ON public.tax_assessment_notices FOR INSERT
+ WITH CHECK (
+ company_id IN (SELECT user_company_ids())
+ AND user_id = auth.uid()
+ );
+CREATE POLICY "update own-company tax_assessment_notices"
+ ON public.tax_assessment_notices FOR UPDATE
+ USING (company_id IN (SELECT user_company_ids()))
+ WITH CHECK (company_id IN (SELECT user_company_ids()));
+CREATE POLICY "delete own-company tax_assessment_notices"
+ ON public.tax_assessment_notices FOR DELETE
+ USING (company_id IN (SELECT user_company_ids()));
+
+CREATE INDEX idx_tax_assessment_notices_company
+ ON public.tax_assessment_notices (company_id);
+CREATE INDEX idx_tax_assessment_notices_due
+ ON public.tax_assessment_notices (company_id, payment_due_date)
+ WHERE archived_at IS NULL;
+CREATE INDEX idx_tax_assessment_notices_period
+ ON public.tax_assessment_notices (fiscal_period_id);
+
+CREATE TRIGGER set_updated_at_tax_assessment_notices
+ BEFORE UPDATE ON public.tax_assessment_notices
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER audit_tax_assessment_notices
+ AFTER INSERT OR UPDATE OR DELETE ON public.tax_assessment_notices
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+-- Relational identity lets edits and archives reconcile the exact generated
+-- deadline without encoding notice ids into user-facing titles.
+ALTER TABLE public.deadlines
+ ADD COLUMN IF NOT EXISTS tax_assessment_notice_id uuid NULL
+ REFERENCES public.tax_assessment_notices(id) ON DELETE RESTRICT;
+
+CREATE INDEX IF NOT EXISTS idx_deadlines_tax_assessment_notice
+ ON public.deadlines (tax_assessment_notice_id)
+ WHERE tax_assessment_notice_id IS NOT NULL;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721085314_fiscal_period_tax_adjustments.sql b/supabase/migrations/20260721085314_fiscal_period_tax_adjustments.sql
new file mode 100644
index 00000000..5a31d30c
--- /dev/null
+++ b/supabase/migrations/20260721085314_fiscal_period_tax_adjustments.sql
@@ -0,0 +1,126 @@
+-- Persist tax-only adjustments used by the year-end tax provision and INK2.
+-- These rows do not create journal entries. They explain the bridge from the
+-- accounting result to the taxable result for one fiscal period.
+
+CREATE TABLE public.fiscal_period_tax_adjustments (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
+ fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT,
+ adjustment_type text NOT NULL CHECK (
+ adjustment_type IN ('non_deductible_expense', 'non_taxable_income')
+ ),
+ source text NOT NULL CHECK (source IN ('detected', 'manual')),
+ source_key text NOT NULL,
+ description text NOT NULL,
+ account_number text NULL CHECK (
+ account_number IS NULL OR account_number ~ '^[0-9]{4}$'
+ ),
+ amount numeric(15, 2) NOT NULL CHECK (amount >= 0),
+ included boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT fiscal_period_tax_adjustments_source_key
+ UNIQUE (company_id, fiscal_period_id, source_key)
+);
+
+ALTER TABLE public.fiscal_period_tax_adjustments ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "view own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments FOR SELECT
+ USING (company_id IN (SELECT public.user_company_ids()));
+
+CREATE POLICY "insert own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments FOR INSERT
+ WITH CHECK (
+ company_id IN (SELECT public.user_company_ids())
+ AND user_id = auth.uid()
+ );
+
+CREATE POLICY "update own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments FOR UPDATE
+ USING (company_id IN (SELECT public.user_company_ids()))
+ WITH CHECK (
+ company_id IN (SELECT public.user_company_ids())
+ AND user_id = auth.uid()
+ );
+
+CREATE POLICY "delete own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments FOR DELETE
+ USING (company_id IN (SELECT public.user_company_ids()));
+
+CREATE INDEX idx_fiscal_period_tax_adjustments_company
+ ON public.fiscal_period_tax_adjustments (company_id);
+
+CREATE INDEX idx_fiscal_period_tax_adjustments_period
+ ON public.fiscal_period_tax_adjustments (fiscal_period_id);
+
+CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_adjustment()
+RETURNS trigger
+LANGUAGE plpgsql
+SET search_path = public, pg_temp
+AS $$
+DECLARE
+ adjustment_row public.fiscal_period_tax_adjustments%ROWTYPE;
+ period_row public.fiscal_periods%ROWTYPE;
+BEGIN
+ IF TG_OP = 'UPDATE'
+ AND (
+ OLD.company_id IS DISTINCT FROM NEW.company_id
+ OR OLD.fiscal_period_id IS DISTINCT FROM NEW.fiscal_period_id
+ ) THEN
+ RAISE EXCEPTION 'Tax adjustment company and fiscal period are immutable'
+ USING ERRCODE = '23514';
+ END IF;
+
+ IF TG_OP = 'DELETE' THEN
+ adjustment_row := OLD;
+ ELSE
+ adjustment_row := NEW;
+ END IF;
+
+ SELECT * INTO period_row
+ FROM public.fiscal_periods
+ WHERE id = adjustment_row.fiscal_period_id;
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Fiscal period not found for tax adjustment'
+ USING ERRCODE = '23503';
+ END IF;
+
+ IF period_row.company_id IS DISTINCT FROM adjustment_row.company_id THEN
+ RAISE EXCEPTION 'Tax adjustment company does not match fiscal period company'
+ USING ERRCODE = '23514';
+ END IF;
+
+ IF period_row.is_closed
+ OR period_row.locked_at IS NOT NULL
+ OR period_row.closing_entry_id IS NOT NULL THEN
+ RAISE EXCEPTION 'Fiscal period is locked for tax adjustments'
+ USING ERRCODE = '23514';
+ END IF;
+
+ IF TG_OP = 'DELETE' THEN
+ RETURN OLD;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER guard_fiscal_period_tax_adjustments
+ BEFORE INSERT OR UPDATE OR DELETE ON public.fiscal_period_tax_adjustments
+ FOR EACH ROW EXECUTE FUNCTION public.guard_fiscal_period_tax_adjustment();
+
+CREATE TRIGGER set_updated_at_fiscal_period_tax_adjustments
+ BEFORE UPDATE ON public.fiscal_period_tax_adjustments
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER audit_fiscal_period_tax_adjustments
+ AFTER INSERT OR UPDATE OR DELETE ON public.fiscal_period_tax_adjustments
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+COMMENT ON TABLE public.fiscal_period_tax_adjustments IS
+ 'Tax-only adjustments bridging accounting result to taxable result for year-end tax and INK2.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721101500_harden_tax_assessment_notices.sql b/supabase/migrations/20260721101500_harden_tax_assessment_notices.sql
new file mode 100644
index 00000000..2f3e98ca
--- /dev/null
+++ b/supabase/migrations/20260721101500_harden_tax_assessment_notices.sql
@@ -0,0 +1,104 @@
+-- Harden tax notice tenant integrity and preserve every reassessment decision.
+
+-- A fiscal year can receive more than one reassessment decision. The decision
+-- date is part of the notice identity so repeated POST requests stay
+-- idempotent without overwriting an earlier decision.
+ALTER TABLE public.tax_assessment_notices
+ DROP CONSTRAINT tax_assessment_notices_company_period_type_key;
+
+ALTER TABLE public.tax_assessment_notices
+ ADD CONSTRAINT tax_assessment_notices_company_period_type_date_key
+ UNIQUE (company_id, fiscal_period_id, decision_type, decision_date);
+
+-- The creator is attribution, not ownership. Company records must survive a
+-- user deletion, including when a generated deadline references the notice.
+ALTER TABLE public.tax_assessment_notices
+ ALTER COLUMN user_id DROP NOT NULL;
+
+ALTER TABLE public.tax_assessment_notices
+ DROP CONSTRAINT tax_assessment_notices_user_id_fkey;
+
+ALTER TABLE public.tax_assessment_notices
+ ADD CONSTRAINT tax_assessment_notices_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
+
+-- Company-owned foreign keys must agree on company_id. The additional unique
+-- keys are safe because both referenced id columns are already primary keys.
+ALTER TABLE public.fiscal_periods
+ ADD CONSTRAINT fiscal_periods_id_company_id_key UNIQUE (id, company_id);
+
+ALTER TABLE public.tax_assessment_notices
+ DROP CONSTRAINT tax_assessment_notices_fiscal_period_id_fkey;
+
+ALTER TABLE public.tax_assessment_notices
+ ADD CONSTRAINT tax_assessment_notices_fiscal_period_company_fkey
+ FOREIGN KEY (fiscal_period_id, company_id)
+ REFERENCES public.fiscal_periods(id, company_id) ON DELETE RESTRICT;
+
+ALTER TABLE public.tax_assessment_notices
+ ADD CONSTRAINT tax_assessment_notices_id_company_id_key UNIQUE (id, company_id);
+
+ALTER TABLE public.deadlines
+ DROP CONSTRAINT deadlines_tax_assessment_notice_id_fkey;
+
+ALTER TABLE public.deadlines
+ ADD CONSTRAINT deadlines_tax_assessment_notice_company_fkey
+ FOREIGN KEY (tax_assessment_notice_id, company_id)
+ REFERENCES public.tax_assessment_notices(id, company_id) ON DELETE RESTRICT;
+
+-- Keep creator attribution immutable for authenticated PostgREST callers.
+CREATE OR REPLACE FUNCTION public.enforce_tax_assessment_notice_attribution()
+RETURNS trigger
+LANGUAGE plpgsql
+SET search_path TO 'public'
+AS $function$
+BEGIN
+ IF auth.uid() IS NOT NULL AND NEW.user_id IS DISTINCT FROM OLD.user_id THEN
+ RAISE EXCEPTION 'Tax assessment notice attribution is immutable'
+ USING ERRCODE = '42501';
+ END IF;
+ RETURN NEW;
+END;
+$function$;
+
+CREATE TRIGGER enforce_tax_assessment_notice_attribution
+ BEFORE UPDATE OF user_id ON public.tax_assessment_notices
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_tax_assessment_notice_attribution();
+
+-- Reads follow the repository-wide membership model. Writes additionally
+-- require the active company and a non-viewer role, matching other tenant
+-- tables and preventing direct PostgREST writes from bypassing the API guard.
+DROP POLICY "insert own-company tax_assessment_notices"
+ ON public.tax_assessment_notices;
+DROP POLICY "update own-company tax_assessment_notices"
+ ON public.tax_assessment_notices;
+DROP POLICY "delete own-company tax_assessment_notices"
+ ON public.tax_assessment_notices;
+
+CREATE POLICY tax_assessment_notices_insert
+ ON public.tax_assessment_notices FOR INSERT TO public
+ WITH CHECK (
+ company_id = public.current_active_company_id()
+ AND public.current_user_can_write()
+ AND user_id = auth.uid()
+ );
+
+CREATE POLICY tax_assessment_notices_update
+ ON public.tax_assessment_notices FOR UPDATE TO public
+ USING (
+ company_id = public.current_active_company_id()
+ AND public.current_user_can_write()
+ )
+ WITH CHECK (
+ company_id = public.current_active_company_id()
+ AND public.current_user_can_write()
+ );
+
+CREATE POLICY tax_assessment_notices_delete
+ ON public.tax_assessment_notices FOR DELETE TO public
+ USING (
+ company_id = public.current_active_company_id()
+ AND public.current_user_can_write()
+ );
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721103000_production_error_root_causes.sql b/supabase/migrations/20260721103000_production_error_root_causes.sql
new file mode 100644
index 00000000..1dcff5fb
--- /dev/null
+++ b/supabase/migrations/20260721103000_production_error_root_causes.sql
@@ -0,0 +1,127 @@
+-- Repair production error root causes found in the Vercel runtime logs.
+--
+-- 1. Register the MCP approval event before processing_history references it.
+-- 2. Add covering indexes for the two high-volume read paths.
+-- 3. Aggregate account balances in Postgres instead of transferring every line.
+-- 4. Page VAT source lines in Postgres instead of loading the full result set.
+
+INSERT INTO public.processing_event_types (event_type)
+VALUES ('PendingOperationApproved')
+ON CONFLICT (event_type) DO NOTHING;
+
+CREATE INDEX IF NOT EXISTS idx_audit_log_company_created_id
+ ON public.audit_log (company_id, created_at DESC, id DESC);
+
+CREATE INDEX IF NOT EXISTS idx_journal_entries_company_posted_date_id
+ ON public.journal_entries (company_id, entry_date, id)
+ WHERE status IN ('posted', 'reversed');
+
+CREATE OR REPLACE FUNCTION public.get_account_period_activity(
+ p_company_id uuid,
+ p_start date,
+ p_end date,
+ p_accounts text[],
+ p_exclude_journal_entry_id uuid DEFAULT NULL
+)
+RETURNS TABLE (
+ account_number text,
+ debit numeric,
+ credit numeric
+)
+LANGUAGE sql
+STABLE
+SECURITY INVOKER
+SET search_path TO 'public'
+AS $$
+ SELECT
+ l.account_number,
+ COALESCE(sum(l.debit_amount), 0)::numeric AS debit,
+ COALESCE(sum(l.credit_amount), 0)::numeric AS credit
+ FROM public.journal_entries je
+ JOIN public.journal_entry_lines l ON l.journal_entry_id = je.id
+ WHERE je.company_id = p_company_id
+ AND je.status IN ('posted', 'reversed')
+ AND je.entry_date >= p_start
+ AND je.entry_date <= p_end
+ AND l.account_number = ANY (p_accounts)
+ AND (
+ p_exclude_journal_entry_id IS NULL
+ OR je.id <> p_exclude_journal_entry_id
+ )
+ GROUP BY l.account_number
+ ORDER BY l.account_number;
+$$;
+
+REVOKE ALL ON FUNCTION public.get_account_period_activity(uuid, date, date, text[], uuid)
+ FROM PUBLIC, anon;
+GRANT EXECUTE ON FUNCTION public.get_account_period_activity(uuid, date, date, text[], uuid)
+ TO authenticated, service_role;
+
+CREATE OR REPLACE FUNCTION public.get_vat_ruta_source_lines(
+ p_company_id uuid,
+ p_start date,
+ p_end date,
+ p_accounts text[],
+ p_cursor_date date DEFAULT NULL,
+ p_cursor_voucher_number integer DEFAULT NULL,
+ p_cursor_entry_id uuid DEFAULT NULL,
+ p_cursor_line_id uuid DEFAULT NULL,
+ p_limit integer DEFAULT 501
+)
+RETURNS TABLE (
+ line_id uuid,
+ journal_entry_id uuid,
+ voucher_number integer,
+ voucher_series text,
+ entry_date date,
+ description text,
+ debit_amount numeric,
+ credit_amount numeric
+)
+LANGUAGE sql
+STABLE
+SECURITY INVOKER
+SET search_path TO 'public'
+AS $$
+ SELECT
+ l.id AS line_id,
+ je.id AS journal_entry_id,
+ je.voucher_number,
+ COALESCE(je.voucher_series, 'A') AS voucher_series,
+ je.entry_date,
+ COALESCE(je.description, '') AS description,
+ l.debit_amount,
+ l.credit_amount
+ FROM public.journal_entries je
+ JOIN public.journal_entry_lines l ON l.journal_entry_id = je.id
+ WHERE je.company_id = p_company_id
+ AND je.status IN ('posted', 'reversed')
+ AND je.entry_date >= p_start
+ AND je.entry_date <= p_end
+ AND l.account_number = ANY (p_accounts)
+ AND (
+ p_cursor_date IS NULL
+ OR (
+ je.entry_date,
+ je.voucher_number,
+ je.id,
+ l.id
+ ) > (
+ p_cursor_date,
+ p_cursor_voucher_number,
+ COALESCE(p_cursor_entry_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid),
+ COALESCE(p_cursor_line_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid)
+ )
+ )
+ ORDER BY je.entry_date, je.voucher_number, je.id, l.id
+ LIMIT LEAST(GREATEST(COALESCE(p_limit, 501), 1), 501);
+$$;
+
+REVOKE ALL ON FUNCTION public.get_vat_ruta_source_lines(
+ uuid, date, date, text[], date, integer, uuid, uuid, integer
+) FROM PUBLIC, anon;
+GRANT EXECUTE ON FUNCTION public.get_vat_ruta_source_lines(
+ uuid, date, date, text[], date, integer, uuid, uuid, integer
+) TO authenticated, service_role;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721110750_pending_operations_add_update_company_settings.sql b/supabase/migrations/20260721110750_pending_operations_add_update_company_settings.sql
new file mode 100644
index 00000000..f3737ec0
--- /dev/null
+++ b/supabase/migrations/20260721110750_pending_operations_add_update_company_settings.sql
@@ -0,0 +1,69 @@
+-- Add update_company_settings to the pending_operations operation type CHECK.
+-- The operation changes payment-routing details shown on future invoices and
+-- therefore stages at medium risk for explicit approval.
+
+ALTER TABLE public.pending_operations
+ DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
+
+ALTER TABLE public.pending_operations
+ ADD CONSTRAINT pending_operations_operation_type_check
+ CHECK (operation_type IN (
+ 'categorize_transaction',
+ 'create_customer',
+ 'create_invoice',
+ 'mark_invoice_paid',
+ 'send_invoice',
+ 'mark_invoice_sent',
+ 'match_transaction_invoice',
+ 'close_period',
+ 'lock_period',
+ 'unlock_period',
+ 'set_opening_balances',
+ 'run_year_end',
+ 'run_currency_revaluation',
+ 'import_sie',
+ 'explain_voucher_gap',
+ 'uncategorize_transaction',
+ 'approve_supplier_invoice',
+ 'credit_supplier_invoice',
+ 'credit_invoice',
+ 'convert_invoice',
+ 'create_transaction',
+ 'attach_document_to_transaction',
+ 'create_voucher',
+ 'correct_entry',
+ 'reverse_entry',
+ 'create_supplier',
+ 'create_supplier_invoice_from_inbox',
+ 'post_annual_depreciation',
+ 'link_invoice_voucher',
+ 'undo_sie_import',
+ 'match_batch_allocate',
+ 'bulk_book_transactions',
+ 'create_salary_run',
+ 'generate_agi',
+ 'link_transaction_journal_entry',
+ 'link_supplier_invoice_voucher',
+ 'submit_vat_declaration',
+ 'submit_agi',
+ 'create_article',
+ 'update_article',
+ 'bulk_book_inbox_items',
+ 'create_dimension_value',
+ 'retag_line_dimensions',
+ 'link_document_to_voucher',
+ 'update_payslip_line',
+ 'register_absence',
+ 'create_employee',
+ 'update_employee',
+ 'set_employee_opening_balances',
+ 'vacation_year_close',
+ 'create_account',
+ 'update_account',
+ 'set_voucher_note',
+ 'book_salary_run',
+ 'delete_absence',
+ 'update_company_settings'
+ )) NOT VALID;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721110751_validate_pending_operations_update_company_settings.sql b/supabase/migrations/20260721110751_validate_pending_operations_update_company_settings.sql
new file mode 100644
index 00000000..8933532b
--- /dev/null
+++ b/supabase/migrations/20260721110751_validate_pending_operations_update_company_settings.sql
@@ -0,0 +1,6 @@
+-- Validate the operation type CHECK re-added in 20260721110750.
+-- This separate transaction avoids a full-table scan while the preceding
+-- migration holds its stronger table lock.
+
+ALTER TABLE public.pending_operations
+ VALIDATE CONSTRAINT pending_operations_operation_type_check;
diff --git a/supabase/migrations/20260721111331_pending_operations_add_update_customer.sql b/supabase/migrations/20260721111331_pending_operations_add_update_customer.sql
new file mode 100644
index 00000000..a99055ff
--- /dev/null
+++ b/supabase/migrations/20260721111331_pending_operations_add_update_customer.sql
@@ -0,0 +1,70 @@
+-- Add update_customer to the pending_operations operation type CHECK.
+-- Customer master-data changes are staged at low risk and remain subject to
+-- explicit approval unless the company has opted into trusted auto-commit.
+
+ALTER TABLE public.pending_operations
+ DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
+
+ALTER TABLE public.pending_operations
+ ADD CONSTRAINT pending_operations_operation_type_check
+ CHECK (operation_type IN (
+ 'categorize_transaction',
+ 'create_customer',
+ 'create_invoice',
+ 'mark_invoice_paid',
+ 'send_invoice',
+ 'mark_invoice_sent',
+ 'match_transaction_invoice',
+ 'close_period',
+ 'lock_period',
+ 'unlock_period',
+ 'set_opening_balances',
+ 'run_year_end',
+ 'run_currency_revaluation',
+ 'import_sie',
+ 'explain_voucher_gap',
+ 'uncategorize_transaction',
+ 'approve_supplier_invoice',
+ 'credit_supplier_invoice',
+ 'credit_invoice',
+ 'convert_invoice',
+ 'create_transaction',
+ 'attach_document_to_transaction',
+ 'create_voucher',
+ 'correct_entry',
+ 'reverse_entry',
+ 'create_supplier',
+ 'create_supplier_invoice_from_inbox',
+ 'post_annual_depreciation',
+ 'link_invoice_voucher',
+ 'undo_sie_import',
+ 'match_batch_allocate',
+ 'bulk_book_transactions',
+ 'create_salary_run',
+ 'generate_agi',
+ 'link_transaction_journal_entry',
+ 'link_supplier_invoice_voucher',
+ 'submit_vat_declaration',
+ 'submit_agi',
+ 'create_article',
+ 'update_article',
+ 'bulk_book_inbox_items',
+ 'create_dimension_value',
+ 'retag_line_dimensions',
+ 'link_document_to_voucher',
+ 'update_payslip_line',
+ 'register_absence',
+ 'create_employee',
+ 'update_employee',
+ 'set_employee_opening_balances',
+ 'vacation_year_close',
+ 'create_account',
+ 'update_account',
+ 'set_voucher_note',
+ 'book_salary_run',
+ 'delete_absence',
+ 'update_company_settings',
+ 'update_customer'
+ )) NOT VALID;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721111332_validate_pending_operations_update_customer.sql b/supabase/migrations/20260721111332_validate_pending_operations_update_customer.sql
new file mode 100644
index 00000000..5f2775f9
--- /dev/null
+++ b/supabase/migrations/20260721111332_validate_pending_operations_update_customer.sql
@@ -0,0 +1,6 @@
+-- Validate the operation type CHECK re-added in 20260721111331.
+-- This separate transaction avoids a full-table scan while the preceding
+-- migration holds its stronger table lock.
+
+ALTER TABLE public.pending_operations
+ VALIDATE CONSTRAINT pending_operations_operation_type_check;
diff --git a/supabase/migrations/20260721115701_suppress_api_key_usage_audit_noise.sql b/supabase/migrations/20260721115701_suppress_api_key_usage_audit_noise.sql
new file mode 100644
index 00000000..673d190f
--- /dev/null
+++ b/supabase/migrations/20260721115701_suppress_api_key_usage_audit_noise.sql
@@ -0,0 +1,34 @@
+-- Keep API-key security and configuration changes in behandlingshistorik, but
+-- do not create a full audit row for every authenticated API or MCP request.
+-- validate_and_increment_api_key updates only these four telemetry columns.
+
+DROP TRIGGER IF EXISTS audit_api_keys_update ON public.api_keys;
+DROP TRIGGER IF EXISTS audit_api_keys ON public.api_keys;
+
+-- Preserve the historical trigger name for account-deletion routines that
+-- temporarily disable audit_api_keys while removing a user.
+CREATE TRIGGER audit_api_keys
+ AFTER INSERT OR DELETE ON public.api_keys
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+CREATE TRIGGER audit_api_keys_update
+ AFTER UPDATE ON public.api_keys
+ FOR EACH ROW
+ WHEN (
+ (to_jsonb(OLD) - ARRAY[
+ 'request_count',
+ 'rate_limit_window_start',
+ 'last_used_at',
+ 'updated_at'
+ ]::text[])
+ IS DISTINCT FROM
+ (to_jsonb(NEW) - ARRAY[
+ 'request_count',
+ 'rate_limit_window_start',
+ 'last_used_at',
+ 'updated_at'
+ ]::text[])
+ )
+ EXECUTE FUNCTION public.write_audit_log();
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721130847_harden_fiscal_period_tax_adjustment_rls.sql b/supabase/migrations/20260721130847_harden_fiscal_period_tax_adjustment_rls.sql
new file mode 100644
index 00000000..2931aa38
--- /dev/null
+++ b/supabase/migrations/20260721130847_harden_fiscal_period_tax_adjustment_rls.sql
@@ -0,0 +1,105 @@
+-- The initial tax-adjustment migration used membership-only write policies.
+-- Align writes with the global role gate: only a non-viewer member may write,
+-- and only for the caller's active company.
+
+ALTER TABLE public.fiscal_period_tax_adjustments
+ ALTER COLUMN id SET DEFAULT uuid_generate_v4();
+
+-- Serialize adjustment writes with fiscal-period close/lock updates. The row
+-- lock prevents a concurrent request from saving tax inputs after closing has
+-- started but before the period status change becomes visible.
+CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_adjustment()
+RETURNS trigger
+LANGUAGE plpgsql
+SET search_path = public, pg_temp
+AS $$
+DECLARE
+ adjustment_row public.fiscal_period_tax_adjustments%ROWTYPE;
+ period_row public.fiscal_periods%ROWTYPE;
+BEGIN
+ IF TG_OP = 'UPDATE'
+ AND (
+ OLD.company_id IS DISTINCT FROM NEW.company_id
+ OR OLD.fiscal_period_id IS DISTINCT FROM NEW.fiscal_period_id
+ ) THEN
+ RAISE EXCEPTION 'Tax adjustment company and fiscal period are immutable'
+ USING ERRCODE = '23514';
+ END IF;
+
+ IF TG_OP = 'DELETE' THEN
+ adjustment_row := OLD;
+ ELSE
+ adjustment_row := NEW;
+ END IF;
+
+ SELECT * INTO period_row
+ FROM public.fiscal_periods
+ WHERE id = adjustment_row.fiscal_period_id
+ FOR UPDATE;
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Fiscal period not found for tax adjustment'
+ USING ERRCODE = '23503';
+ END IF;
+
+ IF period_row.company_id IS DISTINCT FROM adjustment_row.company_id THEN
+ RAISE EXCEPTION 'Tax adjustment company does not match fiscal period company'
+ USING ERRCODE = '23514';
+ END IF;
+
+ IF period_row.is_closed
+ OR period_row.locked_at IS NOT NULL
+ OR period_row.closing_entry_id IS NOT NULL THEN
+ RAISE EXCEPTION 'Fiscal period is locked for tax adjustments'
+ USING ERRCODE = '23514';
+ END IF;
+
+ IF TG_OP = 'DELETE' THEN
+ RETURN OLD;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+DROP POLICY IF EXISTS "insert own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments;
+CREATE POLICY "insert own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments FOR INSERT TO public
+ WITH CHECK (
+ company_id = public.current_active_company_id()
+ AND public.current_user_can_write()
+ AND user_id = auth.uid()
+ );
+
+DROP POLICY IF EXISTS "update own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments;
+CREATE POLICY "update own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments FOR UPDATE TO public
+ USING (
+ company_id = public.current_active_company_id()
+ AND public.current_user_can_write()
+ )
+ WITH CHECK (
+ company_id = public.current_active_company_id()
+ AND public.current_user_can_write()
+ AND user_id = auth.uid()
+ );
+
+DROP POLICY IF EXISTS "delete own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments;
+CREATE POLICY "delete own-company fiscal period tax adjustments"
+ ON public.fiscal_period_tax_adjustments FOR DELETE TO public
+ USING (
+ company_id = public.current_active_company_id()
+ AND public.current_user_can_write()
+ );
+
+-- Only the corporate-tax disposition sets source_id to the fiscal period.
+-- This database invariant closes the read-then-insert race between two POSTs.
+CREATE UNIQUE INDEX uq_year_end_corporate_tax_per_period
+ ON public.journal_entries (company_id, source_id)
+ WHERE source_type = 'year_end'
+ AND source_id IS NOT NULL
+ AND status IN ('draft', 'posted');
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql b/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql
new file mode 100644
index 00000000..1af96d4d
--- /dev/null
+++ b/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql
@@ -0,0 +1,33 @@
+-- BFL 7 kap. 2 § requires accounting information to be preserved through
+-- the end of the seventh calendar year after the calendar year in which the
+-- fiscal year ended. retention_expires_at stores the first date on which the
+-- statutory minimum retention period has elapsed.
+
+CREATE OR REPLACE FUNCTION public.set_bfl_retention_expiry()
+RETURNS trigger
+LANGUAGE plpgsql
+SET search_path = public
+AS $$
+BEGIN
+ NEW.retention_expires_at := make_date(
+ extract(year FROM NEW.period_end)::integer + 8,
+ 1,
+ 1
+ );
+ RETURN NEW;
+END;
+$$;
+
+-- PostgreSQL fires triggers with the same timing alphabetically. The zz
+-- prefix makes this legal correction run after the original migration 017
+-- trigger without modifying that shipped enforcement migration.
+CREATE TRIGGER zz_set_bfl_retention_expiry
+ BEFORE INSERT OR UPDATE OF period_end ON public.fiscal_periods
+ FOR EACH ROW EXECUTE FUNCTION public.set_bfl_retention_expiry();
+
+UPDATE public.fiscal_periods
+SET retention_expires_at = make_date(
+ extract(year FROM period_end)::integer + 8,
+ 1,
+ 1
+);
diff --git a/supabase/migrations/20260721160000_annual_report_versions_and_compliance.sql b/supabase/migrations/20260721160000_annual_report_versions_and_compliance.sql
new file mode 100644
index 00000000..6f93184e
--- /dev/null
+++ b/supabase/migrations/20260721160000_annual_report_versions_and_compliance.sql
@@ -0,0 +1,1114 @@
+-- Versioned annual reports, explicit K2 eligibility facts, disclosure
+-- confirmations, signature evidence, and safer Bolagsverket submission state.
+
+CREATE TABLE public.annual_report_profiles (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
+ fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE CASCADE,
+ user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ is_public_limited_company boolean,
+ is_in_liquidation boolean,
+ securities_traded_on_regulated_market boolean,
+ is_parent_company boolean,
+ parent_group_size text CHECK (parent_group_size IN ('none', 'small', 'large')),
+ prepares_consolidated_accounts boolean,
+ has_foreign_branch boolean,
+ has_crypto_assets boolean,
+ has_share_based_payments boolean,
+ has_convertible_debt boolean,
+ building_revenue_share_pct numeric(5, 2) CHECK (
+ building_revenue_share_pct IS NULL
+ OR building_revenue_share_pct BETWEEN 0 AND 100
+ ),
+ has_material_deferred_tax boolean,
+ reporting_currency text NOT NULL DEFAULT 'SEK' CHECK (reporting_currency IN ('SEK', 'EUR')),
+ auditor_report_required boolean,
+ auditor_report_included boolean NOT NULL DEFAULT false,
+ dividend_prudence_confirmed boolean,
+ narrative_confirmed_at timestamptz,
+ k2_assessment_confirmed_at timestamptz,
+ signer_roster_confirmed_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT annual_report_profiles_company_period_unique UNIQUE (company_id, fiscal_period_id),
+ CONSTRAINT annual_report_profiles_parent_consistency CHECK (
+ is_parent_company IS NOT false
+ OR (parent_group_size IS NULL AND prepares_consolidated_accounts IS NOT true)
+ )
+);
+
+CREATE INDEX idx_annual_report_profiles_period
+ ON public.annual_report_profiles (company_id, fiscal_period_id);
+
+ALTER TABLE public.annual_report_profiles ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY annual_report_profiles_select ON public.annual_report_profiles
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+CREATE POLICY annual_report_profiles_insert ON public.annual_report_profiles
+ FOR INSERT WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = annual_report_profiles.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ AND (user_id IS NULL OR user_id = auth.uid())
+ );
+CREATE POLICY annual_report_profiles_update ON public.annual_report_profiles
+ FOR UPDATE USING (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = annual_report_profiles.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = annual_report_profiles.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+CREATE POLICY annual_report_profiles_delete ON public.annual_report_profiles
+ FOR DELETE USING (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = annual_report_profiles.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+CREATE TRIGGER annual_report_profiles_updated_at
+ BEFORE UPDATE ON public.annual_report_profiles
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+CREATE TRIGGER audit_annual_report_profiles
+ AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_profiles
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+ALTER TABLE public.arsredovisning_narratives
+ ADD COLUMN long_term_debt_over_five_years_confirmed boolean NOT NULL DEFAULT false,
+ ADD COLUMN securities_pledged_confirmed boolean NOT NULL DEFAULT false,
+ ADD COLUMN contingent_liabilities_confirmed boolean NOT NULL DEFAULT false,
+ ADD COLUMN parent_company_confirmed boolean NOT NULL DEFAULT false,
+ ADD COLUMN agm_disposition_outcome text CHECK (
+ agm_disposition_outcome IN ('proposal_approved', 'alternative_decision')
+ ),
+ ADD COLUMN agm_disposition_decision text CHECK (
+ agm_disposition_decision IS NULL OR length(agm_disposition_decision) <= 2000
+ ),
+ ADD COLUMN proposed_dividend numeric(15, 2) CHECK (
+ proposed_dividend IS NULL OR proposed_dividend >= 0
+ ),
+ ADD CONSTRAINT arsredovisning_narratives_agm_decision_consistency CHECK (
+ agm_disposition_outcome IS DISTINCT FROM 'alternative_decision'
+ OR nullif(trim(agm_disposition_decision), '') IS NOT NULL
+ );
+
+CREATE TABLE public.annual_report_versions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
+ fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT,
+ user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ version_number integer NOT NULL CHECK (version_number > 0),
+ schema_version text NOT NULL,
+ framework text NOT NULL CHECK (framework IN ('k2', 'k3')),
+ status text NOT NULL DEFAULT 'draft' CHECK (
+ status IN ('draft', 'ready_for_signature', 'signed', 'filed', 'registered', 'superseded')
+ ),
+ report_data jsonb NOT NULL,
+ ixbrl_data jsonb,
+ content_hash text NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
+ taxonomy_version text,
+ entry_point text,
+ validation_summary jsonb NOT NULL DEFAULT '{}'::jsonb,
+ supersedes_version_id uuid REFERENCES public.annual_report_versions(id),
+ finalized_at timestamptz,
+ finalized_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT annual_report_versions_number_unique UNIQUE (company_id, fiscal_period_id, version_number),
+ CONSTRAINT annual_report_versions_content_unique UNIQUE (company_id, fiscal_period_id, content_hash),
+ CONSTRAINT annual_report_versions_finalized_consistency CHECK (
+ (status = 'draft' AND finalized_at IS NULL)
+ OR (status <> 'draft' AND finalized_at IS NOT NULL)
+ )
+);
+
+CREATE INDEX idx_annual_report_versions_period
+ ON public.annual_report_versions (company_id, fiscal_period_id, version_number DESC);
+CREATE INDEX idx_annual_report_versions_status
+ ON public.annual_report_versions (company_id, status);
+
+ALTER TABLE public.annual_report_versions ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY annual_report_versions_select ON public.annual_report_versions
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+CREATE POLICY annual_report_versions_insert ON public.annual_report_versions
+ FOR INSERT WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = annual_report_versions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ AND (user_id IS NULL OR user_id = auth.uid())
+ AND status = 'draft'
+ AND finalized_at IS NULL
+ AND finalized_by IS NULL
+ );
+CREATE POLICY annual_report_versions_update ON public.annual_report_versions
+ FOR UPDATE USING (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = annual_report_versions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = annual_report_versions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ AND status IN ('signed', 'filed')
+ );
+CREATE POLICY annual_report_versions_no_delete ON public.annual_report_versions
+ FOR DELETE USING (false);
+
+CREATE OR REPLACE FUNCTION public.enforce_annual_report_version_immutability()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF NEW.company_id IS DISTINCT FROM OLD.company_id
+ OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id
+ OR NEW.user_id IS DISTINCT FROM OLD.user_id
+ OR NEW.version_number IS DISTINCT FROM OLD.version_number
+ OR NEW.schema_version IS DISTINCT FROM OLD.schema_version
+ OR NEW.framework IS DISTINCT FROM OLD.framework
+ OR NEW.report_data IS DISTINCT FROM OLD.report_data
+ OR NEW.ixbrl_data IS DISTINCT FROM OLD.ixbrl_data
+ OR NEW.content_hash IS DISTINCT FROM OLD.content_hash
+ OR NEW.taxonomy_version IS DISTINCT FROM OLD.taxonomy_version
+ OR NEW.entry_point IS DISTINCT FROM OLD.entry_point
+ OR NEW.validation_summary IS DISTINCT FROM OLD.validation_summary
+ OR NEW.supersedes_version_id IS DISTINCT FROM OLD.supersedes_version_id
+ OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
+ RAISE EXCEPTION 'Annual report version content is immutable (id=%)', OLD.id
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF NEW.status IS DISTINCT FROM OLD.status AND NOT (
+ (OLD.status = 'draft' AND NEW.status IN ('ready_for_signature', 'superseded'))
+ OR (OLD.status = 'ready_for_signature' AND NEW.status IN ('signed', 'superseded'))
+ OR (OLD.status = 'signed' AND NEW.status IN ('filed', 'superseded'))
+ OR (OLD.status = 'filed' AND NEW.status IN ('registered', 'superseded'))
+ ) THEN
+ RAISE EXCEPTION 'Invalid annual report version status transition: % to %', OLD.status, NEW.status
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF NEW.status = 'signed' AND OLD.status IS DISTINCT FROM 'signed' AND (
+ NOT EXISTS (
+ SELECT 1
+ FROM public.arsredovisning_signature_requests request
+ WHERE request.annual_report_version_id = NEW.id
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM public.arsredovisning_signature_requests request
+ WHERE request.annual_report_version_id = NEW.id
+ AND request.status <> 'signed'
+ )
+ ) THEN
+ RAISE EXCEPTION 'Annual report version cannot be signed before every locked signer has signed'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF NEW.status = 'filed' AND OLD.status IS DISTINCT FROM 'filed' AND NOT EXISTS (
+ SELECT 1
+ FROM public.arsredovisning_submissions submission
+ WHERE submission.annual_report_version_id = NEW.id
+ AND submission.archive_status = 'stored'
+ AND submission.uploaded_at IS NOT NULL
+ AND submission.idnummer IS NOT NULL
+ ) THEN
+ RAISE EXCEPTION 'Annual report version cannot be filed without an archived Bolagsverket upload receipt'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF NEW.status = 'registered' AND OLD.status IS DISTINCT FROM 'registered' AND NOT EXISTS (
+ SELECT 1
+ FROM public.arsredovisning_submissions submission
+ WHERE submission.annual_report_version_id = NEW.id
+ AND submission.status = 'registrerad'
+ ) THEN
+ RAISE EXCEPTION 'Annual report version cannot be registered without a registered Bolagsverket submission'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF OLD.finalized_at IS NOT NULL AND NEW.finalized_at IS DISTINCT FROM OLD.finalized_at THEN
+ RAISE EXCEPTION 'Finalization metadata is immutable (id=%)', OLD.id
+ USING ERRCODE = 'check_violation';
+ END IF;
+ IF OLD.finalized_by IS NOT NULL AND NEW.finalized_by IS DISTINCT FROM OLD.finalized_by THEN
+ RAISE EXCEPTION 'Finalization metadata is immutable (id=%)', OLD.id
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER enforce_annual_report_version_immutability
+ BEFORE UPDATE ON public.annual_report_versions
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_annual_report_version_immutability();
+
+CREATE OR REPLACE FUNCTION public.block_annual_report_version_deletion()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ RAISE EXCEPTION 'Annual report versions are retained as immutable accounting information (id=%)', OLD.id
+ USING ERRCODE = 'check_violation';
+END;
+$$;
+
+CREATE TRIGGER block_annual_report_version_deletion
+ BEFORE DELETE ON public.annual_report_versions
+ FOR EACH ROW EXECUTE FUNCTION public.block_annual_report_version_deletion();
+
+CREATE TRIGGER audit_annual_report_versions
+ AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_versions
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+CREATE TABLE public.annual_report_validation_runs (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
+ fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT,
+ version_id uuid NOT NULL REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT,
+ user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ validation_layer text NOT NULL CHECK (validation_layer IN ('local', 'arelle', 'bolagsverket')),
+ status text NOT NULL CHECK (status IN ('passed', 'warnings', 'failed', 'unavailable')),
+ validator_version text,
+ artifact_hash text CHECK (artifact_hash IS NULL OR artifact_hash ~ '^[a-f0-9]{64}$'),
+ issues jsonb NOT NULL DEFAULT '[]'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_annual_report_validation_runs_version
+ ON public.annual_report_validation_runs (company_id, version_id, created_at DESC);
+
+ALTER TABLE public.annual_report_validation_runs ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY annual_report_validation_runs_select ON public.annual_report_validation_runs
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+CREATE POLICY annual_report_validation_runs_insert ON public.annual_report_validation_runs
+ FOR INSERT WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = annual_report_validation_runs.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ AND (user_id IS NULL OR user_id = auth.uid())
+ );
+CREATE POLICY annual_report_validation_runs_no_update ON public.annual_report_validation_runs
+ FOR UPDATE USING (false) WITH CHECK (false);
+CREATE POLICY annual_report_validation_runs_no_delete ON public.annual_report_validation_runs
+ FOR DELETE USING (false);
+
+CREATE TRIGGER audit_annual_report_validation_runs
+ AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_validation_runs
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+CREATE OR REPLACE FUNCTION public.validate_annual_report_company_links()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ linked_version_company uuid;
+ linked_version_period uuid;
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM public.fiscal_periods fp
+ WHERE fp.id = NEW.fiscal_period_id
+ AND fp.company_id = NEW.company_id
+ ) THEN
+ RAISE EXCEPTION 'Fiscal period does not belong to annual report company'
+ USING ERRCODE = 'foreign_key_violation';
+ END IF;
+
+ IF TG_TABLE_NAME = 'annual_report_validation_runs' THEN
+ SELECT company_id, fiscal_period_id
+ INTO linked_version_company, linked_version_period
+ FROM public.annual_report_versions
+ WHERE id = NEW.version_id;
+ IF linked_version_company IS DISTINCT FROM NEW.company_id
+ OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN
+ RAISE EXCEPTION 'Annual report validation version belongs to another company or period'
+ USING ERRCODE = 'foreign_key_violation';
+ END IF;
+ ELSIF TG_TABLE_NAME = 'annual_report_versions' THEN
+ IF NEW.supersedes_version_id IS NOT NULL THEN
+ SELECT company_id, fiscal_period_id
+ INTO linked_version_company, linked_version_period
+ FROM public.annual_report_versions
+ WHERE id = NEW.supersedes_version_id;
+ IF linked_version_company IS DISTINCT FROM NEW.company_id
+ OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN
+ RAISE EXCEPTION 'Superseded annual report version belongs to another company or period'
+ USING ERRCODE = 'foreign_key_violation';
+ END IF;
+ END IF;
+ ELSIF TG_TABLE_NAME IN ('arsredovisning_signature_requests', 'arsredovisning_submissions') THEN
+ IF NEW.annual_report_version_id IS NOT NULL THEN
+ SELECT company_id, fiscal_period_id
+ INTO linked_version_company, linked_version_period
+ FROM public.annual_report_versions
+ WHERE id = NEW.annual_report_version_id;
+ IF linked_version_company IS DISTINCT FROM NEW.company_id
+ OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN
+ RAISE EXCEPTION 'Linked annual report version belongs to another company or period'
+ USING ERRCODE = 'foreign_key_violation';
+ END IF;
+ END IF;
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER validate_annual_report_profiles_company_links
+ BEFORE INSERT OR UPDATE ON public.annual_report_profiles
+ FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
+
+CREATE TRIGGER validate_annual_report_versions_company_links
+ BEFORE INSERT OR UPDATE ON public.annual_report_versions
+ FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
+
+CREATE TRIGGER validate_annual_report_validation_company_links
+ BEFORE INSERT OR UPDATE ON public.annual_report_validation_runs
+ FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
+
+CREATE OR REPLACE FUNCTION public.create_annual_report_version(
+ p_company_id uuid,
+ p_fiscal_period_id uuid,
+ p_schema_version text,
+ p_framework text,
+ p_status text,
+ p_report_data jsonb,
+ p_ixbrl_data jsonb,
+ p_content_hash text,
+ p_taxonomy_version text,
+ p_entry_point text,
+ p_validation_summary jsonb,
+ p_user_id uuid
+)
+RETURNS SETOF public.annual_report_versions
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = public
+AS $$
+DECLARE
+ existing_row public.annual_report_versions%ROWTYPE;
+ next_version integer;
+ previous_version_id uuid;
+BEGIN
+ IF auth.role() IS DISTINCT FROM 'service_role' AND p_user_id IS DISTINCT FROM auth.uid() THEN
+ RAISE EXCEPTION 'Annual report version user must match authenticated user'
+ USING ERRCODE = 'insufficient_privilege';
+ END IF;
+ IF p_status <> 'draft' THEN
+ RAISE EXCEPTION 'Direct annual report version creation only permits draft status'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_company_id::text || ':' || p_fiscal_period_id::text, 0));
+
+ SELECT * INTO existing_row
+ FROM public.annual_report_versions
+ WHERE company_id = p_company_id
+ AND fiscal_period_id = p_fiscal_period_id
+ AND content_hash = p_content_hash;
+ IF FOUND THEN
+ RETURN NEXT existing_row;
+ RETURN;
+ END IF;
+
+ SELECT version_number, id
+ INTO next_version, previous_version_id
+ FROM public.annual_report_versions
+ WHERE company_id = p_company_id
+ AND fiscal_period_id = p_fiscal_period_id
+ ORDER BY version_number DESC
+ LIMIT 1;
+ next_version := coalesce(next_version, 0) + 1;
+
+ INSERT INTO public.annual_report_versions (
+ company_id,
+ fiscal_period_id,
+ user_id,
+ version_number,
+ schema_version,
+ framework,
+ status,
+ report_data,
+ ixbrl_data,
+ content_hash,
+ taxonomy_version,
+ entry_point,
+ validation_summary,
+ supersedes_version_id,
+ finalized_at,
+ finalized_by
+ ) VALUES (
+ p_company_id,
+ p_fiscal_period_id,
+ p_user_id,
+ next_version,
+ p_schema_version,
+ p_framework,
+ p_status,
+ p_report_data,
+ p_ixbrl_data,
+ p_content_hash,
+ p_taxonomy_version,
+ p_entry_point,
+ p_validation_summary,
+ previous_version_id,
+ NULL,
+ NULL
+ )
+ RETURNING * INTO existing_row;
+
+ RETURN NEXT existing_row;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.create_annual_report_version(
+ uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid
+) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.create_annual_report_version(
+ uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid
+) TO authenticated, service_role;
+
+ALTER TABLE public.arsredovisning_signature_requests
+ ADD COLUMN annual_report_version_id uuid REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT,
+ ADD COLUMN signing_method text CHECK (
+ signing_method IN ('paper_original', 'advanced_e_signature', 'bankid', 'bolagsverket')
+ ),
+ ADD COLUMN evidence_reference text CHECK (
+ evidence_reference IS NULL OR length(evidence_reference) <= 500
+ ),
+ ADD COLUMN evidence_recorded_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
+ ADD COLUMN evidence_recorded_at timestamptz,
+ ADD CONSTRAINT arsredovisning_signature_evidence_consistency CHECK (
+ status <> 'signed'
+ OR annual_report_version_id IS NULL
+ OR (
+ signing_method IS NOT NULL
+ AND evidence_recorded_at IS NOT NULL
+ AND nullif(trim(evidence_reference), '') IS NOT NULL
+ )
+ );
+
+CREATE OR REPLACE FUNCTION public.invalidate_annual_report_signer_roster_confirmation()
+RETURNS trigger
+LANGUAGE plpgsql
+SET search_path = public
+AS $$
+DECLARE
+ affected_company_id uuid;
+ affected_fiscal_period_id uuid;
+ should_invalidate boolean := false;
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ should_invalidate := NEW.annual_report_version_id IS NULL;
+ affected_company_id := NEW.company_id;
+ affected_fiscal_period_id := NEW.fiscal_period_id;
+ ELSIF TG_OP = 'DELETE' THEN
+ should_invalidate := OLD.annual_report_version_id IS NULL;
+ affected_company_id := OLD.company_id;
+ affected_fiscal_period_id := OLD.fiscal_period_id;
+ ELSE
+ should_invalidate := OLD.annual_report_version_id IS NULL
+ AND NEW.annual_report_version_id IS NULL
+ AND (
+ NEW.role IS DISTINCT FROM OLD.role
+ OR NEW.signer_name IS DISTINCT FROM OLD.signer_name
+ OR NEW.status IS DISTINCT FROM OLD.status
+ );
+ affected_company_id := NEW.company_id;
+ affected_fiscal_period_id := NEW.fiscal_period_id;
+ END IF;
+
+ IF should_invalidate THEN
+ UPDATE public.annual_report_profiles
+ SET signer_roster_confirmed_at = NULL
+ WHERE company_id = affected_company_id
+ AND fiscal_period_id = affected_fiscal_period_id
+ AND signer_roster_confirmed_at IS NOT NULL;
+ END IF;
+ RETURN NULL;
+END;
+$$;
+
+CREATE TRIGGER invalidate_annual_report_signer_roster_confirmation
+ AFTER INSERT OR UPDATE OR DELETE ON public.arsredovisning_signature_requests
+ FOR EACH ROW EXECUTE FUNCTION public.invalidate_annual_report_signer_roster_confirmation();
+
+CREATE OR REPLACE FUNCTION public.prepare_annual_report_signature_slots(
+ p_company_id uuid,
+ p_fiscal_period_id uuid,
+ p_annual_report_version_id uuid,
+ p_user_id uuid
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = public
+AS $$
+DECLARE
+ bound_slot_count integer;
+BEGIN
+ IF auth.role() IS DISTINCT FROM 'service_role' AND p_user_id IS DISTINCT FROM auth.uid() THEN
+ RAISE EXCEPTION 'Annual report signature user must match authenticated user'
+ USING ERRCODE = 'insufficient_privilege';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM public.annual_report_versions
+ WHERE id = p_annual_report_version_id
+ AND company_id = p_company_id
+ AND fiscal_period_id = p_fiscal_period_id
+ AND status = 'ready_for_signature'
+ ) THEN
+ RAISE EXCEPTION 'Annual report version is not ready for signatures'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ UPDATE public.arsredovisning_signature_requests
+ SET annual_report_version_id = p_annual_report_version_id
+ WHERE company_id = p_company_id
+ AND fiscal_period_id = p_fiscal_period_id
+ AND status = 'pending'
+ AND annual_report_version_id IS NULL;
+ GET DIAGNOSTICS bound_slot_count = ROW_COUNT;
+
+ IF bound_slot_count = 0 THEN
+ INSERT INTO public.arsredovisning_signature_requests (
+ user_id,
+ company_id,
+ fiscal_period_id,
+ annual_report_version_id,
+ role,
+ signer_name,
+ status
+ )
+ SELECT
+ p_user_id,
+ p_company_id,
+ p_fiscal_period_id,
+ p_annual_report_version_id,
+ roster.role,
+ roster.signer_name,
+ 'pending'
+ FROM (
+ SELECT DISTINCT ON (lower(trim(request.role)), lower(trim(request.signer_name)))
+ request.role,
+ request.signer_name
+ FROM public.arsredovisning_signature_requests request
+ JOIN public.annual_report_versions prior_version
+ ON prior_version.id = request.annual_report_version_id
+ WHERE prior_version.company_id = p_company_id
+ AND prior_version.fiscal_period_id = p_fiscal_period_id
+ AND prior_version.id <> p_annual_report_version_id
+ AND prior_version.version_number = (
+ SELECT max(candidate.version_number)
+ FROM public.annual_report_versions candidate
+ WHERE candidate.company_id = p_company_id
+ AND candidate.fiscal_period_id = p_fiscal_period_id
+ AND candidate.id <> p_annual_report_version_id
+ AND EXISTS (
+ SELECT 1
+ FROM public.arsredovisning_signature_requests candidate_request
+ WHERE candidate_request.annual_report_version_id = candidate.id
+ )
+ )
+ ORDER BY
+ lower(trim(request.role)),
+ lower(trim(request.signer_name)),
+ request.created_at DESC
+ ) AS roster;
+ END IF;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.prepare_annual_report_signature_slots(
+ uuid, uuid, uuid, uuid
+) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.prepare_annual_report_signature_slots(
+ uuid, uuid, uuid, uuid
+) TO service_role;
+
+CREATE OR REPLACE FUNCTION public.create_annual_report_version_with_signatures(
+ p_company_id uuid,
+ p_fiscal_period_id uuid,
+ p_schema_version text,
+ p_framework text,
+ p_status text,
+ p_report_data jsonb,
+ p_ixbrl_data jsonb,
+ p_content_hash text,
+ p_taxonomy_version text,
+ p_entry_point text,
+ p_validation_summary jsonb,
+ p_user_id uuid
+)
+RETURNS SETOF public.annual_report_versions
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = public
+AS $$
+DECLARE
+ version_row public.annual_report_versions%ROWTYPE;
+ live_roster_confirmation timestamptz;
+BEGIN
+ IF auth.role() IS DISTINCT FROM 'service_role' THEN
+ RAISE EXCEPTION 'Final annual report versions may only be created by the trusted application service'
+ USING ERRCODE = 'insufficient_privilege';
+ END IF;
+ IF p_status <> 'ready_for_signature' THEN
+ RAISE EXCEPTION 'Signature-bound annual report creation requires ready_for_signature status'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF jsonb_typeof(p_report_data) IS DISTINCT FROM 'object'
+ OR p_report_data #>> '{fiscal_period,id}' IS DISTINCT FROM p_fiscal_period_id::text
+ OR p_report_data ->> 'accounting_framework' IS DISTINCT FROM p_framework THEN
+ RAISE EXCEPTION 'Annual report payload does not match its company framework or fiscal period'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ IF jsonb_typeof(p_validation_summary) IS DISTINCT FROM 'object'
+ OR p_validation_summary -> 'ok' IS DISTINCT FROM 'true'::jsonb
+ OR p_validation_summary -> 'error_count' IS DISTINCT FROM '0'::jsonb
+ OR p_validation_summary ->> 'stage' IS DISTINCT FROM 'signing'
+ OR jsonb_typeof(p_validation_summary -> 'profile') IS DISTINCT FROM 'object'
+ OR jsonb_typeof(p_validation_summary -> 'disclosures') IS DISTINCT FROM 'object'
+ OR jsonb_typeof(p_validation_summary -> 'eligibility') IS DISTINCT FROM 'object'
+ OR p_validation_summary #>> '{profile,company_id}' IS DISTINCT FROM p_company_id::text
+ OR p_validation_summary #>> '{profile,fiscal_period_id}' IS DISTINCT FROM p_fiscal_period_id::text THEN
+ RAISE EXCEPTION 'Annual report finalization requires a complete server validation snapshot'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ SELECT signer_roster_confirmed_at
+ INTO live_roster_confirmation
+ FROM public.annual_report_profiles
+ WHERE company_id = p_company_id
+ AND fiscal_period_id = p_fiscal_period_id;
+ IF live_roster_confirmation IS NULL
+ OR (p_validation_summary #>> '{profile,signer_roster_confirmed_at}')::timestamptz
+ IS DISTINCT FROM live_roster_confirmation THEN
+ RAISE EXCEPTION 'Annual report signer roster must be confirmed against the current company representatives'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ SELECT * INTO version_row
+ FROM public.create_annual_report_version(
+ p_company_id,
+ p_fiscal_period_id,
+ p_schema_version,
+ p_framework,
+ 'draft',
+ p_report_data,
+ p_ixbrl_data,
+ p_content_hash,
+ p_taxonomy_version,
+ p_entry_point,
+ p_validation_summary,
+ p_user_id
+ );
+
+ IF version_row.status = 'draft' THEN
+ UPDATE public.annual_report_versions
+ SET status = 'ready_for_signature',
+ finalized_at = now(),
+ finalized_by = p_user_id
+ WHERE id = version_row.id
+ RETURNING * INTO version_row;
+
+ UPDATE public.annual_report_versions
+ SET status = 'superseded'
+ WHERE id = version_row.supersedes_version_id
+ AND status IN ('ready_for_signature', 'signed');
+ END IF;
+
+ IF version_row.status = 'ready_for_signature' THEN
+ PERFORM public.prepare_annual_report_signature_slots(
+ p_company_id,
+ p_fiscal_period_id,
+ version_row.id,
+ p_user_id
+ );
+ IF NOT EXISTS (
+ SELECT 1
+ FROM public.arsredovisning_signature_requests
+ WHERE annual_report_version_id = version_row.id
+ ) THEN
+ RAISE EXCEPTION 'Annual report version requires at least one signer slot'
+ USING ERRCODE = 'check_violation';
+ END IF;
+ END IF;
+
+ RETURN NEXT version_row;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.create_annual_report_version_with_signatures(
+ uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid
+) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.create_annual_report_version_with_signatures(
+ uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid
+) TO service_role;
+
+CREATE INDEX idx_arsredovisning_sigreq_version
+ ON public.arsredovisning_signature_requests (company_id, annual_report_version_id);
+WITH duplicate_roster_slots AS (
+ SELECT
+ id,
+ row_number() OVER (
+ PARTITION BY company_id, fiscal_period_id, lower(trim(role)), lower(trim(signer_name))
+ ORDER BY created_at, id
+ ) AS duplicate_number
+ FROM public.arsredovisning_signature_requests
+ WHERE annual_report_version_id IS NULL AND status = 'pending'
+)
+DELETE FROM public.arsredovisning_signature_requests request
+USING duplicate_roster_slots duplicate
+WHERE request.id = duplicate.id
+ AND duplicate.duplicate_number > 1;
+CREATE UNIQUE INDEX uq_arsredovisning_sigreq_unbound_roster
+ ON public.arsredovisning_signature_requests (
+ company_id,
+ fiscal_period_id,
+ (lower(trim(role))),
+ (lower(trim(signer_name)))
+ )
+ WHERE annual_report_version_id IS NULL AND status = 'pending';
+
+CREATE TRIGGER validate_arsredovisning_signature_version_links
+ BEFORE INSERT OR UPDATE ON public.arsredovisning_signature_requests
+ FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
+
+CREATE TRIGGER audit_arsredovisning_signature_requests
+ AFTER INSERT OR UPDATE OR DELETE ON public.arsredovisning_signature_requests
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+CREATE OR REPLACE FUNCTION public.serialize_annual_report_signature_signing()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF NEW.status = 'signed'
+ AND OLD.status IS DISTINCT FROM 'signed'
+ AND NEW.annual_report_version_id IS NOT NULL THEN
+ PERFORM pg_advisory_xact_lock(
+ hashtextextended(NEW.annual_report_version_id::text, 0)
+ );
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER serialize_annual_report_signature_signing
+ BEFORE UPDATE ON public.arsredovisning_signature_requests
+ FOR EACH ROW EXECUTE FUNCTION public.serialize_annual_report_signature_signing();
+
+CREATE OR REPLACE FUNCTION public.complete_annual_report_version_signing()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF NEW.status = 'signed'
+ AND OLD.status IS DISTINCT FROM 'signed'
+ AND NEW.annual_report_version_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM public.arsredovisning_signature_requests pending_signature
+ WHERE pending_signature.annual_report_version_id = NEW.annual_report_version_id
+ AND pending_signature.status <> 'signed'
+ ) THEN
+ UPDATE public.annual_report_versions
+ SET status = 'signed'
+ WHERE id = NEW.annual_report_version_id
+ AND company_id = NEW.company_id
+ AND fiscal_period_id = NEW.fiscal_period_id
+ AND status = 'ready_for_signature';
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER complete_annual_report_version_signing
+ AFTER UPDATE ON public.arsredovisning_signature_requests
+ FOR EACH ROW EXECUTE FUNCTION public.complete_annual_report_version_signing();
+
+CREATE OR REPLACE FUNCTION public.enforce_signed_signature_request_immutability()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF OLD.status = 'signed' THEN
+ IF to_jsonb(NEW) IS DISTINCT FROM to_jsonb(OLD) THEN
+ RAISE EXCEPTION 'Cannot modify a signed signature request (id=%)', OLD.id
+ USING ERRCODE = 'check_violation';
+ END IF;
+ ELSIF OLD.status = 'declined' THEN
+ IF to_jsonb(NEW) IS DISTINCT FROM to_jsonb(OLD) THEN
+ RAISE EXCEPTION 'Cannot modify a declined signature request (id=%)', OLD.id
+ USING ERRCODE = 'check_violation';
+ END IF;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+UPDATE public.arsredovisning_submissions
+SET handling_typ = 'arsredovisning_komplett'
+WHERE handling_typ = 'arsredovisning';
+
+ALTER TABLE public.arsredovisning_submissions
+ DROP CONSTRAINT IF EXISTS arsredovisning_submissions_handling_typ_check,
+ DROP CONSTRAINT IF EXISTS arsredovisning_submissions_status_check;
+
+ALTER TABLE public.arsredovisning_submissions
+ ADD CONSTRAINT arsredovisning_submissions_handling_typ_check CHECK (
+ handling_typ IN ('arsredovisning_komplett', 'arsredovisning_kompletteras', 'revisionsberattelse')
+ ),
+ ADD CONSTRAINT arsredovisning_submissions_status_check CHECK (
+ status IN (
+ 'draft', 'kontrollerad', 'sending', 'uploaded', 'unknown', 'inkommen',
+ 'forelagd', 'komplettering', 'registrerad', 'avslutad', 'error'
+ )
+ ),
+ ADD COLUMN annual_report_version_id uuid REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT,
+ ADD COLUMN request_key text,
+ ADD COLUMN archive_status text NOT NULL DEFAULT 'pending' CHECK (
+ archive_status IN ('pending', 'stored', 'failed')
+ ),
+ ADD COLUMN upload_started_at timestamptz,
+ ADD COLUMN external_receipt jsonb;
+
+CREATE UNIQUE INDEX uq_arsredovisning_submissions_request_key
+ ON public.arsredovisning_submissions (company_id, environment, request_key)
+ WHERE request_key IS NOT NULL;
+
+CREATE UNIQUE INDEX uq_arsredovisning_submissions_remote_id
+ ON public.arsredovisning_submissions (environment, idnummer)
+ WHERE idnummer IS NOT NULL;
+
+CREATE TRIGGER validate_arsredovisning_submission_version_links
+ BEFORE INSERT OR UPDATE ON public.arsredovisning_submissions
+ FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
+
+CREATE OR REPLACE FUNCTION public.enforce_arsred_submission_immutability()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF OLD.uploaded_at IS NOT NULL OR OLD.status IN ('sending', 'unknown', 'uploaded', 'inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad') THEN
+ IF NEW.company_id IS DISTINCT FROM OLD.company_id
+ OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id
+ OR NEW.annual_report_version_id IS DISTINCT FROM OLD.annual_report_version_id
+ OR NEW.handling_typ IS DISTINCT FROM OLD.handling_typ
+ OR NEW.taxonomy_version IS DISTINCT FROM OLD.taxonomy_version
+ OR NEW.entry_point IS DISTINCT FROM OLD.entry_point
+ OR NEW.environment IS DISTINCT FROM OLD.environment
+ OR NEW.request_key IS DISTINCT FROM OLD.request_key
+ OR (OLD.idnummer IS NOT NULL AND NEW.idnummer IS DISTINCT FROM OLD.idnummer)
+ OR (OLD.sha256_checksumma IS NOT NULL AND NEW.sha256_checksumma IS DISTINCT FROM OLD.sha256_checksumma)
+ OR NEW.kontrollsumma IS DISTINCT FROM OLD.kontrollsumma
+ OR NEW.dokument_id IS DISTINCT FROM OLD.dokument_id
+ OR NEW.archive_status IS DISTINCT FROM OLD.archive_status
+ OR NEW.undertecknare_pnr_hash IS DISTINCT FROM OLD.undertecknare_pnr_hash
+ OR NEW.avsandare_pnr_hash IS DISTINCT FROM OLD.avsandare_pnr_hash
+ OR NEW.upload_started_at IS DISTINCT FROM OLD.upload_started_at
+ OR (OLD.bolagsverket_url IS NOT NULL AND NEW.bolagsverket_url IS DISTINCT FROM OLD.bolagsverket_url)
+ OR (OLD.external_receipt IS NOT NULL AND NEW.external_receipt IS DISTINCT FROM OLD.external_receipt)
+ OR (OLD.uploaded_at IS NOT NULL AND NEW.uploaded_at IS DISTINCT FROM OLD.uploaded_at) THEN
+ RAISE EXCEPTION 'Inlämnad årsredovisning kan inte ändras (submission %)', OLD.id
+ USING ERRCODE = 'P0001';
+ END IF;
+ END IF;
+
+ IF NEW.status IS DISTINCT FROM OLD.status AND NOT (
+ (OLD.status = 'draft' AND NEW.status IN ('kontrollerad', 'error'))
+ OR (OLD.status = 'kontrollerad' AND NEW.status IN ('kontrollerad', 'sending', 'error', 'draft'))
+ OR (OLD.status = 'sending' AND NEW.status IN ('uploaded', 'unknown'))
+ OR (OLD.status = 'unknown' AND NEW.status IN ('uploaded', 'inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad'))
+ OR (OLD.status = 'uploaded' AND NEW.status IN ('inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad', 'error'))
+ OR (OLD.status = 'inkommen' AND NEW.status IN ('forelagd', 'komplettering', 'registrerad', 'avslutad'))
+ OR (OLD.status = 'forelagd' AND NEW.status IN ('komplettering', 'registrerad', 'avslutad'))
+ OR (OLD.status = 'komplettering' AND NEW.status IN ('forelagd', 'registrerad', 'avslutad'))
+ OR (OLD.status = 'error' AND NEW.status IN ('draft', 'kontrollerad'))
+ ) THEN
+ RAISE EXCEPTION 'Ogiltig statusövergång för årsredovisningsinlämning: % till %', OLD.status, NEW.status
+ USING ERRCODE = 'P0001';
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+DROP POLICY IF EXISTS "arsredovisning_sigreq_insert"
+ ON public.arsredovisning_signature_requests;
+DROP POLICY IF EXISTS "arsredovisning_sigreq_update"
+ ON public.arsredovisning_signature_requests;
+DROP POLICY IF EXISTS "arsredovisning_sigreq_delete"
+ ON public.arsredovisning_signature_requests;
+
+CREATE POLICY "arsredovisning_sigreq_insert"
+ ON public.arsredovisning_signature_requests FOR INSERT
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = arsredovisning_signature_requests.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+CREATE POLICY "arsredovisning_sigreq_update"
+ ON public.arsredovisning_signature_requests FOR UPDATE
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = arsredovisning_signature_requests.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = arsredovisning_signature_requests.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+CREATE POLICY "arsredovisning_sigreq_delete"
+ ON public.arsredovisning_signature_requests FOR DELETE
+ USING (
+ status = 'pending'
+ AND annual_report_version_id IS NULL
+ AND EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = arsredovisning_signature_requests.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+DROP POLICY IF EXISTS "insert own-company arsredovisning submissions"
+ ON public.arsredovisning_submissions;
+DROP POLICY IF EXISTS "update own-company arsredovisning submissions"
+ ON public.arsredovisning_submissions;
+
+CREATE POLICY "insert own-company arsredovisning submissions"
+ ON public.arsredovisning_submissions FOR INSERT
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = arsredovisning_submissions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+CREATE POLICY "update own-company arsredovisning submissions"
+ ON public.arsredovisning_submissions FOR UPDATE
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = arsredovisning_submissions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = arsredovisning_submissions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+DROP POLICY IF EXISTS "insert own-company avtal acceptances"
+ ON public.bolagsverket_avtal_acceptances;
+CREATE POLICY "insert own-company avtal acceptances"
+ ON public.bolagsverket_avtal_acceptances FOR INSERT
+ WITH CHECK (
+ user_id = auth.uid()
+ AND EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = bolagsverket_avtal_acceptances.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+DROP POLICY IF EXISTS "insert own-company bolagsverket subscriptions"
+ ON public.bolagsverket_subscriptions;
+DROP POLICY IF EXISTS "update own-company bolagsverket subscriptions"
+ ON public.bolagsverket_subscriptions;
+DROP POLICY IF EXISTS "delete own-company bolagsverket subscriptions"
+ ON public.bolagsverket_subscriptions;
+
+CREATE POLICY "insert own-company bolagsverket subscriptions"
+ ON public.bolagsverket_subscriptions FOR INSERT
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = bolagsverket_subscriptions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+CREATE POLICY "update own-company bolagsverket subscriptions"
+ ON public.bolagsverket_subscriptions FOR UPDATE
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = bolagsverket_subscriptions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = bolagsverket_subscriptions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+CREATE POLICY "delete own-company bolagsverket subscriptions"
+ ON public.bolagsverket_subscriptions FOR DELETE
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.company_members
+ WHERE company_members.company_id = bolagsverket_subscriptions.company_id
+ AND company_members.user_id = auth.uid()
+ AND company_members.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721161708_increase_invoice_logo_limit.sql b/supabase/migrations/20260721161708_increase_invoice_logo_limit.sql
new file mode 100644
index 00000000..f728168e
--- /dev/null
+++ b/supabase/migrations/20260721161708_increase_invoice_logo_limit.sql
@@ -0,0 +1,8 @@
+-- Allow larger company logos while keeping the upload route and storage limit
+-- aligned at 10 MB. Existing files and public access remain unchanged.
+
+UPDATE storage.buckets
+SET file_size_limit = 10485760
+WHERE id = 'logos';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721162121_expand_invoice_font_support.sql b/supabase/migrations/20260721162121_expand_invoice_font_support.sql
new file mode 100644
index 00000000..9f2140c8
--- /dev/null
+++ b/supabase/migrations/20260721162121_expand_invoice_font_support.sql
@@ -0,0 +1,37 @@
+-- Add bundled and company-uploaded invoice fonts. Custom font files live in a
+-- dedicated private bucket. The server embeds each font into the generated PDF,
+-- so customer font files never need public URLs.
+
+ALTER TABLE public.company_settings
+ ADD COLUMN IF NOT EXISTS invoice_custom_font_path TEXT NULL,
+ ADD COLUMN IF NOT EXISTS invoice_custom_font_name TEXT NULL;
+
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_invoice_font_check;
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_invoice_font_check
+ CHECK (
+ invoice_font_family IN (
+ 'Helvetica',
+ 'Times-Roman',
+ 'Courier',
+ 'Source Sans 3',
+ 'Source Serif 4',
+ 'Custom'
+ )
+ );
+
+INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+VALUES (
+ 'invoice-fonts',
+ 'invoice-fonts',
+ false,
+ 5242880,
+ ARRAY['font/ttf', 'font/woff']
+)
+ON CONFLICT (id) DO UPDATE
+ SET public = EXCLUDED.public,
+ file_size_limit = EXCLUDED.file_size_limit,
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260721201747_harden_annual_report_signature_transitions.sql b/supabase/migrations/20260721201747_harden_annual_report_signature_transitions.sql
new file mode 100644
index 00000000..3b7d9351
--- /dev/null
+++ b/supabase/migrations/20260721201747_harden_annual_report_signature_transitions.sql
@@ -0,0 +1,116 @@
+-- Keep signer roster drafting available to authenticated company members, but
+-- reserve legally significant signature transitions for the trusted service
+-- role after the API has verified ownership, version state, and evidence.
+
+DROP POLICY IF EXISTS "arsredovisning_sigreq_insert"
+ ON public.arsredovisning_signature_requests;
+
+CREATE POLICY "arsredovisning_sigreq_insert"
+ ON public.arsredovisning_signature_requests
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ company_id IN (SELECT public.user_company_ids())
+ AND user_id = (SELECT auth.uid())
+ AND status = 'pending'
+ AND annual_report_version_id IS NULL
+ AND signing_method IS NULL
+ AND evidence_reference IS NULL
+ AND evidence_recorded_by IS NULL
+ AND evidence_recorded_at IS NULL
+ AND signed_at IS NULL
+ AND bankid_signature_data IS NULL
+ AND signer_personnummer_encrypted IS NULL
+ AND signer_personnummer_hash IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.fiscal_periods fp
+ WHERE fp.id = public.arsredovisning_signature_requests.fiscal_period_id
+ AND fp.company_id = public.arsredovisning_signature_requests.company_id
+ )
+ );
+
+-- Authenticated users may no longer transition or rewrite a signature row
+-- directly through PostgREST. The service role bypasses RLS and is used only
+-- after the API route has completed its ownership and state checks.
+DROP POLICY IF EXISTS "arsredovisning_sigreq_update"
+ ON public.arsredovisning_signature_requests;
+
+DROP POLICY IF EXISTS "arsredovisning_sigreq_delete"
+ ON public.arsredovisning_signature_requests;
+
+CREATE POLICY "arsredovisning_sigreq_delete"
+ ON public.arsredovisning_signature_requests
+ FOR DELETE
+ TO authenticated
+ USING (
+ company_id IN (SELECT public.user_company_ids())
+ AND status = 'pending'
+ AND annual_report_version_id IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.fiscal_periods fp
+ WHERE fp.id = public.arsredovisning_signature_requests.fiscal_period_id
+ AND fp.company_id = public.arsredovisning_signature_requests.company_id
+ )
+ );
+
+-- Existing signed evidence is retained unchanged. NOT VALID preserves those
+-- immutable historical rows while enforcing the structured format for every
+-- new or updated reference.
+ALTER TABLE public.arsredovisning_signature_requests
+ ADD CONSTRAINT arsredovisning_signature_evidence_reference_format
+ CHECK (
+ evidence_reference IS NULL
+ OR evidence_reference ~ '^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$'
+ ) NOT VALID;
+
+COMMENT ON COLUMN public.arsredovisning_signature_requests.evidence_reference IS
+ 'Opaque reference only: archive:, document:, or receipt:. Never store free text or personal data.';
+
+CREATE OR REPLACE FUNCTION public.enforce_annual_report_signature_version_state()
+RETURNS trigger
+LANGUAGE plpgsql
+SET search_path = public
+AS $$
+DECLARE
+ linked_version_status text;
+ linked_version_finalized_date date;
+BEGIN
+ IF NEW.status <> 'signed' THEN
+ RETURN NEW;
+ END IF;
+ IF TG_OP = 'UPDATE' AND OLD.status = 'signed' THEN
+ RETURN NEW;
+ END IF;
+
+ SELECT
+ version.status,
+ (version.finalized_at AT TIME ZONE 'Europe/Stockholm')::date
+ INTO linked_version_status, linked_version_finalized_date
+ FROM public.annual_report_versions version
+ WHERE version.id = NEW.annual_report_version_id;
+
+ IF linked_version_status IS DISTINCT FROM 'ready_for_signature'
+ OR linked_version_finalized_date IS NULL THEN
+ RAISE EXCEPTION 'Annual report version is not ready for signature evidence'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ IF (NEW.signed_at AT TIME ZONE 'Europe/Stockholm')::date
+ < linked_version_finalized_date
+ OR (NEW.signed_at AT TIME ZONE 'Europe/Stockholm')::date
+ > (now() AT TIME ZONE 'Europe/Stockholm')::date THEN
+ RAISE EXCEPTION 'Signature date must be between version finalization and today'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER enforce_annual_report_signature_version_state
+ BEFORE INSERT OR UPDATE ON public.arsredovisning_signature_requests
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_annual_report_signature_version_state();
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/helpers.ts b/tests/helpers.ts
index ca1d840c..568233a6 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -595,6 +595,8 @@ export function makeCompanySettings(
invoice_primary_color: '#1a1a1a',
invoice_accent_color: '#666666',
invoice_font_family: 'Helvetica',
+ invoice_custom_font_path: null,
+ invoice_custom_font_name: null,
invoice_header_text: null,
invoice_footer_text: null,
invoice_email_texts: null,
diff --git a/tests/pg/annual-report-versions.pg.test.ts b/tests/pg/annual-report-versions.pg.test.ts
new file mode 100644
index 00000000..d9494a31
--- /dev/null
+++ b/tests/pg/annual-report-versions.pg.test.ts
@@ -0,0 +1,537 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import type { PoolClient } from 'pg'
+import { getClient, getPool, withUserContext } from './setup'
+import { insertAuthUser, seedCompany } from './fixtures'
+
+async function withCommittedRoleContext(
+ userId: string,
+ role: 'authenticated' | 'service_role',
+ fn: (client: PoolClient) => Promise,
+): Promise {
+ const client = await getClient()
+ try {
+ await client.query('BEGIN')
+ await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
+ JSON.stringify({ sub: userId, role }),
+ ])
+ await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
+ await client.query(`SELECT set_config('request.jwt.claim.role', $1, true)`, [role])
+ await client.query(`SET LOCAL ROLE ${role}`)
+ const result = await fn(client)
+ await client.query('COMMIT')
+ return result
+ } catch (error) {
+ await client.query('ROLLBACK').catch(() => {})
+ throw error
+ } finally {
+ client.release()
+ }
+}
+
+function reportData(fiscalPeriodId: string) {
+ return {
+ accounting_framework: 'k2',
+ company: { name: 'Test AB', org_number: '556012-5790' },
+ fiscal_period: {
+ id: fiscalPeriodId,
+ period_start: '2025-01-01',
+ period_end: '2025-12-31',
+ },
+ }
+}
+
+async function createVersion(params: {
+ companyId: string
+ userId: string
+ fiscalPeriodId: string
+ hash?: string
+ status?: 'draft' | 'ready_for_signature'
+}) {
+ let validationSummary: Record = {
+ stage: 'draft',
+ ok: true,
+ error_count: 0,
+ warning_count: 0,
+ issues: [],
+ }
+ if (params.status === 'ready_for_signature') {
+ const profile = await getPool().query<{ signer_roster_confirmed_at: string }>(
+ `INSERT INTO public.annual_report_profiles
+ (company_id, fiscal_period_id, user_id, is_in_liquidation, signer_roster_confirmed_at)
+ VALUES ($1, $2, $3, false, now())
+ ON CONFLICT (company_id, fiscal_period_id) DO UPDATE
+ SET signer_roster_confirmed_at = now()
+ RETURNING signer_roster_confirmed_at::text`,
+ [params.companyId, params.fiscalPeriodId, params.userId],
+ )
+ validationSummary = {
+ stage: 'signing',
+ ok: true,
+ error_count: 0,
+ warning_count: 0,
+ issues: [],
+ digital_filing_eligible: true,
+ digital_issues: [],
+ profile: {
+ company_id: params.companyId,
+ fiscal_period_id: params.fiscalPeriodId,
+ signer_roster_confirmed_at: profile.rows[0].signer_roster_confirmed_at,
+ },
+ disclosures: {},
+ eligibility: {},
+ }
+ }
+ const rpcName =
+ params.status === 'ready_for_signature'
+ ? 'create_annual_report_version_with_signatures'
+ : 'create_annual_report_version'
+ return withCommittedRoleContext(
+ params.userId,
+ params.status === 'ready_for_signature' ? 'service_role' : 'authenticated',
+ (client) =>
+ client.query<{
+ id: string
+ version_number: number
+ status: string
+ }>(
+ `SELECT id, version_number, status
+ FROM public.${rpcName}(
+ $1, $2, '1.0', 'k2', $3, $4::jsonb, $5::jsonb, $6, '2024-09-12',
+ 'k2-ab-risbs-2024-09-12', $7::jsonb, $8
+ )`,
+ [
+ params.companyId,
+ params.fiscalPeriodId,
+ params.status ?? 'draft',
+ JSON.stringify(reportData(params.fiscalPeriodId)),
+ JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }),
+ params.hash ?? 'a'.repeat(64),
+ JSON.stringify(validationSummary),
+ params.userId,
+ ],
+ ),
+ )
+}
+
+describe('annual report profile and version enforcement', () => {
+ it('isolates profiles by membership and rejects cross-company period links', async () => {
+ const owner = await seedCompany()
+ const strangerId = await insertAuthUser()
+ const viewerId = await insertAuthUser()
+ await getPool().query(
+ `INSERT INTO public.company_members (company_id, user_id, role)
+ VALUES ($1, $2, 'viewer')`,
+ [owner.companyId, viewerId],
+ )
+ const profileId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.annual_report_profiles
+ (id, company_id, fiscal_period_id, user_id, is_public_limited_company)
+ VALUES ($1, $2, $3, $4, false)`,
+ [profileId, owner.companyId, owner.fiscalPeriodId, owner.userId],
+ )
+
+ const ownerView = await withUserContext(owner.userId, (client) =>
+ client.query('SELECT id FROM public.annual_report_profiles WHERE id = $1', [profileId]),
+ )
+ expect(ownerView.rows).toHaveLength(1)
+ const strangerView = await withUserContext(strangerId, (client) =>
+ client.query('SELECT id FROM public.annual_report_profiles WHERE id = $1', [profileId]),
+ )
+ expect(strangerView.rows).toHaveLength(0)
+ const viewerView = await withUserContext(viewerId, (client) =>
+ client.query('SELECT id FROM public.annual_report_profiles WHERE id = $1', [profileId]),
+ )
+ expect(viewerView.rows).toHaveLength(1)
+ await expect(
+ withUserContext(viewerId, (client) =>
+ client.query(
+ `UPDATE public.annual_report_profiles
+ SET is_public_limited_company = true
+ WHERE id = $1`,
+ [profileId],
+ ),
+ ),
+ ).resolves.toMatchObject({ rowCount: 0 })
+
+ const second = await seedCompany()
+ await expect(
+ getPool().query(
+ `INSERT INTO public.annual_report_profiles
+ (company_id, fiscal_period_id, user_id)
+ VALUES ($1, $2, $3)`,
+ [owner.companyId, second.fiscalPeriodId, owner.userId],
+ ),
+ ).rejects.toThrow(/does not belong to annual report company/i)
+ })
+
+ it('allocates sequential versions, deduplicates content, and finalizes a draft atomically', async () => {
+ const owner = await seedCompany()
+ const first = await createVersion(owner)
+ expect(first.rows[0]).toMatchObject({ version_number: 1, status: 'draft' })
+
+ const signatureId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.arsredovisning_signature_requests
+ (id, user_id, company_id, fiscal_period_id, role, signer_name)
+ VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`,
+ [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId],
+ )
+
+ const finalized = await createVersion({ ...owner, status: 'ready_for_signature' })
+ expect(finalized.rows[0]).toMatchObject({
+ id: first.rows[0].id,
+ version_number: 1,
+ status: 'ready_for_signature',
+ })
+ const boundSignature = await getPool().query(
+ `SELECT annual_report_version_id
+ FROM public.arsredovisning_signature_requests
+ WHERE id = $1`,
+ [signatureId],
+ )
+ expect(boundSignature.rows[0].annual_report_version_id).toBe(first.rows[0].id)
+
+ const second = await createVersion({ ...owner, hash: 'b'.repeat(64) })
+ expect(second.rows[0]).toMatchObject({ version_number: 2, status: 'draft' })
+ })
+
+ it('does not finalize a version without a signer roster', async () => {
+ const owner = await seedCompany()
+ await expect(
+ createVersion({ ...owner, status: 'ready_for_signature' }),
+ ).rejects.toThrow(/requires at least one signer slot/i)
+ })
+
+ it('keeps finalization behind the trusted service boundary and rejects forged validation', async () => {
+ const owner = await seedCompany()
+ await expect(
+ withUserContext(owner.userId, (client) =>
+ client.query(
+ `SELECT * FROM public.create_annual_report_version_with_signatures(
+ $1, $2, '1.0', 'k2', 'ready_for_signature', $3::jsonb, $4::jsonb,
+ $5, '2024-09-12', 'k2-ab-risbs-2024-09-12', $6::jsonb, $7
+ )`,
+ [
+ owner.companyId,
+ owner.fiscalPeriodId,
+ JSON.stringify(reportData(owner.fiscalPeriodId)),
+ JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }),
+ 'd'.repeat(64),
+ JSON.stringify({ ok: true, issues: [] }),
+ owner.userId,
+ ],
+ ),
+ ),
+ ).rejects.toThrow(/permission denied|trusted application service/i)
+
+ await expect(
+ withCommittedRoleContext(owner.userId, 'service_role', (client) =>
+ client.query(
+ `SELECT * FROM public.create_annual_report_version_with_signatures(
+ $1, $2, '1.0', 'k2', 'ready_for_signature', $3::jsonb, $4::jsonb,
+ $5, '2024-09-12', 'k2-ab-risbs-2024-09-12', $6::jsonb, $7
+ )`,
+ [
+ owner.companyId,
+ owner.fiscalPeriodId,
+ JSON.stringify(reportData(owner.fiscalPeriodId)),
+ JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }),
+ 'e'.repeat(64),
+ JSON.stringify({ ok: true, issues: [] }),
+ owner.userId,
+ ],
+ ),
+ ),
+ ).rejects.toThrow(/complete server validation snapshot/i)
+ })
+
+ it('keeps signature evidence transitions behind the trusted service boundary', async () => {
+ const owner = await seedCompany()
+ const signatureId = randomUUID()
+
+ const directInsert = await withUserContext(owner.userId, (client) =>
+ client.query(
+ `INSERT INTO public.arsredovisning_signature_requests
+ (id, user_id, company_id, fiscal_period_id, role, signer_name)
+ VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')
+ RETURNING id`,
+ [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId],
+ ),
+ )
+ expect(directInsert.rowCount).toBe(1)
+ await getPool().query(
+ `INSERT INTO public.arsredovisning_signature_requests
+ (id, user_id, company_id, fiscal_period_id, role, signer_name)
+ VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`,
+ [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId],
+ )
+ const version = await createVersion({ ...owner, status: 'ready_for_signature' })
+
+ const directUpdate = await withUserContext(owner.userId, (client) =>
+ client.query(
+ `UPDATE public.arsredovisning_signature_requests
+ SET status = 'signed', signed_at = now(),
+ signing_method = 'paper_original', evidence_reference = 'archive:A-1',
+ evidence_recorded_by = $2, evidence_recorded_at = now()
+ WHERE id = $1
+ RETURNING id`,
+ [signatureId, owner.userId],
+ ),
+ )
+ expect(directUpdate.rowCount).toBe(0)
+
+ const directDelete = await withUserContext(owner.userId, (client) =>
+ client.query(
+ `DELETE FROM public.arsredovisning_signature_requests
+ WHERE id = $1
+ RETURNING id`,
+ [signatureId],
+ ),
+ )
+ expect(directDelete.rowCount).toBe(0)
+
+ await expect(
+ withUserContext(owner.userId, (client) =>
+ client.query(
+ `INSERT INTO public.arsredovisning_signature_requests
+ (user_id, company_id, fiscal_period_id, role, signer_name, status,
+ annual_report_version_id, signed_at, signing_method, evidence_reference,
+ evidence_recorded_by, evidence_recorded_at)
+ VALUES ($1, $2, $3, 'VD', 'Erik Eriksson', 'signed', $4, now(),
+ 'paper_original', 'archive:A-2', $1, now())`,
+ [owner.userId, owner.companyId, owner.fiscalPeriodId, version.rows[0].id],
+ ),
+ ),
+ ).rejects.toThrow(/row-level security policy/i)
+ })
+
+ it('invalidates representative confirmation when the draft signer roster changes', async () => {
+ const owner = await seedCompany()
+ await getPool().query(
+ `INSERT INTO public.annual_report_profiles
+ (company_id, fiscal_period_id, user_id, signer_roster_confirmed_at)
+ VALUES ($1, $2, $3, now())`,
+ [owner.companyId, owner.fiscalPeriodId, owner.userId],
+ )
+ await getPool().query(
+ `INSERT INTO public.arsredovisning_signature_requests
+ (user_id, company_id, fiscal_period_id, role, signer_name)
+ VALUES ($1, $2, $3, 'Styrelseledamot', 'Anna Andersson')`,
+ [owner.userId, owner.companyId, owner.fiscalPeriodId],
+ )
+ const profile = await getPool().query<{ signer_roster_confirmed_at: string | null }>(
+ `SELECT signer_roster_confirmed_at::text
+ FROM public.annual_report_profiles
+ WHERE company_id = $1 AND fiscal_period_id = $2`,
+ [owner.companyId, owner.fiscalPeriodId],
+ )
+ expect(profile.rows[0].signer_roster_confirmed_at).toBeNull()
+ })
+
+ it('does not allow callers to bypass signer preparation with the draft RPC', async () => {
+ const owner = await seedCompany()
+ await expect(
+ withUserContext(owner.userId, (client) =>
+ client.query(
+ `SELECT * FROM public.create_annual_report_version(
+ $1, $2, '1.0', 'k2', 'ready_for_signature', $3::jsonb, $4::jsonb,
+ $5, '2024-09-12', 'k2-ab-risbs-2024-09-12', $6::jsonb, $7
+ )`,
+ [
+ owner.companyId,
+ owner.fiscalPeriodId,
+ JSON.stringify(reportData(owner.fiscalPeriodId)),
+ JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }),
+ 'c'.repeat(64),
+ JSON.stringify({ ok: true, issues: [] }),
+ owner.userId,
+ ],
+ ),
+ ),
+ ).rejects.toThrow(/only permits draft status/i)
+ })
+
+ it('keeps version content immutable and blocks member deletion', async () => {
+ const owner = await seedCompany()
+ const version = await createVersion(owner)
+
+ await expect(
+ withUserContext(owner.userId, (client) =>
+ client.query(
+ `UPDATE public.annual_report_versions
+ SET report_data = '{"changed":true}'::jsonb
+ WHERE id = $1`,
+ [version.rows[0].id],
+ ),
+ ),
+ ).rejects.toThrow(/content is immutable/i)
+
+ const deleted = await withUserContext(owner.userId, (client) =>
+ client.query('DELETE FROM public.annual_report_versions WHERE id = $1 RETURNING id', [
+ version.rows[0].id,
+ ]),
+ )
+ expect(deleted.rowCount).toBe(0)
+ await expect(
+ getPool().query('DELETE FROM public.annual_report_versions WHERE id = $1', [
+ version.rows[0].id,
+ ]),
+ ).rejects.toThrow(/retained as immutable accounting information/i)
+ await expect(
+ getPool().query('DELETE FROM public.fiscal_periods WHERE id = $1', [
+ owner.fiscalPeriodId,
+ ]),
+ ).rejects.toThrow()
+ })
+
+ it('requires version-bound signature evidence and freezes signed rows', async () => {
+ const owner = await seedCompany()
+ const signatureId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.arsredovisning_signature_requests
+ (id, user_id, company_id, fiscal_period_id, role, signer_name)
+ VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`,
+ [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId],
+ )
+ const version = await createVersion({ ...owner, status: 'ready_for_signature' })
+
+ await expect(
+ getPool().query(
+ `UPDATE public.annual_report_versions
+ SET status = 'signed'
+ WHERE id = $1`,
+ [version.rows[0].id],
+ ),
+ ).rejects.toThrow(/before every locked signer has signed/i)
+
+ await expect(
+ getPool().query(
+ `UPDATE public.arsredovisning_signature_requests
+ SET status = 'signed', signed_at = now(), annual_report_version_id = $2
+ WHERE id = $1`,
+ [signatureId, version.rows[0].id],
+ ),
+ ).rejects.toThrow(/signature_evidence_consistency|check constraint/i)
+
+ await expect(
+ getPool().query(
+ `UPDATE public.arsredovisning_signature_requests
+ SET status = 'signed', signed_at = now() - interval '1 day',
+ signing_method = 'paper_original', evidence_reference = 'archive:A-1',
+ evidence_recorded_by = $2, evidence_recorded_at = now()
+ WHERE id = $1`,
+ [signatureId, owner.userId],
+ ),
+ ).rejects.toThrow(/signature date must be between version finalization and today/i)
+
+ await expect(
+ getPool().query(
+ `UPDATE public.arsredovisning_signature_requests
+ SET status = 'signed', signed_at = now() + interval '1 day',
+ signing_method = 'paper_original', evidence_reference = 'archive:A-1',
+ evidence_recorded_by = $2, evidence_recorded_at = now()
+ WHERE id = $1`,
+ [signatureId, owner.userId],
+ ),
+ ).rejects.toThrow(/signature date must be between version finalization and today/i)
+
+ await getPool().query(
+ `UPDATE public.arsredovisning_signature_requests
+ SET status = 'signed', signed_at = now(), annual_report_version_id = $2,
+ signing_method = 'paper_original', evidence_reference = 'archive:A-1',
+ evidence_recorded_by = $3, evidence_recorded_at = now()
+ WHERE id = $1`,
+ [signatureId, version.rows[0].id, owner.userId],
+ )
+ const signedVersion = await getPool().query(
+ 'SELECT status FROM public.annual_report_versions WHERE id = $1',
+ [version.rows[0].id],
+ )
+ expect(signedVersion.rows[0].status).toBe('signed')
+ await expect(
+ getPool().query(
+ `UPDATE public.arsredovisning_signature_requests
+ SET evidence_reference = 'changed' WHERE id = $1`,
+ [signatureId],
+ ),
+ ).rejects.toThrow(/cannot modify a signed signature request/i)
+ })
+
+ it('rejects signature evidence after the linked version stops accepting signatures', async () => {
+ const owner = await seedCompany()
+ const signatureId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.arsredovisning_signature_requests
+ (id, user_id, company_id, fiscal_period_id, role, signer_name)
+ VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`,
+ [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId],
+ )
+ const version = await createVersion({ ...owner, status: 'ready_for_signature' })
+ await getPool().query(
+ `UPDATE public.annual_report_versions
+ SET status = 'superseded'
+ WHERE id = $1`,
+ [version.rows[0].id],
+ )
+
+ await expect(
+ getPool().query(
+ `UPDATE public.arsredovisning_signature_requests
+ SET status = 'signed', signed_at = now(),
+ signing_method = 'paper_original', evidence_reference = 'archive:A-1',
+ evidence_recorded_by = $2, evidence_recorded_at = now()
+ WHERE id = $1`,
+ [signatureId, owner.userId],
+ ),
+ ).rejects.toThrow(/annual report version is not ready for signature evidence/i)
+ })
+
+ it('supersedes an older signed version when a corrected version is locked', async () => {
+ const owner = await seedCompany()
+ const signatureId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.arsredovisning_signature_requests
+ (id, user_id, company_id, fiscal_period_id, role, signer_name)
+ VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`,
+ [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId],
+ )
+ const first = await createVersion({ ...owner, status: 'ready_for_signature' })
+ await getPool().query(
+ `UPDATE public.arsredovisning_signature_requests
+ SET status = 'signed', signed_at = now(),
+ signing_method = 'paper_original', evidence_reference = 'archive:A-1',
+ evidence_recorded_by = $2, evidence_recorded_at = now()
+ WHERE id = $1`,
+ [signatureId, owner.userId],
+ )
+
+ const correction = await createVersion({
+ ...owner,
+ hash: 'b'.repeat(64),
+ status: 'ready_for_signature',
+ })
+ expect(correction.rows[0]).toMatchObject({ version_number: 2, status: 'ready_for_signature' })
+
+ const previous = await getPool().query(
+ 'SELECT status FROM public.annual_report_versions WHERE id = $1',
+ [first.rows[0].id],
+ )
+ expect(previous.rows[0].status).toBe('superseded')
+ })
+
+ it('rejects validation rows linked to another company version', async () => {
+ const first = await seedCompany()
+ const second = await seedCompany()
+ const version = await createVersion(first)
+ await expect(
+ getPool().query(
+ `INSERT INTO public.annual_report_validation_runs
+ (company_id, fiscal_period_id, version_id, user_id, validation_layer, status)
+ VALUES ($1, $2, $3, $4, 'local', 'passed')`,
+ [second.companyId, second.fiscalPeriodId, version.rows[0].id, second.userId],
+ ),
+ ).rejects.toThrow(/belongs to another company or period/i)
+ })
+})
diff --git a/tests/pg/api-key-audit-noise.pg.test.ts b/tests/pg/api-key-audit-noise.pg.test.ts
new file mode 100644
index 00000000..c1133ea4
--- /dev/null
+++ b/tests/pg/api-key-audit-noise.pg.test.ts
@@ -0,0 +1,72 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { insertAuthUser, insertCompany } from './fixtures'
+import { getPool } from './setup'
+
+describe('API-key audit history', () => {
+ it('skips request telemetry while retaining security and configuration changes', async () => {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ const apiKeyId = randomUUID()
+ const keyHash = randomUUID().replaceAll('-', '')
+
+ await getPool().query(
+ `INSERT INTO public.api_keys
+ (id, user_id, company_id, key_hash, key_prefix, name, scopes)
+ VALUES ($1, $2, $3, $4, 'gnubok_sk_test', 'Audit test key', $5)`,
+ [apiKeyId, userId, companyId, keyHash, ['reports:read']],
+ )
+
+ await getPool().query(
+ `SELECT * FROM public.validate_and_increment_api_key($1)`,
+ [keyHash],
+ )
+ await getPool().query(
+ `SELECT * FROM public.validate_and_increment_api_key($1)`,
+ [keyHash],
+ )
+
+ const afterTelemetry = await getPool().query<{ action: string }>(
+ `SELECT action
+ FROM public.audit_log
+ WHERE table_name = 'api_keys'
+ AND record_id = $1
+ ORDER BY created_at, id`,
+ [apiKeyId],
+ )
+ expect(afterTelemetry.rows.map((row) => row.action)).toEqual(['INSERT'])
+
+ await getPool().query(
+ `UPDATE public.api_keys
+ SET name = 'Renamed audit test key',
+ scopes = ARRAY['reports:read', 'customers:read']::text[]
+ WHERE id = $1`,
+ [apiKeyId],
+ )
+
+ const afterConfigurationChange = await getPool().query<{
+ action: string
+ old_name: string | null
+ new_name: string | null
+ }>(
+ `SELECT
+ action,
+ old_state ->> 'name' AS old_name,
+ new_state ->> 'name' AS new_name
+ FROM public.audit_log
+ WHERE table_name = 'api_keys'
+ AND record_id = $1
+ ORDER BY created_at, id`,
+ [apiKeyId],
+ )
+
+ expect(afterConfigurationChange.rows).toEqual([
+ { action: 'INSERT', old_name: null, new_name: 'Audit test key' },
+ {
+ action: 'UPDATE',
+ old_name: 'Audit test key',
+ new_name: 'Renamed audit test key',
+ },
+ ])
+ })
+})
diff --git a/tests/pg/bfl-retention-expiry.pg.test.ts b/tests/pg/bfl-retention-expiry.pg.test.ts
new file mode 100644
index 00000000..81536336
--- /dev/null
+++ b/tests/pg/bfl-retention-expiry.pg.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { getPool } from './setup'
+import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures'
+
+describe('BFL retention expiry', () => {
+ it('retains a fiscal year through the end of the seventh following calendar year', async () => {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ const fiscalPeriodId = await insertFiscalPeriod({
+ userId,
+ companyId,
+ periodStart: '2025-07-01',
+ periodEnd: '2026-06-30',
+ name: '2025/2026',
+ })
+
+ const result = await getPool().query<{ retention_expires_at: string }>(
+ `SELECT retention_expires_at::text
+ FROM public.fiscal_periods
+ WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+
+ expect(result.rows[0].retention_expires_at).toBe('2034-01-01')
+ })
+
+ it('recalculates the first allowed deletion date when an open period end changes', async () => {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
+
+ await getPool().query(
+ `UPDATE public.fiscal_periods
+ SET period_end = '2027-03-31'
+ WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+ const result = await getPool().query<{ retention_expires_at: string }>(
+ `SELECT retention_expires_at::text
+ FROM public.fiscal_periods
+ WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+
+ expect(result.rows[0].retention_expires_at).toBe('2035-01-01')
+ })
+})
diff --git a/tests/pg/fiscal-period-tax-adjustments.pg.test.ts b/tests/pg/fiscal-period-tax-adjustments.pg.test.ts
new file mode 100644
index 00000000..609b4403
--- /dev/null
+++ b/tests/pg/fiscal-period-tax-adjustments.pg.test.ts
@@ -0,0 +1,174 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { getPool, withUserContext } from './setup'
+import {
+ insertAuthUser,
+ insertCompany,
+ insertCompanyMember,
+ insertFiscalPeriod,
+ seedCompany,
+} from './fixtures'
+
+async function setActiveCompany(userId: string, companyId: string): Promise {
+ await getPool().query(
+ `INSERT INTO public.user_preferences (user_id, active_company_id)
+ VALUES ($1, $2)
+ ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
+ [userId, companyId],
+ )
+}
+
+async function insertAdjustment(params: {
+ companyId: string
+ userId: string
+ fiscalPeriodId: string
+}): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.fiscal_period_tax_adjustments
+ (id, company_id, user_id, fiscal_period_id, adjustment_type, source,
+ source_key, description, account_number, amount, included)
+ VALUES ($1, $2, $3, $4, 'non_deductible_expense', 'detected',
+ 'account:6992', 'Ej avdragsgill kostnad', '6992', 2994, true)`,
+ [id, params.companyId, params.userId, params.fiscalPeriodId],
+ )
+ return id
+}
+
+describe('fiscal_period_tax_adjustments constraints and RLS', () => {
+ it('uses the standard UUID default and locks the fiscal period in the guard', async () => {
+ const result = await getPool().query<{ default_expression: string; function_definition: string }>(
+ `SELECT
+ pg_get_expr(d.adbin, d.adrelid) AS default_expression,
+ pg_get_functiondef('public.guard_fiscal_period_tax_adjustment()'::regprocedure)
+ AS function_definition
+ FROM pg_attrdef d
+ JOIN pg_attribute a
+ ON a.attrelid = d.adrelid AND a.attnum = d.adnum
+ WHERE d.adrelid = 'public.fiscal_period_tax_adjustments'::regclass
+ AND a.attname = 'id'`,
+ )
+
+ expect(result.rows[0]?.default_expression).toContain('uuid_generate_v4')
+ expect(result.rows[0]?.function_definition).toMatch(/FOR UPDATE/i)
+ })
+
+ it('isolates adjustments by company membership', async () => {
+ const owner = await seedCompany()
+ const adjustmentId = await insertAdjustment(owner)
+ const strangerId = await insertAuthUser()
+
+ const ownerView = await withUserContext(owner.userId, (client) =>
+ client.query(
+ 'SELECT id FROM public.fiscal_period_tax_adjustments WHERE id = $1',
+ [adjustmentId],
+ ),
+ )
+ expect(ownerView.rows).toHaveLength(1)
+
+ const strangerView = await withUserContext(strangerId, (client) =>
+ client.query(
+ 'SELECT id FROM public.fiscal_period_tax_adjustments WHERE id = $1',
+ [adjustmentId],
+ ),
+ )
+ expect(strangerView.rows).toHaveLength(0)
+ })
+
+ it('rejects a company that does not own the fiscal period', async () => {
+ const first = await seedCompany()
+ const second = await seedCompany()
+
+ await expect(
+ insertAdjustment({
+ companyId: first.companyId,
+ userId: first.userId,
+ fiscalPeriodId: second.fiscalPeriodId,
+ }),
+ ).rejects.toThrow(/does not match fiscal period company/i)
+ })
+
+ it('allows an owner to insert an adjustment for the active company', async () => {
+ const owner = await seedCompany()
+ await setActiveCompany(owner.userId, owner.companyId)
+
+ await withUserContext(owner.userId, async (client) => {
+ const result = await client.query(
+ `INSERT INTO public.fiscal_period_tax_adjustments
+ (company_id, user_id, fiscal_period_id, adjustment_type, source,
+ source_key, description, account_number, amount, included)
+ VALUES ($1, $2, $3, 'non_deductible_expense', 'detected',
+ 'account:6992', 'Ej avdragsgill kostnad', '6992', 2994, true)
+ RETURNING id`,
+ [owner.companyId, owner.userId, owner.fiscalPeriodId],
+ )
+ expect(result.rows).toHaveLength(1)
+ })
+ })
+
+ it('blocks a viewer from inserting an adjustment directly under RLS', async () => {
+ const owner = await seedCompany()
+ const viewerId = await insertAuthUser()
+ await insertCompanyMember({ companyId: owner.companyId, userId: viewerId, role: 'viewer' })
+ await setActiveCompany(viewerId, owner.companyId)
+
+ await withUserContext(viewerId, async (client) => {
+ await expect(
+ client.query(
+ `INSERT INTO public.fiscal_period_tax_adjustments
+ (company_id, user_id, fiscal_period_id, adjustment_type, source,
+ source_key, description, account_number, amount, included)
+ VALUES ($1, $2, $3, 'non_deductible_expense', 'detected',
+ 'account:6992', 'Ej avdragsgill kostnad', '6992', 2994, true)`,
+ [owner.companyId, viewerId, owner.fiscalPeriodId],
+ ),
+ ).rejects.toThrow()
+ })
+ })
+
+ it('blocks writes to a member company that is not the active company', async () => {
+ const owner = await seedCompany()
+ const otherCompanyId = await insertCompany({ createdBy: owner.userId })
+ await insertCompanyMember({ companyId: otherCompanyId, userId: owner.userId, role: 'owner' })
+ const otherPeriodId = await insertFiscalPeriod({
+ userId: owner.userId,
+ companyId: otherCompanyId,
+ })
+ await setActiveCompany(owner.userId, owner.companyId)
+
+ await withUserContext(owner.userId, async (client) => {
+ await expect(
+ client.query(
+ `INSERT INTO public.fiscal_period_tax_adjustments
+ (company_id, user_id, fiscal_period_id, adjustment_type, source,
+ source_key, description, account_number, amount, included)
+ VALUES ($1, $2, $3, 'non_deductible_expense', 'detected',
+ 'account:6992', 'Ej avdragsgill kostnad', '6992', 2994, true)`,
+ [otherCompanyId, owner.userId, otherPeriodId],
+ ),
+ ).rejects.toThrow()
+ })
+ })
+
+ it('blocks adjustment changes after the fiscal period is locked', async () => {
+ const owner = await seedCompany()
+ const adjustmentId = await insertAdjustment(owner)
+ await getPool().query(
+ 'UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1',
+ [owner.fiscalPeriodId],
+ )
+
+ await expect(
+ getPool().query(
+ 'UPDATE public.fiscal_period_tax_adjustments SET amount = 3000 WHERE id = $1',
+ [adjustmentId],
+ ),
+ ).rejects.toThrow(/locked for tax adjustments/i)
+ await expect(
+ getPool().query(
+ 'DELETE FROM public.fiscal_period_tax_adjustments WHERE id = $1',
+ [adjustmentId],
+ ),
+ ).rejects.toThrow(/locked for tax adjustments/i)
+ })
+})
diff --git a/tests/pg/production-error-regressions.pg.test.ts b/tests/pg/production-error-regressions.pg.test.ts
new file mode 100644
index 00000000..a9464682
--- /dev/null
+++ b/tests/pg/production-error-regressions.pg.test.ts
@@ -0,0 +1,165 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { getPool } from './setup'
+import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures'
+
+async function insertEntry(params: {
+ userId: string
+ companyId: string
+ fiscalPeriodId: string
+ voucherNumber: number
+ entryDate: string
+ lines: Array<{ account: string; debit: number; credit: number }>
+}): Promise {
+ const entryId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.journal_entries
+ (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
+ entry_date, description, source_type, status)
+ VALUES ($1, $2, $3, $4, $5, 'A', $6, 'Production regression test', 'manual', 'posted')`,
+ [
+ entryId,
+ params.userId,
+ params.companyId,
+ params.fiscalPeriodId,
+ params.voucherNumber,
+ params.entryDate,
+ ],
+ )
+
+ for (const line of params.lines) {
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount)
+ VALUES ($1, $2, $3, $4)`,
+ [entryId, line.account, line.debit, line.credit],
+ )
+ }
+ return entryId
+}
+
+async function seedCompany() {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ const fiscalPeriodId = await insertFiscalPeriod({
+ userId,
+ companyId,
+ periodStart: '2026-01-01',
+ periodEnd: '2026-12-31',
+ })
+ return { userId, companyId, fiscalPeriodId }
+}
+
+describe('production error regressions', () => {
+ it('registers PendingOperationApproved in the processing history catalog', async () => {
+ const { rows } = await getPool().query(
+ `SELECT event_type
+ FROM public.processing_event_types
+ WHERE event_type = 'PendingOperationApproved'`,
+ )
+
+ expect(rows).toEqual([{ event_type: 'PendingOperationApproved' }])
+ })
+
+ it('aggregates period activity and excludes a specified opening entry', async () => {
+ const ctx = await seedCompany()
+ const openingId = await insertEntry({
+ ...ctx,
+ voucherNumber: 1,
+ entryDate: '2026-01-01',
+ lines: [
+ { account: '1930', debit: 1_000, credit: 0 },
+ { account: '2010', debit: 0, credit: 1_000 },
+ ],
+ })
+ await insertEntry({
+ ...ctx,
+ voucherNumber: 2,
+ entryDate: '2026-03-15',
+ lines: [
+ { account: '1930', debit: 250, credit: 0 },
+ { account: '3001', debit: 0, credit: 250 },
+ ],
+ })
+
+ const { rows } = await getPool().query(
+ `SELECT account_number, debit::text, credit::text
+ FROM public.get_account_period_activity($1, $2, $3, $4, $5)`,
+ [ctx.companyId, '2026-01-01', '2026-12-31', ['1930', '3001'], openingId],
+ )
+
+ expect(rows).toEqual([
+ { account_number: '1930', debit: '250', credit: '0' },
+ { account_number: '3001', debit: '0', credit: '250' },
+ ])
+ })
+
+ it('pages VAT source lines with a stable entry and line cursor', async () => {
+ const ctx = await seedCompany()
+ await insertEntry({
+ ...ctx,
+ voucherNumber: 1,
+ entryDate: '2026-03-01',
+ lines: [
+ { account: '1930', debit: 125, credit: 0 },
+ { account: '2611', debit: 0, credit: 25 },
+ { account: '3001', debit: 0, credit: 100 },
+ ],
+ })
+ await insertEntry({
+ ...ctx,
+ voucherNumber: 2,
+ entryDate: '2026-03-02',
+ lines: [
+ { account: '1930', debit: 250, credit: 0 },
+ { account: '2611', debit: 0, credit: 50 },
+ { account: '3001', debit: 0, credit: 200 },
+ ],
+ })
+
+ const first = await getPool().query(
+ `SELECT * FROM public.get_vat_ruta_source_lines(
+ $1, $2, $3, $4, NULL, NULL, NULL, NULL, 1
+ )`,
+ [ctx.companyId, '2026-03-01', '2026-03-31', ['2611']],
+ )
+ expect(first.rows).toHaveLength(1)
+ expect(first.rows[0].voucher_number).toBe(1)
+
+ const second = await getPool().query(
+ `SELECT * FROM public.get_vat_ruta_source_lines(
+ $1, $2, $3, $4, $5, $6, $7, $8, 1
+ )`,
+ [
+ ctx.companyId,
+ '2026-03-01',
+ '2026-03-31',
+ ['2611'],
+ first.rows[0].entry_date,
+ first.rows[0].voucher_number,
+ first.rows[0].journal_entry_id,
+ first.rows[0].line_id,
+ ],
+ )
+ expect(second.rows).toHaveLength(1)
+ expect(second.rows[0].voucher_number).toBe(2)
+ })
+
+ it('installs the covering indexes used by the timeout fixes', async () => {
+ const { rows } = await getPool().query(
+ `SELECT indexname
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND indexname IN (
+ 'idx_audit_log_company_created_id',
+ 'idx_journal_entries_company_posted_date_id'
+ )
+ ORDER BY indexname`,
+ )
+
+ expect(rows.map((row) => row.indexname)).toEqual([
+ 'idx_audit_log_company_created_id',
+ 'idx_journal_entries_company_posted_date_id',
+ ])
+ })
+})
diff --git a/tests/pg/tax-assessment-notices.pg.test.ts b/tests/pg/tax-assessment-notices.pg.test.ts
new file mode 100644
index 00000000..c9fc810c
--- /dev/null
+++ b/tests/pg/tax-assessment-notices.pg.test.ts
@@ -0,0 +1,111 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { getPool, withUserContext } from './setup'
+import { insertAuthUser, seedCompany } from './fixtures'
+
+async function insertNotice(params: {
+ companyId: string
+ userId: string
+ fiscalPeriodId: string
+ decisionDate?: string
+ paymentDueDate?: string
+}): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.tax_assessment_notices
+ (id, company_id, user_id, fiscal_period_id, decision_type, decision_date, payment_due_date)
+ VALUES ($1, $2, $3, $4, 'final', $5, $6)`,
+ [
+ id,
+ params.companyId,
+ params.userId,
+ params.fiscalPeriodId,
+ params.decisionDate ?? '2026-07-01',
+ params.paymentDueDate ?? '2026-10-12',
+ ],
+ )
+ return id
+}
+
+describe('tax_assessment_notices constraints and RLS', () => {
+ it('isolates notices by company membership', async () => {
+ const owner = await seedCompany()
+ const noticeId = await insertNotice(owner)
+ const strangerId = await insertAuthUser()
+
+ const ownerView = await withUserContext(owner.userId, (client) =>
+ client.query<{ id: string }>(
+ `SELECT id FROM public.tax_assessment_notices WHERE id = $1`,
+ [noticeId],
+ ),
+ )
+ expect(ownerView.rows).toHaveLength(1)
+
+ const strangerView = await withUserContext(strangerId, (client) =>
+ client.query<{ id: string }>(
+ `SELECT id FROM public.tax_assessment_notices WHERE id = $1`,
+ [noticeId],
+ ),
+ )
+ expect(strangerView.rows).toHaveLength(0)
+ })
+
+ it('rejects inserts into another company', async () => {
+ const owner = await seedCompany()
+ const strangerId = await insertAuthUser()
+
+ await expect(withUserContext(strangerId, (client) =>
+ client.query(
+ `INSERT INTO public.tax_assessment_notices
+ (company_id, user_id, fiscal_period_id, decision_type, decision_date, payment_due_date)
+ VALUES ($1, $2, $3, 'final', '2026-07-01', '2026-10-12')`,
+ [owner.companyId, strangerId, owner.fiscalPeriodId],
+ ),
+ )).rejects.toThrow(/row-level security|policy/i)
+ })
+
+ it('requires the exact due date to be on or after the decision date', async () => {
+ const owner = await seedCompany()
+ await expect(insertNotice({
+ ...owner,
+ decisionDate: '2026-07-01',
+ paymentDueDate: '2026-06-30',
+ })).rejects.toThrow(/due_after_decision|check constraint/i)
+ })
+
+ it('prevents deleting a notice referenced by a generated deadline', async () => {
+ const owner = await seedCompany()
+ const noticeId = await insertNotice(owner)
+ await getPool().query(
+ `INSERT INTO public.deadlines
+ (user_id, company_id, title, due_date, deadline_type, source,
+ tax_deadline_type, tax_period, tax_assessment_notice_id)
+ VALUES ($1, $2, 'Kvarskatt', '2026-10-12', 'tax', 'system',
+ 'kvarskatt', $3, $4)`,
+ [owner.userId, owner.companyId, `notice:${noticeId}`, noticeId],
+ )
+
+ await expect(
+ getPool().query(`DELETE FROM public.tax_assessment_notices WHERE id = $1`, [noticeId]),
+ ).rejects.toThrow(/foreign key|violates/i)
+ })
+})
+
+describe('company_settings initial setup state', () => {
+ it('accepts supported paths and rejects unknown values', async () => {
+ const owner = await seedCompany()
+ await getPool().query(
+ `INSERT INTO public.company_settings
+ (user_id, company_id, onboarding_complete, initial_setup_path)
+ VALUES ($1, $2, true, 'migration')`,
+ [owner.userId, owner.companyId],
+ )
+
+ await expect(
+ getPool().query(
+ `UPDATE public.company_settings SET initial_setup_path = 'unknown' WHERE company_id = $1`,
+ [owner.companyId],
+ ),
+ ).rejects.toThrow(/initial_setup_path_check|check constraint/i)
+ })
+})
diff --git a/types/index.ts b/types/index.ts
index 72dd6f9e..1ffeb7de 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -197,6 +197,14 @@ export interface InvoiceEmailTexts {
en?: InvoiceEmailTextOverrides
}
+export type InvoiceFontFamily =
+ | 'Helvetica'
+ | 'Times-Roman'
+ | 'Courier'
+ | 'Source Sans 3'
+ | 'Source Serif 4'
+ | 'Custom'
+
// Company Settings
export interface CompanySettings {
id: string
@@ -336,7 +344,9 @@ export interface CompanySettings {
// render identically to the pre-branding template.
invoice_primary_color: string // hex #RRGGBB, default '#1a1a1a'
invoice_accent_color: string // hex #RRGGBB, default '#666666'
- invoice_font_family: 'Helvetica' | 'Times-Roman' | 'Courier'
+ invoice_font_family: InvoiceFontFamily
+ invoice_custom_font_path: string | null
+ invoice_custom_font_name: string | null
invoice_header_text: string | null
invoice_footer_text: string | null
@@ -360,6 +370,9 @@ export interface CompanySettings {
// Onboarding
onboarding_step: number
onboarding_complete: boolean
+ initial_setup_path?: InitialSetupPath | null
+ initial_setup_completed_at?: string | null
+ initial_setup_dismissed_at?: string | null
// Sector
sector_slug: string | null
@@ -1981,6 +1994,8 @@ export interface CreateFiscalPeriodInput {
export type PendingOperationType =
| 'categorize_transaction'
| 'create_customer'
+ | 'update_customer'
+ | 'update_company_settings'
| 'create_article'
| 'update_article'
// Kontoplan reference data (gnubok_create_account / gnubok_update_account)
@@ -2136,6 +2151,14 @@ export interface OnboardingProgress {
hasSkatteverketConnected: boolean
}
+export type InitialSetupPath = 'migration' | 'bank' | 'fresh'
+
+export interface InitialSetupState {
+ path: InitialSetupPath | null
+ completedAt: string | null
+ dismissedAt: string | null
+}
+
// Onboarding step data
export interface OnboardingStepData {
step1?: {
@@ -2209,6 +2232,23 @@ export type TaxDeadlineType =
| 'intrastat_monthly'
| 'punktskatt_monthly'
| 'fyllnadsinbetalning'
+ | 'kvarskatt'
+
+export type TaxAssessmentDecisionType = 'final' | 'reassessment'
+
+export interface TaxAssessmentNotice {
+ id: string
+ company_id: string
+ user_id: string | null
+ fiscal_period_id: string
+ decision_type: TaxAssessmentDecisionType
+ decision_date: string
+ payment_due_date: string
+ archived_at: string | null
+ created_at: string
+ updated_at: string
+ fiscal_period?: Pick
+}
// Deadline status workflow
export type DeadlineStatus =
@@ -2256,6 +2296,7 @@ export interface Deadline {
dismissed_at: string | null
linked_report_type: string | null
linked_report_period: Record | null
+ tax_assessment_notice_id: string | null
// Relations
customer?: Customer
@@ -2394,7 +2435,8 @@ export const TAX_DEADLINE_TYPE_LABELS: Record = {
ioss_monthly: 'IOSS-deklaration',
intrastat_monthly: 'Intrastat',
punktskatt_monthly: 'Punktskattedeklaration',
- fyllnadsinbetalning: 'Fyllnadsinbetalning'
+ fyllnadsinbetalning: 'Fyllnadsinbetalning',
+ kvarskatt: 'Kvarskatt'
}
// ============================================================