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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
533df34369
commit
cb9ae15d46
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user