Files
accounted/app/api/bookkeeping/fiscal-periods/route.ts
T
Mattsson 24107338fa Fix/balance inconsitency (#306)
* feat: implement fiscal period date fields component and validation logic

* feat: update fiscal period validation and naming logic

* feat: implement RPC for computing prior opening balances

- Added `compute_prior_opening_balances` RPC to aggregate opening balances for balance-sheet accounts when no opening balance entry is set.
- Updated tests across various reports to utilize the new RPC for fetching prior balances.
- Refactored `getOpeningBalances` to call the RPC when necessary, improving performance and reliability.
- Introduced a script to repair fiscal period chains for companies with broken periods, ensuring proper linking and continuity.
- Enhanced error handling and validation in the repair script to ensure data integrity during the process.

* feat: implement duplicate opening-balance repair for multi-year SIE imports

* feat: enhance SIE entry listing and deduplication logic for opening balances

* fix: refine companyHasPriorActivity logic to exclude storno entries and improve balance counting
2026-04-21 21:39:57 +02:00

179 lines
6.1 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 }
)
}
// Resolve previous_period_id for forward chaining so the new period is
// linked to the period it follows. Without this, balance-sheet/trial-balance
// reports fall back to scanning every prior journal line (BFNAR 2013:2
// continuity chain is broken). Backward chaining sets previous_period_id
// on the old earliest period instead (see below), not on the new one.
let previousPeriodId: string | null = null
if (allPeriods && allPeriods.length > 0) {
const latest = allPeriods[allPeriods.length - 1]
if (body.period_start > latest.period_end) {
previousPeriodId = latest.id
}
}
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,
previous_period_id: previousPeriodId,
})
.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 })
}