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:
@@ -0,0 +1,5 @@
|
||||
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
|
||||
|
||||
export default function AuthLoading() {
|
||||
return <AuthPageSkeleton />
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+144
@@ -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) {
|
||||
|
||||
+10
@@ -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)) {
|
||||
|
||||
+197
@@ -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)
|
||||
})
|
||||
})
|
||||
+128
-11
@@ -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 },
|
||||
)
|
||||
|
||||
+102
@@ -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) {
|
||||
|
||||
+164
@@ -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) },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
+286
-1
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user