Å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.
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)
|
|
})
|