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:
co-authored by
Claude Opus 4.6
parent
a9bf24ce0b
commit
66a4027f1e
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{searchTerm}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 "obokförda" 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 "Förhandsgranska" 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} · 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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
{"$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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user