feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)

* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2)

A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an
account was reconciled against to (account_key, through_date), before or
after the sign-off, from every account body on the Avstämning page. Rows
live in account_reconciliation_attachments (append-only, removal stamp by
trigger, RLS like account_reconciliations), bytes in the documents bucket
under the company prefix so its RLS applies unchanged, and the full
archive copies them into bilagor/ with a hash manifest.

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

* fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them

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

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867)

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3)

The bokslut checklist is a catalogue in code with one state row per period
(bokslut_checklist_items): the steps the system can judge (sign-offs through
balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are
computed live and a stored row only overrides them; the manual steps are
the konsult's ticks, with who and when. It sits on the wizard's Kontroll
step and is dumped into the full archive.

A hole between fiscal years (one-file SIE migrations) is now named on the
bokslut readiness screen and on the import result screen, where the next
file is one click away. Non-adjacent period links are #1849's fix.

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

* fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
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>
This commit is contained in:
Jakob Wennberg
2026-08-25 09:34:05 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent ebbdf96b74
commit c62321988b
30 changed files with 2642 additions and 1 deletions
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const listAccountsMock = vi.fn()
const readinessMock = vi.fn()
vi.mock('@/lib/reconciliation/service', () => ({
listReconciliationAccounts: (...args: unknown[]) => listAccountsMock(...args),
}))
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
validateYearEndReadiness: (...args: unknown[]) => readinessMock(...args),
}))
import {
BOKSLUT_CHECKLIST,
assembleChecklist,
buildBokslutChecklist,
computeAutoStates,
setChecklistItem,
type ChecklistRow,
} from '../checklist'
const COMPANY = 'company-1'
const USER = 'user-1'
const PERIOD = { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' }
function account(overrides: Record<string, unknown>) {
return {
account_key: 'manual:2350',
kind: 'manual',
account_number: '2350',
name: 'Banklån',
currency: 'SEK',
logo_url: null,
source: { type: 'manual', synced_at: null, stale: false },
status: { state: 'open', as_of: '2026-12-31T00:00:00.000Z', unexplained_difference: null, open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 } },
superseded_by: null,
signed_off_through: null,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
listAccountsMock.mockReset()
readinessMock.mockReset()
})
describe('catalogue', () => {
it('has unique keys that satisfy the table CHECK, in work order', () => {
const keys = BOKSLUT_CHECKLIST.map((d) => d.key)
expect(new Set(keys).size).toBe(keys.length)
for (const k of keys) expect(k).toMatch(/^[a-z0-9_]{1,64}$/)
expect(keys[0]).toBe('bank_signed')
expect(keys[keys.length - 1]).toBe('annual_accounts_reviewed')
})
})
describe('computeAutoStates', () => {
it('judges sign-offs through the balansdag, tie-outs, and readiness counts', () => {
const accounts = [
account({ account_key: 'bank:1', kind: 'bank', account_number: '1930', signed_off_through: '2026-12-31' }),
account({ account_key: 'bank:2', kind: 'bank', account_number: '1931', signed_off_through: '2026-11-30', superseded_by: 'bank:1' }),
account({ account_key: 'skattekonto', kind: 'skattekonto', account_number: '1630', signed_off_through: null }),
account({ account_key: 'manual:1510', account_number: '1510', status: { state: 'reconciled', as_of: 'x', unexplained_difference: 0, open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 } } }),
account({ account_key: 'manual:2350', signed_off_through: '2026-12-31' }),
account({ account_key: 'manual:2990', account_number: '2990', signed_off_through: '2026-06-30' }),
]
const states = computeAutoStates(accounts as never, { draftCount: 2, unexplainedGaps: 0, trialBalanceBalanced: true }, '2026-12-31')
expect(Object.fromEntries(states)).toEqual({
bank_signed: 'done',
skattekonto_signed: 'open',
ar_reconciled: 'done',
ap_reconciled: 'not_applicable',
balance_accounts_signed: 'open',
no_drafts: 'open',
voucher_gaps_explained: 'done',
trial_balance_balanced: 'done',
})
})
it('marks feeds the company does not have as not applicable and leaves unknown inputs out', () => {
const states = computeAutoStates([], null, '2026-12-31')
expect(states.get('bank_signed')).toBe('not_applicable')
expect(states.get('skattekonto_signed')).toBe('not_applicable')
expect(states.has('no_drafts')).toBe(false)
expect(computeAutoStates(null, null, '2026-12-31').size).toBe(0)
})
})
describe('assembleChecklist', () => {
it('lets a stored row override the computed state and counts the summary', () => {
const rows: ChecklistRow[] = [
{ item_key: 'skattekonto_signed', state: 'not_applicable', note: 'Inget skattekonto kopplat', done_by: USER, done_at: '2027-01-05T08:00:00Z', updated_by: USER, updated_at: '2027-01-05T08:00:00Z' },
{ item_key: 'inventory_valued', state: 'done', note: null, done_by: USER, done_at: '2027-01-06T08:00:00Z', updated_by: USER, updated_at: '2027-01-06T08:00:00Z' },
]
const auto = new Map([['skattekonto_signed', 'open' as const], ['no_drafts', 'done' as const]])
const list = assembleChecklist(PERIOD, auto, rows)
const byKey = Object.fromEntries(list.items.map((i) => [i.key, i]))
expect(byKey.skattekonto_signed).toMatchObject({ auto_state: 'open', stored_state: 'not_applicable', effective_state: 'not_applicable', note: 'Inget skattekonto kopplat' })
expect(byKey.inventory_valued).toMatchObject({ auto_state: null, effective_state: 'done', done_at: '2027-01-06T08:00:00Z' })
expect(byKey.no_drafts).toMatchObject({ auto_state: 'done', stored_state: null, effective_state: 'done' })
expect(byKey.accruals_posted.effective_state).toBe('open')
expect(list.summary).toEqual({ total: BOKSLUT_CHECKLIST.length, done: 2, not_applicable: 1, open: BOKSLUT_CHECKLIST.length - 3 })
})
})
describe('buildBokslutChecklist', () => {
it('reads the period and rows, computes readiness when not given, and survives a failed reconciliation read', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: [{ item_key: 'inventory_valued', state: 'done', note: null, done_by: USER, done_at: '2027-01-06T08:00:00Z', updated_by: USER, updated_at: '2027-01-06T08:00:00Z' }] })
listAccountsMock.mockRejectedValue(new Error('down'))
readinessMock.mockResolvedValue({ draftCount: 0, unexplainedGaps: [{ series: 'A', from: 12, to: 12 }], trialBalanceBalanced: true })
const list = await buildBokslutChecklist(supabase as never, COMPANY, USER, 'fy-2026')
expect(list?.period.id).toBe('fy-2026')
const byKey = Object.fromEntries(list!.items.map((i) => [i.key, i]))
expect(byKey.inventory_valued.effective_state).toBe('done')
expect(byKey.voucher_gaps_explained.effective_state).toBe('open')
expect(byKey.bank_signed).toMatchObject({ auto_state: null, effective_state: 'open' })
expect(listAccountsMock).toHaveBeenCalledWith(supabase, COMPANY, { today: '2026-12-31', windowFrom: '2026-01-01', windowTo: '2026-12-31' })
expect(readinessMock).toHaveBeenCalledWith(supabase, COMPANY, USER, 'fy-2026')
})
it('skips the readiness computation when told to, and returns null for a foreign period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: [] })
listAccountsMock.mockResolvedValue([])
await buildBokslutChecklist(supabase as never, COMPANY, USER, 'fy-2026', { readiness: null })
expect(readinessMock).not.toHaveBeenCalled()
enqueue({ data: null })
expect(await buildBokslutChecklist(supabase as never, COMPANY, USER, 'nope')).toBeNull()
})
})
describe('setChecklistItem', () => {
it('upserts as the acting user with the done stamp, and refuses unknown items or bad states', async () => {
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
enqueue({ data: { item_key: 'inventory_valued', state: 'done', note: 'Inventerat 2026-12-30', done_by: USER, done_at: 'x', updated_by: USER, updated_at: 'x' } })
const row = await setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'inventory_valued', state: 'done', note: ' Inventerat 2026-12-30 ' })
expect(row.state).toBe('done')
const [payload, opts] = findCall('bokslut_checklist_items', 'upsert') as [Record<string, unknown>, { onConflict: string }]
expect(payload).toMatchObject({ company_id: COMPANY, fiscal_period_id: 'fy-2026', item_key: 'inventory_valued', state: 'done', note: 'Inventerat 2026-12-30', done_by: USER, updated_by: USER })
expect(payload.done_at).toBeTruthy()
expect(opts.onConflict).toBe('company_id,fiscal_period_id,item_key')
enqueue({ data: { item_key: 'inventory_valued', state: 'open', note: null, done_by: null, done_at: null, updated_by: USER, updated_at: 'x' } })
await setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'inventory_valued', state: 'open' })
const upserts = findCalls('bokslut_checklist_items', 'upsert')
const [reopened] = upserts[upserts.length - 1] as [Record<string, unknown>]
expect(reopened).toMatchObject({ done_by: null, done_at: null })
await expect(setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'nope', state: 'done' })).rejects.toMatchObject({ code: 'UNKNOWN_ITEM' })
await expect(setChecklistItem(supabase as never, COMPANY, USER, 'fy-2026', { item_key: 'no_drafts', state: 'maybe' as never })).rejects.toMatchObject({ code: 'INVALID_STATE' })
})
})
+275
View File
@@ -0,0 +1,275 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { listReconciliationAccounts } from '@/lib/reconciliation/service'
import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
const log = createLogger('bokslut/checklist')
/**
* The bokslut checklist: the closing steps a redovisningskonsult documents
* per räkenskapsår (Reko 760/765), as a fixed catalogue in code with one
* state row per item in bokslut_checklist_items. Steps the system can judge
* (drafts, voucher gaps, trial balance, sign-offs through balansdagen, the
* reskontra tie-outs) are computed live every time; a stored row overrides
* them (typically "ej tillämpligt") and records the manual ones. The order
* below is the order of the work.
*/
export type ChecklistState = 'open' | 'done' | 'not_applicable'
export type ChecklistGroup = 'avstamning' | 'periodisering' | 'vardering' | 'dispositioner' | 'kontroll' | 'rapportering'
export interface ChecklistItemDef {
key: string
group: ChecklistGroup
label_sv: string
label_en: string
hint_sv?: string
hint_en?: string
/** True when the system computes the state itself; a stored row still overrides. */
auto: boolean
/** Where the work is done. */
href?: string
}
export const BOKSLUT_CHECKLIST: readonly ChecklistItemDef[] = [
{ key: 'bank_signed', group: 'avstamning', label_sv: 'Bankkonton avstämda och signerade per balansdagen', label_en: 'Bank accounts reconciled and signed off as of the balance sheet date', auto: true, href: '/reconciliation' },
{ key: 'skattekonto_signed', group: 'avstamning', label_sv: 'Skattekontot avstämt mot Skatteverket och signerat', label_en: 'Tax account reconciled against Skatteverket and signed off', auto: true, href: '/reconciliation?account=skattekonto' },
{ key: 'ar_reconciled', group: 'avstamning', label_sv: 'Kundfordringar stämda mot kundreskontran', label_en: 'Receivables agree with the customer ledger', auto: true, href: '/reconciliation?account=manual%3A1510' },
{ key: 'ap_reconciled', group: 'avstamning', label_sv: 'Leverantörsskulder stämda mot leverantörsreskontran', label_en: 'Payables agree with the supplier ledger', auto: true, href: '/reconciliation?account=manual%3A2440' },
{ key: 'balance_accounts_signed', group: 'avstamning', label_sv: 'Övriga balanskonton avstämda mot underlag och signerade', label_en: 'Other balance sheet accounts reconciled against documents and signed off', auto: true, href: '/reconciliation' },
{ key: 'underlag_attached', group: 'avstamning', label_sv: 'Underlag bifogat till avstämningarna (kontoutdrag, engagemangsbesked, reskontralistor)', label_en: 'Supporting documents attached to the reconciliations (statements, engagement letters, ledger lists)', auto: false, href: '/reconciliation' },
{ key: 'vat_settled', group: 'avstamning', label_sv: 'Momskontona avstämda mot lämnade deklarationer', label_en: 'VAT accounts agree with the filed returns', auto: false, href: '/reports/vat-declaration' },
{ key: 'accruals_posted', group: 'periodisering', label_sv: 'Periodiseringar bokförda (förutbetalda och upplupna poster)', label_en: 'Accruals and deferrals posted', auto: false, href: '/bookkeeping/year-end/periodisering' },
{ key: 'vacation_liability', group: 'periodisering', label_sv: 'Semesterlöneskuld och sociala avgifter på den avstämda', label_en: 'Vacation liability and its social fees reconciled', auto: false, href: '/reconciliation?account=manual%3A2920' },
{ key: 'inventory_valued', group: 'vardering', label_sv: 'Varulager inventerat och värderat (LVP)', label_en: 'Inventory counted and valued (lower of cost or market)', auto: false },
{ key: 'doubtful_receivables', group: 'vardering', label_sv: 'Osäkra kundfordringar bedömda och nedskrivna vid behov', label_en: 'Doubtful receivables assessed and written down where needed', auto: false },
{ key: 'depreciation_posted', group: 'vardering', label_sv: 'Avskrivningar bokförda enligt anläggningsregistret', label_en: 'Depreciation posted per the fixed asset register', auto: false, href: '/bookkeeping/year-end' },
{ key: 'dispositions_posted', group: 'dispositioner', label_sv: 'Bokslutsdispositioner bokförda (periodiseringsfond, överavskrivningar)', label_en: 'Appropriations posted (tax allocation reserve, excess depreciation)', auto: false, href: '/bookkeeping/year-end' },
{ key: 'tax_provision', group: 'dispositioner', label_sv: 'Årets skatt beräknad och bokförd', label_en: 'Current tax calculated and posted', auto: false, href: '/bookkeeping/year-end' },
{ key: 'no_drafts', group: 'kontroll', label_sv: 'Inga utkast kvar i perioden', label_en: 'No draft entries left in the period', auto: true, href: '/bookkeeping?status=draft' },
{ key: 'voucher_gaps_explained', group: 'kontroll', label_sv: 'Luckor i verifikationsnummerserien förklarade', label_en: 'Voucher number gaps explained', auto: true },
{ key: 'trial_balance_balanced', group: 'kontroll', label_sv: 'Saldobalansen balanserar', label_en: 'The trial balance balances', auto: true, href: '/reports/saldobalans' },
{ key: 'annual_accounts_reviewed', group: 'rapportering', label_sv: 'Årsbokslut eller årsredovisning upprättat och granskat', label_en: 'Annual accounts or annual report prepared and reviewed', auto: false, href: '/bookkeeping/year-end/arsredovisning' },
]
export interface ChecklistRow {
item_key: string
state: ChecklistState
note: string | null
done_by: string | null
done_at: string | null
updated_by: string
updated_at: string
}
export interface ChecklistItem extends ChecklistItemDef {
/** What the system computes, null for manual items or when it could not be computed. */
auto_state: ChecklistState | null
/** The stored row's state, null when nobody has touched the item. */
stored_state: ChecklistState | null
/** stored_state, else auto_state, else open. */
effective_state: ChecklistState
note: string | null
done_by: string | null
done_at: string | null
}
export interface BokslutChecklist {
period: { id: string; name: string; period_start: string; period_end: string }
items: ChecklistItem[]
summary: { total: number; done: number; not_applicable: number; open: number }
}
/** The subset of the readiness validation the auto items read. */
export interface ChecklistReadinessInput {
draftCount: number
unexplainedGaps: number
trialBalanceBalanced: boolean
}
interface PeriodRow {
id: string
name: string
period_start: string
period_end: string
}
function allSigned(accounts: ReconciliationAccount[], through: string): ChecklistState {
if (accounts.length === 0) return 'not_applicable'
return accounts.every((a) => a.signed_off_through != null && a.signed_off_through >= through) ? 'done' : 'open'
}
function tieOut(accounts: ReconciliationAccount[], accountNumber: string, through: string): ChecklistState {
const account = accounts.find((a) => a.kind === 'manual' && a.account_number === accountNumber)
if (!account) return 'not_applicable'
if (account.signed_off_through != null && account.signed_off_through >= through) return 'done'
return account.status?.state === 'reconciled' ? 'done' : 'open'
}
/** Auto states from the reconciliation list and the readiness counts; pure so it is testable without a client. */
export function computeAutoStates(
accounts: ReconciliationAccount[] | null,
readiness: ChecklistReadinessInput | null,
periodEnd: string,
): Map<string, ChecklistState> {
const out = new Map<string, ChecklistState>()
if (accounts) {
out.set('bank_signed', allSigned(accounts.filter((a) => a.kind === 'bank' && !a.superseded_by), periodEnd))
out.set('skattekonto_signed', allSigned(accounts.filter((a) => a.kind === 'skattekonto'), periodEnd))
out.set('ar_reconciled', tieOut(accounts, '1510', periodEnd))
out.set('ap_reconciled', tieOut(accounts, '2440', periodEnd))
out.set('balance_accounts_signed', allSigned(accounts.filter((a) => a.kind === 'manual'), periodEnd))
}
if (readiness) {
out.set('no_drafts', readiness.draftCount === 0 ? 'done' : 'open')
out.set('voucher_gaps_explained', readiness.unexplainedGaps === 0 ? 'done' : 'open')
out.set('trial_balance_balanced', readiness.trialBalanceBalanced ? 'done' : 'open')
}
return out
}
/** Merge catalogue, auto states and stored rows into the checklist; pure. */
export function assembleChecklist(
period: PeriodRow,
autoStates: Map<string, ChecklistState>,
rows: ChecklistRow[],
): BokslutChecklist {
const byKey = new Map(rows.map((r) => [r.item_key, r]))
const items: ChecklistItem[] = BOKSLUT_CHECKLIST.map((def) => {
const row = byKey.get(def.key)
const auto = def.auto ? (autoStates.get(def.key) ?? null) : null
return {
...def,
auto_state: auto,
stored_state: row?.state ?? null,
effective_state: row?.state ?? auto ?? 'open',
note: row?.note ?? null,
done_by: row?.done_by ?? null,
done_at: row?.done_at ?? null,
}
})
const summary = {
total: items.length,
done: items.filter((i) => i.effective_state === 'done').length,
not_applicable: items.filter((i) => i.effective_state === 'not_applicable').length,
open: items.filter((i) => i.effective_state === 'open').length,
}
return { period, items, summary }
}
export interface BuildChecklistOptions {
/** Pass the wizard's validation to avoid recomputing it; computed when absent. */
readiness?: ChecklistReadinessInput | null
}
/** The checklist for one period, or null when the period is not this company's. */
export async function buildBokslutChecklist(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
options: BuildChecklistOptions = {},
): Promise<BokslutChecklist | null> {
const { data: periodData, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
if (periodError) throw new Error(`Kunde inte hämta räkenskapsår: ${periodError.message}`)
const period = periodData as PeriodRow | null
if (!period) return null
const { data: rowData, error: rowError } = await supabase
.from('bokslut_checklist_items')
.select('item_key, state, note, done_by, done_at, updated_by, updated_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
if (rowError) throw new Error(`Kunde inte hämta bokslutschecklistan: ${rowError.message}`)
const rows = (rowData ?? []) as ChecklistRow[]
// The live inputs are advisory: a failed read leaves the auto items without
// a computed state rather than hiding the checklist.
let accounts: ReconciliationAccount[] | null = null
try {
accounts = await listReconciliationAccounts(supabase, companyId, {
today: period.period_end,
windowFrom: period.period_start,
windowTo: period.period_end,
})
} catch (err) {
log.warn('reconciliation accounts unavailable for checklist', { companyId, fiscalPeriodId, error: String(err) })
}
let readiness: ChecklistReadinessInput | null = options.readiness ?? null
if (readiness === undefined || readiness === null) {
if (options.readiness === undefined) {
try {
const v = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
readiness = { draftCount: v.draftCount, unexplainedGaps: v.unexplainedGaps.length, trialBalanceBalanced: v.trialBalanceBalanced }
} catch (err) {
log.warn('readiness unavailable for checklist', { companyId, fiscalPeriodId, error: String(err) })
}
}
}
return assembleChecklist(period, computeAutoStates(accounts, readiness, period.period_end), rows)
}
export type ChecklistErrorCode = 'UNKNOWN_ITEM' | 'INVALID_STATE' | 'NOTE_TOO_LONG'
export class BokslutChecklistError extends Error {
readonly code: ChecklistErrorCode
constructor(message: string, code: ChecklistErrorCode) {
super(message)
this.name = 'BokslutChecklistError'
this.code = code
}
}
export interface SetChecklistItemInput {
item_key: string
state: ChecklistState
note?: string | null
}
/** Upsert one item's state as the acting user; returns the stored row. */
export async function setChecklistItem(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
input: SetChecklistItemInput,
): Promise<ChecklistRow> {
if (!BOKSLUT_CHECKLIST.some((d) => d.key === input.item_key)) {
throw new BokslutChecklistError('Okänt steg i checklistan.', 'UNKNOWN_ITEM')
}
if (!['open', 'done', 'not_applicable'].includes(input.state)) {
throw new BokslutChecklistError('Ogiltigt läge.', 'INVALID_STATE')
}
const note = input.note?.trim() ? input.note.trim() : null
if (note && note.length > 2000) {
throw new BokslutChecklistError('Noteringen är för lång.', 'NOTE_TOO_LONG')
}
const now = new Date().toISOString()
const { data, error } = await supabase
.from('bokslut_checklist_items')
.upsert(
{
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
item_key: input.item_key,
state: input.state,
note,
done_by: input.state === 'open' ? null : userId,
done_at: input.state === 'open' ? null : now,
updated_by: userId,
updated_at: now,
},
{ onConflict: 'company_id,fiscal_period_id,item_key' },
)
.select('item_key, state, note, done_by, done_at, updated_by, updated_at')
.single()
if (error) throw new Error(`Kunde inte spara checklistan: ${error.message}`)
return data as ChecklistRow
}
+22 -1
View File
@@ -6,6 +6,7 @@ import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
import { createLogger } from '@/lib/logger'
import { describeFiscalYearGap, findFiscalYearGaps, type PeriodLike } from '@/lib/bookkeeping/fiscal-year-gaps'
import type { YearEndBlocker, YearEndValidation } from '@/types'
const log = createLogger('bokslut-readiness')
@@ -76,6 +77,26 @@ export interface BokslutReadinessReport {
* Phase 2 will replace each reminder with a concrete proposal once the
* relevant calculator ships.
*/
/**
* A missing räkenskapsår anywhere in the chain is a warning on every
* bokslut: balances do not roll across a hole (a one-file SIE migration
* that skipped a year is the usual cause). Advisory: a failed read costs
* only this warning.
*/
async function fiscalYearGapWarnings(supabase: SupabaseClient, companyId: string): Promise<string[]> {
try {
const { data, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
if (error) throw new Error(error.message)
return findFiscalYearGaps((data ?? []) as PeriodLike[]).map(describeFiscalYearGap)
} catch (err) {
log.warn('fiscal year gap check failed', { companyId, error: err instanceof Error ? err.message : String(err) })
return []
}
}
export async function buildBokslutReadinessReport(
supabase: SupabaseClient,
companyId: string,
@@ -249,7 +270,7 @@ export async function buildBokslutReadinessReport(
ready: validation.ready,
blockers: validation.errors,
blockerItems: validation.blockers,
warnings: validation.warnings,
warnings: [...validation.warnings, ...(await fiscalYearGapWarnings(supabase, companyId))],
reminders,
draftCount: validation.draftCount,
unexplainedGapCount: validation.unexplainedGaps.length,