* 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>
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { YearEndValidation } from '@/types'
|
|
|
|
// Mock both sources the aggregator composes from. Tests focus on composition
|
|
// (reminders by entity, reconciliation surfacing, error tolerance): the
|
|
// underlying validateYearEndReadiness already has its own coverage.
|
|
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
|
|
validateYearEndReadiness: vi.fn(),
|
|
}))
|
|
|
|
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'
|
|
|
|
interface MockBuilder {
|
|
select: ReturnType<typeof vi.fn>
|
|
eq: ReturnType<typeof vi.fn>
|
|
single: ReturnType<typeof vi.fn>
|
|
maybeSingle: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
function makeSupabase(handlers: {
|
|
period: { data: unknown; error: unknown }
|
|
settings: { data: unknown; error: unknown }
|
|
cashAccount?: { data: unknown; error: unknown }
|
|
}) {
|
|
function makeBuilder(table: string): MockBuilder {
|
|
const b: MockBuilder = {
|
|
select: vi.fn(),
|
|
eq: vi.fn(),
|
|
single: vi.fn(),
|
|
maybeSingle: vi.fn(),
|
|
}
|
|
b.select.mockReturnValue(b)
|
|
b.eq.mockReturnValue(b)
|
|
if (table === 'fiscal_periods') {
|
|
b.single.mockResolvedValue(handlers.period)
|
|
} else if (table === 'company_settings') {
|
|
b.maybeSingle.mockResolvedValue(handlers.settings)
|
|
} else if (table === 'cash_accounts') {
|
|
// The aggregator resolves 1930 to its cash_accounts row so the bank total
|
|
// is scoped to that account (#1290).
|
|
b.maybeSingle.mockResolvedValue(
|
|
handlers.cashAccount ?? {
|
|
data: {
|
|
id: CASH_ACCOUNT_ID,
|
|
currency: 'SEK',
|
|
is_primary: true,
|
|
ledger_account: '1930',
|
|
},
|
|
error: null,
|
|
},
|
|
)
|
|
}
|
|
return b
|
|
}
|
|
return {
|
|
from: vi.fn((table: string) => makeBuilder(table)),
|
|
} as unknown as Parameters<typeof buildBokslutReadinessReport>[0]
|
|
}
|
|
|
|
function baseValidation(overrides: Partial<YearEndValidation> = {}): YearEndValidation {
|
|
return {
|
|
ready: true,
|
|
errors: [],
|
|
warnings: [],
|
|
draftCount: 0,
|
|
voucherGaps: [],
|
|
unexplainedGaps: [],
|
|
sequenceMismatches: [],
|
|
trialBalanceBalanced: true,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const PERIOD = {
|
|
id: 'fp-1',
|
|
name: '2025',
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
is_closed: false,
|
|
locked_at: null,
|
|
closing_entry_id: null,
|
|
}
|
|
|
|
const RECON_CLEAN = {
|
|
bank_transaction_total: 100,
|
|
gl_1930_balance: 100,
|
|
gl_1930_period_movement: 100,
|
|
gl_1930_opening_balance: 0,
|
|
difference: 0,
|
|
is_reconciled: true,
|
|
matched_count: 5,
|
|
unmatched_transaction_count: 0,
|
|
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', () => {
|
|
it('returns a ready report with the accruals reminder for AB', async () => {
|
|
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
|
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
|
|
const supabase = makeSupabase({
|
|
period: { data: PERIOD, error: null },
|
|
settings: { data: { entity_type: 'aktiebolag' }, error: null },
|
|
})
|
|
|
|
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
|
|
|
expect(report.ready).toBe(true)
|
|
expect(report.blockers).toEqual([])
|
|
expect(report.entityType).toBe('aktiebolag')
|
|
// Phase 3 handles depreciation + bolagsskatt + p-fond automatically: only
|
|
// the accruals reminder should remain (Phase 4 will replace it).
|
|
expect(report.reminders.map((r) => r.code)).toContain('accruals_manual')
|
|
expect(report.reminders.map((r) => r.code)).not.toContain('depreciation_manual')
|
|
expect(report.reminders.map((r) => r.code)).not.toContain('bolagsskatt_manual')
|
|
expect(report.reminders.map((r) => r.code)).not.toContain('periodiseringsfond_manual')
|
|
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeUndefined()
|
|
expect(report.reconciliation?.is_reconciled).toBe(true)
|
|
// Scoped to the resolved 1930 cash account: a 4-arg call left cashAccountId
|
|
// undefined, so the bank side pooled every SEK account while the GL side
|
|
// stayed on 1930 and the wizard showed a differens with nothing to match
|
|
// (#1290).
|
|
expect(vi.mocked(getReconciliationStatus)).toHaveBeenCalledWith(
|
|
supabase,
|
|
'co-1',
|
|
'2025-01-01',
|
|
'2025-12-31',
|
|
'1930',
|
|
'SEK',
|
|
CASH_ACCOUNT_ID,
|
|
true,
|
|
)
|
|
})
|
|
|
|
it('drops the reconciliation snapshot when the cash-account lookup fails', async () => {
|
|
// resolveCashAccountScope fails CLOSED. The aggregator's catch must turn
|
|
// that into "no snapshot" rather than into an unscoped 4-arg call, which is
|
|
// the pooling path that produced #1290's phantom differens.
|
|
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
|
// getReconciliationStatus is deliberately left un-stubbed: it must never be
|
|
// reached, and an unstubbed mock resolving to undefined would break the
|
|
// report if it were.
|
|
const supabase = makeSupabase({
|
|
period: { data: PERIOD, error: null },
|
|
settings: { data: { entity_type: 'aktiebolag' }, error: null },
|
|
cashAccount: { data: null, error: { code: '57014', message: 'canceling statement' } },
|
|
})
|
|
|
|
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
|
|
|
expect(report.reconciliation).toBeNull()
|
|
expect(vi.mocked(getReconciliationStatus)).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns the EF-only reminder for enskild firma', async () => {
|
|
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
|
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
|
|
const supabase = makeSupabase({
|
|
period: { data: PERIOD, error: null },
|
|
settings: { data: { entity_type: 'enskild_firma' }, error: null },
|
|
})
|
|
|
|
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
|
|
|
expect(report.entityType).toBe('enskild_firma')
|
|
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeDefined()
|
|
})
|
|
|
|
it('surfaces blockers from the underlying validation and stays not-ready', async () => {
|
|
vi.mocked(validateYearEndReadiness).mockResolvedValue(
|
|
baseValidation({
|
|
ready: false,
|
|
errors: ['3 utkast måste bokföras eller raderas innan bokslut'],
|
|
draftCount: 3,
|
|
}),
|
|
)
|
|
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
|
|
const supabase = makeSupabase({
|
|
period: { data: PERIOD, error: null },
|
|
settings: { data: { entity_type: 'aktiebolag' }, error: null },
|
|
})
|
|
|
|
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
|
|
|
expect(report.ready).toBe(false)
|
|
expect(report.blockers).toHaveLength(1)
|
|
expect(report.draftCount).toBe(3)
|
|
})
|
|
|
|
it('adds a reconciliation reminder when bank is unreconciled', async () => {
|
|
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
|
vi.mocked(getReconciliationStatus).mockResolvedValue({
|
|
...RECON_CLEAN,
|
|
is_reconciled: false,
|
|
unmatched_transaction_count: 7,
|
|
difference: 1234.56,
|
|
})
|
|
const supabase = makeSupabase({
|
|
period: { data: PERIOD, error: null },
|
|
settings: { data: { entity_type: 'aktiebolag' }, error: null },
|
|
})
|
|
|
|
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
|
|
|
const reconReminder = report.reminders.find((r) => r.code === 'bank_reconciliation_incomplete')
|
|
expect(reconReminder).toBeDefined()
|
|
expect(reconReminder?.severity).toBe('warning')
|
|
expect(reconReminder?.message).toContain('7')
|
|
// Reconciliation reminder is not a legal blocker: ready should still mirror validation
|
|
expect(report.ready).toBe(true)
|
|
})
|
|
|
|
it('does not break when reconciliation lookup throws', async () => {
|
|
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
|
vi.mocked(getReconciliationStatus).mockRejectedValue(new Error('boom'))
|
|
const supabase = makeSupabase({
|
|
period: { data: PERIOD, error: null },
|
|
settings: { data: { entity_type: 'aktiebolag' }, error: null },
|
|
})
|
|
|
|
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
|
|
|
expect(report.reconciliation).toBeNull()
|
|
expect(report.reminders.find((r) => r.code === 'bank_reconciliation_incomplete')).toBeUndefined()
|
|
expect(report.ready).toBe(true)
|
|
})
|
|
|
|
it('throws when the fiscal period is missing', async () => {
|
|
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
|
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
|
|
const supabase = makeSupabase({
|
|
period: { data: null, error: { message: 'not found' } },
|
|
settings: { data: null, error: null },
|
|
})
|
|
|
|
await expect(
|
|
buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-missing'),
|
|
).rejects.toThrow(/not found/i)
|
|
})
|
|
|
|
it('defaults to aktiebolag when company_settings is missing', async () => {
|
|
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
|
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
|
|
const supabase = makeSupabase({
|
|
period: { data: PERIOD, error: null },
|
|
settings: { data: null, error: null },
|
|
})
|
|
|
|
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
|
|
|
|
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()
|
|
})
|
|
})
|