Files
accounted/app/api/bookkeeping/fiscal-periods/route.ts
T
MattssonandClaude Opus 4.8 2a8bf9b42e Bug/year end numbers (#744)
* fix(bookkeeping): allow creating a fiscal year that fills an interior gap

Fiscal-period creation only allowed chaining a new räkenskapsår before the
earliest or after the latest existing period, so a company with a gap between
years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the
missing year — it failed with "New period must chain before the earliest or
after the latest existing period".

Generalise forward chaining onto the new period's immediate predecessor, which
covers both appending a new latest year and filling an interior gap. The
"prior year must be locked" guard now applies only to true appends, not gap
fills (a backfill, like backward chaining). previous_period_id is set to the
predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain
stays intact. The create dialog suggests the missing year (capped so it never
overlaps the next period), the settings page seeds the dialog at the earliest
gap, and the default suggested name is now "Räkenskapsår <year>".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end

Year-end closing posts the result to 2099 "Årets resultat" and the opening
balance carried it forward on 2099 every year, so 2099 accumulated across
years and the prior result never moved off "Årets resultat".

executeYearEndClosing now posts a separate "Omföring av föregående års
resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss)
into the new period after the continuity check passes, so 2099 starts each
year at zero. Kept as a standalone entry rather than folded into the opening
balance so the IB stays a faithful mirror of the prior UB and IB/UB
continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat.
The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally
left to a separate step.

- new source_type 'result_appropriation' (migration + type + Zod enum)
- generateResultAppropriation helper (planner + poster) wired as step 11
- ResultStep surfaces the omföring voucher
- unit tests + pg-real invariant
- scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): shadow-detect date-drift duplicate bank transactions

The content-dedup bridge buckets on exact (date, ore), so the same
transaction re-imported with a booking date that drifted a day lands in
a different bucket and slips past every dedup layer. Add a measure-only
("shadow") detector that flags would-be +/-1-day duplicates and counts
them, without changing what is inserted - so the gap can be validated on
real data before any enforcement, mirroring the scope-drift shadow.

- shiftIsoDate(): pure, deterministic adjacent-date helper
- ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket
  snapshot, per-row gate with desc-bridge + cross-channel-symmetry
  signals; logs shadow_date_drift_candidates, never alters inserts
- fail-safe date guard so the measurement can never abort an import
- regression tests for both signals, account/window/distinct guards,
  no-double-count, and the malformed-date fail-safe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(bookkeeping): anonymize a customer reference in fiscal-period tests

Remove a real customer name ("AXMD AB") from regression-test comments;
no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(workflows): enhance Docker image scanning and caching mechanisms

* fix(bookkeeping): enhance year-end result appropriation handling and error reporting

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:22:09 +02:00

