Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
341 lines
15 KiB
TypeScript
341 lines
15 KiB
TypeScript
#!/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)
|
|
})
|