* 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.
165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
|
|
export async function GET() {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const companyId = await requireCompanyId(supabase, user.id)
|
|
|
|
const { data, error } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.order('period_start', { ascending: false })
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
if (!writeCheck.ok) return writeCheck.response
|
|
|
|
const companyId = await requireCompanyId(supabase, user.id)
|
|
|
|
const validation = await validateBody(request, CreateFiscalPeriodSchema)
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
|
|
// Fetch all existing periods to determine direction
|
|
const { data: allPeriods } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id, period_start, period_end, is_closed')
|
|
.eq('company_id', companyId)
|
|
.order('period_start', { ascending: true })
|
|
|
|
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 })
|
|
if (durationError) {
|
|
return NextResponse.json({ error: durationError }, { status: 400 })
|
|
}
|
|
|
|
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: 'New period must chain before the earliest or after the latest existing period' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
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 }
|
|
)
|
|
}
|
|
|
|
// 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
|
|
const { data: overlapping } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id, name')
|
|
.eq('company_id', companyId)
|
|
.lte('period_start', body.period_end)
|
|
.gte('period_end', body.period_start)
|
|
.limit(1)
|
|
|
|
if (overlapping && overlapping.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: `Overlaps with existing period: ${overlapping[0].name}` },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('fiscal_periods')
|
|
.insert({
|
|
user_id: user.id,
|
|
company_id: companyId,
|
|
name: body.name,
|
|
period_start: body.period_start,
|
|
period_end: body.period_end,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
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 })
|
|
}
|