Ctrl K
ComunidadGuías…

scripts/undo-year-end-closing.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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; the årsredovisning
 *      has no legal weight yet (no submission, no signed signature, no
 *      signed/filed version; see scripts/lib/year-end-undo-gate.ts). A
 *      version still ready_for_signature is superseded before anything else
 *      so its pending signature requests cannot be signed; 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 { getSwedishLocalDate, reverseEntry } from '../src/lib/bookkeeping/engine'
import { evaluateYearEndUndoGate } from './lib/year-end-undo-gate'

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 ──────────────────────────────────────────────
  // Årsredovisning gate: only legal acts block (a signature, a signed or
  // filed version, a Bolagsverket submission). Pending requests and drafts
  // are paperwork; a ready_for_signature version is superseded below so its
  // pending slots cannot be signed against the numbers this undo changes.
  // See scripts/lib/year-end-undo-gate.ts for the rule.
  const [submissionsRes, signatureRes, versionsRes, narrativeRes] = await Promise.all([
    supabase
      .from('arsredovisning_submissions')
      .select('id, status')
      .eq('company_id', COMPANY_ID)
      .eq('fiscal_period_id', PERIOD_ID),
    supabase
      .from('arsredovisning_signature_requests')
      .select('id, status, signed_at, annual_report_version_id')
      .eq('company_id', COMPANY_ID)
      .eq('fiscal_period_id', PERIOD_ID),
    supabase
      .from('annual_report_versions')
      .select('id, version_number, status')
      .eq('company_id', COMPANY_ID)
      .eq('fiscal_period_id', PERIOD_ID),
    supabase
      .from('arsredovisning_narratives')
      .select('agm_date')
      .eq('company_id', COMPANY_ID)
      .eq('fiscal_period_id', PERIOD_ID)
      .maybeSingle(),
  ])
  for (const res of [submissionsRes, signatureRes, versionsRes, narrativeRes]) {
    if (res.error) fail(`failed to read årsredovisning state: ${res.error.message}`)
  }
  const today = getSwedishLocalDate()
  const gate = evaluateYearEndUndoGate({
    submissions: submissionsRes.data ?? [],
    signatureRequests: signatureRes.data ?? [],
    versions: versionsRes.data ?? [],
    agmDate: narrativeRes.data?.agm_date ?? null,
    today,
  })
  if (gate.blockers.length > 0) fail(gate.blockers.join('\n  '))
  for (const w of gate.warnings) console.warn(`  note: ${w}`)

  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:')
    for (const v of gate.versionsToSupersede) {
      console.log(
        `  0. Supersede årsredovisning version ${v.version_number} (ready_for_signature); ` +
          `${gate.voidedRequests.length} pending signature request(s) can no longer be signed`
      )
    }
    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 ────────────────────────────────────────────────────
  // Void the stale annual report first, so nobody can sign it while the
  // year is being reopened. Same transition the product makes when a report
  // is re-finalized (ready_for_signature -> superseded); the version and its
  // pending requests stay as history, nothing is deleted. Requests are NOT
  // marked 'declined': that status records a board member refusing to sign
  // (ABL 8 kap. evidence), which is not what happened.
  const supersededVersions: Array<{ id: string; version_number: number }> = []
  for (const v of gate.versionsToSupersede) {
    const { data: updated, error: supersedeError } = await supabase
      .from('annual_report_versions')
      .update({ status: 'superseded' })
      .eq('id', v.id)
      .eq('company_id', COMPANY_ID)
      .eq('fiscal_period_id', PERIOD_ID)
      .eq('status', 'ready_for_signature')
      .select('id, version_number')
    if (supersedeError) fail(`failed to supersede årsredovisning version ${v.version_number}: ${supersedeError.message}`)
    if (!updated || updated.length === 0) {
      fail(`årsredovisning version ${v.version_number} changed status during the undo: re-run to re-evaluate`)
    }
    supersededVersions.push(updated[0])
  }
  if (supersededVersions.length > 0) {
    // Close the check-then-act window: a signature recorded between the gate
    // read and the supersede would make the reopen unlawful.
    const { data: recheck, error: recheckError } = await supabase
      .from('arsredovisning_signature_requests')
      .select('id, status, signed_at')
      .eq('company_id', COMPANY_ID)
      .eq('fiscal_period_id', PERIOD_ID)
    if (recheckError) fail(`failed to re-read signature requests: ${recheckError.message}`)
    if ((recheck ?? []).some((r) => r.status === 'signed' || r.signed_at !== null)) {
      fail('a signature was recorded while the undo ran: stop and review before reopening')
    }
    console.log(
      `Superseded årsredovisning version(s) ${supersededVersions.map((v) => v.version_number).join(', ')}; ` +
        `${gate.voidedRequests.length} pending signature request(s) voided`
    )
  }

  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.' +
      (supersededVersions.length > 0
        ? ` Årsredovisning version(s) ${supersededVersions.map((v) => v.version_number).join(', ')} ` +
          `superseded; ${gate.voidedRequests.length} pending signature request(s) voided (unsigned, no legal weight).`
        : ''),
    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,
      superseded_annual_report_version_ids: supersededVersions.map((v) => v.id),
      voided_signature_request_ids: gate.voidedRequests.map((r) => r.id),
    },
  }
  // 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)
})