* 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>
265 lines
9.8 KiB
TypeScript
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 },
|
|
)
|