@@ -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
|
||||
|
||||
Reference in New Issue
Block a user