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:
@@ -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 & 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 "Konto",
|
||||
"Debet", "Kredit" eller "Saldo".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+104
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user