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
+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'
import { Suspense, useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
@@ -13,18 +14,23 @@ import { Loader2, Mail, ArrowLeft, KeyRound, ExternalLink } from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
const branding = getBranding()
import type { BankIdResult } from '@/components/auth/BankIdAuth'
const BankIdAuth = dynamic(
() => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth),
{ ssr: false },
)
// Wrapping in Suspense is required because useSearchParams() forces
// dynamic rendering in Next.js 16; static prerender bails out otherwise.
export default function LoginPage() {
return (
<Suspense fallback={null}>
<Suspense fallback={<AuthPageSkeleton />}>
<LoginPageContent />
</Suspense>
)
@@ -157,7 +163,7 @@ function LoginPageContent() {
if (error) {
toast({
title: tAuth('login_failed_title'),
description: error.message === 'Invalid login credentials'
description: getErrorMessage(error) === 'Invalid login credentials'
? tAuth('login_invalid_credentials')
: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
+2 -1
View File
@@ -11,6 +11,7 @@ import { Loader2, ShieldCheck, Copy, Check, ArrowLeft } from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { userHasPassword } from '@/lib/auth/has-password'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export default function MfaEnrollPage() {
return (
@@ -83,7 +84,7 @@ function MfaEnrollContent() {
if (error) {
toast({
title: 'Kunde inte aktivera 2FA',
description: error.message,
description: getUserErrorMessage(error),
variant: 'destructive',
})
setIsEnrolling(false)
+8 -6
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
@@ -13,20 +14,21 @@ import { Loader2, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
const branding = getBranding()
const BankIdAuth = dynamic(
() => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth),
{ ssr: false },
)
export default function RegisterPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
}>
<Suspense fallback={<AuthPageSkeleton />}>
<RegisterPageContent />
</Suspense>
)
+36 -4
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -21,7 +22,6 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import ArticleForm from '@/components/articles/ArticleForm'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import {
useSubmitWithAccountActivation,
@@ -37,6 +37,19 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Article, ArticleType, CreateArticleInput } from '@/types'
const ArticleForm = dynamic(
() => import('@/components/articles/ArticleForm'),
{
loading: () => (
<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> = {
vara: 'type_vara',
tjanst: 'type_tjanst',
@@ -53,6 +66,7 @@ const SORTABLE_COLUMNS: ReadonlyArray<SortColumn> = [
'price_excl_vat',
'vat_rate',
]
const INITIAL_VISIBLE_ROWS = 100
function compareStrings(a: string, b: string): number {
return a.localeCompare(b, 'sv', { sensitivity: 'base' })
@@ -64,11 +78,13 @@ function ArticlesPageInner() {
const [articles, setArticles] = useState<Article[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('articles')
const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale
const router = useRouter()
@@ -85,6 +101,7 @@ function ArticlesPageInner() {
const updateSort = useCallback(
(column: SortColumn) => {
setVisibleCount(INITIAL_VISIBLE_ROWS)
const params = new URLSearchParams(searchParams.toString())
let nextDir: SortDir = 'asc'
if (column === sortColumn) {
@@ -208,6 +225,7 @@ function ArticlesPageInner() {
})
return arr
}, [filteredArticles, sortColumn, sortDir])
const visibleArticles = sortedArticles.slice(0, visibleCount)
function SortableHeader({
column,
@@ -281,7 +299,10 @@ function ArticlesPageInner() {
<Input
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="pl-10"
/>
</div>
@@ -358,7 +379,7 @@ function ArticlesPageInner() {
</TableRow>
</TableHeader>
<TableBody>
{sortedArticles.map((article) => (
{visibleArticles.map((article) => (
<TableRow
key={article.id}
className="cursor-pointer"
@@ -400,7 +421,7 @@ function ArticlesPageInner() {
{/* Mobile card list */}
<div className="grid gap-4 md:hidden">
{sortedArticles.map((article) => (
{visibleArticles.map((article) => (
<Link key={article.id} href={`/articles/${article.id}`}>
<Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group">
<CardHeader className="pb-3">
@@ -430,6 +451,17 @@ function ArticlesPageInner() {
</Link>
))}
</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'
import { useState, useEffect, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
import { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import NewJournalEntryDialog, { type CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
import type { CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { useToast } from '@/components/ui/use-toast'
import { Plus } from 'lucide-react'
@@ -14,6 +16,11 @@ import { PageHeader } from '@/components/ui/page-header'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry, JournalEntryLine } from '@/types'
const NewJournalEntryDialog = dynamic(
() => import('@/components/bookkeeping/NewJournalEntryDialog'),
{ loading: DialogLoadingSkeleton },
)
interface NextVoucher {
next: number
series: string
@@ -153,20 +160,22 @@ export default function BookkeepingPage() {
<JournalEntryList key={refreshKey} />
<NewJournalEntryDialog
open={showNewEntry}
onOpenChange={(o) => {
setShowNewEntry(o)
if (!o) setCopyPrefill(null)
}}
onCreated={() => {
setRefreshKey((k) => k + 1)
setShowNewEntry(false)
setCopyPrefill(null)
}}
copyPrefill={copyPrefill}
isLoading={isLoadingCopy}
/>
{showNewEntry && (
<NewJournalEntryDialog
open
onOpenChange={(o) => {
setShowNewEntry(o)
if (!o) setCopyPrefill(null)
}}
onCreated={() => {
setRefreshKey((k) => k + 1)
setShowNewEntry(false)
setCopyPrefill(null)
}}
copyPrefill={copyPrefill}
isLoading={isLoadingCopy}
/>
)}
</div>
)
}
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -10,21 +11,29 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { PageHeader } from '@/components/ui/page-header'
import { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2 } from 'lucide-react'
import { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2, Trash2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { DigitalInlamning, INLAMNING_COMING_SOON } from '@/components/bokslut/DigitalInlamning'
import { AnnualReportStudio } from '@/components/bokslut/AnnualReportStudio'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
import type { SignatureRequest } from '@/lib/bokslut/arsredovisning/signature-service'
import type { AnnualReportVersionSummary } from '@/lib/bokslut/arsredovisning/compliance-types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SIGNATURE_EVIDENCE_REFERENCE_PATTERN =
/^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$/
export default function ArsredovisningPage() {
const router = useRouter()
const searchParams = useSearchParams()
const periodId = searchParams.get('period')
const { toast } = useToast()
const tStudio = useTranslations('annualReportStudio')
const [data, setData] = useState<ArsredovisningData | null>(null)
const [signatures, setSignatures] = useState<SignatureRequest[]>([])
@@ -37,11 +46,19 @@ export default function ArsredovisningPage() {
const [description, setDescription] = useState('')
const [importantEvents, setImportantEvents] = useState('')
const [resultatdisposition, setResultatdisposition] = useState('')
const [proposedDividend, setProposedDividend] = useState('')
const [savedDescription, setSavedDescription] = useState('')
const [savedImportantEvents, setSavedImportantEvents] = useState('')
const [savedResultatdisposition, setSavedResultatdisposition] = useState('')
const [savedProposedDividend, setSavedProposedDividend] = useState('')
const [agmDate, setAgmDate] = useState('')
const [savedAgmDate, setSavedAgmDate] = useState('')
const [agmDispositionOutcome, setAgmDispositionOutcome] = useState<
'' | 'proposal_approved' | 'alternative_decision'
>('')
const [savedAgmDispositionOutcome, setSavedAgmDispositionOutcome] = useState('')
const [agmDispositionDecision, setAgmDispositionDecision] = useState('')
const [savedAgmDispositionDecision, setSavedAgmDispositionDecision] = useState('')
// Disclosure fields per ÅRL 5:13-15 § + BFNAR koncernförhållanden.
// Persisted via the same POST endpoint as the förvaltningsberättelse text.
const [longTermDebt, setLongTermDebt] = useState('')
@@ -56,12 +73,37 @@ export default function ArsredovisningPage() {
const [savedParentOrgNr, setSavedParentOrgNr] = useState('')
const [parentCity, setParentCity] = useState('')
const [savedParentCity, setSavedParentCity] = useState('')
const [longTermDebtConfirmed, setLongTermDebtConfirmed] = useState(false)
const [savedLongTermDebtConfirmed, setSavedLongTermDebtConfirmed] = useState(false)
const [securitiesPledgedConfirmed, setSecuritiesPledgedConfirmed] = useState(false)
const [savedSecuritiesPledgedConfirmed, setSavedSecuritiesPledgedConfirmed] = useState(false)
const [contingentLiabilitiesConfirmed, setContingentLiabilitiesConfirmed] = useState(false)
const [savedContingentLiabilitiesConfirmed, setSavedContingentLiabilitiesConfirmed] = useState(false)
const [parentCompanyConfirmed, setParentCompanyConfirmed] = useState(false)
const [savedParentCompanyConfirmed, setSavedParentCompanyConfirmed] = useState(false)
const [savingNarrative, setSavingNarrative] = useState(false)
const [savedAt, setSavedAt] = useState<number | null>(null)
const [narrativeRevision, setNarrativeRevision] = useState<string | null>(null)
// Add-signer form
const [signerName, setSignerName] = useState('')
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(() => {
if (!periodId) return
@@ -75,7 +117,7 @@ export default function ArsredovisningPage() {
.then(([arBody, sigBody]) => {
if (cancelled) return
if (arBody?.error) {
setError(arBody.error.message ?? 'Kunde inte hämta årsredovisning')
setError(getUserErrorMessage(arBody.error) ?? 'Kunde inte hämta årsredovisning')
return
}
const d = arBody.data as ArsredovisningData
@@ -87,11 +129,18 @@ export default function ArsredovisningPage() {
setDescription(d.forvaltningsberattelse.description)
setImportantEvents(d.forvaltningsberattelse.important_events)
setResultatdisposition(d.forvaltningsberattelse.resultatdisposition)
const dividend = String(d.forvaltningsberattelse.proposed_dividend || '')
setProposedDividend(dividend)
setAgmDate(d.forvaltningsberattelse.agm_date ?? '')
setSavedDescription(d.forvaltningsberattelse.description)
setSavedImportantEvents(d.forvaltningsberattelse.important_events)
setSavedResultatdisposition(d.forvaltningsberattelse.resultatdisposition)
setSavedProposedDividend(dividend)
setSavedAgmDate(d.forvaltningsberattelse.agm_date ?? '')
setAgmDispositionOutcome(d.forvaltningsberattelse.agm_disposition_outcome ?? '')
setSavedAgmDispositionOutcome(d.forvaltningsberattelse.agm_disposition_outcome ?? '')
setAgmDispositionDecision(d.forvaltningsberattelse.agm_disposition_decision ?? '')
setSavedAgmDispositionDecision(d.forvaltningsberattelse.agm_disposition_decision ?? '')
const ltd = d.disclosures.long_term_debt_over_five_years
const ltdStr = ltd != null ? String(ltd) : ''
setLongTermDebt(ltdStr)
@@ -106,6 +155,14 @@ export default function ArsredovisningPage() {
setSavedParentOrgNr(d.disclosures.parent_company_org_number ?? '')
setParentCity(d.disclosures.parent_company_city ?? '')
setSavedParentCity(d.disclosures.parent_company_city ?? '')
setLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years)
setSavedLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years)
setSecuritiesPledgedConfirmed(d.disclosures.confirmations.securities_pledged)
setSavedSecuritiesPledgedConfirmed(d.disclosures.confirmations.securities_pledged)
setContingentLiabilitiesConfirmed(d.disclosures.confirmations.contingent_liabilities)
setSavedContingentLiabilitiesConfirmed(d.disclosures.confirmations.contingent_liabilities)
setParentCompanyConfirmed(d.disclosures.confirmations.parent_company)
setSavedParentCompanyConfirmed(d.disclosures.confirmations.parent_company)
setSignatures((sigBody.data ?? []) as SignatureRequest[])
})
.catch(() => {
@@ -123,13 +180,20 @@ export default function ArsredovisningPage() {
description !== savedDescription ||
importantEvents !== savedImportantEvents ||
resultatdisposition !== savedResultatdisposition ||
proposedDividend !== savedProposedDividend ||
agmDate !== savedAgmDate ||
agmDispositionOutcome !== savedAgmDispositionOutcome ||
agmDispositionDecision !== savedAgmDispositionDecision ||
longTermDebt !== savedLongTermDebt ||
securitiesPledged !== savedSecuritiesPledged ||
contingentLiabilities !== savedContingentLiabilities ||
parentName !== savedParentName ||
parentOrgNr !== savedParentOrgNr ||
parentCity !== savedParentCity
parentCity !== savedParentCity ||
longTermDebtConfirmed !== savedLongTermDebtConfirmed ||
securitiesPledgedConfirmed !== savedSecuritiesPledgedConfirmed ||
contingentLiabilitiesConfirmed !== savedContingentLiabilitiesConfirmed ||
parentCompanyConfirmed !== savedParentCompanyConfirmed
const handleSaveNarrative = useCallback(async () => {
if (!periodId) return
@@ -150,6 +214,19 @@ export default function ArsredovisningPage() {
}
longTermDebtParsed = parsed
}
let proposedDividendParsed = 0
if (proposedDividend.trim()) {
const parsed = Number(proposedDividend.replace(/\s/g, '').replace(',', '.'))
if (!Number.isFinite(parsed) || parsed < 0) {
toast({
title: 'Ogiltigt belopp',
description: 'Föreslagen utdelning måste vara noll eller ett positivt belopp.',
variant: 'destructive',
})
return
}
proposedDividendParsed = Math.round(parsed * 100) / 100
}
setSavingNarrative(true)
try {
const res = await fetch(
@@ -161,13 +238,23 @@ export default function ArsredovisningPage() {
description,
important_events: importantEvents,
resultatdisposition,
proposed_dividend: proposedDividendParsed,
agm_date: agmDate || null,
agm_disposition_outcome: agmDispositionOutcome || null,
agm_disposition_decision:
agmDispositionOutcome === 'alternative_decision'
? agmDispositionDecision.trim() || null
: null,
long_term_debt_over_five_years: longTermDebtParsed,
securities_pledged: securitiesPledged.trim() || null,
contingent_liabilities: contingentLiabilities.trim() || null,
parent_company_name: parentName.trim() || null,
parent_company_org_number: parentOrgNr.trim() || null,
parent_company_city: parentCity.trim() || null,
long_term_debt_over_five_years_confirmed: longTermDebtConfirmed,
securities_pledged_confirmed: securitiesPledgedConfirmed,
contingent_liabilities_confirmed: contingentLiabilitiesConfirmed,
parent_company_confirmed: parentCompanyConfirmed,
}),
},
)
@@ -175,7 +262,7 @@ export default function ArsredovisningPage() {
if (!res.ok) {
toast({
title: 'Kunde inte spara texten',
description: body?.error?.message ?? '',
description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive',
})
return
@@ -183,18 +270,27 @@ export default function ArsredovisningPage() {
setSavedDescription(description)
setSavedImportantEvents(importantEvents)
setSavedResultatdisposition(resultatdisposition)
setSavedProposedDividend(proposedDividend)
setSavedAgmDate(agmDate)
setSavedAgmDispositionOutcome(agmDispositionOutcome)
setSavedAgmDispositionDecision(agmDispositionDecision)
setSavedLongTermDebt(longTermDebt)
setSavedSecuritiesPledged(securitiesPledged)
setSavedContingentLiabilities(contingentLiabilities)
setSavedParentName(parentName)
setSavedParentOrgNr(parentOrgNr)
setSavedParentCity(parentCity)
setSavedAt(Date.now())
setSavedLongTermDebtConfirmed(longTermDebtConfirmed)
setSavedSecuritiesPledgedConfirmed(securitiesPledgedConfirmed)
setSavedContingentLiabilitiesConfirmed(contingentLiabilitiesConfirmed)
setSavedParentCompanyConfirmed(parentCompanyConfirmed)
setNarrativeRevision(
typeof body.data?.updated_at === 'string' ? body.data.updated_at : null,
)
} catch (err) {
toast({
title: 'Kunde inte spara texten',
description: err instanceof Error ? err.message : 'Okänt fel',
description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
} finally {
@@ -205,33 +301,59 @@ export default function ArsredovisningPage() {
description,
importantEvents,
resultatdisposition,
proposedDividend,
agmDate,
agmDispositionOutcome,
agmDispositionDecision,
longTermDebt,
securitiesPledged,
contingentLiabilities,
parentName,
parentOrgNr,
parentCity,
longTermDebtConfirmed,
securitiesPledgedConfirmed,
contingentLiabilitiesConfirmed,
parentCompanyConfirmed,
toast,
])
const handleMarkSigned = useCallback(
async (signatureId: string) => {
if (!periodId) return
if (
!selectedSignatureVersionId ||
!SIGNATURE_EVIDENCE_REFERENCE_PATTERN.test(signatureEvidence.trim()) ||
!signatureDate
) {
toast({
title: 'Underskriftsbevis saknas',
description:
'Välj en låst version och ange datum samt en strukturerad referens, till exempel archive:AR-2026-001.',
variant: 'destructive',
})
return
}
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'signed' }),
body: JSON.stringify({
status: 'signed',
annual_report_version_id: selectedSignatureVersionId,
signing_method: signingMethod,
evidence_reference: signatureEvidence.trim(),
signed_at: new Date(`${signatureDate}T12:00:00`).toISOString(),
}),
},
)
const body = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte markera som signerad',
description: body?.error?.message ?? '',
description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive',
})
return
@@ -239,16 +361,31 @@ export default function ArsredovisningPage() {
setSignatures((prev) =>
prev.map((s) => (s.id === signatureId ? (body.data as SignatureRequest) : s)),
)
const versionsResponse = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/versions`,
)
if (versionsResponse.ok) {
const versionsBody = await versionsResponse.json()
handleVersionsChanged((versionsBody.data ?? []) as AnnualReportVersionSummary[])
}
toast({ title: 'Underskrift registrerad' })
} catch (err) {
toast({
title: 'Kunde inte markera som signerad',
description: err instanceof Error ? err.message : 'Okänt fel',
description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
},
[periodId, toast],
[
handleVersionsChanged,
periodId,
selectedSignatureVersionId,
signatureDate,
signatureEvidence,
signingMethod,
toast,
],
)
const handleAddSigner = useCallback(async () => {
@@ -266,7 +403,7 @@ export default function ArsredovisningPage() {
if (!res.ok) {
toast({
title: 'Kunde inte lägga till undertecknare',
description: body?.error?.message ?? '',
description: getUserErrorMessage(body?.error) ?? '',
variant: 'destructive',
})
return
@@ -277,12 +414,37 @@ export default function ArsredovisningPage() {
} catch (err) {
toast({
title: 'Kunde inte lägga till undertecknare',
description: err instanceof Error ? err.message : 'Okänt fel',
description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
}, [periodId, signerName, signerRole, toast])
const handleRemoveSigner = useCallback(
async (signatureId: string) => {
if (!periodId) return
try {
const response = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`,
{ method: 'DELETE' },
)
if (!response.ok) {
const body = await response.json()
throw new Error(getUserErrorMessage(body?.error))
}
setSignatures((current) => current.filter((signature) => signature.id !== signatureId))
toast({ title: tStudio('signer_removed') })
} catch (err) {
toast({
title: tStudio('signer_remove_error'),
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
}
},
[periodId, tStudio, toast],
)
if (!periodId) {
return (
<div className="space-y-8">
@@ -381,6 +543,16 @@ export default function ArsredovisningPage() {
</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>
<CardHeader>
<CardTitle className="text-base">Förvaltningsberättelse: narrativ</CardTitle>
@@ -417,6 +589,20 @@ export default function ArsredovisningPage() {
rows={3}
/>
</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">
<Label htmlFor="ar-agm-date">Datum för årsstämma</Label>
<Input
@@ -431,6 +617,37 @@ export default function ArsredovisningPage() {
fastställelseintyget i PDF:en (krävs för inlämning till Bolagsverket).
</p>
</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>
@@ -458,6 +675,14 @@ export default function ArsredovisningPage() {
<p className="text-xs text-muted-foreground">
ÅRL 5:13 §. Lämna tomt om inga skulder förfaller senare än fem år.
</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 className="space-y-2">
<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."
/>
<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 className="space-y-2">
<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."
/>
<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 className="space-y-2">
<Label htmlFor="ar-parent-name">
@@ -516,13 +757,21 @@ export default function ArsredovisningPage() {
/>
</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 className="flex items-center justify-between pt-2">
<div className="text-xs text-muted-foreground">
{hasUnsavedNarrative ? (
<span>Ändringar sparas inte automatiskt.</span>
) : savedAt ? (
) : narrativeRevision ? (
<span className="inline-flex items-center gap-1 text-success">
<CheckCircle2 className="h-3.5 w-3.5" /> Sparat
</span>
@@ -588,12 +837,78 @@ export default function ArsredovisningPage() {
<CardHeader>
<CardTitle className="text-base">Underskrifter</CardTitle>
<p className="text-sm text-muted-foreground">
Lägg till varje styrelseledamot + VD som ska skriva under. BankID-signering
kommer i en kommande version: för nu visas slottar och status här, och
själva underskriften görs på pappret.
Lägg till varje styrelseledamot och eventuell VD. Lås först en version i
arbetsflödet ovan. När originalet eller en extern e-signatur är klar registrerar
du datum och bevisreferens mot exakt den version som skrevs under.
</p>
</CardHeader>
<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 på 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 && (
<p className="text-sm text-muted-foreground italic">
Inga undertecknare tillagda än.
@@ -602,13 +917,13 @@ export default function ArsredovisningPage() {
{signatures.map((sig) => (
<div
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>
<p className="text-sm font-medium">{sig.signer_name}</p>
<p className="text-xs text-muted-foreground">{sig.role}</p>
</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' ? (
<Badge variant="success">Signerad</Badge>
) : sig.status === 'declined' ? (
@@ -616,10 +931,27 @@ export default function ArsredovisningPage() {
) : (
<>
<Badge variant="outline">Väntar på 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
size="sm"
variant="outline"
className="min-h-11"
onClick={() => void handleMarkSigned(sig.id)}
disabled={
!selectedSignatureVersionId ||
!SIGNATURE_EVIDENCE_REFERENCE_PATTERN.test(signatureEvidence.trim()) ||
!signatureDate
}
>
Markera som signerad
</Button>
@@ -628,14 +960,14 @@ export default function ArsredovisningPage() {
</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">
<Label htmlFor="signer-role" className="text-xs">
Roll
</Label>
<select
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}
onChange={(e) => setSignerRole(e.target.value)}
>
@@ -654,10 +986,10 @@ export default function ArsredovisningPage() {
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
placeholder="t.ex. Anna Andersson"
className="h-9"
className="min-h-11"
/>
</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
</Button>
</div>
@@ -666,15 +998,17 @@ export default function ArsredovisningPage() {
<Card>
<CardHeader>
<CardTitle className="text-base">Ladda ner & lämna in</CardTitle>
<CardTitle className="text-base">PDF för pappersinlämning</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">
Ladda ner PDF-utkastet, granska, skriv ut och låt undertecknarna signera
fastställelseintyget. Ladda sedan upp PDF:en till Bolagsverkets e-tjänst.
Ladda ner PDF-utkastet och granska det. För pappersinlämning ska
årsredovisningens original skrivas under av samtliga styrelseledamöter och
eventuell VD. En bestyrkt kopia med fastställelseintyg skickas sedan per post
till Bolagsverket. PDF-filen kan inte laddas upp som digital årsredovisning.
</p>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Button className="min-h-11" asChild>
<Link href={pdfUrl} target="_blank" rel="noopener noreferrer">
<FileDown className="mr-2 h-4 w-4" /> Ladda ner PDF (utkast)
</Link>
@@ -690,13 +1024,13 @@ export default function ArsredovisningPage() {
: undefined
}
>
<Button variant="outline" asChild>
<Button className="min-h-11" variant="outline" asChild>
<Link
href="https://www.bolagsverket.se/foretag/aktiebolag/arsredovisning/lamna-in-arsredovisning"
href="https://bolagsverket.se/foretag/aktiebolag/arsredovisningforaktiebolag.759.html"
target="_blank"
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>
</Button>
</span>
@@ -721,11 +1055,10 @@ export default function ArsredovisningPage() {
</div>
)}
<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
årsredovisning föreslås bli obligatorisk för K2/K3-aktiebolag för
räkenskapsår som inleds efter 2025-12-31. Använd avsnittet{' '}
<strong>Digital inlämning</strong> nedan för att granska, validera och lämna
in årsredovisningen som iXBRL. PDF:en ovan är ett läsexemplar.
<strong>Digital inlämning är frivillig.</strong> Den görs som iXBRL genom en
ansluten programvara. Accounteds direktinlämning förblir stängd tills avtal,
certifikat och Bolagsverkets acceptanstest är klara. PDF-flödet ovan är den
separata vägen för pappersinlämning.
</div>
</div>
</CardContent>
@@ -148,7 +148,7 @@ export default function YearEndPage() {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
setReportError(body?.error?.message ?? 'Kunde inte ladda bokslutskontroll')
setReportError(getErrorMessage(body?.error) ?? 'Kunde inte ladda bokslutskontroll')
return
}
setReport(body.data as BokslutReadinessReport)
@@ -174,7 +174,7 @@ export default function YearEndPage() {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`)
const body = await res.json()
if (!res.ok) {
setPreviewError(body?.error?.message ?? 'Kunde inte hämta förhandsgranskning')
setPreviewError(getErrorMessage(body?.error) ?? 'Kunde inte hämta förhandsgranskning')
return
}
setPreview(body.data.preview as YearEndPreview)
@@ -198,7 +198,7 @@ export default function YearEndPage() {
// body.error.message is the localized Swedish message picked by
// the structured-error registry. Do NOT interpolate raw details
// here: they can contain DB-sourced strings (V2.3 finding).
setExecuteError(body?.error?.message ?? 'Bokslutet kunde inte verkställas')
setExecuteError(getErrorMessage(body?.error) ?? 'Bokslutet kunde inte verkställas')
return
}
setResult(body.data as YearEndResult)
@@ -26,6 +26,7 @@ import type {
PeriodiseringConfidence,
} from '@/lib/bokslut/accruals/auto-detect'
import type { FiscalPeriod } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review'
@@ -160,7 +161,7 @@ export default function PeriodiseringWizardPage() {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
setLoadError(body?.error?.message ?? 'Kunde inte ladda periodiseringar')
setLoadError(getUserErrorMessage(body?.error) ?? 'Kunde inte ladda periodiseringar')
return
}
const data = body.data as ProposalResponse
@@ -329,7 +330,7 @@ export default function PeriodiseringWizardPage() {
})
const body = await res.json()
if (!res.ok) {
setPostError(body?.error?.message ?? 'Kunde inte bokföra periodiseringarna')
setPostError(getUserErrorMessage(body?.error) ?? 'Kunde inte bokföra periodiseringarna')
return
}
const created = body.data?.created?.length ?? 0
@@ -340,7 +341,7 @@ export default function PeriodiseringWizardPage() {
description: skipped > 0 ? `${skipped} hoppades över (redan postade).` : undefined,
})
} catch (err) {
setPostError(err instanceof Error ? err.message : 'Okänt fel')
setPostError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
} finally {
setPosting(false)
}
+5 -7
View File
@@ -1,7 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { notFound, redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatConversationView from '@/components/agent/ChatConversationView'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -15,12 +14,11 @@ interface PageProps {
// up via /api/agent/invoke with conversation_id supplied.
export default async function ChatConversationPage({ params }: PageProps) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const [{ supabase, user }, companyId] = await Promise.all([
getDashboardAuthContext(),
getDashboardCompanyId(),
])
if (!user) redirect('/login')
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
// Both queries key on the route id, so they run in parallel. The tenant
+5 -6
View File
@@ -1,7 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -13,11 +12,11 @@ export const dynamic = 'force-dynamic'
//
// Plan ref: dev_docs/specialized-agent-plan.md §7 Phase C.
export default async function ChatIntakePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const [{ user }, companyId] = await Promise.all([
getDashboardAuthContext(),
getDashboardCompanyId(),
])
if (!user) redirect('/login')
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
return <ChatIntakeStarter />
+10 -32
View File
@@ -1,8 +1,10 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import ChatSidebar from '@/components/agent/ChatSidebar'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getResolvedDashboardAgentProfile,
} from '../request-context'
export const dynamic = 'force-dynamic'
@@ -10,11 +12,12 @@ export const dynamic = 'force-dynamic'
// conversation (or empty state) in the main panel. Both /chat and /chat/[id]
// share this layout so the sidebar doesn't unmount on conversation switches.
export default async function ChatLayout({ children }: { children: React.ReactNode }) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const [{ supabase, user }, companyId, agent] = await Promise.all([
getDashboardAuthContext(),
getDashboardCompanyId(),
getResolvedDashboardAgentProfile(),
])
if (!user) redirect('/login')
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
// Block the chat surface until the agent is built. Without this a user
@@ -22,31 +25,6 @@ export default async function ChatLayout({ children }: { children: React.ReactNo
// empty conversations list with no Anna to talk to. The home route at /
// renders NewUserChecklist for the same state, so we forward there
// instead of duplicating the welcome screen here.
let { data: agent } = await supabase
.from('agent_profiles')
.select('verified_at')
.eq('company_id', companyId)
.maybeSingle()
// Sandbox sessions get a pre-built assistant: backfill if a pre-seed
// session is missing it so /chat doesn't bounce back to / in a loop.
if (!agent?.verified_at) {
const { data: settings } = await supabase
.from('company_settings')
.select('is_sandbox')
.eq('company_id', companyId)
.maybeSingle()
if (settings?.is_sandbox) {
await ensureSandboxAgentProfile(supabase, companyId)
const refresh = await supabase
.from('agent_profiles')
.select('verified_at')
.eq('company_id', companyId)
.maybeSingle()
agent = refresh.data
}
}
if (!agent?.verified_at) redirect('/')
const { data: conversations } = await supabase
+5 -6
View File
@@ -1,8 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
import ChatNewStarter from '@/components/agent/ChatNewStarter'
import { getIntent } from '@/lib/agent/intents/registry'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -17,11 +16,11 @@ interface PageProps {
// caller-chosen intent/seed, so suggestion chips and ⌘K can route here
// inline instead of opening the slide-in sheet.
export default async function ChatNewPage({ searchParams }: PageProps) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const [{ user }, companyId] = await Promise.all([
getDashboardAuthContext(),
getDashboardCompanyId(),
])
if (!user) redirect('/login')
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
const sp = await searchParams
+36 -4
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -21,7 +22,6 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Users, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import CustomerForm from '@/components/customers/CustomerForm'
import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
import { PageHeader } from '@/components/ui/page-header'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
@@ -31,6 +31,19 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const CustomerForm = dynamic(
() => import('@/components/customers/CustomerForm'),
{
loading: () => (
<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> = {
individual: 'type_individual',
swedish_business: 'type_swedish_business',
@@ -49,6 +62,7 @@ const SORTABLE_COLUMNS: ReadonlyArray<SortColumn> = [
'city',
'created_at',
]
const INITIAL_VISIBLE_ROWS = 100
function getInitials(name: string): string {
return name
@@ -73,11 +87,13 @@ function CustomersPageInner() {
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('customers')
const tCommon = useTranslations('common')
const errorLocale = useLocale() as ErrorLocale
const router = useRouter()
@@ -93,6 +109,7 @@ function CustomersPageInner() {
const updateSort = useCallback(
(column: SortColumn) => {
setVisibleCount(INITIAL_VISIBLE_ROWS)
const params = new URLSearchParams(searchParams.toString())
let nextDir: SortDir = 'asc'
if (column === sortColumn) {
@@ -211,6 +228,7 @@ function CustomersPageInner() {
})
return arr
}, [filteredCustomers, sortColumn, sortDir])
const visibleCustomers = sortedCustomers.slice(0, visibleCount)
function SortableHeader({
column,
@@ -284,7 +302,10 @@ function CustomersPageInner() {
<Input
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="pl-10"
/>
</div>
@@ -350,7 +371,7 @@ function CustomersPageInner() {
</TableRow>
</TableHeader>
<TableBody>
{sortedCustomers.map((customer) => {
{visibleCustomers.map((customer) => {
const identifier = getIdentifier(customer)
return (
<TableRow
@@ -399,7 +420,7 @@ function CustomersPageInner() {
{/* Mobile card list */}
<div className="grid gap-4 md:hidden">
{sortedCustomers.map((customer) => (
{visibleCustomers.map((customer) => (
<Link key={customer.id} href={`/customers/${customer.id}`}>
<Card className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60 h-full group">
<CardHeader className="pb-3">
@@ -441,6 +462,17 @@ function CustomersPageInner() {
</Link>
))}
</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>
+6 -5
View File
@@ -15,6 +15,7 @@ import { AlertTriangle, ArrowRight, CalendarClock, Loader2 } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils'
import type { Deadline } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const supabase = createClient()
@@ -123,7 +124,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('generate_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
} finally {
@@ -155,7 +156,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('create_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
throw error
@@ -208,7 +209,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('toggle_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
}
@@ -236,7 +237,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('update_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
}
@@ -258,7 +259,7 @@ export default function DeadlinesPage() {
} catch (error) {
toast({
title: t('delete_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
}
+5 -5
View File
@@ -1,12 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader'
import { getActiveCompanyId } from '@/lib/company/context'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
import { ExtensionUpsellState } from '@/components/extensions/ExtensionUpsellState'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../../request-context'
export default async function ExtensionWorkspacePage({
params,
@@ -14,8 +13,10 @@ export default async function ExtensionWorkspacePage({
params: Promise<{ sector: string; slug: string }>
}) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const [{ supabase, user }, companyId] = await Promise.all([
getDashboardAuthContext(),
getDashboardCompanyId(),
])
if (!user) redirect('/login')
@@ -29,7 +30,6 @@ export default async function ExtensionWorkspacePage({
// Fail closed: no resolvable company or the capability absent, both block.
const requiredCapability = requiredCapabilityForExtension(sector, slug)
if (requiredCapability) {
const companyId = await getActiveCompanyId(supabase, user.id)
const allowed = companyId
? await hasCapability(supabase, companyId, requiredCapability)
: false
+58 -51
View File
@@ -19,28 +19,11 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
// Bank file import components
import BankFileUploadStep from '@/components/import/BankFileUploadStep'
import BankFilePreviewStep from '@/components/import/BankFilePreviewStep'
import BankFileColumnMappingStep from '@/components/import/BankFileColumnMappingStep'
import BankFileConfirmStep from '@/components/import/BankFileConfirmStep'
import BankFileResultStep from '@/components/import/BankFileResultStep'
// Opening balance import components
import OpeningBalanceUploadStep from '@/components/import/OpeningBalanceUploadStep'
import OpeningBalanceColumnMappingStep from '@/components/import/OpeningBalanceColumnMappingStep'
import OpeningBalanceEditStep from '@/components/import/OpeningBalanceEditStep'
import OpeningBalancePeriodStep from '@/components/import/OpeningBalancePeriodStep'
import OpeningBalanceResultStep from '@/components/import/OpeningBalanceResultStep'
import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types'
// Register import (customers/suppliers) components
import RegisterUploadStep from '@/components/import/RegisterUploadStep'
import RegisterColumnMappingStep, { type RegisterColumnSpec } from '@/components/import/RegisterColumnMappingStep'
import CustomersEditStep from '@/components/import/CustomersEditStep'
import SuppliersEditStep from '@/components/import/SuppliersEditStep'
import ArticlesEditStep from '@/components/import/ArticlesEditStep'
import RegisterResultStep, { type RegisterResult } from '@/components/import/RegisterResultStep'
import type { RegisterResult } from '@/components/import/RegisterResultStep'
import type {
CustomerImportParseResult,
AnnotatedCustomerRow,
@@ -57,12 +40,7 @@ import type {
DetectedArticleColumns,
} from '@/lib/import/articles/types'
// SIE import components
import SIEUploadStep from '@/components/import/SIEUploadStep'
import SIEPreviewStep from '@/components/import/SIEPreviewStep'
import AccountMappingStep from '@/components/import/AccountMappingStep'
import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import ImportResultStep from '@/components/import/ImportResultStep'
import type { ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import { applyMappingOverride } from '@/lib/import/account-mapper'
import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { IngestResult } from '@/lib/transactions/ingest'
@@ -86,6 +64,35 @@ const MigrationWizard = dynamic(
{ ssr: false, loading: () => <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
// ============================================================
@@ -185,7 +192,7 @@ function BankFileImportWizard() {
'Exportera en ny fil från banken om du vill lägga till fler transaktioner.'
)
} else {
setBankError(err.message || 'Kunde inte läsa filen')
setBankError(getErrorMessage(err) || 'Kunde inte läsa filen')
}
} else {
setBankError(typeof err === 'string' ? err : 'Kunde inte läsa filen')
@@ -219,7 +226,7 @@ function BankFileImportWizard() {
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
}
} catch (err) {
setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setBankError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setBankIsLoading(false)
}
@@ -267,7 +274,7 @@ function BankFileImportWizard() {
description: `${data.data.imported} transaktioner importerades`,
})
} catch (err) {
setBankError(err instanceof Error ? err.message : 'Importen misslyckades')
setBankError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setBankIsLoading(false)
}
@@ -524,7 +531,7 @@ function SIEImportWizard() {
const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError'))
const message = isNetworkError
? 'Kunde inte nå servern. Kontrollera din internetanslutning och försök igen.'
: err instanceof Error ? err.message : 'Ett oväntat fel uppstod.'
: getErrorMessage(err)
setErrorType(isNetworkError ? 'network' : 'parse')
setError(message)
toast({ title: isNetworkError ? 'Anslutningsfel' : 'Ett fel uppstod', description: message, variant: 'destructive' })
@@ -677,7 +684,7 @@ function SIEImportWizard() {
setBasAccounts(accountsData.data || [])
}
} catch (err) {
toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive' })
toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? getErrorMessage(err) : 'Försök igen.', variant: 'destructive' })
} finally {
setIsCreatingAccounts(false)
}
@@ -739,7 +746,7 @@ function SIEImportWizard() {
const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError'))
const msg = isNetworkError
? 'Tappade anslutningen till servern under importen. Kontrollera din internetanslutning och se om importen genomfördes under Bokföring.'
: err instanceof Error ? err.message : 'Ett oväntat fel uppstod.'
: getErrorMessage(err)
setError(msg)
toast({ title: 'Import avbröts', description: msg, variant: 'destructive' })
} finally {
@@ -886,7 +893,7 @@ function OpeningBalanceFlow() {
setObStep('edit')
}
} catch (err) {
setObError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setObError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setObIsLoading(false)
}
@@ -918,7 +925,7 @@ function OpeningBalanceFlow() {
setParseResult(data.data)
setObStep('edit')
} catch (err) {
setObError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setObError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setObIsLoading(false)
}
@@ -979,7 +986,7 @@ function OpeningBalanceFlow() {
})
}
} catch (err) {
setObError(err instanceof Error ? err.message : 'Importen misslyckades')
setObError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setObIsLoading(false)
}
@@ -1156,7 +1163,7 @@ function CustomersFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen')
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
@@ -1175,7 +1182,7 @@ function CustomersFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1217,14 +1224,14 @@ function CustomersFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen med de valda kolumnerna')
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen med de valda kolumnerna')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1249,7 +1256,7 @@ function CustomersFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades')
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
@@ -1263,7 +1270,7 @@ function CustomersFlow() {
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades')
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
@@ -1407,7 +1414,7 @@ function SuppliersFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen')
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
@@ -1426,7 +1433,7 @@ function SuppliersFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1474,14 +1481,14 @@ function SuppliersFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen')
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1506,7 +1513,7 @@ function SuppliersFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades')
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
@@ -1520,7 +1527,7 @@ function SuppliersFlow() {
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades')
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
@@ -1657,7 +1664,7 @@ function ArticlesFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen')
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
@@ -1676,7 +1683,7 @@ function ArticlesFlow() {
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1717,14 +1724,14 @@ function ArticlesFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen med de valda kolumnerna')
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen med de valda kolumnerna')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
@@ -1749,7 +1756,7 @@ function ArticlesFlow() {
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades')
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
@@ -1763,7 +1770,7 @@ function ArticlesFlow() {
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades')
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
@@ -20,6 +20,7 @@ import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import type { Invoice, InvoiceItem, Customer } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InvoiceWithRelations extends Invoice {
customer: Customer
@@ -136,7 +137,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
} catch (error) {
toast({
title: t('create_failed_title'),
description: error instanceof Error ? error.message : t('try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('try_again'),
variant: 'destructive',
})
}
+47 -45
View File
@@ -53,6 +53,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const statusVariantMap: Record<InvoiceStatus, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
draft: 'secondary',
@@ -83,7 +84,7 @@ interface InvoiceWithRelations extends Invoice {
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { isSandbox } = useCompany()
const { company, isSandbox } = useCompany()
const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params)
const router = useRouter()
@@ -139,6 +140,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
async function fetchInvoice() {
setIsLoading(true)
// Settings depend only on the active company, so start them with the main
// invoice batch instead of waiting for the invoice row first.
const settingsPromise = company?.id
? supabase
.from('company_settings')
.select('ore_rounding, vat_registered, accounting_method, defer_invoice_booking, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
.eq('company_id', company.id)
.maybeSingle()
: Promise.resolve(null)
// Invoice, reminders, and payments all key on the route id — one
// parallel batch. Only the follow-ups below need the invoice row.
const [{ data, error }, { data: reminderData }, { data: paymentData }] =
@@ -213,19 +224,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)
}
// Follow-ups that need the invoice row: company settings (öresavrundning
// + VAT registration so the detail view matches the PDF — see
// pdf-template.tsx:792 and :876), the credit note, the original invoice,
// and the proforma source. Independent of each other → parallel.
const [settingsRes, creditNoteRes, originalRes, convertedRes] =
await Promise.all([
data.company_id
? supabase
.from('company_settings')
.select('ore_rounding, vat_registered, accounting_method, defer_invoice_booking, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
.eq('company_id', data.company_id)
.maybeSingle()
: Promise.resolve(null),
const settingsRes = await settingsPromise
if (settingsRes) {
const settings = settingsRes.data
setOreRounding(settings?.ore_rounding ?? true)
if (typeof settings?.vat_registered === 'boolean') {
setVatRegistered(settings.vat_registered)
}
setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
setDeferInvoiceBooking(!!settings?.defer_invoice_booking)
setReminderDays([
settings?.reminder_days_level_1 ?? 15,
settings?.reminder_days_level_2 ?? 30,
settings?.reminder_days_level_3 ?? 45,
])
}
// Related documents need the invoice row but do not gate the main detail
// view. Resolve them together after first paint and fill their links in.
setIsLoading(false)
void Promise.all([
!data.credited_invoice_id &&
['sent', 'paid', 'overdue', 'credited'].includes(data.status)
? supabase
@@ -249,31 +267,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.eq('id', data.converted_from_id)
.single()
: Promise.resolve(null),
])
if (settingsRes) {
const settings = settingsRes.data
setOreRounding(settings?.ore_rounding ?? true)
if (typeof settings?.vat_registered === 'boolean') {
setVatRegistered(settings.vat_registered)
}
setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
setDeferInvoiceBooking(!!settings?.defer_invoice_booking)
setReminderDays([
settings?.reminder_days_level_1 ?? 15,
settings?.reminder_days_level_2 ?? 30,
settings?.reminder_days_level_3 ?? 45,
])
}
setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
if (originalRes?.data) {
setOriginalInvoice(originalRes.data as Invoice)
}
if (convertedRes?.data) {
setConvertedFromInvoice(convertedRes.data as Invoice)
}
setIsLoading(false)
]).then(([creditNoteRes, originalRes, convertedRes]) => {
setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
if (originalRes?.data) {
setOriginalInvoice(originalRes.data as Invoice)
}
if (convertedRes?.data) {
setConvertedFromInvoice(convertedRes.data as Invoice)
}
})
}
// #967: deferred booking: create the revenue verifikat afterwards.
@@ -304,7 +306,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('book_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
} finally {
@@ -357,7 +359,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('status_update_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -394,7 +396,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('convert_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -433,7 +435,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('pdf_download_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -495,7 +497,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('finalize_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
} finally {
@@ -540,7 +542,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
} catch (error) {
toast({
title: t('cancel_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
+62 -11
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -11,6 +12,7 @@ import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
DataList,
DataListRow,
@@ -26,13 +28,35 @@ import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import NewInvoiceDialog from '@/components/invoices/NewInvoiceDialog'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Invoice, InvoiceStatus } from '@/types'
function NewInvoiceDialogLoading() {
const t = useTranslations('invoices')
return (
<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'
const INITIAL_VISIBLE_ROWS = 100
const STATUS_CONFIG: Record<InvoiceStatus, { labelKey: string; variant: InvoiceStatusVariant }> = {
draft: { labelKey: 'status_draft', variant: 'secondary' },
sent: { labelKey: 'status_sent', variant: 'default' },
@@ -77,9 +101,11 @@ export default function InvoicesPage() {
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('all')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoices')
const tCommon = useTranslations('common')
const getRelativeTimeLabel = useRelativeTimeLabel()
// The "Ny faktura" modal is driven by the URL (?new=1) so every entry point
@@ -144,6 +170,7 @@ export default function InvoicesPage() {
return matchesSearch && matchesTab
})
const visibleInvoices = filteredInvoices.slice(0, visibleCount)
const isOutstandingReceivable = (i: Invoice) =>
['sent', 'overdue'].includes(i.status) && !i.credited_invoice_id
@@ -218,11 +245,21 @@ export default function InvoicesPage() {
<Input
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="pl-10"
/>
</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">
<TabsTrigger value="all">{t('tab_all')}</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 isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
@@ -378,13 +415,27 @@ export default function InvoicesPage() {
)}
</DataList>
<NewInvoiceDialog
open={showNewInvoice}
copyFromId={copyFromId}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
/>
{!isLoading && visibleCount < filteredInvoices.length && (
<div className="flex justify-center">
<Button
type="button"
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>
)
}
+14 -38
View File
@@ -1,4 +1,3 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
@@ -12,11 +11,15 @@ import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getActiveCompanyId } from '@/lib/company/context'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import type { EntityType, CompanyRole, Team } from '@/types'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getDashboardSettings,
getResolvedDashboardAgentProfile,
} from './request-context'
/**
* Routes inside the dashboard group that must remain reachable when the
@@ -34,9 +37,7 @@ export default async function DashboardLayout({
// current page on in-app navigation to /settings/*; null otherwise.
settingsModal: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { supabase, user } = await getDashboardAuthContext()
if (!user) {
redirect('/login')
@@ -51,7 +52,7 @@ export default async function DashboardLayout({
// so it resolves in parallel, this layout is on the critical path of
// every dashboard page, so sequential round-trips are wall-clock time.
const [companyId, headerStore, { data: teamMembership }] = await Promise.all([
getActiveCompanyId(supabase, user.id),
getDashboardCompanyId(),
// Read the pathname forwarded by middleware so we can branch on it.
headers(),
supabase
@@ -128,7 +129,7 @@ export default async function DashboardLayout({
{ data: memberRow },
{ data: allMemberships },
{ data: settings },
{ data: agentProfileIdentity },
agentProfileIdentity,
{ data: userProfile },
entitlements,
{ data: allSettingsNames },
@@ -136,22 +137,14 @@ export default async function DashboardLayout({
supabase.from('companies').select('*').eq('id', companyId).single(),
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
.eq('company_id', companyId)
.single(),
getDashboardSettings(),
// Nav badge counts (unbooked transactions, pending operations) are NOT
// fetched here anymore: DashboardNav loads them client-side after mount
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
// first paint on every dashboard navigation.
// Agent identity, name + avatar, surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
supabase
.from('agent_profiles')
.select('display_name, avatar_id, verified_at')
.eq('company_id', companyId)
.maybeSingle(),
getResolvedDashboardAgentProfile(),
// The signed-in user's profile, shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
@@ -242,23 +235,6 @@ export default async function DashboardLayout({
const isSandbox = settings?.is_sandbox === true
// Backfill a verified agent_profile for sandbox sessions that pre-date the
// seed change. Without this an old anonymous session shows the "Bygg din
// bokföringsassistent" CTA in three places (dashboard hero, NewUserChecklist
// step 4, /chat layout redirect) and the user can still kick off a build
// flow that the server now 403s. Best-effort; doesn't block the layout
// even if the insert fails.
let resolvedAgentIdentity = agentProfileIdentity
if (isSandbox && !agentProfileIdentity?.verified_at) {
await ensureSandboxAgentProfile(supabase, companyId)
const { data: refreshed } = await supabase
.from('agent_profiles')
.select('display_name, avatar_id, verified_at')
.eq('company_id', companyId)
.maybeSingle()
resolvedAgentIdentity = refreshed ?? agentProfileIdentity
}
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
@@ -283,9 +259,9 @@ export default async function DashboardLayout({
<CompanyProvider value={companyContextValue}>
<AgentSheetProvider
identity={{
displayName: resolvedAgentIdentity?.display_name ?? null,
avatarId: resolvedAgentIdentity?.avatar_id ?? null,
isVerified: Boolean(resolvedAgentIdentity?.verified_at),
displayName: agentProfileIdentity?.display_name ?? null,
avatarId: agentProfileIdentity?.avatar_id ?? null,
isVerified: Boolean(agentProfileIdentity?.verified_at),
}}
>
<CompanyTabSync />
+22 -82
View File
@@ -1,50 +1,31 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import DashboardContent from '@/components/dashboard/DashboardContent'
import WelcomeGate from '@/components/onboarding/WelcomeGate'
import { getActiveCompanyId } from '@/lib/company/context'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
import type { Deadline, OnboardingProgress } from '@/types'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getDashboardSettings,
getResolvedDashboardAgentProfile,
} from './request-context'
export const dynamic = 'force-dynamic'
// Home route = Översikt (DashboardContent). The agent chat has its own nav
// entry at /chat, so / no longer forwards there. New users who haven't built
// their assistant yet get WelcomeGate (the build-agent checklist) instead of
// the dashboard; once the agent is verified, / renders the normal Översikt.
// entry at /chat, so / no longer forwards there. Initial setup is an optional,
// persisted surface inside the dashboard and never replaces the overview.
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const [{ supabase, user }, companyId] = await Promise.all([
getDashboardAuthContext(),
getDashboardCompanyId(),
])
if (!user) {
redirect('/login')
}
const cookieStore = await cookies()
const rawCompanyId = cookieStore.get('gnubok-company-id')?.value
?? await getActiveCompanyId(supabase, user.id)
// Validate the cookie/preference points to a company the user can access.
// Only a positive "no membership row" clears it: a FAILED query means the
// membership is unknown, and treating that as absent bounced onboarded
// users to the wizard on transient failures (issue #1053). RLS still
// guards every downstream query if the cookie is stale.
let companyId = rawCompanyId
if (companyId) {
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership && !membershipError) companyId = null
}
if (!companyId) {
redirect('/onboarding')
}
@@ -61,7 +42,6 @@ export default async function DashboardPage() {
settingsRes,
{ count: customerCount },
{ count: invoiceCount },
{ count: receiptCount },
{ count: transactionCount },
{ data: journalLines },
{ data: unpaidInvoices },
@@ -70,15 +50,13 @@ export default async function DashboardPage() {
{ count: sieImportCount },
{ count: staleUncategorizedCount },
{ count: skatteverketTokenCount },
{ data: agentProfile },
{ count: postedEntriesCount },
agentProfile,
worklist,
suggestedMatches,
] = await Promise.all([
supabase.from('company_settings').select('*').eq('company_id', companyId).maybeSingle(),
getDashboardSettings(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status, company_id)')
@@ -95,9 +73,7 @@ export default async function DashboardPage() {
// carry the active company_id; either filter would work: we use user_id
// because that's what the token-store reads/writes against.
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('agent_profiles').select('verified_at').eq('company_id', companyId).maybeSingle(),
// Any posted entry counts as "company has been used" for the hasData gate.
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted'),
getResolvedDashboardAgentProfile(),
// Pending-work counts + suggested matches come from lib/worklist: the
// same source as the sidebar badges, so the numbers can never diverge.
getWorklistCounts(supabase, companyId),
@@ -118,53 +94,12 @@ export default async function DashboardPage() {
redirect('/onboarding')
}
// Sandbox sessions that pre-date the agent_profile seeding step would
// otherwise still see the "Bygg din bokföringsassistent" hero + the
// NewUserChecklist's agent step lit up. Backfill here so the next render
// sees a verified profile and treats the sandbox as fully set up.
let effectiveAgentVerified = agentProfile?.verified_at ?? null
if (settings?.is_sandbox === true && !effectiveAgentVerified) {
await ensureSandboxAgentProfile(supabase, companyId)
const { data: refreshed } = await supabase
.from('agent_profiles')
.select('verified_at')
.eq('company_id', companyId)
.maybeSingle()
effectiveAgentVerified = refreshed?.verified_at ?? null
}
const agentBuilt = Boolean(effectiveAgentVerified)
// "Has the company already been used?" Any real business data means we must
// NOT hijack the dashboard with the full-screen onboarding gate: existing
// and migrated users get the normal Översikt with a build-assistant prompt
// in the hero slot (see DashboardContent's agentBuilt branch) instead.
const hasData =
(transactionCount || 0) > 0 ||
(sieImportCount || 0) > 0 ||
(invoiceCount || 0) > 0 ||
(receiptCount || 0) > 0 ||
(customerCount || 0) > 0 ||
(postedEntriesCount || 0) > 0
// Only a genuinely empty company without an assistant sees the full
// onboarding checklist (where building the assistant is the last step).
// Everyone else falls through to the dashboard below.
if (!agentBuilt && !hasData) {
return (
<WelcomeGate
companyId={companyId}
hasBookkeepingImported={(sieImportCount || 0) > 0}
hasBankConnected={(transactionCount || 0) > 0}
hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0}
/>
)
}
const agentBuilt = Boolean(agentProfile?.verified_at)
const onboardingProgress: OnboardingProgress = {
hasCustomers: (customerCount || 0) > 0,
hasInvoices: (invoiceCount || 0) > 0,
hasBankConnected: (transactionCount || 0) > 0,
hasBankConnected: (bankConnections?.length || 0) > 0 || (transactionCount || 0) > 0,
hasSIEImport: (sieImportCount || 0) > 0,
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
}
@@ -266,6 +201,11 @@ export default async function DashboardPage() {
worklist={worklist}
suggestedMatches={suggestedMatches}
onboardingProgress={onboardingProgress}
initialSetup={{
path: settings.initial_setup_path ?? null,
completedAt: settings.initial_setup_completed_at ?? null,
dismissedAt: settings.initial_setup_dismissed_at ?? null,
}}
/>
)
}
+10 -33
View File
@@ -688,45 +688,26 @@ export default function PendingOperationsPage() {
const res = await fetch(`/api/pending-operations?status=${activeTab}`)
const json = await res.json()
setOperations(json.data ?? [])
setCounts((prev) => ({ ...prev, [activeTab]: json.count ?? json.data?.length ?? 0 }))
setCounts((prev) => ({
...prev,
...(json.counts ?? {}),
[activeTab]: json.count ?? json.data?.length ?? 0,
}))
} catch {
toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' })
}
setIsLoading(false)
}, [activeTab, toast])
const fetchAllCounts = useCallback(async () => {
const statuses: TabStatus[] = ['pending', 'committed', 'rejected']
try {
const results = await Promise.all(
statuses.map((s) =>
fetch(`/api/pending-operations?status=${s}&limit=1`).then((r) => r.json())
)
)
setCounts({
pending: results[0]?.count ?? 0,
committed: results[1]?.count ?? 0,
rejected: results[2]?.count ?? 0,
})
} catch {
// Counts are best-effort; the active tab's count will still update via fetchOperations
}
}, [])
useEffect(() => {
fetchOperations()
}, [fetchOperations])
useEffect(() => {
fetchAllCounts()
}, [fetchAllCounts])
// Realtime subscription: refetch when ANY pending_operations row changes for
// this company. RLS scopes the channel automatically: we don't see other
// tenants' events. We refetch the whole list (rather than patching state
// in-place) so server-side filtering, sorting, and computed fields stay in
// sync with whatever the API route returned. The counts endpoint isn't
// pushed by the same trigger, so we also refresh counts on every change.
// sync with whatever the API route returned, including all tab counts.
// Trailing debounce: bulk actions emit one event per row, which previously
// stampeded 4 requests per event (list + 3 counts); the burst now collapses
// into a single refetch after the last event.
@@ -742,7 +723,6 @@ export default function PendingOperationsPage() {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
fetchOperations()
fetchAllCounts()
}, 400)
}
)
@@ -751,7 +731,7 @@ export default function PendingOperationsPage() {
if (debounce) clearTimeout(debounce)
void supabase.removeChannel(channel)
}
}, [fetchOperations, fetchAllCounts])
}, [fetchOperations])
// Clear selection when filters/tab change
useEffect(() => {
@@ -772,11 +752,10 @@ export default function PendingOperationsPage() {
setShowCommitDialog(false)
setSelectedOp(null)
fetchOperations()
fetchAllCounts()
} catch (err) {
toast({
title: 'Misslyckades',
description: err instanceof Error ? err.message : 'Okänt fel',
description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
@@ -818,11 +797,10 @@ export default function PendingOperationsPage() {
setShowBulkDialog(false)
setSelectedIds(new Set())
fetchOperations()
fetchAllCounts()
} catch (err) {
toast({
title: 'Misslyckades',
description: err instanceof Error ? err.message : 'Okänt fel',
description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
@@ -887,11 +865,10 @@ export default function PendingOperationsPage() {
setRejectTarget(null)
fetchOperations()
fetchAllCounts()
} catch (err) {
toast({
title: 'Kunde inte avvisa',
description: err instanceof Error ? err.message : 'Okänt fel',
description: err instanceof Error ? getErrorMessage(err) : 'Okänt fel',
variant: 'destructive',
})
}
+23 -1
View File
@@ -1,6 +1,8 @@
import { notFound, redirect } from 'next/navigation'
import { getReport } from '@/lib/reports/catalog'
import { FocusedReport } from '@/components/reports/FocusedReport'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
import type { FiscalPeriod } from '@/types'
/**
* Focused single-report route. Unknown slugs 404; reports that own a dedicated
@@ -16,5 +18,25 @@ export default async function ReportSlugPage({
const report = getReport(slug)
if (!report) notFound()
if (report.route) redirect(report.route)
return <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 { formatDate } from '@/lib/utils'
import type { KassaflodesanalysReport } from '@/lib/reports/kassaflodesanalys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function formatAmount(n: number): string {
return n.toLocaleString('sv-SE', {
@@ -72,7 +73,7 @@ export function KassaflodesanalysClient() {
const { data } = await res.json()
setReport(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
setReport(null)
} finally {
setIsLoadingReport(false)
+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'
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -13,9 +14,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Plus, ArrowLeft, UserCircle } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import NewEmployeeDialog from '@/components/salary/NewEmployeeDialog'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import type { Employee } from '@/types'
const NewEmployeeDialog = dynamic(
() => import('@/components/salary/NewEmployeeDialog'),
{ loading: DialogLoadingSkeleton },
)
const EMPLOYMENT_LABEL_KEYS: Record<string, string> = {
employee: 'employment_employee',
company_owner: 'employment_company_owner',
@@ -142,16 +148,18 @@ export default function EmployeesPage() {
</Card>
)}
<NewEmployeeDialog
open={showNewEmployee}
onOpenChange={(open) => {
if (!open) closeNewEmployee()
}}
onCreated={() => {
closeNewEmployee()
setRefreshKey((k) => k + 1)
}}
/>
{showNewEmployee && (
<NewEmployeeDialog
open
onOpenChange={(open) => {
if (!open) closeNewEmployee()
}}
onCreated={() => {
closeNewEmployee()
setRefreshKey((k) => k + 1)
}}
/>
)}
</div>
)
}
@@ -10,6 +10,7 @@ import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** Translation keys in the `salary_run_employee` namespace. */
const LINE_ITEM_TYPE_KEYS: Record<SalaryLineItemType, string> = {
@@ -86,7 +87,7 @@ export default function SalaryRunEmployeeDetailPage({
if (!sreRes.ok) throw new Error(sreJson.error || t('error_load_employee'))
setData({ run: runJson.data, runEmployee: sreJson.data })
} catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setLoading(false)
}
@@ -108,7 +109,7 @@ export default function SalaryRunEmployeeDetailPage({
}
await load()
} catch (e) {
setError(e instanceof Error ? e.message : t('unknown_error'))
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
} finally {
setCalculating(false)
}
+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_paid_at: string | null
} | null>(null)
const [taxPaymentLoading, setTaxPaymentLoading] = useState(false)
// Skatteverket's per-period AGI submission record: drives the AGI step on
// the progress rail and the panel's state machine (underlag submitted /
@@ -79,11 +80,14 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
setRun(data)
if (data?.period_year && data?.period_month) {
const period = `${data.period_year}-${String(data.period_month).padStart(2, '0')}`
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
if (txRes.ok) {
const tx = await txRes.json()
setTaxPayment(tx.data)
}
setTaxPaymentLoading(true)
void fetch(`/api/skatteverket/tax-payments/${period}`)
.then(async (txRes) => (txRes.ok ? txRes.json() : null))
.then((tx) => {
if (tx) setTaxPayment(tx.data)
})
.catch(() => setTaxPayment(null))
.finally(() => setTaxPaymentLoading(false))
}
}
}
@@ -463,7 +467,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
} catch (err) {
toast({
title: t('toast_zip_failed'),
description: err instanceof Error ? err.message : t('unknown_error'),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
@@ -650,15 +654,24 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
{/* Tax payment (skatt + arbetsgivaravgifter): once AGI has been generated */}
{run.status === 'booked' && run.agi_generated_at && (
<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}
/>
taxPaymentLoading ? (
<Card>
<CardContent className="space-y-4 p-6">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
) : (
<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 */}
+5 -4
View File
@@ -41,6 +41,7 @@ import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/extensions/general/skatteverket/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface SaldoEnvelope {
data: SkatteverketSaldoResponse | null
@@ -194,7 +195,7 @@ export default function SkattekontoPage() {
} catch (err) {
toast({
title: 'Synk misslyckades',
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
@@ -222,7 +223,7 @@ export default function SkattekontoPage() {
} catch (err) {
toast({
title: 'Kunde inte bokföra',
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
@@ -246,7 +247,7 @@ export default function SkattekontoPage() {
} catch (err) {
toast({
title: 'Kunde inte hämta kandidater',
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
setMatchOpenFor(null)
@@ -278,7 +279,7 @@ export default function SkattekontoPage() {
} catch (err) {
toast({
title: 'Kunde inte koppla transaktionen',
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
+25 -21
View File
@@ -91,6 +91,7 @@ export default function SupplierInvoiceDetailPage() {
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
const [paymentAccount, setPaymentAccount] = useState('1930')
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [areAccountsLoading, setAreAccountsLoading] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [duplicateCandidates, setDuplicateCandidates] = useState<
Array<{
@@ -245,31 +246,27 @@ export default function SupplierInvoiceDetailPage() {
}
}, [isPayDialogOpen, invoice, payAmount, paymentAccount])
// Load chart of accounts and remember the last picked payment account so the
// dialog defaults to the user's previous choice instead of re-defaulting to
// 1930 every time.
// The chart of accounts is only needed by the payment dialog. Defer the
// request until the user opens it instead of blocking the detail page.
useEffect(() => {
if (!isPayDialogOpen || accounts.length > 0) return
let cancelled = false
;(async () => {
const [accountsRes, settingsRes] = await Promise.all([
fetch('/api/bookkeeping/accounts'),
fetch('/api/settings'),
])
if (cancelled) return
if (accountsRes.ok) {
const { data } = await accountsRes.json()
setAreAccountsLoading(true)
try {
const response = await fetch('/api/bookkeeping/accounts')
if (cancelled || !response.ok) return
const { data } = await response.json()
if (Array.isArray(data)) setAccounts(data as BASAccount[])
}
if (settingsRes.ok) {
const { data } = await settingsRes.json()
const last = (data as { last_supplier_payment_account?: string | null } | null)?.last_supplier_payment_account
if (last) setPaymentAccount(last)
} finally {
if (!cancelled) setAreAccountsLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
}, [accounts.length, isPayDialogOpen])
async function handleApprove() {
setIsProcessing(true)
@@ -898,6 +895,9 @@ export default function SupplierInvoiceDetailPage() {
open={isPayDialogOpen}
onOpenChange={(open) => {
setIsPayDialogOpen(open)
if (open && companySettings?.last_supplier_payment_account) {
setPaymentAccount(companySettings.last_supplier_payment_account)
}
if (!open) setPayTab('new')
}}
>
@@ -938,11 +938,15 @@ export default function SupplierInvoiceDetailPage() {
</div>
<div className="space-y-2">
<Label htmlFor="payment-account">Betalkonto</Label>
<AccountCombobox
value={paymentAccount}
accounts={accounts}
onChange={setPaymentAccount}
/>
{areAccountsLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<AccountCombobox
value={paymentAccount}
accounts={accounts}
onChange={setPaymentAccount}
/>
)}
<p className="text-xs text-muted-foreground">
T.ex. 1930 bankkonto, 1940 övrigt bankkonto, 2018 egna uttag (EF), 2893 ägarlån (AB).
</p>
+17 -9
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Skeleton } from "@/components/ui/skeleton"
@@ -13,7 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Plus, FileInput, Lock } from 'lucide-react'
import Link from 'next/link'
import { PageHeader } from '@/components/ui/page-header'
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -21,6 +22,11 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { SupplierInvoice } from '@/types'
const NewSupplierInvoiceDialog = dynamic(
() => import('@/components/supplier-invoices/NewSupplierInvoiceDialog'),
{ loading: DialogLoadingSkeleton },
)
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
registered: 'secondary',
approved: 'default',
@@ -267,14 +273,16 @@ export default function SupplierInvoicesPage() {
</TabsContent>
</Tabs>
<NewSupplierInvoiceDialog
open={showNewInvoice}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
inboxItemId={inboxItemId}
onCreated={handleCreated}
/>
{showNewInvoice && (
<NewSupplierInvoiceDialog
open
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
inboxItemId={inboxItemId}
onCreated={handleCreated}
/>
)}
</div>
)
}
+38 -5
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
@@ -13,17 +14,30 @@ import { PageHeader } from '@/components/ui/page-header'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Building2, Lock } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const SupplierForm = dynamic(
() => import('@/components/suppliers/SupplierForm'),
{
loading: () => (
<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> = {
swedish_business: 'type_swedish_business',
eu_business: 'type_eu_business',
non_eu_business: 'type_non_eu_business',
}
const INITIAL_VISIBLE_ROWS = 100
function getPaymentInfo(supplier: Supplier, t: (key: string) => string): { label: string; value: string } | null {
if (supplier.bankgiro) return { label: t('label_bg'), value: supplier.bankgiro }
@@ -46,10 +60,12 @@ export default function SuppliersPage() {
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
const tCommon = useTranslations('common')
async function fetchSuppliers() {
if (!company) return
@@ -111,6 +127,7 @@ export default function SuppliersPage() {
s.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.org_number?.includes(searchTerm)
)
const visibleSuppliers = filteredSuppliers.slice(0, visibleCount)
return (
<div className="space-y-8">
@@ -159,7 +176,10 @@ export default function SuppliersPage() {
<Input
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => {
setSearchTerm(e.target.value)
setVisibleCount(INITIAL_VISIBLE_ROWS)
}}
className="pl-10"
/>
</div>
@@ -200,8 +220,9 @@ export default function SuppliersPage() {
</CardContent>
</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 location = formatLocation(supplier)
return (
@@ -251,7 +272,19 @@ export default function SuppliersPage() {
</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>
)
+222 -184
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import type { SupabaseClient } from '@supabase/supabase-js'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { AnimatePresence } from 'framer-motion'
import { useSearchParams } from 'next/navigation'
@@ -23,8 +24,6 @@ import {
DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu'
import { ChevronDown, EyeOff, Layers, Search, ShieldAlert, Trash2, X } from 'lucide-react'
import TransactionForm from '@/components/transactions/TransactionForm'
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
@@ -33,21 +32,9 @@ import TransactionInboxCard from '@/components/transactions/TransactionInboxCard
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
import SkattekontoInboxCard from '@/components/transactions/SkattekontoInboxCard'
import { SkattekontoMatchDialog } from '@/components/skattekonto/SkattekontoMatchDialog'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import { MatchVoucherDialog } from '@/components/transactions/MatchVoucherDialog'
import InvoicePicker from '@/components/transactions/InvoicePicker'
import SupplierInvoicePicker from '@/components/transactions/SupplierInvoicePicker'
import MatchAllocationDialog from '@/components/transactions/MatchAllocationDialog'
import BulkBookDialog from '@/components/transactions/BulkBookDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import TransactionAttachDocumentDialog from '@/components/transactions/TransactionAttachDocumentDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog'
import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
import TemplatePicker from '@/components/transactions/TemplatePicker'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
@@ -67,6 +54,47 @@ import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status'
function InlineDialogContentLoading() {
return (
<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 SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
@@ -249,8 +277,8 @@ export default function TransactionsPage() {
} | null>(null)
const [duplicateProcessing, setDuplicateProcessing] = useState(false)
// Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma')
// Dashboard layout already resolved the effective entity type from settings.
const entityType = company?.entity_type ?? 'enskild_firma'
// Pagination
const [hasMore, setHasMore] = useState(false)
@@ -294,14 +322,17 @@ export default function TransactionsPage() {
const refreshTransactionsQueuedRef = useRef(false)
// Computed lists
const uncategorizedTransactions = transactions
.filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id))
.sort((a, b) => {
const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0
const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
return b.date.localeCompare(a.date)
})
const uncategorizedTransactions = useMemo(
() => transactions
.filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id))
.sort((a, b) => {
const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0
const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
return b.date.localeCompare(a.date)
}),
[exitingIds, transactions],
)
// Merged inbox: bank tx + SKV rows interleaved by date. Source filter
// narrows to one side. SKV rows always go after bank rows on the same
@@ -311,14 +342,24 @@ export default function TransactionsPage() {
| { source: 'bank'; date: string; data: TransactionWithInvoice }
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
const skvUnmatched = skvRows.filter(r => !r.journal_entry_id)
const skvUnmatched = useMemo(
() => skvRows.filter((row) => !row.journal_entry_id),
[skvRows],
)
const bankToSkvHints = findBankSkvCounterparts({
bankRows: uncategorizedTransactions.map(t => ({ id: t.id, date: t.date, amount: t.amount })),
skvRows: skvUnmatched,
})
const bankToSkvHints = useMemo(
() => findBankSkvCounterparts({
bankRows: uncategorizedTransactions.map((transaction) => ({
id: transaction.id,
date: transaction.date,
amount: transaction.amount,
})),
skvRows: skvUnmatched,
}),
[skvUnmatched, uncategorizedTransactions],
)
const inboxItems: InboxItem[] = (() => {
const inboxItems = useMemo<InboxItem[]>(() => {
const items: InboxItem[] = []
const query = searchTerm.trim().toLowerCase()
if (sourceFilter !== 'skatteverket') {
@@ -356,11 +397,14 @@ export default function TransactionsPage() {
if (a.source !== b.source) return a.source === 'bank' ? -1 : 1
return 0
})
})()
const transactionsWithMatches = transactions.filter(
(t) =>
(t.potential_invoice && !t.invoice_id) ||
(t.potential_supplier_invoice && !t.supplier_invoice_id),
}, [exitingIds, searchTerm, skvRows, sourceFilter, uncategorizedTransactions])
const transactionsWithMatches = useMemo(
() => transactions.filter(
(transaction) =>
(transaction.potential_invoice && !transaction.invoice_id) ||
(transaction.potential_supplier_invoice && !transaction.supplier_invoice_id),
),
[transactions],
)
const PAGE_SIZE = 200
@@ -413,6 +457,7 @@ export default function TransactionsPage() {
const fetchTransactions = useCallback(async (showLoading = false, includeSkvRows = false) => {
if (!companyId) return
if (showLoading) setIsLoading(true)
if (includeSkvRows) void loadSkvRows()
try {
const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([
supabase
@@ -451,13 +496,6 @@ export default function TransactionsPage() {
setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore(rows.length >= PAGE_SIZE)
// Fire-and-forget: load SKV rows in parallel with the rest of the
// page. We don't block on this: if the extension is disabled or the
// user isn't connected the response is 503/401 and we just leave the
// SKV section empty.
if (includeSkvRows) {
void loadSkvRows()
}
} finally {
if (showLoading) setIsLoading(false)
}
@@ -608,27 +646,9 @@ export default function TransactionsPage() {
}
}
// Fetch transactions and entity type in parallel on mount, then suggestions
// Fetch the initial page. Entity type is already available from CompanyContext.
useEffect(() => {
let cancelled = false
async function loadAll() {
// Fetch transactions and entity type in parallel
const [, entityRes] = await Promise.all([
fetchTransactions(true, true),
fetch('/api/settings').then(r => r.json()).catch(() => null),
])
if (cancelled) return
if (entityRes?.data?.entity_type) {
setEntityType(entityRes.data.entity_type)
}
}
loadAll()
return () => { cancelled = true }
void fetchTransactions(true, true)
}, [fetchTransactions])
useEffect(() => {
@@ -1647,7 +1667,7 @@ export default function TransactionsPage() {
} catch (err) {
toast({
title: 'Kunde inte bokföra',
description: err instanceof Error ? err.message : undefined,
description: err instanceof Error ? getErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
@@ -2349,68 +2369,82 @@ export default function TransactionsPage() {
)}
{/* Dialogs */}
<BatchCategorySelector
open={showBatchSelector}
onOpenChange={setShowBatchSelector}
selectedCount={selectedIds.size}
onSelectCategory={handleBatchCategorize}
progress={batchProgress}
/>
{showBatchSelector && (
<BatchCategorySelector
open
onOpenChange={setShowBatchSelector}
selectedCount={selectedIds.size}
onSelectCategory={handleBatchCategorize}
progress={batchProgress}
/>
)}
<InvoiceMatchDialog
open={matchDialogOpen}
onOpenChange={setMatchDialogOpen}
transaction={selectedTransaction}
isConfirming={isConfirmingMatch}
onConfirm={handleConfirmInvoiceMatch}
onLinkToExisting={handleLinkToExistingVoucher}
/>
{matchDialogOpen && (
<InvoiceMatchDialog
open
onOpenChange={setMatchDialogOpen}
transaction={selectedTransaction}
isConfirming={isConfirmingMatch}
onConfirm={handleConfirmInvoiceMatch}
onLinkToExisting={handleLinkToExistingVoucher}
/>
)}
<MatchVoucherDialog
open={matchVoucherTx !== null}
onOpenChange={(o) => { if (!o) setMatchVoucherTx(null) }}
transaction={matchVoucherTx}
onLinked={handleVoucherLinked}
/>
{matchVoucherTx && (
<MatchVoucherDialog
open
onOpenChange={(o) => { if (!o) setMatchVoucherTx(null) }}
transaction={matchVoucherTx}
onLinked={handleVoucherLinked}
/>
)}
<MatchAllocationDialog
open={splitMatchOpen}
onOpenChange={(o) => {
setSplitMatchOpen(o)
if (!o) setSplitMatchTransaction(null)
}}
transaction={splitMatchTransaction}
onSuccess={handleSplitMatchSuccess}
/>
{splitMatchOpen && (
<MatchAllocationDialog
open
onOpenChange={(o) => {
setSplitMatchOpen(o)
if (!o) setSplitMatchTransaction(null)
}}
transaction={splitMatchTransaction}
onSuccess={handleSplitMatchSuccess}
/>
)}
<BulkBookDialog
open={bulkBookOpen}
onOpenChange={setBulkBookOpen}
transactions={selectedTransactions}
onSuccess={handleBulkBookSuccess}
/>
{bulkBookOpen && (
<BulkBookDialog
open
onOpenChange={setBulkBookOpen}
transactions={selectedTransactions}
onSuccess={handleBulkBookSuccess}
/>
)}
<TransactionBookingDialog
open={bookingDialogOpen}
onOpenChange={(o) => {
setBookingDialogOpen(o)
if (!o) setBookingDialogTemplate(null)
}}
transaction={bookingDialogTransaction}
preselectedTemplate={bookingDialogTemplate}
onBooked={handleTransactionBooked}
/>
{bookingDialogOpen && (
<TransactionBookingDialog
open
onOpenChange={(o) => {
setBookingDialogOpen(o)
if (!o) setBookingDialogTemplate(null)
}}
transaction={bookingDialogTransaction}
preselectedTemplate={bookingDialogTemplate}
onBooked={handleTransactionBooked}
/>
)}
<TransactionAttachDocumentDialog
open={attachDocTx !== null}
onOpenChange={(o) => {
if (!o) setAttachDocTx(null)
}}
transaction={attachDocTx}
onAttached={handleDocumentAttached}
/>
{attachDocTx && (
<TransactionAttachDocumentDialog
open
onOpenChange={(o) => {
if (!o) setAttachDocTx(null)
}}
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">
<DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle>
@@ -2471,10 +2505,10 @@ export default function TransactionsPage() {
onPickLibraryTemplate={handlePickLibraryTemplate}
/>
</DialogContent>
</Dialog>
</Dialog>}
<Dialog
open={invoicePickerOpen}
{invoicePickerOpen && <Dialog
open
onOpenChange={(open) => {
if (isMatchingFromPicker) return
setInvoicePickerOpen(open)
@@ -2501,10 +2535,10 @@ export default function TransactionsPage() {
</>
)}
</DialogContent>
</Dialog>
</Dialog>}
<Dialog
open={supplierInvoicePickerOpen}
{supplierInvoicePickerOpen && <Dialog
open
onOpenChange={(open) => {
if (isMatchingSupplierFromPicker) return
setSupplierInvoicePickerOpen(open)
@@ -2531,67 +2565,73 @@ export default function TransactionsPage() {
</>
)}
</DialogContent>
</Dialog>
</Dialog>}
<QuickReviewDialog
key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId) + String(quickReview?.template?.id)}
open={quickReviewOpen}
onOpenChange={setQuickReviewOpen}
transaction={quickReview?.transaction ?? null}
category={quickReview?.category ?? null}
categoryLabel={quickReview?.label ?? ''}
defaultAccount={
// For library templates (no templateId but a template object), use the
// template's debit account as the default; otherwise fall back to the
// category's default account.
!quickReview?.templateId && quickReview?.template
? quickReview.template.debit_account
: quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : ''
}
defaultVat={
!quickReview?.templateId && quickReview?.template
? (quickReview.template.vat_treatment ?? 'none')
: quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none'
}
entityType={entityType as EntityType}
template={quickReview?.template ?? null}
templateId={quickReview?.templateId}
counterpartyLinePattern={quickReview?.linePattern ?? null}
onConfirm={handleQuickReviewConfirm}
onChangeTemplate={handleChangeTemplate}
/>
{quickReviewOpen && (
<QuickReviewDialog
key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId) + String(quickReview?.template?.id)}
open
onOpenChange={setQuickReviewOpen}
transaction={quickReview?.transaction ?? null}
category={quickReview?.category ?? null}
categoryLabel={quickReview?.label ?? ''}
defaultAccount={
// For library templates (no templateId but a template object), use the
// template's debit account as the default; otherwise fall back to the
// category's default account.
!quickReview?.templateId && quickReview?.template
? quickReview.template.debit_account
: quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : ''
}
defaultVat={
!quickReview?.templateId && quickReview?.template
? (quickReview.template.vat_treatment ?? 'none')
: quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none'
}
entityType={entityType as EntityType}
template={quickReview?.template ?? null}
templateId={quickReview?.templateId}
counterpartyLinePattern={quickReview?.linePattern ?? null}
onConfirm={handleQuickReviewConfirm}
onChangeTemplate={handleChangeTemplate}
/>
)}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
{isDialogOpen && <Dialog open onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dialog_add_transaction')}</DialogTitle>
</DialogHeader>
<TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} />
</DialogContent>
</Dialog>
</Dialog>}
<DestructiveConfirmDialog {...confirmDialogProps} />
<EditTransactionTitleDialog
open={editTitleTarget !== null}
onOpenChange={(v) => {
if (!v) setEditTitleTarget(null)
}}
currentTitle={editTitleTarget?.description ?? ''}
originalTitle={editTitleTarget?.original_description ?? null}
onSave={handleSaveTitle}
/>
{editTitleTarget && (
<EditTransactionTitleDialog
open
onOpenChange={(v) => {
if (!v) setEditTitleTarget(null)
}}
currentTitle={editTitleTarget.description ?? ''}
originalTitle={editTitleTarget.original_description ?? null}
onSave={handleSaveTitle}
/>
)}
<SkattekontoMatchDialog
row={skvMatchTarget}
open={!!skvMatchTarget}
onClose={() => setSkvMatchTarget(null)}
onMatched={handleSkvMatched}
/>
{skvMatchTarget && (
<SkattekontoMatchDialog
row={skvMatchTarget}
open
onClose={() => setSkvMatchTarget(null)}
onMatched={handleSkvMatched}
/>
)}
{/* Prong B: match-against-supplier-invoice suggestion */}
<Dialog
open={siMatchSuggestion !== null}
{siMatchSuggestion && <Dialog
open
onOpenChange={(open) => {
if (!open) setSiMatchSuggestion(null)
}}
@@ -2645,11 +2685,11 @@ export default function TransactionsPage() {
</div>
</div>
</DialogContent>
</Dialog>
</Dialog>}
{/* Prong B (customer side): match-against-customer-invoice suggestion */}
<Dialog
open={ciMatchSuggestion !== null}
{ciMatchSuggestion && <Dialog
open
onOpenChange={(open) => {
if (!open) setCiMatchSuggestion(null)
}}
@@ -2708,10 +2748,10 @@ export default function TransactionsPage() {
</div>
</div>
</DialogContent>
</Dialog>
</Dialog>}
<DuplicateBookingDialog
candidate={duplicateWarning?.candidate ?? null}
{duplicateWarning && <DuplicateBookingDialog
candidate={duplicateWarning.candidate}
processing={duplicateProcessing}
onCancel={() => setDuplicateWarning(null)}
// Ledger-only candidate (transaction_id null, e.g. a verifikat from an
@@ -2719,11 +2759,9 @@ export default function TransactionsPage() {
// voucher instead of double-booking it. Success refreshes the same
// state a MatchVoucherDialog link does.
matchTransaction={
duplicateWarning
? transactions.find((tx) => tx.id === duplicateWarning.transactionId) ?? {
id: duplicateWarning.transactionId,
}
: null
transactions.find((tx) => tx.id === duplicateWarning.transactionId) ?? {
id: duplicateWarning.transactionId,
}
}
onMatched={(transactionId, journalEntryId, voucherLabel) => {
setDuplicateWarning(null)
@@ -2739,7 +2777,7 @@ export default function TransactionsPage() {
setDuplicateProcessing(false)
}
}}
/>
/>}
</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(body.error).toContain('Password too weak')
expect(body.error).toBe('Något gick fel. Försök igen.')
expect(updateUser).not.toHaveBeenCalled()
expect(flagFlipCall(updateUserById)).toBeUndefined()
})
@@ -273,7 +273,7 @@ describe('POST /api/account/password', () => {
)
expect(status).toBe(400)
expect(body.error).toContain('Password too similar')
expect(body.error).toBe('Något gick fel. Försök igen.')
expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
expect(flagFlipCall(updateUserById)).toBeUndefined()
})
+2 -1
View File
@@ -4,6 +4,7 @@ import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
import { validateBody } from '@/lib/api/validate'
import { createLogger } from '@/lib/logger'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const log = createLogger('api/account/password')
@@ -85,7 +86,7 @@ export async function POST(request: Request) {
return NextResponse.json(
{
error:
updateError.message ||
getUserErrorMessage(updateError) ||
'Kunde inte uppdatera lösenord. Försök igen.',
},
{ status: 400 },
+4 -3
View File
@@ -10,6 +10,7 @@ import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Make sure extensions are loaded: the chat loop dispatches against the
// agent tool registry which is populated by the mcp-server extension at load.
@@ -87,7 +88,7 @@ export async function POST(request: Request) {
body = BodySchema.parse(await request.json())
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 },
)
}
@@ -164,7 +165,7 @@ export async function POST(request: Request) {
.single()
if (convErr || !newConv) {
return NextResponse.json(
{ error: convErr?.message ?? 'Failed to create conversation' },
{ error: getUserErrorMessage(convErr) ?? 'Failed to create conversation' },
{ status: 500 },
)
}
@@ -202,7 +203,7 @@ export async function POST(request: Request) {
{
error:
err instanceof Error
? `Capture failed: ${err.message}`
? `Capture failed: ${getUserErrorMessage(err)}`
: 'Capture failed',
},
{ status: 500 },
+4 -3
View File
@@ -15,6 +15,7 @@ import { preWarmAtomCache } from '@/lib/agent/composer/prewarm'
import { OPUS_MODEL } from '@/lib/agent/composer/client'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
import type { AtomSelection } from '@/lib/agent/composer/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
@@ -82,7 +83,7 @@ export async function POST(request: Request) {
body = BodySchema.parse(await request.json().catch(() => ({})))
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid body' },
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Invalid body' },
{ status: 400 },
)
}
@@ -229,7 +230,7 @@ export async function POST(request: Request) {
{ onConflict: 'company_id' },
)
if (upsertErr) {
send({ step: 'error', status: 'error', message: upsertErr.message })
send({ step: 'error', status: 'error', message: getUserErrorMessage(upsertErr) })
return
}
@@ -273,7 +274,7 @@ export async function POST(request: Request) {
send({
step: 'error',
status: 'error',
message: err instanceof Error ? err.message : 'Composer pipeline failed',
message: err instanceof Error ? getUserErrorMessage(err) : 'Composer pipeline failed',
})
} finally {
try {
+6 -5
View File
@@ -8,6 +8,7 @@ import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Article } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -32,7 +33,7 @@ export const GET = withRouteContext(
opLog.error('article fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -97,7 +98,7 @@ export const PATCH = withRouteContext(
opLog.error('article update failed', error)
return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -135,7 +136,7 @@ export const DELETE = withRouteContext(
opLog.error('article lookup before delete failed', articleError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: articleError.message },
details: { reason: getUserErrorMessage(articleError) },
})
}
@@ -149,7 +150,7 @@ export const DELETE = withRouteContext(
opLog.error('article usage check failed', usageError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: usageError.message },
details: { reason: getUserErrorMessage(usageError) },
})
}
@@ -170,7 +171,7 @@ export const DELETE = withRouteContext(
opLog.error('article delete failed', deleteError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: deleteError.message },
details: { reason: getUserErrorMessage(deleteError) },
})
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookk
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { Article } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -99,7 +100,7 @@ export const POST = withRouteContext(
log.error('article insert failed', error)
return errorResponseFromCode('ARTICLE_CREATE_FAILED', log, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -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 { AccountBalancesQuerySchema } from '@/lib/api/schemas'
import { getOpeningBalances } from '@/lib/reports/opening-balances'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Per-account saldo as of a date. Used by the journal-entry form to show
@@ -90,46 +89,32 @@ export const GET = withRouteContext('bookkeeping.account_balances', async (reque
// Sum activity from period_start through as_of, excluding the OB entry
// (its lines are already in openingBalances).
let lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
try {
lines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select(
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)'
)
.eq('journal_entries.company_id', companyId)
.in('account_number', accounts)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', period.period_start)
.lte('journal_entries.entry_date', as_of)
const { data: activityRows, error: activityError } = await supabase.rpc(
'get_account_period_activity',
{
p_company_id: companyId,
p_start: period.period_start,
p_end: as_of,
p_accounts: accounts,
p_exclude_journal_entry_id: obEntryId,
},
)
if (obEntryId) {
query = query.neq('journal_entry_id', obEntryId)
}
// Stable total order for correct paging (see fetch-all.ts).
return query.order('id', { ascending: true }).range(from, to)
})
} catch (err) {
if (activityError) {
log.error('period activity lookup failed', {
companyId,
period_id: period.id,
error: err instanceof Error ? err.message : String(err),
error: activityError.message,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
const periodActivity = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
const existing = periodActivity.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
periodActivity.set(line.account_number, existing)
for (const row of activityRows ?? []) {
periodActivity.set(row.account_number, {
debit: Number(row.debit) || 0,
credit: Number(row.credit) || 0,
})
}
return NextResponse.json({
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// DELETE hard-deletes an unused, non-system account; accounts referenced by
// this company's journal entries must be deactivated instead (PUT is_active).
@@ -57,7 +58,7 @@ export const DELETE = withRouteContext(
.eq('company_id', companyId)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 })
}
return NextResponse.json({ success: true })
@@ -95,7 +96,7 @@ export const PUT = withRouteContext(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/bookkeeping/accounts/activate
@@ -41,7 +42,7 @@ export const POST = withRouteContext(
.in('account_number', uniqueNumbers)
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
const existingByNumber = new Map<string, boolean>(
@@ -79,7 +80,7 @@ export const POST = withRouteContext(
.in('account_number', toReactivate)
.select('account_number')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
reactivatedRows = data || []
}
@@ -91,7 +92,7 @@ export const POST = withRouteContext(
.insert(toInsert)
.select('account_number')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
insertedRows = data || []
}
+4 -3
View File
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { PruneAccountsSchema } from '@/lib/api/schemas'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// POST /api/bookkeeping/accounts/prune — bulk cleanup of unused accounts
// ("Rensa oanvända konton"), for charts bloated by an import from an old
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
{ p_company_id: companyId },
)
if (usageError) {
return NextResponse.json({ error: usageError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(usageError) }, { status: 500 })
}
const usageByAccount = new Map<string, number>(
@@ -134,7 +135,7 @@ export const POST = withRouteContext(
// Report what was already deleted so the UI can refresh honestly.
return NextResponse.json(
{
error: deleteError.message,
error: getUserErrorMessage(deleteError),
data: { deleted: toDelete.slice(0, i), skipped, not_found: notFound },
},
{ status: 500 },
@@ -153,7 +154,7 @@ export const POST = withRouteContext(
})
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to prune accounts' },
{ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to prune accounts' },
{ status: 500 },
)
}
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/bookkeeping/accounts/reference
@@ -33,6 +34,6 @@ export const GET = withRouteContext('bookkeeping.accounts.reference', async (_re
return NextResponse.json({ data: userAccounts })
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
return NextResponse.json({ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to fetch accounts' }, { status: 500 })
}
})
+3 -2
View File
@@ -4,6 +4,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ data }` / `{ error: string }` — several pages
// (import, supplier-invoices, article form) consume the list directly.
@@ -46,7 +47,7 @@ export const GET = withRouteContext('bookkeeping.accounts.list', async (request,
return NextResponse.json({ data })
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch accounts' },
{ error: error instanceof Error ? getUserErrorMessage(error) : 'Failed to fetch accounts' },
{ status: 500 },
)
}
@@ -93,7 +94,7 @@ export const POST = withRouteContext(
{ status: 409 },
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// GET /api/bookkeeping/accounts/usage — per-account posting counts for the
// active company, from the get_account_usage_counts RPC. Accounts that have
@@ -17,7 +18,7 @@ export const GET = withRouteContext('bookkeeping.accounts.usage', async (_reques
})
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: data ?? [] })
@@ -106,6 +106,6 @@ describe('POST /api/bookkeeping/accruals/[id]/dissolve', () => {
expect(status).toBe(400)
expect(body.error.code).toBe('ACCRUAL_DISSOLVE_FAILED')
expect(body.error.details.reason).toMatch(/Ingen öppen räkenskapsperiod/)
expect(body.error.details.reason).toBe('Något gick fel. Försök igen.')
})
})
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { dissolveScheduleNow } from '@/lib/bookkeeping/accruals/service'
import {
@@ -39,7 +40,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
return NextResponse.json({ data: result })
} catch (err) {
const reason = err instanceof Error ? err.message : 'unknown'
// Typed domain errors carry a stable code: never match Swedish prose.
if (isAccrualError(err)) {
switch (err.code) {
@@ -57,7 +57,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
log.error('accrual dissolve failed', err as Error, { entityId: id })
return errorResponseFromCode('ACCRUAL_DISSOLVE_FAILED', log, {
requestId,
details: { reason },
details: { reason: getErrorMessage(err) },
})
}
},
@@ -5,6 +5,7 @@ import { createServiceClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { postDueInstallments } from '@/lib/bookkeeping/accruals/service'
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -42,7 +43,7 @@ export const GET = withCronContext('cron.accrual_postings', async (_request, ctx
} catch (error) {
ctx.log.error('failed to load due accrual installments', error as Error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'unknown' },
{ success: false, error: error instanceof Error ? getUserErrorMessage(error) : 'unknown' },
{ status: 500 },
)
}
@@ -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 { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
import { getVersionIxbrlInput } from '@/lib/bokslut/arsredovisning/version-ixbrl'
/**
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl
*
* Generates the iXBRL (XHTML) årsredovisning for the period. The document IS
* the presentation (per TILLAMPNINGSANVISNING): the wizard renders it in an
* iframe as the authoritative preview, and `?download=1` hands the same bytes
* to the user for manual filing at bolagsverket.se (the self-hosted path).
* iframe as the authoritative preview, and `?download=1` exports the same
* bytes for validation and the company's archive. The XHTML file is not a
* standalone manual filing path: digital filing goes through connected
* software and the paper fallback is a certified copy sent by post.
*
* Query params:
* - download=1 → Content-Disposition: attachment
@@ -23,12 +26,19 @@ export const GET = withRouteContext(
try {
const url = new URL(request.url)
const download = url.searchParams.get('download') === '1'
const versionId = url.searchParams.get('version')
const utdelningRaw = url.searchParams.get('utdelning')
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : undefined
const input = await buildIxbrlInput(supabase, companyId, id, {
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
})
const input = versionId
? await getVersionIxbrlInput(supabase, companyId, id, versionId)
: await buildIxbrlInput(supabase, companyId, id, {
proposedDividend:
proposedDividend !== undefined && Number.isFinite(proposedDividend)
? proposedDividend
: undefined,
})
if (!input) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const { xhtml, warnings } = generateK2IxbrlDocument(input)
const safePeriodEnd = input.period.end.replace(/[^\w.-]/g, '_')
@@ -43,6 +53,7 @@ export const GET = withRouteContext(
Pragma: 'no-cache',
// Generation warnings surfaced without disturbing the body.
'X-Ixbrl-Warning-Count': String(warnings.length),
...(versionId ? { 'X-Annual-Report-Version': versionId } : {}),
},
})
} catch (err) {
@@ -4,6 +4,8 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks, type PreflightIssue } from '@/lib/bokslut/ixbrl/validate/rules'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { getVersionIxbrlInput } from '@/lib/bokslut/arsredovisning/version-ixbrl'
/**
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl/validate
@@ -21,12 +23,19 @@ export const GET = withRouteContext(
const { supabase, companyId, log, requestId } = ctx
try {
const url = new URL(request.url)
const versionId = url.searchParams.get('version')
const utdelningRaw = url.searchParams.get('utdelning')
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : undefined
const input = await buildIxbrlInput(supabase, companyId, id, {
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
})
const input = versionId
? await getVersionIxbrlInput(supabase, companyId, id, versionId)
: await buildIxbrlInput(supabase, companyId, id, {
proposedDividend:
proposedDividend !== undefined && Number.isFinite(proposedDividend)
? proposedDividend
: undefined,
})
if (!input) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const result = runPreflightChecks(input)
// Generation dry-run: a document that cannot even be generated must
@@ -47,7 +56,7 @@ export const GET = withRouteContext(
issues.push({
code: 'ACC-GEN',
severity: 'error',
message: `iXBRL-dokumentet kunde inte genereras: ${genErr instanceof Error ? genErr.message : 'okänt fel'}`,
message: `iXBRL-dokumentet kunde inte genereras: ${genErr instanceof Error ? getUserErrorMessage(genErr) : 'okänt fel'}`,
})
}
@@ -61,6 +70,7 @@ export const GET = withRouteContext(
generated_bytes: generatedBytes,
entry_point: input.entryPointId,
period: input.period,
annual_report_version_id: versionId,
},
})
} catch (err) {
@@ -121,6 +121,15 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
expect(res.status).toBe(400)
})
it('returns 400 when the payload contains an unknown field', async () => {
setupSupabase()
const res = await POST(
postReq({ description: 'x', annual_report_version_id: 'forged-version' }),
idParams,
)
expect(res.status).toBe(400)
})
it('returns 404 when the period does not belong to the company', async () => {
const { enqueue } = setupSupabase()
enqueue({ data: null }) // fiscal_periods ownership check
@@ -135,6 +144,7 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check
enqueue({ data: null }) // no registrerad submission
enqueue({ data: narrativeRow }) // upsert
enqueue({ data: null }) // clear narrative confirmation
const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>(
await POST(postReq({ description: 'Bolaget bedriver konsultverksamhet.' }), idParams),
)
@@ -30,6 +30,15 @@ const PostSchema = z.object({
description: sanitizedText(4000).nullable().optional(),
important_events: sanitizedText(4000).nullable().optional(),
resultatdisposition: sanitizedText(2000).nullable().optional(),
proposed_dividend: z
.number()
.min(0)
.max(1_000_000_000_000)
.nullable()
.optional()
.transform((value) =>
value === null || value === undefined ? value : Math.round(value * 100) / 100,
),
// ISO YYYY-MM-DD per the DATE column; null clears it. Validate as a
// real calendar date (not just regex) so '2024-13-99' returns 400 from
// the API instead of bubbling up as a Postgres 500.
@@ -75,6 +84,26 @@ const PostSchema = z.object({
.nullable()
.optional(),
parent_company_city: sanitizedText(100).nullable().optional(),
long_term_debt_over_five_years_confirmed: z.boolean().optional(),
securities_pledged_confirmed: z.boolean().optional(),
contingent_liabilities_confirmed: z.boolean().optional(),
parent_company_confirmed: z.boolean().optional(),
agm_disposition_outcome: z
.enum(['proposal_approved', 'alternative_decision'])
.nullable()
.optional(),
agm_disposition_decision: sanitizedText(2000).nullable().optional(),
}).strict().superRefine((value, ctx) => {
if (
value.agm_disposition_outcome === 'alternative_decision' &&
!value.agm_disposition_decision?.trim()
) {
ctx.addIssue({
code: 'custom',
path: ['agm_disposition_decision'],
message: 'Årsstämmans alternativa beslut måste beskrivas.',
})
}
})
export const GET = withRouteContext(
@@ -150,6 +179,14 @@ export const POST = withRouteContext(
return errorResponseFromCode('ARSREDOVISNING_REGISTERED', log, { requestId })
}
const data = await upsertNarrative(supabase, companyId, user.id, id, validation.data)
const { error: confirmationError } = await supabase
.from('annual_report_profiles')
.update({ narrative_confirmed_at: null })
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
if (confirmationError) {
throw new Error(`Failed to clear narrative confirmation: ${confirmationError.message}`)
}
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
@@ -1,20 +1,53 @@
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import { getAnnualReportVersion } from '@/lib/bokslut/arsredovisning/version-service'
import { ArsredovisningPDF } from '@/lib/bokslut/arsredovisning/arsredovisning-pdf'
import { ArsredovisningK3PDF } from '@/lib/bokslut/arsredovisning/arsredovisning-k3-pdf'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
export const GET = withRouteContext(
'period.arsredovisning_pdf',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
// Narrative edits come from arsredovisning_narratives now, loaded
// inside buildArsredovisningData. The URL stays clean, no narrative
// text in query params, access logs, or browser history.
const data = await buildArsredovisningData(supabase, companyId, id)
const versionId = new URL(request.url).searchParams.get('version')
let versionStatus: string | null = null
let data: ArsredovisningData
if (versionId) {
const version = await getAnnualReportVersion(supabase, companyId, id, versionId)
if (!version) {
return errorResponseFromCode('NOT_FOUND', log, { requestId })
}
versionStatus = version.summary.status
data = structuredClone(version.report_data)
const { data: signatureRows, error: signatureError } = await supabase
.from('arsredovisning_signature_requests')
.select('role, signer_name, signed_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
.eq('annual_report_version_id', versionId)
.order('created_at', { ascending: true })
if (signatureError) {
throw new Error(`Failed to load version signatures: ${signatureError.message}`)
}
data.signatures = (signatureRows ?? []).map((signature) => ({
role: signature.role,
name: signature.signer_name,
signed_at: signature.signed_at,
}))
} else {
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: 'draft',
includeIxbrl: false,
})
data = model.report
}
// Dispatch on the framework recorded in the data. K3 documents need
// the additional kassaflöde + equity-changes pages + richer noter
// that ArsredovisningK3PDF renders. K2 (the default) keeps the
@@ -29,7 +62,10 @@ export const GET = withRouteContext(
// Sanitize the dynamic segment so a stray quote / newline in the date
// (defensive, unlikely to ever happen) can't break the header.
const safePeriodEnd = data.fiscal_period.period_end.replace(/[^\w.-]/g, '_')
const filename = `arsredovisning-${safePeriodEnd}-utkast.pdf`
const suffix = versionStatus && ['signed', 'filed', 'registered'].includes(versionStatus)
? 'papperskopia'
: 'utkast'
const filename = `arsredovisning-${safePeriodEnd}-${suffix}.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
@@ -38,6 +74,7 @@ export const GET = withRouteContext(
// intermediary cache the document.
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
...(versionId ? { 'X-Annual-Report-Version': versionId } : {}),
},
})
} catch (err) {
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model'
import { getAnnualReportCapabilities } from '@/lib/bokslut/arsredovisning/capabilities'
export const GET = withRouteContext(
'period.arsredovisning_data',
@@ -9,8 +10,23 @@ export const GET = withRouteContext(
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const data = await buildArsredovisningData(supabase, companyId, id)
return NextResponse.json({ data })
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: 'draft',
includeIxbrl: false,
})
return NextResponse.json({
data: model.report,
compliance: {
profile: model.profile,
disclosures: model.disclosures,
eligibility: model.eligibility,
validation: model.validation,
capabilities: getAnnualReportCapabilities(
model.report.accounting_framework,
model.eligibility,
),
},
})
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
@@ -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 { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { markSignatureSigned } from '@/lib/bokslut/arsredovisning/signature-service'
import { createServiceClient } from '@/lib/supabase/server'
// PATCH transitions: pending → signed (manual entry for the paper / outside-
// BankID flow) or pending → declined. Real BankID wiring lands in a future
@@ -14,9 +16,39 @@ import { validateBody } from '@/lib/api/validate'
// /periods/A/signatures/SIG_FROM_B can't bypass the path scope
// - .eq('status', 'pending'): state-machine guard so a signed or declined
// row can't be flipped back
const PatchSchema = z.object({
status: z.enum(['signed', 'declined']),
})
const EvidenceReferenceSchema = z
.string()
.trim()
.min(1)
.max(128)
.regex(/^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$/)
const PatchSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('signed'),
annual_report_version_id: z.string().uuid(),
signing_method: z.enum(['paper_original', 'advanced_e_signature', 'bankid']),
evidence_reference: EvidenceReferenceSchema,
signed_at: z.string().datetime().optional(),
}).strict(),
z.object({ status: z.literal('declined') }).strict(),
])
function stockholmDate(instant: string | Date): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(typeof instant === 'string' ? new Date(instant) : instant)
}
function isSignatureDateAllowed(signedAt: string, finalizedAt: string): boolean {
const signedDate = stockholmDate(signedAt)
const finalizedDate = stockholmDate(finalizedAt)
const today = stockholmDate(new Date())
return signedDate >= finalizedDate && signedDate <= today
}
export const PATCH = withRouteContext(
'period.arsredovisning_signature_patch',
@@ -30,15 +62,65 @@ export const PATCH = withRouteContext(
const validation = await validateBody(request, PatchSchema)
if (!validation.success) return validation.response
const update =
validation.data.status === 'signed'
? { status: 'signed' as const, signed_at: new Date().toISOString() }
: { status: 'declined' as const }
try {
const { data, error } = await supabase
if (validation.data.status === 'signed') {
const { data: version, error: versionError } = await supabase
.from('annual_report_versions')
.select('id, status, finalized_at')
.eq('id', validation.data.annual_report_version_id)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'ready_for_signature')
.maybeSingle()
if (versionError) {
throw new Error(`Failed to load annual report version: ${versionError.message}`)
}
if (!version?.finalized_at) {
return errorResponseFromCode('ARSREDOVISNING_VERSION_NOT_SIGNABLE', log, {
requestId,
})
}
const { data: requestRow, error: requestError } = await supabase
.from('arsredovisning_signature_requests')
.select('id, annual_report_version_id, status')
.eq('id', signatureId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'pending')
.maybeSingle()
if (requestError) {
throw new Error(`Failed to load annual report signature: ${requestError.message}`)
}
if (
!requestRow ||
(requestRow.annual_report_version_id !== null &&
requestRow.annual_report_version_id !== validation.data.annual_report_version_id)
) {
return NextResponse.json(
{ error: { code: 'SIGNATURE_INVALID_TRANSITION' } },
{ status: 409 },
)
}
const signedAt = validation.data.signed_at ?? new Date().toISOString()
if (!isSignatureDateAllowed(signedAt, version.finalized_at)) {
return errorResponseFromCode('ARSREDOVISNING_SIGNATURE_DATE_INVALID', log, {
requestId,
})
}
const data = await markSignatureSigned(createServiceClient(), companyId, signatureId, {
fiscalPeriodId,
annualReportVersionId: validation.data.annual_report_version_id,
signingMethod: validation.data.signing_method,
evidenceReference: validation.data.evidence_reference,
evidenceRecordedBy: ctx.user.id,
signedAt,
})
return NextResponse.json({ data })
}
const { data, error } = await createServiceClient()
.from('arsredovisning_signature_requests')
.update(update)
.update({ status: 'declined' as const })
.eq('id', signatureId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
@@ -65,3 +147,38 @@ export const PATCH = withRouteContext(
},
{ requireWrite: true },
)
export const DELETE = withRouteContext(
'period.arsredovisning_signature_delete',
async (
_request,
ctx,
{ params }: { params: Promise<{ id: string; signatureId: string }> },
) => {
const { id: fiscalPeriodId, signatureId } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.delete()
.eq('id', signatureId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'pending')
.is('annual_report_version_id', null)
.select('id')
.maybeSingle()
if (error) throw new Error(`Failed to remove annual report signer: ${error.message}`)
if (!data) {
return NextResponse.json(
{ error: { code: 'ARSREDOVISNING_SIGNER_ROSTER_LOCKED' } },
{ status: 409 },
)
}
return new NextResponse(null, { status: 204 })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
@@ -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.
const CreateSchema = z.object({
role: z.enum(['Styrelseledamot', 'Styrelseordförande', 'VD', 'Verkställande direktör']),
signer_name: z.string().min(1).max(200),
signer_name: z.string().trim().min(1).max(200),
})
export const GET = withRouteContext(
@@ -43,15 +43,35 @@ export const POST = withRouteContext(
// authenticated company before writing. RLS would catch a cross-tenant
// insert anyway, but rejecting at the route layer gives a cleaner
// 404 + avoids the noisy RLS error in the structured-error envelope.
const { data: period } = await supabase
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (periodError) throw new Error(`Failed to load fiscal period: ${periodError.message}`)
if (!period) {
return NextResponse.json({ error: { code: 'PERIOD_NOT_FOUND' } }, { status: 404 })
}
const { data: duplicate, error: duplicateError } = await supabase
.from('arsredovisning_signature_requests')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
.eq('status', 'pending')
.is('annual_report_version_id', null)
.ilike('role', validation.data.role.trim())
.ilike('signer_name', validation.data.signer_name.trim())
.maybeSingle()
if (duplicateError) {
throw new Error(`Failed to check annual report signer roster: ${duplicateError.message}`)
}
if (duplicate) {
return NextResponse.json(
{ error: { code: 'ARSREDOVISNING_SIGNER_ALREADY_EXISTS' } },
{ status: 409 },
)
}
const data = await createSignatureRequest(supabase, companyId, user.id, id, validation.data)
return NextResponse.json({ data })
} catch (err) {
@@ -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 { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildBokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
@@ -27,7 +28,7 @@ export const GET = withRouteContext(
}
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, {
requestId,
details: { reason: message },
details: { reason: getErrorMessage(err) },
})
}
},
@@ -8,6 +8,37 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/bokslut/dispositions-proposal-builder', () => ({
buildDispositionsProposal: vi.fn(),
buildLatentTaxProposal: vi.fn(),
}))
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
loadTaxAdjustmentSnapshot: vi.fn(),
saveTaxAdjustments: vi.fn(),
}))
vi.mock('@/lib/bokslut/tax-provision/bolagsskatt-calculator', () => ({
calculateBolagsskatt: vi.fn(),
getBookedBolagsskatt: vi.fn(),
sumPostedYearEndDispositions: vi.fn(),
}))
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: vi.fn(),
}))
vi.mock('@/lib/bokslut/reserves/periodiseringsfond-service', async (importOriginal) => {
const actual = await importOriginal<
typeof import('@/lib/bokslut/reserves/periodiseringsfond-service')
>()
return { ...actual, listExistingPeriodiseringsfonder: vi.fn() }
})
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
}))
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
@@ -23,7 +54,20 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { POST } from '../route'
import { buildDispositionsProposal } from '@/lib/bokslut/dispositions-proposal-builder'
import {
loadTaxAdjustmentSnapshot,
saveTaxAdjustments,
} from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import {
calculateBolagsskatt,
getBookedBolagsskatt,
sumPostedYearEndDispositions,
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { listExistingPeriodiseringsfonder } from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { POST, PUT } from '../route'
const idParams = { params: Promise.resolve({ id: 'period-1' }) }
@@ -37,10 +81,152 @@ function post(body: unknown) {
)
}
function put(body: unknown) {
return PUT(
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/bokslutsdispositioner', {
method: 'PUT',
body,
}),
idParams,
)
}
function periodClient(period: unknown, error: unknown = null) {
const builder = {
select: vi.fn(),
eq: vi.fn(),
single: vi.fn().mockResolvedValue({ data: period, error }),
}
builder.select.mockReturnValue(builder)
builder.eq.mockReturnValue(builder)
return { from: vi.fn().mockReturnValue(builder) }
}
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
vi.mocked(buildDispositionsProposal).mockResolvedValue({
entityType: 'aktiebolag',
fiscalPeriod: {
id: 'period-1',
name: '2025',
period_start: '2024-10-07',
period_end: '2025-12-31',
},
netResultBefore: 592_722.21,
proposals: [],
})
vi.mocked(saveTaxAdjustments).mockResolvedValue()
vi.mocked(loadTaxAdjustmentSnapshot).mockResolvedValue({
items: [],
nonDeductibleExpenses: 5_244,
nonTaxableIncome: 0,
})
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 592_722.21,
} as Awaited<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', () => {
@@ -85,4 +271,103 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () =
const { status } = await parseJsonResponse(await post({ items: [] }))
expect(status).toBe(400)
})
it('rejects legacy client-supplied tax adjustments with 400', async () => {
const { status } = await parseJsonResponse(
await post({
items: [{
kind: 'bolagsskatt',
manualAdjustments: { nonDeductibleExpenses: 999_999 },
}],
}),
)
expect(status).toBe(400)
})
it('posts the calculated tax through the bookkeeping engine', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2024-10-07',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
const { status, body } = await parseJsonResponse<{
data: { created: Array<{ kind: string }> }
}>(await post({ items: [{ kind: 'bolagsskatt' }] }))
expect(status).toBe(200)
expect(body.data.created).toHaveLength(1)
expect(createJournalEntry).toHaveBeenCalledOnce()
expect(createJournalEntry).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({ source_id: 'period-1' }),
)
})
it('does not post a duplicate when the same tax is already booked', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2024-10-07',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
vi.mocked(getBookedBolagsskatt).mockResolvedValue(123_180)
const { status, body } = await parseJsonResponse<{
data: { created: Array<{ kind: string }> }
}>(await post({ items: [{ kind: 'bolagsskatt' }] }))
expect(status).toBe(200)
expect(body.data.created).toEqual([])
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('returns 409 instead of posting over a different booked tax amount', async () => {
const supabase = periodClient({
id: 'period-1',
name: '2025',
period_start: '2024-10-07',
period_end: '2025-12-31',
opening_balance_entry_id: null,
is_closed: false,
locked_at: null,
closing_entry_id: null,
})
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
vi.mocked(getBookedBolagsskatt).mockResolvedValue(123_181)
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { bookedAmount: number; expectedAmount: number } }
}>(await post({ items: [{ kind: 'bolagsskatt' }] }))
expect(status).toBe(409)
expect(body.error.code).toBe('CONFLICT')
expect(body.error.details).toEqual({ bookedAmount: 123_181, expectedAmount: 123_180 })
expect(createJournalEntry).not.toHaveBeenCalled()
})
})
@@ -4,10 +4,16 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
import {
calculateBolagsskatt,
getBookedBolagsskatt,
sumPostedYearEndDispositions,
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import {
loadTaxAdjustmentSnapshot,
saveTaxAdjustments,
} from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import { calculateSarskildLoneskatt } from '@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator'
import {
getPeriodiseringsfondCohortAccount,
@@ -76,21 +82,68 @@ export const GET = withRouteContext(
},
)
const PutBodySchema = z.object({
manualAdjustments: z.object({
nonDeductibleExpenses: z.number().nonnegative().max(1_000_000_000_000),
nonTaxableIncome: z.number().nonnegative().max(1_000_000_000_000),
}),
detectedAccounts: z.object({
'6992': z.boolean(),
'8423': z.boolean(),
}),
})
export const PUT = withRouteContext(
'period.bokslutsdispositioner_adjustments',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
const validation = await validateBody(request, PutBodySchema)
if (!validation.success) return validation.response
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, is_closed, locked_at, closing_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
if (period.is_closed || period.locked_at || period.closing_entry_id) {
return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
}
await saveTaxAdjustments(
supabase,
companyId,
id,
user.id,
validation.data,
)
const data = await buildDispositionsProposal(supabase, companyId, id)
return NextResponse.json({ data })
} catch (err) {
if (err instanceof Error && /locked for tax adjustments/i.test(err.message)) {
return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
}
opLog.error('bokslutsdispositioner adjustments failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
)
// ============================================================
// POST: commit a list of dispositions chosen by the user
// ============================================================
const ItemSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('bolagsskatt'),
manualAdjustments: z
.object({
nonDeductibleExpenses: z.number().optional(),
nonTaxableIncome: z.number().optional(),
schablonintaktPeriodiseringsfond: z.number().optional(),
other: z.number().optional(),
})
.optional(),
}),
}).strict(),
z.object({
kind: z.literal('sarskild_loneskatt'),
manualAdjustment: z.number().optional(),
@@ -181,6 +234,7 @@ export const POST = withRouteContext(
entry_date: period.period_end,
description: `Bokslutsdisposition: ${proposal.label}`,
source_type: 'year_end',
source_id: item.kind === 'bolagsskatt' ? id : undefined,
voucher_series: 'A',
lines: proposal.lines,
})
@@ -189,6 +243,30 @@ export const POST = withRouteContext(
return NextResponse.json({ data: { created } })
} catch (err) {
if (err instanceof TaxProvisionConflictError) {
return errorResponseFromCode('CONFLICT', opLog, {
requestId,
messageSv:
'Bolagsskatt finns redan bokförd med ett annat belopp. Rätta den befintliga verifikationen med en ändringsverifikation innan du fortsätter.',
messageEn:
'Corporate tax is already posted with a different amount. Correct the existing voucher before continuing.',
details: {
bookedAmount: err.bookedAmount,
expectedAmount: err.expectedAmount,
},
})
}
if (
err instanceof BookkeepingDatabaseError
&& err.operation === 'create_draft_entry'
&& err.cause?.includes('uq_year_end_corporate_tax_per_period')
) {
return errorResponseFromCode('CONFLICT', opLog, {
requestId,
messageSv: 'Bolagsskatten bokförs redan. Ladda om sidan innan du fortsätter.',
messageEn: 'Corporate tax is already being posted. Reload the page before continuing.',
})
}
opLog.error('bokslutsdispositioner post failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
@@ -208,6 +286,15 @@ interface ValidatedPeriod {
opening_balance_entry_id: string | null
}
class TaxProvisionConflictError extends Error {
constructor(
readonly bookedAmount: number,
readonly expectedAmount: number,
) {
super('Booked corporate tax differs from the current calculation')
}
}
async function computeProposal(
item: PostItem,
supabase: Parameters<typeof calculateBolagsskatt>[0],
@@ -224,16 +311,42 @@ async function computeProposal(
// SLP −, överavskrivningar −); bolagsskatt is sorted LAST so they are
// committed by now. Without this the booked tax ignores the avsättning
// (the original customer bug, too-high tax, ÅR/INK2 mismatch).
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const dispositionsEffect = await sumPostedYearEndDispositions(
supabase,
companyId,
fiscalPeriodId,
const [incomeStatement, dispositionsEffect, taxAdjustments, bookedTax, existingFonder] =
await Promise.all([
generateIncomeStatement(supabase, companyId, fiscalPeriodId),
sumPostedYearEndDispositions(supabase, companyId, fiscalPeriodId),
loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId),
getBookedBolagsskatt(supabase, companyId, fiscalPeriodId),
listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
period.period_start,
period.opening_balance_entry_id,
),
])
const schablonintakt = existingFonder.reduce(
(sum, fund) =>
sum + Math.max(0, fund.opening_balance) * getSchablonintaktRate(fiscalYear),
0,
)
return calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
resultBeforeTaxOverride: incomeStatement.net_result + dispositionsEffect.total,
manualAdjustments: item.manualAdjustments,
const manuallyBookedTax = Math.max(
0,
bookedTax - dispositionsEffect.taxProvisionPortion,
)
const proposal = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
resultBeforeTaxOverride:
incomeStatement.net_result + dispositionsEffect.total + manuallyBookedTax,
manualAdjustments: {
nonDeductibleExpenses: taxAdjustments.nonDeductibleExpenses,
nonTaxableIncome: taxAdjustments.nonTaxableIncome,
schablonintaktPeriodiseringsfond: Math.round(schablonintakt),
},
})
const expectedTax = proposal?.amount ?? 0
if (bookedTax > 0 && bookedTax === expectedTax) return null
if (bookedTax > 0) throw new TaxProvisionConflictError(bookedTax, expectedTax)
return proposal && proposal.amount > 0 ? proposal : null
}
case 'sarskild_loneskatt': {
// Already posted in this period (resumed run / duplicate POST): the
@@ -278,6 +391,10 @@ async function computeProposal(
const alreadyProvisioned = currentCohort
? Math.max(0, currentCohort.balance - Math.max(0, currentCohort.opening_balance))
: 0
// A posted current-year allocation is a completed user decision. New
// tax adjustments may increase the legal ceiling, but must never cause
// the page to propose an unsolicited incremental allocation on reload.
if (alreadyProvisioned > 0) return null
// Dispositions posted earlier in this batch (återföring, över-
// avskrivningar, SLP: all sorted before avsättning) are year_end-typed
// and thus invisible in net_result, yet they move the cap base. Add
@@ -289,9 +406,15 @@ async function computeProposal(
companyId,
fiscalPeriodId,
)
const taxAdjustments = await loadTaxAdjustmentSnapshot(
supabase,
companyId,
fiscalPeriodId,
)
const base =
incomeStatement.net_result + postedEffect.total + alreadyProvisioned
+ Math.round(schablonintakt)
+ taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
return proposeAvsattning({
skattemassigtResultatBeforeAvsattning: base,
desiredAmount: item.desiredAmount,
@@ -57,13 +57,13 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/close', () => {
expect(mockClosePeriod).not.toHaveBeenCalled()
})
it('maps a service refusal to 400 with the message', async () => {
it('maps a service refusal to 400 with a safe message', async () => {
mockClosePeriod.mockRejectedValue(new Error('Period contains draft entries'))
const { status, body } = await parseJsonResponse<{ error: string }>(
await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)
)
expect(status).toBe(400)
expect(body.error).toBe('Period contains draft entries')
expect(body.error).toBe('Något gick fel. Försök igen.')
})
it('closes the period on the happy path', async () => {
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ error: string }` — kept for the year-end UI.
// closePeriod throws plain Errors for every refusal (period not found, drafts
@@ -16,7 +17,7 @@ export const POST = withRouteContext(
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to close period' },
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to close period' },
{ status: 400 }
)
}
@@ -5,6 +5,7 @@ import {
} from '@/lib/bookkeeping/currency-revaluation'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** GET: preview currency revaluation for a fiscal period. */
export const GET = withRouteContext(
@@ -76,7 +77,7 @@ export const POST = withRouteContext(
if (fallback.status === 500) {
return errorResponseFromCode('FX_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
return fallback
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'period.entry_count',
@@ -26,7 +27,7 @@ export const GET = withRouteContext(
.in('status', ['posted', 'reversed'])
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(countError) }, { status: 500 })
}
return NextResponse.json({ data: { posted_count: count ?? 0 } })
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
export const POST = withRouteContext(
@@ -27,7 +28,7 @@ export const POST = withRouteContext(
if (/draft/i.test(message)) {
return errorResponseFromCode('PERIOD_LOCK_HAS_DRAFTS', opLog, {
requestId,
details: { reason: message },
details: { reason: getErrorMessage(err) },
})
}
// lockPeriod() refuses to lock a period that still has uncategorized
@@ -36,7 +37,7 @@ export const POST = withRouteContext(
if (/saknar bokföring|okategoriserade affärstransaktion/i.test(message)) {
return errorResponseFromCode('PERIOD_HAS_UNBOOKED_TRANSACTIONS', opLog, {
requestId,
details: { reason: message },
details: { reason: getErrorMessage(err) },
})
}
return errorResponse(err, opLog, { requestId })
@@ -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)
return NextResponse.json({ data: result })
} catch (err) {
opLog.error('year-end execution failed', err as Error)
const message = err instanceof Error ? err.message : ''
// The downstream errors below are matched on stable English keywords
// emitted by year-end-service. Do NOT include the raw message in
@@ -53,6 +52,12 @@ export const POST = withRouteContext(
if (/Next fiscal period already has opening balance/i.test(message)) {
return errorResponseFromCode('YEAR_END_NEXT_PERIOD_HAS_IB', opLog, { requestId })
}
if (/No result accounts to close: period has no activity/i.test(message)) {
return errorResponseFromCode('YEAR_END_NO_ACTIVITY', opLog, {
requestId,
reason: 'year-end blocked because the period has no posted result activity',
})
}
if (/prior.*open/i.test(message)) {
return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, { requestId })
}
@@ -62,12 +67,10 @@ export const POST = withRouteContext(
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
// Fall through bookkeeping/Zod/etc to errorResponse, but cap to YEAR_END_FAILED.
const fallback = errorResponse(err, opLog, { requestId })
if (fallback.status === 500) {
return errorResponseFromCode('YEAR_END_FAILED', opLog, { requestId })
}
return fallback
// Unknown failures stay 5xx and retain the original Error object and
// stack in the log. Expected domain outcomes above are structured 4xx
// warnings, so Vercel runtime-error clusters remain actionable.
return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/bookkeeping/fiscal-periods/period-status?date=YYYY-MM-DD
@@ -45,7 +46,7 @@ export const GET = withRouteContext('period.status_for_date', async (request, ct
{
error: {
code: 'PERIOD_STATUS_ERROR',
message: err instanceof Error ? err.message : 'Kunde inte hämta periodstatus',
message: err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte hämta periodstatus',
},
},
{ status: 500 }
+3 -2
View File
@@ -4,6 +4,7 @@ import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-durati
import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Response shapes are legacy `{ error: string }` (plus one envelope code for
// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI.
@@ -18,7 +19,7 @@ export const GET = withRouteContext('period.list', async (_request, ctx) => {
.order('period_start', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -202,7 +203,7 @@ export const POST = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Keep the continuity chain intact for the period that now follows the new one:
@@ -32,6 +32,7 @@ import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -237,7 +238,7 @@ export const POST = withRouteContext(
payment_id: t.payment_id,
ok: false,
old_journal_entry_id: t.payment_journal_entry_id,
error: err instanceof Error ? err.message : 'Unknown error',
error: err instanceof Error ? getUserErrorMessage(err) : 'Unknown error',
})
}
}
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SetNoDocSchema = z.object({
reason: z.string().trim().max(200).nullable().optional(),
@@ -39,7 +40,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
return NextResponse.json({ data: { exempted: true } })
@@ -64,7 +65,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
return NextResponse.json({ data: { exempted: false } })
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const UpdateNotesSchema = z.object({
notes: z.string().max(2000).nullable(),
@@ -27,7 +28,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
// Zero rows = the entry doesn't exist in this company — report it instead
// of a phantom success.
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { RetagLineDimensionsSchema } from '@/lib/api/schemas'
import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag
@@ -39,10 +40,10 @@ export const POST = withRouteContext<{ params: Promise<{ lineId: string }> }>(
// Anything else is unexpected infrastructure failure → 500 + log.
const message = error.message ?? 'Kunde inte ändra dimensioner'
if (error.code === 'P0001') {
return NextResponse.json({ error: message }, { status: 409 })
return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
}
if (error.code === '42501') {
return NextResponse.json({ error: message }, { status: 403 })
return NextResponse.json({ error: getErrorMessage(error) }, { status: 403 })
}
log.error('retag_line_dimensions failed', new Error(message), { lineId })
return NextResponse.json({ error: 'Kunde inte ändra dimensioner' }, { status: 500 })
@@ -4,6 +4,7 @@ import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
import type { Transaction } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const POST = withRouteContext('mapping_rules.evaluate', async (request, ctx) => {
const { supabase, companyId, log } = ctx
@@ -42,7 +43,7 @@ export const POST = withRouteContext('mapping_rules.evaluate', async (request, c
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Evaluation failed' },
{ error: err instanceof Error ? getUserErrorMessage(err) : 'Evaluation failed' },
{ status: 500 }
)
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext('mapping_rules.list', async (_request, ctx) => {
const { supabase, companyId } = ctx
@@ -14,7 +15,7 @@ export const GET = withRouteContext('mapping_rules.list', async (_request, ctx)
.order('priority')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -59,7 +60,7 @@ export const POST = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BatchNoDocSchema = z.object({
journal_entry_ids: z.array(z.string().uuid()).min(1).max(500),
@@ -40,7 +41,7 @@ export const POST = withRouteContext(
.in('id', chunk)
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 400 })
}
ownedIds.push(...(data ?? []).map((r) => r.id))
}
@@ -6,6 +6,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// A real calendar date in YYYY-MM-DD form. Rejects shaped-but-invalid values
// (e.g. 9999-99-99 or 2026-02-30) that a bare /^\d{4}-\d{2}-\d{2}$/ regex would
@@ -106,10 +107,10 @@ export const POST = withRouteContext(
.in('journal_entry_id', chunk),
])
if (docRes.error) {
return NextResponse.json({ error: docRes.error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(docRes.error) }, { status: 400 })
}
if (exemptRes.error) {
return NextResponse.json({ error: exemptRes.error.message }, { status: 400 })
return NextResponse.json({ error: getUserErrorMessage(exemptRes.error) }, { status: 400 })
}
for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
withDoc.add(r.journal_entry_id)
+4 -3
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Voucher gap detection + explanations (BFNAR 2013:2 — gaps in voucher
// sequences must be documented). Response shapes are legacy `{ data }` /
@@ -31,7 +32,7 @@ export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) =>
const { data: seriesRows, error: seriesError } = await seriesQuery
if (seriesError) {
log.error('voucher series lookup failed', seriesError)
return NextResponse.json({ error: seriesError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(seriesError) }, { status: 500 })
}
if (!seriesRows || seriesRows.length === 0) {
@@ -59,7 +60,7 @@ export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) =>
// render "no gaps" on a compliance view when the check didn't run.
if (gapsError) {
log.error('detect_voucher_gaps failed', gapsError, { series: row.voucher_series })
return NextResponse.json({ error: gapsError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(gapsError) }, { status: 500 })
}
for (const gap of (gaps ?? []) as Array<{ gap_start: number; gap_end: number }>) {
@@ -148,7 +149,7 @@ export const POST = withRouteContext(
{ status: 403 }
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+5 -4
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Only the two content toggles are user-settable. Strict: the previous
// implementation passed the raw JSON body into .update(), which would have
@@ -41,7 +42,7 @@ export const GET = withRouteContext('calendar_feed.get', async (_request, ctx) =
if (error && error.code !== 'PGRST116') {
// PGRST116 = no rows returned, which is fine
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (feed) {
@@ -90,7 +91,7 @@ export const POST = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
@@ -123,7 +124,7 @@ export const PUT = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
@@ -155,7 +156,7 @@ export const DELETE = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/company/check-org-number?org_number=XXXXXXXXXX
@@ -48,7 +49,7 @@ export async function GET(request: Request) {
.is('archived_at', null)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const companies = (data ?? []).map((c: { id: string; name: string }) => ({
+2 -1
View File
@@ -7,6 +7,7 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { createLogger } from '@/lib/logger'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const log = createLogger('api/company/current')
@@ -124,7 +125,7 @@ export const PATCH = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// When opting in to K3, ensure the two latent-tax (uppskjuten skatt)
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/company?owned=true&archived=false
@@ -32,7 +33,7 @@ export async function GET(request: Request) {
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const companies = (data ?? []).map((row) => {
+4 -3
View File
@@ -5,6 +5,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'customer.get',
@@ -27,7 +28,7 @@ export const GET = withRouteContext(
opLog.error('customer fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -120,7 +121,7 @@ export const PATCH = withRouteContext(
opLog.error('customer update failed', error)
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -181,7 +182,7 @@ export const DELETE = withRouteContext(
opLog.error('customer delete failed', error)
return errorResponseFromCode('CUSTOMER_DELETE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
+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 type { Customer } from '@/types'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -78,7 +79,7 @@ export const POST = withRouteContext(
log.error('customer insert failed', error)
return errorResponseFromCode('CUSTOMER_CREATE_FAILED', log, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/deadlines/[id]/complete
@@ -23,7 +24,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
// Toggle completion
@@ -40,7 +41,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+6 -5
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// Sparse update: every Create field, optional. Validated — the previous
// implementation type-asserted the raw JSON, so malformed values reached
@@ -29,7 +30,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -80,7 +81,7 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -112,7 +113,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.maybeSingle()
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
}
if (!existing) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
@@ -128,7 +129,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.select('id')
if (dismissError) {
return NextResponse.json({ error: dismissError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(dismissError) }, { status: 500 })
}
// The row can vanish between lookup and update (generator cleanup
// during a concurrent regeneration); report 404 rather than a
@@ -146,7 +147,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Zero rows = wrong id / another company's deadline — not a success.
if (count === 0) {
+3 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/deadlines
@@ -52,7 +53,7 @@ export const GET = withRouteContext('deadline.list', async (request, ctx) => {
const { data, error } = await query.order('due_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -89,7 +90,7 @@ export const POST = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+2 -1
View File
@@ -14,6 +14,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateDimensionSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -68,7 +69,7 @@ export const PATCH = withRouteContext(
opLog.error('dimension update failed', error)
return errorResponseFromCode('DIMENSION_UPDATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -16,6 +16,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateDimensionValueSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -82,7 +83,7 @@ export const PATCH = withRouteContext(
opLog.error('dimension value update failed', error)
return errorResponseFromCode('DIMENSION_VALUE_UPDATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -118,7 +119,7 @@ export const DELETE = withRouteContext(
opLog.error('dimension value delete failed', error)
return errorResponseFromCode('DIMENSION_VALUE_DELETE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
+2 -1
View File
@@ -14,6 +14,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateDimensionValueSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -81,7 +82,7 @@ export const POST = withRouteContext(
opLog.error('dimension value insert failed', error)
return errorResponseFromCode('DIMENSION_VALUE_CREATE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
Binary file not shown.

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