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
@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest'
import {
deaccent,
reverseMojibake,
reverseCp437Mojibake,
hasLostByte,
hasMojibakeSignature,
hasCp1252Artifact,
isClean,
resolveCorrectName,
REPLACEMENT_CHAR,
} from '../charset-repair'
describe('deaccent', () => {
it('strips Swedish diacritics, preserving case and other chars', () => {
expect(deaccent('Utgående moms försäljning inom Sverige, 25%')).toBe(
'Utgaende moms forsaljning inom Sverige, 25%',
)
expect(deaccent('Övriga bankkonton')).toBe('Ovriga bankkonton')
expect(deaccent('Årets resultat')).toBe('Arets resultat')
expect(deaccent('Löner')).toBe('Loner')
expect(deaccent('HEMKÖP LINNÉ')).toBe('HEMKOP LINNE')
})
it('is a no-op on already-ASCII names', () => {
expect(deaccent('Kassa')).toBe('Kassa')
})
})
describe('reverseMojibake (double-encoding)', () => {
// Real prod fixtures (chart_of_accounts, project pwxtzglxptnnvjrpixpg).
it('recovers lowercase å/ä/ö', () => {
expect(reverseMojibake('Företagskonto / checkkonto')).toBe('Företagskonto / checkkonto')
expect(reverseMojibake('Leverantörsskulder')).toBe('Leverantörsskulder')
expect(reverseMojibake('Utgående moms försäljning inom Sverige, 25%')).toBe(
'Utgående moms försäljning inom Sverige, 25%',
)
})
it('recovers UPPERCASE Å/Ä/Ö (CP1252 punctuation continuation bytes)', () => {
expect(reverseMojibake('Övriga bankkonton')).toBe('Övriga bankkonton')
expect(reverseMojibake('Ã…rets resultat')).toBe('Årets resultat')
})
it('recovers custom (non-BAS) names losslessly', () => {
expect(reverseMojibake('Lån från närstående personer, långfristig del')).toBe(
'Lån från närstående personer, långfristig del',
)
})
it('returns null for already-correct names (no-op safety)', () => {
expect(reverseMojibake('Företagskonto')).toBeNull() // ö alone isn't valid double-encoding
expect(reverseMojibake('Kassa')).toBe('Kassa') // pure ASCII round-trips to itself
})
})
describe('reverseCp437Mojibake (CP437 read as CP1252)', () => {
// Real prod fixtures: CP437 SIE diacritic bytes rendered as CP1252 specials.
it('recovers ö/ä/å/Ö from C1 specials', () => {
expect(reverseCp437Mojibake('F”rmedlad frakt')).toBe('Förmedlad frakt') // ö (0x94→”)
expect(reverseCp437Mojibake('™vriga fastighetskostnader, ej avdragsgilla')).toBe(
'Övriga fastighetskostnader, ej avdragsgilla', // Ö (0x99→™)
)
expect(reverseCp437Mojibake('V„rdef”r„ndring kapitalf”rs„kring')).toBe(
'Värdeförändring kapitalförsäkring', // ä (0x84→„), ö (0x94→”)
)
expect(reverseCp437Mojibake('p† arvoden')).toBe('på arvoden') // å (0x86→†)
expect(reverseCp437Mojibake('L”ner till tj„nstem„n (avgiftsbefriade)')).toBe(
'Löner till tjänstemän (avgiftsbefriade)',
)
})
it('returns null on clean names and on bytes it cannot safely map', () => {
expect(reverseCp437Mojibake('Kassa')).toBeNull()
expect(reverseCp437Mojibake('Företagskonto')).toBeNull() // ö (0xF6→÷) not a CP437 letter byte
})
})
describe('resolveCorrectName — CP437 branch', () => {
it('reverses a CP437-as-CP1252 name without a sibling', () => {
expect(resolveCorrectName('™vriga fastighetskostnader', [])).toEqual({
corrected: 'Övriga fastighetskostnader',
method: 'reverse_cp437',
})
})
})
describe('signature detectors', () => {
it('detects lost-byte and mojibake', () => {
expect(hasLostByte('Ackumulerade nedskrivningar p' + REPLACEMENT_CHAR + ' bilar')).toBe(true)
expect(hasLostByte('Kassa')).toBe(false)
expect(hasMojibakeSignature('Företagskonto')).toBe(true)
expect(hasMojibakeSignature('Företagskonto')).toBe(false)
})
})
describe('resolveCorrectName', () => {
it('restores a stripped name from a de-accent-equal diacritic-bearing sibling', () => {
const r = resolveCorrectName('Utgaende moms forsaljning inom Sverige, 25%', [
'Utgående moms försäljning inom Sverige, 25%', // seed wording (clean sibling)
'Utgående moms på försäljning inom Sverige, 25 %', // BAS wording (different, won't match)
])
expect(r).toEqual({
corrected: 'Utgående moms försäljning inom Sverige, 25%',
method: 'sibling_stripped',
})
})
it('restores a lost-byte name by wildcard-matching one sibling', () => {
const r = resolveCorrectName(`Ackumulerade nedskrivningar p${REPLACEMENT_CHAR} bilar`, [
'Ackumulerade nedskrivningar på bilar',
'Ackumulerade avskrivningar på bilar', // same length but different word — must NOT match
])
expect(r).toEqual({
corrected: 'Ackumulerade nedskrivningar på bilar',
method: 'sibling_lostbyte',
})
})
it('reverses a double-encoded name without needing a sibling', () => {
const r = resolveCorrectName('Ã…rets resultat', [])
expect(r).toEqual({ corrected: 'Årets resultat', method: 'reverse' })
})
it('leaves a clean name untouched (returns null)', () => {
expect(resolveCorrectName('Företagskonto', ['Företagskonto'])).toBeNull()
expect(resolveCorrectName('Kassa', [])).toBeNull()
})
it('refuses to guess when a stripped name has no diacritic-bearing sibling', () => {
// Only stripped / identical siblings → no confident canonical → skip.
expect(resolveCorrectName('Forsaljning webshop', ['Forsaljning webshop'])).toBeNull()
})
it('refuses to guess a lost-byte name when two distinct siblings match', () => {
const r = resolveCorrectName(`Utg${REPLACEMENT_CHAR}ende`, ['Utgående', 'Utgaende'])
// 'Utgaende' (no diacritic) also matches the wildcard → ambiguous → null.
expect(r).toBeNull()
})
it('ignores corrupt candidates when choosing a canonical', () => {
const r = resolveCorrectName('Leverantorsskulder', [
'Leverantörsskulder', // mojibake candidate — ignored
'Leverantörsskulder', // the clean one
])
expect(r?.corrected).toBe('Leverantörsskulder')
})
it('NEVER strips a correct name down to a de-accented sibling (directional guard)', () => {
// The dry-run bug: a correct "Försäljning varor 25%" must not be rewritten to
// a partial-stripped "Försaljning varor 25%" sibling. The input carries
// diacritics, so the stripped branch must not fire.
expect(
resolveCorrectName('Försäljning varor 25%', ['Försaljning varor 25%']),
).toBeNull()
expect(resolveCorrectName('Ränteintäkter', ['Ränteintakter'])).toBeNull()
})
it('does not pick a CP437-as-CP1252 sibling for a lost-byte name', () => {
// The dry-run bug: "F�rmedlad frakt" wildcard-matched "F”rmedlad frakt"
// (itself a different mojibake). Only the genuinely clean sibling wins.
const r = resolveCorrectName(`F${REPLACEMENT_CHAR}rmedlad frakt`, [
'F”rmedlad frakt', // CP1252 artifact — not clean, must be ignored
'Förmedlad frakt', // the clean one
])
expect(r).toEqual({ corrected: 'Förmedlad frakt', method: 'sibling_lostbyte' })
})
it('classifies CP1252 artifacts as not clean', () => {
expect(hasCp1252Artifact('F”rmedlad frakt')).toBe(true)
expect(hasCp1252Artifact('™vriga fastighetskostnader')).toBe(true) // Ö→™ at word start
expect(hasCp1252Artifact('Förmedlad frakt')).toBe(false)
expect(isClean('Förmedlad frakt')).toBe(true)
expect(isClean('F”rmedlad frakt')).toBe(false)
expect(isClean(`F${REPLACEMENT_CHAR}rmedlad`)).toBe(false)
expect(isClean('Företagskonto')).toBe(false)
})
it('does NOT flag a legitimate space-padded en-dash as corrupt', () => {
// Real BAS names: "Kundfordringar – delad faktura" (1513), "Periodiseringsfond
// 2021 – nr 2". The en-dash is space-padded punctuation, not a mangled letter.
expect(hasCp1252Artifact('Kundfordringar – delad faktura')).toBe(false)
expect(hasCp1252Artifact('Periodiseringsfond 2021 – nr 2')).toBe(false)
expect(isClean('Kundfordringar – delad faktura')).toBe(true)
// …so it can serve as the repair target for its lost-byte sibling (the
// en-dash byte 0x96 itself becomes U+FFFD when a CP1252 file is read as UTF-8).
expect(
resolveCorrectName(`Kundfordringar ${REPLACEMENT_CHAR} delad faktura`, [
'Kundfordringar – delad faktura',
]),
).toEqual({ corrected: 'Kundfordringar – delad faktura', method: 'sibling_lostbyte' })
})
})
+224
View File
@@ -0,0 +1,224 @@
/**
* Charset repair for chart_of_accounts account names corrupted during the
* 2026-03-30 → 2026-06-12 seeding/import window. Pure functions only (no DB) so
* the logic is unit-testable; scripts/repair-chart-of-accounts-charset.ts wires
* them to the live table.
*
* Three distinct corruption signatures, each with its own recovery strategy:
*
* 1. double_encoded — UTF-8 bytes were decoded as Windows-1252 and re-encoded
* as UTF-8: "Företagskonto", "Övriga bankkonton", "Årets resultat".
* LOSSLESS to reverse (reverseMojibake): no canonical name required, so it
* recovers custom account names too.
* 2. lost_byte — a CP437/Latin-1 SIE file was decoded as UTF-8; every diacritic
* byte became U+FFFD: "Ackumulerade nedskrivningar p� bilar". The byte is
* GONE — unrecoverable from the string. Restore from a known-good sibling
* name for the same account number, matching with U+FFFD as a 1-char
* wildcard.
* 3. stripped — literals typed without diacritics: "Utgaende moms forsaljning
* inom Sverige, 25%". Diacritics gone; restore from a de-accent-equal
* sibling that actually carries the diacritics.
*
* The seed function's wording differs slightly from BAS_REFERENCE (seed:
* "…försäljning inom Sverige, 25%" vs BAS: "…på försäljning inom Sverige, 25 %"),
* so the caller supplies CANDIDATE correct names per account number — the clean
* sibling rows already in the table, plus the BAS reference name as a fallback —
* and we match per row. Anything ambiguous (≠1 sibling) is left untouched.
*/
export const REPLACEMENT_CHAR = '�'
/**
* Windows-1252 code point → byte for the 0x80–0x9F block, where CP1252 diverges
* from Latin-1 (e.g. 0x96 = U+2013 "–", 0x85 = U+2026 "…"). Reversing
* double-encoding requires mapping these back to their original byte; the å/ä/ö
* continuation bytes for UPPERCASE Å/Ä/Ö (0x85/0x84/0x96) land in this block.
*/
const CP1252_TO_BYTE: Record<number, number> = {
0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85,
0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a,
0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e, 0x2018: 0x91, 0x2019: 0x92,
0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97,
0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c,
0x017e: 0x9e, 0x0178: 0x9f,
}
/** True if the name carries the U+FFFD lost-byte signature. */
export function hasLostByte(s: string): boolean {
return s.includes(REPLACEMENT_CHAR)
}
/** True if the name carries the double-encoded signature (Â/Ã lead byte). */
export function hasMojibakeSignature(s: string): boolean {
return /[ÂÃ]/.test(s)
}
/**
* True if the name contains a Windows-1252 C1 "special" character used IN PLACE
* OF A LETTER — i.e. a CP437/Latin-1 diacritic byte mis-decoded as CP1252
* (CP437 ö 0x94 → "”", ä 0x84 → "„", Ö 0x99 → "™"). Swedish diacritics always
* sit mid-word, so the tell is letter-adjacency: "F”rmedlad" (corrupt) vs
* "Periodiseringsfond 2021 – nr 2" (a legitimate space-padded en-dash, NOT
* corrupt). Such a name must never be a repair target for another row.
*/
export function hasCp1252Artifact(s: string): boolean {
const chars = [...s]
const isLetter = (c: string | undefined): boolean => !!c && /\p{L}/u.test(c)
for (let i = 0; i < chars.length; i++) {
if (!(chars[i].codePointAt(0)! in CP1252_TO_BYTE)) continue
if (isLetter(chars[i - 1]) || isLetter(chars[i + 1])) return true
}
return false
}
/** A name with no corruption signature of any kind — safe to treat as canonical. */
export function isClean(s: string): boolean {
return !hasLostByte(s) && !hasMojibakeSignature(s) && !hasCp1252Artifact(s)
}
// Combining-diacritical-marks block (U+0300–U+036F) left after NFD. Built from
// an ASCII string via RegExp() so the source carries no literal (invisible,
// encoding-fragile) combining marks — the failure mode flagged in review.
const COMBINING_MARKS = new RegExp('[\\u0300-\\u036f]', 'g')
/** Strip combining diacritical marks (å→a, ä→a, ö→o, é→e), preserving case. */
export function deaccent(s: string): string {
return s.normalize('NFD').replace(COMBINING_MARKS, '')
}
/**
* Reverse a double-encoded (UTF-8-bytes-read-as-CP1252-then-re-UTF-8) string.
* Returns the recovered string, or null when the input can't be a clean
* double-encoding (a char outside CP1252, or bytes that aren't valid UTF-8) —
* which also makes it a no-op on already-correct names.
*/
export function reverseMojibake(s: string): string | null {
const bytes: number[] = []
for (const ch of s) {
const cp = ch.codePointAt(0)!
if (cp <= 0xff) {
bytes.push(cp)
} else if (cp in CP1252_TO_BYTE) {
bytes.push(CP1252_TO_BYTE[cp])
} else {
return null
}
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes))
} catch {
return null
}
}
/**
* CP437 high bytes (0x80–0xA5) that decode to Latin letters — the only bytes
* that can legitimately appear inside a Swedish account name. A CP437 SIE file
* decoded as CP1252 turns these into the C1 specials above (ö 0x94 → "”", Ö 0x99
* → "™", ä 0x84 → "„", å 0x86 → "†"); reversing maps the char back to its byte,
* then to the CP437 letter. Bytes outside this set (box-drawing, symbols) never
* occur in account names, so we refuse to reverse them (return null).
*/
const CP437_LETTER: Record<number, string> = {
0x80: 'Ç', 0x81: 'ü', 0x82: 'é', 0x83: 'â', 0x84: 'ä', 0x85: 'à', 0x86: 'å',
0x87: 'ç', 0x88: 'ê', 0x89: 'ë', 0x8a: 'è', 0x8b: 'ï', 0x8c: 'î', 0x8d: 'ì',
0x8e: 'Ä', 0x8f: 'Å', 0x90: 'É', 0x91: 'æ', 0x92: 'Æ', 0x93: 'ô', 0x94: 'ö',
0x95: 'ò', 0x96: 'û', 0x97: 'ù', 0x98: 'ÿ', 0x99: 'Ö', 0x9a: 'Ü', 0xa0: 'á',
0xa1: 'í', 0xa2: 'ó', 0xa3: 'ú', 0xa4: 'ñ', 0xa5: 'Ñ',
}
/**
* Reverse a CP437-decoded-as-CP1252 name ("F”rmedlad" → "Förmedlad", "™vriga" →
* "Övriga"). Each char is mapped back to the byte CP1252 would have produced,
* then re-interpreted as CP437. Returns null when any high byte isn't a known
* CP437 letter (so it's a no-op on clean names and refuses to guess on symbols).
*/
export function reverseCp437Mojibake(s: string): string | null {
let out = ''
let changed = false
for (const ch of s) {
const cp = ch.codePointAt(0)!
const byte = cp <= 0xff ? cp : (cp in CP1252_TO_BYTE ? CP1252_TO_BYTE[cp] : -1)
if (byte < 0) return null
if (byte < 0x80) {
out += ch
} else if (byte in CP437_LETTER) {
out += CP437_LETTER[byte]
changed = true
} else {
return null
}
}
return changed ? out : null
}
/** Build an anchored regex from a lost-byte name, each U+FFFD a single-char wildcard. */
function lostByteRegex(s: string): RegExp {
const escaped = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp('^' + escaped.split(REPLACEMENT_CHAR).join('.') + '$')
}
const uniq = (xs: string[]): string[] => [...new Set(xs)]
export interface RepairResult {
corrected: string
method: 'reverse' | 'reverse_cp437' | 'sibling_stripped' | 'sibling_lostbyte'
}
/**
* Resolve the correct name for a (possibly) corrupted account name.
*
* `candidates` = known names for the SAME account number from elsewhere (clean
* sibling rows + the BAS reference name). Corrupt candidates are ignored.
*
* Returns null when the name is already clean, or when no confident, unambiguous
* correction exists — the caller leaves the row untouched and reports it.
*/
export function resolveCorrectName(
corrupted: string,
candidates: string[],
): RepairResult | null {
// Only genuinely-clean names are eligible repair targets — never another
// corrupted variant (e.g. a CP437-as-CP1252 "F”rmedlad frakt" must not be the
// target for the lost-byte "F�rmedlad frakt").
const clean = uniq(candidates.filter((c) => c && isClean(c)))
// 1. Lost-byte — the byte is gone; match a sibling treating each U+FFFD as one
// wildcard char. Require exactly one distinct sibling so we never guess.
if (hasLostByte(corrupted)) {
const re = lostByteRegex(corrupted)
const matches = uniq(clean.filter((c) => re.test(c)))
return matches.length === 1
? { corrected: matches[0], method: 'sibling_lostbyte' }
: null
}
// 2. Double-encoded (UTF-8-as-CP1252) — reverse losslessly; recovers customs.
if (hasMojibakeSignature(corrupted)) {
const rev = reverseMojibake(corrupted)
if (rev && rev !== corrupted && isClean(rev)) {
return { corrected: rev, method: 'reverse' }
}
return null
}
// 3. CP437-as-CP1252 (a SIE file's diacritic byte rendered as a C1 special) —
// also lossless. Only fires on a mid-word artifact (hasCp1252Artifact).
if (hasCp1252Artifact(corrupted)) {
const rev = reverseCp437Mojibake(corrupted)
if (rev && rev !== corrupted && isClean(rev)) {
return { corrected: rev, method: 'reverse_cp437' }
}
return null
}
// 4. Stripped diacritics — restore from a de-accent-equal sibling that carries
// the diacritics this row lost. DIRECTIONAL GUARD: only act when the input
// is itself fully de-accented (no diacritics), so a CORRECT name is never
// "fixed" down to a stripped sibling. Require a unique clean sibling.
if (deaccent(corrupted) !== corrupted) return null
const matches = uniq(clean.filter((c) => c !== corrupted && deaccent(c) === corrupted))
return matches.length === 1
? { corrected: matches[0], method: 'sibling_stripped' }
: null
}