fix(import): SIE bulk-delete on service client + provider/reporting/b… (#724)

* fix(import): SIE bulk-delete on service client + provider/reporting/banking fixes

Rebuilt branch onto main as a single commit.

- import: run SIE bulk-delete RPCs on the service client to escape the 8s
  statement_timeout; undo_sie_import now takes an explicit actor (p_user_id)
  so its owner/admin gate works when auth.uid() is NULL on the service
  client (migration 20260624120000) + pg-real regression test
- providers: distinguish missing Fortnox license from expired connection;
  provider_consent_tokens PK regression test
- reports: include unmapped BAS expense groups in the income statement
- enable-banking: reconnect closed/expired bank sessions in place
- bookkeeping: surface linked invoices as underlag on the verifikat view
- scripts: track BL cleanup/diagnostic tooling; data files (*.csv) are
  git-ignored and consentId is now a required arg with no silent default

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

* fix(import): add Cache-Control header to journal entry references response

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-14 23:40:26 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent db8983ba9e
commit 43925bc2d3
37 changed files with 2456 additions and 1152 deletions
+252
View File
@@ -0,0 +1,252 @@
/**
* One-off cleanup: balance every unbalanced voucher in the BL test company
* behind the given consent, via BL's
* PUT /journal/ledgerentry/{journalId}/{journalEntryId}/{journalEntryDate}.
*
* BL refuses DELETE on anything but the last voucher of a series, so instead:
* method A (preferred): add a counter ledger entry against 0099
* "Konvertering" with amount = -diff (probe: entityId 0 = new line)
* method B (fallback): update the voucher's first ledger entry so the
* voucher sums to zero
*
* Each method is probed on ONE voucher and verified with a GET before the
* mass run. Empty vouchers (0 lines) are left alone — they pass validation.
*
* Run: npx tsx --env-file=.env --env-file=.env.local scripts/bl-balance-broken.ts <consentId>
*/
import { createClient } from '@supabase/supabase-js'
import { fetchBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { fetchProviderSieFiles } from '@/extensions/general/arcim-migration/lib/sie-fetcher'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
const BL_BASE_URL = 'https://apigateway.blinfo.se/bla-api/v1/sp'
const DELAY_MS = 125 // ~8 req/s, under BL's 10 req/s limit
const consentId = process.argv[2]
if (!consentId) {
console.error('Usage: npx tsx --env-file=.env --env-file=.env.local scripts/bl-balance-broken.ts <consentId>')
console.error('Refusing to run without an explicit consentId — this script writes vouchers to a live BL company.')
process.exit(1)
}
interface BLLedgerEntry {
entityId: number
accountId: string
amount: number
costBearerId: string
costCenterId: string
date: string
id: number
line: number
projectId: string
quantity: number
text: string
accrual: boolean
}
interface BLJournalEntry {
entityId: number
journalId: string
journalEntryId: number
journalEntryDate: string
journalEntryText: string
ledgerEntries: BLLedgerEntry[]
}
const round2 = (n: number) => Math.round(n * 100) / 100
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
function entrySum(e: BLJournalEntry): number {
return round2(e.ledgerEntries.reduce((s, l) => s + (l.amount ?? 0), 0))
}
function headers(accessToken: string, userKey: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
'User-Key': userKey,
'Content-Type': 'application/json',
Accept: 'application/json',
}
}
async function fetchAllEntries(accessToken: string, userKey: string): Promise<BLJournalEntry[]> {
const all: BLJournalEntry[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const params = new URLSearchParams({ page: String(page), rows: '500' })
const res = await fetch(`${BL_BASE_URL}/journal/entry/batch?${params}`, {
headers: headers(accessToken, userKey),
signal: AbortSignal.timeout(60_000),
})
if (!res.ok) throw new Error(`batch page ${page} failed: HTTP ${res.status}`)
const body = await res.json() as { pageRequested: number; totalPages: number; data: BLJournalEntry[] }
all.push(...(body.data ?? []))
totalPages = body.totalPages ?? 1
page++
await sleep(DELAY_MS)
}
return all
}
async function fetchOne(
accessToken: string,
userKey: string,
v: BLJournalEntry,
): Promise<BLJournalEntry> {
const res = await fetch(
`${BL_BASE_URL}/journal/entry/${encodeURIComponent(v.journalId)}/${v.journalEntryId}/${v.journalEntryDate}`,
{ headers: headers(accessToken, userKey), signal: AbortSignal.timeout(30_000) },
)
if (!res.ok) throw new Error(`GET single entry failed: HTTP ${res.status}`)
return res.json() as Promise<BLJournalEntry>
}
async function putLedgerEntry(
accessToken: string,
userKey: string,
v: BLJournalEntry,
body: Partial<BLLedgerEntry>,
): Promise<{ ok: boolean; status: number; body: string }> {
const res = await fetch(
`${BL_BASE_URL}/journal/ledgerentry/${encodeURIComponent(v.journalId)}/${v.journalEntryId}/${v.journalEntryDate}`,
{
method: 'PUT',
headers: headers(accessToken, userKey),
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
},
)
const text = await res.text().catch(() => '')
return { ok: res.ok, status: res.status, body: text.slice(0, 300) }
}
function addLineBody(v: BLJournalEntry, diff: number): Partial<BLLedgerEntry> {
const maxLine = v.ledgerEntries.reduce((m, l) => Math.max(m, l.line ?? 0), 0)
return {
entityId: 0,
accountId: '0099',
amount: round2(-diff),
costBearerId: '',
costCenterId: '',
date: v.journalEntryDate,
line: maxLine + 1,
projectId: '',
quantity: 0,
text: 'Balansering vid migrering (test)',
accrual: false,
}
}
function adjustFirstLineBody(v: BLJournalEntry, diff: number): Partial<BLLedgerEntry> {
const first = v.ledgerEntries[0]!
return { ...first, amount: round2(first.amount - diff) }
}
async function main() {
// Either a User-Key GUID passed directly as the 2nd arg, or resolved from
// the consent in the 1st arg (consents churn on every wizard reconnect).
let userKey = process.argv[3]
if (!userKey) {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
const { data: tokens, error } = await supabase
.from('provider_consent_tokens')
.select('provider_company_id')
.eq('consent_id', consentId)
.limit(1)
if (error || !tokens?.length) throw new Error(`no tokens for consent: ${error?.message ?? 'not found'}`)
userKey = tokens[0]!.provider_company_id as string
}
const token = await fetchBjornLundenToken(
process.env.BJORN_LUNDEN_CLIENT_ID!,
process.env.BJORN_LUNDEN_CLIENT_SECRET!,
)
const at = token.access_token
console.log('Fetching all journal entries via batch API...')
const entries = await fetchAllEntries(at, userKey)
const unbalanced = entries.filter((e) => e.ledgerEntries.length > 0 && Math.abs(entrySum(e)) > 0.01)
const empty = entries.filter((e) => e.ledgerEntries.length === 0).length
console.log(`${entries.length} vouchers total: ${unbalanced.length} unbalanced (will fix), ${empty} empty (left alone)`)
if (unbalanced.length === 0) {
console.log('Nothing to do.')
return
}
// ── Probe method A (add 0099 line) on one voucher ────────────────
const probe = unbalanced[0]!
const probeDiff = entrySum(probe)
console.log(`Probe: ${probe.journalId}${probe.journalEntryId} (${probe.journalEntryDate}, diff ${probeDiff})`)
let method: 'add' | 'adjust' | null = null
const addResult = await putLedgerEntry(at, userKey, probe, addLineBody(probe, probeDiff))
if (addResult.ok) {
const after = await fetchOne(at, userKey, probe)
if (Math.abs(entrySum(after)) <= 0.01) {
method = 'add'
console.log('Method A (add 0099 counter-line) works — verified balanced via GET.')
} else {
console.log(`Method A responded OK but voucher still sums to ${entrySum(after)} — trying method B.`)
}
} else {
console.log(`Method A refused (HTTP ${addResult.status}): ${addResult.body} — trying method B.`)
}
if (!method) {
const fresh = await fetchOne(at, userKey, probe)
const freshDiff = entrySum(fresh)
if (Math.abs(freshDiff) > 0.01) {
const adjResult = await putLedgerEntry(at, userKey, fresh, adjustFirstLineBody(fresh, freshDiff))
if (!adjResult.ok) {
console.error(`ABORT: method B also refused (HTTP ${adjResult.status}): ${adjResult.body}`)
process.exit(1)
}
const after = await fetchOne(at, userKey, fresh)
if (Math.abs(entrySum(after)) > 0.01) {
console.error(`ABORT: method B responded OK but voucher still sums to ${entrySum(after)}`)
process.exit(1)
}
method = 'adjust'
console.log('Method B (adjust first line) works — verified balanced via GET.')
}
}
// ── Mass run ─────────────────────────────────────────────────────
const failures: { voucher: string; status: number; body: string }[] = []
let done = 1
for (const v of unbalanced.slice(1)) {
await sleep(DELAY_MS)
const diff = entrySum(v)
const body = method === 'add' ? addLineBody(v, diff) : adjustFirstLineBody(v, diff)
const result = await putLedgerEntry(at, userKey, v, body)
if (!result.ok) {
failures.push({ voucher: `${v.journalId}${v.journalEntryId} (${v.journalEntryDate})`, status: result.status, body: result.body })
}
done++
if (done % 100 === 0) console.log(` ${done}/${unbalanced.length} (${failures.length} failed)`)
}
console.log(`\nDone: ${done - failures.length}/${unbalanced.length} balanced via method ${method}, ${failures.length} failed`)
if (failures.length > 0) {
console.log('Failures (first 20):')
for (const f of failures.slice(0, 20)) console.log(` ${f.voucher} — HTTP ${f.status} ${f.body}`)
}
console.log('\nRe-fetching SIE export to validate...')
const after = await fetchProviderSieFiles('bjornlunden', at, userKey)
for (const f of after.files) {
const parsed = parseSIEFile(f.rawContent)
const validation = validateSIEFile(parsed)
console.log(`fiscal year ${f.fiscalYear}: ${parsed.vouchers.length} vouchers, valid=${validation.valid}`)
if (validation.errors.length) console.log('remaining errors:\n- ' + validation.errors.slice(0, 5).join('\n- '))
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+145
View File
@@ -0,0 +1,145 @@
/**
* One-off cleanup: delete every broken voucher (empty or unbalanced) in the
* BL test company behind the given consent, via BL's
* DELETE /journal/entry/{journalId}/{journalEntryId}/{journalEntryDate}.
*
* Probes the first voucher and aborts if BL refuses the delete, then runs the
* full list at ~8 req/s and re-validates the SIE export at the end.
*
* Run: npx tsx --env-file=.env --env-file=.env.local scripts/bl-delete-broken.ts <consentId>
*/
import { createClient } from '@supabase/supabase-js'
import { fetchBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { fetchProviderSieFiles } from '@/extensions/general/arcim-migration/lib/sie-fetcher'
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
const BL_BASE_URL = 'https://apigateway.blinfo.se/bla-api/v1/sp'
const DELAY_MS = 125 // ~8 req/s, under BL's 10 req/s limit
const consentId = process.argv[2]
if (!consentId) {
console.error('Usage: npx tsx --env-file=.env --env-file=.env.local scripts/bl-delete-broken.ts <consentId>')
console.error('Refusing to run without an explicit consentId — this script deletes vouchers from a live BL company.')
process.exit(1)
}
interface BrokenVoucher {
series: string
number: number
date: string // yyyy-MM-dd as written in the SIE file
lineCount: number
diff: number
}
function isoLocal(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function deleteVoucher(
accessToken: string,
userKey: string,
v: BrokenVoucher,
): Promise<{ ok: boolean; status: number; body: string }> {
const url = `${BL_BASE_URL}/journal/entry/${encodeURIComponent(v.series)}/${v.number}/${v.date}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Key': userKey,
},
signal: AbortSignal.timeout(30_000),
})
const body = response.ok ? '' : await response.text().catch(() => '')
return { ok: response.ok, status: response.status, body }
}
async function main() {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
const { data: tokens, error } = await supabase
.from('provider_consent_tokens')
.select('provider_company_id')
.eq('consent_id', consentId)
.limit(1)
if (error || !tokens?.length) throw new Error(`no tokens for consent: ${error?.message ?? 'not found'}`)
const userKey = tokens[0]!.provider_company_id as string
const token = await fetchBjornLundenToken(
process.env.BJORN_LUNDEN_CLIENT_ID!,
process.env.BJORN_LUNDEN_CLIENT_SECRET!,
)
console.log('Fetching fresh SIE export to compute the broken-voucher list...')
const { files } = await fetchProviderSieFiles('bjornlunden', token.access_token, userKey)
const broken: BrokenVoucher[] = []
for (const f of files) {
const parsed = parseSIEFile(f.rawContent)
for (const v of parsed.vouchers) {
const diff = Math.round(v.lines.reduce((s, l) => s + l.amount, 0) * 100) / 100
if (Math.abs(diff) > 0.01 || v.lines.length === 0) {
broken.push({
series: v.series,
number: v.number,
date: isoLocal(v.date),
lineCount: v.lines.length,
diff,
})
}
}
}
console.log(`${broken.length} broken vouchers to delete`)
if (broken.length === 0) {
console.log('Nothing to do.')
return
}
// Probe with the first voucher — abort if BL refuses deletes
const probe = broken[0]!
console.log(`Probe delete: ${probe.series}${probe.number} (${probe.date})...`)
const probeResult = await deleteVoucher(token.access_token, userKey, probe)
if (!probeResult.ok) {
console.error(`ABORT: BL refused the probe delete (HTTP ${probeResult.status}): ${probeResult.body}`)
process.exit(1)
}
console.log('Probe OK — deleting the rest...')
const failures: { voucher: string; status: number; body: string }[] = []
let done = 1
for (const v of broken.slice(1)) {
await sleep(DELAY_MS)
const result = await deleteVoucher(token.access_token, userKey, v)
if (!result.ok) {
failures.push({ voucher: `${v.series}${v.number} (${v.date})`, status: result.status, body: result.body.slice(0, 200) })
}
done++
if (done % 100 === 0) console.log(` ${done}/${broken.length} (${failures.length} failed)`)
}
console.log(`\nDone: ${done - failures.length}/${broken.length} deleted, ${failures.length} failed`)
if (failures.length > 0) {
console.log('Failures (first 20):')
for (const f of failures.slice(0, 20)) console.log(` ${f.voucher} — HTTP ${f.status} ${f.body}`)
}
console.log('\nRe-fetching SIE export to validate...')
const after = await fetchProviderSieFiles('bjornlunden', token.access_token, userKey)
for (const f of after.files) {
const parsed = parseSIEFile(f.rawContent)
const validation = validateSIEFile(parsed)
console.log(`fiscal year ${f.fiscalYear}: ${parsed.vouchers.length} vouchers, valid=${validation.valid}`)
if (validation.errors.length) console.log('remaining errors:\n- ' + validation.errors.slice(0, 5).join('\n- '))
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+30
View File
@@ -0,0 +1,30 @@
import { createClient } from '@supabase/supabase-js'
async function main() {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
const { data: consents, error } = await supabase
.from('provider_consents')
.select('id, provider, company_name, status, created_at')
.eq('provider', 'bjornlunden')
.order('created_at', { ascending: false })
.limit(5)
if (error) throw error
console.log(JSON.stringify(consents, null, 2))
for (const c of consents ?? []) {
const { data: tokens } = await supabase
.from('provider_consent_tokens')
.select('consent_id, provider_company_id, token_expires_at')
.eq('consent_id', c.id)
.limit(1)
console.log(`consent ${c.id}: tokens=${tokens?.length ? 'yes' : 'NO'} userKey=${tokens?.[0]?.provider_company_id?.slice(0, 8) ?? '-'}…`)
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+102
View File
@@ -0,0 +1,102 @@
/**
* Temporary diagnostic: list every unbalanced voucher in the BL SIE export
* with its lines, write a CSV report, and print summary statistics.
* Run: npx tsx --env-file=.env --env-file=.env.local scripts/debug-bl-unbalanced.ts
*/
import { writeFileSync } from 'node:fs'
import { createClient } from '@supabase/supabase-js'
import { fetchBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { fetchProviderSieFiles } from '@/extensions/general/arcim-migration/lib/sie-fetcher'
import { parseSIEFile } from '@/lib/import/sie-parser'
const consentId = process.argv[2]
if (!consentId) {
console.error('Usage: npx tsx --env-file=.env --env-file=.env.local scripts/debug-bl-unbalanced.ts <consentId>')
console.error('Refusing to run without an explicit consentId — this script reads a live BL company and writes a report.')
process.exit(1)
}
function isoLocal(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
async function main() {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
const { data: tokens, error } = await supabase
.from('provider_consent_tokens')
.select('provider_company_id')
.eq('consent_id', consentId)
.limit(1)
if (error || !tokens?.length) throw new Error(`no tokens for consent: ${error?.message ?? 'not found'}`)
const userKey = tokens[0]!.provider_company_id as string
const token = await fetchBjornLundenToken(
process.env.BJORN_LUNDEN_CLIENT_ID!,
process.env.BJORN_LUNDEN_CLIENT_SECRET!,
)
const { files } = await fetchProviderSieFiles('bjornlunden', token.access_token, userKey)
for (const f of files) {
const parsed = parseSIEFile(f.rawContent)
const bad = parsed.vouchers
.map((v) => ({
voucher: `${v.series}${v.number}`,
date: isoLocal(v.date),
description: v.description,
lineCount: v.lines.length,
diff: Math.round(v.lines.reduce((s, l) => s + l.amount, 0) * 100) / 100,
lines: v.lines.map((l) => `${l.account}:${l.amount}`).join(' '),
}))
.filter((v) => Math.abs(v.diff) > 0.01 || v.lineCount === 0)
const total = parsed.vouchers.length
const empty = bad.filter((v) => v.lineCount === 0).length
const single = bad.filter((v) => v.lineCount === 1).length
const multi = bad.filter((v) => v.lineCount > 1).length
console.log(`fiscal year ${f.fiscalYear}: ${total} vouchers total, ${bad.length} broken`)
console.log(` empty (0 lines): ${empty}`)
console.log(` one-sided (1 line): ${single}`)
console.log(` multi-line unbalanced: ${multi}`)
// Distribution of diffs to spot recurring patterns (e.g. 1.50 bank fees)
const byDiff = new Map<string, number>()
for (const v of bad.filter((b) => b.lineCount > 0)) {
const key = Math.abs(v.diff).toFixed(2)
byDiff.set(key, (byDiff.get(key) ?? 0) + 1)
}
const topDiffs = [...byDiff.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
console.log(' most common |diff| amounts:')
for (const [amount, count] of topDiffs) console.log(` ${amount} kr × ${count}`)
// Per-series breakdown
const bySeries = new Map<string, number>()
for (const v of bad) {
const series = v.voucher.replace(/\d+$/, '')
bySeries.set(series, (bySeries.get(series) ?? 0) + 1)
}
console.log(' broken per series:', [...bySeries.entries()].map(([s, n]) => `${s}=${n}`).join(' '))
const csvEscape = (s: string) => `"${s.replace(/"/g, '""')}"`
const csv = [
'voucher;date;diff;line_count;description;lines',
...bad.map((v) =>
[v.voucher, v.date, v.diff.toFixed(2), v.lineCount, csvEscape(v.description), csvEscape(v.lines)].join(';'),
),
].join('\n')
const outPath = `scripts/bl-unbalanced-${f.fiscalYear}.csv`
writeFileSync(outPath, '' + csv, 'utf8')
console.log(` full list written to ${outPath}`)
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
-289
View File
@@ -1,289 +0,0 @@
-- One-off BAS96 -> BAS2025 chart-of-accounts remap for Krister Sundling.
-- Run from the Supabase Studio SQL editor (Project Settings -> SQL Editor).
-- Equivalent of scripts/remap-krister-bas96-to-bas2025.ts but executed
-- entirely server-side, so it doesn't need the DB password.
--
-- Before running:
-- 1. Take a Supabase backup (Database -> Backups -> Create backup).
-- 2. Read this entire file. The identity check is at line ~50.
-- 3. Make sure no fiscal period is closed/locked (the script aborts if so).
--
-- Safety:
-- * Identity is hard-coded (ks@sundlingwarn.com + company name contains "sundling").
-- * The whole DO block is one transaction. Any RAISE EXCEPTION rolls back.
-- * Grand debit/credit invariant is checked at the end -- mismatch -> rollback.
-- * Only Krister's company_id is written to. Every UPDATE/INSERT/DELETE filters by it.
--
-- After running, watch the "Notices" panel below the editor for progress and the
-- final summary. If the DO block errors out, the whole transaction rolls back.
BEGIN;
DO $remap$
DECLARE
-- ────────────────────────────────────────────────────────────────
-- Hard-coded identity (no override). Aborts if either doesn't match.
-- ────────────────────────────────────────────────────────────────
v_expected_email constant text := 'ks@sundlingwarn.com';
v_expected_fragment constant text := 'cesu'; -- Krister's holding company: CeSu Invest AB
v_user_id uuid;
v_user_email text;
v_company_id uuid;
v_company_name text;
v_owner_count int;
-- counters
v_inserted_accounts int := 0;
v_updated_lines bigint := 0;
v_deleted_accounts int := 0;
v_locked_periods int;
v_old_id uuid;
v_target_id uuid;
v_line_count bigint;
-- invariants
v_debit_before numeric;
v_credit_before numeric;
v_debit_after numeric;
v_credit_after numeric;
m record; -- mapping iterator
BEGIN
PERFORM set_config('gnubok.allow_delete', 'true', true);
-- ────────────────────────────────────────────────────────────────
-- 1. Resolve user
-- ────────────────────────────────────────────────────────────────
SELECT id, email INTO v_user_id, v_user_email
FROM auth.users
WHERE LOWER(email) = LOWER(v_expected_email);
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'No auth.users row for email %', v_expected_email;
END IF;
-- ────────────────────────────────────────────────────────────────
-- 2. Resolve company (owner/admin role)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) INTO v_owner_count
FROM public.company_members
WHERE user_id = v_user_id AND role IN ('owner', 'admin');
IF v_owner_count = 0 THEN
RAISE EXCEPTION 'User % owns/admins no companies', v_user_id;
ELSIF v_owner_count > 1 THEN
RAISE EXCEPTION
'User % owns/admins % companies -- this script supports exactly one. '
'Add a WHERE c.id = ''<uuid>'' filter below to pick one explicitly.',
v_user_id, v_owner_count;
END IF;
SELECT c.id, c.name INTO v_company_id, v_company_name
FROM public.companies c
JOIN public.company_members cm ON cm.company_id = c.id
WHERE cm.user_id = v_user_id AND cm.role IN ('owner', 'admin');
-- ────────────────────────────────────────────────────────────────
-- 3. Identity assertions (hard checks; no override)
-- ────────────────────────────────────────────────────────────────
IF LOWER(v_user_email) <> LOWER(v_expected_email) THEN
RAISE EXCEPTION 'Identity check FAILED: email % != expected %', v_user_email, v_expected_email;
END IF;
IF POSITION(LOWER(v_expected_fragment) IN LOWER(v_company_name)) = 0 THEN
RAISE EXCEPTION 'Identity check FAILED: company "%" does not contain "%"',
v_company_name, v_expected_fragment;
END IF;
RAISE NOTICE 'Resolved user : % (%)', v_user_email, v_user_id;
RAISE NOTICE 'Resolved company: % (%)', v_company_name, v_company_id;
-- ────────────────────────────────────────────────────────────────
-- 4. Period lock check (bypass GUC does NOT unlock periods)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) INTO v_locked_periods
FROM public.fiscal_periods
WHERE company_id = v_company_id AND (is_closed = true OR locked_at IS NOT NULL);
IF v_locked_periods > 0 THEN
RAISE EXCEPTION 'Refusing to run: % closed/locked fiscal periods exist for this company',
v_locked_periods;
END IF;
-- ────────────────────────────────────────────────────────────────
-- 5. Pre-flight grand totals (invariant)
-- ────────────────────────────────────────────────────────────────
SELECT COALESCE(SUM(l.debit_amount), 0), COALESCE(SUM(l.credit_amount), 0)
INTO v_debit_before, v_credit_before
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = v_company_id;
RAISE NOTICE 'Pre-flight totals: debit=% credit=%', v_debit_before, v_credit_before;
-- ────────────────────────────────────────────────────────────────
-- 5b. Rename every source account to a __mig__ prefix so target lookups
-- can never collide with an empty source row (handles the 1360 swap:
-- old 1360 -> 1760 AND old 1630 -> new 1360 in the same run).
-- ────────────────────────────────────────────────────────────────
UPDATE public.chart_of_accounts
SET account_number = '__mig__' || account_number
WHERE company_id = v_company_id
AND account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1360','1623','1624','1625','1626','1627','1628','1629',
'1630','1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
);
-- ────────────────────────────────────────────────────────────────
-- 6. Iterate mappings: INSERT target if missing, move lines, count.
-- ────────────────────────────────────────────────────────────────
FOR m IN
SELECT * FROM (VALUES
-- Bank och likvida medel
('1040', '1930', 'Företagskonto', 1, 'asset', 'debit', '19'),
('1050', '1940', 'Likviditetskonto', 1, 'asset', 'debit', '19'),
('1051', '1941', 'Valutakonto GBP', 1, 'asset', 'debit', '19'),
('1052', '1942', 'Valutakonto EUR', 1, 'asset', 'debit', '19'),
('1053', '1943', 'Fasträntekonto', 1, 'asset', 'debit', '19'),
('1055', '1944', 'Sparkonto SBAB', 1, 'asset', 'debit', '19'),
-- Värdepapper / placeringar
('1056', '1361', 'Depå Carnegie', 1, 'asset', 'debit', '13'),
('1060', '1385', 'Kapitalförsäkring (Avanza)', 1, 'asset', 'debit', '13'),
('1061', '1386', 'Kapitalförsäkring (Movestic)', 1, 'asset', 'debit', '13'),
('1210', '1510', 'Kundfordringar', 1, 'asset', 'debit', '15'),
('1360', '1760', 'Upplupna ränteintäkter', 1, 'asset', 'debit', '17'),
('1623', '1330', 'Andelar i intresseföretag', 1, 'asset', 'debit', '13'),
('1624', '1311', 'Andelar i dotterföretag — Divigen', 1, 'asset', 'debit', '13'),
('1625', '1350', 'Andelar i andra företag', 1, 'asset', 'debit', '13'),
('1626', '1351', 'Andelar i andra utländska företag', 1, 'asset', 'debit', '13'),
('1627', '1352', 'Andelar — Impilo', 1, 'asset', 'debit', '13'),
('1628', '1353', 'Andelar — Röko', 1, 'asset', 'debit', '13'),
('1629', '1354', 'Andelar — Altor V', 1, 'asset', 'debit', '13'),
('1630', '1360', 'Aktiefonder (HB Microcap)', 1, 'asset', 'debit', '13'),
('1631', '1355', 'Andelar — Altor VI', 1, 'asset', 'debit', '13'),
('1632', '1356', 'Andelar — Impilo Orphan', 1, 'asset', 'debit', '13'),
-- Skatt och moms (2210 + 2211 merged into 1630 Skattekonto)
('2210', '1630', 'Skattekonto', 1, 'asset', 'debit', '16'),
('2211', '1630', 'Skattekonto', 1, 'asset', 'debit', '16'),
('2330', '2941', 'Upplupna lagstadgade soc. avgifter', 2, 'liability', 'credit', '29'),
('2480', '2650', 'Redovisningskonto för moms', 2, 'liability', 'credit', '26'),
('2510', '2710', 'Personalens källskatt', 2, 'liability', 'credit', '27'),
-- Övriga skulder och reserver
('2690', '2890', 'Övriga kortfristiga skulder', 2, 'liability', 'credit', '28'),
('2864', '2126', 'Periodiseringsfond avsatt vid taxering 2026', 2, 'equity', 'credit', '21'),
-- Eget kapital
('2991', '2081', 'Aktiekapital', 2, 'equity', 'credit', '20'),
('2992', '2086', 'Reservfond', 2, 'equity', 'credit', '20'),
('2997', '2091', 'Balanserat resultat', 2, 'equity', 'credit', '20'),
('2999', '2099', 'Årets resultat', 2, 'equity', 'credit', '20')
) AS t(old_number, new_number, new_name, account_class, account_type, normal_balance, account_group)
LOOP
-- Find existing old account (now under the __mig__ prefix)
SELECT id INTO v_old_id
FROM public.chart_of_accounts
WHERE company_id = v_company_id AND account_number = '__mig__' || m.old_number;
IF v_old_id IS NULL THEN
RAISE NOTICE ' skip %: old account not in chart (already migrated?)', m.old_number;
CONTINUE;
END IF;
-- Find existing target account, or INSERT it
SELECT id INTO v_target_id
FROM public.chart_of_accounts
WHERE company_id = v_company_id AND account_number = m.new_number;
IF v_target_id IS NULL THEN
INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type, is_active, is_system_account)
VALUES
(v_user_id, v_company_id, m.new_number, m.new_name, m.account_class,
m.account_group, m.account_type, m.normal_balance, 'full_bas', true, false)
RETURNING id INTO v_target_id;
v_inserted_accounts := v_inserted_accounts + 1;
RAISE NOTICE ' insert account % %', m.new_number, m.new_name;
END IF;
-- Idempotent no-op
IF v_target_id = v_old_id THEN
CONTINUE;
END IF;
-- Move lines old -> target (scoped by parent journal_entries.company_id)
WITH moved AS (
UPDATE public.journal_entry_lines l
SET account_id = v_target_id, account_number = m.new_number
FROM public.journal_entries je
WHERE l.journal_entry_id = je.id
AND je.company_id = v_company_id
AND l.account_id = v_old_id
RETURNING l.id
)
SELECT COUNT(*) INTO v_line_count FROM moved;
v_updated_lines := v_updated_lines + v_line_count;
RAISE NOTICE ' remap % -> % (% lines moved)', m.old_number, m.new_number, v_line_count;
END LOOP;
-- ────────────────────────────────────────────────────────────────
-- 7. (skipped) account_balances was dropped in migration
-- 20240101000027_drop_unused_module_tables.sql -- no cache to clean.
-- ────────────────────────────────────────────────────────────────
-- ────────────────────────────────────────────────────────────────
-- 8. Delete the __mig__-prefixed source rows now that they have no lines.
-- Safety: refuses to delete if any line still references one.
-- ────────────────────────────────────────────────────────────────
FOR m IN
SELECT id, account_number
FROM public.chart_of_accounts
WHERE company_id = v_company_id
AND LEFT(account_number, 7) = '__mig__'
LOOP
SELECT COUNT(*) INTO v_line_count
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = m.id AND je.company_id = v_company_id;
IF v_line_count <> 0 THEN
RAISE EXCEPTION 'Refusing to delete migrate-source account % (%) -- % lines still reference it',
m.account_number, m.id, v_line_count;
END IF;
DELETE FROM public.chart_of_accounts WHERE id = m.id AND company_id = v_company_id;
v_deleted_accounts := v_deleted_accounts + 1;
END LOOP;
-- ────────────────────────────────────────────────────────────────
-- 9. Post-flight invariant check
-- ────────────────────────────────────────────────────────────────
SELECT COALESCE(SUM(l.debit_amount), 0), COALESCE(SUM(l.credit_amount), 0)
INTO v_debit_after, v_credit_after
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = v_company_id;
IF v_debit_before <> v_debit_after OR v_credit_before <> v_credit_after THEN
RAISE EXCEPTION
'INVARIANT BROKEN: grand debit/credit totals diverged. '
'Before D=% C=%, After D=% C=%. Rolling back.',
v_debit_before, v_credit_before, v_debit_after, v_credit_after;
END IF;
RAISE NOTICE '─────────────────────────────────────────';
RAISE NOTICE 'Done.';
RAISE NOTICE ' Inserted accounts : %', v_inserted_accounts;
RAISE NOTICE ' Updated lines : %', v_updated_lines;
RAISE NOTICE ' Deleted accounts : %', v_deleted_accounts;
RAISE NOTICE ' Grand totals OK : debit=% credit=%', v_debit_after, v_credit_after;
RAISE NOTICE '─────────────────────────────────────────';
END
$remap$;
-- Change the next line to ROLLBACK for a dry run, COMMIT to apply.
COMMIT;
-575
View File
@@ -1,575 +0,0 @@
#!/usr/bin/env npx tsx
/**
* One-off BAS96 -> BAS2025 chart-of-accounts remap for Krister Sundling.
*
* Context: Krister imported a SIE from SPCS into gnubok. Balances are correct
* but account numbers use BAS96, which gnubok's reports interpret against
* BAS2025 -- so classification in BR/RR is wrong. Krister only has IB data
* and is travelling, giving us a clean window to fix the chart before he
* enters real vouchers.
*
* Strategy (UUID-based, no UPDATE on chart_of_accounts.account_number):
* 1. Resolve the company from auth.users by email + company_members.
* 2. Hard-check the resolved company's name contains EXPECTED_COMPANY_NAME_FRAGMENT.
* 3. Snapshot chart_of_accounts; build (oldId -> targetId) plan, inserting
* target rows where missing.
* 4. In one transaction with SET LOCAL gnubok.allow_delete='true':
* - INSERT new chart_of_accounts rows for target numbers that don't
* exist yet.
* - UPDATE journal_entry_lines.account_id/account_number from old UUIDs
* to target UUIDs.
* - DELETE old chart_of_accounts rows that no longer have lines.
* 5. Pre/post per-account-class debit/credit totals must match.
*
* Why pg directly: the immutability bypass GUC is transaction-local
* (current_setting('gnubok.allow_delete', true)). supabase-js issues
* each call on its own pooled connection, so the flag wouldn't persist.
*
* Usage:
* DATABASE_URL=postgresql://... npx tsx scripts/remap-krister-bas96-to-bas2025.ts # dry run
* DATABASE_URL=postgresql://... npx tsx scripts/remap-krister-bas96-to-bas2025.ts --commit # apply
* Flags: --email <addr> overrides the default, --company-id <uuid> picks
* one when the user owns multiple companies.
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { Pool, type PoolClient } from 'pg'
import readline from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
// ──────────────────────────────────────────────────────────────────
// Hard-coded identity. The script aborts if these don't match.
// No --force, no override.
// ──────────────────────────────────────────────────────────────────
const EXPECTED_EMAIL = 'ks@sundlingwarn.com'
const EXPECTED_COMPANY_NAME_FRAGMENT = 'cesu' // Krister's holding company: CeSu Invest AB
const CONFIRM_PHRASE = 'remap krister'
// ──────────────────────────────────────────────────────────────────
// Mapping (BAS96 -> BAS2025), agreed with Krister 2026-05-15.
// Order does not matter -- mapping is keyed by old account UUID.
// ──────────────────────────────────────────────────────────────────
type AccountType = 'asset' | 'equity' | 'liability' | 'revenue' | 'expense'
type NormalBalance = 'debit' | 'credit'
interface Mapping {
oldNumber: string
newNumber: string
newName: string
accountClass: number
accountType: AccountType
normalBalance: NormalBalance
accountGroup: string | null
}
const MAPPINGS: ReadonlyArray<Mapping> = [
// Bank och likvida medel
{ oldNumber: '1040', newNumber: '1930', newName: 'Företagskonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1050', newNumber: '1940', newName: 'Likviditetskonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1051', newNumber: '1941', newName: 'Valutakonto GBP', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1052', newNumber: '1942', newName: 'Valutakonto EUR', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1053', newNumber: '1943', newName: 'Fasträntekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
{ oldNumber: '1055', newNumber: '1944', newName: 'Sparkonto SBAB', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '19' },
// Värdepapper och placeringar
{ oldNumber: '1056', newNumber: '1361', newName: 'Depå Carnegie', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1060', newNumber: '1385', newName: 'Kapitalförsäkring (Avanza)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1061', newNumber: '1386', newName: 'Kapitalförsäkring (Movestic)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1210', newNumber: '1510', newName: 'Kundfordringar', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '15' },
{ oldNumber: '1360', newNumber: '1760', newName: 'Upplupna ränteintäkter', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '17' },
{ oldNumber: '1623', newNumber: '1330', newName: 'Andelar i intresseföretag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1624', newNumber: '1311', newName: 'Andelar i dotterföretag — Divigen', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1625', newNumber: '1350', newName: 'Andelar i andra företag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1626', newNumber: '1351', newName: 'Andelar i andra utländska företag', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1627', newNumber: '1352', newName: 'Andelar — Impilo', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1628', newNumber: '1353', newName: 'Andelar — Röko', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1629', newNumber: '1354', newName: 'Andelar — Altor V', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1630', newNumber: '1360', newName: 'Aktiefonder (HB Microcap)', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1631', newNumber: '1355', newName: 'Andelar — Altor VI', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
{ oldNumber: '1632', newNumber: '1356', newName: 'Andelar — Impilo Orphan', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '13' },
// Skatt och moms (merge: 2210 + 2211 -> 1630 Skattekonto)
{ oldNumber: '2210', newNumber: '1630', newName: 'Skattekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '16' },
{ oldNumber: '2211', newNumber: '1630', newName: 'Skattekonto', accountClass: 1, accountType: 'asset', normalBalance: 'debit', accountGroup: '16' },
{ oldNumber: '2330', newNumber: '2941', newName: 'Upplupna lagstadgade soc. avgifter', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '29' },
{ oldNumber: '2480', newNumber: '2650', newName: 'Redovisningskonto för moms', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '26' },
{ oldNumber: '2510', newNumber: '2710', newName: 'Personalens källskatt', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '27' },
// Övriga skulder och reserver
{ oldNumber: '2690', newNumber: '2890', newName: 'Övriga kortfristiga skulder', accountClass: 2, accountType: 'liability', normalBalance: 'credit', accountGroup: '28' },
{ oldNumber: '2864', newNumber: '2126', newName: 'Periodiseringsfond avsatt vid taxering 2026', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '21' },
// Eget kapital
{ oldNumber: '2991', newNumber: '2081', newName: 'Aktiekapital', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2992', newNumber: '2086', newName: 'Reservfond', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2997', newNumber: '2091', newName: 'Balanserat resultat', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
{ oldNumber: '2999', newNumber: '2099', newName: 'Årets resultat', accountClass: 2, accountType: 'equity', normalBalance: 'credit', accountGroup: '20' },
]
// ──────────────────────────────────────────────────────────────────
// Args
// ──────────────────────────────────────────────────────────────────
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
}
const COMMIT = process.argv.includes('--commit')
const EMAIL_OVERRIDE = arg('email')
const COMPANY_ID_OVERRIDE = arg('company-id')
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error(
'Missing DATABASE_URL. Set it to the Supabase Postgres connection string ' +
'(Project Settings -> Database -> Connection string -> URI, with the service password).',
)
process.exit(1)
}
const targetEmail = EMAIL_OVERRIDE ?? EXPECTED_EMAIL
// ──────────────────────────────────────────────────────────────────
// Identity resolution
// ──────────────────────────────────────────────────────────────────
interface UserRow { id: string; email: string }
interface CompanyRow { id: string; name: string; entity_type: string | null }
async function resolveUser(client: PoolClient): Promise<UserRow> {
const res = await client.query<UserRow>(
`SELECT id, email FROM auth.users WHERE LOWER(email) = LOWER($1) LIMIT 2`,
[targetEmail],
)
if (res.rows.length === 0) throw new Error(`No auth.users row for email ${targetEmail}`)
if (res.rows.length > 1) throw new Error(`Multiple auth.users rows for email ${targetEmail} -- aborting`)
return res.rows[0]
}
async function resolveCompany(client: PoolClient, userId: string): Promise<CompanyRow> {
const res = await client.query<CompanyRow>(
`SELECT c.id, c.name, c.entity_type
FROM public.companies c
JOIN public.company_members cm ON cm.company_id = c.id
WHERE cm.user_id = $1 AND cm.role IN ('owner', 'admin')
ORDER BY c.created_at ASC`,
[userId],
)
if (res.rows.length === 0) {
throw new Error(`User ${userId} owns/admins no companies`)
}
if (COMPANY_ID_OVERRIDE) {
const pick = res.rows.find(r => r.id === COMPANY_ID_OVERRIDE)
if (!pick) {
throw new Error(
`--company-id ${COMPANY_ID_OVERRIDE} is not among this user's owned companies: ` +
res.rows.map(r => `${r.id} (${r.name})`).join(', '),
)
}
return pick
}
if (res.rows.length > 1) {
const list = res.rows.map(r => ` ${r.id} ${r.name}`).join('\n')
throw new Error(
`User ${userId} owns/admins multiple companies -- pick one with --company-id <uuid>:\n${list}`,
)
}
return res.rows[0]
}
function assertIdentity(user: UserRow, company: CompanyRow): void {
if (user.email.toLowerCase() !== EXPECTED_EMAIL.toLowerCase()) {
throw new Error(
`Identity check FAILED: resolved user email ${user.email} != expected ${EXPECTED_EMAIL}`,
)
}
if (!company.name.toLowerCase().includes(EXPECTED_COMPANY_NAME_FRAGMENT.toLowerCase())) {
throw new Error(
`Identity check FAILED: resolved company "${company.name}" does not contain "${EXPECTED_COMPANY_NAME_FRAGMENT}"`,
)
}
}
// ──────────────────────────────────────────────────────────────────
// Plan construction
// ──────────────────────────────────────────────────────────────────
interface AccountSnapshotRow {
id: string
account_number: string
account_name: string
account_class: number
account_type: AccountType
normal_balance: NormalBalance
}
interface PlanItem {
mapping: Mapping
oldId: string
targetId: string | null // null means INSERT new row
targetExisted: boolean // true if a row with newNumber already existed
lineCountEstimate: number // # of journal_entry_lines that will be moved
}
async function snapshotAccounts(client: PoolClient, companyId: string): Promise<Map<string, AccountSnapshotRow>> {
const res = await client.query<AccountSnapshotRow>(
`SELECT id, account_number, account_name, account_class, account_type, normal_balance
FROM public.chart_of_accounts
WHERE company_id = $1`,
[companyId],
)
const map = new Map<string, AccountSnapshotRow>()
for (const r of res.rows) map.set(r.account_number, r)
return map
}
async function countLines(client: PoolClient, accountId: string, companyId: string): Promise<number> {
// Scope by company_id via parent journal_entries to defend against any
// accidental cross-tenant account_id reuse (should be impossible since
// UUIDs are unique, but a free defense-in-depth check).
const res = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = $1 AND je.company_id = $2`,
[accountId, companyId],
)
return Number(res.rows[0]?.n ?? '0')
}
async function buildPlan(client: PoolClient, companyId: string): Promise<PlanItem[]> {
const snapshot = await snapshotAccounts(client, companyId)
const items: PlanItem[] = []
for (const m of MAPPINGS) {
const src = snapshot.get(m.oldNumber)
if (!src) continue // already migrated or never existed
const tgt = snapshot.get(m.newNumber)
const lineCount = await countLines(client, src.id, companyId)
items.push({
mapping: m,
oldId: src.id,
targetId: tgt?.id ?? null,
targetExisted: !!tgt,
lineCountEstimate: lineCount,
})
}
return items
}
// ──────────────────────────────────────────────────────────────────
// Pre/post invariants. The remap reclassifies accounts (that's the whole
// point), so per-class sums shift -- the merge 2210+2211 -> 1630 moves
// money from class 2 to class 1. The invariant that MUST hold is the
// company-wide debit/credit sum: the script never touches debit_amount
// or credit_amount on any line, so those sums must be byte-identical
// before/after. Per-class breakdown is informational.
// ──────────────────────────────────────────────────────────────────
interface GrandTotal { total_debit: string; total_credit: string }
interface ClassTotal { account_class: number; account_number: string | null; total_debit: string; total_credit: string }
async function grandTotals(client: PoolClient, companyId: string): Promise<GrandTotal> {
const res = await client.query<GrandTotal>(
`SELECT COALESCE(SUM(l.debit_amount), 0)::text AS total_debit,
COALESCE(SUM(l.credit_amount), 0)::text AS total_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = $1`,
[companyId],
)
return res.rows[0]
}
async function classTotals(client: PoolClient, companyId: string): Promise<ClassTotal[]> {
const res = await client.query<ClassTotal>(
`SELECT coa.account_class,
NULL::text AS account_number,
COALESCE(SUM(l.debit_amount), 0)::text AS total_debit,
COALESCE(SUM(l.credit_amount), 0)::text AS total_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.chart_of_accounts coa ON coa.id = l.account_id
WHERE je.company_id = $1
GROUP BY coa.account_class
ORDER BY coa.account_class`,
[companyId],
)
return res.rows
}
function grandTotalsEqual(a: GrandTotal, b: GrandTotal): boolean {
return a.total_debit === b.total_debit && a.total_credit === b.total_credit
}
// ──────────────────────────────────────────────────────────────────
// Period lock pre-flight
// ──────────────────────────────────────────────────────────────────
async function assertNoLockedPeriods(client: PoolClient, companyId: string): Promise<void> {
const res = await client.query<{ name: string; is_closed: boolean; locked_at: string | null }>(
`SELECT name, is_closed, locked_at::text
FROM public.fiscal_periods
WHERE company_id = $1 AND (is_closed = true OR locked_at IS NOT NULL)`,
[companyId],
)
if (res.rows.length > 0) {
const list = res.rows.map(r => ` ${r.name} (closed=${r.is_closed}, locked_at=${r.locked_at ?? '—'})`).join('\n')
throw new Error(
`Refusing to run: ${res.rows.length} fiscal_periods are closed/locked. ` +
`gnubok.allow_delete does not bypass period locks. Unlock first, or escalate:\n${list}`,
)
}
}
// ──────────────────────────────────────────────────────────────────
// Plan execution (inside one transaction)
// ──────────────────────────────────────────────────────────────────
interface ExecResult {
inserted: number
updatedLines: number
deletedAccounts: number
newTargetIds: Map<string, string> // newNumber -> id
}
async function executePlan(
client: PoolClient,
companyId: string,
ownerUserId: string,
plan: PlanItem[],
): Promise<ExecResult> {
await client.query("SELECT set_config('gnubok.allow_delete', 'true', true)")
const result: ExecResult = { inserted: 0, updatedLines: 0, deletedAccounts: 0, newTargetIds: new Map() }
// Phase 1: INSERT all missing target accounts, dedup by newNumber.
const newNumbersNeeded = new Map<string, Mapping>()
for (const p of plan) {
if (!p.targetExisted && !newNumbersNeeded.has(p.mapping.newNumber)) {
newNumbersNeeded.set(p.mapping.newNumber, p.mapping)
}
}
for (const [, m] of newNumbersNeeded) {
const ins = await client.query<{ id: string }>(
`INSERT INTO public.chart_of_accounts (
user_id, company_id, account_number, account_name, account_class,
account_group, account_type, normal_balance, plan_type, is_active, is_system_account
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'full_bas', true, false)
RETURNING id`,
[ownerUserId, companyId, m.newNumber, m.newName, m.accountClass, m.accountGroup, m.accountType, m.normalBalance],
)
result.newTargetIds.set(m.newNumber, ins.rows[0].id)
result.inserted++
}
// Phase 2: resolve every plan item's final targetId.
for (const p of plan) {
if (!p.targetId) {
const inserted = result.newTargetIds.get(p.mapping.newNumber)
if (!inserted) throw new Error(`Internal: no inserted id for new account ${p.mapping.newNumber}`)
p.targetId = inserted
}
}
// Phase 3: move journal lines from old account_id -> targetId.
for (const p of plan) {
if (!p.targetId) throw new Error('Internal: missing targetId')
if (p.targetId === p.oldId) continue // idempotent no-op
// Defense-in-depth: confirm old account still belongs to this company.
const own = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM public.chart_of_accounts WHERE id = $1 AND company_id = $2`,
[p.oldId, companyId],
)
if (Number(own.rows[0].n) !== 1) {
throw new Error(
`Pre-write check failed: old account ${p.oldId} (${p.mapping.oldNumber}) not owned by company ${companyId}`,
)
}
const upd = await client.query<{ id: string }>(
`UPDATE public.journal_entry_lines AS l
SET account_id = $1, account_number = $2
FROM public.journal_entries AS je
WHERE l.journal_entry_id = je.id
AND je.company_id = $3
AND l.account_id = $4
RETURNING l.id`,
[p.targetId, p.mapping.newNumber, companyId, p.oldId],
)
result.updatedLines += upd.rowCount ?? 0
}
// Phase 4: account_balances was dropped in migration
// 20240101000027_drop_unused_module_tables.sql -- no cache to clean.
// Phase 5: delete old accounts that no longer have any lines.
const oldIds = Array.from(new Set(plan.map(p => p.oldId)))
for (const oldId of oldIds) {
const remaining = await client.query<{ n: string }>(
`SELECT COUNT(*)::text AS n
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE l.account_id = $1 AND je.company_id = $2`,
[oldId, companyId],
)
if (Number(remaining.rows[0].n) !== 0) {
throw new Error(`Refusing to delete account ${oldId}: ${remaining.rows[0].n} lines still reference it`)
}
const del = await client.query(
`DELETE FROM public.chart_of_accounts WHERE id = $1 AND company_id = $2`,
[oldId, companyId],
)
result.deletedAccounts += del.rowCount ?? 0
}
return result
}
// ──────────────────────────────────────────────────────────────────
// Pretty-print plan
// ──────────────────────────────────────────────────────────────────
function printPlan(plan: PlanItem[]): void {
console.log('\nRemap plan:')
const renames = plan.filter(p => p.mapping.oldNumber !== p.mapping.newNumber)
const merges = new Map<string, PlanItem[]>()
for (const p of plan) {
const k = p.mapping.newNumber
if (!merges.has(k)) merges.set(k, [])
merges.get(k)!.push(p)
}
const lineWidth = 6
for (const p of renames) {
const tag = p.targetExisted ? 'merge into existing' : 'rename'
console.log(
` ${p.mapping.oldNumber.padEnd(lineWidth)} ` +
`-> ${p.mapping.newNumber.padEnd(lineWidth)} ` +
`${p.mapping.newName.padEnd(48)} ` +
`(${p.lineCountEstimate} lines, ${tag})`,
)
}
const mergeTargets = [...merges.entries()].filter(([, items]) => items.length > 1)
if (mergeTargets.length > 0) {
console.log('\nMerges (multiple old -> one new):')
for (const [newNumber, items] of mergeTargets) {
console.log(` ${items.map(i => i.mapping.oldNumber).join(' + ')} -> ${newNumber}`)
}
}
const newAccounts = new Set(plan.filter(p => !p.targetExisted).map(p => p.mapping.newNumber))
if (newAccounts.size > 0) {
console.log(`\nNew chart_of_accounts rows to insert: ${newAccounts.size}`)
for (const n of [...newAccounts].sort()) {
const m = plan.find(p => p.mapping.newNumber === n)!.mapping
console.log(` ${n} ${m.newName}`)
}
}
}
// ──────────────────────────────────────────────────────────────────
// Main
// ──────────────────────────────────────────────────────────────────
async function main() {
const pool = new Pool({ connectionString: databaseUrl, max: 2 })
const client = await pool.connect()
try {
console.log('─────────────────────────────────────────────────────────')
console.log('BAS96 -> BAS2025 remap (one-off, Krister Sundling)')
console.log('─────────────────────────────────────────────────────────')
console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)')
console.log('Email:', targetEmail)
const user = await resolveUser(client)
const company = await resolveCompany(client, user.id)
assertIdentity(user, company)
console.log('User :', `${user.email} (${user.id})`)
console.log('Co. :', `${company.name} (${company.id}, ${company.entity_type ?? '?'})`)
await assertNoLockedPeriods(client, company.id)
const plan = await buildPlan(client, company.id)
if (plan.length === 0) {
console.log('\nNothing to remap -- no BAS96 source accounts found. (Already migrated?)')
return
}
printPlan(plan)
const totalLines = plan.reduce((n, p) => n + p.lineCountEstimate, 0)
console.log(`\nTotal journal_entry_lines that will move: ${totalLines}`)
const grandBefore = await grandTotals(client, company.id)
console.log(`\nPre-flight grand totals (must be unchanged by remap):`)
console.log(` total_debit=${grandBefore.total_debit} total_credit=${grandBefore.total_credit}`)
console.log(`\nPre-flight per-class totals (these WILL shift as accounts are reclassified):`)
const classBefore = await classTotals(client, company.id)
for (const r of classBefore) {
console.log(` class ${r.account_class}: debit=${r.total_debit} credit=${r.total_credit}`)
}
if (!COMMIT) {
console.log('\n[dry-run] No changes made. Re-run with --commit to apply.')
return
}
const rl = readline.createInterface({ input, output })
const phrase = await rl.question(
`\nAbout to apply the remap above for ${company.name} (${company.id}).\n` +
`Type '${CONFIRM_PHRASE}' to proceed: `,
)
rl.close()
if (phrase.trim().toLowerCase() !== CONFIRM_PHRASE) {
console.log('Confirmation phrase did not match. Aborting.')
process.exit(1)
}
// Single transaction: bypass flag is transaction-local.
await client.query('BEGIN')
let result: ExecResult
try {
result = await executePlan(client, company.id, user.id, plan)
const grandAfter = await grandTotals(client, company.id)
console.log(`\nPost-flight grand totals (still inside transaction):`)
console.log(` total_debit=${grandAfter.total_debit} total_credit=${grandAfter.total_credit}`)
if (!grandTotalsEqual(grandBefore, grandAfter)) {
throw new Error(
`INVARIANT BROKEN: grand debit/credit sums diverge after remap. ` +
`Before debit=${grandBefore.total_debit} credit=${grandBefore.total_credit}; ` +
`After debit=${grandAfter.total_debit} credit=${grandAfter.total_credit}. Rolling back.`,
)
}
const classAfter = await classTotals(client, company.id)
console.log('\nPost-flight per-class totals (reclassified -- shifts expected):')
for (const r of classAfter) {
console.log(` class ${r.account_class}: debit=${r.total_debit} credit=${r.total_credit}`)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
}
console.log('\n─────────────────────────────────────────────────────────')
console.log('Done.')
console.log('─────────────────────────────────────────────────────────')
console.log(`Inserted accounts : ${result.inserted}`)
console.log(`Updated lines : ${result.updatedLines}`)
console.log(`Deleted accounts : ${result.deletedAccounts}`)
console.log('\nNext: open the balance sheet and trial balance in gnubok as Krister to confirm classification.')
} finally {
client.release()
await pool.end()
}
}
main().catch(err => {
console.error('\nFATAL:', err instanceof Error ? err.message : err)
process.exit(1)
})
-103
View File
@@ -1,103 +0,0 @@
-- Verification queries for the BAS96 -> BAS2025 remap on CeSu Invest AB.
-- Run each block separately in the Supabase SQL editor, or all together
-- and click through the result tabs.
-- ────────────────────────────────────────────────────────────────
-- 1. No leftover __mig__ rows? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) AS mig_rows_remaining
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB'
AND LEFT(coa.account_number, 7) = '__mig__';
-- ────────────────────────────────────────────────────────────────
-- 2. No leftover BAS96 numbers in chart_of_accounts? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT coa.account_number, coa.account_name
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1623','1624','1625','1626','1627','1628','1629',
'1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
);
-- (Note: 1360 and 1630 are intentionally OMITTED here because they exist
-- as legitimate BAS2025 targets after the remap.)
-- ────────────────────────────────────────────────────────────────
-- 3. No leftover BAS96 numbers in journal_entry_lines? (should return 0)
-- ────────────────────────────────────────────────────────────────
SELECT l.account_number, COUNT(*) AS line_count
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
WHERE c.name = 'CeSu Invest AB'
AND l.account_number IN (
'1040','1050','1051','1052','1053','1055','1056','1060','1061',
'1210','1623','1624','1625','1626','1627','1628','1629',
'1631','1632','2210','2211','2330','2480','2510','2690',
'2864','2991','2992','2997','2999'
)
GROUP BY l.account_number;
-- ────────────────────────────────────────────────────────────────
-- 4. account_id <-> account_number consistency on every line.
-- Should return 0 -- every line's account_number must match the
-- chart_of_accounts row it points to.
-- ────────────────────────────────────────────────────────────────
SELECT COUNT(*) AS mismatched_lines
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
JOIN public.chart_of_accounts coa ON coa.id = l.account_id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number <> l.account_number;
-- ────────────────────────────────────────────────────────────────
-- 5. Grand totals -- debits = credits and look plausible.
-- ────────────────────────────────────────────────────────────────
SELECT
SUM(l.debit_amount) AS total_debit,
SUM(l.credit_amount) AS total_credit,
SUM(l.debit_amount) - SUM(l.credit_amount) AS debit_minus_credit
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
JOIN public.companies c ON c.id = je.company_id
WHERE c.name = 'CeSu Invest AB';
-- ────────────────────────────────────────────────────────────────
-- 6. Per-account breakdown (post-remap) — spot-check the BAS2025 numbers.
-- ────────────────────────────────────────────────────────────────
SELECT
coa.account_number,
coa.account_name,
COALESCE(SUM(l.debit_amount), 0) AS debit_sum,
COALESCE(SUM(l.credit_amount), 0) AS credit_sum,
COUNT(l.id) AS line_count
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
LEFT JOIN public.journal_entry_lines l ON l.account_id = coa.id
WHERE c.name = 'CeSu Invest AB'
AND coa.account_number IN (
'1311','1330','1350','1351','1352','1353','1354','1355','1356',
'1360','1361','1385','1386','1510','1630','1760',
'1930','1940','1941','1942','1943','1944',
'2081','2086','2091','2099','2126','2650','2710','2890','2941'
)
GROUP BY coa.account_number, coa.account_name
ORDER BY coa.account_number;
-- ────────────────────────────────────────────────────────────────
-- 7. Summary headcount: chart_of_accounts for CeSu Invest AB.
-- ────────────────────────────────────────────────────────────────
SELECT
COUNT(*) AS total_accounts,
COUNT(*) FILTER (WHERE account_class = 1) AS class_1_assets,
COUNT(*) FILTER (WHERE account_class = 2) AS class_2_eq_liab,
COUNT(*) FILTER (WHERE LEFT(account_number, 7) = '__mig__') AS migration_leftovers
FROM public.chart_of_accounts coa
JOIN public.companies c ON c.id = coa.company_id
WHERE c.name = 'CeSu Invest AB';