Files
accounted/app/api/bookkeeping/fix-cash-mismatch/route.ts
T
cb9ae15d46 fix(storno): return stornoed bank transactions to Att bokfora (#1985)
* fix(storno): return stornoed bank transactions to Att bokfora

reverseEntry() unlinked bank transactions from the reversed entry by
clearing only journal_entry_id. The worklist's "unbooked" predicate is
is_business IS NULL AND is_ignored = false (lib/worklist/types.ts), so
the row stayed "handled": absent from Att bokfora and from the nav badge,
while the storno dialog (reverse_warning) promised the opposite (#1950).

The engine now resets the same triple the uncategorize paths write
(journal_entry_id, is_business, category) plus reconciliation_method,
scoped to rows linked to the reversed entry. Fixed in the engine so the
dashboard, v1 and MCP reverse doors all agree.

Closes #1950

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(storno): release bulk-booked bank rows anchored through transaction_voucher_links

The #1950 fix reset transactions scoped by journal_entry_id, but bulk-booked
samlingsverifikat (bulk_book_transactions RPC) anchor their N>1 bank rows
through transaction_voucher_links only (journal_entry_id stays NULL), so the
reset matched nothing there: all rows kept is_business = true against a
status='reversed' entry, stayed out of Att bokfora and the nav badge, and
is_transaction_booked() still reported them booked. The N=1 variant left a
dangling link row that blocked re-booking (BULK_BOOK_TX_ALREADY_BOOKED) and
kept the reconciliation bridge bucketing the row as matched.

reverseEntry now deletes the reversed entry's junction rows (the same removal
koppla-bort performs) and releases is_business, category and
reconciliation_method only for rows left with no anchor: a remaining-links
read plus journal_entry_id IS NULL guards residual bookings (main verifikat
in journal_entry_id, junction row to the residual verifikat) and
multi-allocated rows so stornoing one voucher never unbooks a still-booked
row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(bookkeeping): restore the booked triple in fix-cash-mismatch's transaction relink

The widened reverseEntry reset (#1950) now nulls is_business, category
and reconciliation_method together with journal_entry_id on the linked
transaction, but the fix-cash-mismatch remediation relinked with only
journal_entry_id. The repaired row ended up booked (pointer at the
posted clearing entry) yet visible in Att bokfora and the nav badge
(worklist predicate: is_business IS NULL), the inverted #1950 symptom;
booking it from the list would conflict-storno the correct clearing
entry and corrupt the AR chain the route just repaired.

The relink now restores the full booked triple, mirroring the
match-invoice route's final update. New route tests cover auth 401,
validation 400, the no-targets path, and assert both relink payloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:24:55 +02:00

265 lines
9.8 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. reverseEntry (step 1) resets
// the whole booked state on the linked row: journal_entry_id,
// is_business, category, reconciliation_method (the #1950 return-to-
// Att-bokfora fix), so restoring only the pointer would leave a booked
// row with is_business NULL, visible in Att bokfora and the nav badge
// (worklist predicate: is_business IS NULL). Restore the full booked
// triple, mirroring the match-invoice route's final update.
if (t.transaction_id) {
const { error: relinkTxErr } = await supabase
.from('transactions')
.update({
journal_entry_id: clearing.id,
is_business: true,
category: 'income_services',
reconciliation_method: null,
})
.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 },
)