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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user