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:
co-authored by
Claude Opus 4.6
parent
e89f2c402d
commit
d0b3f21bde
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user