fix(bokslut): surface unbooked transactions and AR/AP tie-outs in year-end preflight (#1414)
* fix(bokslut): surface unbooked transactions and AR/AP tie-outs in year-end preflight Two gaps in the year-end readiness layer: 1. Unbooked bank transactions were enforced only by lockPeriod, which runs at step 7 of executeYearEndClosing, AFTER the closing entry has posted at step 4. A period with unbooked transactions reported ready: true from gnubok_year_end_readiness and the wizard, then aborted mid-flow, leaving a posted closing entry on an unlocked, unclosed period. The readiness check now runs the same counter as the lock guard (countUnbookedInPeriod, so the number reconciles with the "att bokföra" badge) as a blocking error, failing closed if the check cannot run. The lockPeriod guard stays as defense in depth. The MCP classifier tags the new blocker as kind unbooked_transactions. 2. The Phase-1 avstamningar (kundreskontra vs 1510, leverantörsreskontra vs 2440) existed as reports (lib/reports/ar-reconciliation.ts, supplier-reconciliation.ts) but were wired only to the ledger report routes, never to the bokslut preflight. The readiness aggregator now runs both tie-outs and surfaces mismatches as warning-severity reminders with deep links, mirroring the bank-reconciliation reminder. Warnings only, never blockers: a difference can be legitimate (FX-settled partials). Skipped entirely for kontantmetod companies, where open invoices are deliberately not on 1510/2440 until the year-end conversion exists and the tie-out is permanently unreconciled by construction. Unconvertible-FX rows produce a "could not reconcile" message instead of a phantom difference. YearEndValidation gains an optional unbookedTransactionCount field; the v1 compliance endpoint and MCP readiness tool pick the new blocker up automatically since they share the same engine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): classify the next-period-IB readiness blocker instead of kind other The blocker "Nästa räkenskapsperiod har redan ingående balanser bokförda" was the only validateYearEndReadiness error with no classifier regex, so it always surfaced as kind: 'other'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): log swallowed AR/AP tie-out failures in the readiness aggregator Compliance-review finding: a rejected tie-out produced no reminder and no log entry, making a failed avstämning control indistinguishable from a reconciled one. Still degrades to no reminder (advisory check), but the rejection reason is now traceable, mirroring the unbooked-transaction check's logging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12795,6 +12795,7 @@ export const tools: McpTool[] = [
|
||||
const blockers = validation.errors.map((message) => {
|
||||
let kind: string = 'other'
|
||||
if (/draft journal entries|utkast måste bokföras/i.test(message)) kind = 'draft_entries'
|
||||
else if (/unbooked transaction|saknar bokföring|obokförda transaktioner/i.test(message)) kind = 'unbooked_transactions'
|
||||
else if (/voucher gap|verifikationsnummerglapp/i.test(message)) kind = 'unexplained_voucher_gap'
|
||||
else if (/Sequence counter integrity|Nummerserien i serie/i.test(message)) kind = 'sequence_mismatch'
|
||||
else if (/Trial balance is not balanced|Råbalansen balanserar inte/i.test(message)) kind = 'trial_balance_unbalanced'
|
||||
@@ -12802,6 +12803,7 @@ export const tools: McpTool[] = [
|
||||
else if (/has not yet ended|slutdatumet har inte passerat/i.test(message)) kind = 'period_not_ended'
|
||||
else if (/closing entry already exists|Bokslutsverifikation finns redan/i.test(message)) kind = 'closing_entry_exists'
|
||||
else if (/continuity check failed|IB\/UB-kontinuiteten/i.test(message)) kind = 'opening_balance_continuity'
|
||||
else if (/opening balances already posted|redan ingående balanser bokförda/i.test(message)) kind = 'next_period_ib_posted'
|
||||
else if (/Fiscal period not found|Räkenskapsperioden hittades inte/i.test(message)) kind = 'period_not_found'
|
||||
return { kind, severity: 'high' as const, message }
|
||||
})
|
||||
|
||||
@@ -12,9 +12,19 @@ vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
|
||||
getReconciliationStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/ar-reconciliation', () => ({
|
||||
generateARReconciliation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/supplier-reconciliation', () => ({
|
||||
generateReconciliation: 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'
|
||||
|
||||
const CASH_ACCOUNT_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
|
||||
@@ -101,8 +111,27 @@ const RECON_CLEAN = {
|
||||
unmatched_gl_line_count: 0,
|
||||
}
|
||||
|
||||
const AR_CLEAN = {
|
||||
ar_ledger_total: 0,
|
||||
account_1510_balance: 0,
|
||||
difference: 0,
|
||||
is_reconciled: true,
|
||||
unconverted_fx_count: 0,
|
||||
}
|
||||
|
||||
const AP_CLEAN = {
|
||||
supplier_ledger_total: 0,
|
||||
account_2440_balance: 0,
|
||||
difference: 0,
|
||||
is_reconciled: true,
|
||||
unconverted_fx_count: 0,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: clean tie-outs. Individual tests override to simulate mismatches.
|
||||
vi.mocked(generateARReconciliation).mockResolvedValue(AR_CLEAN)
|
||||
vi.mocked(generateAPReconciliation).mockResolvedValue(AP_CLEAN)
|
||||
})
|
||||
|
||||
describe('buildBokslutReadinessReport', () => {
|
||||
@@ -262,4 +291,77 @@ describe('buildBokslutReadinessReport', () => {
|
||||
expect(report.entityType).toBe('aktiebolag')
|
||||
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces AR and AP tie-out mismatches as warning reminders for accrual companies', async () => {
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
|
||||
vi.mocked(generateARReconciliation).mockResolvedValue({
|
||||
...AR_CLEAN,
|
||||
ar_ledger_total: 25000,
|
||||
account_1510_balance: 20000,
|
||||
difference: 5000,
|
||||
is_reconciled: false,
|
||||
})
|
||||
vi.mocked(generateAPReconciliation).mockResolvedValue({
|
||||
...AP_CLEAN,
|
||||
is_reconciled: false,
|
||||
unconverted_fx_count: 2,
|
||||
})
|
||||
const supabase = makeSupabase({
|
||||
period: { data: PERIOD, error: null },
|
||||
settings: { data: { entity_type: 'aktiebolag', accounting_method: 'accrual' }, error: null },
|
||||
})
|
||||
|
||||
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
||||
|
||||
const ar = report.reminders.find((r) => r.code === 'ar_reconciliation_mismatch')
|
||||
expect(ar?.severity).toBe('warning')
|
||||
expect(ar?.message).toContain('5000.00 kr')
|
||||
expect(ar?.href).toBe('/reports/kundreskontra')
|
||||
const ap = report.reminders.find((r) => r.code === 'ap_reconciliation_mismatch')
|
||||
expect(ap?.severity).toBe('warning')
|
||||
// Unconvertible FX rows make the difference figure unreliable: the message
|
||||
// must say the tie-out could not run, not report a phantom difference.
|
||||
expect(ap?.message).toContain('saknar valutakurs')
|
||||
expect(ap?.href).toBe('/reports/supplier-ledger')
|
||||
// Warnings never flip readiness.
|
||||
expect(report.ready).toBe(true)
|
||||
expect(vi.mocked(generateARReconciliation)).toHaveBeenCalledWith(supabase, 'co-1', 'fp-1')
|
||||
})
|
||||
|
||||
it('skips the AR/AP tie-outs entirely for kontantmetoden companies', async () => {
|
||||
// Under the cash method open invoices are deliberately not on 1510/2440,
|
||||
// so the tie-out is permanently unreconciled by construction and would
|
||||
// only mislead.
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
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 },
|
||||
})
|
||||
|
||||
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
||||
|
||||
expect(vi.mocked(generateARReconciliation)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(generateAPReconciliation)).not.toHaveBeenCalled()
|
||||
expect(report.reminders.find((r) => r.code === 'ar_reconciliation_mismatch')).toBeUndefined()
|
||||
expect(report.reminders.find((r) => r.code === 'ap_reconciliation_mismatch')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('degrades gracefully when a tie-out query fails', async () => {
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN as never)
|
||||
vi.mocked(generateARReconciliation).mockRejectedValue(new Error('boom'))
|
||||
const supabase = makeSupabase({
|
||||
period: { data: PERIOD, error: null },
|
||||
settings: { data: { entity_type: 'aktiebolag', accounting_method: 'accrual' }, 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 === 'ar_reconciliation_mismatch')).toBeUndefined()
|
||||
// The AP side still ran and reported clean independently of the AR failure.
|
||||
expect(vi.mocked(generateAPReconciliation)).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,14 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
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 { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { YearEndValidation } from '@/types'
|
||||
|
||||
const log = createLogger('bokslut-readiness')
|
||||
|
||||
export type ReminderSeverity = 'info' | 'warning'
|
||||
|
||||
export interface BokslutReminder {
|
||||
@@ -83,7 +88,7 @@ export async function buildBokslutReadinessReport(
|
||||
.single(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.select('entity_type, accounting_method')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId),
|
||||
@@ -95,6 +100,9 @@ export async function buildBokslutReadinessReport(
|
||||
|
||||
const period = periodResult.data
|
||||
const entityType = (settingsResult.data?.entity_type ?? 'aktiebolag') as BokslutReadinessReport['entityType']
|
||||
const accountingMethod =
|
||||
((settingsResult.data as { accounting_method?: string | null } | null)?.accounting_method ??
|
||||
'accrual')
|
||||
|
||||
// Bank reconciliation snapshot for the period. Run after period fetch so we
|
||||
// know the date range. Failure here must not break the report: fall back
|
||||
@@ -147,6 +155,52 @@ export async function buildBokslutReadinessReport(
|
||||
})
|
||||
}
|
||||
|
||||
// AR/AP tie-outs: Phase 1 avstämningar per the bokslut process, open
|
||||
// sub-ledger vs konto 1510 / 2440. Accrual companies only: under
|
||||
// kontantmetoden open invoices are deliberately not on 1510/2440 until the
|
||||
// year-end conversion (BFL 5 kap 2 § 3 st) exists, so the tie-out is
|
||||
// permanently "unreconciled" there by construction 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 === 'accrual') {
|
||||
const [arResult, apResult] = await Promise.allSettled([
|
||||
generateARReconciliation(supabase, companyId, fiscalPeriodId),
|
||||
generateAPReconciliation(supabase, companyId, fiscalPeriodId),
|
||||
])
|
||||
// A failed tie-out degrades to "no reminder" (these are advisory), but a
|
||||
// silently swallowed failure is indistinguishable from "reconciled" in
|
||||
// the report, so the rejection must at least be traceable in logs
|
||||
// (compliance review on the avstämning controls, BFNAR 2013:2 kap 8).
|
||||
if (arResult.status === 'rejected') {
|
||||
log.warn('AR tie-out (kundreskontra vs 1510) failed; reminder omitted', arResult.reason)
|
||||
}
|
||||
if (apResult.status === 'rejected') {
|
||||
log.warn('AP tie-out (leverantörsreskontra vs 2440) failed; reminder omitted', apResult.reason)
|
||||
}
|
||||
if (arResult.status === 'fulfilled' && !arResult.value.is_reconciled) {
|
||||
reminders.push({
|
||||
code: 'ar_reconciliation_mismatch',
|
||||
severity: 'warning',
|
||||
message:
|
||||
arResult.value.unconverted_fx_count > 0
|
||||
? `Kundreskontran kan inte stämmas av mot konto 1510: ${arResult.value.unconverted_fx_count} fakturor i utländsk valuta saknar valutakurs.`
|
||||
: `Kundreskontran stämmer inte mot konto 1510: differens ${arResult.value.difference.toFixed(2)} kr. Kontrollera obetalda kundfakturor innan bokslut.`,
|
||||
href: '/reports/kundreskontra',
|
||||
})
|
||||
}
|
||||
if (apResult.status === 'fulfilled' && !apResult.value.is_reconciled) {
|
||||
reminders.push({
|
||||
code: 'ap_reconciliation_mismatch',
|
||||
severity: 'warning',
|
||||
message:
|
||||
apResult.value.unconverted_fx_count > 0
|
||||
? `Leverantörsreskontran kan inte stämmas av mot konto 2440: ${apResult.value.unconverted_fx_count} fakturor i utländsk valuta saknar valutakurs.`
|
||||
: `Leverantörsreskontran stämmer inte mot konto 2440: differens ${apResult.value.difference.toFixed(2)} kr. Kontrollera obetalda leverantörsfakturor innan bokslut.`,
|
||||
href: '/reports/supplier-ledger',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Periodiseringar (accruals) are still manual: no wizard step ships in
|
||||
// Phases 1-3. Depreciation, bolagsskatt and periodiseringsfond now have
|
||||
// dedicated calculators (DepreciationPanel + DispositionsStep) so they're
|
||||
|
||||
@@ -225,6 +225,9 @@ vi.mock('@/lib/bookkeeping/currency-revaluation', () => ({
|
||||
vi.mock('../period-service', () => ({
|
||||
lockPeriod: vi.fn(),
|
||||
closePeriod: vi.fn(),
|
||||
// Default: clean books. Individual tests override to simulate unbooked
|
||||
// transactions or a failed check (fail-closed).
|
||||
countUnbookedInPeriod: vi.fn().mockResolvedValue({ untriaged: 0, businessUnbooked: 0 }),
|
||||
createNextPeriod: vi.fn(),
|
||||
findNextPeriod: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
@@ -232,7 +235,7 @@ vi.mock('../period-service', () => ({
|
||||
import { validateYearEndReadiness, previewYearEndClosing } from '../year-end-service'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { findNextPeriod } from '../period-service'
|
||||
import { countUnbookedInPeriod, findNextPeriod } from '../period-service'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -321,6 +324,50 @@ describe('validateYearEndReadiness', () => {
|
||||
expect(result.errors.some((e: string) => e.includes('slutdatumet har inte passerat'))).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks when the period contains unbooked bank transactions', async () => {
|
||||
// Previously only lockPeriod caught this, at step 7 of the execute flow,
|
||||
// AFTER the closing entry had posted: readiness said ready: true and the
|
||||
// run aborted mid-flow. The count must block up front.
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
results = noGapResults(period)
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
} as never)
|
||||
vi.mocked(countUnbookedInPeriod).mockResolvedValueOnce({ untriaged: 2, businessUnbooked: 1 })
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.unbookedTransactionCount).toBe(3)
|
||||
expect(result.errors.some((e: string) => e.includes('3 transaktioner i perioden saknar bokföring'))).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed when the unbooked-transaction check cannot run', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
results = noGapResults(period)
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
} as never)
|
||||
vi.mocked(countUnbookedInPeriod).mockRejectedValueOnce(new Error('query failed'))
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(
|
||||
result.errors.some((e: string) =>
|
||||
e.includes('Kontrollen av obokförda transaktioner kunde inte genomföras'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('warns on explained voucher gaps', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
todayIsoDate,
|
||||
type PaymentsAsOf,
|
||||
} from '@/lib/reports/reskontra-payments'
|
||||
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
|
||||
import {
|
||||
lockPeriod,
|
||||
closePeriod,
|
||||
countUnbookedInPeriod,
|
||||
createNextPeriod,
|
||||
findNextPeriod,
|
||||
} from './period-service'
|
||||
import { generateResultAppropriation } from './result-appropriation-service'
|
||||
import {
|
||||
previewCurrencyRevaluation,
|
||||
@@ -307,6 +313,35 @@ export async function validateYearEndReadiness(
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// transactions reported ready: true and then aborted mid-flow, leaving a
|
||||
// posted closing entry on an unlocked, unclosed period. Same counter as the
|
||||
// lock guard (countUnbookedInPeriod), so the number reconciles with the
|
||||
// "att bokföra" badge. Fails CLOSED like lockPeriod: a check that could not
|
||||
// run must not pass.
|
||||
let unbookedTransactionCount = 0
|
||||
try {
|
||||
const unbooked = await countUnbookedInPeriod(
|
||||
supabase,
|
||||
companyId,
|
||||
period.period_start,
|
||||
period.period_end,
|
||||
)
|
||||
unbookedTransactionCount = unbooked.untriaged + unbooked.businessUnbooked
|
||||
if (unbookedTransactionCount > 0) {
|
||||
errors.push(
|
||||
`${unbookedTransactionCount} transaktioner i perioden saknar bokföring: bokför dem eller markera dem som privata innan bokslut`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('unbooked-transaction readiness check failed', err as Error)
|
||||
errors.push(
|
||||
'Kontrollen av obokförda transaktioner kunde inte genomföras: försök igen',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ready: errors.length === 0,
|
||||
errors,
|
||||
@@ -316,6 +351,7 @@ export async function validateYearEndReadiness(
|
||||
unexplainedGaps,
|
||||
sequenceMismatches,
|
||||
trialBalanceBalanced,
|
||||
unbookedTransactionCount,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3403,6 +3403,14 @@ export interface YearEndValidation {
|
||||
unexplainedGaps: VoucherGap[]
|
||||
sequenceMismatches: SequenceMismatch[]
|
||||
trialBalanceBalanced: boolean
|
||||
/**
|
||||
* Bank transactions in the period with no verifikat (untriaged +
|
||||
* business-confirmed-but-unbooked). Blocking: lockPeriod refuses to lock
|
||||
* over them, so surfacing the count here stops executeYearEndClosing from
|
||||
* aborting mid-flow at the lock step. Optional: absent on the early
|
||||
* period-not-found return.
|
||||
*/
|
||||
unbookedTransactionCount?: number
|
||||
}
|
||||
|
||||
export interface YearEndPreview {
|
||||
|
||||
Reference in New Issue
Block a user