-
+
updateLine(index, 'account_number', num)}
/>
-
+
updateLine(index, 'line_description', e.target.value)}
@@ -280,7 +295,7 @@ export default function JournalEntryForm({
className="h-8"
/>
-
+
-
+
-
+
-
+
Summa
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
@@ -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.'}
>
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}
/>
@@ -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å"
>
Inget underlag bifogat
- 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.
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx
index 579f17c9..d41ed9d6 100644
--- a/components/bookkeeping/JournalEntryList.tsx
+++ b/components/bookkeeping/JournalEntryList.tsx
@@ -81,39 +81,6 @@ export default function JournalEntryList({ periodId }: Props) {
setExpandedId(expandedId === id ? null : id)
}
- const statusLabel = (status: string) => {
- switch (status) {
- case 'posted':
- return Bokförd
- case 'draft':
- return Utkast
- case 'reversed':
- return Makulerad
- default:
- return {status}
- }
- }
-
- const sourceLabel = (source: string) => {
- const labels: Record = {
- 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 (
@@ -199,10 +166,6 @@ export default function JournalEntryList({ periodId }: Props) {
)
)}
-
- {sourceLabel(entry.source_type)}
-
- {statusLabel(entry.status)}
diff --git a/components/bookkeeping/JournalEntryReviewContent.tsx b/components/bookkeeping/JournalEntryReviewContent.tsx
index 2223f9cf..7e6e42ca 100644
--- a/components/bookkeeping/JournalEntryReviewContent.tsx
+++ b/components/bookkeeping/JournalEntryReviewContent.tsx
@@ -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({
{/* Header info */}
-
+
Räkenskapsår
{periodName}
-
+ {!hideDate && (
+
+ )}
Beskrivning
@@ -59,18 +65,22 @@ export function JournalEntryReviewContent({
{/* Balance status */}
-
-
-
- Debet = Kredit
-
- {attachmentCount != null && attachmentCount > 0 && (
-
-
- {attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'}
-
- )}
-
+ {(showBalanceBadge || (attachmentCount != null && attachmentCount > 0)) && (
+
+ {showBalanceBadge && (
+
+
+ Debet = Kredit
+
+ )}
+ {attachmentCount != null && attachmentCount > 0 && (
+
+
+ {attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'}
+
+ )}
+
+ )}
{/* Debit/Credit table */}
diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx
index 3894e74e..9038d168 100644
--- a/components/dashboard/DashboardContent.tsx
+++ b/components/dashboard/DashboardContent.tsx
@@ -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
-
-
-
-
-
Förfallna fakturor
-
- {summary.overdueInvoicesCount} st
-
-
+
+
+
+
Förfallna fakturor
+
+ {summary.overdueInvoicesCount} st
+
-
@@ -136,17 +128,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
-
-
-
Obetalda fakturor
-
- {summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
-
-
+
+
+
+
Obetalda fakturor
+
+ {summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
+
-
@@ -159,17 +148,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
-
-
-
Transaktioner
-
- {summary.uncategorizedCount} obokförda
-
-
+
+
+
+
Transaktioner
+
+ {summary.uncategorizedCount} obokförda
+
-
@@ -182,19 +168,16 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
-
-
-
Kvitton
-
- {summary.receiptQueue.pending_review_count > 0
- ? `${summary.receiptQueue.pending_review_count} att granska`
- : `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
-
-
+
+
+
+
Kvitton
+
+ {summary.receiptQueue.pending_review_count > 0
+ ? `${summary.receiptQueue.pending_review_count} att granska`
+ : `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
+
-
@@ -207,17 +190,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
-
-
-
Saknade underlag
-
- {summary.missingUnderlagCount} verifikationer utan underlag
-
-
+
+
+
+
Saknade underlag
+
+ {summary.missingUnderlagCount} verifikationer utan underlag
+
-
@@ -231,17 +211,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
-
-
-
Banksamtycke löper ut
-
- {conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
-
-
+
+
+
+
Banksamtycke löper ut
+
+ {conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
+
-
@@ -277,61 +254,33 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{(() => {
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}` : ''}
- {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'
+ })()}
- {/* 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 (
-
-
- {todoItems.map((item) => (
-
-
- {item.count} {item.label}
-
-
- ))}
-
-
- )
- })()}
-
{/* New user checklist */}
{onboardingProgress && (
@@ -358,10 +307,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Card 1: Resultat */}
-
-
- Resultat
-
+ Resultat
= 0 ? 'text-success' : 'text-destructive'
@@ -379,10 +325,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
- Att få betalt
-
+ Att få betalt
{summary.unpaidInvoicesCount}
st
@@ -398,10 +341,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{summary.bankBalance !== null ? (
-
-
- Banksaldo
-
+ Banksaldo
{formatLargeNumber(summary.bankBalance)}
kr
@@ -412,10 +352,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
- Banksaldo
-
+ Banksaldo
Koppla bank
Importera transaktioner
@@ -426,10 +363,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Card 4: Att göra */}
-
-
- Att göra
-
+ Att göra
{todoCount > 0 ? (
<>
@@ -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'
)}>
-
-
-
-
{action.label}
+
+
+ {action.label}
+
{action.desc}
@@ -493,11 +424,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
return (
-
-
-
-
{action.label}
+
+
+ {action.label}
+
{action.description}
@@ -511,11 +442,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
className="group text-left"
>
-
-
-
-
{action.label}
+
+
+ {action.label}
+
{action.description}
@@ -525,21 +456,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
- {/* Upcoming deadlines — always visible */}
- {summary.deadlines && summary.deadlines.length > 0 && (
-
- )}
-
- {/* Tax todo widget — visible when there are incomplete tax deadlines */}
- {summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
-
- )}
-
- {/* Alerts section — always visible */}
+ {/* Alerts section */}
{alertItems.length > 0 && (
Att hantera
@@ -558,6 +475,21 @@ export default function DashboardContent({ firstName, settings, summary, onboard
)}
+ {/* Upcoming deadlines — always visible */}
+ {summary.deadlines && summary.deadlines.length > 0 && (
+
+ )}
+
+ {/* Tax todo widget — visible when there are incomplete tax deadlines */}
+ {summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
+
+ )}
+
+
{/* Collapsible details section */}
setShowMore(!showMore)}
@@ -607,7 +539,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{' '}saknas i resultatet
-
@@ -619,10 +550,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
- Intäkter
-
+ Intäkter
{formatLargeNumber(summary.mtd.income)}
@@ -641,10 +569,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
-
-
- Kostnader
-
+ Kostnader
{formatLargeNumber(summary.mtd.expenses)}
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index ca147a84..6fd0fbcc 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -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 = {
ö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
- {/* Tillägg - collapsible, with marketplace link */}
-
-
- Tillägg
-
-
- {isTillaggExpanded && (
-
- {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 (
-
-
- {def.name}
-
- )
- }) : (
-
- Inga tillägg aktiverade
-
- )}
-
-
- Utforska fler...
-
-
- )}
-
-
{/* Övrigt group - collapsible */}
- {/* Tillägg */}
-
-
- Tillägg
-
- {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 (
-
-
- {def.name}
-
- )
- }) : (
-
- Inga tillägg aktiverade
-
- )}
-
-
- Utforska fler...
-
-
-
{/* Other section */}
diff --git a/components/import/BankFileConfirmStep.tsx b/components/import/BankFileConfirmStep.tsx
index 687d2182..34274916 100644
--- a/components/import/BankFileConfirmStep.tsx
+++ b/components/import/BankFileConfirmStep.tsx
@@ -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 (
+
+
+
+
+
+
Importerar transaktioner...
+
+ {stats.parsed_rows} transaktioner bearbetas
+
+
+
+
+ )
+ }
+
return (
{/* Summary */}
@@ -68,65 +81,33 @@ export default function BankFileConfirmStep({
-
+
Inkomster
-
+
{formatCurrency(stats.total_income)}
-
+
Utgifter
-
+
{formatCurrency(stats.total_expenses)}
{/* Additional info */}
-
-
Format: {format_name}
- {refsCount > 0 && (
+ {refsCount > 0 && (
+
{refsCount} med OCR/referens
- )}
-
-
- {/* Options */}
-
-
Importinställningar
-
-
-
setSkipDuplicates(checked === true)}
- />
-
-
- Hoppa över dubletter
-
-
- Transaktioner som redan finns i systemet importeras inte igen
-
-
-
-
-
- {/* Warning note */}
-
-
-
- Importerade transaktioner visas som "obokförda" på
- transaktionssidan. Du kan bokföra dem manuellt efteråt.
-
-
+ )}
@@ -138,7 +119,7 @@ export default function BankFileConfirmStep({
onExecute({
- skip_duplicates: skipDuplicates,
+ skip_duplicates: true,
auto_categorize: false,
})}
disabled={isLoading}
diff --git a/components/import/BankFilePreviewStep.tsx b/components/import/BankFilePreviewStep.tsx
index a6c030ff..bbbd1644 100644
--- a/components/import/BankFilePreviewStep.tsx
+++ b/components/import/BankFilePreviewStep.tsx
@@ -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({
-
+
Inkomster
-
+
{formatCurrency(stats.total_income)}
@@ -85,30 +83,17 @@ export default function BankFilePreviewStep({
-
+
Utgifter
-
+
{formatCurrency(stats.total_expenses)}
- {/* Format and duplicate info */}
-
-
- Format: {format_name}
-
- {existingTransactionCount > 0 && (
-
-
- {existingTransactionCount} befintliga transaktioner i samma period
-
- )}
-
-
{/* Warnings */}
{warnings.length > 0 && (
@@ -165,9 +150,7 @@ export default function BankFilePreviewStep({
{tx.date}
{tx.description}
= 0 ? 'text-green-600' : 'text-red-600'
- }`}
+ className="text-right font-mono text-sm"
>
{formatCurrency(tx.amount)}
diff --git a/components/import/BankFileResultStep.tsx b/components/import/BankFileResultStep.tsx
index 27e8a8ba..8d6cd341 100644
--- a/components/import/BankFileResultStep.tsx
+++ b/components/import/BankFileResultStep.tsx
@@ -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 (
{/* Status header */}
-
+
{isSuccess ? (
<>
-
+
Import genomförd
>
) : (
@@ -53,49 +49,6 @@ export default function BankFileResultStep({
- {/* Stats */}
-
-
-
-
-
- Importerade
-
- {result.imported}
-
-
-
-
-
-
-
- Dubletter
-
- {result.duplicates}
-
-
-
-
-
-
-
- Auto-bokförda
-
- {result.auto_categorized}
-
-
-
-
-
-
-
- Fakturamatchade
-
- {result.auto_matched_invoices}
-
-
-
-
{/* Next steps */}
{isSuccess && (
diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx
index 24a5844e..1a060359 100644
--- a/components/invoices/InvoiceReviewContent.tsx
+++ b/components/invoices/InvoiceReviewContent.tsx
@@ -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()
for (const item of items) {
@@ -75,11 +71,6 @@ export function InvoiceReviewContent({
- {/* VAT treatment */}
-
- {vatSummary.label}
-
-
{/* Dates */}
diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx
index 49c8a500..ac28a95d 100644
--- a/components/onboarding/NewUserChecklist.tsx
+++ b/components/onboarding/NewUserChecklist.tsx
@@ -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({
>
-
-
-
-
-
-
Kom igång
-
- {completedCount} av {items.length} steg klara
-
-
+
+
Kom igång
+
+ {completedCount} av {items.length} steg klara
+
diff --git a/components/onboarding/Step3TaxRegistration.tsx b/components/onboarding/Step3TaxRegistration.tsx
index 733733a0..2f0ca119 100644
--- a/components/onboarding/Step3TaxRegistration.tsx
+++ b/components/onboarding/Step3TaxRegistration.tsx
@@ -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
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({
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(
initialData.first_year_end
@@ -361,18 +375,79 @@ export default function Step3TaxRegistration({
{isFirstYear && (
-
Startdatum
+
Startdatum
(
- 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 (
+
+ compose(parseInt(v), selectedMonth, selectedYear)}
+ >
+
+
+
+
+ {Array.from({ length: maxDays }, (_, i) => i + 1).map((d) => (
+ {d}
+ ))}
+
+
+ compose(selectedDay, parseInt(v), selectedYear)}
+ >
+
+
+
+
+ {monthNames.map((name, i) => (
+ {name}
+ ))}
+
+
+ compose(selectedDay, selectedMonth, parseInt(v))}
+ >
+
+
+
+
+ {years.map((y) => (
+ {y}
+ ))}
+
+
+
+ )
+ }}
/>
Dagen verksamheten startade (bör vara den 1:a i en månad).
@@ -593,7 +668,7 @@ export default function Step3TaxRegistration({
Månad
- Kvartal (rekommenderas)
+ Kvartal
År
diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx
index b1f53a0c..df67e173 100644
--- a/components/reports/BankReconciliationView.tsx
+++ b/components/reports/BankReconciliationView.tsx
@@ -300,7 +300,7 @@ export function BankReconciliationView() {
Differens
-
+
{formatCurrency(status.difference)}
@@ -433,7 +433,7 @@ export function BankReconciliationView() {
{tx.date}
{tx.description}
- = 0 ? 'text-green-600' : ''}`}>
+
{formatCurrency(tx.amount)}
{tx.reference || '—'}
@@ -506,7 +506,7 @@ export function BankReconciliationView() {
{line.line_description || line.entry_description}
- = 0 ? 'text-green-600' : ''}`}>
+
{formatCurrency(amount)}
{line.source_type}
@@ -554,7 +554,7 @@ export function BankReconciliationView() {
{tx.date}
{tx.description}
- = 0 ? 'text-green-600' : ''}`}>
+
{formatCurrency(tx.amount)}
diff --git a/components/reports/SRUExportView.tsx b/components/reports/SRUExportView.tsx
deleted file mode 100644
index e249d903..00000000
--- a/components/reports/SRUExportView.tsx
+++ /dev/null
@@ -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(null)
- const [coverage, setCoverage] = useState(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(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 (
-
- {/* Info card */}
-
-
- SRU-export
-
-
-
- Generera SRU-fil (Standardiserat Räkenskapsutdrag) för elektronisk inlämning
- till Skatteverket. Blanketttyp bestäms automatiskt utifrån företagsform.
-
-
-
- {loading ? 'Laddar...' : 'Förhandsgranska'}
-
- {data && (
-
-
- Ladda ner SRU-fil
-
- )}
-
-
-
-
- {/* Coverage warning */}
- {coverage && coverage.accountsWithoutSRU > 0 && (
-
-
-
-
-
-
- {coverage.accountsWithoutSRU} av {coverage.totalAccounts} konton saknar SRU-kod
- ({coverage.coveragePercent}% täckning).
- Konton utan SRU-kod inkluderas inte i exporten.
-
- {coverage.missingAccounts.length <= 5 && (
-
- {coverage.missingAccounts.map((a) => (
-
- {a.accountNumber} — {a.accountName}
-
- ))}
-
- )}
-
-
-
-
- )}
-
- {error && (
-
-
-
- {error}
-
-
- )}
-
- {data && (
- <>
- {/* Warnings */}
- {data.warnings.length > 0 && (
-
-
-
-
-
- {data.warnings.map((warning, i) => (
-
{warning}
- ))}
-
-
-
-
- )}
-
- {/* Company + form info */}
-
-
-
-
{data.companyName || 'Okänt företag'}
-
- {formLabel}
- {data.fiscalYear.name}
-
-
- {data.orgNumber && (
-
- Org.nr: {data.orgNumber}
-
- )}
-
-
-
- {/* SRU balances table */}
-
-
- SRU-poster
-
-
- {data.balances.length === 0 ? (
-
- Inga poster med belopp att exportera.
-
- ) : (
-
-
-
- SRU-kod
- Beskrivning
- Belopp
-
-
-
- {data.balances.map((b) => (
-
- ))}
-
-
- )}
-
-
- >
- )}
-
- {!data && !loading && !error && (
-
-
- Klicka "Förhandsgranska" för att se SRU-uppgifter för valt räkenskapsår.
-
-
- )}
-
- )
-}
-
-function SRUBalanceRow({
- balance,
-}: {
- balance: SRUExportResult['balances'][number]
-}) {
- const [expanded, setExpanded] = useState(false)
-
- return (
- <>
- balance.accounts.length > 0 && setExpanded(!expanded)}
- >
- {balance.sruCode}
-
- {balance.description}
- {balance.accounts.length > 0 && (
-
- ({balance.accounts.length} konton)
-
- )}
-
- {formatCurrency(balance.amount)}
-
- {expanded && balance.accounts.length > 0 && (
-
-
-
-
- {balance.accounts.map((acc) => (
-
-
- {acc.accountName}
- {formatCurrency(acc.amount)}
-
- ))}
-
-
-
-
- )}
- >
- )
-}
diff --git a/components/suppliers/SupplierInvoiceReviewContent.tsx b/components/suppliers/SupplierInvoiceReviewContent.tsx
index db8c6471..4bec5aca 100644
--- a/components/suppliers/SupplierInvoiceReviewContent.tsx
+++ b/components/suppliers/SupplierInvoiceReviewContent.tsx
@@ -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,
})
diff --git a/components/transactions/BatchCategorySelector.tsx b/components/transactions/BatchCategorySelector.tsx
index e7c52f30..c8da3db5 100644
--- a/components/transactions/BatchCategorySelector.tsx
+++ b/components/transactions/BatchCategorySelector.tsx
@@ -64,7 +64,7 @@ export default function BatchCategorySelector({
- Underlag behover bifogas separat for varje transaktion efter bokforing.
+ Underlag behöver bifogas separat för varje transaktion efter bokföring.
diff --git a/components/transactions/DescribeTransactionDialog.tsx b/components/transactions/DescribeTransactionDialog.tsx
index 2678e0ee..de3d783e 100644
--- a/components/transactions/DescribeTransactionDialog.tsx
+++ b/components/transactions/DescribeTransactionDialog.tsx
@@ -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({
{step === 'describe' && 'Beskriv transaktion'}
- {step === 'pick' && 'Valj mall'}
- {step === 'batch' && 'Bokfor liknande'}
+ {step === 'pick' && 'Välj mall'}
+ {step === 'batch' && 'Bokför liknande'}
- {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!'}
@@ -351,11 +351,7 @@ export default function DescribeTransactionDialog({
{(step === 'describe' || step === 'pick') && (
{isIncome ? (
@@ -367,7 +363,7 @@ export default function DescribeTransactionDialog({
{transaction.description}
{formatDate(transaction.date)}
-
+
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
@@ -378,7 +374,7 @@ export default function DescribeTransactionDialog({
{step === 'describe' && (
)}
@@ -423,7 +419,7 @@ export default function DescribeTransactionDialog({
{describeResult.needs_more_detail && (
-
Resultaten ar osakra. Forsok beskriv mer detaljerat for battre traffar.
+
Resultaten är osäkra. Försök beskriv mer detaljerat för bättre träffar.
)}
@@ -442,7 +438,7 @@ export default function DescribeTransactionDialog({
- AI-forslag
+ AI-förslag
@@ -495,7 +491,7 @@ export default function DescribeTransactionDialog({
{/* Template cards */}
{describeResult.templates.length === 0 && !aiSuggestion ? (
- Inga matchande mallar hittades. Forsok med en annan beskrivning.
+ Inga matchande mallar hittades. Försök med en annan beskrivning.
) : (
describeResult.templates.map((template) => (
@@ -515,7 +511,7 @@ export default function DescribeTransactionDialog({
{template.name_sv}
{aiAgreesWithTop && template.template_id === topTemplate.template_id && (
- AI bekraftar
+ AI bekräftar
)}
@@ -636,7 +632,7 @@ export default function DescribeTransactionDialog({
) : (
)}
- {isBooking ? 'Bokfor...' : 'Bokfor'}
+ {isBooking ? 'Bokför...' : 'Bokför'}
@@ -647,7 +643,7 @@ export default function DescribeTransactionDialog({
-
Transaktionen ar bokford!
+
Transaktionen är bokförd!
{canBatchApply && (
@@ -656,11 +652,11 @@ export default function DescribeTransactionDialog({
{describeResult.batch_candidate_count}
{' '}
- obokforda transaktioner fran{' '}
+ obokförda transaktioner från{' '}
{describeResult.merchant_name}
- . Anvand samma mall?
+ . Använd samma mall?
)}
@@ -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'}
{canBatchApply && (
) : null}
{isBatchApplying
- ? 'Bokfor...'
- : `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
+ ? 'Bokför...'
+ : `Ja, bokför alla ${describeResult.batch_candidate_count} st`}
)}
diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx
index a0d9f753..c8d60508 100644
--- a/components/transactions/QuickReviewDialog.tsx
+++ b/components/transactions/QuickReviewDialog.tsx
@@ -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
+ 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({
- {/* Category (read-only) */}
+ {/* Template or Category */}
-
Kategori
-
-
{categoryLabel}
+
+ {template ? 'Mall' : 'Kategori'}
+
+
+
+ {template ? template.name_sv : categoryLabel}
+
+ {onChangeTemplate && (
+
+ Byt mall
+
+ )}
+ {/* Template special rules */}
+ {template?.special_rules_sv && (
+
+
+ {template.special_rules_sv}
+
+
+ )}
+
+ {/* Deductibility note */}
+ {template?.deductibility_note_sv && (
+
+
+ {template.deductibility_note_sv}
+
+
+ )}
+
+ {/* Reverse charge warning */}
+ {template?.requires_vat_registration_data && (
+
+
+
+
+ Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
+
+
+
+ )}
+
{/* Journal entry preview */}
{isLiabilityAccount ? (
- Ingen moms for skuld-/eget kapital-konton
+ Ingen moms för skuld-/eget kapital-konton
) : showVatDropdown ? (
{
+ 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 (
@@ -310,46 +322,39 @@ export default function SwipeCategorizationView({
setShowCategorySelect(false)}>
-
Välj kategori
+
Välj mall
-
-
-
- {currentTransaction.description}
-
- {formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
-
-
-
+
+
+ {currentTransaction.description}
+
+ {formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
+
+
+
- {error && (
-
- {error}
-
- )}
-
-
- {categories.map((cat) => (
-
handleCategorySelect(cat.value)}
- disabled={isProcessing}
- >
-
- {cat.label}
- {cat.account && {cat.account} }
-
-
- ))}
+ {error && (
+
+ {error}
+ )}
+
+
+
+
+
@@ -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({
- {/* Selected category */}
+ {/* Selected template or category */}
-
Kategori
-
-
{categoryLabel}
+
+ {selectedTemplate ? 'Mall' : 'Kategori'}
+
+
+
+ {selectedTemplate ? selectedTemplate.name_sv : categoryLabel}
+
+ {
+ setShowReviewStep(false)
+ setShowCategorySelect(true)
+ }}
+ >
+ Byt mall
+
+ {/* Template special rules warning */}
+ {selectedTemplate?.special_rules_sv && (
+
+
+ {selectedTemplate.special_rules_sv}
+
+
+ )}
+
+ {/* Deductibility note */}
+ {selectedTemplate?.deductibility_note_sv && (
+
+
+ {selectedTemplate.deductibility_note_sv}
+
+
+ )}
+
+ {/* Reverse charge VAT registration warning */}
+ {selectedTemplate?.requires_vat_registration_data && (
+
+
+
+
+ Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
+
+
+
+ )}
+
{/* Journal entry preview */}
{isLiabilityAccount ? (
- Ingen moms for skuld-/eget kapital-konton
+ Ingen moms för skuld-/eget kapital-konton
) : showVatDropdown ? (
- {isProcessing ? 'Bokfor...' : 'Bokfor'}
+ {isProcessing ? 'Bokför...' : 'Bokför'}
= {
+ 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 {
+ const grouped = new Map()
+ 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 (
+
+
+
+
+ {template.name_sv}
+
+
+
+ D: {template.debit_account} · K: {template.credit_account}
+
+ {vatLabel && (
+
+ {vatLabel}
+
+ )}
+ {template.requires_vat_registration_data && (
+
+
+ Kräver momsreg.nr
+
+ )}
+
+
+ {template.requires_review && (
+
+ )}
+
+ {template.special_rules_sv && !compact && (
+
+ {template.special_rules_sv}
+
+ )}
+
+ )
+}
+
+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 (
+
+ {/* Search bar */}
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Sök mall..."
+ className="pl-9 h-9"
+ />
+
+
+ {/* Scrollable content */}
+
+ {/* Search results */}
+ {searchResults !== null ? (
+
+
+ {searchResults.length === 0 ? 'Inga resultat' : `${searchResults.length} resultat`}
+
+
+ {searchResults.map((t) => (
+ handleSelect(t)}
+ />
+ ))}
+
+
+ ) : (
+ <>
+ {/* Suggested templates */}
+ {hasSuggestions && (
+
+
Föreslagna
+
+ {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 (
+ handleSelect(fullTemplate)}
+ compact
+ />
+ )
+ })}
+
+
+ )}
+
+ {/* Common templates grouped */}
+
+
Vanliga mallar
+
+ {GROUP_ORDER.filter((g) => commonGrouped.has(g)).map((group) => (
+
+
+ {GROUP_LABELS[group]}
+
+
+ {commonGrouped.get(group)!.map((t) => (
+ handleSelect(t)}
+ compact
+ />
+ ))}
+
+
+ ))}
+
+
+
+ {/* Advanced templates (collapsible) */}
+ {allAdvanced.length > 0 && (
+
+
setShowAdvanced(!showAdvanced)}
+ >
+ Fler mallar ({allAdvanced.length})
+ {showAdvanced ? (
+
+ ) : (
+
+ )}
+
+ {showAdvanced && (
+
+ {GROUP_ORDER.filter((g) => advancedGrouped.has(g)).map((group) => (
+
+
+ {GROUP_LABELS[group]}
+
+
+ {advancedGrouped.get(group)!.map((t) => (
+ handleSelect(t)}
+ compact
+ />
+ ))}
+
+
+ ))}
+
+ )}
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx
index d2fb8981..4b722c0a 100644
--- a/components/transactions/TransactionBookingDialog.tsx
+++ b/components/transactions/TransactionBookingDialog.tsx
@@ -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({
}}>
- Bokfor transaktion
+ Bokför transaktion
- Skapa en verifikation for transaktionen
+ Skapa en verifikation för transaktionen
diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx
index e151d850..70cb0997 100644
--- a/components/transactions/TransactionHistoryList.tsx
+++ b/components/transactions/TransactionHistoryList.tsx
@@ -83,11 +83,7 @@ export default function TransactionHistoryList({
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 ? (
@@ -173,11 +169,7 @@ export default function TransactionHistoryList({
)}
-
0 ? 'text-success' : ''
- }`}
- >
+
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx
index 3080f3cc..711feefe 100644
--- a/components/transactions/TransactionInboxCard.tsx
+++ b/components/transactions/TransactionInboxCard.tsx
@@ -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}
+ ) : templateSuggestions && templateSuggestions.length > 0 ? (
+ <>
+ {templateSuggestions.slice(0, 2).map((ts, idx) => {
+ const tmpl = getTemplateById(ts.template_id)
+ return (
+
{
+ if (onOpenTemplateReview && tmpl) {
+ onOpenTemplateReview(transaction, ts.template_id)
+ } else if (topSuggestion) {
+ handleSuggestionClick(topSuggestion)
+ }
+ }}
+ disabled={isProcessing || isDisabled}
+ >
+ {isProcessing && idx === 0 ? (
+
+ ) : null}
+ {ts.name_sv}
+
+ ({ts.debit_account})
+
+
+ )
+ })}
+ >
) : topSuggestion ? (
) : null}
- {/* Secondary suggestions (up to 1 more) */}
- {!hasInvoiceMatch && suggestions && suggestions.length > 1 && (
- handleSuggestionClick(suggestions[1])}
- disabled={isProcessing || isDisabled}
- >
- {suggestions[1].label}
- {suggestions[1].account && (
-
- ({formatAccountWithName(suggestions[1].account)})
-
- )}
-
- )}
-
{/* Describe transaction */}
{onOpenDescribe && (
)}
- {/* Open category dialog */}
+ {/* Open category dialog / template picker */}
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...'}
)}
diff --git a/components/ui/confirmation-dialog.tsx b/components/ui/confirmation-dialog.tsx
index 72b2bfdd..fe05d525 100644
--- a/components/ui/confirmation-dialog.tsx
+++ b/components/ui/confirmation-dialog.tsx
@@ -55,10 +55,12 @@ export function ConfirmationDialog({
-
+ {warningText && (
+
+ )}
)}
{onAction && actionLabel && (
-
+
{actionLabel}
diff --git a/dev_docs/GAP_ANALYS.md b/dev_docs/GAP_ANALYS.md
deleted file mode 100644
index 384c9392..00000000
--- a/dev_docs/GAP_ANALYS.md
+++ /dev/null
@@ -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.
diff --git a/dev_docs/PR1PR2.md b/dev_docs/PR1PR2.md
deleted file mode 100644
index 5c202e5c..00000000
--- a/dev_docs/PR1PR2.md
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/dev_docs/TWO_PHASES.md b/dev_docs/TWO_PHASES.md
deleted file mode 100644
index b84826f5..00000000
--- a/dev_docs/TWO_PHASES.md
+++ /dev/null
@@ -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> = {
- '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 = {
- 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> = {}
- export const EXTENSION_DEFINITIONS: Record = {}
-
- 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 | 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// 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// → /api/extensions/ext//
-
- 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 Promise>
- 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.
\ No newline at end of file
diff --git a/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.41.50.png b/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.41.50.png
deleted file mode 100644
index 26cd8ab2..00000000
Binary files a/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.41.50.png and /dev/null differ
diff --git a/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.42.17.png b/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.42.17.png
deleted file mode 100644
index 24ac6576..00000000
Binary files a/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.42.17.png and /dev/null differ
diff --git a/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.42.24.png b/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.42.24.png
deleted file mode 100644
index a77f3b36..00000000
Binary files a/dev_docs/bokio/Skärmavbild 2026-01-29 kl. 12.42.24.png and /dev/null differ
diff --git a/export-biz-extension.md b/export-biz-extension.md
deleted file mode 100644
index 3164d848..00000000
--- a/export-biz-extension.md
+++ /dev/null
@@ -1,1153 +0,0 @@
-# Export Business Extensions — Complete Idea Registry
-
-> **Sector slug**: `export`
-> **Target user**: Small Swedish businesses (enskild firma & aktiebolag) that physically export goods from Sweden to EU and non-EU markets.
-> **Constraint note**: Extensions primarily read core bookkeeping data. Some ideas below involve journal entry creation (revaluation, FX gain/loss) — these would require extending the extension permission model or routing through the core engine.
-
----
-
-## Research Summary
-
-### Key pain points for Swedish exporters
-1. **VAT classification complexity** — Domestic (25/12/6%), EU B2B reverse charge (0%), EU B2C (OSS), non-EU export (0%) — each with different momsdeklaration boxes, documentation requirements, and penalty exposure.
-2. **Currency management** — Three accounting moments per foreign invoice (invoice date, payment date, period-end revaluation). Most small exporters get this wrong.
-3. **Multiple reporting obligations** — Momsdeklaration + Periodisk sammanställning (EC Sales List) + Intrastat (if >SEK 12M dispatches) + Tullverket customs declarations — all with different deadlines and formats.
-4. **No market-level profitability visibility** — Same product shipped to Norway vs. USA has wildly different true margins after freight, customs, insurance, and FX costs.
-5. **Missing export documentation = retroactive tax** — Non-EU zero-rating denied without proof of export (Tullverket EAD, CMR, bill of lading). 25% VAT + 20% penalty.
-
-### Relevant BAS accounts
-| Account | Description | Momsdeklaration box |
-|---------|-------------|-------------------|
-| `3001` | Revenue goods 25% (domestic) | Box 05 |
-| `3002` | Revenue goods 12% (domestic) | Box 05 |
-| `3003` | Revenue goods 6% (domestic) | Box 05 |
-| `3105` | Goods export outside EU | Box 36 |
-| `3108` | Goods to EU B2B (reverse charge) | Box 35 |
-| `3305` | Services outside EU | Box 40 |
-| `3308` | Services to EU B2B (reverse charge) | Box 39 |
-| `3109` | Triangular trade sales (trepartshandel) | Box 38 |
-| `3521` | Invoiced freight, EU | Follows goods |
-| `3522` | Invoiced freight, export | Box 36 |
-| `3960` | FX gains (operating) | — |
-| `7960` | FX losses (operating) | — |
-| `3969` | Unrealized FX gains | — |
-| `7969` | Unrealized FX losses | — |
-| `2614` | Output VAT reverse charge 25% | — |
-| `2641` | Deductible input VAT | Box 48 |
-| `2645` | Calculated input VAT (EU acquisitions) | Box 48 |
-| `5710` | Freight, transport, insurance | — |
-| `5720` | Customs and forwarding costs | — |
-| `6320` | Insurance costs | — |
-
-### Regulatory references
-- **Mervärdesskattelagen (ML)** — Swedish VAT Act
-- **Bokföringslagen (BFL)** — Swedish Bookkeeping Act (7-year retention)
-- **VIES** — EU VAT Information Exchange System (free API for VAT number validation)
-- **Riksbanken** — Daily exchange rates (REST API)
-- **SCB Intrastat** — Monthly EU trade statistics (IDEP.web, transitioning to new platform 2026)
-- **Skatteverket** — Momsdeklaration, Periodisk sammanställning (e-filing)
-- **Tullverket** — Export declarations, EAD (Export Accompanying Document)
-
----
-
-## All Extension Ideas
-
-### IDEA 1: Export VAT Autopilot
-
-**Problem**: Every time an exporter creates an invoice to a customer in another EU country, they must manually determine the correct VAT treatment. Get it wrong and Skatteverket can deny the 0% rate or the customer can't deduct VAT.
-
-**What it does**:
-- When creating an invoice, the user enters or selects the customer's country and VAT number (momsregistreringsnummer).
-- The system automatically validates the VAT number via the EU VIES database (free API: https://ec.europa.eu/taxation_customs/vies/).
-- Based on validated status + destination + goods vs. services, the system auto-applies the correct VAT treatment:
- - **Intra-community supply (B2B, goods to EU)**: 0% VAT, auto-adds text "Omvänd skattskyldighet" and the legal reference.
- - **Export outside EU**: 0% VAT, auto-adds "Export" and prompts for customs documentation.
- - **B2C to EU (distance sale)**: Checks if OSS threshold (EUR 10,000) is exceeded, alerts if VAT registration in destination country may be needed.
- - **Domestic**: Standard 25% / 12% / 6% as normal.
-- Auto-books to the correct BAS account (e.g., 3108 for EU goods sales at 0%).
-- Stores proof of transport (CMR, bill of lading reference) linked to the invoice — critical for defending the 0% rate in an audit.
-
-**Data pattern**: `both` (reads core invoice/customer data + stores VIES validation results and document references)
-
-**Why it wins**: Fortnox and Visma leave this entirely to the user. This extension eliminates the #1 compliance risk for exporters.
-
-**Note**: This idea touches core invoice creation flow. May require hooks into the invoice form rather than being a standalone workspace. Could also be implemented as a validation/enrichment layer that runs when invoices are created.
-
----
-
-### IDEA 2: Intrastat Generator
-
-**Problem**: Swedish companies dispatching goods worth >SEK 12M/year (threshold raised from 4.5M in 2025) to other EU countries must file monthly Intrastat reports to SCB. Currently done manually in Excel or IDEP.web.
-
-**What it does**:
-- Each product in the system can be tagged with: CN commodity code (8-digit), net weight (kg), country of origin, and supplementary unit (pieces, liters, etc.).
-- When an invoice is booked for an intra-community dispatch, the system automatically captures: commodity code, invoice value (in SEK), net weight, destination EU country, transaction nature code, delivery terms.
-- At month end, generates a complete Intrastat dispatch declaration in the format accepted by SCB's IDEP.WEB (CSV/XML upload).
-- Tracks cumulative dispatch value against the SEK 12M threshold and alerts when the company becomes obligated.
-- Handles corrections: if a credit note is issued, generates a correction entry for the relevant month.
-
-**Data pattern**: `both` (reads core invoice data + stores product metadata: CN codes, weights, origin)
-
-**Required data fields per Intrastat line**:
-| Field | Source |
-|-------|--------|
-| CN commodity code (8-digit) | Manual entry per product |
-| Partner country (2-letter ISO) | From customer/invoice |
-| Transaction nature code | From invoice type |
-| Net mass (kg) | Manual entry per product |
-| Supplementary unit | Manual entry per product (if required by CN code) |
-| Invoiced value (SEK) | From invoice |
-| Country of origin | Manual entry per product |
-| Partner VAT ID | From customer |
-| Delivery terms (Incoterms) | Manual entry per order |
-
-**Why it wins**: Pure pain for every exporting SME above the threshold. No Swedish bookkeeping system below ERP-level (SAP, Dynamics) does this well. Compelling reason to switch to erp-base.
-
----
-
-### IDEA 3: Multi-Currency Receivables Manager
-
-**Problem**: An exporter invoicing in EUR has open receivables whose SEK value fluctuates daily. At period end, these must be revalued. When payment arrives, there's an FX gain or loss to book. This is messy in current systems.
-
-**What it does**:
-- Invoices can be created in any currency (EUR, USD, NOK, DKK, GBP, etc.) with the exchange rate auto-fetched from Riksbanken's daily rates.
-- Open receivables dashboard showing: original amount, original SEK value, current SEK value, unrealized FX gain/loss — per customer and per currency.
-- Period-end revaluation button: recalculates all open foreign-currency receivables at the closing rate and generates the required journal entries (BAS 3960 Valutakursvinster / 7960 Valutakursförluster / 3969 / 7969).
-- Payment matching in foreign currency: When a EUR payment arrives, matches to EUR invoices and auto-calculates realized FX gain/loss, booking it to the correct BAS accounts.
-- FX exposure summary: Shows total outstanding per currency — useful for deciding whether to hedge.
-
-**Data pattern**: `both` (reads core invoices/transactions + may create journal entries for revaluation)
-
-**Note**: The revaluation and payment-matching features involve journal entry creation, which currently isn't within extension permissions. Options: (a) route through core engine API, (b) generate draft entries for user approval, (c) expand extension capabilities.
-
-**Why it wins**: Fortnox handles basic multi-currency but the revaluation and FX gain/loss workflow is manual. This makes it automated and audit-ready.
-
----
-
-### IDEA 4: EU Sales List / Periodisk Sammanställning Auto-Reporter
-
-**Problem**: Every Swedish company making intra-community B2B supplies must file a quarterly (or monthly) EU sales list (periodisk sammanställning) to Skatteverket, listing each EU customer's VAT number and total value of supplies.
-
-**What it does**:
-- Automatically compiles all 0%-rated intra-community invoices for the period.
-- Groups by customer VAT number and destination country.
-- Separates goods (momsdeklaration box 35) from services (box 39).
-- Generates the report in Skatteverket's required format (XML for e-filing).
-- Cross-references with VIES validation to catch invalid VAT numbers before filing.
-- Handles credit notes (reduces the reported value for that customer).
-- Alerts if any intra-community invoice is missing a validated VAT number.
-- Cross-validates: total in this report should match box 35 + box 39 on the momsdeklaration — flags discrepancies.
-- Filing deadline countdown with alerts.
-
-**Data pattern**: `core` (reads invoices + customer data, no manual data entry needed)
-
-**Filing frequency**:
-| Type | Default | Reduced (if **Decision**: VAT Autopilot deferred (requires core invoice flow changes). 4 extensions is enough for a strong v1.
-> **Language**: Swedish UI with English for international trade terms (Incoterms, VIES, CN codes, FOB/CIF).
-> **Data**: All 4 extensions work with existing core schema — no database migrations needed for v1.
-> **Product metadata**: Stored in `extension_data` table (isolated to Intrastat extension).
-
-### 1. EU Sales List / Periodisk Sammanställning (Idea 4) — Score: 9.00
-- Mandatory reporting, saves real hours, pure read-only, high standalone value
-- Generates downloadable CSV/XML file for upload to Skatteverket
-- Immediately useful to every exporter with EU B2B sales
-
-### 2. Export VAT Monitor / Exportmoms-monitor (Idea 7) — Score: 8.55
-- Post-hoc VAT analysis dashboard, 100% feasible within current architecture
-- Maps revenue to momsdeklaration boxes, catches errors before filing
-
-### 3. Intrastat Generator (Idea 2) — Score: 8.30
-- No competing tool in the SME segment, generates SCB-compatible files
-- Requires manual product metadata (stored in extension_data) but delivers massive time savings
-
-### 4. Multi-Currency Receivables Manager / Valutafordringar (Idea 3) — Score: 7.75
-- Dashboard portion (exposure by currency + unrealized gain/loss) is pure read-only
-- Core schema already has full multi-currency support (currency, exchange_rate, total_sek on invoices)
-- Journal entry generation for revaluation deferred to future phase
-
-### Deferred
-- **Export VAT Autopilot** (Idea 1) — Deferred. Requires modifying core invoice creation flow, which violates the "extensions don't modify core data" constraint. Keep in this document for future consideration.
-
-### Future Phase
-- **Export Document Center** (Idea 5) — Proforma invoices, packing lists, document archive
-- **Freight Cost Allocator** (Idea 6) + **Market Profitability** (Idea 8) — Could merge into "Export Profitability"
-- **Compliance Tracker** (Idea 9) — Partially covered by the 4 selected extensions combined
-
----
-
-## Core Schema Findings
-
-The existing schema already supports everything we need:
-
-**Invoice type** (`types/index.ts`):
-- `currency: Currency` — EUR, USD, GBP, NOK, DKK, SEK
-- `exchange_rate: number | null` — rate at invoice date
-- `subtotal` / `subtotal_sek` — original and SEK amounts
-- `total` / `total_sek` — original and SEK amounts
-- `vat_treatment: VatTreatment` — includes `reverse_charge`, `export`, `exempt`
-- `moms_ruta: string | null` — momsdeklaration box (05, 35, 36, 39, 40)
-
-**Customer type** (`types/index.ts`):
-- `country: string` — ISO country code
-- `vat_number: string | null`
-- `vat_number_validated: boolean`
-- `vat_number_validated_at: string | null`
-- `customer_type: CustomerType`
-
-**JournalEntryLine type** (`types/index.ts`):
-- `currency: string`
-- `amount_in_currency: number | null`
-- `exchange_rate: number | null`
-- `account_number: string` — BAS account (3105, 3108, 3305, 3308, etc.)
-
-**Transaction type** (`types/index.ts`):
-- `currency: Currency`
-- `amount_sek: number | null`
-- `exchange_rate: number | null`
-
----
-
-## Implementation Plan
-
-### Phase 0: Sector Registration & Shared Infrastructure
-
-**Goal**: Register the export sector, create shared components, set up the extension folder structure.
-
-#### 0.1 Create folder structure
-```
-extensions/
- export/
- eu-sales-list/
- index.ts # Extension definition
- lib/
- eu-sales-list-engine.ts # Core logic: aggregate, validate, generate file
- vies-validator.ts # VIES API VAT number validation (shared utility)
- skv-xml-generator.ts # Skatteverket XML format generation
- vat-monitor/
- index.ts
- lib/
- vat-monitor-engine.ts # GL account reading, box mapping, validation
- intrastat/
- index.ts
- lib/
- intrastat-engine.ts # Data aggregation, CN code management
- scb-file-generator.ts # SCB IDEP.web compatible CSV/XML
- currency-receivables/
- index.ts
- lib/
- receivables-engine.ts # Exposure calc, unrealized gain/loss
- riksbanken-rates.ts # Daily rate fetching (extend existing lib/currency/)
-```
-
-#### 0.2 Register sector in `lib/extensions/sectors.ts`
-```typescript
-{
- slug: 'export',
- name: 'Export & Utrikeshandel',
- icon: 'Ship',
- description: 'Verktyg för svenska företag som exporterar varor till EU och övriga världen',
- extensions: [
- {
- slug: 'eu-sales-list',
- name: 'Periodisk sammanställning',
- sector: 'export',
- category: 'accounting',
- icon: 'FileText',
- dataPattern: 'core',
- readsCoreTables: ['invoices', 'customers'],
- hasOwnData: false,
- description: 'Generera periodisk sammanställning (EC Sales List) för Skatteverket',
- longDescription: 'Sammanställer automatiskt alla momsfria EU-försäljningar grupperat per kund och momsregistreringsnummer. Genererar nedladdningsbar fil för uppladdning till Skatteverket. Validerar kundernas VAT-nummer via VIES och flaggar saknade uppgifter.'
- },
- {
- slug: 'vat-monitor',
- name: 'Exportmoms-monitor',
- sector: 'export',
- category: 'reports',
- icon: 'Shield',
- dataPattern: 'core',
- readsCoreTables: ['journal_entry_lines', 'journal_entries', 'invoices'],
- hasOwnData: false,
- description: 'Övervaka momsbehandling för export och EU-handel',
- longDescription: 'Visar intäkter uppdelat på inhemsk försäljning, EU B2B (reverse charge) och export utanför EU. Mappar automatiskt till rätt rutor i momsdeklarationen (ruta 05, 35, 36, 39, 40). Flaggar potentiella fel som saknat momsregistreringsnummer på EU-kunder eller felaktig momsbehandling.'
- },
- {
- slug: 'intrastat',
- name: 'Intrastat-generator',
- sector: 'export',
- category: 'accounting',
- icon: 'BarChart3',
- dataPattern: 'both',
- readsCoreTables: ['invoices', 'customers'],
- hasOwnData: true,
- description: 'Generera Intrastat-deklarationer för rapportering till SCB',
- longDescription: 'Tagga produkter med CN-koder (Combined Nomenclature), vikt och ursprungsland. Genererar kompletta Intrastat-deklarationer i CSV-format för uppladdning till SCB:s IDEP.web. Övervakar tröskelvärdet på 12 MSEK för utförsel och varnar när rapporteringsskyldighet uppstår.'
- },
- {
- slug: 'currency-receivables',
- name: 'Valutafordringar',
- sector: 'export',
- category: 'reports',
- icon: 'TrendingUp',
- dataPattern: 'core',
- readsCoreTables: ['invoices', 'journal_entry_lines', 'transactions'],
- hasOwnData: false,
- description: 'Övervaka valutaexponering och orealiserade kursvinster/-förluster',
- longDescription: 'Visar öppna kundfordringar per valuta med aktuellt SEK-värde baserat på Riksbankens dagskurser. Beräknar orealiserade valutakursvinster och -förluster. Visar realiserade kursdifferenser per period (konto 3960/7960). Ger en samlad bild av företagets valutarisk.'
- }
- ]
-}
-```
-
-#### 0.3 Shared components to build
-All placed in `components/extensions/export/shared/`:
-
-| Component | Purpose | Used by |
-|-----------|---------|---------|
-| `PeriodSelector` | Month/quarter picker for reporting periods | All 4 |
-| `CurrencyDisplay` | Shows amount in original currency + SEK | 3, 4 |
-| `DeadlineCard` | Countdown to next filing deadline | 1, 2 |
-| `ComplianceStatusBadge` | Filed / Pending / Overdue indicator | 1, 2 |
-| `MomsrutaLabel` | Styled label for momsdeklaration box numbers | 1, 2 |
-| `CountryFlag` | Small flag icon + country name for EU countries | 1, 3, 4 |
-| `ExportKPICard` | Extension of existing KPICard with currency formatting | All 4 |
-| `DownloadButton` | Trigger file download (CSV/XML) with loading state | 1, 3 |
-
-#### 0.4 Shared utilities
-Placed in `extensions/export/shared/`:
-
-| Utility | Purpose | Used by |
-|---------|---------|---------|
-| `vies-client.ts` | VIES SOAP/REST API client for VAT number validation | 1 |
-| `riksbanken-client.ts` | Extend existing `lib/currency/` to fetch daily rates | 4 |
-| `eu-countries.ts` | EU member state list with ISO codes, currency, VAT prefixes | 1, 2, 3 |
-| `moms-box-mapping.ts` | Maps BAS accounts + vat_treatment to momsdeklaration boxes | 1, 2 |
-| `file-generators.ts` | CSV and XML file generation utilities | 1, 3 |
-
----
-
-### Phase 1: EU Sales List / Periodisk Sammanställning
-
-**Extension slug**: `eu-sales-list`
-**Data pattern**: `core` (read-only from invoices + customers)
-**Output**: Downloadable CSV/XML file
-
-#### 1.1 Engine (`eu-sales-list-engine.ts`)
-
-**Input**: User ID, period (year + month or quarter), filing type (goods/services/both)
-
-**Logic**:
-1. Fetch all invoices for the period where:
- - `vat_treatment = 'reverse_charge'` (EU B2B)
- - `status` is `sent` or `paid` (not draft)
- - Customer `country` is an EU member state (not Sweden)
-2. Join with customers to get `vat_number`, `country`, `name`
-3. Group by customer `vat_number`
-4. For each customer, separate goods invoices (revenue accounts 3108) from services (3308)
-5. Sum `total_sek` for goods and services separately
-6. Handle credit notes: subtract from the customer's total (can result in negative amounts)
-7. Validate:
- - Flag customers with missing or unvalidated VAT numbers
- - Flag invoices without `moms_ruta` set to '35' or '39'
- - Cross-check: sum of goods should equal journal entries on account 3108 for the period
- - Cross-check: sum of services should equal journal entries on account 3308 for the period
-
-**Output structure**:
-```typescript
-interface ECSalesListReport {
- period: { year: number; month?: number; quarter?: number }
- filingType: 'monthly' | 'quarterly'
- reporterVatNumber: string
- reporterName: string
- lines: ECSalesListLine[]
- totals: { goods: number; services: number; triangulation: number }
- warnings: ECSalesListWarning[]
- crossCheck: { boxMatch: boolean; box35Total: number; box39Total: number }
-}
-
-interface ECSalesListLine {
- customerVatNumber: string
- customerName: string
- customerCountry: string // ISO 2-letter
- goodsAmount: number // SEK, rounded to whole number
- servicesAmount: number // SEK, rounded to whole number
- triangulationAmount: number // SEK, for trepartshandel
-}
-
-interface ECSalesListWarning {
- type: 'missing_vat_number' | 'unvalidated_vat_number' | 'missing_moms_ruta' | 'cross_check_mismatch'
- invoiceId?: string
- customerId?: string
- message: string
-}
-```
-
-#### 1.2 File generators
-
-**Skatteverket XML format** (`skv-xml-generator.ts`):
-- Generate XML matching SKV 5740 schema
-- Include header: reporter VAT number, period, contact info
-- Include lines: customer VAT number, goods amount, services amount
-- Encoding: UTF-8
-
-**CSV fallback** (`csv-generator.ts`):
-- Simple CSV with columns: Customer VAT Number, Country, Goods (SEK), Services (SEK)
-- BOM for Excel compatibility
-
-#### 1.3 VIES integration (`vies-client.ts`)
-- Call EU VIES API to validate customer VAT numbers
-- Cache validation results (valid for 24 hours)
-- Show validation status indicator (valid / invalid / pending / error)
-- Used in the warnings system to flag invalid numbers before filing
-
-#### 1.4 Workspace UI (`EuSalesListWorkspace.tsx`)
-
-**Layout**:
-```
-┌─────────────────────────────────────────────────┐
-│ Periodisk sammanställning │
-│ │
-│ [Period selector: 2026 / Kvartal 1 ▼] │
-│ Filing type: ○ Varor (monthly) ○ Tjänster (quarterly) ○ Båda │
-│ │
-│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
-│ │ Total varor│ │Total tjänst│ │ Kunder │ │
-│ │ 1 250 000 │ │ 340 000 │ │ 12 │ │
-│ │ SEK │ │ SEK │ │ │ │
-│ └────────────┘ └────────────┘ └────────────┘ │
-│ │
-│ ⚠ 2 varningar [Ladda ner ▼] │
-│ │
-│ ┌───────────────────────────────────────────┐ │
-│ │ VAT-nummer │ Land │ Varor │ Tjänster│ │
-│ │ DE123456789 │ 🇩🇪 │ 450 000│ 0 │ │
-│ │ FR87654321 │ 🇫🇷 │ 320 000│ 120 000 │ │
-│ │ FI11223344 │ 🇫🇮 │ 280 000│ 80 000 │ │
-│ │ ⚠ NL(saknas) │ 🇳🇱 │ 200 000│ 0 │ │
-│ │ ... │ │ │ │ │
-│ └───────────────────────────────────────────┘ │
-│ │
-│ Korsvalidering mot momsdeklaration: │
-│ Ruta 35 (varor): 1 250 000 ✓ │
-│ Ruta 39 (tjänster): 340 000 ✓ │
-│ Nästa deadline: 25 april 2026 (31 dagar kvar) │
-└─────────────────────────────────────────────────┘
-```
-
-**Features**:
-- Period selector (month or quarter)
-- KPI cards: total goods, total services, customer count
-- Warning banner with expandable details
-- Sortable table of customers with VAT numbers, country flags, amounts
-- Download button: CSV or XML format
-- Cross-validation section: compares with momsdeklaration box totals
-- Deadline countdown
-
-#### 1.5 Tests (`__tests__/eu-sales-list-engine.test.ts`)
-
-Test cases:
-- Aggregation by customer VAT number (multiple invoices to same customer)
-- Goods vs services separation (based on revenue account)
-- Credit note handling (reduces customer total, can go negative)
-- Missing VAT number warning
-- Unvalidated VAT number warning
-- Cross-check with GL account totals
-- Empty period (no EU sales)
-- Mixed period (some EU, some non-EU, some domestic)
-- Currency conversion (all amounts in SEK regardless of invoice currency)
-
----
-
-### Phase 2: Export VAT Monitor / Exportmoms-monitor
-
-**Extension slug**: `vat-monitor`
-**Data pattern**: `core` (read-only from journal entries + invoices)
-**Output**: Dashboard with momsdeklaration box mapping
-
-#### 2.1 Engine (`vat-monitor-engine.ts`)
-
-**Input**: User ID, period (year + month or quarter)
-
-**Logic**:
-1. Fetch all journal entry lines for the period on revenue accounts:
- - Domestic: `3001` (25%), `3002` (12%), `3003` (6%)
- - EU goods: `3108` (reverse charge, 0%)
- - EU services: `3308` (reverse charge, 0%)
- - Non-EU goods: `3105` (export, 0%)
- - Non-EU services: `3305` (export, 0%)
- - Triangular: `3109` (trepartshandel)
- - Invoiced freight: `3521` (EU), `3522` (export)
-2. Map each account to the correct momsdeklaration box:
- - `3001/3002/3003` → Box 05 (standard taxable sales)
- - `3108` → Box 35 (EU goods)
- - `3305` → Box 40 (other services abroad)
- - `3308` → Box 39 (EU services, main rule)
- - `3105` → Box 36 (goods export outside EU)
- - `3109` → Box 38 (triangular trade sales)
- - `3521` → follows goods treatment (Box 35)
- - `3522` → Box 36
-3. Sum credit amounts per box (revenue is credit-side)
-4. Also fetch VAT account totals:
- - `2611` (output VAT 25%), `2621` (12%), `2631` (6%) → Boxes 10, 11, 12
- - `2641` (input VAT) → Box 48
-5. Calculate net VAT (output - input) → Box 49
-6. Validate:
- - Box 35 + Box 39 should match EU Sales List totals (if extension 1 is enabled)
- - Invoices with `vat_treatment = 'reverse_charge'` should be on accounts 3108/3308
- - Invoices with `vat_treatment = 'export'` should be on accounts 3105/3305
- - Flag any invoice where the `moms_ruta` doesn't match the expected box for its account
-
-**Output structure**:
-```typescript
-interface VatMonitorReport {
- period: { year: number; month?: number; quarter?: number }
- boxes: Record // '05', '10', '11', '12', '35', '36', '39', '40', '48', '49'
- revenueBreakdown: {
- domestic: { amount: number; percentage: number }
- euGoods: { amount: number; percentage: number }
- euServices: { amount: number; percentage: number }
- exportGoods: { amount: number; percentage: number }
- exportServices: { amount: number; percentage: number }
- triangular: { amount: number; percentage: number }
- }
- warnings: VatMonitorWarning[]
- previousPeriod?: VatMonitorReport // For comparison
-}
-
-interface VatBoxData {
- boxNumber: string
- label: string // Swedish label
- amount: number // SEK
- accounts: string[] // Contributing BAS accounts
-}
-
-interface VatMonitorWarning {
- type: 'wrong_account' | 'missing_moms_ruta' | 'vat_treatment_mismatch' | 'missing_vat_number' | 'cross_check_mismatch'
- severity: 'error' | 'warning'
- invoiceId?: string
- message: string
-}
-```
-
-#### 2.2 Box mapping reference (`moms-box-mapping.ts`)
-
-```typescript
-// Shared between EU Sales List and VAT Monitor
-const ACCOUNT_TO_BOX: Record = {
- '3001': '05', '3002': '05', '3003': '05', // Domestic revenue
- '3108': '35', // EU goods (reverse charge)
- '3308': '39', // EU services (reverse charge)
- '3105': '36', // Export goods (non-EU)
- '3305': '40', // Export services (non-EU)
- '3109': '38', // Triangular trade
- '3521': '35', // Invoiced freight EU
- '3522': '36', // Invoiced freight export
- '2611': '10', '2621': '11', '2631': '12', // Output VAT
- '2641': '48', // Input VAT
-}
-
-const BOX_LABELS: Record = {
- '05': 'Momspliktig försäljning',
- '10': 'Utgående moms 25%',
- '11': 'Utgående moms 12%',
- '12': 'Utgående moms 6%',
- '35': 'Varuförsäljning till annat EU-land',
- '36': 'Varuförsäljning utanför EU (export)',
- '37': 'Mellanmans inköp vid trepartshandel',
- '38': 'Mellanmans försäljning vid trepartshandel',
- '39': 'Tjänsteförsäljning till EU (huvudregeln)',
- '40': 'Övrig försäljning av tjänster utomlands',
- '41': 'Försäljning med 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',
-}
-```
-
-#### 2.3 Workspace UI (`VatMonitorWorkspace.tsx`)
-
-**Layout**:
-```
-┌─────────────────────────────────────────────────────────┐
-│ Exportmoms-monitor │
-│ │
-│ [Period: 2026-03 ▼] [Jämför med: 2026-02 ▼] │
-│ │
-│ ── Intäktsfördelning ────────────────────────────── │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ Inrikes │ │ EU varor │ │EU tjänst │ │ Export │ │
-│ │2 100 000 │ │1 250 000 │ │ 340 000 │ │ 890 000 │ │
-│ │ 46% │ │ 27% │ │ 7% │ │ 20% │ │
-│ │ ↑ +5% │ │ ↓ -3% │ │ ↑ +12% │ │ ↑ +8% │ │
-│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
-│ │
-│ ── Momsdeklaration (förhandsvisning) ────────────── │
-│ ┌─────────────────────────────────────────────────┐ │
-│ │ Ruta │ Beskrivning │ Belopp │ │
-│ │ 05 │ Momspliktig försäljning │2 100 000│ │
-│ │ 10 │ Utgående moms 25% │ 525 000│ │
-│ │ 35 │ Varuförsäljning EU │1 250 000│ │
-│ │ 36 │ Export utanför EU │ 890 000│ │
-│ │ 39 │ Tjänsteförsäljning EU │ 340 000│ │
-│ │ 48 │ Ingående moms │ 380 000│ │
-│ │ 49 │ Moms att betala │ 145 000│ │
-│ └─────────────────────────────────────────────────┘ │
-│ │
-│ ⚠ 1 varning: Faktura #2026-042 till DE-kund saknar │
-│ validerat VAT-nummer men är bokförd på konto 3108. │
-│ │
-│ ── Trend (senaste 6 månader) ────────────────────── │
-│ [Bar chart: domestic vs EU vs export per month] │
-└─────────────────────────────────────────────────────────┘
-```
-
-**Features**:
-- Period selector with comparison period
-- KPI cards: revenue by destination type with % share and delta
-- Momsdeklaration preview table with all relevant boxes pre-filled
-- Warning panel with actionable messages
-- 6-month trend chart showing revenue mix over time
-- Drill-down: click a box number to see contributing invoices
-
-#### 2.4 Tests (`__tests__/vat-monitor-engine.test.ts`)
-
-Test cases:
-- Correct box mapping for each revenue account
-- Mixed domestic + EU + export revenue
-- Period comparison (delta calculation)
-- Warning: invoice on 3108 without validated VAT number
-- Warning: invoice `vat_treatment` doesn't match account
-- Warning: `moms_ruta` doesn't match expected box for account
-- Empty period
-- Only domestic sales (no export boxes populated)
-- Freight accounts follow goods treatment
-- VAT calculation: output minus input = box 49
-
----
-
-### Phase 3: Intrastat Generator
-
-**Extension slug**: `intrastat`
-**Data pattern**: `both` (reads invoices + stores product metadata in extension_data)
-**Output**: Downloadable CSV file for SCB IDEP.web
-
-#### 3.1 Product metadata storage
-
-Uses `extension_data` table with these key patterns:
-- `product:{productId}` → `{ cn_code, description, net_weight_kg, country_of_origin, supplementary_unit, supplementary_unit_type }`
-- `settings` → `{ default_transaction_nature: '11', default_delivery_terms: 'FCA', threshold_alert_enabled: true }`
-
-The `productId` is a user-defined identifier (e.g., SKU or product name) since there's no core products table.
-
-#### 3.2 Engine (`intrastat-engine.ts`)
-
-**Input**: User ID, period (year + month)
-
-**Logic**:
-1. Fetch all invoices for the period where:
- - Customer `country` is an EU member state (not Sweden)
- - `vat_treatment = 'reverse_charge'` (B2B goods)
- - Revenue account is `3108` (goods to EU)
- - `status` is `sent` or `paid`
-2. For each invoice line, look up product metadata from extension_data
-3. Aggregate by: CN code + partner country + country of origin + transaction nature + delivery terms
-4. For each aggregated line, calculate:
- - Total invoiced value in SEK (using `total_sek` from invoice)
- - Total net mass (kg) from product metadata × quantity
- - Supplementary units (if required by CN code)
-5. Handle credit notes: generate correction lines for the original period
-6. Calculate cumulative dispatch value (rolling 12 months) for threshold monitoring
-
-**Output structure**:
-```typescript
-interface IntrastatReport {
- period: { year: number; month: number }
- reporterVatNumber: string
- reporterName: string
- flowType: 'dispatch' // We focus on exports
- lines: IntrastatLine[]
- totals: { invoicedValue: number; netMass: number; lineCount: number }
- thresholdStatus: {
- cumulativeValue: number // Rolling 12 months
- threshold: 12_000_000 // SEK
- isObligated: boolean
- percentageUsed: number
- }
- warnings: IntrastatWarning[]
-}
-
-interface IntrastatLine {
- cnCode: string // 8-digit CN commodity code
- partnerCountry: string // 2-letter ISO (destination)
- countryOfOrigin: string // 2-letter ISO
- transactionNature: string // 2-digit code (e.g., '11' for outright sale)
- deliveryTerms: string // Incoterms code
- invoicedValue: number // SEK, rounded to whole
- netMass: number // kg, up to 3 decimals
- supplementaryUnit?: number
- supplementaryUnitType?: string
- partnerVatId: string // Customer VAT number
-}
-
-interface IntrastatWarning {
- type: 'missing_cn_code' | 'missing_weight' | 'missing_origin' | 'unmatched_invoice_line' | 'threshold_approaching'
- invoiceId?: string
- productId?: string
- message: string
-}
-```
-
-#### 3.3 SCB file generator (`scb-file-generator.ts`)
-
-Generates CSV compatible with IDEP.web upload:
-- Header row with field names
-- One row per aggregated line
-- Encoding: UTF-8 with BOM
-- Semicolon-separated (IDEP.web standard)
-- Fields: CN code, partner country, country of origin, transaction nature, delivery terms, invoiced value, net mass, supplementary unit, partner VAT ID
-
-#### 3.4 Workspace UI (`IntrastatWorkspace.tsx`)
-
-**Layout**:
-```
-┌──────────────────────────────────────────────────────────┐
-│ Intrastat-generator │
-│ │
-│ [Period: 2026-03 ▼] │
-│ │
-│ ── Tröskelvärde ──────────────────────────────────── │
-│ ┌────────────────────────────────────────────────┐ │
-│ │ Ackumulerad utförsel (12 mån): 8 450 000 SEK │ │
-│ │ ████████████████░░░░░░░░ 70% av 12 000 000 │ │
-│ │ Status: Under tröskelvärdet (frivillig) │ │
-│ └────────────────────────────────────────────────┘ │
-│ │
-│ ── Produktregister ─────────────────── [+ Lägg till] │
-│ ┌──────────────────────────────────────────────────┐ │
-│ │ Produkt │ CN-kod │ Vikt(kg) │ Ursprung │ │
-│ │ Stålbalk M8 │ 72163100 │ 45.5 │ SE │ │
-│ │ Ventil DN50 │ 84818019 │ 2.3 │ DE │ │
-│ │ ⚠ Pump XL │ (saknas) │ 12.0 │ SE │ │
-│ └──────────────────────────────────────────────────┘ │
-│ │
-│ ── Deklaration mars 2026 ──────────── [Ladda ner CSV] │
-│ ┌──────────────────────────────────────────────────┐ │
-│ │ CN-kod │ Land │ Urspr │ Värde SEK│ Vikt kg │ │
-│ │ 72163100 │ DE │ SE │ 450 000 │ 4 550 │ │
-│ │ 72163100 │ FI │ SE │ 120 000 │ 1 200 │ │
-│ │ 84818019 │ DE │ DE │ 230 000 │ 46 │ │
-│ │ Total │ │ │ 800 000 │ 5 796 │ │
-│ └──────────────────────────────────────────────────┘ │
-│ │
-│ ⚠ 1 varning: Produkt "Pump XL" saknar CN-kod. │
-│ Deadline: 14 april 2026 (10:e arbetsdagen) │
-└──────────────────────────────────────────────────────────┘
-```
-
-**Features**:
-- Threshold progress bar (cumulative 12-month dispatches vs SEK 12M)
-- Product registry: CRUD for product metadata (CN codes, weights, origin)
-- Auto-generated declaration table from period's EU goods invoices
-- Warning panel for missing metadata
-- Download button for SCB-compatible CSV
-- Deadline display (10th business day of following month)
-
-#### 3.5 Tests (`__tests__/intrastat-engine.test.ts`)
-
-Test cases:
-- Aggregation by CN code + country + origin
-- Multiple invoices to same country with same CN code (should aggregate)
-- Credit note correction (negative line for original period)
-- Missing CN code warning
-- Missing weight warning
-- Threshold calculation (rolling 12 months)
-- Threshold crossing alert
-- Empty period (no EU goods dispatches)
-- Non-EU invoices excluded
-- Services excluded (only goods on account 3108)
-
----
-
-### Phase 4: Multi-Currency Receivables Manager / Valutafordringar
-
-**Extension slug**: `currency-receivables`
-**Data pattern**: `core` (read-only from invoices + journal entries + transactions)
-**Output**: Dashboard showing FX exposure and unrealized gains/losses
-
-#### 4.1 Engine (`receivables-engine.ts`)
-
-**Input**: User ID, reference date (default: today)
-
-**Logic**:
-1. Fetch all unpaid invoices (`status = 'sent'` or `'overdue'`) where `currency != 'SEK'`
-2. For each invoice, calculate:
- - Original amount in foreign currency (`total`)
- - Booked SEK value (`total_sek` or `total × exchange_rate`)
- - Current SEK value using today's Riksbanken rate
- - Unrealized gain/loss = current SEK value - booked SEK value
-3. Group by currency for exposure summary
-4. Fetch realized gains/losses from journal entry lines:
- - Account `3960` (gains) credit amounts for the period
- - Account `7960` (losses) debit amounts for the period
-5. Fetch historical realized FX per month for trend analysis
-6. Calculate totals:
- - Total foreign receivables (SEK equivalent at current rate)
- - Total unrealized gain/loss
- - Total realized gain/loss for current period
-
-**Output structure**:
-```typescript
-interface CurrencyReceivablesReport {
- referenceDate: string
- exchangeRates: Record // From Riksbanken
-
- // Exposure by currency
- exposureByCurrency: CurrencyExposure[]
-
- // Individual receivables
- receivables: ForeignReceivable[]
-
- // Realized FX for period
- realizedGainLoss: {
- period: { year: number; month: number }
- gains: number // Account 3960 credit total
- losses: number // Account 7960 debit total
- net: number
- }
-
- // Monthly trend (last 12 months)
- monthlyTrend: MonthlyFXTrend[]
-}
-
-interface CurrencyExposure {
- currency: string
- totalForeignAmount: number // In original currency
- bookedSekValue: number // At invoice-date rates
- currentSekValue: number // At today's Riksbanken rate
- unrealizedGainLoss: number // currentSek - bookedSek
- invoiceCount: number
- averageBookedRate: number
- currentRate: number
-}
-
-interface ForeignReceivable {
- invoiceId: string
- invoiceNumber: string
- customerName: string
- customerCountry: string
- currency: string
- foreignAmount: number
- bookedSekAmount: number
- bookedRate: number
- currentSekAmount: number
- currentRate: number
- unrealizedGainLoss: number
- invoiceDate: string
- dueDate: string
- daysOutstanding: number
-}
-
-interface MonthlyFXTrend {
- month: string // 'YYYY-MM'
- realizedGains: number
- realizedLosses: number
- netRealized: number
- unrealizedAtMonthEnd: number
-}
-```
-
-#### 4.2 Riksbanken rate integration
-
-Extend `lib/currency/` or create `riksbanken-client.ts`:
-- Fetch daily mid-rates from Riksbanken's REST API
-- Cache rates for the current day
-- Support historical rate lookup (for trend calculations)
-- Fallback: use the most recent available rate if today's isn't published yet (rates published at 16:15 on business days)
-
-**Riksbanken API**: `https://api.riksbank.se/swea/v1/CrossRates`
-
-#### 4.3 Workspace UI (`CurrencyReceivablesWorkspace.tsx`)
-
-**Layout**:
-```
-┌──────────────────────────────────────────────────────────┐
-│ Valutafordringar │
-│ │
-│ Växelkurser per 2026-03-15 (Riksbanken) │
-│ EUR: 11.42 USD: 10.85 GBP: 13.72 NOK: 1.02 │
-│ │
-│ ── Valutaexponering ──────────────────────────────── │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ EUR │ │ USD │ │ GBP │ │ Totalt │ │
-│ │€ 125 000 │ │$ 45 000 │ │£ 12 000 │ │ │ │
-│ │1 427 500 │ │ 488 250 │ │ 164 640 │ │2 080 390 │ │
-│ │ SEK │ │ SEK │ │ SEK │ │ SEK │ │
-│ │ +32 500 │ │ -8 200 │ │ +1 440 │ │ +25 740 │ │
-│ │ orealis. │ │ orealis. │ │ orealis. │ │ orealis. │ │
-│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
-│ │
-│ ── Öppna fordringar ───────────────────────────────── │
-│ ┌──────────────────────────────────────────────────┐ │
-│ │ Faktura │ Kund │ Valuta│ Belopp │Orealis.│ │
-│ │ 2026-031 │ Müller GmbH│ EUR │ 50 000 │+12 500 │ │
-│ │ 2026-035 │ Smith Inc │ USD │ 25 000 │ -5 200 │ │
-│ │ 2026-038 │ Dupont SA │ EUR │ 75 000 │+20 000 │ │
-│ │ 2026-041 │ Jones Ltd │ GBP │ 12 000 │ +1 440 │ │
-│ │ 2026-044 │ Brown Corp │ USD │ 20 000 │ -3 000 │ │
-│ └──────────────────────────────────────────────────┘ │
-│ │
-│ ── Realiserade kursdifferenser 2026 ───────────────── │
-│ ┌──────────────────────────────────────────────────┐ │
-│ │ Månad │ Vinst(3960)│ Förlust(7960)│ Netto │ │
-│ │ Jan │ +8 500 │ -3 200 │ +5 300 │ │
-│ │ Feb │ +12 300 │ -7 800 │ +4 500 │ │
-│ │ Mar │ +4 200 │ -1 100 │ +3 100 │ │
-│ │ Totalt │ +25 000 │ -12 100 │ +12 900 │ │
-│ └──────────────────────────────────────────────────┘ │
-│ │
-│ ── Period-end revaluation preview ─────────────────── │
-│ Om bokslut görs idag: netto orealiserad vinst +25 740 │
-│ (Konto 3969: +33 940 / Konto 7969: -8 200) │
-│ ℹ Bokföringsposterna skapas inte av detta tillägg. │
-│ Använd värdena ovan som underlag vid periodbokslut. │
-└──────────────────────────────────────────────────────────┘
-```
-
-**Features**:
-- Live Riksbanken exchange rates display
-- Exposure KPI cards per currency showing foreign amount, SEK value, unrealized gain/loss
-- Sortable receivables table with per-invoice unrealized gain/loss
-- Color coding: green for gains, red for losses
-- Realized FX trend table (monthly, from accounts 3960/7960)
-- Period-end revaluation preview (informational — tells user what entries to make, doesn't create them)
-- Note clarifying that this extension doesn't create journal entries
-
-#### 4.4 Tests (`__tests__/receivables-engine.test.ts`)
-
-Test cases:
-- Unrealized gain calculation (rate increased since invoice date)
-- Unrealized loss calculation (rate decreased)
-- Multiple currencies aggregation
-- Paid invoices excluded from exposure
-- Realized gains from account 3960
-- Realized losses from account 7960
-- Monthly trend calculation
-- Empty state (no foreign receivables)
-- SEK-only invoices excluded
-- Exchange rate not available (use most recent)
-
----
-
-### Phase 5: Integration & Polish
-
-#### 5.1 Register all extensions in loader
-Add to `FIRST_PARTY_EXTENSIONS` in `lib/extensions/loader.ts`:
-```typescript
-import { euSalesListExtension } from '@/extensions/export/eu-sales-list'
-import { vatMonitorExtension } from '@/extensions/export/vat-monitor'
-import { intrastatExtension } from '@/extensions/export/intrastat'
-import { currencyReceivablesExtension } from '@/extensions/export/currency-receivables'
-```
-
-#### 5.2 Register workspace components
-Add to `lib/extensions/workspace-registry.tsx`:
-```typescript
-'export/eu-sales-list': dynamic(() => import('@/components/extensions/export/EuSalesListWorkspace')),
-'export/vat-monitor': dynamic(() => import('@/components/extensions/export/VatMonitorWorkspace')),
-'export/intrastat': dynamic(() => import('@/components/extensions/export/IntrastatWorkspace')),
-'export/currency-receivables': dynamic(() => import('@/components/extensions/export/CurrencyReceivablesWorkspace')),
-```
-
-#### 5.3 Add icon imports
-Update `lib/extensions/icon-resolver.tsx` with new icons:
-- `Ship` — sector icon
-- `FileText` — EU Sales List
-- `Shield` — VAT Monitor
-- `BarChart3` — Intrastat (already exists)
-- `TrendingUp` — Currency Receivables (already exists)
-
-#### 5.4 Cross-extension validation
-When multiple export extensions are enabled:
-- VAT Monitor can cross-reference with EU Sales List totals
-- Intrastat threshold data validates against VAT Monitor's box 35 total
-- Currency Receivables exposure aligns with invoices visible in EU Sales List
-
-#### 5.5 API routes
-Each extension needs data-fetching API routes:
-
-| Route | Method | Purpose |
-|-------|--------|---------|
-| `/api/extensions/export/eu-sales-list/report` | GET | Generate report for period |
-| `/api/extensions/export/eu-sales-list/download` | GET | Download CSV/XML file |
-| `/api/extensions/export/eu-sales-list/validate-vat` | POST | VIES VAT number validation |
-| `/api/extensions/export/vat-monitor/report` | GET | Generate VAT box report |
-| `/api/extensions/export/intrastat/report` | GET | Generate Intrastat declaration |
-| `/api/extensions/export/intrastat/download` | GET | Download SCB CSV file |
-| `/api/extensions/export/intrastat/products` | GET/POST/DELETE | Product metadata CRUD |
-| `/api/extensions/export/currency-receivables/report` | GET | Exposure + unrealized report |
-| `/api/extensions/export/currency-receivables/rates` | GET | Current Riksbanken rates |
-
----
-
-## Build Order Summary
-
-| Phase | Extension | Key deliverables | Depends on |
-|-------|-----------|-----------------|------------|
-| 0 | Infrastructure | Sector registration, shared components, shared utilities | — |
-| 1 | EU Sales List | Engine, VIES client, XML generator, workspace, tests | Phase 0 |
-| 2 | VAT Monitor | Engine, box mapping, workspace, tests | Phase 0 |
-| 3 | Intrastat | Engine, product CRUD, SCB CSV generator, workspace, tests | Phase 0 |
-| 4 | Currency Receivables | Engine, Riksbanken client, workspace, tests | Phase 0 |
-| 5 | Integration | Loader registration, workspace registry, icon imports, cross-validation | Phases 1-4 |
diff --git a/extensions.config.json b/extensions.config.json
index c61a9d8d..58eee9fa 100644
--- a/extensions.config.json
+++ b/extensions.config.json
@@ -1 +1 @@
-{"$schema":"./extensions.schema.json","extensions":["enable-banking","ai-categorization","ai-chat","email"]}
+{"$schema":"./extensions.schema.json","extensions":["enable-banking","ai-chat","email"]}
diff --git a/extensions/general/ai-categorization/categorizer.ts b/extensions/general/ai-categorization/categorizer.ts
index 1253bec7..bef81780 100644
--- a/extensions/general/ai-categorization/categorizer.ts
+++ b/extensions/general/ai-categorization/categorizer.ts
@@ -126,7 +126,7 @@ const CATEGORY_DEFAULT_TEMPLATES: Record = {
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',
}
diff --git a/extensions/general/ai-categorization/lib/description-analyzer.ts b/extensions/general/ai-categorization/lib/description-analyzer.ts
index ec33878a..40d84a2d 100644
--- a/extensions/general/ai-categorization/lib/description-analyzer.ts
+++ b/extensions/general/ai-categorization/lib/description-analyzer.ts
@@ -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)
diff --git a/extensions/general/ai-chat/ingestion/ingest.ts b/extensions/general/ai-chat/ingestion/ingest.ts
index 210ee278..e1b01920 100644
--- a/extensions/general/ai-chat/ingestion/ingest.ts
+++ b/extensions/general/ai-chat/ingestion/ingest.ts
@@ -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)
diff --git a/extensions/general/email/lib/resend-service.ts b/extensions/general/email/lib/resend-service.ts
index e65869a7..f320078b 100644
--- a/extensions/general/email/lib/resend-service.ts
+++ b/extensions/general/email/lib/resend-service.ts
@@ -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,
})),
})
diff --git a/extensions/general/invoice-inbox/__tests__/index.test.ts b/extensions/general/invoice-inbox/__tests__/index.test.ts
index b4a318a2..0f254091 100644
--- a/extensions/general/invoice-inbox/__tests__/index.test.ts
+++ b/extensions/general/invoice-inbox/__tests__/index.test.ts
@@ -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'
diff --git a/extensions/general/push-notifications/payload-builders.ts b/extensions/general/push-notifications/payload-builders.ts
index a6ebd820..52af028c 100644
--- a/extensions/general/push-notifications/payload-builders.ts
+++ b/extensions/general/push-notifications/payload-builders.ts
@@ -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',
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index f52d078e..28597c71 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -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([
diff --git a/lib/bookkeeping/__tests__/booking-templates.test.ts b/lib/bookkeeping/__tests__/booking-templates.test.ts
index 88fbdd6d..391be003 100644
--- a/lib/bookkeeping/__tests__/booking-templates.test.ts
+++ b/lib/bookkeeping/__tests__/booking-templates.test.ts
@@ -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')
+ })
+})
diff --git a/lib/bookkeeping/__tests__/currency-revaluation.test.ts b/lib/bookkeeping/__tests__/currency-revaluation.test.ts
new file mode 100644
index 00000000..a2ea34b0
--- /dev/null
+++ b/lib/bookkeeping/__tests__/currency-revaluation.test.ts
@@ -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[]
+ supplierInvoices?: ReturnType[]
+ existingRevaluation?: boolean
+}) {
+ const fromMap: Record = {
+ 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 = {}
+ 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 = {}
+
+ chain.select = vi.fn().mockImplementation(() => {
+ return chain
+ })
+
+ chain.eq = vi.fn().mockImplementation((col: string, val: unknown) => {
+ filtered = filtered.filter((row: Record) => row[col] === val)
+ return chain
+ })
+
+ chain.neq = vi.fn().mockImplementation((col: string, val: unknown) => {
+ filtered = filtered.filter((row: Record) => row[col] !== val)
+ return chain
+ })
+
+ chain.in = vi.fn().mockImplementation((col: string, vals: unknown[]) => {
+ filtered = filtered.filter((row: Record) => 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) => 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[]
+ supplierInvoices?: ReturnType[]
+ 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 = {}
+ 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 = {
+ 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) {
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+
+ 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) {
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+ })
+ })
+})
diff --git a/lib/bookkeeping/bas-data/class-1-assets.ts b/lib/bookkeeping/bas-data/class-1-assets.ts
index 6f3304cf..8ce923d3 100644
--- a/lib/bookkeeping/bas-data/class-1-assets.ts
+++ b/lib/bookkeeping/bas-data/class-1-assets.ts
@@ -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,
},
diff --git a/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts b/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts
index 75806442..070790ad 100644
--- a/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts
+++ b/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts
@@ -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,
},
diff --git a/lib/bookkeeping/bas-data/class-3-revenue.ts b/lib/bookkeeping/bas-data/class-3-revenue.ts
index 400c282b..d9f2ba81 100644
--- a/lib/bookkeeping/bas-data/class-3-revenue.ts
+++ b/lib/bookkeeping/bas-data/class-3-revenue.ts
@@ -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,
},
diff --git a/lib/bookkeeping/bas-data/class-4-purchases.ts b/lib/bookkeeping/bas-data/class-4-purchases.ts
index 4f7306b5..cf22ee84 100644
--- a/lib/bookkeeping/bas-data/class-4-purchases.ts
+++ b/lib/bookkeeping/bas-data/class-4-purchases.ts
@@ -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,
},
diff --git a/lib/bookkeeping/bas-data/class-5-external-expenses.ts b/lib/bookkeeping/bas-data/class-5-external-expenses.ts
index 05203035..c18e242d 100644
--- a/lib/bookkeeping/bas-data/class-5-external-expenses.ts
+++ b/lib/bookkeeping/bas-data/class-5-external-expenses.ts
@@ -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,
},
diff --git a/lib/bookkeeping/bas-data/class-6-other-external.ts b/lib/bookkeeping/bas-data/class-6-other-external.ts
index ddc1e473..3cfe8b52 100644
--- a/lib/bookkeeping/bas-data/class-6-other-external.ts
+++ b/lib/bookkeeping/bas-data/class-6-other-external.ts
@@ -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,
},
diff --git a/lib/bookkeeping/bas-data/class-7-personnel.ts b/lib/bookkeeping/bas-data/class-7-personnel.ts
index 3e5a6c7e..39c84e48 100644
--- a/lib/bookkeeping/bas-data/class-7-personnel.ts
+++ b/lib/bookkeeping/bas-data/class-7-personnel.ts
@@ -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,
},
diff --git a/lib/bookkeeping/bas-data/class-8-financial.ts b/lib/bookkeeping/bas-data/class-8-financial.ts
index d9977620..a4e9b42e 100644
--- a/lib/bookkeeping/bas-data/class-8-financial.ts
+++ b/lib/bookkeeping/bas-data/class-8-financial.ts
@@ -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,
},
diff --git a/lib/bookkeeping/bas-reference.ts b/lib/bookkeeping/bas-reference.ts
index 900de1ba..e3c00147 100644
--- a/lib/bookkeeping/bas-reference.ts
+++ b/lib/bookkeeping/bas-reference.ts
@@ -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 = {
- 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 = {
/** Swedish labels for BAS account groups (first two digits) */
export const ACCOUNT_GROUP_LABELS: Record = {
// 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',
}
// ---------------------------------------------------------------------------
diff --git a/lib/bookkeeping/booking-templates.ts b/lib/bookkeeping/booking-templates.ts
index 4e9ac9bd..fbf94b62 100644
--- a/lib/bookkeeping/booking-templates.ts
+++ b/lib/bookkeeping/booking-templates.ts
@@ -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 = {
}
// ============================================================
-// 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.
diff --git a/lib/bookkeeping/client-account-names.ts b/lib/bookkeeping/client-account-names.ts
index 3bdb19d5..7abcc874 100644
--- a/lib/bookkeeping/client-account-names.ts
+++ b/lib/bookkeeping/client-account-names.ts
@@ -7,52 +7,52 @@
const ACCOUNT_NAMES: Record = {
// 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',
}
diff --git a/lib/bookkeeping/currency-revaluation.ts b/lib/bookkeeping/currency-revaluation.ts
new file mode 100644
index 00000000..2c1ef123
--- /dev/null
+++ b/lib/bookkeeping/currency-revaluation.ts
@@ -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 {
+ 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 {
+ 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 {
+ const [receivables, payables] = await Promise.all([
+ getOpenForeignCurrencyReceivables(supabase, userId),
+ getOpenForeignCurrencyPayables(supabase, userId),
+ ])
+
+ // Collect distinct currencies
+ const currencies = new Set()
+ 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 = {}
+ 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 {
+ // 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 }
+}
diff --git a/lib/core/bookkeeping/__tests__/year-end-service.test.ts b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
index 8feb583d..b950b3ec 100644
--- a/lib/core/bookkeeping/__tests__/year-end-service.test.ts
+++ b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
@@ -11,7 +11,7 @@ let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
function makeBuilder() {
const b: Record = {}
- 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)
diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts
index 8c270dec..7bbd6d1f 100644
--- a/lib/core/bookkeeping/year-end-service.ts
+++ b/lib/core/bookkeeping/year-end-service.ts
@@ -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,
}
}
diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts
index 5aa8e317..86e0e64a 100644
--- a/lib/extensions/_generated/enabled-extensions.ts
+++ b/lib/extensions/_generated/enabled-extensions.ts
@@ -2,7 +2,6 @@
export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([
'enable-banking',
- 'ai-categorization',
'ai-chat',
'email',
])
diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts
index eeb40d7e..e353a9f5 100644
--- a/lib/extensions/_generated/extension-list.ts
+++ b/lib/extensions/_generated/extension-list.ts
@@ -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,
]
diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts
index bb8affc4..17f5cff1 100644
--- a/lib/extensions/_generated/sector-definitions.ts
+++ b/lib/extensions/_generated/sector-definitions.ts
@@ -15,19 +15,6 @@ export const EXTENSION_DEFINITIONS: Record = {
"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",
diff --git a/lib/extensions/_generated/workspace-map.tsx b/lib/extensions/_generated/workspace-map.tsx
index 8efab471..21d79ebb 100644
--- a/lib/extensions/_generated/workspace-map.tsx
+++ b/lib/extensions/_generated/workspace-map.tsx
@@ -5,6 +5,5 @@ import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record> = {
'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')),
}
diff --git a/lib/extensions/use-account-totals.ts b/lib/extensions/use-account-totals.ts
deleted file mode 100644
index 4e44798c..00000000
--- a/lib/extensions/use-account-totals.ts
+++ /dev/null
@@ -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([])
- const [monthly, setMonthly] = useState([])
- 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,
- }
-}
diff --git a/lib/extensions/use-mock-data.ts b/lib/extensions/use-mock-data.ts
deleted file mode 100644
index db01cd0e..00000000
--- a/lib/extensions/use-mock-data.ts
+++ /dev/null
@@ -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 {
- mockReport: T | null
- isMockActive: boolean
- isLoading: boolean
- importedAt: string | null
- meta: MockMeta | null
- saveMockData: (report: T, meta: Omit) => Promise
- clearMockData: () => Promise
-}
-
-export function useMockData(sector: string, slug: string): UseMockDataResult {
- const { getByKey, save, remove, isLoading } = useExtensionData(sector, slug)
-
- const [mockReport, setMockReport] = useState(null)
- const [isMockActive, setIsMockActive] = useState(false)
- const [meta, setMeta] = useState(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) => {
- const fullMeta: MockMeta = {
- ...metaInput,
- importedAt: new Date().toISOString(),
- }
-
- await save('mock:enabled', { enabled: true })
- await save('mock:report', report as unknown as Record)
- await save('mock:meta', fullMeta as unknown as Record)
-
- 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,
- }
-}
diff --git a/lib/reports/sru-export/sru-generator.ts b/lib/reports/ne-bilaga/sru-generator.ts
similarity index 100%
rename from lib/reports/sru-export/sru-generator.ts
rename to lib/reports/ne-bilaga/sru-generator.ts
diff --git a/lib/reports/sru-export/sru-engine.ts b/lib/reports/sru-export/sru-engine.ts
deleted file mode 100644
index 449edcf9..00000000
--- a/lib/reports/sru-export/sru-engine.ts
+++ /dev/null
@@ -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> {
-
- // 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()
- const accountNameMap = new Map()
- 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()
- 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()
-
- 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 {
-
- 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,
- })),
- }
-}
diff --git a/lib/reports/sru-export/sru-generic-generator.ts b/lib/reports/sru-export/sru-generic-generator.ts
deleted file mode 100644
index c1752c3f..00000000
--- a/lib/reports/sru-export/sru-generic-generator.ts
+++ /dev/null
@@ -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
-}
-
-/**
- * SRU code descriptions for display
- */
-export const SRU_CODE_DESCRIPTIONS: Record = {
- // 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 }
diff --git a/lib/reports/sru-export/types.ts b/lib/reports/sru-export/types.ts
deleted file mode 100644
index 61334732..00000000
--- a/lib/reports/sru-export/types.ts
+++ /dev/null
@@ -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'
diff --git a/lib/transactions/category-suggestions.ts b/lib/transactions/category-suggestions.ts
index bddfa850..2de71739 100644
--- a/lib/transactions/category-suggestions.ts
+++ b/lib/transactions/category-suggestions.ts
@@ -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()
+ 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 {
- let matches: TemplateMatch[]
+ const seen = new Set()
+ 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)
}
diff --git a/lib/vat/moms-box-mapping.ts b/lib/vat/moms-box-mapping.ts
index d0c899d2..4694c520 100644
--- a/lib/vat/moms-box-mapping.ts
+++ b/lib/vat/moms-box-mapping.ts
@@ -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 = {
// 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 */
diff --git a/mock_data/exportmoms-monitor.json b/mock_data/exportmoms-monitor.json
deleted file mode 100644
index b12c85b3..00000000
--- a/mock_data/exportmoms-monitor.json
+++ /dev/null
@@ -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 }
- }
-}
diff --git a/mock_data/intrastat-generator.json b/mock_data/intrastat-generator.json
deleted file mode 100644
index 31e115b7..00000000
--- a/mock_data/intrastat-generator.json
+++ /dev/null
@@ -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
-}
diff --git a/mock_data/periodisk-sammanstallning.json b/mock_data/periodisk-sammanstallning.json
deleted file mode 100644
index 152a4b15..00000000
--- a/mock_data/periodisk-sammanstallning.json
+++ /dev/null
@@ -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
-}
diff --git a/mock_data/valutafordringar.json b/mock_data/valutafordringar.json
deleted file mode 100644
index 6f8af6aa..00000000
--- a/mock_data/valutafordringar.json
+++ /dev/null
@@ -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
- }
-}
diff --git a/public/manifest.json b/public/manifest.json
index 41e34592..0c6fe66c 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -1,6 +1,6 @@
{
- "name": "ERP Base",
- "short_name": "ERPBase",
+ "name": "Gnubok",
+ "short_name": "Gnubok",
"description": "Ekonomihantering",
"start_url": "/",
"display": "standalone",
diff --git a/supabase/migrations/20240101000046_supplier_invoice_overdue_cron.sql b/supabase/migrations/20240101000048_supplier_invoice_overdue_cron.sql
similarity index 100%
rename from supabase/migrations/20240101000046_supplier_invoice_overdue_cron.sql
rename to supabase/migrations/20240101000048_supplier_invoice_overdue_cron.sql
diff --git a/supabase/migrations/20240101000049_currency_revaluation_source_type.sql b/supabase/migrations/20240101000049_currency_revaluation_source_type.sql
new file mode 100644
index 00000000..698efbbc
--- /dev/null
+++ b/supabase/migrations/20240101000049_currency_revaluation_source_type.sql
@@ -0,0 +1,17 @@
+-- Migration 49: Add currency_revaluation source type
+-- ÅRL 4 kap. 13 § requires period-end revaluation of foreign currency items
+
+ALTER TABLE public.journal_entries
+ DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
+
+ALTER TABLE public.journal_entries
+ ADD CONSTRAINT journal_entries_source_type_check
+ CHECK (source_type IN (
+ 'manual', 'bank_transaction', 'invoice_created',
+ 'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment',
+ 'opening_balance', 'year_end',
+ 'storno', 'correction', 'import', 'system',
+ 'supplier_invoice_registered', 'supplier_invoice_paid',
+ 'supplier_invoice_cash_payment', 'supplier_credit_note',
+ 'currency_revaluation'
+ ));
diff --git a/supabase/migrations/20240101000050_invoice_delivery_note_sequences.sql b/supabase/migrations/20240101000050_invoice_delivery_note_sequences.sql
new file mode 100644
index 00000000..506924ef
--- /dev/null
+++ b/supabase/migrations/20240101000050_invoice_delivery_note_sequences.sql
@@ -0,0 +1,76 @@
+-- Migration 50: Separate invoice and delivery note number sequences
+-- BFL requires sequential, gap-free numbering within each document series.
+-- Separate series per document type is standard Swedish practice.
+
+-- =============================================================================
+-- 1. Add delivery note sequence column to company_settings
+-- =============================================================================
+ALTER TABLE public.company_settings
+ ADD COLUMN IF NOT EXISTS next_delivery_note_number INTEGER DEFAULT 1;
+
+-- =============================================================================
+-- 2. Create generate_invoice_number RPC
+-- Atomically reads invoice_prefix + next_invoice_number, increments, returns
+-- formatted number. Uses UPDATE ... RETURNING for concurrent safety.
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.generate_invoice_number(p_user_id UUID)
+RETURNS TEXT
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_prefix TEXT;
+ v_number INTEGER;
+ v_year TEXT;
+BEGIN
+ UPDATE public.company_settings
+ SET next_invoice_number = next_invoice_number + 1,
+ updated_at = now()
+ WHERE user_id = p_user_id
+ RETURNING invoice_prefix, next_invoice_number - 1
+ INTO v_prefix, v_number;
+
+ IF v_number IS NULL THEN
+ RAISE EXCEPTION 'Company settings not found for user %', p_user_id;
+ END IF;
+
+ v_year := EXTRACT(YEAR FROM CURRENT_DATE)::TEXT;
+
+ RETURN COALESCE(v_prefix, '') || v_year || LPAD(v_number::TEXT, 3, '0');
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.generate_invoice_number(UUID) TO authenticated;
+
+-- =============================================================================
+-- 3. Create generate_delivery_note_number RPC
+-- Same pattern as invoice numbers but uses next_delivery_note_number.
+-- Returns FS-{year}{padded_number} format.
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.generate_delivery_note_number(p_user_id UUID)
+RETURNS TEXT
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_number INTEGER;
+ v_year TEXT;
+BEGIN
+ UPDATE public.company_settings
+ SET next_delivery_note_number = next_delivery_note_number + 1,
+ updated_at = now()
+ WHERE user_id = p_user_id
+ RETURNING next_delivery_note_number - 1
+ INTO v_number;
+
+ IF v_number IS NULL THEN
+ RAISE EXCEPTION 'Company settings not found for user %', p_user_id;
+ END IF;
+
+ v_year := EXTRACT(YEAR FROM CURRENT_DATE)::TEXT;
+
+ RETURN 'FS-' || v_year || LPAD(v_number::TEXT, 3, '0');
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.generate_delivery_note_number(UUID) TO authenticated;
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 9082b1cc..8b7a6e95 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -443,6 +443,7 @@ export function makeCompanySettings(
accounting_method: 'accrual',
invoice_prefix: 'F',
next_invoice_number: 1,
+ next_delivery_note_number: 1,
invoice_default_days: 30,
invoice_default_notes: null,
onboarding_step: 6,
diff --git a/types/index.ts b/types/index.ts
index 95164210..0bd8cced 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -64,9 +64,6 @@ export type ReconciliationMethod = 'auto_exact' | 'auto_date_range' | 'auto_refe
// Bank connection status
export type BankConnectionStatus = 'pending' | 'active' | 'expired' | 'revoked' | 'error'
-// Salary payment status
-export type SalaryPaymentStatus = 'planned' | 'paid' | 'reported'
-
// Currency types
export type Currency = 'SEK' | 'EUR' | 'USD' | 'GBP' | 'NOK' | 'DKK'
@@ -130,6 +127,7 @@ export interface CompanySettings {
// Invoice settings
invoice_prefix: string | null
next_invoice_number: number
+ next_delivery_note_number: number
invoice_default_days: number
invoice_default_notes: string | null
@@ -532,34 +530,6 @@ export interface InvoiceItem {
created_at: string
}
-// Salary Payment (for AB)
-export interface SalaryPayment {
- id: string
- user_id: string
-
- // Payment details
- gross_amount: number
- net_amount: number
-
- // Employer costs
- employer_tax: number // Arbetsgivaravgifter (31.42%)
- preliminary_tax: number // Employee preliminary tax
-
- // Dates
- payment_date: string
- period_start: string
- period_end: string
-
- // Status
- status: SalaryPaymentStatus
-
- // Notes
- notes: string | null
-
- created_at: string
- updated_at: string
-}
-
// Tax Rates (reference table)
export interface TaxRate {
id: string
@@ -786,6 +756,7 @@ export type JournalEntrySourceType =
| 'supplier_invoice_paid'
| 'supplier_invoice_cash_payment'
| 'supplier_credit_note'
+ | 'currency_revaluation'
// Journal entry status
export type JournalEntryStatus = 'draft' | 'posted' | 'reversed'
@@ -1843,12 +1814,45 @@ export interface YearEndPreview {
closingAccountName: string
closingLines: CreateJournalEntryLineInput[]
resultAccountSummary: { account_number: string; account_name: string; amount: number }[]
+ currencyRevaluation: CurrencyRevaluationPreview | null
}
export interface YearEndResult {
closingEntry: JournalEntry
nextPeriod: FiscalPeriod
openingBalanceEntry: JournalEntry
+ revaluationEntry: JournalEntry | null
+}
+
+// ============================================================
+// Currency Revaluation Types (Omvärdering utländsk valuta)
+// ============================================================
+
+export interface RevaluationItem {
+ type: 'receivable' | 'payable'
+ source_id: string
+ reference: string
+ currency: Currency
+ amount_in_currency: number
+ original_rate: number
+ closing_rate: number
+ original_sek: number
+ closing_sek: number
+ difference_sek: number
+}
+
+export interface CurrencyRevaluationPreview {
+ items: RevaluationItem[]
+ lines: CreateJournalEntryLineInput[]
+ closingRates: Record
+ totalGain: number
+ totalLoss: number
+ netEffect: number
+}
+
+export interface CurrencyRevaluationResult {
+ entry: JournalEntry
+ preview: CurrencyRevaluationPreview
}
export interface PeriodStatus {