From 31fba5e8f96cde40e4a0afa9f0a7d1c848b84de1 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 6 Aug 2026 10:04:20 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../engine-dimension-validation.test.ts | 88 ++++++- .../dissolution-dimension-exemption.test.ts | 238 ++++++++++++++++++ .../accruals/__tests__/from-invoices.test.ts | 157 ++++++++++++ .../accruals/__tests__/service.test.ts | 91 +++++++ lib/bookkeeping/accruals/from-invoices.ts | 15 ++ lib/bookkeeping/accruals/service.ts | 81 +++--- lib/bookkeeping/dimension-resolver.ts | 4 +- lib/bookkeeping/dimension-rules.ts | 37 +++ lib/bookkeeping/engine.ts | 10 +- ...0805110000_accrual_schedule_dimensions.sql | 14 ++ types/index.ts | 5 + 11 files changed, 707 insertions(+), 33 deletions(-) create mode 100644 lib/bookkeeping/accruals/__tests__/dissolution-dimension-exemption.test.ts create mode 100644 lib/bookkeeping/accruals/__tests__/from-invoices.test.ts create mode 100644 supabase/migrations/20260805110000_accrual_schedule_dimensions.sql diff --git a/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts b/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts index 9e24ba08..cf9fa1f1 100644 --- a/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts +++ b/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts @@ -125,7 +125,10 @@ const DIMENSION_TABLES: Record = { }, } -function makeInput(dimensions?: Record): CreateJournalEntryInput { +function makeInput( + dimensions?: Record, + overrides: Partial = {} +): CreateJournalEntryInput { return { fiscal_period_id: 'period-1', entry_date: '2026-06-15', @@ -138,6 +141,7 @@ function makeInput(dimensions?: Record): 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> + 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> + 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({ diff --git a/lib/bookkeeping/accruals/__tests__/dissolution-dimension-exemption.test.ts b/lib/bookkeeping/accruals/__tests__/dissolution-dimension-exemption.test.ts new file mode 100644 index 00000000..22c54689 --- /dev/null +++ b/lib/bookkeeping/accruals/__tests__/dissolution-dimension-exemption.test.ts @@ -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) { + const inserts: Record = {} + const updates: Record = {} + const cursor: Record = {} + + 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 = {} + 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 = {}) { + 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 = {}) { + 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 { + 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> + 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 + > + 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> + expect(lineRows[0]).toMatchObject({ dimensions: { '6': 'P001' } }) + }) +}) diff --git a/lib/bookkeeping/accruals/__tests__/from-invoices.test.ts b/lib/bookkeeping/accruals/__tests__/from-invoices.test.ts new file mode 100644 index 00000000..edfa9ee9 --- /dev/null +++ b/lib/bookkeeping/accruals/__tests__/from-invoices.test.ts @@ -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 { + 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 { + 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(), + ) + }) +}) diff --git a/lib/bookkeeping/accruals/__tests__/service.test.ts b/lib/bookkeeping/accruals/__tests__/service.test.ts index 24f124c8..6aec7534 100644 --- a/lib/bookkeeping/accruals/__tests__/service.test.ts +++ b/lib/bookkeeping/accruals/__tests__/service.test.ts @@ -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([ diff --git a/lib/bookkeeping/accruals/from-invoices.ts b/lib/bookkeeping/accruals/from-invoices.ts index fb1e1774..02d0bfcf 100644 --- a/lib/bookkeeping/accruals/from-invoices.ts +++ b/lib/bookkeeping/accruals/from-invoices.ts @@ -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, diff --git a/lib/bookkeeping/accruals/service.ts b/lib/bookkeeping/accruals/service.ts index c20a6362..957d5b8f 100644 --- a/lib/bookkeeping/accruals/service.ts +++ b/lib/bookkeeping/accruals/service.ts @@ -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 } export interface PostDueResult { @@ -121,40 +128,53 @@ async function findNextOpenPeriodStart( } function dissolutionLines( - schedule: Pick, + 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() diff --git a/lib/bookkeeping/dimension-resolver.ts b/lib/bookkeeping/dimension-resolver.ts index c0b10bf7..a85289de 100644 --- a/lib/bookkeeping/dimension-resolver.ts +++ b/lib/bookkeeping/dimension-resolver.ts @@ -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, diff --git a/lib/bookkeeping/dimension-rules.ts b/lib/bookkeeping/dimension-rules.ts index b01a4076..57c642f0 100644 --- a/lib/bookkeeping/dimension-rules.ts +++ b/lib/bookkeeping/dimension-rules.ts @@ -214,3 +214,40 @@ export const DIMENSION_RULE_EXEMPT_SOURCE_TYPES: ReadonlySet = 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 = new Set([ + 'accrual', +]) + +export function isDimensionValidationExemptSource( + sourceType: string | null | undefined +): boolean { + return sourceType != null && DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES.has(sourceType) +} diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index c769faef..8c7b3d5f 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -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 diff --git a/supabase/migrations/20260805110000_accrual_schedule_dimensions.sql b/supabase/migrations/20260805110000_accrual_schedule_dimensions.sql new file mode 100644 index 00000000..7f1be613 --- /dev/null +++ b/supabase/migrations/20260805110000_accrual_schedule_dimensions.sql @@ -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'); diff --git a/types/index.ts b/types/index.ts index 14c74bfb..30da1f8b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 created_at: string updated_at: string // Relations