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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user