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:
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user