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