244 lines
9.8 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'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/bookkeeping/fiscal-periods')
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 })
}
// Identify the new period's immediate neighbours. Fiscal periods never overlap
// (the no_overlapping_fiscal_periods DB exclusion constraint), so ordering by
// period_start is also ordering by period_end.
// predecessor = closest existing period ending before the new one starts
// successor = closest existing period starting after the new one ends
const sortedPeriods = allPeriods ?? []
const predecessor = [...sortedPeriods].reverse().find((p) => p.period_end < body.period_start) ?? null
const successor = sortedPeriods.find((p) => p.period_start > body.period_end) ?? null
if (sortedPeriods.length > 0) {
const earliest = sortedPeriods[0]
const latest = sortedPeriods[sortedPeriods.length - 1]
const isPrepend = body.period_end < earliest.period_start
const isAppend = body.period_start > latest.period_end
if (isPrepend) {
// Prepend before the earliest period: new period_end must be the day before
// the earliest period starts. Skip the "no open prior period" constraint —
// backfilling an earlier year needs that year to stay open.
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 }
)
}
} else {
// Forward-like: either append a new latest year OR fill an interior gap
// between two existing years. Both must chain onto their immediate
// predecessor — i.e. start the day after it ends. When appending, the
// predecessor IS the latest period (original forward-chaining behaviour);
// when filling a gap, it's the year just before the hole.
if (predecessor) {
const next = new Date(predecessor.period_end + 'T12:00:00Z')
next.setUTCDate(next.getUTCDate() + 1)
const expectedStart = next.toISOString().split('T')[0]
if (body.period_start !== expectedStart) {
return NextResponse.json(
{ error: `Period must start on ${expectedStart} (day after the preceding period ends)` },
{ status: 400 }
)
}
}
// No predecessor here means the new period reaches back over the earliest
// existing period (an overlap) — the overlap check below returns 409.
// Gap fill: the new period must also butt up against its SUCCESSOR — end
// exactly the day before the successor starts — so it fills the hole
// completely. The predecessor check above only constrains the start side.
// Without this end check a too-short period would leave a fresh sub-gap
// yet still get the successor's previous_period_id relinked onto it
// (below), silently breaking the BFNAR 2013:2 continuity chain; a too-long
// period that bleeds past the successor is separately caught as an overlap
// (409). Appends have no successor, so this is skipped.
if (successor) {
const prevDay = new Date(successor.period_start + 'T12:00:00Z')
prevDay.setUTCDate(prevDay.getUTCDate() - 1)
const expectedEnd = prevDay.toISOString().split('T')[0]
if (body.period_end !== expectedEnd) {
return NextResponse.json(
{ error: `Period must end on ${expectedEnd} (day before the following period starts)` },
{ status: 400 }
)
}
}
// The "prior year must be locked" guard applies only when appending a new
// latest räkenskapsår, not when backfilling a gap between existing years.
// A gap fill is a backfill (like prepend) and must not be blocked by an
// open neighbouring year.
//
// A period counts as "effectively locked" if its own locked_at is set, OR
// company_settings.bookkeeping_locked_through covers its end date (the
// enforce_company_lock_date trigger blocks any entry on/before that date).
// BFL 6 kap allows löpande bokföring of the new year in parallel with
// bokslut work on the prior year, so locked-but-not-closed prior periods
// must not block creating the next räkenskapsår.
if (isAppend) {
const { data: openPeriods } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.eq('is_closed', false)
.is('locked_at', null)
.order('period_start', { ascending: true })
const { data: settings } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
const lockThrough = settings?.bookkeeping_locked_through ?? null
const trulyOpen = (openPeriods ?? []).filter(
(p) => !(lockThrough && p.period_end <= lockThrough)
)
if (trulyOpen.length > 0) {
// Hand the blocking periods (id + name + dates) to the client so the
// "Skapa räkenskapsår" dialog can offer to lock them inline and retry,
// instead of dead-ending the user on a message they can't act on.
const blockingPeriods = trulyOpen.map((p) => ({
id: p.id,
name: p.name,
period_start: p.period_start,
period_end: p.period_end,
}))
return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, {
details: { blockingPeriods },
})
}
}
}
}
// 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 }
)
}
// Chain the new period onto its predecessor (append or gap fill) so reports
// can walk the BFNAR 2013:2 continuity chain instead of scanning every prior
// journal line. Prepend leaves this null and instead relinks the old earliest
// period to follow the new one (below).
const previousPeriodId = predecessor ? predecessor.id : null
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 })
}
// Keep the continuity chain intact for the period that now follows the new one:
// - Prepend: the old earliest period follows the new (earlier) period.
// - Gap fill: the successor period follows the new period.
// (Append has no successor, so nothing to relink.)
if (sortedPeriods.length > 0) {
const earliest = sortedPeriods[0]
const isPrepend = body.period_end < earliest.period_start
const periodToRelink = isPrepend ? earliest : successor
if (periodToRelink) {
await supabase
.from('fiscal_periods')
.update({ previous_period_id: data.id })
.eq('id', periodToRelink.id)
.eq('company_id', companyId)
}
}
return NextResponse.json({ data })
}