a2f7132c94
* fix(year-end): conservative historical repair for carried-forward 2099 The steady-state year-end flow already reclassifies the opening 2099 (Arets resultat) to 2098 (Foregaende ars resultat) right after the opening balance is generated. Periods opened before that fix still carry the prior year's result on 2099. Add lib/core/bookkeeping/result-appropriation-repair.ts: a pure classifier plus assess/post helpers that auto-post the 2099 -> 2098 transfer only when it is unambiguous (open unlocked aktiebolag period, posted explicit opening_balance entry, active 2099/2098 accounts, no posted result_appropriation yet, current posted 2099 still equal to the explicit opening amount, and no other entry touching 2099). Everything else is skipped or listed for manual review; nothing is reconstructed from cumulative history. All writes go through the bookkeeping engine. Rework scripts/repair-result-appropriation.ts into a thin CLI over the library: global/company/period dry-runs, and commit mode that requires one exact --company-id, --period-id, and --user-id and re-assesses immediately before posting. Fixes #735 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(year-end): require company membership for repair attribution Compliance review (ASVS V8.2.1): commit mode accepted any --user-id and attributed the posted journal entry to it unvalidated. The service-role client bypasses RLS, so nothing downstream would catch an outsider uuid. postHistoricalResultRepair now verifies a company_members row for the target company before posting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(year-end): reference the opening-balance underlag on the repair verifikat Swedish compliance review (BFL 5 kap 6-7 §§): the historical repair entry validated against a specific opening-balance entry but never recorded it. Link it machine-readably via source_id and human-readably in the entry note ("Underlag: ingående balans, verifikat A1 (<id>)"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(year-end): harden repair CLI arg parsing, pagination and exit code CodeRabbit review on #1373: - arg() rejects flag-shaped or missing values instead of silently consuming the next flag as an id - global company and period scans paginate via fetchAllRows() so deployments past the PostgREST 1000-row cap are fully covered - exit code is non-zero when any period failed to list, assess or post Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
277 lines
9.4 KiB
TypeScript
277 lines
9.4 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Conservative historical catch-up for the year-open result transfer
|
|
* (2099 -> 2098).
|
|
*
|
|
* Normal year-end closing already posts this transfer immediately after it
|
|
* creates the next period's opening balance. This script is only for periods
|
|
* created before that behavior existed.
|
|
*
|
|
* Historical data cannot be repaired from the frozen opening balance alone.
|
|
* A user or SIE import may already have disposed of 2099, and replaying the
|
|
* opening amount would then move equity twice. The script therefore requires:
|
|
*
|
|
* - an open and unlocked aktiebolag period,
|
|
* - an explicit, posted opening_balance entry,
|
|
* - active 2099 and 2098 accounts,
|
|
* - no existing posted result_appropriation entry,
|
|
* - current posted 2099 equal to the explicit opening 2099, and
|
|
* - no other entry touching 2099 in the period.
|
|
*
|
|
* Everything else is skipped or printed for manual review. Periods without an
|
|
* explicit opening-balance entry are never reconstructed from cumulative
|
|
* history. All writes use the bookkeeping engine and commit mode re-assesses
|
|
* the period immediately before posting. The --user-id the entry is
|
|
* attributed to must be a member of the company.
|
|
*
|
|
* Usage:
|
|
* # Preview every company. Read-only.
|
|
* npx tsx scripts/repair-result-appropriation.ts
|
|
*
|
|
* # Preview one company or one exact period.
|
|
* npx tsx scripts/repair-result-appropriation.ts --company-id <uuid>
|
|
* npx tsx scripts/repair-result-appropriation.ts --company-id <uuid> --period-id <uuid>
|
|
*
|
|
* # Apply one reviewed period only.
|
|
* npx tsx scripts/repair-result-appropriation.ts --commit \
|
|
* --company-id <uuid> --period-id <uuid> --user-id <uuid>
|
|
*
|
|
* Run a reviewed dry-run against staging first. Production writes require
|
|
* explicit approval for the exact company, period, amount, and attribution.
|
|
*/
|
|
|
|
import { config } from 'dotenv'
|
|
config({ path: '.env.local' })
|
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchAllRows } from '../lib/supabase/fetch-all'
|
|
import {
|
|
assessHistoricalResultRepair,
|
|
getHistoricalResultRepairScopeError,
|
|
postHistoricalResultRepair,
|
|
type HistoricalResultRepairAssessment,
|
|
type HistoricalResultRepairReason,
|
|
} from '../lib/core/bookkeeping/result-appropriation-repair'
|
|
|
|
function arg(name: string): string | undefined {
|
|
const index = process.argv.indexOf(`--${name}`)
|
|
if (index < 0) return undefined
|
|
const value = process.argv[index + 1]
|
|
if (value === undefined || value.startsWith('--')) {
|
|
console.error(`--${name} requires a value`)
|
|
process.exit(1)
|
|
}
|
|
return value
|
|
}
|
|
|
|
const ONLY_COMPANY_ID = arg('company-id')
|
|
const ONLY_PERIOD_ID = arg('period-id')
|
|
const USER_ID = arg('user-id')
|
|
const COMMIT = process.argv.includes('--commit')
|
|
|
|
const scopeError = getHistoricalResultRepairScopeError({
|
|
commit: COMMIT,
|
|
companyId: ONLY_COMPANY_ID,
|
|
periodId: ONLY_PERIOD_ID,
|
|
userId: USER_ID,
|
|
})
|
|
if (scopeError) {
|
|
console.error(scopeError)
|
|
process.exit(1)
|
|
}
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
|
process.exit(1)
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, serviceRoleKey) as SupabaseClient
|
|
|
|
const REASON_LABELS: Record<HistoricalResultRepairReason, string> = {
|
|
ready: 'safe to repair',
|
|
non_aktiebolag: 'not an aktiebolag',
|
|
period_closed: 'period is closed',
|
|
period_locked: 'period is locked',
|
|
already_corrected: 'posted result appropriation already exists',
|
|
missing_explicit_opening_balance: 'no explicit opening-balance entry',
|
|
invalid_opening_balance_entry: 'opening-balance pointer is not a posted opening_balance entry',
|
|
missing_required_accounts: 'active 2099 and 2098 accounts are required',
|
|
no_result_to_move: 'opening and current 2099 are zero',
|
|
already_disposed: 'current 2099 is zero, so the result has already been disposed',
|
|
current_balance_differs: 'current 2099 differs from the explicit opening amount',
|
|
intervening_2099_activity: 'another entry touched 2099 in the period',
|
|
}
|
|
|
|
console.log('---------------------------------------------------------')
|
|
console.log('Historical Result Appropriation Repair (2099 -> 2098)')
|
|
console.log('---------------------------------------------------------')
|
|
console.log('Supabase URL :', supabaseUrl)
|
|
console.log('Company scope:', ONLY_COMPANY_ID ?? 'ALL companies')
|
|
console.log('Period scope :', ONLY_PERIOD_ID ?? 'all open periods')
|
|
console.log('Attribution :', USER_ID ?? 'not needed for dry-run')
|
|
console.log('Mode :', COMMIT ? 'COMMIT (writes one reviewed period)' : 'DRY RUN (no writes)')
|
|
console.log('---------------------------------------------------------\n')
|
|
|
|
async function listCompanyIds(): Promise<string[]> {
|
|
if (ONLY_COMPANY_ID) return [ONLY_COMPANY_ID]
|
|
|
|
const rows = await fetchAllRows<{ id: string }>(({ from, to }) =>
|
|
supabase
|
|
.from('companies')
|
|
.select('id')
|
|
.order('created_at', { ascending: true })
|
|
.order('id', { ascending: true })
|
|
.range(from, to),
|
|
)
|
|
return rows.map((company) => company.id)
|
|
}
|
|
|
|
async function listOpenPeriods(
|
|
companyId: string,
|
|
): Promise<Array<{ id: string; name: string }>> {
|
|
const rows = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => {
|
|
let query = supabase
|
|
.from('fiscal_periods')
|
|
.select('id, name')
|
|
.eq('company_id', companyId)
|
|
.eq('is_closed', false)
|
|
.is('locked_at', null)
|
|
|
|
if (ONLY_PERIOD_ID) query = query.eq('id', ONLY_PERIOD_ID)
|
|
|
|
return query.order('period_start', { ascending: true }).range(from, to)
|
|
})
|
|
return rows
|
|
}
|
|
|
|
function describeAssessment(assessment: HistoricalResultRepairAssessment): string {
|
|
const amounts =
|
|
`opening 2099=${assessment.openingNet}, current 2099=${assessment.currentNet}, ` +
|
|
`other 2099 entries=${assessment.nonOpeningActivityEntries}`
|
|
return `${REASON_LABELS[assessment.reason]} (${amounts})`
|
|
}
|
|
|
|
async function main() {
|
|
let companiesScanned = 0
|
|
let periodsScanned = 0
|
|
let safe = 0
|
|
let manualReview = 0
|
|
let skipped = 0
|
|
let posted = 0
|
|
let revalidationBlocked = 0
|
|
let failed = 0
|
|
|
|
const companyIds = await listCompanyIds()
|
|
console.log(`Scanning ${companyIds.length} company(ies)...\n`)
|
|
|
|
for (const companyId of companyIds) {
|
|
companiesScanned++
|
|
let periods: Array<{ id: string; name: string }>
|
|
try {
|
|
periods = await listOpenPeriods(companyId)
|
|
} catch (error) {
|
|
console.error(
|
|
` ${companyId}: FAILED to list periods:`,
|
|
error instanceof Error ? error.message : error,
|
|
)
|
|
failed++
|
|
continue
|
|
}
|
|
|
|
for (const period of periods) {
|
|
periodsScanned++
|
|
let assessment: HistoricalResultRepairAssessment
|
|
try {
|
|
assessment = await assessHistoricalResultRepair(supabase, companyId, period.id)
|
|
} catch (error) {
|
|
console.error(
|
|
` ${companyId} / ${period.name}: FAILED to assess:`,
|
|
error instanceof Error ? error.message : error,
|
|
)
|
|
failed++
|
|
continue
|
|
}
|
|
|
|
if (assessment.status !== 'safe') {
|
|
if (assessment.status === 'skipped') {
|
|
skipped++
|
|
continue
|
|
}
|
|
manualReview++
|
|
console.warn(
|
|
` REVIEW ${companyId} / ${period.name} (${period.id}): ${describeAssessment(assessment)}`,
|
|
)
|
|
continue
|
|
}
|
|
|
|
safe++
|
|
console.log(
|
|
` SAFE ${companyId} / ${period.name} (${period.id}): ` +
|
|
`${assessment.plan.direction} ${assessment.plan.amount} kr, ` +
|
|
assessment.plan.lines
|
|
.map((line) =>
|
|
`${line.account_number} ${line.debit_amount ? `D ${line.debit_amount}` : `K ${line.credit_amount}`}`,
|
|
)
|
|
.join(' / '),
|
|
)
|
|
|
|
if (!COMMIT) continue
|
|
|
|
try {
|
|
const result = await postHistoricalResultRepair(
|
|
supabase,
|
|
companyId,
|
|
USER_ID!,
|
|
period.id,
|
|
)
|
|
if (!result.entry) {
|
|
revalidationBlocked++
|
|
console.warn(
|
|
` NOT POSTED after revalidation: ${describeAssessment(result.assessment)}`,
|
|
)
|
|
continue
|
|
}
|
|
|
|
posted++
|
|
console.log(
|
|
` posted ${result.entry.voucher_series}${result.entry.voucher_number} (${result.entry.id})`,
|
|
)
|
|
} catch (error) {
|
|
console.error(
|
|
` ${companyId} / ${period.name}: FAILED to post:`,
|
|
error instanceof Error ? error.message : error,
|
|
)
|
|
failed++
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('\n---------------------------------------------------------')
|
|
console.log('Summary')
|
|
console.log('---------------------------------------------------------')
|
|
console.log(`Companies scanned : ${companiesScanned}`)
|
|
console.log(`Periods scanned : ${periodsScanned}`)
|
|
console.log(`Safe candidates : ${safe}`)
|
|
console.log(`Manual review : ${manualReview}`)
|
|
console.log(`Skipped : ${skipped}`)
|
|
console.log(`Posted : ${posted}`)
|
|
console.log(`Blocked on recheck : ${revalidationBlocked}`)
|
|
console.log(`Failed : ${failed}`)
|
|
console.log(`Mode : ${COMMIT ? 'COMMIT' : 'DRY RUN'}`)
|
|
if (!COMMIT) {
|
|
console.log(
|
|
'\nCommit mode requires one reviewed --company-id, --period-id, and --user-id.',
|
|
)
|
|
}
|
|
if (failed > 0) {
|
|
process.exitCode = 1
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('\nFATAL:', error instanceof Error ? error.message : error)
|
|
process.exit(1)
|
|
})
|