diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 03fc448f..5dde7586 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' -import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight } from 'lucide-react' +import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, Scale } from 'lucide-react' import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' @@ -22,6 +22,14 @@ import BankFileColumnMappingStep from '@/components/import/BankFileColumnMapping import BankFileConfirmStep from '@/components/import/BankFileConfirmStep' import BankFileResultStep from '@/components/import/BankFileResultStep' +// Opening balance import components +import OpeningBalanceUploadStep from '@/components/import/OpeningBalanceUploadStep' +import OpeningBalanceColumnMappingStep from '@/components/import/OpeningBalanceColumnMappingStep' +import OpeningBalanceEditStep from '@/components/import/OpeningBalanceEditStep' +import OpeningBalancePeriodStep from '@/components/import/OpeningBalancePeriodStep' +import OpeningBalanceResultStep from '@/components/import/OpeningBalanceResultStep' +import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types' + // SIE import components import SIEUploadStep from '@/components/import/SIEUploadStep' import SIEPreviewStep from '@/components/import/SIEPreviewStep' @@ -656,6 +664,260 @@ function SIEImportWizard() { ) } +// ============================================================ +// Opening Balance Import Wizard +// ============================================================ + +type OpeningBalanceStep = 'upload' | 'column_mapping' | 'edit' | 'period' | 'result' + +const OB_STEP_LABELS: Record = { + upload: 'Ladda upp', + column_mapping: 'Kolumnmappning', + edit: 'Granska', + period: 'Period', + result: 'Resultat', +} + +function OpeningBalanceImportWizard() { + const { toast } = useToast() + + const [obStep, setObStep] = useState('upload') + const [obIsLoading, setObIsLoading] = useState(false) + const [obError, setObError] = useState(null) + const [obFile, setObFile] = useState(null) + const [parseResult, setParseResult] = useState(null) + const [editedRows, setEditedRows] = useState<{ + id: string; account_number: string; account_name: string + debit_amount: number; credit_amount: number + }[]>([]) + const [executeResult, setExecuteResult] = useState(null) + + // Determine steps — skip column mapping if confidence >= 0.8 + const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8 + const steps: OpeningBalanceStep[] = needsMapping + ? ['upload', 'column_mapping', 'edit', 'period', 'result'] + : ['upload', 'edit', 'period', 'result'] + const currentStepIndex = steps.indexOf(obStep) + const progress = ((currentStepIndex + 1) / steps.length) * 100 + + const handleFileSelect = useCallback(async (file: File) => { + setObError(null) + setObIsLoading(true) + setObFile(file) + + try { + const formData = new FormData() + formData.append('file', file) + + const res = await fetch('/api/import/opening-balance/parse', { + method: 'POST', + body: formData, + }) + + const data = await res.json() + + if (!res.ok) { + setObError(data.error || 'Kunde inte läsa filen') + return + } + + const result: OpeningBalanceParseResult = data.data + setParseResult(result) + + if (result.rows.length === 0) { + setObError('Inga konton med belopp hittades i filen. Kontrollera att filen innehåller kontonummer och belopp.') + return + } + + toast({ + title: 'Fil analyserad', + description: `${result.rows.length} konton hittades`, + }) + + // Skip column mapping if confidence >= 0.8 + if (result.detected_columns.confidence < 0.8) { + setObStep('column_mapping') + } else { + setObStep('edit') + } + } catch (err) { + setObError(err instanceof Error ? err.message : 'Kunde inte läsa filen') + } finally { + setObIsLoading(false) + } + }, [toast]) + + const handleColumnMappingConfirm = useCallback(async (columns: DetectedColumns) => { + if (!obFile) return + + setObIsLoading(true) + setObError(null) + + try { + const formData = new FormData() + formData.append('file', obFile) + formData.append('column_overrides', JSON.stringify(columns)) + + const res = await fetch('/api/import/opening-balance/parse', { + method: 'POST', + body: formData, + }) + + const data = await res.json() + + if (!res.ok) { + setObError(data.error || 'Kunde inte läsa filen med de valda kolumnerna') + return + } + + setParseResult(data.data) + setObStep('edit') + } catch (err) { + setObError(err instanceof Error ? err.message : 'Kunde inte läsa filen') + } finally { + setObIsLoading(false) + } + }, [obFile]) + + const handleEditContinue = useCallback((rows: typeof editedRows) => { + setEditedRows(rows) + setObStep('period') + }, []) + + const handleExecute = useCallback(async (fiscalPeriodId: string) => { + setObIsLoading(true) + setObError(null) + + try { + const res = await fetch('/api/import/opening-balance/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fiscal_period_id: fiscalPeriodId, + lines: editedRows.map((r) => ({ + account_number: r.account_number, + debit_amount: r.debit_amount, + credit_amount: r.credit_amount, + })), + }), + }) + + const data = await res.json() + + if (!res.ok) { + if (res.status === 409) { + setObError(data.error || 'Perioden har redan ingående balanser') + } else { + setObError(data.error || 'Importen misslyckades') + } + return + } + + setExecuteResult(data.data) + setObStep('result') + + if (data.data.success) { + toast({ + title: 'Ingående balanser bokförda', + description: `${data.data.lines_created} kontorader skapades`, + }) + } + } catch (err) { + setObError(err instanceof Error ? err.message : 'Importen misslyckades') + } finally { + setObIsLoading(false) + } + }, [editedRows, toast]) + + const handleNewImport = () => { + setObStep('upload') + setObFile(null) + setParseResult(null) + setEditedRows([]) + setExecuteResult(null) + setObError(null) + } + + return ( +
+ {/* Progress */} + + +
+
+ + Steg {currentStepIndex + 1}/{steps.length}: {OB_STEP_LABELS[obStep]} + + {steps.map((s, i) => ( + + {OB_STEP_LABELS[s]} + + ))} +
+ +
+
+
+ + {/* Step content */} + {obStep === 'upload' && ( + + )} + + {obStep === 'column_mapping' && parseResult && ( + setObStep('upload')} + /> + )} + + {obStep === 'edit' && parseResult && ( + { + if (needsMapping) { + setObStep('column_mapping') + } else { + setObStep('upload') + } + }} + /> + )} + + {obStep === 'period' && ( + setObStep('edit')} + isLoading={obIsLoading} + error={obError} + /> + )} + + {obStep === 'result' && executeResult && ( + + )} +
+ ) +} + // ============================================================ // PSD2 Bank Connection (inline, from Enable Banking extension) // ============================================================ @@ -848,7 +1110,7 @@ function PSD2ConnectWizard() { // Import Page with Selection Cards // ============================================================ -type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration' +type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'opening_balance' | 'migration' export default function ImportPage() { const { company } = useCompany() @@ -882,7 +1144,7 @@ export default function ImportPage() { setMode('migration') } else { const modeParam = searchParams.get('mode') - if (modeParam && ['psd2', 'bank', 'sie', 'migration'].includes(modeParam)) { + if (modeParam && ['psd2', 'bank', 'sie', 'opening_balance', 'migration'].includes(modeParam)) { setMode(modeParam as ImportMode) } } @@ -1023,7 +1285,40 @@ export default function ImportPage() { - {/* 4. Bokföringsdata (SIE) */} + {/* 4. Ingående balanser */} +
{ if (!isSandbox) setMode('opening_balance') }} + onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('opening_balance') } }} + > +
+ +
+
+

Ingående balanser

+

+ Importera ingående balanser från en Excel- eller CSV-fil. +

