Files
accounted/lib/core/bookkeeping/period-service.ts
T
5d735097a1 fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only (#2393)
* fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only

markPeriodClosedExternally decided "migrated" by asking whether the period
had any non-imported verifikat at all. That was a proxy for the thing the
guard protects (a bokslutsverifikat transferring 3xxx-8xxx, BFL 5-6 kap),
and it shut out the migrated first year whose only native voucher re-keys
the opening balance (1930 D / 2081 K aktiekapital) after a failed SIE
import. With the next year's IB already imported, the normal year-end
refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all.

The guard now asks the ledger the real question: are there lines on result
accounts (BAS class 3-8) in the period? Id-only entry fetch plus per-chunk
head counts with early exit, no line fetch, no journal_entries!inner embed.
The refusal message names what was found and why the normal year-end is
the remedy.

The loaded årsredovisning view also gets the FyPicker in its header. The
no-period branch auto-jumps to the remembered scope (or the newest year)
before its own picker is ever seen, so a user whose scope pointed at the
historical year had no way to reach the current year's årsredovisning
except by changing the scope on some other page and coming back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZjtjw5xnC2SVaEr5HqG8Q

* fix(period): PR #2393 round 1: require next-year IB for balance-sheet-only klarmarkera, bound result range to class 3-8

Triage of the swedish-compliance review:

- Balance-sheet-only native years bypassing the bokslut: partly real. The
  result transfer is not the only thing the normal year-end does; it also
  posts the next year's IB. A natively bookkept year with only balance-
  sheet vouchers (dormant AB, aktiekapital deposit only) could have been
  klarmarkerad and its balances would never have reached the next year.
  The leg now additionally requires the next period to carry IB already,
  which is exactly the shape where the normal year-end refuses
  (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path. Without that IB
  the normal year-end works and stays the remedy, and the message says so.
  The suggested IB-correction tag on entries was not adopted: the ledger
  already answers the question, a tag would be one more concept to know.
- Unbounded account range: real, cosmetic. Bounded with lt '9' so class 9
  interna poster no longer count as result accounts. Storage format is the
  4-digit BAS string every classifier in the codebase already relies on
  (isBalanceSheetAccount reads charAt(0)); the text compare is as robust as
  those.

Tests: next year with IB passes, next year without IB refused, no next
year refused, lt('account_number', '9') asserted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZjtjw5xnC2SVaEr5HqG8Q

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:10:41 +02:00

1067 lines
39 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
}
/** Max journal_entry ids per `.in()` filter: keeps the request URL short. */
const RESULT_LINE_CHECK_CHUNK_SIZE = 100
interface PeriodLedgerShape {
/** Number of journal entries dated inside the period, any source_type. */
entryCount: number
/** Whether any of their lines sits on a result account (BAS class 3-8). */
hasResultLines: boolean
}
/**
* How much bookkeeping the period holds and whether any of it sits on a
* result account (BAS class 3-8), i.e. whether a bokslutsverifikat would
* have anything to transfer. An existence check, not a fetch: entries are
* read id-only, and lines are head-counted per id chunk with early exit, so
* a natively bookkept year answers on its first chunk. Two-step by design;
* see lib/bookkeeping/entry-lines.ts for why the `journal_entries!inner`
* embed is avoided. Throws on any query error so the caller fails closed.
*/
async function inspectPeriodLedger(
supabase: SupabaseClient,
companyId: string,
periodStart: string,
periodEnd: string,
): Promise<PeriodLedgerShape> {
const entries = await fetchAllRows<{ id: string }>(
({ from, to }) =>
supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.gte('entry_date', periodStart)
.lte('entry_date', periodEnd)
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: (e) => e.id },
)
for (let i = 0; i < entries.length; i += RESULT_LINE_CHECK_CHUNK_SIZE) {
const chunk = entries.slice(i, i + RESULT_LINE_CHECK_CHUNK_SIZE).map((e) => e.id)
const { count, error } = await supabase
.from('journal_entry_lines')
.select('id', { count: 'exact', head: true })
.in('journal_entry_id', chunk)
// Account numbers are strings, so these are text comparisons on the
// BAS class digit: classes 3-8 sort at or above '3' and below '9';
// classes 1-2 (balance sheet) sort below '3', class 9 (interna
// poster, never transferred by a bokslut) at or above '9'.
.gte('account_number', '3')
.lt('account_number', '9')
if (error) throw error
if ((count ?? 0) > 0) return { entryCount: entries.length, hasResultLines: true }
}
return { entryCount: entries.length, hasResultLines: false }
}
/**
* 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 and the next year
// without IB (BFL 5-6 kap), with no clean way back once locked.
// "Migrated" is read from the ledger itself. The period passes when it
// contains SIE-imported verifikat (source_type='import'), or when it holds
// no verifikat at all (year closed elsewhere, never imported here), or
// when its native verifikat touch balance-sheet accounts only AND the
// next period already carries its IB. That third leg is the migrated
// first year whose SIE import failed and whose owner re-keyed the opening
// voucher (1930/2081 aktiekapital) by hand: nothing for a bokslut to
// transfer, the balances already continue into the next year, and the
// normal year-end refuses on exactly that IB (NEXT_PERIOD_HAS_IB). Until
// this leg existed the year could not be closed by any path. A native
// balance-sheet-only year whose next period lacks IB stays refused: there
// the normal year-end works and is what carries the balances forward.
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) {
let ledger: PeriodLedgerShape
try {
ledger = await inspectPeriodLedger(
supabase,
companyId,
period.period_start,
period.period_end,
)
} catch {
throw new Error('Kunde inte kontrollera periodens verifikat. Försök igen.')
}
if (ledger.hasResultLines) {
throw new Error(
'Perioden innehåller resultatkonton (3000-8999) bokförda i Accounted och inga importerade verifikat. Använd det vanliga årsbokslutet i stället, så att årets resultat förs över.'
)
}
if (ledger.entryCount > 0) {
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
if (!nextPeriod?.opening_balance_entry_id) {
throw new Error(
'Perioden innehåller bokföring skapad i Accounted och nästa räkenskapsår saknar ingående balanser. Använd det vanliga årsbokslutet i stället, så förs balanserna över.'
)
}
}
}
// 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 }
}