Files
accounted/app/api/bookkeeping/fix-cash-mismatch/route.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

254 lines
9.2 KiB
TypeScript

/**
* GET /api/bookkeeping/fix-cash-mismatch → list affected payments
* POST /api/bookkeeping/fix-cash-mismatch → remediate one payment (or all)
*
* Targeted fix for the cash/clearing routing bug. The old matcher chose its
* journal entry shape from the company's current accounting_method instead
* of from invoice.journal_entry_id, so customers who sent invoices under
* accrual (Dr 1510 / Cr 30xx + 26xx on send) and then matched a bank
* receipt after the company had flipped to kontantmetoden ended up with:
* - 1510 Kundfordran NEVER credited (orphan receivable on the books)
* - 30xx Försäljning AND 26xx Utgående moms double-counted
* - momsdeklaration would over-report output VAT
*
* Detection: any invoice_payments row whose payment journal entry has
* source_type='invoice_cash_payment' while the underlying invoice carries
* its own (still-active) accrual JE.
*
* Remediation per affected payment:
* 1. reverseEntry(payment_je): storno cancels Dr 1930 / Cr 30xx / Cr 26xx
* 2. createInvoicePaymentJournalEntry: posts the correct Dr 1930 / Cr 1510
* 3. Re-link invoice_payments + transactions to the new JE
*
* Net effect on the books: 30xx and 26xx are restored to their correct
* (single-count) amounts, 1510 is cleared, 1930 nets to a single debit,
* invoice keeps status='paid', transaction keeps invoice_id linkage.
*/
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
type AffectedPayment = {
payment_id: string
payment_journal_entry_id: string
invoice_id: string
invoice_number: string | null
counterparty_name: string | null
amount: number
payment_date: string
transaction_id: string | null
invoice_journal_entry_id: string
}
async function findAffected(
supabase: import('@supabase/supabase-js').SupabaseClient,
companyId: string,
): Promise<AffectedPayment[]> {
// 1. Find payment JEs that took the (now-wrong) cash path.
const { data: cashPaymentEntries, error: jeErr } = await supabase
.from('journal_entries')
.select('id, source_id, status')
.eq('company_id', companyId)
.eq('source_type', 'invoice_cash_payment')
.eq('status', 'posted')
if (jeErr) throw jeErr
if (!cashPaymentEntries || cashPaymentEntries.length === 0) return []
// 2. For each, the source_id is the invoice; affected iff that invoice
// ALSO has its own journal_entry_id (i.e. 1510 was booked on send).
const invoiceIds = Array.from(new Set(cashPaymentEntries.map((e) => e.source_id).filter(Boolean)))
if (invoiceIds.length === 0) return []
const { data: invoices, error: invErr } = await supabase
.from('invoices')
.select('id, invoice_number, journal_entry_id, customer:customers(name)')
.eq('company_id', companyId)
.in('id', invoiceIds)
.not('journal_entry_id', 'is', null)
if (invErr) throw invErr
const invoiceMap = new Map(
(invoices ?? []).map((i) => [
i.id as string,
{
invoice_number: (i.invoice_number as string | null) ?? null,
invoice_journal_entry_id: i.journal_entry_id as string,
counterparty_name: ((i.customer as { name?: string | null } | null)?.name) ?? null,
},
]),
)
// 3. Pull the invoice_payments rows so we can show + later re-link.
const affectedJeIds = cashPaymentEntries
.filter((e) => invoiceMap.has(e.source_id as string))
.map((e) => e.id as string)
if (affectedJeIds.length === 0) return []
const { data: payments, error: payErr } = await supabase
.from('invoice_payments')
.select('id, invoice_id, journal_entry_id, amount, payment_date, transaction_id')
.eq('company_id', companyId)
.in('journal_entry_id', affectedJeIds)
if (payErr) throw payErr
return (payments ?? []).map((p) => {
const inv = invoiceMap.get(p.invoice_id as string)!
return {
payment_id: p.id as string,
payment_journal_entry_id: p.journal_entry_id as string,
invoice_id: p.invoice_id as string,
invoice_number: inv.invoice_number,
counterparty_name: inv.counterparty_name,
amount: p.amount as number,
payment_date: p.payment_date as string,
transaction_id: (p.transaction_id as string | null) ?? null,
invoice_journal_entry_id: inv.invoice_journal_entry_id,
}
})
}
export const GET = withRouteContext(
'bookkeeping.fix_cash_mismatch.list',
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
try {
const affected = await findAffected(supabase, companyId!)
return NextResponse.json({ affected })
} catch (err) {
log.error('failed to detect cash-mismatch payments', err as Error)
return errorResponse(err, log, { requestId })
}
},
)
const PostSchema = z.object({
// Either a single payment to fix, or omit to fix all currently detected.
payment_id: z.string().uuid().optional(),
})
export const POST = withRouteContext(
'bookkeeping.fix_cash_mismatch.apply',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
let body: unknown
try {
body = await request.json()
} catch {
body = {}
}
const parsed = PostSchema.safeParse(body)
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, { requestId })
}
const { payment_id } = parsed.data
let targets: AffectedPayment[]
try {
const all = await findAffected(supabase, companyId!)
targets = payment_id ? all.filter((p) => p.payment_id === payment_id) : all
} catch (err) {
log.error('failed to detect targets', err as Error)
return errorResponse(err, log, { requestId })
}
if (targets.length === 0) {
return NextResponse.json({ fixed: 0, results: [] })
}
const results: Array<{
payment_id: string
ok: boolean
old_journal_entry_id: string
storno_journal_entry_id?: string
new_journal_entry_id?: string
error?: string
}> = []
for (const t of targets) {
try {
// Storno the wrong cash entry. This reverses Dr 1930 / Cr 30xx / Cr
// 26xx by posting the mirror, restoring revenue + VAT to their pre-
// match (correctly-counted-once) state.
const storno = await reverseEntry(supabase, companyId!, user.id, t.payment_journal_entry_id)
// Re-fetch the invoice so we have currency / exchange rate metadata
// for the clearing entry. Customer name is best-effort.
const { data: inv, error: invErr } = await supabase
.from('invoices')
.select('*, customer:customers(name)')
.eq('id', t.invoice_id)
.eq('company_id', companyId)
.single()
if (invErr || !inv) throw invErr ?? new Error('invoice missing')
const clearing = await createInvoicePaymentJournalEntry(
supabase,
companyId!,
user.id,
inv as Invoice,
t.payment_date,
undefined,
(inv.customer as { name?: string } | null)?.name ?? t.counterparty_name ?? undefined,
t.amount,
)
if (!clearing) throw new Error('clearing entry creation returned null')
// Re-link the invoice_payments row to the new (correct) JE.
const { error: relinkPayErr } = await supabase
.from('invoice_payments')
.update({ journal_entry_id: clearing.id })
.eq('id', t.payment_id)
.eq('company_id', companyId)
if (relinkPayErr) throw relinkPayErr
// Re-link the transaction too, so /transactions reflects the correct
// voucher when the user clicks through.
if (t.transaction_id) {
const { error: relinkTxErr } = await supabase
.from('transactions')
.update({ journal_entry_id: clearing.id })
.eq('id', t.transaction_id)
.eq('company_id', companyId)
if (relinkTxErr) {
log.warn('failed to relink transaction; voucher chain still correct via payment row', {
transactionId: t.transaction_id,
error: relinkTxErr.message,
})
}
}
results.push({
payment_id: t.payment_id,
ok: true,
old_journal_entry_id: t.payment_journal_entry_id,
storno_journal_entry_id: storno.id,
new_journal_entry_id: clearing.id,
})
} catch (err) {
log.error('remediation failed for payment', err as Error, { paymentId: t.payment_id })
results.push({
payment_id: t.payment_id,
ok: false,
old_journal_entry_id: t.payment_journal_entry_id,
error: err instanceof Error ? getUserErrorMessage(err) : 'Unknown error',
})
}
}
return NextResponse.json({
fixed: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
})
},
{ requireWrite: true },
)