Ctrl K
ComunidadGuías…

scripts/heal-twin-cash-accounts.ts

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env npx tsx
/**
 * Merge twin cash_accounts rows: two or more rows for one physical bank
 * account (same IBAN and currency), left behind by consent renewals before
 * #1805. The work happens in healTwinCashAccounts
 * (lib/cash-accounts/heal-twins.ts); this script only lists, confirms and
 * drives it. Posted journal entries are never touched, and a group whose
 * posted lines already sit on two ledgers is reported and left alone.
 *
 * Dry run by default, for every company with twins or one:
 *
 *   npx tsx scripts/heal-twin-cash-accounts.ts --env <file>
 *   npx tsx scripts/heal-twin-cash-accounts.ts --env <file> --company <uuid>
 *
 * A write needs ONE company, the actor to record, and a typed confirmation
 * that repeats the fingerprint of a fresh dry run. The write is bound to that
 * plan: if the twin groups changed in between (a sync, a re-auth), it aborts
 * before the first write.
 *
 *   npx tsx scripts/heal-twin-cash-accounts.ts --env <file> --company <uuid> --actor-user-id <uuid> --operation-id <uuid> --execute
 *
 * Flags:
 *   --env <file>      explicit env file; the banner identifies the target
 *   --company <uuid>  restrict to one company (required with --execute)
 *   --actor-user-id <id>  the person running the merge, recorded as the actor
 *                     on the CashAccountTwinsMerged behandlingshistorik event
 *   --execute         write; without it nothing is changed
 *   --operation-id <uuid> stable ID required for execution; reuse on a retry
 *   --verify-operation <uuid> read the committed receipt and current plan
 *   --inspect-history   read-only reports for historical started-only events
 *   --started-event <uuid> inspect a specific historical event, with --company
 *
 * Never run by a loop: the founder decides per company.
 */

import { config } from 'dotenv'
import { createInterface } from 'node:readline/promises'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { physicalAccountKey } from '@/lib/cash-accounts/service'
import { getTwinRepairReceipt, healTwinCashAccounts, verifyTwinRepair, reportHistoricalTwinRepairs, type HealTwinsResult, type TwinRepairVerification } from '@/lib/cash-accounts/heal-twins'

function arg(name: string): string | undefined {
  const i = process.argv.indexOf(`--${name}`)
  return i >= 0 ? process.argv[i + 1] : undefined
}

const ENV_FILE = arg('env')
if (!ENV_FILE || ENV_FILE.split(/[\\/]/).at(-1) === '.env.local') {
  console.error('--env must name an explicit repair environment file; .env.local is not a repair target')
  process.exit(1)
}
const loadedEnv = config({ path: ENV_FILE, override: true, quiet: true })
if (loadedEnv.error) {
  console.error(`Cannot read repair environment file: ${ENV_FILE}`)
  process.exit(1)
}

