fix(bookkeeping): comprehensive chart_of_accounts charset repair (#736)

* fix(bookkeeping): comprehensive chart_of_accounts charset repair

The 20260625120000 backfill (PR #734) only covered the 26 short-name seed
accounts. Investigation found the corruption was far broader — ~4,500 rows
across 858 companies — in four signatures, and verified the root cause is
already closed (prod's seed_chart_of_accounts() carries correct diacritics;
the corruption was prod-migration-drift, the seed fix reached prod ~2026-06-12,
no companies corrupted since).

Adds a tested, reusable repair core + a guarded script:

- lib/bookkeeping/charset-repair.ts — pure, unit-tested resolvers:
  * stripped diacritics ("Utgaende moms forsaljning...") → restore from a
    de-accent-equal clean sibling. DIRECTIONAL guard (only acts on a fully
    de-accented input) so a correct name is never stripped down; unique-match
    only, so user-renamed accounts are never clobbered.
  * double-encoded UTF-8-as-CP1252 ("Företagskonto") → lossless CP1252-aware
    byte reversal (recovers custom names too).
  * CP437-as-CP1252 ("F”rmedlad", "™vriga", "V„rdef”r„ndring") → lossless
    CP437 letter reversal.
  * lost-byte U+FFFD ("p� bilar") → fill via single-char-wildcard match to a
    unique clean sibling (the byte is gone, so only a confident sibling wins).
  isClean() rejects mojibake AND mid-word CP1252 artifacts, but treats a
  space-padded en-dash ("Kundfordringar – delad faktura") as legitimate.

- scripts/repair-chart-of-accounts-charset.ts — dry-run by default, --execute to
  apply; idempotent; refuses any non-prod project. Sources canonical names from
  the table's own clean sibling rows + BAS_REFERENCE.

Applied to production (UPDATE-only, account_name is display-only): 4,499 rows
across 858 companies repaired, 0 double-encoded remaining, 0 errors. 247 rows
left untouched and reported — custom account names with lost bytes and no
canonical (unrecoverable from the data; need the source SIE file or manual fix).

21 unit tests cover every transform with real prod fixtures, plus the two
dry-run bugs caught before any write (correct→stripped direction; matching a
CP437-mojibake sibling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scripts): avoid supabase-js generic mismatch in charset repair fetch

next build's tsc rejected fetchAll(supabase: ReturnType<typeof createClient>)
— the default-generic SupabaseClient type doesn't unify with the inferred
createClient() return. Make fetchAll a closure over the inferred client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scripts): add TOCTOU guard to charset repair updates

Per PR review: only write when the row still holds the exact corrupted value
read (.eq account_name), so a concurrent rename is skipped, not clobbered, and
the script is strictly idempotent. Track skipped count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(charset-repair): build combining-marks regex from ASCII string

Per PR review: the deaccent regex literal embedded raw U+0300–U+036F combining
marks (invisible, encoding-fragile). Build it via RegExp('[\\u0300-\\u036f]')
so the source is plain ASCII. Behavior-identical; 21 tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-15 23:01:45 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e5f1d7a916
commit d95a0b6105
3 changed files with 606 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
/**
* 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)
})