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:
Jakob Wennberg
2026-08-05 18:05:38 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 17d48ebb84
commit c0825e9bd2
6 changed files with 252 additions and 3 deletions
@@ -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 })
+37 -1
View File
@@ -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,
}
}