Ctrl K
ComunidadGuías…

scripts/migration/attach-settlement-vouchers.ts

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env npx tsx
/**
 * Support one-shot: attach the verifikat that paid each ALREADY settled
 * supplier invoice, from a list the customer supplies.
 *
 * WHY: a provider migration brings the general ledger in through SIE and the
 * supplier register through the provider API. An invoice the provider reports
 * as settled lands as 'paid' with no supplier_invoice_payments row, because the
 * provider names neither a payment date nor a payment voucher (Bokio publishes
 * no payments endpoint for supplier invoices at all). The invoice page then
 * shows no paying verifikat, and the kontantmetoden year-end cut-off, which
 * reads the rows and nothing else, counts every such invoice as a
 * leverantörsskuld at year end. The customer is the one who knows which
 * verifikat paid which invoice; this script takes that list and writes the
 * missing rows.
 *
 * EVERY RULE LIVES IN THE DATABASE, in attach_supplier_invoice_settlement_voucher
 * (migration 20260921084700). This script only resolves the customer's numbers
 * to ids (lib/invoices/attach-settlement-voucher.ts) and calls it pair by pair.
 *
 * IDEMPOTENT AND SAFE TO RE-RUN. The one write per pair is a single
 * supplier_invoice_payments row, dated at the verifikat. supplier_invoices is
 * never updated and no journal table is touched. A pair that is already
 * attached reports 'already_linked' and writes nothing. Every row carries the
 * note marker 'settlement-evidence', so a run can be found again:
 *   SELECT * FROM supplier_invoice_payments
 *   WHERE company_id = '<uuid>' AND notes LIKE 'settlement-evidence%';
 * A run that went wrong can be undone by deleting exactly those rows, but ONLY
 * while nothing has relied on them. Once a bokslut or a kontantmetoden cut-off
 * has been posted with the rows in place they are part of what that figure
 * rests on (BFL 5 kap 5 §: a correction may not erase the original without a
 * trace), so they are left alone and the correction goes through the cut-off.
 *
 * Input: a JSON array, one object per pair.
 *   [
 *     { "supplier_invoice_number": "1001", "voucher": "V342", "voucher_date": "2026-05-07" },
 *     { "supplier_invoice_number": "1001", "invoice_date": "2026-06-01",
 *       "voucher": "V17", "voucher_date": "2026-06-20" }
 *   ]
 *   voucher        the verifikat as the OLD system numbered it (series + number)
 *   voucher_date   that verifikat's date: old systems restart numbering every
 *                  fiscal year, so the date is what picks the year
 *   invoice_date   only needed when two invoices share a number
 *
 * Usage:
 *   # Dry run (default): every check runs against the real rules, nothing is written.
 *   npx tsx scripts/migration/attach-settlement-vouchers.ts --company <uuid> --file links.json
 *
 *   # Apply.
 *   npx tsx scripts/migration/attach-settlement-vouchers.ts --company <uuid> --file links.json --apply
 *
 *   # Attribute the rows to a specific user instead of the company's owner,
 *   # and write the full per-pair report to a file.
 *   ... --user <uuid> --out report.json
 *
 * Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from .env.local.
 * Treat .env.local as pointing at PRODUCTION: run the dry run first and read
 * its counts before passing --apply.
 */

import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { config as dotenv } from 'dotenv'
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

function argValue(flag: string): string | null {
  const i = process.argv.indexOf(flag)
  const value = process.argv[i + 1]
  return i >= 0 && value && !value.startsWith('--') ? value : null
}

const USAGE =
  'Usage: npx tsx scripts/migration/attach-settlement-vouchers.ts --company <uuid> --file <links.json> [--user <uuid>] [--out <report.json>] [--apply]'

const COMPANY_ID = argValue('--company')?.trim() ?? null
const FILE = argValue('--file')?.trim() ?? null
const USER_ID = argValue('--user')?.trim() ?? null
const OUT = argValue('--out')?.trim() ?? null
const APPLY = process.argv.includes('--apply')

// Refuse before touching env or the network: a run without an explicit
// company is never what support meant.
if (!COMPANY_ID) {
  console.error('--company <uuid> is required: this script never runs across companies.')
  console.error(USAGE)
  process.exit(1)
}
if (!UUID_RE.test(COMPANY_ID)) {
  console.error(`--company must be a uuid, got: ${COMPANY_ID}`)
  process.exit(1)
}
if (USER_ID && !UUID_RE.test(USER_ID)) {
  console.error(`--user must be a uuid, got: ${USER_ID}`)
  process.exit(1)
}
if (!FILE) {
  console.error('--file <links.json> is required.')
  console.error(USAGE)
  process.exit(1)
}

let links: unknown
try {
  links = JSON.parse(readFileSync(resolve(process.cwd(), FILE), 'utf8'))
} catch (error) {
  console.error(`Could not read ${FILE} as JSON: ${error instanceof Error ? error.message : error}`)
  process.exit(1)
}
if (!Array.isArray(links) || links.length === 0) {
  console.error(`${FILE} must hold a non-empty JSON array of pairs.`)
  process.exit(1)
}