const COMPANY_ID = arg('company') ?? null
const ACTOR_USER_ID = arg('actor-user-id') ?? null
const EXECUTE = process.argv.includes('--execute')
const OPERATION_ID = arg('operation-id') ?? null
const VERIFY_OPERATION = arg('verify-operation') ?? null
const INSPECT_HISTORY = process.argv.includes('--inspect-history')
const STARTED_EVENT = arg('started-event') ?? (process.argv.includes('--started-event') ? '' : null)
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

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_FILE}`)
  process.exit(1)
}
if (COMPANY_ID && !UUID_RE.test(COMPANY_ID)) {
  console.error('--company must be a uuid')
  process.exit(1)
}
if (EXECUTE && !COMPANY_ID) {
  console.error('--execute needs --company <uuid>: the merge is decided one company at a time')
  process.exit(1)
}
if (EXECUTE && (!ACTOR_USER_ID || !UUID_RE.test(ACTOR_USER_ID))) {
  console.error('--execute needs --actor-user-id <uuid> (recorded in behandlingshistorik)')
  process.exit(1)
}
if (EXECUTE && (!OPERATION_ID || !UUID_RE.test(OPERATION_ID))) {
  console.error('--execute needs --operation-id <uuid>; retain this ID for retries and receipt recovery')
  process.exit(1)
}
if (VERIFY_OPERATION && (EXECUTE || !COMPANY_ID || !UUID_RE.test(VERIFY_OPERATION))) {
  console.error('--verify-operation needs --company and a valid operation UUID; it is read-only')
  process.exit(1)
}

if ((INSPECT_HISTORY && (EXECUTE || VERIFY_OPERATION)) ||
    (STARTED_EVENT !== null && (!INSPECT_HISTORY || !COMPANY_ID || !UUID_RE.test(STARTED_EVENT)))) {
  console.error('--inspect-history is read-only; --started-event requires --inspect-history, --company and a valid UUID')
  process.exit(1)
}

const supabase: SupabaseClient = createClient(supabaseUrl, serviceRoleKey, {
  auth: { persistSession: false, autoRefreshToken: false },
})

/** Companies holding at least one twin group. */
async function companiesWithTwins(): Promise<string[]> {
  const rows = await fetchAllRows<{ id: string; company_id: string; iban: string | null; currency: string }>(
    ({ from, to }) =>
      supabase
        .from('cash_accounts')
        .select('id, company_id, iban, currency')
        .not('iban', 'is', null)
        .order('id', { ascending: true })
        .range(from, to),
  )
  const seen = new Set<string>()
  const twins = new Set<string>()
  for (const row of rows) {
    const key = physicalAccountKey(row)
    if (!key) continue
    const scoped = `${row.company_id}|${key}`
    if (seen.has(scoped)) twins.add(row.company_id)
    seen.add(scoped)
  }
  return [...twins].sort()
}

function print(result: HealTwinsResult): void {
  console.log(`\nCompany ${result.companyId}: ${result.groups.length} twin group(s), plan ${result.fingerprint}`)
  for (const group of result.groups) {
    // The IBAN is not printed: the report is pasted into tickets.
    const head = `  ledgers ${group.ledgers.join(' + ')} (posted lines on: ${group.postedLedgers.join(', ') || 'none'})`
    if (group.skipped === 'already-merged') {
      console.log(`${head}\n    already merged, nothing to do (keeps ${group.keeper?.ledger_account})`)
      continue
    }
    if (group.skipped) {
      const routed = group.skipped === 'routing-outside-group' ? ` (sync routes to ${group.accountsDataLedgerFrom})` : ''
      console.log(`${head}\n    SKIPPED: ${group.skipped}${routed}`)
      continue
    }
    console.log(`${head}\n    keep ${group.keeper?.ledger_account} (${group.keeper?.id})`)
    if (group.accountsDataLedgerFrom !== null) {
      console.log(`    sync routing ${group.accountsDataLedgerFrom} -> ${group.keeper?.ledger_account}`)
    }
    for (const row of group.retired) {
      console.log(
        `    ${row.ledger_account} (${row.id}): ${row.outcome}, ${row.movable} transaction(s) move, ${row.staying} stay`,
      )
    }
  }
}

function printVerification(result: TwinRepairVerification): void {
  console.log(`Current verification: ${result.status} (${result.verifiedAt})`)
  console.log(`Checked ${result.cashAccountsChecked ?? 0} cash accounts, ${result.transactionsChecked ?? 0} transactions and ${result.journalsChecked ?? 0} journals.`)
  for (const issue of result.issues) console.log(`  ${issue.kind}: ${issue.id}`)
  for (const issue of result.routingIssues) console.log(`  routing ${issue.kind}: connection ${issue.connectionId}, cash account ${issue.cashAccountId ?? 'missing'}`)
  if (result.status === 'insufficient-evidence') {
    console.log('This historical event lacks the state evidence required for complete verification. It needs a separate recovery review.')
  }
  if (result.status !== 'consistent') process.exitCode = 3
}

async function main(): Promise<void> {
  console.log(`Target: ${supabaseUrl} (${ENV_FILE})`)
  console.log(EXECUTE ? 'Mode: EXECUTE' : 'Mode: dry run, nothing is written')

  if (INSPECT_HISTORY) {
    const reports = await reportHistoricalTwinRepairs(supabase, COMPANY_ID, STARTED_EVENT)
    console.log(JSON.stringify(reports, null, 2))
    console.log(`${reports.length} historical event(s) inspected. This reports current consistency, not proof of an original commit.`)
    console.log('No history was appended. Review any recovery outcome before recording it; partial states need a specific recovery plan.')
    if (reports.some(report => report.classification !== 'consistent-with-completion')) process.exitCode = 3
    return
  }

  if (VERIFY_OPERATION && COMPANY_ID) {
    printVerification(await verifyTwinRepair(supabase, COMPANY_ID, VERIFY_OPERATION))
    return
  }

  const recoveryId = EXECUTE ? OPERATION_ID : null
  if (recoveryId && COMPANY_ID) {
    const receipt = await getTwinRepairReceipt(supabase, COMPANY_ID, recoveryId)
    if (receipt) {
      console.log(`Committed receipt for operation ${recoveryId}:`)
      print(receipt)
      printVerification(await verifyTwinRepair(supabase, COMPANY_ID, recoveryId))
      return
    }
  }

  const companyIds = COMPANY_ID ? [COMPANY_ID] : await companiesWithTwins()
  let healable = 0
  let fingerprint = ''
  for (const companyId of companyIds) {
    const result = await healTwinCashAccounts(supabase, companyId, { dryRun: true })
    print(result)
    fingerprint = result.fingerprint
    healable += result.groups.filter((g) => !g.skipped).length
  }
  console.log(`\n${companyIds.length} company(ies), ${healable} group(s) would be merged.`)
  if (!EXECUTE || !COMPANY_ID || !ACTOR_USER_ID || !OPERATION_ID) return
  if (healable === 0) {
    console.log('Nothing to merge.')
    return
  }

  const rl = createInterface({ input: process.stdin, output: process.stdout })
  console.log(`Operation: ${OPERATION_ID}. Retain this ID if the response is lost.`)
  try {
    const answer = await rl.question(`\nType "MERGE ${fingerprint}" to merge these ${healable} group(s): `)
    if (answer.trim() !== `MERGE ${fingerprint}`) {
      console.log('Aborted, nothing written.')
      process.exit(2)
    }
  } finally {
    rl.close()
  }

  print(
    await healTwinCashAccounts(supabase, COMPANY_ID, {
      dryRun: false,
      expectedFingerprint: fingerprint,
      operationId: OPERATION_ID,
      actor: { type: 'user', id: ACTOR_USER_ID, label: 'heal-twin-cash-accounts script' },
    }),
  )
  console.log('\nDone. After-state (dry run):')
  print(await healTwinCashAccounts(supabase, COMPANY_ID, { dryRun: true }))
  printVerification(await verifyTwinRepair(supabase, COMPANY_ID, OPERATION_ID))
}

main().catch((error) => {
  console.error(error instanceof Error ? error.message : error)
  process.exit(1)
})