Files
accounted/lib/bokslut/__tests__/asset-service.test.ts
T
Jakob WennbergandClaude Opus 4.8 cb01b946fc fix(bokslut): map computer/vehicle depreciation to standard 7832 so it resolves (#755) (#822)
* fix(bokslut): map computer/vehicle depreciation to standard 7832 so it resolves (#755)

The computer asset category mapped depreciation expense to 7833 and vehicle to
7834, but neither is in the standard BAS catalog (7834/7835 were removed as
non-standard in #463, guarded by bas-reference.test.ts). Because
backfillStandardBASAccounts only seeds accounts present in BAS_REFERENCE, the
engine threw AccountsNotInChartError on minimal charts and annual depreciation
was blocked.

- Remap computer and vehicle depreciation expense to 7832 (Avskrivningar på
  inventarier, verktyg och installationer). Both 1240 (Bilar) and 1250 (Datorer)
  sit in the maskiner-och-inventarier asset range, so 7832 is the correct
  standard depreciation account — same one equipment already uses. The asset
  register still separates them via 1240/1249 and 1250/1259 on the balance sheet.
- A regression guard surfaced a second gap: other_tangible mapped to 1280/1289,
  but 1280 is 'Pågående nyanläggningar/förskott' and 1289 is not a BAS account.
  Remap other_tangible to 1290/1299 ('Övriga materiella anläggningstillgångar' +
  its ack. avskrivningar) — the BAS-correct accounts, and the range the iXBRL K2
  mapper already classifies other_tangible under. Keeps accumulated = asset + 9.
- Add a guard test asserting every DEFAULT_ACCOUNTS_BY_CATEGORY account resolves
  in BAS_REFERENCE, so a future missing account fails CI instead of a user's
  depreciation run.

No new BAS accounts are added, so the non-standard-accounts guard stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dashboard): drop the duplicate next-best-action hero for a single CTA

The agent-built dashboard showed a 'next best action' hero card AND the unified
'Att göra' worklist below it — two surfaces pointing at the same work (book
transactions, unpaid invoices). Remove the hero so the page leads with metrics +
the single 'Att göra' worklist, giving one unambiguous CTA surface instead of
two. Drops the now-unused nextBestAction computation and the Receipt/
ArrowLeftRight/Clock imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:47:46 +02:00

487 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest'
import {
AssetCorrectionBlockedError,
DEFAULT_ACCOUNTS_BY_CATEGORY,
disposeAsset,
updateAsset,
} from '../assets/asset-service'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import type { Asset } from '@/types'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn().mockResolvedValue({
id: 'entry-1',
voucher_series: 'A',
voucher_number: 1,
}),
}))
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 cat of Object.keys(DEFAULT_ACCOUNTS_BY_CATEGORY) as Array<
keyof typeof DEFAULT_ACCOUNTS_BY_CATEGORY
>) {
const { asset, accumulated, expense } = DEFAULT_ACCOUNTS_BY_CATEGORY[cat]
for (const account of [asset, accumulated, expense]) {
expect(getBASReference(account), `${cat}: ${account} missing from BAS reference`).toBeDefined()
}
}
})
})
describe('disposeAsset — gain/loss account selection', () => {
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'u',
company_id: 'co',
name: 'Test',
category: 'equipment',
acquisition_date: '2023-01-01',
acquisition_cost: 100_000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
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: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
...overrides,
}
}
function makeSupabaseForDispose(asset: Asset, schedules: Array<{ planned_depreciation: number }>) {
// Three from() calls happen inside disposeAsset:
// 1. getAsset (.maybeSingle on 'assets')
// 2. sumPostedDepreciation (.then on 'depreciation_schedules' — server-derived
// accumulated_depreciation; replaces the previously client-supplied value)
// 3. update (.single on 'assets', returning the disposed row)
const builders = {
getBuilder: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: asset, error: null }),
},
schedulesBuilder: (() => {
const b: Record<string, unknown> = {
select: vi.fn(),
eq: vi.fn(),
not: vi.fn(),
then: undefined,
}
;(b.select as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.eq as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.not as ReturnType<typeof vi.fn>).mockReturnValue(b)
b.then = (resolve: (v: { data: unknown; error: unknown }) => void) =>
resolve({ data: schedules, error: null })
return b as { select: ReturnType<typeof vi.fn>; eq: ReturnType<typeof vi.fn>; not: ReturnType<typeof vi.fn> }
})(),
updateBuilder: {
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { ...asset, disposed_at: '2025-06-30', disposed_proceeds: 50_000 },
error: null,
}),
},
}
let calls = 0
const supabase = {
from: vi.fn((table: string) => {
calls++
if (table === 'depreciation_schedules') return builders.schedulesBuilder
return calls === 1 ? builders.getBuilder : builders.updateBuilder
}),
}
return { supabase, builders } as const
}
it('uses 3973 / 7973 for tangible asset disposal (equipment)', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({ category: 'equipment' })
// Two prior posted schedules summing to 40_000 → NBV = 60_000, proceeds 80_000 → gain 20_000
const { supabase } = makeSupabaseForDispose(asset, [
{ planned_depreciation: 20_000 },
{ planned_depreciation: 20_000 },
])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 80_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
expect(call).toBeDefined()
const lines = (call![3] as { lines: { account_number: string; debit_amount: number; credit_amount: number }[] }).lines
// Server-derived accumulated debits 1229
expect(lines.find((l) => l.account_number === '1229')?.debit_amount).toBe(40_000)
// Gain goes to 3973 (tangible), not 3013
expect(lines.find((l) => l.account_number === '3973')).toBeDefined()
expect(lines.find((l) => l.account_number === '3013')).toBeUndefined()
})
it('uses 3013 / 7813 for immaterial asset disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'immaterial',
bas_asset_account: '1010',
bas_accumulated_account: '1019',
bas_expense_account: '7810',
})
// NBV = 50_000, proceeds 10_000 → loss 40_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 50_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
expect(call).toBeDefined()
const lines = (call![3] as { lines: { account_number: string; debit_amount: number }[] }).lines
expect(lines.find((l) => l.account_number === '7813')).toBeDefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
})
it('uses 3971 / 7971 for building disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'building',
acquisition_cost: 2_000_000,
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
})
// NBV = 1_500_000, proceeds 2_000_000 → gain 500_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 500_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 2_000_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string }[] }).lines
// Buildings route to 3971/7971, not 3973/7973
expect(lines.find((l) => l.account_number === '3971')).toBeDefined()
expect(lines.find((l) => l.account_number === '3973')).toBeUndefined()
})
it('uses 3971 / 7971 for land_improvement disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'land_improvement',
acquisition_cost: 100_000,
bas_asset_account: '1150',
bas_accumulated_account: '1159',
bas_expense_account: '7824',
})
// NBV = 80_000, proceeds 40_000 → loss 40_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 20_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 40_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string }[] }).lines
// Markanläggning routes to 7971 like buildings
expect(lines.find((l) => l.account_number === '7971')).toBeDefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
})
it('server-derives accumulated_depreciation — caller cannot inflate gain', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({ category: 'equipment', acquisition_cost: 100_000 })
// Real accumulated = 30_000 from one posted schedule. A malicious client
// could previously pass accumulated_depreciation: 100_000 to fake a fully
// depreciated asset and pocket a 50_000 phantom gain on proceeds. With
// server derivation, the lines reflect the actual 30_000.
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 30_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 50_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string; debit_amount: number; credit_amount: number }[] }).lines
// accumulated debit must be 30_000 (server-derived), not anything else
expect(lines.find((l) => l.account_number === '1229')?.debit_amount).toBe(30_000)
// NBV = 100_000 − 30_000 = 70_000, proceeds 50_000 → loss 20_000 to 7973
expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(20_000)
})
})
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 three 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_entry_lines' → hasManualDepreciationPosted ledger scan → {data}
*/
function mockForUpdate(
asset: Asset,
opts: {
postedCount?: number
otherAssetEntryIds?: string[]
accumulatedCredits?: { journal_entry_id: string }[]
} = {},
) {
const captured: { update: Record<string, unknown> | null } = { update: null }
let schedCall = 0
const supabase = {
from: vi.fn((table: string) => {
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_entry_lines') {
const chain: Record<string, unknown> = {}
chain.select = vi.fn(() => chain)
chain.eq = vi.fn(() => chain)
chain.gt = 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',
})
})
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' })
})
})