Fix/balance inconsitency (#306)

* feat: implement fiscal period date fields component and validation logic

* feat: update fiscal period validation and naming logic

* feat: implement RPC for computing prior opening balances

- Added `compute_prior_opening_balances` RPC to aggregate opening balances for balance-sheet accounts when no opening balance entry is set.
- Updated tests across various reports to utilize the new RPC for fetching prior balances.
- Refactored `getOpeningBalances` to call the RPC when necessary, improving performance and reliability.
- Introduced a script to repair fiscal period chains for companies with broken periods, ensuring proper linking and continuity.
- Enhanced error handling and validation in the repair script to ensure data integrity during the process.

* feat: implement duplicate opening-balance repair for multi-year SIE imports

* feat: enhance SIE entry listing and deduplication logic for opening balances

* fix: refine companyHasPriorActivity logic to exclude storno entries and improve balance counting
This commit is contained in:
Mattsson
2026-04-21 21:39:57 +02:00
committed by GitHub
parent 28df5d851e
commit 24107338fa
15 changed files with 1317 additions and 137 deletions
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env npx tsx
/**
* Repair redundant opening-balance (IB) entries from multi-year SIE imports.
*
* Problem: the pre-fix SIE import created one posted journal entry with
* source_type='opening_balance' for every year imported. Year N+1's IB
* equals year N's UB, which is already the sum of year N's transactions —
* so summing prior lines to derive a cumulative balance double-counts one
* year of movements per extra IB. Result: cash and other balance-sheet
* accounts drift upward each year.
*
* Fix (per-company): keep the earliest IB entry as the company's pre-system
* starting capital; storno (reverse) every later IB entry. The immutability
* trigger on journal_entries blocks DELETE of posted entries, so storno is
* the only legally-compliant path (BFL / BFNAR 2013:2).
*
* Side effect: the storno'd period's fiscal_periods.opening_balance_entry_id
* link is cleared so getOpeningBalances() falls through to the duplicate-
* safe compute_prior_opening_balances RPC for that period.
*
* Also handles the "start over" case via --purge-imports:
* - Storno every SIE-origin journal entry for the company.
* - Delete sie_imports rows so their (company_id, file_hash) pairs free up.
* - Leaves fiscal periods in place (they may host manual entries too).
*
* Usage:
* # Preview IB dedup only
* npx tsx scripts/repair-company-ib-duplicates.ts \
* --company-id <uuid> --user-id <uuid>
*
* # Apply IB dedup
* npx tsx scripts/repair-company-ib-duplicates.ts \
* --company-id <uuid> --user-id <uuid> --commit
*
* # Preview full purge (IBs + all SIE-origin entries + sie_imports rows)
* npx tsx scripts/repair-company-ib-duplicates.ts \
* --company-id <uuid> --user-id <uuid> --purge-imports
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { reverseEntry } from '../lib/bookkeeping/engine'
// ──────────────────────────────────────────────────────────────────
// Args
// ──────────────────────────────────────────────────────────────────
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
}
const COMPANY_ID = arg('company-id')
const USER_ID = arg('user-id')
const COMMIT = process.argv.includes('--commit')
const PURGE_IMPORTS = process.argv.includes('--purge-imports')
if (!COMPANY_ID || !USER_ID) {
console.error(
'Usage: npx tsx scripts/repair-company-ib-duplicates.ts --company-id <uuid> --user-id <uuid> [--purge-imports] [--commit]'
)
process.exit(1)
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceRoleKey) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
const supabase = createClient(supabaseUrl, serviceRoleKey) as SupabaseClient
// ──────────────────────────────────────────────────────────────────
// Banner
// ──────────────────────────────────────────────────────────────────
console.log('─────────────────────────────────────────────────────────')
console.log('IB Duplicate Repair')
console.log('─────────────────────────────────────────────────────────')
console.log('Supabase URL :', supabaseUrl)
console.log('Company :', COMPANY_ID)
console.log('User :', USER_ID)
console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)')
console.log('Purge imports:', PURGE_IMPORTS ? 'YES (also storno SIE-origin entries)' : 'NO')
console.log('─────────────────────────────────────────────────────────\n')
// ──────────────────────────────────────────────────────────────────
// IB dedup
// ──────────────────────────────────────────────────────────────────
interface IbRow {
id: string
fiscal_period_id: string | null
entry_date: string
created_at: string
voucher_series: string | null
voucher_number: number | null
}
async function listPostedIbEntries(): Promise<IbRow[]> {
const { data, error } = await supabase
.from('journal_entries')
.select('id, fiscal_period_id, entry_date, created_at, voucher_series, voucher_number')
.eq('company_id', COMPANY_ID!)
.eq('source_type', 'opening_balance')
.eq('status', 'posted')
.order('entry_date', { ascending: true })
.order('created_at', { ascending: true })
if (error) throw new Error(`Failed to list IB entries: ${error.message}`)
return (data as IbRow[]) ?? []
}
async function unlinkFromFiscalPeriod(entryId: string): Promise<void> {
const { error } = await supabase
.from('fiscal_periods')
.update({ opening_balance_entry_id: null, opening_balances_set: false })
.eq('company_id', COMPANY_ID!)
.eq('opening_balance_entry_id', entryId)
if (error) throw new Error(`Failed to unlink fiscal_periods.opening_balance_entry_id: ${error.message}`)
}
async function stornoIbDuplicates(): Promise<{ kept: IbRow | null; stornoed: number; failed: number }> {
console.log('[1/2] IB dedup')
const ibs = await listPostedIbEntries()
console.log(` · Found ${ibs.length} posted opening_balance entries`)
if (ibs.length === 0) {
console.log(' · Nothing to do.')
return { kept: null, stornoed: 0, failed: 0 }
}
const [earliest, ...redundant] = ibs
console.log(
` · Keeping earliest IB: ${earliest.id} on ${earliest.entry_date} ` +
`(series ${earliest.voucher_series ?? '?'} #${earliest.voucher_number ?? '?'})`
)
for (const r of redundant) {
console.log(
` · Will storno: ${r.id} on ${r.entry_date} ` +
`(series ${r.voucher_series ?? '?'} #${r.voucher_number ?? '?'})`
)
}
if (!COMMIT) {
console.log(' · [dry-run] skipping storno and unlink')
return { kept: earliest, stornoed: 0, failed: 0 }
}
let stornoed = 0
let failed = 0
for (const r of redundant) {
try {
await unlinkFromFiscalPeriod(r.id)
await reverseEntry(supabase, COMPANY_ID!, USER_ID!, r.id)
console.log(` · Stornoed ${r.id}`)
stornoed++
} catch (err) {
console.error(` · FAILED to storno ${r.id}:`, err instanceof Error ? err.message : err)
failed++
}
}
return { kept: earliest, stornoed, failed }
}
// ──────────────────────────────────────────────────────────────────
// Optional full SIE purge (for "start over" scenario)
// ──────────────────────────────────────────────────────────────────
interface SieImportRow {
id: string
filename: string | null
file_hash: string | null
fiscal_period_id: string | null
status: string | null
imported_at: string | null
}
async function listSieImports(): Promise<SieImportRow[]> {
const { data, error } = await supabase
.from('sie_imports')
.select('id, filename, file_hash, fiscal_period_id, status, imported_at')
.eq('company_id', COMPANY_ID!)
.order('imported_at', { ascending: true })
if (error) throw new Error(`Failed to list sie_imports: ${error.message}`)
return (data as SieImportRow[]) ?? []
}
async function listSieOriginEntries(periodIds: string[]): Promise<{ id: string; voucher_number: number | null; entry_date: string }[]> {
if (periodIds.length === 0) return []
const { data, error } = await supabase
.from('journal_entries')
.select('id, voucher_number, entry_date, fiscal_period_id, source_type, status')
.eq('company_id', COMPANY_ID!)
.in('fiscal_period_id', periodIds)
.eq('status', 'posted')
.in('source_type', ['import', 'opening_balance'])
if (error) throw new Error(`Failed to list SIE-origin entries: ${error.message}`)
return (data as { id: string; voucher_number: number | null; entry_date: string }[]) ?? []
}
async function purgeSieImports(): Promise<void> {
console.log('\n[2/2] SIE import purge')
const imports = await listSieImports()
console.log(` · Found ${imports.length} sie_imports rows`)
if (imports.length === 0) {
console.log(' · Nothing to purge.')
return
}
const periodIds = Array.from(new Set(imports.map((i) => i.fiscal_period_id).filter((p): p is string => !!p)))
const entries = await listSieOriginEntries(periodIds)
console.log(` · Found ${entries.length} posted entries in affected fiscal periods (${periodIds.length} periods)`)
for (const imp of imports) {
console.log(
` · Will remove sie_imports row ${imp.id} (${imp.filename ?? 'unnamed'}, file_hash ${imp.file_hash?.slice(0, 12) ?? '?'}…)`
)
}
if (!COMMIT) {
console.log(' · [dry-run] skipping storno and sie_imports delete')
return
}
let stornoed = 0
let failed = 0
for (const e of entries) {
try {
await supabase
.from('fiscal_periods')
.update({ opening_balance_entry_id: null, opening_balances_set: false })
.eq('company_id', COMPANY_ID!)
.eq('opening_balance_entry_id', e.id)
await reverseEntry(supabase, COMPANY_ID!, USER_ID!, e.id)
stornoed++
} catch (err) {
console.error(` · FAILED to storno entry ${e.id}:`, err instanceof Error ? err.message : err)
failed++
}
}
console.log(` · Stornoed ${stornoed}/${entries.length} entries (${failed} failed)`)
const { error: delErr } = await supabase
.from('sie_imports')
.delete()
.eq('company_id', COMPANY_ID!)
if (delErr) {
console.error(` · FAILED to delete sie_imports rows: ${delErr.message}`)
} else {
console.log(` · Deleted ${imports.length} sie_imports rows`)
}
}
// ──────────────────────────────────────────────────────────────────
// Main
// ──────────────────────────────────────────────────────────────────
async function main() {
try {
const ibResult = await stornoIbDuplicates()
if (PURGE_IMPORTS) {
await purgeSieImports()
}
console.log('\n─────────────────────────────────────────────────────────')
console.log('Summary')
console.log('─────────────────────────────────────────────────────────')
if (ibResult.kept) {
console.log(`Kept IB entry : ${ibResult.kept.id} (${ibResult.kept.entry_date})`)
}
console.log(`IBs stornoed : ${ibResult.stornoed}`)
console.log(`IB storno fails : ${ibResult.failed}`)
console.log(`Mode : ${COMMIT ? 'COMMIT' : 'DRY RUN'}`)
if (!COMMIT) {
console.log('\nRe-run with --commit to apply.')
}
} catch (err) {
console.error('\nFATAL:', err instanceof Error ? err.message : err)
process.exit(1)
}
}
main()
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env npx tsx
/**
* One-off repair for a company whose fiscal period chain was broken by
* the pre-fix "change fiscal year" flow: a newer period was created
* without previous_period_id and without an opening_balance_entry_id,
* so the balance sheet falls back to a full-history scan (and times out
* on production with 8k+ prior lines).
*
* Target state expected (validated before running):
*
* Prior period (e.g. 2024/2025): open, has entries, no closing_entry_id.
* Gap: Sep-Dec 2025, zero entries.
* Orphan period (e.g. 2026): previous_period_id=NULL,
* opening_balance_entry_id=NULL,
* zero entries.
*
* End state:
*
* Prior period: year-end-closed (locked, closing_entry_id, is_closed).
* Short period: Sep-Dec 2025, previous_period_id=prior,
* opening_balance_entry_id set, locked.
* Orphan period: previous_period_id=short, opening_balance_entry_id set.
*
* Usage:
* npx tsx scripts/repair-fiscal-period-chain.ts \
* --company-id <uuid> --user-id <uuid> --prior <uuid> --orphan <uuid> \
* [--short-start 2025-09-01] [--short-end 2025-12-31] \
* [--commit] # default is --dry-run
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { createClient } from '@supabase/supabase-js'
import {
previewYearEndClosing,
generateOpeningBalances,
} from '../lib/core/bookkeeping/year-end-service'
import { validateBalanceContinuity } from '../lib/reports/continuity-check'
import { lockPeriod } from '../lib/core/bookkeeping/period-service'
import { executeCurrencyRevaluation } from '../lib/bookkeeping/currency-revaluation'
import { createJournalEntry } from '../lib/bookkeeping/engine'
// ────────────────────────────────────────────────────────────────────
// Args
// ────────────────────────────────────────────────────────────────────
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
}
const COMPANY_ID = arg('company-id')
const USER_ID = arg('user-id')
const PRIOR_ID = arg('prior')
const ORPHAN_ID = arg('orphan')
const SHORT_START = arg('short-start') ?? '2025-09-01'
const SHORT_END = arg('short-end') ?? '2025-12-31'
const COMMIT = process.argv.includes('--commit')
if (!COMPANY_ID || !USER_ID || !PRIOR_ID || !ORPHAN_ID) {
console.error(
'Usage: npx tsx scripts/repair-fiscal-period-chain.ts --company-id <uuid> --user-id <uuid> --prior <uuid> --orphan <uuid> [--short-start 2025-09-01] [--short-end 2025-12-31] [--commit]'
)
process.exit(1)
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceRoleKey) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
const supabase = createClient(supabaseUrl, serviceRoleKey)
// ────────────────────────────────────────────────────────────────────
// Banner
// ────────────────────────────────────────────────────────────────────
console.log('─────────────────────────────────────────────────────────')
console.log('Fiscal Period Chain Repair')
console.log('─────────────────────────────────────────────────────────')
console.log('Supabase URL :', supabaseUrl)
console.log('Company :', COMPANY_ID)
console.log('User :', USER_ID)
console.log('Prior period :', PRIOR_ID)
console.log('Orphan period:', ORPHAN_ID)
console.log('Short period :', `${SHORT_START} → ${SHORT_END}`)
console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)')
console.log('─────────────────────────────────────────────────────────\n')
// ────────────────────────────────────────────────────────────────────
// Validate state
// ────────────────────────────────────────────────────────────────────
async function validateState() {
const { data: prior } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', PRIOR_ID)
.eq('company_id', COMPANY_ID)
.single()
if (!prior) throw new Error(`Prior period ${PRIOR_ID} not found for company ${COMPANY_ID}`)
if (prior.is_closed) throw new Error('Prior period is already closed')
if (prior.closing_entry_id) throw new Error('Prior period already has closing_entry_id')
const { data: orphan } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', ORPHAN_ID)
.eq('company_id', COMPANY_ID)
.single()
if (!orphan) throw new Error(`Orphan period ${ORPHAN_ID} not found for company ${COMPANY_ID}`)
if (orphan.previous_period_id)
throw new Error(`Orphan period already has previous_period_id = ${orphan.previous_period_id}`)
if (orphan.opening_balance_entry_id)
throw new Error('Orphan period already has opening_balance_entry_id')
// Gap check: zero entries between prior end and short end
const { count: gapCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', COMPANY_ID)
.gt('entry_date', prior.period_end)
.lte('entry_date', SHORT_END)
.in('status', ['posted', 'reversed'])
if ((gapCount ?? 0) > 0)
throw new Error(
`Gap between ${prior.period_end} and ${SHORT_END} has ${gapCount} entries — repair assumes zero activity in gap`
)
// Orphan must be empty
const { count: orphanEntries } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', COMPANY_ID)
.eq('fiscal_period_id', ORPHAN_ID)
.in('status', ['posted', 'reversed'])
if ((orphanEntries ?? 0) > 0)
throw new Error(`Orphan period has ${orphanEntries} posted entries — not safe to repair automatically`)
// Short period dates must be contiguous with prior
const nextAfterPrior = new Date(prior.period_end + 'T12:00:00Z')
nextAfterPrior.setUTCDate(nextAfterPrior.getUTCDate() + 1)
const expected = nextAfterPrior.toISOString().split('T')[0]
if (expected !== SHORT_START)
throw new Error(`--short-start ${SHORT_START} must equal ${expected} (day after prior.period_end)`)
// Short end must be day before orphan start
const beforeOrphan = new Date(orphan.period_start + 'T12:00:00Z')
beforeOrphan.setUTCDate(beforeOrphan.getUTCDate() - 1)
const expectedEnd = beforeOrphan.toISOString().split('T')[0]
if (expectedEnd !== SHORT_END)
throw new Error(`--short-end ${SHORT_END} must equal ${expectedEnd} (day before orphan.period_start)`)
return { prior, orphan }
}
// ────────────────────────────────────────────────────────────────────
// Steps
// ────────────────────────────────────────────────────────────────────
async function step1YearEndPrior(priorEnd: string) {
console.log('\n[1/7] Year-end closing on prior period')
console.log(' · Currency revaluation preview')
if (COMMIT) {
await executeCurrencyRevaluation(supabase, COMPANY_ID!, priorEnd, PRIOR_ID!, USER_ID!)
}
console.log(' · Building closing entry preview')
const preview = await previewYearEndClosing(supabase, COMPANY_ID!, USER_ID!, PRIOR_ID!)
console.log(` net result: ${preview.netResult} → ${preview.closingAccount}`)
console.log(` ${preview.closingLines.length} closing lines`)
if (preview.closingLines.length === 0) {
throw new Error('No result accounts to close — prior period has no activity')
}
if (!COMMIT) {
console.log(' · [dry-run] skipping createJournalEntry, update, lock, close')
return { closingEntryId: '<dry-run>' }
}
console.log(' · Creating closing entry')
const closingEntry = await createJournalEntry(supabase, COMPANY_ID!, USER_ID!, {
fiscal_period_id: PRIOR_ID!,
entry_date: priorEnd,
description: 'Årsbokslut (repair)',
source_type: 'year_end',
voucher_series: 'A',
lines: preview.closingLines,
})
await supabase
.from('fiscal_periods')
.update({ closing_entry_id: closingEntry.id })
.eq('id', PRIOR_ID!)
.eq('company_id', COMPANY_ID!)
console.log(' · Locking and closing prior period')
await lockPeriod(supabase, COMPANY_ID!, USER_ID!, PRIOR_ID!)
await supabase
.from('fiscal_periods')
.update({ is_closed: true, closed_at: new Date().toISOString() })
.eq('id', PRIOR_ID!)
.eq('company_id', COMPANY_ID!)
return { closingEntryId: closingEntry.id }
}
async function step2InsertShortPeriod() {
console.log('\n[2/7] Inserting short transition period')
console.log(` · ${SHORT_START} → ${SHORT_END}`)
if (!COMMIT) {
console.log(' · [dry-run] skipping insert')
return '<dry-run>'
}
const { data, error } = await supabase
.from('fiscal_periods')
.insert({
company_id: COMPANY_ID!,
user_id: USER_ID!,
name: `Transition ${SHORT_START.slice(0, 7)}–${SHORT_END.slice(0, 7)}`,
period_start: SHORT_START,
period_end: SHORT_END,
previous_period_id: PRIOR_ID!,
})
.select()
.single()
if (error || !data) throw new Error(`Failed to insert short period: ${error?.message}`)
console.log(` · Short period id: ${data.id}`)
return data.id as string
}
async function step3GenerateShortOb(shortPeriodId: string) {
console.log('\n[3/7] Generating OB entry on short period (carries forward from prior)')
if (!COMMIT) {
console.log(' · [dry-run] skipping generateOpeningBalances')
return '<dry-run>'
}
const ob = await generateOpeningBalances(supabase, COMPANY_ID!, USER_ID!, PRIOR_ID!, shortPeriodId)
console.log(` · OB entry id: ${ob.id}`)
return ob.id
}
async function step4LockShort(shortPeriodId: string) {
console.log('\n[4/7] Locking short period (no period activity to close)')
if (!COMMIT) {
console.log(' · [dry-run] skipping lockPeriod')
return
}
await lockPeriod(supabase, COMPANY_ID!, USER_ID!, shortPeriodId)
}
async function step5LinkOrphan(shortPeriodId: string) {
console.log('\n[5/7] Linking orphan period to short period')
if (!COMMIT) {
console.log(' · [dry-run] skipping update orphan.previous_period_id')
return
}
const { error } = await supabase
.from('fiscal_periods')
.update({ previous_period_id: shortPeriodId })
.eq('id', ORPHAN_ID!)
.eq('company_id', COMPANY_ID!)
if (error) throw new Error(`Failed to link orphan: ${error.message}`)
}
async function step6GenerateOrphanOb(shortPeriodId: string) {
console.log('\n[6/7] Generating OB entry on orphan period (carries forward from short)')
if (!COMMIT) {
console.log(' · [dry-run] skipping generateOpeningBalances')
return '<dry-run>'
}
const ob = await generateOpeningBalances(supabase, COMPANY_ID!, USER_ID!, shortPeriodId, ORPHAN_ID!)
console.log(` · OB entry id: ${ob.id}`)
return ob.id
}
async function step7Continuity() {
console.log('\n[7/7] Validating IB/UB continuity on orphan period')
if (!COMMIT) {
console.log(' · [dry-run] skipping continuity check')
return
}
const result = await validateBalanceContinuity(supabase, COMPANY_ID!, ORPHAN_ID!)
console.log(` · valid: ${result.valid}, checked: ${result.checked_accounts} accounts`)
if (!result.valid) {
console.log(' · discrepancies:')
for (const d of result.discrepancies) {
console.log(` ${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`)
}
}
await supabase
.from('fiscal_periods')
.update({ continuity_verified: result.valid })
.eq('id', ORPHAN_ID!)
.eq('company_id', COMPANY_ID!)
}
// ────────────────────────────────────────────────────────────────────
// Run
// ────────────────────────────────────────────────────────────────────
async function main() {
const { prior } = await validateState()
console.log(`✓ Validated state — prior '${prior.name}' (${prior.period_start} → ${prior.period_end})`)
const { closingEntryId } = await step1YearEndPrior(prior.period_end)
const shortPeriodId = await step2InsertShortPeriod()
const shortObId = await step3GenerateShortOb(shortPeriodId)
await step4LockShort(shortPeriodId)
await step5LinkOrphan(shortPeriodId)
const orphanObId = await step6GenerateOrphanOb(shortPeriodId)
await step7Continuity()
console.log('\n─────────────────────────────────────────────────────────')
console.log('Summary')
console.log('─────────────────────────────────────────────────────────')
console.log('Prior closing entry :', closingEntryId)
console.log('Short period id :', shortPeriodId)
console.log('Short OB entry id :', shortObId)
console.log('Orphan OB entry id :', orphanObId)
console.log('Mode :', COMMIT ? 'COMMITTED' : 'DRY RUN (no writes)')
console.log('─────────────────────────────────────────────────────────')
}
main().catch((err) => {
console.error('\n✗ Repair failed:', err instanceof Error ? err.message : err)
process.exit(1)
})