@@ -1,8 +1,19 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
|
||||
const { reverseEntryMock } = vi.hoisted(() => ({
|
||||
reverseEntryMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
reverseEntry: reverseEntryMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
cancelOrphanedPaymentEntry,
|
||||
recordVoucherGapExplanation,
|
||||
reverseOrphanedJournalEntry,
|
||||
} from '../cancel-orphaned-entry'
|
||||
import { withUnusedVoucherAllocation } from '../errors'
|
||||
|
||||
// The real voucher_gap_explanations column set (supabase/migrations/
|
||||
// 20260402100100_voucher_gap_explanations.sql). company_id, user_id,
|
||||
@@ -55,6 +66,11 @@ function createMockSupabase(opts: {
|
||||
return { supabase, updates, inserts }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reverseEntryMock.mockReset()
|
||||
reverseEntryMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('recordVoucherGapExplanation', () => {
|
||||
it('writes exactly the real NOT NULL columns, with the single voucher as a closed gap range', async () => {
|
||||
const { supabase, inserts } = createMockSupabase({})
|
||||
@@ -143,6 +159,77 @@ describe('recordVoucherGapExplanation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reverseOrphanedJournalEntry', () => {
|
||||
it('routes posted-orphan compensation through engine storno', async () => {
|
||||
const { supabase, inserts, updates } = createMockSupabase({})
|
||||
|
||||
await reverseOrphanedJournalEntry(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-1',
|
||||
'Manuell avstämning krävs.',
|
||||
)
|
||||
|
||||
expect(reverseEntryMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-1',
|
||||
)
|
||||
expect(updates).toEqual([])
|
||||
expect(inserts['voucher_gap_explanations']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('documents only the exact unused reversal voucher exposed by the engine', async () => {
|
||||
const { supabase, inserts } = createMockSupabase({})
|
||||
reverseEntryMock.mockRejectedValueOnce(
|
||||
withUnusedVoucherAllocation(new Error('account lookup failed'), {
|
||||
fiscalPeriodId: 'fp-1',
|
||||
voucherSeries: 'B',
|
||||
voucherNumber: 67,
|
||||
}),
|
||||
)
|
||||
|
||||
await reverseOrphanedJournalEntry(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-1',
|
||||
'Manuell avstämning krävs.',
|
||||
)
|
||||
|
||||
expect(inserts['voucher_gap_explanations']).toEqual([
|
||||
{
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'B',
|
||||
gap_start: 67,
|
||||
gap_end: 67,
|
||||
explanation: 'Manuell avstämning krävs.',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not mislabel the original posted voucher when storno failure has no unused allocation', async () => {
|
||||
const { supabase, inserts } = createMockSupabase({
|
||||
orphan: { fiscal_period_id: 'fp-1', voucher_series: 'B', voucher_number: 66 },
|
||||
})
|
||||
reverseEntryMock.mockRejectedValueOnce(new Error('period locked'))
|
||||
|
||||
await reverseOrphanedJournalEntry(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-1',
|
||||
'Manuell avstämning krävs.',
|
||||
)
|
||||
|
||||
expect(inserts['voucher_gap_explanations']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelOrphanedPaymentEntry', () => {
|
||||
it('cancels the voucher and records a gap explanation', async () => {
|
||||
const { supabase, updates, inserts } = createMockSupabase({
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine'
|
||||
import { BookkeepingDatabaseError, AccountsNotInChartError, CannotReverseStornoError } from '../errors'
|
||||
import {
|
||||
AccountsNotInChartError,
|
||||
BookkeepingDatabaseError,
|
||||
CannotReverseStornoError,
|
||||
getUnusedVoucherAllocation,
|
||||
} from '../errors'
|
||||
import type { CreateJournalEntryLineInput, JournalEntryStatus } from '@/types'
|
||||
|
||||
// Mock Supabase client for createDraftEntry/reverseEntry tests
|
||||
@@ -612,6 +617,141 @@ describe('reverseEntry: entry_date defaults to original entry date', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reverseEntry: unused voucher allocation', () => {
|
||||
it('exposes the exact allocated number when account resolution fails before the reversal insert', async () => {
|
||||
const original = {
|
||||
id: 'entry-1',
|
||||
company_id: 'company-1',
|
||||
status: 'posted',
|
||||
fiscal_period_id: 'period-1',
|
||||
voucher_series: 'B',
|
||||
voucher_number: 41,
|
||||
entry_date: '2024-11-15',
|
||||
description: 'Hyra november',
|
||||
source_type: 'manual',
|
||||
source_id: null,
|
||||
lines: [
|
||||
{ account_number: '5010', debit_amount: 10000, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 10000 },
|
||||
],
|
||||
}
|
||||
const supabase = {
|
||||
rpc: vi.fn().mockResolvedValue({ data: 42, error: null }),
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'journal_entries') {
|
||||
const chain = createMockChain({ singleData: original })
|
||||
return chain
|
||||
}
|
||||
if (table === 'chart_of_accounts') {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'in']) {
|
||||
chain[method] = vi.fn().mockReturnValue(chain)
|
||||
}
|
||||
chain.then = (resolve: (value: unknown) => void) =>
|
||||
resolve({ data: null, error: { message: 'account lookup failed' } })
|
||||
return chain
|
||||
}
|
||||
return createMockChain()
|
||||
}),
|
||||
}
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(BookkeepingDatabaseError)
|
||||
expect(getUnusedVoucherAllocation(caught)).toEqual({
|
||||
fiscalPeriodId: 'period-1',
|
||||
voucherSeries: 'B',
|
||||
voucherNumber: 42,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not label a preserved cancelled reversal header as an unused voucher', async () => {
|
||||
const original = {
|
||||
id: 'entry-1',
|
||||
company_id: 'company-1',
|
||||
status: 'posted',
|
||||
fiscal_period_id: 'period-1',
|
||||
voucher_series: 'B',
|
||||
voucher_number: 41,
|
||||
entry_date: '2024-11-15',
|
||||
description: 'Hyra november',
|
||||
source_type: 'manual',
|
||||
source_id: null,
|
||||
lines: [
|
||||
{ account_number: '5010', debit_amount: 10000, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 10000 },
|
||||
],
|
||||
}
|
||||
const reversal = { id: 'reversal-1', reverses_id: 'entry-1' }
|
||||
const cancelUpdate = vi.fn()
|
||||
const deleteLines = vi.fn()
|
||||
let journalEntryCall = 0
|
||||
let journalLineCall = 0
|
||||
|
||||
const supabase = {
|
||||
rpc: vi.fn().mockResolvedValue({ data: 42, error: null }),
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'journal_entries') {
|
||||
journalEntryCall += 1
|
||||
if (journalEntryCall === 1) return createMockChain({ singleData: original })
|
||||
if (journalEntryCall === 2) return createMockChain({ singleData: reversal })
|
||||
return {
|
||||
update: cancelUpdate.mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'chart_of_accounts') {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'in']) {
|
||||
chain[method] = vi.fn().mockReturnValue(chain)
|
||||
}
|
||||
chain.then = (resolve: (value: unknown) => void) =>
|
||||
resolve({
|
||||
data: [
|
||||
{ id: 'acc-5010', account_number: '5010' },
|
||||
{ id: 'acc-1930', account_number: '1930' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
return chain
|
||||
}
|
||||
if (table === 'journal_entry_lines') {
|
||||
journalLineCall += 1
|
||||
if (journalLineCall === 1) {
|
||||
return {
|
||||
insert: vi.fn().mockResolvedValue({ error: { message: 'line insert failed' } }),
|
||||
}
|
||||
}
|
||||
return {
|
||||
delete: deleteLines.mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return createMockChain()
|
||||
}),
|
||||
}
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(BookkeepingDatabaseError)
|
||||
expect(getUnusedVoucherAllocation(caught)).toBeNull()
|
||||
expect(cancelUpdate).toHaveBeenCalledWith({ status: 'cancelled' })
|
||||
expect(deleteLines).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reverseEntry: storno guard', () => {
|
||||
// BFL 5 kap 5§: a storno-of-a-storno makes the original verifikat's
|
||||
// cancellation chain ambiguous, so stornos are never reversible. A
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { getUnusedVoucherAllocation } from '@/lib/bookkeeping/errors'
|
||||
|
||||
const log = createLogger('cancel-orphaned-entry')
|
||||
|
||||
@@ -71,6 +73,49 @@ export async function recordVoucherGapExplanation(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storno a posted journal entry that could not be linked to its transaction.
|
||||
*
|
||||
* The bookkeeping engine posts entries before the transaction CAS runs. When
|
||||
* that CAS definitively fails, the entry is immutable and must be reversed,
|
||||
* never edited or cancelled in place. Compensation is best-effort so the
|
||||
* caller can preserve the original conflict response.
|
||||
*/
|
||||
export async function reverseOrphanedJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
journalEntryId: string,
|
||||
gapExplanation: string,
|
||||
): Promise<void> {
|
||||
let unusedVoucher: ReturnType<typeof getUnusedVoucherAllocation> = null
|
||||
try {
|
||||
await reverseEntry(supabase, companyId, userId, journalEntryId)
|
||||
return
|
||||
} catch (reverseError) {
|
||||
unusedVoucher = getUnusedVoucherAllocation(reverseError)
|
||||
log.error('failed to storno orphaned journal entry', reverseError as Error, {
|
||||
companyId,
|
||||
journalEntryId,
|
||||
unusedVoucher,
|
||||
})
|
||||
}
|
||||
|
||||
// The original posted voucher is live accounting evidence, never a gap.
|
||||
// Only the engine can identify an exact reversal number that its durable
|
||||
// sequence allocated before a reversal row existed.
|
||||
if (!unusedVoucher) return
|
||||
|
||||
await recordVoucherGapExplanation(supabase, {
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId: unusedVoucher.fiscalPeriodId,
|
||||
voucherSeries: unusedVoucher.voucherSeries,
|
||||
voucherNumber: unusedVoucher.voucherNumber,
|
||||
explanation: gapExplanation,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compensation for the payment-flow CAS guard: a payment voucher was posted,
|
||||
* but the invoice row was settled by a concurrent request between our read
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
EntryAlreadyReversedError,
|
||||
EntryDateOutsideFiscalPeriodError,
|
||||
FiscalPeriodNotFoundError,
|
||||
withUnusedVoucherAllocation,
|
||||
JournalEntryNotBalancedError,
|
||||
JournalEntryNotFoundError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
@@ -1122,18 +1123,33 @@ export async function reverseEntry(
|
||||
original.fiscal_period_id,
|
||||
original.voucher_series || 'A'
|
||||
)
|
||||
const unusedVoucherAllocation = {
|
||||
fiscalPeriodId: original.fiscal_period_id,
|
||||
voucherSeries: original.voucher_series || 'A',
|
||||
voucherNumber,
|
||||
}
|
||||
|
||||
// Resolve account IDs: include inactive rows. The accounts on the
|
||||
// original committed entry were active at commit time; if the user has
|
||||
// since toggled one off, the storno must still be allowed to go through
|
||||
// (BFL 5 kap 5§). Only a truly missing chart row (rare: would require
|
||||
// the row to have been deleted) still throws AccountsNotInChartError.
|
||||
const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines, { includeInactive: true })
|
||||
let accountIdMap: Map<string, string>
|
||||
try {
|
||||
accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines, { includeInactive: true })
|
||||
} catch (resolveError) {
|
||||
// The sequence RPC committed, but no reversal row exists yet. Carry the
|
||||
// exact unused number so the caller can document this real gap.
|
||||
throw withUnusedVoucherAllocation(resolveError, unusedVoucherAllocation)
|
||||
}
|
||||
|
||||
const reversalAccountNumbers = [...new Set(reversedLines.map(l => l.account_number))]
|
||||
const missingReversalAccounts = reversalAccountNumbers.filter(num => !accountIdMap.has(num))
|
||||
if (missingReversalAccounts.length > 0) {
|
||||
throw new AccountsNotInChartError(missingReversalAccounts)
|
||||
throw withUnusedVoucherAllocation(
|
||||
new AccountsNotInChartError(missingReversalAccounts),
|
||||
unusedVoucherAllocation,
|
||||
)
|
||||
}
|
||||
|
||||
// Create reversal entry with reverses_id link
|
||||
@@ -1155,8 +1171,18 @@ export async function reverseEntry(
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (reversalError || !reversalEntry) {
|
||||
throw new BookkeepingDatabaseError('create_reversal_entry', reversalError?.message)
|
||||
if (reversalError) {
|
||||
// PostgreSQL rejected the insert, so the allocated number is confirmed
|
||||
// unused and can be explained without guessing from the original entry.
|
||||
throw withUnusedVoucherAllocation(
|
||||
new BookkeepingDatabaseError('create_reversal_entry', reversalError.message),
|
||||
unusedVoucherAllocation,
|
||||
)
|
||||
}
|
||||
if (!reversalEntry) {
|
||||
// No database error means the outcome is ambiguous. Do not label the
|
||||
// number unused unless the engine has a confirmed failure state.
|
||||
throw new BookkeepingDatabaseError('create_reversal_entry', undefined)
|
||||
}
|
||||
|
||||
// Insert reversal lines with dimensions
|
||||
@@ -1167,6 +1193,9 @@ export async function reverseEntry(
|
||||
.insert(lineInserts)
|
||||
|
||||
if (linesError) {
|
||||
// Keep the reversal header as cancelled bookkeeping evidence. The gap
|
||||
// detector counts every non-draft header, including cancelled rows, so
|
||||
// this allocation is still used and must not be labelled as a gap.
|
||||
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
|
||||
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
|
||||
throw new BookkeepingDatabaseError('create_reversal_lines', linesError.message)
|
||||
@@ -1179,6 +1208,8 @@ export async function reverseEntry(
|
||||
.eq('id', reversalEntry.id)
|
||||
|
||||
if (postError) {
|
||||
// As above, cleanup preserves the allocated voucher on the cancelled
|
||||
// header. A failed or ambiguous cleanup also cannot prove it unused.
|
||||
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
|
||||
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
|
||||
throw new BookkeepingDatabaseError('post_reversal_entry', postError.message)
|
||||
@@ -1198,6 +1229,7 @@ export async function reverseEntry(
|
||||
if (casError || !updatedOriginal || updatedOriginal.length === 0) {
|
||||
// Another concurrent reversal already changed the status: mark the orphaned
|
||||
// reversal as cancelled so it's excluded from reports but remains traceable.
|
||||
// Its header still occupies the voucher number, so no gap metadata applies.
|
||||
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
|
||||
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
|
||||
throw new EntryAlreadyReversedError()
|
||||
|
||||
@@ -341,6 +341,39 @@ export class BookkeepingDatabaseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface UnusedVoucherAllocation {
|
||||
fiscalPeriodId: string
|
||||
voucherSeries: string
|
||||
voucherNumber: number
|
||||
}
|
||||
|
||||
const UNUSED_VOUCHER_ALLOCATION = Symbol('unused-voucher-allocation')
|
||||
|
||||
/**
|
||||
* Preserve the exact durable sequence allocation when an engine operation
|
||||
* fails before any journal-entry row uses the number. The original error type
|
||||
* is retained so existing API mappings remain unchanged.
|
||||
*/
|
||||
export function withUnusedVoucherAllocation<T>(
|
||||
error: T,
|
||||
allocation: UnusedVoucherAllocation,
|
||||
): T {
|
||||
if (error instanceof Error) {
|
||||
Object.defineProperty(error, UNUSED_VOUCHER_ALLOCATION, {
|
||||
value: allocation,
|
||||
enumerable: false,
|
||||
})
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
export function getUnusedVoucherAllocation(error: unknown): UnusedVoucherAllocation | null {
|
||||
if (!(error instanceof Error)) return null
|
||||
return (
|
||||
error as Error & { [UNUSED_VOUCHER_ALLOCATION]?: UnusedVoucherAllocation }
|
||||
)[UNUSED_VOUCHER_ALLOCATION] ?? null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type guard
|
||||
// ============================================================================
|
||||
|
||||
@@ -102,6 +102,31 @@ describe('errorResponse', () => {
|
||||
expect(body.error.details).toMatchObject({ pgCode: '23505' })
|
||||
})
|
||||
|
||||
it('maps the ignored-transaction journal constraint to a typed conflict', async () => {
|
||||
const pgErr = Object.assign(
|
||||
new Error(
|
||||
'new row for relation "transactions" violates check constraint "transactions_is_ignored_no_journal_entry"',
|
||||
),
|
||||
{ code: '23514' },
|
||||
)
|
||||
const res = errorResponse(pgErr, noopLogger, { requestId: 'req_ignored_tx' })
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_IGNORED_CONFLICT')
|
||||
expect(body.error.message).not.toContain('check constraint')
|
||||
expect(body.error.details).toMatchObject({ pgCode: '23514' })
|
||||
})
|
||||
|
||||
it('does not apply unrelated message heuristics to Postgres errors', async () => {
|
||||
const pgErr = Object.assign(new Error('Invoice not found'), { code: 'P0001' })
|
||||
const res = errorResponse(pgErr, noopLogger, { requestId: 'req_pg_unrelated' })
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
})
|
||||
|
||||
it('maps Postgres no-data-found to NOT_FOUND with pgCode', async () => {
|
||||
const pgErr = Object.assign(new Error('invoice not found'), { code: 'P0002' })
|
||||
const res = errorResponse(pgErr, noopLogger, { requestId: 'req_pg_not_found' })
|
||||
|
||||
@@ -189,7 +189,9 @@ export function getStructuredError(
|
||||
const message_sv = getErrorMessage(error)
|
||||
|
||||
const transient = isTransientFailure(error, message_en)
|
||||
let code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
|
||||
let code = isIgnoredTransactionJournalConstraint(error)
|
||||
? 'TX_CATEGORIZE_IGNORED_CONFLICT'
|
||||
: extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
|
||||
// Nothing more specific matched but the failure is transient: surface the
|
||||
// stable TRANSIENT_ERROR code so agents can dispatch on it.
|
||||
if (code === 'UNKNOWN_ERROR' && transient) code = 'TRANSIENT_ERROR'
|
||||
@@ -313,6 +315,13 @@ function isPostgresError(err: unknown): err is { code: string; message: string }
|
||||
)
|
||||
}
|
||||
|
||||
export function isIgnoredTransactionJournalConstraint(error: unknown): boolean {
|
||||
return (
|
||||
isPostgresError(error) &&
|
||||
/transactions_is_ignored_no_journal_entry/i.test(error.message)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical REST error envelope for any thrown value.
|
||||
*
|
||||
@@ -358,7 +367,9 @@ export function errorResponse(
|
||||
|
||||
// 3. Postgres errors
|
||||
if (isPostgresError(err)) {
|
||||
const mapped = postgresCodeToStructured(err.code)
|
||||
const mapped = isIgnoredTransactionJournalConstraint(err)
|
||||
? 'TX_CATEGORIZE_IGNORED_CONFLICT'
|
||||
: postgresCodeToStructured(err.code)
|
||||
if (mapped) {
|
||||
const entry = entryFor(mapped)
|
||||
logAtLevel(log, entry.httpStatus, 'database error', err as unknown as Error, {
|
||||
|
||||
@@ -447,6 +447,16 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.',
|
||||
message_en: 'Transaction was already categorized by another request.',
|
||||
},
|
||||
TX_CATEGORIZE_IGNORED_CONFLICT: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Transaktionen är fortfarande markerad som ignorerad och kan därför inte kopplas till en verifikation.',
|
||||
message_en:
|
||||
'The transaction is still marked as ignored and cannot be linked to a journal entry.',
|
||||
remediation: {
|
||||
description: 'Reload and retry categorization. Report the conflict if it persists.',
|
||||
},
|
||||
},
|
||||
TX_CATEGORIZE_SUGGEST_SI_MATCH: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
|
||||
@@ -120,7 +120,19 @@ describe('commit duplicate guard: categorize_transaction (reverse / book the ban
|
||||
{ data: { id: 'op-1' } },
|
||||
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: [] },
|
||||
{ data: [] }, // no fiscal period yet
|
||||
{ data: null }, // fiscal-period upsert
|
||||
{ data: null }, // journal-entry period lookup: partial categorization path
|
||||
{
|
||||
data: [{
|
||||
id: 'tx-1',
|
||||
date: '2026-03-26',
|
||||
amount: 98565,
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
is_ignored: false,
|
||||
}],
|
||||
}, // guarded transaction update matched
|
||||
])
|
||||
|
||||
const op = makePendingOp({
|
||||
@@ -156,7 +168,19 @@ describe('commit duplicate guard: categorize_transaction (reverse / book the ban
|
||||
{ data: { id: 'op-1' } },
|
||||
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: [] },
|
||||
{ data: [] }, // no fiscal period yet
|
||||
{ data: null }, // fiscal-period upsert
|
||||
{ data: null }, // journal-entry period lookup: partial categorization path
|
||||
{
|
||||
data: [{
|
||||
id: 'tx-1',
|
||||
date: '2026-03-26',
|
||||
amount: 98565,
|
||||
cash_account_id: null,
|
||||
journal_entry_id: null,
|
||||
is_ignored: false,
|
||||
}],
|
||||
}, // guarded transaction update matched
|
||||
])
|
||||
|
||||
const op = makePendingOp({
|
||||
|
||||
@@ -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