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