4e47335308
* 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>
445 lines
18 KiB
TypeScript
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)
|
|
})
|