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