dotenv({ path: resolve(process.cwd(), '.env.local') })

const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
  console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
  process.exit(1)
}

const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
  auth: { persistSession: false, autoRefreshToken: false },
}) as SupabaseClient

/** The user the rows are attributed to: the one named, or the company's owner. */
async function resolveActingUser(): Promise<string> {
  if (USER_ID) {
    const { data, error } = await supabase
      .from('company_members')
      .select('user_id')
      .eq('company_id', COMPANY_ID)
      .eq('user_id', USER_ID)
      // A writing role, as the RLS insert policy demands of a user session:
      // the row must not be attributed to someone who could never have written it.
      .in('role', ['owner', 'admin', 'member'])
      .limit(1)
    if (error) {
      console.error(`Could not read company_members: ${error.message}`)
      process.exit(1)
    }
    if (!data || data.length === 0) {
      console.error(`User ${USER_ID} is not a writing member (owner, admin or member) of company ${COMPANY_ID}.`)
      process.exit(1)
    }
    return USER_ID
  }
  const { data, error } = await supabase
    .from('company_members')
    .select('user_id, created_at')
    .eq('company_id', COMPANY_ID)
    .eq('role', 'owner')
    .order('created_at', { ascending: true })
    .limit(1)
  if (error) {
    console.error(`Could not read company_members: ${error.message}`)
    process.exit(1)
  }
  const owner = (data ?? [])[0] as { user_id: string } | undefined
  if (!owner) {
    console.error(`Company ${COMPANY_ID} has no owner to attribute the rows to. Pass --user <uuid>.`)
    process.exit(1)
  }
  return owner.user_id
}

async function main() {
  const userId = await resolveActingUser()

  console.log('---------------------------------------------------------')
  console.log('Attach settlement vouchers to settled supplier invoices')
  console.log('---------------------------------------------------------')
  console.log('Supabase URL :', SUPABASE_URL)
  console.log('Company      :', COMPANY_ID)
  console.log('Acting user  :', userId, USER_ID ? '(named)' : '(company owner)')
  console.log('Pairs        :', (links as unknown[]).length, `from ${FILE}`)
  console.log('Mode         :', APPLY ? 'APPLY (writes)' : 'DRY RUN (no writes)')
  console.log('---------------------------------------------------------\n')

  // Imported here, not at the top: static imports are hoisted above the
  // dotenv() call, and app modules capture env into module constants when they
  // are first evaluated (see refresh-supplier-payment-state.ts, first run on
  // 2026-09-14).
  const { attachSettlementVouchersBatch } = await import('../../src/lib/invoices/attach-settlement-voucher')
  const result = await attachSettlementVouchersBatch(supabase, {
    companyId: COMPANY_ID!,
    userId,
    links: links as never,
    dryRun: !APPLY,
    notes: `support run ${new Date().toISOString().slice(0, 10)}`,
  })

  // One machine-readable line, so a run can be pasted into a ticket as-is.
  console.log(JSON.stringify({ companyId: COMPANY_ID, dryRun: result.dryRun, total: result.total, ...result.counts }))
  console.log('')
  console.log(`${APPLY ? 'Attached            ' : 'Would attach        '}: ${APPLY ? result.counts.attached : result.counts.would_attach}`)
  console.log(`Already attached    : ${result.counts.already_linked}`)
  console.log(`Refused by the rules: ${result.counts.refused}`)
  console.log(`Invoice not found   : ${result.counts.invoice_not_found}`)
  console.log(`Invoice ambiguous   : ${result.counts.invoice_ambiguous} (add invoice_date)`)
  console.log(`Verifikat not found : ${result.counts.voucher_not_found}`)
  console.log(`Malformed rows      : ${result.counts.invalid_input}`)
  console.log(`Errors              : ${result.counts.error}`)

  const problems = result.reports.filter(
    (r) => r.outcome !== 'attached' && r.outcome !== 'would_attach' && r.outcome !== 'already_linked',
  )
  if (problems.length > 0) {
    console.log(`\nFirst ${Math.min(problems.length, 25)} of ${problems.length} pair(s) that did not attach:`)
    for (const r of problems.slice(0, 25)) {
      const label = `${r.input?.supplier_invoice_number ?? '?'} -> ${r.input?.voucher ?? '?'}`
      console.log(`  #${r.index} ${label}: ${r.outcome}${r.code ? ` ${r.code}` : ''}${r.reason ? ` (${r.reason})` : ''}`)
    }
  }

  if (OUT) {
    writeFileSync(resolve(process.cwd(), OUT), JSON.stringify(result, null, 2))
    console.log(`\nFull per-pair report written to ${OUT}`)
  }

  console.log('')
  if (!APPLY) {
    console.log('DRY RUN: nothing was written. Re-run with --apply to write these.')
  } else {
    console.log(`Done. ${result.counts.attached} supplier invoice(s) now carry the verifikat that paid them.`)
  }
  if (result.counts.error > 0) process.exit(2)
}

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