chore(scripts): add prod repair scripts for Arcim and Capelix incidents (#773)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = {}
|
||||
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)
|
||||
})
|
||||
@@ -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<string, number>()
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user