import { describe, it, expect, vi } from 'vitest' import { DEFAULT_ACCOUNTS_BY_CATEGORY, disposeAsset } from '../assets/asset-service' 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: '7834' }, computer: { asset: '1250', accumulated: '1259', expense: '7833' }, other_tangible: { asset: '1280', accumulated: '1289', 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}$/) } }) }) describe('disposeAsset — gain/loss account selection', () => { function makeAsset(overrides: Partial = {}): 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 = { select: vi.fn(), eq: vi.fn(), not: vi.fn(), then: undefined, } ;(b.select as ReturnType).mockReturnValue(b) ;(b.eq as ReturnType).mockReturnValue(b) ;(b.not as ReturnType).mockReturnValue(b) b.then = (resolve: (v: { data: unknown; error: unknown }) => void) => resolve({ data: schedules, error: null }) return b as { select: ReturnType; eq: ReturnType; not: ReturnType } })(), 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[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[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[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[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[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) }) })