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>
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
#!/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()
|