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>
This commit is contained in:
Jakob Wennberg
2026-08-27 22:24:55 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 533df34369
commit cb9ae15d46
5 changed files with 473 additions and 38 deletions
@@ -0,0 +1,216 @@
/**
* Tests for POST /api/bookkeeping/fix-cash-mismatch.
*
* Exercises the remediation through the real withRouteContext wrapper with
* auth/company/write mocked. The load-bearing assertion is the transaction
* relink payload: reverseEntry (step 1 of the remediation) 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 the relink
* must restore the full booked triple, not just the journal_entry_id pointer.
* Restoring only the pointer leaves a booked row with is_business NULL,
* visible in Att bokfora while linked to a posted entry: the inverted #1950
* symptom.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoicePaymentJournalEntry: vi.fn(),
}))
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { POST } from '../route'
const mockReverseEntry = vi.mocked(reverseEntry)
const mockCreateClearing = vi.mocked(createInvoicePaymentJournalEntry)
const mock = createQueuedMockSupabase()
/** Queue the three findAffected reads for one affected payment. */
function enqueueOneAffectedPayment() {
mock.enqueueMany([
// journal_entries: posted cash-path payment JEs
{ data: [{ id: 'je-cash', source_id: 'inv-1', status: 'posted' }] },
// invoices: the invoice still carries its own accrual JE
{
data: [
{
id: 'inv-1',
invoice_number: 'F-100',
journal_entry_id: 'je-invoice',
customer: { name: 'Acme AB' },
},
],
},
// invoice_payments: the affected payment, matched to a bank transaction
{
data: [
{
id: 'pay-1',
invoice_id: 'inv-1',
journal_entry_id: 'je-cash',
amount: 1250,
payment_date: '2026-01-15',
transaction_id: 'tx-1',
},
],
},
])
}
beforeEach(() => {
vi.clearAllMocks()
mock.reset()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: mock.supabase,
})
requireWriteMock.mockResolvedValue({ ok: true })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockReverseEntry.mockResolvedValue({ id: 'je-storno' } as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateClearing.mockResolvedValue({ id: 'je-clearing' } as any)
})
describe('POST /api/bookkeeping/fix-cash-mismatch', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mock.supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: {},
})
const { status } = await parseJsonResponse(await POST(req, { params: Promise.resolve({}) }))
expect(status).toBe(401)
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns 400 for a non-uuid payment_id', async () => {
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: { payment_id: 'not-a-uuid' },
})
const { status } = await parseJsonResponse(await POST(req, { params: Promise.resolve({}) }))
expect(status).toBe(400)
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns fixed: 0 when no payments are affected', async () => {
mock.enqueue({ data: [] }) // journal_entries: no cash-path JEs
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: {},
})
const { status, body } = await parseJsonResponse<{ fixed: number; results: unknown[] }>(
await POST(req, { params: Promise.resolve({}) }),
)
expect(status).toBe(200)
expect(body).toEqual({ fixed: 0, results: [] })
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('relinks the transaction with the full booked triple, not just the pointer', async () => {
enqueueOneAffectedPayment()
mock.enqueueMany([
// invoices: re-fetch for the clearing entry metadata
{ data: { id: 'inv-1', customer: { name: 'Acme AB' } } },
// invoice_payments: relink update
{ data: null },
// transactions: relink update (the payload under test)
{ data: null },
])
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: {},
})
const { status, body } = await parseJsonResponse<{
fixed: number
failed: number
results: Array<{ ok: boolean; storno_journal_entry_id?: string; new_journal_entry_id?: string }>
}>(await POST(req, { params: Promise.resolve({}) }))
expect(status).toBe(200)
expect(body.fixed).toBe(1)
expect(body.failed).toBe(0)
expect(body.results[0]).toMatchObject({
ok: true,
storno_journal_entry_id: 'je-storno',
new_journal_entry_id: 'je-clearing',
})
expect(mockReverseEntry).toHaveBeenCalledWith(mock.supabase, 'company-1', 'user-1', 'je-cash')
// The relink must restore everything reverseEntry reset: pointer AND the
// booked triple. journal_entry_id alone leaves the row in Att bokfora.
const txUpdate = mock.findCall('transactions', 'update')
expect(txUpdate).toBeDefined()
expect(txUpdate![0]).toEqual({
journal_entry_id: 'je-clearing',
is_business: true,
category: 'income_services',
reconciliation_method: null,
})
// And it targets exactly the matched transaction in the active company.
const txEqCalls = mock
.findCalls('transactions', 'eq')
.map((args) => args as [string, string])
expect(txEqCalls).toContainEqual(['id', 'tx-1'])
expect(txEqCalls).toContainEqual(['company_id', 'company-1'])
})
it('keeps the payment relink to journal_entry_id only', async () => {
enqueueOneAffectedPayment()
mock.enqueueMany([
{ data: { id: 'inv-1', customer: { name: 'Acme AB' } } },
{ data: null }, // invoice_payments update
{ data: null }, // transactions update
])
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: {},
})
await parseJsonResponse(await POST(req, { params: Promise.resolve({}) }))
const payUpdate = mock.findCall('invoice_payments', 'update')
expect(payUpdate).toBeDefined()
expect(payUpdate![0]).toEqual({ journal_entry_id: 'je-clearing' })
})
})
+13 -2
View File
@@ -210,11 +210,22 @@ export const POST = withRouteContext(
if (relinkPayErr) throw relinkPayErr
// Re-link the transaction too, so /transactions reflects the correct
// voucher when the user clicks through.
// 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 })
.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) {