Files
accounted/lib/core/bookkeeping/period-service.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

992 lines
36 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { addDaysIso } from '@/lib/dates/iso'
import type { FiscalPeriod } from '@/types'
const log = createLogger('period-service')
/**
* The two ways a bank transaction can still owe the period a verifikation.
*
* Triage is the act of answering "is this an affärshändelse for the company?".
* The three answers, and what each means for a period lock:
*
* is_business IS NULL, is_ignored = false -> NOT TRIAGED. Nobody has looked
* at it. Counted as `untriaged`; blocks.
* is_business = true -> TRIAGED AS A BUSINESS EVENT. It
* owes a verifikation. Blocks unless it already has one (see below);
* counted as `businessUnbooked` when it does not.
* is_business = false -> TRIAGED AND EXCLUDED (privat
* uttag / not the company's affär). Never blocks: there is nothing to
* bokföra, and holding a lock hostage to a private coffee is over-
* blocking, not compliance.
* is_ignored = true -> TRIAGED AND EXCLUDED, the user's
* explicit "hide this, never going to book it" (migration
* 20260529190000). Never blocks. The column is NOT NULL DEFAULT false,
* so `.eq('is_ignored', false)` cannot silently drop rows.
*
* "Already has a verifikation" is deliberately NOT `journal_entry_id IS NOT
* NULL`. That column only covers the 1:1 case; bulk-booked (N tx -> 1 JE, via
* transaction_voucher_links) and multi-allocated (invoice_payments /
* supplier_invoice_payments) transactions stay NULL while being anchored to a
* real verifikat. See lib/transactions/is-booked.ts and its Postgres mirror
* public.is_transaction_booked(uuid); this helper reproduces all three
* locations so the guard does not block on already-booked rows.
*/
export interface UnbookedInPeriod {
/** Never triaged: is_business IS NULL AND is_ignored = false. */
untriaged: number
/** Triaged as a business event but anchored to no verifikat at all. */
businessUnbooked: number
}
/** PostgREST rejects very long URLs, so `.in()` lists are chunked. */
const ANCHOR_LOOKUP_CHUNK = 200
/**
* Count the bank transactions in [periodStart, periodEnd] that a period lock
* would strand. Throws on any query failure so the caller can fail closed:
* never return 0 for a check that did not actually run.
*/
export async function countUnbookedInPeriod(
supabase: SupabaseClient,
companyId: string,
periodStart: string,
periodEnd: string,
): Promise<UnbookedInPeriod> {
// Leg 1: never triaged. This is the canonical "att bokföra" predicate from
// lib/worklist/categories.ts, so the number here reconciles with the
// "N st att bokföra" badge instead of being a second, unexplainable count.
// Served by the partial index idx_transactions_company_unbooked.
const { count: untriaged, error: untriagedError } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('is_business', null)
.eq('is_ignored', false)
.gte('date', periodStart)
.lte('date', periodEnd)
if (untriagedError) {
throw new Error(`untriaged transaction count failed: ${untriagedError.message}`)
}
// Leg 2: triaged as a business event, but no verifikat anywhere. The user has
// already confirmed this is the company's affärshändelse, so a lock strands
// it just as hard as an untriaged one, only with less excuse. Fetch the ids
// and subtract the ones anchored via the non-denormalized locations.
//
// Paginated via fetchAllRows with a stable id order: PostgREST silently caps
// a bare select at 1000 rows, and this candidate set is NOT bounded in
// practice, because bulk-booked transactions keep journal_entry_id NULL
// (anchored only via transaction_voucher_links). An unpaginated read would
// drop every candidate past row 1000, under-counting businessUnbooked and
// letting a period lock while genuinely unbooked affärshändelser are
// stranded in it (BFL 5 kap 2 §).
let candidates: Array<{ id?: string }>
try {
candidates = await fetchAllRows<{ id?: string }>(({ from, to }) =>
supabase
.from('transactions')
.select('id')
.eq('company_id', companyId)
.eq('is_business', true)
.eq('is_ignored', false)
.is('journal_entry_id', null)
.gte('date', periodStart)
.lte('date', periodEnd)
.order('id', { ascending: true })
.range(from, to)
)
} catch (err) {
throw new Error(
`business transaction lookup failed: ${err instanceof Error ? err.message : String(err)}`
)
}
const candidateIds = candidates
.map((row) => row.id)
.filter((id): id is string => typeof id === 'string')
if (candidateIds.length === 0) {
return { untriaged: untriaged ?? 0, businessUnbooked: 0 }
}
const anchored = new Set<string>()
for (const table of ['transaction_voucher_links', 'invoice_payments', 'supplier_invoice_payments'] as const) {
for (let i = 0; i < candidateIds.length; i += ANCHOR_LOOKUP_CHUNK) {
const chunk = candidateIds.slice(i, i + ANCHOR_LOOKUP_CHUNK)
const { data, error } = await supabase
.from(table)
.select('transaction_id')
.in('transaction_id', chunk)
if (error) {
throw new Error(`${table} anchor lookup failed: ${error.message}`)
}
for (const row of data ?? []) {
const id = (row as { transaction_id?: string | null }).transaction_id
if (id) anchored.add(id)
}
}
}
return {
untriaged: untriaged ?? 0,
businessUnbooked: candidateIds.filter((id) => !anchored.has(id)).length,
}
}
/**
* The company's earliest fiscal_periods.period_start (ISO date), or null when
* no fiscal period exists yet (brand-new company before onboarding created
* one). This is the company's "bookkeeping starts here" boundary: external
* mirrors (e.g. the skattekonto sync) use it as a lower fetch bound, and
* booking flows use it to tell "row predates the first rakenskapsar" apart
* from an ordinary locked period.
*/
export async function getEarliestFiscalPeriodStart(
supabase: SupabaseClient,
companyId: string,
): Promise<string | null> {
const { data, error } = await supabase
.from('fiscal_periods')
.select('period_start')
.eq('company_id', companyId)
.order('period_start', { ascending: true })
.limit(1)
if (error || !data || data.length === 0) return null
const start = (data[0] as { period_start?: unknown }).period_start
return typeof start === 'string' ? start : null
}
/**
* Lock a fiscal period: prevents new journal entries from being posted.
* Requires: period exists, belongs to company, not already locked/closed.
*/
export async function lockPeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
// Fetch period
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
throw new Error('Fiscal period not found')
}
if (period.is_closed) {
throw new Error('Period is already closed')
}
if (period.locked_at) {
throw new Error('Period is already locked')
}
// Refuse to lock while the period still holds bank transactions that would be
// stranded. BFL 5 kap 2 § requires every affärshändelse to be bokförd in the
// period it belongs to; once locked_at is set the enforce_period_lock trigger
// makes these rows unbookable in place, leaving affärshändelser that can only
// be entered by unlocking again or via a rättelse.
//
// Hard block, not an advisory warning with a bypass. There is no legitimate
// "lock anyway" case: locking is a voluntary internal control with no legal
// deadline, while the affärshändelser it would strand do have one (BFL 5 kap
// 2 §: kontant senast nästa arbetsdag, övrigt "så snart det kan ske").
// Every escape hatch the user could want is already an exit from the
// predicate and is named in the message: book it, mark it privat, or mark it
// ignored. Locking first and unlocking later is strictly worse, because the
// unlock lands in the immutable audit_log as a control override.
let unbooked: UnbookedInPeriod
try {
unbooked = await countUnbookedInPeriod(
supabase,
companyId,
period.period_start,
period.period_end,
)
} catch (err) {
// Fail closed. A guard that cannot run must not wave the lock through:
// that is exactly how the previous dead predicate went unnoticed.
log.error('unbooked-transaction guard failed, refusing to lock', {
companyId,
fiscalPeriodId,
reason: err instanceof Error ? err.message : String(err),
})
throw new Error(
'Kunde inte kontrollera obokförda banktransaktioner i perioden. Perioden lämnas olåst. Försök igen.'
)
}
const blockingCount = unbooked.untriaged + unbooked.businessUnbooked
if (blockingCount > 0) {
// Message wording is load-bearing for two separate matchers; keep both
// phrases when editing:
// - "saknar bokföring" -> both lock routes map this to the
// PERIOD_HAS_UNBOOKED_TRANSACTIONS envelope (400) instead of a 500
// (app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts and
// app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts)
// - /Kan inte låsa period:.*affärstransaktion/ -> inferCode() in
// lib/errors/get-structured-error.ts derives the same code for the
// MCP/agent surfaces, which have no HTTP envelope to read. The word
// "affärstransaktion" therefore has to appear unconditionally, not
// only in the breakdown clause that the untriaged-only case omits.
// The infra message above deliberately matches neither: an unreachable DB
// must not send an agent off remediating transactions.
const breakdown = [
unbooked.untriaged > 0 ? `${unbooked.untriaged} ej hanterade` : null,
unbooked.businessUnbooked > 0
? `${unbooked.businessUnbooked} markerade som affärshändelse men utan verifikat`
: null,
]
.filter(Boolean)
.join(', ')
throw new Error(
`Kan inte låsa period: ${blockingCount} banktransaktion(er) i perioden saknar bokföring ` +
`(${breakdown}). Alla affärstransaktioner måste vara bokförda innan perioden låses. ` +
`Gå till Transaktioner, bokför dem eller markera dem som privata eller ignorerade, och lås perioden därefter.`
)
}
const { data: updated, error: updateError } = await supabase
.from('fiscal_periods')
.update({ locked_at: new Date().toISOString() })
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.select()
.single()
if (updateError || !updated) {
throw new Error(`Failed to lock period: ${updateError?.message}`)
}
const result = updated as FiscalPeriod
await eventBus.emit({
type: 'period.locked',
payload: { period: result, companyId, userId },
})
return result
}
/**
* Unlock a fiscal period: clears `locked_at` so new entries can be posted.
* Requires: period exists, belongs to company, is currently locked, not closed.
*/
export async function unlockPeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
throw new Error('Fiscal period not found')
}
if (period.is_closed) {
throw new Error('Cannot unlock a closed period')
}
if (!period.locked_at) {
throw new Error('Period is not locked')
}
const priorLockedAt = period.locked_at
const { data: updated, error: updateError } = await supabase
.from('fiscal_periods')
.update({ locked_at: null })
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.select()
.single()
if (updateError || !updated) {
throw new Error(`Failed to unlock period: ${updateError?.message}`)
}
const result = updated as FiscalPeriod
// BFNAR 2013:2 p. 9.16 (behandlingshistorik): unlocking a locked period is a
// sensitive control change. Persist it to the immutable audit_log (not just
// event_log, which has 30-day TTL) so an auditor can reconstruct who
// unlocked which period and when, even years later.
await supabase.from('audit_log').insert({
user_id: userId,
company_id: companyId,
action: 'UPDATE',
table_name: 'fiscal_periods',
record_id: fiscalPeriodId,
description: `Period unlocked: ${result.name} (${result.period_start} to ${result.period_end})`,
old_state: { locked_at: priorLockedAt },
new_state: { locked_at: null },
})
await eventBus.emit({
type: 'period.unlocked',
payload: { period: result, companyId, userId },
})
return result
}
/**
* Close a fiscal period: marks it as permanently closed.
* Requires: period is locked AND closing_entry_id is set (year-end must run first).
*/
export async function closePeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
throw new Error('Fiscal period not found')
}
if (period.is_closed) {
throw new Error('Period is already closed')
}
if (!period.locked_at) {
throw new Error('Period must be locked before closing')
}
if (!period.closing_entry_id) {
throw new Error('Year-end closing must be executed before closing the period')
}
const { data: updated, error: updateError } = await supabase
.from('fiscal_periods')
.update({
is_closed: true,
closed_at: new Date().toISOString(),
})
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.select()
.single()
if (updateError || !updated) {
throw new Error(`Failed to close period: ${updateError?.message}`)
}
return updated as FiscalPeriod
}
/**
* Mark a fiscal period as closed in a previous bookkeeping system
* ("klarmarkera"). Imported historical years (SIE) arrive with
* is_closed = false and no closing entry, so the year-end page lists them as
* pending bokslut even though the bokslut was already done in the old
* software.
*
* Deliberately bypasses closePeriod's locked_at/closing_entry_id
* preconditions: the closing entry lives in the previous system. Everything
* else stays strict:
* - the period must have ended (a running year cannot be done elsewhere)
* - a period with its own closing entry goes through the normal close
* - already-closed periods are refused
* - the same unbooked-bank-transactions guard as lockPeriod applies, because
* closing strands them exactly the way locking would (BFL 5 kap 2 §)
*
* Sets locked_at too (when missing) so the period carries the full
* closed+locked state the enforcement triggers and readers expect, and writes
* the immutable audit_log entry (BFNAR 2013:2 p. 9.16: this is a control
* decision made by a person, not a year-end run).
*/
export async function markPeriodClosedExternally(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
throw new Error('Fiscal period not found')
}
if (period.is_closed) {
throw new Error('Period is already closed')
}
if (period.closing_entry_id) {
throw new Error(
'Period has a closing entry in Accounted: use the normal year-end close instead'
)
}
const today = new Date().toISOString().slice(0, 10)
if (period.period_end > today) {
throw new Error('Cannot mark a period that has not ended yet as closed')
}
// Klarmarkera exists for MIGRATED years. A period bookkept natively in
// Accounted must go through the real year-end: closing it without a
// bokslutsverifikat leaves 3xxx-8xxx untransferred (BFL 5-6 kap) with no
// clean way back once locked. "Migrated" is read from the ledger itself:
// the period either contains SIE-imported verifikat (source_type='import')
// or no verifikat at all (year closed elsewhere and never imported here).
const { count: importedCount, error: importedError } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('source_type', 'import')
.gte('entry_date', period.period_start)
.lte('entry_date', period.period_end)
if (importedError) {
throw new Error('Kunde inte kontrollera periodens verifikat. Försök igen.')
}
if ((importedCount ?? 0) === 0) {
const { count: totalCount, error: totalError } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.gte('entry_date', period.period_start)
.lte('entry_date', period.period_end)
if (totalError) {
throw new Error('Kunde inte kontrollera periodens verifikat. Försök igen.')
}
if ((totalCount ?? 0) > 0) {
throw new Error(
'Perioden innehåller bokföring skapad i Accounted och inga importerade verifikat. Använd det vanliga årsbokslutet i stället.'
)
}
}
// Same stranding guard as lockPeriod: closing makes unbooked
// affärshändelser in the period unbookable in place. Fail closed if the
// guard cannot run.
let unbooked: UnbookedInPeriod
try {
unbooked = await countUnbookedInPeriod(
supabase,
companyId,
period.period_start,
period.period_end,
)
} catch (err) {
log.error('unbooked-transaction guard failed, refusing to close externally', {
companyId,
fiscalPeriodId,
reason: err instanceof Error ? err.message : String(err),
})
throw new Error(
'Kunde inte kontrollera obokförda banktransaktioner i perioden. Perioden lämnas öppen. Försök igen.'
)
}
const blockingCount = unbooked.untriaged + unbooked.businessUnbooked
if (blockingCount > 0) {
const breakdown = [
unbooked.untriaged > 0 ? `${unbooked.untriaged} ej hanterade` : null,
unbooked.businessUnbooked > 0
? `${unbooked.businessUnbooked} markerade som affärshändelse men utan verifikat`
: null,
]
.filter(Boolean)
.join(', ')
throw new Error(
`Kan inte klarmarkera period: ${blockingCount} banktransaktion(er) i perioden saknar bokföring ` +
`(${breakdown}). Alla affärstransaktioner måste vara bokförda innan perioden stängs. ` +
`Gå till Transaktioner, bokför dem eller markera dem som privata eller ignorerade, och klarmarkera därefter.`
)
}
const now = new Date().toISOString()
const { data: updated, error: updateError } = await supabase
.from('fiscal_periods')
.update({
is_closed: true,
closed_at: now,
closed_externally: true,
locked_at: period.locked_at ?? now,
})
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
// TOCTOU guard: a concurrent normal close between the fetch above and
// this update must not be overwritten with closed_externally=true (and a
// clobbered closed_at). The predicate makes that race a 0-row update,
// which .single() surfaces as an error.
.eq('is_closed', false)
.select()
.single()
if (updateError || !updated) {
throw new Error(`Failed to mark period as externally closed: ${updateError?.message}`)
}
const result = updated as FiscalPeriod
await supabase.from('audit_log').insert({
user_id: userId,
company_id: companyId,
action: 'UPDATE',
table_name: 'fiscal_periods',
record_id: fiscalPeriodId,
description: `Period marked as closed in previous system: ${result.name} (${result.period_start} to ${result.period_end})`,
old_state: { is_closed: false, closed_at: null, locked_at: period.locked_at },
new_state: {
is_closed: true,
closed_at: result.closed_at,
closed_externally: true,
locked_at: result.locked_at,
},
})
return result
}
/**
* Undo "klarmarkera": reopen a period that markPeriodClosedExternally closed.
*
* The close was a person's control decision, not a year-end run, so undoing
* it is the same kind of decision. It is allowed only while the closed state
* still comes from klarmarkera: closed_externally is set and no closing entry
* exists in Accounted. A period closed by closePeriod keeps its
* bokslutsverifikat and is not reopened here. Clears locked_at as well: the
* reason to reopen is to change the period's contents (replace a wrong
* prior-year SIE import, add missing bokslutsdispositioner), and the user can
* klarmarkera or lock again afterwards. Written to the immutable audit_log
* (BFNAR 2013:2 kap. 8).
*
* Observed need 2026-08-27: an owner klarmarkerade five imported years, then
* found the prior-year SIE file was wrong; replace was refused on the closed
* year and unlock refused the closed state, with no way back.
*/
export async function reopenExternallyClosedPeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
throw new Error('Fiscal period not found')
}
if (!period.is_closed) {
throw new Error('Period is not closed')
}
if (!period.closed_externally || period.closing_entry_id) {
throw new Error(
'Period was closed with a year-end run in Accounted and cannot be reopened here'
)
}
const { data: updated, error: updateError } = await supabase
.from('fiscal_periods')
.update({
is_closed: false,
closed_at: null,
closed_externally: false,
locked_at: null,
})
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
// TOCTOU guard, mirror of markPeriodClosedExternally: only the klarmarkera
// state is reversible, so a concurrent normal close (closing entry set)
// makes this a 0-row update, which .single() surfaces as an error.
.eq('is_closed', true)
.eq('closed_externally', true)
.is('closing_entry_id', null)
.select()
.single()
if (updateError || !updated) {
throw new Error(`Failed to reopen period: ${updateError?.message}`)
}
const result = updated as FiscalPeriod
await supabase.from('audit_log').insert({
user_id: userId,
company_id: companyId,
action: 'UPDATE',
table_name: 'fiscal_periods',
record_id: fiscalPeriodId,
description: `Period reopened, previous-system close undone: ${result.name} (${result.period_start} to ${result.period_end})`,
old_state: {
is_closed: true,
closed_at: period.closed_at,
closed_externally: true,
locked_at: period.locked_at,
},
new_state: {
is_closed: false,
closed_at: null,
closed_externally: false,
locked_at: null,
},
})
// The lock is gone too, so downstream listeners see the same transition as
// a plain unlock.
await eventBus.emit({
type: 'period.unlocked',
payload: { period: result, companyId, userId },
})
return result
}
/**
* Create the next fiscal period following the current one.
* Computes dates based on the current period's length (handles brutet räkenskapsår).
* Sets previous_period_id for chain validation.
*/
export async function createNextPeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
currentPeriodId: string
): Promise<FiscalPeriod> {
const { data: current, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', currentPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !current) {
throw new Error('Current fiscal period not found')
}
// Compute next period start (day after current end) in pure UTC, see
// findNextPeriod for the DST off-by-one rationale.
const nextStart = new Date(current.period_end + 'T00:00:00Z')
nextStart.setUTCDate(nextStart.getUTCDate() + 1)
// After a broken first fiscal year, subsequent years should always be
// 12 months (standard fiscal year). The first year is the only one that
// can be longer/shorter than 12 months per BFL 3 kap.
const nextEnd = new Date(nextStart)
nextEnd.setUTCMonth(nextEnd.getUTCMonth() + 12)
// Go to last day of the previous month: setUTCDate(0) rolls back into
// the prior month's last day.
nextEnd.setUTCDate(0)
const nextStartStr = nextStart.toISOString().slice(0, 10)
const nextEndStr = nextEnd.toISOString().slice(0, 10)
// Validate period duration: subsequent periods always start on 1st of month
const durationError = validatePeriodDuration(nextStartStr, nextEndStr, { isFirstPeriod: false })
if (durationError) {
throw new Error(durationError)
}
// Check for overlapping periods
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', nextEndStr)
.gte('period_end', nextStartStr)
.limit(1)
if (overlapping && overlapping.length > 0) {
throw new Error('Next fiscal period already exists or overlaps with an existing period')
}
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
const startYear = nextStart.getUTCFullYear()
const endYear = nextEnd.getUTCFullYear()
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
const { data: newPeriod, error: insertError } = await supabase
.from('fiscal_periods')
.insert({
company_id: companyId,
user_id: userId,
name,
period_start: nextStartStr,
period_end: nextEndStr,
previous_period_id: currentPeriodId,
})
.select()
.single()
if (insertError || !newPeriod) {
throw new Error(`Failed to create next period: ${insertError?.message}`)
}
// Heal a chain wired across the gap this period fills: a later period that
// claims the CURRENT period as predecessor but actually starts the day
// after the new one now follows the new period (findNextPeriod explains
// how such links arise). The new period's own row cannot match: it starts
// the day after current, not the day after itself. Best-effort: the period
// is created either way, and findNextPeriod no longer trusts a
// non-adjacent link.
const { data: mischained } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.eq('previous_period_id', currentPeriodId)
.eq('period_start', addDaysIso(nextEndStr, 1))
if (mischained && mischained.length > 0) {
const successorIds = mischained.map((row: { id: string }) => row.id)
const { error: relinkError } = await supabase
.from('fiscal_periods')
.update({ previous_period_id: newPeriod.id })
.in('id', successorIds)
.eq('company_id', companyId)
if (relinkError) {
log.error('failed to relink successor onto the newly created period', relinkError, {
companyId,
newPeriodId: newPeriod.id,
successorIds,
})
}
}
return newPeriod as FiscalPeriod
}
/**
* Look up the next fiscal period after the given one without creating it.
*
* Used by year-end closing to handle the common case where the next period
* was already created (e.g. by SIE import, manual creation, or a previous
* partial year-end run). Returns null when no such period exists.
*
* Matches first on previous_period_id chain, then falls back to a
* period_start = (current.period_end + 1 day) lookup so periods created
* before the chain was wired up are still recognised.
*/
export async function findNextPeriod(
supabase: SupabaseClient,
companyId: string,
currentPeriodId: string
): Promise<FiscalPeriod | null> {
const { data: current, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', currentPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !current) {
return null
}
const { data: chained } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', companyId)
.eq('previous_period_id', currentPeriodId)
.maybeSingle()
// UTC-only arithmetic: anchor the date string at UTC midnight, then
// advance via setUTCDate. Using Date(string) + setDate/getDate causes an
// off-by-one on servers in TZ+ when the day after period_end crosses a
// DST spring-forward, because setDate(local) writes local-time fields
// and toISOString() converts back through the shifted offset.
const expectedStartStr = addDaysIso(current.period_end, 1)
// The chain is only trusted when it is date-adjacent. SIE import used to
// point previous_period_id at the NEAREST later period regardless of the
// gap (40 such rows on prod as of 2026-08-24), and year-end then seeded a
// whole missing year's opening balances into a period two years out
// because this returned the chained row unchecked (feedback seq 249297).
// A non-adjacent link means the true next period is missing or unlinked:
// fall through to the date lookup and let the caller create it.
if (chained) {
const chainedPeriod = chained as FiscalPeriod
if (chainedPeriod.period_start === expectedStartStr) {
return chainedPeriod
}
log.warn('fiscal period chain is not date-adjacent: ignoring previous_period_id link', {
companyId,
currentPeriodId,
chainedPeriodId: chainedPeriod.id,
expectedStart: expectedStartStr,
chainedStart: chainedPeriod.period_start,
})
}
const { data: byDate } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', companyId)
.eq('period_start', expectedStartStr)
.maybeSingle()
return (byDate as FiscalPeriod | null) ?? null
}
export type PeriodStatusValue = 'open' | 'locked' | 'closed'
export interface PeriodStatusForDate {
period_id: string | null
status: PeriodStatusValue
/**
* For `locked` status: either the period's `locked_at` timestamp (ISO) or the
* company-wide `bookkeeping_locked_through` date (ISO), whichever applies.
* `null` for open/closed, and `null` when `lookup_failed` is true.
*/
lock_date: string | null
/**
* True when the lock lookup itself failed (PostgREST error, an overlapping
* fiscal_periods pair breaking `.maybeSingle()`, a network blip). `status`
* is then reported as `'locked'`, because a period whose state we could not
* read must never be presented as writable.
*
* Keep the three cases distinguishable, callers treat them differently:
* { status: 'open', period_id: <id> } -> verified open
* { status: 'open', period_id: null } -> verified: no
* covering period exists at all. Not an error: the engine's
* ensure-period helper creates one on write.
* { status: 'locked', period_id: null, lookup_failed } -> unverified.
* Nothing is known about the date. Retryable; a genuinely locked
* period always carries a real period_id or lock_date.
*
* Deliberately an additive optional field rather than a fourth
* `PeriodStatusValue`: the union is consumed as an exhaustive
* `Record<PeriodStatusValue, string>` in lib/agent/intents/bokslut-step.ts,
* so widening it would break unrelated callers at compile time.
*/
lookup_failed?: boolean
}
/**
* Fail-closed verdict for a lock lookup that could not be completed.
*
* Reported as `locked` on purpose. The alternative (`open`) is the bug this
* exists to prevent: every caller of resolvePeriodStatusForDate uses the
* result to decide whether a write into a period is allowed, so a swallowed
* query error used to read as "go ahead" on the storno, MCP staging, agent
* draft and pending-operation commit paths.
*/
function periodLookupFailed(
companyId: string,
date: string,
stage: 'company_settings' | 'fiscal_periods',
error: { message?: string } | null,
): PeriodStatusForDate {
log.error('period status lookup failed, failing closed', {
companyId,
date,
stage,
reason: error?.message,
})
return { period_id: null, status: 'locked', lock_date: null, lookup_failed: true }
}
/**
* Resolve the period status for a given affärshändelse date: answers
* "can a verifikation with this entry_date be posted right now?" using the
* same two-layer logic the DB triggers enforce:
*
* 1. company-wide bookkeeping_locked_through (covers everything on/before)
* 2. the fiscal_period covering the date (is_closed or locked_at)
*
* Returned shape is the canonical `period_status` envelope threaded into MCP
* tool responses so agents and widgets can disable writes without round-trips.
*
* Fails CLOSED: if either lookup errors we report `locked` with
* `lookup_failed: true` rather than `open`. Never reintroduce a bare
* `const { data } = await ...` here; dropping the `error` is what turned this
* shared helper into a fail-open on every write path that consults it.
*
* Mirrors lib/api/v1/check-period-lock.ts (used by the v1 REST surface). The
* two helpers share the same query pattern; if either changes, update both.
*/
export async function resolvePeriodStatusForDate(
supabase: SupabaseClient,
companyId: string,
date: string,
): Promise<PeriodStatusForDate> {
// Layer 1: company-wide lock date.
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
if (settingsError) {
// Unknown company lock date: the date could be behind it. Fail closed.
return periodLookupFailed(companyId, date, 'company_settings', settingsError)
}
const lockThrough = settings?.bookkeeping_locked_through ?? null
if (lockThrough && date <= lockThrough) {
// Find the covering period if any: useful for widget greying. An error
// here is deliberately non-fatal: the verdict is already `locked` on the
// company lock date alone, so a failed refinement can only cost the
// period_id, never flip the answer to open.
const { data: period } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', date)
.gte('period_end', date)
.maybeSingle()
return { period_id: period?.id ?? null, status: 'locked', lock_date: lockThrough }
}
// Layer 2: fiscal period status.
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, is_closed, locked_at')
.eq('company_id', companyId)
.lte('period_start', date)
.gte('period_end', date)
.maybeSingle()
if (periodError) {
// Distinct from `!period` below: that is "verified, no covering period
// exists"; this is "we do not know what period covers this date".
return periodLookupFailed(companyId, date, 'fiscal_periods', periodError)
}
if (!period) {
// No covering period: treated as open at this layer; the engine's own
// ensure-period helper will create one. Agents should still warn the user.
return { period_id: null, status: 'open', lock_date: null }
}
if (period.is_closed) {
return { period_id: period.id, status: 'closed', lock_date: null }
}
if (period.locked_at) {
return { period_id: period.id, status: 'locked', lock_date: period.locked_at }
}
return { period_id: period.id, status: 'open', lock_date: null }
}