feat: import system improvements, INK2 fix, and Swedish text corrections (#10)

- SIE parser: Windows-1252 and CP437 encoding detection and decoding
- Bank file parser: add Nordea Business (Företag) CSV format
- Bank file parser: improve format detection for SEB, Länsförsäkringar, generic CSV
- INK2 engine: calculate årets resultat (7222) from income statement for open fiscal years
- Dashboard: parallel Supabase queries, simplified dashboard page
- Fix Swedish characters (å, ä, ö) in BAS data descriptions, validation messages, AI consent disclosures
- Import wizard UI improvements across all steps
- Migration: add 'bas_range' match type to sie_account_mappings constraint
- Extensive new tests for SIE parser encoding and bank file parser

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-11 18:05:37 +01:00
committed by GitHub
parent a1ca96453c
commit e8fb84b4fd
39 changed files with 1129 additions and 425 deletions
+14 -27
View File
@@ -23,40 +23,27 @@ export default function DeadlinesPage() {
setIsLoading(true)
try {
// Fetch deadlines with customer names
const { data: deadlinesData, error: deadlinesError } = await supabase
.from('deadlines')
.select('*, customer:customers(name)')
.order('due_date', { ascending: true })
if (deadlinesError) throw deadlinesError
// Fetch customers for the form
const { data: customersData, error: customersError } = await supabase
.from('customers')
.select('id, name')
.order('name', { ascending: true })
if (customersError) throw customersError
// Fetch overdue invoices summary
const today = new Date().toISOString().split('T')[0]
const { data: overdueData, error: overdueError } = await supabase
.from('invoices')
.select('total_sek, total')
.in('status', ['sent', 'unpaid'])
.lt('due_date', today)
if (overdueError) throw overdueError
// Fetch all data in parallel
const [deadlinesRes, customersRes, overdueRes] = await Promise.all([
supabase.from('deadlines').select('*, customer:customers(name)').order('due_date', { ascending: true }),
supabase.from('customers').select('id, name').order('name', { ascending: true }),
supabase.from('invoices').select('total_sek, total').in('status', ['sent', 'unpaid']).lt('due_date', today),
])
const overdueCount = overdueData?.length || 0
const overdueTotal = (overdueData || []).reduce(
if (deadlinesRes.error) throw deadlinesRes.error
if (customersRes.error) throw customersRes.error
if (overdueRes.error) throw overdueRes.error
const overdueCount = overdueRes.data?.length || 0
const overdueTotal = (overdueRes.data || []).reduce(
(sum, inv) => sum + (inv.total_sek || inv.total || 0),
0
)
setDeadlines(deadlinesData || [])
setCustomers(customersData || [])
setDeadlines(deadlinesRes.data || [])
setCustomers(customersRes.data || [])
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
} catch {
toast({
+36 -9
View File
@@ -38,6 +38,7 @@ import type {
ParseIssue,
} from '@/lib/import/types'
import type { BASAccount } from '@/types'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
// ============================================================
// Bank File Import Wizard Steps
@@ -733,16 +734,21 @@ type ImportMode = null | 'psd2' | 'bank' | 'sie'
export default function ImportPage() {
const [mode, setMode] = useState<ImportMode>(null)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
// If extension isn't compiled in, we know synchronously it's unavailable
const bankingCompiledIn = ENABLED_EXTENSION_IDS.has('enable-banking')
const [hasBankingExtension, setHasBankingExtension] = useState<boolean | null>(
bankingCompiledIn ? null : false
)
useEffect(() => {
if (!bankingCompiledIn) return
fetch('/api/extensions/toggles/general/enable-banking')
.then(res => res.ok ? res.json() : null)
.then(json => {
if (json?.data?.enabled) setHasBankingExtension(true)
setHasBankingExtension(json?.data?.enabled || false)
})
.catch(() => {})
}, [])
.catch(() => setHasBankingExtension(false))
}, [bankingCompiledIn])
return (
<div className="space-y-6">
@@ -755,11 +761,26 @@ export default function ImportPage() {
</div>
{mode === null && (
<div className={`grid gap-4 ${hasBankingExtension ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
{hasBankingExtension && (
<div className={`grid gap-4 ${hasBankingExtension !== false ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
{hasBankingExtension === null && (
<Card className="animate-pulse">
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted" />
<div className="space-y-2 w-full flex flex-col items-center">
<div className="h-5 bg-muted rounded w-24" />
<div className="h-4 bg-muted rounded w-48" />
<div className="h-4 bg-muted rounded w-40" />
</div>
</CardContent>
</Card>
)}
{hasBankingExtension === true && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors border-primary/20 relative"
role="button"
tabIndex={0}
className="cursor-pointer hover:border-primary/50 active:scale-[0.98] transition-all border-primary/20 relative"
onClick={() => setMode('psd2')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('psd2') } }}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
@@ -779,8 +800,11 @@ export default function ImportPage() {
)}
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
role="button"
tabIndex={0}
className="cursor-pointer hover:border-primary/50 active:scale-[0.98] transition-all"
onClick={() => setMode('bank')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('bank') } }}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
@@ -799,8 +823,11 @@ export default function ImportPage() {
</Card>
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
role="button"
tabIndex={0}
className="cursor-pointer hover:border-primary/50 active:scale-[0.98] transition-all"
onClick={() => setMode('sie')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('sie') } }}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
+61 -144
View File
@@ -14,44 +14,70 @@ export default async function DashboardPage() {
redirect('/login')
}
// Fetch profile for name
const { data: profile } = await supabase
.from('profiles')
.select('full_name')
.eq('id', user.id)
.single()
// Fetch current year date boundaries
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
const now = new Date()
const today = now.toISOString().split('T')[0]
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
// Source types that require supporting documents
const needsDocSourceTypes = [
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import',
]
// Fetch all data in parallel
const [
{ data: profile },
{ data: settings },
{ count: customerCount },
{ count: invoiceCount },
{ count: receiptCount },
{ count: transactionCount },
{ data: journalLines },
{ data: transactions },
{ data: unpaidInvoices },
{ data: bankConnections },
{ data: deadlines },
{ count: pendingReviewCount },
{ count: unmatchedReceiptsCount },
{ count: unmatchedTransactionsCount },
{ count: postedEntriesCount },
{ data: entriesWithDocs },
{ data: recentReceiptActivity },
{ data: enabledToggles },
] = await Promise.all([
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase.from('company_settings').select('*').eq('user_id', user.id).single(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status)')
.eq('journal_entry.status', 'posted')
.gte('journal_entry.entry_date', startOfYearStr),
supabase.from('transactions').select('amount, amount_sek, is_business').eq('user_id', user.id).gte('date', startOfYearStr),
supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('user_id', user.id).in('status', ['sent', 'overdue']),
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('user_id', user.id).eq('status', 'active'),
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('user_id', user.id).eq('is_completed', false)
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'extracted'),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'confirmed').is('matched_transaction_id', null),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).lt('amount', 0).is('receipt_id', null),
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'posted').in('source_type', needsDocSourceTypes),
supabase.from('document_attachments').select('journal_entry_id').eq('user_id', user.id).eq('is_current_version', true).not('journal_entry_id', 'is', null),
supabase.from('receipts').select('created_at').eq('user_id', user.id).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
supabase.from('extension_toggles').select('sector_slug, extension_slug').eq('user_id', user.id).eq('enabled', true),
])
const firstName = profile?.full_name?.split(' ')[0] || null
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
// ── EF / AB dashboard ──────────────────────────────────────────────
// Fetch onboarding progress for new user checklist
const { count: customerCount } = await supabase
.from('customers')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const { count: invoiceCount } = await supabase
.from('invoices')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const { count: receiptCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const { count: transactionCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const onboardingProgress: OnboardingProgress = {
hasCustomers: (customerCount || 0) > 0,
hasInvoices: (invoiceCount || 0) > 0,
@@ -59,17 +85,6 @@ export default async function DashboardPage() {
hasBankConnected: (transactionCount || 0) > 0,
}
// Fetch current year date boundaries
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
// Fetch posted journal entry lines for revenue/expense accounts (classes 3-7)
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status)')
.eq('journal_entry.status', 'posted')
.gte('journal_entry.entry_date', startOfYearStr)
// Calculate totals from journal entry lines using account classes
const calculateTotals = (lines: typeof journalLines, fromDate: string) => {
const filtered = (lines || []).filter((l) => {
@@ -100,13 +115,6 @@ export default async function DashboardPage() {
const ytdTotals = calculateTotals(journalLines, startOfYearStr)
const mtdTotals = calculateTotals(journalLines, startOfMonthStr)
// Fetch uncategorized transaction counts (still useful for the alert)
const { data: transactions } = await supabase
.from('transactions')
.select('amount, amount_sek, is_business')
.eq('user_id', user.id)
.gte('date', startOfYearStr)
const uncategorizedTxns = (transactions || []).filter(
(t) => t.is_business === null
)
@@ -118,19 +126,11 @@ export default async function DashboardPage() {
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(Number(t.amount_sek || t.amount)), 0)
// Fetch unpaid invoices with VAT amounts
const { data: unpaidInvoices } = await supabase
.from('invoices')
.select('total, total_sek, vat_amount, vat_amount_sek, status')
.eq('user_id', user.id)
.in('status', ['sent', 'overdue'])
const unpaidTotal = (unpaidInvoices || []).reduce(
(sum, inv) => sum + Number(inv.total_sek || inv.total),
0
)
// Calculate VAT from unpaid invoices (this is VAT we've invoiced but not yet received)
const unpaidVatTotal = (unpaidInvoices || []).reduce(
(sum, inv) => sum + Number(inv.vat_amount_sek || inv.vat_amount || 0),
0
@@ -140,13 +140,6 @@ export default async function DashboardPage() {
(inv) => inv.status === 'overdue'
).length
// Fetch bank balance and consent info (if connected)
const { data: bankConnections } = await supabase
.from('bank_connections')
.select('id, accounts_data, status, consent_expires, bank_name')
.eq('user_id', user.id)
.eq('status', 'active')
let bankBalance: number | null = null
if (bankConnections && bankConnections.length > 0) {
const allBalances = bankConnections.flatMap(conn => {
@@ -158,7 +151,6 @@ export default async function DashboardPage() {
}
}
// Compute expiring bank connections (consent expires within 14 days)
const nowMs = new Date().getTime()
const expiringBankConnections = (bankConnections || [])
.filter(conn => {
@@ -176,80 +168,12 @@ export default async function DashboardPage() {
),
}))
// Fetch upcoming deadlines (next 7 days + overdue)
const now = new Date()
const today = now.toISOString().split('T')[0]
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const { data: deadlines } = await supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('user_id', user.id)
.eq('is_completed', false)
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`)
.order('due_date', { ascending: true })
// Fetch receipt queue summary
const { count: pendingReviewCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'extracted')
const { count: unmatchedReceiptsCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'confirmed')
.is('matched_transaction_id', null)
const { count: unmatchedTransactionsCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.lt('amount', 0)
.is('receipt_id', null)
// Count journal entries missing underlag (documents)
// Source types that require supporting documents
const needsDocSourceTypes = [
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import',
]
const { count: postedEntriesCount } = await supabase
.from('journal_entries')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'posted')
.in('source_type', needsDocSourceTypes)
const { data: entriesWithDocs } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('user_id', user.id)
.eq('is_current_version', true)
.not('journal_entry_id', 'is', null)
const uniqueEntriesWithDocs = new Set(
(entriesWithDocs || []).map((d) => d.journal_entry_id)
).size
const missingUnderlagCount = Math.max(0, (postedEntriesCount || 0) - uniqueEntriesWithDocs)
// Calculate receipt streak
const { data: recentReceiptActivity } = await supabase
.from('receipts')
.select('created_at')
.eq('user_id', user.id)
.eq('status', 'confirmed')
.order('created_at', { ascending: false })
.limit(30)
let streakCount = 0
if (recentReceiptActivity && recentReceiptActivity.length > 0) {
const todayDate = new Date()
@@ -273,13 +197,6 @@ export default async function DashboardPage() {
streak_count: streakCount,
}
// Fetch enabled extension toggles
const { data: enabledToggles } = await supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true)
return (
<DashboardContent
firstName={firstName}
+15 -13
View File
@@ -35,8 +35,10 @@ import type { CompanySettings } from '@/types'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const BankingPanel = getSettingsPanel('enable-banking')
const bankingCompiledIn = ENABLED_EXTENSION_IDS.has('enable-banking')
export default function SettingsPage() {
const router = useRouter()
@@ -47,7 +49,9 @@ export default function SettingsPage() {
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
const [hasBankingExtension, setHasBankingExtension] = useState<boolean | null>(
bankingCompiledIn ? null : false
)
const [hasCalendarExtension, setHasCalendarExtension] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState('')
@@ -70,21 +74,15 @@ export default function SettingsPage() {
return
}
// Fetch settings
const { data: settingsData } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
setSettings(settingsData)
// Check extension toggles in parallel
const [bankingToggleRes, calendarToggleRes] = await Promise.all([
// Fetch settings and extension toggles in parallel
const [settingsRes, bankingToggleRes, calendarToggleRes] = await Promise.all([
supabase.from('company_settings').select('*').eq('user_id', user.id).single(),
fetch('/api/extensions/toggles/general/enable-banking').catch(() => null),
fetch('/api/extensions/toggles/general/calendar').catch(() => null),
])
setSettings(settingsRes.data)
if (bankingToggleRes?.ok) {
const { data } = await bankingToggleRes.json()
setHasBankingExtension(data?.enabled || false)
@@ -541,7 +539,11 @@ export default function SettingsPage() {
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
{!settings?.is_sandbox && (
<TabsContent value="banking" className="space-y-6">
{hasBankingExtension && BankingPanel ? (
{hasBankingExtension === null ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : hasBankingExtension && BankingPanel ? (
<BankingPanel />
) : (
<Card>
+44 -44
View File
@@ -110,40 +110,36 @@ export default function TransactionsPage() {
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (potentialInvoiceIds.length > 0) {
const { data: invoices } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.in('id', potentialInvoiceIds)
if (invoices) {
invoiceMap = invoices.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
}
// Fetch matched inbox items for unbooked transactions
const unbookedTxIds = (txData || [])
.filter((t) => !t.journal_entry_id && t.is_business === null)
.map((t) => t.id)
// Fetch invoices and inbox items in parallel
const [invoiceResult, inboxResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
unbookedTxIds.length > 0
? supabase.from('invoice_inbox_items').select('*').in('matched_transaction_id', unbookedTxIds).in('status', ['ready', 'processing'])
: Promise.resolve({ data: null }),
])
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (invoiceResult.data) {
invoiceMap = invoiceResult.data.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
if (unbookedTxIds.length > 0) {
const { data: inboxItems } = await supabase
.from('invoice_inbox_items')
.select('*')
.in('matched_transaction_id', unbookedTxIds)
.in('status', ['ready', 'processing'])
if (inboxItems) {
inboxItemMap = inboxItems.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
if (inboxResult.data) {
inboxItemMap = inboxResult.data.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
@@ -178,24 +174,27 @@ export default function TransactionsPage() {
setIsLoadingSuggestions(false)
}
// Fetch transactions on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchTransactions() }, [])
// Fetch entity type for tooltip context
// Fetch transactions and entity type in parallel on mount, then suggestions
useEffect(() => {
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const data = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Non-critical, defaults to enskild_firma
let cancelled = false
async function loadAll() {
// Fetch transactions and entity type in parallel
const [, entityRes] = await Promise.all([
fetchTransactions(),
fetch('/api/settings').then(r => r.json()).catch(() => null),
])
if (cancelled) return
if (entityRes?.entity_type) {
setEntityType(entityRes.entity_type)
}
}
fetchEntityType()
loadAll()
return () => { cancelled = true }
}, [])
// Auto-fetch suggestions when transactions load
@@ -207,6 +206,7 @@ export default function TransactionsPage() {
if (uncatIds.length > 0) {
fetchCategorySuggestions(uncatIds)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactions.length])
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
+52 -52
View File
@@ -25,14 +25,14 @@ export default function DPAPage() {
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Detta personuppgiftsbitradesavtal (&quot;DPA&quot;) ingar mellan:
Detta personuppgiftsbitradesavtal (&quot;DPA&quot;) ingår mellan:
</p>
<ul>
<li><strong>Personuppgiftsansvarig (&quot;den Ansvarige&quot;):</strong> Du som anvandare av Gnubok,
i egenskap av ansvarig for de personuppgifter du registrerar i tjansten
(kunder, leverantorer, anstallda m.fl.).</li>
<li><strong>Personuppgiftsbitrade (&quot;Bitradet&quot;):</strong> Arcim, som tillhandahaller
Gnubok-tjansten och behandlar personuppgifter pa dina vagar.</li>
<li><strong>Personuppgiftsansvarig (&quot;den Ansvarige&quot;):</strong> Du som användare av Gnubok,
i egenskap av ansvarig för de personuppgifter du registrerar i tjänsten
(kunder, leverantörer, anställda m.fl.).</li>
<li><strong>Personuppgiftsbiträde (&quot;Biträdet&quot;):</strong> Arcim, som tillhandahåller
Gnubok-tjänsten och behandlar personuppgifter dina vägnar.</li>
</ul>
</CardContent>
</Card>
@@ -42,10 +42,10 @@ export default function DPAPage() {
<CardTitle>2. Behandlingens syfte och omfattning</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Bitradet behandlar personuppgifter for foljande andamal:</p>
<p>Biträdet behandlar personuppgifter för följande ändamål:</p>
<ul>
<li>Tillhandahallande av bokforings- och redovisningstjanster</li>
<li>Lagring och arkivering av bokforingsmaterial</li>
<li>Tillhandahållande av bokförings- och redovisningstjänster</li>
<li>Lagring och arkivering av bokföringsmaterial</li>
<li>Fakturering och betalningshantering</li>
<li>Bankkontosynkronisering (PSD2)</li>
<li>AI-assisterad kategorisering och kvittohantering (efter separat samtycke)</li>
@@ -53,92 +53,92 @@ export default function DPAPage() {
<p>Kategorier av registrerade vars uppgifter behandlas:</p>
<ul>
<li>Den Ansvariges kunder (namn, kontaktuppgifter, organisationsnummer)</li>
<li>Den Ansvariges leverantorer (namn, kontaktuppgifter, bankuppgifter)</li>
<li>Den Ansvarige sjalv (kontouppgifter, foretagsinformation)</li>
<li>Den Ansvariges leverantörer (namn, kontaktuppgifter, bankuppgifter)</li>
<li>Den Ansvarige själv (kontouppgifter, företagsinformation)</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>3. Tekniska och organisatoriska atgarder</CardTitle>
<CardTitle>3. Tekniska och organisatoriska åtgärder</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Bitradet vidtar foljande atgarder for att skydda personuppgifterna:</p>
<p>Biträdet vidtar följande åtgärder för att skydda personuppgifterna:</p>
<ul>
<li><strong>Kryptering:</strong> All data krypteras i transit (TLS 1.3) och i vila (AES-256)</li>
<li><strong>Atkomstkontroll:</strong> Row Level Security (RLS) sakerstaller att varje anvandare
enbart kan komma at sina egna uppgifter</li>
<li><strong>Autentisering:</strong> Sakra inloggningsmetoder (magic link, inga losenord lagrade)</li>
<li><strong>Integritetskontroll:</strong> SHA-256 checksummor for alla dokument, med
<li><strong>Åtkomstkontroll:</strong> Row Level Security (RLS) säkerställer att varje användare
enbart kan komma åt sina egna uppgifter</li>
<li><strong>Autentisering:</strong> Säkra inloggningsmetoder (magic link, inga lösenord lagrade)</li>
<li><strong>Integritetskontroll:</strong> SHA-256 checksummor för alla dokument, med
regelbunden verifiering</li>
<li><strong>Revisionslogg:</strong> Alla andringshandelser loggas automatiskt av databasen
<li><strong>Revisionslogg:</strong> Alla ändringshandelser loggas automatiskt av databasen
(ej redigerbara)</li>
<li><strong>Oforanderlig bokforing:</strong> Bokforda verifikationer kan inte andras eller
raderas (databasutlosare)</li>
<li><strong>Sakerhetskopior:</strong> Kontinuerliga databaskopior med point-in-time-recovery</li>
<li><strong>EU-lagring:</strong> All primar datalagring sker i EU (eu-central-1)</li>
<li><strong>Oföränderlig bokföring:</strong> Bokförda verifikationer kan inte ändras eller
raderas (databasutlösare)</li>
<li><strong>Säkerhetskopior:</strong> Kontinuerliga databaskopior med point-in-time-recovery</li>
<li><strong>EU-lagring:</strong> All primär datalagring sker i EU (eu-central-1)</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>4. Underbitraden</CardTitle>
<CardTitle>4. Underbiträden</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Bitradet anvander underbitraden for att tillhandahalla tjansten. En fullstandig
forteckning over underbitraden, inklusive syfte och geografisk plats, finns i
var{' '}
Biträdet använder underbiträden för att tillhandahålla tjänsten. En fullständig
förteckning över underbiträden, inklusive syfte och geografisk plats, finns i
vår{' '}
<Link href="/privacy" className="text-primary underline underline-offset-4">
integritetspolicy
</Link>.
</p>
<p>
Bitradet kommer att informera den Ansvarige minst 30 dagar i forvag innan
en ny underbitrade anlitas, sa att den Ansvarige har mojlighet att invanda.
Biträdet kommer att informera den Ansvarige minst 30 dagar i förväg innan
en ny underbiträde anlitas, att den Ansvarige har möjlighet att invända.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>5. Dataintrangsnotifiering</CardTitle>
<CardTitle>5. Dataintrångsnotifiering</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vid en personuppgiftsincident ska Bitradet utan ondodigt drojsmal, och senast
inom 72 timmar fran det att incidenten upptacktes, meddela den Ansvarige.
Meddelandet ska innehalla:
Vid en personuppgiftsincident ska Biträdet utan onödigt dröjsmål, och senast
inom 72 timmar från det att incidenten upptäcktes, meddela den Ansvarige.
Meddelandet ska innehålla:
</p>
<ul>
<li>Typ av personuppgiftsincident</li>
<li>Kategorier och ungefirligt antal registrerade som berorts</li>
<li>Kategorier och ungefärligt antal registrerade som berörts</li>
<li>Sannolika konsekvenser av incidenten</li>
<li>Atgarder som vidtagits eller foreslas for att hantera incidenten</li>
<li>Åtgärder som vidtagits eller föreslås för att hantera incidenten</li>
</ul>
<p>
Bitradet ska bistå den Ansvarige med den information som behovs for att den
Ansvarige ska kunna uppfylla sin anmalningsplikt till IMY (Integritetsskyddsmyndigheten).
Biträdet ska bistå den Ansvarige med den information som behövs för att den
Ansvarige ska kunna uppfylla sin anmälningsplikt till IMY (Integritetsskyddsmyndigheten).
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>6. Revisionsratt</CardTitle>
<CardTitle>6. Revisionsrätt</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Den Ansvarige har ratt att, direkt eller genom en oberoende revisor, utfora
revisioner och inspektioner for att sakerst alla att Bitradet uppfyller sina
atagarder enligt detta avtal. Bitradet ska tillhandahalla all nodvandig
Den Ansvarige har rätt att, direkt eller genom en oberoende revisor, utföra
revisioner och inspektioner för att säkerställa att Biträdet uppfyller sina
åtaganden enligt detta avtal. Biträdet ska tillhandahålla all nödvändig
information och medverka till revisioner.
</p>
<p>
Revisioner ska ske med rimligt varsel (minst 30 dagar) och under ordinarie
kontorstider. Bitradet kan erbjuda alternativ i form av tredjepartsgranskningar
kontorstider. Biträdet kan erbjuda alternativ i form av tredjepartsgranskningar
eller certifieringar.
</p>
</CardContent>
@@ -150,22 +150,22 @@ export default function DPAPage() {
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vid uppsagning av tjansten ska Bitradet, enligt den Ansvariges val:
Vid uppsägning av tjänsten ska Biträdet, enligt den Ansvariges val:
</p>
<ul>
<li>
<strong>Aterlamna:</strong> Exportera alla personuppgifter i maskinlasbart format
(SIE4, JSON, CSV) via tjansens exportfunktioner.
<strong>Återlämna:</strong> Exportera alla personuppgifter i maskinläsbart format
(SIE4, JSON, CSV) via tjänstens exportfunktioner.
</li>
<li>
<strong>Radera:</strong> Radera alla personuppgifter inom 30 dagar fran
anvandarens begaran, med undantag for uppgifter som maste bevaras enligt lag.
<strong>Radera:</strong> Radera alla personuppgifter inom 30 dagar från
användarens begäran, med undantag för uppgifter som måste bevaras enligt lag.
</li>
</ul>
<p>
<strong>Undantag:</strong> Bokforingsmaterial som omfattas av Bokforingslagen (BFL)
7 kap. 2 § (7 ars arkiveringskrav) raderas forst nar lagringsfristen lopt ut.
Under denna period ar materialet skyddat mot obehorig atkomst och andring.
<strong>Undantag:</strong> Bokföringsmaterial som omfattas av Bokföringslagen (BFL)
7 kap. 2 § (7 års arkiveringskrav) raderas först när lagringsfristen löpt ut.
Under denna period är materialet skyddat mot obehörig åtkomst och ändring.
</p>
</CardContent>
</Card>
@@ -173,9 +173,9 @@ export default function DPAPage() {
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center">
Detta personuppgiftsbitradesavtal trader i kraft nar du skapar ett konto pa
Gnubok och galler sa lange du anvander tjansten. For fragor, kontakta oss
pa privacy@gnubok.se.
Detta personuppgiftsbitradesavtal träder i kraft när du skapar ett konto
Gnubok och gäller länge du använder tjänsten. För frågor, kontakta oss
privacy@gnubok.se.
</p>
</CardContent>
</Card>
+50 -50
View File
@@ -24,9 +24,9 @@ export default function PrivacyPolicyPage() {
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Arcim (&quot;vi&quot;, &quot;oss&quot;) ar personuppgiftsansvarig for behandlingen av dina
personuppgifter i samband med anvandningen av Gnubok. Vi behandlar dina uppgifter i
enlighet med EU:s dataskyddsforordning (GDPR) och svensk dataskyddslagstiftning.
Arcim (&quot;vi&quot;, &quot;oss&quot;) är personuppgiftsansvarig för behandlingen av dina
personuppgifter i samband med användningen av Gnubok. Vi behandlar dina uppgifter i
enlighet med EU:s dataskyddsförordning (GDPR) och svensk dataskyddslagstiftning.
</p>
</CardContent>
</Card>
@@ -36,39 +36,39 @@ export default function PrivacyPolicyPage() {
<CardTitle>2. Vilka uppgifter vi behandlar</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Vi behandlar foljande kategorier av personuppgifter:</p>
<p>Vi behandlar följande kategorier av personuppgifter:</p>
<ul>
<li><strong>Kontouppgifter:</strong> E-postadress (for inloggning via magic link)</li>
<li><strong>Foretagsuppgifter:</strong> Foretagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Bokforingsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Kontouppgifter:</strong> E-postadress (för inloggning via magic link)</li>
<li><strong>Företagsuppgifter:</strong> Företagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Bokföringsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokforingsunderlag</li>
<li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, anvandningsstatistik</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokföringsunderlag</li>
<li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, användningsstatistik</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>3. Rattslig grund (GDPR Art. 6)</CardTitle>
<CardTitle>3. Rättslig grund (GDPR Art. 6)</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<ul>
<li>
<strong>Avtal (Art. 6.1b):</strong> Behandling som ar nodvandig for att fullgora vara
tjanster enligt anvandaravtalet.
<strong>Avtal (Art. 6.1b):</strong> Behandling som är nödvändig för att fullgöra våra
tjänster enligt användaravtalet.
</li>
<li>
<strong>Rattslig forpliktelse (Art. 6.1c):</strong> Bokforingslagens (BFL) krav pa
7 ars arkivering av raknenskapsmaterial.
<strong>Rättslig förpliktelse (Art. 6.1c):</strong> Bokföringslagens (BFL) krav
7 års arkivering av räkenskapsmaterial.
</li>
<li>
<strong>Berattigat intresse (Art. 6.1f):</strong> Produktforbattringar, sakerhet och
bedrageriforbud.
<strong>Berättigat intresse (Art. 6.1f):</strong> Produktförbättringar, säkerhet och
bedrägeriforbud.
</li>
<li>
<strong>Samtycke (Art. 6.1a):</strong> For AI-baserade funktioner som skickar data
till tredjepartstjanster (se separat samtycke vid aktivering).
<strong>Samtycke (Art. 6.1a):</strong> För AI-baserade funktioner som skickar data
till tredjepartstjänster (se separat samtycke vid aktivering).
</li>
</ul>
</CardContent>
@@ -76,12 +76,12 @@ export default function PrivacyPolicyPage() {
<Card>
<CardHeader>
<CardTitle>4. Underbitraden</CardTitle>
<CardTitle>4. Underbiträden</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vi anvander foljande underbitraden for att tillhandahalla tjansten. Uppgifterna nedan anger
vilka uppgifter som delas med respektive underbitrade, syftet samt var behandlingen sker
Vi använder följande underbiträden för att tillhandahålla tjänsten. Uppgifterna nedan anger
vilka uppgifter som delas med respektive underbiträde, syftet samt var behandlingen sker
(GDPR Art. 13).
</p>
@@ -89,7 +89,7 @@ export default function PrivacyPolicyPage() {
<table className="min-w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-semibold">Underbitrade</th>
<th className="text-left py-2 pr-4 font-semibold">Underbiträde</th>
<th className="text-left py-2 pr-4 font-semibold">Syfte</th>
<th className="text-left py-2 pr-4 font-semibold">Plats</th>
<th className="text-left py-2 font-semibold">Skyddsmekanism</th>
@@ -105,7 +105,7 @@ export default function PrivacyPolicyPage() {
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Vercel</td>
<td className="py-2 pr-4">Applikationshosting</td>
<td className="py-2 pr-4">Globalt CDN (EU-regioner tillgangliga)</td>
<td className="py-2 pr-4">Globalt CDN (EU-regioner tillgängliga)</td>
<td className="py-2">EU Data Residency</td>
</tr>
<tr className="border-b">
@@ -120,7 +120,7 @@ export default function PrivacyPolicyPage() {
<tr className="border-b">
<td className="py-2 pr-4 font-medium">OpenAI</td>
<td className="py-2 pr-4">
Embedding-generering for likhetssokning (transaktionsmallar, kunskapsbas)
Embedding-generering för likhetssökning (transaktionsmallar, kunskapsbas)
</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
@@ -139,7 +139,7 @@ export default function PrivacyPolicyPage() {
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Recapt</td>
<td className="py-2 pr-4">Produktanalys och anvanderfeedback</td>
<td className="py-2 pr-4">Produktanalys och användarfeedback</td>
<td className="py-2 pr-4">EU</td>
<td className="py-2">EU-baserad</td>
</tr>
@@ -148,21 +148,21 @@ export default function PrivacyPolicyPage() {
</div>
<p className="mt-4 text-sm text-muted-foreground">
AI-funktioner (Anthropic, OpenAI) kraver separat samtycke fore aktivering.
Data skickas forst nar du aktivt godkanner anvandningen.
AI-funktioner (Anthropic, OpenAI) kräver separat samtycke före aktivering.
Data skickas först när du aktivt godkänner användningen.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>5. Tredjelandsoverforing</CardTitle>
<CardTitle>5. Tredjelandsöverföring</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Vissa underbitraden ar baserade i USA. For dessa overforing anvands EU-kommissionens
Vissa underbiträden är baserade i USA. För dessa överföringar används EU-kommissionens
standardavtalsklausuler (SCCs) som skyddsmekanism i enlighet med GDPR kapitel V.
All primaer datalagring (databas, filer) sker inom EU via Supabase (eu-central-1).
All primär datalagring (databas, filer) sker inom EU via Supabase (eu-central-1).
</p>
</CardContent>
</Card>
@@ -174,13 +174,13 @@ export default function PrivacyPolicyPage() {
<CardContent className="prose prose-sm max-w-none">
<ul>
<li>
<strong>Bokforingsmaterial:</strong> 7 ar fran rakenskapsarets slut, i enlighet
med Bokforingslagen (BFL) 7 kap. 2 §. Systemet hindrar radering av material
kopplat till bokforda verifikationer under denna period.
<strong>Bokföringsmaterial:</strong> 7 år från räkenskapsårets slut, i enlighet
med Bokföringslagen (BFL) 7 kap. 2 §. Systemet hindrar radering av material
kopplat till bokförda verifikationer under denna period.
</li>
<li>
<strong>Kontouppgifter:</strong> Sa lange kontot ar aktivt, plus 30 dagar efter
begaran om radering (for att hantera pagaende bokforingsplikter).
<strong>Kontouppgifter:</strong> länge kontot är aktivt, plus 30 dagar efter
begäran om radering (för att hantera pågående bokföringsplikter).
</li>
<li>
<strong>Tekniska loggar:</strong> Maximalt 90 dagar.
@@ -191,24 +191,24 @@ export default function PrivacyPolicyPage() {
<Card>
<CardHeader>
<CardTitle>7. Dina rattigheter</CardTitle>
<CardTitle>7. Dina rättigheter</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Du har foljande rattigheter enligt GDPR:</p>
<p>Du har följande rättigheter enligt GDPR:</p>
<ul>
<li><strong>Tillgang (Art. 15):</strong> Du kan begara en kopia av alla dina personuppgifter.</li>
<li><strong>Rattelse (Art. 16):</strong> Du kan begara rattelse av felaktiga uppgifter.</li>
<li><strong>Radering (Art. 17):</strong> Du kan begara radering, med undantag for uppgifter som
omfattas av lagstadgade arkiveringskrav (BFL 7 ar).</li>
<li><strong>Begransning (Art. 18):</strong> Du kan begara begransning av behandlingen.</li>
<li><strong>Tillgång (Art. 15):</strong> Du kan begära en kopia av alla dina personuppgifter.</li>
<li><strong>Rättelse (Art. 16):</strong> Du kan begära rättelse av felaktiga uppgifter.</li>
<li><strong>Radering (Art. 17):</strong> Du kan begära radering, med undantag för uppgifter som
omfattas av lagstadgade arkiveringskrav (BFL 7 år).</li>
<li><strong>Begränsning (Art. 18):</strong> Du kan begära begränsning av behandlingen.</li>
<li><strong>Dataportabilitet (Art. 20):</strong> Du kan exportera dina uppgifter i
maskinlasbart format (SIE4, JSON, CSV) via exportfunktionerna i appen.</li>
<li><strong>Invandning (Art. 21):</strong> Du kan invanda mot behandling baserad pa
berattigat intresse.</li>
maskinläsbart format (SIE4, JSON, CSV) via exportfunktionerna i appen.</li>
<li><strong>Invändning (Art. 21):</strong> Du kan invända mot behandling baserad
berättigat intresse.</li>
</ul>
<p>
For att utova dina rattigheter, kontakta oss pa adressen nedan. Vi besvarar alla
forfragar inom 30 dagar.
För att utöva dina rättigheter, kontakta oss adressen nedan. Vi besvarar alla
förfrågningar inom 30 dagar.
</p>
</CardContent>
</Card>
@@ -219,14 +219,14 @@ export default function PrivacyPolicyPage() {
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
For fragor om behandlingen av dina personuppgifter, kontakta oss:
För frågor om behandlingen av dina personuppgifter, kontakta oss:
</p>
<ul>
<li><strong>Foretag:</strong> Arcim</li>
<li><strong>Företag:</strong> Arcim</li>
<li><strong>E-post:</strong> privacy@gnubok.se</li>
</ul>
<p>
Du har aven ratt att lamna klagomal till Integritetsskyddsmyndigheten (IMY),
Du har även rätt att lämna klagomål till Integritetsskyddsmyndigheten (IMY),
www.imy.se.
</p>
</CardContent>
+6 -6
View File
@@ -52,16 +52,16 @@ export function AiConsentDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI-samtycke kravs</DialogTitle>
<DialogTitle>AI-samtycke krävs</DialogTitle>
<DialogDescription>
Denna funktion anvander AI-tjanster fran externa leverantorer.
Granska informationen nedan innan du fortsatter.
Denna funktion använder AI-tjänster från externa leverantörer.
Granska informationen nedan innan du fortsätter.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<p className="text-sm font-medium mb-1">Leverantor</p>
<p className="text-sm font-medium mb-1">Leverantör</p>
<p className="text-sm text-muted-foreground">{disclosure.provider}</p>
</div>
@@ -80,11 +80,11 @@ export function AiConsentDialog({
</div>
<p className="text-xs text-muted-foreground">
Las mer i var{' '}
Läs mer i vår{' '}
<Link href="/privacy" className="underline underline-offset-4" target="_blank">
integritetspolicy
</Link>
. Du kan nar som helst aterkalla ditt samtycke i installningarna.
. Du kan när som helst återkalla ditt samtycke i inställningarna.
</p>
</div>
@@ -18,8 +18,8 @@ interface ConfirmDeleteDialogProps {
export default function ConfirmDeleteDialog({
open,
onOpenChange,
title = 'Bekrafta borttagning',
description = 'Ar du saker pa att du vill ta bort detta? Atgarden kan inte angras.',
title = 'Bekräfta borttagning',
description = 'Är du säker på att du vill ta bort detta? Åtgärden kan inte ångras.',
onConfirm,
isDeleting = false,
}: ConfirmDeleteDialogProps) {
+4 -4
View File
@@ -204,7 +204,7 @@ export default function AccountMappingStep({
</div>
{/* Mapping table */}
<div className="border rounded-lg overflow-hidden">
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
@@ -317,11 +317,11 @@ export default function AccountMappingStep({
</Card>
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={onContinue} disabled={!canContinue}>
<Button className="min-h-11" onClick={onContinue} disabled={!canContinue}>
{canContinue ? 'Fortsätt till granskning' : `${stats.unmapped} konton saknar mappning`}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
@@ -248,7 +248,7 @@ export default function BankFileColumnMappingStep({
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border max-h-64 overflow-y-auto">
<div className="rounded-md border max-h-64 overflow-x-auto overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
@@ -286,12 +286,12 @@ export default function BankFileColumnMappingStep({
)}
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button onClick={handleConfirm} disabled={!isValid}>
<Button className="min-h-11" onClick={handleConfirm} disabled={!isValid}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
+3 -2
View File
@@ -112,12 +112,13 @@ export default function BankFileConfirmStep({
</Card>
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isLoading}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button
className="min-h-11"
onClick={() => onExecute({
skip_duplicates: true,
auto_categorize: false,
+5 -5
View File
@@ -41,7 +41,7 @@ export default function BankFilePreviewStep({
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
@@ -129,7 +129,7 @@ export default function BankFilePreviewStep({
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border max-h-96 overflow-y-auto">
<div className="rounded-md border max-h-96 overflow-x-auto overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
@@ -201,12 +201,12 @@ export default function BankFilePreviewStep({
)}
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button onClick={onContinue} disabled={hasIssues || transactions.length === 0}>
<Button className="min-h-11" onClick={onContinue} disabled={hasIssues || transactions.length === 0}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
+11 -13
View File
@@ -30,7 +30,7 @@ export default function BankFileResultStep({
<CardTitle className="flex items-center gap-2">
{isSuccess ? (
<>
<CheckCircle className="h-6 w-6 text-muted-foreground" />
<CheckCircle className="h-6 w-6 text-success" />
Import genomförd
</>
) : (
@@ -97,21 +97,19 @@ export default function BankFileResultStep({
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onNewImport}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onNewImport}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny import
</Button>
<div className="flex gap-2">
{isSuccess && (
<Button asChild>
<Link href="/transactions">
Visa transaktioner
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
)}
</div>
{isSuccess && (
<Button className="min-h-11" asChild>
<Link href="/transactions">
Visa transaktioner
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
)}
</div>
</div>
)
+6 -3
View File
@@ -23,6 +23,7 @@ import type { BankFileFormatId } from '@/lib/import/bank-file/types'
const FORMAT_NAMES: Record<string, string> = {
nordea: 'Nordea',
nordea_business: 'Nordea Företag',
seb: 'SEB',
swedbank: 'Swedbank',
handelsbanken: 'Handelsbanken',
@@ -110,18 +111,19 @@ export default function BankFileUploadStep({
</CardHeader>
<CardContent className="space-y-4">
{/* Format override */}
<div className="flex items-center gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<label className="text-sm font-medium whitespace-nowrap">Bank/format:</label>
<Select
value={formatOverride || 'auto'}
onValueChange={handleFormatChange}
>
<SelectTrigger className="w-64">
<SelectTrigger className="w-full sm:w-64">
<SelectValue placeholder="Automatisk identifiering" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Automatisk identifiering</SelectItem>
<SelectItem value="nordea">Nordea</SelectItem>
<SelectItem value="nordea_business">Nordea Företag</SelectItem>
<SelectItem value="seb">SEB</SelectItem>
<SelectItem value="swedbank">Swedbank</SelectItem>
<SelectItem value="handelsbanken">Handelsbanken</SelectItem>
@@ -181,7 +183,8 @@ export default function BankFileUploadStep({
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<p className="font-medium">Dra och släpp bankfil här</p>
<p className="font-medium hidden sm:block">Dra och släpp bankfil här</p>
<p className="font-medium sm:hidden">Tryck för att välja bankfil</p>
<p className="text-sm text-muted-foreground">
CSV, TXT eller XML (max 10 MB)
</p>
+6 -6
View File
@@ -51,7 +51,7 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
{/* Statistics */}
{result.success && (
<div className="grid gap-4 md:grid-cols-3">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
@@ -183,21 +183,21 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onNewImport}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onNewImport}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny import
</Button>
<div className="flex gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
{result.success && (
<>
<Button variant="outline" asChild>
<Button variant="outline" className="min-h-11" asChild>
<Link href="/bookkeeping">
Visa bokföring
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button asChild>
<Button className="min-h-11" asChild>
<Link href="/reports">
Visa rapporter
<ExternalLink className="ml-2 h-4 w-4" />
+3 -3
View File
@@ -246,11 +246,11 @@ export default function ImportReviewStep({
</Card>
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isLoading}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
Tillbaka
</Button>
<Button onClick={handleExecute} disabled={isLoading}>
<Button className="min-h-11" onClick={handleExecute} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+5 -5
View File
@@ -99,7 +99,7 @@ export default function SIEPreviewStep({
</Card>
{/* Statistics */}
<div className="grid gap-4 md:grid-cols-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
@@ -201,7 +201,7 @@ export default function SIEPreviewStep({
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<p className="text-sm text-muted-foreground">Totalt</p>
<p className="font-medium">{preview.mappingStatus.total}</p>
@@ -303,11 +303,11 @@ export default function SIEPreviewStep({
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={onContinue} disabled={hasBlockingErrors}>
<Button className="min-h-11" onClick={onContinue} disabled={hasBlockingErrors}>
{preview.mappingStatus.lowConfidence > 0 || preview.mappingStatus.unmapped > 0
? 'Granska mappningar'
: 'Fortsätt'}
+4 -2
View File
@@ -103,8 +103,10 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<p className="font-medium">Dra och släpp SIE-fil här</p>
<p className="text-sm text-muted-foreground">eller klicka för att välja fil</p>
<p className="font-medium hidden sm:block">Dra och släpp SIE-fil här</p>
<p className="font-medium sm:hidden">Tryck för att välja SIE-fil</p>
<p className="text-sm text-muted-foreground hidden sm:block">eller klicka för att välja fil</p>
<p className="text-sm text-muted-foreground sm:hidden">.sie eller .se-filer</p>
</div>
</div>
)}
@@ -73,7 +73,7 @@ describe('description-analyzer', () => {
vatTreatment: 'standard_25',
category: 'income_services',
confidence: 0.9,
reasoning: 'Konsultarvode bokfors som tjansteintakt.',
reasoning: 'Konsultarvode bokförs som tjänsteintäkt.',
warnings: [],
templateId: null,
})
+1 -1
View File
@@ -1977,7 +1977,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '16',
account_type: 'asset',
normal_balance: 'debit',
description: 'Fordran på Skatteverket nar ingående moms överstiger utgående moms.',
description: 'Fordran på Skatteverket när ingående moms överstiger utgående moms.',
sru_code: '7212',
k2_excluded: false,
},
@@ -602,7 +602,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20',
account_type: 'equity',
normal_balance: 'credit',
description: 'Foregaende ars resultat innan det fordelats till balanserat resultat eller utdelning.',
description: 'Föregående års resultat innan det fördelats till balanserat resultat eller utdelning.',
sru_code: '7221',
k2_excluded: false,
},
@@ -1779,7 +1779,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '78',
account_type: 'expense',
normal_balance: 'debit',
description: 'Planmässig avskrivning av goodwill, patent och andra immateriella tillgangar.',
description: 'Planmässig avskrivning av goodwill, patent och andra immateriella tillgångar.',
sru_code: '7325',
k2_excluded: false,
},
+4 -4
View File
@@ -35,11 +35,11 @@ describe('validateSwedishPersonalNumber', () => {
})
it('rejects invalid month', () => {
expect(validateSwedishPersonalNumber('199913011234')).toBe('Ogiltig manad')
expect(validateSwedishPersonalNumber('199913011234')).toBe('Ogiltig månad')
})
it('rejects month 00', () => {
expect(validateSwedishPersonalNumber('199900011234')).toBe('Ogiltig manad')
expect(validateSwedishPersonalNumber('199900011234')).toBe('Ogiltig månad')
})
it('rejects invalid day', () => {
@@ -51,12 +51,12 @@ describe('validateSwedishPersonalNumber', () => {
})
it('rejects year before 1900', () => {
expect(validateSwedishPersonalNumber('189901011234')).toBe('Ogiltigt ar')
expect(validateSwedishPersonalNumber('189901011234')).toBe('Ogiltigt år')
})
it('rejects future year', () => {
const futureYear = new Date().getFullYear() + 1
expect(validateSwedishPersonalNumber(`${futureYear}01011234`)).toBe('Ogiltigt ar')
expect(validateSwedishPersonalNumber(`${futureYear}01011234`)).toBe('Ogiltigt år')
})
it('rejects invalid Luhn checksum', () => {
+5 -5
View File
@@ -33,18 +33,18 @@ export const AI_DATA_DISCLOSURES: Record<AiExtensionId, {
}> = {
'receipt-ocr': {
provider: 'Anthropic',
dataTypes: ['Kvittobilder', 'Extraherad text fran kvitton'],
purpose: 'Automatisk avlasning och kategorisering av kvitton',
dataTypes: ['Kvittobilder', 'Extraherad text från kvitton'],
purpose: 'Automatisk avläsning och kategorisering av kvitton',
},
'ai-categorization': {
provider: 'Anthropic, OpenAI',
dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokformallar'],
dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokförmallar'],
purpose: 'Automatisk kategorisering av banktransaktioner',
},
'ai-chat': {
provider: 'Anthropic, OpenAI',
dataTypes: ['Chattmeddelanden', 'Bokforingsdata som refereras i chatten'],
purpose: 'AI-assistent for bokforingsfragor',
dataTypes: ['Chattmeddelanden', 'Bokföringsdata som refereras i chatten'],
purpose: 'AI-assistent för bokföringsfrågor',
},
}
+2 -2
View File
@@ -15,9 +15,9 @@ export function validateSwedishPersonalNumber(pnr: string): string | null {
const month = parseInt(cleaned.slice(4, 6))
const day = parseInt(cleaned.slice(6, 8))
if (month < 1 || month > 12) return 'Ogiltig manad'
if (month < 1 || month > 12) return 'Ogiltig månad'
if (day < 1 || day > 31) return 'Ogiltig dag'
if (year < 1900 || year > new Date().getFullYear()) return 'Ogiltigt ar'
if (year < 1900 || year > new Date().getFullYear()) return 'Ogiltigt år'
// Luhn check on the last 10 digits (YYMMDDXXXX)
const luhnDigits = cleaned.slice(2)
+230 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseSIEFile, validateSIEFile } from '../sie-parser'
import { parseSIEFile, validateSIEFile, detectEncoding, decodeBuffer } from '../sie-parser'
// --- SIE content fixtures ---
@@ -433,3 +433,232 @@ describe('validateSIEFile', () => {
expect(ibWarning).toBeUndefined()
})
})
// --- Fix 2: Windows-1252 encoding detection and decoding ---
describe('detectEncoding — Windows-1252', () => {
it('detects Windows-1252 when Swedish chars use Win-1252 byte values', () => {
// Build a buffer with Windows-1252 encoded Swedish text: "#FNAMN Företag"
// å=0xE5, ä=0xE4, ö=0xF6 in Windows-1252 (NOT in CP437 map)
const text = '#FNAMN F'
const encoder = new TextEncoder()
const prefix = encoder.encode(text)
// Add ö (0xF6) r (0x72) e (0x65) t (0x74) a (0x61) g (0x67)
const buf = new Uint8Array(prefix.length + 6)
buf.set(prefix)
buf[prefix.length] = 0xf6 // ö in Windows-1252
buf[prefix.length + 1] = 0x72 // r
buf[prefix.length + 2] = 0x65 // e
buf[prefix.length + 3] = 0x74 // t
buf[prefix.length + 4] = 0x61 // a
buf[prefix.length + 5] = 0x67 // g
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('windows1252')
})
it('detects UTF-8 BOM even when Windows-1252 bytes are present', () => {
const buf = new Uint8Array([0xef, 0xbb, 0xbf, 0x23, 0xe5]) // BOM + # + å-win1252
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('utf8')
})
it('detects CP437 when CP437-specific bytes are present', () => {
// 0x86 = å in CP437 (not in Win-1252 Swedish set)
const buf = new Uint8Array([0x23, 0x86, 0x86, 0x86])
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('cp437')
})
it('detects UTF-8 multi-byte Swedish chars', () => {
// å in UTF-8 = C3 A5, ä = C3 A4
const buf = new Uint8Array([0x23, 0xc3, 0xa5, 0xc3, 0xa4])
const encoding = detectEncoding(buf.buffer)
expect(encoding).toBe('utf8')
})
})
describe('decodeBuffer — Windows-1252', () => {
it('decodes Windows-1252 Swedish characters correctly', () => {
// "åäö" in Windows-1252 = [0xE5, 0xE4, 0xF6]
const buf = new Uint8Array([0xe5, 0xe4, 0xf6])
const result = decodeBuffer(buf.buffer, 'windows1252')
expect(result).toBe('åäö')
})
it('decodes Windows-1252 uppercase Swedish characters correctly', () => {
// "ÅÄÖ" in Windows-1252 = [0xC5, 0xC4, 0xD6]
const buf = new Uint8Array([0xc5, 0xc4, 0xd6])
const result = decodeBuffer(buf.buffer, 'windows1252')
expect(result).toBe('ÅÄÖ')
})
it('decodes CP437 Swedish characters correctly', () => {
// å in CP437 = 0x86, ä = 0x84, ö = 0x94
const buf = new Uint8Array([0x86, 0x84, 0x94])
const result = decodeBuffer(buf.buffer, 'cp437')
expect(result).toBe('åäö')
})
})
// --- Fix 3: Invalid date rejection ---
describe('parseSIEFile — invalid date handling', () => {
it('rejects Feb 30 (auto-rolled dates) in #RAR', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20240230', // Feb 30 is invalid
].join('\n')
const result = parseSIEFile(content)
// RAR with invalid end date should produce a warning and not add the fiscal year
expect(result.header.fiscalYears).toHaveLength(0)
expect(result.issues.some((i) => i.message.includes('Invalid fiscal year dates'))).toBe(true)
})
it('rejects Apr 31 in #VER date', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1930 "Företagskonto"',
'#KONTO 3001 "Försäljning"',
'#VER A 1 20240431 "Invalid date"', // Apr 31 is invalid
'{',
'#TRANS 1930 {} 1000.00',
'#TRANS 3001 {} -1000.00',
'}',
].join('\n')
const result = parseSIEFile(content)
// Voucher should not be created because date is invalid
expect(result.vouchers).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'error' && i.message.includes('Invalid voucher definition'))).toBe(true)
})
it('accepts valid leap year date Feb 29', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1930 "Konto"',
'#KONTO 3001 "Konto"',
'#VER A 1 20240229 "Leap year"',
'{',
'#TRANS 1930 {} 1000.00',
'#TRANS 3001 {} -1000.00',
'}',
].join('\n')
const result = parseSIEFile(content)
expect(result.vouchers).toHaveLength(1)
expect(result.vouchers[0].date).toEqual(new Date(2024, 1, 29))
})
it('rejects Feb 29 in non-leap year', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20230101 20231231',
'#KONTO 1930 "Konto"',
'#VER A 1 20230229 "Not a leap year"',
'{',
'#TRANS 1930 {} 1000.00',
'}',
].join('\n')
const result = parseSIEFile(content)
expect(result.vouchers).toHaveLength(0)
expect(result.issues.some((i) => i.message.includes('Invalid voucher definition'))).toBe(true)
})
})
// --- Fix 4: Missing amount handling ---
describe('parseSIEFile — missing amount handling', () => {
it('skips #IB with missing amount and adds warning', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#IB 0 1510', // No amount field
].join('\n')
const result = parseSIEFile(content)
expect(result.openingBalances).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #IB'))).toBe(true)
})
it('skips #UB with missing amount and adds warning', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#UB 0 1510', // No amount field
].join('\n')
const result = parseSIEFile(content)
expect(result.closingBalances).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #UB'))).toBe(true)
})
it('skips #RES with missing amount and adds warning', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 3001 "Försäljning"',
'#RES 0 3001', // No amount field
].join('\n')
const result = parseSIEFile(content)
expect(result.resultBalances).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #RES'))).toBe(true)
})
it('skips #TRANS with missing amount and adds warning', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1930 "Företagskonto"',
'#VER A 1 20240115 "Test"',
'{',
'#TRANS 1930 {}', // No amount field
'}',
].join('\n')
const result = parseSIEFile(content)
expect(result.vouchers).toHaveLength(1)
expect(result.vouchers[0].lines).toHaveLength(0)
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Missing amount in #TRANS'))).toBe(true)
})
it('still parses valid #IB lines alongside missing-amount ones', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#KONTO 1930 "Företagskonto"',
'#IB 0 1510', // Missing → skipped
'#IB 0 1930 100000.00', // Valid → kept
].join('\n')
const result = parseSIEFile(content)
expect(result.openingBalances).toHaveLength(1)
expect(result.openingBalances[0].account).toBe('1930')
expect(result.openingBalances[0].amount).toBe(100000)
})
})
@@ -8,6 +8,8 @@
import { detectFileFormat, parseBankFile, generateExternalId, generateFileHash, getFormat, getAllFormats } from '../parser'
import type { ParsedBankTransaction, BankFileFormatId } from '../types'
import { parseGenericCSV } from '../formats/generic-csv'
import { parseCSVLine } from '../formats/nordea'
// ---------------------------------------------------------------------------
// Test data — realistic CSV/XML content for each Swedish bank format
@@ -171,6 +173,22 @@ const EMPTY_FILE = ''
const HEADER_ONLY_NORDEA = 'Datum,Transaktion,Kategori,Belopp,Saldo\n'
const NORDEA_BUSINESS_CSV = [
'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta',
'2024-01-15;-99,00;;SPOTIFY AB;SPOTIFY AB;Kortköp;12 345,67;SEK',
'2024-01-14;-432,50;;ICA MAXI;ICA MAXI LINDHAGEN;Kortköp;12 444,67;SEK',
'2024-01-13;25 000,00;ARBETSGIVAREN AB;;ARBETSGIVAREN AB;Löneutbetalning;12 877,17;SEK',
].join('\n')
const NORDEA_BUSINESS_CSV_SWEDISH_CHARS = [
'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta',
'2024-03-01;-85,00;;GÖTEBORGS HAMNCAFÉ;GÖTEBORGS HAMNCAFÉ;Kortköp;5 000,00;SEK',
'2024-03-02;-249,00;;ÅHLENS CITY;ÅHLENS CITY;Kortköp;4 751,00;SEK',
'2024-03-03;1 200,00;ÄRLA GÅRD AB;;ÄRLA GÅRD AB;Betalning;5 951,00;SEK',
].join('\n')
const HEADER_ONLY_NORDEA_BUSINESS = 'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta\n'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -182,6 +200,18 @@ describe('detectFileFormat', () => {
expect(format!.id).toBe('nordea')
})
it('detects Nordea Business CSV from semicolon-delimited header with bokföringsdag and rubrik', () => {
const format = detectFileFormat(NORDEA_BUSINESS_CSV, 'PLUSGIROKONTO FTG 212 68 87-5.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea_business')
})
it('does not confuse Nordea Business with SEB (SEB has valutadag)', () => {
const format = detectFileFormat(NORDEA_BUSINESS_CSV, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea_business')
})
it('detects SEB CSV from semicolon-delimited header with bokföringsdag', () => {
const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv')
expect(format).not.toBeNull()
@@ -370,6 +400,104 @@ describe('parseBankFile — Nordea format', () => {
})
})
describe('parseBankFile — Nordea Business format', () => {
it('parses semicolon-delimited CSV with correct columns', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
expect(result.format).toBe('nordea_business')
expect(result.format_name).toBe('Nordea Företag')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses negative amounts', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
const spotify = result.transactions[0]
expect(spotify.amount).toBe(-99)
expect(spotify.date).toBe('2024-01-15')
expect(spotify.currency).toBe('SEK')
})
it('correctly parses positive amounts with space thousands separator', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
const salary = result.transactions[2]
expect(salary.amount).toBe(25000)
expect(salary.date).toBe('2024-01-13')
})
it('builds description from Namn and Rubrik columns', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
const spotify = result.transactions[0]
expect(spotify.description).toBe('SPOTIFY AB — Kortköp')
const salary = result.transactions[2]
expect(salary.description).toBe('ARBETSGIVAREN AB — Löneutbetalning')
})
it('extracts counterparty from Mottagare (expense) or Avsändare (income)', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
// Expense: counterparty from Mottagare
expect(result.transactions[0].counterparty).toBe('SPOTIFY AB')
// Income: counterparty from Avsändare
expect(result.transactions[2].counterparty).toBe('ARBETSGIVAREN AB')
})
it('parses balance field', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
expect(result.transactions[0].balance).toBe(12345.67)
expect(result.transactions[2].balance).toBe(12877.17)
})
it('handles Swedish characters', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV_SWEDISH_CHARS, 'nordea_ftg.csv')
expect(result.transactions).toHaveLength(3)
expect(result.transactions[0].description).toContain('GÖTEBORGS HAMNCAFÉ')
expect(result.transactions[1].description).toContain('ÅHLENS CITY')
expect(result.transactions[2].description).toContain('ÄRLA GÅRD AB')
})
it('stores raw_line for each transaction', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
result.transactions.forEach((tx) => {
expect(tx.raw_line).toBeDefined()
expect(tx.raw_line!.length).toBeGreaterThan(0)
})
})
it('handles header-only file with no data rows', () => {
const result = parseBankFile(HEADER_ONLY_NORDEA_BUSINESS, 'nordea_ftg.csv')
expect(result.format).toBe('nordea_business')
expect(result.transactions).toHaveLength(0)
expect(result.date_from).toBeNull()
expect(result.date_to).toBeNull()
expect(result.stats.parsed_rows).toBe(0)
})
it('calculates correct date range', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
it('calculates correct income and expense stats', () => {
const result = parseBankFile(NORDEA_BUSINESS_CSV, 'nordea_ftg.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
expect(result.stats.skipped_rows).toBe(0)
})
})
describe('parseBankFile — SEB format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
@@ -1265,3 +1393,154 @@ describe('edge cases and robustness', () => {
expect(result.transactions[0].amount).toBe(0)
})
})
// --- Fix 5: SEB duplicate condition removal ---
describe('SEB detection — no duplicate conditions', () => {
it('detects SEB with bokföringsdag header', () => {
const content = 'Bokföringsdag;Valutadag;Text;Belopp;Saldo\n2024-01-15;2024-01-15;Test;-100,00;5000,00'
const format = detectFileFormat(content, 'seb.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('seb')
})
it('detects SEB with bokforingsdatum header (no diacritics)', () => {
const content = 'Bokforingsdatum;Valutadag;Text;Belopp;Saldo\n2024-01-15;2024-01-15;Test;-100,00;5000,00'
const format = detectFileFormat(content, 'seb.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('seb')
})
})
// --- Fix 6: Länsförsäkringar false positive prevention ---
describe('Länsförsäkringar detection — false positive prevention', () => {
it('detects valid LF data rows with comma-decimal amounts', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('detects LF header-less data with two dates and comma-decimal amount', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV_NO_HEADER, 'lf.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('does not false-positive on generic semicolon CSV with two date columns but non-numeric 5th field', () => {
// This CSV has two date columns + 5 fields but the 5th field is not a number
const falsePositive = [
'"2024-01-15";"2024-01-15";"Typ";"Text";"not-a-number"',
].join('\n')
const format = detectFileFormat(falsePositive, 'generic.csv')
// Should NOT detect as Länsförsäkringar
expect(format?.id).not.toBe('lansforsakringar')
})
})
// --- Fix 7: Generic CSV column bounds checking ---
describe('parseGenericCSV — column bounds checking', () => {
it('skips rows with too few columns and adds warning', () => {
const content = [
'Date,Description,Amount',
'2024-01-15,Test,-100.00',
'2024-01-16,Short', // Only 2 columns, mapping needs column 2 (amount)
].join('\n')
const result = parseGenericCSV(content, {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: '.',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
})
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].description).toBe('Test')
expect(result.stats.skipped_rows).toBe(1)
expect(result.issues.some((i) => i.message.includes('columns but mapping requires'))).toBe(true)
})
it('skips rows when mapping index exceeds field count', () => {
const content = [
'A,B',
'val1,val2',
].join('\n')
const result = parseGenericCSV(content, {
date: 0,
description: 1,
amount: 5, // Column 5 does not exist (only 2 columns)
delimiter: ',',
decimal_separator: '.',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
})
expect(result.transactions).toHaveLength(0)
expect(result.stats.skipped_rows).toBe(1)
expect(result.issues).toHaveLength(1)
})
it('parses normally when all columns are within bounds', () => {
const content = [
'Date,Description,Amount',
'2024-01-15,SPOTIFY,-99.00',
'2024-01-16,SALARY,25000.00',
].join('\n')
const result = parseGenericCSV(content, {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: '.',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
})
expect(result.transactions).toHaveLength(2)
expect(result.stats.skipped_rows).toBe(0)
expect(result.issues).toHaveLength(0)
})
})
// --- Fix 8: parseCSVLine unclosed quote handling ---
describe('parseCSVLine — unclosed quote handling', () => {
it('handles normal quoted fields correctly', () => {
const fields = parseCSVLine('"hello","world"', ',')
expect(fields).toEqual(['hello', 'world'])
})
it('handles escaped quotes (doubled) inside fields', () => {
const fields = parseCSVLine('"he said ""hi""",other', ',')
expect(fields).toEqual(['he said "hi"', 'other'])
})
it('produces a result even with unclosed quotes instead of hanging', () => {
// Unclosed quote: the parser should not hang or crash
const fields = parseCSVLine('"unclosed,field2,field3', ',')
// With unclosed quote, everything after the opening quote is one field
// The important thing is it doesn't crash and returns something
expect(fields.length).toBeGreaterThan(0)
})
it('preserves data with unclosed quote rather than losing it', () => {
const fields = parseCSVLine('normal,"unclosed value', ',')
// Should have at least the first field and whatever was accumulated
expect(fields.length).toBe(2)
expect(fields[0]).toBe('normal')
// The unclosed quoted field should still contain the text
expect(fields[1]).toContain('unclosed value')
})
it('handles semicolon delimiter with quotes', () => {
const fields = parseCSVLine('"2024-01-15";"SPOTIFY AB";"-99,00"', ';')
expect(fields).toEqual(['2024-01-15', 'SPOTIFY AB', '-99,00'])
})
})
@@ -34,6 +34,18 @@ export function parseGenericCSV(
f.trim().replace(/^"|"$/g, '')
)
// Validate required column indices are within bounds
const maxRequired = Math.max(mapping.date, mapping.description, mapping.amount)
if (maxRequired >= fields.length) {
issues.push({
row: i + 1,
message: `Row has ${fields.length} columns but mapping requires column ${maxRequired + 1}`,
severity: 'warning',
})
skippedRows++
continue
}
const dateStr = fields[mapping.date]
const description = fields[mapping.description] || 'Unknown'
const amountStr = fields[mapping.amount]
@@ -27,10 +27,18 @@ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
* Check if a line has the Länsförsäkringar structure:
* two adjacent YYYY-MM-DD date fields in a semicolon-delimited, quoted row.
*/
// Matches Swedish-format numbers like "-1 234,56", "1234,50", "-500,00"
const COMMA_NUMBER_RE = /^-?[\d\s]+,\d{1,2}$/
function isLFRow(line: string): boolean {
if (!line.includes(';')) return false
const fields = parseCSVLine(line, ';').map((f) => f.trim())
return fields.length >= 5 && DATE_RE.test(fields[0]) && DATE_RE.test(fields[1])
const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
return (
fields.length >= 5 &&
DATE_RE.test(fields[0]) &&
DATE_RE.test(fields[1]) &&
COMMA_NUMBER_RE.test(fields[4].trim())
)
}
/**
@@ -0,0 +1,152 @@
/**
* Nordea Business CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Bokföringsdag, Belopp, Avsändare, Mottagare, Namn, Rubrik, Saldo, Valuta
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* This is the format used by Nordea Business / Internetbanken Företag
* (netbank.nordea.se), including Plusgiro and corporate accounts.
* It differs from the personal banking format which is comma-delimited.
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const nordeaBusinessFormat: BankFileFormat = {
id: 'nordea_business',
name: 'Nordea Företag',
description: 'Nordea Företag CSV (Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// Nordea Business: semicolon-delimited with "bokföringsdag" and "rubrik"
// "rubrik" distinguishes from SEB (which has "valutadag"/"verifikationsnummer")
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') || firstLine.includes('bokforingsdag')) &&
(firstLine.includes('rubrik') || (firstLine.includes('avsändare') && firstLine.includes('mottagare')))
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header to find column indices dynamically
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex(
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdag')
)
const amountIdx = headers.findIndex((h) => h === 'belopp' || h.includes('belopp'))
const senderIdx = headers.findIndex((h) => h.includes('avsändare') || h.includes('avsandare'))
const receiverIdx = headers.findIndex((h) => h.includes('mottagare'))
const nameIdx = headers.findIndex((h) => h === 'namn')
const subjectIdx = headers.findIndex((h) => h === 'rubrik')
const balanceIdx = headers.findIndex((h) => h === 'saldo' || h.includes('saldo'))
const currencyIdx = headers.findIndex((h) => h === 'valuta' || h.includes('valuta'))
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (Bokföringsdag, Belopp)',
severity: 'error',
})
return {
format: 'nordea_business',
format_name: 'Nordea Företag',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]?.trim()
const amountStr = fields[amountIdx]
if (!date || !amountStr) {
issues.push({ row: i + 1, message: 'Missing required fields', severity: 'warning' })
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
// Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
// Build description from Namn + Rubrik (name is the counterparty, rubrik is the subject/memo)
const name = nameIdx >= 0 ? fields[nameIdx]?.trim() : ''
const subject = subjectIdx >= 0 ? fields[subjectIdx]?.trim() : ''
const description = [name, subject].filter(Boolean).join(' — ') || 'Unknown'
// Counterparty from Avsändare (incoming) or Mottagare (outgoing)
const sender = senderIdx >= 0 ? fields[senderIdx]?.trim() : null
const receiver = receiverIdx >= 0 ? fields[receiverIdx]?.trim() : null
const counterparty = (amount > 0 ? sender : receiver) || null
const balance = balanceIdx >= 0 && fields[balanceIdx] ? parseCommaDecimal(fields[balanceIdx]) : null
const currency = currencyIdx >= 0 && fields[currencyIdx] ? fields[currencyIdx].trim() : 'SEK'
transactions.push({
date,
description,
amount,
currency: currency || 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: counterparty || null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'nordea_business',
format_name: 'Nordea Företag',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+2
View File
@@ -144,6 +144,8 @@ function parseCSVLine(line: string, delimiter: string): string[] {
}
}
// Push last field — if inQuotes is still true, the quote was unclosed.
// Treat the accumulated data as-is rather than silently merging fields.
fields.push(current)
return fields
}
+2 -3
View File
@@ -30,8 +30,7 @@ export const sebFormat: BankFileFormat = {
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum') ||
firstLine.includes('bokföringsdag')) &&
firstLine.includes('bokforingsdatum')) &&
(firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer'))
)
},
@@ -50,7 +49,7 @@ export const sebFormat: BankFileFormat = {
// Find column indices dynamically
const dateIdx = headers.findIndex(
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdatum') || h.includes('bokföringsdag')
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdatum')
)
const descIdx = headers.findIndex(
(h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning')
+2
View File
@@ -8,6 +8,7 @@
import * as crypto from 'crypto'
import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types'
import { nordeaFormat } from './formats/nordea'
import { nordeaBusinessFormat } from './formats/nordea-business'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
@@ -27,6 +28,7 @@ import { genericCSVFormat } from './formats/generic-csv'
const FORMATS: BankFileFormat[] = [
camt053Format,
nordeaFormat,
nordeaBusinessFormat,
sebFormat,
swedbankFormat,
handelsbankenFormat,
+1
View File
@@ -44,6 +44,7 @@ export interface BankFileParseIssue {
/** Supported bank file format identifiers */
export type BankFileFormatId =
| 'nordea'
| 'nordea_business'
| 'seb'
| 'swedbank'
| 'handelsbanken'
+65 -6
View File
@@ -41,6 +41,17 @@ const CP437_MAP: Record<number, string> = {
0x9b: 'ø', // ø
}
// Windows-1252 bytes for Swedish characters (superset of ISO-8859-1)
// These bytes are NOT in the CP437 map, so they need separate detection.
const WIN1252_SWEDISH_BYTES = new Set([
0xe5, // å
0xe4, // ä
0xf6, // ö
0xc5, // Å
0xc4, // Ä
0xd6, // Ö
])
/**
* Detect the encoding of a SIE file by looking for Swedish characters
*/
@@ -52,10 +63,11 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
return 'utf8'
}
// Look for CP437 Swedish characters in first 1000 bytes
// Look for encoding-specific Swedish characters in first 1000 bytes
const sampleSize = Math.min(bytes.length, 1000)
let cp437Count = 0
let utf8Count = 0
let win1252Count = 0
for (let i = 0; i < sampleSize; i++) {
const byte = bytes[i]
@@ -65,6 +77,11 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
cp437Count++
}
// Check for Windows-1252 Swedish characters
if (WIN1252_SWEDISH_BYTES.has(byte)) {
win1252Count++
}
// Check for UTF-8 multi-byte sequences for Swedish chars
// Ä = C3 84, Å = C3 85, Ö = C3 96, ä = C3 A4, å = C3 A5, ö = C3 B6
if (byte === 0xc3 && i + 1 < sampleSize) {
@@ -75,7 +92,10 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
}
}
return utf8Count > cp437Count ? 'utf8' : 'cp437'
if (utf8Count > cp437Count && utf8Count > win1252Count) return 'utf8'
if (cp437Count > win1252Count) return 'cp437'
if (win1252Count > 0) return 'windows1252'
return 'cp437'
}
/**
@@ -87,6 +107,11 @@ export function decodeBuffer(buffer: ArrayBuffer, encoding: SIEEncoding): string
return decoder.decode(buffer)
}
if (encoding === 'windows1252') {
const decoder = new TextDecoder('windows-1252')
return decoder.decode(buffer)
}
// CP437 decoding
const bytes = new Uint8Array(buffer)
let result = ''
@@ -123,7 +148,14 @@ function parseSIEDate(dateStr: string): Date | null {
return null
}
return new Date(year, month, day)
const date = new Date(year, month, day)
// Reject invalid dates that auto-roll (e.g. Feb 30 → Mar 2)
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
return null
}
return date
}
/**
@@ -398,7 +430,14 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// #IB yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10)
const account = fields[2]
const amount = parseNumberField(fields[3])
const amountStr = fields[3]
if (!amountStr || amountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #IB, skipping line', tag)
break
}
const amount = parseNumberField(amountStr)
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) {
@@ -411,7 +450,14 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// #UB yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10)
const account = fields[2]
const amount = parseNumberField(fields[3])
const amountStr = fields[3]
if (!amountStr || amountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #UB, skipping line', tag)
break
}
const amount = parseNumberField(amountStr)
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) {
@@ -424,7 +470,14 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// #RES yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10)
const account = fields[2]
const amount = parseNumberField(fields[3])
const amountStr = fields[3]
if (!amountStr || amountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #RES, skipping line', tag)
break
}
const amount = parseNumberField(amountStr)
const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) {
@@ -479,6 +532,12 @@ export function parseSIEFile(content: string): ParsedSIEFile {
fieldIndex++
}
const transAmountStr = fields[fieldIndex]
if (!transAmountStr || transAmountStr.trim() === '') {
addIssue(issues, 'warning', lineNum, 'Missing amount in #TRANS, skipping line', tag)
break
}
const amount = parseNumberField(fields[fieldIndex++])
const transLine: SIETransactionLine = {
+1 -1
View File
@@ -9,7 +9,7 @@
export type SIEType = 1 | 2 | 3 | 4
// Encoding types supported by SIE files
export type SIEEncoding = 'cp437' | 'utf8'
export type SIEEncoding = 'cp437' | 'utf8' | 'windows1252'
// Import status
export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed'
+17 -3
View File
@@ -322,9 +322,6 @@ export async function generateINK2Declaration(
const totalAssets = rutor['7201'] + rutor['7202'] + rutor['7203'] +
rutor['7210'] + rutor['7211'] + rutor['7212']
const totalEquityLiabilities = rutor['7220'] + rutor['7221'] + rutor['7222'] +
rutor['7230'] + rutor['7231']
// Operating result = revenue - operating costs
const operatingResult = rutor['7310'] -
rutor['7320'] - rutor['7330'] - rutor['7340'] -
@@ -333,6 +330,23 @@ export async function generateINK2Declaration(
// Result after financial items
const resultAfterFinancial = operatingResult + rutor['7370'] + rutor['7380']
// Årets resultat (7222): During an open fiscal year, account 2099 has no balance —
// the profit only exists as the net of income statement accounts (class 3-8).
// After year-end closing, 2099 has the balance and income accounts are zeroed.
// Adding resultAfterFinancial handles both cases correctly (0 + profit, or profit + 0).
rutor['7222'] += roundToKrona(resultAfterFinancial)
breakdown['7222'].total = rutor['7222']
if (resultAfterFinancial !== 0) {
breakdown['7222'].accounts.push({
accountNumber: 'calc',
accountName: 'Beräknat resultat från resultaträkningen',
amount: roundToKrona(resultAfterFinancial),
})
}
const totalEquityLiabilities = rutor['7220'] + rutor['7221'] + rutor['7222'] +
rutor['7230'] + rutor['7231']
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
@@ -0,0 +1,10 @@
-- Add 'bas_range' to the match_type CHECK constraint on sie_account_mappings.
-- The account-mapper.ts creates mappings with matchType 'bas_range', but the
-- original CHECK (migration 007) only allowed ('exact', 'name', 'class', 'manual').
alter table sie_account_mappings
drop constraint if exists sie_account_mappings_match_type_check;
alter table sie_account_mappings
add constraint sie_account_mappings_match_type_check
check (match_type in ('exact', 'name', 'class', 'manual', 'bas_range'));