diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 6d2a5cc9..3a487004 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 } }) diff --git a/lib/bokslut/__tests__/readiness-aggregator.test.ts b/lib/bokslut/__tests__/readiness-aggregator.test.ts index 268d82a7..2ef1c4f7 100644 --- a/lib/bokslut/__tests__/readiness-aggregator.test.ts +++ b/lib/bokslut/__tests__/readiness-aggregator.test.ts @@ -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() + }) }) diff --git a/lib/bokslut/readiness-aggregator.ts b/lib/bokslut/readiness-aggregator.ts index 4f92167c..b1613fa5 100644 --- a/lib/bokslut/readiness-aggregator.ts +++ b/lib/bokslut/readiness-aggregator.ts @@ -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 diff --git a/lib/core/bookkeeping/__tests__/year-end-service.test.ts b/lib/core/bookkeeping/__tests__/year-end-service.test.ts index 4021a201..43bec603 100644 --- a/lib/core/bookkeeping/__tests__/year-end-service.test.ts +++ b/lib/core/bookkeeping/__tests__/year-end-service.test.ts @@ -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 }) diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts index 11decc4d..8133cbf8 100644 --- a/lib/core/bookkeeping/year-end-service.ts +++ b/lib/core/bookkeeping/year-end-service.ts @@ -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, } } diff --git a/types/index.ts b/types/index.ts index 62c38a11..14c74bfb 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 {