Files
accounted/scripts/repair-chart-of-accounts-charset.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
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>
2026-07-04 15:58:06 +02:00

195 lines
7.5 KiB
TypeScript

/**
* Comprehensive charset repair for chart_of_accounts account names.
*
* Background: account names were corrupted for ~888 companies in the seeding /
* import window 2026-03-30 → 2026-06-12, in three signatures (see
* lib/bookkeeping/charset-repair.ts): stripped diacritics, lost-byte U+FFFD, and
* double-encoded UTF-8. The root causes are CLOSED: the runtime
* seed_chart_of_accounts() function on prod now carries correct diacritics (the
* fix migration 20260516130000 was applied to prod ~2026-06-12; classic
* prod-migration-drift, so companies created during the drift window were
* seeded by the old stripped function). This script repairs the legacy rows.
*
* Strategy (per row): resolveCorrectName() against CANDIDATE correct names for
* the same account number: the clean sibling rows already in the table plus the
* BAS reference name. Double-encoded reverses losslessly (recovers customs too);
* stripped/lost-byte require an unambiguous clean sibling, else the row is left
* untouched and reported. Never clobbers user-renamed accounts.
*
* Usage: npx tsx scripts/repair-chart-of-accounts-charset.ts [--execute]
* Without --execute it prints the plan only (dry run). Idempotent: clean rows
* resolve to no-op, so re-running is safe.
*/
import fs from 'fs'
import path from 'path'
import { createClient } from '@supabase/supabase-js'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import {
resolveCorrectName,
hasLostByte,
hasMojibakeSignature,
hasCp1252Artifact,
isClean,
type RepairResult,
} from '@/lib/bookkeeping/charset-repair'
const isCorrupt = (s: string) =>
hasLostByte(s) || hasMojibakeSignature(s) || hasCp1252Artifact(s)
function loadEnv(): { url: string; key: string } {
const envPath = path.resolve(process.cwd(), '.env.local')
const vars: Record<string, string> = {}
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^([A-Z0-9_]+)=(.*)$/)
if (m) vars[m[1]] = m[2].trim()
}
const url = vars.NEXT_PUBLIC_SUPABASE_URL
const key = vars.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) throw new Error('Missing Supabase env in .env.local')
if (!url.includes('pwxtzglxptnnvjrpixpg')) {
throw new Error(`Refusing to run against unexpected project: ${url}`)
}
return { url, key }
}
const EXECUTE = process.argv.includes('--execute')
interface Row {
id: string
company_id: string
account_number: string
account_name: string
}
async function main() {
const { url, key } = loadEnv()
const supabase = createClient(url, key, { auth: { persistSession: false } })
console.log(`=== chart_of_accounts charset repair: ${EXECUTE ? 'EXECUTE' : 'DRY RUN'} ===\n`)
// Closure over the inferred client so we don't annotate (and mismatch) the
// supabase-js generic parameters.
const fetchAll = async (): Promise<Row[]> => {
const out: Row[] = []
const PAGE = 1000
for (let from = 0; ; from += PAGE) {
const { data, error } = await supabase
.from('chart_of_accounts')
.select('id, company_id, account_number, account_name')
.order('id', { ascending: true })
.range(from, from + PAGE - 1)
if (error) throw new Error(`fetch chart_of_accounts failed: ${error.message}`)
const batch = (data ?? []) as Row[]
out.push(...batch)
if (batch.length < PAGE) break
}
return out
}
const rows = await fetchAll()
console.log(`Scanned ${rows.length} chart_of_accounts rows across ${new Set(rows.map((r) => r.company_id)).size} companies.\n`)
// Candidate correct names per account number = clean sibling names in the
// table + the BAS reference name. (Clean = no lost-byte, no mojibake.)
const candidates = new Map<string, Set<string>>()
for (const r of rows) {
if (isClean(r.account_name)) {
const set = candidates.get(r.account_number) ?? new Set<string>()
set.add(r.account_name)
candidates.set(r.account_number, set)
}
}
for (const r of rows) {
const bas = getBASReference(r.account_number)?.account_name
if (bas) {
const set = candidates.get(r.account_number) ?? new Set<string>()
set.add(bas)
candidates.set(r.account_number, set)
}
}
const fixes: Array<Row & { result: RepairResult }> = []
const unresolved: Row[] = []
for (const r of rows) {
const cand = [...(candidates.get(r.account_number) ?? new Set<string>())]
const result = resolveCorrectName(r.account_name, cand)
if (result && result.corrected !== r.account_name) {
fixes.push({ ...r, result })
} else if (!result && isCorrupt(r.account_name)) {
unresolved.push(r) // carries a corruption signature but no confident fix
}
}
// ── Report ──────────────────────────────────────────────────────
const byMethod = (m: RepairResult['method']) => fixes.filter((f) => f.result.method === m)
const sample = (arr: Array<Row & { result: RepairResult }>) =>
arr.slice(0, 8).map((f) => ` ${f.account_number} "${f.account_name}" → "${f.result.corrected}"`)
for (const m of ['sibling_stripped', 'reverse', 'reverse_cp437', 'sibling_lostbyte'] as const) {
const g = byMethod(m)
console.log(`[${m}] ${g.length} rows across ${new Set(g.map((f) => f.company_id)).size} companies`)
if (g.length) console.log(sample(g).join('\n'))
console.log()
}
console.log(`Total fixes: ${fixes.length} rows across ${new Set(fixes.map((f) => f.company_id)).size} companies.`)
if (unresolved.length) {
console.log(`\n[unresolved] ${unresolved.length} rows carry a corruption signature but have no unambiguous clean sibling: left untouched:`)
console.log(
[...new Map(unresolved.map((u) => [`${u.account_number}|${u.account_name}`, u])).values()]
.slice(0, 20)
.map((u) => ` ${u.account_number} "${u.account_name}"`)
.join('\n'),
)
}
if (!EXECUTE) {
console.log('\nDRY RUN: no rows changed. Re-run with --execute to apply.')
return
}
// ── Apply (chunked) ─────────────────────────────────────────────
let applied = 0
let skipped = 0
let errors = 0
const CHUNK = 25
for (let i = 0; i < fixes.length; i += CHUNK) {
const chunk = fixes.slice(i, i + CHUNK)
const results = await Promise.all(
chunk.map((f) =>
supabase
.from('chart_of_accounts')
.update({ account_name: f.result.corrected })
.eq('id', f.id)
.eq('company_id', f.company_id)
// TOCTOU guard: only write if the row still holds the exact corrupted
// value we read. A concurrent rename → 0 rows changed (skipped, not
// clobbered). Also makes the script strictly idempotent.
.eq('account_name', f.account_name)
.select('id')
.then(({ data, error }) =>
error
? { ok: false as const, msg: error.message }
: { ok: true as const, changed: (data?.length ?? 0) > 0 }),
),
)
for (const r of results) {
if (!r.ok) {
errors++
console.error(` update failed: ${r.msg}`)
} else if (r.changed) {
applied++
} else {
skipped++ // row changed under us since the read: left as-is
}
}
if ((i / CHUNK) % 10 === 0) console.log(` …${applied}/${fixes.length} applied`)
}
console.log(`\nDone. Applied ${applied}, skipped ${skipped}, errors ${errors}.`)
}
main().catch((err) => {
console.error('\nREPAIR FAILED:', err)
process.exit(1)
})