Files
accounted/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts
T
Mattsson ade4ad5971 Fiscal period and multi bank (#228)
* feat: add fiscal period backward chaining and entry date validation

Support creating fiscal periods before the earliest existing period
(backward chaining) for backfill scenarios, alongside the existing
forward chaining. The engine now validates that entry dates fall within
the selected fiscal period, with a Swedish error message. The journal
entry form auto-selects the matching period and shows a warning with
a CreatePeriodDialog when no period covers the entry date.


* feat: support multi-bank-account for imports and reconciliation

Plumb a configurable settlement account through the entire bank import
pipeline — mapping engine, transaction entries, ingest, and
reconciliation — so secondary bank accounts (e.g. 1931, 1932) work
correctly instead of hardcoding 1930. Adds a get_unlinked_bank_lines
RPC that generalizes the existing get_unlinked_1930_lines with a
fallback for backwards compatibility. The bank file import UI now shows
a bank account selector when multiple 19xx accounts exist. Also adds
default_vat_code/sru_code to account creation and fixes uploadDocument
argument order in enable-banking sync.
2026-04-13 11:13:02 +02:00

238 lines
8.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock dependencies
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { createClient } from '@/lib/supabase/server'
import { POST } from '../route'
function createMockRequest(body: unknown): Request {
return new Request('http://localhost/api/bookkeeping/fiscal-periods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
type Period = { id: string; period_start: string; period_end: string; is_closed: boolean; name?: string }
/**
* Build a mock supabase that tracks sequential from('fiscal_periods') calls
* and returns the correct data based on the select() arguments.
*/
function buildMockSupabase(options: {
user?: { id: string } | null
allPeriods?: Period[]
openCount?: number
overlapping?: Array<{ id: string; name: string }>
insertResult?: { data: unknown; error: unknown }
}) {
const {
user = { id: 'user-1' },
allPeriods = [],
openCount = 0,
overlapping = [],
insertResult = { data: { id: 'new-period', name: 'FY 2025' }, error: null },
} = options
// Track from() calls to fiscal_periods
let fpCallIndex = 0
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
},
from: vi.fn().mockImplementation(() => {
fpCallIndex++
const callNum = fpCallIndex
// Build a chainable object that resolves differently based on the call chain
const chainable: Record<string, unknown> = {}
// For the allPeriods query (call 1): .select('id, period_start, ...').eq(...).order(...)
// For openCount query (call 2): .select('id', { count: ... }).eq(...).eq(...)
// For overlap query (call 3): .select('id, name').eq(...).lte(...).gte(...).limit(...)
// For insert (call 4): .insert(...).select().single()
// For update (call 5): .update(...).eq(...).eq(...)
chainable.select = vi.fn().mockImplementation((_sel: string, opts?: { count?: string }) => {
if (opts?.count === 'exact') {
// openCount query
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ count: openCount }),
}),
}
}
if (callNum === 1) {
// allPeriods query
return {
eq: vi.fn().mockReturnValue({
order: vi.fn().mockResolvedValue({ data: allPeriods, error: null }),
}),
}
}
// overlap query or any other select
return {
eq: vi.fn().mockReturnValue({
lte: vi.fn().mockReturnValue({
gte: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue({ data: overlapping, error: null }),
}),
}),
order: vi.fn().mockResolvedValue({ data: allPeriods, error: null }),
}),
}
})
chainable.insert = vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue(insertResult),
}),
})
chainable.update = vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ error: null }),
}),
})
return chainable
}),
}
;(createClient as ReturnType<typeof vi.fn>).mockResolvedValue(supabase)
return supabase
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('POST /api/bookkeeping/fiscal-periods', () => {
it('returns 401 when not authenticated', async () => {
buildMockSupabase({ user: null })
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(401)
})
it('creates first period successfully', async () => {
buildMockSupabase({ allPeriods: [] })
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toBeDefined()
})
it('rejects overlapping periods', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true }],
overlapping: [{ id: 'p1', name: 'FY 2024' }],
})
// Forward chain from 2024, start = 2025-01-01
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error).toMatch(/Overlaps/)
})
it('rejects forward period with wrong start date', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true }],
})
const req = createMockRequest({ name: 'FY 2026', period_start: '2026-02-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/must start on 2026-01-01/)
})
it('rejects forward period when unclosed period exists', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openCount: 1,
})
const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error).toMatch(/unclosed period/)
})
it('allows backward period creation', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }],
overlapping: [],
})
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(200)
})
it('rejects backward period with wrong end date', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }],
})
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-11-30' })
const res = await POST(req)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/must end on 2025-12-31/)
})
it('backward chaining skips unclosed period constraint', async () => {
// There's an unclosed period (2026), but backward creation should still work
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }],
openCount: 1,
overlapping: [],
})
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(200)
})
it('rejects period that is neither forward nor backward', async () => {
buildMockSupabase({
allPeriods: [
{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true },
{ id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
],
})
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/must chain before the earliest or after the latest/)
})
it('rejects invalid period duration (> 18 months)', async () => {
buildMockSupabase({ allPeriods: [] })
const req = createMockRequest({ name: 'Long period', period_start: '2025-01-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/18 months/)
})
it('rejects invalid body', async () => {
buildMockSupabase({})
const req = createMockRequest({ name: '', period_start: 'bad', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(400)
})
})