@@ -36,6 +36,10 @@ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
|
||||
detectBookingDuplicate: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
// Mock VAT validation
|
||||
vi.mock('@/lib/vat/vies-client', () => ({
|
||||
validateVatNumber: vi.fn().mockResolvedValue({ valid: true }),
|
||||
@@ -130,7 +134,7 @@ describe('POST /api/pending-operations/:id/commit', () => {
|
||||
{ data: tx }, // fetch transaction
|
||||
{ data: settings }, // fetch company settings
|
||||
{ data: [{ id: 'fp-1' }] }, // fiscal period check
|
||||
{ data: null, error: null }, // update transaction
|
||||
{ data: [{ id: 'tx-1' }], error: null }, // transaction CAS matched
|
||||
{ data: null, error: null }, // upsert counterparty template
|
||||
{ data: null, error: null }, // update pending op status
|
||||
])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+96
-6
@@ -69,6 +69,7 @@ vi.mock('@/lib/transactions/inbox-underlag', () => ({
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { withUnusedVoucherAllocation } from '@/lib/bookkeeping/errors'
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
@@ -83,6 +84,7 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
|
||||
// Insert payloads are recorded verbatim: the proxy would happily accept a
|
||||
// phantom column, so the assertion has to inspect the object itself.
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
const updates: Record<string, unknown[]> = {}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
@@ -95,13 +97,14 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'insert') (inserts[table] ??= []).push(args[0])
|
||||
if (prop === 'update') (updates[table] ??= []).push(args[0])
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { supabase: { from: vi.fn((table: string) => buildChain(table)) }, inserts }
|
||||
return { supabase: { from: vi.fn((table: string) => buildChain(table)) }, inserts, updates }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
@@ -170,7 +173,7 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function happyPathSupabase() {
|
||||
function happyPathSupabase(transactionOverrides: Record<string, unknown> = {}) {
|
||||
return makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: [
|
||||
@@ -184,6 +187,7 @@ function happyPathSupabase() {
|
||||
merchant_name: 'ICA',
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
...transactionOverrides,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
@@ -244,13 +248,98 @@ describe('POST /api/v1/.../transactions/{id}/categorize underlag propagation', (
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_RACE')
|
||||
expect(propagateUnderlagMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a race conflict when the guarded update matches no row without creating an entry', async () => {
|
||||
const { supabase } = casRaceSupabase()
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
createTxJE.mockResolvedValueOnce(null)
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ is_business: true, category: 'expense_office' }),
|
||||
routeParams(),
|
||||
)
|
||||
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_RACE')
|
||||
expect(reverseEntryMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('atomically unignores an ignored transaction when categorizing it', async () => {
|
||||
const { supabase, updates } = happyPathSupabase({ is_ignored: true })
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
|
||||
const res = await POST(makeRequest({ is_business: false }), routeParams())
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(updates.transactions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
is_business: false,
|
||||
category: 'private',
|
||||
is_ignored: false,
|
||||
journal_entry_id: 'je-fresh',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('maps an ignored-row constraint to a typed conflict and stornos the posted orphan', async () => {
|
||||
const { supabase } = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: [
|
||||
{
|
||||
data: {
|
||||
id: TX_ID,
|
||||
company_id: COMPANY_ID,
|
||||
date: '2026-05-12',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA',
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
is_ignored: true,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: null,
|
||||
error: {
|
||||
code: '23514',
|
||||
message:
|
||||
'new row for relation "transactions" violates check constraint "transactions_is_ignored_no_journal_entry"',
|
||||
},
|
||||
},
|
||||
],
|
||||
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
|
||||
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
|
||||
const res = await POST(makeRequest({ is_business: false }), routeParams())
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_IGNORED_CONFLICT')
|
||||
expect(body.error.message).not.toContain('check constraint')
|
||||
expect(reverseEntryMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
'je-fresh',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/.../transactions/{id}/categorize CAS race', () => {
|
||||
it('documents the stranded voucher with the real voucher_gap_explanations columns when the storno fails', async () => {
|
||||
const { supabase, inserts } = casRaceSupabase()
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
reverseEntryMock.mockRejectedValueOnce(new Error('period locked'))
|
||||
reverseEntryMock.mockRejectedValueOnce(
|
||||
withUnusedVoucherAllocation(new Error('account lookup failed'), {
|
||||
fiscalPeriodId: 'period-1',
|
||||
voucherSeries: 'B',
|
||||
voucherNumber: 43,
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ is_business: true, category: 'expense_office' }),
|
||||
@@ -268,9 +357,10 @@ describe('POST /api/v1/.../transactions/{id}/categorize CAS race', () => {
|
||||
user_id: 'user-1',
|
||||
fiscal_period_id: 'period-1',
|
||||
voucher_series: 'B',
|
||||
gap_start: 42,
|
||||
gap_end: 42,
|
||||
explanation: 'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.',
|
||||
gap_start: 43,
|
||||
gap_end: 43,
|
||||
explanation:
|
||||
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -32,8 +32,7 @@ import {
|
||||
buildMappingResultFromCounterpartyTemplate,
|
||||
} from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { recordVoucherGapExplanation } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
@@ -155,7 +154,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
: 'private'
|
||||
const { error: updateErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({ is_business, category: finalCat })
|
||||
.update({ is_business, category: finalCat, is_ignored: false })
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
if (updateErr) return v1ErrorResponse(updateErr, txLog, { requestId: ctx.requestId })
|
||||
@@ -443,72 +442,44 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.update({
|
||||
is_business,
|
||||
category: finalCategory,
|
||||
is_ignored: false,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
.select('*')
|
||||
|
||||
if (updateErr) return v1ErrorResponse(updateErr, txLog, { requestId: ctx.requestId })
|
||||
if (updateErr) {
|
||||
if (journalEntryId) {
|
||||
await reverseOrphanedJournalEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
journalEntryId,
|
||||
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
||||
)
|
||||
}
|
||||
return v1ErrorResponse(updateErr, txLog, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
if ((!updateResult || updateResult.length === 0) && journalEntryId) {
|
||||
// Lost the race. The orphan JE was created with status='posted' by the
|
||||
// engine, so the immutability trigger blocks a direct status flip to
|
||||
// 'cancelled'. BFL 5 kap 5 § requires corrections via a reversing
|
||||
// entry (storno): issue one. The pair (orphan + storno) keeps the
|
||||
// verifikationsnummer series unbroken; no voucher_gap_explanations row
|
||||
// is needed because there's no gap.
|
||||
try {
|
||||
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId)
|
||||
} catch (revErr) {
|
||||
// Storno failure on the orphan is rare but creates an unreconcilable
|
||||
// ledger state (posted JE with no reversal). BFL 5 kap 5 § requires
|
||||
// every correction be traceable. Document the gap explicitly so a
|
||||
// human can reconcile manually rather than losing the trail to logs.
|
||||
txLog.error('TX_CATEGORIZE_RACE: failed to storno orphaned JE', revErr as Error, {
|
||||
orphanJournalEntryId: journalEntryId,
|
||||
})
|
||||
try {
|
||||
const { data: orphan } = await ctx.supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
if (orphan && orphan.voucher_series) {
|
||||
// Skip the gap row when the engine didn't tag a series on the
|
||||
// orphan. Filing under a fallback series (previously 'A') would
|
||||
// index the gap explanation under the wrong key, hiding it from
|
||||
// series-specific audit queries (BFL 5 kap 6 §). A missing series
|
||||
// is logged above already; a human will reconcile via that trail.
|
||||
//
|
||||
// The insert itself lives in the shared helper: it owns the real
|
||||
// voucher_gap_explanations column set (gap_start/gap_end/user_id)
|
||||
// and logs a failed insert loudly instead of swallowing it.
|
||||
await recordVoucherGapExplanation(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
userId: ctx.userId,
|
||||
fiscalPeriodId: orphan.fiscal_period_id,
|
||||
voucherSeries: orphan.voucher_series,
|
||||
voucherNumber: orphan.voucher_number,
|
||||
explanation:
|
||||
'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.',
|
||||
})
|
||||
}
|
||||
} catch (gapErr) {
|
||||
txLog.error(
|
||||
'TX_CATEGORIZE_RACE: failed to look up the orphan for its gap explanation',
|
||||
gapErr as Error,
|
||||
{ orphanJournalEntryId: journalEntryId },
|
||||
)
|
||||
}
|
||||
if (!updateResult || updateResult.length === 0) {
|
||||
if (journalEntryId) {
|
||||
await reverseOrphanedJournalEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
journalEntryId,
|
||||
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
||||
)
|
||||
}
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_RACE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
const updatedTransaction = updateResult[0] as Transaction
|
||||
|
||||
// Propagate the underlag onto the new verifikat: anchor the transaction's
|
||||
// pinned document and stamp matched inbox items so they leave the active
|
||||
// inbox. Same shared step the dashboard categorize, /book and bulk-book
|
||||
@@ -528,7 +499,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
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: ctx.userId,
|
||||
|
||||
+204
-7
@@ -60,6 +60,7 @@ vi.mock('@/lib/transactions/inbox-underlag', () => ({
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { withUnusedVoucherAllocation } from '@/lib/bookkeeping/errors'
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
@@ -74,6 +75,7 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
|
||||
// Insert payloads are recorded verbatim: the proxy would happily accept a
|
||||
// phantom column, so assertions have to inspect the object itself.
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
const updates: Record<string, unknown[]> = {}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
@@ -86,13 +88,14 @@ function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
if (prop === 'insert') (inserts[table] ??= []).push(args[0])
|
||||
if (prop === 'update') (updates[table] ??= []).push(args[0])
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { supabase: { from: vi.fn((table: string) => buildChain(table)) }, inserts }
|
||||
return { supabase: { from: vi.fn((table: string) => buildChain(table)) }, inserts, updates }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
@@ -129,6 +132,193 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('POST batch-categorize', () => {
|
||||
it('atomically unignores ignored rows when categorizing them as private', async () => {
|
||||
const { supabase, updates } = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: [
|
||||
{
|
||||
data: {
|
||||
id: TX_A,
|
||||
company_id: COMPANY_ID,
|
||||
date: '2026-05-12',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA',
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
is_ignored: true,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ id: TX_A }], error: null },
|
||||
],
|
||||
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
|
||||
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
|
||||
{
|
||||
items: [{ transaction_id: TX_A, categorization: { is_business: false } }],
|
||||
},
|
||||
),
|
||||
batchParams(),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.summary).toEqual({ total: 1, succeeded: 1, failed: 0 })
|
||||
expect(updates.transactions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
is_business: false,
|
||||
category: 'private',
|
||||
is_ignored: false,
|
||||
journal_entry_id: 'je-fresh',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('maps the ignored-row constraint to a typed per-item error', async () => {
|
||||
const { supabase } = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: [
|
||||
{
|
||||
data: {
|
||||
id: TX_A,
|
||||
company_id: COMPANY_ID,
|
||||
date: '2026-05-12',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA',
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
is_ignored: true,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: null,
|
||||
error: {
|
||||
code: '23514',
|
||||
message:
|
||||
'new row for relation "transactions" violates check constraint "transactions_is_ignored_no_journal_entry"',
|
||||
},
|
||||
},
|
||||
],
|
||||
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
|
||||
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
|
||||
{
|
||||
items: [{ transaction_id: TX_A, categorization: { is_business: false } }],
|
||||
},
|
||||
),
|
||||
batchParams(),
|
||||
)
|
||||
|
||||
const body = await res.json()
|
||||
expect(body.data.results[0].error).toMatchObject({
|
||||
code: 'TX_CATEGORIZE_IGNORED_CONFLICT',
|
||||
message:
|
||||
'Transaktionen är fortfarande markerad som ignorerad och kan därför inte kopplas till en verifikation.',
|
||||
})
|
||||
expect(body.data.summary).toEqual({ total: 1, succeeded: 0, failed: 1 })
|
||||
expect(reverseEntryMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
'je-fresh',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a per-item race conflict when the guarded update matches no row without creating an entry', async () => {
|
||||
const { supabase } = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: [
|
||||
{
|
||||
data: {
|
||||
id: TX_A,
|
||||
company_id: COMPANY_ID,
|
||||
date: '2026-05-12',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA',
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
],
|
||||
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
|
||||
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
createTxJE.mockResolvedValueOnce(null)
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
|
||||
{
|
||||
items: [{ transaction_id: TX_A, categorization: { is_business: true, category: 'expense_office' } }],
|
||||
},
|
||||
),
|
||||
batchParams(),
|
||||
)
|
||||
|
||||
const body = await res.json()
|
||||
expect(body.data.results[0].error.code).toBe('TX_CATEGORIZE_RACE')
|
||||
expect(body.data.summary).toEqual({ total: 1, succeeded: 0, failed: 1 })
|
||||
expect(reverseEntryMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps unrelated transaction update errors mapped to INTERNAL_ERROR', async () => {
|
||||
const { supabase } = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: [
|
||||
{
|
||||
data: {
|
||||
id: TX_A,
|
||||
company_id: COMPANY_ID,
|
||||
date: '2026-05-12',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA',
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: null,
|
||||
error: { code: 'P0001', message: 'Invoice not found' },
|
||||
},
|
||||
],
|
||||
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
|
||||
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
|
||||
{
|
||||
items: [{ transaction_id: TX_A, categorization: { is_business: false } }],
|
||||
},
|
||||
),
|
||||
batchParams(),
|
||||
)
|
||||
|
||||
const body = await res.json()
|
||||
expect(body.data.results[0].error.code).toBe('INTERNAL_ERROR')
|
||||
expect(reverseEntryMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the linked cash account in validation and the posted mapping', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
@@ -430,9 +620,15 @@ describe('POST batch-categorize', () => {
|
||||
voucher_gap_explanations: { data: null, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
// Storno fails: the orphan keeps its number, so the break in the
|
||||
// verifikationsnummerserie must be documented (BFNAR 2013:2).
|
||||
reverseEntryMock.mockRejectedValueOnce(new Error('period locked'))
|
||||
// The reversal sequence allocation fails before a reversal row is stored,
|
||||
// so the engine exposes the exact unused number for documentation.
|
||||
reverseEntryMock.mockRejectedValueOnce(
|
||||
withUnusedVoucherAllocation(new Error('account lookup failed'), {
|
||||
fiscalPeriodId: 'period-1',
|
||||
voucherSeries: 'B',
|
||||
voucherNumber: 43,
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(
|
||||
@@ -458,9 +654,10 @@ describe('POST batch-categorize', () => {
|
||||
user_id: 'user-1',
|
||||
fiscal_period_id: 'period-1',
|
||||
voucher_series: 'B',
|
||||
gap_start: 42,
|
||||
gap_end: 42,
|
||||
explanation: 'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.',
|
||||
gap_start: 43,
|
||||
gap_end: 43,
|
||||
explanation:
|
||||
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,12 +27,13 @@ import {
|
||||
validateTemplateForEntity,
|
||||
} from '@/lib/bookkeeping/booking-templates'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { recordVoucherGapExplanation } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
|
||||
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { getStructuredError } from '@/lib/errors/get-structured-error'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { EntityType, Transaction, TransactionCategory } from '@/types'
|
||||
@@ -117,6 +118,21 @@ interface Item {
|
||||
error?: { code: string; message: string; details?: unknown }
|
||||
}
|
||||
|
||||
function categorizeUpdateError(error: unknown): NonNullable<Item['error']> {
|
||||
const structured = getStructuredError(error)
|
||||
if (structured.code === 'TX_CATEGORIZE_IGNORED_CONFLICT') {
|
||||
const mapped = getErrorEntry(structured.code)
|
||||
return {
|
||||
code: structured.code,
|
||||
message: mapped?.message_sv ?? 'Transaktionens tillstånd ändrades samtidigt.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: getErrorMessage(error),
|
||||
}
|
||||
}
|
||||
|
||||
async function categorizeOne(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
@@ -277,7 +293,7 @@ async function categorizeOne(
|
||||
if (transaction.journal_entry_id) {
|
||||
const { error: updateErr } = await supabase
|
||||
.from('transactions')
|
||||
.update({ is_business, category: finalCategory })
|
||||
.update({ is_business, category: finalCategory, is_ignored: false })
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
if (updateErr) {
|
||||
@@ -285,7 +301,7 @@ async function categorizeOne(
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: { code: 'INTERNAL_ERROR', message: 'Failed to update flags.' },
|
||||
error: categorizeUpdateError(updateErr),
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -367,62 +383,39 @@ async function categorizeOne(
|
||||
.update({
|
||||
is_business,
|
||||
category: finalCategory,
|
||||
is_ignored: false,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
.select('*')
|
||||
if (updateErr) {
|
||||
if (journalEntryId) {
|
||||
await reverseOrphanedJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId,
|
||||
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
||||
)
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: { code: 'INTERNAL_ERROR', message: getErrorMessage(updateErr) },
|
||||
error: categorizeUpdateError(updateErr),
|
||||
}
|
||||
}
|
||||
if ((!updated || updated.length === 0) && journalEntryId) {
|
||||
// CAS race: storno the orphan (BFL 5 kap 5 §). Direct statusflip
|
||||
// would be blocked by enforce_journal_entry_immutability since the
|
||||
// engine writes the JE as posted. Same fix as the single :categorize
|
||||
// route. Storno keeps the verifikationsnummer series unbroken.
|
||||
try {
|
||||
await reverseEntry(supabase, companyId, userId, journalEntryId)
|
||||
} catch (revErr) {
|
||||
log.error('batch-categorize TX_CATEGORIZE_RACE: failed to storno orphaned JE', revErr as Error, {
|
||||
request_index: index,
|
||||
orphanJournalEntryId: journalEntryId,
|
||||
})
|
||||
// Document the gap so the orphan is traceable per BFL 5 kap 5 §.
|
||||
try {
|
||||
const { data: orphan } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (orphan && orphan.voucher_series) {
|
||||
// Same rationale as the single :categorize route: skip the gap row
|
||||
// when no series exists rather than filing under a fallback series
|
||||
// that an audit query won't find. The insert itself lives in the
|
||||
// shared helper, which owns the real voucher_gap_explanations
|
||||
// column set (gap_start/gap_end/user_id) and logs failures loudly.
|
||||
await recordVoucherGapExplanation(supabase, {
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId: orphan.fiscal_period_id,
|
||||
voucherSeries: orphan.voucher_series,
|
||||
voucherNumber: orphan.voucher_number,
|
||||
explanation:
|
||||
'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.',
|
||||
})
|
||||
}
|
||||
} catch (gapErr) {
|
||||
log.error('batch-categorize: failed to look up the orphan for its gap explanation', gapErr as Error, {
|
||||
request_index: index,
|
||||
orphanJournalEntryId: journalEntryId,
|
||||
})
|
||||
}
|
||||
if (!updated || updated.length === 0) {
|
||||
if (journalEntryId) {
|
||||
await reverseOrphanedJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId,
|
||||
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
||||
)
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -432,6 +425,8 @@ async function categorizeOne(
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTransaction = updated[0] as Transaction
|
||||
|
||||
// Propagate the underlag onto the new verifikat: anchor the transaction's
|
||||
// pinned document and stamp matched inbox items. Same shared step as the
|
||||
// single :categorize route and every dashboard booking path; best-effort
|
||||
@@ -445,7 +440,7 @@ async function categorizeOne(
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user