feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)

Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr,
invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/
Anthropic/OpenAI deps) to simplify core and reduce bundle size.

Restructure monolithic settings page into dedicated sub-pages (company,
bookkeeping, invoicing, tax, banking, api, account, team, templates) with
shared layout and sidebar navigation.

Add atomic commit_journal_entry RPC so voucher number increment and status
update happen in a single transaction — prevents burned numbers on constraint
failures. Add continuity check report and voucher gap explanation tracking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
@@ -69,23 +69,33 @@ beforeEach(() => {
})
describe('validateYearEndReadiness', () => {
// Helper: standard results for no-gap, single-series validation
function noGapResults(period: ReturnType<typeof makeFiscalPeriod>, overrides: {
draftCount?: number
postedCount?: number
revalCount?: number
fxReceivables?: number
fxPayables?: number
} = {}) {
return [
{ data: period, error: null }, // fetch period (.single)
{ data: null, error: null, count: overrides.draftCount ?? 0 }, // count drafts (thenable)
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences (thenable)
{ data: [], error: null }, // detect_voucher_gaps RPC
// no gaps → gap_explanations query skipped
{ data: { last_number: 10 }, error: null }, // reconciliation: voucher_sequences.last_number (.single)
{ data: { voucher_number: 10 }, error: null }, // reconciliation: journal_entries max (.maybeSingle)
// trial balance mocked separately
{ data: null, error: null, count: overrides.postedCount ?? 5 }, // count posted (thenable)
{ data: null, error: null, count: overrides.revalCount ?? 0 }, // count revaluation (thenable)
{ data: null, error: null, count: overrides.fxReceivables ?? 0 }, // fx receivables (thenable)
{ data: null, error: null, count: overrides.fxPayables ?? 0 }, // fx payables (thenable)
]
}
it('returns errors when drafts exist', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
results = [
// 0: fetch period (.single)
{ data: period, error: null },
// 1: count drafts (thenable chain) — count: 3
{ data: null, error: null, count: 3 },
// 2: count posted entries (thenable chain) — count: 10
{ data: null, error: null, count: 10 },
// 3: count revaluation entries — count: 0
{ data: null, error: null, count: 0 },
// 4: count fx receivables — count: 0
{ data: null, error: null, count: 0 },
// 5: count fx payables — count: 0
{ data: null, error: null, count: 0 },
]
results = noGapResults(period, { draftCount: 3, postedCount: 10 })
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
@@ -102,15 +112,7 @@ describe('validateYearEndReadiness', () => {
it('returns errors when trial balance is unbalanced', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
results = [
{ data: period, error: null },
{ data: null, error: null, count: 0 }, // no drafts
{ data: null, error: null, count: 5 }, // some posted
{ data: null, error: null, count: 0 }, // no revaluation
{ data: null, error: null, count: 0 }, // no fx receivables
{ data: null, error: null, count: 0 }, // no fx payables
]
results = noGapResults(period)
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
@@ -126,10 +128,31 @@ describe('validateYearEndReadiness', () => {
expect(result.errors.some((e: string) => e.includes('Trial balance'))).toBe(true)
})
it('warns on voucher gaps', async () => {
it('returns error when period has not yet ended', async () => {
const period = makeFiscalPeriod({
id: 'fp-1',
is_closed: false,
closing_entry_id: null,
period_end: '2099-12-31',
})
results = noGapResults(period)
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 0,
totalCredit: 0,
} as never)
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('not yet ended'))).toBe(true)
})
it('warns on explained voucher gaps', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
// Create a client with custom rpc that returns gap data
const builder = makeBuilder()
const supabase = {
from: vi.fn().mockImplementation(() => builder),
@@ -141,12 +164,17 @@ describe('validateYearEndReadiness', () => {
resultIdx = 0
results = [
{ data: period, error: null },
{ data: null, error: null, count: 0 }, // no drafts
{ data: null, error: null, count: 5 }, // some posted
{ data: null, error: null, count: 0 }, // no revaluation
{ data: null, error: null, count: 0 }, // no fx receivables
{ data: null, error: null, count: 0 }, // no fx payables
{ data: period, error: null }, // fetch period
{ data: null, error: null, count: 0 }, // count drafts
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences
// rpc for detect_voucher_gaps handled by custom mock
{ data: [{ voucher_series: 'A', gap_start: 5, gap_end: 7 }], error: null }, // gap_explanations
{ data: { last_number: 10 }, error: null }, // reconciliation: last_number
{ data: { voucher_number: 10 }, error: null }, // reconciliation: max voucher
{ data: null, error: null, count: 5 }, // count posted
{ data: null, error: null, count: 0 }, // count revaluation
{ data: null, error: null, count: 0 }, // fx receivables
{ data: null, error: null, count: 0 }, // fx payables
]
vi.mocked(generateTrialBalance).mockResolvedValue({
@@ -157,8 +185,164 @@ describe('validateYearEndReadiness', () => {
} as never)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.some((w: string) => w.includes('gap'))).toBe(true)
expect(result.warnings.some((w: string) => w.includes('documented'))).toBe(true)
expect(result.voucherGaps).toHaveLength(1)
expect(result.voucherGaps[0].series).toBe('A')
expect(result.unexplainedGaps).toHaveLength(0)
expect(result.ready).toBe(true)
})
it('blocks on unexplained voucher gaps', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
const builder = makeBuilder()
const supabase = {
from: vi.fn().mockImplementation(() => builder),
rpc: vi.fn().mockResolvedValue({
data: [{ gap_start: 5, gap_end: 7 }],
error: null,
}),
}
resultIdx = 0
results = [
{ data: period, error: null }, // fetch period
{ data: null, error: null, count: 0 }, // count drafts
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences
// rpc for detect_voucher_gaps handled by custom mock
{ data: [], error: null }, // gap_explanations — empty
{ data: { last_number: 10 }, error: null }, // reconciliation: last_number
{ data: { voucher_number: 10 }, error: null }, // reconciliation: max voucher
{ data: null, error: null, count: 5 }, // count posted
{ data: null, error: null, count: 0 }, // count revaluation
{ data: null, error: null, count: 0 }, // fx receivables
{ data: null, error: null, count: 0 }, // fx payables
]
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
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('Unexplained voucher gap'))).toBe(true)
expect(result.unexplainedGaps).toHaveLength(1)
expect(result.unexplainedGaps[0]).toEqual({ gap_start: 5, gap_end: 7, series: 'A' })
})
it('detects gaps across multiple voucher series', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
const builder = makeBuilder()
let rpcCallCount = 0
const supabase = {
from: vi.fn().mockImplementation(() => builder),
rpc: vi.fn().mockImplementation(() => {
rpcCallCount++
if (rpcCallCount === 1) {
return Promise.resolve({ data: [{ gap_start: 3, gap_end: 3 }], error: null })
}
return Promise.resolve({ data: [{ gap_start: 1, gap_end: 2 }], error: null })
}),
}
resultIdx = 0
results = [
{ data: period, error: null }, // fetch period
{ data: null, error: null, count: 0 }, // count drafts
{ data: [{ voucher_series: 'A' }, { voucher_series: 'B' }], error: null }, // voucher_sequences
// rpc calls handled by custom mock
{ data: [], error: null }, // gap_explanations — empty
{ data: { last_number: 5 }, error: null }, // reconciliation A: last_number
{ data: { voucher_number: 5 }, error: null }, // reconciliation A: max voucher
{ data: { last_number: 3 }, error: null }, // reconciliation B: last_number
{ data: { voucher_number: 3 }, error: null }, // reconciliation B: max voucher
{ data: null, error: null, count: 5 }, // count posted
{ data: null, error: null, count: 0 }, // count revaluation
{ data: null, error: null, count: 0 }, // fx receivables
{ data: null, error: null, count: 0 }, // fx payables
]
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.voucherGaps).toHaveLength(2)
expect(result.voucherGaps[0]).toEqual({ gap_start: 3, gap_end: 3, series: 'A' })
expect(result.voucherGaps[1]).toEqual({ gap_start: 1, gap_end: 2, series: 'B' })
expect(result.unexplainedGaps).toHaveLength(2)
expect(result.errors.some((e: string) => e.includes('series A'))).toBe(true)
expect(result.errors.some((e: string) => e.includes('series B'))).toBe(true)
})
it('detects sequence counter mismatch (counter < actual)', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
results = [
{ data: period, error: null }, // fetch period
{ data: null, error: null, count: 0 }, // count drafts
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences
{ data: [], error: null }, // detect_voucher_gaps RPC
// no gaps → gap_explanations skipped
{ data: { last_number: 5 }, error: null }, // reconciliation: last_number (counter behind!)
{ data: { voucher_number: 10 }, error: null }, // reconciliation: max voucher
{ data: null, error: null, count: 5 }, // count posted
{ data: null, error: null, count: 0 }, // count revaluation
{ data: null, error: null, count: 0 }, // fx receivables
{ data: null, error: null, count: 0 }, // fx payables
]
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
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('Sequence counter integrity error'))).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
expect(result.sequenceMismatches[0]).toEqual({ series: 'A', sequenceCounter: 5, actualMax: 10 })
})
it('warns when sequence counter is ahead of actual (burned numbers)', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
results = [
{ data: period, error: null }, // fetch period
{ data: null, error: null, count: 0 }, // count drafts
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences
{ data: [], error: null }, // detect_voucher_gaps RPC
// no gaps → gap_explanations skipped
{ data: { last_number: 12 }, error: null }, // reconciliation: last_number (counter ahead)
{ data: { voucher_number: 10 }, error: null }, // reconciliation: max voucher
{ data: null, error: null, count: 5 }, // count posted
{ data: null, error: null, count: 0 }, // count revaluation
{ data: null, error: null, count: 0 }, // fx receivables
{ data: null, error: null, count: 0 }, // fx payables
]
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(true) // warning, not blocking
expect(result.warnings.some((w: string) => w.includes('Sequence counter ahead'))).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
})
})
+135 -10
View File
@@ -8,6 +8,7 @@ import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
import type {
YearEndValidation,
YearEndPreview,
@@ -16,6 +17,7 @@ import type {
FiscalPeriod,
JournalEntry,
VoucherGap,
SequenceMismatch,
} from '@/types'
/**
@@ -46,10 +48,18 @@ export async function validateYearEndReadiness(
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: false,
}
}
// Check: period must have ended (BFNAR 2017:3 / ÅRL 2:1)
const today = new Date().toISOString().split('T')[0]
if (period.period_end > today) {
errors.push('Cannot close a fiscal period that has not yet ended')
}
// Check: period not already closed
if (period.is_closed) {
errors.push('Period is already closed')
@@ -73,19 +83,109 @@ export async function validateYearEndReadiness(
errors.push(`${drafts} draft journal entries must be posted or deleted before closing`)
}
// Check: voucher continuity
// Check: voucher continuity across all series
let voucherGaps: VoucherGap[] = []
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: 'A',
})
const { data: seriesRows } = await supabase
.from('voucher_sequences')
.select('voucher_series')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
if (!gapsError && gaps && gaps.length > 0) {
voucherGaps = gaps as VoucherGap[]
warnings.push(
`Voucher number gaps detected: ${voucherGaps.map((g) => `${g.gap_start}-${g.gap_end}`).join(', ')}`
const seriesToCheck = seriesRows && seriesRows.length > 0
? seriesRows.map((r: { voucher_series: string }) => r.voucher_series)
: ['A']
for (const series of seriesToCheck) {
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: series,
})
if (!gapsError && gaps && gaps.length > 0) {
const tagged = (gaps as Array<{ gap_start: number; gap_end: number }>).map((g) => ({
...g,
series,
}))
voucherGaps.push(...tagged)
}
}
// Check gap explanations — unexplained gaps block year-end (BFNAR 2013:2 punkt 5.8)
let unexplainedGaps: VoucherGap[] = []
if (voucherGaps.length > 0) {
const { data: explanations } = await supabase
.from('voucher_gap_explanations')
.select('voucher_series, gap_start, gap_end')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
const explanationSet = new Set(
(explanations ?? []).map(
(e: { voucher_series: string; gap_start: number; gap_end: number }) =>
`${e.voucher_series}:${e.gap_start}:${e.gap_end}`
)
)
for (const gap of voucherGaps) {
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
if (explanationSet.has(key)) {
warnings.push(
`Voucher gap in series ${gap.series} (${gap.gap_start}-${gap.gap_end}) — documented`
)
} else {
unexplainedGaps.push(gap)
errors.push(
`Unexplained voucher gap in series ${gap.series}: ${gap.gap_start}-${gap.gap_end}`
)
}
}
}
// Check: sequence counter reconciliation
const sequenceMismatches: SequenceMismatch[] = []
if (seriesRows && seriesRows.length > 0) {
for (const row of seriesRows as Array<{ voucher_series: string }>) {
const { data: seqData } = await supabase
.from('voucher_sequences')
.select('last_number')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('voucher_series', row.voucher_series)
.single()
const { data: maxData } = await supabase
.from('journal_entries')
.select('voucher_number')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('voucher_series', row.voucher_series)
.neq('status', 'draft')
.order('voucher_number', { ascending: false })
.limit(1)
.maybeSingle()
const sequenceCounter = seqData?.last_number ?? 0
const actualMax = maxData?.voucher_number ?? 0
if (sequenceCounter !== actualMax) {
sequenceMismatches.push({
series: row.voucher_series,
sequenceCounter,
actualMax,
})
if (sequenceCounter < actualMax) {
errors.push(
`Sequence counter integrity error in series ${row.voucher_series}: counter=${sequenceCounter} but max voucher=${actualMax}`
)
} else {
warnings.push(
`Sequence counter ahead of actual entries in series ${row.voucher_series}: counter=${sequenceCounter}, max voucher=${actualMax}`
)
}
}
}
}
// Check: trial balance is balanced
@@ -144,12 +244,19 @@ export async function validateYearEndReadiness(
}
}
// Check: continuity_verified flag from prior year-end
if (period.continuity_verified === false) {
errors.push('Opening balance continuity check failed for this period — resolve discrepancies before closing')
}
return {
ready: errors.length === 0,
errors,
warnings,
draftCount: drafts,
voucherGaps,
unexplainedGaps,
sequenceMismatches,
trialBalanceBalanced,
}
}
@@ -374,6 +481,24 @@ export async function executeYearEndClosing(
nextPeriod.id
)
// 10. Validate IB/UB continuity and persist result
const continuity = await validateBalanceContinuity(supabase, companyId, nextPeriod.id)
await supabase
.from('fiscal_periods')
.update({ continuity_verified: continuity.valid })
.eq('id', nextPeriod.id)
.eq('company_id', companyId)
if (!continuity.valid) {
throw new Error(
`IB/UB continuity check failed: ${continuity.discrepancies.length} account(s) differ. ` +
continuity.discrepancies.map(d =>
`${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`
).join('; ')
)
}
// Fetch the now-closed period for the event payload
const { data: closedPeriod } = await supabase
.from('fiscal_periods')