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:
@@ -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.')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user