* fix(skattekonto): scope the sync and the avdragen-skatt rule for enskild firma
Two EF problems on the skattekonto surface:
1. Stuck pre-company rows. The sync never passed datumFrom, so SKV's
~555-day default lookback imported the owner's PERSONAL skattekonto
history from before the company existed. Those rows can never be
booked (no fiscal period covers them), never deleted (external
mirror), and had no ignore path: visible forever.
- syncSkattekonto now bounds the fetch at the company's earliest
fiscal_periods.period_start (new getEarliestFiscalPeriodStart in
period-service; no bound when no period exists yet). Applied
uniformly to EF and AB.
- New skattekonto_transactions.is_ignored column (migration
20260819080000, copies the transactions.is_ignored precedent:
CHECK that an ignored row has no journal_entry_id, partial index;
the existing company-scoped UPDATE policy already covers it) plus
PATCH /skattekonto/transaktioner/:id/ignore (409 on booked rows,
race-guarded on journal_entry_id IS NULL). Ignored rows leave the
default GET buckets; ignored_count is always reported and
include_ignored=1 returns the rows, surfaced as a count line +
"Ignorerade" band on /skattekonto and an Ignorera affordance with
confirm + Ångra on both /skattekonto and the /transactions inbox.
- PERIOD_LOCKED for a date before the first fiscal period now says
the row predates the company's bookkeeping and can be ignored,
instead of "lås upp perioden" (a dead end for those rows).
2. "Avdragen skatt" auto-mapped to 2710 for every entity type. For an
EF without employees that line is almost always A-skatt an outside
employer withheld from the owner's private salary, not the firm's
payroll liability. New data-driven skattekonto_rules.requires_employer
column (migration 20260819080100, set on the avdragen-skatt seed and
its per-company clones); the matcher gates such rules for an
enskild_firma unless company_settings.employer_registered is true
(the existing AGI gate signal, fetched in the same settings query).
Gated rows take the NO_COUNTER_ACCOUNT path with a distinct hint;
AB and employer-registered EF keep 2710 unconditionally. Regression
guard pins EF preliminärskatt to 2013.
The nightly sync upsert excludes is_ignored so it can never silently
un-ignore a row. New pg tests for the CHECK + RLS need a test:pg run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skattekonto): clamp datumFrom to the SKV window, gate ignored rows, widen the employer signal
Review fixes on the EF-scoping PR:
- sync: clamp datumFrom to max(earliestPeriodStart, today - 555 days); a
bookkeeping start older than SKV's 555-day default is omitted entirely,
since sending it would widen the window past the default and anything
older than ~915 days fails the whole sync with felkod 2. The misleading
"no-op for AB" comment is corrected and boundary tests added.
- booking/match: an ignored row now throws a typed ROW_IGNORED error
(409) before any draft is created or link is written, in both
bokforSkattekontoTransaction and matchSkattekontoToEntry.
- page: the Nasta dragning / shortfall math re-includes ignored upcoming
charges (SKV draws them regardless of our ignore flag) while the
work-list buckets keep excluding them.
- employer gate: treat employer_registered ?? pays_salaries as the
signal (same fallback as lib/tax/deadline-config.ts), so an EF that
attested pays_salaries keeps 2710 for avdragen skatt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(skattekonto): assert the is_ignored RLS toggle inside the rolled-back transaction
withUserContext always rolls back (tests/pg/setup.ts), so the previous test
wrote inside it and read the pre-write value back on the pool connection:
it failed against a correct policy and would have passed against a missing
one only by accident. The assertions now live inside the same transaction,
pin rowCount=1 (an RLS-filtered UPDATE silently matches zero rows), and a
new test pins the negative: a non-member's UPDATE matches zero rows.
Falsification-verified against a real Postgres: dropping the UPDATE policy
makes both tests fail; with the policy they pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
988 lines
35 KiB
TypeScript
988 lines
35 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 type { FiscalPeriod, PeriodStatus } 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 kap. 8 (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 kap. 8: 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
|
|
}
|
|
|
|
/**
|
|
* 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}`)
|
|
}
|
|
|
|
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()
|
|
|
|
if (chained) {
|
|
return chained as FiscalPeriod
|
|
}
|
|
|
|
// 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 = addDaysUTC(current.period_end, 1)
|
|
|
|
const { data: byDate } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.eq('period_start', expectedStartStr)
|
|
.maybeSingle()
|
|
|
|
return (byDate as FiscalPeriod | null) ?? null
|
|
}
|
|
|
|
/** Add `days` to a YYYY-MM-DD string in pure UTC and return YYYY-MM-DD. */
|
|
function addDaysUTC(isoDate: string, days: number): string {
|
|
const d = new Date(isoDate + 'T00:00:00Z')
|
|
d.setUTCDate(d.getUTCDate() + days)
|
|
return d.toISOString().slice(0, 10)
|
|
}
|
|
|
|
/**
|
|
* Create a previous fiscal period before the given one.
|
|
* Computes a 12-month period ending the day before the given period starts.
|
|
* Updates previous_period_id chain so the given period points to the new one.
|
|
*/
|
|
export async function createPreviousPeriod(
|
|
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 previous period end (day before current start)
|
|
const prevEnd = new Date(current.period_start + 'T12:00:00Z')
|
|
prevEnd.setUTCDate(prevEnd.getUTCDate() - 1)
|
|
|
|
// Compute previous period start (1st of month, 12 months before prevEnd)
|
|
const prevStart = new Date(prevEnd)
|
|
prevStart.setUTCMonth(prevStart.getUTCMonth() - 11)
|
|
prevStart.setUTCDate(1)
|
|
|
|
const prevStartStr = prevStart.toISOString().split('T')[0]
|
|
const prevEndStr = prevEnd.toISOString().split('T')[0]
|
|
|
|
// Validate period duration
|
|
const durationError = validatePeriodDuration(prevStartStr, prevEndStr, { 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', prevEndStr)
|
|
.gte('period_end', prevStartStr)
|
|
.limit(1)
|
|
|
|
if (overlapping && overlapping.length > 0) {
|
|
throw new Error('Previous fiscal period already exists or overlaps with an existing period')
|
|
}
|
|
|
|
// Generate name
|
|
const startYear = prevStart.getFullYear()
|
|
const endYear = prevEnd.getFullYear()
|
|
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: prevStartStr,
|
|
period_end: prevEndStr,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (insertError || !newPeriod) {
|
|
throw new Error(`Failed to create previous period: ${insertError?.message}`)
|
|
}
|
|
|
|
// Update the current period to point to the new one
|
|
const { error: updateError } = await supabase
|
|
.from('fiscal_periods')
|
|
.update({ previous_period_id: newPeriod.id })
|
|
.eq('id', currentPeriodId)
|
|
.eq('company_id', companyId)
|
|
|
|
if (updateError) {
|
|
throw new Error(`Failed to update period chain: ${updateError.message}`)
|
|
}
|
|
|
|
return newPeriod as FiscalPeriod
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
/**
|
|
* Get status summary for a fiscal period.
|
|
*/
|
|
export async function getPeriodStatus(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
fiscalPeriodId: string
|
|
): Promise<PeriodStatus> {
|
|
|
|
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')
|
|
}
|
|
|
|
// Count draft entries in this period
|
|
const { count: draftCount } = await supabase
|
|
.from('journal_entries')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('company_id', companyId)
|
|
.eq('fiscal_period_id', fiscalPeriodId)
|
|
.eq('status', 'draft')
|
|
|
|
// Check if next period exists via the chain pointer
|
|
const { data: nextPeriod } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('previous_period_id', fiscalPeriodId)
|
|
.maybeSingle()
|
|
|
|
return {
|
|
is_locked: !!period.locked_at,
|
|
is_closed: period.is_closed,
|
|
has_closing_entry: !!period.closing_entry_id,
|
|
has_opening_balances: period.opening_balances_set,
|
|
draft_count: draftCount ?? 0,
|
|
next_period_exists: !!nextPeriod,
|
|
}
|
|
}
|