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
@@ -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,
@@ -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,