feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup

- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-04 14:19:56 +01:00
co-authored by Claude Opus 4.6
parent a9bf24ce0b
commit 66a4027f1e
104 changed files with 4964 additions and 5501 deletions
+1 -1
View File
@@ -251,7 +251,7 @@ Path params extracted as `_paramName` search params (e.g., `/:id` → `searchPar
## Database & Migrations
**Location**: `supabase/migrations/` — 45 files, numbered `20240101000001`–`20240101000045`.
**Next migration**: `20240101000046_*.sql`
**Next migration**: `20240101000049_*.sql`
### Placeholder Migrations
+60 -53
View File
@@ -5,9 +5,8 @@ import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Mail, Sparkles } from 'lucide-react'
import { Loader2, Mail, ArrowLeft } from 'lucide-react'
import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function LoginPage() {
@@ -61,47 +60,56 @@ export default function LoginPage() {
if (isEmailSent) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<Mail className="h-6 w-6 text-primary" />
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
<CardTitle className="text-2xl">Kolla din e-post</CardTitle>
<CardDescription>
Vi har skickat en inloggningslänk till <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-sm text-muted-foreground mb-4">
Klicka på länken i e-posten för att logga in. Länken är giltig i 1 timme.
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Kolla din e-post</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Vi har skickat en inloggningslänk till{' '}
<span className="font-medium text-foreground">{email}</span>
</p>
<Button
variant="ghost"
onClick={() => setIsEmailSent(false)}
>
Använd en annan e-post
</Button>
</CardContent>
</Card>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
Klicka på länken i e-posten för att logga in.
Länken är giltig i 1 timme.
</p>
</div>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => setIsEmailSent(false)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Använd en annan e-post
</Button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">ERP Base</CardTitle>
<CardDescription>
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<h1 className="font-display text-[2.75rem] leading-none font-medium tracking-tight text-foreground">
Gnubok
</h1>
<p className="text-muted-foreground text-sm mt-3">
Logga in med din e-post för att hantera din ekonomi
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Input
@@ -114,11 +122,12 @@ export default function LoginPage() {
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
</div>
<Button
type="submit"
className="w-full"
className="w-full h-11"
disabled={isLoading}
>
{isLoading ? (
@@ -127,26 +136,24 @@ export default function LoginPage() {
Skickar...
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Skicka inloggningslänk
</>
'Skicka inloggningslänk'
)}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Genom att logga in godkänner du våra{' '}
<a href="#" className="underline hover:text-primary">
villkor
</a>{' '}
och{' '}
<a href="#" className="underline hover:text-primary">
integritetspolicy
</a>
.
</p>
</CardContent>
</Card>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground leading-relaxed">
Genom att logga in godkänner du våra{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
villkor
</a>{' '}
och{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
integritetspolicy
</a>
.
</p>
</div>
</div>
)
}
+13 -681
View File
@@ -1,174 +1,13 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { SuccessAnimation } from '@/components/ui/success-animation'
import { useToast } from '@/components/ui/use-toast'
import {
CheckCircle2,
AlertCircle,
AlertTriangle,
ArrowLeft,
ArrowRight,
Loader2,
Lock,
BookOpen,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type {
FiscalPeriod,
YearEndValidation,
YearEndPreview,
YearEndResult,
} from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
const STEP_LABELS = ['Välj period', 'Validering', 'Förhandsgranskning', 'Genomför']
import { ArrowLeft, Lock } from 'lucide-react'
export default function YearEndPage() {
const { toast } = useToast()
const [step, setStep] = useState(0)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriodId, setSelectedPeriodId] = useState('')
const [validation, setValidation] = useState<YearEndValidation | null>(null)
const [preview, setPreview] = useState<YearEndPreview | null>(null)
const [result, setResult] = useState<YearEndResult | null>(null)
const [loading, setLoading] = useState(false)
const [loadingPeriods, setLoadingPeriods] = useState(true)
const [executing, setExecuting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [showLinesDetail, setShowLinesDetail] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const selectedPeriod = periods.find((p) => p.id === selectedPeriodId)
useEffect(() => {
fetchPeriods()
}, [])
async function fetchPeriods() {
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
const allPeriods: FiscalPeriod[] = data || []
setPeriods(allPeriods)
// Pre-select first open period
const openPeriod = allPeriods.find((p) => !p.is_closed)
if (openPeriod) {
setSelectedPeriodId(openPeriod.id)
}
} catch {
toast({ title: 'Fel', description: 'Kunde inte hämta räkenskapsår', variant: 'destructive' })
} finally {
setLoadingPeriods(false)
}
}
const fetchValidationAndPreview = useCallback(async () => {
if (!selectedPeriodId) return
setLoading(true)
setError(null)
setValidation(null)
setPreview(null)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`)
const json = await res.json()
if (!res.ok) {
setError(json.error || 'Kunde inte validera perioden')
return
}
setValidation(json.data.validation)
setPreview(json.data.preview)
} catch {
setError('Nätverksfel vid validering')
} finally {
setLoading(false)
}
}, [selectedPeriodId])
async function executeYearEnd() {
setShowConfirmDialog(false)
setExecuting(true)
setError(null)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`, {
method: 'POST',
})
const json = await res.json()
if (!res.ok) {
setError(json.error || 'Årsbokslut misslyckades')
toast({ title: 'Fel', description: json.error || 'Årsbokslut misslyckades', variant: 'destructive' })
return
}
setResult(json.data)
setShowSuccess(true)
} catch {
setError('Nätverksfel vid genomförande')
toast({ title: 'Fel', description: 'Nätverksfel vid genomförande', variant: 'destructive' })
} finally {
setExecuting(false)
}
}
function goToStep(nextStep: number) {
if (nextStep === 1 && !validation) {
fetchValidationAndPreview()
}
setStep(nextStep)
}
function getPeriodStatus(period: FiscalPeriod) {
if (period.is_closed) return { label: 'Stängd', variant: 'secondary' as const }
if (period.locked_at) return { label: 'Låst', variant: 'outline' as const }
return { label: 'Öppen', variant: 'default' as const }
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Årsbokslut</h1>
<p className="text-muted-foreground">
Stäng räkenskapsåret och generera ingående balanser
</p>
</div>
<h1 className="text-3xl font-bold tracking-tight">Årsbokslut</h1>
<Button variant="outline" asChild>
<Link href="/bookkeeping">
<ArrowLeft className="mr-2 h-4 w-4" />
@@ -177,524 +16,17 @@ export default function YearEndPage() {
</Button>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2">
{STEP_LABELS.map((label, i) => (
<div key={label} className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
i < step
? 'bg-primary text-primary-foreground'
: i === step
? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
: 'bg-muted text-muted-foreground'
}`}
>
{i < step ? <CheckCircle2 className="h-4 w-4" /> : i + 1}
</div>
<span
className={`text-sm hidden sm:inline ${
i === step ? 'font-medium' : 'text-muted-foreground'
}`}
>
{label}
</span>
</div>
{i < STEP_LABELS.length - 1 && (
<div className={`h-px w-8 ${i < step ? 'bg-primary' : 'bg-border'}`} />
)}
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted mb-4">
<Lock className="h-7 w-7 text-muted-foreground" />
</div>
))}
</div>
{/* Error banner */}
{error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-3 py-4">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
)}
{/* Step 0: Period Selection */}
{step === 0 && (
<Card>
<CardHeader>
<CardTitle>Välj räkenskapsår att stänga</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loadingPeriods ? (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : periods.length === 0 ? (
<p className="text-muted-foreground text-sm">
Inga räkenskapsår hittades. Skapa ett räkenskapsår först.
</p>
) : (
<>
<div className="space-y-2">
{periods.map((period) => {
const status = getPeriodStatus(period)
const isSelected = period.id === selectedPeriodId
return (
<button
key={period.id}
onClick={() => setSelectedPeriodId(period.id)}
disabled={period.is_closed}
className={`w-full flex items-center justify-between rounded-lg border p-4 text-left transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: period.is_closed
? 'border-border bg-muted/50 opacity-60 cursor-not-allowed'
: 'border-border hover:border-primary/50 hover:bg-muted/30'
}`}
>
<div>
<p className="font-medium">{period.name}</p>
<p className="text-sm text-muted-foreground">
{period.period_start} – {period.period_end}
</p>
</div>
<Badge variant={status.variant}>{status.label}</Badge>
</button>
)
})}
</div>
<div className="flex justify-end">
<Button
onClick={() => goToStep(1)}
disabled={!selectedPeriodId || (selectedPeriod?.is_closed ?? false)}
>
Nästa
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</>
)}
</CardContent>
</Card>
)}
{/* Step 1: Validation */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Validering — {selectedPeriod?.name}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-6 w-2/3" />
</div>
) : validation ? (
<>
{/* Ready indicator */}
<div
className={`flex items-center gap-3 rounded-lg p-4 ${
validation.ready
? 'bg-green-50 dark:bg-green-950/20'
: 'bg-red-50 dark:bg-red-950/20'
}`}
>
{validation.ready ? (
<CheckCircle2 className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
)}
<div>
<p className="font-medium">
{validation.ready
? 'Perioden är redo för årsbokslut'
: 'Perioden kan inte stängas ännu'}
</p>
{!validation.ready && (
<p className="text-sm text-muted-foreground">
Åtgärda felen nedan innan du kan fortsätta
</p>
)}
</div>
</div>
{/* Errors */}
{validation.errors.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-red-600 dark:text-red-400">Fel som måste åtgärdas</p>
{validation.errors.map((err, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
<span>{err}</span>
</div>
))}
</div>
)}
{/* Warnings */}
{validation.warnings.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-amber-600 dark:text-amber-400">Varningar</p>
{validation.warnings.map((warn, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 flex-shrink-0" />
<span>{warn}</span>
</div>
))}
</div>
)}
{/* Details */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="rounded-lg border p-3">
<p className="text-muted-foreground">Utkast kvar</p>
<p className="text-lg font-medium">{validation.draftCount}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-muted-foreground">Saldobalans</p>
<p className="text-lg font-medium">
{validation.trialBalanceBalanced ? 'Balanserad' : 'Obalanserad'}
</p>
</div>
</div>
{/* Voucher gaps */}
{validation.voucherGaps.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium mb-2">Verifikationsnummerluckor</p>
<div className="flex flex-wrap gap-2">
{validation.voucherGaps.map((gap, i) => (
<Badge key={i} variant="outline">
{gap.gap_start}–{gap.gap_end}
</Badge>
))}
</div>
</div>
)}
</>
) : null}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep(0)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={fetchValidationAndPreview} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Validera igen
</Button>
<Button
onClick={() => goToStep(2)}
disabled={!validation?.ready}
>
Nästa
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Preview */}
{step === 2 && preview && (
<div className="space-y-4">
{/* Net result highlight */}
<Card>
<CardContent className="py-6">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-1">Årets resultat</p>
<p
className={`text-4xl font-bold tracking-tight ${
preview.netResult >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}
>
{formatAmount(preview.netResult)} kr
</p>
<p className="text-sm text-muted-foreground mt-2">
Bokförs på {preview.closingAccount} — {preview.closingAccountName}
</p>
</div>
</CardContent>
</Card>
{/* Result account summary */}
<Card>
<CardHeader>
<CardTitle>Resultatkonton som nollställs</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Namn</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.resultAccountSummary.map((account) => (
<TableRow key={account.account_number}>
<TableCell><AccountNumber number={account.account_number} name={account.account_name} /></TableCell>
<TableCell>{account.account_name}</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(account.amount)} kr
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Closing journal lines (expandable) */}
<Card>
<CardHeader>
<button
onClick={() => setShowLinesDetail(!showLinesDetail)}
className="flex items-center justify-between w-full"
>
<CardTitle>Bokslutsverifikation ({preview.closingLines.length} rader)</CardTitle>
{showLinesDetail ? (
<ChevronUp className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
)}
</button>
</CardHeader>
{showLinesDetail && (
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Debet</TableHead>
<TableHead className="text-right">Kredit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.closingLines.map((line, i) => (
<TableRow key={i}>
<TableCell><AccountNumber number={line.account_number} /></TableCell>
<TableCell>{line.line_description}</TableCell>
<TableCell className="text-right font-mono">
{line.debit_amount > 0 ? formatAmount(line.debit_amount) : ''}
</TableCell>
<TableCell className="text-right font-mono">
{line.credit_amount > 0 ? formatAmount(line.credit_amount) : ''}
</TableCell>
</TableRow>
))}
{/* Totals row */}
<TableRow className="font-medium border-t-2">
<TableCell colSpan={2}>Summa</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(
preview.closingLines.reduce((sum, l) => sum + l.debit_amount, 0)
)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(
preview.closingLines.reduce((sum, l) => sum + l.credit_amount, 0)
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
)}
</Card>
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep(1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button onClick={() => goToStep(3)}>
Nästa
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Step 3: Execute */}
{step === 3 && !result && (
<Card>
<CardHeader>
<CardTitle>Genomför årsbokslut</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<p className="text-sm text-muted-foreground">
Följande åtgärder kommer att genomföras:
</p>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<BookOpen className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
Bokslutsverifikation skapas med {preview?.closingLines.length} rader
</li>
<li className="flex items-start gap-2">
<Lock className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
Perioden {selectedPeriod?.name} låses och stängs permanent
</li>
<li className="flex items-start gap-2">
<ArrowRight className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
Nytt räkenskapsår skapas med ingående balanser
</li>
</ul>
{preview && (
<div className="pt-2 border-t">
<p className="text-sm">
Årets resultat:{' '}
<span className="font-medium">
{formatAmount(preview.netResult)} kr
</span>{' '}
→ {preview.closingAccount} ({preview.closingAccountName})
</p>
</div>
)}
</div>
<div className="rounded-lg bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Denna åtgärd kan inte ångras
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Perioden stängs permanent enligt Bokföringslagen. Säkerställ att alla bokföringar
är korrekta innan du fortsätter.
</p>
</div>
</div>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep(2)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button
variant="destructive"
onClick={() => setShowConfirmDialog(true)}
disabled={executing}
>
{executing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Genomför årsbokslut
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Success state */}
{step === 3 && result && (
<Card>
<CardContent className="py-8">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-950/30">
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
</div>
<div>
<h2 className="text-xl font-semibold">Årsbokslutet är genomfört</h2>
<p className="text-muted-foreground mt-1">
{selectedPeriod?.name} har stängts och ett nytt räkenskapsår har skapats.
</p>
</div>
<div className="grid gap-3 max-w-md mx-auto text-left">
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="text-muted-foreground">Bokslutsverifikation</span>
<Link
href="/bookkeeping"
className="text-primary hover:underline font-medium"
>
Visa
</Link>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="text-muted-foreground">Period stängd</span>
<Badge variant="secondary">
<Lock className="mr-1 h-3 w-3" />
Stängd
</Badge>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="text-muted-foreground">Nytt räkenskapsår</span>
<span className="font-medium">{result.nextPeriod.name}</span>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="text-muted-foreground">Ingående balanser</span>
<Badge variant="default">
<CheckCircle2 className="mr-1 h-3 w-3" />
Skapade
</Badge>
</div>
</div>
<div className="pt-4">
<Button asChild>
<Link href="/bookkeeping">
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till bokföring
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Confirmation dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bekräfta årsbokslut</DialogTitle>
<DialogDescription>
Är du säker på att du vill stänga <strong>{selectedPeriod?.name}</strong>?
Denna åtgärd kan inte ångras. Perioden kommer att stängas permanent.
</DialogDescription>
</DialogHeader>
{preview && (
<div className="rounded-lg border p-3 text-sm">
<p>
Årets resultat:{' '}
<span className="font-medium">{formatAmount(preview.netResult)} kr</span>
</p>
<p className="text-muted-foreground">
Bokförs på {preview.closingAccount} — {preview.closingAccountName}
</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowConfirmDialog(false)}>
Avbryt
</Button>
<Button variant="destructive" onClick={executeYearEnd}>
Stäng perioden
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Success animation overlay */}
<SuccessAnimation
show={showSuccess}
title="Årsbokslut genomfört!"
description={`${selectedPeriod?.name} har stängts`}
variant="celebration"
/>
<h2 className="text-xl font-semibold mb-2">Kommer snart</h2>
<p className="text-muted-foreground max-w-md">
Årsbokslut är under utveckling och kommer att finnas tillgängligt i en kommande version.
</p>
</CardContent>
</Card>
</div>
)
}
+7 -7
View File
@@ -28,9 +28,9 @@ import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt foretag',
eu_business: 'EU-foretag',
non_eu_business: 'Utanfor EU',
swedish_business: 'Svenskt företag',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
const customerTypeIcons: Record<CustomerType, React.ElementType> = {
@@ -263,7 +263,7 @@ export default function CustomerDetailPage({
{/* Business details */}
<Card>
<CardHeader>
<CardTitle className="text-base">Foretagsuppgifter</CardTitle>
<CardTitle className="text-base">Företagsuppgifter</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{customer.org_number && (
@@ -286,7 +286,7 @@ export default function CustomerDetailPage({
{customer.default_payment_terms || 30} dagar
</div>
{!customer.org_number && !customer.vat_number && (
<p className="text-sm text-muted-foreground">Inga foretagsuppgifter</p>
<p className="text-sm text-muted-foreground">Inga företagsuppgifter</p>
)}
</CardContent>
</Card>
@@ -294,7 +294,7 @@ export default function CustomerDetailPage({
{/* Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Oversikt</CardTitle>
<CardTitle className="text-base">Översikt</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm">
@@ -346,7 +346,7 @@ export default function CustomerDetailPage({
{formatCurrency(invoice.total, invoice.currency)}
</span>
<Badge variant={invoice.payment_status === 'paid' ? 'success' : 'secondary'}>
{invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Forsenad' : 'Obestallt'}
{invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Försenad' : 'Obetald'}
</Badge>
</div>
</Link>
+450
View File
@@ -0,0 +1,450 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { formatCurrency } from '@/lib/utils'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, EntityType } from '@/types'
const statusConfig: Record<string, { label: string; color: string }> = {
registered: { label: 'Obetald', color: 'bg-blue-100 text-blue-800' },
approved: { label: 'Obetald', color: 'bg-yellow-100 text-yellow-800' },
paid: { label: 'Betald', color: 'bg-green-100 text-green-800' },
partially_paid: { label: 'Delbetald', color: 'bg-orange-100 text-orange-800' },
overdue: { label: 'Förfallen', color: 'bg-red-100 text-red-800' },
disputed: { label: 'Tvist', color: 'bg-purple-100 text-purple-800' },
credited: { label: 'Krediterad', color: 'bg-gray-100 text-gray-800' },
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function ExpenseDetailPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchInvoice()
fetchEntityType()
}, [params.id])
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Fel', description: error, variant: 'destructive' })
} else {
setInvoice(data)
setPayAmount(String(data.remaining_amount))
}
setIsLoading(false)
}
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const { data } = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Default to enskild_firma
}
}
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Godkänd', description: 'Utgiften har godkänts' })
fetchInvoice()
}
setIsProcessing(false)
}
async function handleMarkPaid() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: parseFloat(payAmount) }),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
description: `${formatAmount(parseFloat(payAmount))} kr registrerat`,
})
setIsPayDialogOpen(false)
fetchInvoice()
}
setIsProcessing(false)
}
async function handleCredit() {
const ok = await confirmAction({
title: 'Registrera kreditfaktura',
description: 'En kreditfaktura skapas som reverserar den ursprungliga fakturan. Denna åtgärd kan inte ångras.',
confirmLabel: 'Registrera kreditfaktura',
variant: 'warning',
})
if (!ok) return
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Kreditfaktura registrerad' })
fetchInvoice()
}
setIsProcessing(false)
}
async function handleDelete() {
const ok = await confirmAction({
title: 'Ta bort utgift',
description: 'Utgiften och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.',
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Borttagen' })
router.push('/expenses')
}
}
if (isLoading) {
return (
<div className="space-y-6 max-w-4xl">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<Card className="animate-pulse"><CardContent className="h-48" /></Card>
</div>
)
}
if (!invoice) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Utgiften hittades inte</p>
<Button variant="outline" className="mt-4" onClick={() => router.push('/expenses')}>
Tillbaka
</Button>
</div>
)
}
const items = (invoice.items || []) as SupplierInvoiceItem[]
const payments = (invoice.payments || []) as SupplierInvoicePayment[]
const status = statusConfig[invoice.status] || { label: invoice.status, color: '' }
return (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/expenses')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
Utgift #{invoice.arrival_number}
</h1>
<Badge className={status.color}>
{status.label}
</Badge>
</div>
<p className="text-muted-foreground">
{invoice.supplier?.name} · Faktura {invoice.supplier_invoice_number}
</p>
</div>
</div>
{/* Context-aware actions */}
<div className="flex gap-2">
{invoice.status === 'registered' && (
<>
<Button onClick={handleApprove} disabled={isProcessing}>
<CheckCircle className="mr-2 h-4 w-4" />
Godkänn
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete} disabled={isProcessing}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
{['approved', 'overdue'].includes(invoice.status) && (
<>
<Button onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
<CreditCard className="mr-2 h-4 w-4" />
Markera betald
</Button>
<Button variant="outline" onClick={handleCredit} disabled={isProcessing}>
<FileText className="mr-2 h-4 w-4" />
Kreditfaktura
</Button>
</>
)}
{invoice.status === 'partially_paid' && (
<Button onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
<CreditCard className="mr-2 h-4 w-4" />
Registrera betalning
</Button>
)}
</div>
</div>
{/* Card 1: Fakturadetaljer */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Fakturadetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Info grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Leverantör</span>
<p className="font-medium">
{invoice.supplier ? (
<Link href={`/suppliers/${invoice.supplier.id}`} className="text-primary hover:underline">
{invoice.supplier.name}
</Link>
) : '-'}
</p>
{invoice.supplier?.org_number && (
<p className="text-xs text-muted-foreground">Org.nr: {invoice.supplier.org_number}</p>
)}
</div>
<div>
<span className="text-muted-foreground">Fakturanummer</span>
<p className="font-medium">{invoice.supplier_invoice_number}</p>
</div>
<div>
<span className="text-muted-foreground">Ankomstnummer</span>
<p className="font-medium font-mono">{invoice.arrival_number}</p>
</div>
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{invoice.invoice_date}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{invoice.due_date}</p>
</div>
{invoice.delivery_date && (
<div>
<span className="text-muted-foreground">Leveransdatum</span>
<p className="font-medium">{invoice.delivery_date}</p>
</div>
)}
{invoice.payment_reference && (
<div>
<span className="text-muted-foreground">OCR/referens</span>
<p className="font-medium font-mono">{invoice.payment_reference}</p>
</div>
)}
</div>
{invoice.reverse_charge && (
<Badge className="bg-purple-100 text-purple-800">Omvänd skattskyldighet</Badge>
)}
{/* Line items */}
{items.length > 0 && (
<div className="border-t pt-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-20">Konto</th>
<th className="pb-2 w-16 text-right">Moms%</th>
<th className="pb-2 w-28 text-right">Belopp</th>
<th className="pb-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2"><AccountNumber number={item.account_number} /></td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
</tr>
))}
</tbody>
</table>
{/* Amounts summary */}
<div className="mt-4 pt-4 border-t space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="font-mono">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="font-mono">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-bold text-base pt-2 border-t">
<span>Totalt</span>
<span className="font-mono">{formatAmount(invoice.total)} {invoice.currency}</span>
</div>
<div className="flex justify-between pt-2">
<span className="text-muted-foreground">Betalt</span>
<span className="font-mono text-green-600">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Kvar att betala</span>
<span className="font-mono">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
</div>
</div>
</div>
)}
{/* Notes inline */}
{invoice.notes && (
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground">{invoice.notes}</p>
</div>
)}
</CardContent>
</Card>
{/* Card 2: Betalningar & bokföring (only if data exists) */}
{(payments.length > 0 || invoice.registration_journal_entry_id) && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Betalningar & bokföring</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Payment history */}
{payments.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Betalningshistorik</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Datum</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2">Verifikation</th>
<th className="pb-2">Anteckning</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.payment_date}</td>
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
</td>
<td className="py-2 text-muted-foreground">{p.notes || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Journal entry links */}
<div className="text-sm space-y-2">
<p className="font-medium">Verifikationer</p>
{invoice.registration_journal_entry_id ? (
<div className="flex justify-between">
<span className="text-muted-foreground">Registreringsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.registration_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.registration_journal_entry_id.substring(0, 8)}...
</Link>
</div>
) : (
<p className="text-muted-foreground">Ingen registreringsverifikation</p>
)}
{invoice.payment_journal_entry_id && (
<div className="flex justify-between">
<span className="text-muted-foreground">Betalningsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.payment_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.payment_journal_entry_id.substring(0, 8)}...
</Link>
</div>
)}
</div>
</CardContent>
</Card>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Pay Dialog */}
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Markera som betald</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Belopp att betala</Label>
<Input
type="number"
step="0.01"
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Kvar att betala: {formatAmount(invoice.remaining_amount)} {invoice.currency}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
Avbryt
</Button>
<Button onClick={handleMarkPaid} disabled={isProcessing}>
{isProcessing ? 'Bearbetar...' : 'Registrera betalning'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+746
View File
@@ -0,0 +1,746 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller, useFieldArray } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2 } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType } from '@/types'
interface LineItem {
description: string
amount: number
account_number: string
vat_rate: number
}
interface FormData {
supplier_id: string
supplier_invoice_number: string
invoice_date: string
due_date: string
delivery_date: string
currency: string
exchange_rate: string
reverse_charge: boolean
payment_reference: string
notes: string
items: LineItem[]
}
interface NewSupplierForm {
name: string
supplier_type: string
org_number: string
bankgiro: string
plusgiro: string
default_expense_account: string
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatment {
if (reverseCharge) return 'reverse_charge'
const rates = new Set(items.map((i) => i.vat_rate))
if (rates.size === 1) {
const rate = rates.values().next().value!
if (rate === 0.25) return 'standard_25'
if (rate === 0.12) return 'reduced_12'
if (rate === 0.06) return 'reduced_6'
if (rate === 0) return 'exempt'
}
return 'standard_25'
}
export default function NewExpensePage() {
const router = useRouter()
const { toast } = useToast()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
const [showNewSupplier, setShowNewSupplier] = useState(false)
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [newSupplier, setNewSupplier] = useState<NewSupplierForm>({
name: '',
supplier_type: 'swedish_business',
org_number: '',
bankgiro: '',
plusgiro: '',
default_expense_account: '',
})
const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm<FormData>({
defaultValues: {
supplier_id: '',
supplier_invoice_number: '',
invoice_date: new Date().toISOString().split('T')[0],
due_date: '',
delivery_date: '',
currency: 'SEK',
exchange_rate: '',
reverse_charge: false,
payment_reference: '',
notes: '',
items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25 }],
},
})
useUnsavedChanges(isDirty)
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const watchedItems = watch('items')
const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency')
const isEF = entityType === 'enskild_firma'
useEffect(() => {
fetchSuppliers()
fetchAccounts()
fetchEntityType()
}, [])
// Auto-fill due date and defaults when supplier is selected
useEffect(() => {
if (watchedSupplierId) {
const supplier = suppliers.find((s) => s.id === watchedSupplierId)
if (supplier) {
const invoiceDate = watch('invoice_date')
if (invoiceDate) {
const due = new Date(invoiceDate)
due.setDate(due.getDate() + supplier.default_payment_terms)
setValue('due_date', due.toISOString().split('T')[0])
}
if (supplier.default_expense_account && fields.length > 0) {
setValue('items.0.account_number', supplier.default_expense_account)
}
if (supplier.default_currency) {
setValue('currency', supplier.default_currency)
}
if (supplier.supplier_type === 'eu_business') {
setValue('reverse_charge', true)
}
}
}
}, [watchedSupplierId, suppliers])
async function fetchSuppliers() {
const res = await fetch('/api/suppliers')
const { data } = await res.json()
setSuppliers(data || [])
}
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
}
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const { data } = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Default to enskild_firma
}
}
function handleAccountChange(index: number, accountNumber: string) {
setValue(`items.${index}.account_number`, accountNumber)
const currentDesc = watch(`items.${index}.description`)
if (!currentDesc && accountNumber.length === 4) {
const desc = getAccountDescription(accountNumber)
if (desc) {
setValue(`items.${index}.description`, desc.name)
}
}
}
// Calculate totals
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.amount || 0) * 100) / 100
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
return { lineTotal, vatAmount }
})
const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0)
const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0)
const total = Math.round((subtotal + totalVat) * 100) / 100
async function handleCreateSupplier() {
if (!newSupplier.name.trim()) {
toast({ title: 'Fel', description: 'Ange leverantörsnamn', variant: 'destructive' })
return
}
setIsCreatingSupplier(true)
const payload: Record<string, unknown> = {
name: newSupplier.name,
supplier_type: newSupplier.supplier_type,
}
if (newSupplier.org_number) payload.org_number = newSupplier.org_number
if (newSupplier.bankgiro) payload.bankgiro = newSupplier.bankgiro
if (newSupplier.plusgiro) payload.plusgiro = newSupplier.plusgiro
if (newSupplier.default_expense_account) payload.default_expense_account = newSupplier.default_expense_account
const res = await fetch('/api/suppliers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte skapa leverantör', description: result.error, variant: 'destructive' })
} else {
const created = result.data as Supplier
setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
setValue('supplier_id', created.id)
setShowNewSupplier(false)
setNewSupplier({ name: '', supplier_type: 'swedish_business', org_number: '', bankgiro: '', plusgiro: '', default_expense_account: '' })
toast({ title: 'Leverantör skapad', description: created.name })
}
setIsCreatingSupplier(false)
}
function onSubmit(data: FormData) {
if (!data.supplier_id) {
toast({ title: 'Fel', description: 'Välj en leverantör', variant: 'destructive' })
return
}
if (!data.supplier_invoice_number) {
toast({ title: 'Fel', description: 'Ange fakturanummer', variant: 'destructive' })
return
}
if (isEF) {
// EF: submit directly (auto-approve after create)
setPendingData(data)
handleDirectSubmit(data)
} else {
// AB: show review dialog first
setPendingData(data)
setShowReview(true)
}
}
function buildPayload(data: FormData) {
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge)
return {
supplier_id: data.supplier_id,
supplier_invoice_number: data.supplier_invoice_number,
invoice_date: data.invoice_date,
due_date: data.due_date,
delivery_date: data.delivery_date || undefined,
currency: data.currency,
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
vat_treatment: vatTreatment,
reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined,
notes: data.notes || undefined,
items: data.items.map((item) => ({
description: item.description,
amount: item.amount,
account_number: item.account_number,
vat_rate: item.vat_rate,
})),
}
}
// EF flow: create + auto-approve
async function handleDirectSubmit(data: FormData) {
setIsSubmitting(true)
const res = await fetch('/api/supplier-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload(data)),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte registrera utgift', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' })
setIsSubmitting(false)
return
}
// Auto-approve for EF
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
if (!approveRes.ok) {
toast({
title: 'Varning',
description: 'Utgiften skapades men kunde inte godkännas automatiskt',
variant: 'destructive',
})
router.push(`/expenses/${result.data.id}`)
} else {
toast({ title: 'Utgift registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
router.push('/expenses')
}
setIsSubmitting(false)
}
// AB flow: create after review dialog confirmation
async function handleConfirm() {
if (!pendingData) return
setIsSubmitting(true)
const res = await fetch('/api/supplier-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload(pendingData)),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte registrera utgift', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' })
} else {
toast({ title: 'Utgift registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
setShowReview(false)
router.push(`/expenses/${result.data.id}`)
}
setIsSubmitting(false)
}
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/expenses')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Ny utgift</h1>
<p className="text-muted-foreground">
Registrera en inkommande faktura
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Section 1: Faktura */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Faktura</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Leverantör *</Label>
<Controller
name="supplier_id"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => {
if (v === '__new__') {
setShowNewSupplier(true)
} else {
field.onChange(v)
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj leverantör" />
</SelectTrigger>
<SelectContent>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
<SelectItem value="__new__" className="text-primary font-medium">
+ Lägg till ny leverantör...
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Leverantörens fakturanummer *</Label>
<Input
placeholder="Fakturanr från leverantören"
{...register('supplier_invoice_number')}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Fakturadatum *</Label>
<Input type="date" {...register('invoice_date')} />
</div>
<div className="space-y-2">
<Label>Förfallodatum *</Label>
<Input type="date" {...register('due_date')} />
</div>
<div className="space-y-2">
<Label>OCR / Betalningsreferens</Label>
<Input
placeholder="OCR-nummer"
{...register('payment_reference')}
/>
</div>
</div>
</CardContent>
</Card>
{/* Section 2: Kontering */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Kontering</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ description: '', amount: 0, account_number: '', vat_rate: 0.25 })
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
</Button>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 w-28">Konto</th>
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-32">Belopp (exkl.)</th>
<th className="pb-2 w-24">Momssats</th>
<th className="pb-2 w-24 text-right">Moms</th>
<th className="pb-2 w-8"></th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.id} className="border-b last:border-0 align-top">
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
control={control}
render={({ field: f }) => (
<AccountCombobox
value={f.value}
accounts={accounts}
onChange={(val) => handleAccountChange(index, val)}
/>
)}
/>
</td>
<td className="py-2 pr-2">
<Input
placeholder="Beskrivning"
{...register(`items.${index}.description`)}
/>
</td>
<td className="py-2 pr-2">
<Input
type="number"
step="0.01"
placeholder="0,00"
{...register(`items.${index}.amount`, { valueAsNumber: true })}
/>
</td>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<Select
value={String(f.value)}
onValueChange={(v) => f.onChange(parseFloat(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25">25%</SelectItem>
<SelectItem value="0.12">12%</SelectItem>
<SelectItem value="0.06">6%</SelectItem>
<SelectItem value="0">0%</SelectItem>
</SelectContent>
</Select>
)}
/>
</td>
<td className="py-2 pr-2 text-right font-mono pt-4">
{formatAmount(itemTotals[index]?.vatAmount || 0)}
</td>
<td className="py-2 pt-3">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
{/* Totals */}
<div className="mt-4 pt-4 border-t space-y-2 text-right">
<div className="flex justify-end gap-8">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="font-mono w-32">{formatAmount(subtotal)} kr</span>
</div>
<div className="flex justify-end gap-8">
<span className="text-muted-foreground">Moms</span>
<span className="font-mono w-32">{formatAmount(totalVat)} kr</span>
</div>
<div className="flex justify-end gap-8 font-bold text-lg">
<span>Totalt</span>
<span className="font-mono w-32">{formatAmount(total)} kr</span>
</div>
</div>
</CardContent>
</Card>
{/* Section 3: Övrigt (collapsible) */}
<Card>
<CardHeader
className="cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => setAdvancedOpen(!advancedOpen)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Övrigt</CardTitle>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
</div>
</CardHeader>
{advancedOpen && (
<CardContent className="space-y-4 pt-0">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Valuta</Label>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SEK">SEK</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="NOK">NOK</SelectItem>
<SelectItem value="DKK">DKK</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{watchedCurrency !== 'SEK' && (
<div className="space-y-2">
<Label>Växelkurs</Label>
<Input
type="number"
step="0.0001"
placeholder="1.0000"
{...register('exchange_rate')}
/>
</div>
)}
</div>
<div className="space-y-2">
<Label>Leveransdatum (ML krav)</Label>
<Input type="date" {...register('delivery_date')} />
</div>
<div className="flex items-center gap-2">
<Controller
name="reverse_charge"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<Label>Omvänd skattskyldighet (reverse charge)</Label>
</div>
<div className="space-y-2">
<Label>Anteckningar</Label>
<Textarea
placeholder="Interna anteckningar om denna utgift..."
{...register('notes')}
/>
</div>
</CardContent>
)}
</Card>
{/* Submit */}
<div className="flex justify-end gap-4">
<Button type="button" variant="outline" onClick={() => router.push('/expenses')}>
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Registrerar...
</>
) : isEF ? (
'Registrera utgift'
) : (
'Granska & registrera'
)}
</Button>
</div>
</form>
{/* Review dialog (AB only) */}
{pendingData && !isEF && (() => {
const selectedSupplier = suppliers.find((s) => s.id === pendingData.supplier_id)
if (!selectedSupplier) return null
return (
<ConfirmationDialog
open={showReview}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska utgift"
warningText="Utgiften registreras och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en ändringsverifikation."
confirmLabel="Bekräfta & registrera"
>
<SupplierInvoiceReviewContent
supplier={selectedSupplier}
invoiceNumber={pendingData.supplier_invoice_number}
invoiceDate={pendingData.invoice_date}
dueDate={pendingData.due_date}
deliveryDate={pendingData.delivery_date || undefined}
currency={pendingData.currency}
exchangeRate={pendingData.exchange_rate || undefined}
reverseCharge={pendingData.reverse_charge}
paymentReference={pendingData.payment_reference || undefined}
items={pendingData.items}
subtotal={subtotal}
totalVat={totalVat}
total={total}
/>
</ConfirmationDialog>
)
})()}
{/* New Supplier Dialog */}
<Dialog open={showNewSupplier} onOpenChange={setShowNewSupplier}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ny leverantör</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Namn *</Label>
<Input
placeholder="Leverantörens namn"
value={newSupplier.name}
onChange={(e) => setNewSupplier((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Typ</Label>
<Select
value={newSupplier.supplier_type}
onValueChange={(v) => setNewSupplier((p) => ({ ...p, supplier_type: v }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Organisationsnummer</Label>
<Input
placeholder="XXXXXX-XXXX"
value={newSupplier.org_number}
onChange={(e) => setNewSupplier((p) => ({ ...p, org_number: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bankgiro</Label>
<Input
placeholder="XXX-XXXX"
value={newSupplier.bankgiro}
onChange={(e) => setNewSupplier((p) => ({ ...p, bankgiro: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Plusgiro</Label>
<Input
placeholder="XXXXXX-X"
value={newSupplier.plusgiro}
onChange={(e) => setNewSupplier((p) => ({ ...p, plusgiro: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Standardkonto (kostnad)</Label>
<Input
placeholder="t.ex. 5010"
value={newSupplier.default_expense_account}
onChange={(e) => setNewSupplier((p) => ({ ...p, default_expense_account: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewSupplier(false)}>
Avbryt
</Button>
<Button onClick={handleCreateSupplier} disabled={isCreatingSupplier}>
{isCreatingSupplier ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
</>
) : (
'Skapa leverantör'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+288
View File
@@ -0,0 +1,288 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { Plus, Search, Wallet, Clock, AlertCircle } from 'lucide-react'
import type { SupplierInvoice } from '@/types'
type ExpenseInvoice = SupplierInvoice & { supplier?: { id: string; name: string } }
const UNPAID_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid']
const PAID_STATUSES = ['paid', 'credited']
function getSimplifiedStatus(status: string): { label: string; variant: 'default' | 'secondary' | 'destructive'; borderColor: string } {
switch (status) {
case 'registered':
case 'approved':
return { label: 'Obetald', variant: 'default', borderColor: 'border-l-warning' }
case 'overdue':
return { label: 'Förfallen', variant: 'destructive', borderColor: 'border-l-destructive' }
case 'partially_paid':
return { label: 'Delbetald', variant: 'default', borderColor: 'border-l-orange-400' }
case 'paid':
return { label: 'Betald', variant: 'secondary', borderColor: 'border-l-success' }
case 'credited':
return { label: 'Krediterad', variant: 'secondary', borderColor: 'border-l-muted-foreground/30' }
default:
return { label: status, variant: 'secondary', borderColor: 'border-l-muted-foreground/30' }
}
}
function getRelativeTimeLabel(dueDateStr: string, status: string): { text: string; color: string } | null {
if (PAID_STATUSES.includes(status)) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const dueDate = new Date(dueDateStr)
dueDate.setHours(0, 0, 0, 0)
const diffDays = Math.round((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return { text: `${Math.abs(diffDays)} dagar försenad`, color: 'text-destructive' }
} else if (diffDays === 0) {
return { text: 'Förfaller idag', color: 'text-warning-foreground' }
} else if (diffDays <= 3) {
return { text: `${diffDays} dagar kvar`, color: 'text-warning-foreground' }
} else if (diffDays <= 7) {
return { text: `${diffDays} dagar kvar`, color: 'text-muted-foreground' }
}
return null
}
export default function ExpensesPage() {
const [invoices, setInvoices] = useState<ExpenseInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('unpaid')
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchExpenses()
}, [])
async function fetchExpenses() {
setIsLoading(true)
const { data, error } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(id, name)')
.order('due_date', { ascending: true })
if (error) {
toast({ title: 'Fel', description: 'Kunde inte hämta utgifter', variant: 'destructive' })
} else {
setInvoices(data || [])
}
setIsLoading(false)
}
const filteredInvoices = invoices.filter((inv) => {
const matchesSearch =
(inv.supplier?.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.supplier_invoice_number.toLowerCase().includes(searchTerm.toLowerCase())
const matchesTab =
activeTab === 'unpaid'
? UNPAID_STATUSES.includes(inv.status)
: PAID_STATUSES.includes(inv.status)
return matchesSearch && matchesTab
})
const unpaidInvoices = invoices.filter((i) => UNPAID_STATUSES.includes(i.status))
const overdueInvoices = invoices.filter((i) => i.status === 'overdue')
const stats = {
unpaidAmount: unpaidInvoices.reduce((sum, i) => sum + i.remaining_amount, 0),
unpaidCount: unpaidInvoices.length,
overdueAmount: overdueInvoices.reduce((sum, i) => sum + i.remaining_amount, 0),
overdueCount: overdueInvoices.length,
}
return (
<div className="space-y-6">
<PageHeader
title="Utgifter"
description="Registrera och hantera dina utgifter"
action={
<Link href="/expenses/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny utgift
</Button>
</Link>
}
/>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Att betala</p>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
<p className="text-xs text-muted-foreground">{stats.unpaidCount} utgifter</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<AlertCircle className={`h-6 w-6 ${stats.overdueCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
</div>
<div>
{stats.overdueCount > 0 ? (
<>
<p className="text-sm text-muted-foreground">Förfallet</p>
<p className="text-2xl font-bold tabular-nums text-destructive">{formatCurrency(stats.overdueAmount)}</p>
<p className="text-xs text-muted-foreground">{stats.overdueCount} utgifter</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground">Totalt antal</p>
<p className="text-2xl font-bold tabular-nums">{invoices.length}</p>
</>
)}
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search and tabs */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök på leverantör eller fakturanummer..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="unpaid">Att betala</TabsTrigger>
<TabsTrigger value="paid">Betalda</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Expense list */}
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-5 bg-muted rounded w-32" />
<div className="h-4 bg-muted rounded w-48" />
</div>
<div className="h-8 bg-muted rounded w-24" />
</div>
</CardContent>
</Card>
))}
</div>
) : filteredInvoices.length === 0 ? (
<Card>
<CardContent>
{searchTerm ? (
<div className="flex flex-col items-center justify-center py-12">
<Wallet className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga träffar</h3>
<p className="text-muted-foreground text-center mt-1">
Inga utgifter matchar &quot;{searchTerm}&quot;
</p>
</div>
) : invoices.length === 0 ? (
<EmptyState
icon={Wallet}
title="Inga utgifter ännu"
description="Registrera din första utgift. Vi bokför automatiskt och håller koll på betalningar."
actionLabel="Ny utgift"
actionHref="/expenses/new"
/>
) : (
<div className="flex flex-col items-center justify-center py-12">
<Wallet className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga utgifter i denna kategori</h3>
<p className="text-muted-foreground text-center mt-1">
Prova att byta flik för att se fler utgifter
</p>
</div>
)}
</CardContent>
</Card>
) : (
<div className="space-y-2">
{filteredInvoices.map((inv) => {
const status = getSimplifiedStatus(inv.status)
const relativeTime = inv.due_date ? getRelativeTimeLabel(inv.due_date, inv.status) : null
return (
<Link key={inv.id} href={`/expenses/${inv.id}`}>
<Card className={`hover:border-primary/50 transition-colors cursor-pointer border-l-4 ${status.borderColor} ${inv.status === 'overdue' ? 'ring-1 ring-destructive/20' : ''}`}>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center">
<Wallet className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{inv.supplier?.name || 'Okänd leverantör'}</p>
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
{status.label}
</Badge>
</div>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
{inv.supplier_invoice_number} · Förfaller {inv.due_date}
</p>
{relativeTime && (
<span className={`text-xs font-medium ${relativeTime.color}`}>
{relativeTime.text}
</span>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="font-medium tabular-nums">
{formatCurrency(inv.total, inv.currency)}
</p>
{inv.remaining_amount !== inv.total && inv.remaining_amount > 0 && (
<p className="text-sm text-muted-foreground tabular-nums">
Kvar: {formatCurrency(inv.remaining_amount, inv.currency)}
</p>
)}
</div>
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}
+58 -27
View File
@@ -3,9 +3,9 @@
import { useState, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeftRight, FileText } from 'lucide-react'
import { ArrowLeftRight, FileText, ArrowLeft } from 'lucide-react'
// Bank file import components
import BankFileUploadStep from '@/components/import/BankFileUploadStep'
@@ -64,7 +64,6 @@ function BankFileImportWizard() {
const [detectedFormatName, setDetectedFormatName] = useState<string | null>(null)
const [fileHash, setFileHash] = useState<string>('')
const [filename, setFilename] = useState<string>('')
const [existingTxCount, setExistingTxCount] = useState(0)
const [rawFileContent, setRawFileContent] = useState<string>('')
// Column mapping for generic CSV
@@ -110,8 +109,6 @@ function BankFileImportWizard() {
setDetectedFormatName(data.data.detected_format_name)
setFileHash(data.data.file_hash)
setFilename(data.data.filename)
setExistingTxCount(data.data.existing_transaction_count)
// Store headers for generic CSV mapping
if (data.data.headers) {
setCsvHeaders(data.data.headers)
@@ -200,7 +197,6 @@ function BankFileImportWizard() {
setDetectedFormatName(null)
setFileHash('')
setFilename('')
setExistingTxCount(0)
setIngestResult(null)
setBankError(null)
setCsvHeaders([])
@@ -245,7 +241,6 @@ function BankFileImportWizard() {
{bankStep === 'preview' && parseResult && (
<BankFilePreviewStep
parseResult={parseResult}
existingTransactionCount={existingTxCount}
onContinue={() => {
if (parseResult.format === 'generic_csv') {
setBankStep('column_mapping')
@@ -541,10 +536,14 @@ function SIEImportWizard() {
}
// ============================================================
// Import Page with Tabs
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'bank' | 'sie'
export default function ImportPage() {
const [mode, setMode] = useState<ImportMode>(null)
return (
<div className="space-y-6">
{/* Header */}
@@ -555,27 +554,59 @@ export default function ImportPage() {
</p>
</div>
{/* Tabbed layout */}
<Tabs defaultValue="bank" className="space-y-6">
<TabsList>
<TabsTrigger value="bank">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Banktransaktioner
</TabsTrigger>
<TabsTrigger value="sie">
<FileText className="mr-2 h-4 w-4" />
Bokföringsdata (SIE)
</TabsTrigger>
</TabsList>
{mode === null && (
<div className="grid gap-4 md:grid-cols-2">
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setMode('bank')}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<ArrowLeftRight className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">Banktransaktioner</h3>
<p className="text-sm text-muted-foreground mt-1">
Importera kontoutdrag från din bank. Stöder CSV, OFX och de flesta svenska banker.
</p>
</div>
<p className="text-xs text-muted-foreground">
CSV, OFX, SEB, Swedbank, Handelsbanken, Nordea m.fl.
</p>
</CardContent>
</Card>
<TabsContent value="bank">
<BankFileImportWizard />
</TabsContent>
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setMode('sie')}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">Bokföringsdata (SIE)</h3>
<p className="text-sm text-muted-foreground mt-1">
Importera verifikationer och kontoplan från ett annat bokföringsprogram via SIE-filer.
</p>
</div>
<p className="text-xs text-muted-foreground">
SIE4-format (.se, .si)
</p>
</CardContent>
</Card>
</div>
)}
<TabsContent value="sie">
<SIEImportWizard />
</TabsContent>
</Tabs>
{mode !== null && (
<Button variant="ghost" size="sm" onClick={() => setMode(null)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till val
</Button>
)}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
</div>
)
}
+1 -13
View File
@@ -16,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates, getVatSummaryFromItems } from '@/lib/invoices/vat-rules'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
@@ -413,18 +413,6 @@ export default function NewInvoicePage() {
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
)}
{selectedCustomer && vatRules && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<p className="text-sm">
<strong>Momsbehandling:</strong> {getVatSummaryFromItems(watchItems).label}
</p>
{vatRules.reverseChargeText && (
<p className="text-xs text-muted-foreground mt-1">
Omvänd skattskyldighet tillämpas
</p>
)}
</div>
)}
</CardContent>
</Card>
+5 -13
View File
@@ -29,18 +29,11 @@ export default async function DashboardLayout({
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
const [{ data: enabledToggles }, { count: uncategorizedCount }] = await Promise.all([
supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.is('is_business', null),
])
const { count: uncategorizedCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.is('is_business', null)
return (
<div className="min-h-screen bg-background">
@@ -54,7 +47,6 @@ export default async function DashboardLayout({
<DashboardNav
companyName={settings.company_name || 'Min verksamhet'}
entityType={entityType}
enabledExtensions={enabledToggles || []}
uncategorizedTransactionCount={uncategorizedCount ?? 0}
/>
<main id="main-content" className="pb-20 md:pb-0 md:pl-[232px]" role="main">
+1 -19
View File
@@ -6,10 +6,9 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight, ArrowLeftRight } from 'lucide-react'
import { Download, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { SRUExportView } from '@/components/reports/SRUExportView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
@@ -111,15 +110,12 @@ export default function ReportsPage() {
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="trial-balance" className="w-full justify-start">
<Scale className="h-4 w-4 mr-1.5" />
Saldobalans
</TabsTrigger>
<TabsTrigger value="income-statement" className="w-full justify-start">
<TrendingUp className="h-4 w-4 mr-1.5" />
Resultaträkning
</TabsTrigger>
<TabsTrigger value="balance-sheet" className="w-full justify-start">
<FileText className="h-4 w-4 mr-1.5" />
Balansräkning
</TabsTrigger>
</TabsList>
@@ -132,19 +128,13 @@ export default function ReportsPage() {
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="vat-declaration" className="w-full justify-start">
<Receipt className="h-4 w-4 mr-1.5" />
Momsdeklaration
</TabsTrigger>
{isEnskildFirma && (
<TabsTrigger value="ne-declaration" className="w-full justify-start">
<Briefcase className="h-4 w-4 mr-1.5" />
NE-bilaga
</TabsTrigger>
)}
<TabsTrigger value="sru-export" className="w-full justify-start">
<FileDown className="h-4 w-4 mr-1.5" />
SRU-export
</TabsTrigger>
</TabsList>
</div>
@@ -155,19 +145,15 @@ export default function ReportsPage() {
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="huvudbok" className="w-full justify-start">
<BookOpen className="h-4 w-4 mr-1.5" />
Huvudbok
</TabsTrigger>
<TabsTrigger value="grundbok" className="w-full justify-start">
<List className="h-4 w-4 mr-1.5" />
Grundbok
</TabsTrigger>
<TabsTrigger value="kundreskontra" className="w-full justify-start">
<Users className="h-4 w-4 mr-1.5" />
Kundreskontra
</TabsTrigger>
<TabsTrigger value="supplier-ledger" className="w-full justify-start">
<Building2 className="h-4 w-4 mr-1.5" />
Leverantörsreskontra
</TabsTrigger>
</TabsList>
@@ -180,7 +166,6 @@ export default function ReportsPage() {
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="bank-reconciliation" className="w-full justify-start">
<ArrowLeftRight className="h-4 w-4 mr-1.5" />
Bankavstämning
</TabsTrigger>
</TabsList>
@@ -204,9 +189,6 @@ export default function ReportsPage() {
<NEDeclarationView periodId={selectedPeriod} />
</TabsContent>
)}
<TabsContent value="sru-export">
<SRUExportView periodId={selectedPeriod} />
</TabsContent>
<TabsContent value="huvudbok">
<GeneralLedgerView periodId={selectedPeriod} />
</TabsContent>
+170 -43
View File
@@ -11,19 +11,24 @@ import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { Separator } from '@/components/ui/separator'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Loader2,
Building,
CreditCard,
User,
LogOut,
Bell,
Calendar,
Sun,
Moon,
Monitor,
Palette,
ExternalLink,
AlertTriangle,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import type { CompanySettings } from '@/types'
@@ -44,6 +49,10 @@ export default function SettingsPage() {
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
const [hasNotificationExtension, setHasNotificationExtension] = useState(false)
const [hasCalendarExtension, setHasCalendarExtension] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
@@ -118,9 +127,10 @@ export default function SettingsPage() {
setSettings(settingsData)
// Check extension toggles in parallel
const [bankingToggleRes, notificationToggleRes] = await Promise.all([
const [bankingToggleRes, notificationToggleRes, calendarToggleRes] = await Promise.all([
fetch('/api/extensions/toggles/general/enable-banking').catch(() => null),
fetch('/api/extensions/toggles/general/push-notifications').catch(() => null),
fetch('/api/extensions/toggles/general/calendar').catch(() => null),
])
if (bankingToggleRes?.ok) {
@@ -142,6 +152,11 @@ export default function SettingsPage() {
setHasNotificationExtension(data?.enabled || false)
}
if (calendarToggleRes?.ok) {
const { data } = await calendarToggleRes.json()
setHasCalendarExtension(data?.enabled || false)
}
setIsLoading(false)
}
@@ -204,6 +219,33 @@ export default function SettingsPage() {
router.push('/login')
}
async function handleDeleteAccount() {
if (deleteConfirmText !== 'RADERA') return
setIsDeleting(true)
try {
const response = await fetch('/api/account/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: 'RADERA' }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte radera kontot')
}
router.push('/login')
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte radera kontot',
variant: 'destructive',
})
setIsDeleting(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -224,27 +266,25 @@ export default function SettingsPage() {
<Tabs defaultValue={initialTab} className="space-y-6">
<TabsList className="flex-wrap h-auto gap-1">
<TabsTrigger value="company">
<Building className="mr-2 h-4 w-4" />
Företag
</TabsTrigger>
<TabsTrigger value="banking">
<CreditCard className="mr-2 h-4 w-4" />
Bank (PSD2)
</TabsTrigger>
<TabsTrigger value="notifications">
<Bell className="mr-2 h-4 w-4" />
Aviseringar
</TabsTrigger>
<TabsTrigger value="calendar">
<Calendar className="mr-2 h-4 w-4" />
Kalender
</TabsTrigger>
{hasNotificationExtension && (
<TabsTrigger value="notifications">
Aviseringar
</TabsTrigger>
)}
{hasCalendarExtension && (
<TabsTrigger value="calendar">
Kalender
</TabsTrigger>
)}
<TabsTrigger value="appearance">
<Palette className="mr-2 h-4 w-4" />
Utseende
</TabsTrigger>
<TabsTrigger value="account">
<User className="mr-2 h-4 w-4" />
Konto
</TabsTrigger>
</TabsList>
@@ -268,7 +308,11 @@ export default function SettingsPage() {
id="company_name"
name="company_name"
defaultValue={settings?.company_name || ''}
disabled={settings?.onboarding_complete === true}
/>
{settings?.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
@@ -276,7 +320,11 @@ export default function SettingsPage() {
id="org_number"
name="org_number"
defaultValue={settings?.org_number || ''}
disabled={settings?.onboarding_complete === true}
/>
{settings?.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
</div>
@@ -485,32 +533,20 @@ export default function SettingsPage() {
</TabsContent>
{/* Notification settings — loaded dynamically from extension */}
<TabsContent value="notifications">
{hasNotificationExtension && NotificationPanel ? (
<NotificationPanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Bell className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="font-medium mb-1">Push-notiser är inte aktiverade</p>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Aktivera tillägget Push-notiser för att få aviseringar om förfallna fakturor, deadlines och andra händelser.
</p>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
Gå till Tillägg
</Link>
</Button>
</CardContent>
</Card>
)}
</TabsContent>
{hasNotificationExtension && (
<TabsContent value="notifications">
{NotificationPanel ? (
<NotificationPanel />
) : null}
</TabsContent>
)}
{/* Calendar feed settings */}
<TabsContent value="calendar">
<CalendarFeedSettings />
</TabsContent>
{hasCalendarExtension && (
<TabsContent value="calendar">
<CalendarFeedSettings />
</TabsContent>
)}
{/* Appearance settings */}
<TabsContent value="appearance">
@@ -610,7 +646,7 @@ export default function SettingsPage() {
</TabsContent>
{/* Account settings */}
<TabsContent value="account">
<TabsContent value="account" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Kontoinställningar</CardTitle>
@@ -630,8 +666,99 @@ export default function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Radera konto</CardTitle>
<CardDescription>
Permanent borttagning av ditt konto och all data
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-2 text-sm">
<p className="font-medium text-destructive">Varning: Denna åtgärd kan inte ångras</p>
<p className="text-muted-foreground">
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
</p>
<p className="text-muted-foreground">
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år. Du ansvarar själv för att exportera och arkivera din bokföringsdata innan du raderar kontot.
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera bokföringsdata (SIE)
</Link>
</Button>
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
>
Radera mitt konto
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Delete account confirmation dialog */}
<Dialog open={showDeleteDialog} onOpenChange={(open) => {
setShowDeleteDialog(open)
if (!open) setDeleteConfirmText('')
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogDescription>
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
<Input
id="delete-confirm"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="RADERA"
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowDeleteDialog(false)
setDeleteConfirmText('')
}}
disabled={isDeleting}
>
Avbryt
</Button>
<Button
variant="destructive"
onClick={handleDeleteAccount}
disabled={deleteConfirmText !== 'RADERA' || isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
</>
) : (
'Radera permanent'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+81 -8
View File
@@ -20,10 +20,12 @@ import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog'
import TemplatePicker from '@/components/transactions/TemplatePicker'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem } from '@/types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -54,11 +56,17 @@ export default function TransactionsPage() {
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Template picker dialog
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [templatePickerTransaction, setTemplatePickerTransaction] = useState<TransactionWithInvoice | null>(null)
// Quick review dialog (suggestion review before booking)
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
const [quickReviewTransaction, setQuickReviewTransaction] = useState<TransactionWithInvoice | null>(null)
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('')
const [quickReviewTemplate, setQuickReviewTemplate] = useState<BookingTemplate | null>(null)
const [quickReviewTemplateId, setQuickReviewTemplateId] = useState<string | undefined>(undefined)
// Describe dialog
const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
@@ -502,8 +510,8 @@ export default function TransactionsPage() {
}
function openCategoryDialog(transaction: TransactionWithInvoice) {
setBookingDialogTransaction(transaction)
setBookingDialogOpen(true)
setTemplatePickerTransaction(transaction)
setTemplatePickerOpen(true)
}
function handleOpenQuickReview(transaction: TransactionWithInvoice, suggestion: SuggestedCategory) {
@@ -515,17 +523,59 @@ export default function TransactionsPage() {
setQuickReviewOpen(true)
}
function handleTemplateSelected(template: BookingTemplate) {
setTemplatePickerOpen(false)
const tx = templatePickerTransaction
if (!tx) return
setQuickReviewTransaction(tx)
setQuickReviewCategory(template.fallback_category)
setQuickReviewLabel(template.name_sv)
setQuickReviewTemplate(template)
setQuickReviewTemplateId(template.id)
setQuickReviewOpen(true)
}
function handleOpenTemplateReview(transaction: TransactionWithInvoice, templateId: string) {
const template = getTemplateById(templateId)
if (!template) return
setQuickReviewTransaction(transaction)
setQuickReviewCategory(template.fallback_category)
setQuickReviewLabel(template.name_sv)
setQuickReviewTemplate(template)
setQuickReviewTemplateId(template.id)
setQuickReviewOpen(true)
}
function handleChangeTemplate() {
setQuickReviewOpen(false)
if (quickReviewTransaction) {
setTemplatePickerTransaction(quickReviewTransaction)
setTemplatePickerOpen(true)
}
}
function handleManualBooking() {
setTemplatePickerOpen(false)
if (templatePickerTransaction) {
setBookingDialogTransaction(templatePickerTransaction)
setBookingDialogOpen(true)
}
}
async function handleQuickReviewConfirm(
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined
accountOverride: string | undefined,
templateId?: string
): Promise<string | null> {
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride)
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
if (journalEntryId) {
setQuickReviewOpen(false)
setQuickReviewTransaction(null)
setQuickReviewCategory(null)
setQuickReviewTemplate(null)
setQuickReviewTemplateId(undefined)
}
return journalEntryId
}
@@ -567,7 +617,7 @@ export default function TransactionsPage() {
onCategorize={handleCategorize}
onMatchInvoice={handleMatchInvoice}
onClose={() => setShowSwipeView(false)}
entityType={entityType as import('@/types').EntityType}
entityType={entityType as EntityType}
/>
)
}
@@ -629,6 +679,7 @@ export default function TransactionsPage() {
onOpenCategoryDialog={openCategoryDialog}
onOpenDescribe={ENABLED_EXTENSION_IDS.has('ai-categorization') ? openDescribeDialog : undefined}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
/>
))}
@@ -684,8 +735,27 @@ export default function TransactionsPage() {
onBooked={handleTransactionBooked}
/>
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Välj mall</DialogTitle>
</DialogHeader>
<TemplatePicker
direction={templatePickerTransaction && templatePickerTransaction.amount < 0 ? 'expense' : 'income'}
entityType={entityType as EntityType}
suggestedTemplates={templatePickerTransaction ? templateSuggestions[templatePickerTransaction.id] : undefined}
onSelect={handleTemplateSelected}
/>
<div className="pt-2 border-t">
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={handleManualBooking}>
Bokför manuellt utan mall...
</Button>
</div>
</DialogContent>
</Dialog>
<QuickReviewDialog
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory)}
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory) + String(quickReviewTemplateId)}
open={quickReviewOpen}
onOpenChange={setQuickReviewOpen}
transaction={quickReviewTransaction}
@@ -693,8 +763,11 @@ export default function TransactionsPage() {
categoryLabel={quickReviewLabel}
defaultAccount={quickReviewCategory ? getDefaultAccountForCategory(quickReviewCategory) : ''}
defaultVat={quickReviewCategory ? (getDefaultVatTreatmentForCategory(quickReviewCategory) ?? 'none') : 'none'}
entityType={entityType as import('@/types').EntityType}
entityType={entityType as EntityType}
template={quickReviewTemplate}
templateId={quickReviewTemplateId}
onConfirm={handleQuickReviewConfirm}
onChangeTemplate={handleChangeTemplate}
/>
<DescribeTransactionDialog
+31 -69
View File
@@ -14,8 +14,6 @@ import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
import Step6SectorSelection from '@/components/onboarding/Step2SectorSelection'
import Step7ExtensionSuggestions from '@/components/onboarding/Step3ExtensionSuggestions'
const STEP_TITLES = [
'Verksamhetsform',
@@ -23,8 +21,6 @@ const STEP_TITLES = [
'Skatteregistrering',
'F-skatt',
'Anslut bank',
'Bransch',
'Tillägg',
]
function translatePeriodError(msg: string): string {
@@ -53,9 +49,8 @@ function OnboardingPageContent() {
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const [sectorSlug, setSectorSlug] = useState<string | null>(null)
const totalSteps = 7
const totalSteps = 5
const stepTitles = STEP_TITLES
// Load existing settings on mount
@@ -76,10 +71,9 @@ function OnboardingPageContent() {
if (data) {
setSettings(data)
setCurrentStep(data.onboarding_step || 1)
if ((data as Record<string, unknown>).sector_slug) {
setSectorSlug((data as Record<string, unknown>).sector_slug as string)
}
const step = data.onboarding_step || 1
// Clamp to max steps (handles existing users mid-onboarding from old 7-step flow)
setCurrentStep(step > totalSteps ? totalSteps : step)
}
setIsLoading(false)
@@ -95,9 +89,14 @@ function OnboardingPageContent() {
title: 'Bank ansluten!',
description: 'Din bank har kopplats.',
})
saveSettings({}, 6).then((success) => {
// Complete onboarding after bank connection
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
if (success) {
setCurrentStep(6)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}
})
}
@@ -148,6 +147,11 @@ function OnboardingPageContent() {
}
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Fix org number bug: clear dependent fields when entity type changes
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) {
stepData = { ...stepData, org_number: '', company_name: '' }
}
const nextStep = currentStep + 1
const success = await saveSettings(stepData, nextStep)
@@ -275,6 +279,10 @@ function OnboardingPageContent() {
if (nextStep > totalSteps) {
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
} else {
setCurrentStep(nextStep)
@@ -307,41 +315,6 @@ function OnboardingPageContent() {
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
const handleSectorNext = async (data: { sector_slug: string | null }) => {
setSectorSlug(data.sector_slug)
const nextStep = currentStep + 1
const settingsUpdate: Partial<CompanySettings> = {}
if (data.sector_slug) {
;(settingsUpdate as Record<string, unknown>).sector_slug = data.sector_slug
}
const success = await saveSettings(settingsUpdate, nextStep)
if (success) {
setCurrentStep(nextStep)
}
}
const handleExtensionsNext = async (data: { enabled_extensions: { sector_slug: string; extension_slug: string }[] }) => {
// Insert extension toggles if any were selected
if (data.enabled_extensions.length > 0) {
try {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const rows = data.enabled_extensions.map(ext => ({
user_id: user.id,
sector_slug: ext.sector_slug,
extension_slug: ext.extension_slug,
enabled: true,
}))
await supabase.from('extension_toggles').upsert(rows, {
onConflict: 'user_id,sector_slug,extension_slug',
})
}
} catch (err) {
console.error('Failed to save extension toggles:', err)
}
}
}
const renderSteps = () => (
<>
{currentStep === 1 && (
@@ -354,6 +327,7 @@ function OnboardingPageContent() {
{currentStep === 2 && (
<Step2CompanyDetails
key={settings.entity_type}
initialData={{
company_name: settings.company_name ?? undefined,
org_number: settings.org_number ?? undefined,
@@ -379,6 +353,7 @@ function OnboardingPageContent() {
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
}}
entityType={settings.entity_type as EntityType}
orgNumber={settings.org_number ?? undefined}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
@@ -408,29 +383,8 @@ function OnboardingPageContent() {
}}
onComplete={async (data) => {
if (data) {
await saveSettings(data, 6)
await saveSettings(data, totalSteps)
}
setCurrentStep(6)
}}
onBack={handleBack}
onSkip={() => setCurrentStep(6)}
isSaving={isSaving}
/>
)}
{currentStep === 6 && (
<Step6SectorSelection
onNext={handleSectorNext}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 7 && (
<Step7ExtensionSuggestions
sectorSlug={sectorSlug}
onNext={async (data) => {
await handleExtensionsNext(data)
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
@@ -439,6 +393,14 @@ function OnboardingPageContent() {
router.push('/')
}}
onBack={handleBack}
onSkip={async () => {
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}}
isSaving={isSaving}
/>
)}
+54
View File
@@ -0,0 +1,54 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
const DeleteAccountSchema = z.object({
confirm: z.literal('RADERA'),
})
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = DeleteAccountSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Skriv RADERA för att bekräfta kontoborttagning' },
{ status: 400 }
)
}
try {
// Delete the auth user via service role — all data cascades via ON DELETE CASCADE
const serviceClient = await createServiceClient()
const { error } = await serviceClient.auth.admin.deleteUser(user.id)
if (error) {
console.error('Failed to delete user:', error)
return NextResponse.json(
{ error: 'Kunde inte radera kontot. Försök igen.' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Account deletion error:', error)
return NextResponse.json(
{ error: 'Kunde inte radera kontot. Försök igen.' },
{ status: 500 }
)
}
}
@@ -0,0 +1,91 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
/**
* GET: Preview currency revaluation for a fiscal period
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Fetch period to get closing date
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (periodError || !period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
const preview = await previewCurrencyRevaluation(supabase, user.id, period.period_end)
return NextResponse.json({ data: preview })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to preview currency revaluation' },
{ status: 400 }
)
}
}
/**
* POST: Execute currency revaluation for a fiscal period
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Fetch period to get closing date
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (periodError || !period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
if (period.is_closed) {
return NextResponse.json({ error: 'Period is already closed' }, { status: 400 })
}
const result = await executeCurrencyRevaluation(supabase, user.id, period.period_end, id)
if (!result) {
return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' })
}
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to execute currency revaluation' },
{ status: 400 }
)
}
}
+2 -2
View File
@@ -27,8 +27,8 @@ export async function GET(request: Request) {
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
.eq('user_id', user.id)
.order('entry_date', { ascending: false })
.order('voucher_number', { ascending: false })
.order('voucher_series', { ascending: true })
.order('voucher_number', { ascending: true })
.range(offset, offset + limit - 1)
if (periodId) {
+14 -8
View File
@@ -157,15 +157,21 @@ export async function POST(request: Request) {
}
}
// Generate invoice number with appropriate prefix
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
const invoiceNumber = documentType === 'proforma'
? `PF-${baseNumber}`
: documentType === 'delivery_note'
? `FS-${baseNumber}`
// Generate document number from the appropriate sequence
let invoiceNumber: string
if (documentType === 'delivery_note') {
const { data: dnNumber } = await supabase.rpc('generate_delivery_note_number', {
p_user_id: user.id,
})
invoiceNumber = dnNumber
} else {
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
invoiceNumber = documentType === 'proforma'
? `PF-${baseNumber}`
: baseNumber
}
// Create invoice
const { data: invoice, error: invoiceError } = await supabase
+1 -1
View File
@@ -5,7 +5,7 @@ import {
generateSRUFile,
sruFileToString,
getSRUFilename,
} from '@/lib/reports/sru-export/sru-generator'
} from '@/lib/reports/ne-bilaga/sru-generator'
/**
* GET /api/reports/ne-bilaga
@@ -1,29 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSRUCoverage } from '@/lib/reports/sru-export/sru-engine'
/**
* GET /api/reports/sru-export/coverage
*
* Returns SRU code coverage stats: how many accounts have SRU codes vs total,
* and a list of accounts missing codes.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const coverage = await getSRUCoverage(supabase, user.id)
return NextResponse.json({ data: coverage })
} catch (err) {
console.error('Error fetching SRU coverage:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch SRU coverage' },
{ status: 500 }
)
}
}
-150
View File
@@ -1,150 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { aggregateBalancesBySRU } from '@/lib/reports/sru-export/sru-engine'
import {
generateGenericSRU,
getGenericSRUFilename,
sruFileToString,
validateSRUFile,
SRU_CODE_DESCRIPTIONS,
} from '@/lib/reports/sru-export/sru-generic-generator'
import type { SRUFormType } from '@/lib/reports/sru-export/sru-generic-generator'
import type { EntityType } from '@/types'
/**
* GET /api/reports/sru-export
*
* Generate SRU export from chart_of_accounts sru_code mappings.
*
* Query parameters:
* - period_id: Fiscal period ID (required)
* - format: 'json' (default) or 'sru' for downloadable file
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const format = searchParams.get('format') || 'json'
if (!periodId) {
return NextResponse.json(
{ error: 'period_id is required' },
{ status: 400 }
)
}
try {
// Fetch company settings to determine entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, company_name, org_number')
.eq('user_id', user.id)
.single()
if (!settings) {
return NextResponse.json(
{ error: 'Company settings not found. Complete onboarding first.' },
{ status: 400 }
)
}
// Determine form type from entity type
const formType: SRUFormType = settings.entity_type === 'aktiebolag' ? 'INK2' : 'NE'
// Fetch fiscal period for date range
const { data: period } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', periodId)
.eq('user_id', user.id)
.single()
if (!period) {
return NextResponse.json(
{ error: 'Fiscal period not found' },
{ status: 404 }
)
}
// Aggregate balances by SRU code
const sruBalances = await aggregateBalancesBySRU(supabase, user.id, periodId)
if (format === 'sru') {
// Generate and return SRU file
const sruFile = generateGenericSRU({
formType,
orgNumber: settings.org_number,
companyName: settings.company_name || 'Okänt företag',
fiscalYearStart: period.period_start,
fiscalYearEnd: period.period_end,
sruBalances,
})
const validation = validateSRUFile(sruFile)
if (!validation.isValid) {
return NextResponse.json(
{ error: 'SRU validation failed', details: validation.errors },
{ status: 500 }
)
}
const sruContent = sruFileToString(sruFile)
const filename = getGenericSRUFilename(formType, settings.org_number, period.period_start)
return new NextResponse(sruContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}
// Default: return JSON preview
const balancesArray = Array.from(sruBalances.values())
.sort((a, b) => a.sruCode.localeCompare(b.sruCode))
.map((b) => ({
sruCode: b.sruCode,
description: SRU_CODE_DESCRIPTIONS[b.sruCode] || `SRU ${b.sruCode}`,
amount: b.amount,
accounts: b.accounts,
}))
const warnings: string[] = []
if (!period.is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
}
if (balancesArray.length === 0) {
warnings.push('Inga konton med SRU-koder har bokförda poster i denna period.')
}
return NextResponse.json({
data: {
formType,
entityType: settings.entity_type as EntityType,
companyName: settings.company_name,
orgNumber: settings.org_number,
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
},
balances: balancesArray,
warnings,
},
})
} catch (err) {
console.error('Error generating SRU export:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate SRU export' },
{ status: 500 }
)
}
}
+7 -1
View File
@@ -38,7 +38,7 @@ export async function PUT(request: Request) {
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month, onboarding_complete')
.eq('user_id', user.id)
.single()
@@ -46,6 +46,12 @@ export async function PUT(request: Request) {
if (!validation.success) return validation.response
const body = validation.data
// Lock company_name and org_number after onboarding is complete
if (oldSettings && (oldSettings as Record<string, unknown>).onboarding_complete === true) {
delete (body as Record<string, unknown>).company_name
delete (body as Record<string, unknown>).org_number
}
// Validate: enskild firma must use calendar year (BFL 3 kap.)
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates'
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
@@ -158,6 +158,12 @@ export async function POST(
if (body.template_id) {
const template = getTemplateById(body.template_id)
if (template) {
// Hard entity guard — reject templates that don't match the user's entity type
const entityValidation = validateTemplateForEntity(template, entityType)
if (!entityValidation.valid) {
return NextResponse.json({ error: entityValidation.error }, { status: 400 })
}
finalCategory = is_business ? template.fallback_category : 'private'
console.log(`[categorize] tx=${id} using template="${body.template_id}" (${template.name_sv}) → category=${finalCategory}, debit=${template.debit_account}, credit=${template.credit_account}, vat=${template.vat_treatment}`)
} else {
@@ -1,27 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { extensionRegistry } from '@/lib/extensions/registry'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
// Minimum confidence threshold — below this, suggestions are considered weak
// and we trigger on-demand AI categorization to get better results.
const WEAK_SUGGESTION_THRESHOLD = 0.55
/**
* Check if suggestions are "weak" — only history-based fallbacks
* with no strong rule/pattern/AI match.
*/
function hasWeakSuggestions(result: SuggestedCategory[]): boolean {
if (result.length === 0) return true
// Weak if the best suggestion is below threshold
const bestConfidence = Math.max(...result.map((s) => s.confidence))
if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true
// Weak if all suggestions are from history only (no rule/pattern/ai match)
if (result.every((s) => s.source === 'history')) return true
return false
}
/**
* POST /api/transactions/suggest-categories
* Batch endpoint for getting category suggestions for multiple transactions
@@ -115,7 +96,6 @@ export async function POST(request: Request) {
// Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
const needsAiIds: string[] = []
for (const tx of transactions) {
let result = getSuggestedCategories(
@@ -128,14 +108,10 @@ export async function POST(request: Request) {
const aiSuggestions = aiSuggestionsMap[tx.id]
if (aiSuggestions && aiSuggestions.length > 0) {
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
} else if (hasWeakSuggestions(result)) {
// No pre-computed AI suggestion AND rule-based suggestions are weak —
// mark this transaction for on-demand AI categorization
needsAiIds.push(tx.id)
}
suggestions[tx.id] = result
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
}
// Inject document template suggestions from matched inbox items
@@ -183,63 +159,5 @@ export async function POST(request: Request) {
// Non-blocking
}
// Trigger on-demand AI categorization for transactions with weak suggestions
if (needsAiIds.length > 0) {
console.log(
`[suggest-categories] ${needsAiIds.length} transactions have weak suggestions, triggering on-demand AI:`,
needsAiIds.map((id) => {
const tx = transactions.find((t) => t.id === id)
return tx
? { id, description: tx.description, merchant_name: tx.merchant_name, amount: tx.amount }
: { id }
})
)
try {
const aiExt = extensionRegistry.get('ai-categorization')
if (!aiExt?.services?.categorizeTransactions) {
console.log('[suggest-categories] AI categorization extension not loaded, skipping on-demand AI')
return NextResponse.json({ suggestions, template_suggestions })
}
const aiResults: Array<{ transactionId: string; category: string; basAccount: string; confidence: number; reasoning: string; isPrivate?: boolean; templateId?: string }> = await aiExt.services.categorizeTransactions(user.id, needsAiIds)
console.log(
'[suggest-categories] AI categorization results:',
aiResults.map((r) => ({
id: r.transactionId,
category: r.category,
basAccount: r.basAccount,
confidence: r.confidence,
reasoning: r.reasoning,
isPrivate: r.isPrivate,
templateId: r.templateId,
}))
)
// Group AI results by transactionId (AI now returns 2 per transaction)
const groupedAi: Record<string, { category: string; basAccount: string; confidence: number; reasoning: string }[]> = {}
for (const aiResult of aiResults) {
if (!groupedAi[aiResult.transactionId]) {
groupedAi[aiResult.transactionId] = []
}
groupedAi[aiResult.transactionId].push({
category: aiResult.category,
basAccount: aiResult.basAccount,
confidence: aiResult.confidence,
reasoning: aiResult.reasoning,
})
}
for (const [txId, aiSuggestions] of Object.entries(groupedAi)) {
const existing = suggestions[txId] || []
const tx = transactions.find((t) => t.id === txId)
suggestions[txId] = mergeAiSuggestions(existing, aiSuggestions, tx?.amount)
}
} catch (err) {
// AI categorization is non-blocking — log and continue with existing suggestions
console.error('[suggest-categories] On-demand AI categorization failed:', err)
}
}
return NextResponse.json({ suggestions, template_suggestions })
}
+2 -2
View File
@@ -23,13 +23,13 @@ const fraunces = Fraunces({
});
export const metadata: Metadata = {
title: "ERP Base",
title: "Gnubok",
description: "Ekonomihantering",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "ERP Base",
title: "Gnubok",
},
};
+12 -12
View File
@@ -161,12 +161,6 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
}, 150)
}
// Find matching account for helper text
const matchedAccount = useMemo(() => {
if (!value || value.length !== 4) return null
return accounts.find((a) => a.account_number === value) || null
}, [value, accounts])
return (
<div ref={containerRef} className="relative">
<Input
@@ -181,12 +175,6 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
autoComplete="off"
/>
{/* Account name helper text */}
{matchedAccount && (
<p className="text-[11px] text-muted-foreground truncate mt-0.5 leading-tight">
{matchedAccount.account_name}
</p>
)}
{/* Dropdown */}
{isOpen && flatList.length > 0 && (
@@ -225,6 +213,18 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
))}
</div>
)}
{/* Empty state */}
{isOpen && search.trim() && flatList.length === 0 && (
<div className="absolute z-50 top-full left-0 mt-1 w-64 rounded-md border border-input bg-card shadow-md p-3">
<p className="text-sm text-muted-foreground">
Hittade inget konto som matchar.
</p>
<p className="text-xs text-muted-foreground mt-1">
Kontot kan behöva aktiveras i din kontoplan.
</p>
</div>
)}
</div>
)
}
@@ -40,18 +40,18 @@ interface ReferenceAccount extends BASReferenceAccount {
// ---------------------------------------------------------------------------
const CLASS_LABELS: Record<number, string> = {
1: 'Tillgangar',
1: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Rorelseintatker',
4: 'Varuinkop och material',
5: 'Ovriga externa kostnader',
6: 'Ovriga externa kostnader',
3: 'Rörelseintäkter',
4: 'Varuinköp och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
const TYPE_LABELS: Record<string, string> = {
asset: 'Tillgang',
asset: 'Tillgång',
liability: 'Skuld',
equity: 'EK',
revenue: 'Intakt',
@@ -315,7 +315,7 @@ export default function ChartOfAccountsManager() {
onCheckedChange={setHideK2Excluded}
className="scale-75"
/>
<span className="text-muted-foreground">Dolj K2-undantagna</span>
<span className="text-muted-foreground">Dölj K2-undantagna</span>
</label>
)}
</div>
@@ -324,7 +324,7 @@ export default function ChartOfAccountsManager() {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sok konto (nummer eller namn)..."
placeholder="Sök konto (nummer eller namn)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -454,7 +454,7 @@ export default function ChartOfAccountsManager() {
{filteredAccounts.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
{searchQuery ? 'Inga konton matchar sokningen' : 'Inga konton i kontoplanen'}
{searchQuery ? 'Inga konton matchar sökningen' : 'Inga konton i kontoplanen'}
</CardContent>
</Card>
)}
@@ -554,7 +554,7 @@ export default function ChartOfAccountsManager() {
) : (
<Plus className="mr-1 h-3 w-3" />
)}
Lagg till
Lägg till
</Button>
)}
</td>
@@ -571,7 +571,7 @@ export default function ChartOfAccountsManager() {
{filteredReference.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga konton matchar sokningen
Inga konton matchar sökningen
</CardContent>
</Card>
)}
+44 -27
View File
@@ -116,6 +116,19 @@ export default function JournalEntryForm({
if (account) {
updated[index].line_description = account.account_name
}
// Auto-fill balancing amount when both amount fields are empty
if (!updated[index].debit_amount && !updated[index].credit_amount) {
const otherLines = updated.filter((_, i) => i !== index)
const otherDebit = otherLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const otherCredit = otherLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const diff = Math.round((otherCredit - otherDebit) * 100) / 100
if (diff > 0) {
updated[index].debit_amount = diff.toFixed(2)
} else if (diff < 0) {
updated[index].credit_amount = Math.abs(diff).toFixed(2)
}
}
}
setLines(updated)
@@ -191,7 +204,7 @@ export default function JournalEntryForm({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`,
variant: 'destructive',
})
}
@@ -217,7 +230,7 @@ export default function JournalEntryForm({
const formContent = (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className={`grid gap-4 ${embedded && initialDate ? 'grid-cols-2' : 'grid-cols-3'}`}>
<div>
<Label>Räkenskapsår</Label>
<select
@@ -232,14 +245,16 @@ export default function JournalEntryForm({
))}
</select>
</div>
<div>
<Label>Datum</Label>
<Input
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
/>
</div>
{!(embedded && initialDate) && (
<div>
<Label>Datum</Label>
<Input
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
/>
</div>
)}
<div>
<Label>Beskrivning</Label>
<Input
@@ -255,24 +270,24 @@ export default function JournalEntryForm({
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-32 text-right">Debet</th>
<th className="py-2 w-32 text-right">Kredit</th>
<th className="py-2 w-28">Konto</th>
<th className="py-2 px-1">Beskrivning</th>
<th className="py-2 w-32 px-1 text-right">Debet</th>
<th className="py-2 w-32 px-1 text-right">Kredit</th>
<th className="py-2 w-10"></th>
</tr>
</thead>
<tbody>
{lines.map((line, index) => (
<tr key={index} className="border-b">
<td className="py-1">
<td className="py-1.5">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
/>
</td>
<td className="py-1 px-1">
<td className="py-1.5 px-1">
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
@@ -280,7 +295,7 @@ export default function JournalEntryForm({
className="h-8"
/>
</td>
<td className="py-1">
<td className="py-1.5 px-1">
<Input
type="number"
value={line.debit_amount}
@@ -291,7 +306,7 @@ export default function JournalEntryForm({
step="0.01"
/>
</td>
<td className="py-1">
<td className="py-1.5 px-1">
<Input
type="number"
value={line.credit_amount}
@@ -302,7 +317,7 @@ export default function JournalEntryForm({
step="0.01"
/>
</td>
<td className="py-1">
<td className="py-1.5">
<Button
variant="ghost"
size="sm"
@@ -318,18 +333,18 @@ export default function JournalEntryForm({
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">
<td colSpan={2} className="py-2 px-1">
Summa
</td>
<td
className={`py-2 text-right ${
className={`py-2 px-1 text-right ${
isBalanced ? 'text-green-600' : 'text-red-600'
}`}
>
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td
className={`py-2 text-right ${
className={`py-2 px-1 text-right ${
isBalanced ? 'text-green-600' : 'text-red-600'
}`}
>
@@ -390,7 +405,7 @@ export default function JournalEntryForm({
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska verifikation"
warningText="En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno."
warningText={embedded ? '' : 'En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno.'}
>
<JournalEntryReviewContent
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
@@ -400,6 +415,8 @@ export default function JournalEntryForm({
totalDebit={totalDebit}
totalCredit={totalCredit}
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
showBalanceBadge={!embedded}
hideDate={!!embedded}
/>
</ConfirmationDialog>
@@ -413,16 +430,16 @@ export default function JournalEntryForm({
}}
isSubmitting={false}
title="Underlag saknas"
warningText="Ingen verifikation har bifogats. Enligt bokforingslagen (BFL) kravs underlag for varje bokforingspost."
confirmLabel="Fortsatt anda"
warningText="Ingen verifikation har bifogats. Enligt bokföringslagen (BFL) krävs underlag för varje bokföringspost."
confirmLabel="Fortsätt ändå"
>
<div className="flex items-start gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-300">
<p className="font-medium mb-1">Inget underlag bifogat</p>
<p>
Enligt bokforingslagen (BFL 5 kap. 6-7 §§) ska varje bokforingspost ha en verifikation som
underlag. Du kan bifoga underlag nu eller fortsatta utan.
Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som
underlag. Du kan bifoga underlag nu eller fortsätta utan.
</p>
</div>
</div>
@@ -81,39 +81,6 @@ export default function JournalEntryList({ periodId }: Props) {
setExpandedId(expandedId === id ? null : id)
}
const statusLabel = (status: string) => {
switch (status) {
case 'posted':
return <Badge variant="success">Bokförd</Badge>
case 'draft':
return <Badge variant="secondary">Utkast</Badge>
case 'reversed':
return <Badge variant="destructive">Makulerad</Badge>
default:
return <Badge variant="outline">{status}</Badge>
}
}
const sourceLabel = (source: string) => {
const labels: Record<string, string> = {
manual: 'Manuell',
bank_transaction: 'Banktransaktion',
invoice_created: 'Faktura',
invoice_paid: 'Betalning',
credit_note: 'Kreditfaktura',
salary_payment: 'Lön',
opening_balance: 'Ingående balans',
year_end: 'Årsbokslut',
supplier_invoice_registered: 'Leverantörsfaktura',
supplier_invoice_paid: 'Leverantörsbetalning',
supplier_invoice_cash_payment: 'Kontant leverantörsbetalning',
import: 'Import',
storno: 'Storno',
correction: 'Korrigering',
}
return labels[source] || source
}
if (loading) {
return (
<Card>
@@ -199,10 +166,6 @@ export default function JournalEntryList({ periodId }: Props) {
</span>
)
)}
<Badge variant="outline" className="text-xs mr-2">
{sourceLabel(entry.source_type)}
</Badge>
{statusLabel(entry.status)}
</div>
</button>
@@ -19,6 +19,8 @@ interface JournalEntryReviewContentProps {
totalDebit: number
totalCredit: number
attachmentCount?: number
showBalanceBadge?: boolean
hideDate?: boolean
}
function formatAmount(amount: number): string {
@@ -33,6 +35,8 @@ export function JournalEntryReviewContent({
totalDebit,
totalCredit,
attachmentCount,
showBalanceBadge = true,
hideDate = false,
}: JournalEntryReviewContentProps) {
const activeLines = lines.filter(
(l) => l.account_number && (l.debit_amount || l.credit_amount)
@@ -42,15 +46,17 @@ export function JournalEntryReviewContent({
<div className="space-y-4">
{/* Header info */}
<div className="bg-muted rounded-lg p-4 space-y-2">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className={`grid gap-4 text-sm ${hideDate ? 'grid-cols-1' : 'grid-cols-2'}`}>
<div>
<span className="text-muted-foreground">Räkenskapsår</span>
<p className="font-medium">{periodName}</p>
</div>
<div>
<span className="text-muted-foreground">Datum</span>
<p className="font-medium">{entryDate}</p>
</div>
{!hideDate && (
<div>
<span className="text-muted-foreground">Datum</span>
<p className="font-medium">{entryDate}</p>
</div>
)}
</div>
<div className="text-sm">
<span className="text-muted-foreground">Beskrivning</span>
@@ -59,18 +65,22 @@ export function JournalEntryReviewContent({
</div>
{/* Balance status */}
<div className="flex items-center gap-2">
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Debet = Kredit
</Badge>
{attachmentCount != null && attachmentCount > 0 && (
<Badge variant="outline">
<Paperclip className="h-3 w-3 mr-1" />
{attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'}
</Badge>
)}
</div>
{(showBalanceBadge || (attachmentCount != null && attachmentCount > 0)) && (
<div className="flex items-center gap-2">
{showBalanceBadge && (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Debet = Kredit
</Badge>
)}
{attachmentCount != null && attachmentCount > 0 && (
<Badge variant="outline">
<Paperclip className="h-3 w-3 mr-1" />
{attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'}
</Badge>
)}
</div>
)}
{/* Debit/Credit table */}
<table className="w-full text-sm">
+101 -176
View File
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { cn, formatCurrency } from '@/lib/utils'
import {
calculateEFTax,
@@ -16,18 +15,14 @@ import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadline
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import {
TrendingUp,
TrendingDown,
Receipt,
ArrowLeftRight,
ChevronDown,
ChevronUp,
ArrowRight,
Camera,
Users,
Landmark,
CheckCircle2,
ClipboardList,
FileWarning,
} from 'lucide-react'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
@@ -113,17 +108,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="overdue" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-2 border-l-destructive hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
</p>
</div>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -136,17 +128,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="unpaid" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</div>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -159,17 +148,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="transactions" href="/transactions" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} obokförda
</p>
</div>
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} obokförda
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -182,19 +168,16 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="receipts" href="/receipts" className="group">
<Card className="h-full border-l-2 border-l-primary hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.receiptQueue.pending_review_count > 0
? `${summary.receiptQueue.pending_review_count} att granska`
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
</p>
</div>
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.receiptQueue.pending_review_count > 0
? `${summary.receiptQueue.pending_review_count} att granska`
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -207,17 +190,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
</p>
</div>
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -231,17 +211,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="bank-expiry" href="/settings?tab=banking" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Banksamtycke löper ut</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
</p>
</div>
<div className="flex items-center gap-3">
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Banksamtycke löper ut</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -277,61 +254,33 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Godmorgon'
if (hour < 5) return 'God natt'
if (hour < 10) return 'Godmorgon'
if (hour < 14) return 'Hej'
if (hour < 18) return 'God eftermiddag'
return 'God kväll'
})()}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground mt-1">
{summary.overdueInvoicesCount > 0
? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
: summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0
? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines`
: 'Allt är som det ska'}
{(() => {
if (summary.overdueInvoicesCount > 0)
return `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
const passedDeadlines = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length
if (passedDeadlines > 0)
return `${passedDeadlines} passerade deadlines`
if (summary.uncategorizedCount > 0)
return `${summary.uncategorizedCount} obokförda transaktioner`
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0)
return `${summary.receiptQueue.pending_review_count} kvitton att granska`
if (summary.missingUnderlagCount > 0)
return `${summary.missingUnderlagCount} verifikationer saknar underlag`
if (summary.unpaidInvoicesCount > 0)
return `${summary.unpaidInvoicesCount} obetalda fakturor`
return 'Allt är som det ska'
})()}
</p>
</header>
{/* Status pills */}
{(() => {
const passedDeadlines = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date())
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
if (passedDeadlines.length > 0) {
todoItems.push({ label: 'passerade deadlines', href: '/deadlines', count: passedDeadlines.length, variant: 'destructive' })
}
if (summary.overdueInvoicesCount > 0) {
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
}
if (summary.uncategorizedCount > 0) {
todoItems.push({ label: 'obokförda', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
}
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
}
if (summary.missingUnderlagCount > 0) {
todoItems.push({ label: 'saknade underlag', href: '/bookkeeping?missingUnderlag=true', count: summary.missingUnderlagCount, variant: 'warning' })
}
if (todoItems.length === 0) return null
return (
<section className="mb-10">
<div className="flex flex-wrap gap-1.5">
{todoItems.map((item) => (
<Link key={item.href} href={item.href}>
<Badge
variant={item.variant === 'destructive' ? 'destructive' : item.variant === 'warning' ? 'outline' : 'secondary'}
className="px-2.5 py-1 text-xs cursor-pointer hover:opacity-80 transition-opacity"
>
{item.count} {item.label}
</Badge>
</Link>
))}
</div>
</section>
)
})()}
{/* New user checklist */}
{onboardingProgress && (
<section className="mb-10">
@@ -358,10 +307,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Card 1: Resultat */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Resultat</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
<p className={cn(
'font-display text-xl font-medium tabular-nums leading-tight',
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
@@ -379,10 +325,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link href="/invoices?status=unpaid">
<Card className="h-full hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Receipt className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Att få betalt</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Att få betalt</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
@@ -398,10 +341,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Landmark className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Banksaldo</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.bankBalance)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
@@ -412,10 +352,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link href="/import">
<Card className="h-full hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Landmark className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Banksaldo</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="text-sm font-medium text-primary">Koppla bank</p>
<p className="text-[11px] text-muted-foreground mt-1">Importera transaktioner</p>
</CardContent>
@@ -426,10 +363,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Card 4: Att göra */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<ClipboardList className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Att göra</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Att göra</p>
{todoCount > 0 ? (
<>
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
@@ -469,17 +403,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
? 'border-primary/20 bg-primary/[0.03] hover:bg-primary/[0.06]'
: 'border-border/40 hover:bg-muted/30'
)}>
<div className={cn(
'p-2 rounded-lg',
action.accent ? 'bg-primary/8' : 'bg-muted/50'
)}>
<Icon className={cn(
'h-4 w-4',
action.accent ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className={cn(
'h-3.5 w-3.5 flex-shrink-0',
action.accent ? 'text-primary' : 'text-muted-foreground'
)} />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.desc}</p>
</div>
</div>
@@ -493,11 +424,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
return (
<Link key={action.key} href={action.href} className="group">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
@@ -511,11 +442,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
className="group text-left"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
@@ -525,21 +456,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</div>
</section>
{/* Upcoming deadlines — always visible */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-10">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
{/* Alerts section — always visible */}
{/* Alerts section */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
@@ -558,6 +475,21 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
)}
{/* Upcoming deadlines — always visible */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-10">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
{/* Collapsible details section */}
<button
onClick={() => setShowMore(!showMore)}
@@ -607,7 +539,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{' '}saknas i resultatet
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
</div>
</Link>
</section>
@@ -619,10 +550,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<div className="grid md:grid-cols-2 gap-3">
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-3.5 w-3.5 text-success" />
<span className="text-sm text-muted-foreground">Intäkter</span>
</div>
<p className="text-sm text-muted-foreground mb-3">Intäkter</p>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
@@ -641,10 +569,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
<span className="text-sm text-muted-foreground">Kostnader</span>
</div>
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
+4 -153
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -23,16 +23,13 @@ import {
ChevronDown,
Building2,
FileInput,
Store,
Wallet,
} from 'lucide-react'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { EntityType } from '@/types'
interface DashboardNavProps {
companyName: string
entityType: EntityType
enabledExtensions?: { sector_slug: string; extension_slug: string }[]
uncategorizedTransactionCount?: number
}
@@ -51,6 +48,7 @@ const navItems: NavItem[] = [
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/expenses', label: 'Utgifter', icon: Wallet, group: 'finans' },
// Temporarily hidden pending module rework (see feedback #49)
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans', hidden: true },
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'finans', hidden: true },
@@ -68,7 +66,7 @@ const groupLabels: Record<string, string> = {
övrigt: 'Övrigt',
}
export default function DashboardNav({ companyName, entityType, enabledExtensions, uncategorizedTransactionCount = 0 }: DashboardNavProps) {
export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0 }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
@@ -77,46 +75,8 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
const isOnOvrigtPage = ['/help', '/settings'].some(p => pathname.startsWith(p))
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
// Auto-expand Tillägg when on an extension page or marketplace, or when manually toggled (persisted)
const isOnExtensionPage = pathname.startsWith('/e/') || pathname.startsWith('/extensions')
const [manualTillaggExpanded, setManualTillaggExpanded] = useState(false)
const isTillaggExpanded = isOnExtensionPage || manualTillaggExpanded
// Restore persisted state after hydration to avoid SSR mismatch
useEffect(() => {
const stored = localStorage.getItem('tillagg-expanded') === 'true'
if (stored) setManualTillaggExpanded(true)
}, [])
const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? [])
const fetchExtensions = useCallback(async () => {
try {
const res = await fetch('/api/extensions/toggles')
if (res.ok) {
const { data } = await res.json()
if (data) setLiveExtensions(data)
}
} catch {
// keep current state on error
}
}, [])
// Fetch extensions on mount if Tillägg starts expanded
useEffect(() => {
if (isTillaggExpanded) fetchExtensions()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const toggleTillagg = () => {
const next = !isTillaggExpanded
setManualTillaggExpanded(next)
localStorage.setItem('tillagg-expanded', String(next))
if (next) fetchExtensions()
}
const openMobileMenu = () => {
setIsMobileMenuOpen(true)
fetchExtensions()
}
const handleLogout = async () => {
@@ -233,68 +193,6 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
</div>
</div>
{/* Tillägg - collapsible, with marketplace link */}
<div className="mb-4">
<button
onClick={toggleTillagg}
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>Tillägg</span>
<ChevronDown className={cn(
"h-3 w-3 transition-transform duration-200",
isTillaggExpanded && "rotate-180"
)} />
</button>
{isTillaggExpanded && (
<div className="space-y-px animate-fade-in">
{liveExtensions.length > 0 ? liveExtensions.map((toggle) => {
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
if (!def) return null
const ExtIcon = resolveIcon(def.icon)
const href = `/e/${toggle.sector_slug}/${toggle.extension_slug}`
const active = isActive(href)
return (
<Link
key={`${toggle.sector_slug}/${toggle.extension_slug}`}
href={href}
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<ExtIcon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
{def.name}
</Link>
)
}) : (
<p className="px-3 py-2 text-[12px] text-muted-foreground/60">
Inga tillägg aktiverade
</p>
)}
<Link
href="/extensions"
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
isActive('/extensions')
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/40'
)}
>
<Store className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
isActive('/extensions') ? "text-primary" : "text-muted-foreground/50 group-hover:text-muted-foreground"
)} />
Utforska fler...
</Link>
</div>
)}
</div>
{/* Övrigt group - collapsible */}
<div className="mb-4">
<button
@@ -488,53 +386,6 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
})}
</div>
{/* Tillägg */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Tillägg
</p>
{liveExtensions.length > 0 ? liveExtensions.map((toggle) => {
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
if (!def) return null
const ExtIcon = resolveIcon(def.icon)
const href = `/e/${toggle.sector_slug}/${toggle.extension_slug}`
const active = isActive(href)
return (
<Link
key={`${toggle.sector_slug}/${toggle.extension_slug}`}
href={href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
)}
>
<ExtIcon className="h-5 w-5" />
{def.name}
</Link>
)
}) : (
<p className="px-3 py-2 text-xs text-muted-foreground/60">
Inga tillägg aktiverade
</p>
)}
<Link
href="/extensions"
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
isActive('/extensions')
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground/60 hover:bg-secondary/50 hover:text-foreground'
)}
>
<Store className="h-5 w-5" />
Utforska fler...
</Link>
</div>
{/* Other section */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
+28 -47
View File
@@ -1,17 +1,13 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
ArrowLeft,
Loader2,
Play,
FileText,
AlertTriangle,
Link2,
Calendar,
} from 'lucide-react'
@@ -31,11 +27,28 @@ export default function BankFileConfirmStep({
onBack,
isLoading,
}: BankFileConfirmStepProps) {
const [skipDuplicates, setSkipDuplicates] = useState(true)
const { transactions, stats, date_from, date_to, format_name } = parseResult
const { transactions, stats, date_from, date_to } = parseResult
const refsCount = transactions.filter((t) => t.reference).length
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24 space-y-6">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
<div className="text-center space-y-2">
<p className="text-lg font-medium">Importerar transaktioner...</p>
<p className="text-sm text-muted-foreground">
{stats.parsed_rows} transaktioner bearbetas
</p>
</div>
<div className="w-48 h-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '60%' }} />
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Summary */}
@@ -68,65 +81,33 @@ export default function BankFileConfirmStep({
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-green-600 mb-1">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Inkomster</span>
</div>
<p className="text-xl font-bold text-green-600">
<p className="text-xl font-bold">
{formatCurrency(stats.total_income)}
</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-red-600 mb-1">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Utgifter</span>
</div>
<p className="text-xl font-bold text-red-600">
<p className="text-xl font-bold">
{formatCurrency(stats.total_expenses)}
</p>
</div>
</div>
{/* Additional info */}
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">Format: {format_name}</Badge>
{refsCount > 0 && (
{refsCount > 0 && (
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="text-blue-600 border-blue-300">
<Link2 className="mr-1 h-3 w-3" />
{refsCount} med OCR/referens
</Badge>
)}
</div>
{/* Options */}
<div className="border rounded-lg p-4 space-y-4">
<h3 className="text-sm font-medium">Importinställningar</h3>
<div className="flex items-center space-x-3">
<Checkbox
id="skip-duplicates"
checked={skipDuplicates}
onCheckedChange={(checked) => setSkipDuplicates(checked === true)}
/>
<div>
<Label htmlFor="skip-duplicates" className="text-sm font-medium cursor-pointer">
Hoppa över dubletter
</Label>
<p className="text-xs text-muted-foreground">
Transaktioner som redan finns i systemet importeras inte igen
</p>
</div>
</div>
</div>
{/* Warning note */}
<div className="flex gap-3 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">
Importerade transaktioner visas som &quot;obokförda&quot; på
transaktionssidan. Du kan bokföra dem manuellt efteråt.
</p>
</div>
)}
</CardContent>
</Card>
@@ -138,7 +119,7 @@ export default function BankFileConfirmStep({
</Button>
<Button
onClick={() => onExecute({
skip_duplicates: skipDuplicates,
skip_duplicates: true,
auto_categorize: false,
})}
disabled={isLoading}
+6 -23
View File
@@ -25,18 +25,16 @@ import type { BankFileParseResult } from '@/lib/import/bank-file/types'
interface BankFilePreviewStepProps {
parseResult: BankFileParseResult
existingTransactionCount: number
onContinue: () => void
onBack: () => void
}
export default function BankFilePreviewStep({
parseResult,
existingTransactionCount,
onContinue,
onBack,
}: BankFilePreviewStepProps) {
const { transactions, stats, issues, date_from, date_to, format_name } = parseResult
const { transactions, stats, issues, date_from, date_to } = parseResult
const hasIssues = issues.filter((i) => i.severity === 'error').length > 0
const warnings = issues.filter((i) => i.severity === 'warning')
@@ -73,11 +71,11 @@ export default function BankFilePreviewStep({
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-green-600 mb-1">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<TrendingUp className="h-4 w-4" />
<span className="text-sm">Inkomster</span>
</div>
<p className="text-lg font-bold text-green-600">
<p className="text-lg font-bold">
{formatCurrency(stats.total_income)}
</p>
</CardContent>
@@ -85,30 +83,17 @@ export default function BankFilePreviewStep({
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-red-600 mb-1">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<TrendingDown className="h-4 w-4" />
<span className="text-sm">Utgifter</span>
</div>
<p className="text-lg font-bold text-red-600">
<p className="text-lg font-bold">
{formatCurrency(stats.total_expenses)}
</p>
</CardContent>
</Card>
</div>
{/* Format and duplicate info */}
<div className="flex items-center gap-4">
<Badge variant="secondary">
Format: {format_name}
</Badge>
{existingTransactionCount > 0 && (
<Badge variant="outline" className="text-yellow-600 border-yellow-300">
<AlertTriangle className="mr-1 h-3 w-3" />
{existingTransactionCount} befintliga transaktioner i samma period
</Badge>
)}
</div>
{/* Warnings */}
{warnings.length > 0 && (
<Card className="border-yellow-300">
@@ -165,9 +150,7 @@ export default function BankFilePreviewStep({
<TableCell className="font-mono text-sm">{tx.date}</TableCell>
<TableCell className="text-sm">{tx.description}</TableCell>
<TableCell
className={`text-right font-mono text-sm ${
tx.amount >= 0 ? 'text-green-600' : 'text-red-600'
}`}
className="text-right font-mono text-sm"
>
{formatCurrency(tx.amount)}
</TableCell>
+2 -49
View File
@@ -6,10 +6,6 @@ import { Button } from '@/components/ui/button'
import {
CheckCircle,
XCircle,
FileText,
Link2,
Sparkles,
Copy,
ArrowRight,
RotateCcw,
ExternalLink,
@@ -30,12 +26,12 @@ export default function BankFileResultStep({
return (
<div className="space-y-6">
{/* Status header */}
<Card className={isSuccess ? 'border-green-300' : 'border-destructive/50'}>
<Card className={isSuccess ? 'border-border' : 'border-destructive/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{isSuccess ? (
<>
<CheckCircle className="h-6 w-6 text-green-600" />
<CheckCircle className="h-6 w-6 text-muted-foreground" />
Import genomförd
</>
) : (
@@ -53,49 +49,6 @@ export default function BankFileResultStep({
</CardHeader>
</Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Importerade</span>
</div>
<p className="text-2xl font-bold text-green-600">{result.imported}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Copy className="h-4 w-4" />
<span className="text-sm">Dubletter</span>
</div>
<p className="text-2xl font-bold text-muted-foreground">{result.duplicates}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Sparkles className="h-4 w-4" />
<span className="text-sm">Auto-bokförda</span>
</div>
<p className="text-2xl font-bold">{result.auto_categorized}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Link2 className="h-4 w-4" />
<span className="text-sm">Fakturamatchade</span>
</div>
<p className="text-2xl font-bold">{result.auto_matched_invoices}</p>
</CardContent>
</Card>
</div>
{/* Next steps */}
{isSuccess && (
<Card className="bg-muted/50">
@@ -2,7 +2,6 @@
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { getVatSummaryFromItems } from '@/lib/invoices/vat-rules'
import { formatCurrency } from '@/lib/utils'
import type { Customer, Currency } from '@/types'
@@ -48,9 +47,6 @@ export function InvoiceReviewContent({
non_eu_business: 'Utanför EU',
}
// Derive VAT summary from items
const vatSummary = getVatSummaryFromItems(items)
// Calculate per-rate VAT breakdown
const vatByRate = new Map<number, number>()
for (const item of items) {
@@ -75,11 +71,6 @@ export function InvoiceReviewContent({
</Badge>
</div>
{/* VAT treatment */}
<Badge className="text-sm px-3 py-1">
{vatSummary.label}
</Badge>
{/* Dates */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
+7 -27
View File
@@ -8,13 +8,8 @@ import { Progress } from '@/components/ui/progress'
import {
Check,
Circle,
Users,
Receipt,
Building2,
Camera,
Sparkles,
X,
ChevronRight,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -23,7 +18,6 @@ interface ChecklistItem {
label: string
description: string
href: string
icon: React.ComponentType<{ className?: string }>
completed: boolean
}
@@ -61,7 +55,6 @@ export default function NewUserChecklist({
label: 'Skapa konto',
description: 'Du har ett konto!',
href: '#',
icon: Check,
completed: true, // Always completed if they're seeing this
},
{
@@ -69,7 +62,6 @@ export default function NewUserChecklist({
label: 'Lägg till din första kund',
description: 'Spara kunduppgifter för enkel fakturering',
href: '/customers/new',
icon: Users,
completed: hasCustomers,
},
{
@@ -77,7 +69,6 @@ export default function NewUserChecklist({
label: 'Skicka din första faktura',
description: 'Skapa en professionell faktura på 60 sekunder',
href: '/invoices/new',
icon: Receipt,
completed: hasInvoices,
},
{
@@ -85,7 +76,6 @@ export default function NewUserChecklist({
label: 'Importera transaktioner',
description: 'Importera kontoutdrag från din bank',
href: '/import',
icon: Building2,
completed: hasBankConnected,
},
{
@@ -93,7 +83,6 @@ export default function NewUserChecklist({
label: 'Skanna ditt första kvitto',
description: 'Fotografera för automatisk bokföring',
href: '/receipts/scan',
icon: Camera,
completed: hasReceipts,
},
]
@@ -150,16 +139,11 @@ export default function NewUserChecklist({
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<div>
<CardTitle className="text-base">Kom igång</CardTitle>
<p className="text-sm text-muted-foreground">
{completedCount} av {items.length} steg klara
</p>
</div>
<div>
<CardTitle className="text-base">Kom igång</CardTitle>
<p className="text-sm text-muted-foreground">
{completedCount} av {items.length} steg klara
</p>
</div>
<Button
variant="ghost"
@@ -175,9 +159,7 @@ export default function NewUserChecklist({
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-1">
{items.map((item) => {
const Icon = item.icon
return (
{items.map((item) => (
<li key={item.id}>
{item.completed ? (
<div className="flex items-center gap-3 p-2 rounded-lg">
@@ -204,12 +186,10 @@ export default function NewUserChecklist({
{item.description}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
</Link>
)}
</li>
)
})}
))}
</ul>
</CardContent>
</Card>
+86 -11
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -49,6 +49,7 @@ interface Step3Output {
interface Step3Props {
initialData: Partial<Step3Output>
entityType?: EntityType
orgNumber?: string
onNext: (data: Step3Output) => void
onBack: () => void
isSaving: boolean
@@ -138,6 +139,7 @@ function getABFirstYearEndDates(
export default function Step3TaxRegistration({
initialData,
entityType,
orgNumber,
onNext,
onBack,
isSaving,
@@ -149,6 +151,7 @@ export default function Step3TaxRegistration({
handleSubmit,
watch,
control,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -174,6 +177,17 @@ export default function Step3TaxRegistration({
const fiscalYearEndMonth = watch('fiscal_year_end_month')
const accountingMethod = watch('accounting_method')
// Auto-fill VAT number when vat_registered toggles on
const vatNumber = watch('vat_number')
useEffect(() => {
if (vatRegistered && !vatNumber && orgNumber) {
const cleaned = orgNumber.replace(/[-\s]/g, '')
if (cleaned.length >= 10) {
setValue('vat_number', `SE${cleaned}01`)
}
}
}, [vatRegistered, vatNumber, orgNumber, setValue])
// State for AB first-year end month selector
const [abEndMonth, setAbEndMonth] = useState<number>(
initialData.first_year_end
@@ -361,18 +375,79 @@ export default function Step3TaxRegistration({
{isFirstYear && (
<div className="space-y-4 rounded-lg bg-muted/50 p-4">
<div className="space-y-2">
<Label htmlFor="first_year_start">Startdatum</Label>
<Label>Startdatum</Label>
<Controller
name="first_year_start"
control={control}
render={({ field }) => (
<Input
id="first_year_start"
type="date"
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value)}
/>
)}
render={({ field }) => {
const parsed = field.value ? (() => {
const d = new Date(field.value)
if (isNaN(d.getTime())) return null
return { day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear() }
})() : null
const selectedDay = parsed?.day ?? 0
const selectedMonth = parsed?.month ?? 0
const selectedYear = parsed?.year ?? 0
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 7 }, (_, i) => currentYear - 5 + i)
const maxDays = selectedMonth && selectedYear
? lastDayOfMonth(selectedYear, selectedMonth)
: 31
const compose = (day: number, month: number, year: number) => {
if (day && month && year) {
const clamped = Math.min(day, lastDayOfMonth(year, month))
field.onChange(`${year}-${String(month).padStart(2, '0')}-${String(clamped).padStart(2, '0')}`)
}
}
return (
<div className="grid grid-cols-3 gap-2">
<Select
value={selectedDay ? selectedDay.toString() : ''}
onValueChange={(v) => compose(parseInt(v), selectedMonth, selectedYear)}
>
<SelectTrigger>
<SelectValue placeholder="Dag" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: maxDays }, (_, i) => i + 1).map((d) => (
<SelectItem key={d} value={d.toString()}>{d}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedMonth ? selectedMonth.toString() : ''}
onValueChange={(v) => compose(selectedDay, parseInt(v), selectedYear)}
>
<SelectTrigger>
<SelectValue placeholder="Månad" />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>{name}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedYear ? selectedYear.toString() : ''}
onValueChange={(v) => compose(selectedDay, selectedMonth, parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="År" />
</SelectTrigger>
<SelectContent>
{years.map((y) => (
<SelectItem key={y} value={y.toString()}>{y}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}}
/>
<p className="text-xs text-muted-foreground">
Dagen verksamheten startade (bör vara den 1:a i en månad).
@@ -593,7 +668,7 @@ export default function Step3TaxRegistration({
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal (rekommenderas)</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="yearly">År</SelectItem>
</SelectContent>
</Select>
@@ -300,7 +300,7 @@ export function BankReconciliationView() {
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span className={status.is_reconciled ? 'text-green-600' : 'text-red-600'}>
<span>
{formatCurrency(status.difference)}
</span>
</div>
@@ -433,7 +433,7 @@ export function BankReconciliationView() {
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[200px]">{tx.description}</td>
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
@@ -506,7 +506,7 @@ export function BankReconciliationView() {
<td className="py-2 truncate max-w-[300px]">
{line.line_description || line.entry_description}
</td>
<td className={`py-2 text-right font-mono ${amount >= 0 ? 'text-green-600' : ''}`}>
<td className="py-2 text-right font-mono">
{formatCurrency(amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
@@ -554,7 +554,7 @@ export function BankReconciliationView() {
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2">
-242
View File
@@ -1,242 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { SRUExportResult } from '@/lib/reports/sru-export/types'
import type { SRUCoverageStats } from '@/lib/reports/sru-export/sru-engine'
import { formatCurrency } from '@/lib/utils'
export function SRUExportView({ periodId }: { periodId: string }) {
const [data, setData] = useState<SRUExportResult | null>(null)
const [coverage, setCoverage] = useState<SRUCoverageStats | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchCoverage()
}, [])
async function fetchCoverage() {
try {
const res = await fetch('/api/reports/sru-export/coverage')
const result = await res.json()
if (result.data) {
setCoverage(result.data)
}
} catch {
// Coverage is optional, ignore errors
}
}
const fetchExport = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/sru-export?period_id=${periodId}&format=json`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta SRU-export')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/reports/sru-export?period_id=${periodId}&format=sru`, '_blank')
}
const formLabel = data?.formType === 'INK2' ? 'INK2 (Aktiebolag)' : 'NE (Enskild firma)'
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">SRU-export</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Generera SRU-fil (Standardiserat Räkenskapsutdrag) för elektronisk inlämning
till Skatteverket. Blanketttyp bestäms automatiskt utifrån företagsform.
</p>
<div className="flex gap-2">
<Button onClick={fetchExport} disabled={loading}>
{loading ? 'Laddar...' : 'Förhandsgranska'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{/* Coverage warning */}
{coverage && coverage.accountsWithoutSRU > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
<p className="text-sm text-orange-800">
{coverage.accountsWithoutSRU} av {coverage.totalAccounts} konton saknar SRU-kod
({coverage.coveragePercent}% täckning).
Konton utan SRU-kod inkluderas inte i exporten.
</p>
{coverage.missingAccounts.length <= 5 && (
<ul className="text-xs text-orange-700 mt-1 space-y-0.5">
{coverage.missingAccounts.map((a) => (
<li key={a.accountNumber}>
{a.accountNumber} — {a.accountName}
</li>
))}
</ul>
)}
</div>
</div>
</CardContent>
</Card>
)}
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company + form info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{data.companyName || 'Okänt företag'}</CardTitle>
<div className="flex gap-2">
<Badge className="bg-blue-100 text-blue-800">{formLabel}</Badge>
<Badge className="bg-blue-100 text-blue-800">{data.fiscalYear.name}</Badge>
</div>
</div>
{data.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* SRU balances table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">SRU-poster</CardTitle>
</CardHeader>
<CardContent>
{data.balances.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga poster med belopp att exportera.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">SRU-kod</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-32 text-right">Belopp</th>
</tr>
</thead>
<tbody>
{data.balances.map((b) => (
<SRUBalanceRow key={b.sruCode} balance={b} />
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Förhandsgranska&quot; för att se SRU-uppgifter för valt räkenskapsår.
</CardContent>
</Card>
)}
</div>
)
}
function SRUBalanceRow({
balance,
}: {
balance: SRUExportResult['balances'][number]
}) {
const [expanded, setExpanded] = useState(false)
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => balance.accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2 font-mono">{balance.sruCode}</td>
<td className="py-2">
{balance.description}
{balance.accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({balance.accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">{formatCurrency(balance.amount)}</td>
</tr>
{expanded && balance.accounts.length > 0 && (
<tr>
<td colSpan={3} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{balance.accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1 w-16"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">{formatCurrency(acc.amount)}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
@@ -72,20 +72,20 @@ function buildJournalPreview(
const fiktivVat = Math.round(subtotal * vatRate * 100) / 100
lines.push({
account_number: '2645',
description: 'Beraknad ingaende moms',
description: 'Beräknad ingående moms',
debit: fiktivVat,
credit: 0,
})
lines.push({
account_number: '2614',
description: 'Utgaende moms omvand',
description: 'Utgående moms omvänd',
debit: 0,
credit: fiktivVat,
})
// Credit: 2440 at subtotal (no real VAT for reverse charge)
lines.push({
account_number: '2440',
description: 'Leverantorsskulder',
description: 'Leverantörsskulder',
debit: 0,
credit: Math.round(subtotal * 100) / 100,
})
@@ -93,7 +93,7 @@ function buildJournalPreview(
if (totalVat > 0) {
lines.push({
account_number: '2641',
description: 'Ingaende moms',
description: 'Ingående moms',
debit: Math.round(totalVat * 100) / 100,
credit: 0,
})
@@ -101,7 +101,7 @@ function buildJournalPreview(
// Credit: 2440 at total incl. VAT
lines.push({
account_number: '2440',
description: 'Leverantorsskulder',
description: 'Leverantörsskulder',
debit: 0,
credit: Math.round(total * 100) / 100,
})
@@ -64,7 +64,7 @@ export default function BatchCategorySelector({
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<Paperclip className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-800 dark:text-amber-300">
Underlag behover bifogas separat for varje transaktion efter bokforing.
Underlag behöver bifogas separat för varje transaktion efter bokföring.
</p>
</div>
@@ -82,26 +82,26 @@ function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
const isExpense = transaction.amount < 0
if (!isExpense) {
return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning']
return ['Konsultarvode', 'Försäljning av varor', 'Återbetalning']
}
if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) {
return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret']
}
if (desc.includes('hotel') || desc.includes('hotell') || desc.includes('boende') || desc.includes('resa')) {
return ['Tjansteresa', 'Hotell konferens', 'Flygbiljett']
return ['Tjänsteresa', 'Hotell konferens', 'Flygbiljett']
}
if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) {
return ['Taxi till kund', 'Tjansteresa', 'Pendling']
return ['Taxi till kund', 'Tjänsteresa', 'Pendling']
}
if (desc.includes('google') || desc.includes('meta') || desc.includes('facebook') || desc.includes('linkedin')) {
return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsforingskampanj']
return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsföringskampanj']
}
if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) {
return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial']
}
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam']
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjänst', 'Reklam']
}
function getVatRateFromTreatment(treatment: string | null): number {
@@ -163,7 +163,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte soka mallar',
description: result.error || 'Kunde inte söka mallar',
variant: 'destructive',
})
setIsSearching(false)
@@ -176,7 +176,7 @@ export default function DescribeTransactionDialog({
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid sokning',
description: 'Något gick fel vid sökning',
variant: 'destructive',
})
}
@@ -227,7 +227,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokfora transaktion',
description: result.error || 'Kunde inte bokföra transaktion',
variant: 'destructive',
})
setIsBooking(false)
@@ -239,14 +239,14 @@ export default function DescribeTransactionDialog({
setIsBooking(false)
onCategorized(transaction.id, result.journal_entry_id || null)
} else {
toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
onCategorized(transaction.id, result.journal_entry_id || null)
handleOpenChange(false)
}
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid bokforing',
description: 'Något gick fel vid bokföring',
variant: 'destructive',
})
setIsBooking(false)
@@ -282,7 +282,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokfora batch',
description: result.error || 'Kunde inte bokföra batch',
variant: 'destructive',
})
setIsBatchApplying(false)
@@ -300,7 +300,7 @@ export default function DescribeTransactionDialog({
} else {
toast({
title: 'Klart',
description: `${applied} transaktioner bokforda`,
description: `${applied} transaktioner bokförda`,
})
}
onBatchApplied?.(applied)
@@ -308,7 +308,7 @@ export default function DescribeTransactionDialog({
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid batchbokforing',
description: 'Något gick fel vid batchbokföring',
variant: 'destructive',
})
setIsBatchApplying(false)
@@ -316,7 +316,7 @@ export default function DescribeTransactionDialog({
}
function handleSkipBatch() {
toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
handleOpenChange(false)
}
@@ -337,13 +337,13 @@ export default function DescribeTransactionDialog({
<DialogHeader>
<DialogTitle>
{step === 'describe' && 'Beskriv transaktion'}
{step === 'pick' && 'Valj mall'}
{step === 'batch' && 'Bokfor liknande'}
{step === 'pick' && 'Välj mall'}
{step === 'batch' && 'Bokför liknande'}
</DialogTitle>
<DialogDescription>
{step === 'describe' && 'Beskriv vad transaktionen galler sa hittar vi ratt bokforingsmall'}
{step === 'pick' && 'Valj den mall som stammer bast'}
{step === 'batch' && 'Transaktion bokford!'}
{step === 'describe' && 'Beskriv vad transaktionen gäller så hittar vi rätt bokföringsmall'}
{step === 'pick' && 'Välj den mall som stämmer bäst'}
{step === 'batch' && 'Transaktion bokförd!'}
</DialogDescription>
</DialogHeader>
@@ -351,11 +351,7 @@ export default function DescribeTransactionDialog({
{(step === 'describe' || step === 'pick') && (
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
className="h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 bg-muted text-muted-foreground"
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
@@ -367,7 +363,7 @@ export default function DescribeTransactionDialog({
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
<p className="font-medium text-sm flex-shrink-0">
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
@@ -378,7 +374,7 @@ export default function DescribeTransactionDialog({
{step === 'describe' && (
<div className="space-y-4">
<Textarea
placeholder="Beskriv vad transaktionen galler, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
placeholder="Beskriv vad transaktionen gäller, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
@@ -412,7 +408,7 @@ export default function DescribeTransactionDialog({
) : (
<Search className="mr-2 h-4 w-4" />
)}
{isSearching ? 'Soker...' : 'Sok'}
{isSearching ? 'Söker...' : 'Sök'}
</Button>
</div>
)}
@@ -423,7 +419,7 @@ export default function DescribeTransactionDialog({
{describeResult.needs_more_detail && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<p>Resultaten ar osakra. Forsok beskriv mer detaljerat for battre traffar.</p>
<p>Resultaten är osäkra. Försök beskriv mer detaljerat för bättre träffar.</p>
</div>
)}
@@ -442,7 +438,7 @@ export default function DescribeTransactionDialog({
<div className="flex items-center gap-1.5 mb-1">
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
AI-forslag
AI-förslag
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
@@ -495,7 +491,7 @@ export default function DescribeTransactionDialog({
{/* Template cards */}
{describeResult.templates.length === 0 && !aiSuggestion ? (
<p className="text-sm text-muted-foreground text-center py-4">
Inga matchande mallar hittades. Forsok med en annan beskrivning.
Inga matchande mallar hittades. Försök med en annan beskrivning.
</p>
) : (
describeResult.templates.map((template) => (
@@ -515,7 +511,7 @@ export default function DescribeTransactionDialog({
<p className="font-medium text-sm">{template.name_sv}</p>
{aiAgreesWithTop && template.template_id === topTemplate.template_id && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
AI bekraftar
AI bekräftar
</Badge>
)}
</div>
@@ -636,7 +632,7 @@ export default function DescribeTransactionDialog({
) : (
<Check className="mr-2 h-4 w-4" />
)}
{isBooking ? 'Bokfor...' : 'Bokfor'}
{isBooking ? 'Bokför...' : 'Bokför'}
</Button>
</div>
</div>
@@ -647,7 +643,7 @@ export default function DescribeTransactionDialog({
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg bg-success/10">
<CheckCircle2 className="h-6 w-6 text-success flex-shrink-0" />
<p className="text-sm font-medium">Transaktionen ar bokford!</p>
<p className="text-sm font-medium">Transaktionen är bokförd!</p>
</div>
{canBatchApply && (
@@ -656,11 +652,11 @@ export default function DescribeTransactionDialog({
<span className="font-medium text-foreground">
{describeResult.batch_candidate_count}
</span>{' '}
obokforda transaktioner fran{' '}
obokförda transaktioner från{' '}
<span className="font-medium text-foreground">
{describeResult.merchant_name}
</span>
. Anvand samma mall?
. Använd samma mall?
</p>
)}
@@ -671,7 +667,7 @@ export default function DescribeTransactionDialog({
onClick={handleSkipBatch}
disabled={isBatchApplying}
>
{canBatchApply ? 'Nej, bara den har' : 'Stang'}
{canBatchApply ? 'Nej, bara den här' : 'Stäng'}
</Button>
{canBatchApply && (
<Button
@@ -683,8 +679,8 @@ export default function DescribeTransactionDialog({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isBatchApplying
? 'Bokfor...'
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
? 'Bokför...'
: `Ja, bokför alla ${describeResult.batch_candidate_count} st`}
</Button>
)}
</div>
+60 -9
View File
@@ -6,8 +6,9 @@ import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -26,12 +27,16 @@ interface QuickReviewDialogProps {
defaultAccount: string
defaultVat: VatTreatment | 'none'
entityType?: EntityType
template?: BookingTemplate | null
templateId?: string
onConfirm: (
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined
accountOverride: string | undefined,
templateId?: string
) => Promise<string | null>
onChangeTemplate?: () => void
}
export default function QuickReviewDialog({
@@ -43,7 +48,10 @@ export default function QuickReviewDialog({
defaultAccount,
defaultVat,
entityType,
template,
templateId,
onConfirm,
onChangeTemplate,
}: QuickReviewDialogProps) {
const { toast } = useToast()
const [accountOverride, setAccountOverride] = useState(defaultAccount)
@@ -96,7 +104,7 @@ export default function QuickReviewDialog({
? accountOverride
: undefined
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override)
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override, templateId)
// Link uploaded documents to the journal entry
if (journalEntryId && uploadedFiles.length > 0) {
@@ -116,7 +124,7 @@ export default function QuickReviewDialog({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
variant: 'destructive',
})
}
@@ -171,14 +179,57 @@ export default function QuickReviewDialog({
</p>
</div>
{/* Category (read-only) */}
{/* Template or Category */}
<div>
<label className="text-sm font-medium text-muted-foreground">Kategori</label>
<div className="mt-1">
<Badge variant="outline" className="text-sm py-1 px-3">{categoryLabel}</Badge>
<label className="text-sm font-medium text-muted-foreground">
{template ? 'Mall' : 'Kategori'}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
{template ? template.name_sv : categoryLabel}
</Badge>
{onChangeTemplate && (
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={onChangeTemplate}
>
Byt mall
</button>
)}
</div>
</div>
{/* Template special rules */}
{template?.special_rules_sv && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
{template.special_rules_sv}
</p>
</div>
)}
{/* Deductibility note */}
{template?.deductibility_note_sv && (
<div className="rounded-lg border border-blue-300/50 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2">
<p className="text-xs text-blue-800 dark:text-blue-300 leading-snug">
{template.deductibility_note_sv}
</p>
</div>
)}
{/* Reverse charge warning */}
{template?.requires_vat_registration_data && (
<div className="rounded-lg border border-amber-400/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2">
<div className="flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
</div>
)}
{/* Journal entry preview */}
<JournalEntryPreview
amount={transaction.amount}
@@ -207,7 +258,7 @@ export default function QuickReviewDialog({
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
Ingen moms för skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
@@ -10,7 +10,8 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import TemplatePicker from './TemplatePicker'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -47,7 +48,6 @@ export default function SwipeCategorizationView({
entityType,
}: SwipeCategorizationViewProps) {
const { toast } = useToast()
const [showAllCategories, setShowAllCategories] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
const [showCategorySelect, setShowCategorySelect] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
@@ -134,6 +134,18 @@ export default function SwipeCategorizationView({
setError(null)
}, [])
const handlePickerTemplateSelect = useCallback((template: BookingTemplate) => {
setPendingCategory(template.fallback_category)
setAccountOverride(template.debit_account)
setVatTreatment(template.vat_treatment ?? 'none')
setPendingTemplateId(template.id)
setPendingInboxItemId(null)
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
}, [])
const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => {
const template = getTemplateById(templateId)
if (!template) return
@@ -226,7 +238,7 @@ export default function SwipeCategorizationView({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
variant: 'destructive',
})
}
@@ -301,8 +313,8 @@ export default function SwipeCategorizationView({
}
if (showCategorySelect) {
const categories =
currentTransaction.amount > 0 ? incomeCategories : expenseCategories
const direction = currentTransaction.amount > 0 ? 'income' : 'expense'
const txSuggestions = templateSuggestions?.[currentTransaction.id]
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
@@ -310,46 +322,39 @@ export default function SwipeCategorizationView({
<Button variant="ghost" size="icon" onClick={() => setShowCategorySelect(false)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Välj kategori</h1>
<h1 className="font-semibold">Välj mall</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4">
<Card className="mb-4">
<CardContent className="pt-4">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-2xl font-bold mt-2">
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
</p>
</CardContent>
</Card>
<Card className="mx-4 mt-3">
<CardContent className="pt-4">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-2xl font-bold mt-2">
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
</p>
</CardContent>
</Card>
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm mb-4">
{error}
</div>
)}
<div className="space-y-2">
{categories.map((cat) => (
<Button
key={cat.value}
variant="outline"
className="w-full justify-start h-auto py-3"
onClick={() => handleCategorySelect(cat.value)}
disabled={isProcessing}
>
<div className="flex items-baseline gap-2">
<span>{cat.label}</span>
{cat.account && <span className="text-xs text-muted-foreground">{cat.account}</span>}
</div>
</Button>
))}
{error && (
<div className="mx-4 mt-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<div className="flex-1 overflow-hidden mt-2">
<TemplatePicker
direction={direction}
entityType={entityType}
suggestedTemplates={txSuggestions}
onSelect={handlePickerTemplateSelect}
selectedTemplateId={pendingTemplateId ?? undefined}
/>
</div>
<div className="p-4 border-t">
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
className="w-full text-muted-foreground"
onClick={handleSkip}
>
<SkipForward className="mr-2 h-4 w-4" />
@@ -364,6 +369,7 @@ export default function SwipeCategorizationView({
const categoryLabel = [...expenseCategories, ...incomeCategories].find(
(c) => c.value === pendingCategory
)?.label || pendingCategory
const selectedTemplate = pendingTemplateId ? getTemplateById(pendingTemplateId) : null
// Auto-clear VAT when a class 2 (liability/equity) account is selected
const isLiabilityAccount = accountOverride.startsWith('2')
@@ -398,14 +404,58 @@ export default function SwipeCategorizationView({
</CardContent>
</Card>
{/* Selected category */}
{/* Selected template or category */}
<div>
<label className="text-sm font-medium text-muted-foreground">Kategori</label>
<div className="mt-1">
<Badge variant="outline" className="text-sm py-1 px-3">{categoryLabel}</Badge>
<label className="text-sm font-medium text-muted-foreground">
{selectedTemplate ? 'Mall' : 'Kategori'}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
{selectedTemplate ? selectedTemplate.name_sv : categoryLabel}
</Badge>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => {
setShowReviewStep(false)
setShowCategorySelect(true)
}}
>
Byt mall
</button>
</div>
</div>
{/* Template special rules warning */}
{selectedTemplate?.special_rules_sv && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2.5">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
{selectedTemplate.special_rules_sv}
</p>
</div>
)}
{/* Deductibility note */}
{selectedTemplate?.deductibility_note_sv && (
<div className="rounded-lg border border-blue-300/50 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2.5">
<p className="text-xs text-blue-800 dark:text-blue-300 leading-snug">
{selectedTemplate.deductibility_note_sv}
</p>
</div>
)}
{/* Reverse charge VAT registration warning */}
{selectedTemplate?.requires_vat_registration_data && (
<div className="rounded-lg border border-amber-400/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2.5">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
</div>
)}
{/* Journal entry preview */}
<JournalEntryPreview
amount={currentTransaction.amount}
@@ -434,7 +484,7 @@ export default function SwipeCategorizationView({
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
Ingen moms för skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
@@ -518,7 +568,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing || !accountOverride}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokfor...' : 'Bokfor'}
{isProcessing ? 'Bokför...' : 'Bokför'}
</Button>
<Button
variant="ghost"
+329
View File
@@ -0,0 +1,329 @@
'use client'
import { useState, useMemo } from 'react'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Search, ChevronDown, ChevronUp, AlertTriangle, Info } from 'lucide-react'
import {
getCommonTemplates,
getAdvancedTemplates,
searchTemplates,
type BookingTemplate,
type TemplateGroup,
} from '@/lib/bookkeeping/booking-templates'
import type { EntityType } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
const GROUP_ORDER: TemplateGroup[] = [
'premises', 'vehicle', 'it_software', 'office_supplies', 'marketing',
'travel', 'representation', 'insurance', 'professional_services',
'bank_finance', 'telecom', 'education', 'personnel', 'revenue',
'financial', 'private_transfers', 'equipment',
]
const GROUP_LABELS: Record<TemplateGroup, string> = {
premises: 'Lokalkostnader',
vehicle: 'Fordon',
it_software: 'IT & Programvara',
office_supplies: 'Kontorsmaterial',
marketing: 'Marknadsföring',
travel: 'Resor & Transport',
representation: 'Representation',
insurance: 'Försäkringar',
professional_services: 'Professionella tjänster',
bank_finance: 'Bank & Finans',
telecom: 'Telekom & Internet',
education: 'Utbildning',
personnel: 'Personal',
revenue: 'Intäkter',
financial: 'Finansiella poster',
private_transfers: 'Privata transaktioner',
equipment: 'Inventarier & Utrustning',
}
function getVatLabel(template: BookingTemplate): string | null {
if (!template.vat_treatment) return null
switch (template.vat_treatment) {
case 'standard_25': return '25% moms'
case 'reduced_12': return '12% moms'
case 'reduced_6': return '6% moms'
case 'reverse_charge': return 'Omvänd moms'
case 'export': return 'Momsfri (export)'
case 'exempt': return 'Momsfri'
default: return null
}
}
function groupTemplates(templates: BookingTemplate[]): Map<TemplateGroup, BookingTemplate[]> {
const grouped = new Map<TemplateGroup, BookingTemplate[]>()
for (const t of templates) {
const list = grouped.get(t.group) || []
list.push(t)
grouped.set(t.group, list)
}
return grouped
}
interface TemplateCardProps {
template: BookingTemplate
selected: boolean
onClick: () => void
compact?: boolean
}
function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) {
const vatLabel = getVatLabel(template)
return (
<button
type="button"
onClick={onClick}
className={`w-full text-left rounded-lg border px-3 py-2.5 transition-colors hover:bg-muted/50 ${
selected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className={`font-medium ${compact ? 'text-sm' : 'text-sm'} leading-tight`}>
{template.name_sv}
</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs font-mono text-muted-foreground">
D: {template.debit_account} &middot; K: {template.credit_account}
</span>
{vatLabel && (
<Badge
variant="secondary"
className={`text-[10px] px-1.5 py-0 ${
template.vat_treatment === 'reverse_charge'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: ''
}`}
>
{vatLabel}
</Badge>
)}
{template.requires_vat_registration_data && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-amber-400 text-amber-600 dark:text-amber-400 gap-0.5">
<AlertTriangle className="h-2.5 w-2.5" />
Kräver momsreg.nr
</Badge>
)}
</div>
</div>
{template.requires_review && (
<Info className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
)}
</div>
{template.special_rules_sv && !compact && (
<p className="text-[11px] text-muted-foreground mt-1.5 leading-snug">
{template.special_rules_sv}
</p>
)}
</button>
)
}
interface TemplatePickerProps {
direction: 'expense' | 'income'
entityType?: EntityType
suggestedTemplates?: SuggestedTemplate[]
recentTemplateIds?: string[]
onSelect: (template: BookingTemplate) => void
selectedTemplateId?: string
}
export default function TemplatePicker({
direction,
entityType,
suggestedTemplates,
onSelect,
selectedTemplateId,
}: TemplatePickerProps) {
const [searchQuery, setSearchQuery] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
// Map direction to template direction filter (transfers show in both)
const templateDirection = direction === 'income' ? 'income' : 'expense'
const commonTemplates = useMemo(
() => getCommonTemplates(entityType, templateDirection),
[entityType, templateDirection]
)
const advancedTemplates = useMemo(
() => getAdvancedTemplates(entityType, templateDirection),
[entityType, templateDirection]
)
// Also include transfer templates in both directions
const commonTransfers = useMemo(
() => getCommonTemplates(entityType, 'transfer'),
[entityType]
)
const advancedTransfers = useMemo(
() => getAdvancedTemplates(entityType, 'transfer'),
[entityType]
)
const allCommon = useMemo(
() => [...commonTemplates, ...commonTransfers],
[commonTemplates, commonTransfers]
)
const allAdvanced = useMemo(
() => [...advancedTemplates, ...advancedTransfers],
[advancedTemplates, advancedTransfers]
)
// Search results
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return null
return searchTemplates(searchQuery, entityType).filter((t) => {
if (t.direction === templateDirection || t.direction === 'transfer') return true
return false
})
}, [searchQuery, entityType, templateDirection])
// Group templates by group for display
const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon])
const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced])
const handleSelect = (template: BookingTemplate) => {
onSelect(template)
}
// Suggested templates section
const hasSuggestions = suggestedTemplates && suggestedTemplates.length > 0
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="relative px-4 pt-3 pb-2">
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground mt-0.5" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Sök mall..."
className="pl-9 h-9"
/>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-auto px-4 pb-4 space-y-4">
{/* Search results */}
{searchResults !== null ? (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
{searchResults.length === 0 ? 'Inga resultat' : `${searchResults.length} resultat`}
</p>
<div className="space-y-1.5">
{searchResults.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
/>
))}
</div>
</div>
) : (
<>
{/* Suggested templates */}
{hasSuggestions && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Föreslagna</p>
<div className="space-y-1.5">
{suggestedTemplates!.slice(0, 5).map((s) => {
// Find the full template object
const fullTemplate = allCommon.find((t) => t.id === s.template_id) ||
allAdvanced.find((t) => t.id === s.template_id)
if (!fullTemplate) return null
return (
<TemplateCard
key={s.template_id}
template={fullTemplate}
selected={selectedTemplateId === s.template_id}
onClick={() => handleSelect(fullTemplate)}
compact
/>
)
})}
</div>
</div>
)}
{/* Common templates grouped */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Vanliga mallar</p>
<div className="space-y-3">
{GROUP_ORDER.filter((g) => commonGrouped.has(g)).map((group) => (
<div key={group}>
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
{GROUP_LABELS[group]}
</p>
<div className="space-y-1.5">
{commonGrouped.get(group)!.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
compact
/>
))}
</div>
</div>
))}
</div>
</div>
{/* Advanced templates (collapsible) */}
{allAdvanced.length > 0 && (
<div>
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-xs text-muted-foreground h-8"
onClick={() => setShowAdvanced(!showAdvanced)}
>
Fler mallar ({allAdvanced.length})
{showAdvanced ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</Button>
{showAdvanced && (
<div className="space-y-3 mt-2">
{GROUP_ORDER.filter((g) => advancedGrouped.has(g)).map((group) => (
<div key={group}>
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
{GROUP_LABELS[group]}
</p>
<div className="space-y-1.5">
{advancedGrouped.get(group)!.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
compact
/>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -72,7 +72,7 @@ export default function TransactionBookingDialog({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`,
variant: 'destructive',
})
}
@@ -93,9 +93,9 @@ export default function TransactionBookingDialog({
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokfor transaktion</DialogTitle>
<DialogTitle>Bokför transaktion</DialogTitle>
<DialogDescription>
Skapa en verifikation for transaktionen
Skapa en verifikation för transaktionen
</DialogDescription>
</DialogHeader>
@@ -83,11 +83,7 @@ export default function TransactionHistoryList({
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`h-10 w-10 rounded-full flex items-center justify-center ${
transaction.amount > 0
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
className="h-10 w-10 rounded-full flex items-center justify-center bg-muted text-muted-foreground"
>
{transaction.amount > 0 ? (
<ArrowUpRight className="h-5 w-5" />
@@ -173,11 +169,7 @@ export default function TransactionHistoryList({
</Button>
)}
<div className="text-right">
<p
className={`font-medium ${
transaction.amount > 0 ? 'text-success' : ''
}`}
>
<p className="font-medium">
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
@@ -9,6 +9,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText, Paperclip } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
@@ -26,6 +27,7 @@ interface TransactionInboxCardProps {
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onOpenDescribe?: (transaction: TransactionWithInvoice) => void
onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void
onOpenTemplateReview?: (transaction: TransactionWithInvoice, templateId: string) => void
onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void
}
@@ -44,6 +46,7 @@ export default function TransactionInboxCard({
onOpenCategoryDialog,
onOpenDescribe,
onOpenQuickReview,
onOpenTemplateReview,
onToggleSelect,
onAnimationComplete,
}: TransactionInboxCardProps) {
@@ -174,6 +177,36 @@ export default function TransactionInboxCard({
)}
Matcha Leverantörsfaktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
</Button>
) : templateSuggestions && templateSuggestions.length > 0 ? (
<>
{templateSuggestions.slice(0, 2).map((ts, idx) => {
const tmpl = getTemplateById(ts.template_id)
return (
<Button
key={ts.template_id}
size="sm"
variant={idx === 0 ? 'default' : 'outline'}
className="h-8 text-xs"
onClick={() => {
if (onOpenTemplateReview && tmpl) {
onOpenTemplateReview(transaction, ts.template_id)
} else if (topSuggestion) {
handleSuggestionClick(topSuggestion)
}
}}
disabled={isProcessing || isDisabled}
>
{isProcessing && idx === 0 ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
{ts.name_sv}
<span className="ml-1 opacity-70 font-normal">
({ts.debit_account})
</span>
</Button>
)
})}
</>
) : topSuggestion ? (
<Button
size="sm"
@@ -199,24 +232,6 @@ export default function TransactionInboxCard({
</Button>
) : null}
{/* Secondary suggestions (up to 1 more) */}
{!hasInvoiceMatch && suggestions && suggestions.length > 1 && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => handleSuggestionClick(suggestions[1])}
disabled={isProcessing || isDisabled}
>
{suggestions[1].label}
{suggestions[1].account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(suggestions[1].account)})
</span>
)}
</Button>
)}
{/* Describe transaction */}
{onOpenDescribe && (
<Button
@@ -231,15 +246,17 @@ export default function TransactionInboxCard({
</Button>
)}
{/* Open category dialog */}
{/* Open category dialog / template picker */}
<Button
size="sm"
variant={!hasInvoiceMatch && !topSuggestion ? 'default' : 'outline'}
variant={!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0) ? 'default' : 'outline'}
className="h-8 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{!hasInvoiceMatch && !topSuggestion ? 'Bokför' : 'Bokför manuellt...'}
{!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0)
? 'Välj mall...'
: 'Välj mall...'}
</Button>
</div>
)}
+6 -4
View File
@@ -55,10 +55,12 @@ export function ConfirmationDialog({
</div>
<div className="border-t px-6 py-4 space-y-4">
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">{warningText}</p>
</div>
{warningText && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">{warningText}</p>
</div>
)}
<DialogFooter>
<Button
+1 -1
View File
@@ -67,7 +67,7 @@ export function EmptyState({
</Link>
)}
{onAction && actionLabel && (
<Button onClick={onAction} className="bg-accent hover:bg-accent/90 text-accent-foreground">
<Button onClick={onAction}>
<Plus className="mr-2 h-4 w-4" />
{actionLabel}
</Button>
-51
View File
@@ -1,51 +0,0 @@
ERP-Base: Gap-analys mot svensk bokföringsmarknad
1. SAKNAS HELT — Kritiska luckor
1.1 Leverantörsreskontra
Alla etablerade system har fullständig leverantörsreskontra: registrering av inkommande fakturor, förfallodatum, betalningsstatus, automatisk bokföring vid betalning. Ditt system saknar tabeller och flöden för leverantörsfakturor. Detta är ett absolut krav för att kunna kallas bokföringssystem.
Behövs: suppliers-tabell, supplier_invoices-tabell, flöde för registrering/betalning/bokföring, leverantörsreskontra-rapport, stöd för både kontant- och fakturametoden.
1.2 Kundreskontra (formellt)
Du har invoices och customers, men det saknas en explicit kundreskontra-vy som visar utestående fordringar, förfallna fakturor, och avstämning mot konto 1510. Alla konkurrenter har detta som standardfunktion.
1.3 Lönehantering
salary_payments finns men alla konkurrenter (Fortnox, Bokio, Visma) erbjuder komplett lönehantering: lönespecifikationer, arbetsgivaravgifter, skattetabeller (FOS-förfrågan mot Skatteverket), AGI-rapportering, semesterhantering. Detta är en separat modul som de flesta SME-kunder förväntar sig.
Behövs: Skattetabellhantering, lönespec-generering (PDF), arbetsgivaravgiftsberäkning, AGI-rapportering, semesterskuld, förmånsberäkning (bil, etc).
1.4 Årsredovisning (K2/K3)
Aktiebolag måste lämna årsredovisning till Bolagsverket. Fortnox och Björn Lundén genererar detta. Din plattform har årsbokslut men saknar årsredovisningsgenerering med förvaltningsberättelse, noter, och formell K2/K3-struktur.
Behövs: Generering av förvaltningsberättelse, resultaträkning (K2-format), balansräkning (K2-format), noter, digital inlämning till Bolagsverket (XBRL).
1.5 Kontantmetod-stöd
Många enskilda firmor bokför med kontantmetoden (bokslutsmetoden). Ditt system verkar byggt kring faktureringsmetoden. Båda måste stödjas, med automatisk övergång till fakturametod vid bokslut för kontantmetoden.
1.6 Anläggningsregister
Inventarier, maskiner, fastigheter — med avskrivningsplaner (linjär/degressiv), restvärden, och automatisk avskrivningsbokföring. Saknas helt. Krävs för AB med tillgångar.
1.7 Offert/Order-flöde
Fortnox och Visma har offert → order → faktura-kedja. Inte nödvändigt för MVP men förväntat i ett komplett system.
2. FINNS MEN OTILLRÄCKLIGT — Behöver utökas
2.1 Bokföringsmallar / Konteringshjälp
Bokio's stora USP är smart konteringshjälp: användaren väljer "IT-tjänst 25% moms" och systemet konterar automatiskt. Du har AI-kategorisering, men saknar troligen ett bibliotek av färdiga bokföringsmallar för vanliga affärshändelser som en nybörjare kan välja mellan.
Behövs: 50-100 vanliga transaktionsmallar (kontorsmateriell, IT-tjänst, bensin, representation, etc) med korrekt moms och kontering.
2.2 Bankavstämning
Du har PSD2-transaktionssynk, men behöver explicit bankavstämning: matcha banktransaktioner mot bokförda poster, markera avstämda, visa differenser. Alla konkurrenter har detta.
2.3 Momsdeklaration
Du nämner "10 rutor" men verifierar att den genererar korrekt SKV 4820-underlag? Behöver också stödja: EU-handel (omvänd skattskyldighet), import/export-moms, olika momssatser (25/12/6/0%), tröskelbelopp (120 000 SEK från 2025).
2.4 SIE-export
Du har SIE4-export. Verifiera att SIE-import också fungerar korrekt (ingående balanser, verifikationer, kontoplan) — detta är kritiskt för att kunder ska kunna byta till ditt system från Fortnox/Bokio.
2.5 Rapporter
Du har saldobalans, resultat, balans, moms. Saknar troligen:
Huvudbok (alla transaktioner per konto)
Grundbok (verifikationslista i datumordning)
Kundreskontra-rapport
Leverantörsreskontra-rapport
Periodrapporter (jämförelse mellan perioder)
Kassaflödesanalys
3. HYGIEN-FUNKTIONER — Förväntas av alla
3.1 Autentisering
BankID-inloggning förväntas av svenska användare. Inte nödvändigt dag 1, men e-post + lösenord + 2FA via TOTP är minimum.
3.2 Mobilapp / Responsivt
Alla konkurrenter har mobilapp eller fullt responsivt gränssnitt. Kvittofotografering från mobil är en hygienfaktor.
3.3 Periodlåsning
Bokföringslagen kräver att bokföring är "varaktig" — du behöver kunna låsa perioder så att poster inte kan ändras i efterhand utan att det syns. Du har WORM-arkiv, verifiera att periodlåsning är implementerad.
3.4 Fleranvändarstöd
Roller: ägare, redovisningskonsult (extern), anställd. Behörigheter per modul. Alla konkurrenter har detta. Redovisningskonsult-access är affärskritiskt — byråer är den viktigaste distributionskanalen.
3.5 Verifikationskedja
Varje verifikation behöver: löpnummer utan luckor, datum, belopp, motkonto, beskrivning, bifogat underlag. Du har detta delvis via WORM + voucher numbering, men verifiera fullständigt BFL-compliance.
-232
View File
@@ -1,232 +0,0 @@
Architecture Cleanup & SupabaseClient Injection
Context
External feedback identified the codebase as overengineered in some areas (unused sector extensions, dead event types) and
underengineered in one critical area (no abstraction boundary between core lib/ and the Supabase platform). The goal is to
slim down dead weight and refactor lib/ functions to accept SupabaseClient as a parameter instead of self-instantiating,
matching a pattern already used by bank-reconciliation.ts, ingest.ts, and other files.
Two PRs:
- PR1: Cleanup (delete sector/export extensions, prune 6 dead events)
- PR2: SupabaseClient injection refactor (reports first, then engine + core services)
---
PR1: Cleanup
1a. Delete sector & export extension directories
Delete these 6 directories entirely:
extensions/restaurant/
extensions/construction/
extensions/hotel/
extensions/tech/
extensions/ecommerce/
extensions/export/
Delete their workspace components:
components/extensions/restaurant/
components/extensions/construction/
components/extensions/hotel/
components/extensions/tech/
components/extensions/ecommerce/
components/extensions/export/
1b. Update sector registry
lib/extensions/types.ts:13 — reduce SectorSlug union:
// Before
export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce' | 'export'
// After
export type SectorSlug = 'general'
lib/extensions/sectors.ts — remove 6 sector shells from SECTOR_SHELLS array (lines 21-57), keeping only the general entry.
1c. Update sectors test
lib/extensions/__tests__/sectors.test.ts — the test uses buildDefinitionsFromManifests() which walks extensions/ at runtime,
so counts auto-adjust. But hardcoded assertions need updating:
- Line 48: expect(SECTORS.length).toBe(7) → .toBe(1)
- Line 51: expect(getAllExtensions().length).toBe(25) → update to match remaining general extensions count (count manifests in
extensions/general/)
- Lines 63-66: "at least one extension per sector" — still valid for 1 sector
- Lines 69-73: Change getSector('restaurant') test to getSector('general')
- Lines 81-87: Change getExtensionDefinition('restaurant', 'food-cost') to a general extension
- Lines 94-96: Change getExtensionsBySector('restaurant') to getExtensionsBySector('general')
1d. Remove 6 dead event types
lib/events/types.ts — remove these 6 union members from CoreEvent:
- invoice.paid (line 32)
- invoice.overdue (line 33)
- bank.statement_received (line 39)
- bank.payment_notification (line 40)
- customer.pseudonymized (line 46)
- audit.security_event (line 72)
Remove from the import on line 1-16:
- CAMT053Statement
- CAMT054Notification
- AuditSecurityEvent
types/index.ts — delete the 3 placeholder interfaces (lines 1669-1685):
- CAMT053Statement
- CAMT054Notification
- AuditSecurityEvent
1e. Verification
npm run build # Confirm no broken imports
npm test # All tests pass
npm run setup:extensions # Codegen still works (manifests removed)
---
PR2: SupabaseClient Injection Refactor
Phase 1: Report generators (10 files)
These files self-instantiate createClient(). Refactor each to accept supabase: SupabaseClient as first parameter.
┌────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────┐
│ File │ Functions to change │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/trial-balance.ts │ generateTrialBalance(supabase, userId, periodId), │
│ │ generateTrialBalanceManual(supabase, userId, periodId) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/vat-declaration.ts │ calculateVatDeclaration(supabase, userId, ...) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/sie-export.ts │ generateSIEExport(supabase, userId, options) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/general-ledger.ts │ generateGeneralLedger(supabase, userId, periodId, ...) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/journal-register.ts │ generateJournalRegister(supabase, userId, periodId) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/monthly-breakdown.ts │ generateMonthlyBreakdown(supabase, userId, periodId) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/supplier-ledger.ts │ generateSupplierLedger(supabase, userId, asOfDate) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/supplier-reconciliation.ts │ generateReconciliation(supabase, userId, periodId) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/ar-ledger.ts │ generateARLedger(supabase, userId, asOfDate) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/ar-reconciliation.ts │ generateARReconciliation(supabase, userId, periodId) │
└────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────┘
Not changed (no direct createClient call):
- income-statement.ts — delegates to generateTrialBalance(), which gets the client. Pass supabase through:
generateIncomeStatement(supabase, userId, periodId).
- balance-sheet.ts — same pattern, delegates to generateTrialBalance().
Sub-reports (also need injection):
- lib/reports/ne-bilaga/ne-engine.ts — generateNEDeclaration(supabase, userId, periodId)
- lib/reports/sru-export/sru-engine.ts — aggregateBalancesBySRU(supabase, userId, periodId), getSRUCoverage(supabase, userId)
Mechanical change per file:
1. Remove import { createClient } from '@/lib/supabase/server'
2. Add import type { SupabaseClient } from '@supabase/supabase-js'
3. Add supabase: SupabaseClient as first parameter
4. Delete the const supabase = await createClient() line
Update callers — each report API route already creates a client for auth. Pass it through:
// Before (app/api/reports/trial-balance/route.ts)
const result = await generateTrialBalance(user.id, periodId)
// After
const result = await generateTrialBalance(supabase, user.id, periodId)
12 API routes to update:
- app/api/reports/trial-balance/route.ts
- app/api/reports/income-statement/route.ts
- app/api/reports/balance-sheet/route.ts
- app/api/reports/vat-declaration/route.ts
- app/api/reports/sie-export/route.ts
- app/api/reports/general-ledger/route.ts
- app/api/reports/journal-register/route.ts
- app/api/reports/monthly-breakdown/route.ts
- app/api/reports/supplier-ledger/route.ts
- app/api/reports/ar-ledger/route.ts
- app/api/reports/ne-bilaga/route.ts
- app/api/reports/sru-export/route.ts (+ coverage/route.ts)
Phase 2: Core services (7 files)
Same mechanical pattern. Each function gets supabase: SupabaseClient as first parameter.
┌──────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┐
│ File │ Functions │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/core/bookkeeping/period-service.ts │ lockPeriod, closePeriod, createNextPeriod, getPeriodStatus │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/core/bookkeeping/storno-service.ts │ correctEntry │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ │ validateYearEndReadiness, previewYearEndClosing, generateOpeningBalances │
│ lib/core/bookkeeping/year-end-service.ts │ (note: executeYearEndClosing calls others that self-instantiate, so it also │
│ │ needs the param and must pass it through) │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ │ uploadDocument, createNewVersion, linkToJournalEntry, verifyIntegrity (keep │
│ lib/core/documents/document-service.ts │ ensureDocumentsBucket using createServiceClient — it needs service role for │
│ │ bucket ops) │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/core/audit/audit-service.ts │ getAuditLog, getEntityHistory, getCorrectionChain │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/core/tax/tax-code-service.ts │ getTaxCodes, getTaxCodeByCode, calculateMomsFromTaxCodes, seedTaxCodes │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/invoices/invoice-matching.ts │ findMatchingInvoices │
└──────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┘
Phase 3: Bookkeeping engine + mapping (3 files)
┌───────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────┐
│ File │ Functions │
├───────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ │ getNextVoucherNumber, findFiscalPeriod, createDraftEntry, commitEntry, │
│ lib/bookkeeping/engine.ts │ createJournalEntry, reverseEntry (validateBalance stays pure, resolveAccountIds │
│ │ already takes client) │
├───────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ lib/bookkeeping/mapping-engine.ts │ evaluateMappingRules, saveUserMappingRule │
├───────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ lib/import/sie-import.ts │ checkDuplicateImport, importVouchers, saveMappings, loadMappings, executeSIEImport │
└───────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────┘
Engine.ts cascade: Since createJournalEntry calls createDraftEntry and commitEntry, and commitEntry calls
getNextVoucherNumber, the client flows through all internal calls. This eliminates the current pattern where each sub-function
creates its own independent client.
Special cases
- lib/bookkeeping/handlers/supplier-invoice-handler.ts — event handler, invoked by event bus. The handler creates its own
client because it has no caller to receive one from. Leave as-is — this is the edge of the system where the event bus must
bootstrap a client.
- lib/extensions/toggle-check.ts — uses createServiceClient() (service role, no cookies). Leave as-is — extension toggle
checks bypass RLS intentionally.
- lib/core/documents/document-service.ts ensureDocumentsBucket — uses createServiceClient(). Leave as-is for this one private
function.
Phase 2/3 caller updates
The API routes calling these functions need the same one-line change as the report routes: pass the existing supabase variable
as the first argument.
Verification
npm run build # No broken imports
npm test # All tests pass — mock pattern changes from module mock to direct client mock
npx vitest run lib/reports # Report tests specifically
npx vitest run lib/bookkeeping # Engine tests specifically
Tests currently mock @/lib/supabase/server. After the refactor, tests can pass a mock client directly via createMockSupabase()
from tests/helpers.ts — which is already the pattern used by the tests. The vi.mock('@/lib/supabase/server') calls in test
files can be removed for functions that now accept the client as a parameter.
---
What we're NOT changing
- engine.ts single write path
- DB enforcement triggers
- Extension toggle system
- Codegen from extensions.config.json
- Event bus design (lib/events/bus.ts)
- Report system structure
- Reconciliation 4-pass algorithm
- AI extension architecture
-472
View File
@@ -1,472 +0,0 @@
Here is Claude's plan:
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Plan A: Extension Opt-In Foundation (~3 days)
Context
erp-base is being open-sourced. The core (Swedish bookkeeping) should work standalone with only Supabase. Extensions (AI,
PSD2, push, email, sector tools) ship in the same repo as an "extensions library" that developers explicitly opt into via a
JSON config file. Everything is open-source — monetization happens on the hosted platform.
This plan establishes the architectural boundary. After this plan:
- Core builds and runs with an empty config (zero extensions)
- Extensions are enabled by adding their ID to extensions.config.json
- CI prevents anyone from accidentally importing extension code in core
- Every extension has a manifest describing its dependencies
Plan B (separate, future) handles the full cleanup: email extraction, API route migration, template-embeddings relocation,
SRU/NE-bilaga merge, cross-extension cleanup, and documentation.
Current Coupling (What We're Fixing)
Only 3 files in lib/ directly import from extensions/:
┌───────────────────────────────────────┬───────────────────────────────────────────────────────────┐
│ File │ Coupling │
├───────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ lib/extensions/loader.ts │ 12 hardcoded static imports from @/extensions/ │
├───────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ lib/extensions/workspace-registry.tsx │ 24 hardcoded next/dynamic() imports │
├───────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ lib/extensions/sectors.ts │ Hardcoded extension metadata (data only, no code imports) │
└───────────────────────────────────────┴───────────────────────────────────────────────────────────┘
Everything else is already clean — event bus, registry, context factory, types, core API routes, core components.
Implementation
Step 1: Create the config file and JSON schema
New file: extensions.config.json
{
"$schema": "./extensions.schema.json",
"extensions": []
}
New file: extensions.schema.json
JSON Schema listing all valid extension IDs with descriptions, giving IDE autocompletion. Generated from manifest files (or
hand-maintained initially).
Step 2: Add manifest.json to every extension
Each extension directory gets a manifest describing its metadata, imports, and requirements.
Format:
{
"id": "receipt-ocr",
"sector": "general",
"exportName": "receiptOcrExtension",
"workspace": "@/components/extensions/general/ReceiptOcrWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk"],
"definition": {
"name": "Receipt OCR",
"category": "import",
"icon": "Camera",
"dataPattern": "both",
"description": "Scan and process receipts with AI",
"longDescription": "..."
}
}
Extensions to manifest (24 total):
┌────────────────────────┬────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│ Extension │ Path │ Required Env Vars │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ receipt-ocr │ extensions/general/receipt-ocr/ │ ANTHROPIC_API_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ ai-categorization │ extensions/general/ai-categorization/ │ ANTHROPIC_API_KEY, OPENAI_API_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ ai-chat │ extensions/general/ai-chat/ │ ANTHROPIC_API_KEY, OPENAI_API_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ invoice-inbox │ extensions/general/invoice-inbox/ │ ANTHROPIC_API_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ enable-banking │ extensions/general/enable-banking/ │ ENABLE_BANKING_APP_ID, ENABLE_BANKING_PRIVATE_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ push-notifications │ extensions/general/push-notifications/ │ VAPID_PRIVATE_KEY, NEXT_PUBLIC_VAPID_PUBLIC_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ calendar │ extensions/general/calendar/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ eu-sales-list │ extensions/export/eu-sales-list/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ vat-monitor │ extensions/export/vat-monitor/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ intrastat │ extensions/export/intrastat/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ currency-receivables │ extensions/export/currency-receivables/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ food-cost │ extensions/restaurant/food-cost/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ earnings-per-liter │ extensions/restaurant/earnings-per-liter/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ pos-import │ extensions/restaurant/pos-import/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ tip-tracking │ extensions/restaurant/tip-tracking/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ rot-calculator │ extensions/construction/rot-calculator/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ project-cost │ extensions/construction/project-cost/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ revpar │ extensions/hotel/revpar/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ occupancy │ extensions/hotel/occupancy/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ billable-hours │ extensions/tech/billable-hours/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ project-billing │ extensions/tech/project-billing/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ shopify-import │ extensions/ecommerce/shopify-import/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ multichannel-revenue │ extensions/ecommerce/multichannel-revenue/ │ (none) │
└────────────────────────┴────────────────────────────────────────────┴───────────────────────────────────────────────────┘
Step 3: Build the generator script
New file: scripts/generate-extension-registry.ts
The generator:
1. Reads extensions.config.json to get enabled extension IDs
2. For each ID, finds and reads extensions/**/manifest.json matching that ID
3. Generates 3 files under lib/extensions/_generated/:
lib/extensions/_generated/extension-list.ts — When config has ["receipt-ocr", "ai-categorization"]:
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import type { Extension } from '../types'
import { receiptOcrExtension } from '@/extensions/general/receipt-ocr'
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
]
lib/extensions/_generated/workspace-map.tsx — Dynamic import map:
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import dynamic from 'next/dynamic'
import type { ComponentType } from 'react'
import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
'general/receipt-ocr': dynamic(() => import('@/components/extensions/general/ReceiptOcrWorkspace')),
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
}
lib/extensions/_generated/sector-definitions.ts — Extension metadata:
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import type { ExtensionDefinition } from '../types'
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
general: [
{ slug: 'receipt-ocr', name: 'Receipt OCR', ... },
{ slug: 'ai-categorization', name: 'AI Categorization', ... },
],
}
When config is empty ("extensions": []):
export const FIRST_PARTY_EXTENSIONS: Extension[] = []
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {}
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {}
Generator features:
- npm run setup:extensions — Generate + validate env vars (warn if missing)
- npm run setup:extensions -- --list — Print all available extensions with descriptions
- Outputs: "Enabled: receipt-ocr, ai-categorization. Warning: OPENAI_API_KEY not set (required by ai-categorization)"
Step 4: Modify loader, workspace-registry, and sectors to use generated files
lib/extensions/loader.ts — Replace hardcoded imports:
import { extensionRegistry } from './registry'
import { FIRST_PARTY_EXTENSIONS } from './_generated/extension-list'
let loaded = false
export function loadExtensions(): void {
if (loaded) return
loaded = true
for (const extension of FIRST_PARTY_EXTENSIONS) {
extensionRegistry.register(extension)
}
}
lib/extensions/workspace-registry.tsx — Replace hardcoded map:
import type { ComponentType } from 'react'
import { WORKSPACES } from './_generated/workspace-map'
export interface WorkspaceComponentProps {
userId: string
}
export function getWorkspaceComponent(
sector: string,
slug: string
): ComponentType<WorkspaceComponentProps> | null {
return WORKSPACES[`${sector}/${slug}`] ?? null
}
lib/extensions/sectors.ts — Replace hardcoded extension definitions:
The sector shells (general, restaurant, construction, etc.) stay hardcoded since they are structural. The extension
definitions per sector come from the generated file. Merge them at runtime.
Step 5: Commit empty defaults for generated files
These are committed so core compiles out of the box without running the generator:
- lib/extensions/_generated/extension-list.ts → Empty FIRST_PARTY_EXTENSIONS
- lib/extensions/_generated/workspace-map.tsx → Empty WORKSPACES
- lib/extensions/_generated/sector-definitions.ts → Empty EXTENSION_DEFINITIONS
Add to .gitignore a comment explaining these files are auto-generated but the defaults are committed.
Step 6: npm scripts
Add to package.json:
{
"setup:extensions": "tsx scripts/generate-extension-registry.ts",
"prebuild": "npm run setup:extensions",
"predev": "npm run setup:extensions"
}
This ensures the generated files are always up-to-date before build/dev.
Step 7: CI regression guard
New file: .github/workflows/core-build.yml
name: Core Build (no extensions)
on: [pull_request]
jobs:
core-only:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Reset extensions config
run: echo '{"extensions":[]}' > extensions.config.json
- run: npm run setup:extensions
- run: npm run build
- run: npm test
- name: Check no core imports from extensions
run: |
VIOLATIONS=$(grep -r "from '@/extensions/" lib/ app/api/ components/ --include="*.ts" --include="*.tsx" \
| grep -v "app/api/extensions/" \
| grep -v "components/extensions/" \
| grep -v "lib/extensions/_generated/" \
| grep -v "lib/extensions/loader.ts" || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: Core code imports from @/extensions/:"
echo "$VIOLATIONS"
exit 1
fi
Note: After this plan, lib/extensions/loader.ts will no longer import from @/extensions/ (it imports from _generated/), so the
exclusion for loader.ts is just a safety measure during transition.
Verification Criteria
1. Empty config builds: echo '{"extensions":[]}' > extensions.config.json && npm run setup:extensions && npm run build →
succeeds
2. Single extension works: Add "calendar" to config → npm run setup:extensions && npm run build → calendar extension available
at /e/general/calendar
3. Full config works: Add all 12 currently-loaded extension IDs → npm run setup:extensions && npm run build → identical to
current behavior
4. CI catches violations: If someone adds import { x } from '@/extensions/foo' in lib/utils.ts, the CI job fails
5. All tests pass: npm test with both empty and full config
6. Generator warns about missing env vars: Enable ai-categorization without OPENAI_API_KEY → warning printed, build still
succeeds
Critical Files
┌─────────────────────────────────────────────────┬────────────────────────────────────────────────────────┐
│ File │ Action │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ extensions.config.json │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ extensions.schema.json │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ scripts/generate-extension-registry.ts │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/_generated/extension-list.ts │ Create (empty default) │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/_generated/workspace-map.tsx │ Create (empty default) │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/_generated/sector-definitions.ts │ Create (empty default) │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ .github/workflows/core-build.yml │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/loader.ts │ Modify: import from generated file │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/workspace-registry.tsx │ Modify: import from generated file │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/sectors.ts │ Modify: import definitions from generated file │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ extensions/*/manifest.json (24 files) │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ package.json │ Modify: add setup:extensions, prebuild, predev scripts │
└─────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘
---
Plan B: Full Decoupling (Reference — Execute Later)
This plan is for after Plan A is complete. Context preserved here so nothing is lost.
Prerequisites
Plan A complete: config system works, manifests exist, CI guard in place.
Phase 3: Create the email extension (~1.5 days)
Extract email from core into an extension. Core only needs Supabase.
1. Create extensions/general/email/:
- index.ts — Extension definition, subscribes to invoice.created, invoice.overdue
- lib/email-service.ts — Resend integration (moved from lib/email/)
- lib/templates/ — Invoice, reminder, notification templates
- manifest.json — requires RESEND_API_KEY, RESEND_FROM_EMAIL
2. Create NoopEmailAdapter in core (lib/email/service.ts):
- Core defines EmailService interface + no-op default
- Email extension registers real implementation via services pattern on the registry
- Invoice flows check if email service is available; if not, skip sending (no crash)
3. Event-driven: Core emits invoice.created, invoice.overdue. Email extension subscribes, sends emails. If not loaded, events
fire but nothing sends.
4. Move cron: /api/invoices/reminders/cron becomes a thin proxy or moves into email extension's apiRoutes.
Current email files to move:
- lib/email/ → Review what's here, extract Resend-specific code into extension
- Invoice template generation stays in core (PDF generation), email delivery moves to extension
Phase 4: Move extension API routes into extensions (~5-7 days)
Move handler logic from app/api/extensions/<name>/ route files into each extension's apiRoutes array. The catch-all at
app/api/extensions/ext/[...path]/route.ts dispatches.
Frontend URL change: /api/extensions/<name>/<action> → /api/extensions/ext/<name>/<action>
Routes to convert (move handler into extension apiRoutes):
- ai-categorization/suggestions/, ai-categorization/settings/
- ai-chat/, ai-chat/stream/, ai-chat/sessions/
- invoice-inbox/inbox/, invoice-inbox/inbox/[id]/*, invoice-inbox/settings/
- receipt-ocr/upload/, receipt-ocr/[id]/*, receipt-ocr/settings/, receipt-ocr/queue/
- push-notifications/subscribe/, push-notifications/settings/
- All export/* routes
Thin proxy routes (external callbacks / cron — keep but make extension-agnostic):
- invoice-inbox/webhook/ — Resend webhook: delegates to extensionRegistry.get('invoice-inbox')?.apiRoutes
- enable-banking/callback/ — PSD2 OAuth: delegates to registry
- enable-banking/sync/cron/ — Vercel cron: delegates to registry
- push-notifications/cron/ — Vercel cron: delegates to registry
Keep as-is (core framework):
- toggles/, [sector]/[slug]/data/, [sector]/[slug]/settings/, ext/[...path]/
Delete all other dedicated routes after moving logic.
Phase 5: Move template-embeddings.ts out of core (~1 day)
lib/bookkeeping/template-embeddings.ts imports @langchain/openai.
1. Move to extensions/general/ai-categorization/lib/template-embeddings.ts
2. Add services field to Extension interface (lib/extensions/types.ts):
services?: Record<string, (...args: unknown[]) => Promise<unknown>>
3. ai-categorization registers: services: { findSimilarTemplates: ... }
4. app/api/transactions/suggest-categories/route.ts uses registry:
const aiExt = extensionRegistry.get('ai-categorization')
const templateSuggestions = aiExt?.services?.findSimilarTemplates
? await aiExt.services.findSimilarTemplates(transaction, entityType)
: []
// Rule-based suggestions from mapping-engine.ts always available
Phase 6: Merge SRU export and NE-bilaga into core (~1 day)
Tax compliance features, no external deps, always available:
- Move extensions/sru-export/ → lib/reports/sru-export/
- Move extensions/ne-bilaga/ → lib/reports/ne-bilaga/
- Move workspace components → components/reports/
- Move API routes → app/api/reports/sru-export/, app/api/reports/ne-bilaga/
- Remove from extension system (no manifest, not in loader)
Phase 7: Cross-extension dependency cleanup (~0.5 days)
invoice-inbox imports processReceiptFromDocument from receipt-ocr.
Use services pattern:
- receipt-ocr registers: services: { processReceiptFromDocument }
- invoice-inbox calls: extensionRegistry.get('receipt-ocr')?.services?.processReceiptFromDocument(...)
- Gracefully skips if receipt-ocr not enabled
Phase 8: Documentation (~1 day)
1. README.md: Self-hosting guide — core setup (just Supabase), extension opt-in
2. EXTENSIONS.md: Extension interface, events, context API, how to build extensions
3. scripts/create-extension.ts: Scaffolds new extension (manifest, index.ts, types, workspace)
Plan B Effort Summary
┌──────────────────────────────────┬─────────────┐
│ Phase │ Effort │
├──────────────────────────────────┼─────────────┤
│ Phase 3: Email extension │ 1.5 days │
├──────────────────────────────────┼─────────────┤
│ Phase 4: API route migration │ 5-7 days │
├──────────────────────────────────┼─────────────┤
│ Phase 5: Template-embeddings │ 1 day │
├──────────────────────────────────┼─────────────┤
│ Phase 6: SRU/NE-bilaga merge │ 1 day │
├──────────────────────────────────┼─────────────┤
│ Phase 7: Cross-extension cleanup │ 0.5 days │
├──────────────────────────────────┼─────────────┤
│ Phase 8: Documentation │ 1 day │
├──────────────────────────────────┼─────────────┤
│ Total │ ~10-12 days │
└──────────────────────────────────┴─────────────┘
Environment Variable Reference
Core (required):
- NEXT_PUBLIC_SUPABASE_URL — Supabase project URL
- NEXT_PUBLIC_SUPABASE_ANON_KEY — Supabase anonymous key
- SUPABASE_SERVICE_ROLE_KEY — Supabase service role key
- NEXT_PUBLIC_APP_URL — App base URL
- CRON_SECRET — Auth for core cron jobs (deadlines, tax deadlines, document verification)
Extension env vars:
┌────────────────────────┬───────────────────────────────────────────────────┬────────────────────────┐
│ Extension │ Required │ Optional │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ email │ RESEND_API_KEY, RESEND_FROM_EMAIL │ RESEND_WEBHOOK_SECRET │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ receipt-ocr │ ANTHROPIC_API_KEY │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ ai-categorization │ ANTHROPIC_API_KEY, OPENAI_API_KEY │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ ai-chat │ ANTHROPIC_API_KEY, OPENAI_API_KEY │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ invoice-inbox │ ANTHROPIC_API_KEY │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ enable-banking │ ENABLE_BANKING_APP_ID, ENABLE_BANKING_PRIVATE_KEY │ ENABLE_BANKING_SANDBOX │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ push-notifications │ VAPID_PRIVATE_KEY, NEXT_PUBLIC_VAPID_PUBLIC_KEY │ VAPID_SUBJECT │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ calendar │ (none) │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ All export extensions │ (none) │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ All sector extensions │ (none) │ │
└────────────────────────┴───────────────────────────────────────────────────┴────────────────────────┘
Key Architectural Decisions
1. Config is JSON — No TypeScript in config. Generator reads it without a compiler. CI validates trivially.
2. Core never imports from @/extensions/ — Enforced by CI. The only bridge is the generated _generated/ files.
3. Extensions communicate via events and services — Event bus for async reactions, services record for synchronous
capabilities.
4. Extension tables stay in shared DB — Empty when extension isn't enabled. RLS prevents access issues. No migration splitting
needed.
5. CRON_SECRET is core — 3 core cron jobs need it (deadlines, tax deadlines, document verification).
6. Email is an extension — Core works without email. Invoices can be created/downloaded but not sent.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["enable-banking","ai-categorization","ai-chat","email"]}
{"$schema":"./extensions.schema.json","extensions":["enable-banking","ai-chat","email"]}
@@ -126,7 +126,7 @@ const CATEGORY_DEFAULT_TEMPLATES: Record<string, string> = {
expense_consumables: 'office_supplies_general',
expense_vehicle: 'vehicle_fuel',
expense_telecom: 'telecom_mobile',
expense_marketing: 'marketing_online_ads',
expense_marketing: 'marketing_online_ads_eu',
expense_education: 'education_course',
expense_professional_services: 'prof_accounting',
}
@@ -100,37 +100,37 @@ function buildSystemPrompt(entityType: EntityType): string {
const privateAccount = entityType === 'aktiebolag' ? '2893' : '2013'
const entityLabel = entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'
return `Du ar expert pa svensk bokforing enligt BAS-kontoplanen. Analysera anvandarens beskrivning av en banktransaktion och returnera ett bokforingsforslag.
return `Du är expert på svensk bokföring enligt BAS-kontoplanen. Analysera användarens beskrivning av en banktransaktion och returnera ett bokföringsförslag.
VANLIGA BAS-KONTON:
Utgifter: 5010 Lokalhyra | 5410 Forbrukningsinventarier | 5420 Programvara | 5460 Forbrukningsvaror | 5611 Bil/drivmedel | 5800 Resekostnader | 5910 Annonsering | 6071 Representation mat | 6200 Telefon/internet | 6530 Redovisning/konsult | 6570 Bankavgifter | 6991 Ovriga kostnader | ${entityType === 'aktiebolag' ? '7610' : '6991'} Utbildning
Intakter: 3001 Forsaljning 25% | 3002 Forsaljning 12% | 3003 Forsaljning 6% | 3305 Export | 3308 EU-tjanster | 3900 Ovriga intakter
Moms: 2611 Utg moms 25% | 2621 Utg moms 12% | 2631 Utg moms 6% | 2641 Ing moms | 2645 Beraknad ing moms
Skulder: 2350 Skulder till kreditinstitut (banklan, Almi) | 2440 Leverantorsskulder (ENBART for leverantorsfakturor)
Ovrigt: 1510 Kundfordringar | 1930 Foretagskonto | 8410 Rantekostnader | ${privateAccount} Privat
Utgifter: 5010 Lokalhyra | 5410 Förbrukningsinventarier | 5420 Programvara | 5460 Förbrukningsvaror | 5611 Bil/drivmedel | 5800 Resekostnader | 5910 Annonsering | 6071 Representation mat | 6200 Telefon/internet | 6530 Redovisning/konsult | 6570 Bankavgifter | 6991 Övriga kostnader | ${entityType === 'aktiebolag' ? '7610' : '6991'} Utbildning
Intäkter: 3001 Försäljning 25% | 3002 Försäljning 12% | 3003 Försäljning 6% | 3305 Export | 3308 EU-tjänster | 3900 Övriga intäkter
Moms: 2611 Utg moms 25% | 2621 Utg moms 12% | 2631 Utg moms 6% | 2641 Ing moms | 2645 Beräknad ing moms
Skulder: 2350 Skulder till kreditinstitut (banklån, Almi) | 2440 Leverantörsskulder (ENBART för leverantörsfakturor)
Övrigt: 1510 Kundfordringar | 1930 Företagskonto | 8410 Räntekostnader | ${privateAccount} Privat
MOMSREGLER:
- standard_25: Normala varor/tjanster (25%)
- standard_25: Normala varor/tjänster (25%)
- reduced_12: Livsmedel, hotell, konstverk (12%)
- reduced_6: Bocker, tidningar, kollektivtrafik, kultur (6%)
- reverse_charge: Tjanstekop fran utlandet/EU
- export: Forsaljning utanfor Sverige
- exempt: Momsfritt (bank, forsakring, sjukvard, utbildning)
- reduced_6: Böcker, tidningar, kollektivtrafik, kultur (6%)
- reverse_charge: Tjänsteköp från utlandet/EU
- export: Försäljning utanför Sverige
- exempt: Momsfritt (bank, försäkring, sjukvård, utbildning)
VARNINGSREGLER:
- Representation/maltider: Max 300 kr/person exkl moms for avdragsratt (IL 16 kap 2§)
- Gavor: Reklamgavor max 300 kr, representationsgavor max 180 kr
- Blandad anvandning (telefon/dator): Bara yrkesmassig del avdragsgill
- Bankavgifter, kortavgifter, valutavaxling: MOMSFRIA (exempt)
- Representation/måltider: Max 300 kr/person exkl moms för avdragsrätt (IL 16 kap 2§)
- Gåvor: Reklamgåvor max 300 kr, representationsgåvor max 180 kr
- Blandad användning (telefon/dator): Bara yrkesmässig del avdragsgill
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA (exempt)
Foretagsform: ${entityLabel}
Företagsform: ${entityLabel}
Privatkonto: ${privateAccount}
REGLER:
1. Negativt belopp = utgift: debitera kostnadskonto, kreditera 1930
2. Positivt belopp = intakt: debitera 1930, kreditera intaktskonto
3. Ge ett klart reasoning pa svenska som forklarar valet
4. Lagg till warnings for avdragsbegransningar eller speciella regler
2. Positivt belopp = intäkt: debitera 1930, kreditera intäktskonto
3. Ge ett klart reasoning på svenska som förklarar valet
4. Lägg till warnings för avdragsbegränsningar eller speciella regler
5. templateId: null (vi matchar mallar separat)`
}
@@ -145,12 +145,12 @@ export async function analyzeDescription(
const isExpense = input.transactionAmount < 0
const userPrompt = `Transaktion:
- Anvandarens beskrivning: "${input.description}"
- Användarens beskrivning: "${input.description}"
- Banktext: "${input.transactionDescription}"
- Belopp: ${input.transactionAmount} ${input.currency}
- Datum: ${input.transactionDate}${input.merchantName ? `\n- Handlare: ${input.merchantName}` : ''}
Analysera och returnera bokforingsforslag med analyze_description-verktyget.`
Analysera och returnera bokföringsförslag med analyze_description-verktyget.`
let lastError: Error | null = null
@@ -248,7 +248,7 @@ function validateResult(
// Reasoning — must be a non-empty string
const reasoning = typeof raw.reasoning === 'string' && raw.reasoning.length > 0
? raw.reasoning
: (isExpense ? 'Utgift bokford pa standardkonto' : 'Intakt bokford pa standardkonto')
: (isExpense ? 'Utgift bokförd på standardkonto' : 'Intäkt bokförd på standardkonto')
// Warnings
const warnings = Array.isArray(raw.warnings)
@@ -17,6 +17,8 @@ import * as path from 'path'
import * as crypto from 'crypto'
// Configuration
// NOTE: The ai_knowledge_base directory must be created and populated before running ingestion.
// Create dev_docs/ai_knowledge_base/ and add markdown files to ingest.
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'ai_knowledge_base')
const CHUNK_SIZE = 1000
const CHUNK_OVERLAP = 200
@@ -255,6 +257,12 @@ async function ingest() {
console.log('Starting knowledge base ingestion...')
console.log(`Reading files from: ${DOCS_DIR}`)
if (!fs.existsSync(DOCS_DIR)) {
console.error(`Error: Knowledge base directory not found: ${DOCS_DIR}`)
console.error('Create dev_docs/ai_knowledge_base/ and add markdown files before running ingestion.')
process.exit(1)
}
// Get all markdown files
const files = fs
.readdirSync(DOCS_DIR)
@@ -37,8 +37,8 @@ export class ResendEmailService implements EmailService {
}
const from = fromName
? `${fromName} via ERP Base <${DEFAULT_FROM_EMAIL}>`
: `ERP Base <${DEFAULT_FROM_EMAIL}>`
? `${fromName} via Gnubok <${DEFAULT_FROM_EMAIL}>`
: `Gnubok <${DEFAULT_FROM_EMAIL}>`
try {
const resend = getResendClient()
@@ -53,8 +53,8 @@ export class ResendEmailService implements EmailService {
filename: att.filename,
content: typeof att.content === 'string'
? Buffer.from(att.content, 'base64')
: att.content,
content_type: att.contentType,
: Buffer.from(att.content),
contentType: att.contentType,
})),
})
@@ -15,6 +15,11 @@ vi.mock('../lib/supplier-matcher', () => ({
matchSupplier: vi.fn(),
}))
// Mock api-routes to avoid transitive server-only import from document-analyzer
vi.mock('../api-routes', () => ({
invoiceInboxApiRoutes: [],
}))
import { createClient } from '@/lib/supabase/server'
import { invoiceInboxExtension, getSettings, saveSettings } from '../index'
@@ -121,7 +121,7 @@ export function createReceiptMatchedPayload(
export function createMissingUnderlagPayload(count: number): NotificationPayload {
return {
title: 'Saknade underlag',
body: `${count} verifikation(er) saknar underlag. Bifoga for att uppfylla bokforingslagen.`,
body: `${count} verifikation(er) saknar underlag. Bifoga för att uppfylla bokföringslagen.`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: 'missing-underlag-weekly',
+1
View File
@@ -102,6 +102,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'supplier_credit_note',
'currency_revaluation',
])
export const AccountTypeSchema = z.enum([
@@ -9,6 +9,9 @@ import {
searchTemplates,
findMatchingTemplates,
buildMappingResultFromTemplate,
getCommonTemplates,
getAdvancedTemplates,
validateTemplateForEntity,
type BookingTemplate,
} from '../booking-templates'
@@ -17,8 +20,8 @@ import {
// ============================================================
describe('BOOKING_TEMPLATES data integrity', () => {
it('has exactly 48 templates', () => {
expect(BOOKING_TEMPLATES).toHaveLength(48)
it('has exactly 51 templates', () => {
expect(BOOKING_TEMPLATES).toHaveLength(51)
})
it('all template IDs are unique', () => {
@@ -46,6 +49,7 @@ describe('BOOKING_TEMPLATES data integrity', () => {
expect(typeof t.default_private).toBe('boolean')
expect(t.fallback_category).toBeTruthy()
expect(t.description_sv).toBeTruthy()
expect(typeof t.common).toBe('boolean')
expect(Array.isArray(t.mcc_codes)).toBe(true)
expect(Array.isArray(t.keywords)).toBe(true)
expect(t.keywords.length).toBeGreaterThan(0)
@@ -136,7 +140,7 @@ describe('getTemplateGroups', () => {
it('every template is in exactly one group', () => {
const groups = getTemplateGroups()
const allTemplates = groups.flatMap((g) => g.templates)
expect(allTemplates).toHaveLength(48)
expect(allTemplates).toHaveLength(51)
})
})
@@ -175,8 +179,8 @@ describe('searchTemplates', () => {
})
it('supports multi-token search', () => {
const results = searchTemplates('annonsering marknadsföring')
expect(results.some((t) => t.id === 'marketing_online_ads')).toBe(true)
const results = searchTemplates('annonsering EU')
expect(results.some((t) => t.id === 'marketing_online_ads_eu')).toBe(true)
})
})
@@ -205,7 +209,7 @@ describe('findMatchingTemplates', () => {
merchant_name: 'Google',
})
const matches = findMatchingTemplates(tx)
expect(matches.some((m) => m.template.id === 'marketing_online_ads')).toBe(true)
expect(matches.some((m) => m.template.id === 'marketing_online_ads_eu')).toBe(true)
})
it('returns empty for a transaction with no signals', () => {
@@ -404,3 +408,141 @@ describe('buildMappingResultFromTemplate', () => {
expect(result.description).toBe('Drivmedel & Laddning: OKQ8 tankstation')
})
})
// ============================================================
// Template Curation Helpers
// ============================================================
describe('getCommonTemplates', () => {
it('returns only templates with common: true', () => {
const common = getCommonTemplates()
expect(common.length).toBeGreaterThan(0)
for (const t of common) {
expect(t.common).toBe(true)
}
})
it('filters by entity type', () => {
const efCommon = getCommonTemplates('enskild_firma')
for (const t of efCommon) {
expect(t.entity_applicability).not.toBe('aktiebolag')
}
})
it('filters by direction', () => {
const expenses = getCommonTemplates(undefined, 'expense')
for (const t of expenses) {
expect(t.direction).toBe('expense')
}
})
})
describe('getAdvancedTemplates', () => {
it('returns only templates with common: false', () => {
const advanced = getAdvancedTemplates()
expect(advanced.length).toBeGreaterThan(0)
for (const t of advanced) {
expect(t.common).toBe(false)
}
})
it('common + advanced = all templates (for a given entity/direction)', () => {
const common = getCommonTemplates()
const advanced = getAdvancedTemplates()
expect(common.length + advanced.length).toBe(BOOKING_TEMPLATES.length)
})
})
describe('validateTemplateForEntity', () => {
it('accepts template with entity_applicability "all"', () => {
const template = getTemplateById('premises_rent')!
const result = validateTemplateForEntity(template, 'aktiebolag')
expect(result.valid).toBe(true)
})
it('accepts EF template for EF entity', () => {
const template = getTemplateById('private_withdrawal_ef')!
const result = validateTemplateForEntity(template, 'enskild_firma')
expect(result.valid).toBe(true)
})
it('rejects EF template for AB entity', () => {
const template = getTemplateById('private_withdrawal_ef')!
const result = validateTemplateForEntity(template, 'aktiebolag')
expect(result.valid).toBe(false)
expect(result.error).toContain('enskild_firma')
})
it('rejects AB template for EF entity', () => {
const template = getTemplateById('personnel_salary')!
const result = validateTemplateForEntity(template, 'enskild_firma')
expect(result.valid).toBe(false)
expect(result.error).toContain('aktiebolag')
})
})
// ============================================================
// New/Split Templates
// ============================================================
describe('new and split templates', () => {
it('has marketing_online_ads_eu with reverse_charge', () => {
const t = getTemplateById('marketing_online_ads_eu')
expect(t).toBeDefined()
expect(t!.vat_treatment).toBe('reverse_charge')
expect(t!.common).toBe(true)
expect(t!.requires_vat_registration_data).toBe(true)
})
it('has marketing_online_ads_domestic with standard_25', () => {
const t = getTemplateById('marketing_online_ads_domestic')
expect(t).toBeDefined()
expect(t!.vat_treatment).toBe('standard_25')
expect(t!.common).toBe(false)
})
it('has representation_internal with account 7622', () => {
const t = getTemplateById('representation_internal')
expect(t).toBeDefined()
expect(t!.debit_account).toBe('7622')
expect(t!.vat_treatment).toBeNull()
expect(t!.common).toBe(true)
})
it('has shareholder_loan_received (AB, D:1930 K:2393)', () => {
const t = getTemplateById('shareholder_loan_received')
expect(t).toBeDefined()
expect(t!.debit_account).toBe('1930')
expect(t!.credit_account).toBe('2393')
expect(t!.entity_applicability).toBe('aktiebolag')
expect(t!.common).toBe(true)
})
it('has shareholder_loan_disbursed (AB, D:1680 K:1930)', () => {
const t = getTemplateById('shareholder_loan_disbursed')
expect(t).toBeDefined()
expect(t!.debit_account).toBe('1680')
expect(t!.credit_account).toBe('1930')
expect(t!.entity_applicability).toBe('aktiebolag')
expect(t!.common).toBe(false)
})
it('personnel_employer_tax uses debit account 2731 (liability clearing)', () => {
const t = getTemplateById('personnel_employer_tax')
expect(t).toBeDefined()
expect(t!.debit_account).toBe('2731')
})
it('personnel_salary has special_rules_sv warning', () => {
const t = getTemplateById('personnel_salary')
expect(t).toBeDefined()
expect(t!.special_rules_sv).toContain('nettolön')
expect(t!.requires_review).toBe(true)
})
it('representation_external has updated deductibility note with VAT cap', () => {
const t = getTemplateById('representation_external')
expect(t).toBeDefined()
expect(t!.deductibility_note_sv).toContain('46 kr/person')
})
})
@@ -0,0 +1,666 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { CreateJournalEntryInput, Currency } from '@/types'
import { makeInvoice, makeSupplierInvoice } from '@/tests/helpers'
// Mock riksbanken
vi.mock('@/lib/currency/riksbanken', () => ({
fetchMultipleRates: vi.fn(),
}))
// Mock engine
vi.mock('../engine', () => ({
createJournalEntry: vi.fn().mockImplementation(
async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
id: 'entry-1',
...input,
lines: input.lines,
status: 'posted',
voucher_number: 1,
voucher_series: 'A',
user_id: _userId,
committed_at: '2024-12-31T00:00:00Z',
reversed_by_id: null,
reverses_id: null,
correction_of_id: null,
attachment_urls: null,
created_at: '2024-12-31T00:00:00Z',
updated_at: '2024-12-31T00:00:00Z',
})
),
}))
// Mock supabase server
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
const { fetchMultipleRates } = await import('@/lib/currency/riksbanken')
const mockedFetchRates = vi.mocked(fetchMultipleRates)
const { createJournalEntry } = await import('../engine')
const mockedCreateEntry = vi.mocked(createJournalEntry)
const {
getOpenForeignCurrencyReceivables,
getOpenForeignCurrencyPayables,
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} = await import('../currency-revaluation')
// Helper to build mock supabase
function createMockSupabase(config: {
invoices?: ReturnType<typeof makeInvoice>[]
supplierInvoices?: ReturnType<typeof makeSupplierInvoice>[]
existingRevaluation?: boolean
}) {
const fromMap: Record<string, unknown[]> = {
invoices: config.invoices || [],
supplier_invoices: config.supplierInvoices || [],
}
const supabase = {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'journal_entries') {
// For idempotency check
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnThis(),
then: undefined,
count: undefined,
// Build chain that resolves with count
...((() => {
const chain: Record<string, unknown> = {}
chain.eq = vi.fn().mockReturnValue(chain)
chain.select = vi.fn().mockReturnValue(chain)
// Terminal — return count
Object.defineProperty(chain, 'then', {
value: (resolve: (val: unknown) => void) => {
resolve({
count: config.existingRevaluation ? 1 : 0,
error: null,
})
},
})
return chain
})()),
}),
}
}
const data = fromMap[table] || []
const chain = buildFilterChain(data)
return chain
}),
}
return supabase
}
function buildFilterChain(data: unknown[]) {
let filtered = [...data]
const chain: Record<string, unknown> = {}
chain.select = vi.fn().mockImplementation(() => {
return chain
})
chain.eq = vi.fn().mockImplementation((col: string, val: unknown) => {
filtered = filtered.filter((row: Record<string, unknown>) => row[col] === val)
return chain
})
chain.neq = vi.fn().mockImplementation((col: string, val: unknown) => {
filtered = filtered.filter((row: Record<string, unknown>) => row[col] !== val)
return chain
})
chain.in = vi.fn().mockImplementation((col: string, vals: unknown[]) => {
filtered = filtered.filter((row: Record<string, unknown>) => vals.includes(row[col]))
return chain
})
chain.not = vi.fn().mockImplementation((col: string, op: string, _val: unknown) => {
if (op === 'is') {
filtered = filtered.filter((row: Record<string, unknown>) => row[col] != null)
}
return chain
})
// Make it thenable for await
chain.then = (resolve: (val: unknown) => void) => {
resolve({ data: filtered, error: null })
}
return chain
}
// Better mock for supabase that supports journal_entries idempotency check
function createFullMockSupabase(config: {
invoices?: ReturnType<typeof makeInvoice>[]
supplierInvoices?: ReturnType<typeof makeSupplierInvoice>[]
existingRevaluation?: boolean
}) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase: any = {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'journal_entries') {
const countResult = {
count: config.existingRevaluation ? 1 : 0,
error: null,
}
const journalChain: Record<string, unknown> = {}
journalChain.select = vi.fn().mockReturnValue(journalChain)
journalChain.eq = vi.fn().mockReturnValue(journalChain)
journalChain.then = (resolve: (val: unknown) => void) => {
resolve(countResult)
}
return journalChain
}
const fromMap: Record<string, unknown[]> = {
invoices: config.invoices || [],
supplier_invoices: config.supplierInvoices || [],
}
return buildFilterChain(fromMap[table] || [])
}),
}
return supabase
}
describe('currency-revaluation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getOpenForeignCurrencyReceivables', () => {
it('returns non-SEK invoices with sent/overdue status', async () => {
const eurInvoice = makeInvoice({
status: 'sent',
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
})
const sekInvoice = makeInvoice({
status: 'sent',
currency: 'SEK',
total: 5000,
})
const supabase = createMockSupabase({ invoices: [eurInvoice, sekInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'user-1')
expect(result).toHaveLength(1)
expect(result[0].currency).toBe('EUR')
})
it('excludes paid invoices', async () => {
const paidEurInvoice = makeInvoice({
status: 'paid',
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
})
const supabase = createMockSupabase({ invoices: [paidEurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'user-1')
expect(result).toHaveLength(0)
})
it('excludes invoices without exchange_rate', async () => {
const noRateInvoice = makeInvoice({
status: 'sent',
currency: 'EUR',
exchange_rate: null,
total: 1000,
})
const supabase = createMockSupabase({ invoices: [noRateInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'user-1')
expect(result).toHaveLength(0)
})
})
describe('getOpenForeignCurrencyPayables', () => {
it('returns non-SEK supplier invoices with open status', async () => {
const eurSI = makeSupplierInvoice({
status: 'registered',
currency: 'EUR',
exchange_rate: 11.5,
remaining_amount: 5000,
})
const sekSI = makeSupplierInvoice({
status: 'registered',
currency: 'SEK',
remaining_amount: 3000,
})
const supabase = createMockSupabase({ supplierInvoices: [eurSI, sekSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyPayables(supabase as any, 'user-1')
expect(result).toHaveLength(1)
expect(result[0].currency).toBe('EUR')
})
it('includes partially_paid supplier invoices', async () => {
const partialSI = makeSupplierInvoice({
status: 'partially_paid',
currency: 'USD',
exchange_rate: 10.5,
remaining_amount: 2000,
})
const supabase = createMockSupabase({ supplierInvoices: [partialSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyPayables(supabase as any, 'user-1')
expect(result).toHaveLength(1)
expect(result[0].remaining_amount).toBe(2000)
})
it('excludes paid supplier invoices', async () => {
const paidSI = makeSupplierInvoice({
status: 'paid',
currency: 'EUR',
exchange_rate: 11.5,
})
const supabase = createMockSupabase({ supplierInvoices: [paidSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyPayables(supabase as any, 'user-1')
expect(result).toHaveLength(0)
})
})
describe('previewCurrencyRevaluation', () => {
function mockRates(rates: Record<Currency, number>) {
mockedFetchRates.mockResolvedValue(
new Map(
Object.entries(rates).map(([currency, rate]) => [
currency as Currency,
{ currency: currency as Currency, rate, date: '2024-12-31' },
])
)
)
}
it('returns empty preview when no foreign currency items', async () => {
const supabase = createMockSupabase({
invoices: [],
supplierInvoices: [],
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
expect(preview.items).toHaveLength(0)
expect(preview.lines).toHaveLength(0)
expect(preview.netEffect).toBe(0)
})
it('computes receivable gain (closing rate > original rate)', async () => {
const eurInvoice = makeInvoice({
id: 'inv-1',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-001',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
expect(preview.items).toHaveLength(1)
expect(preview.items[0].type).toBe('receivable')
expect(preview.items[0].difference_sek).toBe(500) // 1000 * (11.5 - 11.0)
// Should debit 1510 (receivable up), credit 3960 (gain)
const debit1510 = preview.lines.find(l => l.account_number === '1510' && l.debit_amount > 0)
const credit3960 = preview.lines.find(l => l.account_number === '3960' && l.credit_amount > 0)
expect(debit1510).toBeDefined()
expect(debit1510!.debit_amount).toBe(500)
expect(credit3960).toBeDefined()
expect(credit3960!.credit_amount).toBe(500)
expect(preview.totalGain).toBe(500)
expect(preview.totalLoss).toBe(0)
expect(preview.netEffect).toBe(500)
})
it('computes receivable loss (closing rate < original rate)', async () => {
const eurInvoice = makeInvoice({
id: 'inv-2',
status: 'overdue',
currency: 'EUR',
exchange_rate: 12.0,
total: 1000,
invoice_number: 'F-002',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
expect(preview.items[0].difference_sek).toBe(-500) // 1000 * (11.5 - 12.0)
// Should credit 1510 (receivable down), debit 7960 (loss)
const credit1510 = preview.lines.find(l => l.account_number === '1510' && l.credit_amount > 0)
const debit7960 = preview.lines.find(l => l.account_number === '7960' && l.debit_amount > 0)
expect(credit1510).toBeDefined()
expect(credit1510!.credit_amount).toBe(500)
expect(debit7960).toBeDefined()
expect(debit7960!.debit_amount).toBe(500)
expect(preview.totalLoss).toBe(500)
expect(preview.totalGain).toBe(0)
expect(preview.netEffect).toBe(-500)
})
it('computes payable loss (closing rate > original rate — liability grew)', async () => {
const eurSI = makeSupplierInvoice({
id: 'si-1',
status: 'registered',
currency: 'EUR',
exchange_rate: 11.0,
remaining_amount: 2000,
supplier_invoice_number: 'LF-001',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
const supabase = createMockSupabase({ supplierInvoices: [eurSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
expect(preview.items[0].type).toBe('payable')
expect(preview.items[0].difference_sek).toBe(1000) // 2000 * (11.5 - 11.0)
// Should debit 7960 (loss), credit 2440 (liability up)
const debit7960 = preview.lines.find(l => l.account_number === '7960' && l.debit_amount > 0)
const credit2440 = preview.lines.find(l => l.account_number === '2440' && l.credit_amount > 0)
expect(debit7960).toBeDefined()
expect(debit7960!.debit_amount).toBe(1000)
expect(credit2440).toBeDefined()
expect(credit2440!.credit_amount).toBe(1000)
})
it('computes payable gain (closing rate < original rate — liability shrank)', async () => {
const eurSI = makeSupplierInvoice({
id: 'si-2',
status: 'approved',
currency: 'EUR',
exchange_rate: 12.0,
remaining_amount: 2000,
supplier_invoice_number: 'LF-002',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
const supabase = createMockSupabase({ supplierInvoices: [eurSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
expect(preview.items[0].difference_sek).toBe(-1000) // 2000 * (11.5 - 12.0)
// Should debit 2440 (liability down), credit 3960 (gain)
const debit2440 = preview.lines.find(l => l.account_number === '2440' && l.debit_amount > 0)
const credit3960 = preview.lines.find(l => l.account_number === '3960' && l.credit_amount > 0)
expect(debit2440).toBeDefined()
expect(debit2440!.debit_amount).toBe(1000)
expect(credit3960).toBeDefined()
expect(credit3960!.credit_amount).toBe(1000)
})
it('handles mixed currencies correctly', async () => {
const eurInvoice = makeInvoice({
id: 'inv-eur',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-EUR',
})
const usdInvoice = makeInvoice({
id: 'inv-usd',
status: 'sent',
currency: 'USD',
exchange_rate: 10.0,
total: 500,
invoice_number: 'F-USD',
})
mockRates({ EUR: 11.5, USD: 10.5 } as Record<Currency, number>)
const supabase = createMockSupabase({ invoices: [eurInvoice, usdInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
expect(preview.items).toHaveLength(2)
// EUR: 1000 * (11.5 - 11.0) = 500
// USD: 500 * (10.5 - 10.0) = 250
expect(preview.totalGain).toBe(750)
})
it('aggregates journal lines correctly with mixed gains and losses', async () => {
const gainInvoice = makeInvoice({
id: 'inv-gain',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-GAIN',
})
const lossSI = makeSupplierInvoice({
id: 'si-loss',
status: 'registered',
currency: 'EUR',
exchange_rate: 11.0,
remaining_amount: 2000,
supplier_invoice_number: 'LF-LOSS',
})
// EUR went up to 11.5
mockRates({ EUR: 11.5 } as Record<Currency, number>)
const supabase = createMockSupabase({
invoices: [gainInvoice],
supplierInvoices: [lossSI],
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
// Receivable gain: 1000 * 0.5 = 500 → Debit 1510, Credit 3960
// Payable loss: 2000 * 0.5 = 1000 → Debit 7960, Credit 2440
expect(preview.totalGain).toBe(500)
expect(preview.totalLoss).toBe(1000)
expect(preview.netEffect).toBe(-500)
// Verify all entries balance
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(Math.round(totalDebit * 100) / 100).toBe(Math.round(totalCredit * 100) / 100)
})
it('uses remaining_amount for partially paid supplier invoices', async () => {
const partialSI = makeSupplierInvoice({
id: 'si-partial',
status: 'partially_paid',
currency: 'EUR',
exchange_rate: 11.0,
total: 10000,
remaining_amount: 5000, // Half paid
supplier_invoice_number: 'LF-PARTIAL',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
const supabase = createMockSupabase({ supplierInvoices: [partialSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
// Only remaining 5000 EUR is revalued, not full 10000
expect(preview.items[0].amount_in_currency).toBe(5000)
expect(preview.items[0].difference_sek).toBe(2500) // 5000 * (11.5 - 11.0)
})
it('skips items with zero difference', async () => {
const eurInvoice = makeInvoice({
id: 'inv-same',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
invoice_number: 'F-SAME',
})
// Closing rate equals original rate
mockRates({ EUR: 11.5 } as Record<Currency, number>)
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
expect(preview.items).toHaveLength(0)
expect(preview.lines).toHaveLength(0)
})
it('all generated journal lines balance (debits === credits)', async () => {
const eurInvoice = makeInvoice({
id: 'inv-bal',
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1234.56,
invoice_number: 'F-BAL',
})
const gbpSI = makeSupplierInvoice({
id: 'si-bal',
status: 'overdue',
currency: 'GBP',
exchange_rate: 14.0,
remaining_amount: 789.12,
supplier_invoice_number: 'LF-BAL',
})
mockRates({ EUR: 11.8, GBP: 13.5 } as Record<Currency, number>)
const supabase = createMockSupabase({
invoices: [eurInvoice],
supplierInvoices: [gbpSI],
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
})
})
describe('executeCurrencyRevaluation', () => {
function mockRates(rates: Record<Currency, number>) {
mockedFetchRates.mockResolvedValue(
new Map(
Object.entries(rates).map(([currency, rate]) => [
currency as Currency,
{ currency: currency as Currency, rate, date: '2024-12-31' },
])
)
)
}
it('returns null when no foreign currency items exist', async () => {
const supabase = createFullMockSupabase({
invoices: [],
supplierInvoices: [],
existingRevaluation: false,
})
mockRates({} as Record<Currency, number>)
const result = await executeCurrencyRevaluation(supabase, 'user-1', '2024-12-31', 'period-1')
expect(result).toBeNull()
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('creates journal entry with correct source_type', async () => {
const eurInvoice = makeInvoice({
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-001',
})
mockRates({ EUR: 11.5 } as Record<Currency, number>)
const supabase = createFullMockSupabase({
invoices: [eurInvoice],
existingRevaluation: false,
})
const result = await executeCurrencyRevaluation(supabase, 'user-1', '2024-12-31', 'period-1')
expect(result).not.toBeNull()
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const callArgs = mockedCreateEntry.mock.calls[0]
expect(callArgs[2].source_type).toBe('currency_revaluation')
expect(callArgs[2].fiscal_period_id).toBe('period-1')
expect(callArgs[2].entry_date).toBe('2024-12-31')
expect(callArgs[2].description).toContain('Omvärdering utländsk valuta')
})
it('throws when revaluation already exists for period (idempotency)', async () => {
const supabase = createFullMockSupabase({
existingRevaluation: true,
})
await expect(
executeCurrencyRevaluation(supabase, 'user-1', '2024-12-31', 'period-1')
).rejects.toThrow('Currency revaluation already exists for this period')
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('returns entry and preview in result', async () => {
const eurInvoice = makeInvoice({
status: 'sent',
currency: 'EUR',
exchange_rate: 11.0,
total: 1000,
invoice_number: 'F-001',
})
mockRates({ EUR: 12.0 } as Record<Currency, number>)
const supabase = createFullMockSupabase({
invoices: [eurInvoice],
existingRevaluation: false,
})
const result = await executeCurrencyRevaluation(supabase, 'user-1', '2024-12-31', 'period-1')
expect(result).not.toBeNull()
expect(result!.entry).toBeDefined()
expect(result!.preview).toBeDefined()
expect(result!.preview.items).toHaveLength(1)
expect(result!.preview.totalGain).toBe(1000) // 1000 * (12 - 11)
})
})
})
+26 -26
View File
@@ -8,7 +8,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '10',
account_type: 'asset',
normal_balance: 'debit',
description: 'Aktiverade utgifter for utvecklingsarbete, t.ex. mjukvaruutveckling eller produktutveckling.',
description: 'Aktiverade utgifter för utvecklingsarbete, t.ex. mjukvaruutveckling eller produktutveckling.',
sru_code: '7201',
k2_excluded: true,
},
@@ -96,7 +96,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '10',
account_type: 'asset',
normal_balance: 'debit',
description: 'Aktiverade kostnader for patent och liknande rattigheter.',
description: 'Aktiverade kostnader för patent och liknande rättigheter.',
sru_code: '7201',
k2_excluded: false,
},
@@ -162,7 +162,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '10',
account_type: 'asset',
normal_balance: 'debit',
description: 'Goodwill som uppkommer vid forvrav av rorelse eller inkramsforvrav.',
description: 'Goodwill som uppkommer vid forvrav av rörelse eller inkråmsförvärv.',
sru_code: '7201',
k2_excluded: false,
},
@@ -338,7 +338,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '11',
account_type: 'asset',
normal_balance: 'debit',
description: 'Anskaffningsvarde for byggnader som ags av foretaget.',
description: 'Anskaffningsvärde för byggnader som ägs av företaget.',
sru_code: '7202',
k2_excluded: false,
},
@@ -382,7 +382,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '11',
account_type: 'asset',
normal_balance: 'credit',
description: 'Ackumulerad vardeminskning pa byggnader sedan anskaffningstidpunkten.',
description: 'Ackumulerad värdeminskning på byggnader sedan anskaffningstidpunkten.',
sru_code: '7202',
k2_excluded: false,
},
@@ -415,7 +415,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '11',
account_type: 'asset',
normal_balance: 'debit',
description: 'Anskaffningsvarde for mark som ags av foretaget. Mark skrivs inte av.',
description: 'Anskaffningsvärde för mark som ägs av företaget. Mark skrivs inte av.',
sru_code: '7202',
k2_excluded: false,
},
@@ -437,7 +437,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '11',
account_type: 'asset',
normal_balance: 'debit',
description: 'Aktiverade utgifter for markanlaggningar som parkering, dranering och brunnar.',
description: 'Aktiverade utgifter för markanläggningar som parkering, dränering och brunnar.',
sru_code: '7202',
k2_excluded: false,
},
@@ -668,7 +668,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '12',
account_type: 'asset',
normal_balance: 'credit',
description: 'Ackumulerad vardeminskning pa inventarier och verktyg sedan anskaffning.',
description: 'Ackumulerad värdeminskning på inventarier och verktyg sedan anskaffning.',
sru_code: '7202',
k2_excluded: false,
},
@@ -756,7 +756,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '12',
account_type: 'asset',
normal_balance: 'debit',
description: 'Materiella anlaggningstillgangar som inte passar i ovriga underkategorier.',
description: 'Materiella anläggningstillgångar som inte passar i övriga underkategorier.',
sru_code: '7202',
k2_excluded: false,
},
@@ -811,7 +811,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '13',
account_type: 'asset',
normal_balance: 'debit',
description: 'Aktier och andelar i dotterbolag och koncernforetag.',
description: 'Aktier och andelar i dotterbolag och koncernföretag.',
sru_code: '7203',
k2_excluded: false,
},
@@ -1229,7 +1229,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '13',
account_type: 'asset',
normal_balance: 'debit',
description: 'Langfristiga fordringar som inte faller under andra kategorier, t.ex. deposition.',
description: 'Långfristiga fordringar som inte faller under andra kategorier, t.ex. deposition.',
sru_code: '7203',
k2_excluded: false,
},
@@ -1328,7 +1328,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '14',
account_type: 'asset',
normal_balance: 'debit',
description: 'Ravaror och material som anvands i produktion men inte ar fardiga produkter.',
description: 'Ravaror och material som används i produktion men inte är färdiga produkter.',
sru_code: '7210',
k2_excluded: false,
},
@@ -1471,7 +1471,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '14',
account_type: 'asset',
normal_balance: 'debit',
description: 'Halvfabrikat och arbeten under tillverkning som annu inte slutforts.',
description: 'Halvfabrikat och arbeten under tillverkning som ännu inte slutförts.',
sru_code: '7210',
k2_excluded: false,
},
@@ -1592,7 +1592,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '15',
account_type: 'asset',
normal_balance: 'debit',
description: 'Pengar som kunder ar skyldiga foretaget for skickade fakturor som inte betalats annu.',
description: 'Pengar som kunder är skyldiga företaget för skickade fakturor som inte betalats ännu.',
sru_code: '7211',
k2_excluded: false,
},
@@ -1658,7 +1658,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '15',
account_type: 'asset',
normal_balance: 'credit',
description: 'Vardering av befarade kundforluster, minskar kundfordringsbalansen.',
description: 'Värdering av befarade kundförluster, minskar kundfordringsbalansen.',
sru_code: '7211',
k2_excluded: false,
},
@@ -1878,7 +1878,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '16',
account_type: 'asset',
normal_balance: 'debit',
description: 'Utlagg eller forskott till anstallda som ska aterbetalas.',
description: 'Utlägg eller förskott till anställda som ska återbetalas.',
sru_code: '7212',
k2_excluded: false,
},
@@ -1955,7 +1955,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '16',
account_type: 'asset',
normal_balance: 'debit',
description: 'Foretagets skattekonto hos Skatteverket. Visar saldo for inbetalda skatter och avgifter.',
description: 'Företagets skattekonto hos Skatteverket. Visar saldo för inbetalda skatter och avgifter.',
sru_code: '7212',
k2_excluded: false,
},
@@ -1977,7 +1977,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '16',
account_type: 'asset',
normal_balance: 'debit',
description: 'Fordran pa Skatteverket nar ingaende moms overstiger utgaende moms.',
description: 'Fordran på Skatteverket nar ingående moms överstiger utgående moms.',
sru_code: '7212',
k2_excluded: false,
},
@@ -2197,7 +2197,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '17',
account_type: 'asset',
normal_balance: 'debit',
description: 'Hyra som betalats i forskott men avser kommande perioder.',
description: 'Hyra som betalats i förskott men avser kommande perioder.',
sru_code: '7212',
k2_excluded: false,
},
@@ -2208,7 +2208,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '17',
account_type: 'asset',
normal_balance: 'debit',
description: 'Leasingavgifter betalade i forskott som avser framtida perioder.',
description: 'Leasingavgifter betalade i förskott som avser framtida perioder.',
sru_code: '7212',
k2_excluded: false,
},
@@ -2285,7 +2285,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '17',
account_type: 'asset',
normal_balance: 'debit',
description: 'Forutbetalda kostnader och upplupna intakter som inte ryms i andra underkonton.',
description: 'Förutbetalda kostnader och upplupna intäkter som inte ryms i andra underkonton.',
sru_code: '7212',
k2_excluded: false,
},
@@ -2296,7 +2296,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '18',
account_type: 'asset',
normal_balance: 'debit',
description: 'Kortfristiga aktieinnehav i borsnoterade foretag avsedda att saljas inom 12 manader.',
description: 'Kortfristiga aktieinnehav i börsnoterade företag avsedda att säljas inom 12 månader.',
sru_code: '7212',
k2_excluded: false,
},
@@ -2384,7 +2384,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '19',
account_type: 'asset',
normal_balance: 'debit',
description: 'Kontanta pengar i foretagets kassa.',
description: 'Kontanta pengar i företagets kassa.',
sru_code: '7212',
k2_excluded: false,
},
@@ -2428,7 +2428,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '19',
account_type: 'asset',
normal_balance: 'debit',
description: 'Pengar pa foretagets PlusGiro-konto.',
description: 'Pengar på företagets PlusGiro-konto.',
sru_code: '7212',
k2_excluded: false,
},
@@ -2439,7 +2439,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '19',
account_type: 'asset',
normal_balance: 'debit',
description: 'Foretagets huvudsakliga bankkonto for dagliga in- och utbetalningar.',
description: 'Företagets huvudsakliga bankkonto för dagliga in- och utbetalningar.',
sru_code: '7212',
k2_excluded: false,
},
@@ -2450,7 +2450,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '19',
account_type: 'asset',
normal_balance: 'debit',
description: 'Ytterligare bankkonton utover huvudkontot, t.ex. sparkonto.',
description: 'Ytterligare bankkonton utöver huvudkontot, t.ex. sparkonto.',
sru_code: '7212',
k2_excluded: false,
},
@@ -8,7 +8,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Agarens insatta kapital i enskild firma. Visar vad agaren har investerat.',
description: 'Ägarens insatta kapital i enskild firma. Visar vad ägaren har investerat.',
sru_code: '7221',
k2_excluded: false,
},
@@ -30,7 +30,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'debit',
description: 'Pengar som agaren av en enskild firma tar ut privat ur foretaget.',
description: 'Pengar som ägaren av en enskild firma tar ut privat ur företaget.',
sru_code: '7221',
k2_excluded: false,
},
@@ -41,7 +41,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Tillskott fran agaren under lopande rakenskapsar i enskild firma.',
description: 'Tillskott från ägaren under löpande räkenskapsår i enskild firma.',
sru_code: '7221',
k2_excluded: false,
},
@@ -52,7 +52,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Pengar som agaren satter in privat i foretaget (enskild firma).',
description: 'Pengar som ägaren sätter in privat i företaget (enskild firma).',
sru_code: '7221',
k2_excluded: false,
},
@@ -63,7 +63,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Arets vinst eller forlust i enskild firma.',
description: 'Årets vinst eller förlust i enskild firma.',
sru_code: '7221',
k2_excluded: false,
},
@@ -459,7 +459,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Fond for uppskrivning av anlaggningstillgangar i aktiebolag.',
description: 'Fond för uppskrivning av anläggningstillgångar i aktiebolag.',
sru_code: '7221',
k2_excluded: false,
},
@@ -525,7 +525,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'debit',
description: 'Ackumulerade vinster eller forluster fran tidigare ar som inte delats ut.',
description: 'Ackumulerade vinster eller förluster från tidigare är som inte delats ut.',
sru_code: '7221',
k2_excluded: false,
},
@@ -547,7 +547,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Tillskott fran aktieagare som inte ar lan, okar fritt eget kapital.',
description: 'Tillskott från aktieägare som inte är lån, ökar fritt eget kapital.',
sru_code: '7221',
k2_excluded: false,
},
@@ -613,7 +613,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Vinst eller forlust for innevarande rakenskapsar (aktiebolag).',
description: 'Vinst eller förlust för innevarande räkenskapsår (aktiebolag).',
sru_code: '7222',
k2_excluded: false,
},
@@ -767,7 +767,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '21',
account_type: 'untaxed_reserves',
normal_balance: 'credit',
description: 'Skattemassiga overavskrivningar pa inventarier utover plan (periodiseringsfond).',
description: 'Skattemässiga överavskrivningar på inventarier utöver plan (periodiseringsfond).',
sru_code: '7230',
k2_excluded: false,
},
@@ -976,7 +976,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '23',
account_type: 'liability',
normal_balance: 'credit',
description: 'Langfristiga lan fran banker och kreditinstitut.',
description: 'Långfristiga lån från banker och kreditinstitut.',
sru_code: '7230',
k2_excluded: false,
},
@@ -1064,7 +1064,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '23',
account_type: 'liability',
normal_balance: 'credit',
description: 'Langfristiga skulder utover banklan, t.ex. lan fran privatpersoner.',
description: 'Långfristiga skulder utöver banklån, t.ex. lån från privatpersoner.',
sru_code: '7230',
k2_excluded: false,
},
@@ -1295,7 +1295,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '24',
account_type: 'liability',
normal_balance: 'credit',
description: 'Banklan och checkkrediter med aterbetalningstid under 12 manader.',
description: 'Banklån och checkkrediter med återbetalningstid under 12 månader.',
sru_code: '7230',
k2_excluded: false,
},
@@ -1427,7 +1427,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '24',
account_type: 'liability',
normal_balance: 'credit',
description: 'Pengar som foretaget ar skyldigt leverantorer for mottagna fakturor.',
description: 'Pengar som företaget är skyldigt leverantörer för mottagna fakturor.',
sru_code: '7230',
k2_excluded: false,
},
@@ -1636,7 +1636,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '25',
account_type: 'liability',
normal_balance: 'credit',
description: 'Skulder till Skatteverket for preliminar skatt och andra skattebetalningar.',
description: 'Skulder till Skatteverket för preliminär skatt och andra skattebetalningar.',
sru_code: '7231',
k2_excluded: false,
},
@@ -1724,7 +1724,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '26',
account_type: 'liability',
normal_balance: 'credit',
description: 'Moms du tar ut pa forsaljning med 25% momssats. Ska betalas in till Skatteverket.',
description: 'Moms du tar ut på försäljning med 25% momssats. Ska betalas in till Skatteverket.',
sru_code: '7231',
k2_excluded: false,
},
@@ -1757,7 +1757,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '26',
account_type: 'liability',
normal_balance: 'credit',
description: 'Utgaende moms vid omvand skattskyldighet (reverse charge) med 25% momssats.',
description: 'Utgående moms vid omvänd skattskyldighet (reverse charge) med 25% momssats.',
sru_code: '7231',
k2_excluded: false,
},
@@ -1812,7 +1812,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '26',
account_type: 'liability',
normal_balance: 'credit',
description: 'Moms pa forsaljning med 12% momssats, t.ex. livsmedel och hotell.',
description: 'Moms på försäljning med 12% momssats, t.ex. livsmedel och hotell.',
sru_code: '7231',
k2_excluded: false,
},
@@ -1845,7 +1845,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '26',
account_type: 'liability',
normal_balance: 'credit',
description: 'Utgaende moms vid omvand skattskyldighet (reverse charge) med 12% momssats.',
description: 'Utgående moms vid omvänd skattskyldighet (reverse charge) med 12% momssats.',
sru_code: '7231',
k2_excluded: false,
},
@@ -1900,7 +1900,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '26',
account_type: 'liability',
normal_balance: 'credit',
description: 'Moms pa forsaljning med 6% momssats, t.ex. bocker och tidningar.',
description: 'Moms på försäljning med 6% momssats, t.ex. böcker och tidningar.',
sru_code: '7231',
k2_excluded: false,
},
@@ -1933,7 +1933,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '26',
account_type: 'liability',
normal_balance: 'credit',
description: 'Utgaende moms vid omvand skattskyldighet (reverse charge) med 6% momssats.',
description: 'Utgående moms vid omvänd skattskyldighet (reverse charge) med 6% momssats.',
sru_code: '7231',
k2_excluded: false,
},
@@ -1988,7 +1988,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '26',
account_type: 'liability',
normal_balance: 'credit',
description: 'Moms pa inkop som foretaget har ratt att dra av. Minskar momsskulden.',
description: 'Moms på inköp som företaget har ratt att dra av. Minskar momsskulden.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2010,7 +2010,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '26',
account_type: 'liability',
normal_balance: 'credit',
description: 'Ingaende moms som beraknas sjalv vid inkop fran andra EU-lander (omvand skattskyldighet).',
description: 'Ingående moms som beräknas själv vid inköp från andra EU-länder (omvänd skattskyldighet).',
sru_code: '7231',
k2_excluded: false,
},
@@ -2098,7 +2098,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '27',
account_type: 'liability',
normal_balance: 'credit',
description: 'Innehallen preliminarskatt pa anstallda loner som ska betalas till Skatteverket.',
description: 'Innehållen preliminärskatt på anställda löner som ska betalas till Skatteverket.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2120,7 +2120,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '27',
account_type: 'liability',
normal_balance: 'credit',
description: 'Arbetsgivaravgifter redovisade men annu inte inbetalda till Skatteverket.',
description: 'Arbetsgivaravgifter redovisade men ännu inte inbetalda till Skatteverket.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2307,7 +2307,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '28',
account_type: 'liability',
normal_balance: 'credit',
description: 'Skulder till anstallda for t.ex. reseforskott eller utlagg.',
description: 'Skulder till anställda för t.ex. reseförskott eller utlägg.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2560,7 +2560,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '28',
account_type: 'liability',
normal_balance: 'credit',
description: 'Pengar som aktiebolaget lanat av sina agare. Vanligt i mindre AB.',
description: 'Pengar som aktiebolaget lånat av sina ägare. Vanligt i mindre AB.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2593,7 +2593,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '28',
account_type: 'liability',
normal_balance: 'credit',
description: 'Beslutad men annu ej utbetald aktieutdelning.',
description: 'Beslutad men ännu ej utbetald aktieutdelning.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2615,7 +2615,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '29',
account_type: 'liability',
normal_balance: 'credit',
description: 'Loner som intjanats men annu inte utbetalats vid periodens slut.',
description: 'Löner som intjänats men ännu inte utbetalats vid periodens slut.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2659,7 +2659,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '29',
account_type: 'liability',
normal_balance: 'credit',
description: 'Skuld for intjanade men inte uttagna semesterdagar.',
description: 'Skuld för intjänade men inte uttagna semesterdagar.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2692,7 +2692,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '29',
account_type: 'liability',
normal_balance: 'credit',
description: 'Arbetsgivaravgifter som hanfor sig till redovisade loner men annu inte betalats.',
description: 'Arbetsgivaravgifter som hänför sig till redovisade löner men ännu inte betalats.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2780,7 +2780,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '29',
account_type: 'liability',
normal_balance: 'credit',
description: 'Rantekostnader som upplupit men inte fakturerats eller betalats annu.',
description: 'Räntekostnader som upplupit men inte fakturerats eller betalats ännu.',
sru_code: '7231',
k2_excluded: false,
},
@@ -2846,7 +2846,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '29',
account_type: 'liability',
normal_balance: 'credit',
description: 'Upplupna kostnader och forutbetalda intakter som inte ryms i andra underkonton.',
description: 'Upplupna kostnader och förutbetalda intäkter som inte ryms i andra underkonton.',
sru_code: '7231',
k2_excluded: false,
},
+17 -17
View File
@@ -19,7 +19,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '30',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran forsaljning med 25% moms - den vanligaste intaktsraden for svenska foretag.',
description: 'Intäkter från försäljning med 25% moms - den vanligaste intäktsraden för svenska företag.',
sru_code: '7310',
k2_excluded: false,
},
@@ -30,7 +30,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '30',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran forsaljning med 12% moms, t.ex. livsmedel och restaurang.',
description: 'Intäkter från försäljning med 12% moms, t.ex. livsmedel och restaurang.',
sru_code: '7310',
k2_excluded: false,
},
@@ -41,7 +41,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '30',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran forsaljning med 6% moms, t.ex. bocker, tidningar och kollektivtrafik.',
description: 'Intäkter från försäljning med 6% moms, t.ex. böcker, tidningar och kollektivtrafik.',
sru_code: '7310',
k2_excluded: false,
},
@@ -52,7 +52,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '30',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran forsaljning som ar undantagen fran moms, t.ex. sjukvard och utbildning.',
description: 'Intäkter från försäljning som är undantagen från moms, t.ex. sjukvård och utbildning.',
sru_code: '7310',
k2_excluded: false,
},
@@ -63,7 +63,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '31',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Forsaljning av varor utanfor Sverige, gruppkonto.',
description: 'Försäljning av varor utanfor Sverige, gruppkonto.',
sru_code: '7311',
k2_excluded: false,
},
@@ -74,7 +74,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '31',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran forsaljning av varor till kunder utanfor EU. Momsfritt.',
description: 'Intäkter från försäljning av varor till kunder utanfor EU. Momsfritt.',
sru_code: '7310',
k2_excluded: false,
},
@@ -96,7 +96,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '31',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran forsaljning av varor till momsregistrerade foretag i andra EU-lander.',
description: 'Intäkter från försäljning av varor till momsregistrerade företag i andra EU-länder.',
sru_code: '7310',
k2_excluded: false,
},
@@ -162,7 +162,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '33',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran forsaljning av tjanster till kunder utanfor EU. Momsfritt.',
description: 'Intäkter från försäljning av tjänster till kunder utanfor EU. Momsfritt.',
sru_code: '7310',
k2_excluded: false,
},
@@ -173,7 +173,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '33',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran forsaljning av tjanster till foretag i andra EU-lander. Omvand skattskyldighet.',
description: 'Intäkter från försäljning av tjänster till företag i andra EU-länder. Omvänd skattskyldighet.',
sru_code: '7310',
k2_excluded: false,
},
@@ -250,7 +250,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '35',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Kostnader for emballage som vidarefaktureras till kunder.',
description: 'Kostnader för emballage som vidarefaktureras till kunder.',
sru_code: '7310',
k2_excluded: false,
},
@@ -294,7 +294,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '35',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Fraktkostnader som vidarefaktureras till kunder i andra EU-lander.',
description: 'Fraktkostnader som vidarefaktureras till kunder i andra EU-länder.',
sru_code: '7310',
k2_excluded: false,
},
@@ -646,7 +646,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '37',
account_type: 'revenue',
normal_balance: 'debit',
description: 'Oreskillnad som uppstar vid avrundning av betalningar (oret).',
description: 'Oreskillnad som uppstår vid avrundning av betalningar (oret).',
sru_code: '7310',
k2_excluded: false,
},
@@ -745,7 +745,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '39',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Andra intakter som inte hor till karnverksamheten, t.ex. uthyrning av lokal.',
description: 'Andra intäkter som inte hör till kärnverksamheten, t.ex. uthyrning av lokal.',
sru_code: '7311',
k2_excluded: false,
},
@@ -756,7 +756,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '39',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Intakter fran uthyrning av lokaler, mark eller annan egendom.',
description: 'Intäkter från uthyrning av lokaler, mark eller annan egendom.',
sru_code: '7310',
k2_excluded: false,
},
@@ -877,7 +877,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '39',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Vinster som uppstar vid valutavaxling eller betalningar i utlandsk valuta.',
description: 'Vinster som uppstår vid valutaväxling eller betalningar i utländsk valuta.',
sru_code: '7310',
k2_excluded: false,
},
@@ -888,7 +888,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '39',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Vinst vid forsaljning av anlaggningstillgangar, t.ex. maskiner eller inventarier.',
description: 'Vinst vid försäljning av anläggningstillgångar, t.ex. maskiner eller inventarier.',
sru_code: '7311',
k2_excluded: false,
},
@@ -998,7 +998,7 @@ export const CLASS_3_ACCOUNTS: BASReferenceAccount[] = [
account_group: '39',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Diverse andra rorelseintakter som inte passar i ovriga kategorier.',
description: 'Diverse andra rörelseintäkter som inte passar i övriga kategorier.',
sru_code: '7310',
k2_excluded: false,
},
@@ -19,7 +19,7 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for inkop av varor avsedda for vidareforssaljning.',
description: 'Kostnader för inköp av varor avsedda för vidareförsäljning.',
sru_code: '7320',
k2_excluded: false,
},
@@ -382,7 +382,7 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Varuinkop fran utlandet (ravaror och fornodenheter).',
description: 'Varuinköp från utlandet (råvaror och förnödenheter).',
sru_code: '7320',
k2_excluded: false,
},
@@ -580,7 +580,7 @@ export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
account_group: '46',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for arbete utfort av underleverantorer som del av leverans till kund.',
description: 'Kostnader för arbete utfört av underleverantörer som del av leverans till kund.',
sru_code: '7320',
k2_excluded: false,
},
@@ -19,7 +19,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '50',
account_type: 'expense',
normal_balance: 'debit',
description: 'Manadshyra for kontorslokal, lager eller annan arbetsplats.',
description: 'Månadshyra för kontorslokal, lager eller annan arbetsplats.',
sru_code: '7321',
k2_excluded: false,
},
@@ -74,7 +74,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '50',
account_type: 'expense',
normal_balance: 'debit',
description: 'Elkostnader for foretagets lokaler.',
description: 'Elkostnader för företagets lokaler.',
sru_code: '7321',
k2_excluded: false,
},
@@ -118,7 +118,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '50',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for stadning och rengoring av foretagets lokaler.',
description: 'Kostnader för städning och rengöring av företagets lokaler.',
sru_code: '7321',
k2_excluded: false,
},
@@ -426,7 +426,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '52',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for leasing eller hyra av maskiner, bilar och annan utrustning.',
description: 'Kostnader för leasing eller hyra av maskiner, bilar och annan utrustning.',
sru_code: '7321',
k2_excluded: false,
},
@@ -492,7 +492,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '53',
account_type: 'expense',
normal_balance: 'debit',
description: 'Energikostnader for drift, t.ex. el for produktion.',
description: 'Energikostnader för drift, t.ex. el för produktion.',
sru_code: '7321',
k2_excluded: false,
},
@@ -635,7 +635,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '54',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for mjukvara, prenumerationer och licenser.',
description: 'Kostnader för mjukvara, prenumerationer och licenser.',
sru_code: '7321',
k2_excluded: false,
},
@@ -668,7 +668,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '54',
account_type: 'expense',
normal_balance: 'debit',
description: 'Forbrukningsmaterial som inte ar kontorsmaterial, t.ex. forpackningsmaterial.',
description: 'Förbrukningsmaterial som inte är kontorsmaterial, t.ex. förpackningsmaterial.',
sru_code: '7321',
k2_excluded: false,
},
@@ -690,7 +690,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '55',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for reparation och underhall av maskiner, inventarier och lokaler.',
description: 'Kostnader för reparation och underhåll av maskiner, inventarier och lokaler.',
sru_code: '7321',
k2_excluded: false,
},
@@ -778,7 +778,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '56',
account_type: 'expense',
normal_balance: 'debit',
description: 'Drivmedel, forsakring, reparation och ovriga kostnader for foretagsbilar.',
description: 'Drivmedel, försäkring, reparation och övriga kostnader för företagsbilar.',
sru_code: '7321',
k2_excluded: false,
},
@@ -1372,7 +1372,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '57',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for frakt och transport av varor till och fran foretaget.',
description: 'Kostnader för frakt och transport av varor till och från företaget.',
sru_code: '7321',
k2_excluded: false,
},
@@ -1471,7 +1471,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '58',
account_type: 'expense',
normal_balance: 'debit',
description: 'Tjansteresor: tag, flyg, taxi och ovriga resekostnader.',
description: 'Tjänsteresor: tåg, flyg, taxi och övriga resekostnader.',
sru_code: '7321',
k2_excluded: false,
},
@@ -1482,7 +1482,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '58',
account_type: 'expense',
normal_balance: 'debit',
description: 'Rese- och transportbiljetter for tjansteresor (tag, flyg, buss).',
description: 'Rese- och transportbiljetter för tjänsteresor (tåg, flyg, buss).',
sru_code: '7321',
k2_excluded: false,
},
@@ -1493,7 +1493,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '58',
account_type: 'expense',
normal_balance: 'debit',
description: 'Hyrbilskostnader vid tjansteresor.',
description: 'Hyrbilskostnader vid tjänsteresor.',
sru_code: '7321',
k2_excluded: false,
},
@@ -1559,7 +1559,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '59',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for marknadsforing, annonser, Google Ads, sociala medier och reklamkampanjer.',
description: 'Kostnader för marknadsföring, annonser, Google Ads, sociala medier och reklamkampanjer.',
sru_code: '7321',
k2_excluded: false,
},
@@ -1581,7 +1581,7 @@ export const CLASS_5_ACCOUNTS: BASReferenceAccount[] = [
account_group: '59',
account_type: 'expense',
normal_balance: 'debit',
description: 'Trycksaker for marknadsforingsandamal, t.ex. broschyrer och visitkort.',
description: 'Trycksaker för marknadsföringsändamål, t.ex. broschyrer och visitkort.',
sru_code: '7321',
k2_excluded: false,
},
@@ -184,7 +184,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '60',
account_type: 'expense',
normal_balance: 'debit',
description: 'Representation som overstiger avdragsgilla beloppet. Inte skattemassigt avdragsgill.',
description: 'Representation som överstiger avdragsgilla beloppet. Inte skattemässigt avdragsgill.',
sru_code: '7321',
k2_excluded: false,
},
@@ -228,7 +228,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '61',
account_type: 'expense',
normal_balance: 'debit',
description: 'Pennor, papper, toner, USB-minnen och ovriga kontorsfornodenheter.',
description: 'Pennor, papper, toner, USB-minnen och övriga kontorsförnödenheter.',
sru_code: '7321',
k2_excluded: false,
},
@@ -239,7 +239,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '61',
account_type: 'expense',
normal_balance: 'debit',
description: 'Trycksaker for internt bruk, t.ex. blanketter och formular.',
description: 'Trycksaker för internt bruk, t.ex. blanketter och formulär.',
sru_code: '7321',
k2_excluded: false,
},
@@ -272,7 +272,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '62',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnad for fast telefoni och telefonabonnemang.',
description: 'Kostnad för fast telefoni och telefonabonnemang.',
sru_code: '7321',
k2_excluded: false,
},
@@ -283,7 +283,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '62',
account_type: 'expense',
normal_balance: 'debit',
description: 'Mobilabonnemang och samtalskostnader for foretagets mobiler.',
description: 'Mobilabonnemang och samtalskostnader för företagets mobiler.',
sru_code: '7321',
k2_excluded: false,
},
@@ -305,7 +305,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '62',
account_type: 'expense',
normal_balance: 'debit',
description: 'Bredband, internetabonnemang, domaner, hosting och molntjanster.',
description: 'Bredband, internetabonnemang, domäner, hosting och molntjänster.',
sru_code: '7321',
k2_excluded: false,
},
@@ -316,7 +316,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '62',
account_type: 'expense',
normal_balance: 'debit',
description: 'Porto, frimarken och kostnader for postutskick.',
description: 'Porto, frimärken och kostnader för postutskick.',
sru_code: '7321',
k2_excluded: false,
},
@@ -349,7 +349,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '63',
account_type: 'expense',
normal_balance: 'debit',
description: 'Premiekostnader for foretagets forsakringar, t.ex. ansvars- och egendomsforsakring.',
description: 'Premiekostnader för företagets försäkringar, t.ex. ansvars- och egendomsförsäkring.',
sru_code: '7321',
k2_excluded: false,
},
@@ -448,7 +448,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '63',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for garantier och garantiavsattningar.',
description: 'Kostnader för garantier och garantiavsättningar.',
sru_code: '7321',
k2_excluded: false,
},
@@ -657,7 +657,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '65',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for matning och besiktning.',
description: 'Kostnader för mätning och besiktning.',
sru_code: '7321',
k2_excluded: false,
},
@@ -679,7 +679,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '65',
account_type: 'expense',
normal_balance: 'debit',
description: 'Avgifter till bokforingsbyrta eller redovisningskonsult.',
description: 'Avgifter till bokföringsbyrå eller redovisningskonsult.',
sru_code: '7321',
k2_excluded: false,
},
@@ -690,7 +690,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '65',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for extern IT-support, konsultation och drifttjanster.',
description: 'Kostnader för extern IT-support, konsultation och drifttjänster.',
sru_code: '7321',
k2_excluded: false,
},
@@ -701,7 +701,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '65',
account_type: 'expense',
normal_balance: 'debit',
description: 'Arvode till externa konsulter for radgivning och specialisttjanster.',
description: 'Arvode till externa konsulter för rådgivning och specialisttjänster.',
sru_code: '7321',
k2_excluded: false,
},
@@ -789,7 +789,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '65',
account_type: 'expense',
normal_balance: 'debit',
description: 'Serviceavtal och underhalskostnader for utrustning och system.',
description: 'Serviceavtal och underhållskostnader för utrustning och system.',
sru_code: '7321',
k2_excluded: false,
},
@@ -800,7 +800,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '65',
account_type: 'expense',
normal_balance: 'debit',
description: 'Avgifter for banktjanster, betalformedling, Swish och kortinlosen.',
description: 'Avgifter för banktjänster, betalförmedling, Swish och kortinlösen.',
sru_code: '7321',
k2_excluded: false,
},
@@ -1031,7 +1031,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '69',
account_type: 'expense',
normal_balance: 'debit',
description: 'Prenumerationer pa tidningar, tidskrifter och branschpublikationer.',
description: 'Prenumerationer på tidningar, tidskrifter och branschpublikationer.',
sru_code: '7321',
k2_excluded: false,
},
@@ -1086,7 +1086,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '69',
account_type: 'expense',
normal_balance: 'debit',
description: 'Diverse externa kostnader som inte passar in under andra konton men ar avdragsgilla.',
description: 'Diverse externa kostnader som inte passar in under andra konton men är avdragsgilla.',
sru_code: '7330',
k2_excluded: false,
},
@@ -1097,7 +1097,7 @@ export const CLASS_6_ACCOUNTS: BASReferenceAccount[] = [
account_group: '69',
account_type: 'expense',
normal_balance: 'debit',
description: 'Externa kostnader som inte ar skattemassigt avdragsgilla, t.ex. boter och forseningsavgifter.',
description: 'Externa kostnader som inte är skattemässigt avdragsgilla, t.ex. böter och förseningsavgifter.',
sru_code: '7330',
k2_excluded: false,
},
+15 -15
View File
@@ -19,7 +19,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '70',
account_type: 'expense',
normal_balance: 'debit',
description: 'Bruttoloner (fore skatt) till kollektivanstallda.',
description: 'Bruttolöner (före skatt) till kollektivanställda.',
sru_code: '7322',
k2_excluded: false,
},
@@ -184,7 +184,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '70',
account_type: 'expense',
normal_balance: 'debit',
description: 'Semesterloner till kollektivanstallda.',
description: 'Semesterlöner till kollektivanställda.',
sru_code: '7322',
k2_excluded: false,
},
@@ -217,7 +217,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '70',
account_type: 'expense',
normal_balance: 'debit',
description: 'Justering av skuld for intjanade semesterdagar som annu inte tagits ut.',
description: 'Justering av skuld för intjänade semesterdagar som ännu inte tagits ut.',
sru_code: '7322',
k2_excluded: false,
},
@@ -239,7 +239,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '72',
account_type: 'expense',
normal_balance: 'debit',
description: 'Bruttoloner till tjansteman.',
description: 'Bruttolöner till tjänstemän.',
sru_code: '7322',
k2_excluded: false,
},
@@ -558,7 +558,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '72',
account_type: 'expense',
normal_balance: 'debit',
description: 'Justering av semesterlonskuld for tjansteman.',
description: 'Justering av semesterlöneskuld för tjänstemän.',
sru_code: '7322',
k2_excluded: false,
},
@@ -602,7 +602,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '73',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnadsersattningar till anstallda, t.ex. milersattning och traktamente.',
description: 'Kostnadsersättningar till anställda, t.ex. milersättning och traktamente.',
sru_code: '7322',
k2_excluded: false,
},
@@ -987,7 +987,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '74',
account_type: 'expense',
normal_balance: 'debit',
description: 'Premiebetalningar for tjanstepension till anstallda.',
description: 'Premiebetalningar för tjänstepension till anställda.',
sru_code: '7322',
k2_excluded: false,
},
@@ -1218,7 +1218,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '75',
account_type: 'expense',
normal_balance: 'debit',
description: 'Beraknade sociala avgifter pa upplupna semesterloner och andra loneskulder.',
description: 'Beräknade sociala avgifter på upplupna semesterlöner och andra löneskulder.',
sru_code: '7322',
k2_excluded: false,
},
@@ -1262,7 +1262,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '75',
account_type: 'expense',
normal_balance: 'debit',
description: 'Sarskild loneskatt pa pensionskostnader (24,26%).',
description: 'Särskild löneskatt på pensionskostnader (24,26%).',
sru_code: '7322',
k2_excluded: false,
},
@@ -1438,7 +1438,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '76',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for utbildning, kurser och konferenser for anstallda.',
description: 'Kostnader för utbildning, kurser och konferenser för anställda.',
sru_code: '7322',
k2_excluded: false,
},
@@ -1779,7 +1779,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '78',
account_type: 'expense',
normal_balance: 'debit',
description: 'Planmassig avskrivning av goodwill, patent och andra immateriella tillgangar.',
description: 'Planmässig avskrivning av goodwill, patent och andra immateriella tillgangar.',
sru_code: '7325',
k2_excluded: false,
},
@@ -1878,7 +1878,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '78',
account_type: 'expense',
normal_balance: 'debit',
description: 'Arlig vardeminskning pa byggnader. Typiskt 2-5% per ar beroende pa byggnadstyp.',
description: 'Årlig värdeminskning på byggnader. Typiskt 2-5% per är beroende på byggnadstyp.',
sru_code: '7324',
k2_excluded: false,
},
@@ -1944,7 +1944,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '78',
account_type: 'expense',
normal_balance: 'debit',
description: 'Arlig vardeminskning pa inventarier, maskiner och verktyg.',
description: 'Årlig värdeminskning på inventarier, maskiner och verktyg.',
sru_code: '7325',
k2_excluded: false,
},
@@ -1999,7 +1999,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '79',
account_type: 'expense',
normal_balance: 'debit',
description: 'Forluster som uppstar vid valutavaxling eller betalningar i utlandsk valuta.',
description: 'Förluster som uppstår vid valutaväxling eller betalningar i utländsk valuta.',
sru_code: '7360',
k2_excluded: false,
},
@@ -2010,7 +2010,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '79',
account_type: 'expense',
normal_balance: 'debit',
description: 'Forlust vid avyttring av immateriella och materiella anlaggningstillgangar.',
description: 'Förlust vid avyttring av immateriella och materiella anläggningstillgångar.',
sru_code: '7321',
k2_excluded: false,
},
@@ -811,7 +811,7 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
account_group: '83',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Ranta pa bankkontosaldo, sparkonton och utlanade pengar.',
description: 'Ränta på bankkontosaldo, sparkonton och utlånade pengar.',
sru_code: '7313',
k2_excluded: false,
},
@@ -1064,7 +1064,7 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
account_group: '84',
account_type: 'expense',
normal_balance: 'debit',
description: 'Ranta pa lan, krediter och ovriga skulder till kreditgivare.',
description: 'Ränta på lån, krediter och övriga skulder till kreditgivare.',
sru_code: '7323',
k2_excluded: false,
},
@@ -1372,7 +1372,7 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
account_group: '88',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Forandring av periodiseringsfond.',
description: 'Förändring av periodiseringsfond.',
sru_code: '7380',
k2_excluded: false,
},
@@ -1438,7 +1438,7 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
account_group: '88',
account_type: 'revenue',
normal_balance: 'credit',
description: 'Forandring av overavskrivningar.',
description: 'Förändring av överavskrivningar.',
sru_code: '7380',
k2_excluded: false,
},
@@ -1603,7 +1603,7 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
account_group: '89',
account_type: 'expense',
normal_balance: 'debit',
description: 'Beraknad inkomstskatt pa det skattepliktiga resultatet for rakenskapsaret.',
description: 'Beräknad inkomstskatt på det skattepliktiga resultatet för räkenskapsåret.',
sru_code: '7380',
k2_excluded: false,
},
@@ -1669,7 +1669,7 @@ export const CLASS_8_ACCOUNTS: BASReferenceAccount[] = [
account_group: '89',
account_type: 'expense',
normal_balance: 'debit',
description: 'Slutresultatkonto som visar vinst eller forlust efter alla intakter och kostnader.',
description: 'Slutresultatkonto som visar vinst eller förlust efter alla intäkter och kostnader.',
sru_code: '7380',
k2_excluded: false,
},
+61 -61
View File
@@ -36,12 +36,12 @@ export { BAS_REFERENCE } from './bas-data'
/** Swedish labels for each BAS account class (1-8) */
export const ACCOUNT_CLASS_LABELS: Record<number, string> = {
1: 'Tillgangar',
1: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Rorelseintatker',
4: 'Varuinkop och material',
5: 'Ovriga externa kostnader',
6: 'Ovriga externa kostnader',
3: 'Rörelseintäkter',
4: 'Varuinköp och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
@@ -49,100 +49,100 @@ export const ACCOUNT_CLASS_LABELS: Record<number, string> = {
/** Swedish labels for BAS account groups (first two digits) */
export const ACCOUNT_GROUP_LABELS: Record<string, string> = {
// Class 1 - Assets
'10': 'Immateriella anlaggningstillgangar',
'10': 'Immateriella anläggningstillgångar',
'11': 'Byggnader och mark',
'12': 'Maskiner respektive inventarier',
'13': 'Finansiella anlaggningstillgangar',
'14': 'Lager, produkter i arbete och pagaende arbeten',
'13': 'Finansiella anläggningstillgångar',
'14': 'Lager, produkter i arbete och pågående arbeten',
'15': 'Kundfordringar',
'16': 'Ovriga kortfristiga fordringar',
'17': 'Forutbetalda kostnader och upplupna intakter',
'16': 'Övriga kortfristiga fordringar',
'17': 'Förutbetalda kostnader och upplupna intäkter',
'18': 'Kortfristiga placeringar',
'19': 'Kassa och bank',
// Class 2 - Equity & Liabilities
'20': 'Eget kapital',
'21': 'Obeskattade reserver',
'22': 'Avsattningar',
'23': 'Langfristiga skulder',
'24': 'Kortfristiga skulder till kreditinstitut, kunder och leverantorer',
'22': 'Avsättningar',
'23': 'Långfristiga skulder',
'24': 'Kortfristiga skulder till kreditinstitut, kunder och leverantörer',
'25': 'Skatteskulder',
'26': 'Moms och punktskatter',
'27': 'Personalens skatter, avgifter och loneavdrag',
'28': 'Ovriga kortfristiga skulder',
'29': 'Upplupna kostnader och forutbetalda intakter',
'27': 'Personalens skatter, avgifter och löneavdrag',
'28': 'Övriga kortfristiga skulder',
'29': 'Upplupna kostnader och förutbetalda intäkter',
// Class 3 - Revenue
'30': 'Huvudintakter',
'31': 'Forsaljning av varor utanfor Sverige',
'32': 'Forsaljning VMB och omvand moms',
'33': 'Forsaljning av tjanster utanfor Sverige',
'34': 'Forsaljning, egna uttag',
'30': 'Huvudintäkter',
'31': 'Försäljning av varor utanför Sverige',
'32': 'Försäljning VMB och omvänd moms',
'33': 'Försäljning av tjänster utanför Sverige',
'34': 'Försäljning, egna uttag',
'35': 'Fakturerade kostnader',
'36': 'Rorelsens sidointakter',
'37': 'Intaktskorrigeringar',
'38': 'Aktiverat arbete for egen rakning',
'39': 'Ovriga rorelseintakter',
'36': 'Rörelsens sidointäkter',
'37': 'Intäktskorrigeringar',
'38': 'Aktiverat arbete för egen räkning',
'39': 'Övriga rörelseintäkter',
// Class 4 - Cost of goods
'40': 'Inkop av handelsvaror',
'41': 'Inkop av varor och material',
'42': 'Salda handelsvaror VMB',
'43': 'Inkop av ravaror och material i Sverige',
'44': 'Inkop av ravaror m.m., omvand betalningsskyldighet',
'45': 'Inkop av ravaror m.m. fran utlandet',
'46': 'Inkop av tjanster, underentreprenader och legoarbeten',
'47': 'Reduktion av inkopspriser',
'40': 'Inköp av handelsvaror',
'41': 'Inköp av varor och material',
'42': 'Sålda handelsvaror VMB',
'43': 'Inköp av råvaror och material i Sverige',
'44': 'Inköp av råvaror m.m., omvänd betalningsskyldighet',
'45': 'Inköp av råvaror m.m. från utlandet',
'46': 'Inköp av tjänster, underentreprenader och legoarbeten',
'47': 'Reduktion av inköpspriser',
'48': 'Andra produktionskostnader',
'49': 'Forandring av lager, produkter i arbete och pagaende arbeten',
'49': 'Förändring av lager, produkter i arbete och pågående arbeten',
// Class 5 - External expenses
'50': 'Lokalkostnader',
'51': 'Fastighetskostnader',
'52': 'Hyra av anlaggningstillgangar',
'53': 'Energikostnader for drift',
'54': 'Forbrukningsinventarier och forbrukningsmaterial',
'55': 'Reparation och underhall',
'56': 'Kostnader for transportmedel',
'52': 'Hyra av anläggningstillgångar',
'53': 'Energikostnader för drift',
'54': 'Förbrukningsinventarier och förbrukningsmaterial',
'55': 'Reparation och underhåll',
'56': 'Kostnader för transportmedel',
'57': 'Frakter och transporter',
'58': 'Resekostnader',
'59': 'Reklam och PR',
// Class 6 - Other external expenses
'60': 'Ovriga forsaljningskostnader',
'60': 'Övriga försäljningskostnader',
'61': 'Kontorsmateriel och trycksaker',
'62': 'Tele, data och post',
'63': 'Foretagsforsakringar och ovriga riskkostnader',
'64': 'Forvaltningskostnader',
'65': 'Ovriga externa tjanster',
'63': 'Företagsförsäkringar och övriga riskkostnader',
'64': 'Förvaltningskostnader',
'65': 'Övriga externa tjänster',
'66': 'Franchisingavgifter',
'67': 'Sarskilt for ideella foreningar och stiftelser',
'67': 'Särskilt för ideella föreningar och stiftelser',
'68': 'Inhyrd personal',
'69': 'Ovriga externa kostnader',
'69': 'Övriga externa kostnader',
// Class 7 - Personnel
'70': 'Loner till kollektivanstallda',
'71': 'Loner till anstallda',
'72': 'Loner till tjansteman och foretagsledare',
'73': 'Kostnadsersattningar och formaner',
'70': 'Löner till kollektivanställda',
'71': 'Löner till anställda',
'72': 'Löner till tjänstemän och företagsledare',
'73': 'Kostnadsersättningar och förmåner',
'74': 'Pensionskostnader',
'75': 'Sociala och andra avgifter enligt lag och avtal',
'76': 'Ovriga personalkostnader',
'77': 'Nedskrivningar och aterforing av nedskrivningar',
'76': 'Övriga personalkostnader',
'77': 'Nedskrivningar och återföring av nedskrivningar',
'78': 'Avskrivningar enligt plan',
'79': 'Ovriga rorelsekostnader',
'79': 'Övriga rörelsekostnader',
// Class 8 - Financial
'80': 'Resultat fran andelar i koncernforetag',
'81': 'Resultat fran andelar i intresseforetag',
'82': 'Resultat fran ovriga vardepapper och langfristiga fordringar',
'83': 'Ovriga ranteintakter och liknande resultatposter',
'84': 'Rantekostnader och liknande resultatposter',
'85': 'Extraordinara intakter',
'86': 'Extraordinara kostnader',
'87': 'Bokslutsdispositioner (intakter)',
'80': 'Resultat från andelar i koncernföretag',
'81': 'Resultat från andelar i intresseföretag',
'82': 'Resultat från övriga värdepapper och långfristiga fordringar',
'83': 'Övriga ränteintäkter och liknande resultatposter',
'84': 'Räntekostnader och liknande resultatposter',
'85': 'Extraordinära intäkter',
'86': 'Extraordinära kostnader',
'87': 'Bokslutsdispositioner (intäkter)',
'88': 'Bokslutsdispositioner',
'89': 'Skatter och arets resultat',
'89': 'Skatter och årets resultat',
}
// ---------------------------------------------------------------------------
+191 -19
View File
@@ -57,6 +57,8 @@ export interface BookingTemplate {
default_private: boolean
fallback_category: TransactionCategory
description_sv: string
common: boolean
requires_vat_registration_data?: boolean
}
export interface TemplateGroupInfo {
@@ -96,7 +98,7 @@ const GROUP_LABELS: Record<TemplateGroup, { sv: string; en: string }> = {
}
// ============================================================
// Template Data (48 templates)
// Template Data
// ============================================================
export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
@@ -123,6 +125,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_office',
description_sv: 'Månadshyra för kontors- eller affärslokal',
common: true,
},
{
id: 'premises_rent_vat',
@@ -146,6 +149,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_office',
description_sv: 'Lokalhyra med moms (frivilligt momsregistrerad hyresvärd)',
common: false,
},
{
id: 'premises_electricity',
@@ -168,6 +172,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_office',
description_sv: 'El, uppvärmning och vatten för kontors- eller affärslokal',
common: true,
},
// --- VEHICLE (4) ---
@@ -193,6 +198,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Drivmedel (bensin/diesel) eller laddning (elbil) för tjänstefordon',
common: true,
},
{
id: 'vehicle_leasing',
@@ -217,6 +223,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Leasingavgift för tjänstefordon',
common: false,
},
{
id: 'vehicle_repairs',
@@ -239,6 +246,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Reparation, service och underhåll av fordon',
common: false,
},
{
id: 'vehicle_parking',
@@ -261,6 +269,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Parkeringsavgift och trängselskatt vid tjänsteärende',
common: false,
},
// --- IT & SOFTWARE (3) ---
@@ -285,6 +294,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_software',
description_sv: 'Programvarulicens eller SaaS-prenumeration (svensk leverantör med moms)',
common: false,
},
{
id: 'it_saas_eu',
@@ -308,6 +318,8 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_software',
description_sv: 'Programvara från utländsk leverantör med omvänd skattskyldighet',
common: true,
requires_vat_registration_data: true,
},
{
id: 'it_cloud_hosting',
@@ -331,6 +343,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_software',
description_sv: 'Molnbaserade tjänster, webbhotell, serverhosting och domännamn',
common: true,
},
// --- OFFICE SUPPLIES (2) ---
@@ -355,6 +368,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_office',
description_sv: 'Kontorsmaterial, trycksaker och förbrukningsvaror',
common: true,
},
{
id: 'office_postage',
@@ -378,13 +392,14 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_office',
description_sv: 'Porto och fraktkostnader',
common: true,
},
// --- MARKETING (2) ---
// --- MARKETING (3) ---
{
id: 'marketing_online_ads',
name_sv: 'Annonsering & Marknadsföring',
name_en: 'Advertising & Marketing',
id: 'marketing_online_ads_eu',
name_sv: 'Annonsering EU (omvänd moms)',
name_en: 'Online ads EU (reverse charge)',
group: 'marketing',
direction: 'expense',
entity_applicability: 'all',
@@ -402,7 +417,33 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
auto_match_confidence: 0.90,
default_private: false,
fallback_category: 'expense_marketing',
description_sv: 'Digital annonsering och marknadsföring',
description_sv: 'Digital annonsering från EU-leverantör (Google/Meta från Irland)',
common: true,
requires_vat_registration_data: true,
},
{
id: 'marketing_online_ads_domestic',
name_sv: 'Annonsering (svensk moms)',
name_en: 'Online ads (domestic VAT)',
group: 'marketing',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5910',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
special_rules_sv: 'Svensk leverantör med momsregistrering',
mcc_codes: [7311],
keywords: ['annons', 'reklam', 'advertising', 'kampanj', 'blocket', 'eniro'],
risk_level: 'NONE',
requires_review: false,
impact_score: 7,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_marketing',
description_sv: 'Digital annonsering från svensk leverantör med 25% moms',
common: false,
},
{
id: 'marketing_design',
@@ -425,6 +466,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_marketing',
description_sv: 'Grafisk design, reklam, foto/video och marknadsföringsverktyg',
common: false,
},
// --- TRAVEL (3) ---
@@ -450,6 +492,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Resor i tjänsten: flyg, tåg, taxi, hyrbil (6% moms)',
common: true,
},
{
id: 'travel_international',
@@ -473,6 +516,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Utrikesresor i tjänsten (momsfritt)',
common: false,
},
{
id: 'travel_hotel',
@@ -496,9 +540,10 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Hotellövernattning i tjänsten (12% moms)',
common: true,
},
// --- REPRESENTATION (2) ---
// --- REPRESENTATION (3) ---
{
id: 'representation_external',
name_sv: 'Extern representation',
@@ -511,7 +556,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
vat_treatment: 'reduced_12',
vat_rate: 0.12,
deductibility: 'conditional',
deductibility_note_sv: 'Max 300 kr/person exkl moms (IL 16 kap 2§)',
deductibility_note_sv: 'Avdragsgill moms max 46 kr/person. Representationskostnad max 300 kr/person exkl moms (IL 16 kap 2§)',
special_rules_sv: 'Dokumentera: syfte, deltagare, företag. Momsavdrag max 300 kr/person.',
mcc_codes: [5812, 5813, 5814],
keywords: ['representation', 'lunch', 'middag', 'restaurang', 'restaurant', 'kund', 'kundmöte', 'gåva', 'present', 'representationsgåva'],
@@ -522,6 +567,32 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Representation med kund/affärspartner (dokumentera noggrant)',
common: true,
},
{
id: 'representation_internal',
name_sv: 'Intern representation',
name_en: 'Internal representation',
group: 'representation',
direction: 'expense',
entity_applicability: 'all',
debit_account: '7622',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'conditional',
deductibility_note_sv: 'Max 60 kr/person',
special_rules_sv: 'Personalfest, intern lunch etc. Momsfritt. Max 60 kr/person för avdragsrätt.',
mcc_codes: [5812, 5813, 5814],
keywords: ['personalfest', 'intern representation', 'teamlunch', 'personallunch', 'fika', 'julfest', 'after work', 'intern lunch'],
risk_level: 'LOW',
requires_review: false,
impact_score: 5,
auto_match_confidence: 0.70,
default_private: false,
fallback_category: 'expense_representation',
description_sv: 'Intern representation (personalfest, teamlunch)',
common: true,
},
{
id: 'representation_conference',
@@ -544,6 +615,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_education',
description_sv: 'Avgifter för konferenser och mässor',
common: false,
},
// --- INSURANCE (3) ---
@@ -569,6 +641,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Företags-, ansvars- och fordonsförsäkring (momsfritt)',
common: true,
},
{
id: 'insurance_pension_ef',
@@ -593,6 +666,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Pensionssparande för enskild firma (granska avdragsregel)',
common: false,
},
{
id: 'insurance_pension_ab',
@@ -616,6 +690,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Tjänstepension för anställda i aktiebolag',
common: false,
},
// --- PROFESSIONAL SERVICES (2) ---
@@ -640,6 +715,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_professional_services',
description_sv: 'Redovisning, bokföring, revision och juridiska tjänster',
common: true,
},
{
id: 'prof_consulting',
@@ -662,6 +738,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_professional_services',
description_sv: 'Konsultarvoden och rådgivningstjänster',
common: true,
},
// --- BANK & FINANCE (5) ---
@@ -687,6 +764,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_bank_fees',
description_sv: 'Bankavgifter, kontoavgifter och kortavgifter',
common: true,
},
{
id: 'bank_interest_income',
@@ -709,6 +787,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'income_other',
description_sv: 'Ränteintäkter på bankkonto eller placeringar',
common: false,
},
{
id: 'bank_interest_expense',
@@ -731,6 +810,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Räntekostnad på lån eller kredit',
common: false,
},
{
id: 'bank_currency_loss',
@@ -753,6 +833,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_currency_exchange',
description_sv: 'Valutakursförluster vid betalning i utländsk valuta',
common: false,
},
{
id: 'bank_currency_gain',
@@ -775,6 +856,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'income_other',
description_sv: 'Valutakursvinster vid betalning i utländsk valuta',
common: false,
},
// --- TELECOM (2) ---
@@ -800,6 +882,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Telefon och mobilabonnemang (granska yrkesmässig andel)',
common: true,
},
{
id: 'telecom_internet',
@@ -822,6 +905,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Internetanslutning för kontor',
common: false,
},
// --- EDUCATION (2) ---
@@ -848,6 +932,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_education',
description_sv: 'Yrkesrelaterade kurser och utbildningar',
common: false,
},
{
id: 'education_membership',
@@ -871,6 +956,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_education',
description_sv: 'Medlemsavgift i branschorganisation eller yrkesförening',
common: false,
},
// --- PERSONNEL (3) ---
@@ -886,6 +972,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'OBS: Denna mall bokför nettolön. Personalskatt (2710) och arbetsgivaravgifter (2731) måste bokföras separat.',
mcc_codes: [],
keywords: ['lön', 'salary', 'nettolön', 'löneutbetalning'],
risk_level: 'NONE',
@@ -895,6 +982,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Nettolön till anställd',
common: true,
},
{
id: 'personnel_employer_tax',
@@ -903,11 +991,12 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
group: 'personnel',
direction: 'expense',
entity_applicability: 'aktiebolag',
debit_account: '7510',
debit_account: '2731',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Betalning av arbetsgivaravgift-skuld. Kostnad (D: 7510 / K: 2731) bokförs vid lönekörning.',
mcc_codes: [],
keywords: ['arbetsgivaravgift', 'sociala avgifter', 'employer tax', 'skattekonto'],
risk_level: 'NONE',
@@ -916,7 +1005,8 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Arbetsgivaravgifter (31.42%)',
description_sv: 'Betalning av arbetsgivaravgifter',
common: true,
},
{
id: 'personnel_preliminary_tax',
@@ -939,6 +1029,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Preliminärskatt till Skatteverket',
common: false,
},
// --- REVENUE (4) ---
@@ -963,6 +1054,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'income_services',
description_sv: 'Intäkter från tjänste- eller varuförsäljning med 25% moms',
common: true,
},
{
id: 'revenue_reduced_12',
@@ -985,6 +1077,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'income_products',
description_sv: 'Intäkter från livsmedelsförsäljning eller logi med 12% moms',
common: true,
},
{
id: 'revenue_eu_services',
@@ -1008,6 +1101,8 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'income_services',
description_sv: 'Tjänsteförsäljning till EU-företag (momsfritt, ruta 39)',
common: true,
requires_vat_registration_data: true,
},
{
id: 'revenue_export',
@@ -1031,6 +1126,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'income_services',
description_sv: 'Export av varor/tjänster utanför EU (momsfritt, ruta 40)',
common: true,
},
// --- FINANCIAL (2) ---
@@ -1055,6 +1151,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Amortering av banklån',
common: false,
},
{
id: 'financial_tax_account',
@@ -1077,9 +1174,10 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Insättning på skattekonto hos Skatteverket',
common: true,
},
// --- PRIVATE TRANSFERS (4) ---
// --- PRIVATE TRANSFERS (5) ---
{
id: 'private_withdrawal_ef',
name_sv: 'Eget uttag (EF)',
@@ -1101,6 +1199,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: true,
fallback_category: 'private',
description_sv: 'Privat uttag från företagskonto (enskild firma)',
common: true,
},
{
id: 'private_deposit_ef',
@@ -1123,28 +1222,53 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: true,
fallback_category: 'private',
description_sv: 'Egen insättning till företagskonto (enskild firma)',
common: true,
},
{
id: 'private_shareholder_loan',
name_sv: 'Skuld till aktieägare (AB)',
name_en: 'Shareholder loan (AB)',
id: 'shareholder_loan_received',
name_sv: 'Lån från ägare (AB)',
name_en: 'Shareholder loan received (AB)',
group: 'private_transfers',
direction: 'transfer',
entity_applicability: 'aktiebolag',
debit_account: '2893',
debit_account: '1930',
credit_account: '2393',
vat_treatment: null,
vat_rate: 0,
deductibility: 'non_deductible',
mcc_codes: [],
keywords: ['aktieägare', 'lån', 'shareholder', 'skuld till ägare', 'insättning', 'tillskott'],
risk_level: 'LOW',
requires_review: true,
impact_score: 6,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'income_other',
description_sv: 'Ägare lånar pengar till bolaget (skuld till ägare)',
common: true,
},
{
id: 'shareholder_loan_disbursed',
name_sv: 'Fordran på ägare (AB)',
name_en: 'Shareholder loan disbursed (AB)',
group: 'private_transfers',
direction: 'transfer',
entity_applicability: 'aktiebolag',
debit_account: '1680',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'non_deductible',
mcc_codes: [],
keywords: ['aktieägare', 'lån', 'shareholder', 'skuld till ägare'],
risk_level: 'LOW',
keywords: ['aktieägare', 'lån till ägare', 'shareholder loan', 'fordran ägare'],
risk_level: 'HIGH',
requires_review: true,
impact_score: 6,
auto_match_confidence: 0.75,
auto_match_confidence: 0.70,
default_private: true,
fallback_category: 'private',
description_sv: 'Utbetalning bokförd som skuld till aktieägare',
description_sv: 'Bolaget betalar ut till ägare (fordran på ägare)',
common: false,
},
{
id: 'private_expense',
@@ -1168,6 +1292,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: true,
fallback_category: 'private',
description_sv: 'Privat kostnad betald från företagskonto',
common: false,
},
// --- EQUIPMENT (2) ---
@@ -1193,6 +1318,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_equipment',
description_sv: 'Inventarier under halva prisbasbeloppet (IT-utrustning, möbler, verktyg)',
common: true,
},
{
id: 'equipment_capital',
@@ -1216,6 +1342,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
default_private: false,
fallback_category: 'expense_equipment',
description_sv: 'Inventarie som ska aktiveras och skrivas av',
common: false,
},
]
@@ -1301,6 +1428,51 @@ export function searchTemplates(query: string, entityType?: EntityType): Booking
})
}
/**
* Get common templates, filtered by entity type and direction.
*/
export function getCommonTemplates(
entityType?: EntityType,
direction?: 'expense' | 'income' | 'transfer'
): BookingTemplate[] {
return BOOKING_TEMPLATES.filter((t) => {
if (!t.common) return false
if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) return false
if (direction && t.direction !== direction) return false
return true
})
}
/**
* Get advanced (non-common) templates, filtered by entity type and direction.
*/
export function getAdvancedTemplates(
entityType?: EntityType,
direction?: 'expense' | 'income' | 'transfer'
): BookingTemplate[] {
return BOOKING_TEMPLATES.filter((t) => {
if (t.common) return false
if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) return false
if (direction && t.direction !== direction) return false
return true
})
}
/**
* Validate that a template is valid for the given entity type.
*/
export function validateTemplateForEntity(
template: BookingTemplate,
entityType: EntityType
): { valid: boolean; error?: string } {
if (template.entity_applicability === 'all') return { valid: true }
if (template.entity_applicability === entityType) return { valid: true }
return {
valid: false,
error: `Template "${template.name_sv}" is only valid for ${template.entity_applicability}. Your entity type is ${entityType}.`,
}
}
/**
* Multi-signal matching against a transaction.
* Returns top matches sorted by confidence descending.
+21 -21
View File
@@ -7,52 +7,52 @@
const ACCOUNT_NAMES: Record<string, string> = {
// Assets (1xxx)
'1510': 'Kundfordringar',
'1930': 'Foretagskonto',
'1930': 'Företagskonto',
// Equity & Liabilities (2xxx)
'2013': 'Ovriga egna uttag',
'2018': 'Egna insattningar',
'2440': 'Leverantorsskulder',
'2013': 'Övriga egna uttag',
'2018': 'Egna insättningar',
'2440': 'Leverantörsskulder',
'2611': 'Utg. moms 25%',
'2621': 'Utg. moms 12%',
'2631': 'Utg. moms 6%',
'2614': 'Utg. moms omvand',
'2614': 'Utg. moms omvänd',
'2641': 'Ing. moms',
'2645': 'Beraknad ing. moms',
'2893': 'Skuld till agare',
'2645': 'Beräknad ing. moms',
'2893': 'Skuld till ägare',
// Revenue (3xxx)
'3001': 'Forsaljning 25%',
'3002': 'Forsaljning 12%',
'3003': 'Forsaljning 6%',
'3004': 'Momsfri forsaljning',
'3305': 'Exportforsaljning',
'3308': 'EU-tjanster',
'3900': 'Ovriga rorelseintakter',
'3001': 'Försäljning 25%',
'3002': 'Försäljning 12%',
'3003': 'Försäljning 6%',
'3004': 'Momsfri försäljning',
'3305': 'Exportförsäljning',
'3308': 'EU-tjänster',
'3900': 'Övriga rörelseintäkter',
// Cost of goods (4xxx)
'4010': 'Varuinkop',
'4010': 'Varuinköp',
// External expenses (5xxx)
'5010': 'Lokalhyra',
'5410': 'Forbrukningsinventarier',
'5410': 'Förbrukningsinventarier',
'5420': 'Programvaror',
'5460': 'Forbrukningsvaror',
'5460': 'Förbrukningsvaror',
'5611': 'Drivmedel bil',
'5800': 'Resekostnader',
'5910': 'Annonsering',
// Other external expenses (6xxx)
'6071': 'Representation',
'6110': 'Kontorsforbrukning',
'6110': 'Kontorsförbrukning',
'6200': 'Telefon & internet',
'6530': 'Redovisningstjanster',
'6530': 'Redovisningstjänster',
'6570': 'Bankavgifter',
'6991': 'Ovriga kostnader',
'6991': 'Övriga kostnader',
// Personnel (7xxx)
'7610': 'Utbildning',
'7960': 'Valutakursforluster',
'7960': 'Valutakursförluster',
'3960': 'Valutakursvinster',
}
+317
View File
@@ -0,0 +1,317 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type {
Currency,
Invoice,
SupplierInvoice,
RevaluationItem,
CurrencyRevaluationPreview,
CurrencyRevaluationResult,
CreateJournalEntryLineInput,
} from '@/types'
/**
* Fetch open foreign-currency receivables (invoices).
* Returns invoices with status 'sent' or 'overdue', non-SEK currency,
* and a known exchange rate.
*/
export async function getOpenForeignCurrencyReceivables(
supabase: SupabaseClient,
userId: string
): Promise<Invoice[]> {
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('user_id', userId)
.in('status', ['sent', 'overdue'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
if (error) {
throw new Error(`Failed to fetch foreign currency receivables: ${error.message}`)
}
return (data || []) as Invoice[]
}
/**
* Fetch open foreign-currency payables (supplier invoices).
* Returns supplier invoices with open statuses, non-SEK currency,
* and a known exchange rate. Uses remaining_amount for partial payments.
*/
export async function getOpenForeignCurrencyPayables(
supabase: SupabaseClient,
userId: string
): Promise<SupplierInvoice[]> {
const { data, error } = await supabase
.from('supplier_invoices')
.select('*')
.eq('user_id', userId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
if (error) {
throw new Error(`Failed to fetch foreign currency payables: ${error.message}`)
}
return (data || []) as SupplierInvoice[]
}
/**
* Preview currency revaluation without persisting.
* Computes per-item differences and aggregated journal lines.
*
* Receivables (1510):
* closing > original → gain: Debit 1510, Credit 3960
* closing < original → loss: Credit 1510, Debit 7960
*
* Payables (2440):
* closing > original → loss (liability grew): Debit 7960, Credit 2440
* closing < original → gain (liability shrank): Debit 2440, Credit 3960
*/
export async function previewCurrencyRevaluation(
supabase: SupabaseClient,
userId: string,
closingDate: string
): Promise<CurrencyRevaluationPreview> {
const [receivables, payables] = await Promise.all([
getOpenForeignCurrencyReceivables(supabase, userId),
getOpenForeignCurrencyPayables(supabase, userId),
])
// Collect distinct currencies
const currencies = new Set<Currency>()
for (const inv of receivables) {
currencies.add(inv.currency)
}
for (const si of payables) {
currencies.add(si.currency as Currency)
}
if (currencies.size === 0) {
return {
items: [],
lines: [],
closingRates: {},
totalGain: 0,
totalLoss: 0,
netEffect: 0,
}
}
// Fetch closing rates
const rateMap = await fetchMultipleRates(
Array.from(currencies),
new Date(closingDate)
)
const closingRates: Record<string, number> = {}
for (const [currency, rate] of rateMap) {
closingRates[currency] = rate.rate
}
const items: RevaluationItem[] = []
// Process receivables
for (const inv of receivables) {
const closingRate = rateMap.get(inv.currency)?.rate
if (!closingRate || !inv.exchange_rate) continue
const amountInCurrency = inv.total
const originalSek = Math.round(amountInCurrency * inv.exchange_rate * 100) / 100
const closingSek = Math.round(amountInCurrency * closingRate * 100) / 100
const difference = Math.round((closingSek - originalSek) * 100) / 100
if (Math.abs(difference) < 0.01) continue
items.push({
type: 'receivable',
source_id: inv.id,
reference: inv.invoice_number,
currency: inv.currency,
amount_in_currency: amountInCurrency,
original_rate: inv.exchange_rate,
closing_rate: closingRate,
original_sek: originalSek,
closing_sek: closingSek,
difference_sek: difference,
})
}
// Process payables (use remaining_amount for partial payments)
for (const si of payables) {
const closingRate = rateMap.get(si.currency as Currency)?.rate
if (!closingRate || !si.exchange_rate) continue
const amountInCurrency = si.remaining_amount
if (amountInCurrency <= 0) continue
const originalSek = Math.round(amountInCurrency * si.exchange_rate * 100) / 100
const closingSek = Math.round(amountInCurrency * closingRate * 100) / 100
const difference = Math.round((closingSek - originalSek) * 100) / 100
if (Math.abs(difference) < 0.01) continue
items.push({
type: 'payable',
source_id: si.id,
reference: si.supplier_invoice_number,
currency: si.currency as Currency,
amount_in_currency: amountInCurrency,
original_rate: si.exchange_rate,
closing_rate: closingRate,
original_sek: originalSek,
closing_sek: closingSek,
difference_sek: difference,
})
}
// Build aggregated journal lines
let debit1510 = 0 // Receivable gain (revalue up)
let credit1510 = 0 // Receivable loss (revalue down)
let debit2440 = 0 // Payable gain (liability shrank)
let credit2440 = 0 // Payable loss (liability grew)
let debit3960 = 0 // Placeholder — we won't debit 3960
let credit3960 = 0 // Gains
let debit7960 = 0 // Losses
let credit7960 = 0 // Placeholder — we won't credit 7960
for (const item of items) {
if (item.type === 'receivable') {
if (item.difference_sek > 0) {
// Closing > original → gain: Debit 1510, Credit 3960
debit1510 += item.difference_sek
credit3960 += item.difference_sek
} else {
// Closing < original → loss: Credit 1510, Debit 7960
credit1510 += Math.abs(item.difference_sek)
debit7960 += Math.abs(item.difference_sek)
}
} else {
// Payable
if (item.difference_sek > 0) {
// Closing > original → loss (liability grew): Debit 7960, Credit 2440
debit7960 += item.difference_sek
credit2440 += item.difference_sek
} else {
// Closing < original → gain (liability shrank): Debit 2440, Credit 3960
debit2440 += Math.abs(item.difference_sek)
credit3960 += Math.abs(item.difference_sek)
}
}
}
const lines: CreateJournalEntryLineInput[] = []
if (debit1510 > 0) {
lines.push({
account_number: '1510',
debit_amount: Math.round(debit1510 * 100) / 100,
credit_amount: 0,
line_description: 'Omvärdering kundfordringar — orealiserad kursvinst',
})
}
if (credit1510 > 0) {
lines.push({
account_number: '1510',
debit_amount: 0,
credit_amount: Math.round(credit1510 * 100) / 100,
line_description: 'Omvärdering kundfordringar — orealiserad kursförlust',
})
}
if (debit2440 > 0) {
lines.push({
account_number: '2440',
debit_amount: Math.round(debit2440 * 100) / 100,
credit_amount: 0,
line_description: 'Omvärdering leverantörsskulder — orealiserad kursvinst',
})
}
if (credit2440 > 0) {
lines.push({
account_number: '2440',
debit_amount: 0,
credit_amount: Math.round(credit2440 * 100) / 100,
line_description: 'Omvärdering leverantörsskulder — orealiserad kursförlust',
})
}
if (credit3960 > 0) {
lines.push({
account_number: '3960',
debit_amount: 0,
credit_amount: Math.round(credit3960 * 100) / 100,
line_description: 'Orealiserade valutakursvinster',
})
}
if (debit7960 > 0) {
lines.push({
account_number: '7960',
debit_amount: Math.round(debit7960 * 100) / 100,
credit_amount: 0,
line_description: 'Orealiserade valutakursförluster',
})
}
const totalGain = Math.round(credit3960 * 100) / 100
const totalLoss = Math.round(debit7960 * 100) / 100
const netEffect = Math.round((totalGain - totalLoss) * 100) / 100
return {
items,
lines,
closingRates,
totalGain,
totalLoss,
netEffect,
}
}
/**
* Execute currency revaluation for a fiscal period.
* Creates a journal entry with source_type 'currency_revaluation'.
*
* Returns null if no foreign-currency items exist.
* Throws if a revaluation entry already exists for this period (idempotency).
*/
export async function executeCurrencyRevaluation(
supabase: SupabaseClient,
userId: string,
closingDate: string,
fiscalPeriodId: string
): Promise<CurrencyRevaluationResult | null> {
// Idempotency check: prevent double revaluation
const { count, error: checkError } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('source_type', 'currency_revaluation')
.eq('status', 'posted')
if (checkError) {
throw new Error(`Failed to check existing revaluation: ${checkError.message}`)
}
if ((count ?? 0) > 0) {
throw new Error('Currency revaluation already exists for this period')
}
const preview = await previewCurrencyRevaluation(supabase, userId, closingDate)
if (preview.items.length === 0 || preview.lines.length === 0) {
return null
}
const entry = await createJournalEntry(supabase, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: closingDate,
description: `Omvärdering utländsk valuta ${closingDate}`,
source_type: 'currency_revaluation',
voucher_series: 'A',
lines: preview.lines,
})
return { entry, preview }
}
@@ -11,7 +11,7 @@ let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'neq', 'not', 'or', 'order', 'limit', 'is']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -39,6 +39,18 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/currency-revaluation', () => ({
previewCurrencyRevaluation: vi.fn().mockResolvedValue({
items: [],
lines: [],
closingRates: {},
totalGain: 0,
totalLoss: 0,
netEffect: 0,
}),
executeCurrencyRevaluation: vi.fn().mockResolvedValue(null),
}))
vi.mock('../period-service', () => ({
lockPeriod: vi.fn(),
closePeriod: vi.fn(),
@@ -67,6 +79,12 @@ describe('validateYearEndReadiness', () => {
{ data: null, error: null, count: 3 },
// 2: count posted entries (thenable chain) — count: 10
{ data: null, error: null, count: 10 },
// 3: count revaluation entries — count: 0
{ data: null, error: null, count: 0 },
// 4: count fx receivables — count: 0
{ data: null, error: null, count: 0 },
// 5: count fx payables — count: 0
{ data: null, error: null, count: 0 },
]
vi.mocked(generateTrialBalance).mockResolvedValue({
@@ -89,6 +107,9 @@ describe('validateYearEndReadiness', () => {
{ data: period, error: null },
{ data: null, error: null, count: 0 }, // no drafts
{ data: null, error: null, count: 5 }, // some posted
{ data: null, error: null, count: 0 }, // no revaluation
{ data: null, error: null, count: 0 }, // no fx receivables
{ data: null, error: null, count: 0 }, // no fx payables
]
vi.mocked(generateTrialBalance).mockResolvedValue({
@@ -121,8 +142,11 @@ describe('validateYearEndReadiness', () => {
resultIdx = 0
results = [
{ data: period, error: null },
{ data: null, error: null, count: 0 },
{ data: null, error: null, count: 5 },
{ data: null, error: null, count: 0 }, // no drafts
{ data: null, error: null, count: 5 }, // some posted
{ data: null, error: null, count: 0 }, // no revaluation
{ data: null, error: null, count: 0 }, // no fx receivables
{ data: null, error: null, count: 0 }, // no fx payables
]
vi.mocked(generateTrialBalance).mockResolvedValue({
@@ -143,6 +167,8 @@ describe('previewYearEndClosing', () => {
results = [
// 0: fetch company_settings (.single)
{ data: { entity_type: 'aktiebolag' }, error: null },
// 1: fetch fiscal period for closing date (.single)
{ data: { period_end: '2024-12-31' }, error: null },
]
vi.mocked(generateIncomeStatement).mockResolvedValue({
@@ -173,6 +199,8 @@ describe('previewYearEndClosing', () => {
it('uses 2010 for EF entity type', async () => {
results = [
{ data: { entity_type: 'enskild_firma' }, error: null },
// fetch fiscal period for closing date (.single)
{ data: { period_end: '2024-12-31' }, error: null },
]
vi.mocked(generateIncomeStatement).mockResolvedValue({ net_result: 50000 } as never)
+77 -7
View File
@@ -4,6 +4,10 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { lockPeriod, closePeriod, createNextPeriod } from './period-service'
import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
import type {
YearEndValidation,
YearEndPreview,
@@ -105,6 +109,40 @@ export async function validateYearEndReadiness(
warnings.push('No posted journal entries in this period')
}
// Check: foreign currency items exist but haven't been revalued
const { count: revalCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('source_type', 'currency_revaluation')
.eq('status', 'posted')
if ((revalCount ?? 0) === 0) {
// Check if there are any open foreign currency items
const { count: fxReceivables } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.in('status', ['sent', 'overdue'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
const { count: fxPayables } = await supabase
.from('supplier_invoices')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
if (((fxReceivables ?? 0) + (fxPayables ?? 0)) > 0) {
warnings.push(
'Open foreign currency items exist but have not been revalued (ÅRL 4:13)'
)
}
}
return {
ready: errors.length === 0,
errors,
@@ -212,12 +250,33 @@ export async function previewYearEndClosing(
}
}
// Fetch fiscal period for closing date
const { data: periodData } = await supabase
.from('fiscal_periods')
.select('period_end')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
let currencyRevaluation = null
if (periodData) {
const revalPreview = await previewCurrencyRevaluation(
supabase,
userId,
periodData.period_end
)
if (revalPreview.items.length > 0) {
currencyRevaluation = revalPreview
}
}
return {
netResult,
closingAccount,
closingAccountName,
closingLines,
resultAccountSummary,
currencyRevaluation,
}
}
@@ -255,14 +314,24 @@ export async function executeYearEndClosing(
throw new Error('Fiscal period not found')
}
// 2. Get closing preview
// 2. Execute currency revaluation BEFORE closing entry
// Revaluation posts to 3960/7960 (class 3/7 result accounts) which
// the closing entry then zeros out.
const revaluationResult = await executeCurrencyRevaluation(
supabase,
userId,
period.period_end,
fiscalPeriodId
)
// 3. Get closing preview (now includes revaluation effects in trial balance)
const preview = await previewYearEndClosing(supabase, userId, fiscalPeriodId)
if (preview.closingLines.length === 0) {
throw new Error('No result accounts to close — period has no activity')
}
// 3. Create closing entry via the journal engine
// 4. Create closing entry via the journal engine
const closingEntry = await createJournalEntry(supabase, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: period.period_end,
@@ -272,7 +341,7 @@ export async function executeYearEndClosing(
lines: preview.closingLines,
})
// 4. Update fiscal period with closing_entry_id
// 5. Update fiscal period with closing_entry_id
const { error: updateError } = await supabase
.from('fiscal_periods')
.update({ closing_entry_id: closingEntry.id })
@@ -283,16 +352,16 @@ export async function executeYearEndClosing(
throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
}
// 5. Lock the period
// 6. Lock the period
await lockPeriod(supabase, userId, fiscalPeriodId)
// 6. Close the period
// 7. Close the period
await closePeriod(supabase, userId, fiscalPeriodId)
// 7. Create next period
// 8. Create next period
const nextPeriod = await createNextPeriod(supabase, userId, fiscalPeriodId)
// 8. Generate opening balances in next period
// 9. Generate opening balances in next period
const openingBalanceEntry = await generateOpeningBalances(
supabase,
userId,
@@ -319,6 +388,7 @@ export async function executeYearEndClosing(
closingEntry,
nextPeriod,
openingBalanceEntry,
revaluationEntry: revaluationResult?.entry ?? null,
}
}
@@ -2,7 +2,6 @@
export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'enable-banking',
'ai-categorization',
'ai-chat',
'email',
])
@@ -1,13 +1,11 @@
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import type { Extension } from '../types'
import { enableBankingExtension } from '@/extensions/general/enable-banking'
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
import { aiChatExtension } from '@/extensions/general/ai-chat'
import { emailExtension } from '@/extensions/general/email'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
aiCategorizationExtension,
aiChatExtension,
emailExtension,
]
@@ -15,19 +15,6 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"hasOwnData": true,
"subscriptionNotice": "Denna integration kräver ett aktivt Enable Banking-abonnemang. Utan abonnemang kommer bankintegration inte att fungera."
},
{
"slug": "ai-categorization",
"name": "AI-kategorisering",
"sector": "general",
"category": "operations",
"icon": "Sparkles",
"dataPattern": "core",
"description": "AI-drivna kategoriförslag för transaktioner",
"longDescription": "Använder AI för att automatiskt föreslå BAS-kontokategorier för dina banktransaktioner. Lär sig från dina tidigare bokföringsval.",
"readsCoreTables": [
"transactions"
]
},
{
"slug": "ai-chat",
"name": "AI-assistent",
@@ -5,6 +5,5 @@ import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
}
-80
View File
@@ -1,80 +0,0 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
interface AccountTotal {
account_number: string
debit: number
credit: number
net: number
}
interface MonthlyTotal {
month: string
account_number: string
debit: number
credit: number
net: number
}
interface UseAccountTotalsOptions {
from: string
to: string
dateFrom?: string
dateTo?: string
groupBy?: 'month'
}
export function useAccountTotals(options: UseAccountTotalsOptions) {
const [totals, setTotals] = useState<AccountTotal[]>([])
const [monthly, setMonthly] = useState<MonthlyTotal[]>([])
const [isLoading, setIsLoading] = useState(true)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => { mountedRef.current = false }
}, [])
const refresh = useCallback(async () => {
setIsLoading(true)
try {
const params = new URLSearchParams({
from: options.from,
to: options.to,
})
if (options.dateFrom) params.set('date_from', options.dateFrom)
if (options.dateTo) params.set('date_to', options.dateTo)
if (options.groupBy) params.set('group_by', options.groupBy)
const res = await fetch(`/api/bookkeeping/account-totals?${params}`)
if (res.ok) {
const json = await res.json()
if (mountedRef.current) {
setTotals(json.totals ?? [])
setMonthly(json.monthly ?? [])
}
}
} finally {
if (mountedRef.current) setIsLoading(false)
}
}, [options.from, options.to, options.dateFrom, options.dateTo, options.groupBy])
useEffect(() => {
refresh()
}, [refresh])
const totalDebit = totals.reduce((sum, t) => sum + t.debit, 0)
const totalCredit = totals.reduce((sum, t) => sum + t.credit, 0)
const totalNet = totals.reduce((sum, t) => sum + t.net, 0)
return {
totals,
monthly,
isLoading,
totalDebit: Math.round(totalDebit * 100) / 100,
totalCredit: Math.round(totalCredit * 100) / 100,
totalNet: Math.round(totalNet * 100) / 100,
refresh,
}
}
-85
View File
@@ -1,85 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useExtensionData } from './use-extension-data'
interface MockMeta {
importedAt: string
source: 'csv' | 'json'
fileName: string
rowCount: number
}
interface UseMockDataResult<T> {
mockReport: T | null
isMockActive: boolean
isLoading: boolean
importedAt: string | null
meta: MockMeta | null
saveMockData: (report: T, meta: Omit<MockMeta, 'importedAt'>) => Promise<void>
clearMockData: () => Promise<void>
}
export function useMockData<T>(sector: string, slug: string): UseMockDataResult<T> {
const { getByKey, save, remove, isLoading } = useExtensionData(sector, slug)
const [mockReport, setMockReport] = useState<T | null>(null)
const [isMockActive, setIsMockActive] = useState(false)
const [meta, setMeta] = useState<MockMeta | null>(null)
// Read mock state from extension data on load
useEffect(() => {
if (isLoading) return
const enabledRecord = getByKey('mock:enabled')
const reportRecord = getByKey('mock:report')
const metaRecord = getByKey('mock:meta')
if (enabledRecord && (enabledRecord.value as { enabled?: boolean }).enabled && reportRecord) {
setIsMockActive(true)
setMockReport(reportRecord.value as T)
if (metaRecord) {
setMeta(metaRecord.value as unknown as MockMeta)
}
} else {
setIsMockActive(false)
setMockReport(null)
setMeta(null)
}
}, [isLoading, getByKey])
const saveMockData = useCallback(async (report: T, metaInput: Omit<MockMeta, 'importedAt'>) => {
const fullMeta: MockMeta = {
...metaInput,
importedAt: new Date().toISOString(),
}
await save('mock:enabled', { enabled: true })
await save('mock:report', report as unknown as Record<string, unknown>)
await save('mock:meta', fullMeta as unknown as Record<string, unknown>)
setIsMockActive(true)
setMockReport(report)
setMeta(fullMeta)
}, [save])
const clearMockData = useCallback(async () => {
await remove('mock:enabled')
await remove('mock:report')
await remove('mock:meta')
setIsMockActive(false)
setMockReport(null)
setMeta(null)
}, [remove])
return {
mockReport,
isMockActive,
isLoading,
importedAt: meta?.importedAt ?? null,
meta,
saveMockData,
clearMockData,
}
}
-148
View File
@@ -1,148 +0,0 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { JournalEntry, JournalEntryLine } from '@/types'
/**
* SRU aggregation engine
*
* Fetches posted journal entries for a fiscal period, computes net balance
* per account, and groups by sru_code from chart_of_accounts.
*/
export interface SRUBalance {
sruCode: string
amount: number
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
}
export interface SRUCoverageStats {
totalAccounts: number
accountsWithSRU: number
accountsWithoutSRU: number
coveragePercent: number
missingAccounts: Array<{
accountNumber: string
accountName: string
}>
}
/**
* Aggregate account balances by SRU code for a given fiscal period.
* Returns a Map of sru_code → summed amount, plus per-account detail.
*/
export async function aggregateBalancesBySRU(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<Map<string, SRUBalance>> {
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts with SRU codes
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null; normal_balance: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code, normal_balance')
.eq('user_id', userId)
.eq('is_active', true)
.range(from, to)
)
// Build lookup maps
const accountSRUMap = new Map<string, string>()
const accountNameMap = new Map<string, string>()
for (const acc of accounts) {
if (acc.sru_code) {
accountSRUMap.set(acc.account_number, acc.sru_code)
}
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate net balances per account (debit - credit)
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Group balances by SRU code
const sruBalances = new Map<string, SRUBalance>()
for (const [accountNumber, balance] of accountBalances) {
if (Math.abs(balance) < 0.01) continue
const sruCode = accountSRUMap.get(accountNumber)
if (!sruCode) continue
let entry = sruBalances.get(sruCode)
if (!entry) {
entry = { sruCode, amount: 0, accounts: [] }
sruBalances.set(sruCode, entry)
}
entry.amount += balance
entry.accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: Math.round(balance),
})
}
// Round totals
for (const entry of sruBalances.values()) {
entry.amount = Math.round(entry.amount)
}
return sruBalances
}
/**
* Get SRU code coverage stats for a user's chart of accounts.
* Returns how many accounts have vs lack SRU codes.
*/
export async function getSRUCoverage(supabase: SupabaseClient, userId: string): Promise<SRUCoverageStats> {
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
const withSRU = accounts.filter((a) => a.sru_code)
const withoutSRU = accounts.filter((a) => !a.sru_code)
return {
totalAccounts: accounts.length,
accountsWithSRU: withSRU.length,
accountsWithoutSRU: withoutSRU.length,
coveragePercent: accounts.length > 0
? Math.round((withSRU.length / accounts.length) * 100)
: 0,
missingAccounts: withoutSRU.map((a) => ({
accountNumber: a.account_number,
accountName: a.account_name,
})),
}
}
@@ -1,140 +0,0 @@
import type { SRUFile, SRURecord } from '@/lib/reports/ne-bilaga/types'
import { sruFileToString, validateSRUFile } from './sru-generator'
import type { SRUBalance } from './sru-engine'
/**
* Generic SRU file generator
*
* Generates SRU files from aggregated SRU balances for any form type
* (NE for enskild firma, INK2 for aktiebolag).
*
* Reuses sruFileToString() and validateSRUFile() from the existing
* NE-specific generator.
*/
export type SRUFormType = 'NE' | 'INK2'
export interface GenericSRUParams {
formType: SRUFormType
orgNumber: string | null
companyName: string
fiscalYearStart: string // YYYY-MM-DD
fiscalYearEnd: string // YYYY-MM-DD
sruBalances: Map<string, SRUBalance>
}
/**
* SRU code descriptions for display
*/
export const SRU_CODE_DESCRIPTIONS: Record<string, string> = {
// NE form (EF)
'7310': 'Försäljning med moms',
'7311': 'Momsfria intäkter',
'7312': 'Bil/bostadsförmån',
'7313': 'Ränteintäkter',
'7320': 'Varuinköp',
'7321': 'Övriga kostnader',
'7322': 'Lönekostnader',
'7323': 'Räntekostnader',
'7324': 'Avskrivningar fastighet',
'7325': 'Avskrivningar övrigt',
'7350': 'Årets resultat',
// INK2 form (AB) — balance sheet
'7201': 'Immateriella anläggningstillgångar',
'7202': 'Materiella anläggningstillgångar',
'7203': 'Finansiella anläggningstillgångar',
'7210': 'Varulager',
'7211': 'Kundfordringar',
'7212': 'Övriga omsättningstillgångar',
'7220': 'Aktiekapital',
'7221': 'Övrigt eget kapital',
'7222': 'Årets resultat',
'7230': 'Skulder',
'7231': 'Övriga skulder',
// INK2 form (AB) — income statement
'7330': 'Övriga externa kostnader',
'7340': 'Personalkostnader',
'7360': 'Övriga rörelsekostnader',
'7370': 'Finansiella poster',
'7380': 'Extraordinära poster',
}
/**
* Generate a generic SRU file from aggregated SRU balances.
*/
export function generateGenericSRU(params: GenericSRUParams): SRUFile {
const { formType, orgNumber, fiscalYearStart, fiscalYearEnd, sruBalances } = params
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({ fieldCode: 'SKAPAT', value: formatSRUDate(now) })
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: formType })
// Company identification
if (orgNumber) {
const cleanOrgNumber = orgNumber.replace(/-/g, '')
records.push({ fieldCode: 'IDENTITET', value: cleanOrgNumber })
}
// Fiscal year
const startSRU = fiscalYearStart.replace(/-/g, '')
const endSRU = fiscalYearEnd.replace(/-/g, '')
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${startSRU}-${endSRU}`,
})
// SRU balance entries — one #UPPGIFT per non-zero SRU code
const sortedEntries = Array.from(sruBalances.entries())
.sort(([a], [b]) => a.localeCompare(b))
for (const [sruCode, balance] of sortedEntries) {
if (balance.amount !== 0) {
records.push({
fieldCode: 'UPPGIFT',
value: `${sruCode} ${Math.round(balance.amount)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Get filename for generic SRU file download
*/
export function getGenericSRUFilename(
formType: SRUFormType,
orgNumber: string | null,
fiscalYearStart: string
): string {
const year = fiscalYearStart.substring(0, 4)
const cleanOrg = orgNumber?.replace(/-/g, '') || 'unknown'
return `${formType}_${cleanOrg}_${year}.sru`
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
// Re-export helpers from the existing SRU generator
export { sruFileToString, validateSRUFile }
-28
View File
@@ -1,28 +0,0 @@
import type { EntityType } from '@/types'
// Generic SRU export types
export interface SRUExportResult {
formType: 'NE' | 'INK2'
entityType: EntityType
companyName: string | null
orgNumber: string | null
fiscalYear: {
id: string
name: string
start: string
end: string
}
balances: Array<{
sruCode: string
description: string
amount: number
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
}>
warnings: string[]
}
export { type SRUCoverageStats } from './sru-engine'
+115 -25
View File
@@ -1,6 +1,6 @@
import { suggestCategory } from '@/lib/tax/expense-warnings'
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { extensionRegistry } from '@/lib/extensions/registry'
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
@@ -246,42 +246,132 @@ export interface SuggestedTemplate {
requires_review: boolean
}
/**
* Get recently used templates from mapping rules.
* Extracts unique template_id values and returns them as suggestions.
*/
export function getRecentlyUsedTemplates(
mappingRules: MappingRule[],
entityType?: EntityType,
direction?: 'expense' | 'income' | 'transfer'
): SuggestedTemplate[] {
const seen = new Set<string>()
const results: SuggestedTemplate[] = []
// Sort by most recent (highest priority first)
const sorted = [...mappingRules]
.filter((r) => r.is_active && r.template_id)
.sort((a, b) => (b.confidence_score || 0) - (a.confidence_score || 0))
for (const rule of sorted) {
if (!rule.template_id || seen.has(rule.template_id)) continue
seen.add(rule.template_id)
const template = getTemplateById(rule.template_id)
if (!template) continue
// Filter by entity applicability
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) continue
// Filter by direction
if (direction && template.direction !== direction && template.direction !== 'transfer') continue
results.push({
template_id: template.id,
name_sv: template.name_sv,
name_en: template.name_en,
group: template.group,
debit_account: template.debit_account,
credit_account: template.credit_account,
confidence: 0.85,
description_sv: template.description_sv,
risk_level: template.risk_level,
requires_review: template.requires_review,
})
if (results.length >= 5) break
}
return results
}
/**
* Get suggested booking templates for a transaction.
* Tries embedding-based semantic search first, falls back to keyword matching.
* Keyword matching as primary, AI embedding search as optional enhancer.
*/
export async function getSuggestedTemplates(
transaction: Transaction,
entityType?: EntityType
entityType?: EntityType,
mappingRules?: MappingRule[]
): Promise<SuggestedTemplate[]> {
let matches: TemplateMatch[]
const seen = new Set<string>()
const results: SuggestedTemplate[] = []
// 1. Boost recently-used templates from mapping rules
if (mappingRules) {
const direction = transaction.amount < 0 ? 'expense' : 'income'
const recent = getRecentlyUsedTemplates(mappingRules, entityType, direction)
for (const r of recent) {
if (!seen.has(r.template_id)) {
seen.add(r.template_id)
results.push(r)
}
}
}
// 2. Keyword + MCC matching (always available, no API keys needed)
const keywordMatches = findMatchingTemplates(transaction, entityType)
for (const m of keywordMatches) {
if (!seen.has(m.template.id)) {
seen.add(m.template.id)
results.push({
template_id: m.template.id,
name_sv: m.template.name_sv,
name_en: m.template.name_en,
group: m.template.group,
debit_account: m.template.debit_account,
credit_account: m.template.credit_account,
confidence: m.confidence,
description_sv: m.template.description_sv,
risk_level: m.template.risk_level,
requires_review: m.template.requires_review,
})
}
}
// 3. If AI extension loaded, merge in embedding-based matches (higher confidence)
try {
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.findSimilarTemplates) {
matches = await aiExt.services.findSimilarTemplates(transaction, entityType)
} else {
matches = []
const aiMatches: TemplateMatch[] = await aiExt.services.findSimilarTemplates(transaction, entityType)
for (const m of aiMatches) {
const existing = results.find((r) => r.template_id === m.template.id)
if (existing) {
// AI match upgrades confidence if higher
if (m.confidence > existing.confidence) {
existing.confidence = m.confidence
}
} else {
results.push({
template_id: m.template.id,
name_sv: m.template.name_sv,
name_en: m.template.name_en,
group: m.template.group,
debit_account: m.template.debit_account,
credit_account: m.template.credit_account,
confidence: m.confidence,
description_sv: m.template.description_sv,
risk_level: m.template.risk_level,
requires_review: m.template.requires_review,
})
}
}
}
} catch {
matches = []
// AI enhancement is non-blocking
}
// Fall back to keyword matching if embedding search returns nothing
if (matches.length === 0) {
matches = findMatchingTemplates(transaction, entityType)
}
return matches.map((m: TemplateMatch) => ({
template_id: m.template.id,
name_sv: m.template.name_sv,
name_en: m.template.name_en,
group: m.template.group,
debit_account: m.template.debit_account,
credit_account: m.template.credit_account,
confidence: m.confidence,
description_sv: m.template.description_sv,
risk_level: m.template.risk_level,
requires_review: m.template.requires_review,
}))
return results
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 10)
}
+35 -35
View File
@@ -12,31 +12,31 @@
/** Momsdeklaration box number */
export type MomsBox =
| '05' // Momspliktig forsaljning (taxable sales)
| '05' // Momspliktig försäljning (taxable sales)
| '06' // Momspliktiga uttag (taxable withdrawals)
| '07' // Vinstmarginalbeskattning (margin scheme)
| '08' // Hyresinkomster frivillig beskattning (rental)
| '10' // Utgaende moms 25%
| '11' // Utgaende moms 12%
| '12' // Utgaende moms 6%
| '20' // Inkop varor fran EU
| '21' // Inkop tjanster fran EU
| '22' // Inkop tjanster utanfor EU
| '23' // Inkop varor Sverige omvand skattskyldighet
| '24' // Inkop tjanster Sverige omvand skattskyldighet
| '30' // Utgaende moms inkop 25%
| '31' // Utgaende moms inkop 12%
| '32' // Utgaende moms inkop 6%
| '35' // Varuforssaljning till annat EU-land
| '36' // Varuforssaljning utanfor EU (export)
| '37' // Mellanmans inkop trepartshandel
| '38' // Mellanmans forsaljning trepartshandel
| '39' // Tjansteforssaljning EU (huvudregeln)
| '40' // Ovrig forsaljning av tjanster utomlands
| '41' // Forsaljning omvand skattskyldighet Sverige
| '42' // Ovrig forsaljning m.m.
| '48' // Ingaende moms att dra av
| '49' // Moms att betala eller fa tillbaka
| '10' // Utgående moms 25%
| '11' // Utgående moms 12%
| '12' // Utgående moms 6%
| '20' // Inköp varor från EU
| '21' // Inköp tjänster från EU
| '22' // Inköp tjänster utanför EU
| '23' // Inköp varor Sverige omvänd skattskyldighet
| '24' // Inköp tjänster Sverige omvänd skattskyldighet
| '30' // Utgående moms inköp 25%
| '31' // Utgående moms inköp 12%
| '32' // Utgående moms inköp 6%
| '35' // Varuförsäljning till annat EU-land
| '36' // Varuförsäljning utanför EU (export)
| '37' // Mellanmans inköp trepartshandel
| '38' // Mellanmans försäljning trepartshandel
| '39' // Tjänsteförsäljning EU (huvudregeln)
| '40' // Övrig försäljning av tjänster utomlands
| '41' // Försäljning omvänd skattskyldighet Sverige
| '42' // Övrig försäljning m.m.
| '48' // Ingående moms att dra av
| '49' // Moms att betala eller få tillbaka
| '50' // Importbeskattningsunderlag
| '60' // Importmoms 25%
| '61' // Importmoms 12%
@@ -45,35 +45,35 @@ export type MomsBox =
/** Map BAS revenue account to momsdeklaration box */
export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
// Domestic revenue (taxable) → Box 05
'3001': '05', // Forsaljning varor/tjanster 25%
'3002': '05', // Forsaljning varor/tjanster 12%
'3003': '05', // Forsaljning varor/tjanster 6%
'3001': '05', // Försäljning varor/tjänster 25%
'3002': '05', // Försäljning varor/tjänster 12%
'3003': '05', // Försäljning varor/tjänster 6%
// EU goods (reverse charge, VAT-free) → Box 35
'3108': '35', // Forsaljning varor till annat EU-land
'3108': '35', // Försäljning varor till annat EU-land
'3521': '35', // Fakturerade frakter EU (follows goods treatment)
// Non-EU goods export (zero-rated) → Box 36
'3105': '36', // Forsaljning varor export utanfor EU
'3105': '36', // Försäljning varor export utanför EU
'3522': '36', // Fakturerade frakter export
// Triangular trade → Box 38
'3109': '38', // Mellanmans forsaljning trepartshandel
'3109': '38', // Mellanmans försäljning trepartshandel
// EU services (reverse charge, main rule) → Box 39
'3308': '39', // Forsaljning tjanster EU
'3308': '39', // Försäljning tjänster EU
// Non-EU services → Box 40
'3305': '40', // Forsaljning tjanster export utanfor EU
'3305': '40', // Försäljning tjänster export utanför EU
// Output VAT → Boxes 10, 11, 12
'2611': '10', // Utgaende moms 25%
'2621': '11', // Utgaende moms 12%
'2631': '12', // Utgaende moms 6%
'2611': '10', // Utgående moms 25%
'2621': '11', // Utgående moms 12%
'2631': '12', // Utgående moms 6%
// Input VAT → Box 48
'2641': '48', // Ingaende moms
'2645': '48', // Beraknad ingaende moms (EU forvarv)
'2641': '48', // Ingående moms
'2645': '48', // Beräknad ingående moms (EU förvärv)
}
/** Swedish labels for each momsdeklaration box */
-56
View File
@@ -1,56 +0,0 @@
{
"period": { "year": 2025, "month": 12 },
"boxes": [
{ "boxNumber": "05", "label": "Momspliktiga intakter", "amount": 1850000, "accounts": ["3001", "3002", "3003"] },
{ "boxNumber": "10", "label": "Utgaende moms 25%", "amount": 375000, "accounts": ["2611"] },
{ "boxNumber": "11", "label": "Utgaende moms 12%", "amount": 18000, "accounts": ["2621"] },
{ "boxNumber": "12", "label": "Utgaende moms 6%", "amount": 4500, "accounts": ["2631"] },
{ "boxNumber": "35", "label": "Varuforsal jning till annat EU-land", "amount": 711500, "accounts": ["3305"] },
{ "boxNumber": "36", "label": "Tjansteforsal jning till annat EU-land", "amount": 405000, "accounts": ["3308"] },
{ "boxNumber": "38", "label": "Exportforsal jning utanfor EU", "amount": 230000, "accounts": ["3305"] },
{ "boxNumber": "39", "label": "Omvand skattskyldighet — inkop", "amount": 60000, "accounts": [] },
{ "boxNumber": "40", "label": "Inkop varor fran EU", "amount": 185000, "accounts": ["4515"] },
{ "boxNumber": "48", "label": "Ingaende moms", "amount": 289000, "accounts": ["2641", "2645"] },
{ "boxNumber": "49", "label": "Moms att betala", "amount": 108500, "accounts": [] }
],
"revenueBreakdown": {
"domestic": { "amount": 1850000, "percentage": 57 },
"euGoods": { "amount": 711500, "percentage": 22 },
"euServices": { "amount": 405000, "percentage": 12 },
"exportGoods": { "amount": 230000, "percentage": 7 },
"exportServices": { "amount": 0, "percentage": 0 },
"triangular": { "amount": 54000, "percentage": 2 },
"totalRevenue": 3250500
},
"vatSummary": {
"outputVat25": 375000,
"outputVat12": 18000,
"outputVat6": 4500,
"totalOutputVat": 397500,
"inputVat": 289000,
"netVat": 108500,
"isRefund": false
},
"warnings": [
{
"type": "box_mismatch",
"severity": "warning",
"message": "Ruta 39 (omvand skattskyldighet) har 60 000 SEK men inga matchande kontoposter hittades. Kontrollera bokforingen."
},
{
"type": "high_input_vat_ratio",
"severity": "warning",
"message": "Ingaende moms (289 000 SEK) utgor 73% av utgaende moms. Kontrollera att alla avdrag ar korrekta."
}
],
"comparison": {
"domestic": { "current": 1850000, "previous": 1620000, "change": 230000, "changePercent": 14 },
"euGoods": { "current": 711500, "previous": 580000, "change": 131500, "changePercent": 23 },
"euServices": { "current": 405000, "previous": 390000, "change": 15000, "changePercent": 4 },
"exportGoods": { "current": 230000, "previous": 310000, "change": -80000, "changePercent": -26 },
"exportServices": { "current": 0, "previous": 0, "change": 0, "changePercent": null },
"triangular": { "current": 54000, "previous": 0, "change": 54000, "changePercent": null },
"totalRevenue": { "current": 3250500, "previous": 2900000, "change": 350500, "changePercent": 12 },
"netVat": { "current": 108500, "previous": 95200, "change": 13300, "changePercent": 14 }
}
}
-116
View File
@@ -1,116 +0,0 @@
{
"period": { "year": 2025, "month": 12 },
"reporterVatNumber": "SE556677889901",
"reporterName": "Testbolaget AB",
"lines": [
{
"cnCode": "72163100",
"partnerCountry": "DE",
"countryOfOrigin": "SE",
"transactionNature": "11",
"deliveryTerms": "DAP",
"invoicedValue": 245000,
"netMass": 4500,
"supplementaryUnit": null,
"supplementaryUnitType": null,
"partnerVatId": "DE123456789"
},
{
"cnCode": "84713000",
"partnerCountry": "FR",
"countryOfOrigin": "CN",
"transactionNature": "11",
"deliveryTerms": "EXW",
"invoicedValue": 128000,
"netMass": 85,
"supplementaryUnit": 40,
"supplementaryUnitType": "st",
"partnerVatId": "FR98765432101"
},
{
"cnCode": "39269090",
"partnerCountry": "NL",
"countryOfOrigin": "SE",
"transactionNature": "11",
"deliveryTerms": "FCA",
"invoicedValue": 78000,
"netMass": 620,
"supplementaryUnit": null,
"supplementaryUnitType": null,
"partnerVatId": "NL456789012B01"
},
{
"cnCode": "85176200",
"partnerCountry": "FI",
"countryOfOrigin": "SE",
"transactionNature": "11",
"deliveryTerms": "DAP",
"invoicedValue": 56000,
"netMass": 12,
"supplementaryUnit": 200,
"supplementaryUnitType": "st",
"partnerVatId": "FI12345678"
},
{
"cnCode": "72163100",
"partnerCountry": "ES",
"countryOfOrigin": "SE",
"transactionNature": "11",
"deliveryTerms": "CIF",
"invoicedValue": 132000,
"netMass": 2800,
"supplementaryUnit": null,
"supplementaryUnitType": null,
"partnerVatId": "ES87654321A"
},
{
"cnCode": "73064090",
"partnerCountry": "IT",
"countryOfOrigin": "SE",
"transactionNature": "11",
"deliveryTerms": "DAP",
"invoicedValue": 67500,
"netMass": 1450,
"supplementaryUnit": null,
"supplementaryUnitType": null,
"partnerVatId": "IT01234567890"
},
{
"cnCode": "44079910",
"partnerCountry": "PL",
"countryOfOrigin": "SE",
"transactionNature": "11",
"deliveryTerms": "FCA",
"invoicedValue": 189000,
"netMass": 18600,
"supplementaryUnit": null,
"supplementaryUnitType": null,
"partnerVatId": "PL5678901234"
},
{
"cnCode": "84713000",
"partnerCountry": "DE",
"countryOfOrigin": "TW",
"transactionNature": "11",
"deliveryTerms": "DAP",
"invoicedValue": 94000,
"netMass": 62,
"supplementaryUnit": 30,
"supplementaryUnitType": "st",
"partnerVatId": "DE123456789"
}
],
"totals": {
"invoicedValue": 989500,
"netMass": 28129,
"lineCount": 8
},
"thresholdStatus": {
"cumulativeValue": 7850000,
"threshold": 9000000,
"isObligated": false,
"percentageUsed": 87
},
"warnings": [],
"invoiceCount": 14
}
-115
View File
@@ -1,115 +0,0 @@
{
"period": { "year": 2025, "quarter": 4 },
"filingType": "quarterly",
"reporterVatNumber": "SE556677889901",
"reporterName": "Testbolaget AB",
"lines": [
{
"customerVatNumber": "DE123456789",
"customerName": "Berliner Maschinenbau GmbH",
"customerCountry": "DE",
"customerId": "cust-001",
"goodsAmount": 245000,
"servicesAmount": 0,
"triangulationAmount": 0,
"invoiceCount": 4
},
{
"customerVatNumber": "FR98765432101",
"customerName": "Lyon Digital SARL",
"customerCountry": "FR",
"customerId": "cust-002",
"goodsAmount": 0,
"servicesAmount": 185000,
"triangulationAmount": 0,
"invoiceCount": 3
},
{
"customerVatNumber": "NL456789012B01",
"customerName": "Amsterdam Trading BV",
"customerCountry": "NL",
"customerId": "cust-003",
"goodsAmount": 78000,
"servicesAmount": 42000,
"triangulationAmount": 0,
"invoiceCount": 2
},
{
"customerVatNumber": "FI12345678",
"customerName": "Helsinki Solutions Oy",
"customerCountry": "FI",
"customerId": "cust-004",
"goodsAmount": 0,
"servicesAmount": 96000,
"triangulationAmount": 0,
"invoiceCount": 1
},
{
"customerVatNumber": "ES87654321A",
"customerName": "Barcelona Componentes SL",
"customerCountry": "ES",
"customerId": "cust-005",
"goodsAmount": 132000,
"servicesAmount": 0,
"triangulationAmount": 54000,
"invoiceCount": 3
},
{
"customerVatNumber": "IT01234567890",
"customerName": "Milano Engineering SpA",
"customerCountry": "IT",
"customerId": "cust-006",
"goodsAmount": 67500,
"servicesAmount": 28000,
"triangulationAmount": 0,
"invoiceCount": 2
},
{
"customerVatNumber": "PL5678901234",
"customerName": "Warszawa Logistik Sp. z o.o.",
"customerCountry": "PL",
"customerId": "cust-007",
"goodsAmount": 189000,
"servicesAmount": 0,
"triangulationAmount": 0,
"invoiceCount": 5
},
{
"customerVatNumber": "DK12345678",
"customerName": "Kobenhavn Konsult ApS",
"customerCountry": "DK",
"customerId": "cust-008",
"goodsAmount": 0,
"servicesAmount": 54000,
"triangulationAmount": 0,
"invoiceCount": 1
}
],
"totals": {
"goods": 711500,
"services": 405000,
"triangulation": 54000,
"total": 1170500
},
"warnings": [
{
"type": "missing_vat_validation",
"severity": "warning",
"customerId": "cust-005",
"customerName": "Barcelona Componentes SL",
"message": "VAT-nummer ES87654321A har inte validerats mot VIES. Verifiera innan inlämning."
}
],
"crossCheck": {
"box35Match": true,
"box35ReportTotal": 711500,
"box35GLTotal": 711500,
"box39Match": false,
"box39ReportTotal": 405000,
"box39GLTotal": 403800
},
"invoiceCount": 21,
"customerCount": 8,
"deadline": "2026-02-20",
"daysUntilDeadline": 14
}
-245
View File
@@ -1,245 +0,0 @@
{
"referenceDate": "2025-12-15",
"exchangeRates": [
{ "currency": "EUR", "rate": 11.4215, "date": "2025-12-15" },
{ "currency": "USD", "rate": 10.3870, "date": "2025-12-15" },
{ "currency": "GBP", "rate": 13.5420, "date": "2025-12-15" },
{ "currency": "NOK", "rate": 0.9845, "date": "2025-12-15" }
],
"exposureByCurrency": [
{
"currency": "EUR",
"totalForeignAmount": 48500,
"bookedSekValue": 541350,
"currentSekValue": 553943,
"unrealizedGainLoss": 12593,
"invoiceCount": 4,
"averageBookedRate": 11.1619,
"currentRate": 11.4215
},
{
"currency": "USD",
"totalForeignAmount": 72000,
"bookedSekValue": 741600,
"currentSekValue": 747864,
"unrealizedGainLoss": 6264,
"invoiceCount": 3,
"averageBookedRate": 10.3000,
"currentRate": 10.3870
},
{
"currency": "GBP",
"totalForeignAmount": 15000,
"bookedSekValue": 199500,
"currentSekValue": 203130,
"unrealizedGainLoss": 3630,
"invoiceCount": 1,
"averageBookedRate": 13.3000,
"currentRate": 13.5420
},
{
"currency": "NOK",
"totalForeignAmount": 320000,
"bookedSekValue": 316800,
"currentSekValue": 315040,
"unrealizedGainLoss": -1760,
"invoiceCount": 2,
"averageBookedRate": 0.9900,
"currentRate": 0.9845
}
],
"receivables": [
{
"invoiceId": "inv-1001",
"invoiceNumber": "1001",
"customerName": "Berliner Maschinenbau GmbH",
"customerCountry": "DE",
"currency": "EUR",
"foreignAmount": 22000,
"bookedSekAmount": 245300,
"bookedRate": 11.15,
"currentSekAmount": 251273,
"currentRate": 11.4215,
"unrealizedGainLoss": 5973,
"invoiceDate": "2025-10-15",
"dueDate": "2025-12-15",
"daysOutstanding": 61
},
{
"invoiceId": "inv-1008",
"invoiceNumber": "1008",
"customerName": "Lyon Digital SARL",
"customerCountry": "FR",
"currency": "EUR",
"foreignAmount": 14500,
"bookedSekAmount": 163050,
"bookedRate": 11.245,
"currentSekAmount": 165612,
"currentRate": 11.4215,
"unrealizedGainLoss": 2562,
"invoiceDate": "2025-11-05",
"dueDate": "2026-01-05",
"daysOutstanding": 40
},
{
"invoiceId": "inv-1012",
"invoiceNumber": "1012",
"customerName": "Amsterdam Trading BV",
"customerCountry": "NL",
"currency": "EUR",
"foreignAmount": 8000,
"bookedSekAmount": 89600,
"bookedRate": 11.20,
"currentSekAmount": 91372,
"currentRate": 11.4215,
"unrealizedGainLoss": 1772,
"invoiceDate": "2025-11-20",
"dueDate": "2025-12-20",
"daysOutstanding": 25
},
{
"invoiceId": "inv-1015",
"invoiceNumber": "1015",
"customerName": "Helsinki Solutions Oy",
"customerCountry": "FI",
"currency": "EUR",
"foreignAmount": 4000,
"bookedSekAmount": 43400,
"bookedRate": 10.85,
"currentSekAmount": 45686,
"currentRate": 11.4215,
"unrealizedGainLoss": 2286,
"invoiceDate": "2025-12-01",
"dueDate": "2026-01-01",
"daysOutstanding": 14
},
{
"invoiceId": "inv-1003",
"invoiceNumber": "1003",
"customerName": "New York Consulting Inc",
"customerCountry": "US",
"currency": "USD",
"foreignAmount": 35000,
"bookedSekAmount": 360500,
"bookedRate": 10.30,
"currentSekAmount": 363545,
"currentRate": 10.3870,
"unrealizedGainLoss": 3045,
"invoiceDate": "2025-09-28",
"dueDate": "2025-11-28",
"daysOutstanding": 78
},
{
"invoiceId": "inv-1009",
"invoiceNumber": "1009",
"customerName": "Chicago Parts LLC",
"customerCountry": "US",
"currency": "USD",
"foreignAmount": 22000,
"bookedSekAmount": 224400,
"bookedRate": 10.20,
"currentSekAmount": 228514,
"currentRate": 10.3870,
"unrealizedGainLoss": 4114,
"invoiceDate": "2025-10-20",
"dueDate": "2025-12-20",
"daysOutstanding": 56
},
{
"invoiceId": "inv-1018",
"invoiceNumber": "1018",
"customerName": "San Francisco Tech Corp",
"customerCountry": "US",
"currency": "USD",
"foreignAmount": 15000,
"bookedSekAmount": 156700,
"bookedRate": 10.4467,
"currentSekAmount": 155805,
"currentRate": 10.3870,
"unrealizedGainLoss": -895,
"invoiceDate": "2025-12-05",
"dueDate": "2026-02-05",
"daysOutstanding": 10
},
{
"invoiceId": "inv-1010",
"invoiceNumber": "1010",
"customerName": "London Engineering Ltd",
"customerCountry": "GB",
"currency": "GBP",
"foreignAmount": 15000,
"bookedSekAmount": 199500,
"bookedRate": 13.30,
"currentSekAmount": 203130,
"currentRate": 13.5420,
"unrealizedGainLoss": 3630,
"invoiceDate": "2025-11-01",
"dueDate": "2026-01-01",
"daysOutstanding": 44
},
{
"invoiceId": "inv-1005",
"invoiceNumber": "1005",
"customerName": "Oslo Shipping AS",
"customerCountry": "NO",
"currency": "NOK",
"foreignAmount": 200000,
"bookedSekAmount": 198000,
"bookedRate": 0.99,
"currentSekAmount": 196900,
"currentRate": 0.9845,
"unrealizedGainLoss": -1100,
"invoiceDate": "2025-10-10",
"dueDate": "2025-12-10",
"daysOutstanding": 66
},
{
"invoiceId": "inv-1016",
"invoiceNumber": "1016",
"customerName": "Bergen Industri AS",
"customerCountry": "NO",
"currency": "NOK",
"foreignAmount": 120000,
"bookedSekAmount": 118800,
"bookedRate": 0.99,
"currentSekAmount": 118140,
"currentRate": 0.9845,
"unrealizedGainLoss": -660,
"invoiceDate": "2025-11-22",
"dueDate": "2026-01-22",
"daysOutstanding": 23
}
],
"realizedGainLoss": {
"year": 2025,
"gains": 28450,
"losses": 7820,
"net": 20630
},
"monthlyTrend": [
{ "month": "2025-01", "realizedGains": 1200, "realizedLosses": 0, "netRealized": 1200 },
{ "month": "2025-02", "realizedGains": 0, "realizedLosses": 890, "netRealized": -890 },
{ "month": "2025-03", "realizedGains": 3400, "realizedLosses": 0, "netRealized": 3400 },
{ "month": "2025-04", "realizedGains": 2100, "realizedLosses": 1250, "netRealized": 850 },
{ "month": "2025-05", "realizedGains": 0, "realizedLosses": 2300, "netRealized": -2300 },
{ "month": "2025-06", "realizedGains": 4500, "realizedLosses": 0, "netRealized": 4500 },
{ "month": "2025-07", "realizedGains": 1850, "realizedLosses": 680, "netRealized": 1170 },
{ "month": "2025-08", "realizedGains": 3200, "realizedLosses": 0, "netRealized": 3200 },
{ "month": "2025-09", "realizedGains": 5600, "realizedLosses": 1400, "netRealized": 4200 },
{ "month": "2025-10", "realizedGains": 2800, "realizedLosses": 0, "netRealized": 2800 },
{ "month": "2025-11", "realizedGains": 1500, "realizedLosses": 1300, "netRealized": 200 },
{ "month": "2025-12", "realizedGains": 2300, "realizedLosses": 0, "netRealized": 2300 }
],
"revalPreview": {
"totalUnrealizedGainLoss": 20727,
"gains": 22487,
"losses": 1760
},
"totals": {
"bookedSekValue": 1799250,
"currentSekValue": 1819977,
"totalUnrealizedGainLoss": 20727,
"receivableCount": 10,
"currencyCount": 4
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ERP Base",
"short_name": "ERPBase",
"name": "Gnubok",
"short_name": "Gnubok",
"description": "Ekonomihantering",
"start_url": "/",
"display": "standalone",

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