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
co-authored by Claude Opus 4.6
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) setIsLoading(true)
try { 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 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 if (deadlinesRes.error) throw deadlinesRes.error
const overdueTotal = (overdueData || []).reduce( 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), (sum, inv) => sum + (inv.total_sek || inv.total || 0),
0 0
) )
setDeadlines(deadlinesData || []) setDeadlines(deadlinesRes.data || [])
setCustomers(customersData || []) setCustomers(customersRes.data || [])
setOverdueInvoices({ count: overdueCount, total: overdueTotal }) setOverdueInvoices({ count: overdueCount, total: overdueTotal })
} catch { } catch {
toast({ toast({
+36 -9
View File
@@ -38,6 +38,7 @@ import type {
ParseIssue, ParseIssue,
} from '@/lib/import/types' } from '@/lib/import/types'
import type { BASAccount } from '@/types' import type { BASAccount } from '@/types'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
// ============================================================ // ============================================================
// Bank File Import Wizard Steps // Bank File Import Wizard Steps
@@ -733,16 +734,21 @@ type ImportMode = null | 'psd2' | 'bank' | 'sie'
export default function ImportPage() { export default function ImportPage() {
const [mode, setMode] = useState<ImportMode>(null) 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(() => { useEffect(() => {
if (!bankingCompiledIn) return
fetch('/api/extensions/toggles/general/enable-banking') fetch('/api/extensions/toggles/general/enable-banking')
.then(res => res.ok ? res.json() : null) .then(res => res.ok ? res.json() : null)
.then(json => { .then(json => {
if (json?.data?.enabled) setHasBankingExtension(true) setHasBankingExtension(json?.data?.enabled || false)
}) })
.catch(() => {}) .catch(() => setHasBankingExtension(false))
}, []) }, [bankingCompiledIn])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -755,11 +761,26 @@ export default function ImportPage() {
</div> </div>
{mode === null && ( {mode === null && (
<div className={`grid gap-4 ${hasBankingExtension ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}> <div className={`grid gap-4 ${hasBankingExtension !== false ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
{hasBankingExtension && ( {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 <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')} 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"> <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"> <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 <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')} 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"> <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"> <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>
<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')} 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"> <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"> <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') redirect('/login')
} }
// Fetch profile for name // Fetch current year date boundaries
const { data: profile } = await supabase const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
.from('profiles') const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
.select('full_name') const now = new Date()
.eq('id', user.id) const today = now.toISOString().split('T')[0]
.single() 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 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 = { const onboardingProgress: OnboardingProgress = {
hasCustomers: (customerCount || 0) > 0, hasCustomers: (customerCount || 0) > 0,
hasInvoices: (invoiceCount || 0) > 0, hasInvoices: (invoiceCount || 0) > 0,
@@ -59,17 +85,6 @@ export default async function DashboardPage() {
hasBankConnected: (transactionCount || 0) > 0, 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 // Calculate totals from journal entry lines using account classes
const calculateTotals = (lines: typeof journalLines, fromDate: string) => { const calculateTotals = (lines: typeof journalLines, fromDate: string) => {
const filtered = (lines || []).filter((l) => { const filtered = (lines || []).filter((l) => {
@@ -100,13 +115,6 @@ export default async function DashboardPage() {
const ytdTotals = calculateTotals(journalLines, startOfYearStr) const ytdTotals = calculateTotals(journalLines, startOfYearStr)
const mtdTotals = calculateTotals(journalLines, startOfMonthStr) 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( const uncategorizedTxns = (transactions || []).filter(
(t) => t.is_business === null (t) => t.is_business === null
) )
@@ -118,19 +126,11 @@ export default async function DashboardPage() {
.filter((t) => t.amount < 0) .filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(Number(t.amount_sek || 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( const unpaidTotal = (unpaidInvoices || []).reduce(
(sum, inv) => sum + Number(inv.total_sek || inv.total), (sum, inv) => sum + Number(inv.total_sek || inv.total),
0 0
) )
// Calculate VAT from unpaid invoices (this is VAT we've invoiced but not yet received)
const unpaidVatTotal = (unpaidInvoices || []).reduce( const unpaidVatTotal = (unpaidInvoices || []).reduce(
(sum, inv) => sum + Number(inv.vat_amount_sek || inv.vat_amount || 0), (sum, inv) => sum + Number(inv.vat_amount_sek || inv.vat_amount || 0),
0 0
@@ -140,13 +140,6 @@ export default async function DashboardPage() {
(inv) => inv.status === 'overdue' (inv) => inv.status === 'overdue'
).length ).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 let bankBalance: number | null = null
if (bankConnections && bankConnections.length > 0) { if (bankConnections && bankConnections.length > 0) {
const allBalances = bankConnections.flatMap(conn => { 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 nowMs = new Date().getTime()
const expiringBankConnections = (bankConnections || []) const expiringBankConnections = (bankConnections || [])
.filter(conn => { .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( const uniqueEntriesWithDocs = new Set(
(entriesWithDocs || []).map((d) => d.journal_entry_id) (entriesWithDocs || []).map((d) => d.journal_entry_id)
).size ).size
const missingUnderlagCount = Math.max(0, (postedEntriesCount || 0) - uniqueEntriesWithDocs) 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 let streakCount = 0
if (recentReceiptActivity && recentReceiptActivity.length > 0) { if (recentReceiptActivity && recentReceiptActivity.length > 0) {
const todayDate = new Date() const todayDate = new Date()
@@ -273,13 +197,6 @@ export default async function DashboardPage() {
streak_count: streakCount, 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 ( return (
<DashboardContent <DashboardContent
firstName={firstName} firstName={firstName}
+15 -13
View File
@@ -35,8 +35,10 @@ import type { CompanySettings } from '@/types'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { SecuritySettings } from '@/components/settings/SecuritySettings' import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const BankingPanel = getSettingsPanel('enable-banking') const BankingPanel = getSettingsPanel('enable-banking')
const bankingCompiledIn = ENABLED_EXTENSION_IDS.has('enable-banking')
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter() const router = useRouter()
@@ -47,7 +49,9 @@ export default function SettingsPage() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<CompanySettings | null>(null) 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 [hasCalendarExtension, setHasCalendarExtension] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState('') const [deleteConfirmText, setDeleteConfirmText] = useState('')
@@ -70,21 +74,15 @@ export default function SettingsPage() {
return return
} }
// Fetch settings // Fetch settings and extension toggles in parallel
const { data: settingsData } = await supabase const [settingsRes, bankingToggleRes, calendarToggleRes] = await Promise.all([
.from('company_settings') supabase.from('company_settings').select('*').eq('user_id', user.id).single(),
.select('*')
.eq('user_id', user.id)
.single()
setSettings(settingsData)
// Check extension toggles in parallel
const [bankingToggleRes, calendarToggleRes] = await Promise.all([
fetch('/api/extensions/toggles/general/enable-banking').catch(() => null), fetch('/api/extensions/toggles/general/enable-banking').catch(() => null),
fetch('/api/extensions/toggles/general/calendar').catch(() => null), fetch('/api/extensions/toggles/general/calendar').catch(() => null),
]) ])
setSettings(settingsRes.data)
if (bankingToggleRes?.ok) { if (bankingToggleRes?.ok) {
const { data } = await bankingToggleRes.json() const { data } = await bankingToggleRes.json()
setHasBankingExtension(data?.enabled || false) setHasBankingExtension(data?.enabled || false)
@@ -541,7 +539,11 @@ export default function SettingsPage() {
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */} {/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
{!settings?.is_sandbox && ( {!settings?.is_sandbox && (
<TabsContent value="banking" className="space-y-6"> <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 /> <BankingPanel />
) : ( ) : (
<Card> <Card>
+44 -44
View File
@@ -110,40 +110,36 @@ export default function TransactionsPage() {
.filter((t) => t.potential_invoice_id) .filter((t) => t.potential_invoice_id)
.map((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 || []) const unbookedTxIds = (txData || [])
.filter((t) => !t.journal_entry_id && t.is_business === null) .filter((t) => !t.journal_entry_id && t.is_business === null)
.map((t) => t.id) .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> = {} let inboxItemMap: Record<string, InvoiceInboxItem> = {}
if (unbookedTxIds.length > 0) { if (inboxResult.data) {
const { data: inboxItems } = await supabase inboxItemMap = inboxResult.data.reduce((acc, item) => {
.from('invoice_inbox_items') if (item.matched_transaction_id) {
.select('*') acc[item.matched_transaction_id] = item as InvoiceInboxItem
.in('matched_transaction_id', unbookedTxIds) }
.in('status', ['ready', 'processing']) return acc
if (inboxItems) { }, {} as Record<string, InvoiceInboxItem>)
inboxItemMap = inboxItems.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) => ({ const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
@@ -178,24 +174,27 @@ export default function TransactionsPage() {
setIsLoadingSuggestions(false) setIsLoadingSuggestions(false)
} }
// Fetch transactions on mount // Fetch transactions and entity type in parallel on mount, then suggestions
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchTransactions() }, [])
// Fetch entity type for tooltip context
useEffect(() => { useEffect(() => {
async function fetchEntityType() { let cancelled = false
try {
const res = await fetch('/api/settings') async function loadAll() {
const data = await res.json() // Fetch transactions and entity type in parallel
if (data?.entity_type) { const [, entityRes] = await Promise.all([
setEntityType(data.entity_type) fetchTransactions(),
} fetch('/api/settings').then(r => r.json()).catch(() => null),
} catch { ])
// Non-critical, defaults to enskild_firma
if (cancelled) return
if (entityRes?.entity_type) {
setEntityType(entityRes.entity_type)
} }
} }
fetchEntityType()
loadAll()
return () => { cancelled = true }
}, []) }, [])
// Auto-fetch suggestions when transactions load // Auto-fetch suggestions when transactions load
@@ -207,6 +206,7 @@ export default function TransactionsPage() {
if (uncatIds.length > 0) { if (uncatIds.length > 0) {
fetchCategorySuggestions(uncatIds) fetchCategorySuggestions(uncatIds)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactions.length]) }, [transactions.length])
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => { const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
+52 -52
View File
@@ -25,14 +25,14 @@ export default function DPAPage() {
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
Detta personuppgiftsbitradesavtal (&quot;DPA&quot;) ingar mellan: Detta personuppgiftsbitradesavtal (&quot;DPA&quot;) ingår mellan:
</p> </p>
<ul> <ul>
<li><strong>Personuppgiftsansvarig (&quot;den Ansvarige&quot;):</strong> Du som anvandare av Gnubok, <li><strong>Personuppgiftsansvarig (&quot;den Ansvarige&quot;):</strong> Du som användare av Gnubok,
i egenskap av ansvarig for de personuppgifter du registrerar i tjansten i egenskap av ansvarig för de personuppgifter du registrerar i tjänsten
(kunder, leverantorer, anstallda m.fl.).</li> (kunder, leverantörer, anställda m.fl.).</li>
<li><strong>Personuppgiftsbitrade (&quot;Bitradet&quot;):</strong> Arcim, som tillhandahaller <li><strong>Personuppgiftsbiträde (&quot;Biträdet&quot;):</strong> Arcim, som tillhandahåller
Gnubok-tjansten och behandlar personuppgifter pa dina vagar.</li> Gnubok-tjänsten och behandlar personuppgifter på dina vägnar.</li>
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
@@ -42,10 +42,10 @@ export default function DPAPage() {
<CardTitle>2. Behandlingens syfte och omfattning</CardTitle> <CardTitle>2. Behandlingens syfte och omfattning</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <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> <ul>
<li>Tillhandahallande av bokforings- och redovisningstjanster</li> <li>Tillhandahållande av bokförings- och redovisningstjänster</li>
<li>Lagring och arkivering av bokforingsmaterial</li> <li>Lagring och arkivering av bokföringsmaterial</li>
<li>Fakturering och betalningshantering</li> <li>Fakturering och betalningshantering</li>
<li>Bankkontosynkronisering (PSD2)</li> <li>Bankkontosynkronisering (PSD2)</li>
<li>AI-assisterad kategorisering och kvittohantering (efter separat samtycke)</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> <p>Kategorier av registrerade vars uppgifter behandlas:</p>
<ul> <ul>
<li>Den Ansvariges kunder (namn, kontaktuppgifter, organisationsnummer)</li> <li>Den Ansvariges kunder (namn, kontaktuppgifter, organisationsnummer)</li>
<li>Den Ansvariges leverantorer (namn, kontaktuppgifter, bankuppgifter)</li> <li>Den Ansvariges leverantörer (namn, kontaktuppgifter, bankuppgifter)</li>
<li>Den Ansvarige sjalv (kontouppgifter, foretagsinformation)</li> <li>Den Ansvarige själv (kontouppgifter, företagsinformation)</li>
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>3. Tekniska och organisatoriska atgarder</CardTitle> <CardTitle>3. Tekniska och organisatoriska åtgärder</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <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> <ul>
<li><strong>Kryptering:</strong> All data krypteras i transit (TLS 1.3) och i vila (AES-256)</li> <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 <li><strong>Åtkomstkontroll:</strong> Row Level Security (RLS) säkerställer att varje användare
enbart kan komma at sina egna uppgifter</li> enbart kan komma åt sina egna uppgifter</li>
<li><strong>Autentisering:</strong> Sakra inloggningsmetoder (magic link, inga losenord lagrade)</li> <li><strong>Autentisering:</strong> Säkra inloggningsmetoder (magic link, inga lösenord lagrade)</li>
<li><strong>Integritetskontroll:</strong> SHA-256 checksummor for alla dokument, med <li><strong>Integritetskontroll:</strong> SHA-256 checksummor för alla dokument, med
regelbunden verifiering</li> 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> (ej redigerbara)</li>
<li><strong>Oforanderlig bokforing:</strong> Bokforda verifikationer kan inte andras eller <li><strong>Oföränderlig bokföring:</strong> Bokförda verifikationer kan inte ändras eller
raderas (databasutlosare)</li> raderas (databasutlösare)</li>
<li><strong>Sakerhetskopior:</strong> Kontinuerliga databaskopior med point-in-time-recovery</li> <li><strong>Säkerhetskopior:</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>EU-lagring:</strong> All primär datalagring sker i EU (eu-central-1)</li>
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>4. Underbitraden</CardTitle> <CardTitle>4. Underbiträden</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
Bitradet anvander underbitraden for att tillhandahalla tjansten. En fullstandig Biträdet använder underbiträden för att tillhandahålla tjänsten. En fullständig
forteckning over underbitraden, inklusive syfte och geografisk plats, finns i förteckning över underbiträden, inklusive syfte och geografisk plats, finns i
var{' '} vår{' '}
<Link href="/privacy" className="text-primary underline underline-offset-4"> <Link href="/privacy" className="text-primary underline underline-offset-4">
integritetspolicy integritetspolicy
</Link>. </Link>.
</p> </p>
<p> <p>
Bitradet kommer att informera den Ansvarige minst 30 dagar i forvag innan Biträdet kommer att informera den Ansvarige minst 30 dagar i förväg innan
en ny underbitrade anlitas, sa att den Ansvarige har mojlighet att invanda. en ny underbiträde anlitas, så att den Ansvarige har möjlighet att invända.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>5. Dataintrangsnotifiering</CardTitle> <CardTitle>5. Dataintrångsnotifiering</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
Vid en personuppgiftsincident ska Bitradet utan ondodigt drojsmal, och senast Vid en personuppgiftsincident ska Biträdet utan onödigt dröjsmål, och senast
inom 72 timmar fran det att incidenten upptacktes, meddela den Ansvarige. inom 72 timmar från det att incidenten upptäcktes, meddela den Ansvarige.
Meddelandet ska innehalla: Meddelandet ska innehålla:
</p> </p>
<ul> <ul>
<li>Typ av personuppgiftsincident</li> <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>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> </ul>
<p> <p>
Bitradet ska bistå den Ansvarige med den information som behovs for att den Biträdet ska bistå den Ansvarige med den information som behövs för att den
Ansvarige ska kunna uppfylla sin anmalningsplikt till IMY (Integritetsskyddsmyndigheten). Ansvarige ska kunna uppfylla sin anmälningsplikt till IMY (Integritetsskyddsmyndigheten).
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>6. Revisionsratt</CardTitle> <CardTitle>6. Revisionsrätt</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
Den Ansvarige har ratt att, direkt eller genom en oberoende revisor, utfora Den Ansvarige har rätt att, direkt eller genom en oberoende revisor, utföra
revisioner och inspektioner for att sakerst alla att Bitradet uppfyller sina revisioner och inspektioner för att säkerställa att Biträdet uppfyller sina
atagarder enligt detta avtal. Bitradet ska tillhandahalla all nodvandig åtaganden enligt detta avtal. Biträdet ska tillhandahålla all nödvändig
information och medverka till revisioner. information och medverka till revisioner.
</p> </p>
<p> <p>
Revisioner ska ske med rimligt varsel (minst 30 dagar) och under ordinarie 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. eller certifieringar.
</p> </p>
</CardContent> </CardContent>
@@ -150,22 +150,22 @@ export default function DPAPage() {
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <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> </p>
<ul> <ul>
<li> <li>
<strong>Aterlamna:</strong> Exportera alla personuppgifter i maskinlasbart format <strong>Återlämna:</strong> Exportera alla personuppgifter i maskinläsbart format
(SIE4, JSON, CSV) via tjansens exportfunktioner. (SIE4, JSON, CSV) via tjänstens exportfunktioner.
</li> </li>
<li> <li>
<strong>Radera:</strong> Radera alla personuppgifter inom 30 dagar fran <strong>Radera:</strong> Radera alla personuppgifter inom 30 dagar från
anvandarens begaran, med undantag for uppgifter som maste bevaras enligt lag. användarens begäran, med undantag för uppgifter som måste bevaras enligt lag.
</li> </li>
</ul> </ul>
<p> <p>
<strong>Undantag:</strong> Bokforingsmaterial som omfattas av Bokforingslagen (BFL) <strong>Undantag:</strong> Bokföringsmaterial som omfattas av Bokföringslagen (BFL)
7 kap. 2 § (7 ars arkiveringskrav) raderas forst nar lagringsfristen lopt ut. 7 kap. 2 § (7 års arkiveringskrav) raderas först när lagringsfristen löpt ut.
Under denna period ar materialet skyddat mot obehorig atkomst och andring. Under denna period är materialet skyddat mot obehörig åtkomst och ändring.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -173,9 +173,9 @@ export default function DPAPage() {
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Detta personuppgiftsbitradesavtal trader i kraft nar du skapar ett konto pa Detta personuppgiftsbitradesavtal träder i kraft när du skapar ett konto på
Gnubok och galler sa lange du anvander tjansten. For fragor, kontakta oss Gnubok och gäller så länge du använder tjänsten. För frågor, kontakta oss
pa privacy@gnubok.se. på privacy@gnubok.se.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
+50 -50
View File
@@ -24,9 +24,9 @@ export default function PrivacyPolicyPage() {
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
Arcim (&quot;vi&quot;, &quot;oss&quot;) ar personuppgiftsansvarig for behandlingen av dina Arcim (&quot;vi&quot;, &quot;oss&quot;) är personuppgiftsansvarig för behandlingen av dina
personuppgifter i samband med anvandningen av Gnubok. Vi behandlar dina uppgifter i personuppgifter i samband med användningen av Gnubok. Vi behandlar dina uppgifter i
enlighet med EU:s dataskyddsforordning (GDPR) och svensk dataskyddslagstiftning. enlighet med EU:s dataskyddsförordning (GDPR) och svensk dataskyddslagstiftning.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -36,39 +36,39 @@ export default function PrivacyPolicyPage() {
<CardTitle>2. Vilka uppgifter vi behandlar</CardTitle> <CardTitle>2. Vilka uppgifter vi behandlar</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <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> <ul>
<li><strong>Kontouppgifter:</strong> E-postadress (for inloggning via magic link)</li> <li><strong>Kontouppgifter:</strong> E-postadress (för inloggning via magic link)</li>
<li><strong>Foretagsuppgifter:</strong> Foretagsnamn, organisationsnummer, adress, kontaktuppgifter</li> <li><strong>Företagsuppgifter:</strong> Företagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Bokforingsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</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>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokforingsunderlag</li> <li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokföringsunderlag</li>
<li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, anvandningsstatistik</li> <li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, användningsstatistik</li>
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>3. Rattslig grund (GDPR Art. 6)</CardTitle> <CardTitle>3. Rättslig grund (GDPR Art. 6)</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<ul> <ul>
<li> <li>
<strong>Avtal (Art. 6.1b):</strong> Behandling som ar nodvandig for att fullgora vara <strong>Avtal (Art. 6.1b):</strong> Behandling som är nödvändig för att fullgöra våra
tjanster enligt anvandaravtalet. tjänster enligt användaravtalet.
</li> </li>
<li> <li>
<strong>Rattslig forpliktelse (Art. 6.1c):</strong> Bokforingslagens (BFL) krav pa <strong>Rättslig förpliktelse (Art. 6.1c):</strong> Bokföringslagens (BFL) krav på
7 ars arkivering av raknenskapsmaterial. 7 års arkivering av räkenskapsmaterial.
</li> </li>
<li> <li>
<strong>Berattigat intresse (Art. 6.1f):</strong> Produktforbattringar, sakerhet och <strong>Berättigat intresse (Art. 6.1f):</strong> Produktförbättringar, säkerhet och
bedrageriforbud. bedrägeriforbud.
</li> </li>
<li> <li>
<strong>Samtycke (Art. 6.1a):</strong> For AI-baserade funktioner som skickar data <strong>Samtycke (Art. 6.1a):</strong> För AI-baserade funktioner som skickar data
till tredjepartstjanster (se separat samtycke vid aktivering). till tredjepartstjänster (se separat samtycke vid aktivering).
</li> </li>
</ul> </ul>
</CardContent> </CardContent>
@@ -76,12 +76,12 @@ export default function PrivacyPolicyPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>4. Underbitraden</CardTitle> <CardTitle>4. Underbiträden</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
Vi anvander foljande underbitraden for att tillhandahalla tjansten. Uppgifterna nedan anger Vi använder följande underbiträden för att tillhandahålla tjänsten. Uppgifterna nedan anger
vilka uppgifter som delas med respektive underbitrade, syftet samt var behandlingen sker vilka uppgifter som delas med respektive underbiträde, syftet samt var behandlingen sker
(GDPR Art. 13). (GDPR Art. 13).
</p> </p>
@@ -89,7 +89,7 @@ export default function PrivacyPolicyPage() {
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead> <thead>
<tr className="border-b"> <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">Syfte</th>
<th className="text-left py-2 pr-4 font-semibold">Plats</th> <th className="text-left py-2 pr-4 font-semibold">Plats</th>
<th className="text-left py-2 font-semibold">Skyddsmekanism</th> <th className="text-left py-2 font-semibold">Skyddsmekanism</th>
@@ -105,7 +105,7 @@ export default function PrivacyPolicyPage() {
<tr className="border-b"> <tr className="border-b">
<td className="py-2 pr-4 font-medium">Vercel</td> <td className="py-2 pr-4 font-medium">Vercel</td>
<td className="py-2 pr-4">Applikationshosting</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> <td className="py-2">EU Data Residency</td>
</tr> </tr>
<tr className="border-b"> <tr className="border-b">
@@ -120,7 +120,7 @@ export default function PrivacyPolicyPage() {
<tr className="border-b"> <tr className="border-b">
<td className="py-2 pr-4 font-medium">OpenAI</td> <td className="py-2 pr-4 font-medium">OpenAI</td>
<td className="py-2 pr-4"> <td className="py-2 pr-4">
Embedding-generering for likhetssokning (transaktionsmallar, kunskapsbas) Embedding-generering för likhetssökning (transaktionsmallar, kunskapsbas)
</td> </td>
<td className="py-2 pr-4">USA</td> <td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td> <td className="py-2">SCCs (standardavtalsklausuler)</td>
@@ -139,7 +139,7 @@ export default function PrivacyPolicyPage() {
</tr> </tr>
<tr className="border-b"> <tr className="border-b">
<td className="py-2 pr-4 font-medium">Recapt</td> <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 pr-4">EU</td>
<td className="py-2">EU-baserad</td> <td className="py-2">EU-baserad</td>
</tr> </tr>
@@ -148,21 +148,21 @@ export default function PrivacyPolicyPage() {
</div> </div>
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
AI-funktioner (Anthropic, OpenAI) kraver separat samtycke fore aktivering. AI-funktioner (Anthropic, OpenAI) kräver separat samtycke före aktivering.
Data skickas forst nar du aktivt godkanner anvandningen. Data skickas först när du aktivt godkänner användningen.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>5. Tredjelandsoverforing</CardTitle> <CardTitle>5. Tredjelandsöverföring</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <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. 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> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -174,13 +174,13 @@ export default function PrivacyPolicyPage() {
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<ul> <ul>
<li> <li>
<strong>Bokforingsmaterial:</strong> 7 ar fran rakenskapsarets slut, i enlighet <strong>Bokföringsmaterial:</strong> 7 år från räkenskapsårets slut, i enlighet
med Bokforingslagen (BFL) 7 kap. 2 §. Systemet hindrar radering av material med Bokföringslagen (BFL) 7 kap. 2 §. Systemet hindrar radering av material
kopplat till bokforda verifikationer under denna period. kopplat till bokförda verifikationer under denna period.
</li> </li>
<li> <li>
<strong>Kontouppgifter:</strong> Sa lange kontot ar aktivt, plus 30 dagar efter <strong>Kontouppgifter:</strong> Så länge kontot är aktivt, plus 30 dagar efter
begaran om radering (for att hantera pagaende bokforingsplikter). begäran om radering (för att hantera pågående bokföringsplikter).
</li> </li>
<li> <li>
<strong>Tekniska loggar:</strong> Maximalt 90 dagar. <strong>Tekniska loggar:</strong> Maximalt 90 dagar.
@@ -191,24 +191,24 @@ export default function PrivacyPolicyPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>7. Dina rattigheter</CardTitle> <CardTitle>7. Dina rättigheter</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <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> <ul>
<li><strong>Tillgang (Art. 15):</strong> Du kan begara en kopia av alla dina personuppgifter.</li> <li><strong>Tillgång (Art. 15):</strong> Du kan begära en kopia av alla dina personuppgifter.</li>
<li><strong>Rattelse (Art. 16):</strong> Du kan begara rattelse av felaktiga uppgifter.</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 begara radering, med undantag for uppgifter som <li><strong>Radering (Art. 17):</strong> Du kan begära radering, med undantag för uppgifter som
omfattas av lagstadgade arkiveringskrav (BFL 7 ar).</li> omfattas av lagstadgade arkiveringskrav (BFL 7 år).</li>
<li><strong>Begransning (Art. 18):</strong> Du kan begara begransning av behandlingen.</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 <li><strong>Dataportabilitet (Art. 20):</strong> Du kan exportera dina uppgifter i
maskinlasbart format (SIE4, JSON, CSV) via exportfunktionerna i appen.</li> maskinläsbart format (SIE4, JSON, CSV) via exportfunktionerna i appen.</li>
<li><strong>Invandning (Art. 21):</strong> Du kan invanda mot behandling baserad pa <li><strong>Invändning (Art. 21):</strong> Du kan invända mot behandling baserad på
berattigat intresse.</li> berättigat intresse.</li>
</ul> </ul>
<p> <p>
For att utova dina rattigheter, kontakta oss pa adressen nedan. Vi besvarar alla För att utöva dina rättigheter, kontakta oss på adressen nedan. Vi besvarar alla
forfragar inom 30 dagar. förfrågningar inom 30 dagar.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -219,14 +219,14 @@ export default function PrivacyPolicyPage() {
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
For fragor om behandlingen av dina personuppgifter, kontakta oss: För frågor om behandlingen av dina personuppgifter, kontakta oss:
</p> </p>
<ul> <ul>
<li><strong>Foretag:</strong> Arcim</li> <li><strong>Företag:</strong> Arcim</li>
<li><strong>E-post:</strong> privacy@gnubok.se</li> <li><strong>E-post:</strong> privacy@gnubok.se</li>
</ul> </ul>
<p> <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. www.imy.se.
</p> </p>
</CardContent> </CardContent>
+6 -6
View File
@@ -52,16 +52,16 @@ export function AiConsentDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>AI-samtycke kravs</DialogTitle> <DialogTitle>AI-samtycke krävs</DialogTitle>
<DialogDescription> <DialogDescription>
Denna funktion anvander AI-tjanster fran externa leverantorer. Denna funktion använder AI-tjänster från externa leverantörer.
Granska informationen nedan innan du fortsatter. Granska informationen nedan innan du fortsätter.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div> <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> <p className="text-sm text-muted-foreground">{disclosure.provider}</p>
</div> </div>
@@ -80,11 +80,11 @@ export function AiConsentDialog({
</div> </div>
<p className="text-xs text-muted-foreground"> <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"> <Link href="/privacy" className="underline underline-offset-4" target="_blank">
integritetspolicy integritetspolicy
</Link> </Link>
. Du kan nar som helst aterkalla ditt samtycke i installningarna. . Du kan när som helst återkalla ditt samtycke i inställningarna.
</p> </p>
</div> </div>
@@ -18,8 +18,8 @@ interface ConfirmDeleteDialogProps {
export default function ConfirmDeleteDialog({ export default function ConfirmDeleteDialog({
open, open,
onOpenChange, onOpenChange,
title = 'Bekrafta borttagning', title = 'Bekräfta borttagning',
description = 'Ar du saker pa att du vill ta bort detta? Atgarden kan inte angras.', description = 'Är du säker på att du vill ta bort detta? Åtgärden kan inte ångras.',
onConfirm, onConfirm,
isDeleting = false, isDeleting = false,
}: ConfirmDeleteDialogProps) { }: ConfirmDeleteDialogProps) {
+4 -4
View File
@@ -204,7 +204,7 @@ export default function AccountMappingStep({
</div> </div>
{/* Mapping table */} {/* Mapping table */}
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -317,11 +317,11 @@ export default function AccountMappingStep({
</Card> </Card>
{/* Actions */} {/* Actions */}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={onBack}> <Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka Tillbaka
</Button> </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`} {canContinue ? 'Fortsätt till granskning' : `${stats.unmapped} konton saknar mappning`}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
@@ -248,7 +248,7 @@ export default function BankFileColumnMappingStep({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -286,12 +286,12 @@ export default function BankFileColumnMappingStep({
)} )}
{/* Navigation */} {/* Navigation */}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={onBack}> <Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka Tillbaka
</Button> </Button>
<Button onClick={handleConfirm} disabled={!isValid}> <Button className="min-h-11" onClick={handleConfirm} disabled={!isValid}>
Fortsätt Fortsätt
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
+3 -2
View File
@@ -112,12 +112,13 @@ export default function BankFileConfirmStep({
</Card> </Card>
{/* Actions */} {/* Actions */}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={onBack} disabled={isLoading}> <Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka Tillbaka
</Button> </Button>
<Button <Button
className="min-h-11"
onClick={() => onExecute({ onClick={() => onExecute({
skip_duplicates: true, skip_duplicates: true,
auto_categorize: false, auto_categorize: false,
+5 -5
View File
@@ -41,7 +41,7 @@ export default function BankFilePreviewStep({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Summary cards */} {/* Summary cards */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1"> <div className="flex items-center gap-2 text-muted-foreground mb-1">
@@ -129,7 +129,7 @@ export default function BankFilePreviewStep({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -201,12 +201,12 @@ export default function BankFilePreviewStep({
)} )}
{/* Navigation */} {/* Navigation */}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={onBack}> <Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka Tillbaka
</Button> </Button>
<Button onClick={onContinue} disabled={hasIssues || transactions.length === 0}> <Button className="min-h-11" onClick={onContinue} disabled={hasIssues || transactions.length === 0}>
Fortsätt Fortsätt
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
+11 -13
View File
@@ -30,7 +30,7 @@ export default function BankFileResultStep({
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
{isSuccess ? ( {isSuccess ? (
<> <>
<CheckCircle className="h-6 w-6 text-muted-foreground" /> <CheckCircle className="h-6 w-6 text-success" />
Import genomförd Import genomförd
</> </>
) : ( ) : (
@@ -97,21 +97,19 @@ export default function BankFileResultStep({
)} )}
{/* Actions */} {/* Actions */}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={onNewImport}> <Button variant="outline" className="min-h-11" onClick={onNewImport}>
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
Ny import Ny import
</Button> </Button>
<div className="flex gap-2"> {isSuccess && (
{isSuccess && ( <Button className="min-h-11" asChild>
<Button asChild> <Link href="/transactions">
<Link href="/transactions"> Visa transaktioner
Visa transaktioner <ArrowRight className="ml-2 h-4 w-4" />
<ArrowRight className="ml-2 h-4 w-4" /> </Link>
</Link> </Button>
</Button> )}
)}
</div>
</div> </div>
</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> = { const FORMAT_NAMES: Record<string, string> = {
nordea: 'Nordea', nordea: 'Nordea',
nordea_business: 'Nordea Företag',
seb: 'SEB', seb: 'SEB',
swedbank: 'Swedbank', swedbank: 'Swedbank',
handelsbanken: 'Handelsbanken', handelsbanken: 'Handelsbanken',
@@ -110,18 +111,19 @@ export default function BankFileUploadStep({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Format override */} {/* 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> <label className="text-sm font-medium whitespace-nowrap">Bank/format:</label>
<Select <Select
value={formatOverride || 'auto'} value={formatOverride || 'auto'}
onValueChange={handleFormatChange} onValueChange={handleFormatChange}
> >
<SelectTrigger className="w-64"> <SelectTrigger className="w-full sm:w-64">
<SelectValue placeholder="Automatisk identifiering" /> <SelectValue placeholder="Automatisk identifiering" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Automatisk identifiering</SelectItem> <SelectItem value="auto">Automatisk identifiering</SelectItem>
<SelectItem value="nordea">Nordea</SelectItem> <SelectItem value="nordea">Nordea</SelectItem>
<SelectItem value="nordea_business">Nordea Företag</SelectItem>
<SelectItem value="seb">SEB</SelectItem> <SelectItem value="seb">SEB</SelectItem>
<SelectItem value="swedbank">Swedbank</SelectItem> <SelectItem value="swedbank">Swedbank</SelectItem>
<SelectItem value="handelsbanken">Handelsbanken</SelectItem> <SelectItem value="handelsbanken">Handelsbanken</SelectItem>
@@ -181,7 +183,8 @@ export default function BankFileUploadStep({
<div className="space-y-4"> <div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-muted-foreground" /> <Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<div> <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"> <p className="text-sm text-muted-foreground">
CSV, TXT eller XML (max 10 MB) CSV, TXT eller XML (max 10 MB)
</p> </p>
+6 -6
View File
@@ -51,7 +51,7 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
{/* Statistics */} {/* Statistics */}
{result.success && ( {result.success && (
<div className="grid gap-4 md:grid-cols-3"> <div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1"> <div className="flex items-center gap-2 text-muted-foreground mb-1">
@@ -183,21 +183,21 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
)} )}
{/* Actions */} {/* Actions */}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={onNewImport}> <Button variant="outline" className="min-h-11" onClick={onNewImport}>
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
Ny import Ny import
</Button> </Button>
<div className="flex gap-2"> <div className="flex flex-col gap-2 sm:flex-row">
{result.success && ( {result.success && (
<> <>
<Button variant="outline" asChild> <Button variant="outline" className="min-h-11" asChild>
<Link href="/bookkeeping"> <Link href="/bookkeeping">
Visa bokföring Visa bokföring
<ExternalLink className="ml-2 h-4 w-4" /> <ExternalLink className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
<Button asChild> <Button className="min-h-11" asChild>
<Link href="/reports"> <Link href="/reports">
Visa rapporter Visa rapporter
<ExternalLink className="ml-2 h-4 w-4" /> <ExternalLink className="ml-2 h-4 w-4" />
+3 -3
View File
@@ -246,11 +246,11 @@ export default function ImportReviewStep({
</Card> </Card>
{/* Actions */} {/* Actions */}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={onBack} disabled={isLoading}> <Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
Tillbaka Tillbaka
</Button> </Button>
<Button onClick={handleExecute} disabled={isLoading}> <Button className="min-h-11" onClick={handleExecute} disabled={isLoading}>
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+5 -5
View File
@@ -99,7 +99,7 @@ export default function SIEPreviewStep({
</Card> </Card>
{/* Statistics */} {/* Statistics */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1"> <div className="flex items-center gap-2 text-muted-foreground mb-1">
@@ -201,7 +201,7 @@ export default function SIEPreviewStep({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div> <div>
<p className="text-sm text-muted-foreground">Totalt</p> <p className="text-sm text-muted-foreground">Totalt</p>
<p className="font-medium">{preview.mappingStatus.total}</p> <p className="font-medium">{preview.mappingStatus.total}</p>
@@ -303,11 +303,11 @@ export default function SIEPreviewStep({
)} )}
{/* Actions */} {/* Actions */}
<div className="flex justify-between"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" onClick={onBack}> <Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka Tillbaka
</Button> </Button>
<Button onClick={onContinue} disabled={hasBlockingErrors}> <Button className="min-h-11" onClick={onContinue} disabled={hasBlockingErrors}>
{preview.mappingStatus.lowConfidence > 0 || preview.mappingStatus.unmapped > 0 {preview.mappingStatus.lowConfidence > 0 || preview.mappingStatus.unmapped > 0
? 'Granska mappningar' ? 'Granska mappningar'
: 'Fortsätt'} : 'Fortsätt'}
+4 -2
View File
@@ -103,8 +103,10 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl
<div className="space-y-4"> <div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-muted-foreground" /> <Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<div> <div>
<p className="font-medium">Dra och släpp SIE-fil här</p> <p className="font-medium hidden sm:block">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 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>
</div> </div>
)} )}
@@ -73,7 +73,7 @@ describe('description-analyzer', () => {
vatTreatment: 'standard_25', vatTreatment: 'standard_25',
category: 'income_services', category: 'income_services',
confidence: 0.9, confidence: 0.9,
reasoning: 'Konsultarvode bokfors som tjansteintakt.', reasoning: 'Konsultarvode bokförs som tjänsteintäkt.',
warnings: [], warnings: [],
templateId: null, templateId: null,
}) })
+1 -1
View File
@@ -1977,7 +1977,7 @@ export const CLASS_1_ACCOUNTS: BASReferenceAccount[] = [
account_group: '16', account_group: '16',
account_type: 'asset', account_type: 'asset',
normal_balance: 'debit', 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', sru_code: '7212',
k2_excluded: false, k2_excluded: false,
}, },
@@ -602,7 +602,7 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
account_group: '20', account_group: '20',
account_type: 'equity', account_type: 'equity',
normal_balance: 'credit', 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', sru_code: '7221',
k2_excluded: false, k2_excluded: false,
}, },
@@ -1779,7 +1779,7 @@ export const CLASS_7_ACCOUNTS: BASReferenceAccount[] = [
account_group: '78', account_group: '78',
account_type: 'expense', account_type: 'expense',
normal_balance: 'debit', 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', sru_code: '7325',
k2_excluded: false, k2_excluded: false,
}, },
+4 -4
View File
@@ -35,11 +35,11 @@ describe('validateSwedishPersonalNumber', () => {
}) })
it('rejects invalid month', () => { it('rejects invalid month', () => {
expect(validateSwedishPersonalNumber('199913011234')).toBe('Ogiltig manad') expect(validateSwedishPersonalNumber('199913011234')).toBe('Ogiltig månad')
}) })
it('rejects month 00', () => { it('rejects month 00', () => {
expect(validateSwedishPersonalNumber('199900011234')).toBe('Ogiltig manad') expect(validateSwedishPersonalNumber('199900011234')).toBe('Ogiltig månad')
}) })
it('rejects invalid day', () => { it('rejects invalid day', () => {
@@ -51,12 +51,12 @@ describe('validateSwedishPersonalNumber', () => {
}) })
it('rejects year before 1900', () => { it('rejects year before 1900', () => {
expect(validateSwedishPersonalNumber('189901011234')).toBe('Ogiltigt ar') expect(validateSwedishPersonalNumber('189901011234')).toBe('Ogiltigt år')
}) })
it('rejects future year', () => { it('rejects future year', () => {
const futureYear = new Date().getFullYear() + 1 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', () => { it('rejects invalid Luhn checksum', () => {
+5 -5
View File
@@ -33,18 +33,18 @@ export const AI_DATA_DISCLOSURES: Record<AiExtensionId, {
}> = { }> = {
'receipt-ocr': { 'receipt-ocr': {
provider: 'Anthropic', provider: 'Anthropic',
dataTypes: ['Kvittobilder', 'Extraherad text fran kvitton'], dataTypes: ['Kvittobilder', 'Extraherad text från kvitton'],
purpose: 'Automatisk avlasning och kategorisering av kvitton', purpose: 'Automatisk avläsning och kategorisering av kvitton',
}, },
'ai-categorization': { 'ai-categorization': {
provider: 'Anthropic, OpenAI', provider: 'Anthropic, OpenAI',
dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokformallar'], dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokförmallar'],
purpose: 'Automatisk kategorisering av banktransaktioner', purpose: 'Automatisk kategorisering av banktransaktioner',
}, },
'ai-chat': { 'ai-chat': {
provider: 'Anthropic, OpenAI', provider: 'Anthropic, OpenAI',
dataTypes: ['Chattmeddelanden', 'Bokforingsdata som refereras i chatten'], dataTypes: ['Chattmeddelanden', 'Bokföringsdata som refereras i chatten'],
purpose: 'AI-assistent for bokforingsfragor', 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 month = parseInt(cleaned.slice(4, 6))
const day = parseInt(cleaned.slice(6, 8)) 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 (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) // Luhn check on the last 10 digits (YYMMDDXXXX)
const luhnDigits = cleaned.slice(2) const luhnDigits = cleaned.slice(2)
+230 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { parseSIEFile, validateSIEFile } from '../sie-parser' import { parseSIEFile, validateSIEFile, detectEncoding, decodeBuffer } from '../sie-parser'
// --- SIE content fixtures --- // --- SIE content fixtures ---
@@ -433,3 +433,232 @@ describe('validateSIEFile', () => {
expect(ibWarning).toBeUndefined() 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 { detectFileFormat, parseBankFile, generateExternalId, generateFileHash, getFormat, getAllFormats } from '../parser'
import type { ParsedBankTransaction, BankFileFormatId } from '../types' 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 // 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 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 // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -182,6 +200,18 @@ describe('detectFileFormat', () => {
expect(format!.id).toBe('nordea') 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', () => { it('detects SEB CSV from semicolon-delimited header with bokföringsdag', () => {
const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv') const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv')
expect(format).not.toBeNull() 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', () => { describe('parseBankFile — SEB format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => { it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv') const result = parseBankFile(SEB_CSV, 'seb.csv')
@@ -1265,3 +1393,154 @@ describe('edge cases and robustness', () => {
expect(result.transactions[0].amount).toBe(0) 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, '') 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 dateStr = fields[mapping.date]
const description = fields[mapping.description] || 'Unknown' const description = fields[mapping.description] || 'Unknown'
const amountStr = fields[mapping.amount] 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: * Check if a line has the Länsförsäkringar structure:
* two adjacent YYYY-MM-DD date fields in a semicolon-delimited, quoted row. * 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 { function isLFRow(line: string): boolean {
if (!line.includes(';')) return false if (!line.includes(';')) return false
const fields = parseCSVLine(line, ';').map((f) => f.trim()) const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
return fields.length >= 5 && DATE_RE.test(fields[0]) && DATE_RE.test(fields[1]) 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) fields.push(current)
return fields return fields
} }
+2 -3
View File
@@ -30,8 +30,7 @@ export const sebFormat: BankFileFormat = {
return ( return (
firstLine.includes(';') && firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') || (firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum') || firstLine.includes('bokforingsdatum')) &&
firstLine.includes('bokföringsdag')) &&
(firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer')) (firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer'))
) )
}, },
@@ -50,7 +49,7 @@ export const sebFormat: BankFileFormat = {
// Find column indices dynamically // Find column indices dynamically
const dateIdx = headers.findIndex( 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( const descIdx = headers.findIndex(
(h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning') (h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning')
+2
View File
@@ -8,6 +8,7 @@
import * as crypto from 'crypto' import * as crypto from 'crypto'
import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types' import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types'
import { nordeaFormat } from './formats/nordea' import { nordeaFormat } from './formats/nordea'
import { nordeaBusinessFormat } from './formats/nordea-business'
import { sebFormat } from './formats/seb' import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank' import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken' import { handelsbankenFormat } from './formats/handelsbanken'
@@ -27,6 +28,7 @@ import { genericCSVFormat } from './formats/generic-csv'
const FORMATS: BankFileFormat[] = [ const FORMATS: BankFileFormat[] = [
camt053Format, camt053Format,
nordeaFormat, nordeaFormat,
nordeaBusinessFormat,
sebFormat, sebFormat,
swedbankFormat, swedbankFormat,
handelsbankenFormat, handelsbankenFormat,
+1
View File
@@ -44,6 +44,7 @@ export interface BankFileParseIssue {
/** Supported bank file format identifiers */ /** Supported bank file format identifiers */
export type BankFileFormatId = export type BankFileFormatId =
| 'nordea' | 'nordea'
| 'nordea_business'
| 'seb' | 'seb'
| 'swedbank' | 'swedbank'
| 'handelsbanken' | 'handelsbanken'
+65 -6
View File
@@ -41,6 +41,17 @@ const CP437_MAP: Record<number, string> = {
0x9b: 'ø', // ø 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 * Detect the encoding of a SIE file by looking for Swedish characters
*/ */
@@ -52,10 +63,11 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
return 'utf8' 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) const sampleSize = Math.min(bytes.length, 1000)
let cp437Count = 0 let cp437Count = 0
let utf8Count = 0 let utf8Count = 0
let win1252Count = 0
for (let i = 0; i < sampleSize; i++) { for (let i = 0; i < sampleSize; i++) {
const byte = bytes[i] const byte = bytes[i]
@@ -65,6 +77,11 @@ export function detectEncoding(buffer: ArrayBuffer): SIEEncoding {
cp437Count++ cp437Count++
} }
// Check for Windows-1252 Swedish characters
if (WIN1252_SWEDISH_BYTES.has(byte)) {
win1252Count++
}
// Check for UTF-8 multi-byte sequences for Swedish chars // Check for UTF-8 multi-byte sequences for Swedish chars
// Ä = C3 84, Å = C3 85, Ö = C3 96, ä = C3 A4, å = C3 A5, ö = C3 B6 // Ä = C3 84, Å = C3 85, Ö = C3 96, ä = C3 A4, å = C3 A5, ö = C3 B6
if (byte === 0xc3 && i + 1 < sampleSize) { 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) return decoder.decode(buffer)
} }
if (encoding === 'windows1252') {
const decoder = new TextDecoder('windows-1252')
return decoder.decode(buffer)
}
// CP437 decoding // CP437 decoding
const bytes = new Uint8Array(buffer) const bytes = new Uint8Array(buffer)
let result = '' let result = ''
@@ -123,7 +148,14 @@ function parseSIEDate(dateStr: string): Date | null {
return 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] // #IB yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10) const yearIndex = parseInt(fields[1], 10)
const account = fields[2] 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 const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) { if (account) {
@@ -411,7 +450,14 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// #UB yearIndex accountNumber amount [quantity] // #UB yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10) const yearIndex = parseInt(fields[1], 10)
const account = fields[2] 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 const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) { if (account) {
@@ -424,7 +470,14 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// #RES yearIndex accountNumber amount [quantity] // #RES yearIndex accountNumber amount [quantity]
const yearIndex = parseInt(fields[1], 10) const yearIndex = parseInt(fields[1], 10)
const account = fields[2] 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 const quantity = fields[4] ? parseNumberField(fields[4]) : undefined
if (account) { if (account) {
@@ -479,6 +532,12 @@ export function parseSIEFile(content: string): ParsedSIEFile {
fieldIndex++ 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 amount = parseNumberField(fields[fieldIndex++])
const transLine: SIETransactionLine = { const transLine: SIETransactionLine = {
+1 -1
View File
@@ -9,7 +9,7 @@
export type SIEType = 1 | 2 | 3 | 4 export type SIEType = 1 | 2 | 3 | 4
// Encoding types supported by SIE files // Encoding types supported by SIE files
export type SIEEncoding = 'cp437' | 'utf8' export type SIEEncoding = 'cp437' | 'utf8' | 'windows1252'
// Import status // Import status
export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed' 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'] + const totalAssets = rutor['7201'] + rutor['7202'] + rutor['7203'] +
rutor['7210'] + rutor['7211'] + rutor['7212'] rutor['7210'] + rutor['7211'] + rutor['7212']
const totalEquityLiabilities = rutor['7220'] + rutor['7221'] + rutor['7222'] +
rutor['7230'] + rutor['7231']
// Operating result = revenue - operating costs // Operating result = revenue - operating costs
const operatingResult = rutor['7310'] - const operatingResult = rutor['7310'] -
rutor['7320'] - rutor['7330'] - rutor['7340'] - rutor['7320'] - rutor['7330'] - rutor['7340'] -
@@ -333,6 +330,23 @@ export async function generateINK2Declaration(
// Result after financial items // Result after financial items
const resultAfterFinancial = operatingResult + rutor['7370'] + rutor['7380'] 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 // Add warnings
if (!(period as FiscalPeriod).is_closed) { if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.') 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'));