* 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.
238 lines
8.3 KiB
TypeScript
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)
|
|
})
|
|
})
|