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

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

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

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

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

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

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

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

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

---------

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

158 lines
4.4 KiB
TypeScript

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(),
)
})
})