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:
Jakob Wennberg
2026-08-06 10:04:20 +02:00
committed by GitHub
parent d7320a6c87
commit 31fba5e8f9
11 changed files with 707 additions and 33 deletions
@@ -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([
+15
View File
@@ -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,
+51 -30
View File
@@ -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()
+3 -1
View File
@@ -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,
+37
View File
@@ -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)
}
+9 -1
View File
@@ -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');
+5
View File
@@ -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