Fix/m sprint fixes (#613)
* fix(dashboard): exclude ignored and already-triaged transactions from stale count The "Gamla transaktioner" widget counted transactions that had been ignored or already marked as is_business=true but not yet booked, so users saw a nag for a row they had already dealt with — and the /transactions inbox correctly hid it. Align the count with the inbox criterion (is_business IS NULL, is_ignored = false) so the widget clears when the row leaves the inbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): read entity_type from settings response wrapper The transactions page read entityRes.entity_type directly, but /api/settings returns { data: { entity_type, ... } }. The expression was always undefined, so setEntityType never fired and entityType stayed at its initial 'enskild_firma'. The template picker's entity_type filter then dropped every aktiebolag-tagged user template for AB customers — only entity_type='all' templates made it through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * stale templates bank sync journal entry from transaction * fixed pr comments * fixed pr comment --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fc7a46c3f2
commit
ea1bf01f1e
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
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 ? err.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
fixed: results.filter((r) => r.ok).length,
|
||||
failed: results.filter((r) => !r.ok).length,
|
||||
results,
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
|
||||
|
||||
const logger = createLogger('journal-entries')
|
||||
|
||||
@@ -56,6 +57,18 @@ export async function DELETE(
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Read source_type/source_id BEFORE deleting so we can revert the linked
|
||||
// invoice/supplier_invoice status afterwards. The GL row gets cancelled by
|
||||
// delete_last_voucher but the invoice's paid status lives outside the GL
|
||||
// and would otherwise stay stuck on "paid" after the user deletes the
|
||||
// payment voucher.
|
||||
const { data: entryBefore } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, source_type, source_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const { data, error } = await supabase.rpc('delete_last_voucher', {
|
||||
p_company_id: companyId,
|
||||
p_entry_id: id,
|
||||
@@ -69,6 +82,14 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
if (entryBefore) {
|
||||
try {
|
||||
await syncInvoiceStatusFromPaymentEntry(supabase, companyId, entryBefore)
|
||||
} catch (syncError) {
|
||||
logger.warn('payment status sync failed after delete', { entryId: id, error: syncError })
|
||||
}
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.deleted',
|
||||
payload: {
|
||||
|
||||
Reference in New Issue
Block a user