From bc09cea07e24fa9732f00e8dd80acb88928d35f0 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:42:57 +0200 Subject: [PATCH] chore(scripts): add prod repair scripts for Arcim and Capelix incidents (#773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(scripts): add prod repair scripts for Arcim and Capelix incidents Two idempotent, dry-run-by-default repair scripts, committed for the audit trail (matching the existing scripts/repair-*.ts convention). Neither runs automatically — applying requires an explicit --execute/--commit flag. - repair-arcim-supplier-payments.ts: Arcim Technology AB (2026-06-11). Two supplier invoices left in inconsistent half-states (swallowed AccountsNotInChartError on 3740; bank-sync auto-link without a booked payment) plus expense booked on 5010 instead of 5420/6580. Runs through the real engine (createJournalEntry/correctEntry) so voucher numbering and balance triggers behave as in-app; every step checks its precondition. - repair-capelix-invoice-payment.ts: Capelix AB invoice-001 double-booking (2026-05-29), root-caused to the invoiceAlreadyBooked dead-column read fixed in PR #713. Storno-only per BFL/BFNAR 2013:2: reverse the wrong cash entry, post the correct 1930/1510 clearing entry, relink the bank tx + payment row. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(scripts): scope Capelix invoice_payments relink to company_id Address review (PR Agent + compliance swarm): the Step 3b invoice_payments update filtered on journal_entry_id only; add .eq('company_id', COMPANY_ID) to match the sibling transactions update directly above it (tenant isolation / defense-in-depth). invoice_payments carries company_id (multi-tenant refactor). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/repair-arcim-supplier-payments.ts | 290 ++++++++++++++++++++++ scripts/repair-capelix-invoice-payment.ts | 208 ++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 scripts/repair-arcim-supplier-payments.ts create mode 100644 scripts/repair-capelix-invoice-payment.ts diff --git a/scripts/repair-arcim-supplier-payments.ts b/scripts/repair-arcim-supplier-payments.ts new file mode 100644 index 00000000..841fbf18 --- /dev/null +++ b/scripts/repair-arcim-supplier-payments.ts @@ -0,0 +1,290 @@ +/** + * One-off prod repair for Arcim Technology AB (2026-06-11). + * + * Context: two supplier invoices were left in inconsistent half-states: + * - 20250928 (TIC): match-supplier-invoice marked it paid but the payment + * voucher failed (AccountsNotInChartError: 3740 missing) and the failure + * was swallowed. No payment JE, payments row + tx unlinked to any JE. + * - 18299 (RosholmDell): bank sync auto-linked transactions.supplier_invoice_id + * at high confidence without booking a payment; invoice stuck 'registered'. + * - Both registration vouchers (A64/A65) booked the expense on 5010 (form + * default) instead of 5420 / 6580. + * + * Runs through the real engine (createJournalEntry / correctEntry) so voucher + * numbering, balance triggers, and correction links behave exactly as in-app. + * Idempotent: every step checks its precondition and skips if already done. + * + * Usage: npx tsx scripts/repair-arcim-supplier-payments.ts [--execute] + * Without --execute it only prints the plan and preconditions (dry run). + */ +import fs from 'fs' +import path from 'path' +import { createClient } from '@supabase/supabase-js' +import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { correctEntry } from '@/lib/core/bookkeeping/storno-service' + +const COMPANY_ID = 'ed461bc1-dbb5-4568-ae20-9337515878e2' +const USER_ID = '9762dd12-7009-4ba2-aa9f-f9966d53e077' + +const TIC_INVOICE_ID = '580b3c81-ced8-4a5a-8757-97ddef7919d5' +const TIC_PAYMENT_ROW_ID = 'bcfc57d3-2a8a-47cc-8b70-a0650089d9d1' +const TIC_TX_ID = '8e608944-a82c-4a1b-8807-bb7d2d6092b6' +const TIC_REGISTRATION_JE = '667db3a5-c388-42eb-a05f-0b45f26fb3db' // A64 + +const RD_INVOICE_ID = 'bbb2ecd4-e373-4b1e-9908-927d9c906fbc' +const RD_TX_ID = 'b5339acc-d47c-406a-8f3d-d1d9b4d82f93' +const RD_REGISTRATION_JE = 'f1a06d39-331b-4eb5-92cb-5f47d5e52881' // A65 + +const PAYMENT_DATE = '2026-06-08' + +// BAS 2026 metadata, copied verbatim from lib/bookkeeping/bas-data/ +const MISSING_ACCOUNTS = [ + { + account_number: '3740', + account_name: 'Öres- och kronutjämning', + account_class: 3, + account_group: '37', + account_type: 'revenue', + normal_balance: 'debit', + description: 'Öresskillnad som uppstår vid avrundning av betalningar (öret).', + sru_code: '7310', + k2_excluded: false, + }, + { + account_number: '6580', + account_name: 'Advokat- och rättegångskostnader', + account_class: 6, + account_group: '65', + account_type: 'expense', + normal_balance: 'debit', + description: 'Advokat- och rättegångskostnader', + sru_code: '7321', + k2_excluded: false, + }, +] + +function loadEnv(): { url: string; key: string } { + const envPath = path.resolve(process.cwd(), '.env.local') + const lines = fs.readFileSync(envPath, 'utf8').split('\n') + const vars: Record = {} + for (const line of lines) { + const m = line.match(/^([A-Z0-9_]+)=(.*)$/) + if (m) vars[m[1]] = m[2].trim() + } + const url = vars.NEXT_PUBLIC_SUPABASE_URL + const key = vars.SUPABASE_SERVICE_ROLE_KEY + if (!url || !key) throw new Error('Missing Supabase env in .env.local') + if (!url.includes('pwxtzglxptnnvjrpixpg')) { + throw new Error(`Refusing to run against unexpected project: ${url}`) + } + return { url, key } +} + +const EXECUTE = process.argv.includes('--execute') + +async function main() { + const { url, key } = loadEnv() + const supabase = createClient(url, key, { auth: { persistSession: false } }) + + const mode = EXECUTE ? 'EXECUTE' : 'DRY RUN' + console.log(`=== Arcim supplier payment repair — ${mode} ===\n`) + + // ---------- Step 0: verify preconditions ---------- + const { data: tic } = await supabase + .from('supplier_invoices') + .select('status, paid_amount, remaining_amount, payment_journal_entry_id') + .eq('id', TIC_INVOICE_ID).eq('company_id', COMPANY_ID).single() + const { data: rd } = await supabase + .from('supplier_invoices') + .select('status, paid_amount, remaining_amount, payment_journal_entry_id') + .eq('id', RD_INVOICE_ID).eq('company_id', COMPANY_ID).single() + if (!tic || !rd) throw new Error('Could not load invoices') + console.log('TIC invoice:', tic) + console.log('RD invoice :', rd) + + const fiscalPeriodId = await findFiscalPeriod(supabase, COMPANY_ID, PAYMENT_DATE) + if (!fiscalPeriodId) throw new Error(`No fiscal period for ${PAYMENT_DATE}`) + console.log('Fiscal period:', fiscalPeriodId, '\n') + + // ---------- Step 1: ensure 3740 + 6580 exist ---------- + for (const acc of MISSING_ACCOUNTS) { + const { data: existing } = await supabase + .from('chart_of_accounts') + .select('id') + .eq('company_id', COMPANY_ID) + .eq('account_number', acc.account_number) + .maybeSingle() + if (existing) { + console.log(`[skip] account ${acc.account_number} already in chart`) + continue + } + console.log(`[plan] add account ${acc.account_number} ${acc.account_name}`) + if (EXECUTE) { + const { error } = await supabase.from('chart_of_accounts').insert({ + ...acc, + company_id: COMPANY_ID, + user_id: USER_ID, + is_active: true, + }) + if (error) throw new Error(`Insert ${acc.account_number} failed: ${error.message}`) + console.log(`[done] account ${acc.account_number} added`) + } + } + + // ---------- Step 2: TIC payment voucher + link backfill ---------- + if (tic.payment_journal_entry_id) { + console.log('[skip] TIC already has payment_journal_entry_id') + } else { + console.log('[plan] TIC payment voucher: D 2440 11231.25 / K 1930 11231.00 / K 3740 0.25') + if (EXECUTE) { + const je = await createJournalEntry(supabase, COMPANY_ID, USER_ID, { + fiscal_period_id: fiscalPeriodId, + entry_date: PAYMENT_DATE, + description: + 'Utbetalning leverantörsfaktura 20250928, The Intelligence Company AB (publ)', + source_type: 'supplier_invoice_paid', + source_id: TIC_INVOICE_ID, + lines: [ + { account_number: '2440', debit_amount: 11231.25, credit_amount: 0, line_description: 'Kvittning leverantörsskuld' }, + { account_number: '1930', debit_amount: 0, credit_amount: 11231, line_description: 'Utbetalning från bank' }, + { account_number: '3740', debit_amount: 0, credit_amount: 0.25, line_description: 'Öresavrundning' }, + ], + }) + console.log(`[done] TIC payment voucher ${je.voucher_series}-${je.voucher_number} (${je.id})`) + + const upd1 = await supabase.from('supplier_invoices') + .update({ payment_journal_entry_id: je.id, transaction_id: TIC_TX_ID }) + .eq('id', TIC_INVOICE_ID).eq('company_id', COMPANY_ID) + if (upd1.error) throw new Error(`TIC invoice backfill failed: ${upd1.error.message}`) + + const upd2 = await supabase.from('supplier_invoice_payments') + .update({ journal_entry_id: je.id }) + .eq('id', TIC_PAYMENT_ROW_ID).eq('company_id', COMPANY_ID) + if (upd2.error) throw new Error(`TIC payment row backfill failed: ${upd2.error.message}`) + + const upd3 = await supabase.from('transactions') + .update({ journal_entry_id: je.id }) + .eq('id', TIC_TX_ID).eq('company_id', COMPANY_ID) + if (upd3.error) throw new Error(`TIC tx backfill failed: ${upd3.error.message}`) + console.log('[done] TIC links backfilled (invoice, payment row, transaction)') + } + } + + // ---------- Step 3: RosholmDell payment voucher + full settle ---------- + if (rd.status === 'paid') { + console.log('[skip] RD invoice already paid') + } else { + console.log('[plan] RD payment voucher: D 2440 29890 / K 1930 29890; settle invoice') + if (EXECUTE) { + const je = await createJournalEntry(supabase, COMPANY_ID, USER_ID, { + fiscal_period_id: fiscalPeriodId, + entry_date: PAYMENT_DATE, + description: 'Utbetalning leverantörsfaktura 18299, RosholmDell Advokatbyrå AB', + source_type: 'supplier_invoice_paid', + source_id: RD_INVOICE_ID, + lines: [ + { account_number: '2440', debit_amount: 29890, credit_amount: 0, line_description: 'Kvittning leverantörsskuld' }, + { account_number: '1930', debit_amount: 0, credit_amount: 29890, line_description: 'Utbetalning från bank' }, + ], + }) + console.log(`[done] RD payment voucher ${je.voucher_series}-${je.voucher_number} (${je.id})`) + + const upd1 = await supabase.from('supplier_invoices') + .update({ + status: 'paid', + paid_amount: 29890, + remaining_amount: 0, + paid_at: new Date().toISOString(), + payment_journal_entry_id: je.id, + transaction_id: RD_TX_ID, + }) + .eq('id', RD_INVOICE_ID).eq('company_id', COMPANY_ID).eq('status', 'registered') + .select('id') + if (upd1.error || !upd1.data?.length) { + throw new Error(`RD invoice settle failed: ${upd1.error?.message ?? 'status changed concurrently'}`) + } + + const ins = await supabase.from('supplier_invoice_payments').insert({ + user_id: USER_ID, + company_id: COMPANY_ID, + supplier_invoice_id: RD_INVOICE_ID, + payment_date: PAYMENT_DATE, + amount: 29890, + currency: 'SEK', + exchange_rate_difference: 0, + journal_entry_id: je.id, + transaction_id: RD_TX_ID, + }) + if (ins.error) throw new Error(`RD payment row insert failed: ${ins.error.message}`) + + const upd2 = await supabase.from('transactions') + .update({ journal_entry_id: je.id, is_business: true }) + .eq('id', RD_TX_ID).eq('company_id', COMPANY_ID) + if (upd2.error) throw new Error(`RD tx backfill failed: ${upd2.error.message}`) + console.log('[done] RD invoice settled + links backfilled') + } + } + + // ---------- Step 4: corrections A64 (5010→5420) and A65 (5010→6580) ---------- + const corrections = [ + { + label: 'A64 (TIC): 5010 → 5420 Programvaror', + entryId: TIC_REGISTRATION_JE, + lines: [ + { account_number: '5420', debit_amount: 8985, credit_amount: 0, line_description: 'Leverantörsfaktura 20250928, The Intelligence Company AB (publ) (ankomst 1)' }, + { account_number: '2641', debit_amount: 2246.25, credit_amount: 0, line_description: 'Ingående moms 25% Leverantörsfaktura 20250928, The Intelligence Company AB (publ) (ankomst 1)' }, + { account_number: '2440', debit_amount: 0, credit_amount: 11231.25, line_description: 'Leverantörsfaktura 20250928, The Intelligence Company AB (publ) (ankomst 1)' }, + ], + }, + { + label: 'A65 (RosholmDell): 5010 → 6580 Advokat- och rättegångskostnader', + entryId: RD_REGISTRATION_JE, + lines: [ + { account_number: '6580', debit_amount: 23912, credit_amount: 0, line_description: 'Leverantörsfaktura 18299, RosholmDell Advokatbyrå AB (ankomst 2)' }, + { account_number: '2641', debit_amount: 5978, credit_amount: 0, line_description: 'Ingående moms 25% Leverantörsfaktura 18299, RosholmDell Advokatbyrå AB (ankomst 2)' }, + { account_number: '2440', debit_amount: 0, credit_amount: 29890, line_description: 'Leverantörsfaktura 18299, RosholmDell Advokatbyrå AB (ankomst 2)' }, + ], + }, + ] + + for (const c of corrections) { + const { data: orig } = await supabase + .from('journal_entries') + .select('status') + .eq('id', c.entryId).eq('company_id', COMPANY_ID).single() + if (!orig) throw new Error(`Original entry not found for ${c.label}`) + if (orig.status !== 'posted') { + console.log(`[skip] ${c.label} — original status is '${orig.status}' (already corrected?)`) + continue + } + console.log(`[plan] correct ${c.label}`) + if (EXECUTE) { + const { reversal, corrected } = await correctEntry( + supabase, COMPANY_ID, USER_ID, c.entryId, c.lines, + ) + console.log( + `[done] ${c.label}: storno ${reversal.voucher_series}-${reversal.voucher_number}, ` + + `corrected ${corrected.voucher_series}-${corrected.voucher_number}`, + ) + } + } + + // ---------- Step 5: verify ---------- + if (EXECUTE) { + const { data: ap } = await supabase + .from('journal_entry_lines') + .select('debit_amount, credit_amount, journal_entry:journal_entries!inner(company_id, status)') + .eq('account_number', '2440') + .eq('journal_entry.company_id', COMPANY_ID) + .in('journal_entry.status', ['posted', 'reversed']) + const apNet = (ap ?? []).reduce((s, l) => s + (l.credit_amount ?? 0) - (l.debit_amount ?? 0), 0) + console.log(`\n2440 net balance over posted entries: ${Math.round(apNet * 100) / 100} (expect 0)`) + } + + console.log('\nDone.') +} + +main().catch((err) => { + console.error('\nREPAIR FAILED:', err) + process.exit(1) +}) diff --git a/scripts/repair-capelix-invoice-payment.ts b/scripts/repair-capelix-invoice-payment.ts new file mode 100644 index 00000000..dd03f1e3 --- /dev/null +++ b/scripts/repair-capelix-invoice-payment.ts @@ -0,0 +1,208 @@ +#!/usr/bin/env npx tsx +/** + * Repair the Capelix AB invoice-001 double-booking (2026-05-29). + * + * Incident: v1 mark-paid read invoiceAlreadyBooked from a column its select + * never fetched (fixed in PR #713). Capelix (kontantmetoden) had invoice 001 + * registered at send (voucher A6: Dr 1510 / Cr 3001 1500 / Cr 2611 375), so + * mark-paid should have CLEARED 1510 — instead it booked a cash entry + * (voucher A41: Dr 1930 / Cr 3001 1500 / Cr 2611 375). Net damage: revenue + * 3001 and VAT 2611 double-counted, 1510 carries an orphaned 1 875 kr debit. + * + * Fix (engine-faithful, storno-only per BFL/BFNAR 2013:2 — period is open, + * moms_period=yearly with no declaration filed, so no rättelse needed): + * 1. reverseEntry(A41) — storno the wrong cash entry per 2026-05-29. + * 2. createInvoicePaymentJournalEntry(...) — the correct clearing entry + * (Dr 1930 / Cr 1510, 1 875 kr) per 2026-05-29. + * 3. Relink the bank transaction and invoice_payments row from A41 to the + * new clearing entry (same economics — keeps reconciliation intact). + * + * Net account effect: 3001 −1500, 2611 −375, 1510 −1875 (cleared), 1930 + * unchanged (matches the real bank inflow). + * + * Every step checks preconditions and skips completed work — safe to re-run. + * + * Usage: + * npx tsx scripts/repair-capelix-invoice-payment.ts # dry run + * npx tsx scripts/repair-capelix-invoice-payment.ts --commit # apply + */ + +import { config } from 'dotenv' +config({ path: '.env.local' }) +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { reverseEntry } from '../lib/bookkeeping/engine' +import { createInvoicePaymentJournalEntry } from '../lib/bookkeeping/invoice-entries' +import type { Invoice } from '../types' + +const COMPANY_ID = 'c02c8b65-c7c3-4830-b099-853ad174fbd0' // Capelix AB +const OWNER_USER_ID = '81526a33-df2d-41a6-a3dd-98bea1efa80d' +const INVOICE_ID = '2a9ba8c4-8c2b-45b6-a492-8367b881b968' // invoice 001 +const REGISTRATION_ENTRY_ID = '3f721508-5ab4-468f-9f35-36b41a8d26a5' // A6 +const WRONG_CASH_ENTRY_ID = 'b25b0f6f-d88e-465e-a966-f3a57638638d' // A41 +const PAYMENT_DATE = '2026-05-29' + +const COMMIT = process.argv.includes('--commit') + +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 + +async function entryStatus(id: string): Promise<{ status: string; voucher: string } | null> { + const { data } = await supabase + .from('journal_entries') + .select('status, voucher_series, voucher_number') + .eq('company_id', COMPANY_ID) + .eq('id', id) + .maybeSingle() + if (!data) return null + return { status: data.status, voucher: `${data.voucher_series}${data.voucher_number}` } +} + +async function main() { + console.log('─────────────────────────────────────────────────────────') + console.log(`Capelix invoice-001 repair — ${COMMIT ? 'COMMIT' : 'DRY RUN'}`) + console.log('─────────────────────────────────────────────────────────') + + // ── Preconditions ────────────────────────────────────────────── + const registration = await entryStatus(REGISTRATION_ENTRY_ID) + const wrongCash = await entryStatus(WRONG_CASH_ENTRY_ID) + if (!registration || registration.status !== 'posted') { + throw new Error(`Registration entry A6 not found/posted (got ${registration?.status}) — aborting`) + } + console.log(`✓ Registration ${registration.voucher}: posted`) + + const { data: invoiceRow } = await supabase + .from('invoices') + .select('*, customer:customers(name)') + .eq('company_id', COMPANY_ID) + .eq('id', INVOICE_ID) + .maybeSingle() + if (!invoiceRow) throw new Error('Invoice not found — aborting') + if (invoiceRow.journal_entry_id !== REGISTRATION_ENTRY_ID) { + throw new Error( + `invoice.journal_entry_id is ${invoiceRow.journal_entry_id}, expected the A6 registration entry — aborting`, + ) + } + console.log(`✓ Invoice ${invoiceRow.invoice_number}: status=${invoiceRow.status}, total=${invoiceRow.total}, linked to A6`) + + // Existing correct clearing entry? (idempotency for step 2) + const { data: existingClearing } = await supabase + .from('journal_entries') + .select('id, status, voucher_series, voucher_number') + .eq('company_id', COMPANY_ID) + .eq('source_type', 'invoice_paid') + .eq('source_id', INVOICE_ID) + .eq('status', 'posted') + .maybeSingle() + + // ── Step 1: storno A41 ───────────────────────────────────────── + let stornoDone = false + if (!wrongCash) throw new Error('Wrong cash entry A41 not found — aborting') + if (wrongCash.status === 'reversed') { + console.log(`✓ Step 1 already done: ${wrongCash.voucher} is reversed`) + stornoDone = true + } else if (wrongCash.status === 'posted') { + if (COMMIT) { + const storno = await reverseEntry(supabase, COMPANY_ID, OWNER_USER_ID, WRONG_CASH_ENTRY_ID, PAYMENT_DATE) + console.log(`✓ Step 1: stornoed ${wrongCash.voucher} → reversal voucher ${storno.voucher_series}${storno.voucher_number}`) + stornoDone = true + } else { + console.log(`→ Step 1 (dry run): would storno ${wrongCash.voucher} (Dr 1930 / Cr 3001 / Cr 2611) per ${PAYMENT_DATE}`) + } + } else { + throw new Error(`Unexpected A41 status: ${wrongCash.status} — aborting`) + } + + // ── Step 2: correct clearing entry ───────────────────────────── + let clearingId: string | null = existingClearing?.id ?? null + if (existingClearing) { + console.log(`✓ Step 2 already done: clearing entry ${existingClearing.voucher_series}${existingClearing.voucher_number} exists`) + } else if (COMMIT) { + if (!stornoDone) throw new Error('Refusing to book clearing before storno — aborting') + const clearing = await createInvoicePaymentJournalEntry( + supabase, + COMPANY_ID, + OWNER_USER_ID, + invoiceRow as unknown as Invoice, + PAYMENT_DATE, + undefined, + (invoiceRow as { customer?: { name?: string } }).customer?.name, + // Full invoice total: the engine would otherwise read remaining_amount, + // which is 0 on this already-paid row. + Number(invoiceRow.total), + ) + if (!clearing) throw new Error('Clearing entry was not created (no open fiscal period?) — aborting') + clearingId = clearing.id + console.log(`✓ Step 2: booked clearing Dr 1930 / Cr 1510 ${invoiceRow.total} kr → voucher ${clearing.voucher_series}${clearing.voucher_number}`) + } else { + console.log(`→ Step 2 (dry run): would book clearing entry Dr 1930 / Cr 1510 ${invoiceRow.total} kr per ${PAYMENT_DATE}`) + } + + // ── Step 3: relink bank transaction + payment row ────────────── + if (COMMIT && clearingId) { + const { data: relinkTx } = await supabase + .from('transactions') + .update({ journal_entry_id: clearingId }) + .eq('company_id', COMPANY_ID) + .eq('journal_entry_id', WRONG_CASH_ENTRY_ID) + .select('id') + console.log(`✓ Step 3a: relinked ${relinkTx?.length ?? 0} bank transaction(s) A41 → clearing`) + + const { data: relinkPay } = await supabase + .from('invoice_payments') + .update({ journal_entry_id: clearingId }) + .eq('company_id', COMPANY_ID) + .eq('journal_entry_id', WRONG_CASH_ENTRY_ID) + .select('id') + console.log(`✓ Step 3b: relinked ${relinkPay?.length ?? 0} invoice_payments row(s) A41 → clearing`) + } else if (!COMMIT) { + const { count: txCount } = await supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', COMPANY_ID) + .eq('journal_entry_id', WRONG_CASH_ENTRY_ID) + console.log(`→ Step 3 (dry run): would relink ${txCount ?? 0} bank transaction(s) + invoice_payments row(s) to the clearing entry`) + } + + // ── Verify ───────────────────────────────────────────────────── + if (COMMIT) { + // True ledger net: include 'reversed' entries too — a reversed entry's + // lines stay in the GL and its storno (which carries the same source_id) + // cancels them. Filtering to 'posted' only would count the storno without + // its counterpart and report a false imbalance. + const { data: lines } = await supabase + .from('journal_entry_lines') + .select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(company_id, source_id, status)') + .eq('journal_entry.company_id', COMPANY_ID) + .eq('journal_entry.source_id', INVOICE_ID) + .in('journal_entry.status', ['posted', 'reversed']) + const net = new Map() + for (const l of lines ?? []) { + const acct = l.account_number as string + net.set(acct, (net.get(acct) ?? 0) + Number(l.debit_amount) - Number(l.credit_amount)) + } + console.log('Net per account over posted entries for this invoice:') + for (const [acct, sum] of [...net.entries()].sort()) { + console.log(` ${acct}: ${Math.round(sum * 100) / 100}`) + } + const ok = + Math.abs(net.get('1510') ?? 0) < 0.005 && + Math.round(Math.abs(net.get('3001') ?? 0)) === 1500 && + Math.round(Math.abs(net.get('2611') ?? 0)) === 375 && + Math.round(net.get('1930') ?? 0) === 1875 + console.log(ok ? '✓ VERIFIED: 1510 cleared, revenue/VAT single-counted, 1930 matches bank inflow' : '✗ VERIFY FAILED — inspect manually') + if (!ok) process.exit(1) + } + + console.log('─────────────────────────────────────────────────────────') + console.log(COMMIT ? 'Repair complete.' : 'Dry run complete — re-run with --commit to apply.') +} + +main().catch((err) => { + console.error('Repair failed:', err) + process.exit(1) +})