Files
accounted/lib/bookkeeping/accruals/__tests__/service.test.ts
T
31fba5e8f9 fix(accruals): carry origin dimensions onto dissolution entries (#1419)
* fix(accruals): carry origin dimensions onto dissolution entries

A project-tagged deferred invoice line lost its dimensions bag on every
monthly dissolution: the schedule never stored the bag, so dissolution
lines booked untagged and the per-project P&L drifted from the origin.

Persist the merged bag (invoice default_dimensions with the item bag on
top, same merge the origin generators use) on accrual_schedules and
attach it to BOTH dissolution lines: both origin generators tag the
interim 17xx/29xx line too, so per-dimension views of the interim
account keep netting to zero. Pre-existing schedules stay at '{}' and
keep today's untagged behavior; backfill is a separate follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(accruals): let dissolutions post when the tagged dimension value is archived

Carrying the origin's dimensions bag onto dissolution lines pulled every
monthly periodisering into validateEntryDimensions. With dimensions_enabled,
a project archived after the origin invoice was booked made the validator
reject the dissolution: the accrual service caught the rejection, wrote
last_error and left the installment pending forever. The remaining months of
prepaid cost would never reach 5xxx/6xxx, the interim 17xx/29xx account would
stay overstated, and the trial balance still balances, so no year-end check
fires and the arsredovisning is filed with understated cost.

Add a second, narrow exempt set (DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES =
{'accrual'}) with an isDimensionValidationExemptSource() helper, and skip the
soft registry validation for it in createDraftEntry. A dissolution is the
mechanical continuation of an already-approved, already-posted decision: the
same category as a storno, which the engine already bypasses.

The tag is kept, never stripped: an archived value still exists in the
registry and the cost genuinely belongs to that project. The rules set
(required/default/fixed) stays untouched, and the other two
validateEntryDimensions call sites keep validating: updateDraftEntry is a
user edit of an editable draft, and replaceOpeningBalanceEntry rejects any
source_type other than opening_balance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:04:20 +02:00

661 lines
22 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
cancelSchedulesForSource,
createAccrualSchedule,
dissolveScheduleNow,
postDueInstallments,
} from '@/lib/bookkeeping/accruals/service'
import {
createJournalEntry,
findFiscalPeriod,
reverseEntry,
} from '@/lib/bookkeeping/engine'
import {
CannotReverseNonPostedError,
EntryAlreadyReversedError,
} from '@/lib/bookkeeping/errors'
import {
ACCRUAL_NOTHING_TO_DISSOLVE,
ACCRUAL_SCHEDULE_NOT_ACTIVE,
ACCRUAL_SCHEDULE_NOT_FOUND,
} from '@/lib/bookkeeping/accruals/errors'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
findFiscalPeriod: vi.fn(),
reverseEntry: vi.fn(),
}))
const mockCreateJournalEntry = vi.mocked(createJournalEntry)
const mockFindFiscalPeriod = vi.mocked(findFiscalPeriod)
const mockReverseEntry = vi.mocked(reverseEntry)
const COMPANY = 'company-1'
const USER = 'user-1'
function makeSchedule(overrides: Record<string, unknown> = {}) {
return {
id: 'sched-1',
user_id: USER,
company_id: COMPANY,
direction: 'expense',
supplier_invoice_id: 'si-1',
supplier_invoice_item_id: 'sii-1',
invoice_id: null,
invoice_item_id: null,
balance_account: '1730',
target_account: '6310',
total_amount: 12000,
period_start: '2026-01-01',
period_end: '2026-12-31',
months: 12,
origin_journal_entry_id: 'je-origin',
posting_floor_date: '2026-01-15',
status: 'active',
description: 'Försäkring 2026',
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-01-15T00:00:00Z',
...overrides,
}
}
function makeInstallment(overrides: Record<string, unknown> = {}) {
return {
id: 'inst-1',
user_id: USER,
company_id: COMPANY,
schedule_id: 'sched-1',
period_month: '2026-01-01',
amount: 1000,
status: 'pending',
journal_entry_id: null,
posted_at: null,
last_error: null,
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-01-15T00:00:00Z',
schedule: makeSchedule(),
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-new' } as never)
mockReverseEntry.mockResolvedValue({ id: 'je-storno' } as never)
})
describe('postDueInstallments', () => {
it('posts a due expense installment with Dr target / Cr balance', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] }, // due installments
{ data: { bookkeeping_locked_through: null } }, // company_settings
{ data: [{ id: 'inst-1' }] }, // CAS claim
{ count: 0 }, // remaining pending
{ data: null }, // schedule -> completed
])
const result = await postDueInstallments(
supabase as unknown as SupabaseClient,
COMPANY,
{ userId: USER, today: '2026-01-20' },
)
expect(result).toMatchObject({ posted: 1, failed: 0, skipped: 0 })
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.source_type).toBe('accrual')
expect(input.source_id).toBe('sched-1')
// Floor: period month 2026-01-01 but origin entry dated 2026-01-15.
expect(input.entry_date).toBe('2026-01-15')
expect(input.lines).toEqual([
expect.objectContaining({ account_number: '6310', debit_amount: 1000, credit_amount: 0 }),
expect.objectContaining({ account_number: '1730', debit_amount: 0, credit_amount: 1000 }),
])
})
it('flips the lines for revenue schedules (Dr 29xx / Cr 3xxx)', async () => {
const revenueSchedule = makeSchedule({
id: 'sched-2',
direction: 'revenue',
supplier_invoice_id: null,
invoice_id: 'inv-1',
balance_account: '2970',
target_account: '3001',
posting_floor_date: '2026-01-01',
})
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment({ schedule_id: 'sched-2', schedule: revenueSchedule })] },
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-1' }] },
{ count: 1 }, // still pending months -> schedule stays active
])
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.lines).toEqual([
expect.objectContaining({ account_number: '2970', debit_amount: 1000, credit_amount: 0 }),
expect.objectContaining({ account_number: '3001', debit_amount: 0, credit_amount: 1000 }),
])
})
it('carries the schedule dimensions bag onto both dissolution lines', async () => {
const tagged = makeSchedule({ dimensions: { '1': 'KS01', '6': 'P001' } })
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment({ schedule: tagged })] },
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-1' }] },
{ count: 0 },
{ data: null },
])
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
const input = mockCreateJournalEntry.mock.calls[0][3]
// Both lines: the origin entry tags its interim 17xx/29xx line too, so
// per-dimension views of the interim account must net to zero.
expect(input.lines).toEqual([
expect.objectContaining({
account_number: '6310',
dimensions: { '1': 'KS01', '6': 'P001' },
}),
expect.objectContaining({
account_number: '1730',
dimensions: { '1': 'KS01', '6': 'P001' },
}),
])
})
it('adds no dimensions key when the schedule bag is empty', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment({ schedule: makeSchedule({ dimensions: {} }) })] },
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-1' }] },
{ count: 0 },
{ data: null },
])
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
for (const line of input.lines) {
expect(line).not.toHaveProperty('dimensions')
}
})
it('shifts the entry date past the company lock date', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment({ period_month: '2026-02-01' })] },
{ data: { bookkeeping_locked_through: '2026-03-31' } },
{ data: [{ id: 'inst-1' }] },
{ count: 0 },
{ data: null },
])
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-04-10',
})
expect(mockCreateJournalEntry.mock.calls[0][3].entry_date).toBe('2026-04-01')
})
it('skips months that have not begun yet', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [] }, // the .lte filter excludes future months server-side
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.posted).toBe(0)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('reverses its own entry when the CAS claim is lost', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: [] }, // CAS claim lost (another runner won)
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, skipped: 1, failed: 0 })
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-new',
)
})
it('reverses its own entry when the CAS claim UPDATE itself errors', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null, error: { message: 'connection reset' } }, // claim errored
{ data: null }, // last_error update in the catch
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, skipped: 0, failed: 1 })
expect(result.errors[0].message).toMatch(/Failed to mark installment posted/)
// Without the storno the next cron run would double-book the month.
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-new',
)
})
it('records the reversal failure too when storno after claim error fails', async () => {
mockReverseEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null, error: { message: 'connection reset' } }, // claim errored
{ data: null }, // last_error update in the catch
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.failed).toBe(1)
expect(result.errors[0].message).toMatch(/misslyckades också/)
expect(result.errors[0].message).toMatch(/Bokföringen är låst/)
})
it('clamps the posting date to the next open fiscal period when the date falls in a closed one', async () => {
mockFindFiscalPeriod.mockResolvedValueOnce(null) // 2026 period is closed
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: { id: 'fp-next', period_start: '2027-01-01' } }, // next open period
{ data: [{ id: 'inst-1' }] }, // CAS claim
{ count: 0 },
{ data: null },
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 1, failed: 0 })
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.entry_date).toBe('2027-01-01')
expect(input.fiscal_period_id).toBe('fp-next')
})
it('records an actionable last_error when no open period exists at or after the date', async () => {
mockFindFiscalPeriod.mockResolvedValueOnce(null)
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null }, // no open period after the date either
{ data: null }, // last_error update
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, failed: 1 })
expect(result.errors[0].message).toMatch(/eller senare/)
expect(result.errors[0].message).toMatch(/skapa nästa räkenskapsår/)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('records last_error and continues when posting fails', async () => {
mockCreateJournalEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: [
makeInstallment({ id: 'inst-1' }),
makeInstallment({ id: 'inst-2', period_month: '2026-02-01' }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: null }, // last_error update for inst-1
{ data: [{ id: 'inst-2' }] }, // CAS claim for inst-2
{ count: 0 },
{ data: null },
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-02-20',
})
expect(result).toMatchObject({ posted: 1, failed: 1 })
expect(result.errors[0]).toMatchObject({ installmentId: 'inst-1' })
})
it('ignores installments whose schedule is not active', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: [
makeInstallment({ schedule: makeSchedule({ status: 'cancelled' }) }),
],
},
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.posted).toBe(0)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
})
describe('createAccrualSchedule', () => {
const spec = {
direction: 'expense' as const,
supplierInvoiceId: 'si-1',
supplierInvoiceItemId: 'sii-1',
balanceAccount: '1730',
targetAccount: '6310',
totalAmountSek: 12000,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
description: 'Försäkring 2026',
}
it('creates the schedule and 12 installments without posting when postCatchUp=false', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule insert
{ data: null }, // installments insert
])
const schedule = await createAccrualSchedule(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
spec,
{ originJournalEntryId: 'je-origin', postingFloorDate: '2026-01-15', postCatchUp: false },
)
expect(schedule.id).toBe('sched-1')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('persists the spec dimensions bag on the schedule row', async () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule({ dimensions: { '6': 'P001' } }) },
{ data: null },
])
await createAccrualSchedule(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ ...spec, dimensions: { '6': 'P001' } },
{ postingFloorDate: '2026-01-15', postCatchUp: false },
)
const insert = findCall('accrual_schedules', 'insert')?.[0]
expect(insert).toMatchObject({ dimensions: { '6': 'P001' } })
})
it('defaults the schedule dimensions to an empty bag', async () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() },
{ data: null },
])
await createAccrualSchedule(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
spec,
{ postingFloorDate: '2026-01-15', postCatchUp: false },
)
const insert = findCall('accrual_schedules', 'insert')?.[0]
expect(insert).toMatchObject({ dimensions: {} })
})
it('cleans up the schedule when installment insert fails', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() },
{ data: null, error: { message: 'boom' } },
{ data: null }, // cleanup delete
])
await expect(
createAccrualSchedule(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
spec,
{ postingFloorDate: '2026-01-15', postCatchUp: false },
),
).rejects.toThrow(/installments/i)
})
})
describe('dissolveScheduleNow', () => {
it('posts one combined entry for the remaining months', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule fetch
{
data: [
makeInstallment({ id: 'inst-11', period_month: '2026-11-01', amount: 1000 }),
makeInstallment({ id: 'inst-12', period_month: '2026-12-01', amount: 1000 }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-11' }, { id: 'inst-12' }] }, // claim both
{ data: null }, // schedule completed
])
const result = await dissolveScheduleNow(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
'sched-1',
{ today: '2026-10-15' },
)
expect(result).toMatchObject({ journalEntryId: 'je-new', amount: 2000 })
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.entry_date).toBe('2026-10-15')
expect(input.lines[0]).toMatchObject({ account_number: '6310', debit_amount: 2000 })
})
it('reverses the combined entry when the claim is incomplete', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() },
{
data: [
makeInstallment({ id: 'inst-11', period_month: '2026-11-01', amount: 1000 }),
makeInstallment({ id: 'inst-12', period_month: '2026-12-01', amount: 1000 }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-11' }] }, // only one claimed
])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1', {
today: '2026-10-15',
}),
).rejects.toThrow(/ändrades samtidigt/i)
expect(mockReverseEntry).toHaveBeenCalled()
})
it('throws a typed not-found error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: null, error: { message: 'No rows' } }])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'missing'),
).rejects.toMatchObject({
code: ACCRUAL_SCHEDULE_NOT_FOUND,
message: 'Periodiseringen hittades inte',
})
})
it('throws a typed not-active error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: makeSchedule({ status: 'completed' }) }])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1'),
).rejects.toMatchObject({
code: ACCRUAL_SCHEDULE_NOT_ACTIVE,
message: 'Periodiseringen är inte aktiv',
})
})
it('throws a typed nothing-to-dissolve error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule fetch
{ data: [] }, // nothing pending
{ count: 0 }, // completeScheduleIfDone: remaining pending
{ data: null }, // schedule -> completed
])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1'),
).rejects.toMatchObject({
code: ACCRUAL_NOTHING_TO_DISSOLVE,
message: 'Det finns inget kvar att lösa upp',
})
})
})
describe('cancelSchedulesForSource', () => {
it('cancels pending months, reverses posted ones, and cancels the schedule', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] }, // schedules for source
{ data: null }, // pending -> cancelled
{ data: [{ id: 'inst-1', journal_entry_id: 'je-jan' }] }, // posted rows
{ data: null }, // schedule -> cancelled
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
{ reversalDate: '2026-05-01' },
)
expect(result).toEqual({ cancelledSchedules: 1, reversedEntries: 1, failedReversals: 0 })
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-jan',
'2026-05-01',
)
})
it('leaves the schedule active and reports the failure when a storno fails', async () => {
mockReverseEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] }, // schedules for source
{ data: null }, // pending -> cancelled
{ data: [{ id: 'inst-1', journal_entry_id: 'je-jan' }] }, // posted rows
{ data: null }, // last_error update on the stuck installment
// NOTE: no schedule -> cancelled update is queued; the schedule must
// stay 'active' so the UI keeps showing the un-reversed remainder.
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
)
expect(result).toEqual({ cancelledSchedules: 0, reversedEntries: 0, failedReversals: 1 })
})
it('treats already-reversed dissolutions as success and still cancels the schedule', async () => {
mockReverseEntry
.mockRejectedValueOnce(new EntryAlreadyReversedError())
.mockRejectedValueOnce(new CannotReverseNonPostedError('reversed'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] },
{ data: null }, // pending -> cancelled
{
data: [
{ id: 'inst-1', journal_entry_id: 'je-jan' },
{ id: 'inst-2', journal_entry_id: 'je-feb' },
],
},
{ data: null }, // schedule -> cancelled
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
)
expect(result).toEqual({ cancelledSchedules: 1, reversedEntries: 0, failedReversals: 0 })
})
it('requires a source id', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
cancelSchedulesForSource(supabase as unknown as SupabaseClient, COMPANY, USER, {}),
).rejects.toThrow(/source invoice id/i)
})
})