- 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.
248 lines
6.8 KiB
TypeScript
248 lines
6.8 KiB
TypeScript
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',
|
|
}),
|
|
)
|
|
})
|
|
})
|