Files
accounted/scripts/undo-year-end-closing.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

445 lines
18 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Administrative undo of an executed year-end closing (bokslut).
*
* Restores a company to the state just before "Verkställ bokslut" so the
* dispositions/preview step can be re-run, WITHOUT violating verifikat
* immutability: every journal change is a storno posted through the
* bookkeeping engine (BFL 5 kap 5 §). Nothing is edited or deleted.
*
* What it does, in order:
* 1. Preconditions: the period has a closing entry; no årsredovisning
* submission or signature request exists for the company; the next
* period (if any) is open and has no closing entry of its own. Other
* posted entries in the next period are reported but do not block
* (their balances are independent of the IB; the re-run's continuity
* check revalidates everything).
* 2. Reverse the next period's result_appropriation entry (2099 -> 2098).
* 3. Reverse the next period's opening_balance entry. reverseEntry()
* itself clears opening_balance_entry_id + opening_balances_set on the
* period (two-step, per enforce_opening_balance_immutability).
* 4. Reset the next period's continuity_verified to NULL.
* 5. Reopen the closed period (is_closed=false, closed_at=null,
* locked_at=null).
* 6. Reverse the closing entry (storno in the reopened period).
* 7. Clear closing_entry_id. This must come AFTER the storno: the
* enforce_opening_balance_immutability trigger only allows detaching a
* closing entry that is reversed with a posted storno chain
* (migration 20260720140000).
* 8. Write an explicit audit_log row (BFNAR 2013:2 kap. 8: reopening is a
* sensitive control change) and verify the final state.
*
* RESUMABLE: every step is idempotent-or-skipped, so if a run dies midway
* (e.g. after the reopen but before the detach) simply re-run with the same
* arguments: already-reversed entries are skipped, the reopen is skipped
* when the period is already open, and the run continues from the first
* incomplete step.
*
* Dispositions already booked in the period (periodiseringsfond, SLP,
* överavskrivningar) are NOT touched: only the closing entry and the two
* auto-generated new-year entries are reversed.
*
* Attribution (BFL 5 kap 6 §): pass --user-id to attribute the stornos
* explicitly (normally the company owner who requested the reset); defaults
* to the company owner, with a loud warning on the arbitrary-member fallback.
*
* Usage:
* # Dry run (read-only) against the env in .env.local
* npx tsx scripts/undo-year-end-closing.ts --company-id <uuid> --period-id <uuid>
*
* # Apply, attributing to a specific user. --confirm-url must restate the
* # target Supabase URL so the operator confirms WHICH environment mutates.
* npx tsx scripts/undo-year-end-closing.ts --company-id <uuid> --period-id <uuid> \
* --user-id <uuid> --commit --confirm-url https://<project>.supabase.co
*
* # Against another environment (e.g. production)
* npx tsx scripts/undo-year-end-closing.ts --env-file .env.prod.local ...
*
* Run against staging first; only run against prod after reviewing the dry-run.
*/
import { config } from 'dotenv'
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
}
config({ path: arg('env-file') ?? '.env.local' })
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { reverseEntry } from '../lib/bookkeeping/engine'
const COMPANY_ID = arg('company-id')
const PERIOD_ID = arg('period-id')
const USER_ID_ARG = arg('user-id')
const COMMIT = process.argv.includes('--commit')
const CONFIRM_URL = arg('confirm-url')
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !serviceKey) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
process.exit(1)
}
if (!serviceKey.startsWith('eyJ') && !serviceKey.startsWith('sb_secret_')) {
console.error(
'SUPABASE_SERVICE_ROLE_KEY does not look like a service-role key (expected a JWT or sb_secret_ prefix); check the env file'
)
process.exit(1)
}
if (!COMPANY_ID || !PERIOD_ID) {
console.error(
'Usage: --company-id <uuid> --period-id <uuid> [--user-id <uuid>] [--env-file <path>] [--commit --confirm-url <supabase-url>]'
)
process.exit(1)
}
// A prefix check on the key cannot catch a right-looking key from the WRONG
// environment (e.g. a staging env file against prod). For mutations, the
// operator must restate the target URL so an accidental env swap fails loud.
if (COMMIT && CONFIRM_URL !== url) {
console.error(
`--commit requires --confirm-url to exactly match the target Supabase URL.\n` +
` target: ${url}\n` +
` confirm-url: ${CONFIRM_URL ?? '(missing)'}`
)
process.exit(1)
}
const supabase: SupabaseClient = createClient(url, serviceKey, {
auth: { persistSession: false },
})
function fail(msg: string): never {
console.error(`BLOCKED: ${msg}`)
process.exit(1)
}
type EntryRow = {
id: string
voucher_series: string | null
voucher_number: number | null
entry_date: string
description: string
source_type: string
status: string
}
function label(e: EntryRow): string {
return `${e.voucher_series ?? 'A'}${e.voucher_number} ${e.entry_date} "${e.description}" [${e.source_type}/${e.status}]`
}
async function main() {
console.log(`Mode: ${COMMIT ? 'COMMIT' : 'dry-run'} target: ${url}`)
// ── Load period ────────────────────────────────────────────────
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', PERIOD_ID)
.eq('company_id', COMPANY_ID)
.single()
if (periodError || !period) fail(`fiscal period not found: ${periodError?.message}`)
console.log(`Period: ${period.name} (${period.period_start} - ${period.period_end})`)
if (!period.closing_entry_id) fail('period has no closing_entry_id: nothing to undo')
if (!period.is_closed) {
console.warn(' note: period is not closed (resuming a partial undo, or year-end failed midway)')
}
// ── Preconditions ──────────────────────────────────────────────
const { count: submissions } = await supabase
.from('arsredovisning_submissions')
.select('id', { count: 'exact', head: true })
.eq('company_id', COMPANY_ID)
.eq('fiscal_period_id', PERIOD_ID)
if ((submissions ?? 0) > 0) fail('an årsredovisning submission exists for this period: refuse to reopen')
const { count: signatureRequests } = await supabase
.from('arsredovisning_signature_requests')
.select('id', { count: 'exact', head: true })
.eq('company_id', COMPANY_ID)
.eq('fiscal_period_id', PERIOD_ID)
if ((signatureRequests ?? 0) > 0) fail('an årsredovisning signature request exists for this period: refuse to reopen')
const { data: settings } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', COMPANY_ID)
.maybeSingle()
if (
settings?.bookkeeping_locked_through &&
settings.bookkeeping_locked_through >= period.period_end
) {
fail(
`company lock date ${settings.bookkeeping_locked_through} covers the period end: clear it first`
)
}
const { data: closingEntry } = await supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number, entry_date, description, source_type, status')
.eq('id', period.closing_entry_id)
.eq('company_id', COMPANY_ID)
.single()
if (!closingEntry) fail('closing entry row not found')
if (closingEntry.status !== 'posted' && closingEntry.status !== 'reversed') {
fail(`closing entry has unexpected status (${closingEntry.status})`)
}
console.log(`Closing entry: ${label(closingEntry)}`)
// ── Next period + its auto-generated entries ───────────────────
// Chain lookup first, then date-based fallback (day after period_end),
// mirroring findNextPeriod() in period-service: periods created before the
// previous_period_id chain was wired up must still be found, or their
// IB/appropriation entries would be left posted and block the re-run.
let { data: nextPeriod } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', COMPANY_ID)
.eq('previous_period_id', PERIOD_ID)
.maybeSingle()
if (!nextPeriod) {
const dayAfter = new Date(period.period_end + 'T00:00:00Z')
dayAfter.setUTCDate(dayAfter.getUTCDate() + 1)
const { data: byDate } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', COMPANY_ID)
.eq('period_start', dayAfter.toISOString().slice(0, 10))
.maybeSingle()
nextPeriod = byDate
}
let ibEntry: EntryRow | null = null
let appropriationEntry: EntryRow | null = null
if (nextPeriod) {
console.log(`Next period: ${nextPeriod.name} (${nextPeriod.period_start} - ${nextPeriod.period_end})`)
if (nextPeriod.is_closed || nextPeriod.closing_entry_id) {
fail('next period is itself closed: undo that year first')
}
if (nextPeriod.locked_at) fail('next period is locked: unlock it first')
const { data: nextEntries } = await supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number, entry_date, description, source_type, status')
.eq('company_id', COMPANY_ID)
.eq('fiscal_period_id', nextPeriod.id)
.neq('status', 'cancelled')
.order('voucher_number', { ascending: true })
for (const e of (nextEntries ?? []) as EntryRow[]) {
if (e.status !== 'posted') continue
// The IB entry is matched by the period's own link when set; the
// source_type scan is the fallback for a partial run where the link
// was already cleared but a posted IB somehow remains.
if (
e.source_type === 'opening_balance' &&
(!nextPeriod.opening_balance_entry_id || e.id === nextPeriod.opening_balance_entry_id)
) {
ibEntry = e
} else if (e.source_type === 'result_appropriation') {
appropriationEntry = e
} else {
// Real bookkeeping already exists in the new year. That is fine for
// the reset itself (their balances are independent of the IB), but
// say so loudly so the operator has thought about it.
console.warn(` note: next period has other posted entries, e.g. ${label(e)}`)
}
}
if (ibEntry) console.log(`Opening balance entry: ${label(ibEntry)}`)
if (appropriationEntry) console.log(`Result appropriation entry: ${label(appropriationEntry)}`)
} else {
console.log('No next period found: only the closing entry will be reversed')
}
// ── Attribution ────────────────────────────────────────────────
let userId = USER_ID_ARG
if (!userId) {
const { data: owner } = await supabase
.from('company_members')
.select('user_id, role')
.eq('company_id', COMPANY_ID)
.eq('role', 'owner')
.limit(1)
.maybeSingle()
if (owner) {
userId = owner.user_id
} else {
const { data: anyMember } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', COMPANY_ID)
.limit(1)
.maybeSingle()
if (!anyMember) fail('no company member to attribute the stornos to')
userId = anyMember.user_id
console.warn('WARNING: no owner found; attributing to an arbitrary member. Pass --user-id.')
}
}
console.log(`Attribution user: ${userId}`)
if (!COMMIT) {
console.log('\nDry run only. Planned actions:')
if (appropriationEntry) console.log(` 1. Storno ${label(appropriationEntry)}`)
if (ibEntry) console.log(` 2. Storno ${label(ibEntry)} (clears IB link + flag on next period)`)
if (nextPeriod) console.log(' 3. Reset next period continuity_verified to NULL')
if (period.is_closed || period.locked_at) {
console.log(` 4. Reopen ${period.name}: is_closed=false, closed_at=null, locked_at=null`)
}
if (closingEntry.status === 'posted') console.log(` 5. Storno ${label(closingEntry)}`)
console.log(' 6. Clear closing_entry_id on the reopened period (+ audit_log)')
console.log('Re-run with --commit to apply.')
return
}
// ── Execute ────────────────────────────────────────────────────
if (appropriationEntry) {
console.log('Reversing result appropriation entry…')
const storno = await reverseEntry(supabase, COMPANY_ID!, userId!, appropriationEntry.id)
console.log(` posted storno ${storno.voucher_series}${storno.voucher_number}`)
}
if (ibEntry) {
console.log('Reversing opening balance entry…')
const storno = await reverseEntry(supabase, COMPANY_ID!, userId!, ibEntry.id)
console.log(` posted storno ${storno.voucher_series}${storno.voucher_number}`)
}
if (nextPeriod) {
const { error: contError } = await supabase
.from('fiscal_periods')
.update({ continuity_verified: null })
.eq('id', nextPeriod.id)
.eq('company_id', COMPANY_ID)
if (contError) fail(`failed to reset continuity_verified: ${contError.message}`)
}
if (period.is_closed || period.locked_at) {
console.log('Reopening the closed period…')
// closing_entry_id is NOT cleared here: the immutability trigger only
// allows detaching a closing entry that is already storno-reversed, so
// the link is cleared after the storno below.
const { error: reopenError } = await supabase
.from('fiscal_periods')
.update({
is_closed: false,
closed_at: null,
locked_at: null,
})
.eq('id', PERIOD_ID)
.eq('company_id', COMPANY_ID)
if (reopenError) fail(`failed to reopen period: ${reopenError.message}`)
} else {
console.log('Period already open (resume): skipping reopen')
}
let closingStornoLabel = 'already reversed (resume)'
if (closingEntry.status === 'posted') {
console.log('Reversing closing entry…')
const closingStorno = await reverseEntry(supabase, COMPANY_ID!, userId!, closingEntry.id)
closingStornoLabel = `${closingStorno.voucher_series}${closingStorno.voucher_number}`
console.log(` posted storno ${closingStornoLabel}`)
} else {
console.log('Closing entry already reversed (resume): skipping storno')
}
console.log('Detaching closing entry from the period…')
const { error: detachError } = await supabase
.from('fiscal_periods')
.update({ closing_entry_id: null })
.eq('id', PERIOD_ID)
.eq('company_id', COMPANY_ID)
if (detachError) fail(`failed to clear closing_entry_id: ${detachError.message}`)
// Audit trail AFTER the mutations so the row describes what actually
// happened (BFNAR 2013:2 kap. 8 behandlingshistorik). The automatic
// write_audit_log trigger also recorded each UPDATE individually.
const auditRow = {
user_id: userId,
company_id: COMPANY_ID,
action: 'UPDATE',
table_name: 'fiscal_periods',
record_id: PERIOD_ID,
description:
`Administrative year-end undo: period reopened (${period.name}, ${period.period_start} to ${period.period_end}); ` +
`closing entry ${closingEntry.voucher_series ?? 'A'}${closingEntry.voucher_number} reversed by storno ${closingStornoLabel} ` +
'and detached; auto-generated new-year entries reversed on user request.',
old_state: {
is_closed: period.is_closed,
closed_at: period.closed_at,
locked_at: period.locked_at,
closing_entry_id: period.closing_entry_id,
},
new_state: { is_closed: false, closed_at: null, locked_at: null, closing_entry_id: null },
}
// BFNAR 2013:2 kap. 8: the behandlingshistorik row is part of the undo.
// The mutations cannot be rolled back from here (each already committed via
// PostgREST), so retry the insert before giving up.
let auditError: { message: string } | null = null
for (let attempt = 1; attempt <= 3; attempt++) {
const { error } = await supabase.from('audit_log').insert(auditRow)
auditError = error
if (!auditError) break
console.warn(` audit_log insert attempt ${attempt}/3 failed: ${auditError.message}`)
}
if (auditError) {
fail(
`audit_log insert failed after 3 attempts: ${auditError.message}\n` +
'The period state itself is valid (all mutations completed), but the undo is NOT ' +
'complete until the behandlingshistorik row exists (BFNAR 2013:2 kap. 8). ' +
'Insert the audit_log row manually (see auditRow in this script for the exact ' +
'content) before treating the undo as done.'
)
}
// ── Verify ─────────────────────────────────────────────────────
const { data: closingAfter } = await supabase
.from('journal_entries')
.select('status')
.eq('id', closingEntry.id)
.eq('company_id', COMPANY_ID)
.single()
const { data: periodAfter } = await supabase
.from('fiscal_periods')
.select('is_closed, locked_at, closing_entry_id')
.eq('id', PERIOD_ID)
.eq('company_id', COMPANY_ID)
.single()
const { data: nextAfter } = nextPeriod
? await supabase
.from('fiscal_periods')
.select('opening_balance_entry_id, opening_balances_set, continuity_verified')
.eq('id', nextPeriod.id)
.eq('company_id', COMPANY_ID)
.single()
: { data: null }
const ok =
closingAfter?.status === 'reversed' &&
periodAfter?.is_closed === false &&
periodAfter?.locked_at === null &&
periodAfter?.closing_entry_id === null &&
(!nextPeriod ||
(nextAfter?.opening_balance_entry_id === null && nextAfter?.opening_balances_set === false))
if (!ok) {
console.error('VERIFICATION FAILED: inspect state manually', {
closingAfter,
periodAfter,
nextAfter,
})
process.exit(1)
}
console.log('\nDone. The period is open again; the dispositions/preview step can be re-run.')
}
main().catch((err) => {
console.error('FAILED:', err)
process.exit(1)
})