fix: unignore transactions during categorization (#1683)

Fixes #1660
This commit is contained in:
Mattsson
2026-08-19 11:00:02 +02:00
committed by GitHub
parent b069d9a9fe
commit 43cde6deb9
22 changed files with 1270 additions and 190 deletions
@@ -36,6 +36,11 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
}))
const mockReverseOrphanedJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
reverseOrphanedJournalEntry: (...args: unknown[]) => mockReverseOrphanedJournalEntry(...args),
}))
// Booking-time duplicate guard: mocked so route tests exercise the WIRING
// (warn / force / mismatch); the detection query itself is unit-tested in
// lib/transactions/__tests__/booking-duplicate-detection.test.ts.
@@ -79,6 +84,7 @@ describe('POST /api/transactions/[id]/book', () => {
// No booking-duplicate by default; guard tests override per-case.
mockDetectDup.mockResolvedValue(null)
mockAppendProcessingHistory.mockResolvedValue('evt-1')
mockReverseOrphanedJournalEntry.mockResolvedValue(undefined)
})
it('returns 401 when not authenticated', async () => {
@@ -197,7 +203,7 @@ describe('POST /api/transactions/[id]/book', () => {
mockCreateJournalEntry.mockResolvedValue(je)
// Update transaction
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'tx-1' }], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
@@ -231,6 +237,32 @@ describe('POST /api/transactions/[id]/book', () => {
)
})
it('atomically unignores an ignored transaction when booking it', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
journal_entry_id: null,
is_ignored: true,
})
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockResolvedValue(makeJournalEntry({ id: 'je-new' }))
enqueue({ data: [{ id: 'tx-1' }], error: null })
const response = await POST(
createMockRequest('/api/transactions/tx-1/book', { method: 'POST', body: validBody }),
createMockRouteParams({ id: 'tx-1' }),
)
expect(response.status).toBe(200)
expect(findCalls('transactions', 'update')).toContainEqual([
expect.objectContaining({
journal_entry_id: 'je-new',
is_business: true,
is_ignored: false,
}),
])
})
it('returns 500 when transaction update fails', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
@@ -245,10 +277,72 @@ describe('POST /api/transactions/[id]/book', () => {
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body } = await parseJsonResponse<{
error: { code: string; message: string }
}>(response)
expect(status).toBe(500)
expect(body.error).toBe('Failed to update transaction')
expect(body.error).toMatchObject({
code: 'INTERNAL_ERROR',
message: 'Ett oväntat serverfel uppstod. Försök igen senare.',
})
expect(mockReverseOrphanedJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-new',
expect.any(String),
)
})
it('stornos the posted orphan when another booking wins the transaction-link race', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockResolvedValue(je)
enqueue({ data: [], error: null })
const response = await POST(
createMockRequest('/api/transactions/tx-1/book', { method: 'POST', body: validBody }),
createMockRouteParams({ id: 'tx-1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_RACE')
expect(mockReverseOrphanedJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-new',
expect.any(String),
)
})
it('maps the ignored-row constraint to a typed conflict and stornos the posted orphan', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, is_ignored: true })
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockResolvedValue(makeJournalEntry({ id: 'je-new' }))
enqueue({
data: null,
error: {
code: '23514',
message:
'new row for relation "transactions" violates check constraint "transactions_is_ignored_no_journal_entry"',
},
})
const response = await POST(
createMockRequest('/api/transactions/tx-1/book', { method: 'POST', body: validBody }),
createMockRouteParams({ id: 'tx-1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_IGNORED_CONFLICT')
expect(body.error.message).not.toContain('check constraint')
expect(mockReverseOrphanedJournalEntry).toHaveBeenCalledTimes(1)
})
// ── Underlag propagation (pinned document + matched inbox items) ──────
@@ -262,7 +356,7 @@ describe('POST /api/transactions/[id]/book', () => {
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch transaction
mockCreateJournalEntry.mockResolvedValue(je)
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: [{ id: 'tx-1' }], error: null }) // update transaction
enqueue({ data: { document_id: 'doc-1' } }) // propagate: tx pin lookup
enqueue({ data: { journal_entry_id: null } }) // pinned doc unanchored
enqueue({ data: { id: 'je-new' } }) // linkToJournalEntry: entry ownership check
@@ -287,7 +381,7 @@ describe('POST /api/transactions/[id]/book', () => {
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch transaction
mockCreateJournalEntry.mockResolvedValue(je)
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: [{ id: 'tx-1' }], error: null }) // update transaction
enqueue({ data: { document_id: null } }) // propagate: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: null }] }) // matched inbox item
enqueue({ data: null }) // stamp update
@@ -310,7 +404,7 @@ describe('POST /api/transactions/[id]/book', () => {
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch transaction
mockCreateJournalEntry.mockResolvedValue(je)
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: [{ id: 'tx-1' }], error: null }) // update transaction
enqueue({ data: { document_id: 'doc-1' } }) // propagate: tx pin lookup
enqueue({ data: { journal_entry_id: null } }) // pinned doc unanchored
enqueue({ data: null }) // linkToJournalEntry: entry lookup fails -> throws
@@ -364,7 +458,7 @@ describe('POST /api/transactions/[id]/book', () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // update
enqueue({ data: [{ id: 'tx-1' }], error: null }) // update
mockDetectDup.mockResolvedValue({
transaction_id: SIBLING_UUID,
journal_entry_id: 'je-existing',
@@ -431,7 +525,7 @@ describe('POST /api/transactions/[id]/book', () => {
const tx = makeTransaction({ id: 'tx-1', amount: 98565, journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // update
enqueue({ data: [{ id: 'tx-1' }], error: null }) // update
mockDetectDup.mockResolvedValue({
transaction_id: null,
journal_entry_id: VOUCHER_JE_UUID,
+32 -7
View File
@@ -3,12 +3,13 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { BookTransactionSchema } from '@/lib/api/schemas'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import type { Transaction } from '@/types'
@@ -17,7 +18,7 @@ ensureInitialized()
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.book',
async (request, { supabase, user, companyId, log }, { params }) => {
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, BookTransactionSchema)
@@ -166,22 +167,46 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
// Link transaction to the journal entry
const { error: updateError } = await supabase
const { data: updateResult, error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: journalEntry.id,
is_business: true,
is_ignored: false,
category: 'uncategorized',
})
.eq('id', id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.select('*')
if (updateError) {
return NextResponse.json(
{ error: 'Failed to update transaction' },
{ status: 500 }
await reverseOrphanedJournalEntry(
supabase,
companyId,
user.id,
journalEntry.id,
'Bokföringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
)
return errorResponse(updateError, log, { requestId })
}
if (!updateResult || updateResult.length === 0) {
// CAS guard: another request linked this transaction after our read. The
// posted orphan is immutable, so compensate through the engine with a
// storno entry instead of overwriting the winning journal entry link.
await reverseOrphanedJournalEntry(
supabase,
companyId,
user.id,
journalEntry.id,
'Bokföringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
)
return errorResponseFromCode('TX_CATEGORIZE_RACE', log, { requestId })
}
const updatedTransaction = updateResult[0] as Transaction
// A hunt- or hand-matched inbox item is consumed by this booking even
// though the dialog never saw it: link its underlag to the verifikat and
// stamp it so it leaves the active inbox (best-effort, logged inside).
@@ -192,7 +217,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
transaction: updatedTransaction,
account: lines[0]?.account_number || '',
taxCode: '',
userId: user.id,
@@ -9,7 +9,7 @@ import {
import { eventBus } from '@/lib/events'
import { JournalEntryNotBalancedError } from '@/lib/bookkeeping/errors'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
@@ -80,13 +80,10 @@ vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
}))
// CAS-race compensation is centralized in lib/bookkeeping/cancel-orphaned-entry.
// The route must delegate to it rather than hand-rolling the cancel + the
// voucher_gap_explanations insert (BFNAR 2013:2). The exact insert payload is
// asserted in that helper's own test.
const mockCancelOrphanedPaymentEntry = vi.fn()
// Posted-orphan compensation is centralized and routes through engine storno.
const mockReverseOrphanedJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphanedPaymentEntry(...args),
reverseOrphanedJournalEntry: (...args: unknown[]) => mockReverseOrphanedJournalEntry(...args),
}))
const mockFindMissingActiveAccounts = vi.fn()
@@ -128,10 +125,10 @@ describe('POST /api/transactions/[id]/categorize', () => {
// Default: no booking-time duplicate. The dedicated guard test overrides this.
mockDetectDup.mockResolvedValue(null)
mockAppendProcessingHistory.mockResolvedValue('evt-1')
mockCancelOrphanedPaymentEntry.mockResolvedValue(undefined)
mockReverseOrphanedJournalEntry.mockResolvedValue(undefined)
})
it('delegates the CAS-race orphan to cancelOrphanedPaymentEntry (documented voucher gap)', async () => {
it('delegates the CAS-race orphan to engine-backed storno compensation', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
@@ -160,13 +157,13 @@ describe('POST /api/transactions/[id]/categorize', () => {
expect((body.error as { code: string }).code).toBe('TX_CATEGORIZE_RACE')
// No hand-rolled insert: the helper owns the real column set.
expect(mockCancelOrphanedPaymentEntry).toHaveBeenCalledTimes(1)
expect(mockCancelOrphanedPaymentEntry).toHaveBeenCalledWith(
expect(mockReverseOrphanedJournalEntry).toHaveBeenCalledTimes(1)
expect(mockReverseOrphanedJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-1',
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
)
})
@@ -207,7 +204,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
// Fetch transaction
enqueue({ data: tx, error: null })
// Update transaction
enqueue({ data: null, error: null })
enqueue({ data: [{ ...tx, is_business: true, category: 'expense_software' }], error: null })
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
@@ -225,6 +222,37 @@ describe('POST /api/transactions/[id]/categorize', () => {
expect(body.already_had_journal_entry).toBe(true)
expect(body.journal_entry_id).toBe('je-existing')
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
expect(
findCalls('transactions', 'eq').filter(([column]) => column === 'company_id'),
).toHaveLength(2)
})
it('returns a race conflict when the guarded update matches no row without creating an entry', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: null,
journal_entry_id: null,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [{ id: 'period-1' }], error: null })
mockCreateTransactionJournalEntry.mockResolvedValueOnce(null)
enqueue({ data: [], error: null })
const response = await POST(
createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: false },
}),
createMockRouteParams({ id: 'tx-1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_RACE')
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
})
it('creates journal entry for business expense', async () => {
@@ -282,6 +310,46 @@ describe('POST /api/transactions/[id]/categorize', () => {
)
})
it('atomically unignores an ignored transaction when categorizing it', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: null,
journal_entry_id: null,
is_ignored: true,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [{ id: 'period-1' }], error: null })
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: [{ ...tx, is_business: false, category: 'private', is_ignored: false, journal_entry_id: 'je-1' }], error: null })
const categorizedHandler = vi.fn()
eventBus.on('transaction.categorized', categorizedHandler)
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: false },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
expect(response.status).toBe(200)
expect(findCalls('transactions', 'update')).toContainEqual([
expect.objectContaining({
is_business: false,
category: 'private',
is_ignored: false,
journal_entry_id: 'je-1',
}),
])
expect(categorizedHandler).toHaveBeenCalledWith(
expect.objectContaining({
transaction: expect.objectContaining({ is_ignored: false }),
}),
)
})
it('passes body.dimensions onto the mapping result the engine books', async () => {
const tx = makeTransaction({
id: 'tx-1',
@@ -418,7 +486,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked'))
// Update transaction
enqueue({ data: null, error: null })
enqueue({ data: [{ ...tx, is_business: true, category: 'expense_software' }], error: null })
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
@@ -454,7 +522,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
mockCreateTransactionJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(100, 80))
// Update transaction
enqueue({ data: null, error: null })
enqueue({ data: [{ ...tx, is_business: true, category: 'expense_software' }], error: null })
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
@@ -502,6 +570,54 @@ describe('POST /api/transactions/[id]/categorize', () => {
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR')
expect(mockReverseOrphanedJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-1',
expect.any(String),
)
})
it('maps an ignored-row constraint to a typed conflict and stornos the posted orphan', async () => {
const tx = makeTransaction({
id: 'tx-1',
journal_entry_id: null,
merchant_name: null,
is_ignored: true,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [{ id: 'period-1' }], error: null })
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({
data: null,
error: {
code: '23514',
message:
'new row for relation "transactions" violates check constraint "transactions_is_ignored_no_journal_entry"',
},
})
const response = await POST(
createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: false },
}),
createMockRouteParams({ id: 'tx-1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_IGNORED_CONFLICT')
expect(body.error.message).not.toContain('check constraint')
expect(mockReverseOrphanedJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-1',
expect.any(String),
)
})
it('returns 400 when mapping result has empty debit_account', async () => {
+30 -15
View File
@@ -5,7 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
@@ -138,8 +138,9 @@ export const POST = withRouteContext(
const { error: updateErr } = await supabase
.from('transactions')
.update({ is_business, category: finalCat })
.update({ is_business, category: finalCat, is_ignored: false })
.eq('id', id)
.eq('company_id', companyId)
if (updateErr) {
txLog.error('failed to update already-categorized transaction', updateErr)
@@ -921,33 +922,47 @@ export const POST = withRouteContext(
.update({
is_business,
category: finalCategory,
is_ignored: false,
journal_entry_id: journalEntryId,
})
.eq('id', id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.select('id')
.select('*')
if (updateError) {
txLog.error('failed to update transaction', updateError)
if (journalEntryId) {
await reverseOrphanedJournalEntry(
supabase,
companyId,
user.id,
journalEntryId,
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
)
}
return errorResponse(updateError, txLog, { requestId })
}
if ((!updateResult || updateResult.length === 0) && journalEntryId) {
if (!updateResult || updateResult.length === 0) {
// CAS guard: another request set journal_entry_id between our read and
// write. Cancel the orphaned entry and document the voucher gap through
// the shared helper (BFNAR 2013:2), which owns the correct
// voucher_gap_explanations column set and logs failures loudly.
await cancelOrphanedPaymentEntry(
supabase,
companyId,
user.id,
journalEntryId,
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
)
// write. If this request posted an orphan, compensate through the
// bookkeeping engine with a storno entry.
if (journalEntryId) {
await reverseOrphanedJournalEntry(
supabase,
companyId,
user.id,
journalEntryId,
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
)
}
return errorResponseFromCode('TX_CATEGORIZE_RACE', txLog, { requestId })
}
const updatedTransaction = updateResult[0] as Transaction
// Flag any inbox underlag already matched to this transaction as booked.
// The block above only fires when the caller passes an explicit
// inbox_item_id (booking straight from the inbox flow). Booking the same
@@ -991,7 +1006,7 @@ export const POST = withRouteContext(
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
transaction: updatedTransaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId: user.id,