+
+ {['XLSX', 'CSV'].map(fmt => ( + + {fmt} + + ))} +
+
+ +
+ + {/* 5. Bokföringsdata (SIE) */}
} {mode === 'bank' && } {mode === 'sie' && } + {mode === 'opening_balance' && } {mode === 'migration' && }
) diff --git a/app/api/import/opening-balance/__tests__/execute.test.ts b/app/api/import/opening-balance/__tests__/execute.test.ts new file mode 100644 index 00000000..31fcbf0d --- /dev/null +++ b/app/api/import/opening-balance/__tests__/execute.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const mockCreateJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), +})) + +vi.mock('@/lib/bookkeeping/bas-reference', () => ({ + getBASReference: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/supabase/fetch-all', () => ({ + fetchAllRows: vi.fn().mockResolvedValue([ + { account_number: '1930' }, + { account_number: '2099' }, + { account_number: '2440' }, + { account_number: '1510' }, + { account_number: '3001' }, + ]), +})) + +import { POST } from '../execute/route' + +const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000' + +function makeRequest(body: unknown) { + return createMockRequest('/api/import/opening-balance/execute', { + method: 'POST', + body, + }) +} + +describe('POST /api/import/opening-balance/execute', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 for unauthenticated requests', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const res = await POST(makeRequest({ + fiscal_period_id: PERIOD_ID, + lines: [ + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 50000 }, + ], + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(401) + expect(body.error).toBe('Unauthorized') + }) + + it('returns 400 for invalid body', async () => { + const res = await POST(makeRequest({ + fiscal_period_id: 'not-a-uuid', + lines: [], + })) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(400) + }) + + it('returns 404 for non-existent fiscal period', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const res = await POST(makeRequest({ + fiscal_period_id: PERIOD_ID, + lines: [ + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 50000 }, + ], + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(404) + expect(body.error).toContain('hittades inte') + }) + + it('returns 409 if period already has opening balances', async () => { + enqueue({ + data: { + id: PERIOD_ID, + company_id: 'company-1', + is_closed: false, + locked_at: null, + opening_balances_set: true, + opening_balance_entry_id: 'entry-existing', + period_start: '2026-01-01', + }, + }) + + const res = await POST(makeRequest({ + fiscal_period_id: PERIOD_ID, + lines: [ + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 50000 }, + ], + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(409) + expect(body.existing_entry_id).toBe('entry-existing') + }) + + it('returns 400 for unbalanced lines', async () => { + enqueue({ + data: { + id: PERIOD_ID, + company_id: 'company-1', + is_closed: false, + locked_at: null, + opening_balances_set: false, + period_start: '2026-01-01', + }, + }) + + const res = await POST(makeRequest({ + fiscal_period_id: PERIOD_ID, + lines: [ + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 40000 }, + ], + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(400) + expect(body.error).toContain('balanserar inte') + }) + + it('returns 400 for P&L accounts', async () => { + enqueue({ + data: { + id: PERIOD_ID, + company_id: 'company-1', + is_closed: false, + locked_at: null, + opening_balances_set: false, + period_start: '2026-01-01', + }, + }) + + const res = await POST(makeRequest({ + fiscal_period_id: PERIOD_ID, + lines: [ + { account_number: '3001', debit_amount: 0, credit_amount: 50000 }, + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + ], + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(400) + expect(body.error).toContain('Resultatkonton') + }) + + it('creates journal entry on success', async () => { + // Fiscal period query + enqueue({ + data: { + id: PERIOD_ID, + company_id: 'company-1', + is_closed: false, + locked_at: null, + opening_balances_set: false, + period_start: '2026-01-01', + }, + }) + + // Fiscal period update + enqueue({ data: null }) + + mockCreateJournalEntry.mockResolvedValue({ + id: 'entry-new', + company_id: 'company-1', + user_id: 'user-1', + fiscal_period_id: PERIOD_ID, + entry_date: '2026-01-01', + description: 'Ingående balanser (Excel-import)', + source_type: 'opening_balance', + voucher_series: 'A', + voucher_number: 1, + source_id: null, + reverses_id: null, + reversed_by_id: null, + correction_of_id: null, + attachment_urls: null, + notes: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }) + + const res = await POST(makeRequest({ + fiscal_period_id: PERIOD_ID, + lines: [ + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 50000 }, + ], + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + expect(body.data.journal_entry_id).toBe('entry-new') + expect(body.data.lines_created).toBe(2) + expect(body.data.total_debit).toBe(50000) + expect(body.data.total_credit).toBe(50000) + + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + fiscal_period_id: PERIOD_ID, + entry_date: '2026-01-01', + source_type: 'opening_balance', + voucher_series: 'A', + }), + ) + }) +}) diff --git a/app/api/import/opening-balance/execute/route.ts b/app/api/import/opening-balance/execute/route.ts new file mode 100644 index 00000000..5a79aff7 --- /dev/null +++ b/app/api/import/opening-balance/execute/route.ts @@ -0,0 +1,250 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas' +import { requireWritePermission } from '@/lib/auth/require-write' +import { requireCompanyId } from '@/lib/company/context' +import { createJournalEntry } from '@/lib/bookkeeping/engine' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import type { CreateJournalEntryLineInput } from '@/types' + +ensureInitialized() + +/** + * POST /api/import/opening-balance/execute + * + * Creates an opening balance journal entry from user-confirmed lines. + * Auto-activates BAS accounts not yet in the company's chart. + */ +export async function POST(request: Request) { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const result = await validateBody(request, OpeningBalanceExecuteSchema) + if (!result.success) return result.response + + const { fiscal_period_id, lines } = result.data + + let companyId: string + try { + companyId = await requireCompanyId(supabase, user.id) + } catch { + return NextResponse.json({ error: 'Inget aktivt företag' }, { status: 400 }) + } + + try { + // 1. Verify fiscal period exists, belongs to company, and is not closed/locked + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', fiscal_period_id) + .eq('company_id', companyId) + .single() + + if (periodError || !period) { + return NextResponse.json( + { error: 'Räkenskapsperioden hittades inte' }, + { status: 404 }, + ) + } + + if (period.is_closed) { + return NextResponse.json( + { error: 'Räkenskapsperioden är stängd' }, + { status: 400 }, + ) + } + + if (period.locked_at) { + return NextResponse.json( + { error: 'Räkenskapsperioden är låst' }, + { status: 400 }, + ) + } + + // 2. Check if period already has opening balances + if (period.opening_balances_set) { + return NextResponse.json( + { + error: 'Räkenskapsperioden har redan ingående balanser', + existing_entry_id: period.opening_balance_entry_id, + }, + { status: 409 }, + ) + } + + // 3. Filter out zero-amount lines and validate no P&L accounts + const validLines = lines.filter((l) => l.debit_amount > 0 || l.credit_amount > 0) + + if (validLines.length < 2) { + return NextResponse.json( + { error: 'Minst två rader med belopp krävs' }, + { status: 400 }, + ) + } + + // Reject class 3-8 accounts + const pnlAccounts = validLines + .map((l) => l.account_number) + .filter((num) => { + const cls = parseInt(num.charAt(0), 10) + return cls >= 3 && cls <= 8 + }) + + if (pnlAccounts.length > 0) { + return NextResponse.json( + { + error: `Resultatkonton (klass 3-8) kan inte användas i ingående balanser: ${pnlAccounts.slice(0, 5).join(', ')}`, + }, + { status: 400 }, + ) + } + + // 4. Verify balance + let totalDebit = 0 + let totalCredit = 0 + for (const line of validLines) { + totalDebit = Math.round((totalDebit + line.debit_amount) * 100) / 100 + totalCredit = Math.round((totalCredit + line.credit_amount) * 100) / 100 + } + + const diff = Math.round((totalDebit - totalCredit) * 100) / 100 + if (Math.abs(diff) >= 0.01) { + return NextResponse.json( + { error: `Debet och kredit balanserar inte — differens: ${diff.toFixed(2)} SEK` }, + { status: 400 }, + ) + } + + // 5. Auto-activate BAS accounts not in company's chart + const accountNumbers = [...new Set(validLines.map((l) => l.account_number))] + + const existingAccounts = await fetchAllRows(({ from, to }) => + supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId) + .range(from, to), + ) + + const existingNumbers = new Set(existingAccounts.map((a) => a.account_number)) + const accountsToActivate = accountNumbers + .filter((num) => !existingNumbers.has(num)) + .map((num) => { + const ref = getBASReference(num) + + if (ref) { + return { + user_id: user.id, + company_id: companyId, + account_number: ref.account_number, + account_name: ref.account_name, + account_class: ref.account_class, + account_group: ref.account_group, + account_type: ref.account_type, + normal_balance: ref.normal_balance, + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: ref.description, + sru_code: ref.sru_code, + sort_order: parseInt(ref.account_number), + } + } + + // Derive metadata from account number + const accountClass = parseInt(num.charAt(0), 10) + const accountGroup = num.substring(0, 2) + const accountType = + accountClass === 1 ? 'asset' + : accountClass === 2 ? 'liability' + : accountClass === 3 ? 'revenue' + : 'expense' + const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' + + return { + user_id: user.id, + company_id: companyId, + account_number: num, + account_name: `Konto ${num}`, + account_class: accountClass, + account_group: accountGroup, + account_type: accountType, + normal_balance: normalBalance, + plan_type: 'full_bas' as const, + is_active: true, + is_system_account: false, + description: `Konto ${num}`, + sru_code: null, + sort_order: parseInt(num), + } + }) + + if (accountsToActivate.length > 0) { + const { error: activateError } = await supabase + .from('chart_of_accounts') + .insert(accountsToActivate) + + if (activateError) { + console.error('Failed to activate accounts:', activateError) + return NextResponse.json( + { error: 'Kunde inte aktivera konton i kontoplanen' }, + { status: 500 }, + ) + } + } + + // 6. Create journal entry via engine + const entryLines: CreateJournalEntryLineInput[] = validLines.map((line) => ({ + account_number: line.account_number, + debit_amount: line.debit_amount, + credit_amount: line.credit_amount, + line_description: `IB ${line.account_number}`, + })) + + const entry = await createJournalEntry(supabase, companyId, user.id, { + fiscal_period_id, + entry_date: period.period_start, + description: 'Ingående balanser (Excel-import)', + source_type: 'opening_balance', + voucher_series: 'A', + lines: entryLines, + }) + + // 7. Update fiscal period + await supabase + .from('fiscal_periods') + .update({ + opening_balance_entry_id: entry.id, + opening_balances_set: true, + }) + .eq('id', fiscal_period_id) + .eq('company_id', companyId) + + return NextResponse.json({ + data: { + success: true, + journal_entry_id: entry.id, + fiscal_period_id, + lines_created: entryLines.length, + total_debit: totalDebit, + total_credit: totalCredit, + }, + }) + } catch (error) { + console.error('Opening balance execute error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Importen misslyckades' }, + { status: 500 }, + ) + } +} diff --git a/app/api/import/opening-balance/parse/route.ts b/app/api/import/opening-balance/parse/route.ts new file mode 100644 index 00000000..6cd3fe8f --- /dev/null +++ b/app/api/import/opening-balance/parse/route.ts @@ -0,0 +1,74 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { parseOpeningBalanceFile } from '@/lib/import/opening-balance/parser' +import type { DetectedColumns } from '@/lib/import/opening-balance/types' + +const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods'] +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB + +/** + * POST /api/import/opening-balance/parse + * + * Accepts an Excel/CSV file via FormData, auto-detects columns, + * returns parsed opening balance rows with BAS matching. + */ +export async function POST(request: Request) { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get('file') as File | null + const columnOverridesRaw = formData.get('column_overrides') as string | null + + if (!file) { + return NextResponse.json({ error: 'Ingen fil bifogad' }, { status: 400 }) + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: 'Filen är för stor (max 10MB)' }, + { status: 400 }, + ) + } + + // Validate file extension + const ext = '.' + file.name.split('.').pop()?.toLowerCase() + if (!ALLOWED_EXTENSIONS.includes(ext)) { + return NextResponse.json( + { error: `Filformatet stöds inte. Tillåtna format: ${ALLOWED_EXTENSIONS.join(', ')}` }, + { status: 400 }, + ) + } + + try { + const buffer = await file.arrayBuffer() + + // Parse optional column overrides + let columnOverrides: DetectedColumns | undefined + if (columnOverridesRaw) { + try { + columnOverrides = JSON.parse(columnOverridesRaw) + } catch { + return NextResponse.json( + { error: 'Ogiltigt kolumnmappningsformat' }, + { status: 400 }, + ) + } + } + + const result = parseOpeningBalanceFile(buffer, file.name, columnOverrides) + + return NextResponse.json({ data: result }) + } catch (error) { + console.error('Opening balance parse error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Kunde inte tolka filen' }, + { status: 500 }, + ) + } +} diff --git a/components/import/OpeningBalanceColumnMappingStep.tsx b/components/import/OpeningBalanceColumnMappingStep.tsx new file mode 100644 index 00000000..f7ea85ad --- /dev/null +++ b/components/import/OpeningBalanceColumnMappingStep.tsx @@ -0,0 +1,237 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Label } from '@/components/ui/label' +import type { DetectedColumns, BalanceColumnLayout } from '@/lib/import/opening-balance/types' + +interface OpeningBalanceColumnMappingStepProps { + headers: string[] + previewRows: string[][] + detectedColumns: DetectedColumns + onConfirm: (columns: DetectedColumns) => void + onBack: () => void +} + +export default function OpeningBalanceColumnMappingStep({ + headers, + previewRows, + detectedColumns, + onConfirm, + onBack, +}: OpeningBalanceColumnMappingStepProps) { + const [accountNumberCol, setAccountNumberCol] = useState( + detectedColumns.account_number_col, + ) + const [accountNameCol, setAccountNameCol] = useState( + detectedColumns.account_name_col, + ) + const [layout, setLayout] = useState( + detectedColumns.layout, + ) + const [balanceCol, setBalanceCol] = useState( + detectedColumns.balance_col, + ) + const [debitCol, setDebitCol] = useState( + detectedColumns.debit_col, + ) + const [creditCol, setCreditCol] = useState( + detectedColumns.credit_col, + ) + + const columnOptions = headers.map((h, i) => ({ + value: String(i), + label: `${i + 1}: ${h || '(tom)'}`, + })) + + const canContinue = + accountNumberCol >= 0 && + (layout === 'net' ? balanceCol !== null : debitCol !== null && creditCol !== null) + + const handleConfirm = () => { + onConfirm({ + account_number_col: accountNumberCol, + account_name_col: accountNameCol, + layout, + balance_col: layout === 'net' ? balanceCol : null, + debit_col: layout === 'debit_credit' ? debitCol : null, + credit_col: layout === 'debit_credit' ? creditCol : null, + confidence: 1, // User-confirmed + }) + } + + return ( + + + Kolumnmappning + + Vi kunde inte automatiskt identifiera alla kolumner. Ange vilka kolumner + som innehåller kontonummer och belopp. + + + + {/* Layout toggle */} +
+ + +
+ + {/* Required mappings */} +
+
+ + +
+ +
+ + +
+ + {layout === 'net' && ( +
+ + +
+ )} + + {layout === 'debit_credit' && ( + <> +
+ + +
+ +
+ + +
+ + )} +
+ + {/* Preview */} + {previewRows.length > 0 && ( +
+ +
+ + + + {headers.map((h, i) => ( + + ))} + + + + {previewRows.slice(0, 5).map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {h || `Kolumn ${i + 1}`} +
+ {cell} +
+
+
+ )} + + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/components/import/OpeningBalanceEditStep.tsx b/components/import/OpeningBalanceEditStep.tsx new file mode 100644 index 00000000..fb992144 --- /dev/null +++ b/components/import/OpeningBalanceEditStep.tsx @@ -0,0 +1,400 @@ +'use client' + +import { useState, useMemo, useCallback, useRef } from 'react' +import Fuse from 'fuse.js' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react' +import { cn } from '@/lib/utils' +import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' +import type { ParsedOpeningBalanceRow } from '@/lib/import/opening-balance/types' + +interface EditableRow { + id: string + account_number: string + account_name: string + debit_amount: number + credit_amount: number + validation_errors: string[] + bas_match: string | null +} + +interface OpeningBalanceEditStepProps { + rows: ParsedOpeningBalanceRow[] + onContinue: (rows: EditableRow[]) => void + onBack: () => void +} + +// Filter BAS reference to balance sheet accounts only (class 1-2) for primary suggestions +const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter( + (a) => a.account_class === 1 || a.account_class === 2, +) + +const ALL_BAS_ACCOUNTS = BAS_REFERENCE + +let fuseInstance: Fuse | null = null +function getFuse() { + if (!fuseInstance) { + fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, { + keys: ['account_number', 'account_name'], + threshold: 0.3, + includeScore: true, + }) + } + return fuseInstance +} + +let balanceFuseInstance: Fuse | null = null +function getBalanceFuse() { + if (!balanceFuseInstance) { + balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, { + keys: ['account_number', 'account_name'], + threshold: 0.3, + includeScore: true, + }) + } + return balanceFuseInstance +} + +let idCounter = 0 +function generateId() { + return `row_${++idCounter}_${Date.now()}` +} + +export default function OpeningBalanceEditStep({ + rows: initialRows, + onContinue, + onBack, +}: OpeningBalanceEditStepProps) { + const [rows, setRows] = useState(() => + initialRows.map((r) => ({ + id: generateId(), + account_number: r.account_number, + account_name: r.account_name, + debit_amount: r.debit_amount, + credit_amount: r.credit_amount, + validation_errors: r.validation_errors, + bas_match: r.bas_match, + })), + ) + + const [activeAutocomplete, setActiveAutocomplete] = useState(null) + const [autocompleteQuery, setAutocompleteQuery] = useState('') + const autocompleteRef = useRef(null) + + // Compute totals + const totals = useMemo(() => { + let debit = 0 + let credit = 0 + for (const row of rows) { + debit = Math.round((debit + row.debit_amount) * 100) / 100 + credit = Math.round((credit + row.credit_amount) * 100) / 100 + } + const diff = Math.round((debit - credit) * 100) / 100 + return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 } + }, [rows]) + + // Validation + const hasErrors = useMemo(() => { + return rows.some((r) => { + if (!/^\d{4}$/.test(r.account_number)) return true + if (r.debit_amount === 0 && r.credit_amount === 0) return true + if (r.validation_errors.length > 0) return true + return false + }) + }, [rows]) + + const canContinue = totals.isBalanced && !hasErrors && rows.length >= 2 + + // Autocomplete results + const autocompleteResults = useMemo(() => { + if (!autocompleteQuery || autocompleteQuery.length < 1) return [] + // If the query is numeric, search all accounts; otherwise prefer balance sheet + const isNumeric = /^\d+$/.test(autocompleteQuery) + const fuse = isNumeric ? getFuse() : getBalanceFuse() + return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item) + }, [autocompleteQuery]) + + const updateRow = useCallback((id: string, updates: Partial) => { + setRows((prev) => + prev.map((r) => { + if (r.id !== id) return r + const updated = { ...r, ...updates } + + // Re-validate + const errors: string[] = [] + if (!/^\d{4}$/.test(updated.account_number)) { + errors.push('Ogiltigt kontonummer') + } + const cls = parseInt(updated.account_number.charAt(0), 10) + if (cls >= 3 && cls <= 8) { + errors.push(`Resultatkonto (klass ${cls})`) + } + updated.validation_errors = errors + + return updated + }), + ) + }, []) + + const deleteRow = useCallback((id: string) => { + setRows((prev) => prev.filter((r) => r.id !== id)) + }, []) + + const addRow = useCallback(() => { + setRows((prev) => [ + ...prev, + { + id: generateId(), + account_number: '', + account_name: '', + debit_amount: 0, + credit_amount: 0, + validation_errors: ['Ogiltigt kontonummer'], + bas_match: null, + }, + ]) + }, []) + + const selectAutocompleteItem = useCallback( + (rowId: string, account: (typeof BAS_REFERENCE)[0]) => { + updateRow(rowId, { + account_number: account.account_number, + account_name: account.account_name, + bas_match: account.account_name, + }) + setActiveAutocomplete(null) + setAutocompleteQuery('') + }, + [updateRow], + ) + + const handleAutoBalance = useCallback(() => { + if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK + if (totals.isBalanced) return + + const adjustmentRow: EditableRow = { + id: generateId(), + account_number: '2099', + account_name: 'Årets resultat', + debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff), + credit_amount: totals.diff > 0 ? totals.diff : 0, + validation_errors: [], + bas_match: 'Årets resultat', + } + setRows((prev) => [...prev, adjustmentRow]) + }, [totals]) + + return ( + + + Granska och redigera + + Kontrollera att kontonummer och belopp stämmer. Du kan lägga till, ta bort + och ändra rader. Debet och kredit måste balansera innan du kan fortsätta. + + + + {/* Table */} +
+ + + + + + + + + + + {rows.map((row) => ( + 0 && 'bg-destructive/5', + )} + > + + + + + + + ))} + + + + + + + + {!totals.isBalanced && ( + + + + + )} + +
KontoKontonamnDebetKredit +
+ { + const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4) + updateRow(row.id, { account_number: val }) + setActiveAutocomplete(row.id) + setAutocompleteQuery(val) + }} + onFocus={() => { + setActiveAutocomplete(row.id) + setAutocompleteQuery(row.account_number) + }} + onBlur={() => { + // Delay to allow click on autocomplete items + setTimeout(() => setActiveAutocomplete(null), 200) + }} + placeholder="1930" + className="h-8 font-mono tabular-nums w-20" + maxLength={4} + /> + {/* Autocomplete dropdown */} + {activeAutocomplete === row.id && + autocompleteResults.length > 0 && ( +
+ {autocompleteResults.map((item) => ( + + ))} +
+ )} +
+
+ + {row.account_name} + + {row.validation_errors.length > 0 && ( + + + + )} +
+
+ + updateRow(row.id, { + debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100, + }) + } + placeholder="0,00" + className="h-8 text-right tabular-nums w-28" + min={0} + step={0.01} + /> + + + updateRow(row.id, { + credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100, + }) + } + placeholder="0,00" + className="h-8 text-right tabular-nums w-28" + min={0} + step={0.01} + /> + + +
+ Summa + + {totals.debit.toLocaleString('sv-SE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + {totals.credit.toLocaleString('sv-SE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + +
+ Differens + + {totals.diff.toLocaleString('sv-SE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{' '} + SEK + +
+
+ + {/* Actions row */} +
+ + {!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && ( + + )} +
+ + {/* Warnings */} + {!totals.isBalanced && Math.abs(totals.diff) > 1 && ( +
+ +

+ Debet och kredit balanserar inte. Differens:{' '} + {totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK. + Kontrollera beloppen innan du fortsätter. +

+
+ )} + + {/* Navigation */} +
+ + +
+
+
+ ) +} diff --git a/components/import/OpeningBalancePeriodStep.tsx b/components/import/OpeningBalancePeriodStep.tsx new file mode 100644 index 00000000..b61e65d8 --- /dev/null +++ b/components/import/OpeningBalancePeriodStep.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Label } from '@/components/ui/label' +import { AlertCircle, Loader2, CheckCircle2 } from 'lucide-react' +import type { FiscalPeriod } from '@/types' + +interface EditableRow { + id: string + account_number: string + account_name: string + debit_amount: number + credit_amount: number +} + +interface OpeningBalancePeriodStepProps { + rows: EditableRow[] + onExecute: (fiscalPeriodId: string) => void + onBack: () => void + isLoading: boolean + error: string | null +} + +export default function OpeningBalancePeriodStep({ + rows, + onExecute, + onBack, + isLoading, + error, +}: OpeningBalancePeriodStepProps) { + const [periods, setPeriods] = useState([]) + const [selectedPeriodId, setSelectedPeriodId] = useState('') + const [loadingPeriods, setLoadingPeriods] = useState(true) + + // Compute totals + let totalDebit = 0 + let totalCredit = 0 + for (const row of rows) { + totalDebit = Math.round((totalDebit + row.debit_amount) * 100) / 100 + totalCredit = Math.round((totalCredit + row.credit_amount) * 100) / 100 + } + + useEffect(() => { + async function fetchPeriods() { + setLoadingPeriods(true) + try { + const res = await fetch('/api/bookkeeping/fiscal-periods') + if (res.ok) { + const data = await res.json() + const allPeriods: FiscalPeriod[] = data.data || [] + setPeriods(allPeriods) + + // Auto-select first open period without OB + const openPeriod = allPeriods.find( + (p) => !p.is_closed && !p.locked_at && !p.opening_balances_set, + ) + if (openPeriod) { + setSelectedPeriodId(openPeriod.id) + } + } + } catch { + // Silent — user can still select period + } finally { + setLoadingPeriods(false) + } + } + fetchPeriods() + }, []) + + const selectedPeriod = periods.find((p) => p.id === selectedPeriodId) + const periodHasOB = selectedPeriod?.opening_balances_set + const periodIsClosed = selectedPeriod?.is_closed + const periodIsLocked = !!selectedPeriod?.locked_at + + const canExecute = + selectedPeriodId && + !periodHasOB && + !periodIsClosed && + !periodIsLocked && + !isLoading + + const handleExecute = useCallback(() => { + if (canExecute) { + onExecute(selectedPeriodId) + } + }, [canExecute, selectedPeriodId, onExecute]) + + return ( + + + Välj räkenskapsperiod + + Välj vilken räkenskapsperiod de ingående balanserna ska bokföras på. + + + + {/* Period selector */} +
+ + {loadingPeriods ? ( +
+ + Hämtar perioder... +
+ ) : periods.length === 0 ? ( +
+ +

+ Inga räkenskapsperioder hittades. Skapa en räkenskapsperiod under Bokföring först. +

+
+ ) : ( + + )} +
+ + {/* Warnings */} + {periodHasOB && ( +
+ +

+ Denna period har redan ingående balanser. Ta bort den befintliga verifikationen + (via stornering) innan du importerar nya. +

+
+ )} + + {/* Summary */} +
+

Sammanfattning

+
+ Antal konton: + {rows.length} + Total debet: + + {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK + + Total kredit: + + {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK + +
+
+ + Balanserar +
+
+ + {/* Error */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/components/import/OpeningBalanceResultStep.tsx b/components/import/OpeningBalanceResultStep.tsx new file mode 100644 index 00000000..e609e684 --- /dev/null +++ b/components/import/OpeningBalanceResultStep.tsx @@ -0,0 +1,113 @@ +'use client' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { CheckCircle2, XCircle, ArrowRight } from 'lucide-react' +import Link from 'next/link' +import type { OpeningBalanceExecuteResult } from '@/lib/import/opening-balance/types' + +interface OpeningBalanceResultStepProps { + result: OpeningBalanceExecuteResult + onNewImport: () => void +} + +export default function OpeningBalanceResultStep({ + result, + onNewImport, +}: OpeningBalanceResultStepProps) { + return ( + + +
+ {result.success ? ( + + ) : ( + + )} + + {result.success + ? 'Ingående balanser bokförda' + : 'Importen misslyckades'} + +
+
+ + {result.success ? ( + <> + {/* Stats */} +
+
+

+ {result.lines_created} +

+

Kontorader

+
+
+

+ {result.total_debit.toLocaleString('sv-SE', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })} +

+

Debet (SEK)

+
+
+

+ {result.total_credit.toLocaleString('sv-SE', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })} +

+

Kredit (SEK)

+
+
+ + {/* Next steps */} +
+

Nästa steg

+
    +
  1. Granska verifikationen i bokföringen
  2. +
  3. Kontrollera balansrapporten att beloppen stämmer
  4. +
  5. Börja importera banktransaktioner eller skapa verifikationer
  6. +
+
+ + {/* Actions */} +
+ {result.journal_entry_id && ( + + )} + + +
+ + ) : ( + <> +
+

+ {result.error || 'Ett okänt fel uppstod vid importen.'} +

+
+ +
+ +
+ + )} +
+
+ ) +} diff --git a/components/import/OpeningBalanceUploadStep.tsx b/components/import/OpeningBalanceUploadStep.tsx new file mode 100644 index 00000000..f1dfec3a --- /dev/null +++ b/components/import/OpeningBalanceUploadStep.tsx @@ -0,0 +1,130 @@ +'use client' + +import { useCallback, useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Upload, FileSpreadsheet, AlertCircle, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface OpeningBalanceUploadStepProps { + onFileSelect: (file: File) => void + isLoading: boolean + error: string | null +} + +export default function OpeningBalanceUploadStep({ + onFileSelect, + isLoading, + error, +}: OpeningBalanceUploadStepProps) { + const [isDragging, setIsDragging] = useState(false) + + const ACCEPTED_TYPES = '.xlsx,.xls,.csv,.ods' + + const handleFile = useCallback((file: File) => { + const ext = file.name.split('.').pop()?.toLowerCase() + if (!ext || !['xlsx', 'xls', 'csv', 'ods'].includes(ext)) { + return + } + onFileSelect(file) + }, [onFileSelect]) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) handleFile(file) + }, [handleFile]) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback(() => { + setIsDragging(false) + }, []) + + return ( + + + Ladda upp fil med ingående balanser + + Ladda upp en Excel- eller CSV-fil med ditt företags ingående balanser. + Filen bör innehålla kontonummer och belopp (debet/kredit eller saldo). + + + + {/* Drop zone */} +
+ {isLoading ? ( +
+ +

Läser fil och identifierar kolumner...

+
+ ) : ( + <> + +

+ Dra och släpp din fil här +

+

+ eller +

+ +

+ XLSX, XLS, CSV, ODS — max 10 MB +

+ + )} +
+ + {/* Error */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Info */} +
+ +
+

Filformat

+

+ Filen bör ha en rubrikrad med kolumner för kontonummer och belopp. + Vanliga format stöds automatiskt — t.ex. kolumner som heter "Konto", + "Debet", "Kredit" eller "Saldo". +

+
+
+
+
+ ) +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 28ce082b..e3a78c79 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -599,3 +599,16 @@ export const SaveGapExplanationSchema = z.object({ gap_end: z.number().int().positive(), explanation: z.string().min(1).max(500), }) + +// ============================================================ +// Opening balance import schemas +// ============================================================ + +export const OpeningBalanceExecuteSchema = z.object({ + fiscal_period_id: uuid, + lines: z.array(z.object({ + account_number: accountNumber, + debit_amount: nonNegativeAmount, + credit_amount: nonNegativeAmount, + })).min(2, 'At least two lines are required for double-entry'), +}) diff --git a/lib/import/opening-balance/__tests__/column-detector.test.ts b/lib/import/opening-balance/__tests__/column-detector.test.ts new file mode 100644 index 00000000..1d81d2ef --- /dev/null +++ b/lib/import/opening-balance/__tests__/column-detector.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { detectColumns } from '../column-detector' + +describe('detectColumns', () => { + it('detects Swedish debit/credit headers', () => { + const headers = ['Kontonr', 'Kontonamn', 'Debet', 'Kredit'] + const dataRows = [ + ['1930', 'Företagskonto', '50000', '0'], + ['2440', 'Leverantörsskulder', '0', '15000'], + ] + + const result = detectColumns(headers, dataRows) + + expect(result.account_number_col).toBe(0) + expect(result.account_name_col).toBe(1) + expect(result.layout).toBe('debit_credit') + expect(result.debit_col).toBe(2) + expect(result.credit_col).toBe(3) + expect(result.confidence).toBeGreaterThanOrEqual(0.8) + }) + + it('detects net balance layout', () => { + const headers = ['Konto', 'Namn', 'Saldo'] + const dataRows = [ + ['1930', 'Företagskonto', '50000'], + ['2440', 'Leverantörsskulder', '-15000'], + ] + + const result = detectColumns(headers, dataRows) + + expect(result.account_number_col).toBe(0) + expect(result.layout).toBe('net') + expect(result.balance_col).toBe(2) + expect(result.confidence).toBeGreaterThanOrEqual(0.8) + }) + + it('detects English headers', () => { + const headers = ['Account Number', 'Account Name', 'Debit', 'Credit'] + const dataRows = [ + ['1930', 'Business account', '50000', '0'], + ] + + const result = detectColumns(headers, dataRows) + + expect(result.account_number_col).toBe(0) + expect(result.account_name_col).toBe(1) + expect(result.layout).toBe('debit_credit') + }) + + it('falls back to data analysis when no headers match', () => { + const headers = ['Col A', 'Col B', 'Col C'] + const dataRows = [ + ['1930', 'Something', '50000'], + ['2440', 'Other', '15000'], + ['1510', 'Third', '8000'], + ] + + const result = detectColumns(headers, dataRows) + + // Should detect column 0 as account numbers (4-digit pattern) + expect(result.account_number_col).toBe(0) + // Column 2 should be detected as numeric + expect(result.balance_col).not.toBeNull() + }) + + it('returns low confidence when nothing is detected', () => { + const headers = ['X', 'Y', 'Z'] + const dataRows = [ + ['abc', 'def', 'ghi'], + ] + + const result = detectColumns(headers, dataRows) + + expect(result.confidence).toBeLessThan(0.5) + }) + + it('detects IB keyword as balance column', () => { + const headers = ['Konto', 'IB', 'UB'] + const dataRows = [ + ['1930', '50000', '55000'], + ] + + const result = detectColumns(headers, dataRows) + + expect(result.account_number_col).toBe(0) + expect(result.layout).toBe('net') + expect(result.balance_col).toBe(1) + }) + + it('handles empty data rows', () => { + const headers = ['Kontonr', 'Debet', 'Kredit'] + const dataRows: string[][] = [] + + const result = detectColumns(headers, dataRows) + + expect(result.account_number_col).toBe(0) + expect(result.layout).toBe('debit_credit') + }) +}) diff --git a/lib/import/opening-balance/__tests__/parser.test.ts b/lib/import/opening-balance/__tests__/parser.test.ts new file mode 100644 index 00000000..1dfdc6c3 --- /dev/null +++ b/lib/import/opening-balance/__tests__/parser.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi } from 'vitest' +import { parseAmount } from '../parser' + +// Mock the BAS reference for consistent testing +vi.mock('@/lib/bookkeeping/bas-reference', () => ({ + getBASReference: (accountNumber: string) => { + const accounts: Record = { + '1930': { account_name: 'Företagskonto / checkkonto / affärskonto' }, + '2440': { account_name: 'Leverantörsskulder' }, + '1510': { account_name: 'Kundfordringar' }, + '2099': { account_name: 'Årets resultat' }, + } + return accounts[accountNumber] ? { ...accounts[accountNumber], account_number: accountNumber } : null + }, +})) + +describe('parseAmount', () => { + it('handles numbers', () => { + expect(parseAmount(50000)).toBe(50000) + expect(parseAmount(123.456)).toBe(123.46) + }) + + it('handles Swedish decimal commas', () => { + expect(parseAmount('50 000,00')).toBe(50000) + expect(parseAmount('1 234,56')).toBe(1234.56) + }) + + it('handles strings with dots as thousand separators', () => { + expect(parseAmount('50.000,00')).toBe(50000) + }) + + it('handles plain strings', () => { + expect(parseAmount('50000')).toBe(50000) + expect(parseAmount('-15000')).toBe(-15000) + }) + + it('handles empty/null/undefined', () => { + expect(parseAmount(null)).toBe(0) + expect(parseAmount(undefined)).toBe(0) + expect(parseAmount('')).toBe(0) + expect(parseAmount('-')).toBe(0) + }) + + it('rounds to 2 decimal places', () => { + expect(parseAmount(0.1 + 0.2)).toBe(0.3) + }) +}) + +describe('parseOpeningBalanceFile', () => { + // We can't easily test the full parser without creating actual Excel buffers, + // but we can test via the xlsx library's ability to create workbooks in memory + it('is importable', async () => { + const { parseOpeningBalanceFile } = await import('../parser') + expect(typeof parseOpeningBalanceFile).toBe('function') + }) + + it('parses a simple CSV-like workbook', async () => { + const XLSX = await import('xlsx') + const { parseOpeningBalanceFile } = await import('../parser') + + // Create a workbook in memory + const wb = XLSX.utils.book_new() + const data = [ + ['Kontonr', 'Kontonamn', 'Debet', 'Kredit'], + ['1930', 'Företagskonto', 50000, 0], + ['2440', 'Leverantörsskulder', 0, 15000], + ['1510', 'Kundfordringar', 10000, 0], + ['2099', 'Årets resultat', 0, 45000], + ] + const ws = XLSX.utils.aoa_to_sheet(data) + XLSX.utils.book_append_sheet(wb, ws, 'Balans') + + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) + const result = parseOpeningBalanceFile(buffer, 'test.xlsx') + + expect(result.filename).toBe('test.xlsx') + expect(result.sheet_name).toBe('Balans') + expect(result.rows.length).toBe(4) + expect(result.total_debit).toBe(60000) + expect(result.total_credit).toBe(60000) + expect(result.is_balanced).toBe(true) + + // Check first row + const row1930 = result.rows.find((r) => r.account_number === '1930') + expect(row1930).toBeDefined() + expect(row1930!.debit_amount).toBe(50000) + expect(row1930!.credit_amount).toBe(0) + expect(row1930!.bas_match).toBeTruthy() + }) + + it('handles net balance layout', async () => { + const XLSX = await import('xlsx') + const { parseOpeningBalanceFile } = await import('../parser') + + const wb = XLSX.utils.book_new() + const data = [ + ['Konto', 'Saldo'], + ['1930', 50000], + ['2440', -15000], + ] + const ws = XLSX.utils.aoa_to_sheet(data) + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) + const result = parseOpeningBalanceFile(buffer, 'test.xlsx') + + expect(result.rows.length).toBe(2) + + const row1930 = result.rows.find((r) => r.account_number === '1930') + expect(row1930!.debit_amount).toBe(50000) + expect(row1930!.credit_amount).toBe(0) + + const row2440 = result.rows.find((r) => r.account_number === '2440') + expect(row2440!.debit_amount).toBe(0) + expect(row2440!.credit_amount).toBe(15000) + }) + + it('warns about P&L accounts (class 3-8)', async () => { + const XLSX = await import('xlsx') + const { parseOpeningBalanceFile } = await import('../parser') + + const wb = XLSX.utils.book_new() + const data = [ + ['Kontonr', 'Debet', 'Kredit'], + ['1930', 50000, 0], + ['3001', 0, 50000], // Revenue account — should warn + ] + const ws = XLSX.utils.aoa_to_sheet(data) + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) + const result = parseOpeningBalanceFile(buffer, 'test.xlsx') + + const revenueRow = result.rows.find((r) => r.account_number === '3001') + expect(revenueRow).toBeDefined() + expect(revenueRow!.validation_errors.length).toBeGreaterThan(0) + expect(revenueRow!.validation_errors[0]).toContain('resultatkonto') + }) + + it('merges duplicate account rows', async () => { + const XLSX = await import('xlsx') + const { parseOpeningBalanceFile } = await import('../parser') + + const wb = XLSX.utils.book_new() + const data = [ + ['Kontonr', 'Debet', 'Kredit'], + ['1930', 30000, 0], + ['1930', 20000, 0], + ['2099', 0, 50000], + ] + const ws = XLSX.utils.aoa_to_sheet(data) + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) + const result = parseOpeningBalanceFile(buffer, 'test.xlsx') + + // Should merge duplicates + const row1930 = result.rows.find((r) => r.account_number === '1930') + expect(row1930!.debit_amount).toBe(50000) + expect(result.rows.length).toBe(2) // 1930 (merged) + 2099 + expect(result.warnings.some((w) => w.includes('1930'))).toBe(true) + }) + + it('skips zero-amount rows', async () => { + const XLSX = await import('xlsx') + const { parseOpeningBalanceFile } = await import('../parser') + + const wb = XLSX.utils.book_new() + const data = [ + ['Kontonr', 'Debet', 'Kredit'], + ['1930', 50000, 0], + ['1510', 0, 0], // Should be skipped + ['2099', 0, 50000], + ] + const ws = XLSX.utils.aoa_to_sheet(data) + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) + const result = parseOpeningBalanceFile(buffer, 'test.xlsx') + + expect(result.rows.length).toBe(2) // 1930 + 2099 (1510 skipped) + }) + + it('detects unbalanced entries', async () => { + const XLSX = await import('xlsx') + const { parseOpeningBalanceFile } = await import('../parser') + + const wb = XLSX.utils.book_new() + const data = [ + ['Kontonr', 'Debet', 'Kredit'], + ['1930', 50000, 0], + ['2099', 0, 40000], // Deliberately unbalanced + ] + const ws = XLSX.utils.aoa_to_sheet(data) + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) + const result = parseOpeningBalanceFile(buffer, 'test.xlsx') + + expect(result.is_balanced).toBe(false) + expect(result.total_debit).toBe(50000) + expect(result.total_credit).toBe(40000) + }) +}) diff --git a/lib/import/opening-balance/column-detector.ts b/lib/import/opening-balance/column-detector.ts new file mode 100644 index 00000000..12adcd2e --- /dev/null +++ b/lib/import/opening-balance/column-detector.ts @@ -0,0 +1,182 @@ +import type { DetectedColumns, BalanceColumnLayout } from './types' + +/** Swedish/English keywords for account number columns */ +const ACCOUNT_NUMBER_KEYWORDS = [ + 'konto', 'kontonr', 'kontonummer', 'kto', 'account', 'account_number', + 'account number', 'acct', 'nr', +] + +/** Keywords for account name columns */ +const ACCOUNT_NAME_KEYWORDS = [ + 'kontonamn', 'benämning', 'namn', 'name', 'account name', 'description', + 'benamning', 'text', 'beteckning', +] + +/** Keywords for debit columns */ +const DEBIT_KEYWORDS = ['debet', 'debit', 'deb'] + +/** Keywords for credit columns */ +const CREDIT_KEYWORDS = ['kredit', 'credit', 'kred', 'cred'] + +/** Keywords for net balance columns */ +const BALANCE_KEYWORDS = [ + 'saldo', 'balans', 'balance', 'ib', 'ingående', 'ingaende', + 'ingående balans', 'opening', 'opening balance', 'belopp', 'amount', +] + +function normalize(header: string): string { + return header.toLowerCase().trim().replace(/[_\-./]/g, ' ') +} + +function matchesKeywords(header: string, keywords: string[]): boolean { + const normalized = normalize(header) + return keywords.some((kw) => normalized === kw || normalized.includes(kw)) +} + +/** + * Check if a column of string values looks like 4-digit account numbers. + * Returns the fraction of non-empty values that match /^\d{4}$/. + */ +function accountNumberScore(values: string[]): number { + const nonEmpty = values.filter((v) => v.trim().length > 0) + if (nonEmpty.length === 0) return 0 + const matching = nonEmpty.filter((v) => /^\d{4}$/.test(v.trim())) + return matching.length / nonEmpty.length +} + +/** + * Check if a column of string values looks like numeric amounts. + * Handles Swedish decimal commas and thousand separators. + */ +function numericScore(values: string[]): number { + const nonEmpty = values.filter((v) => v.trim().length > 0) + if (nonEmpty.length === 0) return 0 + const matching = nonEmpty.filter((v) => { + const cleaned = v.trim().replace(/\s/g, '').replace(/\./g, '').replace(',', '.') + return !isNaN(parseFloat(cleaned)) && isFinite(Number(cleaned)) + }) + return matching.length / nonEmpty.length +} + +/** + * Detect column layout from headers and sample data rows. + * + * @param headers - Array of header strings from the first row + * @param dataRows - 2D array of string values (rows × columns) + * @returns DetectedColumns with confidence score + */ +export function detectColumns( + headers: string[], + dataRows: string[][], +): DetectedColumns { + let accountNumberCol = -1 + let accountNameCol: number | null = null + let debitCol: number | null = null + let creditCol: number | null = null + let balanceCol: number | null = null + let confidence = 0 + + // Phase 1: Header keyword matching + for (let i = 0; i < headers.length; i++) { + const h = headers[i] + if (accountNumberCol === -1 && matchesKeywords(h, ACCOUNT_NUMBER_KEYWORDS)) { + accountNumberCol = i + } else if (accountNameCol === null && matchesKeywords(h, ACCOUNT_NAME_KEYWORDS)) { + accountNameCol = i + } else if (debitCol === null && matchesKeywords(h, DEBIT_KEYWORDS)) { + debitCol = i + } else if (creditCol === null && matchesKeywords(h, CREDIT_KEYWORDS)) { + creditCol = i + } else if (balanceCol === null && matchesKeywords(h, BALANCE_KEYWORDS)) { + balanceCol = i + } + } + + // Phase 2: Data-driven fallback for account number column + if (accountNumberCol === -1 && dataRows.length > 0) { + let bestScore = 0 + for (let i = 0; i < headers.length; i++) { + const colValues = dataRows.map((row) => row[i] || '') + const score = accountNumberScore(colValues) + if (score > bestScore && score >= 0.5) { + bestScore = score + accountNumberCol = i + } + } + } + + // If we still haven't found the account number column, give up + if (accountNumberCol === -1) { + return { + account_number_col: 0, + account_name_col: null, + layout: 'net', + balance_col: null, + debit_col: null, + credit_col: null, + confidence: 0, + } + } + + // Phase 3: Detect numeric columns if debit/credit/balance not found via headers + if (debitCol === null && creditCol === null && balanceCol === null && dataRows.length > 0) { + const numericCols: number[] = [] + for (let i = 0; i < headers.length; i++) { + if (i === accountNumberCol || i === accountNameCol) continue + const colValues = dataRows.map((row) => row[i] || '') + if (numericScore(colValues) >= 0.5) { + numericCols.push(i) + } + } + + if (numericCols.length === 1) { + balanceCol = numericCols[0] + } else if (numericCols.length >= 2) { + // Assume first two numeric columns are debit and credit + debitCol = numericCols[0] + creditCol = numericCols[1] + } + } + + // Determine layout + let layout: BalanceColumnLayout = 'net' + if (debitCol !== null && creditCol !== null) { + layout = 'debit_credit' + } else if (balanceCol !== null) { + layout = 'net' + } else if (debitCol !== null || creditCol !== null) { + // Only one of debit/credit found — treat as net balance + balanceCol = debitCol ?? creditCol + debitCol = null + creditCol = null + layout = 'net' + } + + // Calculate confidence + const hasAccountCol = accountNumberCol >= 0 + const hasAmountCol = layout === 'debit_credit' + ? (debitCol !== null && creditCol !== null) + : balanceCol !== null + const hasNameCol = accountNameCol !== null + + if (hasAccountCol && hasAmountCol) { + // Boost confidence if data also validates + const colValues = dataRows.map((row) => row[accountNumberCol] || '') + const dataScore = accountNumberScore(colValues) + confidence = hasNameCol ? 0.9 + dataScore * 0.1 : 0.8 + dataScore * 0.1 + } else if (hasAccountCol) { + confidence = 0.4 + } + + confidence = Math.min(confidence, 1) + + return { + account_number_col: accountNumberCol, + account_name_col: accountNameCol, + layout, + balance_col: balanceCol, + debit_col: debitCol, + credit_col: creditCol, + confidence: Math.round(confidence * 100) / 100, + } +} diff --git a/lib/import/opening-balance/parser.ts b/lib/import/opening-balance/parser.ts new file mode 100644 index 00000000..d38342f2 --- /dev/null +++ b/lib/import/opening-balance/parser.ts @@ -0,0 +1,229 @@ +import * as XLSX from 'xlsx' +import { detectColumns } from './column-detector' +import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import type { + DetectedColumns, + ParsedOpeningBalanceRow, + OpeningBalanceParseResult, +} from './types' + +/** + * Parse a numeric value from a cell, handling Swedish decimal commas, + * thousand separators, and various string formats. + */ +export function parseAmount(value: unknown): number { + if (value === null || value === undefined || value === '') return 0 + if (typeof value === 'number') return Math.round(value * 100) / 100 + + const str = String(value).trim() + if (str === '' || str === '-') return 0 + + // Remove thousand separators (spaces and dots before comma) + // Then replace comma with dot for parsing + const cleaned = str + .replace(/\s/g, '') // Remove spaces (thousand separator) + .replace(/\.(?=\d{3})/g, '') // Remove dots used as thousand separators + .replace(',', '.') // Swedish decimal comma → dot + + const num = parseFloat(cleaned) + if (isNaN(num)) return 0 + return Math.round(num * 100) / 100 +} + +/** + * Parse an opening balance file (Excel or CSV) and return structured rows + * with validation and BAS account matching. + * + * @param buffer - Raw file buffer + * @param filename - Original filename (used for format detection) + * @param columnOverrides - Optional manual column mapping (from column mapping step) + */ +export function parseOpeningBalanceFile( + buffer: ArrayBuffer, + filename: string, + columnOverrides?: DetectedColumns, +): OpeningBalanceParseResult { + const workbook = XLSX.read(buffer, { type: 'array' }) + + // Pick the sheet with the most rows (heuristic for multi-sheet workbooks) + let bestSheet = workbook.SheetNames[0] + let bestRowCount = 0 + for (const name of workbook.SheetNames) { + const sheet = workbook.Sheets[name] + const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1') + const rowCount = range.e.r - range.s.r + 1 + if (rowCount > bestRowCount) { + bestRowCount = rowCount + bestSheet = name + } + } + + const sheet = workbook.Sheets[bestSheet] + const rawData: string[][] = XLSX.utils.sheet_to_json(sheet, { + header: 1, + defval: '', + raw: false, + }) + + if (rawData.length < 2) { + return { + filename, + sheet_name: bestSheet, + total_rows: 0, + detected_columns: columnOverrides || { + account_number_col: 0, + account_name_col: null, + layout: 'net', + balance_col: null, + debit_col: null, + credit_col: null, + confidence: 0, + }, + headers: rawData[0]?.map((h) => String(h)) || [], + preview_rows: [], + rows: [], + total_debit: 0, + total_credit: 0, + is_balanced: true, + warnings: ['Filen innehåller för få rader.'], + } + } + + const headers = rawData[0].map((h) => String(h)) + const dataRows = rawData.slice(1) + + // Detect or use overridden columns + const columns = columnOverrides || detectColumns(headers, dataRows) + + const rows: ParsedOpeningBalanceRow[] = [] + const warnings: string[] = [] + const seenAccounts = new Map() // account_number → first row_index + + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i] + const rawAccountNumber = String(row[columns.account_number_col] || '').trim() + + // Skip empty rows + if (!rawAccountNumber) continue + + // Clean account number (remove leading zeros, spaces, dashes) + const accountNumber = rawAccountNumber.replace(/[^0-9]/g, '') + + // Skip non-4-digit account numbers (likely header/total rows) + if (!/^\d{4}$/.test(accountNumber)) { + // Could be a summary/total row — skip silently unless it looked intentional + if (rawAccountNumber.length > 0 && !/^(summa|total|sum|samman)/i.test(rawAccountNumber)) { + warnings.push(`Rad ${i + 2}: "${rawAccountNumber}" är inte ett giltigt kontonummer (4 siffror) — hoppades över`) + } + continue + } + + // Get account name from file or BAS reference + const accountNameFromFile = columns.account_name_col !== null + ? String(row[columns.account_name_col] || '').trim() + : '' + + const basRef = getBASReference(accountNumber) + const basMatch = basRef?.account_name ?? null + const accountName = accountNameFromFile || basMatch || `Konto ${accountNumber}` + + // Parse amounts + let debitAmount = 0 + let creditAmount = 0 + + if (columns.layout === 'debit_credit') { + debitAmount = parseAmount(columns.debit_col !== null ? row[columns.debit_col] : 0) + creditAmount = parseAmount(columns.credit_col !== null ? row[columns.credit_col] : 0) + } else { + // Net balance: positive → debit, negative → credit + const netAmount = parseAmount(columns.balance_col !== null ? row[columns.balance_col] : 0) + if (netAmount > 0) { + debitAmount = netAmount + } else if (netAmount < 0) { + creditAmount = Math.abs(netAmount) + } + } + + // Validate + const validationErrors: string[] = [] + const is_valid_account = /^\d{4}$/.test(accountNumber) + + if (!is_valid_account) { + validationErrors.push('Ogiltigt kontonummer') + } + + if (debitAmount === 0 && creditAmount === 0) { + // Zero-amount rows will be filtered silently + continue + } + + if (debitAmount > 0 && creditAmount > 0) { + validationErrors.push('Kontot har både debet- och kreditbelopp') + } + + // Warn on P&L accounts (class 3-8) + const accountClass = parseInt(accountNumber.charAt(0), 10) + if (accountClass >= 3 && accountClass <= 8) { + validationErrors.push(`Konto ${accountNumber} är ett resultatkonto (klass ${accountClass}) — ingående balanser ska normalt bara innehålla balanskonton (klass 1-2)`) + } + + // Track duplicates + if (seenAccounts.has(accountNumber)) { + warnings.push(`Konto ${accountNumber} förekommer på flera rader — beloppen kommer summeras`) + } + seenAccounts.set(accountNumber, i + 2) // +2 for header row + 1-based + + rows.push({ + row_index: i + 2, + account_number: accountNumber, + account_name: accountName, + debit_amount: debitAmount, + credit_amount: creditAmount, + is_valid: validationErrors.length === 0, + validation_errors: validationErrors, + bas_match: basMatch, + }) + } + + // Merge duplicate accounts + const mergedMap = new Map() + for (const row of rows) { + const existing = mergedMap.get(row.account_number) + if (existing) { + existing.debit_amount = Math.round((existing.debit_amount + row.debit_amount) * 100) / 100 + existing.credit_amount = Math.round((existing.credit_amount + row.credit_amount) * 100) / 100 + } else { + mergedMap.set(row.account_number, { ...row }) + } + } + const mergedRows = Array.from(mergedMap.values()) + + // Compute totals + let totalDebit = 0 + let totalCredit = 0 + for (const row of mergedRows) { + totalDebit = Math.round((totalDebit + row.debit_amount) * 100) / 100 + totalCredit = Math.round((totalCredit + row.credit_amount) * 100) / 100 + } + + const diff = Math.round((totalDebit - totalCredit) * 100) / 100 + const isBalanced = Math.abs(diff) < 0.01 + + if (!isBalanced) { + warnings.push(`Debet (${totalDebit.toFixed(2)}) och kredit (${totalCredit.toFixed(2)}) balanserar inte — differens: ${diff.toFixed(2)} SEK`) + } + + return { + filename, + sheet_name: bestSheet, + total_rows: mergedRows.length, + detected_columns: columns, + headers, + preview_rows: dataRows.slice(0, 5), + rows: mergedRows, + total_debit: totalDebit, + total_credit: totalCredit, + is_balanced: isBalanced, + warnings, + } +} diff --git a/lib/import/opening-balance/types.ts b/lib/import/opening-balance/types.ts new file mode 100644 index 00000000..7c35beb7 --- /dev/null +++ b/lib/import/opening-balance/types.ts @@ -0,0 +1,68 @@ +/** Layout of the balance columns in the uploaded file */ +export type BalanceColumnLayout = 'net' | 'debit_credit' + +/** Result of auto-detecting columns in the uploaded file */ +export interface DetectedColumns { + account_number_col: number + account_name_col: number | null + layout: BalanceColumnLayout + /** Column index for net balance (used when layout === 'net') */ + balance_col: number | null + /** Column index for debit amounts (used when layout === 'debit_credit') */ + debit_col: number | null + /** Column index for credit amounts (used when layout === 'debit_credit') */ + credit_col: number | null + /** 0-1 confidence score for the detection */ + confidence: number +} + +/** A single parsed row from the opening balance file */ +export interface ParsedOpeningBalanceRow { + row_index: number + account_number: string + account_name: string + debit_amount: number + credit_amount: number + is_valid: boolean + validation_errors: string[] + /** Matched BAS account name, if found */ + bas_match: string | null +} + +/** Full result from parsing an opening balance file */ +export interface OpeningBalanceParseResult { + filename: string + sheet_name: string + total_rows: number + detected_columns: DetectedColumns + /** Raw headers from the first row of the file */ + headers: string[] + /** First 5 raw data rows for preview in column mapping */ + preview_rows: string[][] + rows: ParsedOpeningBalanceRow[] + total_debit: number + total_credit: number + is_balanced: boolean + warnings: string[] +} + +/** Input for executing the opening balance import */ +export interface OpeningBalanceExecuteInput { + fiscal_period_id: string + lines: { + account_number: string + debit_amount: number + credit_amount: number + }[] +} + +/** Result of executing the opening balance import */ +export interface OpeningBalanceExecuteResult { + success: boolean + journal_entry_id: string | null + fiscal_period_id: string + lines_created: number + total_debit: number + total_credit: number + error?: string +} diff --git a/package-lock.json b/package-lock.json index 3399e2c5..3ed9e246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "svix": "^1.85.0", "tailwind-merge": "^3.4.0", "web-push": "^3.6.7", + "xlsx": "^0.18.5", "zod": "^4.3.6" }, "devDependencies": { @@ -7844,6 +7845,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -8454,6 +8464,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -8584,6 +8607,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8667,6 +8699,18 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10153,6 +10197,15 @@ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "license": "MIT" }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/framer-motion": { "version": "12.29.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.2.tgz", @@ -14595,6 +14648,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -16186,6 +16251,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -16231,6 +16314,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 6013ed39..d68a44d6 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "svix": "^1.85.0", "tailwind-merge": "^3.4.0", "web-push": "^3.6.7", + "xlsx": "^0.18.5", "zod": "^4.3.6" }, "devDependencies": { diff --git a/supabase/migrations/20260411000001_user_company_ids_filter_archived.sql b/supabase/migrations/20260411000001_user_company_ids_filter_archived.sql index c0fdd77b..43384708 100644 --- a/supabase/migrations/20260411000001_user_company_ids_filter_archived.sql +++ b/supabase/migrations/20260411000001_user_company_ids_filter_archived.sql @@ -34,3 +34,5 @@ ALTER TABLE public.companies CREATE INDEX companies_active_idx ON public.companies (id) WHERE archived_at IS NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260413160000_booking_template_library.sql b/supabase/migrations/20260413160000_booking_template_library.sql index 9ad7135b..74eaf711 100644 --- a/supabase/migrations/20260413160000_booking_template_library.sql +++ b/supabase/migrations/20260413160000_booking_template_library.sql @@ -66,33 +66,30 @@ CREATE POLICY "btl_select" ON public.booking_template_library OR team_id IN (SELECT public.user_team_ids()) ); --- INSERT: company templates (non-viewers) or team templates (team members) +-- INSERT: company templates or team templates (viewer check enforced in API) CREATE POLICY "btl_insert" ON public.booking_template_library FOR INSERT WITH CHECK ( NOT is_system - AND public.current_user_can_write() - AND ( - company_id = public.current_active_company_id() - OR (company_id IS NULL AND team_id IN (SELECT public.user_team_ids())) - ) - ); - --- UPDATE: own company or own team templates (non-viewers), never system -CREATE POLICY "btl_update" ON public.booking_template_library - FOR UPDATE USING ( - NOT is_system - AND public.current_user_can_write() AND ( company_id IN (SELECT public.user_company_ids()) OR (company_id IS NULL AND team_id IN (SELECT public.user_team_ids())) ) ); --- DELETE: own company or own team templates (non-viewers), never system +-- UPDATE: own company or own team templates, never system +CREATE POLICY "btl_update" ON public.booking_template_library + FOR UPDATE USING ( + NOT is_system + AND ( + company_id IN (SELECT public.user_company_ids()) + OR (company_id IS NULL AND team_id IN (SELECT public.user_team_ids())) + ) + ); + +-- DELETE: own company or own team templates, never system CREATE POLICY "btl_delete" ON public.booking_template_library FOR DELETE USING ( NOT is_system - AND public.current_user_can_write() AND ( company_id IN (SELECT public.user_company_ids()) OR (company_id IS NULL AND team_id IN (SELECT public.user_team_ids())) @@ -294,11 +291,18 @@ INSERT INTO public.booking_template_library (name, description, category, entity ]'::jsonb ); +-- REPRESENTATION +INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES +( + 'Representation (avdragsgill, 25% moms)', + 'Extern representation med avdragsgill moms. Max 300 kr/person exkl. moms.', + 'representation', 'all', TRUE, '[ {"account": "6072", "label": "Representation avdragsgill", "side": "debit", "type": "business", "ratio": 0.8}, {"account": "2641", "label": "Ingående moms", "side": "debit", "type": "vat", "vat_rate": 0.25}, {"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0} ]'::jsonb +); -- YEAR-END / FINANCIAL INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES