* 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>
158 lines
4.4 KiB
TypeScript
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(),
|
|
)
|
|
})
|
|
})
|