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:
co-authored by
Claude Opus 4.8
parent
db8983ba9e
commit
43925bc2d3
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user