#!/usr/bin/env npx tsx /** * Support one-shot: refresh the payment state of migrated supplier invoices * from the provider that supplied them. * * WHY: a migration can only write what its mapper read. The Bokio * supplier-invoice mapper read `status` and `paidAmount`, neither of which * exists on Bokio's `supplierInvoiceGet` schema, so every imported invoice * landed as "Registrerad" with its whole total outstanding (365 invoices for * the company that reported it on 2026-09-14, 417 for a second Bokio * company). The mapper now reads `remainingAmount`, the payment field Bokio * does publish, but the rows already written stay wrong until something asks * the provider again. The in-app route (POST /reconcile) does this for a * member of the company; this script does the same for support, who is not a * member of the customer's company, using the service-role key. * * IDEMPOTENT AND SAFE TO RE-RUN. It never inserts and never deletes. It reads * the provider's own `remainingAmount` and writes only `status`, * `paid_amount`, `remaining_amount` and `paid_at`, only on supplier invoices * of the given company that carry no registration voucher, no payment * voucher, no payment (`paid_amount = 0`), are not credit notes and still * stand as registered/approved/overdue. A second run finds those rows already * settled, so they are no longer candidates and nothing is written. Anything * the provider still reports as open is left exactly as it is: re-running * cannot drift the reskontra. * * It does NOT correct a row that is already linked to a verifikat. If such a * row is ever wrong, BFL 5 kap 5 ยง applies and the correction goes through * storno (reverseEntry / correctEntry), case by case, never through a sweep. * * Usage: * # Dry run (default): reports what it would write, writes nothing. * npx tsx scripts/migration/refresh-supplier-payment-state.ts --company * * # Apply. * npx tsx scripts/migration/refresh-supplier-payment-state.ts --company --apply * * # Use a specific consent instead of the company's latest accepted one. * npx tsx scripts/migration/refresh-supplier-payment-state.ts --company --consent * * 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 { resolve } from 'node:path' /** * Providers whose supplier register `fetchSupplierInvoicesDirect` can read * (lib/providers/provider-data-fetcher.ts). WINT is deliberately absent: its * incoming-invoice endpoint is Full-spec only and the fetcher returns an * empty list, which would report a silent "0 matched" run rather than a * refusal. */ const SUPPORTED_PROVIDERS = ['fortnox', 'visma', 'briox', 'bokio', 'bjornlunden'] as const 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/refresh-supplier-payment-state.ts --company [--consent ] [--apply]' const COMPANY_ID = argValue('--company')?.trim() ?? null const CONSENT_ID = argValue('--consent')?.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 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 (CONSENT_ID && !UUID_RE.test(CONSENT_ID)) { console.error(`--consent must be a uuid, got: ${CONSENT_ID}`) 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 interface ConsentRow { id: string provider: string | null name: string | null company_name: string | null created_at: string } /** The consent to ask, either the one named on the command line or the company's latest accepted one. */ async function resolveConsentRow(): Promise { const query = supabase .from('provider_consents') .select('id, provider, name, company_name, created_at') .eq('company_id', COMPANY_ID) const { data, error } = CONSENT_ID ? await query.eq('id', CONSENT_ID).limit(1) // status 1 = Accepted. A consent still at 0 (token submitted, migration // pending) or revoked/inactive is not something to sweep a reskontra with. : await query.eq('status', 1).order('created_at', { ascending: false }).limit(1) if (error) { console.error(`Could not read provider_consents: ${error.message}`) process.exit(1) } const row = (data ?? [])[0] as ConsentRow | undefined if (!row) { console.error( CONSENT_ID ? `No provider consent ${CONSENT_ID} on company ${COMPANY_ID}.` : `No accepted provider consent (status 1) on company ${COMPANY_ID}. Nothing to ask.`, ) process.exit(1) } if (!row.provider || !(SUPPORTED_PROVIDERS as readonly string[]).includes(row.provider)) { console.error( `Consent ${row.id} has provider "${row.provider ?? 'none'}", which this pass cannot read supplier ` + `invoices from. Supported: ${SUPPORTED_PROVIDERS.join(', ')}.`, ) process.exit(1) } return row } async function main() { const consent = await resolveConsentRow() console.log('---------------------------------------------------------') console.log('Refresh migrated supplier payment state') console.log('---------------------------------------------------------') console.log('Supabase URL :', SUPABASE_URL) console.log('Company :', COMPANY_ID) console.log('Consent :', `${consent.id} (${consent.provider}${consent.company_name ? `, ${consent.company_name}` : ''})`) 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 lib/supabase/server.ts captures NEXT_PUBLIC_SUPABASE_URL // into a module constant when it is first evaluated. Loaded before the env, // that constant is undefined and every service client built from it fails // with "Your project's URL and Key are required" while the script's own // client (built after dotenv) works. First run on 2026-09-14 hit exactly that. const { refreshMigratedSupplierPaymentState } = await import( '../../src/extensions/general/arcim-migration/lib/refresh-migrated-payment-state' ) const result = await refreshMigratedSupplierPaymentState({ supabase, companyId: COMPANY_ID!, consentId: consent.id, dryRun: !APPLY, }) // One machine-readable line, so a run can be pasted into a ticket as-is. console.log(JSON.stringify({ companyId: COMPANY_ID, consentId: consent.id, provider: consent.provider, ...result })) console.log('') console.log(`Provider returned : ${result.providerInvoices} supplier invoice(s)`) console.log(`Matched open rows here : ${result.matched}`) console.log(`Settled at provider : ${result.updated}${APPLY ? ' (written)' : ' (would be written)'}`) console.log(`Still open at provider : ${result.unchanged} (left untouched)`) console.log(`No open row here : ${result.unmatched} (already booked, paid, never imported, or ambiguous join)`) console.log('') if (!APPLY) { console.log('DRY RUN: nothing was written. Re-run with --apply to write these.') } else if (result.updated === 0) { console.log('Nothing to write: every matched invoice is still open at the provider.') } else { console.log(`Done. ${result.updated} supplier invoice(s) now carry the provider's payment state.`) } } main().catch((error) => { console.error('Failed:', error instanceof Error ? error.message : error) process.exit(1) })