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.
This commit is contained in:
Mattsson
2026-04-13 11:13:02 +02:00
committed by GitHub
parent 258a64a849
commit ade4ad5971
21 changed files with 1025 additions and 73 deletions
+2
View File
@@ -76,6 +76,8 @@ export async function POST(request: Request) {
plan_type: body.plan_type || 'k1',
is_system_account: false,
description: body.description || null,
default_vat_code: body.default_vat_code || null,
sru_code: body.sru_code || null,
sort_order: parseInt(body.account_number),
})
.select()
@@ -0,0 +1,237 @@
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)
})
})
+64 -26
View File
@@ -46,17 +46,14 @@ export async function POST(request: Request) {
if (!validation.success) return validation.response
const body = validation.data
// Enforce continuity: new period must chain from the latest existing period (BFL 3:1)
const { data: latest } = await supabase
// Fetch all existing periods to determine direction
const { data: allPeriods } = await supabase
.from('fiscal_periods')
.select('period_end, is_closed')
.select('id, period_start, period_end, is_closed')
.eq('company_id', companyId)
.order('period_end', { ascending: false })
.limit(1)
.maybeSingle()
.order('period_start', { ascending: true })
// First period for this company may start on any day (BFL 3 kap.)
const isFirstPeriod = !latest
const isFirstPeriod = !allPeriods || allPeriods.length === 0
// Validate period duration (max 18 months per BFL 3 kap.)
const durationError = validatePeriodDuration(body.period_start, body.period_end, { isFirstPeriod })
@@ -64,30 +61,59 @@ export async function POST(request: Request) {
return NextResponse.json({ error: durationError }, { status: 400 })
}
if (latest) {
const prev = new Date(latest.period_end + 'T00:00:00')
prev.setDate(prev.getDate() + 1)
const expectedStart = prev.toISOString().split('T')[0]
if (body.period_start !== expectedStart) {
if (allPeriods && allPeriods.length > 0) {
const earliest = allPeriods[0]
const latest = allPeriods[allPeriods.length - 1]
const isBackward = body.period_end < earliest.period_start
const isForward = body.period_start > latest.period_end
if (!isBackward && !isForward) {
// Neither backward nor forward — must overlap or be in the middle
return NextResponse.json(
{ error: `Period must start on ${expectedStart} (day after latest period ends)` },
{ error: 'New period must chain before the earliest or after the latest existing period' },
{ status: 400 }
)
}
}
// Enforce: max one unclosed period (no skipping ahead)
const { count: openCount } = await supabase
.from('fiscal_periods')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_closed', false)
if (isBackward) {
// Backward chaining: new period_end must be day before earliest period_start
const expectedEnd = new Date(earliest.period_start + 'T12:00:00Z')
expectedEnd.setUTCDate(expectedEnd.getUTCDate() - 1)
const expectedEndStr = expectedEnd.toISOString().split('T')[0]
if (body.period_end !== expectedEndStr) {
return NextResponse.json(
{ error: `Period must end on ${expectedEndStr} (day before earliest period starts)` },
{ status: 400 }
)
}
// Skip "no unclosed period" constraint for backward chaining (backfill needs the period open)
} else {
// Forward chaining: keep existing constraints (contiguity + no unclosed periods)
const prev = new Date(latest.period_end + 'T12:00:00Z')
prev.setUTCDate(prev.getUTCDate() + 1)
const expectedStart = prev.toISOString().split('T')[0]
if (body.period_start !== expectedStart) {
return NextResponse.json(
{ error: `Period must start on ${expectedStart} (day after latest period ends)` },
{ status: 400 }
)
}
if (openCount && openCount > 0) {
return NextResponse.json(
{ error: 'Cannot create a new period while an unclosed period exists' },
{ status: 409 }
)
// Enforce: max one unclosed period (no skipping ahead) — forward only
const { count: openCount } = await supabase
.from('fiscal_periods')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_closed', false)
if (openCount && openCount > 0) {
return NextResponse.json(
{ error: 'Cannot create a new period while an unclosed period exists' },
{ status: 409 }
)
}
}
}
// Defense-in-depth: check for overlapping periods
@@ -122,5 +148,17 @@ export async function POST(request: Request) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// For backward chaining: update the old earliest period's previous_period_id
if (allPeriods && allPeriods.length > 0) {
const earliest = allPeriods[0]
if (body.period_end < earliest.period_start) {
await supabase
.from('fiscal_periods')
.update({ previous_period_id: data.id })
.eq('id', earliest.id)
.eq('company_id', companyId)
}
}
return NextResponse.json({ data })
}
+3 -2
View File
@@ -18,6 +18,7 @@ interface ExecuteRequest {
file_hash: string
skip_duplicates: boolean
auto_categorize: boolean
settlement_account?: string
}
/**
@@ -41,7 +42,7 @@ export async function POST(request: Request) {
const companyId = await requireCompanyId(supabase, user.id)
const body: ExecuteRequest = await request.json()
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true, settlement_account } = body
if (!transactions || transactions.length === 0) {
return NextResponse.json({ error: 'No transactions to import' }, { status: 400 })
@@ -84,7 +85,7 @@ export async function POST(request: Request) {
}))
// Run ingestion pipeline
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions)
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, settlement_account ? { settlementAccount: settlement_account } : undefined)
// Update import record with results
await supabase