feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) (#1391)

* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse)

Disposal books depreciation to the disposal date, clears cost and
accumulated depreciation, books gain (3973) or loss (7973), applies
output VAT on third-party sales, honors the ML 5 kap. 38 §
verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning
server-side from tax years and original input VAT. The voucher, the
disposal-date depreciation schedule and the immutable register state
commit in one dedicated commit_asset_disposal RPC transaction that
delegates voucher numbering to commit_journal_entry.

Fixes #325

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

* fix(assets): harden disposal per review and pg-real findings

- commit_asset_disposal now uses the NULL-safe caller_is_company_member()
  guard (tenant-guard ratchet) and passes the allowed 'user_accept'
  commit_method instead of the unlisted 'asset_disposal' value
- disposal metadata invariants validated in the RPC (non-negative
  proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no
  proceeds) since the RPC is independently callable
- new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so
  the migration never blocks writes on the hot journal_entries table
- disposeAsset paginates fiscal periods and depreciation schedules with
  fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||)
- engine imports shared AssetDisposalType/AssetJamkningDirection/
  VatTreatment unions; post-commit reload retries once and logs before
  surfacing, so a transient read cannot masquerade as a failed disposal
- dispose page parses Swedish-formatted amounts (125 000,50) and blocks
  submission on unparseable proceeds
