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:
co-authored by
Claude Fable 5
parent
1c9d378df8
commit
cb3ef45f14
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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' }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user