Files
accounted/scripts/undo-year-end-closing.ts
T
Mattsson 4e47335308 feat(year-end): administrative undo of executed year-end closing + skatteverket scope fixes (#1081)
* fix(skatteverket): request the ska scope for skattekonto v2

The skattekonto v2 API rejects skahmst-only tokens with 403 "The required
scopes are not authorized" (observed in prod 2026-07-20; no company has
synced since 2026-05-10). The requested `skattekonto` scope is silently
dropped from every grant, while `ska` appears in one real May grant, so
request it too: SKV grants the intersection, so this is harmless if wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): correct the skattekonto scope model around ska

Root cause of the May 10 skattekonto outage, confirmed via git history and
prod token data: the `ska` scope (the interactive skattekonto API's actual
scope, requested since the extension's first commit in March) was removed
by the "remove unused scopes" cleanup in the #431 series. Every token
issued after that hour lacks it and the API answers 403 "The required
scopes are not authorized"; no company has synced since. The May 15 repair
re-added skahmst, which per its tjanstebeskrivning is a different bulk
E-transport service and does not substitute; `skattekonto` is not a real
SKV scope name and is silently dropped from grants.

Follow-up to the ska re-request (cd8f7a30):
- document the confirmed scope model in oauth.ts so ska is never
  "cleaned up" again
- panel missing-scope warning and reconnect-button now gate on ska,
  not skahmst/skattekonto
- scope badge labels: ska takes the saldo & transaktioner label,
  skahmst relabeled as the E-transport file service
- consent-page note covers both terse scope names and says ska is
  required

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(year-end): warn on untaxed profit at verkstall, Swedish readiness messages, always-visible period selector

An aktiebolag could execute year-end with a profit and zero bolagsskatt
booked without any warning (support case: closing moved 592k to 2099
untaxed). The preview now computes bolagsskattMissing (AB + profit + no
89xx account among closed accounts, 8999 excluded) and both the preview
and execute steps render an advisory, bypassable warning.

validateYearEndReadiness messages are now Swedish (the bokslut wizard is
a stays-Swedish surface); the MCP year_end_readiness classifier matches
both the new Swedish strings and the legacy English ones.

The wizard period selector now always renders, keeps a selected-but-
ineligible period selectable, and resets a stale ?period= id from
another company instead of leaving the user stuck on the wrong year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(year-end): administrative undo of an executed year-end closing

Storno-only reset used when a bokslut was executed prematurely (e.g.
without bolagsskatt) and no arsredovisning exists yet: reverses the next
period's result_appropriation and opening_balance entries, reopens the
period, reverses the closing entry, and detaches closing_entry_id.
Resumable if interrupted midway; attribution per BFL 5 kap 6.

Migration 20260720140000 adds the trigger escape hatch: closing_entry_id
may only change once set when the old closing entry is reversed with a
posted storno chain (status flag alone is forgeable via PostgREST), and
a non-NULL replacement must be a posted year_end entry in the same
period. Covered by a pg-real test.

planResultAppropriation idempotency is now posted-only: a reversed
omforing no longer blocks the re-run from posting a fresh 2099 -> 2098
reclassification (it previously returned null silently, leaving the new
year's equity polluted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address CodeRabbit, PR-Agent and compliance findings

- undo script: company_id filters on verify queries, period-scope the
  arsredovisning precondition checks, validate service-key format,
  escalate audit_log insert failure to a hard error (BFNAR 2013:2)
- detach migration: company-scope the storno chain EXISTS, replace the
  em dash in the new error message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address round-2 compliance swarm and Swedish review findings

- undo script: require --confirm-url with --commit so an env swap fails
  loud; retry the audit_log insert 3x and direct the operator to insert
  the behandlingshistorik row manually on final failure (BFNAR 2013:2)
- year-end preview: document why resultAccountSummary is a complete 89xx
  scan; warning text now also names periodiseringsfond and
  overavskrivningar as legitimate zero-tax reasons

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:17:43 +02: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)
})