@@ -234,7 +234,7 @@ describe('bulkBookMatchedInboxItems: booking', () => {
|
||||
// 4. ensureFiscalPeriod → existing period
|
||||
{ data: [{ id: 'fp-1' }] },
|
||||
// 5. transactions update (mark booked)
|
||||
{ error: null },
|
||||
{ data: [{ id: 'tx-1' }], error: null },
|
||||
// 6. propagation select (no matched inbox rows to stamp in this mock)
|
||||
{ data: [] },
|
||||
])
|
||||
@@ -275,7 +275,7 @@ describe('bulkBookMatchedInboxItems: booking', () => {
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: { ledger_account: '1931' } },
|
||||
{ data: [{ id: 'fp-1' }] },
|
||||
{ error: null },
|
||||
{ data: [{ id: 'tx-1' }], error: null },
|
||||
{ data: [] },
|
||||
])
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('bulkBookMatchedInboxItems: booking', () => {
|
||||
{ data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: [{ id: 'fp-1' }] },
|
||||
{ error: null },
|
||||
{ data: [{ id: 'tx-1' }], error: null },
|
||||
{ data: [] },
|
||||
])
|
||||
|
||||
@@ -346,7 +346,7 @@ describe('bulkBookMatchedInboxItems: booking', () => {
|
||||
{ data: { id: 'tx-2', date: '2026-06-02', amount: -25, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: [{ id: 'fp-1' }] },
|
||||
{ error: null },
|
||||
{ data: [{ id: 'tx-2' }], error: null },
|
||||
{ data: [] },
|
||||
])
|
||||
|
||||
@@ -382,7 +382,7 @@ describe('bulkBookMatchedInboxItems: WhatsApp channel-context notes threading',
|
||||
{ data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: [{ id: 'fp-1' }] },
|
||||
{ error: null },
|
||||
{ data: [{ id: 'tx-1' }], error: null },
|
||||
{ data: [] },
|
||||
]
|
||||
|
||||
@@ -537,7 +537,7 @@ describe('bulkBookMatchedInboxItems: intra-batch duplicate handling', () => {
|
||||
{ data: { id: txId, date: '2026-06-01', amount, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: [{ id: 'fp-1' }] },
|
||||
{ error: null },
|
||||
{ data: [{ id: txId }], error: null },
|
||||
{ data: { document_id: null } }, // propagation: tx pin lookup
|
||||
{ data: [] }, // propagation: matched inbox items
|
||||
]
|
||||
|
||||
@@ -12,9 +12,13 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const mockCreateJE = vi.fn()
|
||||
const mockReverseOrphanedJE = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
|
||||
reverseOrphanedJournalEntry: (...args: unknown[]) => mockReverseOrphanedJE(...args),
|
||||
}))
|
||||
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
|
||||
detectBookingDuplicate: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
@@ -57,16 +61,77 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mockCreateJE.mockResolvedValue({ id: 'je-override-1' })
|
||||
mockReverseOrphanedJE.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('categorizeMatchedTransaction: accountOverride', () => {
|
||||
it('atomically unignores the transaction when categorizing it', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
const categorizedHandler = vi.fn()
|
||||
eventBus.on('transaction.categorized', categorizedHandler)
|
||||
enqueue({ data: txRow({ is_ignored: true }) })
|
||||
enqueue({ data: settingsRow })
|
||||
enqueue({ data: [{ id: 'fp-1' }] })
|
||||
enqueue({
|
||||
data: [txRow({
|
||||
is_business: false,
|
||||
category: 'private',
|
||||
is_ignored: false,
|
||||
journal_entry_id: 'je-override-1',
|
||||
})],
|
||||
})
|
||||
|
||||
const result = await categorizeMatchedTransaction(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
TX_ID,
|
||||
{ category: 'private' },
|
||||
)
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(findCalls('transactions', 'update')).toContainEqual([
|
||||
expect.objectContaining({
|
||||
is_business: false,
|
||||
category: 'private',
|
||||
is_ignored: false,
|
||||
journal_entry_id: 'je-override-1',
|
||||
}),
|
||||
])
|
||||
expect(categorizedHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
transaction: expect.objectContaining({ is_ignored: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a race conflict when the guarded update matches no row without creating an entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: txRow() })
|
||||
enqueue({ data: settingsRow })
|
||||
enqueue({ data: [{ id: 'fp-1' }] })
|
||||
enqueue({ data: [] })
|
||||
mockCreateJE.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await categorizeMatchedTransaction(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
TX_ID,
|
||||
{ category: 'private' },
|
||||
)
|
||||
|
||||
expect(result.status).toBe(409)
|
||||
expect(mockReverseOrphanedJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts the entry with the override on the business side', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: txRow() }) // transactions select
|
||||
enqueue({ data: settingsRow }) // company_settings
|
||||
enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } }) // override chart hit
|
||||
enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod: open period exists
|
||||
enqueue({ data: null }) // transactions update
|
||||
enqueue({ data: [{ id: TX_ID }] }) // transactions update
|
||||
|
||||
const result = await categorizeMatchedTransaction(
|
||||
supabase as never, 'user-1', 'company-1', TX_ID,
|
||||
@@ -90,7 +155,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => {
|
||||
enqueue({ data: settingsRow })
|
||||
enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } })
|
||||
enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod
|
||||
enqueue({ data: null }) // transactions update
|
||||
enqueue({ data: [{ id: TX_ID }] }) // transactions update
|
||||
|
||||
const result = await categorizeMatchedTransaction(
|
||||
supabase as never, 'user-1', 'company-1', TX_ID,
|
||||
@@ -138,4 +203,62 @@ describe('categorizeMatchedTransaction: accountOverride', () => {
|
||||
expect(result.error).toMatch(/private/)
|
||||
expect(mockCreateJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stornos a posted entry when the ignored-row constraint rejects the link', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: txRow({ is_ignored: true }) })
|
||||
enqueue({ data: settingsRow })
|
||||
enqueue({ data: [{ id: 'fp-1' }] })
|
||||
enqueue({
|
||||
data: null,
|
||||
error: {
|
||||
code: '23514',
|
||||
message:
|
||||
'new row for relation "transactions" violates check constraint "transactions_is_ignored_no_journal_entry"',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await categorizeMatchedTransaction(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
TX_ID,
|
||||
{ category: 'private' },
|
||||
)
|
||||
|
||||
expect(result.status).toBe(409)
|
||||
expect(mockReverseOrphanedJE).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-override-1',
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a company-scoped CAS and stornos a concurrent loser', async () => {
|
||||
const { supabase, enqueue, calls } = createQueuedMockSupabase()
|
||||
enqueue({ data: txRow() })
|
||||
enqueue({ data: settingsRow })
|
||||
enqueue({ data: [{ id: 'fp-1' }] })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const result = await categorizeMatchedTransaction(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
TX_ID,
|
||||
{ category: 'private' },
|
||||
)
|
||||
|
||||
expect(result.status).toBe(409)
|
||||
expect(mockReverseOrphanedJE).toHaveBeenCalledTimes(1)
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: 'transactions', method: 'eq', args: ['id', TX_ID] },
|
||||
{ table: 'transactions', method: 'eq', args: ['company_id', 'company-1'] },
|
||||
{ table: 'transactions', method: 'is', args: ['journal_entry_id', null] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import { applyAccountOverride } from '@/lib/bookkeeping/account-override'
|
||||
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes'
|
||||
@@ -40,6 +41,7 @@ import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
|
||||
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { getStructuredError } from '@/lib/errors/get-structured-error'
|
||||
import type { InboxChannelContext, Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
|
||||
|
||||
const log = createLogger('transactions/categorize-core')
|
||||
@@ -232,9 +234,9 @@ export async function categorizeMatchedTransaction(
|
||||
// must not block re-categorization: the row reads as "utan koppling" in the
|
||||
// UI, so a fresh booking has to be allowed (issue #988). Only a live posted
|
||||
// link means it was genuinely categorized in the meantime. The UPDATE below
|
||||
// is unconditional (no null-lock), so it overwrites the stale pointer; the
|
||||
// duplicate guard still catches an existing live correction and steers the
|
||||
// user to link instead.
|
||||
// uses the observed stale pointer as its CAS value, so it only replaces the
|
||||
// pointer if no concurrent request changed it. The duplicate guard still
|
||||
// catches an existing live correction and steers the user to link instead.
|
||||
if (
|
||||
transaction.journal_entry_id &&
|
||||
(await hasLiveJournalEntryLink(supabase, companyId, transaction.journal_entry_id))
|
||||
@@ -392,16 +394,55 @@ export async function categorizeMatchedTransaction(
|
||||
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
const updateQuery = supabase
|
||||
.from('transactions')
|
||||
.update({ is_business: isBusiness, category, journal_entry_id: journalEntryId })
|
||||
.update({
|
||||
is_business: isBusiness,
|
||||
category,
|
||||
is_ignored: false,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', txId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
const guardedUpdate = transaction.journal_entry_id
|
||||
? updateQuery.eq('journal_entry_id', transaction.journal_entry_id)
|
||||
: updateQuery.is('journal_entry_id', null)
|
||||
|
||||
const { data: updateResult, error: updateError } = await guardedUpdate.select('*')
|
||||
|
||||
if (updateError) {
|
||||
log.error('Failed to update transaction:', updateError)
|
||||
return { error: 'Failed to update transaction', status: 500 }
|
||||
if (journalEntryId) {
|
||||
await reverseOrphanedJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId,
|
||||
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
||||
)
|
||||
}
|
||||
const structured = getStructuredError(updateError)
|
||||
return structured.code === 'TX_CATEGORIZE_IGNORED_CONFLICT'
|
||||
? { error: structured.message_sv, status: 409 }
|
||||
: { error: 'Failed to update transaction', status: 500 }
|
||||
}
|
||||
|
||||
if (!updateResult || updateResult.length === 0) {
|
||||
if (journalEntryId) {
|
||||
await reverseOrphanedJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId,
|
||||
'Kategoriseringsverifikation utan transaktionskoppling; automatisk storno misslyckades. Manuell avstämning krävs.',
|
||||
)
|
||||
}
|
||||
return { error: 'Transaction was categorized by another request.', status: 409 }
|
||||
}
|
||||
|
||||
const updatedTransaction = updateResult[0] as Transaction
|
||||
|
||||
// Propagate the underlag from matched invoice-inbox items onto the new
|
||||
// verifikation and stamp them consumed (BFL 7 kap): shared with the other
|
||||
// booking paths, see lib/transactions/inbox-underlag.ts. Best-effort: the
|
||||
@@ -419,7 +460,7 @@ export async function categorizeMatchedTransaction(
|
||||
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