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

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-11 18:05:37 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a1ca96453c
commit e8fb84b4fd
39 changed files with 1129 additions and 425 deletions
+14 -27
View File
@@ -23,40 +23,27 @@ export default function DeadlinesPage() {
setIsLoading(true)
try {
// Fetch deadlines with customer names
const { data: deadlinesData, error: deadlinesError } = await supabase
.from('deadlines')
.select('*, customer:customers(name)')
.order('due_date', { ascending: true })
if (deadlinesError) throw deadlinesError
// Fetch customers for the form
const { data: customersData, error: customersError } = await supabase
.from('customers')
.select('id, name')
.order('name', { ascending: true })
if (customersError) throw customersError
// Fetch overdue invoices summary
const today = new Date().toISOString().split('T')[0]
const { data: overdueData, error: overdueError } = await supabase
.from('invoices')
.select('total_sek, total')
.in('status', ['sent', 'unpaid'])
.lt('due_date', today)
if (overdueError) throw overdueError
// Fetch all data in parallel
const [deadlinesRes, customersRes, overdueRes] = await Promise.all([
supabase.from('deadlines').select('*, customer:customers(name)').order('due_date', { ascending: true }),
supabase.from('customers').select('id, name').order('name', { ascending: true }),
supabase.from('invoices').select('total_sek, total').in('status', ['sent', 'unpaid']).lt('due_date', today),
])
const overdueCount = overdueData?.length || 0
const overdueTotal = (overdueData || []).reduce(
if (deadlinesRes.error) throw deadlinesRes.error
if (customersRes.error) throw customersRes.error
if (overdueRes.error) throw overdueRes.error
const overdueCount = overdueRes.data?.length || 0
const overdueTotal = (overdueRes.data || []).reduce(
(sum, inv) => sum + (inv.total_sek || inv.total || 0),
0
)
setDeadlines(deadlinesData || [])
setCustomers(customersData || [])
setDeadlines(deadlinesRes.data || [])
setCustomers(customersRes.data || [])
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
} catch {
toast({
+36 -9
View File
@@ -38,6 +38,7 @@ import type {
ParseIssue,
} from '@/lib/import/types'
import type { BASAccount } from '@/types'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
// ============================================================
// Bank File Import Wizard Steps
@@ -733,16 +734,21 @@ type ImportMode = null | 'psd2' | 'bank' | 'sie'
export default function ImportPage() {
const [mode, setMode] = useState<ImportMode>(null)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
// If extension isn't compiled in, we know synchronously it's unavailable
const bankingCompiledIn = ENABLED_EXTENSION_IDS.has('enable-banking')
const [hasBankingExtension, setHasBankingExtension] = useState<boolean | null>(
bankingCompiledIn ? null : false
)
useEffect(() => {
if (!bankingCompiledIn) return
fetch('/api/extensions/toggles/general/enable-banking')
.then(res => res.ok ? res.json() : null)
.then(json => {
if (json?.data?.enabled) setHasBankingExtension(true)
setHasBankingExtension(json?.data?.enabled || false)
})
.catch(() => {})
}, [])
.catch(() => setHasBankingExtension(false))
}, [bankingCompiledIn])
return (
<div className="space-y-6">
@@ -755,11 +761,26 @@ export default function ImportPage() {
</div>
{mode === null && (
<div className={`grid gap-4 ${hasBankingExtension ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
{hasBankingExtension && (
<div className={`grid gap-4 ${hasBankingExtension !== false ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
{hasBankingExtension === null && (
<Card className="animate-pulse">
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted" />
<div className="space-y-2 w-full flex flex-col items-center">
<div className="h-5 bg-muted rounded w-24" />
<div className="h-4 bg-muted rounded w-48" />
<div className="h-4 bg-muted rounded w-40" />
</div>
</CardContent>
</Card>
)}
{hasBankingExtension === true && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors border-primary/20 relative"
role="button"
tabIndex={0}
className="cursor-pointer hover:border-primary/50 active:scale-[0.98] transition-all border-primary/20 relative"
onClick={() => setMode('psd2')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('psd2') } }}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
@@ -779,8 +800,11 @@ export default function ImportPage() {
)}
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
role="button"
tabIndex={0}
className="cursor-pointer hover:border-primary/50 active:scale-[0.98] transition-all"
onClick={() => setMode('bank')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('bank') } }}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
@@ -799,8 +823,11 @@ export default function ImportPage() {
</Card>
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
role="button"
tabIndex={0}
className="cursor-pointer hover:border-primary/50 active:scale-[0.98] transition-all"
onClick={() => setMode('sie')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('sie') } }}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
+61 -144
View File
@@ -14,44 +14,70 @@ export default async function DashboardPage() {
redirect('/login')
}
// Fetch profile for name
const { data: profile } = await supabase
.from('profiles')
.select('full_name')
.eq('id', user.id)
.single()
// Fetch current year date boundaries
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
const now = new Date()
const today = now.toISOString().split('T')[0]
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
// Source types that require supporting documents
const needsDocSourceTypes = [
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import',
]
// Fetch all data in parallel
const [
{ data: profile },
{ data: settings },
{ count: customerCount },
{ count: invoiceCount },
{ count: receiptCount },
{ count: transactionCount },
{ data: journalLines },
{ data: transactions },
{ data: unpaidInvoices },
{ data: bankConnections },
{ data: deadlines },
{ count: pendingReviewCount },
{ count: unmatchedReceiptsCount },
{ count: unmatchedTransactionsCount },
{ count: postedEntriesCount },
{ data: entriesWithDocs },
{ data: recentReceiptActivity },
{ data: enabledToggles },
] = await Promise.all([
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase.from('company_settings').select('*').eq('user_id', user.id).single(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status)')
.eq('journal_entry.status', 'posted')
.gte('journal_entry.entry_date', startOfYearStr),
supabase.from('transactions').select('amount, amount_sek, is_business').eq('user_id', user.id).gte('date', startOfYearStr),
supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('user_id', user.id).in('status', ['sent', 'overdue']),
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('user_id', user.id).eq('status', 'active'),
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('user_id', user.id).eq('is_completed', false)
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'extracted'),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'confirmed').is('matched_transaction_id', null),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).lt('amount', 0).is('receipt_id', null),
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'posted').in('source_type', needsDocSourceTypes),
supabase.from('document_attachments').select('journal_entry_id').eq('user_id', user.id).eq('is_current_version', true).not('journal_entry_id', 'is', null),
supabase.from('receipts').select('created_at').eq('user_id', user.id).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
supabase.from('extension_toggles').select('sector_slug, extension_slug').eq('user_id', user.id).eq('enabled', true),
])
const firstName = profile?.full_name?.split(' ')[0] || null
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
// ── EF / AB dashboard ──────────────────────────────────────────────
// Fetch onboarding progress for new user checklist
const { count: customerCount } = await supabase
.from('customers')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const { count: invoiceCount } = await supabase
.from('invoices')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const { count: receiptCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const { count: transactionCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const onboardingProgress: OnboardingProgress = {
hasCustomers: (customerCount || 0) > 0,
hasInvoices: (invoiceCount || 0) > 0,
@@ -59,17 +85,6 @@ export default async function DashboardPage() {
hasBankConnected: (transactionCount || 0) > 0,
}
// Fetch current year date boundaries
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
// Fetch posted journal entry lines for revenue/expense accounts (classes 3-7)
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status)')
.eq('journal_entry.status', 'posted')
.gte('journal_entry.entry_date', startOfYearStr)
// Calculate totals from journal entry lines using account classes
const calculateTotals = (lines: typeof journalLines, fromDate: string) => {
const filtered = (lines || []).filter((l) => {
@@ -100,13 +115,6 @@ export default async function DashboardPage() {
const ytdTotals = calculateTotals(journalLines, startOfYearStr)
const mtdTotals = calculateTotals(journalLines, startOfMonthStr)
// Fetch uncategorized transaction counts (still useful for the alert)
const { data: transactions } = await supabase
.from('transactions')
.select('amount, amount_sek, is_business')
.eq('user_id', user.id)
.gte('date', startOfYearStr)
const uncategorizedTxns = (transactions || []).filter(
(t) => t.is_business === null
)
@@ -118,19 +126,11 @@ export default async function DashboardPage() {
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(Number(t.amount_sek || t.amount)), 0)
// Fetch unpaid invoices with VAT amounts
const { data: unpaidInvoices } = await supabase
.from('invoices')
.select('total, total_sek, vat_amount, vat_amount_sek, status')
.eq('user_id', user.id)
.in('status', ['sent', 'overdue'])
const unpaidTotal = (unpaidInvoices || []).reduce(
(sum, inv) => sum + Number(inv.total_sek || inv.total),
0
)
// Calculate VAT from unpaid invoices (this is VAT we've invoiced but not yet received)
const unpaidVatTotal = (unpaidInvoices || []).reduce(
(sum, inv) => sum + Number(inv.vat_amount_sek || inv.vat_amount || 0),
0
@@ -140,13 +140,6 @@ export default async function DashboardPage() {
(inv) => inv.status === 'overdue'
).length
// Fetch bank balance and consent info (if connected)
const { data: bankConnections } = await supabase
.from('bank_connections')
.select('id, accounts_data, status, consent_expires, bank_name')
.eq('user_id', user.id)
.eq('status', 'active')
let bankBalance: number | null = null
if (bankConnections && bankConnections.length > 0) {
const allBalances = bankConnections.flatMap(conn => {
@@ -158,7 +151,6 @@ export default async function DashboardPage() {
}
}
// Compute expiring bank connections (consent expires within 14 days)
const nowMs = new Date().getTime()
const expiringBankConnections = (bankConnections || [])
.filter(conn => {
@@ -176,80 +168,12 @@ export default async function DashboardPage() {
),
}))
// Fetch upcoming deadlines (next 7 days + overdue)
const now = new Date()
const today = now.toISOString().split('T')[0]
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const { data: deadlines } = await supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('user_id', user.id)
.eq('is_completed', false)
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`)
.order('due_date', { ascending: true })
// Fetch receipt queue summary
const { count: pendingReviewCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'extracted')
const { count: unmatchedReceiptsCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'confirmed')
.is('matched_transaction_id', null)
const { count: unmatchedTransactionsCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.lt('amount', 0)
.is('receipt_id', null)
// Count journal entries missing underlag (documents)
// Source types that require supporting documents
const needsDocSourceTypes = [
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import',
]
const { count: postedEntriesCount } = await supabase
.from('journal_entries')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'posted')
.in('source_type', needsDocSourceTypes)
const { data: entriesWithDocs } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('user_id', user.id)
.eq('is_current_version', true)
.not('journal_entry_id', 'is', null)
const uniqueEntriesWithDocs = new Set(
(entriesWithDocs || []).map((d) => d.journal_entry_id)
).size
const missingUnderlagCount = Math.max(0, (postedEntriesCount || 0) - uniqueEntriesWithDocs)
// Calculate receipt streak
const { data: recentReceiptActivity } = await supabase
.from('receipts')
.select('created_at')
.eq('user_id', user.id)
.eq('status', 'confirmed')
.order('created_at', { ascending: false })
.limit(30)
let streakCount = 0
if (recentReceiptActivity && recentReceiptActivity.length > 0) {
const todayDate = new Date()
@@ -273,13 +197,6 @@ export default async function DashboardPage() {
streak_count: streakCount,
}
// Fetch enabled extension toggles
const { data: enabledToggles } = await supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true)
return (
<DashboardContent
firstName={firstName}
+15 -13
View File
@@ -35,8 +35,10 @@ import type { CompanySettings } from '@/types'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const BankingPanel = getSettingsPanel('enable-banking')
const bankingCompiledIn = ENABLED_EXTENSION_IDS.has('enable-banking')
export default function SettingsPage() {
const router = useRouter()
@@ -47,7 +49,9 @@ export default function SettingsPage() {
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
const [hasBankingExtension, setHasBankingExtension] = useState<boolean | null>(
bankingCompiledIn ? null : false
)
const [hasCalendarExtension, setHasCalendarExtension] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState('')
@@ -70,21 +74,15 @@ export default function SettingsPage() {
return
}
// Fetch settings
const { data: settingsData } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
setSettings(settingsData)
// Check extension toggles in parallel
const [bankingToggleRes, calendarToggleRes] = await Promise.all([
// Fetch settings and extension toggles in parallel
const [settingsRes, bankingToggleRes, calendarToggleRes] = await Promise.all([
supabase.from('company_settings').select('*').eq('user_id', user.id).single(),
fetch('/api/extensions/toggles/general/enable-banking').catch(() => null),
fetch('/api/extensions/toggles/general/calendar').catch(() => null),
])
setSettings(settingsRes.data)
if (bankingToggleRes?.ok) {
const { data } = await bankingToggleRes.json()
setHasBankingExtension(data?.enabled || false)
@@ -541,7 +539,11 @@ export default function SettingsPage() {
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
{!settings?.is_sandbox && (
<TabsContent value="banking" className="space-y-6">
{hasBankingExtension && BankingPanel ? (
{hasBankingExtension === null ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : hasBankingExtension && BankingPanel ? (
<BankingPanel />
) : (
<Card>
+44 -44
View File
@@ -110,40 +110,36 @@ export default function TransactionsPage() {
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (potentialInvoiceIds.length > 0) {
const { data: invoices } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.in('id', potentialInvoiceIds)
if (invoices) {
invoiceMap = invoices.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
}
// Fetch matched inbox items for unbooked transactions
const unbookedTxIds = (txData || [])
.filter((t) => !t.journal_entry_id && t.is_business === null)
.map((t) => t.id)
// Fetch invoices and inbox items in parallel
const [invoiceResult, inboxResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
unbookedTxIds.length > 0
? supabase.from('invoice_inbox_items').select('*').in('matched_transaction_id', unbookedTxIds).in('status', ['ready', 'processing'])
: Promise.resolve({ data: null }),
])
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (invoiceResult.data) {
invoiceMap = invoiceResult.data.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
if (unbookedTxIds.length > 0) {
const { data: inboxItems } = await supabase
.from('invoice_inbox_items')
.select('*')
.in('matched_transaction_id', unbookedTxIds)
.in('status', ['ready', 'processing'])
if (inboxItems) {
inboxItemMap = inboxItems.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
if (inboxResult.data) {
inboxItemMap = inboxResult.data.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
@@ -178,24 +174,27 @@ export default function TransactionsPage() {
setIsLoadingSuggestions(false)
}
// Fetch transactions on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchTransactions() }, [])
// Fetch entity type for tooltip context
// Fetch transactions and entity type in parallel on mount, then suggestions
useEffect(() => {
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const data = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Non-critical, defaults to enskild_firma
let cancelled = false
async function loadAll() {
// Fetch transactions and entity type in parallel
const [, entityRes] = await Promise.all([
fetchTransactions(),
fetch('/api/settings').then(r => r.json()).catch(() => null),
])
if (cancelled) return
if (entityRes?.entity_type) {
setEntityType(entityRes.entity_type)
}
}
fetchEntityType()
loadAll()
return () => { cancelled = true }
}, [])
// Auto-fetch suggestions when transactions load
@@ -207,6 +206,7 @@ export default function TransactionsPage() {
if (uncatIds.length > 0) {
fetchCategorySuggestions(uncatIds)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactions.length])
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
+52 -52
View File
@@ -25,14 +25,14 @@ export default function DPAPage() {
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Detta personuppgiftsbitradesavtal (&quot;DPA&quot;) ingar mellan:
Detta personuppgiftsbitradesavtal (&quot;DPA&quot;) ingår mellan:
</p>
<ul>
<li><strong>Personuppgiftsansvarig (&quot;den Ansvarige&quot;):</strong> Du som anvandare av Gnubok,
i egenskap av ansvarig for de personuppgifter du registrerar i tjansten
(kunder, leverantorer, anstallda m.fl.).</li>
<li><strong>Personuppgiftsbitrade (&quot;Bitradet&quot;):</strong> Arcim, som tillhandahaller
Gnubok-tjansten och behandlar personuppgifter pa dina vagar.</li>
<li><strong>Personuppgiftsansvarig (&quot;den Ansvarige&quot;):</strong> Du som användare av Gnubok,
i egenskap av ansvarig för de personuppgifter du registrerar i tjänsten
(kunder, leverantörer, anställda m.fl.).</li>
<li><strong>Personuppgiftsbiträde (&quot;Biträdet&quot;):</strong> Arcim, som tillhandahåller
Gnubok-tjänsten och behandlar personuppgifter 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>
+50 -50
View File
@@ -24,9 +24,9 @@ export default function PrivacyPolicyPage() {
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Arcim (&quot;vi&quot;, &quot;oss&quot;) ar personuppgiftsansvarig for behandlingen av dina
personuppgifter i samband med anvandningen av Gnubok. Vi behandlar dina uppgifter i
enlighet med EU:s dataskyddsforordning (GDPR) och svensk dataskyddslagstiftning.
Arcim (&quot;vi&quot;, &quot;oss&quot;) är personuppgiftsansvarig för behandlingen av dina
personuppgifter i samband med användningen av Gnubok. Vi behandlar dina uppgifter i
enlighet med EU:s dataskyddsförordning (GDPR) och svensk dataskyddslagstiftning.
</p>
</CardContent>
</Card>
@@ -36,39 +36,39 @@ export default function PrivacyPolicyPage() {
<CardTitle>2. Vilka uppgifter vi behandlar</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>Vi behandlar foljande kategorier av personuppgifter:</p>
<p>Vi behandlar följande kategorier av personuppgifter:</p>
<ul>
<li><strong>Kontouppgifter:</strong> E-postadress (for inloggning via magic link)</li>
<li><strong>Foretagsuppgifter:</strong> Foretagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Bokforingsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Kontouppgifter:</strong> E-postadress (för inloggning via magic link)</li>
<li><strong>Företagsuppgifter:</strong> Företagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Bokföringsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokforingsunderlag</li>
<li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, anvandningsstatistik</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokföringsunderlag</li>
<li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, användningsstatistik</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>3. Rattslig grund (GDPR Art. 6)</CardTitle>
<CardTitle>3. Rättslig grund (GDPR Art. 6)</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<ul>
<li>
<strong>Avtal (Art. 6.1b):</strong> Behandling som ar nodvandig for att fullgora vara
tjanster enligt anvandaravtalet.
<strong>Avtal (Art. 6.1b):</strong> Behandling som är nödvändig för att fullgöra våra
tjänster enligt användaravtalet.
</li>
<li>
<strong>Rattslig forpliktelse (Art. 6.1c):</strong> Bokforingslagens (BFL) krav pa
7 ars arkivering av raknenskapsmaterial.
<strong>Rättslig förpliktelse (Art. 6.1c):</strong> Bokföringslagens (BFL) krav 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>