feat: stage kontantmetod year-end cutoff (#1586)

* feat: stage kontantmetod year-end cutoff

* fix: keep cutoff tool payload searchable

* fix: trim year-end tool metadata
This commit is contained in:
Mattsson
2026-08-13 15:55:01 +02:00
committed by GitHub
parent 9be20b4274
commit 73c63209f1
20 changed files with 1460 additions and 136 deletions
+7 -5
View File
@@ -67,6 +67,7 @@ export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutSte
tools: [
'gnubok_year_end_readiness',
'gnubok_post_kontantmetod_cutoff',
'gnubok_list_fiscal_periods',
'gnubok_propose_accruals',
'gnubok_propose_annual_depreciation',
@@ -173,11 +174,12 @@ export const bokslutStep = defineAgentIntent<BokslutStepArgs, CapturedBokslutSte
lines.push('')
lines.push('Arbetssätt: hjälp användaren genom STEGET de står i:')
lines.push('1. Kör gnubok_year_end_readiness för att se vad som saknas.')
lines.push('2. Om steget är "accruals": använd gnubok_propose_accruals för periodiseringar och förklara varje förslag (när påverkar det BR/RR, varför detta belopp?).')
lines.push('3. Om steget är "depreciation": gnubok_propose_annual_depreciation. Förklara planenlig vs. överavskrivning, K2 schablonregler vs. K3 individual.')
lines.push('4. Om steget är "dispositioner": gnubok_propose_dispositioner. Periodiseringsfond, koncernbidrag (om holding), årets skatt.')
lines.push('5. Om steget är "arsredovisning": preview via gnubok_preview_arsredovisning, granska noter, förvaltningsberättelse, underskrifter, deadline.')
lines.push('6. Om EF: använd gnubok_preview_ef_declaration. Räntefördelning, expansionsfond, NE-bilaga.')
lines.push('2. Om kontantmetodens bokslutsavgränsning blockerar: använd gnubok_post_kontantmetod_cutoff, visa alla föreslagna verifikat och vändningar, och inhämta uttryckligt godkännande före bokföring.')
lines.push('3. Om steget är "accruals": använd gnubok_propose_accruals för periodiseringar och förklara varje förslag (när påverkar det BR/RR, varför detta belopp?).')
lines.push('4. Om steget är "depreciation": gnubok_propose_annual_depreciation. Förklara planenlig vs. överavskrivning, K2 schablonregler vs. K3 individual.')
lines.push('5. Om steget är "dispositioner": gnubok_propose_dispositioner. Periodiseringsfond, koncernbidrag (om holding), årets skatt.')
lines.push('6. Om steget är "arsredovisning": preview via gnubok_preview_arsredovisning, granska noter, förvaltningsberättelse, underskrifter, deadline.')
lines.push('7. Om EF: använd gnubok_preview_ef_declaration. Räntefördelning, expansionsfond, NE-bilaga.')
lines.push('')
lines.push('Var BFL-rigorös: bokslut är irreversibelt när det låses. Peka på risker innan du föreslår staging av en operation.')
lines.push('Svara på svenska. Ditt första svar är det första användaren ser: gå rakt på sak.')
+1
View File
@@ -271,6 +271,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_lock_period: 'bookkeeping:write',
gnubok_unlock_period: 'bookkeeping:write',
gnubok_run_year_end: 'bookkeeping:write',
gnubok_post_kontantmetod_cutoff: 'bookkeeping:write',
gnubok_year_end_readiness: 'reports:read',
gnubok_set_opening_balances: 'bookkeeping:write',
gnubok_run_currency_revaluation: 'bookkeeping:write',
@@ -20,16 +20,11 @@ vi.mock('@/lib/reports/supplier-reconciliation', () => ({
generateReconciliation: vi.fn(),
}))
vi.mock('@/lib/core/bookkeeping/kontantmetod-cutoff', () => ({
collectKontantmetodCutoff: vi.fn(),
}))
import { buildBokslutReadinessReport } from '../readiness-aggregator'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
import { collectKontantmetodCutoff } from '@/lib/core/bookkeeping/kontantmetod-cutoff'
const CASH_ACCOUNT_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
@@ -350,7 +345,6 @@ describe('buildBokslutReadinessReport', () => {
// only mislead.
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
vi.mocked(collectKontantmetodCutoff).mockResolvedValue({ receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] })
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null },
@@ -380,17 +374,16 @@ describe('buildBokslutReadinessReport', () => {
// The AP side still ran and reported clean independently of the AR failure.
expect(vi.mocked(generateAPReconciliation)).toHaveBeenCalled()
})
it('reminds kontantmetoden companies to book the year-end cut-off', async () => {
// BFL 5 kap 2 §: fordringar och skulder must be booked at räkenskapsårets
// utgång even though the year is otherwise kept on a cash basis.
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
vi.mocked(collectKontantmetodCutoff).mockResolvedValue({
receivables: [{ id: 'i1', reference: 'F-1', vatTreatment: 'standard_25', outstanding: 1250, vat: 250 }],
payables: [{ id: 's1', reference: 'L-1', outstanding: 500, vat: 100, netByAccount: [] }],
unknownVatTreatment: [],
strayVatOnZeroRate: [],
it('passes through the legal kontantmetoden cut-off blocker from core readiness', async () => {
const message =
'2 obetalda fakturor var utestående vid periodens slut. Förhandsgranska och bokför kontantmetodens bokslutsavgränsning.'
vi.mocked(validateYearEndReadiness).mockResolvedValue({
...baseValidation(),
ready: false,
blockers: [{ code: 'KONTANTMETOD_CUTOFF_REQUIRED', message }],
errors: [message],
})
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null },
@@ -398,28 +391,13 @@ describe('buildBokslutReadinessReport', () => {
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
const cutoff = report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required')
expect(cutoff?.severity).toBe('warning')
expect(cutoff?.message).toContain('2 obetalda fakturor')
expect(cutoff?.message).toContain('vilande')
// Advisory only: it must never flip readiness on its own.
expect(report.ready).toBe(true)
})
it('emits no cut-off reminder when nothing was outstanding at period end', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
vi.mocked(collectKontantmetodCutoff).mockResolvedValue({ receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] })
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null },
expect(report.ready).toBe(false)
expect(report.blockerItems).toContainEqual({
code: 'KONTANTMETOD_CUTOFF_REQUIRED', message,
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required')).toBeUndefined()
})
it('never runs the cut-off check for faktureringsmetoden companies', async () => {
it('keeps faktureringsmetoden reconciliation behavior unchanged', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
vi.mocked(generateARReconciliation).mockResolvedValue({ is_reconciled: true, difference: 0, unconverted_fx_count: 0 } as never)
@@ -430,20 +408,7 @@ describe('buildBokslutReadinessReport', () => {
})
await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(vi.mocked(collectKontantmetodCutoff)).not.toHaveBeenCalled()
})
it('degrades gracefully when the cut-off check fails', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
vi.mocked(collectKontantmetodCutoff).mockRejectedValue(new Error('boom'))
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.ready).toBe(true)
expect(report.reminders.find((r) => r.code === 'kontantmetod_cutoff_required')).toBeUndefined()
expect(vi.mocked(generateARReconciliation)).toHaveBeenCalled()
expect(vi.mocked(generateAPReconciliation)).toHaveBeenCalled()
})
})
-58
View File
@@ -4,7 +4,6 @@ import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliatio
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
import { collectKontantmetodCutoff } from '@/lib/core/bookkeeping/kontantmetod-cutoff'
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
import { createLogger } from '@/lib/logger'
import type { YearEndBlocker, YearEndValidation } from '@/types'
@@ -167,63 +166,6 @@ export async function buildBokslutReadinessReport(
// construction for the whole year and would only mislead.
// Warnings, never blockers: a difference can be legitimate (e.g. partial
// payments settled at a different FX rate than the invoice-date rate).
if (accountingMethod === 'cash') {
// Kontantmetoden year-end cut-off (BFL 5 kap 2 §): fordringar och skulder
// must be booked at räkenskapsårets utgång even though the year is kept on
// a cash basis. Advisory here, not a blocker: promoting it would newly
// block every cash company mid-bokslut, and the posting step is the
// founder's call to gate on.
try {
const cutoff = await collectKontantmetodCutoff(
supabase,
companyId,
period.period_start,
period.period_end,
)
const openCount = cutoff.receivables.length + cutoff.payables.length
if (openCount > 0) {
reminders.push({
code: 'kontantmetod_cutoff_required',
severity: 'warning',
message:
`${openCount} obetalda fakturor var utestående vid periodens slut. ` +
'Kontantmetoden kräver att fordringar och skulder bokförs vid ' +
'räkenskapsårets utgång (BFL 5 kap 2 §). Momsen bokas som vilande ' +
'och redovisas först vid betalning.',
href: '/reports/kundreskontra',
})
}
// Surfaced separately: these rows block the cut-off entirely, so the
// user needs to see them even when nothing else is outstanding.
if (cutoff.strayVatOnZeroRate.length > 0) {
reminders.push({
code: 'kontantmetod_cutoff_stray_vat',
severity: 'warning',
message:
`${cutoff.strayVatOnZeroRate.length} fakturor har moms trots en momsfri ` +
'momsinställning och kan inte tas med i bokslutsavgränsningen. Rätta dem innan bokslut: ' +
`${cutoff.strayVatOnZeroRate.slice(0, 5).join(', ')}`,
href: '/invoices',
})
}
if (cutoff.unknownVatTreatment.length > 0) {
reminders.push({
code: 'kontantmetod_cutoff_missing_vat_treatment',
severity: 'warning',
message:
`${cutoff.unknownVatTreatment.length} fakturor saknar momsinställning och kan ` +
'inte tas med i bokslutsavgränsningen. Komplettera dem innan bokslut: ' +
`${cutoff.unknownVatTreatment.slice(0, 5).join(', ')}`,
href: '/invoices',
})
}
} catch (err) {
// Advisory: never break the wizard on it, but keep the failure traceable
// so a silently missing reminder is not mistaken for "nothing open".
log.warn('kontantmetoden cut-off check failed; reminder omitted', err as Error)
}
}
if (accountingMethod === 'accrual') {
const [arResult, apResult] = await Promise.allSettled([
generateARReconciliation(supabase, companyId, fiscalPeriodId),
@@ -8,7 +8,11 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
import {
buildCutoffLines,
buildCutoffNote,
cutoffCollectionsEqual,
collectKontantmetodCutoff,
distributeOre,
inspectKontantmetodCutoffPostings,
KONTANTMETOD_CUTOFF_DESCRIPTIONS,
postKontantmetodCutoff,
nextDay,
reverseLines,
@@ -264,6 +268,184 @@ describe('buildCutoffNote (BFL 5 kap 6-7 §: traceability)', () => {
})
})
describe('cut-off snapshot and posting inspection', () => {
const makeJournalSupabase = (rows: unknown[], error: { message: string } | null = null) => ({
from: () => {
const query: Record<string, unknown> = {}
query.select = () => query
query.eq = () => query
query.in = () => query
query.then = (resolve: (value: unknown) => unknown) => resolve({ data: rows, error })
return query
},
}) as never
it('treats collection order as irrelevant but catches changed source data', () => {
const first = {
receivables: [receivable({ id: 'b' }), receivable({ id: 'a' })],
payables: [payable({ id: 'p' })],
unknownVatTreatment: [],
strayVatOnZeroRate: [],
}
const reordered = {
...first,
receivables: [...first.receivables].reverse(),
}
expect(cutoffCollectionsEqual(first, reordered)).toBe(true)
expect(
cutoffCollectionsEqual(first, {
...reordered,
receivables: [receivable({ id: 'a', outstanding: 1300 }), receivable({ id: 'b' })],
}),
).toBe(false)
})
it('requires exact cut-off lines and exact next-period reversals', async () => {
const lines = buildCutoffLines([receivable()], [payable()])
const rows = [
{
id: 'ar', fiscal_period_id: 'fp-1',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
lines: lines.receivableLines,
},
{
id: 'ar-rev', fiscal_period_id: 'fp-2',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
lines: reverseLines(lines.receivableLines),
},
{
id: 'ap', fiscal_period_id: 'fp-1',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable,
lines: lines.payableLines,
},
{
id: 'ap-rev', fiscal_period_id: 'fp-2',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal,
lines: reverseLines(lines.payableLines),
},
]
const status = await inspectKontantmetodCutoffPostings(
makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines,
)
expect(status).toMatchObject({
complete: true,
hasAny: true,
receivableEntryId: 'ar',
receivableReversalId: 'ar-rev',
payableEntryId: 'ap',
payableReversalId: 'ap-rev',
missing: [],
})
})
it('treats a single stale immutable marker as a conflict', async () => {
const lines = buildCutoffLines([receivable()], [])
const stale = lines.receivableLines.map((line) =>
line.account_number === '1510' ? { ...line, debit_amount: 999 } : line,
)
const rows = [
{
id: 'ar', fiscal_period_id: 'fp-1',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
lines: stale,
},
{
id: 'ar-rev', fiscal_period_id: 'fp-2',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
lines: reverseLines(lines.receivableLines),
},
]
const status = await inspectKontantmetodCutoffPostings(
makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines,
)
expect(status.complete).toBe(false)
expect(status.missing).toContain('receivable')
expect(status.duplicates).toContain(KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable)
})
it('treats multiple exact markers as a duplicate conflict', async () => {
const lines = buildCutoffLines([receivable()], [])
const rows = [
{
id: 'ar', fiscal_period_id: 'fp-1',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
lines: lines.receivableLines,
},
{
id: 'ar-duplicate', fiscal_period_id: 'fp-1',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
lines: lines.receivableLines,
},
{
id: 'ar-rev', fiscal_period_id: 'fp-2',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
lines: reverseLines(lines.receivableLines),
},
]
const status = await inspectKontantmetodCutoffPostings(
makeJournalSupabase(rows), 'co-1', 'fp-1', 'fp-2', lines,
)
expect(status.complete).toBe(false)
expect(status.missing).toContain('receivable')
expect(status.duplicates).toContain(KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable)
})
it('fails closed when the immutable journal cannot be inspected', async () => {
await expect(
inspectKontantmetodCutoffPostings(
makeJournalSupabase([], { message: 'connection lost' }),
'co-1', 'fp-1', 'fp-2', buildCutoffLines([], []),
),
).rejects.toThrow(/kunde inte kontrolleras/i)
})
})
describe('collectKontantmetodCutoff', () => {
it('fails closed when either reskontra query fails', async () => {
const supabase = {
from: (table: string) => {
const query: Record<string, unknown> = {}
for (const name of ['select', 'eq', 'lte', 'in']) query[name] = () => query
query.then = (resolve: (value: unknown) => unknown) => resolve({
data: table === 'supplier_invoices' ? [] : null,
error: table === 'invoices' ? { message: 'read failed' } : null,
})
return query
},
}
await expect(
collectKontantmetodCutoff(supabase as never, 'co-1', '2026-01-01', '2026-12-31'),
).rejects.toThrow(/kunde inte läsa reskontran/i)
})
it('fails closed when payment history cannot be reconstructed', async () => {
const rows: Record<string, unknown[]> = {
invoices: [{
id: 'inv-1', invoice_number: 'F-1', invoice_date: '2026-12-01', status: 'sent',
total: 1250, vat_amount: 250, vat_treatment: 'standard_25', document_type: 'invoice',
}],
supplier_invoices: [],
}
const supabase = {
from: (table: string) => {
const query: Record<string, unknown> = {}
for (const name of ['select', 'eq', 'lte', 'in']) query[name] = () => query
query.then = (resolve: (value: unknown) => unknown) => resolve({
data: rows[table] ?? [],
error: table === 'invoice_payments' ? { message: 'payment read failed' } : null,
})
return query
},
}
await expect(
collectKontantmetodCutoff(supabase as never, 'co-1', '2026-01-01', '2026-12-31'),
).rejects.toThrow(/kunde inte läsa betalningar/i)
})
})
describe('postKontantmetodCutoff', () => {
const OPEN_NEXT = {
id: 'fp-next',
@@ -273,14 +455,20 @@ describe('postKontantmetodCutoff', () => {
locked_at: null,
}
const makeSupabase = (next: Record<string, unknown> | null) => ({
from: () => ({
select: () => ({
eq: () => ({
eq: () => ({ maybeSingle: async () => ({ data: next, error: next ? null : { message: 'x' } }) }),
}),
}),
}),
const makeSupabase = (next: Record<string, unknown> | null, journalRows: unknown[] = []) => ({
from: (table: string) => {
const query: Record<string, unknown> = {}
query.select = () => query
query.eq = () => query
query.in = () => query
query.maybeSingle = async () => ({
data: table === 'fiscal_periods' ? next : null,
error: table === 'fiscal_periods' && !next ? { message: 'x' } : null,
})
query.then = (resolve: (value: unknown) => unknown) =>
resolve({ data: table === 'journal_entries' ? journalRows : null, error: null })
return query
},
}) as never
const baseOpts = {
@@ -366,6 +554,58 @@ describe('postKontantmetodCutoff', () => {
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
})
it('refuses to post a second cut-off when an earlier marker exists', async () => {
await expect(
postKontantmetodCutoff(
makeSupabase(OPEN_NEXT, [{
id: 'existing',
fiscal_period_id: 'fp-1',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
lines: buildCutoffLines([receivable()], []).receivableLines,
}]),
'co-1',
'user-1',
baseOpts,
),
).rejects.toThrow(/delvis eller dubbelt bokförd/i)
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
})
it('resumes with the missing payable pair after a prior receivable pair succeeded', async () => {
const receivableLines = buildCutoffLines([receivable()], []).receivableLines
const existingRows = [
{
id: 'ar', fiscal_period_id: 'fp-1',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
lines: receivableLines,
},
{
id: 'ar-rev', fiscal_period_id: 'fp-next',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
lines: reverseLines(receivableLines),
},
]
vi.mocked(createJournalEntry)
.mockResolvedValueOnce({ id: 'ap' } as never)
.mockResolvedValueOnce({ id: 'ap-rev' } as never)
const result = await postKontantmetodCutoff(
makeSupabase(OPEN_NEXT, existingRows),
'co-1',
'user-1',
{ ...baseOpts, payables: [payable()] },
)
expect(result.receivableEntry?.id).toBe('ar')
expect(result.receivableReversal?.id).toBe('ar-rev')
expect(result.payableEntry?.id).toBe('ap')
expect(result.payableReversal?.id).toBe('ap-rev')
expect(createJournalEntry).toHaveBeenCalledTimes(2)
expect(vi.mocked(createJournalEntry).mock.calls[0]?.[3].description).toBe(
KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable,
)
})
it('stornoes the cut-off when its vändning fails, leaving no inflated 1510', async () => {
// The failure mode the module exists to prevent: a committed cut-off with
// no vändning inflates 1510/2440 permanently and double-books every
@@ -34,6 +34,7 @@ interface SeededTables {
supplier_invoices?: Row[]
invoice_payments?: Row[]
supplier_invoice_payments?: Row[]
company_settings?: Row[]
}
/**
@@ -236,6 +237,11 @@ import { validateYearEndReadiness, previewYearEndClosing } from '../year-end-ser
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { countUnbookedInPeriod, findNextPeriod } from '../period-service'
import {
buildCutoffLines,
KONTANTMETOD_CUTOFF_DESCRIPTIONS,
reverseLines,
} from '../kontantmetod-cutoff'
beforeEach(() => {
vi.clearAllMocks()
@@ -662,6 +668,91 @@ describe('validateYearEndReadiness', () => {
})
})
describe('validateYearEndReadiness: kontantmetoden cut-off gate', () => {
const nextPeriod = {
id: 'fp-2', period_start: '2025-01-01', period_end: '2025-12-31',
is_closed: false, locked_at: null, opening_balance_entry_id: null,
}
const openInvoice = {
id: 'inv-1', company_id: 'company-1', invoice_number: 'F-1',
invoice_date: '2024-12-15', status: 'sent', total: 1250, total_sek: 1250,
vat_amount: 250, vat_amount_sek: 250, vat_treatment: 'standard_25',
credited_invoice_id: null, document_type: 'invoice',
}
beforeEach(() => {
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [], isBalanced: true, totalDebit: 10000, totalCredit: 10000,
} as never)
vi.mocked(findNextPeriod).mockResolvedValue(nextPeriod as never)
})
function cashTables(journalEntries: Row[] = []): SeededTables {
return {
...fxBaseTables({ journal_entries: journalEntries }),
company_settings: [{
company_id: 'company-1', accounting_method: 'cash', entity_type: 'aktiebolag',
}],
invoices: [openInvoice],
}
}
it('blocks year-end when an outstanding invoice has no matching cut-off pair', async () => {
const result = await validateYearEndReadiness(
makeFilteringClient(cashTables()) as never,
'company-1', 'user-1', 'fp-1',
)
expect(result.ready).toBe(false)
expect(result.blockers).toContainEqual(expect.objectContaining({
code: 'KONTANTMETOD_CUTOFF_REQUIRED',
}))
})
it('clears only after the exact cut-off and next-period reversal are posted', async () => {
const expected = buildCutoffLines([{
id: 'inv-1', reference: 'F-1', vatTreatment: 'standard_25',
outstanding: 1250, vat: 250,
}], [])
const markers = [
{
id: 'cutoff', company_id: 'company-1', fiscal_period_id: 'fp-1',
voucher_series: 'A', voucher_number: 10, status: 'posted', source_type: 'year_end',
source_id: 'fp-1', entry_date: '2024-12-31',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
lines: expected.receivableLines,
},
{
id: 'reversal', company_id: 'company-1', fiscal_period_id: 'fp-2',
voucher_series: 'A', voucher_number: 1, status: 'posted', source_type: 'year_end',
source_id: 'fp-1', entry_date: '2025-01-01',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
lines: reverseLines(expected.receivableLines),
},
]
const result = await validateYearEndReadiness(
makeFilteringClient(cashTables(markers)) as never,
'company-1', 'user-1', 'fp-1',
)
expect(result.blockers.some((blocker) =>
blocker.code === 'KONTANTMETOD_CUTOFF_REQUIRED' ||
blocker.code === 'KONTANTMETOD_CUTOFF_CHECK_FAILED',
)).toBe(false)
expect(result.ready).toBe(true)
})
it('fails closed when the cut-off query cannot run', async () => {
const result = await validateYearEndReadiness(
makeFilteringClient(cashTables(), 'company_settings') as never,
'company-1', 'user-1', 'fp-1',
)
expect(result.blockers).toContainEqual(expect.objectContaining({
code: 'KONTANTMETOD_CUTOFF_CHECK_FAILED',
}))
})
})
describe('validateYearEndReadiness: open FX items at balansdagen (ÅRL 4 kap. 13 §)', () => {
beforeEach(() => {
vi.mocked(generateTrialBalance).mockResolvedValue({
+280 -6
View File
@@ -63,6 +63,13 @@ export const VILANDE_INPUT_VAT_ACCOUNT = '2648'
export const RECEIVABLES_ACCOUNT = '1510'
export const PAYABLES_ACCOUNT = '2440'
export const KONTANTMETOD_CUTOFF_DESCRIPTIONS = {
receivable: 'Kundfordringar vid bokslut (kontantmetoden)',
receivableReversal: 'Vändning kundfordringar bokslut (kontantmetoden)',
payable: 'Leverantörsskulder vid bokslut (kontantmetoden)',
payableReversal: 'Vändning leverantörsskulder bokslut (kontantmetoden)',
} as const
/** A customer invoice still outstanding at period end. Amounts are SEK. */
export interface CutoffReceivable {
id: string
@@ -109,6 +116,39 @@ export interface CutoffLines {
payableTotal: number
}
interface PostedCutoffEntry {
id: string
fiscal_period_id: string
description: string
lines: Array<{
account_number: string
debit_amount: number | string | null
credit_amount: number | string | null
}>
}
export interface KontantmetodCutoffPostingStatus {
complete: boolean
hasAny: boolean
receivableEntryId: string | null
receivableReversalId: string | null
payableEntryId: string | null
payableReversalId: string | null
missing: Array<'receivable' | 'receivable_reversal' | 'payable' | 'payable_reversal'>
duplicates: string[]
}
export function hasIncompleteKontantmetodCutoffPair(
status: KontantmetodCutoffPostingStatus,
lines: CutoffLines,
): boolean {
const receivablePartial = lines.receivableLines.length > 0 &&
Boolean(status.receivableEntryId) !== Boolean(status.receivableReversalId)
const payablePartial = lines.payableLines.length > 0 &&
Boolean(status.payableEntryId) !== Boolean(status.payableReversalId)
return status.duplicates.length > 0 || receivablePartial || payablePartial
}
// Go through roundOre first: Math.round(x * 100) alone mis-rounds exact-half
// values that arrive with float drift (lib/money.ts).
const toOre = (amount: number): number => Math.round(roundOre(amount) * 100)
@@ -303,6 +343,133 @@ export function buildCutoffLines(
}
}
function comparableLines(lines: Array<{
account_number: string
debit_amount: number | string | null
credit_amount: number | string | null
}>): string[] {
return lines
.map((line) =>
[
line.account_number,
roundOre(Number(line.debit_amount ?? 0)).toString(),
roundOre(Number(line.credit_amount ?? 0)).toString(),
].join(':'),
)
.sort()
}
export function cutoffLinesEqual(
left: CreateJournalEntryLineInput[],
right: Array<{
account_number: string
debit_amount: number | string | null
credit_amount: number | string | null
}>,
): boolean {
const normalizedLeft = comparableLines(left)
const normalizedRight = comparableLines(right)
return normalizedLeft.length === normalizedRight.length &&
normalizedLeft.every((line, index) => line === normalizedRight[index])
}
/**
* Inspect the immutable journal for a complete cut-off and its day-one
* reversals. Matching exact account totals, rather than only a description,
* makes a late invoice or payment reopen the blocker until a fresh cut-off is
* posted. `source_id` anchors all four entries to the year being closed.
*/
export async function inspectKontantmetodCutoffPostings(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
nextFiscalPeriodId: string,
expected: CutoffLines,
): Promise<KontantmetodCutoffPostingStatus> {
const { data, error } = await supabase
.from('journal_entries')
.select(
'id, fiscal_period_id, description, lines:journal_entry_lines(account_number, debit_amount, credit_amount)',
)
.eq('company_id', companyId)
.eq('source_type', 'year_end')
.eq('source_id', fiscalPeriodId)
.eq('status', 'posted')
.in('fiscal_period_id', [fiscalPeriodId, nextFiscalPeriodId])
.in('description', Object.values(KONTANTMETOD_CUTOFF_DESCRIPTIONS))
if (error) {
throw new Error(`Kontantmetodens bokslutsavgränsning kunde inte kontrolleras: ${error.message}`)
}
const rows = (data ?? []) as PostedCutoffEntry[]
const missing: KontantmetodCutoffPostingStatus['missing'] = []
const duplicates: string[] = []
const matchOne = (
description: string,
periodId: string,
lines: CreateJournalEntryLineInput[],
missingKind: KontantmetodCutoffPostingStatus['missing'][number],
): string | null => {
const candidates = rows.filter(
(row) => row.description === description && row.fiscal_period_id === periodId,
)
if (lines.length === 0) {
if (candidates.length > 0) duplicates.push(description)
return null
}
const exact = candidates.filter((row) => cutoffLinesEqual(lines, row.lines ?? []))
if (candidates.length !== 1 || exact.length !== 1) {
// Any marker with non-matching lines is a conflict, even when only one
// exists. Treating it as merely missing could stage a second cut-off on
// top of an immutable entry after the source reskontra changed.
if (candidates.length > 0) duplicates.push(description)
missing.push(missingKind)
return null
}
return exact[0]!.id
}
const receivableEntryId = matchOne(
KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
fiscalPeriodId,
expected.receivableLines,
'receivable',
)
const receivableReversalId = matchOne(
KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal,
nextFiscalPeriodId,
reverseLines(expected.receivableLines),
'receivable_reversal',
)
const payableEntryId = matchOne(
KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable,
fiscalPeriodId,
expected.payableLines,
'payable',
)
const payableReversalId = matchOne(
KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal,
nextFiscalPeriodId,
reverseLines(expected.payableLines),
'payable_reversal',
)
return {
complete: missing.length === 0 && duplicates.length === 0,
hasAny: rows.length > 0,
receivableEntryId,
receivableReversalId,
payableEntryId,
payableReversalId,
missing,
duplicates,
}
}
/** Swap every debit and credit: the vändning posted on day 1 of the new year. */
export function reverseLines(
lines: CreateJournalEntryLineInput[],
@@ -342,6 +509,33 @@ export interface CutoffCollection {
strayVatOnZeroRate: string[]
}
export interface KontantmetodCutoffAssessment {
collection: CutoffCollection
lines: CutoffLines
postings: KontantmetodCutoffPostingStatus
}
function sortedCollection(collection: CutoffCollection): CutoffCollection {
return {
receivables: [...collection.receivables].sort((a, b) => a.id.localeCompare(b.id)),
payables: [...collection.payables]
.map((row) => ({
...row,
netByAccount: [...row.netByAccount].sort((a, b) => a.account.localeCompare(b.account)),
}))
.sort((a, b) => a.id.localeCompare(b.id)),
unknownVatTreatment: [...collection.unknownVatTreatment].sort(),
strayVatOnZeroRate: [...collection.strayVatOnZeroRate].sort(),
}
}
export function cutoffCollectionsEqual(
left: CutoffCollection,
right: CutoffCollection,
): boolean {
return JSON.stringify(sortedCollection(left)) === JSON.stringify(sortedCollection(right))
}
/**
* An aggregate verifikat still has to say which affärshändelser it covers
* (BFL 5 kap 6-7 §: motpart and underlag must be traceable). The lines are
@@ -394,6 +588,13 @@ export async function collectKontantmetodCutoff(
.in('status', ['registered', 'approved', 'partially_paid', 'paid']),
])
if (invoicesResult.error || supplierResult.error) {
throw new Error(
'Kontantmetodens bokslutsavgränsning kunde inte läsa reskontran: ' +
(invoicesResult.error?.message ?? supplierResult.error?.message ?? 'okänt fel'),
)
}
const invoices = (invoicesResult.data ?? []) as Array<Record<string, unknown>>
const supplierInvoices = (supplierResult.data ?? []) as Array<Record<string, unknown>>
@@ -410,7 +611,7 @@ export async function collectKontantmetodCutoff(
.eq('company_id', companyId)
.lte('payment_date', periodEnd)
.in('invoice_id', invoiceIds)
: Promise.resolve({ data: [] as Array<Record<string, unknown>> }),
: Promise.resolve({ data: [] as Array<Record<string, unknown>>, error: null }),
supplierIds.length > 0
? supabase
.from('supplier_invoice_payments')
@@ -418,9 +619,16 @@ export async function collectKontantmetodCutoff(
.eq('company_id', companyId)
.lte('payment_date', periodEnd)
.in('supplier_invoice_id', supplierIds)
: Promise.resolve({ data: [] as Array<Record<string, unknown>> }),
: Promise.resolve({ data: [] as Array<Record<string, unknown>>, error: null }),
])
if (invoicePayments.error || supplierPayments.error) {
throw new Error(
'Kontantmetodens bokslutsavgränsning kunde inte läsa betalningar: ' +
(invoicePayments.error?.message ?? supplierPayments.error?.message ?? 'okänt fel'),
)
}
const paidByInvoice = new Map<string, number>()
for (const row of (invoicePayments.data ?? []) as Array<Record<string, unknown>>) {
const id = row.invoice_id as string
@@ -529,6 +737,31 @@ export async function collectKontantmetodCutoff(
return { receivables, payables, unknownVatTreatment, strayVatOnZeroRate }
}
export async function assessKontantmetodCutoff(
supabase: SupabaseClient,
companyId: string,
period: { id: string; period_start: string; period_end: string },
nextFiscalPeriodId: string,
entityType: EntityType = 'aktiebolag',
): Promise<KontantmetodCutoffAssessment> {
const collection = await collectKontantmetodCutoff(
supabase,
companyId,
period.period_start,
period.period_end,
)
const lines = buildCutoffLines(collection.receivables, collection.payables, entityType)
const postings = await inspectKontantmetodCutoffPostings(
supabase,
companyId,
period.id,
nextFiscalPeriodId,
lines,
)
return { collection, lines, postings }
}
export interface PostCutoffResult {
receivableEntry: JournalEntry | null
receivableReversal: JournalEntry | null
@@ -645,6 +878,38 @@ export async function postKontantmetodCutoff(
await assertReversalPeriodPostable(supabase, companyId, opts.nextFiscalPeriodId, reversalDate)
const existing = await inspectKontantmetodCutoffPostings(
supabase,
companyId,
opts.fiscalPeriodId,
opts.nextFiscalPeriodId,
{
receivableLines,
payableLines,
receivableTotal: receivableLines.reduce((sum, line) => sum + line.debit_amount, 0),
payableTotal: payableLines.reduce((sum, line) => sum + line.credit_amount, 0),
},
)
if (hasIncompleteKontantmetodCutoffPair(existing, {
receivableLines,
payableLines,
receivableTotal: receivableLines.reduce((sum, line) => sum + line.debit_amount, 0),
payableTotal: payableLines.reduce((sum, line) => sum + line.credit_amount, 0),
})) {
throw new Error(
'Kontantmetodens bokslutsavgränsning är delvis eller dubbelt bokförd för perioden. Kontrollera och rätta verifikaten innan du försöker igen.',
)
}
if (existing.receivableEntryId && existing.receivableReversalId) {
result.receivableEntry = { id: existing.receivableEntryId } as JournalEntry
result.receivableReversal = { id: existing.receivableReversalId } as JournalEntry
}
if (existing.payableEntryId && existing.payableReversalId) {
result.payableEntry = { id: existing.payableEntryId } as JournalEntry
result.payableReversal = { id: existing.payableReversalId } as JournalEntry
}
/**
* Post a cut-off/vändning pair. On reversal failure the cut-off is stornoed
* so the pair is all-or-nothing from the ledger's point of view.
@@ -654,11 +919,19 @@ export async function postKontantmetodCutoff(
label: string,
references: string[],
): Promise<[JournalEntry, JournalEntry]> => {
const description = label === 'Kundfordringar'
? KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable
: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable
const reversalDescription = label === 'Kundfordringar'
? KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal
: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: opts.fiscalPeriodId,
entry_date: opts.periodEnd,
description: `${label} vid bokslut (kontantmetoden)`,
description,
source_type: 'year_end',
source_id: opts.fiscalPeriodId,
notes: buildCutoffNote(label, references),
lines,
})
@@ -667,8 +940,9 @@ export async function postKontantmetodCutoff(
const reversal = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: opts.nextFiscalPeriodId,
entry_date: reversalDate,
description: `Vändning ${label.toLowerCase()} bokslut (kontantmetoden)`,
description: reversalDescription,
source_type: 'year_end',
source_id: opts.fiscalPeriodId,
notes: buildCutoffNote(`Vändning ${label.toLowerCase()}`, references),
lines: reverseLines(lines),
})
@@ -690,7 +964,7 @@ export async function postKontantmetodCutoff(
}
}
if (receivableLines.length > 0) {
if (receivableLines.length > 0 && !result.receivableEntry) {
const [entry, reversal] = await postPair(
receivableLines,
'Kundfordringar',
@@ -700,7 +974,7 @@ export async function postKontantmetodCutoff(
result.receivableReversal = reversal
}
if (payableLines.length > 0) {
if (payableLines.length > 0 && !result.payableEntry) {
const [entry, reversal] = await postPair(
payableLines,
'Leverantörsskulder',
+61
View File
@@ -26,6 +26,7 @@ import {
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
import { assessKontantmetodCutoff } from './kontantmetod-cutoff'
import type {
YearEndValidation,
YearEndBlocker,
@@ -341,6 +342,66 @@ export async function validateYearEndReadiness(
}
}
// Kontantmetoden is a legal hard gate, not an advisory: BFL 5 kap 2 §
// requires every unpaid receivable and liability to be booked at the fiscal
// year end. Gate before executeYearEndClosing posts its closing entry. A
// later lock-time check would leave a partial close behind on failure.
try {
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (settingsError) throw settingsError
if (settings?.accounting_method === 'cash') {
if (!nextPeriod) {
blockers.push({
code: 'KONTANTMETOD_CUTOFF_REQUIRED',
message:
'Kontantmetodens bokslutsavgränsning kan inte bokföras förrän nästa räkenskapsår är upplagt. Skapa nästa period, förhandsgranska och bokför avgränsningen innan bokslut.',
})
} else {
const assessment = await assessKontantmetodCutoff(
supabase,
companyId,
period,
nextPeriod.id,
settings.entity_type ?? 'aktiebolag',
)
const invalidCount =
assessment.collection.unknownVatTreatment.length +
assessment.collection.strayVatOnZeroRate.length
const outstandingCount =
assessment.collection.receivables.length + assessment.collection.payables.length
if (invalidCount > 0) {
blockers.push({
code: 'KONTANTMETOD_CUTOFF_REQUIRED',
message:
`${invalidCount} fakturor kan inte tas med i kontantmetodens bokslutsavgränsning på grund av saknad eller oförenlig momsinställning. Rätta fakturorna och bokför avgränsningen innan bokslut.`,
})
} else if (!assessment.postings.complete) {
blockers.push({
code: 'KONTANTMETOD_CUTOFF_REQUIRED',
message:
outstandingCount > 0
? `${outstandingCount} obetalda fakturor var utestående vid periodens slut. Förhandsgranska och bokför kontantmetodens bokslutsavgränsning med vändningar innan bokslut (BFL 5 kap 2 §).`
: 'En tidigare kontantmetodavgränsning stämmer inte längre med reskontran. Kontrollera och rätta verifikaten innan bokslut.',
})
}
}
}
} catch (err) {
log.warn('kontantmetoden cut-off readiness check failed', err as Error)
blockers.push({
code: 'KONTANTMETOD_CUTOFF_CHECK_FAILED',
message:
'Kontrollen av kontantmetodens bokslutsavgränsning kunde inte genomföras: försök igen innan bokslut',
})
}
// Check: unbooked bank transactions in the period. lockPeriod enforces this
// at step 7 of executeYearEndClosing, AFTER the closing entry has already
// posted at step 4: without this readiness check a period with unbooked
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/core/bookkeeping/kontantmetod-cutoff', async () => {
const actual = await vi.importActual<
typeof import('@/lib/core/bookkeeping/kontantmetod-cutoff')
>('@/lib/core/bookkeeping/kontantmetod-cutoff')
return {
...actual,
assessKontantmetodCutoff: vi.fn(),
postKontantmetodCutoff: vi.fn(),
}
})
import { commitPendingOperation } from '../commit'
import {
assessKontantmetodCutoff,
buildCutoffLines,
postKontantmetodCutoff,
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
const collection = {
receivables: [{
id: 'inv-1', reference: 'F-1', vatTreatment: 'standard_25' as const,
outstanding: 1250, vat: 250,
}],
payables: [],
unknownVatTreatment: [],
strayVatOnZeroRate: [],
}
function makePendingOp(overrides: Partial<PendingOperation> = {}): PendingOperation {
return {
id: 'op-1', user_id: 'user-1', company_id: 'company-1',
operation_type: 'post_kontantmetod_cutoff', status: 'pending', title: 'cut-off',
params: { fiscal_period_id: 'fp-1', next_fiscal_period_id: 'fp-2', collection },
preview_data: {}, result_data: null, actor_type: 'api_key', actor_id: null,
actor_label: null, risk_level: 'high', created_at: '2026-08-13T00:00:00Z',
resolved_at: null, updated_at: '2026-08-13T00:00:00Z',
...overrides,
} as PendingOperation
}
function makeSupabase(options: {
period?: unknown
settings?: unknown
nextPeriod?: unknown
} = {}) {
const period = options.period ?? {
id: 'fp-1', period_start: '2026-01-01', period_end: '2026-12-31',
is_closed: false, locked_at: null,
}
const settings = options.settings ?? { accounting_method: 'cash', entity_type: 'aktiebolag' }
const nextPeriod = Object.prototype.hasOwnProperty.call(options, 'nextPeriod')
? options.nextPeriod
: {
id: 'fp-2', period_start: '2027-01-01', period_end: '2027-12-31',
is_closed: false, locked_at: null,
}
let fiscalReads = 0
const updates: Array<{ table: string; value: unknown }> = []
const from = vi.fn((table: string) => {
const chain: Record<string, unknown> = {}
for (const name of ['select', 'eq', 'in', 'order', 'limit']) chain[name] = () => chain
chain.update = (value: unknown) => {
updates.push({ table, value })
return chain
}
const response = () => {
if (table === 'pending_operations') return { data: { id: 'op-1' }, error: null }
if (table === 'company_settings') return { data: settings, error: null }
if (table === 'fiscal_periods') {
fiscalReads++
return { data: fiscalReads === 1 ? period : nextPeriod, error: null }
}
return { data: null, error: null }
}
chain.maybeSingle = async () => response()
chain.single = async () => response()
chain.then = (resolve: (value: unknown) => unknown) => resolve(response())
return chain
})
return { auth: {}, from, updates }
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(assessKontantmetodCutoff).mockResolvedValue({
collection,
lines: buildCutoffLines(collection.receivables, collection.payables),
postings: {
complete: false, hasAny: false, receivableEntryId: null,
receivableReversalId: null, payableEntryId: null, payableReversalId: null,
missing: ['receivable', 'receivable_reversal'], duplicates: [],
},
})
vi.mocked(postKontantmetodCutoff).mockResolvedValue({
receivableEntry: { id: 'je-1' } as never,
receivableReversal: { id: 'je-2' } as never,
payableEntry: null,
payableReversal: null,
})
})
describe('commitPendingOperation: post_kontantmetod_cutoff', () => {
it('revalidates the frozen preview and posts through the cut-off service', async () => {
const supabase = makeSupabase()
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1', makePendingOp(),
)
expect(result.status).toBe('committed')
expect(result.data).toEqual({
receivable_entry_id: 'je-1',
receivable_reversal_entry_id: 'je-2',
payable_entry_id: null,
payable_reversal_entry_id: null,
})
expect(postKontantmetodCutoff).toHaveBeenCalledWith(
expect.anything(), 'company-1', 'user-1',
expect.objectContaining({
fiscalPeriodId: 'fp-1', nextFiscalPeriodId: 'fp-2',
receivables: collection.receivables, payables: [],
}),
)
})
it('rejects when the reskontra changed after staging', async () => {
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection: {
...collection,
receivables: [{ ...collection.receivables[0]!, outstanding: 1300 }],
},
lines: buildCutoffLines([], []),
postings: { complete: false, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
})
const result = await commitPendingOperation(
makeSupabase() as never, 'user-1', 'company-1', makePendingOp(),
)
expect(result).toMatchObject({ status: 'rejected', http_status: 409 })
expect(result.error).toMatch(/ändrats sedan förhandsgranskningen/i)
expect(postKontantmetodCutoff).not.toHaveBeenCalled()
})
it('rejects a duplicate, locked period, wrong accounting method, and missing next period', async () => {
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection,
lines: buildCutoffLines(collection.receivables, []),
postings: { complete: true, hasAny: true, receivableEntryId: 'je-1', receivableReversalId: 'je-2', payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
})
await expect(commitPendingOperation(
makeSupabase() as never, 'user-1', 'company-1', makePendingOp(),
)).resolves.toMatchObject({ status: 'rejected', http_status: 409 })
await expect(commitPendingOperation(
makeSupabase({ period: { id: 'fp-1', period_end: '2026-12-31', locked_at: 'x', is_closed: false } }) as never,
'user-1', 'company-1', makePendingOp(),
)).resolves.toMatchObject({ status: 'rejected', http_status: 409 })
await expect(commitPendingOperation(
makeSupabase({ settings: { accounting_method: 'accrual' } }) as never,
'user-1', 'company-1', makePendingOp(),
)).resolves.toMatchObject({ status: 'rejected', http_status: 409 })
await expect(commitPendingOperation(
makeSupabase({ nextPeriod: null }) as never,
'user-1', 'company-1', makePendingOp(),
)).resolves.toMatchObject({ status: 'rejected', http_status: 409 })
})
})
+113
View File
@@ -53,6 +53,13 @@ import {
executeYearEndClosing,
generateOpeningBalances,
} from '@/lib/core/bookkeeping/year-end-service'
import {
assessKontantmetodCutoff,
cutoffCollectionsEqual,
hasIncompleteKontantmetodCutoffPair,
postKontantmetodCutoff,
type CutoffCollection,
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation'
import {
createSupplierCreditNoteEntry,
@@ -3191,6 +3198,104 @@ async function commitRunYearEnd(
}
}
async function commitPostKontantmetodCutoff(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
const fiscalPeriodId = params.fiscal_period_id as string
const nextFiscalPeriodId = params.next_fiscal_period_id as string
const stagedCollection = params.collection as CutoffCollection | undefined
if (!fiscalPeriodId || !nextFiscalPeriodId || !stagedCollection) {
return { error: 'Invalid staged kontantmetod cut-off parameters', status: 400 }
}
const [{ data: period }, { data: settings }] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, period_start, period_end, is_closed, locked_at')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.maybeSingle(),
])
if (!period) return { error: 'Fiscal period not found', status: 404 }
if (period.is_closed || period.locked_at) {
return { error: 'Räkenskapsperioden är stängd eller låst', status: 409 }
}
if (settings?.accounting_method !== 'cash') {
return { error: 'Företaget använder inte kontantmetoden', status: 409 }
}
const { data: nextPeriod } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end, is_closed, locked_at')
.eq('id', nextFiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
if (!nextPeriod) return { error: 'Nästa räkenskapsår hittades inte', status: 409 }
try {
const assessment = await assessKontantmetodCutoff(
supabase,
companyId,
period,
nextFiscalPeriodId,
settings.entity_type ?? 'aktiebolag',
)
if (assessment.postings.complete || hasIncompleteKontantmetodCutoffPair(
assessment.postings,
assessment.lines,
)) {
return {
error:
'Kontantmetodens bokslutsavgränsning är redan bokförd eller delvis bokförd för perioden',
status: 409,
}
}
if (!cutoffCollectionsEqual(stagedCollection, assessment.collection)) {
return {
error:
'Reskontran har ändrats sedan förhandsgranskningen. Skapa en ny förhandsgranskning innan du bokför.',
status: 409,
}
}
const result = await postKontantmetodCutoff(supabase, companyId, userId, {
fiscalPeriodId,
nextFiscalPeriodId,
periodEnd: period.period_end,
receivables: assessment.collection.receivables,
payables: assessment.collection.payables,
entityType: settings.entity_type ?? 'aktiebolag',
unknownVatTreatment: assessment.collection.unknownVatTreatment,
strayVatOnZeroRate: assessment.collection.strayVatOnZeroRate,
})
return {
data: {
receivable_entry_id: result.receivableEntry?.id ?? null,
receivable_reversal_entry_id: result.receivableReversal?.id ?? null,
payable_entry_id: result.payableEntry?.id ?? null,
payable_reversal_entry_id: result.payableReversal?.id ?? null,
},
}
} catch (err) {
if (isBookkeepingError(err)) throw err
return {
error: err instanceof Error ? err.message : 'Kontantmetodens bokslutsavgränsning misslyckades',
status: 400,
}
}
}
async function commitSetOpeningBalances(
supabase: SupabaseClient,
userId: string,
@@ -5693,6 +5798,14 @@ async function commitPendingOperationInner(
case 'run_year_end':
result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params)
break
case 'post_kontantmetod_cutoff':
result = await commitPostKontantmetodCutoff(
supabase,
userId,
companyId,
pendingOp.params,
)
break
case 'set_opening_balances':
result = await commitSetOpeningBalances(supabase, userId, companyId, pendingOp.params)
break
+1
View File
@@ -110,6 +110,7 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
unlock_period: 'high',
set_opening_balances: 'high',
run_year_end: 'high',
post_kontantmetod_cutoff: 'high',
run_currency_revaluation: 'high',
// Planenlig avskrivning: one journal entry per asset, each independently
// reversible (storno). Mid-stakes bokslut posting: staged and human-reviewed,