* 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>
217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
/**
|
|
* 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' })
|
|
})
|
|
})
|