feat: add opening balance import functionality (#238)

- Implemented OpeningBalanceResultStep component to display results of the import process, including success messages and error handling.
- Created OpeningBalanceUploadStep component for file upload with drag-and-drop support, including validation for accepted file types.
- Developed column detection logic in column-detector.ts to identify account number, name, debit, credit, and balance columns based on headers and data.
- Added parser functionality in parser.ts to handle parsing of opening balance files, including validation and BAS account matching.
- Created tests for column detection and parsing logic to ensure accuracy and reliability.
- Defined types for detected columns and parsed rows in types.ts to improve type safety and clarity in the codebase.
This commit is contained in:
Mattsson
2026-04-14 15:35:50 +02:00
committed by GitHub
parent bf36ebfd88
commit a3fea6fb7c
19 changed files with 2865 additions and 19 deletions
+300 -4
View File
@@ -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<OpeningBalanceStep, string> = {
upload: 'Ladda upp',
column_mapping: 'Kolumnmappning',
edit: 'Granska',
period: 'Period',
result: 'Resultat',
}
function OpeningBalanceImportWizard() {
const { toast } = useToast()
const [obStep, setObStep] = useState<OpeningBalanceStep>('upload')
const [obIsLoading, setObIsLoading] = useState(false)
const [obError, setObError] = useState<string | null>(null)
const [obFile, setObFile] = useState<File | null>(null)
const [parseResult, setParseResult] = useState<OpeningBalanceParseResult | null>(null)
const [editedRows, setEditedRows] = useState<{
id: string; account_number: string; account_name: string
debit_amount: number; credit_amount: number
}[]>([])
const [executeResult, setExecuteResult] = useState<OpeningBalanceExecuteResult | null>(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 (
<div className="space-y-6">
{/* Progress */}
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{steps.length}: {OB_STEP_LABELS[obStep]}
</span>
{steps.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{OB_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{/* Step content */}
{obStep === 'upload' && (
<OpeningBalanceUploadStep
onFileSelect={handleFileSelect}
isLoading={obIsLoading}
error={obError}
/>
)}
{obStep === 'column_mapping' && parseResult && (
<OpeningBalanceColumnMappingStep
headers={parseResult.headers}
previewRows={parseResult.preview_rows}
detectedColumns={parseResult.detected_columns}
onConfirm={handleColumnMappingConfirm}
onBack={() => setObStep('upload')}
/>
)}
{obStep === 'edit' && parseResult && (
<OpeningBalanceEditStep
rows={parseResult.rows}
onContinue={handleEditContinue}
onBack={() => {
if (needsMapping) {
setObStep('column_mapping')
} else {
setObStep('upload')
}
}}
/>
)}
{obStep === 'period' && (
<OpeningBalancePeriodStep
rows={editedRows}
onExecute={handleExecute}
onBack={() => setObStep('edit')}
isLoading={obIsLoading}
error={obError}
/>
)}
{obStep === 'result' && executeResult && (
<OpeningBalanceResultStep
result={executeResult}
onNewImport={handleNewImport}
/>
)}
</div>
)
}
// ============================================================
// 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() {
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
{/* 4. Bokföringsdata (SIE) */}
{/* 4. Ingående balanser */}
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
aria-disabled={isSandbox}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-all',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
)}
onClick={() => { if (!isSandbox) setMode('opening_balance') }}
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('opening_balance') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<Scale className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Ingående balanser</h3>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Importera ingående balanser från en Excel- eller CSV-fil.
</p>
<div className="flex flex-wrap gap-1.5 mt-2.5">
{['XLSX', 'CSV'].map(fmt => (
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
{fmt}
</span>
))}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
{/* 5. Bokföringsdata (SIE) */}
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
@@ -1069,6 +1364,7 @@ export default function ImportPage() {
{mode === 'psd2' && <PSD2ConnectWizard />}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
{mode === 'opening_balance' && <OpeningBalanceImportWizard />}
{mode === 'migration' && <MigrationWizard userId={userId} />}
</div>
)
@@ -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',
}),
)
})
})
@@ -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 },
)
}
}
@@ -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 },
)
}
}
@@ -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<number | null>(
detectedColumns.account_name_col,
)
const [layout, setLayout] = useState<BalanceColumnLayout>(
detectedColumns.layout,
)
const [balanceCol, setBalanceCol] = useState<number | null>(
detectedColumns.balance_col,
)
const [debitCol, setDebitCol] = useState<number | null>(
detectedColumns.debit_col,
)
const [creditCol, setCreditCol] = useState<number | null>(
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 (
<Card>
<CardHeader>
<CardTitle>Kolumnmappning</CardTitle>
<CardDescription>
Vi kunde inte automatiskt identifiera alla kolumner. Ange vilka kolumner
som innehåller kontonummer och belopp.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Layout toggle */}
<div className="space-y-2">
<Label>Beloppslayout</Label>
<Select value={layout} onValueChange={(v) => setLayout(v as BalanceColumnLayout)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="net">Nettobelopp (en kolumn)</SelectItem>
<SelectItem value="debit_credit">Debet &amp; kredit (två kolumner)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Required mappings */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Kontonummer *</Label>
<Select
value={String(accountNumberCol)}
onValueChange={(v) => setAccountNumberCol(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Kontonamn</Label>
<Select
value={accountNameCol !== null ? String(accountNameCol) : 'none'}
onValueChange={(v) => setAccountNameCol(v === 'none' ? null : Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">(ingen)</SelectItem>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{layout === 'net' && (
<div className="space-y-2">
<Label>Saldo/Belopp *</Label>
<Select
value={balanceCol !== null ? String(balanceCol) : ''}
onValueChange={(v) => setBalanceCol(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{layout === 'debit_credit' && (
<>
<div className="space-y-2">
<Label>Debet *</Label>
<Select
value={debitCol !== null ? String(debitCol) : ''}
onValueChange={(v) => setDebitCol(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Kredit *</Label>
<Select
value={creditCol !== null ? String(creditCol) : ''}
onValueChange={(v) => setCreditCol(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
{/* Preview */}
{previewRows.length > 0 && (
<div className="space-y-2">
<Label className="text-muted-foreground">Förhandsgranskning (5 första raderna)</Label>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left font-medium whitespace-nowrap">
{h || `Kolumn ${i + 1}`}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.slice(0, 5).map((row, ri) => (
<tr key={ri} className="border-b last:border-0">
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-1.5 whitespace-nowrap tabular-nums">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={handleConfirm} disabled={!canContinue}>
Fortsätt
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -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<typeof BAS_REFERENCE[0]> | 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<typeof BAS_REFERENCE[0]> | 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<EditableRow[]>(() =>
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<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteRef = useRef<HTMLDivElement>(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<EditableRow>) => {
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 (
<Card>
<CardHeader>
<CardTitle>Granska och redigera</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium w-28">Konto</th>
<th className="px-3 py-2 text-left font-medium">Kontonamn</th>
<th className="px-3 py-2 text-right font-medium w-32">Debet</th>
<th className="px-3 py-2 text-right font-medium w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
row.validation_errors.length > 0 && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5 relative">
<Input
value={row.account_number}
onChange={(e) => {
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 && (
<div
ref={autocompleteRef}
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-md border bg-popover shadow-md"
>
{autocompleteResults.map((item) => (
<button
key={item.account_number}
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
onMouseDown={(e) => {
e.preventDefault()
selectAutocompleteItem(row.id, item)
}}
>
<span className="font-mono text-muted-foreground tabular-nums">
{item.account_number}
</span>
<span className="truncate">{item.account_name}</span>
</button>
))}
</div>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-sm truncate max-w-xs">
{row.account_name}
</span>
{row.validation_errors.length > 0 && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.debit_amount || ''}
onChange={(e) =>
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}
/>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.credit_amount || ''}
onChange={(e) =>
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}
/>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="px-3 py-2" colSpan={2}>
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.debit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.credit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td />
</tr>
{!totals.isBalanced && (
<tr className="text-destructive">
<td className="px-3 py-1 text-sm" colSpan={2}>
Differens
</td>
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
{totals.diff.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
SEK
</td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
<Scale className="h-3.5 w-3.5 mr-1.5" />
Avrunda ({totals.diff > 0 ? '+' : ''}{totals.diff.toFixed(2)} till 2099)
</Button>
)}
</div>
{/* Warnings */}
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Debet och kredit balanserar inte. Differens:{' '}
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK.
Kontrollera beloppen innan du fortsätter.
</p>
</div>
)}
{/* Navigation */}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={() => onContinue(rows)} disabled={!canContinue}>
Fortsätt
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -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<FiscalPeriod[]>([])
const [selectedPeriodId, setSelectedPeriodId] = useState<string>('')
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 (
<Card>
<CardHeader>
<CardTitle>Välj räkenskapsperiod</CardTitle>
<CardDescription>
Välj vilken räkenskapsperiod de ingående balanserna ska bokföras på.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Period selector */}
<div className="space-y-2">
<Label>Räkenskapsperiod</Label>
{loadingPeriods ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin" />
Hämtar perioder...
</div>
) : periods.length === 0 ? (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Inga räkenskapsperioder hittades. Skapa en räkenskapsperiod under Bokföring först.
</p>
</div>
) : (
<Select value={selectedPeriodId} onValueChange={setSelectedPeriodId}>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id} disabled={p.is_closed || !!p.locked_at}>
{p.name} ({p.period_start} — {p.period_end})
{p.opening_balances_set && ' — har redan IB'}
{p.is_closed && ' — stängd'}
{p.locked_at && !p.is_closed && ' — låst'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Warnings */}
{periodHasOB && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">
Denna period har redan ingående balanser. Ta bort den befintliga verifikationen
(via stornering) innan du importerar nya.
</p>
</div>
)}
{/* Summary */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<h4 className="text-sm font-medium">Sammanfattning</h4>
<div className="grid grid-cols-2 gap-y-2 text-sm">
<span className="text-muted-foreground">Antal konton:</span>
<span className="tabular-nums text-right">{rows.length}</span>
<span className="text-muted-foreground">Total debet:</span>
<span className="tabular-nums text-right">
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
</span>
<span className="text-muted-foreground">Total kredit:</span>
<span className="tabular-nums text-right">
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
</span>
</div>
<div className="flex items-center gap-2 pt-1 border-t text-sm">
<CheckCircle2 className="h-4 w-4 text-success" />
<span className="text-success font-medium">Balanserar</span>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack} disabled={isLoading}>
Tillbaka
</Button>
<Button onClick={handleExecute} disabled={!canExecute}>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Bokför...
</>
) : (
'Bokför ingående balanser'
)}
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -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 (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
{result.success ? (
<CheckCircle2 className="h-6 w-6 text-success" />
) : (
<XCircle className="h-6 w-6 text-destructive" />
)}
<CardTitle>
{result.success
? 'Ingående balanser bokförda'
: 'Importen misslyckades'}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{result.success ? (
<>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg border bg-muted/30 p-4 text-center">
<p className="text-2xl font-semibold tabular-nums">
{result.lines_created}
</p>
<p className="text-sm text-muted-foreground mt-1">Kontorader</p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 text-center">
<p className="text-2xl font-semibold tabular-nums">
{result.total_debit.toLocaleString('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}
</p>
<p className="text-sm text-muted-foreground mt-1">Debet (SEK)</p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 text-center">
<p className="text-2xl font-semibold tabular-nums">
{result.total_credit.toLocaleString('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}
</p>
<p className="text-sm text-muted-foreground mt-1">Kredit (SEK)</p>
</div>
</div>
{/* Next steps */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Nästa steg</h4>
<ol className="text-sm text-muted-foreground space-y-1.5 list-decimal list-inside">
<li>Granska verifikationen i bokföringen</li>
<li>Kontrollera balansrapporten att beloppen stämmer</li>
<li>Börja importera banktransaktioner eller skapa verifikationer</li>
</ol>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
{result.journal_entry_id && (
<Button asChild>
<Link href={`/bookkeeping/${result.journal_entry_id}`}>
Visa verifikation
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
)}
<Button variant="outline" asChild>
<Link href="/reports">
Visa rapporter
</Link>
</Button>
<Button variant="ghost" onClick={onNewImport}>
Ny import
</Button>
</div>
</>
) : (
<>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<p className="text-sm text-destructive">
{result.error || 'Ett okänt fel uppstod vid importen.'}
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={onNewImport}>
Försök igen
</Button>
</div>
</>
)}
</CardContent>
</Card>
)
}
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Ladda upp fil med ingående balanser</CardTitle>
<CardDescription>
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).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Drop zone */}
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/20 hover:border-muted-foreground/40',
isLoading && 'pointer-events-none opacity-60',
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{isLoading ? (
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Läser fil och identifierar kolumner...</p>
</div>
) : (
<>
<Upload className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm font-medium">
Dra och släpp din fil här
</p>
<p className="text-sm text-muted-foreground mt-1">
eller
</p>
<label>
<input
type="file"
accept={ACCEPTED_TYPES}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
e.target.value = ''
}}
/>
<Button variant="outline" size="sm" className="mt-2" asChild>
<span>Välj fil</span>
</Button>
</label>
<p className="text-xs text-muted-foreground mt-3">
XLSX, XLS, CSV, ODS — max 10 MB
</p>
</>
)}
</div>
{/* Error */}
{error && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Info */}
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3">
<FileSpreadsheet className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-sm text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Filformat</p>
<p>
Filen bör ha en rubrikrad med kolumner för kontonummer och belopp.
Vanliga format stöds automatiskt — t.ex. kolumner som heter &quot;Konto&quot;,
&quot;Debet&quot;, &quot;Kredit&quot; eller &quot;Saldo&quot;.
</p>
</div>
</div>
</CardContent>
</Card>
)
}
+13
View File
@@ -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'),
})
@@ -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')
})
})
@@ -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<string, { account_name: string }> = {
'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)
})
})
@@ -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,
}
}
+229
View File
@@ -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<string, number>() // 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<string, ParsedOpeningBalanceRow>()
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,
}
}
+68
View File
@@ -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
}
+104
View File
@@ -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",
+1
View File
@@ -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": {
@@ -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';
@@ -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