- assets pg tests write disposal attributes in the disposal transition
  itself and gain a regression test that the register is frozen after

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-04 09:59:52 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1c9d378df8
commit cb3ef45f14
22 changed files with 19644 additions and 1805 deletions
+3 -247
View File
@@ -1,21 +1,13 @@
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 = {
@@ -67,244 +59,6 @@ describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
})
})
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 {
@@ -507,3 +261,5 @@ describe('updateAsset: acquisition-basis correction guard', () => {
expect(captured.update).toMatchObject({ acquisition_date: '2025-08-15' })
})
})
@@ -0,0 +1,228 @@
import { describe, expect, it } from 'vitest'
import { buildAssetDisposalPlan, type DisposeAssetInput } from '../asset-service'
import type { Asset, AssetCategory } from '@/types'
const PERIOD = { id: 'period-2026', period_start: '2026-01-01', period_end: '2026-12-31' }
const PERIODS = [
{ id: 'period-2025', period_start: '2025-01-01' },
{ id: 'period-2026', period_start: '2026-01-01' },
{ id: 'period-2027', period_start: '2027-01-01' },
]
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'company-1',
name: 'Machine',
category: 'equipment',
acquisition_date: '2025-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: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides,
}
}
function makeInput(overrides: Partial<DisposeAssetInput> = {}): DisposeAssetInput {
return {
disposal_type: 'sale',
disposed_at: '2026-06-30',
disposed_proceeds: 100_000,
proceeds_account: '1930',
fiscal_period_id: PERIOD.id,
vat_treatment: 'standard_25',
...overrides,
}
}
function build(overrides: {
asset?: Partial<Asset>
input?: Partial<DisposeAssetInput>
schedules?: Array<{
fiscal_period_id: string
planned_depreciation: number
journal_entry_id: string | null
}>
} = {}) {
return buildAssetDisposalPlan({
asset: makeAsset(overrides.asset),
input: makeInput(overrides.input),
fiscalPeriod: PERIOD,
periods: PERIODS,
schedules: overrides.schedules ?? [
{
fiscal_period_id: 'period-2025',
planned_depreciation: 20_000,
journal_entry_id: 'entry-2025',
},
],
})
}
describe('buildAssetDisposalPlan', () => {
it('books depreciation through the disposal date before removing the asset', () => {
const plan = build()
expect(plan.currentDepreciation).toBe(9_918)
expect(plan.accumulatedDepreciation).toBe(29_918)
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '7832', debit_amount: 9_918 }),
expect.objectContaining({ account_number: '1229', credit_amount: 9_918 }),
expect.objectContaining({ account_number: '1229', debit_amount: 29_918 }),
expect.objectContaining({ account_number: '1220', credit_amount: 100_000 }),
]),
)
})
it('derives 25 percent output VAT from gross proceeds on the server', () => {
const plan = build({ input: { disposed_proceeds: 125_000 } })
expect(plan.proceedsVat).toBe(25_000)
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '1930', debit_amount: 125_000 }),
expect.objectContaining({ account_number: '2611', credit_amount: 25_000 }),
]),
)
})
it.each<[AssetCategory, string, string]>([
['immaterial', '3971', '7971'],
['building', '3972', '7972'],
['land_improvement', '3972', '7972'],
['equipment', '3973', '7973'],
])('uses the BAS disposal pair for %s', (category, gainAccount, lossAccount) => {
const accountOverrides =
category === 'immaterial'
? { bas_asset_account: '1010', bas_accumulated_account: '1019', bas_expense_account: '7810' }
: category === 'building' || category === 'land_improvement'
? { bas_asset_account: '1110', bas_accumulated_account: '1119', bas_expense_account: '7821' }
: {}
const gain = build({
asset: { category, acquisition_cost: 20_000, ...accountOverrides },
input: { disposed_proceeds: 125_000 },
schedules: [],
})
const loss = build({
asset: { category, acquisition_cost: 100_000, ...accountOverrides },
input: { disposal_type: 'scrap', disposed_proceeds: 0, vat_treatment: undefined },
schedules: [],
})
expect(gain.lines.some((line) => line.account_number === gainAccount)).toBe(true)
expect(loss.lines.some((line) => line.account_number === lossAccount)).toBe(true)
})
it('fully clears a fully depreciated asset on scrapping', () => {
const plan = build({
asset: { acquisition_date: '2021-01-01' },
input: { disposal_type: 'scrap', disposed_proceeds: 0, vat_treatment: undefined },
schedules: [
{ fiscal_period_id: 'period-2025', planned_depreciation: 100_000, journal_entry_id: 'entry' },
],
})
expect(plan.gainOrLoss).toBe(0)
expect(plan.lines).toEqual([
expect.objectContaining({ account_number: '1229', debit_amount: 100_000 }),
expect.objectContaining({ account_number: '1220', credit_amount: 100_000 }),
])
})
it('refuses disposal when a later period already has posted depreciation', () => {
expect(() =>
build({
schedules: [
{ fiscal_period_id: 'period-2027', planned_depreciation: 20_000, journal_entry_id: 'entry' },
],
}),
).toThrow('later_depreciation_posted')
})
it('refuses to overwrite a mismatched posted current-period schedule', () => {
expect(() =>
build({
schedules: [
{ fiscal_period_id: 'period-2026', planned_depreciation: 20_000, journal_entry_id: 'entry' },
],
}),
).toThrow('current_depreciation_mismatch')
})
it('books negative VAT adjustment to 6999 and 2641', () => {
const plan = build({
asset: { acquisition_cost: 300_000 },
input: {
vat_treatment: 'exempt',
jamkning_original_input_vat: 75_000,
jamkning_original_deduction_percent: 100,
},
})
expect(plan.jamkning).toMatchObject({ direction: 'decrease', amount: 60_000 })
expect(plan.lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ account_number: '6999', debit_amount: 60_000 }),
expect.objectContaining({ account_number: '2641', credit_amount: 60_000 }),
]),
)
})
it('requires explicit ML 5:38 confirmation for a business transfer', () => {
expect(() =>
build({
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
},
}),
).toThrow('ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED')
})
it('requires an adjustment document when an investment-good obligation transfers', () => {
expect(() =>
build({
asset: { acquisition_cost: 300_000 },
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
business_transfer_confirmed: true,
jamkning_original_input_vat: 50_000,
jamkning_original_deduction_percent: 100,
},
}),
).toThrow('ASSET_ADJUSTMENT_DOCUMENT_REQUIRED')
const plan = build({
asset: { acquisition_cost: 300_000 },
input: {
disposal_type: 'business_transfer',
vat_treatment: undefined,
business_transfer_confirmed: true,
adjustment_document_confirmed: true,
jamkning_original_input_vat: 50_000,
jamkning_original_deduction_percent: 100,
},
})
expect(plan.jamkning.direction).toBe('transferred')
})
})
@@ -0,0 +1,163 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
describe('commit_asset_disposal (pg-real)', () => {
async function insertAsset(userId: string, companyId: string): Promise<string> {
const assetId = randomUUID()
await getPool().query(
`INSERT INTO public.assets (
id, user_id, company_id, name, category, acquisition_date,
acquisition_cost, salvage_value, useful_life_months,
depreciation_method, bas_asset_account, bas_accumulated_account,
bas_expense_account
) VALUES ($1, $2, $3, 'Machine', 'equipment', '2025-01-01',
100000, 0, 60, 'linear', '1220', '1229', '7832')`,
[assetId, userId, companyId],
)
return assetId
}
async function insertDraft(args: {
userId: string
companyId: string
fiscalPeriodId: string
debitLines: Array<[string, number]>
creditLines: Array<[string, number]>
}): Promise<string> {
const entryId = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entries (
id, user_id, company_id, fiscal_period_id, voucher_number,
voucher_series, entry_date, description, source_type, status
) VALUES ($1, $2, $3, $4, 0, 'A', '2026-06-30',
'Asset disposal', 'system', 'draft')`,
[entryId, args.userId, args.companyId, args.fiscalPeriodId],
)
for (const [account, amount] of args.debitLines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, 0)`,
[entryId, account, amount],
)
}
for (const [account, amount] of args.creditLines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, 0, $3)`,
[entryId, account, amount],
)
}
return entryId
}
async function commit(args: {
companyId: string
assetId: string
entryId: string
fiscalPeriodId: string
disposalType?: string
currentDepreciation?: number
}) {
return getPool().query(
`SELECT * FROM public.commit_asset_disposal(
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::text,
'2026-06-30'::date, 80000::numeric, 0::numeric, 'exempt'::text,
$6::numeric, 0::numeric, 'none'::text, 4::integer, 5::integer,
0::numeric, 0::numeric, 0::numeric, NULL::text, NULL::text
)`,
[
args.companyId,
args.assetId,
args.entryId,
args.fiscalPeriodId,
args.disposalType ?? 'sale',
args.currentDepreciation ?? 0,
],
)
}
it('posts the voucher, schedule, and register state in one transaction', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const assetId = await insertAsset(userId, companyId)
const entryId = await insertDraft({
userId,
companyId,
fiscalPeriodId,
debitLines: [['7832', 10_000], ['1229', 30_000], ['1930', 80_000]],
creditLines: [['1229', 10_000], ['1220', 100_000], ['3973', 10_000]],
})
await commit({ companyId, assetId, entryId, fiscalPeriodId, currentDepreciation: 10_000 })
const entry = await getPool().query(
`SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
[entryId],
)
const asset = await getPool().query(
`SELECT disposed_at::text, disposal_type, disposal_journal_entry_id
FROM public.assets WHERE id = $1`,
[assetId],
)
const schedule = await getPool().query(
`SELECT planned_depreciation::numeric, journal_entry_id
FROM public.depreciation_schedules
WHERE asset_id = $1 AND fiscal_period_id = $2`,
[assetId, fiscalPeriodId],
)
expect(entry.rows[0]).toMatchObject({ status: 'posted' })
expect(entry.rows[0].voucher_number).toBeGreaterThan(0)
expect(asset.rows[0]).toMatchObject({
disposed_at: '2026-06-30',
disposal_type: 'sale',
disposal_journal_entry_id: entryId,
})
expect(Number(schedule.rows[0].planned_depreciation)).toBe(10_000)
expect(schedule.rows[0].journal_entry_id).toBe(entryId)
await expect(
getPool().query(`UPDATE public.assets SET disposed_proceeds = 1 WHERE id = $1`, [assetId]),
).rejects.toThrow(/Cannot modify financial or disposal attributes/)
await expect(
getPool().query(`UPDATE public.assets SET notes = 'Audit note' WHERE id = $1`, [assetId]),
).resolves.toBeDefined()
})
it('rolls the voucher commit back when the register update fails', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const assetId = await insertAsset(userId, companyId)
const entryId = await insertDraft({
userId,
companyId,
fiscalPeriodId,
debitLines: [['1930', 80_000], ['7973', 20_000]],
creditLines: [['1220', 100_000]],
})
await expect(
commit({
companyId,
assetId,
entryId,
fiscalPeriodId,
disposalType: 'invalid',
}),
).rejects.toThrow()
const entry = await getPool().query(
`SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
[entryId],
)
const asset = await getPool().query(
`SELECT disposed_at FROM public.assets WHERE id = $1`,
[assetId],
)
expect(entry.rows[0]).toMatchObject({ status: 'draft', voucher_number: 0 })
expect(asset.rows[0].disposed_at).toBeNull()
})
})
@@ -1,436 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { disposeAsset } from '../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,
}),
}))
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,
}
}
interface CapturedLine {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}
function makeSupabaseForDispose(asset: Asset, schedules: Array<{ planned_depreciation: number }>) {
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: '2026-05-26' },
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
}
async function captureLines(asset: Asset, schedules: Array<{ planned_depreciation: number }>, input: Parameters<typeof disposeAsset>[4]): Promise<{ lines: CapturedLine[]; updateArgs: Record<string, unknown> }> {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const { supabase, builders } = makeSupabaseForDispose(asset, schedules)
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
input,
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: CapturedLine[] }).lines
const updateArgs = (builders.updateBuilder.update.mock.calls[0]?.[0] ?? {}) as Record<string, unknown>
return { lines, updateArgs }
}
function sumDebit(lines: CapturedLine[]): number {
return Math.round(lines.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100
}
function sumCredit(lines: CapturedLine[]): number {
return Math.round(lines.reduce((s, l) => s + l.credit_amount, 0) * 100) / 100
}
describe('disposeAsset: VAT on proceeds', () => {
it('standard_25 sale appends a 2611 credit and balances', async () => {
// Acquisition 100 000, accumulated 40 000 → NBV 60 000.
// Gross proceeds 100 000 → net 80 000 → vat 20 000 → gain 20 000.
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 100_000,
proceeds_vat: 20_000,
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(20_000)
// Gain on NET proceeds, not gross: 80 000 net − 60 000 NBV = 20 000 gain
expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
// No loss line on a gain scenario
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
// Bank debit = gross
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(100_000)
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('reduced_12 sale uses BAS 2621', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 100_000,
proceeds_vat: 100_000 - 100_000 / 1.12,
vat_treatment: 'reduced_12',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '2621')).toBeDefined()
expect(lines.find((l) => l.account_number === '2611')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('reduced_6 sale uses BAS 2631', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 100_000,
proceeds_vat: 100_000 - 100_000 / 1.06,
vat_treatment: 'reduced_6',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '2631')).toBeDefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('reverse_charge sale posts NO VAT line', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 80_000,
proceeds_vat: 0,
vat_treatment: 'reverse_charge',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number.startsWith('261'))).toBeUndefined()
// The full proceeds counts as net (no VAT taken out) → gain = 80 000 − 60 000 = 20 000
expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('export sale posts NO VAT line', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 80_000,
proceeds_vat: 0,
vat_treatment: 'export',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number.startsWith('26'))).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('throws when proceeds_vat > 0 but no vat_treatment is supplied', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const { supabase } = makeSupabaseForDispose(makeAsset(), [{ planned_depreciation: 40_000 }])
await expect(
disposeAsset(supabase as unknown as Parameters<typeof disposeAsset>[0], 'co', 'u', 'asset-1', {
disposed_at: '2026-05-26',
disposed_proceeds: 100_000,
proceeds_vat: 20_000,
fiscal_period_id: 'fp',
}),
).rejects.toThrow(/vat_treatment/)
})
it('throws when reverse_charge is selected but proceeds_vat > 0', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const { supabase } = makeSupabaseForDispose(makeAsset(), [{ planned_depreciation: 40_000 }])
await expect(
disposeAsset(supabase as unknown as Parameters<typeof disposeAsset>[0], 'co', 'u', 'asset-1', {
disposed_at: '2026-05-26',
disposed_proceeds: 80_000,
proceeds_vat: 5_000,
vat_treatment: 'reverse_charge',
fiscal_period_id: 'fp',
}),
).rejects.toThrow(/reverse_charge/)
})
it('zero-VAT sale (no fields passed) still posts a balanced entry', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-05-26',
disposed_proceeds: 50_000,
fiscal_period_id: 'fp',
},
)
// No VAT line
expect(lines.find((l) => l.account_number.startsWith('26'))).toBeUndefined()
// Loss on 50 000 − 60 000 = -10 000 → 7973 debit
expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(10_000)
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
})
describe('disposeAsset: jämkning (input VAT correction)', () => {
it('credits 2641 and debits 6991 for the jämkning amount', async () => {
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 60_000,
fiscal_period_id: 'fp',
// 5-year asset sold after 3 years; 24 months remain × 20 000 VAT × 24/60 = 8 000
jamkning_amount: 8_000,
jamkning_remaining_months: 24,
jamkning_total_months: 60,
jamkning_original_input_vat: 20_000,
},
)
// 2641 credit (reverses prior input VAT deduction)
expect(lines.find((l) => l.account_number === '2641')?.credit_amount).toBe(8_000)
// Jämkning is a VAT correction (ML 8a kap), not a disposal loss: it must
// route to 6991 "Övriga externa kostnader, avdragsgilla", NOT to 78xx.
expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(8_000)
// No 78xx line: proceeds 60 000 = NBV 60 000 means no gain/loss, and the
// jämkning explicitly does not contaminate the disposal-loss accounts.
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('combined VAT + jämkning + gain stays balanced', async () => {
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 100_000, // gross
proceeds_vat: 20_000, // 25%
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
jamkning_amount: 8_000,
jamkning_remaining_months: 24,
jamkning_total_months: 60,
jamkning_original_input_vat: 20_000,
},
)
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(20_000)
expect(lines.find((l) => l.account_number === '2641')?.credit_amount).toBe(8_000)
// Gain 20 000 on net proceeds → 3973 credit; jämkning 8 000 → 6991 debit
// (NOT 7973: see ML 8a kap, jämkning is a VAT correction not a loss).
expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(8_000)
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('zero jämkning amount produces no extra lines', async () => {
const asset = makeAsset()
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 60_000,
fiscal_period_id: 'fp',
jamkning_amount: 0,
},
)
expect(lines.find((l) => l.account_number === '2641')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('routes jämkning to 6991 even for building category (never to 78xx)', async () => {
const asset = makeAsset({
category: 'building',
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
acquisition_cost: 2_000_000,
})
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 200_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 1_800_000,
fiscal_period_id: 'fp',
// 10-year fastighet, 60 months remaining out of 120, original 200 000 → 100 000
jamkning_amount: 100_000,
jamkning_remaining_months: 60,
jamkning_total_months: 120,
jamkning_original_input_vat: 200_000,
},
)
// Jämkning goes to 6991 regardless of asset category: it's a VAT
// correction per ML 8a kap, NOT a förlust vid avyttring (78xx).
expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(100_000)
// The disposal itself is at a loss (NBV 1.8M = proceeds 1.8M? Let's check:
// acq 2.0M − ack 0.2M = NBV 1.8M, proceeds 1.8M → no gain/loss line). The
// only debit-side cost line is the 6991 jämkning entry.
expect(lines.find((l) => l.account_number === '7971')).toBeUndefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('persists jämkning + VAT audit metadata on the asset row', async () => {
const asset = makeAsset()
const { updateArgs } = await captureLines(
asset,
[{ planned_depreciation: 40_000 }],
{
disposed_at: '2026-01-01',
disposed_proceeds: 100_000,
proceeds_vat: 20_000,
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
jamkning_amount: 8_000,
jamkning_remaining_months: 24,
jamkning_total_months: 60,
jamkning_original_input_vat: 20_000,
},
)
expect(updateArgs.disposed_proceeds).toBe(100_000)
expect(updateArgs.disposed_proceeds_vat).toBe(20_000)
expect(updateArgs.disposed_vat_treatment).toBe('standard_25')
expect(updateArgs.jamkning_amount).toBe(8_000)
expect(updateArgs.jamkning_remaining_months).toBe(24)
expect(updateArgs.jamkning_total_months).toBe(60)
expect(updateArgs.jamkning_original_input_vat).toBe(20_000)
})
})
describe('disposeAsset: gain vs loss with VAT', () => {
it('gain scenario: net proceeds > NBV → 3973 credit', async () => {
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 50_000 }],
{
disposed_at: '2026-05-26',
// NBV = 50 000, net proceeds = 80 000 → gain 30 000
disposed_proceeds: 100_000,
proceeds_vat: 20_000,
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(30_000)
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
it('loss scenario: net proceeds < NBV → 7973 debit', async () => {
const asset = makeAsset({ category: 'equipment' })
const { lines } = await captureLines(
asset,
[{ planned_depreciation: 20_000 }],
{
disposed_at: '2026-05-26',
// NBV = 80 000, net proceeds = 40 000 → loss 40 000
disposed_proceeds: 50_000,
proceeds_vat: 10_000,
vat_treatment: 'standard_25',
fiscal_period_id: 'fp',
},
)
expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(40_000)
expect(lines.find((l) => l.account_number === '3973')).toBeUndefined()
expect(sumDebit(lines)).toBe(sumCredit(lines))
})
})
+94 -183
View File
@@ -1,205 +1,116 @@
import { describe, it, expect } from 'vitest'
import {
computeJamkningAmount,
assessJamkningEligibility,
} from '../jamkning'
describe('computeJamkningAmount', () => {
it('5-year asset sold after 3 years (24 months remaining, 20 000 kr input VAT) → 8 000 kr', () => {
// ML 8a kap 7 §: (24 / 60) × 20 000 = 8 000
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 60,
remainingMonths: 24,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(8_000)
})
it('10-year fastighet sold after 7 years (36 months remaining, 200 000 kr input VAT) → 60 000 kr', () => {
// ML 8a kap 7 §: (36 / 120) × 200 000 = 60 000
const amount = computeJamkningAmount({
originalInputVat: 200_000,
totalCorrectionMonths: 120,
remainingMonths: 36,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(60_000)
})
it('sold after the correction period (0 remaining) → 0', () => {
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 60,
remainingMonths: 0,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(0)
})
it('sold immediately (60 months remaining on 60-month period) → full originalInputVat', () => {
// (60 / 60) × 20 000 = 20 000: the full deduction must be reversed
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 60,
remainingMonths: 60,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(20_000)
})
it('returns 0 when disposalEvent is no_jamkning', () => {
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 60,
remainingMonths: 24,
disposalEvent: 'no_jamkning',
})
expect(amount).toBe(0)
})
it('caps remaining months at totalCorrectionMonths (defensive)', () => {
// A caller bug could pass remainingMonths > totalCorrectionMonths.
// Cap at the total so the answer never exceeds originalInputVat.
const amount = computeJamkningAmount({
originalInputVat: 10_000,
totalCorrectionMonths: 60,
remainingMonths: 120,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(10_000)
})
it('handles negligible cost (zero originalInputVat) → 0 without NaN', () => {
const amount = computeJamkningAmount({
originalInputVat: 0,
totalCorrectionMonths: 60,
remainingMonths: 24,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(0)
expect(Number.isNaN(amount)).toBe(false)
})
it('returns 0 when totalCorrectionMonths is 0 (avoid divide-by-zero)', () => {
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: 0,
remainingMonths: 0,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(0)
expect(Number.isFinite(amount)).toBe(true)
})
it('returns 0 when totalCorrectionMonths is negative (defensive)', () => {
const amount = computeJamkningAmount({
originalInputVat: 20_000,
totalCorrectionMonths: -60,
remainingMonths: -24,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(0)
expect(Number.isFinite(amount)).toBe(true)
})
it('rounds to two decimals (no öre stray cents)', () => {
// (17 / 60) × 10 000 = 2833.333... → 2833.33
const amount = computeJamkningAmount({
originalInputVat: 10_000,
totalCorrectionMonths: 60,
remainingMonths: 17,
disposalEvent: 'triggers_jamkning',
})
expect(amount).toBe(2_833.33)
})
})
import { describe, expect, it } from 'vitest'
import { assessJamkning, assessJamkningEligibility } from '../jamkning'
describe('assessJamkningEligibility', () => {
it('returns 120 months for fastighet BAS 1110', () => {
const e = assessJamkningEligibility({
basAssetAccount: '1110',
basExpenseAccount: '7821',
category: 'building',
acquisitionDate: '2020-01-01',
disposalDate: '2026-01-01',
})
expect(e.totalCorrectionMonths).toBe(120)
// 6 years = 72 months elapsed → 48 months remaining
expect(e.elapsedMonths).toBe(72)
expect(e.remainingMonths).toBe(48)
expect(e.withinCorrectionPeriod).toBe(true)
it('counts the acquisition and disposal tax years for movable property', () => {
expect(
assessJamkningEligibility({
acquisitionDate: '2023-11-30',
disposalDate: '2026-01-02',
category: 'equipment',
}),
).toMatchObject({ totalYears: 5, elapsedYears: 3, remainingYears: 2 })
})
it('returns 60 months for equipment BAS 1220', () => {
const e = assessJamkningEligibility({
basAssetAccount: '1220',
basExpenseAccount: '7832',
it('uses ten years and the higher threshold for real property', () => {
expect(
assessJamkningEligibility({
acquisitionDate: '2023-01-01',
disposalDate: '2026-12-31',
basAssetAccount: '1110',
}),
).toMatchObject({ totalYears: 10, remainingYears: 7, threshold: 100_000 })
})
})
describe('assessJamkning', () => {
it('calculates a positive adjustment from total original VAT', () => {
const result = assessJamkning({
acquisitionDate: '2023-01-01',
disposalDate: '2025-06-30',
category: 'equipment',
acquisitionDate: '2024-01-01',
disposalDate: '2026-01-01',
originalInputVat: 100_000,
originalDeductionPercent: 40,
disposalType: 'sale',
vatTreatment: 'standard_25',
netProceeds: 1_000_000,
})
expect(result).toMatchObject({
direction: 'increase',
remainingYears: 3,
amount: 36_000,
capped: false,
})
expect(e.totalCorrectionMonths).toBe(60)
// 2 years = 24 months → 36 months remaining
expect(e.elapsedMonths).toBe(24)
expect(e.remainingMonths).toBe(36)
expect(e.withinCorrectionPeriod).toBe(true)
})
it('detects markanläggning (BAS 1150) as real property → 120 months', () => {
const e = assessJamkningEligibility({
basAssetAccount: '1150',
basExpenseAccount: '7824',
category: 'land_improvement',
acquisitionDate: '2023-06-01',
disposalDate: '2024-06-01',
it('caps positive movable-property adjustment at 25 percent of net proceeds', () => {
const result = assessJamkning({
acquisitionDate: '2025-01-01',
disposalDate: '2025-12-31',
category: 'equipment',
originalInputVat: 100_000,
originalDeductionPercent: 0,
disposalType: 'sale',
vatTreatment: 'standard_25',
netProceeds: 40_000,
})
expect(e.totalCorrectionMonths).toBe(120)
expect(result).toMatchObject({ direction: 'increase', amount: 10_000, capped: true })
})
it('falls back to category when account is unrecognized', () => {
// No BAS account provided: has to rely on the category signal.
const e = assessJamkningEligibility({
category: 'building',
it('calculates a negative adjustment for an exempt sale', () => {
const result = assessJamkning({
acquisitionDate: '2024-01-01',
disposalDate: '2026-01-01',
category: 'machinery',
originalInputVat: 75_000,
originalDeductionPercent: 100,
disposalType: 'sale',
vatTreatment: 'exempt',
netProceeds: 200_000,
})
expect(e.totalCorrectionMonths).toBe(120)
expect(result).toMatchObject({ direction: 'decrease', remainingYears: 3, amount: 45_000 })
})
it('reports withinCorrectionPeriod = false after the full period elapses', () => {
const e = assessJamkningEligibility({
basAssetAccount: '1220',
category: 'equipment',
acquisitionDate: '2020-01-01',
disposalDate: '2026-01-01',
})
// 6 years = 72 months elapsed > 60 → 0 remaining
expect(e.remainingMonths).toBe(0)
expect(e.withinCorrectionPeriod).toBe(false)
it('does not adjust below the investment-good threshold', () => {
expect(
assessJamkning({
acquisitionDate: '2026-01-01',
disposalDate: '2026-06-30',
category: 'equipment',
originalInputVat: 49_999,
originalDeductionPercent: 100,
disposalType: 'sale',
vatTreatment: 'exempt',
}),
).toMatchObject({ direction: 'none', amount: 0, reason: 'below_threshold' })
})
it('counts complete months only (day-precision)', () => {
// 2023-01-15 to 2026-01-14 → 35 complete months (the 36th hasn't finished)
const e = assessJamkningEligibility({
basAssetAccount: '1220',
category: 'equipment',
acquisitionDate: '2023-01-15',
disposalDate: '2026-01-14',
})
expect(e.elapsedMonths).toBe(35)
expect(e.remainingMonths).toBe(25)
it('transfers the obligation in a qualifying business transfer', () => {
expect(
assessJamkning({
acquisitionDate: '2026-01-01',
disposalDate: '2026-06-30',
category: 'equipment',
originalInputVat: 50_000,
originalDeductionPercent: 100,
disposalType: 'business_transfer',
}),
).toMatchObject({ direction: 'transferred', amount: 0, reason: 'transferred' })
})
it('clamps elapsedMonths to 0 if disposalDate precedes acquisitionDate', () => {
// Defensive: should never happen in practice but must not blow up.
const e = assessJamkningEligibility({
basAssetAccount: '1220',
category: 'equipment',
acquisitionDate: '2026-01-01',
disposalDate: '2024-01-01',
})
expect(e.elapsedMonths).toBe(0)
expect(e.remainingMonths).toBe(60)
it('does not adjust a scrapped asset', () => {
expect(
assessJamkning({
acquisitionDate: '2026-01-01',
disposalDate: '2026-06-30',
category: 'equipment',
originalInputVat: 50_000,
originalDeductionPercent: 100,
disposalType: 'scrap',
}),
).toMatchObject({ direction: 'none', amount: 0, reason: 'scrap' })
})
})
+374 -269
View File
@@ -1,11 +1,18 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import {
commitAssetDisposal,
createDraftEntry,
} from '@/lib/bookkeeping/engine'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { computeAnnualDepreciation } from './depreciation-engine'
import { assessJamkning, assessJamkningEligibility } from './jamkning'
import type {
Asset,
AssetCategory,
AssetDisposalType,
DepreciationMethod,
FiscalPeriod,
K3Component,
CreateJournalEntryLineInput,
JournalEntry,
@@ -477,54 +484,23 @@ function inBasRange(account: string, range: [string, string]): boolean {
return account >= range[0] && account <= range[1]
}
export type AssetDisposalVatTreatment = Exclude<VatTreatment, 'reduced_12' | 'reduced_6'>
export interface DisposeAssetInput {
/** ISO date of disposal: typically the day of sale or scrapping. */
disposal_type: AssetDisposalType
disposed_at: string
/** Cash / receivable received for the asset, INCLUDING VAT when applicable.
* Zero for scrapping. */
/** Gross consideration, including VAT for a taxable sale. */
disposed_proceeds: number
/** Optional override for the bank/receivable account credited with the
* proceeds. Defaults to 1930 (företagskonto). */
proceeds_account?: string
/** Fiscal period the disposal entry lands in. Caller resolves this from
* disposed_at: we don't auto-derive to keep the period-lock check at
* the route layer. */
fiscal_period_id: string
/**
* Output VAT on the proceeds (ML 3 kap 3 § / 7 kap 3 §). When > 0, a
* credit on the matching 26xx account is appended to the journal entry,
* and `disposed_proceeds` is treated as the GROSS amount (incl. VAT).
* The net (proceeds − vat) is what gets compared to NBV to compute
* gain/loss. Defaults to 0 (sale was momsfri / outside scope).
*/
proceeds_vat?: number
/**
* Treatment for `proceeds_vat`. Required when `proceeds_vat > 0` because
* the engine needs to know which 26xx account to credit:
* - standard_25 → 2611
* - reduced_12 → 2621
* - reduced_6 → 2631
* - reverse_charge / export / exempt → no VAT line (treated as
* informational; proceeds_vat must be 0 in those cases)
*/
vat_treatment?: VatTreatment
/**
* Jämkning amount per ML 8a kap 7 §: when the disposal happens inside
* the korrigeringstid, the originally-deducted input VAT must be
* partially paid back. The caller computes this via
* computeJamkningAmount() (lib/bokslut/assets/jamkning.ts) and passes
* the result here. A positive value means "pay back to the state" and
* is booked as a CREDIT to 2641 (reverses the original input-VAT
* deduction) with an offsetting debit on the asset's gain/loss account.
* Zero / undefined = no jämkning line.
*/
jamkning_amount?: number
/** Audit metadata: remaining months in korrigeringstid at disposal date. */
jamkning_remaining_months?: number
/** Audit metadata: total korrigeringstid (60 or 120 months). */
jamkning_total_months?: number
/** Audit metadata: original input VAT deducted at acquisition. */
vat_treatment?: AssetDisposalVatTreatment
/** Total original input VAT, whether or not it was fully deducted. */
jamkning_original_input_vat?: number
jamkning_original_deduction_percent?: number
/** Confirms that the transaction qualifies under ML 5:38. */
business_transfer_confirmed?: boolean
/** Confirms that the required adjustment document is handled at transfer. */
adjustment_document_confirmed?: boolean
}
export interface DisposalResult {
@@ -535,29 +511,265 @@ export interface DisposalResult {
gain_or_loss: number
}
/**
* Dispose of an asset. Posts a journal entry that:
* - Debit accumulated depreciation (to zero out the asset's accumulated
* account)
* - Credit acquisition cost (to zero out the asset's anskaffning account)
* - Debit proceeds account (bank / receivable) for sale price (gross,
* incl VAT)
* - Credit 26xx (output VAT) when the sale is momspliktig (standard_25 →
* 2611, reduced_12 → 2621, reduced_6 → 2631)
* - Credit 2641 + Debit loss account for the jämkning amount when the
* disposal happens inside the korrigeringstid (ML 8a kap 4-7 §§)
* - Debit 78xx (loss on sale) OR Credit 30xx (gain on sale) for the
* net gain / loss vs NBV: accounts branch on category (3013/7813
* for immaterial, 3971/7971 for building / markanläggning, 3973/7973
* for everything else).
*
* Gain/loss is computed on the NET proceeds (excl VAT), since VAT is a
* pass-through to Skatteverket and does not affect resultaträkningen.
*
* After posting, marks the asset row with disposed_at, disposed_proceeds,
* disposed_proceeds_vat, disposed_vat_treatment, and jämkning audit fields.
* The DB trigger then prevents further edits to financial fields.
*/
export class AssetNotFoundError extends Error {
readonly code = 'ASSET_NOT_FOUND'
}
export class AssetAlreadyDisposedError extends Error {
readonly code = 'ASSET_ALREADY_DISPOSED'
}
export class AssetDisposalBlockedError extends Error {
readonly code = 'ASSET_DISPOSAL_BLOCKED'
constructor(readonly reason: 'later_depreciation_posted' | 'current_depreciation_mismatch') {
super(reason)
}
}
export class AssetJamkningDataRequiredError extends Error {
readonly code = 'ASSET_JAMKNING_DATA_REQUIRED'
}
export class AssetAdjustmentDocumentRequiredError extends Error {
readonly code = 'ASSET_ADJUSTMENT_DOCUMENT_REQUIRED'
constructor() {
super('ASSET_ADJUSTMENT_DOCUMENT_REQUIRED')
}
}
export class AssetBusinessTransferConfirmationRequiredError extends Error {
readonly code = 'ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED'
constructor() {
super('ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED')
}
}
interface DisposalScheduleRow {
fiscal_period_id: string
planned_depreciation: number | string
journal_entry_id: string | null
}
interface DisposalPlan {
lines: CreateJournalEntryLineInput[]
currentDepreciation: number
accumulatedDepreciation: number
proceedsGross: number
proceedsVat: number
vatTreatment: AssetDisposalVatTreatment | null
gainOrLoss: number
jamkning: ReturnType<typeof assessJamkning>
}
export function buildAssetDisposalPlan(args: {
asset: Asset
input: DisposeAssetInput
fiscalPeriod: Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>
periods: Array<Pick<FiscalPeriod, 'id' | 'period_start'>>
schedules: DisposalScheduleRow[]
}): DisposalPlan {
const { asset, input, fiscalPeriod, periods, schedules } = args
if (input.disposed_at < asset.acquisition_date) {
throw new Error('Avyttringsdatum kan inte vara före anskaffningsdatum.')
}
const periodStartById = new Map(periods.map((period) => [period.id, period.period_start]))
const posted = schedules.filter((schedule) => schedule.journal_entry_id !== null)
const laterPosted = posted.some(
(schedule) => (periodStartById.get(schedule.fiscal_period_id) ?? '') > fiscalPeriod.period_start,
)
if (laterPosted) throw new AssetDisposalBlockedError('later_depreciation_posted')
const currentPosted = posted.find(
(schedule) => schedule.fiscal_period_id === fiscalPeriod.id,
)
const priorAccumulated = round2(
posted
.filter((schedule) => {
const start = periodStartById.get(schedule.fiscal_period_id)
return start !== undefined && start < fiscalPeriod.period_start
})
.reduce((sum, schedule) => sum + Number(schedule.planned_depreciation), 0),
)
const requiredCurrent = computeAnnualDepreciation(
{ ...asset, disposed_at: input.disposed_at },
fiscalPeriod,
priorAccumulated,
).amount
let currentDepreciation = requiredCurrent
if (currentPosted) {
if (Math.abs(Number(currentPosted.planned_depreciation) - requiredCurrent) > 0.01) {
throw new AssetDisposalBlockedError('current_depreciation_mismatch')
}
currentDepreciation = 0
}
const accumulatedDepreciation = round2(
priorAccumulated + (currentPosted ? Number(currentPosted.planned_depreciation) : requiredCurrent),
)
const acquisitionCost = round2(Number(asset.acquisition_cost))
const proceedsGross = input.disposal_type === 'scrap' ? 0 : round2(input.disposed_proceeds)
const vatTreatment = input.disposal_type === 'sale' ? input.vat_treatment ?? null : null
if (input.disposal_type === 'sale' && proceedsGross > 0 && !vatTreatment) {
throw new Error('Momsbehandling krävs vid försäljning.')
}
const proceedsVat = round2(
vatTreatment === 'standard_25' ? proceedsGross * (0.25 / 1.25) : 0,
)
const proceedsNet = round2(proceedsGross - proceedsVat)
const netBookValue = round2(acquisitionCost - accumulatedDepreciation)
const gainOrLoss = round2(proceedsNet - netBookValue)
if (input.disposal_type === 'business_transfer' && !input.business_transfer_confirmed) {
throw new AssetBusinessTransferConfirmationRequiredError()
}
const eligibility = assessJamkningEligibility({
acquisitionDate: asset.acquisition_date,
disposalDate: input.disposed_at,
basAssetAccount: asset.bas_asset_account,
category: asset.category,
})
const possibleInvestmentGoodCost = eligibility.totalYears === 10 ? 400_000 : 200_000
if (
eligibility.withinAdjustmentPeriod &&
acquisitionCost >= possibleInvestmentGoodCost &&
(input.jamkning_original_input_vat === undefined ||
input.jamkning_original_deduction_percent === undefined)
) {
throw new AssetJamkningDataRequiredError()
}
const jamkning = assessJamkning({
acquisitionDate: asset.acquisition_date,
disposalDate: input.disposed_at,
category: asset.category,
basAssetAccount: asset.bas_asset_account,
originalInputVat: input.jamkning_original_input_vat ?? 0,
originalDeductionPercent: input.jamkning_original_deduction_percent ?? 0,
disposalType: input.disposal_type,
vatTreatment: vatTreatment ?? undefined,
netProceeds: proceedsNet,
})
if (
jamkning.direction === 'transferred' &&
!input.adjustment_document_confirmed
) {
throw new AssetAdjustmentDocumentRequiredError()
}
const lines: CreateJournalEntryLineInput[] = []
if (currentDepreciation > 0.005) {
lines.push(
{
account_number: asset.bas_expense_account,
debit_amount: currentDepreciation,
credit_amount: 0,
line_description: `Avskrivning till avyttringsdag: ${asset.name}`,
},
{
account_number: asset.bas_accumulated_account,
debit_amount: 0,
credit_amount: currentDepreciation,
line_description: `Ackumulerad avskrivning till avyttringsdag: ${asset.name}`,
},
)
}
if (accumulatedDepreciation > 0.005) {
lines.push({
account_number: asset.bas_accumulated_account,
debit_amount: accumulatedDepreciation,
credit_amount: 0,
line_description: `Avyttring: nollställ ackumulerad avskrivning ${asset.name}`,
})
}
if (acquisitionCost > 0.005) {
lines.push({
account_number: asset.bas_asset_account,
debit_amount: 0,
credit_amount: acquisitionCost,
line_description: `Avyttring: nollställ anskaffningsvärde ${asset.name}`,
})
}
if (proceedsGross > 0.005) {
lines.push({
account_number: input.proceeds_account ?? '1930',
debit_amount: proceedsGross,
credit_amount: 0,
line_description: `Avyttring: erhållet belopp ${asset.name}`,
})
}
if (proceedsVat > 0.005 && vatTreatment) {
lines.push({
account_number: outputVatAccountFor(vatTreatment) ?? '2611',
debit_amount: 0,
credit_amount: proceedsVat,
line_description: `Utgående moms vid avyttring av ${asset.name}`,
})
}
const { gain, loss } = disposalAccounts(asset.category)
if (gainOrLoss > 0.005) {
lines.push({
account_number: gain,
debit_amount: 0,
credit_amount: gainOrLoss,
line_description: `Vinst vid avyttring av ${asset.name}`,
})
} else if (gainOrLoss < -0.005) {
lines.push({
account_number: loss,
debit_amount: Math.abs(gainOrLoss),
credit_amount: 0,
line_description: `Förlust vid avyttring av ${asset.name}`,
})
}
if (jamkning.amount > 0.005 && jamkning.direction === 'decrease') {
lines.push(
{
account_number: '6999',
debit_amount: jamkning.amount,
credit_amount: 0,
line_description: `Negativ justering av ingående moms: ${asset.name}`,
},
{
account_number: '2641',
debit_amount: 0,
credit_amount: jamkning.amount,
line_description: `Justerad ingående moms enligt ML 15 kap: ${asset.name}`,
},
)
} else if (jamkning.amount > 0.005 && jamkning.direction === 'increase') {
lines.push(
{
account_number: '2641',
debit_amount: jamkning.amount,
credit_amount: 0,
line_description: `Justerad ingående moms enligt ML 15 kap: ${asset.name}`,
},
{
account_number: '6999',
debit_amount: 0,
credit_amount: jamkning.amount,
line_description: `Positiv justering av ingående moms: ${asset.name}`,
},
)
}
return {
lines,
currentDepreciation,
accumulatedDepreciation,
proceedsGross,
proceedsVat,
vatTreatment,
gainOrLoss,
jamkning,
}
}
export async function disposeAsset(
supabase: SupabaseClient,
companyId: string,
@@ -566,152 +778,50 @@ export async function disposeAsset(
input: DisposeAssetInput,
): Promise<DisposalResult> {
const asset = await getAsset(supabase, companyId, assetId)
if (!asset) throw new Error('Asset not found')
if (asset.disposed_at) {
throw new Error('Asset is already disposed')
}
if (!asset) throw new AssetNotFoundError()
if (asset.disposed_at) throw new AssetAlreadyDisposedError()
// Derive accumulated depreciation server-side from posted
// depreciation_schedules so a malicious or buggy caller cannot inflate the
// book-value calculation. Limitation: manual avskrivningsverifikationer
// posted outside the engine aren't captured here. Phase 5+ can replace
// this with a trial-balance scan on bas_accumulated_account.
const accumulated = await sumPostedDepreciation(supabase, companyId, assetId)
const acquisitionCost = Number(asset.acquisition_cost)
const proceedsGross = round2(Number(input.disposed_proceeds))
const proceedsVat = round2(Number(input.proceeds_vat ?? 0))
const proceedsNet = round2(proceedsGross - proceedsVat)
const vatTreatment = input.vat_treatment
// Internal validation guard: when caller passes a VAT amount, treatment
// must accompany it so we can resolve the BAS 26xx account. The API
// layer also enforces this via Zod refinement; mirroring here keeps
// the engine self-defending against direct callers (MCP, scripts).
if (proceedsVat > 0.005 && !vatTreatment) {
// Paginated past PostgREST's silent 1000-row cap. A truncated period list
// can drop input.fiscal_period_id (false "Fiscal period not found") and
// starves the later_depreciation_posted guard of period start dates.
let periods: Array<Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>>
let scheduleRows: DisposalScheduleRow[]
try {
;[periods, scheduleRows] = await Promise.all([
fetchAllRows<Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>>(
({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
),
fetchAllRows<DisposalScheduleRow>(({ from, to }) =>
supabase
.from('depreciation_schedules')
.select('fiscal_period_id, planned_depreciation, journal_entry_id')
.eq('company_id', companyId)
.eq('asset_id', assetId)
.order('fiscal_period_id', { ascending: true })
.range(from, to),
),
])
} catch (error) {
throw new Error(
'vat_treatment krävs när proceeds_vat > 0: engine kan inte avgöra rätt 26xx-konto.',
`Failed to load disposal context: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Treatments that produce no VAT line must carry 0 VAT.
if (
proceedsVat > 0.005 &&
vatTreatment &&
(vatTreatment === 'reverse_charge' ||
vatTreatment === 'export' ||
vatTreatment === 'exempt')
) {
throw new Error(
`proceeds_vat måste vara 0 för momsbehandling "${vatTreatment}".`,
)
}
// Gain/loss is computed on the NET proceeds: VAT is pass-through and
// never hits the income statement.
const netBookValue = round2(acquisitionCost - accumulated)
const gainOrLoss = round2(proceedsNet - netBookValue)
const proceedsAccount = input.proceeds_account ?? '1930'
// ── Jämkning (ML 8a kap 7 §) ──────────────────────────────────────
// When the disposal happens inside the korrigeringstid, part of the
// originally-deducted input VAT must be paid back. Caller passes the
// precomputed amount (positive = debt to the state).
//
// Booking direction: We credit 2641 to reverse the original input-VAT
// deduction (2641 normal balance is debit; a credit reduces the
// deduction). The offset is debited to BAS 6991: jämkning is a VAT
// correction per ML 8a kap, NOT a disposal loss, so it must NOT hit
// the 78xx förlust-vid-avyttring accounts. See the jämkning lines
// below for details.
const jamkning = round2(Number(input.jamkning_amount ?? 0))
const lines: CreateJournalEntryLineInput[] = []
if (accumulated > 0.005) {
lines.push({
account_number: asset.bas_accumulated_account,
debit_amount: round2(accumulated),
credit_amount: 0,
line_description: `Avyttring: nollställ ack. avskrivning ${asset.name}`,
})
}
lines.push({
account_number: asset.bas_asset_account,
debit_amount: 0,
credit_amount: round2(acquisitionCost),
line_description: `Avyttring: nollställ anskaffning ${asset.name}`,
const fiscalPeriod = periods.find((period) => period.id === input.fiscal_period_id)
if (!fiscalPeriod) throw new Error('Fiscal period not found')
const plan = buildAssetDisposalPlan({
asset,
input,
fiscalPeriod,
periods,
schedules: scheduleRows,
})
if (proceedsGross > 0.005) {
lines.push({
account_number: proceedsAccount,
debit_amount: proceedsGross,
credit_amount: 0,
line_description: `Avyttring: erhållet belopp ${asset.name}`,
})
}
// Output VAT line: credit the matching 26xx account.
if (proceedsVat > 0.005 && vatTreatment) {
const vatAccount = outputVatAccountFor(vatTreatment)
if (vatAccount) {
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: proceedsVat,
line_description: `Utgående moms ${vatRateLabel(vatTreatment)} avyttring ${asset.name}`,
})
}
}
// Disposal gain/loss accounts vary by asset class: BAS 2026 splits them
// because INK2R routes each pair to a different field. Mixing them
// misclassifies in the tax declaration.
// - immaterial → 3013 (vinst) / 7813 (förlust)
// - building / markanlägg → 3971 / 7971
// - other tangible → 3973 / 7973
const isBuilding = asset.category === 'building' || asset.category === 'land_improvement'
const gainAccount =
asset.category === 'immaterial' ? '3013' : isBuilding ? '3971' : '3973'
const lossAccount =
asset.category === 'immaterial' ? '7813' : isBuilding ? '7971' : '7973'
if (gainOrLoss > 0.005) {
lines.push({
account_number: gainAccount,
debit_amount: 0,
credit_amount: gainOrLoss,
line_description: `Vinst vid avyttring av ${asset.name}`,
})
} else if (gainOrLoss < -0.005) {
lines.push({
account_number: lossAccount,
debit_amount: Math.abs(gainOrLoss),
credit_amount: 0,
line_description: `Förlust vid avyttring av ${asset.name}`,
})
}
// Jämkning lines: credit 2641 + debit 6991. Jämkning is a VAT
// correction per ML 8a kap, NOT a disposal loss. Routing it through
// the 78xx förlust-vid-avyttring accounts would distort both the
// gain/loss line on the income statement and the INK2R mapping, and
// mix tax-correction costs with disposal losses in the audit trail.
// BAS 6991 "Övriga externa kostnader, avdragsgilla" is the seeded
// catch-all för-en-extern-kostnad account that fits a repayment of
// previously-deducted input VAT.
if (jamkning > 0.005) {
lines.push({
account_number: '6991',
debit_amount: jamkning,
credit_amount: 0,
line_description: `Jämkning av tidigare avdragen ingående moms enligt ML 8a kap (${asset.name})`,
})
lines.push({
account_number: '2641',
debit_amount: 0,
credit_amount: jamkning,
line_description: `Återförd ingående moms jämkning ${asset.name}`,
})
}
// K3 component breakdown: when the asset was depreciated per-component,
// we surface the component list in the journal entry notes so auditors
@@ -727,43 +837,71 @@ export async function disposeAsset(
.join('; ')}`
: null
let disposalEntry: JournalEntry | null = null
if (lines.length > 0) {
disposalEntry = await createJournalEntry(supabase, companyId, userId, {
let draft: JournalEntry | null = null
if (plan.lines.length > 0) {
draft = await createDraftEntry(supabase, companyId, userId, {
fiscal_period_id: input.fiscal_period_id,
entry_date: input.disposed_at,
description: `Avyttring av tillgång: ${asset.name}`,
source_type: 'manual',
lines,
source_type: 'system',
lines: plan.lines,
...(componentNotes ? { notes: componentNotes } : {}),
})
}
const { data: updated, error: updateError } = await supabase
.from('assets')
.update({
disposed_at: input.disposed_at,
disposed_proceeds: proceedsGross,
disposed_proceeds_vat: proceedsVat,
disposed_vat_treatment: vatTreatment ?? null,
jamkning_amount: jamkning,
jamkning_remaining_months: input.jamkning_remaining_months ?? null,
jamkning_total_months: input.jamkning_total_months ?? null,
jamkning_original_input_vat: input.jamkning_original_input_vat ?? null,
})
.eq('id', assetId)
.eq('company_id', companyId)
.select('*')
.single()
let disposalEntry: JournalEntry | null
try {
disposalEntry = await commitAssetDisposal(
supabase,
companyId,
userId,
draft?.id ?? null,
{
asset_id: assetId,
fiscal_period_id: input.fiscal_period_id,
disposal_type: input.disposal_type,
disposed_at: input.disposed_at,
disposed_proceeds: plan.proceedsGross,
proceeds_vat: plan.proceedsVat,
vat_treatment: plan.vatTreatment,
current_depreciation: plan.currentDepreciation,
jamkning_amount: plan.jamkning.amount,
jamkning_direction: plan.jamkning.direction,
jamkning_remaining_years: plan.jamkning.remainingYears ?? null,
jamkning_total_years: plan.jamkning.totalYears || null,
jamkning_original_input_vat: input.jamkning_original_input_vat ?? null,
jamkning_original_deduction_percent:
input.jamkning_original_deduction_percent ?? null,
jamkning_new_deduction_percent: plan.jamkning.newDeductionPercent,
},
)
} catch (error) {
if (draft) {
await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.eq('id', draft.id)
.eq('status', 'draft')
}
throw error
}
if (updateError || !updated) {
throw new Error(`Failed to mark asset disposed: ${updateError?.message ?? 'unknown'}`)
const updated = (await getAsset(supabase, companyId, assetId)) ?? {
...asset,
disposed_at: input.disposed_at,
disposed_proceeds: plan.proceedsGross,
disposed_proceeds_vat: plan.proceedsVat,
disposed_vat_treatment: plan.vatTreatment,
disposal_type: input.disposal_type,
disposal_journal_entry_id: disposalEntry?.id ?? null,
jamkning_amount: plan.jamkning.amount,
jamkning_direction: plan.jamkning.direction,
}
return {
asset: updated as Asset,
disposal_entry: disposalEntry,
gain_or_loss: gainOrLoss,
gain_or_loss: plan.gainOrLoss,
}
}
@@ -784,49 +922,16 @@ function outputVatAccountFor(treatment: VatTreatment): string | null {
}
}
function vatRateLabel(treatment: VatTreatment): string {
switch (treatment) {
case 'standard_25':
return '25%'
case 'reduced_12':
return '12%'
case 'reduced_6':
return '6%'
default:
return ''
}
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/**
* Sum every posted depreciation_schedules row for an asset to get accumulated
* depreciation as of "now". Used by disposeAsset so the caller cannot
* influence the book-value calculation.
*/
async function sumPostedDepreciation(
supabase: SupabaseClient,
companyId: string,
assetId: string,
): Promise<number> {
const { data, error } = await supabase
.from('depreciation_schedules')
.select('planned_depreciation')
.eq('company_id', companyId)
.eq('asset_id', assetId)
.not('journal_entry_id', 'is', null)
if (error) {
throw new Error(`Failed to sum depreciation for asset ${assetId}: ${error.message}`)
function disposalAccounts(category: AssetCategory): { gain: string; loss: string } {
if (category === 'immaterial') return { gain: '3971', loss: '7971' }
if (category === 'building' || category === 'land_improvement') {
return { gain: '3972', loss: '7972' }
}
type Row = { planned_depreciation: number | string }
return ((data ?? []) as Row[]).reduce(
(sum, row) => sum + (Number(row.planned_depreciation) || 0),
0,
)
return { gain: '3973', loss: '7973' }
}
/**
+3 -1
View File
@@ -1,6 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { listAssets } from './asset-service'
import type {
Asset,
FiscalPeriod,
@@ -272,6 +271,9 @@ export async function proposeAnnualPostings(
companyId: string,
fiscalPeriodId: string,
): Promise<DepreciationProposal> {
// Loaded here to keep the pure depreciation calculator reusable from the
// asset disposal service without creating a module initialization cycle.
const { listAssets } = await import('./asset-service')
const [periodResult, assets, currentSchedulesResult, priorSchedulesResult] = await Promise.all([
supabase
.from('fiscal_periods')
+163 -155
View File
@@ -1,190 +1,198 @@
/**
* Jämkning helpers: input-VAT correction on disposal of investeringsvara
* within the korrigeringstid (ML 8a kap 4-7 §§).
* Pure helpers for adjustment of input VAT on investment goods under
* Mervardesskattelagen (2023:200), chapter 15.
*
* When an asset that had input VAT deducted at acquisition is disposed of
* within the correction period, part of the original deducted input VAT
* must be paid back. The amount is the portion that corresponds to the
* remaining months of the correction period.
*
* Correction periods per ML 8a kap 6 §:
* - 60 months (5 years) for lös egendom / movable property
* - 120 months (10 years) for fastighet / markanläggning (real property)
*
* The two functions in this file are PURE (no I/O, no Supabase, no clock
* read) so they can be tested with simple input/output cases.
*
* Caller responsibility:
* - Decide whether a disposal event triggers jämkning. The most common
* trigger is a sale within korrigeringstid, but ML 8a kap also lists
* "ändrad användning" and "utträde ur skattskyldighet". The caller
* passes the boolean so this helper stays domain-agnostic.
* - Source `originalInputVat`. For new assets this comes from the
* supplier invoice that booked the acquisition; for legacy assets the
* user has to enter it manually.
* The acquisition year and disposal year both count in the adjustment
* period. The basis is total original input VAT, not only the amount that was
* deducted at acquisition.
*/
import type { AssetCategory } from '@/types'
import type { AssetCategory, VatTreatment } from '@/types'
/**
* Inputs to compute the jämkning amount on disposal.
*/
export interface JamkningInput {
/** Original input VAT deducted at acquisition (BAS 2641 debit). */
export type JamkningDirection = 'increase' | 'decrease' | 'none' | 'transferred'
export interface JamkningAssessmentInput {
acquisitionDate: string
disposalDate: string
category?: AssetCategory
basAssetAccount?: string
originalInputVat: number
/**
* Total correction period in months. 60 for movable property,
* 120 for fastighet / markanläggning. Caller decides which.
*/
totalCorrectionMonths: number
/**
* Months remaining in the correction period as of the disposal date.
* Caller computes this so the helper avoids any clock / calendar
* dependency.
*/
remainingMonths: number
/**
* Whether the disposal event triggers jämkning at all. Most disposals
* within the korrigeringstid trigger it, but the caller may opt out
* (e.g. the buyer continues to use the asset in a fully taxable
* verksamhet and assumes the jämkning obligation via avtal: ML 8a kap
* 12 §).
*/
disposalEvent: 'triggers_jamkning' | 'no_jamkning'
originalDeductionPercent: number
disposalType: 'sale' | 'scrap' | 'business_transfer'
vatTreatment?: VatTreatment
netProceeds?: number
}
export interface JamkningAssessment {
isInvestmentGood: boolean
threshold: number
totalYears: number
remainingYears: number
originalDeductionPercent: number
newDeductionPercent: number | null
direction: JamkningDirection
amount: number
capped: boolean
reason:
| 'below_threshold'
| 'outside_adjustment_period'
| 'scrap'
| 'transferred'
| 'change_below_five_points'
| 'adjustment'
}
/**
* Compute the jämkning amount per ML 8a kap 7 §. Returns a positive number
* representing the amount to be paid back to the state (i.e. reverse the
* input-VAT deduction). When disposal happens AFTER the correction period
* (remainingMonths <= 0) the formula returns 0: caller can simply skip
* the line.
*
* Formula: (remaining / total) × originalInputVat
*
* Edge cases:
* - disposalEvent = 'no_jamkning' → 0
* - totalCorrectionMonths <= 0 → 0 (defensive: caller bug)
* - remainingMonths <= 0 → 0 (asset is past the correction period)
* - remainingMonths > totalCorrectionMonths → caps at originalInputVat
* (sold immediately, before any correction period has elapsed)
* Assess a disposal using the one-time adjustment formula in ML 15:13-18.
* A taxable sale of movable property is capped at 25 percent of net proceeds.
*/
export function computeJamkningAmount(input: JamkningInput): number {
if (input.disposalEvent === 'no_jamkning') return 0
if (input.totalCorrectionMonths <= 0) return 0
if (input.remainingMonths <= 0) return 0
export function assessJamkning(input: JamkningAssessmentInput): JamkningAssessment {
const realProperty = isRealProperty(input)
const totalYears = realProperty ? 10 : 5
const threshold = realProperty ? 100_000 : 50_000
const remainingYears = yearsRemaining(
input.acquisitionDate,
input.disposalDate,
totalYears,
)
const originalDeductionPercent = clampPercent(input.originalDeductionPercent)
const base = {
isInvestmentGood: input.originalInputVat >= threshold,
threshold,
totalYears,
remainingYears,
originalDeductionPercent,
}
const remaining = Math.min(input.remainingMonths, input.totalCorrectionMonths)
const raw = (remaining / input.totalCorrectionMonths) * input.originalInputVat
return Math.round(raw * 100) / 100
if (!base.isInvestmentGood) {
return noAdjustment(base, 'below_threshold', originalDeductionPercent)
}
if (remainingYears === 0) {
return noAdjustment(base, 'outside_adjustment_period', originalDeductionPercent)
}
if (input.disposalType === 'scrap') {
return noAdjustment(base, 'scrap', originalDeductionPercent)
}
if (input.disposalType === 'business_transfer') {
return {
...base,
newDeductionPercent: null,
direction: 'transferred',
amount: 0,
capped: false,
reason: 'transferred',
}
}
const newDeductionPercent = treatmentRetainsDeduction(input.vatTreatment) ? 100 : 0
const change = newDeductionPercent - originalDeductionPercent
if (Math.abs(change) < 5) {
return noAdjustment(base, 'change_below_five_points', newDeductionPercent)
}
const rawAmount = round2(
input.originalInputVat * (Math.abs(change) / 100) * (remainingYears / totalYears),
)
let amount = rawAmount
let capped = false
if (!realProperty && change > 0 && treatmentRetainsDeduction(input.vatTreatment)) {
const cap = round2(Math.max(0, Number(input.netProceeds ?? 0)) * 0.25)
if (amount > cap) {
amount = cap
capped = true
}
}
return {
...base,
newDeductionPercent,
direction: change > 0 ? 'increase' : 'decrease',
amount,
capped,
reason: 'adjustment',
}
}
/**
* Suggested eligibility check for an asset disposal. Returns the
* totalCorrectionMonths the caller should pass to computeJamkningAmount,
* along with the remainingMonths derived from acquisitionDate and
* disposalDate.
*
* The threshold lives here (not in the asset row) because it's a property
* of the asset CATEGORY / BAS account class, not user-editable per-asset:
*
* - Fastighet (BAS 1100-1199) → 120 months
* - Markanläggning (BAS 1150-1159): also 120 months
* - All other movable property → 60 months
*
* Pure: takes only dates and the asset's BAS account, returns numbers.
* Caller decides whether to surface the suggestion in the UI.
*/
export interface JamkningEligibility {
/** Suggested total correction period (60 or 120 months). */
totalCorrectionMonths: number
/** Months elapsed between acquisitionDate and disposalDate (clamped at 0). */
elapsedMonths: number
/** Remaining months in the correction period (clamped at 0). */
remainingMonths: number
/**
* Whether the disposal falls WITHIN the correction period. Convenience
* boolean: equivalent to `remainingMonths > 0`. Caller uses this to
* show / hide the jämkning UI.
*/
withinCorrectionPeriod: boolean
totalYears: number
elapsedYears: number
remainingYears: number
withinAdjustmentPeriod: boolean
threshold: number
}
export function assessJamkningEligibility(args: {
basExpenseAccount?: string
basAssetAccount?: string
category?: AssetCategory
acquisitionDate: string
disposalDate: string
}): JamkningEligibility {
const totalCorrectionMonths = isRealProperty(args) ? 120 : 60
const elapsed = monthsBetween(args.acquisitionDate, args.disposalDate)
const elapsedClamped = Math.max(0, elapsed)
const remaining = Math.max(0, totalCorrectionMonths - elapsedClamped)
const realProperty = isRealProperty(args)
const totalYears = realProperty ? 10 : 5
const acquisitionYear = isoYear(args.acquisitionDate)
const disposalYear = isoYear(args.disposalDate)
const elapsedYears = Math.max(0, disposalYear - acquisitionYear)
const remainingYears = yearsRemaining(args.acquisitionDate, args.disposalDate, totalYears)
return {
totalCorrectionMonths,
elapsedMonths: elapsedClamped,
remainingMonths: remaining,
withinCorrectionPeriod: remaining > 0,
totalYears,
elapsedYears,
remainingYears,
withinAdjustmentPeriod: remainingYears > 0,
threshold: realProperty ? 100_000 : 50_000,
}
}
/**
* Real property (fastighet / markanläggning) per BAS 1100-1199 lives on
* the 10-year (120 mån) correction period. Everything else uses 5 years.
*
* The plan's contract is that this resolves off the asset's BAS account
* range, with category as a secondary signal. Two reasons we prefer
* account-driven over category-driven:
* 1. The account is what BAS reports / SIE / INK2R actually read; the
* category is just a UI label.
* 2. Users who override the BAS account to something outside the
* category's default range get a consistent answer with what their
* reports show.
*/
function noAdjustment(
base: Pick<
JamkningAssessment,
| 'isInvestmentGood'
| 'threshold'
| 'totalYears'
| 'remainingYears'
| 'originalDeductionPercent'
>,
reason: Exclude<JamkningAssessment['reason'], 'transferred' | 'adjustment'>,
newDeductionPercent: number,
): JamkningAssessment {
return {
...base,
newDeductionPercent,
direction: 'none',
amount: 0,
capped: false,
reason,
}
}
function treatmentRetainsDeduction(treatment: VatTreatment | undefined): boolean {
return treatment !== undefined && treatment !== 'exempt'
}
function isRealProperty(args: {
basExpenseAccount?: string
basAssetAccount?: string
category?: AssetCategory
}): boolean {
// Prefer the asset (anskaffning) account when supplied: it's the most
// direct mapping to the BAS class.
const assetAccount = args.basAssetAccount
if (assetAccount && /^1[1][0-9]{2}$/.test(assetAccount)) return true
// Expense account check: 7820-7829 = byggnader/markanläggning.
const expense = args.basExpenseAccount
if (expense && /^782[0-9]$/.test(expense)) return true
// Category fallback for callers who only have the asset row's category
// (e.g. UI that hasn't loaded the full asset yet).
if (args.category === 'building' || args.category === 'land_improvement') {
return true
}
return false
if (args.basAssetAccount && /^11\d{2}$/.test(args.basAssetAccount)) return true
return args.category === 'building' || args.category === 'land_improvement'
}
/**
* Calendar months between two ISO date strings, rounded toward zero.
* Counts complete months only: partial months don't tick the clock.
*
* The Swedish tax authorities count months, not days, for jämkning
* (ML 8a kap 6 §). Example: acquired 2023-01-15, sold 2026-01-14 →
* 35 months elapsed (the 36th month hasn't completed yet).
*/
function monthsBetween(fromIso: string, toIso: string): number {
const from = parseIsoDate(fromIso)
const to = parseIsoDate(toIso)
if (!from || !to) return 0
let months = (to.year - from.year) * 12 + (to.month - from.month)
if (to.day < from.day) months -= 1
return months
function yearsRemaining(acquisitionDate: string, disposalDate: string, totalYears: number): number {
const acquisitionYear = isoYear(acquisitionDate)
const disposalYear = isoYear(disposalDate)
return Math.max(0, Math.min(totalYears, totalYears - (disposalYear - acquisitionYear)))
}
function parseIsoDate(iso: string): { year: number; month: number; day: number } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
if (!m) return null
return {
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
}
function isoYear(value: string): number {
const match = /^(\d{4})-\d{2}-\d{2}$/.exec(value)
return match ? Number(match[1]) : 0
}
function clampPercent(value: number): number {
return Math.min(100, Math.max(0, value))
}
function round2(value: number): number {
return Math.round(value * 100) / 100
}
+120
View File
@@ -29,11 +29,14 @@ import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import { getActor } from '@/lib/bookkeeping/actor-context'
import type {
AssetDisposalType,
AssetJamkningDirection,
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
JournalEntryLine,
JournalEntrySourceType,
VatTreatment,
} from '@/types'
const log = createLogger('bookkeeping.engine')
@@ -669,6 +672,123 @@ export async function commitEntry(
return result
}
export interface CommitAssetDisposalInput {
asset_id: string
fiscal_period_id: string
disposal_type: AssetDisposalType
disposed_at: string
disposed_proceeds: number
proceeds_vat: number
vat_treatment: VatTreatment | null
current_depreciation: number
jamkning_amount: number
jamkning_direction: AssetJamkningDirection
jamkning_remaining_years: number | null
jamkning_total_years: number | null
jamkning_original_input_vat: number | null
jamkning_original_deduction_percent: number | null
jamkning_new_deduction_percent: number | null
}
/**
* Commit a prepared asset-disposal draft and update the asset register in the
* same database transaction. The dedicated RPC delegates voucher numbering to
* commit_journal_entry, so disposal cannot leave a posted voucher without the
* corresponding immutable register state.
*/
export async function commitAssetDisposal(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string | null,
input: CommitAssetDisposalInput,
): Promise<JournalEntry | null> {
const actor = getActor()
const { error } = await supabase.rpc('commit_asset_disposal', {
p_company_id: companyId,
p_asset_id: input.asset_id,
p_entry_id: entryId,
p_fiscal_period_id: input.fiscal_period_id,
p_disposal_type: input.disposal_type,
p_disposed_at: input.disposed_at,
p_disposed_proceeds: input.disposed_proceeds,
p_proceeds_vat: input.proceeds_vat,
p_vat_treatment: input.vat_treatment,
p_current_depreciation: input.current_depreciation,
p_jamkning_amount: input.jamkning_amount,
p_jamkning_direction: input.jamkning_direction,
p_jamkning_remaining_years: input.jamkning_remaining_years,
p_jamkning_total_years: input.jamkning_total_years,
p_jamkning_original_input_vat: input.jamkning_original_input_vat,
p_jamkning_original_deduction_percent: input.jamkning_original_deduction_percent,
p_jamkning_new_deduction_percent: input.jamkning_new_deduction_percent,
p_actor_type: actor?.type ?? null,
p_actor_label: actor?.label ?? null,
})
if (error) {
log.error('commit_asset_disposal RPC failed', error, {
operation: 'commit_asset_disposal',
companyId,
userId,
entityType: 'asset',
entityId: input.asset_id,
journalEntryId: entryId,
pgCode: (error as { code?: string }).code,
})
throw new BookkeepingDatabaseError('commit_asset_disposal', error.message)
}
if (!entryId) return null
// The RPC has already committed the voucher and the register update at this
// point. A transient reload failure must not masquerade as a failed
// disposal, so retry once and log the divergence before surfacing it.
let completeEntry: JournalEntry | null = null
let lastFetchError: { message: string } | null = null
for (let attempt = 0; attempt < 2 && !completeEntry; attempt++) {
const { data, error: fetchError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entryId)
.eq('company_id', companyId)
.single()
if (data && !fetchError) {
completeEntry = data as JournalEntry
} else {
lastFetchError = fetchError ?? { message: 'posted entry not found' }
}
}
if (!completeEntry) {
log.error(
'asset disposal committed but posted entry reload failed',
lastFetchError,
{
operation: 'commit_asset_disposal',
companyId,
userId,
entityType: 'asset',
entityId: input.asset_id,
journalEntryId: entryId,
},
)
throw new BookkeepingDatabaseError(
'fetch_asset_disposal_entry',
`disposal voucher is committed but could not be reloaded: ${
lastFetchError?.message ?? 'posted entry not found'
}`,
)
}
const result = completeEntry
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: result, userId, companyId },
})
return result
}
/**
* Create a journal entry with lines (verifikation)
* Convenience wrapper: creates draft + commits in one step.
+2
View File
@@ -289,6 +289,8 @@ export type BookkeepingOperation =
| 'create_draft_entry'
| 'create_entry_lines'
| 'commit_entry'
| 'commit_asset_disposal'
| 'fetch_asset_disposal_entry'
| 'create_reversal_entry'
| 'create_reversal_lines'
| 'post_reversal_entry'
+38
View File
@@ -3115,6 +3115,44 @@ const BOLAGSVERKET: Record<string, StructuredErrorEntry> = {
}
const ASSETS: Record<string, StructuredErrorEntry> = {
ASSET_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Tillgången kunde inte hittas.',
message_en: 'Asset not found.',
},
ASSET_ALREADY_DISPOSED: {
httpStatus: 409,
message_sv: 'Tillgången är redan avyttrad.',
message_en: 'The asset has already been disposed.',
},
ASSET_DISPOSAL_BLOCKED: {
httpStatus: 409,
message_sv:
'Avyttringen kan inte bokföras eftersom avskrivningar redan finns för samma eller en senare period. Återför den felaktiga avskrivningen med storno först.',
message_en:
'The disposal cannot be posted because depreciation already exists for the same or a later period. Reverse the incorrect depreciation first.',
},
ASSET_JAMKNING_DATA_REQUIRED: {
httpStatus: 422,
message_sv:
'Ange ursprunglig ingående moms och ursprunglig avdragsprocent för att bedöma justering enligt ML 15 kap.',
message_en:
'Enter the original input VAT and original deduction percentage to assess adjustment under ML chapter 15.',
},
ASSET_ADJUSTMENT_DOCUMENT_REQUIRED: {
httpStatus: 422,
message_sv:
'Bekräfta att en justeringshandling upprättas när justeringsskyldigheten överförs.',
message_en:
'Confirm that an adjustment document is prepared when the adjustment obligation is transferred.',
},
ASSET_BUSINESS_TRANSFER_CONFIRMATION_REQUIRED: {
httpStatus: 422,
message_sv:
'Bekräfta att överlåtelsen omfattar en hel verksamhet eller självständig verksamhetsgren och uppfyller villkoren i ML 5 kap. 38 §.',
message_en:
'Confirm that the transfer covers an entire business or independent branch and meets the conditions in ML chapter 5, section 38.',
},
ASSET_CORRECTION_BLOCKED: {
httpStatus: 409,
message_sv: