Bug/gh issues fiz (#1103)

* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

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

* New css class name
This commit is contained in:
Mattsson
2026-07-21 23:00:15 +02:00
committed by GitHub
parent 702512437a
commit e11f70b347
467 changed files with 17569 additions and 2221 deletions
+87 -6
View File
@@ -364,15 +364,96 @@ processing_activities:
- error_message_truncated_500_chars - error_message_truncated_500_chars
- no_tool_args_or_results_persisted - 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 - id: arsredovisning.bolagsverket.submit
name: Digital inlämning av årsredovisning till Bolagsverket name: Digital inlämning av årsredovisning till Bolagsverket
purpose: >- purpose: >-
Lämna in årsredovisningen (iXBRL) digitalt till Bolagsverkets eget Lämna in årsredovisningen (iXBRL) digitalt till Bolagsverkets eget
utrymme (ÅRL 8 kap.). Avsändarens och undertecknarens personnummer utrymme (ÅRL 8 kap.). Avsändarens och undertecknarens personnummer
krävs av Bolagsverkets API (skapa-inlamningtoken, lamna-in) och krävs av Bolagsverkets API (skapa-inlamningtoken, lamna-in) och
används transient i anropen; i databasen sparas endast används endast transient i anropen. Varken personnummer eller hash av
företagssaltade SHA-256-hashar. Fastställelseintyget signeras med personnummer sparas i inlämningsposten. Fastställelseintyget signeras
e-legitimation hos Bolagsverket, aldrig i appen. med e-legitimation hos Bolagsverket, aldrig i appen.
lawful_basis: art_6_1_c # legal obligation (ÅRL filing duty) lawful_basis: art_6_1_c # legal obligation (ÅRL filing duty)
special_category_basis: null special_category_basis: null
controller: gnubok-tenant controller: gnubok-tenant
@@ -380,7 +461,7 @@ processing_activities:
data_subjects: data_subjects:
- business_owner # avsändare/undertecknare (styrelseledamot, VD) - business_owner # avsändare/undertecknare (styrelseledamot, VD)
data_categories: 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.name # undertecknarens namn
- user.contact # undertecknarens/kvittens e-post - user.contact # undertecknarens/kvittens e-post
- user.financial # årsredovisningens finansiella innehåll - user.financial # årsredovisningens finansiella innehåll
@@ -396,13 +477,13 @@ processing_activities:
duration: 7y duration: 7y
basis: bfl_7_kap # filed .xhtml archived as räkenskapsinformation basis: bfl_7_kap # filed .xhtml archived as räkenskapsinformation
stored_in: 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) - document_attachments # the exact filed iXBRL bytes (WORM)
security_measures: security_measures:
- mtls_client_certificate_to_bolagsverket - mtls_client_certificate_to_bolagsverket
- mtls_key_env_only_injected_from_secret_manager # never in settings/DB; see .env.example custody note - 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 - 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 - personnummer_never_logged
- webhook_secret_constant_time_validation - webhook_secret_constant_time_validation
- rls_company_scoped - rls_company_scoped
+6
View File
@@ -46,3 +46,9 @@ CRON_SECRET=generate-a-random-secret
# BOLAGSVERKET_CLIENT_CERT= # BOLAGSVERKET_CLIENT_CERT=
# BOLAGSVERKET_CLIENT_KEY= # BOLAGSVERKET_CLIENT_KEY=
# BOLAGSVERKET_CA= # 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=
+13
View File
@@ -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
+4
View File
@@ -21,11 +21,15 @@ The accounting rules are Swedish law, enforced by DB triggers. Code that violate
General prohibitions: 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. - **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. - **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`). - **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 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. - **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. - **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. - **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 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`. - Never create a NUL/nul file: `\Accounted\NUL`.
+23
View File
@@ -244,5 +244,28 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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 <img> (physics sizes by %, next/image adds nothing for 1-35KB webp). [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 <img> (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] 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-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] 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] 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.
+84
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
export default function AuthLoading() {
return <AuthPageSkeleton />
}
+9 -3
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { Suspense, useState, useEffect } from 'react' import { Suspense, useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link' 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 { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid' import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service' import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search' import { detectWebmailHint } from '@/lib/auth/webmail-search'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
const branding = getBranding() const branding = getBranding()
import type { BankIdResult } from '@/components/auth/BankIdAuth' 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 // Wrapping in Suspense is required because useSearchParams() forces
// dynamic rendering in Next.js 16; static prerender bails out otherwise. // dynamic rendering in Next.js 16; static prerender bails out otherwise.
export default function LoginPage() { export default function LoginPage() {
return ( return (
<Suspense fallback={null}> <Suspense fallback={<AuthPageSkeleton />}>
<LoginPageContent /> <LoginPageContent />
</Suspense> </Suspense>
) )
@@ -157,7 +163,7 @@ function LoginPageContent() {
if (error) { if (error) {
toast({ toast({
title: tAuth('login_failed_title'), title: tAuth('login_failed_title'),
description: error.message === 'Invalid login credentials' description: getErrorMessage(error) === 'Invalid login credentials'
? tAuth('login_invalid_credentials') ? tAuth('login_invalid_credentials')
: getErrorMessage(error, { context: 'auth', locale: errorLocale }), : getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive', variant: 'destructive',
+2 -1
View File
@@ -11,6 +11,7 @@ import { Loader2, ShieldCheck, Copy, Check, ArrowLeft } from 'lucide-react'
import { getBranding } from '@/lib/branding/service' import { getBranding } from '@/lib/branding/service'
import { userHasPassword } from '@/lib/auth/has-password' import { userHasPassword } from '@/lib/auth/has-password'
import { safeReturnTo } from '@/lib/auth/safe-return-to' import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export default function MfaEnrollPage() { export default function MfaEnrollPage() {
return ( return (
@@ -83,7 +84,7 @@ function MfaEnrollContent() {
if (error) { if (error) {
toast({ toast({
title: 'Kunde inte aktivera 2FA', title: 'Kunde inte aktivera 2FA',
description: error.message, description: getUserErrorMessage(error),
variant: 'destructive', variant: 'destructive',
}) })
setIsEnrolling(false) setIsEnrolling(false)
+8 -6
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, Suspense } from 'react' import { useState, useEffect, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl' 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 { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid' import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
import type { BankIdResult } from '@/components/auth/BankIdAuth' import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service' import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search' import { detectWebmailHint } from '@/lib/auth/webmail-search'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
const branding = getBranding() const branding = getBranding()
const BankIdAuth = dynamic(
() => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth),
{ ssr: false },
)
export default function RegisterPage() { export default function RegisterPage() {
return ( return (
<Suspense fallback={ <Suspense fallback={<AuthPageSkeleton />}>
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
}>
<RegisterPageContent /> <RegisterPageContent />
</Suspense> </Suspense>
) )
+36 -4
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react' import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
@@ -21,7 +22,6 @@ import {
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' import { Plus, Search, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import ArticleForm from '@/components/articles/ArticleForm'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import { import {
useSubmitWithAccountActivation, useSubmitWithAccountActivation,
@@ -37,6 +37,19 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Article, ArticleType, CreateArticleInput } from '@/types' import type { Article, ArticleType, CreateArticleInput } from '@/types'
const ArticleForm = dynamic(
() => import('@/components/articles/ArticleForm'),
{
loading: () => (
<div className="space-y-4 py-4" role="status">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</div>
),
},
)
const ARTICLE_TYPE_LABEL_KEYS: Record<ArticleType, string> = { const ARTICLE_TYPE_LABEL_KEYS: Record<ArticleType, string> = {
vara: 'type_vara', vara: 'type_vara',
tjanst: 'type_tjanst', tjanst: 'type_tjanst',
@@ -53,6 +66,7 @@ const SORTABLE_COLUMNS: ReadonlyArray<SortColumn> = [
'price_excl_vat', 'price_excl_vat',
'vat_rate', 'vat_rate',
] ]
const INITIAL_VISIBLE_ROWS = 100
function compareStrings(a: string, b: string): number { function compareStrings(a: string, b: string): number {
return a.localeCompare(b, 'sv', { sensitivity: 'base' }) return a.localeCompare(b, 'sv', { sensitivity: 'base' })
@@ -64,11 +78,13 @@ function ArticlesPageInner() {
const [articles, setArticles] = useState<Article[]>([]) const [articles, setArticles] = useState<Article[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast() const { toast } = useToast()
const supabase = createClient() const supabase = createClient()
const t = useTranslations('articles') const t = useTranslations('articles')
const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale const errorLocale = useLocale() as ErrorLocale
const router = useRouter() const router = useRouter()
@@ -85,6 +101,7 @@ function ArticlesPageInner() {
const updateSort = useCallback( const updateSort = useCallback(
(column: SortColumn) => { (column: SortColumn) => {
setVisibleCount(INITIAL_VISIBLE_ROWS)
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
let nextDir: SortDir = 'asc' let nextDir: SortDir = 'asc'
if (column === sortColumn) { if (column === sortColumn) {
@@ -208,6 +225,7 @@ function ArticlesPageInner() {
}) })
return arr return arr
}, [filteredArticles, sortColumn, sortDir]) }, [filteredArticles, sortColumn, sortDir])
const visibleArticles = sortedArticles.slice(0, visibleCount)
function SortableHeader({ function SortableHeader({
column, column,
@@ -281,7 +299,10 @@ function ArticlesPageInner() {
<Input <Input
placeholder={t('search_placeholder')} placeholder={t('search_placeholder')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="pl-10" className="pl-10"
/> />
</div> </div>
@@ -358,7 +379,7 @@ function ArticlesPageInner() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sortedArticles.map((article) => ( {visibleArticles.map((article) => (
<TableRow <TableRow
key={article.id} key={article.id}
className="cursor-pointer" className="cursor-pointer"
@@ -400,7 +421,7 @@ function ArticlesPageInner() {
{/* Mobile card list */} {/* Mobile card list */}
<div className="grid gap-4 md:hidden"> <div className="grid gap-4 md:hidden">
{sortedArticles.map((article) => ( {visibleArticles.map((article) => (
<Link key={article.id} href={`/articles/${article.id}`}> <Link key={article.id} href={`/articles/${article.id}`}>
<Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group"> <Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -430,6 +451,17 @@ function ArticlesPageInner() {
</Link> </Link>
))} ))}
</div> </div>
{visibleCount < sortedArticles.length && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
>
{tCommon('load_more')}
</Button>
</div>
)}
</> </>
)} )}
+24 -15
View File
@@ -1,12 +1,14 @@
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
import { type FormLine } from '@/components/bookkeeping/JournalEntryForm' 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 AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { Plus } from 'lucide-react' 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 { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry, JournalEntryLine } from '@/types' import type { JournalEntry, JournalEntryLine } from '@/types'
const NewJournalEntryDialog = dynamic(
() => import('@/components/bookkeeping/NewJournalEntryDialog'),
{ loading: DialogLoadingSkeleton },
)
interface NextVoucher { interface NextVoucher {
next: number next: number
series: string series: string
@@ -153,20 +160,22 @@ export default function BookkeepingPage() {
<JournalEntryList key={refreshKey} /> <JournalEntryList key={refreshKey} />
<NewJournalEntryDialog {showNewEntry && (
open={showNewEntry} <NewJournalEntryDialog
onOpenChange={(o) => { open
setShowNewEntry(o) onOpenChange={(o) => {
if (!o) setCopyPrefill(null) setShowNewEntry(o)
}} if (!o) setCopyPrefill(null)
onCreated={() => { }}
setRefreshKey((k) => k + 1) onCreated={() => {
setShowNewEntry(false) setRefreshKey((k) => k + 1)
setCopyPrefill(null) setShowNewEntry(false)
}} setCopyPrefill(null)
copyPrefill={copyPrefill} }}
isLoading={isLoadingCopy} copyPrefill={copyPrefill}
/> isLoading={isLoadingCopy}
/>
)}
</div> </div>
) )
} }
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -10,21 +11,29 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { PageHeader } from '@/components/ui/page-header' 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 { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { DigitalInlamning, INLAMNING_COMING_SOON } from '@/components/bokslut/DigitalInlamning' 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 { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
import type { SignatureRequest } from '@/lib/bokslut/arsredovisning/signature-service' 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() { export default function ArsredovisningPage() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const periodId = searchParams.get('period') const periodId = searchParams.get('period')
const { toast } = useToast() const { toast } = useToast()
const tStudio = useTranslations('annualReportStudio')
const [data, setData] = useState<ArsredovisningData | null>(null) const [data, setData] = useState<ArsredovisningData | null>(null)
const [signatures, setSignatures] = useState<SignatureRequest[]>([]) const [signatures, setSignatures] = useState<SignatureRequest[]>([])
@@ -37,11 +46,19 @@ export default function ArsredovisningPage() {
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [importantEvents, setImportantEvents] = useState('') const [importantEvents, setImportantEvents] = useState('')
const [resultatdisposition, setResultatdisposition] = useState('') const [resultatdisposition, setResultatdisposition] = useState('')
const [proposedDividend, setProposedDividend] = useState('')
const [savedDescription, setSavedDescription] = useState('') const [savedDescription, setSavedDescription] = useState('')
const [savedImportantEvents, setSavedImportantEvents] = useState('') const [savedImportantEvents, setSavedImportantEvents] = useState('')
const [savedResultatdisposition, setSavedResultatdisposition] = useState('') const [savedResultatdisposition, setSavedResultatdisposition] = useState('')
const [savedProposedDividend, setSavedProposedDividend] = useState('')
const [agmDate, setAgmDate] = useState('') const [agmDate, setAgmDate] = useState('')
const [savedAgmDate, setSavedAgmDate] = 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. // Disclosure fields per ÅRL 5:13-15 § + BFNAR koncernförhållanden.
// Persisted via the same POST endpoint as the förvaltningsberättelse text. // Persisted via the same POST endpoint as the förvaltningsberättelse text.
const [longTermDebt, setLongTermDebt] = useState('') const [longTermDebt, setLongTermDebt] = useState('')
@@ -56,12 +73,37 @@ export default function ArsredovisningPage() {
const [savedParentOrgNr, setSavedParentOrgNr] = useState('') const [savedParentOrgNr, setSavedParentOrgNr] = useState('')
const [parentCity, setParentCity] = useState('') const [parentCity, setParentCity] = useState('')
const [savedParentCity, setSavedParentCity] = 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 [savingNarrative, setSavingNarrative] = useState(false)
const [savedAt, setSavedAt] = useState<number | null>(null) const [narrativeRevision, setNarrativeRevision] = useState<string | null>(null)
// Add-signer form // Add-signer form
const [signerName, setSignerName] = useState('') const [signerName, setSignerName] = useState('')
const [signerRole, setSignerRole] = useState('Styrelseledamot') const [signerRole, setSignerRole] = useState('Styrelseledamot')
const [versions, setVersions] = useState<AnnualReportVersionSummary[]>([])
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(() => { useEffect(() => {
if (!periodId) return if (!periodId) return
@@ -75,7 +117,7 @@ export default function ArsredovisningPage() {
.then(([arBody, sigBody]) => { .then(([arBody, sigBody]) => {
if (cancelled) return if (cancelled) return
if (arBody?.error) { if (arBody?.error) {
setError(arBody.error.message ?? 'Kunde inte hämta årsredovisning') setError(getUserErrorMessage(arBody.error) ?? 'Kunde inte hämta årsredovisning')
return return
} }
const d = arBody.data as ArsredovisningData const d = arBody.data as ArsredovisningData
@@ -87,11 +129,18 @@ export default function ArsredovisningPage() {
setDescription(d.forvaltningsberattelse.description) setDescription(d.forvaltningsberattelse.description)
setImportantEvents(d.forvaltningsberattelse.important_events) setImportantEvents(d.forvaltningsberattelse.important_events)
setResultatdisposition(d.forvaltningsberattelse.resultatdisposition) setResultatdisposition(d.forvaltningsberattelse.resultatdisposition)
const dividend = String(d.forvaltningsberattelse.proposed_dividend || '')
setProposedDividend(dividend)
setAgmDate(d.forvaltningsberattelse.agm_date ?? '') setAgmDate(d.forvaltningsberattelse.agm_date ?? '')
setSavedDescription(d.forvaltningsberattelse.description) setSavedDescription(d.forvaltningsberattelse.description)
setSavedImportantEvents(d.forvaltningsberattelse.important_events) setSavedImportantEvents(d.forvaltningsberattelse.important_events)
setSavedResultatdisposition(d.forvaltningsberattelse.resultatdisposition) setSavedResultatdisposition(d.forvaltningsberattelse.resultatdisposition)
setSavedProposedDividend(dividend)
setSavedAgmDate(d.forvaltningsberattelse.agm_date ?? '') 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 ltd = d.disclosures.long_term_debt_over_five_years
const ltdStr = ltd != null ? String(ltd) : '' const ltdStr = ltd != null ? String(ltd) : ''
setLongTermDebt(ltdStr) setLongTermDebt(ltdStr)
@@ -106,6 +155,14 @@ export default function ArsredovisningPage() {
setSavedParentOrgNr(d.disclosures.parent_company_org_number ?? '') setSavedParentOrgNr(d.disclosures.parent_company_org_number ?? '')
setParentCity(d.disclosures.parent_company_city ?? '') setParentCity(d.disclosures.parent_company_city ?? '')
setSavedParentCity(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[]) setSignatures((sigBody.data ?? []) as SignatureRequest[])
}) })
.catch(() => { .catch(() => {
@@ -123,13 +180,20 @@ export default function ArsredovisningPage() {
description !== savedDescription || description !== savedDescription ||
importantEvents !== savedImportantEvents || importantEvents !== savedImportantEvents ||
resultatdisposition !== savedResultatdisposition || resultatdisposition !== savedResultatdisposition ||
proposedDividend !== savedProposedDividend ||
agmDate !== savedAgmDate || agmDate !== savedAgmDate ||
agmDispositionOutcome !== savedAgmDispositionOutcome ||
agmDispositionDecision !== savedAgmDispositionDecision ||
longTermDebt !== savedLongTermDebt || longTermDebt !== savedLongTermDebt ||
securitiesPledged !== savedSecuritiesPledged || securitiesPledged !== savedSecuritiesPledged ||
contingentLiabilities !== savedContingentLiabilities || contingentLiabilities !== savedContingentLiabilities ||
parentName !== savedParentName || parentName !== savedParentName ||
parentOrgNr !== savedParentOrgNr || parentOrgNr !== savedParentOrgNr ||
parentCity !== savedParentCity parentCity !== savedParentCity ||
longTermDebtConfirmed !== savedLongTermDebtConfirmed ||
securitiesPledgedConfirmed !== savedSecuritiesPledgedConfirmed ||
contingentLiabilitiesConfirmed !== savedContingentLiabilitiesConfirmed ||
parentCompanyConfirmed !== savedParentCompanyConfirmed
const handleSaveNarrative = useCallback(async () => { const handleSaveNarrative = useCallback(async () => {
if (!periodId) return if (!periodId) return
@@ -150,6 +214,19 @@ export default function ArsredovisningPage() {
} }
longTermDebtParsed = parsed 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) setSavingNarrative(true)
try { try {
const res = await fetch( const res = await fetch(
@@ -161,13 +238,23 @@ export default function ArsredovisningPage() {
description, description,
important_events: importantEvents, important_events: importantEvents,
resultatdisposition, resultatdisposition,
proposed_dividend: proposedDividendParsed,
agm_date: agmDate || null, 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, long_term_debt_over_five_years: longTermDebtParsed,
securities_pledged: securitiesPledged.trim() || null, securities_pledged: securitiesPledged.trim() || null,
contingent_liabilities: contingentLiabilities.trim() || null, contingent_liabilities: contingentLiabilities.trim() || null,
parent_company_name: parentName.trim() || null, parent_company_name: parentName.trim() || null,
parent_company_org_number: parentOrgNr.trim() || null, parent_company_org_number: parentOrgNr.trim() || null,
parent_company_city: parentCity.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) { if (!res.ok) {
toast({ toast({
title: 'Kunde inte spara texten', title: 'Kunde inte spara texten',
description: body?.error?.message ?? '', description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive', variant: 'destructive',
}) })
return return
@@ -183,18 +270,27 @@ export default function ArsredovisningPage() {
setSavedDescription(description) setSavedDescription(description)
setSavedImportantEvents(importantEvents) setSavedImportantEvents(importantEvents)
setSavedResultatdisposition(resultatdisposition) setSavedResultatdisposition(resultatdisposition)
setSavedProposedDividend(proposedDividend)
setSavedAgmDate(agmDate) setSavedAgmDate(agmDate)
setSavedAgmDispositionOutcome(agmDispositionOutcome)
setSavedAgmDispositionDecision(agmDispositionDecision)
setSavedLongTermDebt(longTermDebt) setSavedLongTermDebt(longTermDebt)
setSavedSecuritiesPledged(securitiesPledged) setSavedSecuritiesPledged(securitiesPledged)
setSavedContingentLiabilities(contingentLiabilities) setSavedContingentLiabilities(contingentLiabilities)
setSavedParentName(parentName) setSavedParentName(parentName)
setSavedParentOrgNr(parentOrgNr) setSavedParentOrgNr(parentOrgNr)
setSavedParentCity(parentCity) 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) { } catch (err) {
toast({ toast({
title: 'Kunde inte spara texten', 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', variant: 'destructive',
}) })
} finally { } finally {
@@ -205,33 +301,59 @@ export default function ArsredovisningPage() {
description, description,
importantEvents, importantEvents,
resultatdisposition, resultatdisposition,
proposedDividend,
agmDate, agmDate,
agmDispositionOutcome,
agmDispositionDecision,
longTermDebt, longTermDebt,
securitiesPledged, securitiesPledged,
contingentLiabilities, contingentLiabilities,
parentName, parentName,
parentOrgNr, parentOrgNr,
parentCity, parentCity,
longTermDebtConfirmed,
securitiesPledgedConfirmed,
contingentLiabilitiesConfirmed,
parentCompanyConfirmed,
toast, toast,
]) ])
const handleMarkSigned = useCallback( const handleMarkSigned = useCallback(
async (signatureId: string) => { async (signatureId: string) => {
if (!periodId) return 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 { try {
const res = await fetch( const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`, `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`,
{ {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, 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() const body = await res.json()
if (!res.ok) { if (!res.ok) {
toast({ toast({
title: 'Kunde inte markera som signerad', title: 'Kunde inte markera som signerad',
description: body?.error?.message ?? '', description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive', variant: 'destructive',
}) })
return return
@@ -239,16 +361,31 @@ export default function ArsredovisningPage() {
setSignatures((prev) => setSignatures((prev) =>
prev.map((s) => (s.id === signatureId ? (body.data as SignatureRequest) : s)), 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' }) toast({ title: 'Underskrift registrerad' })
} catch (err) { } catch (err) {
toast({ toast({
title: 'Kunde inte markera som signerad', 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', variant: 'destructive',
}) })
} }
}, },
[periodId, toast], [
handleVersionsChanged,
periodId,
selectedSignatureVersionId,
signatureDate,
signatureEvidence,
signingMethod,
toast,
],
) )
const handleAddSigner = useCallback(async () => { const handleAddSigner = useCallback(async () => {
@@ -266,7 +403,7 @@ export default function ArsredovisningPage() {
if (!res.ok) { if (!res.ok) {
toast({ toast({
title: 'Kunde inte lägga till undertecknare', title: 'Kunde inte lägga till undertecknare',
description: body?.error?.message ?? '', description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive', variant: 'destructive',
}) })
return return
@@ -277,12 +414,37 @@ export default function ArsredovisningPage() {
} catch (err) { } catch (err) {
toast({ toast({
title: 'Kunde inte lägga till undertecknare', 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', variant: 'destructive',
}) })
} }
}, [periodId, signerName, signerRole, toast]) }, [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) { if (!periodId) {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -381,6 +543,16 @@ export default function ArsredovisningPage() {
</Card> </Card>
)} )}
<AnnualReportStudio
periodId={periodId}
periodStart={data.fiscal_period.period_start}
periodEnd={data.fiscal_period.period_end}
framework={data.accounting_framework}
hasUnsavedNarrative={hasUnsavedNarrative}
narrativeRevision={narrativeRevision}
onVersionsChanged={handleVersionsChanged}
/>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Förvaltningsberättelse: narrativ</CardTitle> <CardTitle className="text-base">Förvaltningsberättelse: narrativ</CardTitle>
@@ -417,6 +589,20 @@ export default function ArsredovisningPage() {
rows={3} rows={3}
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="ar-dividend">Föreslagen utdelning (kr)</Label>
<Input
id="ar-dividend"
inputMode="decimal"
value={proposedDividend}
onChange={(event) => setProposedDividend(event.target.value)}
placeholder="0"
className="min-h-11 max-w-[220px] tabular-nums"
/>
<p className="text-xs text-muted-foreground">
Beloppet används i resultatdispositionen i samma version av PDF och iXBRL.
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ar-agm-date">Datum för årsstämma</Label> <Label htmlFor="ar-agm-date">Datum för årsstämma</Label>
<Input <Input
@@ -431,6 +617,37 @@ export default function ArsredovisningPage() {
fastställelseintyget i PDF:en (krävs för inlämning till Bolagsverket). fastställelseintyget i PDF:en (krävs för inlämning till Bolagsverket).
</p> </p>
</div> </div>
<div className="space-y-2">
<Label htmlFor="ar-agm-outcome">Årsstämmans beslut om resultatdisposition</Label>
<select
id="ar-agm-outcome"
className="min-h-11 w-full max-w-xl rounded-md border border-border bg-background px-3 text-sm"
value={agmDispositionOutcome}
onChange={(event) =>
setAgmDispositionOutcome(
event.target.value as
| ''
| 'proposal_approved'
| 'alternative_decision',
)
}
>
<option value="">Välj efter genomförd årsstämma</option>
<option value="proposal_approved">Styrelsens förslag godkändes</option>
<option value="alternative_decision">Årsstämman fattade ett annat beslut</option>
</select>
</div>
{agmDispositionOutcome === 'alternative_decision' && (
<div className="space-y-2">
<Label htmlFor="ar-agm-decision">Årsstämmans beslut</Label>
<Textarea
id="ar-agm-decision"
value={agmDispositionDecision}
onChange={(event) => setAgmDispositionDecision(event.target.value)}
rows={3}
/>
</div>
)}
<div className="pt-4 border-t border-border space-y-4"> <div className="pt-4 border-t border-border space-y-4">
<div> <div>
@@ -458,6 +675,14 @@ export default function ArsredovisningPage() {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
ÅRL 5:13 §. Lämna tomt om inga skulder förfaller senare än fem år. ÅRL 5:13 §. Lämna tomt om inga skulder förfaller senare än fem år.
</p> </p>
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm">
<Checkbox
id="ar-ltd-confirmed"
checked={longTermDebtConfirmed}
onCheckedChange={(checked) => setLongTermDebtConfirmed(Boolean(checked))}
/>
Jag har kontrollerat uppgiften, även om beloppet är noll.
</label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ar-securities">Ställda säkerheter</Label> <Label htmlFor="ar-securities">Ställda säkerheter</Label>
@@ -469,6 +694,14 @@ export default function ArsredovisningPage() {
placeholder="t.ex. Företagsinteckning 500 000 kr som säkerhet för bankkredit." placeholder="t.ex. Företagsinteckning 500 000 kr som säkerhet för bankkredit."
/> />
<p className="text-xs text-muted-foreground">ÅRL 5:14 §.</p> <p className="text-xs text-muted-foreground">ÅRL 5:14 §.</p>
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm">
<Checkbox
id="ar-securities-confirmed"
checked={securitiesPledgedConfirmed}
onCheckedChange={(checked) => setSecuritiesPledgedConfirmed(Boolean(checked))}
/>
Jag har kontrollerat ställda säkerheter, även om svaret är inga.
</label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ar-contingent">Eventualförpliktelser</Label> <Label htmlFor="ar-contingent">Eventualförpliktelser</Label>
@@ -480,6 +713,14 @@ export default function ArsredovisningPage() {
placeholder="t.ex. Borgensåtagande för dotterbolags krediter 200 000 kr." placeholder="t.ex. Borgensåtagande för dotterbolags krediter 200 000 kr."
/> />
<p className="text-xs text-muted-foreground">ÅRL 5:15 §.</p> <p className="text-xs text-muted-foreground">ÅRL 5:15 §.</p>
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm">
<Checkbox
id="ar-contingent-confirmed"
checked={contingentLiabilitiesConfirmed}
onCheckedChange={(checked) => setContingentLiabilitiesConfirmed(Boolean(checked))}
/>
Jag har kontrollerat eventualförpliktelser, även om svaret är inga.
</label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ar-parent-name"> <Label htmlFor="ar-parent-name">
@@ -516,13 +757,21 @@ export default function ArsredovisningPage() {
/> />
</div> </div>
</div> </div>
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm">
<Checkbox
id="ar-parent-confirmed"
checked={parentCompanyConfirmed}
onCheckedChange={(checked) => setParentCompanyConfirmed(Boolean(checked))}
/>
Jag har kontrollerat koncernförhållandet, även om bolaget saknar moderföretag.
</label>
</div> </div>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{hasUnsavedNarrative ? ( {hasUnsavedNarrative ? (
<span>Ändringar sparas inte automatiskt.</span> <span>Ändringar sparas inte automatiskt.</span>
) : savedAt ? ( ) : narrativeRevision ? (
<span className="inline-flex items-center gap-1 text-success"> <span className="inline-flex items-center gap-1 text-success">
<CheckCircle2 className="h-3.5 w-3.5" /> Sparat <CheckCircle2 className="h-3.5 w-3.5" /> Sparat
</span> </span>
@@ -588,12 +837,78 @@ export default function ArsredovisningPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-base">Underskrifter</CardTitle> <CardTitle className="text-base">Underskrifter</CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Lägg till varje styrelseledamot + VD som ska skriva under. BankID-signering Lägg till varje styrelseledamot och eventuell VD. Lås först en version i
kommer i en kommande version: för nu visas slottar och status här, och arbetsflödet ovan. När originalet eller en extern e-signatur är klar registrerar
själva underskriften görs pappret. du datum och bevisreferens mot exakt den version som skrevs under.
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-4 rounded-md border border-border bg-muted/20 p-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="signature-version">Låst version</Label>
<select
id="signature-version"
className="min-h-11 w-full rounded-md border border-border bg-background px-3 text-sm"
value={selectedSignatureVersionId}
onChange={(event) => setSelectedSignatureVersionId(event.target.value)}
>
<option value="">Välj version</option>
{versions
.filter((version) => version.status === 'ready_for_signature')
.map((version) => (
<option key={version.id} value={version.id}>
Version {version.version_number}: {version.content_hash.slice(0, 12)}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="signature-method">Underskriftsmetod</Label>
<select
id="signature-method"
className="min-h-11 w-full rounded-md border border-border bg-background px-3 text-sm"
value={signingMethod}
onChange={(event) =>
setSigningMethod(
event.target.value as
| 'paper_original'
| 'advanced_e_signature'
| 'bankid',
)
}
>
<option value="paper_original">Undertecknat original papper</option>
<option value="advanced_e_signature">Avancerad e-signatur</option>
<option value="bankid">BankID via extern signeringstjänst</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="signature-date">Underskriftsdatum</Label>
<Input
id="signature-date"
type="date"
className="min-h-11"
value={signatureDate}
onChange={(event) => setSignatureDate(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="signature-evidence">Bevisreferens</Label>
<Input
id="signature-evidence"
className="min-h-11"
value={signatureEvidence}
onChange={(event) => setSignatureEvidence(event.target.value)}
placeholder="archive:AR-2026-001"
maxLength={128}
autoComplete="off"
/>
</div>
<p className="text-xs text-muted-foreground md:col-span-2">
Accounted registrerar beviset men skapar inte själva underskriften. Spara
originalet eller signeringskvittot enligt bolagets dokumenthantering.
</p>
</div>
{signatures.length === 0 && ( {signatures.length === 0 && (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">
Inga undertecknare tillagda än. Inga undertecknare tillagda än.
@@ -602,13 +917,13 @@ export default function ArsredovisningPage() {
{signatures.map((sig) => ( {signatures.map((sig) => (
<div <div
key={sig.id} key={sig.id}
className="flex items-center justify-between border-b border-border last:border-b-0 pb-3 last:pb-0" className="flex flex-col gap-3 border-b border-border pb-3 last:border-b-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
> >
<div> <div>
<p className="text-sm font-medium">{sig.signer_name}</p> <p className="text-sm font-medium">{sig.signer_name}</p>
<p className="text-xs text-muted-foreground">{sig.role}</p> <p className="text-xs text-muted-foreground">{sig.role}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
{sig.status === 'signed' ? ( {sig.status === 'signed' ? (
<Badge variant="success">Signerad</Badge> <Badge variant="success">Signerad</Badge>
) : sig.status === 'declined' ? ( ) : sig.status === 'declined' ? (
@@ -616,10 +931,27 @@ export default function ArsredovisningPage() {
) : ( ) : (
<> <>
<Badge variant="outline">Väntar underskrift</Badge> <Badge variant="outline">Väntar underskrift</Badge>
{sig.annual_report_version_id === null && (
<Button
size="icon"
variant="ghost"
className="min-h-11 min-w-11"
aria-label={tStudio('remove_signer')}
onClick={() => void handleRemoveSigner(sig.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="min-h-11"
onClick={() => void handleMarkSigned(sig.id)} onClick={() => void handleMarkSigned(sig.id)}
disabled={
!selectedSignatureVersionId ||
!SIGNATURE_EVIDENCE_REFERENCE_PATTERN.test(signatureEvidence.trim()) ||
!signatureDate
}
> >
Markera som signerad Markera som signerad
</Button> </Button>
@@ -628,14 +960,14 @@ export default function ArsredovisningPage() {
</div> </div>
</div> </div>
))} ))}
<div className="flex flex-wrap gap-2 items-end pt-2"> <div className="flex flex-col gap-3 pt-2 sm:flex-row sm:flex-wrap sm:items-end">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="signer-role" className="text-xs"> <Label htmlFor="signer-role" className="text-xs">
Roll Roll
</Label> </Label>
<select <select
id="signer-role" id="signer-role"
className="border border-border rounded-md h-9 text-sm px-2 bg-background" className="min-h-11 rounded-md border border-border bg-background px-3 text-sm"
value={signerRole} value={signerRole}
onChange={(e) => setSignerRole(e.target.value)} onChange={(e) => setSignerRole(e.target.value)}
> >
@@ -654,10 +986,10 @@ export default function ArsredovisningPage() {
value={signerName} value={signerName}
onChange={(e) => setSignerName(e.target.value)} onChange={(e) => setSignerName(e.target.value)}
placeholder="t.ex. Anna Andersson" placeholder="t.ex. Anna Andersson"
className="h-9" className="min-h-11"
/> />
</div> </div>
<Button onClick={handleAddSigner} disabled={!signerName.trim()}> <Button className="min-h-11 w-full sm:w-auto" onClick={handleAddSigner} disabled={!signerName.trim()}>
<Plus className="mr-1 h-4 w-4" /> Lägg till <Plus className="mr-1 h-4 w-4" /> Lägg till
</Button> </Button>
</div> </div>
@@ -666,15 +998,17 @@ export default function ArsredovisningPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Ladda ner & lämna in</CardTitle> <CardTitle className="text-base">PDF för pappersinlämning</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm"> <CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Ladda ner PDF-utkastet, granska, skriv ut och låt undertecknarna signera Ladda ner PDF-utkastet och granska det. För pappersinlämning ska
fastställelseintyget. Ladda sedan upp PDF:en till Bolagsverkets e-tjänst. å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.
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button asChild> <Button className="min-h-11" asChild>
<Link href={pdfUrl} target="_blank" rel="noopener noreferrer"> <Link href={pdfUrl} target="_blank" rel="noopener noreferrer">
<FileDown className="mr-2 h-4 w-4" /> Ladda ner PDF (utkast) <FileDown className="mr-2 h-4 w-4" /> Ladda ner PDF (utkast)
</Link> </Link>
@@ -690,13 +1024,13 @@ export default function ArsredovisningPage() {
: undefined : undefined
} }
> >
<Button variant="outline" asChild> <Button className="min-h-11" variant="outline" asChild>
<Link <Link
href="https://www.bolagsverket.se/foretag/aktiebolag/arsredovisning/lamna-in-arsredovisning" href="https://bolagsverket.se/foretag/aktiebolag/arsredovisningforaktiebolag.759.html"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<ExternalLink className="mr-2 h-4 w-4" /> Bolagsverket Mina Sidor <ExternalLink className="mr-2 h-4 w-4" /> Bolagsverket om årsredovisning
</Link> </Link>
</Button> </Button>
</span> </span>
@@ -721,11 +1055,10 @@ export default function ArsredovisningPage() {
</div> </div>
)} )}
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs"> <div className="rounded-md border border-border bg-muted/30 p-3 text-xs">
<strong>Notis om digital inlämning:</strong> Digital inlämning (iXBRL) av <strong>Digital inlämning är frivillig.</strong> Den görs som iXBRL genom en
årsredovisning föreslås bli obligatorisk för K2/K3-aktiebolag för ansluten programvara. Accounteds direktinlämning förblir stängd tills avtal,
räkenskapsår som inleds efter 2025-12-31. Använd avsnittet{' '} certifikat och Bolagsverkets acceptanstest är klara. PDF-flödet ovan är den
<strong>Digital inlämning</strong> nedan för att granska, validera och lämna separata vägen för pappersinlämning.
in årsredovisningen som iXBRL. PDF:en ovan är ett läsexemplar.
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -148,7 +148,7 @@ export default function YearEndPage() {
const body = await res.json() const body = await res.json()
if (cancelled) return if (cancelled) return
if (!res.ok) { if (!res.ok) {
setReportError(body?.error?.message ?? 'Kunde inte ladda bokslutskontroll') setReportError(getErrorMessage(body?.error) ?? 'Kunde inte ladda bokslutskontroll')
return return
} }
setReport(body.data as BokslutReadinessReport) 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 res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`)
const body = await res.json() const body = await res.json()
if (!res.ok) { if (!res.ok) {
setPreviewError(body?.error?.message ?? 'Kunde inte hämta förhandsgranskning') setPreviewError(getErrorMessage(body?.error) ?? 'Kunde inte hämta förhandsgranskning')
return return
} }
setPreview(body.data.preview as YearEndPreview) setPreview(body.data.preview as YearEndPreview)
@@ -198,7 +198,7 @@ export default function YearEndPage() {
// body.error.message is the localized Swedish message picked by // body.error.message is the localized Swedish message picked by
// the structured-error registry. Do NOT interpolate raw details // the structured-error registry. Do NOT interpolate raw details
// here: they can contain DB-sourced strings (V2.3 finding). // 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 return
} }
setResult(body.data as YearEndResult) setResult(body.data as YearEndResult)
@@ -26,6 +26,7 @@ import type {
PeriodiseringConfidence, PeriodiseringConfidence,
} from '@/lib/bokslut/accruals/auto-detect' } from '@/lib/bokslut/accruals/auto-detect'
import type { FiscalPeriod } from '@/types' import type { FiscalPeriod } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review' type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review'
@@ -160,7 +161,7 @@ export default function PeriodiseringWizardPage() {
const body = await res.json() const body = await res.json()
if (cancelled) return if (cancelled) return
if (!res.ok) { if (!res.ok) {
setLoadError(body?.error?.message ?? 'Kunde inte ladda periodiseringar') setLoadError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda periodiseringar')
return return
} }
const data = body.data as ProposalResponse const data = body.data as ProposalResponse
@@ -329,7 +330,7 @@ export default function PeriodiseringWizardPage() {
}) })
const body = await res.json() const body = await res.json()
if (!res.ok) { if (!res.ok) {
setPostError(body?.error?.message ?? 'Kunde inte bokföra periodiseringarna') setPostError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra periodiseringarna')
return return
} }
const created = body.data?.created?.length ?? 0 const created = body.data?.created?.length ?? 0
@@ -340,7 +341,7 @@ export default function PeriodiseringWizardPage() {
description: skipped > 0 ? `${skipped} hoppades över (redan postade).` : undefined, description: skipped > 0 ? `${skipped} hoppades över (redan postade).` : undefined,
}) })
} catch (err) { } catch (err) {
setPostError(err instanceof Error ? err.message : 'Okänt fel') setPostError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally { } finally {
setPosting(false) setPosting(false)
} }
+5 -7
View File
@@ -1,7 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatConversationView from '@/components/agent/ChatConversationView' import ChatConversationView from '@/components/agent/ChatConversationView'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -15,12 +14,11 @@ interface PageProps {
// up via /api/agent/invoke with conversation_id supplied. // up via /api/agent/invoke with conversation_id supplied.
export default async function ChatConversationPage({ params }: PageProps) { export default async function ChatConversationPage({ params }: PageProps) {
const { id } = await params const { id } = await params
const supabase = await createClient() const [{ supabase, user }, companyId] = await Promise.all([
getDashboardAuthContext(),
const { data: { user } } = await supabase.auth.getUser() getDashboardCompanyId(),
])
if (!user) redirect('/login') if (!user) redirect('/login')
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding') if (!companyId) redirect('/onboarding')
// Both queries key on the route id, so they run in parallel. The tenant // Both queries key on the route id, so they run in parallel. The tenant
+5 -6
View File
@@ -1,7 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter' import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic' 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. // Plan ref: dev_docs/specialized-agent-plan.md §7 Phase C.
export default async function ChatIntakePage() { export default async function ChatIntakePage() {
const supabase = await createClient() const [{ user }, companyId] = await Promise.all([
const { data: { user } } = await supabase.auth.getUser() getDashboardAuthContext(),
getDashboardCompanyId(),
])
if (!user) redirect('/login') if (!user) redirect('/login')
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding') if (!companyId) redirect('/onboarding')
return <ChatIntakeStarter /> return <ChatIntakeStarter />
+10 -32
View File
@@ -1,8 +1,10 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' 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 ChatSidebar from '@/components/agent/ChatSidebar'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getResolvedDashboardAgentProfile,
} from '../request-context'
export const dynamic = 'force-dynamic' 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] // 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. // share this layout so the sidebar doesn't unmount on conversation switches.
export default async function ChatLayout({ children }: { children: React.ReactNode }) { export default async function ChatLayout({ children }: { children: React.ReactNode }) {
const supabase = await createClient() const [{ supabase, user }, companyId, agent] = await Promise.all([
const { data: { user } } = await supabase.auth.getUser() getDashboardAuthContext(),
getDashboardCompanyId(),
getResolvedDashboardAgentProfile(),
])
if (!user) redirect('/login') if (!user) redirect('/login')
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding') if (!companyId) redirect('/onboarding')
// Block the chat surface until the agent is built. Without this a user // 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 / // empty conversations list with no Anna to talk to. The home route at /
// renders NewUserChecklist for the same state, so we forward there // renders NewUserChecklist for the same state, so we forward there
// instead of duplicating the welcome screen here. // 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('/') if (!agent?.verified_at) redirect('/')
const { data: conversations } = await supabase const { data: conversations } = await supabase
+5 -6
View File
@@ -1,8 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatNewStarter from '@/components/agent/ChatNewStarter' import ChatNewStarter from '@/components/agent/ChatNewStarter'
import { getIntent } from '@/lib/agent/intents/registry' import { getIntent } from '@/lib/agent/intents/registry'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -17,11 +16,11 @@ interface PageProps {
// caller-chosen intent/seed, so suggestion chips and ⌘K can route here // caller-chosen intent/seed, so suggestion chips and ⌘K can route here
// inline instead of opening the slide-in sheet. // inline instead of opening the slide-in sheet.
export default async function ChatNewPage({ searchParams }: PageProps) { export default async function ChatNewPage({ searchParams }: PageProps) {
const supabase = await createClient() const [{ user }, companyId] = await Promise.all([
const { data: { user } } = await supabase.auth.getUser() getDashboardAuthContext(),
getDashboardCompanyId(),
])
if (!user) redirect('/login') if (!user) redirect('/login')
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding') if (!companyId) redirect('/onboarding')
const sp = await searchParams const sp = await searchParams
+36 -4
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react' import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
@@ -21,7 +22,6 @@ import {
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' 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 { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
import { PageHeader } from '@/components/ui/page-header' import { PageHeader } from '@/components/ui/page-header'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
@@ -31,6 +31,19 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types' import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const CustomerForm = dynamic(
() => import('@/components/customers/CustomerForm'),
{
loading: () => (
<div className="space-y-4 py-4" role="status">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</div>
),
},
)
const CUSTOMER_TYPE_LABEL_KEYS: Record<CustomerType, string> = { const CUSTOMER_TYPE_LABEL_KEYS: Record<CustomerType, string> = {
individual: 'type_individual', individual: 'type_individual',
swedish_business: 'type_swedish_business', swedish_business: 'type_swedish_business',
@@ -49,6 +62,7 @@ const SORTABLE_COLUMNS: ReadonlyArray<SortColumn> = [
'city', 'city',
'created_at', 'created_at',
] ]
const INITIAL_VISIBLE_ROWS = 100
function getInitials(name: string): string { function getInitials(name: string): string {
return name return name
@@ -73,11 +87,13 @@ function CustomersPageInner() {
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast() const { toast } = useToast()
const supabase = createClient() const supabase = createClient()
const t = useTranslations('customers') const t = useTranslations('customers')
const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale const errorLocale = useLocale() as ErrorLocale
const router = useRouter() const router = useRouter()
@@ -93,6 +109,7 @@ function CustomersPageInner() {
const updateSort = useCallback( const updateSort = useCallback(
(column: SortColumn) => { (column: SortColumn) => {
setVisibleCount(INITIAL_VISIBLE_ROWS)
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
let nextDir: SortDir = 'asc' let nextDir: SortDir = 'asc'
if (column === sortColumn) { if (column === sortColumn) {
@@ -211,6 +228,7 @@ function CustomersPageInner() {
}) })
return arr return arr
}, [filteredCustomers, sortColumn, sortDir]) }, [filteredCustomers, sortColumn, sortDir])
const visibleCustomers = sortedCustomers.slice(0, visibleCount)
function SortableHeader({ function SortableHeader({
column, column,
@@ -284,7 +302,10 @@ function CustomersPageInner() {
<Input <Input
placeholder={t('search_placeholder')} placeholder={t('search_placeholder')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="pl-10" className="pl-10"
/> />
</div> </div>
@@ -350,7 +371,7 @@ function CustomersPageInner() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sortedCustomers.map((customer) => { {visibleCustomers.map((customer) => {
const identifier = getIdentifier(customer) const identifier = getIdentifier(customer)
return ( return (
<TableRow <TableRow
@@ -399,7 +420,7 @@ function CustomersPageInner() {
{/* Mobile card list */} {/* Mobile card list */}
<div className="grid gap-4 md:hidden"> <div className="grid gap-4 md:hidden">
{sortedCustomers.map((customer) => ( {visibleCustomers.map((customer) => (
<Link key={customer.id} href={`/customers/${customer.id}`}> <Link key={customer.id} href={`/customers/${customer.id}`}>
<Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group"> <Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -441,6 +462,17 @@ function CustomersPageInner() {
</Link> </Link>
))} ))}
</div> </div>
{visibleCount < sortedCustomers.length && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
>
{tCommon('load_more')}
</Button>
</div>
)}
</> </>
)} )}
</div> </div>
+6 -5
View File
@@ -15,6 +15,7 @@ import { AlertTriangle, ArrowRight, CalendarClock, Loader2 } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import type { Deadline } from '@/types' import type { Deadline } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const supabase = createClient() const supabase = createClient()
@@ -123,7 +124,7 @@ export default function DeadlinesPage() {
} catch (error) { } catch (error) {
toast({ toast({
title: t('generate_failed_title'), title: t('generate_failed_title'),
description: error instanceof Error ? error.message : t('retry'), description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive', variant: 'destructive',
}) })
} finally { } finally {
@@ -155,7 +156,7 @@ export default function DeadlinesPage() {
} catch (error) { } catch (error) {
toast({ toast({
title: t('create_failed_title'), title: t('create_failed_title'),
description: error instanceof Error ? error.message : t('retry'), description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive', variant: 'destructive',
}) })
throw error throw error
@@ -208,7 +209,7 @@ export default function DeadlinesPage() {
} catch (error) { } catch (error) {
toast({ toast({
title: t('toggle_failed_title'), title: t('toggle_failed_title'),
description: error instanceof Error ? error.message : t('retry'), description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive', variant: 'destructive',
}) })
} }
@@ -236,7 +237,7 @@ export default function DeadlinesPage() {
} catch (error) { } catch (error) {
toast({ toast({
title: t('update_failed_title'), title: t('update_failed_title'),
description: error instanceof Error ? error.message : t('retry'), description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive', variant: 'destructive',
}) })
} }
@@ -258,7 +259,7 @@ export default function DeadlinesPage() {
} catch (error) { } catch (error) {
toast({ toast({
title: t('delete_failed_title'), title: t('delete_failed_title'),
description: error instanceof Error ? error.message : t('retry'), description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive', variant: 'destructive',
}) })
} }
+5 -5
View File
@@ -1,12 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation' import { redirect, notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import { getExtensionDefinition } from '@/lib/extensions/sectors' import { getExtensionDefinition } from '@/lib/extensions/sectors'
import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader' import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader'
import { getActiveCompanyId } from '@/lib/company/context'
import { hasCapability } from '@/lib/entitlements/has-capability' import { hasCapability } from '@/lib/entitlements/has-capability'
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys' import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
import { ExtensionUpsellState } from '@/components/extensions/ExtensionUpsellState' import { ExtensionUpsellState } from '@/components/extensions/ExtensionUpsellState'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../../request-context'
export default async function ExtensionWorkspacePage({ export default async function ExtensionWorkspacePage({
params, params,
@@ -14,8 +13,10 @@ export default async function ExtensionWorkspacePage({
params: Promise<{ sector: string; slug: string }> params: Promise<{ sector: string; slug: string }>
}) { }) {
const { sector, slug } = await params const { sector, slug } = await params
const supabase = await createClient() const [{ supabase, user }, companyId] = await Promise.all([
const { data: { user } } = await supabase.auth.getUser() getDashboardAuthContext(),
getDashboardCompanyId(),
])
if (!user) redirect('/login') if (!user) redirect('/login')
@@ -29,7 +30,6 @@ export default async function ExtensionWorkspacePage({
// Fail closed: no resolvable company or the capability absent, both block. // Fail closed: no resolvable company or the capability absent, both block.
const requiredCapability = requiredCapabilityForExtension(sector, slug) const requiredCapability = requiredCapabilityForExtension(sector, slug)
if (requiredCapability) { if (requiredCapability) {
const companyId = await getActiveCompanyId(supabase, user.id)
const allowed = companyId const allowed = companyId
? await hasCapability(supabase, companyId, requiredCapability) ? await hasCapability(supabase, companyId, requiredCapability)
: false : false
+58 -51
View File
@@ -19,28 +19,11 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' 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' import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types'
// Register import (customers/suppliers) components // Register import (customers/suppliers) components
import RegisterUploadStep from '@/components/import/RegisterUploadStep'
import RegisterColumnMappingStep, { type RegisterColumnSpec } from '@/components/import/RegisterColumnMappingStep' import RegisterColumnMappingStep, { type RegisterColumnSpec } from '@/components/import/RegisterColumnMappingStep'
import CustomersEditStep from '@/components/import/CustomersEditStep' import type { RegisterResult } from '@/components/import/RegisterResultStep'
import SuppliersEditStep from '@/components/import/SuppliersEditStep'
import ArticlesEditStep from '@/components/import/ArticlesEditStep'
import RegisterResultStep, { type RegisterResult } from '@/components/import/RegisterResultStep'
import type { import type {
CustomerImportParseResult, CustomerImportParseResult,
AnnotatedCustomerRow, AnnotatedCustomerRow,
@@ -57,12 +40,7 @@ import type {
DetectedArticleColumns, DetectedArticleColumns,
} from '@/lib/import/articles/types' } from '@/lib/import/articles/types'
// SIE import components import type { ImportExecuteOptions } from '@/components/import/ImportReviewStep'
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 { applyMappingOverride } from '@/lib/import/account-mapper' import { applyMappingOverride } from '@/lib/import/account-mapper'
import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types' import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { IngestResult } from '@/lib/transactions/ingest' import type { IngestResult } from '@/lib/transactions/ingest'
@@ -86,6 +64,35 @@ const MigrationWizard = dynamic(
{ ssr: false, loading: () => <div className="flex items-center gap-3 text-muted-foreground p-6"><Loader2 className="h-5 w-5 animate-spin" />Laddar migreringsverktyg...</div> } { ssr: false, loading: () => <div className="flex items-center gap-3 text-muted-foreground p-6"><Loader2 className="h-5 w-5 animate-spin" />Laddar migreringsverktyg...</div> }
) )
function ImportStepLoading() {
return (
<div className="flex min-h-48 items-center justify-center" role="status">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)
}
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 // 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.' 'Exportera en ny fil från banken om du vill lägga till fler transaktioner.'
) )
} else { } else {
setBankError(err.message || 'Kunde inte läsa filen') setBankError(getErrorMessage(err) || 'Kunde inte läsa filen')
} }
} else { } else {
setBankError(typeof err === 'string' ? err : 'Kunde inte läsa filen') 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.') setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
} }
} catch (err) { } 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 { } finally {
setBankIsLoading(false) setBankIsLoading(false)
} }
@@ -267,7 +274,7 @@ function BankFileImportWizard() {
description: `${data.data.imported} transaktioner importerades`, description: `${data.data.imported} transaktioner importerades`,
}) })
} catch (err) { } catch (err) {
setBankError(err instanceof Error ? err.message : 'Importen misslyckades') setBankError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally { } finally {
setBankIsLoading(false) setBankIsLoading(false)
} }
@@ -524,7 +531,7 @@ function SIEImportWizard() {
const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError')) const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError'))
const message = isNetworkError const message = isNetworkError
? 'Kunde inte nå servern. Kontrollera din internetanslutning och försök igen.' ? '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') setErrorType(isNetworkError ? 'network' : 'parse')
setError(message) setError(message)
toast({ title: isNetworkError ? 'Anslutningsfel' : 'Ett fel uppstod', description: message, variant: 'destructive' }) toast({ title: isNetworkError ? 'Anslutningsfel' : 'Ett fel uppstod', description: message, variant: 'destructive' })
@@ -677,7 +684,7 @@ function SIEImportWizard() {
setBasAccounts(accountsData.data || []) setBasAccounts(accountsData.data || [])
} }
} catch (err) { } 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 { } finally {
setIsCreatingAccounts(false) setIsCreatingAccounts(false)
} }
@@ -739,7 +746,7 @@ function SIEImportWizard() {
const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError')) const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError'))
const msg = isNetworkError const msg = isNetworkError
? 'Tappade anslutningen till servern under importen. Kontrollera din internetanslutning och se om importen genomfördes under Bokföring.' ? '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) setError(msg)
toast({ title: 'Import avbröts', description: msg, variant: 'destructive' }) toast({ title: 'Import avbröts', description: msg, variant: 'destructive' })
} finally { } finally {
@@ -886,7 +893,7 @@ function OpeningBalanceFlow() {
setObStep('edit') setObStep('edit')
} }
} catch (err) { } 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 { } finally {
setObIsLoading(false) setObIsLoading(false)
} }
@@ -918,7 +925,7 @@ function OpeningBalanceFlow() {
setParseResult(data.data) setParseResult(data.data)
setObStep('edit') setObStep('edit')
} catch (err) { } 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 { } finally {
setObIsLoading(false) setObIsLoading(false)
} }
@@ -979,7 +986,7 @@ function OpeningBalanceFlow() {
}) })
} }
} catch (err) { } catch (err) {
setObError(err instanceof Error ? err.message : 'Importen misslyckades') setObError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally { } finally {
setObIsLoading(false) setObIsLoading(false)
} }
@@ -1156,7 +1163,7 @@ function CustomersFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { 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 return
} }
@@ -1175,7 +1182,7 @@ function CustomersFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit') setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) { } 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -1217,14 +1224,14 @@ function CustomersFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { 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 return
} }
setParseResult(data.data) setParseResult(data.data)
setStep('edit') setStep('edit')
} catch (err) { } 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -1249,7 +1256,7 @@ function CustomersFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades') setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return return
} }
@@ -1263,7 +1270,7 @@ function CustomersFlow() {
variant: r.success ? 'default' : 'destructive', variant: r.success ? 'default' : 'destructive',
}) })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades') setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -1407,7 +1414,7 @@ function SuppliersFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { 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 return
} }
@@ -1426,7 +1433,7 @@ function SuppliersFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit') setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) { } 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -1474,14 +1481,14 @@ function SuppliersFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { 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 return
} }
setParseResult(data.data) setParseResult(data.data)
setStep('edit') setStep('edit')
} catch (err) { } 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -1506,7 +1513,7 @@ function SuppliersFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades') setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return return
} }
@@ -1520,7 +1527,7 @@ function SuppliersFlow() {
variant: r.success ? 'default' : 'destructive', variant: r.success ? 'default' : 'destructive',
}) })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades') setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -1657,7 +1664,7 @@ function ArticlesFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { 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 return
} }
@@ -1676,7 +1683,7 @@ function ArticlesFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit') setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) { } 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -1717,14 +1724,14 @@ function ArticlesFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { 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 return
} }
setParseResult(data.data) setParseResult(data.data)
setStep('edit') setStep('edit')
} catch (err) { } 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -1749,7 +1756,7 @@ function ArticlesFlow() {
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades') setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return return
} }
@@ -1763,7 +1770,7 @@ function ArticlesFlow() {
variant: r.success ? 'default' : 'destructive', variant: r.success ? 'default' : 'destructive',
}) })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades') setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -20,6 +20,7 @@ import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys' import { CAPABILITY } from '@/lib/entitlements/keys'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode' import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import type { Invoice, InvoiceItem, Customer } from '@/types' import type { Invoice, InvoiceItem, Customer } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InvoiceWithRelations extends Invoice { interface InvoiceWithRelations extends Invoice {
customer: Customer customer: Customer
@@ -136,7 +137,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
} catch (error) { } catch (error) {
toast({ toast({
title: t('create_failed_title'), 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', variant: 'destructive',
}) })
} }
+47 -45
View File
@@ -53,6 +53,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types' import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const statusVariantMap: Record<InvoiceStatus, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = { const statusVariantMap: Record<InvoiceStatus, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
draft: 'secondary', draft: 'secondary',
@@ -83,7 +84,7 @@ interface InvoiceWithRelations extends Invoice {
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite() const { canWrite } = useCanWrite()
const { isSandbox } = useCompany() const { company, isSandbox } = useCompany()
const canEmail = useCapability(CAPABILITY.email_send) const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params) const { id } = use(params)
const router = useRouter() const router = useRouter()
@@ -139,6 +140,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
async function fetchInvoice() { async function fetchInvoice() {
setIsLoading(true) 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 // Invoice, reminders, and payments all key on the route id — one
// parallel batch. Only the follow-ups below need the invoice row. // parallel batch. Only the follow-ups below need the invoice row.
const [{ data, error }, { data: reminderData }, { data: paymentData }] = 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 const settingsRes = await settingsPromise
// + VAT registration so the detail view matches the PDF — see if (settingsRes) {
// pdf-template.tsx:792 and :876), the credit note, the original invoice, const settings = settingsRes.data
// and the proforma source. Independent of each other → parallel. setOreRounding(settings?.ore_rounding ?? true)
const [settingsRes, creditNoteRes, originalRes, convertedRes] = if (typeof settings?.vat_registered === 'boolean') {
await Promise.all([ setVatRegistered(settings.vat_registered)
data.company_id }
? supabase setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
.from('company_settings') setDeferInvoiceBooking(!!settings?.defer_invoice_booking)
.select('ore_rounding, vat_registered, accounting_method, defer_invoice_booking, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3') setReminderDays([
.eq('company_id', data.company_id) settings?.reminder_days_level_1 ?? 15,
.maybeSingle() settings?.reminder_days_level_2 ?? 30,
: Promise.resolve(null), 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 && !data.credited_invoice_id &&
['sent', 'paid', 'overdue', 'credited'].includes(data.status) ['sent', 'paid', 'overdue', 'credited'].includes(data.status)
? supabase ? supabase
@@ -249,31 +267,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.eq('id', data.converted_from_id) .eq('id', data.converted_from_id)
.single() .single()
: Promise.resolve(null), : Promise.resolve(null),
]) ]).then(([creditNoteRes, originalRes, convertedRes]) => {
setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
if (settingsRes) { if (originalRes?.data) {
const settings = settingsRes.data setOriginalInvoice(originalRes.data as Invoice)
setOreRounding(settings?.ore_rounding ?? true) }
if (typeof settings?.vat_registered === 'boolean') { if (convertedRes?.data) {
setVatRegistered(settings.vat_registered) setConvertedFromInvoice(convertedRes.data as Invoice)
} }
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)
} }
// #967: deferred booking: create the revenue verifikat afterwards. // #967: deferred booking: create the revenue verifikat afterwards.
@@ -304,7 +306,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) { } catch (error) {
toast({ toast({
title: t('book_failed_title'), 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', variant: 'destructive',
}) })
} finally { } finally {
@@ -357,7 +359,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) { } catch (error) {
toast({ toast({
title: t('status_update_failed_title'), 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', variant: 'destructive',
}) })
} }
@@ -394,7 +396,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) { } catch (error) {
toast({ toast({
title: t('convert_failed_title'), 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', variant: 'destructive',
}) })
} }
@@ -433,7 +435,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) { } catch (error) {
toast({ toast({
title: t('pdf_download_failed_title'), 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', variant: 'destructive',
}) })
} }
@@ -495,7 +497,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) { } catch (error) {
toast({ toast({
title: t('finalize_failed_title'), 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', variant: 'destructive',
}) })
} finally { } finally {
@@ -540,7 +542,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) { } catch (error) {
toast({ toast({
title: t('cancel_failed_title'), 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', variant: 'destructive',
}) })
} }
+62 -11
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
@@ -11,6 +12,7 @@ import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header' import { PageHeader } from '@/components/ui/page-header'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { import {
DataList, DataList,
DataListRow, DataListRow,
@@ -26,13 +28,35 @@ import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding' import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react' import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state' import { EmptyInvoices } from '@/components/ui/empty-state'
import NewInvoiceDialog from '@/components/invoices/NewInvoiceDialog'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Invoice, InvoiceStatus } from '@/types' import type { Invoice, InvoiceStatus } from '@/types'
function NewInvoiceDialogLoading() {
const t = useTranslations('invoices')
return (
<Dialog open>
<DialogContent className="sm:max-w-3xl">
<DialogTitle>{t('new_invoice')}</DialogTitle>
<div className="space-y-4 py-4" role="status">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-40" />
</div>
</DialogContent>
</Dialog>
)
}
const NewInvoiceDialog = dynamic(
() => import('@/components/invoices/NewInvoiceDialog'),
{ loading: NewInvoiceDialogLoading },
)
type InvoiceStatusVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive' type InvoiceStatusVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
const INITIAL_VISIBLE_ROWS = 100
const STATUS_CONFIG: Record<InvoiceStatus, { labelKey: string; variant: InvoiceStatusVariant }> = { const STATUS_CONFIG: Record<InvoiceStatus, { labelKey: string; variant: InvoiceStatusVariant }> = {
draft: { labelKey: 'status_draft', variant: 'secondary' }, draft: { labelKey: 'status_draft', variant: 'secondary' },
sent: { labelKey: 'status_sent', variant: 'default' }, sent: { labelKey: 'status_sent', variant: 'default' },
@@ -77,9 +101,11 @@ export default function InvoicesPage() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('all') const [activeTab, setActiveTab] = useState('all')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const { toast } = useToast() const { toast } = useToast()
const supabase = createClient() const supabase = createClient()
const t = useTranslations('invoices') const t = useTranslations('invoices')
const tCommon = useTranslations('common')
const getRelativeTimeLabel = useRelativeTimeLabel() const getRelativeTimeLabel = useRelativeTimeLabel()
// The "Ny faktura" modal is driven by the URL (?new=1) so every entry point // 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 return matchesSearch && matchesTab
}) })
const visibleInvoices = filteredInvoices.slice(0, visibleCount)
const isOutstandingReceivable = (i: Invoice) => const isOutstandingReceivable = (i: Invoice) =>
['sent', 'overdue'].includes(i.status) && !i.credited_invoice_id ['sent', 'overdue'].includes(i.status) && !i.credited_invoice_id
@@ -218,11 +245,21 @@ export default function InvoicesPage() {
<Input <Input
placeholder={t('search_placeholder')} placeholder={t('search_placeholder')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="pl-10" className="pl-10"
/> />
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="min-w-0"> <Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="min-w-0"
>
<TabsList className="w-max max-w-full justify-start"> <TabsList className="w-max max-w-full justify-start">
<TabsTrigger value="all">{t('tab_all')}</TabsTrigger> <TabsTrigger value="all">{t('tab_all')}</TabsTrigger>
<TabsTrigger value="unpaid">{t('tab_unpaid')}</TabsTrigger> <TabsTrigger value="unpaid">{t('tab_unpaid')}</TabsTrigger>
@@ -264,7 +301,7 @@ export default function InvoicesPage() {
/> />
) )
) : ( ) : (
filteredInvoices.map((invoice) => { visibleInvoices.map((invoice) => {
const status = STATUS_CONFIG[invoice.status] const status = STATUS_CONFIG[invoice.status]
const isCreditNote = !!invoice.credited_invoice_id const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
@@ -378,13 +415,27 @@ export default function InvoicesPage() {
)} )}
</DataList> </DataList>
<NewInvoiceDialog {!isLoading && visibleCount < filteredInvoices.length && (
open={showNewInvoice} <div className="flex justify-center">
copyFromId={copyFromId} <Button
onOpenChange={(open) => { type="button"
if (!open) closeNewInvoice() variant="outline"
}} onClick={() => setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
/> >
{tCommon('load_more')}
</Button>
</div>
)}
{showNewInvoice && (
<NewInvoiceDialog
open
copyFromId={copyFromId}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
/>
)}
</div> </div>
) )
} }
+14 -38
View File
@@ -1,4 +1,3 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav' import DashboardNav from '@/components/dashboard/DashboardNav'
@@ -12,11 +11,15 @@ import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors' import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext' import { CompanyProvider } from '@/contexts/CompanyContext'
import { getActiveCompanyId } from '@/lib/company/context'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability' import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service' import { getBranding } from '@/lib/branding/service'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import type { EntityType, CompanyRole, Team } from '@/types' 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 * 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. // current page on in-app navigation to /settings/*; null otherwise.
settingsModal: React.ReactNode settingsModal: React.ReactNode
}) { }) {
const supabase = await createClient() const { supabase, user } = await getDashboardAuthContext()
const { data: { user } } = await supabase.auth.getUser()
if (!user) { if (!user) {
redirect('/login') redirect('/login')
@@ -51,7 +52,7 @@ export default async function DashboardLayout({
// so it resolves in parallel, this layout is on the critical path of // so it resolves in parallel, this layout is on the critical path of
// every dashboard page, so sequential round-trips are wall-clock time. // every dashboard page, so sequential round-trips are wall-clock time.
const [companyId, headerStore, { data: teamMembership }] = await Promise.all([ 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. // Read the pathname forwarded by middleware so we can branch on it.
headers(), headers(),
supabase supabase
@@ -128,7 +129,7 @@ export default async function DashboardLayout({
{ data: memberRow }, { data: memberRow },
{ data: allMemberships }, { data: allMemberships },
{ data: settings }, { data: settings },
{ data: agentProfileIdentity }, agentProfileIdentity,
{ data: userProfile }, { data: userProfile },
entitlements, entitlements,
{ data: allSettingsNames }, { data: allSettingsNames },
@@ -136,22 +137,14 @@ export default async function DashboardLayout({
supabase.from('companies').select('*').eq('id', companyId).single(), 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('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_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 getDashboardSettings(),
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
.eq('company_id', companyId)
.single(),
// Nav badge counts (unbooked transactions, pending operations) are NOT // Nav badge counts (unbooked transactions, pending operations) are NOT
// fetched here anymore: DashboardNav loads them client-side after mount // fetched here anymore: DashboardNav loads them client-side after mount
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking // (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
// first paint on every dashboard navigation. // first paint on every dashboard navigation.
// Agent identity, name + avatar, surfaced on the FAB and chat // Agent identity, name + avatar, surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path). // surfaces. Null when no agent_profile exists yet (banner CTA path).
supabase getResolvedDashboardAgentProfile(),
.from('agent_profiles')
.select('display_name, avatar_id, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
// The signed-in user's profile, shown in the bottom-left account // The signed-in user's profile, shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged // popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top. // 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 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 = { const companyContextValue = {
company: companyWithName, company: companyWithName,
role: memberRow.role as CompanyRole, role: memberRow.role as CompanyRole,
@@ -283,9 +259,9 @@ export default async function DashboardLayout({
<CompanyProvider value={companyContextValue}> <CompanyProvider value={companyContextValue}>
<AgentSheetProvider <AgentSheetProvider
identity={{ identity={{
displayName: resolvedAgentIdentity?.display_name ?? null, displayName: agentProfileIdentity?.display_name ?? null,
avatarId: resolvedAgentIdentity?.avatar_id ?? null, avatarId: agentProfileIdentity?.avatar_id ?? null,
isVerified: Boolean(resolvedAgentIdentity?.verified_at), isVerified: Boolean(agentProfileIdentity?.verified_at),
}} }}
> >
<CompanyTabSync /> <CompanyTabSync />
+22 -82
View File
@@ -1,50 +1,31 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import DashboardContent from '@/components/dashboard/DashboardContent' 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 { getDisplayTotal } from '@/lib/invoices/rounding'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist' import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
import type { Deadline, OnboardingProgress } from '@/types' import type { Deadline, OnboardingProgress } from '@/types'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getDashboardSettings,
getResolvedDashboardAgentProfile,
} from './request-context'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// Home route = Översikt (DashboardContent). The agent chat has its own nav // 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 // entry at /chat, so / no longer forwards there. Initial setup is an optional,
// their assistant yet get WelcomeGate (the build-agent checklist) instead of // persisted surface inside the dashboard and never replaces the overview.
// the dashboard; once the agent is verified, / renders the normal Översikt.
export default async function DashboardPage() { export default async function DashboardPage() {
const supabase = await createClient() const [{ supabase, user }, companyId] = await Promise.all([
getDashboardAuthContext(),
const { data: { user } } = await supabase.auth.getUser() getDashboardCompanyId(),
])
if (!user) { if (!user) {
redirect('/login') 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) { if (!companyId) {
redirect('/onboarding') redirect('/onboarding')
} }
@@ -61,7 +42,6 @@ export default async function DashboardPage() {
settingsRes, settingsRes,
{ count: customerCount }, { count: customerCount },
{ count: invoiceCount }, { count: invoiceCount },
{ count: receiptCount },
{ count: transactionCount }, { count: transactionCount },
{ data: journalLines }, { data: journalLines },
{ data: unpaidInvoices }, { data: unpaidInvoices },
@@ -70,15 +50,13 @@ export default async function DashboardPage() {
{ count: sieImportCount }, { count: sieImportCount },
{ count: staleUncategorizedCount }, { count: staleUncategorizedCount },
{ count: skatteverketTokenCount }, { count: skatteverketTokenCount },
{ data: agentProfile }, agentProfile,
{ count: postedEntriesCount },
worklist, worklist,
suggestedMatches, suggestedMatches,
] = await Promise.all([ ] = 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('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('invoices').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('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('journal_entry_lines') supabase.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status, company_id)') .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 // carry the active company_id; either filter would work: we use user_id
// because that's what the token-store reads/writes against. // 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('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('agent_profiles').select('verified_at').eq('company_id', companyId).maybeSingle(), getResolvedDashboardAgentProfile(),
// 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'),
// Pending-work counts + suggested matches come from lib/worklist: the // Pending-work counts + suggested matches come from lib/worklist: the
// same source as the sidebar badges, so the numbers can never diverge. // same source as the sidebar badges, so the numbers can never diverge.
getWorklistCounts(supabase, companyId), getWorklistCounts(supabase, companyId),
@@ -118,53 +94,12 @@ export default async function DashboardPage() {
redirect('/onboarding') redirect('/onboarding')
} }
// Sandbox sessions that pre-date the agent_profile seeding step would const agentBuilt = Boolean(agentProfile?.verified_at)
// 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 (
<WelcomeGate
companyId={companyId}
hasBookkeepingImported={(sieImportCount || 0) > 0}
hasBankConnected={(transactionCount || 0) > 0}
hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0}
/>
)
}
const onboardingProgress: OnboardingProgress = { const onboardingProgress: OnboardingProgress = {
hasCustomers: (customerCount || 0) > 0, hasCustomers: (customerCount || 0) > 0,
hasInvoices: (invoiceCount || 0) > 0, hasInvoices: (invoiceCount || 0) > 0,
hasBankConnected: (transactionCount || 0) > 0, hasBankConnected: (bankConnections?.length || 0) > 0 || (transactionCount || 0) > 0,
hasSIEImport: (sieImportCount || 0) > 0, hasSIEImport: (sieImportCount || 0) > 0,
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0, hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
} }
@@ -266,6 +201,11 @@ export default async function DashboardPage() {
worklist={worklist} worklist={worklist}
suggestedMatches={suggestedMatches} suggestedMatches={suggestedMatches}
onboardingProgress={onboardingProgress} onboardingProgress={onboardingProgress}
initialSetup={{
path: settings.initial_setup_path ?? null,
completedAt: settings.initial_setup_completed_at ?? null,
dismissedAt: settings.initial_setup_dismissed_at ?? null,
}}
/> />
) )
} }
+10 -33
View File
@@ -688,45 +688,26 @@ export default function PendingOperationsPage() {
const res = await fetch(`/api/pending-operations?status=${activeTab}`) const res = await fetch(`/api/pending-operations?status=${activeTab}`)
const json = await res.json() const json = await res.json()
setOperations(json.data ?? []) 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 { } catch {
toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' }) toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' })
} }
setIsLoading(false) setIsLoading(false)
}, [activeTab, toast]) }, [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(() => { useEffect(() => {
fetchOperations() fetchOperations()
}, [fetchOperations]) }, [fetchOperations])
useEffect(() => {
fetchAllCounts()
}, [fetchAllCounts])
// Realtime subscription: refetch when ANY pending_operations row changes for // Realtime subscription: refetch when ANY pending_operations row changes for
// this company. RLS scopes the channel automatically: we don't see other // this company. RLS scopes the channel automatically: we don't see other
// tenants' events. We refetch the whole list (rather than patching state // tenants' events. We refetch the whole list (rather than patching state
// in-place) so server-side filtering, sorting, and computed fields stay in // in-place) so server-side filtering, sorting, and computed fields stay in
// sync with whatever the API route returned. The counts endpoint isn't // sync with whatever the API route returned, including all tab counts.
// pushed by the same trigger, so we also refresh counts on every change.
// Trailing debounce: bulk actions emit one event per row, which previously // Trailing debounce: bulk actions emit one event per row, which previously
// stampeded 4 requests per event (list + 3 counts); the burst now collapses // stampeded 4 requests per event (list + 3 counts); the burst now collapses
// into a single refetch after the last event. // into a single refetch after the last event.
@@ -742,7 +723,6 @@ export default function PendingOperationsPage() {
if (debounce) clearTimeout(debounce) if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => { debounce = setTimeout(() => {
fetchOperations() fetchOperations()
fetchAllCounts()
}, 400) }, 400)
} }
) )
@@ -751,7 +731,7 @@ export default function PendingOperationsPage() {
if (debounce) clearTimeout(debounce) if (debounce) clearTimeout(debounce)
void supabase.removeChannel(channel) void supabase.removeChannel(channel)
} }
}, [fetchOperations, fetchAllCounts]) }, [fetchOperations])
// Clear selection when filters/tab change // Clear selection when filters/tab change
useEffect(() => { useEffect(() => {
@@ -772,11 +752,10 @@ export default function PendingOperationsPage() {
setShowCommitDialog(false) setShowCommitDialog(false)
setSelectedOp(null) setSelectedOp(null)
fetchOperations() fetchOperations()
fetchAllCounts()
} catch (err) { } catch (err) {
toast({ toast({
title: 'Misslyckades', title: 'Misslyckades',
description: err instanceof Error ? err.message : 'Okänt fel', description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive', variant: 'destructive',
}) })
} }
@@ -818,11 +797,10 @@ export default function PendingOperationsPage() {
setShowBulkDialog(false) setShowBulkDialog(false)
setSelectedIds(new Set()) setSelectedIds(new Set())
fetchOperations() fetchOperations()
fetchAllCounts()
} catch (err) { } catch (err) {
toast({ toast({
title: 'Misslyckades', title: 'Misslyckades',
description: err instanceof Error ? err.message : 'Okänt fel', description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive', variant: 'destructive',
}) })
} }
@@ -887,11 +865,10 @@ export default function PendingOperationsPage() {
setRejectTarget(null) setRejectTarget(null)
fetchOperations() fetchOperations()
fetchAllCounts()
} catch (err) { } catch (err) {
toast({ toast({
title: 'Kunde inte avvisa', title: 'Kunde inte avvisa',
description: err instanceof Error ? err.message : 'Okänt fel', description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive', variant: 'destructive',
}) })
} }
+23 -1
View File
@@ -1,6 +1,8 @@
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { getReport } from '@/lib/reports/catalog' import { getReport } from '@/lib/reports/catalog'
import { FocusedReport } from '@/components/reports/FocusedReport' 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 * 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) const report = getReport(slug)
if (!report) notFound() if (!report) notFound()
if (report.route) redirect(report.route) if (report.route) redirect(report.route)
return <FocusedReport slug={slug} />
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 (
<FocusedReport
slug={slug}
initialPeriods={(periods ?? []) as FiscalPeriod[]}
initialCompanyId={companyId}
/>
)
} }
@@ -17,6 +17,7 @@ import {
import { ArrowLeft, Download, FileSpreadsheet, AlertTriangle, CheckCircle2 } from 'lucide-react' import { ArrowLeft, Download, FileSpreadsheet, AlertTriangle, CheckCircle2 } from 'lucide-react'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import type { KassaflodesanalysReport } from '@/lib/reports/kassaflodesanalys' import type { KassaflodesanalysReport } from '@/lib/reports/kassaflodesanalys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function formatAmount(n: number): string { function formatAmount(n: number): string {
return n.toLocaleString('sv-SE', { return n.toLocaleString('sv-SE', {
@@ -72,7 +73,7 @@ export function KassaflodesanalysClient() {
const { data } = await res.json() const { data } = await res.json()
setReport(data) setReport(data)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel') setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
setReport(null) setReport(null)
} finally { } finally {
setIsLoadingReport(false) setIsLoadingReport(false)
+79
View File
@@ -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
})
+19 -11
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl' 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 { Plus, ArrowLeft, UserCircle } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import NewEmployeeDialog from '@/components/salary/NewEmployeeDialog' import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import type { Employee } from '@/types' import type { Employee } from '@/types'
const NewEmployeeDialog = dynamic(
() => import('@/components/salary/NewEmployeeDialog'),
{ loading: DialogLoadingSkeleton },
)
const EMPLOYMENT_LABEL_KEYS: Record<string, string> = { const EMPLOYMENT_LABEL_KEYS: Record<string, string> = {
employee: 'employment_employee', employee: 'employment_employee',
company_owner: 'employment_company_owner', company_owner: 'employment_company_owner',
@@ -142,16 +148,18 @@ export default function EmployeesPage() {
</Card> </Card>
)} )}
<NewEmployeeDialog {showNewEmployee && (
open={showNewEmployee} <NewEmployeeDialog
onOpenChange={(open) => { open
if (!open) closeNewEmployee() onOpenChange={(open) => {
}} if (!open) closeNewEmployee()
onCreated={() => { }}
closeNewEmployee() onCreated={() => {
setRefreshKey((k) => k + 1) closeNewEmployee()
}} setRefreshKey((k) => k + 1)
/> }}
/>
)}
</div> </div>
) )
} }
@@ -10,6 +10,7 @@ import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel' import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types' 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. */ /** Translation keys in the `salary_run_employee` namespace. */
const LINE_ITEM_TYPE_KEYS: Record<SalaryLineItemType, string> = { const LINE_ITEM_TYPE_KEYS: Record<SalaryLineItemType, string> = {
@@ -86,7 +87,7 @@ export default function SalaryRunEmployeeDetailPage({
if (!sreRes.ok) throw new Error(sreJson.error || t('error_load_employee')) if (!sreRes.ok) throw new Error(sreJson.error || t('error_load_employee'))
setData({ run: runJson.data, runEmployee: sreJson.data }) setData({ run: runJson.data, runEmployee: sreJson.data })
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error')) setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -108,7 +109,7 @@ export default function SalaryRunEmployeeDetailPage({
} }
await load() await load()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error')) setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally { } finally {
setCalculating(false) setCalculating(false)
} }
+28 -15
View File
@@ -61,6 +61,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
tax_payment_file_generated_at: string | null tax_payment_file_generated_at: string | null
tax_paid_at: string | null tax_paid_at: string | null
} | null>(null) } | null>(null)
const [taxPaymentLoading, setTaxPaymentLoading] = useState(false)
// Skatteverket's per-period AGI submission record: drives the AGI step on // Skatteverket's per-period AGI submission record: drives the AGI step on
// the progress rail and the panel's state machine (underlag submitted / // 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) setRun(data)
if (data?.period_year && data?.period_month) { if (data?.period_year && data?.period_month) {
const period = `${data.period_year}-${String(data.period_month).padStart(2, '0')}` const period = `${data.period_year}-${String(data.period_month).padStart(2, '0')}`
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`) setTaxPaymentLoading(true)
if (txRes.ok) { void fetch(`/api/skatteverket/tax-payments/${period}`)
const tx = await txRes.json() .then(async (txRes) => (txRes.ok ? txRes.json() : null))
setTaxPayment(tx.data) .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) { } catch (err) {
toast({ toast({
title: t('toast_zip_failed'), 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', variant: 'destructive',
}) })
} finally { } finally {
@@ -650,15 +654,24 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
{/* Tax payment (skatt + arbetsgivaravgifter): once AGI has been generated */} {/* Tax payment (skatt + arbetsgivaravgifter): once AGI has been generated */}
{run.status === 'booked' && run.agi_generated_at && ( {run.status === 'booked' && run.agi_generated_at && (
<TaxPaymentPanel taxPaymentLoading ? (
period={periodLabel} <Card>
totalTax={run.total_tax} <CardContent className="space-y-4 p-6">
totalAvgifter={run.total_avgifter} <Skeleton className="h-5 w-40" />
paymentFileGeneratedAt={taxPayment?.tax_payment_file_generated_at ?? null} <Skeleton className="h-20 w-full" />
taxPaidAt={taxPayment?.tax_paid_at ?? null} </CardContent>
readOnly={!canWrite} </Card>
onChange={loadRun} ) : (
/> <TaxPaymentPanel
period={periodLabel}
totalTax={run.total_tax}
totalAvgifter={run.total_avgifter}
paymentFileGeneratedAt={taxPayment?.tax_payment_file_generated_at ?? null}
taxPaidAt={taxPayment?.tax_paid_at ?? null}
readOnly={!canWrite}
onChange={loadRun}
/>
)
)} )}
{/* AGI (Arbetsgivardeklaration): available once the run is booked */} {/* AGI (Arbetsgivardeklaration): available once the run is booked */}
+5 -4
View File
@@ -41,6 +41,7 @@ import type {
SkattekontoTransactionWithSuggestion, SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction, StoredSkattekontoTransaction,
} from '@/extensions/general/skatteverket/types' } from '@/extensions/general/skatteverket/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface SaldoEnvelope { interface SaldoEnvelope {
data: SkatteverketSaldoResponse | null data: SkatteverketSaldoResponse | null
@@ -194,7 +195,7 @@ export default function SkattekontoPage() {
} catch (err) { } catch (err) {
toast({ toast({
title: 'Synk misslyckades', title: 'Synk misslyckades',
description: err instanceof Error ? err.message : undefined, description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive', variant: 'destructive',
}) })
} finally { } finally {
@@ -222,7 +223,7 @@ export default function SkattekontoPage() {
} catch (err) { } catch (err) {
toast({ toast({
title: 'Kunde inte bokföra', title: 'Kunde inte bokföra',
description: err instanceof Error ? err.message : undefined, description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive', variant: 'destructive',
}) })
} finally { } finally {
@@ -246,7 +247,7 @@ export default function SkattekontoPage() {
} catch (err) { } catch (err) {
toast({ toast({
title: 'Kunde inte hämta kandidater', title: 'Kunde inte hämta kandidater',
description: err instanceof Error ? err.message : undefined, description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive', variant: 'destructive',
}) })
setMatchOpenFor(null) setMatchOpenFor(null)
@@ -278,7 +279,7 @@ export default function SkattekontoPage() {
} catch (err) { } catch (err) {
toast({ toast({
title: 'Kunde inte koppla transaktionen', title: 'Kunde inte koppla transaktionen',
description: err instanceof Error ? err.message : undefined, description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive', variant: 'destructive',
}) })
} finally { } finally {
+25 -21
View File
@@ -91,6 +91,7 @@ export default function SupplierInvoiceDetailPage() {
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0]) const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
const [paymentAccount, setPaymentAccount] = useState('1930') const [paymentAccount, setPaymentAccount] = useState('1930')
const [accounts, setAccounts] = useState<BASAccount[]>([]) const [accounts, setAccounts] = useState<BASAccount[]>([])
const [areAccountsLoading, setAreAccountsLoading] = useState(false)
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [duplicateCandidates, setDuplicateCandidates] = useState< const [duplicateCandidates, setDuplicateCandidates] = useState<
Array<{ Array<{
@@ -245,31 +246,27 @@ export default function SupplierInvoiceDetailPage() {
} }
}, [isPayDialogOpen, invoice, payAmount, paymentAccount]) }, [isPayDialogOpen, invoice, payAmount, paymentAccount])
// Load chart of accounts and remember the last picked payment account so the // The chart of accounts is only needed by the payment dialog. Defer the
// dialog defaults to the user's previous choice instead of re-defaulting to // request until the user opens it instead of blocking the detail page.
// 1930 every time.
useEffect(() => { useEffect(() => {
if (!isPayDialogOpen || accounts.length > 0) return
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
const [accountsRes, settingsRes] = await Promise.all([ setAreAccountsLoading(true)
fetch('/api/bookkeeping/accounts'), try {
fetch('/api/settings'), const response = await fetch('/api/bookkeeping/accounts')
]) if (cancelled || !response.ok) return
if (cancelled) return const { data } = await response.json()
if (accountsRes.ok) {
const { data } = await accountsRes.json()
if (Array.isArray(data)) setAccounts(data as BASAccount[]) if (Array.isArray(data)) setAccounts(data as BASAccount[])
} } finally {
if (settingsRes.ok) { if (!cancelled) setAreAccountsLoading(false)
const { data } = await settingsRes.json()
const last = (data as { last_supplier_payment_account?: string | null } | null)?.last_supplier_payment_account
if (last) setPaymentAccount(last)
} }
})() })()
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [accounts.length, isPayDialogOpen])
async function handleApprove() { async function handleApprove() {
setIsProcessing(true) setIsProcessing(true)
@@ -898,6 +895,9 @@ export default function SupplierInvoiceDetailPage() {
open={isPayDialogOpen} open={isPayDialogOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
setIsPayDialogOpen(open) setIsPayDialogOpen(open)
if (open && companySettings?.last_supplier_payment_account) {
setPaymentAccount(companySettings.last_supplier_payment_account)
}
if (!open) setPayTab('new') if (!open) setPayTab('new')
}} }}
> >
@@ -938,11 +938,15 @@ export default function SupplierInvoiceDetailPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="payment-account">Betalkonto</Label> <Label htmlFor="payment-account">Betalkonto</Label>
<AccountCombobox {areAccountsLoading ? (
value={paymentAccount} <Skeleton className="h-10 w-full" />
accounts={accounts} ) : (
onChange={setPaymentAccount} <AccountCombobox
/> value={paymentAccount}
accounts={accounts}
onChange={setPaymentAccount}
/>
)}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
T.ex. 1930 bankkonto, 1940 övrigt bankkonto, 2018 egna uttag (EF), 2893 ägarlån (AB). T.ex. 1930 bankkonto, 1940 övrigt bankkonto, 2018 egna uttag (EF), 2893 ägarlån (AB).
</p> </p>
+17 -9
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Skeleton } from "@/components/ui/skeleton" 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 { Plus, FileInput, Lock } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { PageHeader } from '@/components/ui/page-header' 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 { useCanWrite } from '@/lib/hooks/use-can-write'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message' 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 { getDisplayTotal } from '@/lib/invoices/rounding'
import type { SupplierInvoice } from '@/types' import type { SupplierInvoice } from '@/types'
const NewSupplierInvoiceDialog = dynamic(
() => import('@/components/supplier-invoices/NewSupplierInvoiceDialog'),
{ loading: DialogLoadingSkeleton },
)
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = { const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
registered: 'secondary', registered: 'secondary',
approved: 'default', approved: 'default',
@@ -267,14 +273,16 @@ export default function SupplierInvoicesPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<NewSupplierInvoiceDialog {showNewInvoice && (
open={showNewInvoice} <NewSupplierInvoiceDialog
onOpenChange={(open) => { open
if (!open) closeNewInvoice() onOpenChange={(open) => {
}} if (!open) closeNewInvoice()
inboxItemId={inboxItemId} }}
onCreated={handleCreated} inboxItemId={inboxItemId}
/> onCreated={handleCreated}
/>
)}
</div> </div>
) )
} }
+38 -5
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -13,17 +14,30 @@ import { PageHeader } from '@/components/ui/page-header'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Building2, Lock } from 'lucide-react' import { Plus, Search, Building2, Lock } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link' import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types' import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const SupplierForm = dynamic(
() => import('@/components/suppliers/SupplierForm'),
{
loading: () => (
<div className="space-y-4 py-4" role="status">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</div>
),
},
)
const SUPPLIER_TYPE_KEYS: Record<SupplierType, string> = { const SUPPLIER_TYPE_KEYS: Record<SupplierType, string> = {
swedish_business: 'type_swedish_business', swedish_business: 'type_swedish_business',
eu_business: 'type_eu_business', eu_business: 'type_eu_business',
non_eu_business: 'type_non_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 { function getPaymentInfo(supplier: Supplier, t: (key: string) => string): { label: string; value: string } | null {
if (supplier.bankgiro) return { label: t('label_bg'), value: supplier.bankgiro } if (supplier.bankgiro) return { label: t('label_bg'), value: supplier.bankgiro }
@@ -46,10 +60,12 @@ export default function SuppliersPage() {
const [suppliers, setSuppliers] = useState<Supplier[]>([]) const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast() const { toast } = useToast()
const supabase = createClient() const supabase = createClient()
const tCommon = useTranslations('common')
async function fetchSuppliers() { async function fetchSuppliers() {
if (!company) return if (!company) return
@@ -111,6 +127,7 @@ export default function SuppliersPage() {
s.email?.toLowerCase().includes(searchTerm.toLowerCase()) || s.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.org_number?.includes(searchTerm) s.org_number?.includes(searchTerm)
) )
const visibleSuppliers = filteredSuppliers.slice(0, visibleCount)
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -159,7 +176,10 @@ export default function SuppliersPage() {
<Input <Input
placeholder={t('search_placeholder')} placeholder={t('search_placeholder')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="pl-10" className="pl-10"
/> />
</div> </div>
@@ -200,8 +220,9 @@ export default function SuppliersPage() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <>
{filteredSuppliers.map((supplier) => { <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{visibleSuppliers.map((supplier) => {
const payment = getPaymentInfo(supplier, t) const payment = getPaymentInfo(supplier, t)
const location = formatLocation(supplier) const location = formatLocation(supplier)
return ( return (
@@ -251,7 +272,19 @@ export default function SuppliersPage() {
</Link> </Link>
) )
})} })}
</div> </div>
{visibleCount < filteredSuppliers.length && (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setVisibleCount((count) => count + INITIAL_VISIBLE_ROWS)}
>
{tCommon('load_more')}
</Button>
</div>
)}
</>
)} )}
</div> </div>
) )
+222 -184
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
import dynamic from 'next/dynamic'
import Link from 'next/link' import Link from 'next/link'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence } from 'framer-motion'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
@@ -23,8 +24,6 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { ChevronDown, EyeOff, Layers, Search, ShieldAlert, Trash2, X } from 'lucide-react' 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 TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip' import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton' import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
@@ -33,21 +32,9 @@ import TransactionInboxCard from '@/components/transactions/TransactionInboxCard
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList' import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState' import InboxZeroState from '@/components/transactions/InboxZeroState'
import SkattekontoInboxCard from '@/components/transactions/SkattekontoInboxCard' 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 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 { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-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 { isImportedTransaction } from '@/lib/transactions/origin'
import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status' import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status'
function InlineDialogContentLoading() {
return (
<div className="space-y-4 py-4" role="status">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-40" />
</div>
)
}
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 InvoiceWithCustomer = Invoice & { customer?: Customer }
type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier } type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
@@ -249,8 +277,8 @@ export default function TransactionsPage() {
} | null>(null) } | null>(null)
const [duplicateProcessing, setDuplicateProcessing] = useState(false) const [duplicateProcessing, setDuplicateProcessing] = useState(false)
// Entity type for tooltip context // Dashboard layout already resolved the effective entity type from settings.
const [entityType, setEntityType] = useState<string>('enskild_firma') const entityType = company?.entity_type ?? 'enskild_firma'
// Pagination // Pagination
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
@@ -294,14 +322,17 @@ export default function TransactionsPage() {
const refreshTransactionsQueuedRef = useRef(false) const refreshTransactionsQueuedRef = useRef(false)
// Computed lists // Computed lists
const uncategorizedTransactions = transactions const uncategorizedTransactions = useMemo(
.filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id)) () => transactions
.sort((a, b) => { .filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id))
const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0 .sort((a, b) => {
const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0 const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0
return b.date.localeCompare(a.date) 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 // 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 // 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: 'bank'; date: string; data: TransactionWithInvoice }
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion } | { 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({ const bankToSkvHints = useMemo(
bankRows: uncategorizedTransactions.map(t => ({ id: t.id, date: t.date, amount: t.amount })), () => findBankSkvCounterparts({
skvRows: skvUnmatched, bankRows: uncategorizedTransactions.map((transaction) => ({
}) id: transaction.id,
date: transaction.date,
amount: transaction.amount,
})),
skvRows: skvUnmatched,
}),
[skvUnmatched, uncategorizedTransactions],
)
const inboxItems: InboxItem[] = (() => { const inboxItems = useMemo<InboxItem[]>(() => {
const items: InboxItem[] = [] const items: InboxItem[] = []
const query = searchTerm.trim().toLowerCase() const query = searchTerm.trim().toLowerCase()
if (sourceFilter !== 'skatteverket') { if (sourceFilter !== 'skatteverket') {
@@ -356,11 +397,14 @@ export default function TransactionsPage() {
if (a.source !== b.source) return a.source === 'bank' ? -1 : 1 if (a.source !== b.source) return a.source === 'bank' ? -1 : 1
return 0 return 0
}) })
})() }, [exitingIds, searchTerm, skvRows, sourceFilter, uncategorizedTransactions])
const transactionsWithMatches = transactions.filter( const transactionsWithMatches = useMemo(
(t) => () => transactions.filter(
(t.potential_invoice && !t.invoice_id) || (transaction) =>
(t.potential_supplier_invoice && !t.supplier_invoice_id), (transaction.potential_invoice && !transaction.invoice_id) ||
(transaction.potential_supplier_invoice && !transaction.supplier_invoice_id),
),
[transactions],
) )
const PAGE_SIZE = 200 const PAGE_SIZE = 200
@@ -413,6 +457,7 @@ export default function TransactionsPage() {
const fetchTransactions = useCallback(async (showLoading = false, includeSkvRows = false) => { const fetchTransactions = useCallback(async (showLoading = false, includeSkvRows = false) => {
if (!companyId) return if (!companyId) return
if (showLoading) setIsLoading(true) if (showLoading) setIsLoading(true)
if (includeSkvRows) void loadSkvRows()
try { try {
const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([ const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([
supabase supabase
@@ -451,13 +496,6 @@ export default function TransactionsPage() {
setTotalUncategorizedCount(uncatCount ?? 0) setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore(rows.length >= PAGE_SIZE) 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 { } finally {
if (showLoading) setIsLoading(false) 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(() => { useEffect(() => {
let cancelled = false void fetchTransactions(true, true)
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 }
}, [fetchTransactions]) }, [fetchTransactions])
useEffect(() => { useEffect(() => {
@@ -1647,7 +1667,7 @@ export default function TransactionsPage() {
} catch (err) { } catch (err) {
toast({ toast({
title: 'Kunde inte bokföra', title: 'Kunde inte bokföra',
description: err instanceof Error ? err.message : undefined, description: err instanceof Error ? getErrorMessage(err) : undefined,
variant: 'destructive', variant: 'destructive',
}) })
} finally { } finally {
@@ -2349,68 +2369,82 @@ export default function TransactionsPage() {
)} )}
{/* Dialogs */} {/* Dialogs */}
<BatchCategorySelector {showBatchSelector && (
open={showBatchSelector} <BatchCategorySelector
onOpenChange={setShowBatchSelector} open
selectedCount={selectedIds.size} onOpenChange={setShowBatchSelector}
onSelectCategory={handleBatchCategorize} selectedCount={selectedIds.size}
progress={batchProgress} onSelectCategory={handleBatchCategorize}
/> progress={batchProgress}
/>
)}
<InvoiceMatchDialog {matchDialogOpen && (
open={matchDialogOpen} <InvoiceMatchDialog
onOpenChange={setMatchDialogOpen} open
transaction={selectedTransaction} onOpenChange={setMatchDialogOpen}
isConfirming={isConfirmingMatch} transaction={selectedTransaction}
onConfirm={handleConfirmInvoiceMatch} isConfirming={isConfirmingMatch}
onLinkToExisting={handleLinkToExistingVoucher} onConfirm={handleConfirmInvoiceMatch}
/> onLinkToExisting={handleLinkToExistingVoucher}
/>
)}
<MatchVoucherDialog {matchVoucherTx && (
open={matchVoucherTx !== null} <MatchVoucherDialog
onOpenChange={(o) => { if (!o) setMatchVoucherTx(null) }} open
transaction={matchVoucherTx} onOpenChange={(o) => { if (!o) setMatchVoucherTx(null) }}
onLinked={handleVoucherLinked} transaction={matchVoucherTx}
/> onLinked={handleVoucherLinked}
/>
)}
<MatchAllocationDialog {splitMatchOpen && (
open={splitMatchOpen} <MatchAllocationDialog
onOpenChange={(o) => { open
setSplitMatchOpen(o) onOpenChange={(o) => {
if (!o) setSplitMatchTransaction(null) setSplitMatchOpen(o)
}} if (!o) setSplitMatchTransaction(null)
transaction={splitMatchTransaction} }}
onSuccess={handleSplitMatchSuccess} transaction={splitMatchTransaction}
/> onSuccess={handleSplitMatchSuccess}
/>
)}
<BulkBookDialog {bulkBookOpen && (
open={bulkBookOpen} <BulkBookDialog
onOpenChange={setBulkBookOpen} open
transactions={selectedTransactions} onOpenChange={setBulkBookOpen}
onSuccess={handleBulkBookSuccess} transactions={selectedTransactions}
/> onSuccess={handleBulkBookSuccess}
/>
)}
<TransactionBookingDialog {bookingDialogOpen && (
open={bookingDialogOpen} <TransactionBookingDialog
onOpenChange={(o) => { open
setBookingDialogOpen(o) onOpenChange={(o) => {
if (!o) setBookingDialogTemplate(null) setBookingDialogOpen(o)
}} if (!o) setBookingDialogTemplate(null)
transaction={bookingDialogTransaction} }}
preselectedTemplate={bookingDialogTemplate} transaction={bookingDialogTransaction}
onBooked={handleTransactionBooked} preselectedTemplate={bookingDialogTemplate}
/> onBooked={handleTransactionBooked}
/>
)}
<TransactionAttachDocumentDialog {attachDocTx && (
open={attachDocTx !== null} <TransactionAttachDocumentDialog
onOpenChange={(o) => { open
if (!o) setAttachDocTx(null) onOpenChange={(o) => {
}} if (!o) setAttachDocTx(null)
transaction={attachDocTx} }}
onAttached={handleDocumentAttached} transaction={attachDocTx}
/> onAttached={handleDocumentAttached}
/>
)}
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}> {templatePickerOpen && <Dialog open onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto"> <DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle> <DialogTitle>Bokför transaktion</DialogTitle>
@@ -2471,10 +2505,10 @@ export default function TransactionsPage() {
onPickLibraryTemplate={handlePickLibraryTemplate} onPickLibraryTemplate={handlePickLibraryTemplate}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>}
<Dialog {invoicePickerOpen && <Dialog
open={invoicePickerOpen} open
onOpenChange={(open) => { onOpenChange={(open) => {
if (isMatchingFromPicker) return if (isMatchingFromPicker) return
setInvoicePickerOpen(open) setInvoicePickerOpen(open)
@@ -2501,10 +2535,10 @@ export default function TransactionsPage() {
</> </>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>}
<Dialog {supplierInvoicePickerOpen && <Dialog
open={supplierInvoicePickerOpen} open
onOpenChange={(open) => { onOpenChange={(open) => {
if (isMatchingSupplierFromPicker) return if (isMatchingSupplierFromPicker) return
setSupplierInvoicePickerOpen(open) setSupplierInvoicePickerOpen(open)
@@ -2531,67 +2565,73 @@ export default function TransactionsPage() {
</> </>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>}
<QuickReviewDialog {quickReviewOpen && (
key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId) + String(quickReview?.template?.id)} <QuickReviewDialog
open={quickReviewOpen} key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId) + String(quickReview?.template?.id)}
onOpenChange={setQuickReviewOpen} open
transaction={quickReview?.transaction ?? null} onOpenChange={setQuickReviewOpen}
category={quickReview?.category ?? null} transaction={quickReview?.transaction ?? null}
categoryLabel={quickReview?.label ?? ''} category={quickReview?.category ?? null}
defaultAccount={ categoryLabel={quickReview?.label ?? ''}
// For library templates (no templateId but a template object), use the defaultAccount={
// template's debit account as the default; otherwise fall back to the // For library templates (no templateId but a template object), use the
// category's default account. // template's debit account as the default; otherwise fall back to the
!quickReview?.templateId && quickReview?.template // category's default account.
? quickReview.template.debit_account !quickReview?.templateId && quickReview?.template
: quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : '' ? quickReview.template.debit_account
} : quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : ''
defaultVat={ }
!quickReview?.templateId && quickReview?.template defaultVat={
? (quickReview.template.vat_treatment ?? 'none') !quickReview?.templateId && quickReview?.template
: quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none' ? (quickReview.template.vat_treatment ?? 'none')
} : quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none'
entityType={entityType as EntityType} }
template={quickReview?.template ?? null} entityType={entityType as EntityType}
templateId={quickReview?.templateId} template={quickReview?.template ?? null}
counterpartyLinePattern={quickReview?.linePattern ?? null} templateId={quickReview?.templateId}
onConfirm={handleQuickReviewConfirm} counterpartyLinePattern={quickReview?.linePattern ?? null}
onChangeTemplate={handleChangeTemplate} onConfirm={handleQuickReviewConfirm}
/> onChangeTemplate={handleChangeTemplate}
/>
)}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> {isDialogOpen && <Dialog open onOpenChange={setIsDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('dialog_add_transaction')}</DialogTitle> <DialogTitle>{t('dialog_add_transaction')}</DialogTitle>
</DialogHeader> </DialogHeader>
<TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} /> <TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>}
<DestructiveConfirmDialog {...confirmDialogProps} /> <DestructiveConfirmDialog {...confirmDialogProps} />
<EditTransactionTitleDialog {editTitleTarget && (
open={editTitleTarget !== null} <EditTransactionTitleDialog
onOpenChange={(v) => { open
if (!v) setEditTitleTarget(null) onOpenChange={(v) => {
}} if (!v) setEditTitleTarget(null)
currentTitle={editTitleTarget?.description ?? ''} }}
originalTitle={editTitleTarget?.original_description ?? null} currentTitle={editTitleTarget.description ?? ''}
onSave={handleSaveTitle} originalTitle={editTitleTarget.original_description ?? null}
/> onSave={handleSaveTitle}
/>
)}
<SkattekontoMatchDialog {skvMatchTarget && (
row={skvMatchTarget} <SkattekontoMatchDialog
open={!!skvMatchTarget} row={skvMatchTarget}
onClose={() => setSkvMatchTarget(null)} open
onMatched={handleSkvMatched} onClose={() => setSkvMatchTarget(null)}
/> onMatched={handleSkvMatched}
/>
)}
{/* Prong B: match-against-supplier-invoice suggestion */} {/* Prong B: match-against-supplier-invoice suggestion */}
<Dialog {siMatchSuggestion && <Dialog
open={siMatchSuggestion !== null} open
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setSiMatchSuggestion(null) if (!open) setSiMatchSuggestion(null)
}} }}
@@ -2645,11 +2685,11 @@ export default function TransactionsPage() {
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>}
{/* Prong B (customer side): match-against-customer-invoice suggestion */} {/* Prong B (customer side): match-against-customer-invoice suggestion */}
<Dialog {ciMatchSuggestion && <Dialog
open={ciMatchSuggestion !== null} open
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setCiMatchSuggestion(null) if (!open) setCiMatchSuggestion(null)
}} }}
@@ -2708,10 +2748,10 @@ export default function TransactionsPage() {
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>}
<DuplicateBookingDialog {duplicateWarning && <DuplicateBookingDialog
candidate={duplicateWarning?.candidate ?? null} candidate={duplicateWarning.candidate}
processing={duplicateProcessing} processing={duplicateProcessing}
onCancel={() => setDuplicateWarning(null)} onCancel={() => setDuplicateWarning(null)}
// Ledger-only candidate (transaction_id null, e.g. a verifikat from an // 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 // voucher instead of double-booking it. Success refreshes the same
// state a MatchVoucherDialog link does. // state a MatchVoucherDialog link does.
matchTransaction={ matchTransaction={
duplicateWarning transactions.find((tx) => tx.id === duplicateWarning.transactionId) ?? {
? transactions.find((tx) => tx.id === duplicateWarning.transactionId) ?? { id: duplicateWarning.transactionId,
id: duplicateWarning.transactionId, }
}
: null
} }
onMatched={(transactionId, journalEntryId, voucherLabel) => { onMatched={(transactionId, journalEntryId, voucherLabel) => {
setDuplicateWarning(null) setDuplicateWarning(null)
@@ -2739,7 +2777,7 @@ export default function TransactionsPage() {
setDuplicateProcessing(false) setDuplicateProcessing(false)
} }
}} }}
/> />}
</div> </div>
) )
+18
View File
@@ -0,0 +1,18 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function OnboardingLoading() {
return (
<div className="w-full space-y-6 rounded-lg border bg-card p-6" aria-busy="true">
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-full" />
</div>
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
<Skeleton className="ml-auto h-10 w-32" />
</div>
)
}
@@ -194,7 +194,7 @@ describe('POST /api/account/password', () => {
) )
expect(status).toBe(400) 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(updateUser).not.toHaveBeenCalled()
expect(flagFlipCall(updateUserById)).toBeUndefined() expect(flagFlipCall(updateUserById)).toBeUndefined()
}) })
@@ -273,7 +273,7 @@ describe('POST /api/account/password', () => {
) )
expect(status).toBe(400) 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(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
expect(flagFlipCall(updateUserById)).toBeUndefined() expect(flagFlipCall(updateUserById)).toBeUndefined()
}) })
+2 -1
View File
@@ -4,6 +4,7 @@ import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth' import { requireAuth } from '@/lib/auth/require-auth'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const log = createLogger('api/account/password') const log = createLogger('api/account/password')
@@ -85,7 +86,7 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ {
error: error:
updateError.message || getUserErrorMessage(updateError) ||
'Kunde inte uppdatera lösenord. Försök igen.', 'Kunde inte uppdatera lösenord. Försök igen.',
}, },
{ status: 400 }, { status: 400 },
+4 -3
View File
@@ -10,6 +10,7 @@ import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
import { guardSandbox } from '@/lib/sandbox/guard' import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability' import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys' 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 // Make sure extensions are loaded: the chat loop dispatches against the
// agent tool registry which is populated by the mcp-server extension at load. // 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()) body = BodySchema.parse(await request.json())
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' }, { error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 }, { status: 400 },
) )
} }
@@ -164,7 +165,7 @@ export async function POST(request: Request) {
.single() .single()
if (convErr || !newConv) { if (convErr || !newConv) {
return NextResponse.json( return NextResponse.json(
{ error: convErr?.message ?? 'Failed to create conversation' }, { error: getUserErrorMessage(convErr) ?? 'Failed to create conversation' },
{ status: 500 }, { status: 500 },
) )
} }
@@ -202,7 +203,7 @@ export async function POST(request: Request) {
{ {
error: error:
err instanceof Error err instanceof Error
? `Capture failed: ${err.message}` ? `Capture failed: ${getUserErrorMessage(err)}`
: 'Capture failed', : 'Capture failed',
}, },
{ status: 500 }, { status: 500 },
+4 -3
View File
@@ -15,6 +15,7 @@ import { preWarmAtomCache } from '@/lib/agent/composer/prewarm'
import { OPUS_MODEL } from '@/lib/agent/composer/client' import { OPUS_MODEL } from '@/lib/agent/composer/client'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch' import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
import type { AtomSelection } from '@/lib/agent/composer/schemas' import type { AtomSelection } from '@/lib/agent/composer/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BodySchema = z.object({ const BodySchema = z.object({
company_id: z.string().uuid().optional(), company_id: z.string().uuid().optional(),
@@ -82,7 +83,7 @@ export async function POST(request: Request) {
body = BodySchema.parse(await request.json().catch(() => ({}))) body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' }, { error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 }, { status: 400 },
) )
} }
@@ -229,7 +230,7 @@ export async function POST(request: Request) {
{ onConflict: 'company_id' }, { onConflict: 'company_id' },
) )
if (upsertErr) { if (upsertErr) {
send({ step: 'error', status: 'error', message: upsertErr.message }) send({ step: 'error', status: 'error', message: getUserErrorMessage(upsertErr) })
return return
} }
@@ -273,7 +274,7 @@ export async function POST(request: Request) {
send({ send({
step: 'error', step: 'error',
status: 'error', status: 'error',
message: err instanceof Error ? err.message : 'Composer pipeline failed', message: err instanceof Error ? getUserErrorMessage(err) : 'Composer pipeline failed',
}) })
} finally { } finally {
try { try {
+6 -5
View File
@@ -8,6 +8,7 @@ import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Article } from '@/types' import type { Article } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized() ensureInitialized()
@@ -32,7 +33,7 @@ export const GET = withRouteContext(
opLog.error('article fetch failed', error) opLog.error('article fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, { return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId, requestId,
details: { reason: error.message }, details: { reason: getUserErrorMessage(error) },
}) })
} }
@@ -97,7 +98,7 @@ export const PATCH = withRouteContext(
opLog.error('article update failed', error) opLog.error('article update failed', error)
return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, { return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, {
requestId, 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) opLog.error('article lookup before delete failed', articleError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, { return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId, requestId,
details: { reason: articleError.message }, details: { reason: getUserErrorMessage(articleError) },
}) })
} }
@@ -149,7 +150,7 @@ export const DELETE = withRouteContext(
opLog.error('article usage check failed', usageError) opLog.error('article usage check failed', usageError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, { return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId, requestId,
details: { reason: usageError.message }, details: { reason: getUserErrorMessage(usageError) },
}) })
} }
@@ -170,7 +171,7 @@ export const DELETE = withRouteContext(
opLog.error('article delete failed', deleteError) opLog.error('article delete failed', deleteError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, { return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId, requestId,
details: { reason: deleteError.message }, details: { reason: getUserErrorMessage(deleteError) },
}) })
} }
+2 -1
View File
@@ -10,6 +10,7 @@ import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookk
import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { Article } from '@/types' import type { Article } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized() ensureInitialized()
@@ -99,7 +100,7 @@ export const POST = withRouteContext(
log.error('article insert failed', error) log.error('article insert failed', error)
return errorResponseFromCode('ARTICLE_CREATE_FAILED', log, { return errorResponseFromCode('ARTICLE_CREATE_FAILED', log, {
requestId, requestId,
details: { reason: error.message }, details: { reason: getUserErrorMessage(error) },
}) })
} }
@@ -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<string, string>) {
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',
})
})
})
+17 -32
View File
@@ -3,7 +3,6 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate' import { validateQuery } from '@/lib/api/validate'
import { AccountBalancesQuerySchema } from '@/lib/api/schemas' import { AccountBalancesQuerySchema } from '@/lib/api/schemas'
import { getOpeningBalances } from '@/lib/reports/opening-balances' 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 * 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 // Sum activity from period_start through as_of, excluding the OB entry
// (its lines are already in openingBalances). // (its lines are already in openingBalances).
let lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }> const { data: activityRows, error: activityError } = await supabase.rpc(
try { 'get_account_period_activity',
lines = await fetchAllRows<{ {
account_number: string p_company_id: companyId,
debit_amount: number p_start: period.period_start,
credit_amount: number p_end: as_of,
}>(({ from, to }) => { p_accounts: accounts,
let query = supabase p_exclude_journal_entry_id: obEntryId,
.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)
if (obEntryId) { if (activityError) {
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) {
log.error('period activity lookup failed', { log.error('period activity lookup failed', {
companyId, companyId,
period_id: period.id, period_id: period.id,
error: err instanceof Error ? err.message : String(err), error: activityError.message,
}) })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
} }
const periodActivity = new Map<string, { debit: number; credit: number }>() const periodActivity = new Map<string, { debit: number; credit: number }>()
for (const line of lines) { for (const row of activityRows ?? []) {
const existing = periodActivity.get(line.account_number) || { debit: 0, credit: 0 } periodActivity.set(row.account_number, {
existing.debit += Number(line.debit_amount) || 0 debit: Number(row.debit) || 0,
existing.credit += Number(line.credit_amount) || 0 credit: Number(row.credit) || 0,
periodActivity.set(line.account_number, existing) })
} }
return NextResponse.json({ return NextResponse.json({
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas' 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 // DELETE hard-deletes an unused, non-system account; accounts referenced by
// this company's journal entries must be deactivated instead (PUT is_active). // this company's journal entries must be deactivated instead (PUT is_active).
@@ -57,7 +58,7 @@ export const DELETE = withRouteContext(
.eq('company_id', companyId) .eq('company_id', companyId)
if (deleteError) { if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 })
} }
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
@@ -95,7 +96,7 @@ export const PUT = withRouteContext(
if (error.code === 'PGRST116') { if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 }) 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 }) return NextResponse.json({ data })
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** /**
* POST /api/bookkeeping/accounts/activate * POST /api/bookkeeping/accounts/activate
@@ -41,7 +42,7 @@ export const POST = withRouteContext(
.in('account_number', uniqueNumbers) .in('account_number', uniqueNumbers)
if (fetchError) { if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
} }
const existingByNumber = new Map<string, boolean>( const existingByNumber = new Map<string, boolean>(
@@ -79,7 +80,7 @@ export const POST = withRouteContext(
.in('account_number', toReactivate) .in('account_number', toReactivate)
.select('account_number') .select('account_number')
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
reactivatedRows = data || [] reactivatedRows = data || []
} }
@@ -91,7 +92,7 @@ export const POST = withRouteContext(
.insert(toInsert) .insert(toInsert)
.select('account_number') .select('account_number')
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
insertedRows = data || [] insertedRows = data || []
} }
+4 -3
View File
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { PruneAccountsSchema } from '@/lib/api/schemas' import { PruneAccountsSchema } from '@/lib/api/schemas'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference' import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all' 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 // POST /api/bookkeeping/accounts/prune — bulk cleanup of unused accounts
// ("Rensa oanvända konton"), for charts bloated by an import from an old // ("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 }, { p_company_id: companyId },
) )
if (usageError) { if (usageError) {
return NextResponse.json({ error: usageError.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(usageError) }, { status: 500 })
} }
const usageByAccount = new Map<string, number>( const usageByAccount = new Map<string, number>(
@@ -134,7 +135,7 @@ export const POST = withRouteContext(
// Report what was already deleted so the UI can refresh honestly. // Report what was already deleted so the UI can refresh honestly.
return NextResponse.json( return NextResponse.json(
{ {
error: deleteError.message, error: getUserErrorMessage(deleteError),
data: { deleted: toDelete.slice(0, i), skipped, not_found: notFound }, data: { deleted: toDelete.slice(0, i), skipped, not_found: notFound },
}, },
{ status: 500 }, { status: 500 },
@@ -153,7 +154,7 @@ export const POST = withRouteContext(
}) })
} catch (error) { } catch (error) {
return NextResponse.json( 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 }, { status: 500 },
) )
} }
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** /**
* GET /api/bookkeeping/accounts/reference * GET /api/bookkeeping/accounts/reference
@@ -33,6 +34,6 @@ export const GET = withRouteContext('bookkeeping.accounts.reference', async (_re
return NextResponse.json({ data: userAccounts }) return NextResponse.json({ data: userAccounts })
} catch (error) { } 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 })
} }
}) })
+3 -2
View File
@@ -4,6 +4,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate' import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas' 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 // Response shapes are legacy `{ data }` / `{ error: string }` — several pages
// (import, supplier-invoices, article form) consume the list directly. // (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 }) return NextResponse.json({ data })
} catch (error) { } catch (error) {
return NextResponse.json( 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 }, { status: 500 },
) )
} }
@@ -93,7 +94,7 @@ export const POST = withRouteContext(
{ status: 409 }, { status: 409 },
) )
} }
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ data }) return NextResponse.json({ data })
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' 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 // GET /api/bookkeeping/accounts/usage — per-account posting counts for the
// active company, from the get_account_usage_counts RPC. Accounts that have // 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) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ data: data ?? [] }) return NextResponse.json({ data: data ?? [] })
@@ -106,6 +106,6 @@ describe('POST /api/bookkeeping/accruals/[id]/dissolve', () => {
expect(status).toBe(400) expect(status).toBe(400)
expect(body.error.code).toBe('ACCRUAL_DISSOLVE_FAILED') 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.')
}) })
}) })
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context' 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 { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { dissolveScheduleNow } from '@/lib/bookkeeping/accruals/service' import { dissolveScheduleNow } from '@/lib/bookkeeping/accruals/service'
import { import {
@@ -39,7 +40,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}) })
return NextResponse.json({ data: result }) return NextResponse.json({ data: result })
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : 'unknown'
// Typed domain errors carry a stable code: never match Swedish prose. // Typed domain errors carry a stable code: never match Swedish prose.
if (isAccrualError(err)) { if (isAccrualError(err)) {
switch (err.code) { 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 }) log.error('accrual dissolve failed', err as Error, { entityId: id })
return errorResponseFromCode('ACCRUAL_DISSOLVE_FAILED', log, { return errorResponseFromCode('ACCRUAL_DISSOLVE_FAILED', log, {
requestId, requestId,
details: { reason }, details: { reason: getErrorMessage(err) },
}) })
} }
}, },
@@ -5,6 +5,7 @@ import { createServiceClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { postDueInstallments } from '@/lib/bookkeeping/accruals/service' import { postDueInstallments } from '@/lib/bookkeeping/accruals/service'
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute' import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized() ensureInitialized()
@@ -42,7 +43,7 @@ export const GET = withCronContext('cron.accrual_postings', async (_request, ctx
} catch (error) { } catch (error) {
ctx.log.error('failed to load due accrual installments', error as Error) ctx.log.error('failed to load due accrual installments', error as Error)
return NextResponse.json( return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'unknown' }, { success: false, error: error instanceof Error ? getUserErrorMessage(error) : 'unknown' },
{ status: 500 }, { status: 500 },
) )
} }
@@ -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) }),
)
})
})
@@ -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<boolean> {
const { data } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
return Boolean(data)
}
function responseData(model: Awaited<ReturnType<typeof buildCanonicalAnnualReport>>) {
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 },
)
@@ -2,14 +2,17 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input' import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document' 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 * GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl
* *
* Generates the iXBRL (XHTML) årsredovisning for the period. The document IS * Generates the iXBRL (XHTML) årsredovisning for the period. The document IS
* the presentation (per TILLAMPNINGSANVISNING): the wizard renders it in an * the presentation (per TILLAMPNINGSANVISNING): the wizard renders it in an
* iframe as the authoritative preview, and `?download=1` hands the same bytes * iframe as the authoritative preview, and `?download=1` exports the same
* to the user for manual filing at bolagsverket.se (the self-hosted path). * 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: * Query params:
* - download=1 Content-Disposition: attachment * - download=1 Content-Disposition: attachment
@@ -23,12 +26,19 @@ export const GET = withRouteContext(
try { try {
const url = new URL(request.url) const url = new URL(request.url)
const download = url.searchParams.get('download') === '1' const download = url.searchParams.get('download') === '1'
const versionId = url.searchParams.get('version')
const utdelningRaw = url.searchParams.get('utdelning') 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, { const input = versionId
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0, ? 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 { xhtml, warnings } = generateK2IxbrlDocument(input)
const safePeriodEnd = input.period.end.replace(/[^\w.-]/g, '_') const safePeriodEnd = input.period.end.replace(/[^\w.-]/g, '_')
@@ -43,6 +53,7 @@ export const GET = withRouteContext(
Pragma: 'no-cache', Pragma: 'no-cache',
// Generation warnings surfaced without disturbing the body. // Generation warnings surfaced without disturbing the body.
'X-Ixbrl-Warning-Count': String(warnings.length), 'X-Ixbrl-Warning-Count': String(warnings.length),
...(versionId ? { 'X-Annual-Report-Version': versionId } : {}),
}, },
}) })
} catch (err) { } catch (err) {
@@ -4,6 +4,8 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input' import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document' import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks, type PreflightIssue } from '@/lib/bokslut/ixbrl/validate/rules' 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 * GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl/validate
@@ -21,12 +23,19 @@ export const GET = withRouteContext(
const { supabase, companyId, log, requestId } = ctx const { supabase, companyId, log, requestId } = ctx
try { try {
const url = new URL(request.url) const url = new URL(request.url)
const versionId = url.searchParams.get('version')
const utdelningRaw = url.searchParams.get('utdelning') 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, { const input = versionId
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0, ? 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) const result = runPreflightChecks(input)
// Generation dry-run: a document that cannot even be generated must // Generation dry-run: a document that cannot even be generated must
@@ -47,7 +56,7 @@ export const GET = withRouteContext(
issues.push({ issues.push({
code: 'ACC-GEN', code: 'ACC-GEN',
severity: 'error', 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, generated_bytes: generatedBytes,
entry_point: input.entryPointId, entry_point: input.entryPointId,
period: input.period, period: input.period,
annual_report_version_id: versionId,
}, },
}) })
} catch (err) { } catch (err) {
@@ -121,6 +121,15 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
expect(res.status).toBe(400) 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 () => { it('returns 404 when the period does not belong to the company', async () => {
const { enqueue } = setupSupabase() const { enqueue } = setupSupabase()
enqueue({ data: null }) // fiscal_periods ownership check 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: { id: 'period-1' } }) // fiscal_periods ownership check
enqueue({ data: null }) // no registrerad submission enqueue({ data: null }) // no registrerad submission
enqueue({ data: narrativeRow }) // upsert enqueue({ data: narrativeRow }) // upsert
enqueue({ data: null }) // clear narrative confirmation
const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>( const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>(
await POST(postReq({ description: 'Bolaget bedriver konsultverksamhet.' }), idParams), await POST(postReq({ description: 'Bolaget bedriver konsultverksamhet.' }), idParams),
) )
@@ -30,6 +30,15 @@ const PostSchema = z.object({
description: sanitizedText(4000).nullable().optional(), description: sanitizedText(4000).nullable().optional(),
important_events: sanitizedText(4000).nullable().optional(), important_events: sanitizedText(4000).nullable().optional(),
resultatdisposition: sanitizedText(2000).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 // 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 // real calendar date (not just regex) so '2024-13-99' returns 400 from
// the API instead of bubbling up as a Postgres 500. // the API instead of bubbling up as a Postgres 500.
@@ -75,6 +84,26 @@ const PostSchema = z.object({
.nullable() .nullable()
.optional(), .optional(),
parent_company_city: sanitizedText(100).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( export const GET = withRouteContext(
@@ -150,6 +179,14 @@ export const POST = withRouteContext(
return errorResponseFromCode('ARSREDOVISNING_REGISTERED', log, { requestId }) return errorResponseFromCode('ARSREDOVISNING_REGISTERED', log, { requestId })
} }
const data = await upsertNarrative(supabase, companyId, user.id, id, validation.data) 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 }) return NextResponse.json({ data })
} catch (err) { } catch (err) {
return errorResponse(err, log, { requestId }) return errorResponse(err, log, { requestId })
@@ -1,20 +1,53 @@
import { renderToBuffer } from '@react-pdf/renderer' import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' 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 { ArsredovisningPDF } from '@/lib/bokslut/arsredovisning/arsredovisning-pdf'
import { ArsredovisningK3PDF } from '@/lib/bokslut/arsredovisning/arsredovisning-k3-pdf' import { ArsredovisningK3PDF } from '@/lib/bokslut/arsredovisning/arsredovisning-k3-pdf'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
export const GET = withRouteContext( export const GET = withRouteContext(
'period.arsredovisning_pdf', 'period.arsredovisning_pdf',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params const { id } = await params
const { supabase, companyId, log, requestId } = ctx const { supabase, companyId, log, requestId } = ctx
try { try {
// Narrative edits come from arsredovisning_narratives now, loaded // Narrative edits come from arsredovisning_narratives now, loaded
// inside buildArsredovisningData. The URL stays clean, no narrative // inside buildArsredovisningData. The URL stays clean, no narrative
// text in query params, access logs, or browser history. // 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 // Dispatch on the framework recorded in the data. K3 documents need
// the additional kassaflöde + equity-changes pages + richer noter // the additional kassaflöde + equity-changes pages + richer noter
// that ArsredovisningK3PDF renders. K2 (the default) keeps the // 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 // Sanitize the dynamic segment so a stray quote / newline in the date
// (defensive, unlikely to ever happen) can't break the header. // (defensive, unlikely to ever happen) can't break the header.
const safePeriodEnd = data.fiscal_period.period_end.replace(/[^\w.-]/g, '_') 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), { return new Response(new Uint8Array(pdfBuffer), {
headers: { headers: {
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
@@ -38,6 +74,7 @@ export const GET = withRouteContext(
// intermediary cache the document. // intermediary cache the document.
'Cache-Control': 'private, no-store, no-cache, must-revalidate', 'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
...(versionId ? { 'X-Annual-Report-Version': versionId } : {}),
}, },
}) })
} catch (err) { } catch (err) {
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' 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( export const GET = withRouteContext(
'period.arsredovisning_data', 'period.arsredovisning_data',
@@ -9,8 +10,23 @@ export const GET = withRouteContext(
const { id } = await params const { id } = await params
const { supabase, companyId, log, requestId } = ctx const { supabase, companyId, log, requestId } = ctx
try { try {
const data = await buildArsredovisningData(supabase, companyId, id) const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
return NextResponse.json({ data }) 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) { } catch (err) {
const message = err instanceof Error ? err.message : '' const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) { if (/not found/i.test(message)) {
@@ -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)
})
})
@@ -1,8 +1,10 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context' 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 { 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- // PATCH transitions: pending → signed (manual entry for the paper / outside-
// BankID flow) or pending → declined. Real BankID wiring lands in a future // 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 // /periods/A/signatures/SIG_FROM_B can't bypass the path scope
// - .eq('status', 'pending'): state-machine guard so a signed or declined // - .eq('status', 'pending'): state-machine guard so a signed or declined
// row can't be flipped back // row can't be flipped back
const PatchSchema = z.object({ const EvidenceReferenceSchema = z
status: z.enum(['signed', 'declined']), .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( export const PATCH = withRouteContext(
'period.arsredovisning_signature_patch', 'period.arsredovisning_signature_patch',
@@ -30,15 +62,65 @@ export const PATCH = withRouteContext(
const validation = await validateBody(request, PatchSchema) const validation = await validateBody(request, PatchSchema)
if (!validation.success) return validation.response 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 { 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') .from('arsredovisning_signature_requests')
.update(update) .update({ status: 'declined' as const })
.eq('id', signatureId) .eq('id', signatureId)
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId) .eq('fiscal_period_id', fiscalPeriodId)
@@ -65,3 +147,38 @@ export const PATCH = withRouteContext(
}, },
{ requireWrite: true }, { 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 },
)
@@ -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 })
})
})
@@ -14,7 +14,7 @@ import {
// (Art.25.2): Swedish names are well under that; bound is a defense. // (Art.25.2): Swedish names are well under that; bound is a defense.
const CreateSchema = z.object({ const CreateSchema = z.object({
role: z.enum(['Styrelseledamot', 'Styrelseordförande', 'VD', 'Verkställande direktör']), 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( export const GET = withRouteContext(
@@ -43,15 +43,35 @@ export const POST = withRouteContext(
// authenticated company before writing. RLS would catch a cross-tenant // authenticated company before writing. RLS would catch a cross-tenant
// insert anyway, but rejecting at the route layer gives a cleaner // insert anyway, but rejecting at the route layer gives a cleaner
// 404 + avoids the noisy RLS error in the structured-error envelope. // 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') .from('fiscal_periods')
.select('id') .select('id')
.eq('id', id) .eq('id', id)
.eq('company_id', companyId) .eq('company_id', companyId)
.maybeSingle() .maybeSingle()
if (periodError) throw new Error(`Failed to load fiscal period: ${periodError.message}`)
if (!period) { if (!period) {
return NextResponse.json({ error: { code: 'PERIOD_NOT_FOUND' } }, { status: 404 }) 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) const data = await createSignatureRequest(supabase, companyId, user.id, id, validation.data)
return NextResponse.json({ data }) return NextResponse.json({ data })
} catch (err) { } catch (err) {
@@ -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<typeof version> }>(
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' }),
}),
)
})
})
@@ -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<boolean> {
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 },
)
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' 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 { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildBokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator' import { buildBokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
@@ -27,7 +28,7 @@ export const GET = withRouteContext(
} }
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, { return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, {
requestId, requestId,
details: { reason: message }, details: { reason: getErrorMessage(err) },
}) })
} }
}, },
@@ -8,6 +8,37 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers' 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() const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({ vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args), requireAuth: (...args: unknown[]) => requireAuthMock(...args),
@@ -23,7 +54,20 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), 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' }) } 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(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null }) requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
requireWriteMock.mockResolvedValue({ ok: true }) 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<ReturnType<typeof generateIncomeStatement>>)
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<typeof createJournalEntry>
>)
})
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', () => { 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: [] })) const { status } = await parseJsonResponse(await post({ items: [] }))
expect(status).toBe(400) 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()
})
}) })
@@ -4,10 +4,16 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { createJournalEntry } from '@/lib/bookkeeping/engine' import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
import { import {
calculateBolagsskatt, calculateBolagsskatt,
getBookedBolagsskatt,
sumPostedYearEndDispositions, sumPostedYearEndDispositions,
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator' } 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 { calculateSarskildLoneskatt } from '@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator'
import { import {
getPeriodiseringsfondCohortAccount, 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 // POST: commit a list of dispositions chosen by the user
// ============================================================ // ============================================================
const ItemSchema = z.discriminatedUnion('kind', [ const ItemSchema = z.discriminatedUnion('kind', [
z.object({ z.object({
kind: z.literal('bolagsskatt'), kind: z.literal('bolagsskatt'),
manualAdjustments: z }).strict(),
.object({
nonDeductibleExpenses: z.number().optional(),
nonTaxableIncome: z.number().optional(),
schablonintaktPeriodiseringsfond: z.number().optional(),
other: z.number().optional(),
})
.optional(),
}),
z.object({ z.object({
kind: z.literal('sarskild_loneskatt'), kind: z.literal('sarskild_loneskatt'),
manualAdjustment: z.number().optional(), manualAdjustment: z.number().optional(),
@@ -181,6 +234,7 @@ export const POST = withRouteContext(
entry_date: period.period_end, entry_date: period.period_end,
description: `Bokslutsdisposition: ${proposal.label}`, description: `Bokslutsdisposition: ${proposal.label}`,
source_type: 'year_end', source_type: 'year_end',
source_id: item.kind === 'bolagsskatt' ? id : undefined,
voucher_series: 'A', voucher_series: 'A',
lines: proposal.lines, lines: proposal.lines,
}) })
@@ -189,6 +243,30 @@ export const POST = withRouteContext(
return NextResponse.json({ data: { created } }) return NextResponse.json({ data: { created } })
} catch (err) { } 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) opLog.error('bokslutsdispositioner post failed', err as Error)
return errorResponse(err, opLog, { requestId }) return errorResponse(err, opLog, { requestId })
} }
@@ -208,6 +286,15 @@ interface ValidatedPeriod {
opening_balance_entry_id: string | null 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( async function computeProposal(
item: PostItem, item: PostItem,
supabase: Parameters<typeof calculateBolagsskatt>[0], supabase: Parameters<typeof calculateBolagsskatt>[0],
@@ -224,16 +311,42 @@ async function computeProposal(
// SLP , överavskrivningar ); bolagsskatt is sorted LAST so they are // SLP , överavskrivningar ); bolagsskatt is sorted LAST so they are
// committed by now. Without this the booked tax ignores the avsättning // committed by now. Without this the booked tax ignores the avsättning
// (the original customer bug, too-high tax, ÅR/INK2 mismatch). // (the original customer bug, too-high tax, ÅR/INK2 mismatch).
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId) const [incomeStatement, dispositionsEffect, taxAdjustments, bookedTax, existingFonder] =
const dispositionsEffect = await sumPostedYearEndDispositions( await Promise.all([
supabase, generateIncomeStatement(supabase, companyId, fiscalPeriodId),
companyId, sumPostedYearEndDispositions(supabase, companyId, fiscalPeriodId),
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, { const manuallyBookedTax = Math.max(
resultBeforeTaxOverride: incomeStatement.net_result + dispositionsEffect.total, 0,
manualAdjustments: item.manualAdjustments, 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': { case 'sarskild_loneskatt': {
// Already posted in this period (resumed run / duplicate POST): the // Already posted in this period (resumed run / duplicate POST): the
@@ -278,6 +391,10 @@ async function computeProposal(
const alreadyProvisioned = currentCohort const alreadyProvisioned = currentCohort
? Math.max(0, currentCohort.balance - Math.max(0, currentCohort.opening_balance)) ? Math.max(0, currentCohort.balance - Math.max(0, currentCohort.opening_balance))
: 0 : 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- // Dispositions posted earlier in this batch (återföring, över-
// avskrivningar, SLP: all sorted before avsättning) are year_end-typed // avskrivningar, SLP: all sorted before avsättning) are year_end-typed
// and thus invisible in net_result, yet they move the cap base. Add // and thus invisible in net_result, yet they move the cap base. Add
@@ -289,9 +406,15 @@ async function computeProposal(
companyId, companyId,
fiscalPeriodId, fiscalPeriodId,
) )
const taxAdjustments = await loadTaxAdjustmentSnapshot(
supabase,
companyId,
fiscalPeriodId,
)
const base = const base =
incomeStatement.net_result + postedEffect.total + alreadyProvisioned incomeStatement.net_result + postedEffect.total + alreadyProvisioned
+ Math.round(schablonintakt) + Math.round(schablonintakt)
+ taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
return proposeAvsattning({ return proposeAvsattning({
skattemassigtResultatBeforeAvsattning: base, skattemassigtResultatBeforeAvsattning: base,
desiredAmount: item.desiredAmount, desiredAmount: item.desiredAmount,
@@ -57,13 +57,13 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/close', () => {
expect(mockClosePeriod).not.toHaveBeenCalled() 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')) mockClosePeriod.mockRejectedValue(new Error('Period contains draft entries'))
const { status, body } = await parseJsonResponse<{ error: string }>( const { status, body } = await parseJsonResponse<{ error: string }>(
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams) await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
) )
expect(status).toBe(400) 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 () => { it('closes the period on the happy path', async () => {
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { closePeriod } from '@/lib/core/bookkeeping/period-service' 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. // Response shapes are legacy `{ error: string }` — kept for the year-end UI.
// closePeriod throws plain Errors for every refusal (period not found, drafts // closePeriod throws plain Errors for every refusal (period not found, drafts
@@ -16,7 +17,7 @@ export const POST = withRouteContext(
return NextResponse.json({ data: period }) return NextResponse.json({ data: period })
} catch (err) { } catch (err) {
return NextResponse.json( 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 } { status: 400 }
) )
} }
@@ -5,6 +5,7 @@ import {
} from '@/lib/bookkeeping/currency-revaluation' } from '@/lib/bookkeeping/currency-revaluation'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' 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. */ /** GET: preview currency revaluation for a fiscal period. */
export const GET = withRouteContext( export const GET = withRouteContext(
@@ -76,7 +77,7 @@ export const POST = withRouteContext(
if (fallback.status === 500) { if (fallback.status === 500) {
return errorResponseFromCode('FX_FAILED', opLog, { return errorResponseFromCode('FX_FAILED', opLog, {
requestId, requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' }, details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
}) })
} }
return fallback return fallback
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext( export const GET = withRouteContext(
'period.entry_count', 'period.entry_count',
@@ -26,7 +27,7 @@ export const GET = withRouteContext(
.in('status', ['posted', 'reversed']) .in('status', ['posted', 'reversed'])
if (countError) { 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 } }) return NextResponse.json({ data: { posted_count: count ?? 0 } })
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service' import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
import { withRouteContext } from '@/lib/api/with-route-context' 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 { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
export const POST = withRouteContext( export const POST = withRouteContext(
@@ -27,7 +28,7 @@ export const POST = withRouteContext(
if (/draft/i.test(message)) { if (/draft/i.test(message)) {
return errorResponseFromCode('PERIOD_LOCK_HAS_DRAFTS', opLog, { return errorResponseFromCode('PERIOD_LOCK_HAS_DRAFTS', opLog, {
requestId, requestId,
details: { reason: message }, details: { reason: getErrorMessage(err) },
}) })
} }
// lockPeriod() refuses to lock a period that still has uncategorized // 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)) { if (/saknar bokföring|okategoriserade affärstransaktion/i.test(message)) {
return errorResponseFromCode('PERIOD_HAS_UNBOOKED_TRANSACTIONS', opLog, { return errorResponseFromCode('PERIOD_HAS_UNBOOKED_TRANSACTIONS', opLog, {
requestId, requestId,
details: { reason: message }, details: { reason: getErrorMessage(err) },
}) })
} }
return errorResponse(err, opLog, { requestId }) return errorResponse(err, opLog, { requestId })
@@ -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',
)
})
})
@@ -44,7 +44,6 @@ export const POST = withRouteContext(
const result = await executeYearEndClosing(supabase, companyId!, user.id, id) const result = await executeYearEndClosing(supabase, companyId!, user.id, id)
return NextResponse.json({ data: result }) return NextResponse.json({ data: result })
} catch (err) { } catch (err) {
opLog.error('year-end execution failed', err as Error)
const message = err instanceof Error ? err.message : '' const message = err instanceof Error ? err.message : ''
// The downstream errors below are matched on stable English keywords // The downstream errors below are matched on stable English keywords
// emitted by year-end-service. Do NOT include the raw message in // 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)) { if (/Next fiscal period already has opening balance/i.test(message)) {
return errorResponseFromCode('YEAR_END_NEXT_PERIOD_HAS_IB', opLog, { requestId }) 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)) { if (/prior.*open/i.test(message)) {
return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, { requestId }) return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, { requestId })
} }
@@ -62,12 +67,10 @@ export const POST = withRouteContext(
if (/not found/i.test(message)) { if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId }) return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
} }
// Fall through bookkeeping/Zod/etc to errorResponse, but cap to YEAR_END_FAILED. // Unknown failures stay 5xx and retain the original Error object and
const fallback = errorResponse(err, opLog, { requestId }) // stack in the log. Expected domain outcomes above are structured 4xx
if (fallback.status === 500) { // warnings, so Vercel runtime-error clusters remain actionable.
return errorResponseFromCode('YEAR_END_FAILED', opLog, { requestId }) return errorResponse(err, opLog, { requestId })
}
return fallback
} }
}, },
{ requireWrite: true }, { requireWrite: true },
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' 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 * 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: { error: {
code: 'PERIOD_STATUS_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 } { status: 500 }
+3 -2
View File
@@ -4,6 +4,7 @@ import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-durati
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas' import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error' 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 // Response shapes are legacy `{ error: string }` (plus one envelope code for
// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI. // 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 }) .order('period_start', { ascending: false })
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ data }) return NextResponse.json({ data })
@@ -202,7 +203,7 @@ export const POST = withRouteContext(
.single() .single()
if (error) { 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: // Keep the continuity chain intact for the period that now follows the new one:
@@ -32,6 +32,7 @@ import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types' import type { Invoice } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized() ensureInitialized()
@@ -237,7 +238,7 @@ export const POST = withRouteContext(
payment_id: t.payment_id, payment_id: t.payment_id,
ok: false, ok: false,
old_journal_entry_id: t.payment_journal_entry_id, 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',
}) })
} }
} }
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod' import { z } from 'zod'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SetNoDocSchema = z.object({ const SetNoDocSchema = z.object({
reason: z.string().trim().max(200).nullable().optional(), reason: z.string().trim().max(200).nullable().optional(),
@@ -39,7 +40,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
) )
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 400 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
} }
return NextResponse.json({ data: { exempted: true } }) return NextResponse.json({ data: { exempted: true } })
@@ -64,7 +65,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId) .eq('company_id', companyId)
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 400 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
} }
return NextResponse.json({ data: { exempted: false } }) return NextResponse.json({ data: { exempted: false } })
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod' import { z } from 'zod'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const UpdateNotesSchema = z.object({ const UpdateNotesSchema = z.object({
notes: z.string().max(2000).nullable(), notes: z.string().max(2000).nullable(),
@@ -27,7 +28,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
.maybeSingle() .maybeSingle()
if (error) { 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 // Zero rows = the entry doesn't exist in this company — report it instead
// of a phantom success. // of a phantom success.
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { RetagLineDimensionsSchema } from '@/lib/api/schemas' import { RetagLineDimensionsSchema } from '@/lib/api/schemas'
import { getErrorMessage } from '@/lib/errors/get-error-message'
/** /**
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag * 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. // Anything else is unexpected infrastructure failure → 500 + log.
const message = error.message ?? 'Kunde inte ändra dimensioner' const message = error.message ?? 'Kunde inte ändra dimensioner'
if (error.code === 'P0001') { if (error.code === 'P0001') {
return NextResponse.json({ error: message }, { status: 409 }) return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
} }
if (error.code === '42501') { 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 }) log.error('retag_line_dimensions failed', new Error(message), { lineId })
return NextResponse.json({ error: 'Kunde inte ändra dimensioner' }, { status: 500 }) return NextResponse.json({ error: 'Kunde inte ändra dimensioner' }, { status: 500 })
@@ -4,6 +4,7 @@ import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas' import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
import type { Transaction } from '@/types' 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) => { export const POST = withRouteContext('mapping_rules.evaluate', async (request, ctx) => {
const { supabase, companyId, log } = 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 }) return NextResponse.json({ data: result })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Evaluation failed' }, { error: err instanceof Error ? getUserErrorMessage(err) : 'Evaluation failed' },
{ status: 500 } { status: 500 }
) )
} }
+3 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas' 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) => { export const GET = withRouteContext('mapping_rules.list', async (_request, ctx) => {
const { supabase, companyId } = ctx const { supabase, companyId } = ctx
@@ -14,7 +15,7 @@ export const GET = withRouteContext('mapping_rules.list', async (_request, ctx)
.order('priority') .order('priority')
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ data }) return NextResponse.json({ data })
@@ -59,7 +60,7 @@ export const POST = withRouteContext(
.single() .single()
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ data }) return NextResponse.json({ data })
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required' import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories' import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BatchNoDocSchema = z.object({ const BatchNoDocSchema = z.object({
journal_entry_ids: z.array(z.string().uuid()).min(1).max(500), journal_entry_ids: z.array(z.string().uuid()).min(1).max(500),
@@ -40,7 +41,7 @@ export const POST = withRouteContext(
.in('id', chunk) .in('id', chunk)
if (error) { 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)) ownedIds.push(...(data ?? []).map((r) => r.id))
} }
@@ -6,6 +6,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required' import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories' import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' 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 // 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 // (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), .in('journal_entry_id', chunk),
]) ])
if (docRes.error) { if (docRes.error) {
return NextResponse.json({ error: docRes.error.message }, { status: 400 }) return NextResponse.json({ error: getUserErrorMessage(docRes.error) }, { status: 400 })
} }
if (exemptRes.error) { 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 }[]) { for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
withDoc.add(r.journal_entry_id) withDoc.add(r.journal_entry_id)
+4 -3
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate' import { validateBody, validateQuery } from '@/lib/api/validate'
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas' 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 // Voucher gap detection + explanations (BFNAR 2013:2 — gaps in voucher
// sequences must be documented). Response shapes are legacy `{ data }` / // 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 const { data: seriesRows, error: seriesError } = await seriesQuery
if (seriesError) { if (seriesError) {
log.error('voucher series lookup failed', 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) { 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. // render "no gaps" on a compliance view when the check didn't run.
if (gapsError) { if (gapsError) {
log.error('detect_voucher_gaps failed', gapsError, { series: row.voucher_series }) 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 }>) { for (const gap of (gaps ?? []) as Array<{ gap_start: number; gap_end: number }>) {
@@ -148,7 +149,7 @@ export const POST = withRouteContext(
{ status: 403 } { status: 403 }
) )
} }
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ data }) return NextResponse.json({ data })
+5 -4
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate' 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 // Only the two content toggles are user-settable. Strict: the previous
// implementation passed the raw JSON body into .update(), which would have // 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') { if (error && error.code !== 'PGRST116') {
// PGRST116 = no rows returned, which is fine // 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) { if (feed) {
@@ -90,7 +91,7 @@ export const POST = withRouteContext(
.single() .single()
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ return NextResponse.json({
@@ -123,7 +124,7 @@ export const PUT = withRouteContext(
.single() .single()
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ return NextResponse.json({
@@ -155,7 +156,7 @@ export const DELETE = withRouteContext(
.single() .single()
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ return NextResponse.json({
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth' import { requireAuth } from '@/lib/auth/require-auth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' 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 * GET /api/company/check-org-number?org_number=XXXXXXXXXX
@@ -48,7 +49,7 @@ export async function GET(request: Request) {
.is('archived_at', null) .is('archived_at', null)
if (error) { 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 }) => ({ const companies = (data ?? []).map((c: { id: string; name: string }) => ({
+2 -1
View File
@@ -7,6 +7,7 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const log = createLogger('api/company/current') const log = createLogger('api/company/current')
@@ -124,7 +125,7 @@ export const PATCH = withRouteContext(
.single() .single()
if (error) { 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) // When opting in to K3, ensure the two latent-tax (uppskjuten skatt)
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth' import { requireAuth } from '@/lib/auth/require-auth'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** /**
* GET /api/company?owned=true&archived=false * GET /api/company?owned=true&archived=false
@@ -32,7 +33,7 @@ export async function GET(request: Request) {
const { data, error } = await query const { data, error } = await query
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
const companies = (data ?? []).map((row) => { const companies = (data ?? []).map((row) => {
+4 -3
View File
@@ -5,6 +5,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number' import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext( export const GET = withRouteContext(
'customer.get', 'customer.get',
@@ -27,7 +28,7 @@ export const GET = withRouteContext(
opLog.error('customer fetch failed', error) opLog.error('customer fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, { return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId, requestId,
details: { reason: error.message }, details: { reason: getUserErrorMessage(error) },
}) })
} }
@@ -120,7 +121,7 @@ export const PATCH = withRouteContext(
opLog.error('customer update failed', error) opLog.error('customer update failed', error)
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, {
requestId, requestId,
details: { reason: error.message }, details: { reason: getUserErrorMessage(error) },
}) })
} }
@@ -181,7 +182,7 @@ export const DELETE = withRouteContext(
opLog.error('customer delete failed', error) opLog.error('customer delete failed', error)
return errorResponseFromCode('CUSTOMER_DELETE_FAILED', opLog, { return errorResponseFromCode('CUSTOMER_DELETE_FAILED', opLog, {
requestId, requestId,
details: { reason: error.message }, details: { reason: getUserErrorMessage(error) },
}) })
} }
+2 -1
View File
@@ -8,6 +8,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types' import type { Customer } from '@/types'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number' import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized() ensureInitialized()
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
log.error('customer insert failed', error) log.error('customer insert failed', error)
return errorResponseFromCode('CUSTOMER_CREATE_FAILED', log, { return errorResponseFromCode('CUSTOMER_CREATE_FAILED', log, {
requestId, requestId,
details: { reason: error.message }, details: { reason: getUserErrorMessage(error) },
}) })
} }
+3 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** /**
* POST /api/deadlines/[id]/complete * POST /api/deadlines/[id]/complete
@@ -23,7 +24,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (fetchError.code === 'PGRST116') { if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) 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 // Toggle completion
@@ -40,7 +41,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.single() .single()
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
} }
return NextResponse.json({ data }) return NextResponse.json({ data })

Some files were not shown because too many files have changed in this diff Show More