* fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults K2 companies (BFNAR 2016:10 punkt 10.4) may not capitalize internally developed intangibles, but the asset register defaulted the immaterial category onto 1010/1019 (Utvecklingsutgifter) for everyone and had no framework gate beyond K3_REQUIRED_FOR_COMPONENTS. - New K2_EXCLUDED_ACCOUNT gate (422) in POST /api/assets and PATCH /api/assets/[id]: when accounting_framework is not k3, reject any asset whose resolved asset or accumulated account is flagged k2_excluded in the BAS reference. Resolution mirrors the service defaults so category defaults cannot sneak onto 1010/1019; patches that leave category and accounts untouched skip the gate so legacy assets stay editable. - Shared guard helper in lib/bokslut/assets/k2-account-guard.ts; code registered in structured-errors.ts with Swedish and English messages. - CreateAssetDialog: non K3 companies now book immaterial assets on the purchased pair 1090/1099 with a quiet hint that egenupparbetad utveckling requires K3; K3 companies picking immaterial see a note about fond for utvecklingsutgifter (2089) per ARL 4 kap. 2 par. - Route tests: K2 rejected on 1010 defaults and explicit overrides, K2 accepted on purchased accounts, K3 accepted on 1010, PATCH equivalents and a gate skip regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): cite punkt 10.4 only when the intangible group triggered the K2 gate The K2 gate fires on ANY account the BAS chart flags k2_excluded, but the rejection hardcoded an egenupparbetade immateriella / BFNAR 2016:10 punkt 10.4 citation. The flag also covers accounts excluded from K2 for unrelated reasons (1370/2240/8940 uppskjuten skatt, 1518, 2089, 2092, 2096, 2448, 3940, 7940, 8290 to 8480), so those users got a factually wrong legal citation in a compliance product. PATCH can reach them today: UpdateAssetSchema has no BAS range refinement, so an explicit bas_asset_account override outside the category range hits the gate before updateAsset() raises its range error. - k2ExcludedAccountMessages() now picks the wording from what actually triggered the gate. The boundary is derived from the chart itself (k2_excluded + account_class 1 + kontogrupp 10), which is exactly the egenupparbetade set 1010, 1011, 1012, 1018, 1019, 1081; no magic list, so a flag change in bas-data moves the boundary with it. Other Ej K2 accounts get a generic message: the chart marks it Ej K2 and it requires K3, with no invented paragraph reference. - Both messages are bilingual (message_sv / message_en, registry shape) and the routes now return message_en alongside message. - The static K2_EXCLUDED_ACCOUNT registry entry drops the intangible citation too: it is the code level fallback for every k2_excluded account. - Tests: route level distinction pinned in id.test.ts (1010/1081 cite 10.4, 1370 must not), plus a guard unit test asserting the derived group and that no non group 10 Ej K2 account ever cites 10.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): let K2 companies register acquired intangibles, server side The K2 gate blocked a lawful case. K2 forbids only EGENUPPARBETADE immateriella tillgangar; acquired ones may be recognized (k2-vs-k3.md:24, "Only acquired intangibles may be recognized"). But asset-service still resolved category 'immaterial' to 1010/1019 for everyone, and only CreateAssetDialog compensated with an explicit 1090/1099 override. EditAssetDialog sends just the changed fields and has no account inputs, so a K2 aktiebolag recategorizing a bought licence to "Immateriell tillgang" hit the defaults, got a 422, and was told to switch the company to K3, which would pull in komponentavskrivning and uppskjuten skatt and rewrite the whole arsredovisning. The asset stayed on 1220/1229 and kept being presented as a tangible asset. - defaultAccountsForCategory(category, framework) is the single resolution point: immaterial resolves to the acquired pair 1090/1099 unless the framework is k3, every other category is unchanged. Both createAsset() and updateAsset()'s category realign go through resolveDefaultAccounts(), which reads companies.accounting_framework only for the intangible category and throws rather than guessing when that read fails. Explicit overrides and the realign-skip semantics are untouched. - Both routes resolve gate accounts through the same function, so the check mirrors what the service will persist. A K2 company on the defaults now passes; a deliberate override onto 1010/1011/1012/1018/1019/1081 still 422s. - CreateAssetDialog drops its now redundant client override so the two surfaces cannot drift; the hint text stays. - The 422 no longer asserts the company's framework (the companies read behind it discards its error, so a transient failure would assert it against a K3 company) and no longer proposes a regelverk change. It states that the account is reserved for egenupparbetade utvecklingsutgifter, which require K3, and points at 1090 for an acquired intangible. Punkt 10.4 stays scoped to the kontogrupp 10 group, derived from the chart as before. sv and en. 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>
505 lines
19 KiB
TypeScript
505 lines
19 KiB
TypeScript
|
||
import { describe, it, expect, vi } from 'vitest'
|
||
import {
|
||
AssetCorrectionBlockedError,
|
||
DEFAULT_ACCOUNTS_BY_CATEGORY,
|
||
createAsset,
|
||
defaultAccountsForCategory,
|
||
updateAsset,
|
||
} from '../assets/asset-service'
|
||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||
import type { AccountingFramework, Asset, AssetCategory } from '@/types'
|
||
|
||
describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
|
||
it('maps every AssetCategory to a BAS-aligned account triple', () => {
|
||
const expected = {
|
||
immaterial: { asset: '1010', accumulated: '1019', expense: '7810' },
|
||
building: { asset: '1110', accumulated: '1119', expense: '7821' },
|
||
land_improvement: { asset: '1150', accumulated: '1159', expense: '7824' },
|
||
machinery: { asset: '1210', accumulated: '1219', expense: '7831' },
|
||
equipment: { asset: '1220', accumulated: '1229', expense: '7832' },
|
||
vehicle: { asset: '1240', accumulated: '1249', expense: '7832' },
|
||
computer: { asset: '1250', accumulated: '1259', expense: '7832' },
|
||
other_tangible: { asset: '1290', accumulated: '1299', expense: '7839' },
|
||
} as const
|
||
expect(DEFAULT_ACCOUNTS_BY_CATEGORY).toEqual(expected)
|
||
})
|
||
|
||
it('uses the convention that accumulated = asset + 9 for tangible categories', () => {
|
||
const tangible = ['machinery', 'equipment', 'vehicle', 'computer', 'other_tangible'] as const
|
||
for (const cat of tangible) {
|
||
const triple = DEFAULT_ACCOUNTS_BY_CATEGORY[cat]
|
||
const assetNum = parseInt(triple.asset, 10)
|
||
const accumulatedNum = parseInt(triple.accumulated, 10)
|
||
expect(accumulatedNum).toBe(assetNum + 9)
|
||
}
|
||
})
|
||
|
||
it('expense accounts are in the 78xx range (planenliga avskrivningar)', () => {
|
||
for (const cat of Object.keys(DEFAULT_ACCOUNTS_BY_CATEGORY) as Array<
|
||
keyof typeof DEFAULT_ACCOUNTS_BY_CATEGORY
|
||
>) {
|
||
const expense = DEFAULT_ACCOUNTS_BY_CATEGORY[cat].expense
|
||
expect(expense).toMatch(/^78\d{2}$/)
|
||
}
|
||
})
|
||
|
||
// Regression guard for #755: 7833/7834 were referenced here but absent from
|
||
// the BAS reference, so backfillStandardBASAccounts could not seed them and
|
||
// annual depreciation threw AccountsNotInChartError. Every account in the
|
||
// triple must resolve in BAS_REFERENCE: otherwise the lazy backfill silently
|
||
// can't add it and the depreciation posting fails on minimal charts.
|
||
it('every account in the triple exists in the BAS reference (backfillable)', () => {
|
||
for (const framework of ['k2', 'k3'] as const) {
|
||
for (const cat of Object.keys(DEFAULT_ACCOUNTS_BY_CATEGORY) as AssetCategory[]) {
|
||
const { asset, accumulated, expense } = defaultAccountsForCategory(cat, framework)
|
||
for (const account of [asset, accumulated, expense]) {
|
||
expect(
|
||
getBASReference(account),
|
||
`${framework}/${cat}: ${account} missing from BAS reference`,
|
||
).toBeDefined()
|
||
}
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
/**
|
||
* K2 (BFNAR 2016:10 punkt 10.4) forbids capitalizing EGENUPPARBETADE
|
||
* immateriella tillgångar, which is what 1010/1019 carry. An ACQUIRED
|
||
* intangible is lawful under K2 and belongs on 1090/1099
|
||
* (.claude/skills/swedish-year-end-closing/references/k2-vs-k3.md:24, "Only
|
||
* acquired intangibles may be recognized"), so the default has to follow the
|
||
* company's framework rather than being one pair for everyone.
|
||
*/
|
||
describe('defaultAccountsForCategory', () => {
|
||
it('gives a K3 company the egenupparbetade pair for immaterial', () => {
|
||
expect(defaultAccountsForCategory('immaterial', 'k3')).toEqual({
|
||
asset: '1010',
|
||
accumulated: '1019',
|
||
expense: '7810',
|
||
})
|
||
})
|
||
|
||
it.each([['k2' as const], [null], [undefined]])(
|
||
'gives the acquired pair 1090/1099 for immaterial when the framework is %s',
|
||
(framework: AccountingFramework | null | undefined) => {
|
||
expect(defaultAccountsForCategory('immaterial', framework)).toEqual({
|
||
asset: '1090',
|
||
accumulated: '1099',
|
||
expense: '7810',
|
||
})
|
||
},
|
||
)
|
||
|
||
it('leaves 1090/1099 unflagged in the BAS chart, so the K2 gate passes them', () => {
|
||
expect(getBASReference('1090')?.k2_excluded).toBe(false)
|
||
expect(getBASReference('1099')?.k2_excluded).toBe(false)
|
||
// Inside the 1010-1099 window the immaterial Zod/service range checks allow.
|
||
expect('1090' >= '1010' && '1099' <= '1099').toBe(true)
|
||
})
|
||
|
||
it('is framework-independent for every tangible category', () => {
|
||
const tangible: AssetCategory[] = [
|
||
'building',
|
||
'land_improvement',
|
||
'machinery',
|
||
'equipment',
|
||
'vehicle',
|
||
'computer',
|
||
'other_tangible',
|
||
]
|
||
for (const cat of tangible) {
|
||
expect(defaultAccountsForCategory(cat, 'k2')).toEqual(DEFAULT_ACCOUNTS_BY_CATEGORY[cat])
|
||
expect(defaultAccountsForCategory(cat, 'k3')).toEqual(DEFAULT_ACCOUNTS_BY_CATEGORY[cat])
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('updateAsset: acquisition-basis correction guard', () => {
|
||
function makeAssetRow(overrides: Partial<Asset> = {}): Asset {
|
||
return {
|
||
id: 'asset-1',
|
||
user_id: 'u',
|
||
company_id: 'co',
|
||
name: 'reMarkable Paper Pro',
|
||
category: 'computer',
|
||
acquisition_date: '2025-04-19',
|
||
acquisition_cost: 7999.2,
|
||
salvage_value: 0,
|
||
useful_life_months: 36,
|
||
depreciation_method: 'linear',
|
||
bas_asset_account: '1250',
|
||
bas_accumulated_account: '1259',
|
||
bas_expense_account: '7833',
|
||
restvarde_target: null,
|
||
disposed_at: null,
|
||
disposed_proceeds: null,
|
||
disposed_proceeds_vat: 0,
|
||
disposed_vat_treatment: null,
|
||
jamkning_amount: 0,
|
||
jamkning_remaining_months: null,
|
||
jamkning_total_months: null,
|
||
jamkning_original_input_vat: null,
|
||
k3_components: null,
|
||
notes: null,
|
||
created_at: '2026-06-11T00:00:00Z',
|
||
updated_at: '2026-06-11T00:00:00Z',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
const asSupabase = (s: unknown) => s as Parameters<typeof updateAsset>[0]
|
||
|
||
/**
|
||
* Minimal Supabase mock that captures the final UPDATE payload. updateAsset's
|
||
* correction guard touches four tables:
|
||
* - 'assets' → getAsset (.maybeSingle) and the update (.single)
|
||
* - 'depreciation_schedules' → hasPostedDepreciation (1st call, head {count})
|
||
* then hasManualDepreciationPosted (2nd call,
|
||
* .select('journal_entry_id') → {data})
|
||
* - 'journal_entries' → hasManualDepreciationPosted entries step of the
|
||
* two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts)
|
||
* - 'journal_entry_lines' → hasManualDepreciationPosted ledger scan → {data}
|
||
*/
|
||
function mockForUpdate(
|
||
asset: Asset,
|
||
opts: {
|
||
postedCount?: number
|
||
otherAssetEntryIds?: string[]
|
||
accumulatedCredits?: { journal_entry_id: string }[]
|
||
accountingFramework?: AccountingFramework | null
|
||
} = {},
|
||
) {
|
||
const captured: { update: Record<string, unknown> | null } = { update: null }
|
||
let schedCall = 0
|
||
const supabase = {
|
||
from: vi.fn((table: string) => {
|
||
if (table === 'companies') {
|
||
// Only read when a category change needs a framework-dependent
|
||
// default (the immaterial category).
|
||
const chain: Record<string, unknown> = {}
|
||
chain.select = vi.fn(() => chain)
|
||
chain.eq = vi.fn(() => chain)
|
||
chain.single = vi.fn(async () => ({
|
||
data: { accounting_framework: opts.accountingFramework ?? null },
|
||
error: null,
|
||
}))
|
||
return chain
|
||
}
|
||
if (table === 'depreciation_schedules') {
|
||
schedCall += 1
|
||
const isCountQuery = schedCall === 1
|
||
const chain: Record<string, unknown> = {}
|
||
chain.select = vi.fn(() => chain)
|
||
chain.eq = vi.fn(() => chain)
|
||
chain.neq = vi.fn(() => chain)
|
||
chain.not = vi.fn(() => chain)
|
||
chain.then = (resolve: (v: unknown) => void) =>
|
||
resolve(
|
||
isCountQuery
|
||
? { count: opts.postedCount ?? 0, error: null }
|
||
: {
|
||
data: (opts.otherAssetEntryIds ?? []).map((id) => ({
|
||
journal_entry_id: id,
|
||
})),
|
||
error: null,
|
||
},
|
||
)
|
||
return chain
|
||
}
|
||
if (table === 'journal_entries') {
|
||
// Entries step of the two-step fetch: derive the entry ids from the
|
||
// line fixtures so the chunked line query has ids to ask for.
|
||
const entryIds = [
|
||
...new Set((opts.accumulatedCredits ?? []).map((l) => l.journal_entry_id)),
|
||
]
|
||
const chain: Record<string, unknown> = {}
|
||
chain.select = vi.fn(() => chain)
|
||
chain.eq = vi.fn(() => chain)
|
||
chain.order = vi.fn(() => chain)
|
||
chain.range = vi.fn(() => chain)
|
||
chain.then = (resolve: (v: unknown) => void) =>
|
||
resolve({
|
||
data: entryIds.length > 0 ? entryIds.map((id) => ({ id })) : [{ id: 'entry-none' }],
|
||
error: null,
|
||
})
|
||
return chain
|
||
}
|
||
if (table === 'journal_entry_lines') {
|
||
const chain: Record<string, unknown> = {}
|
||
chain.select = vi.fn(() => chain)
|
||
chain.eq = vi.fn(() => chain)
|
||
chain.gt = vi.fn(() => chain)
|
||
chain.in = vi.fn(() => chain)
|
||
chain.order = vi.fn(() => chain)
|
||
chain.range = vi.fn(() => chain)
|
||
chain.then = (resolve: (v: unknown) => void) =>
|
||
resolve({ data: opts.accumulatedCredits ?? [], error: null })
|
||
return chain
|
||
}
|
||
const chain: Record<string, unknown> = {}
|
||
chain.select = vi.fn(() => chain)
|
||
chain.eq = vi.fn(() => chain)
|
||
chain.maybeSingle = vi.fn(async () => ({ data: asset, error: null }))
|
||
chain.update = vi.fn((payload: Record<string, unknown>) => {
|
||
captured.update = payload
|
||
return chain
|
||
})
|
||
chain.single = vi.fn(async () => ({
|
||
data: { ...asset, ...(captured.update ?? {}) },
|
||
error: null,
|
||
}))
|
||
return chain
|
||
}),
|
||
}
|
||
return { supabase, captured }
|
||
}
|
||
|
||
it('corrects acquisition_date when not disposed and no depreciation is posted', async () => {
|
||
const { supabase } = mockForUpdate(makeAssetRow(), { postedCount: 0 })
|
||
const result = await updateAsset(asSupabase(supabase), 'co', 'asset-1', {
|
||
acquisition_date: '2025-08-15',
|
||
})
|
||
expect(result.acquisition_date).toBe('2025-08-15')
|
||
})
|
||
|
||
it('blocks an acquisition_date correction once depreciation is posted', async () => {
|
||
const { supabase } = mockForUpdate(makeAssetRow(), { postedCount: 2 })
|
||
await expect(
|
||
updateAsset(asSupabase(supabase), 'co', 'asset-1', { acquisition_date: '2025-08-15' }),
|
||
).rejects.toBeInstanceOf(AssetCorrectionBlockedError)
|
||
})
|
||
|
||
it('blocks an acquisition_cost correction on a disposed asset', async () => {
|
||
const { supabase } = mockForUpdate(
|
||
makeAssetRow({ disposed_at: '2026-01-01', disposed_proceeds: 1000 }),
|
||
)
|
||
await expect(
|
||
updateAsset(asSupabase(supabase), 'co', 'asset-1', { acquisition_cost: 5000 }),
|
||
).rejects.toThrow(/disposed/i)
|
||
})
|
||
|
||
it('allows a name-only edit even when depreciation is posted', async () => {
|
||
// A name-only patch touches no acquisition-basis field, so the guard never
|
||
// runs and the edit succeeds regardless of depreciation state.
|
||
const { supabase } = mockForUpdate(makeAssetRow(), { postedCount: 5 })
|
||
const result = await updateAsset(asSupabase(supabase), 'co', 'asset-1', {
|
||
name: 'reMarkable Paper Pro 2',
|
||
})
|
||
expect(result.name).toBe('reMarkable Paper Pro 2')
|
||
})
|
||
|
||
it('realigns the BAS triple to the new category defaults on a category correction', async () => {
|
||
const { supabase, captured } = mockForUpdate(makeAssetRow(), { postedCount: 0 })
|
||
await updateAsset(asSupabase(supabase), 'co', 'asset-1', { category: 'equipment' })
|
||
// computer (1250/1259/7833) → equipment defaults (1220/1229/7832)
|
||
expect(captured.update).toMatchObject({
|
||
category: 'equipment',
|
||
bas_asset_account: '1220',
|
||
bas_accumulated_account: '1229',
|
||
bas_expense_account: '7832',
|
||
})
|
||
})
|
||
|
||
// A K2 aktiebolag that bought a software licence and first filed it under
|
||
// "Inventarier" must be able to recategorize it to "Immateriell tillgång":
|
||
// K2 forbids only EGENUPPARBETADE intangibles, and an acquired one is
|
||
// lawful on 1090/1099. Landing it on 1010/1019 would be the Ej K2 pair and
|
||
// the API gate would reject the whole edit.
|
||
it('realigns a category correction to immaterial onto 1090/1099 for a K2 company', async () => {
|
||
const { supabase, captured } = mockForUpdate(
|
||
makeAssetRow({
|
||
category: 'equipment',
|
||
bas_asset_account: '1220',
|
||
bas_accumulated_account: '1229',
|
||
bas_expense_account: '7832',
|
||
}),
|
||
{ postedCount: 0, accountingFramework: 'k2' },
|
||
)
|
||
await updateAsset(asSupabase(supabase), 'co', 'asset-1', { category: 'immaterial' })
|
||
expect(captured.update).toMatchObject({
|
||
category: 'immaterial',
|
||
bas_asset_account: '1090',
|
||
bas_accumulated_account: '1099',
|
||
bas_expense_account: '7810',
|
||
})
|
||
})
|
||
|
||
it('realigns a category correction to immaterial onto 1010/1019 for a K3 company', async () => {
|
||
const { supabase, captured } = mockForUpdate(
|
||
makeAssetRow({
|
||
category: 'equipment',
|
||
bas_asset_account: '1220',
|
||
bas_accumulated_account: '1229',
|
||
bas_expense_account: '7832',
|
||
}),
|
||
{ postedCount: 0, accountingFramework: 'k3' },
|
||
)
|
||
await updateAsset(asSupabase(supabase), 'co', 'asset-1', { category: 'immaterial' })
|
||
expect(captured.update).toMatchObject({
|
||
category: 'immaterial',
|
||
bas_asset_account: '1010',
|
||
bas_accumulated_account: '1019',
|
||
bas_expense_account: '7810',
|
||
})
|
||
})
|
||
|
||
it('keeps an explicit account override on a recategorization to immaterial', async () => {
|
||
// Explicit accounts suppress the realign entirely (unchanged semantics):
|
||
// a K3 company deliberately picking 1010/1019 still gets them.
|
||
const { supabase, captured } = mockForUpdate(
|
||
makeAssetRow({
|
||
category: 'equipment',
|
||
bas_asset_account: '1220',
|
||
bas_accumulated_account: '1229',
|
||
bas_expense_account: '7832',
|
||
}),
|
||
{ postedCount: 0, accountingFramework: 'k3' },
|
||
)
|
||
await updateAsset(asSupabase(supabase), 'co', 'asset-1', {
|
||
category: 'immaterial',
|
||
bas_asset_account: '1010',
|
||
bas_accumulated_account: '1019',
|
||
bas_expense_account: '7810',
|
||
})
|
||
expect(captured.update).toMatchObject({
|
||
bas_asset_account: '1010',
|
||
bas_accumulated_account: '1019',
|
||
})
|
||
})
|
||
|
||
it('blocks a correction when depreciation was hand-posted (no engine schedule)', async () => {
|
||
// No depreciation_schedules row, but a manual credit to the asset's 1259
|
||
// accumulated account exists in the ledger: must still block.
|
||
const { supabase } = mockForUpdate(makeAssetRow(), {
|
||
postedCount: 0,
|
||
otherAssetEntryIds: [],
|
||
accumulatedCredits: [{ journal_entry_id: 'manual-entry-1' }],
|
||
})
|
||
await expect(
|
||
updateAsset(asSupabase(supabase), 'co', 'asset-1', { acquisition_date: '2025-08-15' }),
|
||
).rejects.toBeInstanceOf(AssetCorrectionBlockedError)
|
||
})
|
||
|
||
it('allows a correction when the only 1259 credit is a sibling asset’s engine entry', async () => {
|
||
// Two computers share 1259. The sibling was depreciated via the engine, so
|
||
// its journal entry is attributable to the OTHER asset and must NOT block a
|
||
// correction of this still-undepreciated asset (no false positive).
|
||
const { supabase, captured } = mockForUpdate(makeAssetRow(), {
|
||
postedCount: 0,
|
||
otherAssetEntryIds: ['sibling-engine-entry'],
|
||
accumulatedCredits: [{ journal_entry_id: 'sibling-engine-entry' }],
|
||
})
|
||
const result = await updateAsset(asSupabase(supabase), 'co', 'asset-1', {
|
||
acquisition_date: '2025-08-15',
|
||
})
|
||
expect(result.acquisition_date).toBe('2025-08-15')
|
||
expect(captured.update).toMatchObject({ acquisition_date: '2025-08-15' })
|
||
})
|
||
})
|
||
|
||
describe('createAsset: framework-aware immaterial defaults', () => {
|
||
function mockForCreate(
|
||
opts: { accountingFramework?: AccountingFramework | null; companyError?: string } = {},
|
||
) {
|
||
const captured: { insert: Record<string, unknown> | null; companyReads: number } = {
|
||
insert: null,
|
||
companyReads: 0,
|
||
}
|
||
const supabase = {
|
||
from: vi.fn((table: string) => {
|
||
const chain: Record<string, unknown> = {}
|
||
if (table === 'companies') {
|
||
captured.companyReads += 1
|
||
chain.select = vi.fn(() => chain)
|
||
chain.eq = vi.fn(() => chain)
|
||
chain.single = vi.fn(async () =>
|
||
opts.companyError
|
||
? { data: null, error: { message: opts.companyError } }
|
||
: { data: { accounting_framework: opts.accountingFramework ?? null }, error: null },
|
||
)
|
||
return chain
|
||
}
|
||
chain.insert = vi.fn((payload: Record<string, unknown>) => {
|
||
captured.insert = payload
|
||
return chain
|
||
})
|
||
chain.select = vi.fn(() => chain)
|
||
chain.single = vi.fn(async () => ({
|
||
data: { id: 'asset-new', ...(captured.insert ?? {}) },
|
||
error: null,
|
||
}))
|
||
return chain
|
||
}),
|
||
}
|
||
return { supabase, captured }
|
||
}
|
||
|
||
const asSupabase = (s: unknown) => s as Parameters<typeof createAsset>[0]
|
||
|
||
const baseInput = {
|
||
name: 'Programvarulicens',
|
||
category: 'immaterial' as const,
|
||
acquisition_date: '2025-03-01',
|
||
acquisition_cost: 60_000,
|
||
useful_life_months: 60,
|
||
}
|
||
|
||
it('books a K2 company immaterial asset on the acquired pair 1090/1099', async () => {
|
||
const { supabase, captured } = mockForCreate({ accountingFramework: 'k2' })
|
||
await createAsset(asSupabase(supabase), 'co', 'user-1', baseInput)
|
||
expect(captured.insert).toMatchObject({
|
||
bas_asset_account: '1090',
|
||
bas_accumulated_account: '1099',
|
||
bas_expense_account: '7810',
|
||
})
|
||
})
|
||
|
||
it('books a K3 company immaterial asset on 1010/1019', async () => {
|
||
const { supabase, captured } = mockForCreate({ accountingFramework: 'k3' })
|
||
await createAsset(asSupabase(supabase), 'co', 'user-1', baseInput)
|
||
expect(captured.insert).toMatchObject({
|
||
bas_asset_account: '1010',
|
||
bas_accumulated_account: '1019',
|
||
})
|
||
})
|
||
|
||
it('still honours an explicit account override', async () => {
|
||
const { supabase, captured } = mockForCreate({ accountingFramework: 'k2' })
|
||
await createAsset(asSupabase(supabase), 'co', 'user-1', {
|
||
...baseInput,
|
||
bas_asset_account: '1030',
|
||
bas_accumulated_account: '1039',
|
||
})
|
||
expect(captured.insert).toMatchObject({
|
||
bas_asset_account: '1030',
|
||
bas_accumulated_account: '1039',
|
||
})
|
||
})
|
||
|
||
it('does not read the company for a tangible category', async () => {
|
||
const { supabase, captured } = mockForCreate({ accountingFramework: 'k2' })
|
||
await createAsset(asSupabase(supabase), 'co', 'user-1', {
|
||
...baseInput,
|
||
name: 'MacBook Pro',
|
||
category: 'computer',
|
||
})
|
||
expect(captured.companyReads).toBe(0)
|
||
expect(captured.insert).toMatchObject({
|
||
bas_asset_account: '1250',
|
||
bas_accumulated_account: '1259',
|
||
})
|
||
})
|
||
|
||
it('throws instead of guessing a framework when the company read fails', async () => {
|
||
const { supabase, captured } = mockForCreate({ companyError: 'connection reset' })
|
||
await expect(
|
||
createAsset(asSupabase(supabase), 'co', 'user-1', baseInput),
|
||
).rejects.toThrow(/accounting framework/i)
|
||
expect(captured.insert).toBeNull()
|
||
})
|
||
})
|
||
|
||
|