* 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>
239 lines
7.9 KiB
TypeScript
239 lines
7.9 KiB
TypeScript
/**
|
|
* Regression: a periodisering must ALWAYS dissolve, even when the project (or
|
|
* cost centre) the schedule is tagged with has been archived or dropped from
|
|
* the registry since the origin invoice was booked.
|
|
*
|
|
* Dissolution lines carry the origin's dimensions bag, which pulls them into
|
|
* validateEntryDimensions. With dimensions_enabled and an archived value that
|
|
* validator rejects the entry, postDueInstallments catches the rejection,
|
|
* writes last_error and leaves the installment pending: the remaining months
|
|
* of cost never reach the P&L account, the interim 17xx/29xx account stays
|
|
* overstated, and the trial balance still balances so nothing downstream
|
|
* notices. Hence the source-type exemption in createDraftEntry.
|
|
*
|
|
* Unlike service.test.ts this file does NOT mock the engine: the whole chain
|
|
* (schedule bag -> dissolution lines -> createDraftEntry -> commit) runs, so
|
|
* the test fails if the exemption is removed anywhere along it.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { postDueInstallments } from '@/lib/bookkeeping/accruals/service'
|
|
|
|
vi.mock('@/lib/events', () => ({
|
|
eventBus: { emit: vi.fn().mockResolvedValue([]) },
|
|
}))
|
|
|
|
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
|
|
backfillStandardBASAccounts: vi.fn().mockResolvedValue([]),
|
|
}))
|
|
|
|
const COMPANY = 'company-1'
|
|
const USER = 'user-1'
|
|
|
|
interface Result {
|
|
data?: unknown
|
|
error?: unknown
|
|
count?: number
|
|
}
|
|
|
|
interface TableMock {
|
|
/** Results for awaited (thenable) chains, consumed in order; last repeats. */
|
|
rows?: Result[]
|
|
/** Result for .single() / .maybeSingle() on this table. */
|
|
row?: Result
|
|
}
|
|
|
|
/**
|
|
* Table-keyed Supabase mock. Awaited chains and .single() resolve from
|
|
* separate slots because several tables are read both ways (fiscal_periods:
|
|
* findFiscalPeriod takes the list, createDraftEntry takes the row).
|
|
*/
|
|
function buildSupabase(tables: Record<string, TableMock>) {
|
|
const inserts: Record<string, unknown[]> = {}
|
|
const updates: Record<string, unknown[]> = {}
|
|
const cursor: Record<string, number> = {}
|
|
|
|
const nextRows = (table: string): Result => {
|
|
const list = tables[table]?.rows ?? []
|
|
if (list.length === 0) return { data: null, error: null }
|
|
const index = Math.min(cursor[table] ?? 0, list.length - 1)
|
|
cursor[table] = index + 1
|
|
return { data: null, error: null, ...list[index] }
|
|
}
|
|
|
|
const from = vi.fn().mockImplementation((table: string) => {
|
|
const chain: Record<string, unknown> = {}
|
|
for (const method of ['select', 'eq', 'neq', 'in', 'gt', 'gte', 'lte', 'order', 'limit', 'delete']) {
|
|
chain[method] = vi.fn().mockReturnValue(chain)
|
|
}
|
|
chain.insert = vi.fn().mockImplementation((payload: unknown) => {
|
|
;(inserts[table] ??= []).push(payload)
|
|
return chain
|
|
})
|
|
chain.update = vi.fn().mockImplementation((payload: unknown) => {
|
|
;(updates[table] ??= []).push(payload)
|
|
return chain
|
|
})
|
|
const single = vi.fn().mockImplementation(async () => ({
|
|
data: null,
|
|
error: null,
|
|
...(tables[table]?.row ?? {}),
|
|
}))
|
|
chain.single = single
|
|
chain.maybeSingle = single
|
|
chain.then = (resolve: (value: unknown) => void) => resolve(nextRows(table))
|
|
return chain
|
|
})
|
|
|
|
const supabase = {
|
|
from,
|
|
rpc: vi.fn().mockResolvedValue({ data: null, error: null }),
|
|
}
|
|
|
|
return { supabase, inserts, updates, queriedTables: () => from.mock.calls.map((c) => c[0] as string) }
|
|
}
|
|
|
|
/** Expense schedule tagged with project P001 on dimension 6. */
|
|
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',
|
|
dimensions: { '6': 'P001' },
|
|
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-07-01',
|
|
amount: 1000,
|
|
status: 'pending',
|
|
journal_entry_id: null,
|
|
posted_at: null,
|
|
last_error: null,
|
|
schedule: makeSchedule(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param valueRows rows the registry returns for P001. Empty = the value was
|
|
* removed; is_active false = it was archived.
|
|
*/
|
|
function buildTables(valueRows: Array<Record<string, unknown>>): Record<string, TableMock> {
|
|
return {
|
|
accrual_schedule_installments: {
|
|
rows: [
|
|
{ data: [makeInstallment()] }, // due installments
|
|
{ data: [{ id: 'inst-1' }] }, // CAS claim
|
|
{ count: 0 }, // remaining pending
|
|
],
|
|
},
|
|
company_settings: {
|
|
row: {
|
|
data: {
|
|
bookkeeping_locked_through: null,
|
|
dimensions_enabled: true,
|
|
default_voucher_series_per_source_type: null,
|
|
},
|
|
},
|
|
},
|
|
fiscal_periods: {
|
|
rows: [{ data: [{ id: 'fp-1' }] }],
|
|
row: { data: { name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' } },
|
|
},
|
|
account_dimension_rules: { rows: [{ data: [] }] },
|
|
dimensions: { rows: [{ data: [{ id: 'dim-proj', sie_dim_no: 6 }] }] },
|
|
dimension_values: { rows: [{ data: valueRows }] },
|
|
chart_of_accounts: {
|
|
rows: [
|
|
{
|
|
data: [
|
|
{ id: 'acc-6310', account_number: '6310' },
|
|
{ id: 'acc-1730', account_number: '1730' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
journal_entries: { row: { data: { id: 'je-1', status: 'draft', voucher_series: 'A' } } },
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('postDueInstallments with an archived dimension value', () => {
|
|
it('posts the dissolution and keeps the tag', async () => {
|
|
const { supabase, inserts, updates } = buildSupabase(
|
|
buildTables([{ dimension_id: 'dim-proj', code: 'P001', is_active: false }])
|
|
)
|
|
|
|
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
|
userId: USER,
|
|
today: '2026-07-20',
|
|
})
|
|
|
|
expect(result).toMatchObject({ posted: 1, failed: 0, skipped: 0, errors: [] })
|
|
|
|
// Both lines booked, both still tagged with the archived project: the cost
|
|
// belongs to P001 whether or not the value is still selectable.
|
|
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
|
|
expect(lineRows).toHaveLength(2)
|
|
expect(lineRows[0]).toMatchObject({
|
|
account_number: '6310',
|
|
debit_amount: 1000,
|
|
dimensions: { '6': 'P001' },
|
|
})
|
|
expect(lineRows[1]).toMatchObject({
|
|
account_number: '1730',
|
|
credit_amount: 1000,
|
|
dimensions: { '6': 'P001' },
|
|
})
|
|
|
|
// The installment was claimed, not parked with a last_error for the cron
|
|
// to retry forever.
|
|
const installmentUpdates = (updates.accrual_schedule_installments ?? []) as Array<
|
|
Record<string, unknown>
|
|
>
|
|
expect(installmentUpdates).toHaveLength(1)
|
|
expect(installmentUpdates[0]).toMatchObject({ status: 'posted', journal_entry_id: 'je-1' })
|
|
expect(installmentUpdates.some((u) => typeof u.last_error === 'string')).toBe(false)
|
|
})
|
|
|
|
it('posts the dissolution when the tagged value is gone from the registry', async () => {
|
|
const { supabase, inserts } = buildSupabase(buildTables([]))
|
|
|
|
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
|
|
userId: USER,
|
|
today: '2026-07-20',
|
|
})
|
|
|
|
expect(result).toMatchObject({ posted: 1, failed: 0 })
|
|
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
|
|
expect(lineRows[0]).toMatchObject({ dimensions: { '6': 'P001' } })
|
|
})
|
|
})
|