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)
})
})