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
@@ -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