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>
This commit is contained in:
@@ -125,7 +125,10 @@ const DIMENSION_TABLES: Record<string, TableResult> = {
|
||||
},
|
||||
}
|
||||
|
||||
function makeInput(dimensions?: Record<string, string>): CreateJournalEntryInput {
|
||||
function makeInput(
|
||||
dimensions?: Record<string, string>,
|
||||
overrides: Partial<CreateJournalEntryInput> = {}
|
||||
): CreateJournalEntryInput {
|
||||
return {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2026-06-15',
|
||||
@@ -138,6 +141,7 @@ function makeInput(dimensions?: Record<string, string>): CreateJournalEntryInput
|
||||
{ account_number: '4010', debit_amount: 100, credit_amount: 0, dimensions },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100, dimensions },
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +235,88 @@ describe('createDraftEntry: dimension validation wiring', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Accrual dissolutions (source_type 'accrual') are exempt from the soft
|
||||
* registry validation: they replay the origin entry's dimensions bag month
|
||||
* after month, so a value archived (or removed from the registry) after the
|
||||
* origin was posted must not be able to stop the remaining installments from
|
||||
* booking. Exemption is by source type only, and it does NOT strip the tag.
|
||||
*/
|
||||
describe('createDraftEntry: accrual dissolution exemption', () => {
|
||||
const accrual = { source_type: 'accrual' as const, source_id: 'sched-1' }
|
||||
|
||||
it('posts a dissolution tagged with a value archived since the origin entry', async () => {
|
||||
const { supabase, inserts, queriedTables } = buildSupabase({
|
||||
...BASE_TABLES,
|
||||
...DIMENSION_TABLES,
|
||||
dimension_values: {
|
||||
data: [{ dimension_id: 'dim-proj', code: 'P001', is_active: false }],
|
||||
},
|
||||
})
|
||||
|
||||
const entry = await createDraftEntry(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
makeInput({ '6': 'P001' }, accrual)
|
||||
)
|
||||
|
||||
expect(entry.id).toBe('entry-1')
|
||||
// The tag survives: an archived value still exists in the registry and the
|
||||
// cost genuinely belongs to that project.
|
||||
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
|
||||
expect(lineRows[0].dimensions).toEqual({ '6': 'P001' })
|
||||
expect(lineRows[1].dimensions).toEqual({ '6': 'P001' })
|
||||
// Registry never consulted at all for an exempt source.
|
||||
expect(queriedTables()).not.toContain('dimensions')
|
||||
expect(queriedTables()).not.toContain('dimension_values')
|
||||
})
|
||||
|
||||
it('posts a dissolution tagged with a code that is not in the registry', async () => {
|
||||
const { supabase, inserts } = buildSupabase({
|
||||
...BASE_TABLES,
|
||||
...DIMENSION_TABLES,
|
||||
dimension_values: { data: [] },
|
||||
})
|
||||
|
||||
const entry = await createDraftEntry(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
makeInput({ '6': 'P999' }, accrual)
|
||||
)
|
||||
|
||||
expect(entry.id).toBe('entry-1')
|
||||
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
|
||||
expect(lineRows[0].dimensions).toEqual({ '6': 'P999' })
|
||||
})
|
||||
|
||||
it('still rejects the same archived value on an operational source', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
...BASE_TABLES,
|
||||
...DIMENSION_TABLES,
|
||||
dimension_values: {
|
||||
data: [{ dimension_id: 'dim-proj', code: 'P001', is_active: false }],
|
||||
},
|
||||
})
|
||||
|
||||
// The exemption is narrow: only 'accrual' skips validation, every
|
||||
// operational source keeps its typed Swedish rejection.
|
||||
await expect(
|
||||
createDraftEntry(supabase as never, 'company-1', 'user-1', makeInput({ '6': 'P001' }))
|
||||
).rejects.toBeInstanceOf(DimensionValidationError)
|
||||
|
||||
await expect(
|
||||
createDraftEntry(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
makeInput({ '6': 'P001' }, { source_type: 'supplier_invoice_registered' })
|
||||
)
|
||||
).rejects.toBeInstanceOf(DimensionValidationError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateDraftEntry: dimension validation wiring', () => {
|
||||
it('rejects an unknown code before the header or lines are touched', async () => {
|
||||
const { supabase, updates, queriedTables } = buildSupabase({
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeSupplierInvoice,
|
||||
} from '@/tests/helpers'
|
||||
import {
|
||||
createSchedulesForCustomerInvoice,
|
||||
createSchedulesForSupplierInvoice,
|
||||
} from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { createAccrualSchedule } from '@/lib/bookkeeping/accruals/service'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { AccrualSchedule, InvoiceItem, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
|
||||
createAccrualSchedule: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateAccrualSchedule = vi.mocked(createAccrualSchedule)
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
|
||||
function makeSupplierItem(
|
||||
overrides: Partial<SupplierInvoiceItem> = {},
|
||||
): SupplierInvoiceItem {
|
||||
return {
|
||||
id: 'sii-1',
|
||||
supplier_invoice_id: 'si-1',
|
||||
sort_order: 0,
|
||||
description: 'Försäkring 2026',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 12000,
|
||||
line_total: 12000,
|
||||
account_number: '6310',
|
||||
vat_code: null,
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 3000,
|
||||
reverse_charge_rate: null,
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '1730',
|
||||
created_at: '2026-01-15T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeInvoiceItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'ii-1',
|
||||
invoice_id: 'inv-1',
|
||||
sort_order: 0,
|
||||
description: 'Serviceavtal 2026',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 12000,
|
||||
line_total: 12000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 3000,
|
||||
accrual_period_start: '2026-01-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2970',
|
||||
created_at: '2026-01-15T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateAccrualSchedule.mockResolvedValue({ id: 'sched-1' } as AccrualSchedule)
|
||||
})
|
||||
|
||||
describe('createSchedulesForSupplierInvoice', () => {
|
||||
it('passes the invoice default merged with the item bag as the spec dimensions', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([{ data: [] }]) // no existing schedules
|
||||
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
default_dimensions: { '1': 'KS01', '6': 'P000' },
|
||||
})
|
||||
const item = makeSupplierItem({ dimensions: { '6': 'P001' } })
|
||||
|
||||
const result = await createSchedulesForSupplierInvoice(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
invoice,
|
||||
[item],
|
||||
'je-origin',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ created: 1, failed: 0 })
|
||||
// Item bag wins per key over the invoice default: identical merge to the
|
||||
// registration entry's interim 17xx line (groupExpenseBuckets).
|
||||
expect(mockCreateAccrualSchedule).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY,
|
||||
USER,
|
||||
expect.objectContaining({ dimensions: { '1': 'KS01', '6': 'P001' } }),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('passes undefined dimensions when neither invoice nor item is tagged', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([{ data: [] }])
|
||||
|
||||
await createSchedulesForSupplierInvoice(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
makeSupplierInvoice({ id: 'si-1' }),
|
||||
[makeSupplierItem()],
|
||||
'je-origin',
|
||||
)
|
||||
|
||||
const spec = mockCreateAccrualSchedule.mock.calls[0][3]
|
||||
expect(spec.dimensions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSchedulesForCustomerInvoice', () => {
|
||||
it('passes the invoice default merged with the item bag as the spec dimensions', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([{ data: [] }]) // no existing schedules
|
||||
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
const item = makeInvoiceItem({ dimensions: { '6': 'P001' } })
|
||||
|
||||
const result = await createSchedulesForCustomerInvoice(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
USER,
|
||||
invoice,
|
||||
[item],
|
||||
'je-origin',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ created: 1, failed: 0 })
|
||||
expect(mockCreateAccrualSchedule).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY,
|
||||
USER,
|
||||
expect.objectContaining({
|
||||
direction: 'revenue',
|
||||
dimensions: { '1': 'KS01', '6': 'P001' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -147,6 +147,59 @@ describe('postDueInstallments', () => {
|
||||
])
|
||||
})
|
||||
|
||||
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([
|
||||
@@ -369,6 +422,44 @@ describe('createAccrualSchedule', () => {
|
||||
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([
|
||||
|
||||
@@ -14,6 +14,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Invoice, InvoiceItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { createAccrualSchedule } from '@/lib/bookkeeping/accruals/service'
|
||||
import {
|
||||
coerceDimensionsBag,
|
||||
mergeDimensionBags,
|
||||
} from '@/lib/bookkeeping/dimension-resolver'
|
||||
import {
|
||||
itemHasAccrual,
|
||||
suggestBalanceAccount,
|
||||
@@ -53,6 +57,11 @@ export async function createSchedulesForSupplierInvoice(
|
||||
),
|
||||
)
|
||||
|
||||
// Same merge the registration entry used for the item's interim 17xx line
|
||||
// (groupExpenseBuckets): the schedule's bag must match the origin so the
|
||||
// dissolutions keep the per-dimension interim balance at zero.
|
||||
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
|
||||
|
||||
for (const item of accrualItems) {
|
||||
if (item.id && covered.has(item.id)) continue
|
||||
try {
|
||||
@@ -77,6 +86,7 @@ export async function createSchedulesForSupplierInvoice(
|
||||
periodStart: item.accrual_period_start as string,
|
||||
periodEnd: item.accrual_period_end as string,
|
||||
description: `${item.description} (leverantörsfaktura ${invoice.supplier_invoice_number})`,
|
||||
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
|
||||
},
|
||||
{
|
||||
originJournalEntryId,
|
||||
@@ -124,6 +134,10 @@ export async function createSchedulesForCustomerInvoice(
|
||||
),
|
||||
)
|
||||
|
||||
// Same merge the revenue entry used for the item's interim 29xx line
|
||||
// (generatePerRateLines): item bag wins per key over the invoice default.
|
||||
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
|
||||
|
||||
for (const item of accrualItems) {
|
||||
if (item.id && covered.has(item.id)) continue
|
||||
const target = resolveRevenueTarget(item, invoice.vat_treatment, entityType)
|
||||
@@ -153,6 +167,7 @@ export async function createSchedulesForCustomerInvoice(
|
||||
periodStart: item.accrual_period_start as string,
|
||||
periodEnd: item.accrual_period_end as string,
|
||||
description: `${item.description} (faktura ${invoice.invoice_number ?? ''})`.trim(),
|
||||
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
|
||||
},
|
||||
{
|
||||
originJournalEntryId,
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
firstOfMonth,
|
||||
maxIsoDate,
|
||||
} from '@/lib/bookkeeping/accruals/compute'
|
||||
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { roundOre, sumOre } from '@/lib/money'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
@@ -63,6 +64,12 @@ export interface AccrualScheduleSpec {
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
description: string
|
||||
/**
|
||||
* Dimensions bag ({sie_dim_no: object_code}) as booked on the origin
|
||||
* entry's interim line (invoice default_dimensions merged with the item
|
||||
* bag). Carried onto both dissolution lines.
|
||||
*/
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface PostDueResult {
|
||||
@@ -121,40 +128,53 @@ async function findNextOpenPeriodStart(
|
||||
}
|
||||
|
||||
function dissolutionLines(
|
||||
schedule: Pick<ScheduleRow, 'direction' | 'balance_account' | 'target_account'>,
|
||||
schedule: Pick<
|
||||
ScheduleRow,
|
||||
'direction' | 'balance_account' | 'target_account' | 'dimensions'
|
||||
>,
|
||||
amount: number,
|
||||
lineDescription: string,
|
||||
): CreateJournalEntryLineInput[] {
|
||||
if (schedule.direction === 'expense') {
|
||||
return [
|
||||
{
|
||||
account_number: schedule.target_account,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
{
|
||||
account_number: schedule.balance_account,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
]
|
||||
const lines: CreateJournalEntryLineInput[] =
|
||||
schedule.direction === 'expense'
|
||||
? [
|
||||
{
|
||||
account_number: schedule.target_account,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
{
|
||||
account_number: schedule.balance_account,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
account_number: schedule.balance_account,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
{
|
||||
account_number: schedule.target_account,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
]
|
||||
// Both origin generators tag the interim (17xx/29xx) line with the item's
|
||||
// merged bag (groupExpenseBuckets / generatePerRateLines keep the bag when
|
||||
// the account swaps to the interim account), so BOTH dissolution lines get
|
||||
// the schedule's bag: the P&L line for the per-dimension result, the
|
||||
// balance line so per-dimension views of the interim account net to zero.
|
||||
const dimensions = coerceDimensionsBag(schedule.dimensions)
|
||||
if (dimensions) {
|
||||
for (const line of lines) line.dimensions = { ...dimensions }
|
||||
}
|
||||
return [
|
||||
{
|
||||
account_number: schedule.balance_account,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
{
|
||||
account_number: schedule.target_account,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: lineDescription,
|
||||
},
|
||||
]
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +219,7 @@ export async function createAccrualSchedule(
|
||||
posting_floor_date: options.postingFloorDate,
|
||||
status: 'active',
|
||||
description: spec.description,
|
||||
dimensions: spec.dimensions ?? {},
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
@@ -163,7 +163,9 @@ export function dimensionsBagKey(dimensions?: LineDimensions): string {
|
||||
* write that follows hits the same database anyway. Reversal/storno/correction
|
||||
* paths intentionally bypass this function: they copy posted data verbatim
|
||||
* (BFL 5 kap 5§ requires the storno to mirror the original even if a value
|
||||
* has since been archived).
|
||||
* has since been archived). Accrual dissolutions bypass it on the same
|
||||
* grounds (DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES in dimension-rules.ts):
|
||||
* they replay the origin entry's bag and must always be able to post.
|
||||
*/
|
||||
export async function validateEntryDimensions(
|
||||
supabase: SupabaseClient,
|
||||
|
||||
@@ -214,3 +214,40 @@ export const DIMENSION_RULE_EXEMPT_SOURCE_TYPES: ReadonlySet<string> = new Set([
|
||||
export function isDimensionRuleExemptSource(sourceType: string | null | undefined): boolean {
|
||||
return sourceType != null && DIMENSION_RULE_EXEMPT_SOURCE_TYPES.has(sourceType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Source types exempt from the SOFT REGISTRY VALIDATION in
|
||||
* validateEntryDimensions (dimension-resolver.ts). Deliberately a SECOND and
|
||||
* much narrower set than DIMENSION_RULE_EXEMPT_SOURCE_TYPES above: that one
|
||||
* governs POLICY (required/default/fixed rules), this one governs whether a
|
||||
* tagged bag has to resolve against the registry at all.
|
||||
*
|
||||
* Only 'accrual' qualifies. A periodisering dissolution is the mechanical
|
||||
* continuation of a decision that was already approved and already posted:
|
||||
* the origin entry booked the net amount to the interim account (17xx/29xx)
|
||||
* and the schedule replays it month by month onto the P&L account. The
|
||||
* dissolution lines copy the ORIGIN's bag verbatim, so the storno argument
|
||||
* applies unchanged: the value may have been archived in the months since
|
||||
* the origin was posted, and rejecting the copy would leave every remaining
|
||||
* installment PENDING forever (the service records last_error and the daily
|
||||
* cron retries the same impossible entry). The deferred cost would never
|
||||
* reach its 5xxx/6xxx account, the interim account would stay overstated,
|
||||
* and the trial balance would still balance, so no year-end check fires.
|
||||
*
|
||||
* The tag itself is kept, never stripped: an archived value still exists in
|
||||
* the registry and the cost genuinely belongs to that project, so dropping
|
||||
* it would understate the project instead.
|
||||
*
|
||||
* Nothing else belongs here. import/opening_balance carry user-supplied
|
||||
* codes on a FIRST posting: skipping validation there would silently write
|
||||
* registry-orphaned tags that no dimension report can group.
|
||||
*/
|
||||
export const DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES: ReadonlySet<string> = new Set([
|
||||
'accrual',
|
||||
])
|
||||
|
||||
export function isDimensionValidationExemptSource(
|
||||
sourceType: string | null | undefined
|
||||
): boolean {
|
||||
return sourceType != null && DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES.has(sourceType)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
assertMandatoryDimensions,
|
||||
fetchActiveDimensionRules,
|
||||
isDimensionRuleExemptSource,
|
||||
isDimensionValidationExemptSource,
|
||||
} from '@/lib/bookkeeping/dimension-rules'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
|
||||
@@ -264,7 +265,14 @@ export async function createDraftEntry(
|
||||
// enabled companies get registry validation with a typed Swedish rejection.
|
||||
// Runs before any insert so a rejection leaves no orphan rows. Reversal/
|
||||
// storno/correction paths bypass this: they copy posted data verbatim.
|
||||
await validateEntryDimensions(supabase, companyId, lines)
|
||||
// Accrual dissolutions bypass it for exactly that reason too: they replay
|
||||
// the origin entry's bag, so a value archived after the origin was posted
|
||||
// must not be able to strand the remaining months as pending and leave the
|
||||
// interim 17xx/29xx account overstated. See
|
||||
// DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES.
|
||||
if (!isDimensionValidationExemptSource(input.source_type)) {
|
||||
await validateEntryDimensions(supabase, companyId, lines)
|
||||
}
|
||||
|
||||
// Validate that entry_date falls within the selected fiscal period
|
||||
const { data: period, error: periodError } = await supabase
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Accrual schedules carry the origin line's dimensions bag so dissolution
|
||||
-- entries can tag their lines the way the origin entry tagged its interim
|
||||
-- (17xx/29xx) line. Without this, every monthly dissolution books untagged
|
||||
-- and a project-tagged deferred invoice line silently disappears from the
|
||||
-- per-project P&L while the tagged interim balance never nets to zero.
|
||||
--
|
||||
-- Shape: {sie_dim_no: object_code}, same as journal_entry_lines.dimensions;
|
||||
-- the CHECK mirrors dimension_values.attributes (20260702084500). Existing
|
||||
-- rows get '{}' (untagged): pre-existing schedules keep today's behavior; a
|
||||
-- backfill from the origin entry is a separate follow-up.
|
||||
|
||||
ALTER TABLE public.accrual_schedules
|
||||
ADD COLUMN dimensions jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
CHECK (jsonb_typeof(dimensions) = 'object');
|
||||
@@ -1722,6 +1722,11 @@ export interface AccrualSchedule {
|
||||
posting_floor_date: string
|
||||
status: AccrualScheduleStatus
|
||||
description: string | null
|
||||
// Dimensions bag ({sie_dim_no: object_code}) copied from the origin line
|
||||
// (invoice default_dimensions merged with the item bag); carried onto both
|
||||
// dissolution lines. jsonb DEFAULT '{}'. Optional in TS for pre-migration
|
||||
// fixtures.
|
||||
dimensions?: Record<string, string>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// Relations
|
||||
|
||||
Reference in New Issue
Block a user