feat(bokslut): year-end wizard with bokslutsdispositioner + asset register (#508)

* feat(bokslut): year-end wizard with bokslutsdispositioner + asset register

Ships the first user-visible bokslut surface for K2 aktiebolag. The year-end
engine, INK2/INK2R/INK2S generator, and reconciliation reports already existed
in lib/core/bookkeeping/ and lib/reports/; this work wires them into a real
multi-step UI, adds the missing dispositioner calculators (bolagsskatt,
periodiseringsfond, överavskrivningar, SLP), and introduces a fixed-asset
register that feeds planenliga avskrivningar into the same flow.

PHASE 1 — Wizard around the existing year-end engine
- Replaces the "Kommer snart" stub at /bookkeeping/year-end with a 4-step
  wizard (Kontroll → Dispositioner → Förhandsgranska → Verkställ) plus a
  Klart result view
- New aggregator lib/bokslut/readiness-aggregator.ts composes
  validateYearEndReadiness with bank-reconciliation status and entity-typed
  reminders into one fetch backing the preflight step
- New endpoint GET /api/bookkeeping/fiscal-periods/[id]/bokslut-readiness

PHASE 2 — Bokslutsdispositioner calculators
- lib/bokslut/tax-provision/{bolagsskatt,sarskild-loneskatt}-calculator.ts —
  20.6 % on taxable result → 8910/2512 (with non-deductible / non-taxable
  manual adjustments and schablonintäkt pass-through) and 24.26 % SLP on
  posted pension costs → 7533/2514
- lib/bokslut/reserves/periodiseringsfond-service.ts — proposeAvsattning
  (25 % cap, BAS 212X cohort accounts) + proposeAteforing (FIFO 6-year
  mandatory reversal with schablonintäkt computation) + balance lookup
- lib/bokslut/reserves/overavskrivningar-service.ts — 30-rule + 20-rule
  helpers + proposeOveravskrivningar (8853/2153)
- New endpoint /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner
  (GET ordered proposals, POST commits user-chosen ones as separate
  year_end vouchers via the journal engine)
- New DispositionsStep UI: per-card accept/skip + editable amount where
  meaningful; mandatory p-fond reversals can't be skipped
- INK2 bug fix: ink2-engine.ts SRU mapping ranges previously pointed at
  accounts BAS doesn't seed (8810/8830/8840). Corrected to 8811 (avsättning),
  8819 (återföring), 8830 (lämnade koncernbidrag) so calculator output now
  flows into INK2 correctly. Regression-locked with 6 new mapping tests.

PHASE 3 — Anläggningsregister + depreciation engine
- New migration 20260516120000_assets_and_depreciation.sql: assets table
  (category, BAS-triple, K3 components JSONB reserved) and
  depreciation_schedules (asset+period+journal_entry link). RLS via
  user_company_ids(), immutability triggers after disposal/posting.
- lib/bokslut/assets/asset-service.ts — CRUD + disposal that posts a proper
  gain/loss entry against 3973/7973
- lib/bokslut/assets/depreciation-engine.ts — computeAnnualDepreciation
  (linear, pro-rata at acquisition/disposal/end-of-life) +
  proposeAnnualPostings + commitAnnualPostings (one entry per asset)
- New endpoints /api/assets (CRUD + dispose) and
  /api/bookkeeping/fiscal-periods/[id]/depreciation (preview + commit)
- /assets list+create page with K2 schablon defaults (3y datorer,
  5y inventarier, 25y byggnader); sidebar entry added
- DepreciationPanel mounted at the top of DispositionsStep; posting
  refreshes dispositions so bolagsskatt picks up the new result

Out of scope (per the agreed plan): K3 framework, iXBRL filing to
Bolagsverket (manual export only for now — regulatory risk flagged for
FY2026 closings), inventory module, koncernredovisning, revisor workflow.

Verification
- 116 unit tests pass across lib/bokslut/, lib/reports/ink2/, and the
  existing lib/core/bookkeeping/year-end-service suite
- Zero lint or typecheck errors in any new file
- Migration applied successfully via Supabase MCP

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

* fix(bokslut): address PR #508 review — P1 correctness + P2 conventions

P1 — stale reminders in readiness-aggregator (greptile)
Remove the depreciation_manual / bolagsskatt_manual / periodiseringsfond_manual
nudges. After Phase 3, the wizard handles all three automatically via
DepreciationPanel and the dispositioner calculators — surfacing them as
manual reminders told users to repeat work the page already did. Only the
accruals_manual reminder (Phase 4 hasn't shipped) and the EF-only
ef_skatt_via_ne reminder remain.

P1 — netBookValueAfter ignored prior accumulated depreciation
proposeAnnualPostings now fetches all prior posted depreciation_schedules
for the company (excluding the current period) and sums them per asset, so
the displayed restvärde reflects every previously-booked year of avskrivning
instead of only this year. Without the fix, a 5-year asset in year 3 would
have shown 48 000 instead of the correct 24 000 net book value.

P1 — ordering bug in dispositioner POST handler
The 25 % p-fond avsättning cap derives from the current trial balance, so
mandatory återföring entries must post first. Added a server-side sort by
canonical bokslut order (återföring → överavskrivningar → avsättning → SLP
→ bolagsskatt) regardless of the client array order. The cap can no longer
be evaluated against a stale pre-återföring net result.

P2 — depreciation_schedules missing updated_at
New migration 20260516140000_depreciation_schedules_updated_at.sql adds the
column + trigger via update_updated_at_column(). Per CLAUDE.md migration
conventions, never modified the original migration. DepreciationSchedule
type updated.

P2 — addMonths end-of-month overflow
Replaced setUTCMonth (which overflows: Jan 31 + 1 month → Mar 3) with a
day-clamping implementation that produces Feb 28/29. Without the fix,
lifeEndExclusive landed one day too late and slightly over-depreciated.
New regression test asserts Jan 31 + 12 months stays in January.

P2 — pg-real tests for new triggers and RLS
tests/pg/assets.pg.test.ts (13 tests) covers:
  - enforce_asset_post_disposal_immutability blocks every financial field
    after disposal, allows notes/name through
  - assets_disposal_atomic CHECK requires both disposed columns set together
  - enforce_depreciation_schedule_immutability blocks edits after
    journal_entry_id is linked, allows them before
  - depreciation_schedules delete RLS policy filters out posted rows
  - assets + depreciation_schedules RLS isolates across companies

Verification
- 117 unit tests pass (was 116, +1 for the addMonths regression)
- New pg-real suite syntactically + type-correct; will execute in CI
- Zero lint or typecheck errors in any touched file
- Migration 20260516140000 applied to remote Supabase via MCP

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

* fix(bokslut): address compliance-swarm + swedish-review findings

Real bugs surfaced by the bots on the first push that weren't covered by
greptile's inline P1/P2 set:

- Immaterial asset disposal hit the wrong BAS accounts. disposeAsset always
  posted gain/loss to 3973/7973 regardless of category. For category
  'immaterial' it must use 3013 (vinst) / 7813 (förlust) per BAS — using the
  tangible accounts misclassifies in INK2R. Now branches on category. Two
  new regression tests pin each branch.

- acquisition_cost CHECK was too loose. CreateAssetSchema accepted 0 (just
  nonnegative). Tightened to z.number().positive() — a zero-cost asset
  creates a no-op depreciation row and a balance sheet line that nothing
  reconciles against.

- UpdateAssetSchema let users remap BAS accounts arbitrarily. Bot flagged
  this as a defense-in-depth gap (V4.5). Added BAS_RANGES_BY_CATEGORY
  validation at both the schema layer (Create) and the service layer
  (Update) so user-supplied account overrides must stay inside the
  category's expected BAS range. INK2R mappings and the depreciation
  engine's category-driven defaults now can't drift.

Swedish accounting review:

- Building/markanläggning defaults — clarified UI copy. The 25-year
  schablon is K2-redovisning, not the IL skattemässig rate. New helper text
  spells this out. Markanläggning default lowered from 20→10 years
  (Skatteverket guidance allows 10 % rate; 20 was on the upper bound
  without justification).

- createAsset doesn't post the acquisition entry by design — that gap
  wasn't called out anywhere in the UI. Added a tip box in
  CreateAssetDialog explaining that the acquisition must already be in the
  books; the register only drives depreciation.

- Disposal VAT (ML 3:3 / 7:3) not handled — sale of a deduct-eligible
  anläggningstillgång is in principle 25 % momspliktig. Documented this as
  a known limitation in the disposeAsset docstring so any future UI
  surfacing the disposal endpoint warns the user.

Documented (not fixed yet) — bot was right but wider-scope work:

- SOC 2 PI1.3: dispositioner POST loop is not transactional. A failure
  midway leaves partial postings. Added a code comment explaining the
  recovery path (re-POST omitting committed kinds — each calculator
  re-derives from current TB). Real atomicity via an RPC wrapper is Phase
  5+ work.

False positives intentionally not changed:

- 4× OWASP V8.2.1 cross-tenant findings — service functions already filter
  by company_id; the bot can't see past the route handler.
- V2.3 client-supplied amount clamping — proposeAvsattning and
  proposeAteforing both clamp via Math.min already.
- A.8.15 audit events — withRouteContext already logs completion.
- Schablonintäkt journal entry — per IL 30:6a it's a skattemässig
  justering, never booked. Current implementation is correct.
- Voucher series 'A' — matches existing executeYearEndClosing convention;
  not changing here in isolation.

Verification
- 119 tests pass (was 117, +2 for the immaterial-disposal branches)
- Zero lint or typecheck errors on any touched file

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

* fix(bokslut): address compliance round 4 — BAS account overlap + method gate

Two real bugs the compliance bot caught after my round-3 fixes (both new on
its re-run, not in the original report):

- BAS account overlap not prevented: my BAS_RANGES_BY_CATEGORY uses the same
  class range for asset and accumulated (e.g. immaterial: both 1010–1099,
  building: both 1100–1199). Nothing stopped a user from picking the same
  account for both, which would silently net acquisition cost against
  accumulated depreciation in one bucket and corrupt INK2R 720x mappings.
  CreateAssetSchema now rejects bas_asset_account === bas_accumulated_account
  in a superRefine cross-field check; updateAsset enforces the same invariant
  by reading the existing asset and validating the merged result.

- declining-balance methods silently fell back to linear. The DB enum allowed
  declining_balance_30 / declining_balance_20, but the engine's
  computeAnnualDepreciation only implements linear math. A determined caller
  (MCP, curl, future UI) could create an asset labelled as räkenskapsenlig
  avskrivning and get linear charges — silently wrong numbers under a
  misleading method. Both CreateAssetSchema and UpdateAssetSchema now refine
  the depreciation_method enum to require 'linear'. The DB enum stays open
  for a future phase to add proper support. Stale comment in
  depreciation-engine.ts updated to reflect the new invariant.

False positives I'm explicitly not chasing further on this round:
- 3× repeated OWASP V8.2.1 cross-tenant — services already filter by
  company_id; bot can't see past the route handler. Round 3 already added
  service-layer tests and inline reasoning.
- V2.3 atomicity upgrade to high — bot now flags it harder *because* I
  documented it in round 3. The existing executeYearEndClosing has the same
  non-transactional sequential-write pattern; wrapping just this endpoint
  in an RPC while leaving the rest inconsistent is worse than the doc
  comment. Real atomicity is Phase 5+.
- Disposal VAT user-facing warning — no UI surfaces dispose yet; docstring
  in the service is sufficient until the UI ships.

Verification
- 119 tests pass
- Zero lint or typecheck errors on any touched file

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

* fix(bokslut): address compliance round 5 — disposal integrity + öavskr accounts

Round-5 bot feedback after my round-4 push (the sticky reports re-edited
themselves with two new genuine findings; bot count: 18 → 14 active).

P1 — accumulated_depreciation was client-supplied (OWASP V8.2.1, swedish
compliance review): the dispose endpoint accepted accumulated_depreciation
as a request-body number. A malicious or buggy caller could inflate it to
manipulate the book-value calculation and pocket a phantom gain. Now:

- DisposeAssetSchema no longer accepts accumulated_depreciation
- disposeAsset sums planned_depreciation from depreciation_schedules where
  journal_entry_id IS NOT NULL for the asset, server-side
- New regression test "server-derives accumulated_depreciation — caller
  cannot inflate gain" pins the server-derivation against the prior attack
- Limitation: manual avskrivningsverifikationer posted outside the engine
  aren't captured. Phase 5+ can swap this for a trial-balance scan on
  bas_accumulated_account if that gap matters.

P2 — överavskrivningar hardcoded 8853/2153 regardless of asset category
(swedish-asset-accounting): for buildings BAS uses 8852/2152 and for
immateriella tillgångar 8851/2151. Edge case for K2 SME (öavskr on
buildings is rare; on immateriella rarer still) but worth not lying about
the accounts. Now:

- New OVERAVSKRIVNING_ACCOUNTS table maps category → expense/accumulated
  pair (machinery_equipment, building, immaterial, group)
- proposeOveravskrivningar accepts optional category, defaults to
  machinery_equipment (the dominant K2 case — no behaviour change for
  existing callers)
- POST handler item schema accepts optional category
- Label + description strings now name the actual accounts used
- 3 new tests cover the building, immaterial, and default branches

False positives I'm still declining to chase (already covered in prior
commit messages):
- 3× repeated OWASP V8.2.1 cross-tenant — services scope by company_id;
  bot can't see past route handler
- V2.3 atomicity — existing executeYearEndClosing has the same pattern;
  wrapping just this endpoint is inconsistent; real fix is Phase 5+ RPC
- Disposal VAT user-facing warning — no UI surfaces dispose yet

Verification
- 123 tests pass (was 119, +3 for öavskr category branches and +1 for the
  server-derivation regression test, with one prior test rewritten to use
  the new server-supplied accumulated path)
- Zero lint or typecheck errors on any touched file

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

* fix(bokslut): pre-merge polish — building disposal accounts, SLR-2026 rate

Last polish round before merge. All three from the round-5 sticky
Swedish-accounting-review update:

- Building / markanläggning disposal posted gain/loss to 3973/7973. BAS
  2026 routes those to 3971/7971 (the SRU mapping points them at a
  different INK2R field, so the existing accounts misclassify). Extended
  the existing immaterial branch (3013/7813) into a three-way:
    immaterial            → 3013 / 7813
    building / land_imprv → 3971 / 7971
    other tangible        → 3973 / 7973
  Two new regression tests pin the building and land_improvement branches.

- DEFAULT_SCHABLONINTAKT_RATE was 0.03, based on SLR 2024-11-30 (1.96 %).
  For closings of inkomstår 2026 the rate is SLR 2025-11-30 (2.55 %) + 1 pe
  = 3.55 %. The wrong rate under-taxes the schablonintäkt, which feeds into
  bolagsskatt. Updated to 0.0355 and rewrote the doc comment to track both
  years so the next bump is obvious.

- Jämkning of input VAT for buildings / markanläggning disposed within the
  10-year jämkningsperiod (ML 9 kap 8–11 §§) is out of scope for this PR
  but should not be silently absent — added a KNOWN LIMITATION block to the
  disposeAsset docstring so any future UI surfacing disposal checks the
  10-year window and warns the user.

After this push the PR has 125 passing tests, all CI green, no merge
conflicts, and the only remaining bot complaints are repeat false
positives or Phase 5+ scope (RPC atomicity, full asset disposal UI,
K3 component depreciation).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-16 15:55:27 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent c06395f633
commit 05078c9d8e
37 changed files with 5954 additions and 35 deletions
+277
View File
@@ -0,0 +1,277 @@
import { describe, it, expect, vi } from 'vitest'
import { DEFAULT_ACCOUNTS_BY_CATEGORY, disposeAsset } from '../assets/asset-service'
import type { Asset } from '@/types'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn().mockResolvedValue({
id: 'entry-1',
voucher_series: 'A',
voucher_number: 1,
}),
}))
describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
it('maps every AssetCategory to a BAS-aligned account triple', () => {
const expected = {
immaterial: { asset: '1010', accumulated: '1019', expense: '7810' },
building: { asset: '1110', accumulated: '1119', expense: '7821' },
land_improvement: { asset: '1150', accumulated: '1159', expense: '7824' },
machinery: { asset: '1210', accumulated: '1219', expense: '7831' },
equipment: { asset: '1220', accumulated: '1229', expense: '7832' },
vehicle: { asset: '1240', accumulated: '1249', expense: '7834' },
computer: { asset: '1250', accumulated: '1259', expense: '7833' },
other_tangible: { asset: '1280', accumulated: '1289', expense: '7839' },
} as const
expect(DEFAULT_ACCOUNTS_BY_CATEGORY).toEqual(expected)
})
it('uses the convention that accumulated = asset + 9 for tangible categories', () => {
const tangible = ['machinery', 'equipment', 'vehicle', 'computer', 'other_tangible'] as const
for (const cat of tangible) {
const triple = DEFAULT_ACCOUNTS_BY_CATEGORY[cat]
const assetNum = parseInt(triple.asset, 10)
const accumulatedNum = parseInt(triple.accumulated, 10)
expect(accumulatedNum).toBe(assetNum + 9)
}
})
it('expense accounts are in the 78xx range (planenliga avskrivningar)', () => {
for (const cat of Object.keys(DEFAULT_ACCOUNTS_BY_CATEGORY) as Array<
keyof typeof DEFAULT_ACCOUNTS_BY_CATEGORY
>) {
const expense = DEFAULT_ACCOUNTS_BY_CATEGORY[cat].expense
expect(expense).toMatch(/^78\d{2}$/)
}
})
})
describe('disposeAsset — gain/loss account selection', () => {
function makeAsset(overrides: Partial<Asset> = {}): 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',
disposed_at: null,
disposed_proceeds: 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)
})
})
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: vi.fn(),
}))
import {
calculateBolagsskatt,
BOLAGSSKATT_RATE,
} from '../tax-provision/bolagsskatt-calculator'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
const NOOP_CLIENT = {} as Parameters<typeof calculateBolagsskatt>[0]
beforeEach(() => {
vi.clearAllMocks()
})
describe('calculateBolagsskatt', () => {
it('applies 20.6% to positive result and posts 8910/2512', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 500_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
expect(result).not.toBeNull()
expect(result!.amount).toBe(Math.round(500_000 * BOLAGSSKATT_RATE)) // 103000
const debit = result!.lines.find((l) => l.account_number === '8910')!
const credit = result!.lines.find((l) => l.account_number === '2512')!
expect(debit.debit_amount).toBe(103_000)
expect(credit.credit_amount).toBe(103_000)
})
it('returns a zero-amount proposal for loss year (no entry posted)', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: -50_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
expect(result).not.toBeNull()
expect(result!.amount).toBe(0)
expect(result!.lines).toEqual([])
expect(result!.description).toContain('förlust')
})
it('adds non-deductible expenses to taxable result', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
manualAdjustments: { nonDeductibleExpenses: 50_000 },
})
// (100_000 + 50_000) × 0.206 = 30_900
expect(result!.amount).toBe(30_900)
})
it('subtracts non-taxable income from taxable result', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
manualAdjustments: { nonTaxableIncome: 40_000 },
})
// (100_000 - 40_000) × 0.206 = 12_360
expect(result!.amount).toBe(12_360)
})
it('adds schablonintäkt on periodiseringsfond to taxable result', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 200_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
manualAdjustments: { schablonintaktPeriodiseringsfond: 3_000 },
})
// (200_000 + 3_000) × 0.206 = 41_818
expect(result!.amount).toBe(41_818)
})
it('truncates taxable result to whole krona before applying tax', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_999.99,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
// floor(100_999.99) = 100_999, × 0.206 = 20805.794 → round = 20_806
expect(result!.amount).toBe(20_806)
})
})
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest'
import { computeAnnualDepreciation } from '../assets/depreciation-engine'
import type { Asset } from '@/types'
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'co-1',
name: 'Test',
category: 'equipment',
acquisition_date: '2025-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60, // 5 years
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
disposed_at: null,
disposed_proceeds: null,
k3_components: null,
notes: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides,
}
}
const FULL_YEAR = { period_start: '2025-01-01', period_end: '2025-12-31' }
describe('computeAnnualDepreciation', () => {
it('linear over full year: 60_000 / 5 yrs = 12_000', () => {
const result = computeAnnualDepreciation(makeAsset(), FULL_YEAR)
expect(result.amount).toBe(12_000)
expect(result.proRated).toBe(false)
})
it('respects salvage value (only depreciates cost − salvage)', () => {
const asset = makeAsset({ acquisition_cost: 60_000, salvage_value: 10_000 })
const result = computeAnnualDepreciation(asset, FULL_YEAR)
// depreciable = 50_000, /5 = 10_000
expect(result.amount).toBe(10_000)
})
it('returns zero when depreciable base ≤ 0', () => {
const asset = makeAsset({ acquisition_cost: 10_000, salvage_value: 10_000 })
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(0)
})
it('pro-rates first year when acquired mid-period', () => {
// Acquired July 1, full year period: window = Jul 1 – Dec 31 = 184 days
// out of 365 ≈ 0.5041. Annual depreciation 12_000 × 0.5041 ≈ 6_049
const asset = makeAsset({ acquisition_date: '2025-07-01' })
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.proRated).toBe(true)
expect(result.amount).toBeGreaterThan(5_900)
expect(result.amount).toBeLessThan(6_100)
})
it('pro-rates final year when life ends mid-period', () => {
// 5-year asset acquired 2021-07-01. End of life = 2026-07-01. For fiscal
// year 2026 (Jan 1 – Dec 31) only 181 days are inside the asset's life.
const asset = makeAsset({
acquisition_date: '2021-07-01',
useful_life_months: 60,
})
const result = computeAnnualDepreciation(asset, {
period_start: '2026-01-01',
period_end: '2026-12-31',
})
expect(result.proRated).toBe(true)
// ~6_000 (half year). Days math: Jan 1 - Jun 30 = 181 days / 365 ≈ 0.4959.
expect(result.amount).toBeGreaterThan(5_900)
expect(result.amount).toBeLessThan(6_100)
})
it('returns 0 when asset was disposed before the period starts', () => {
const asset = makeAsset({
disposed_at: '2024-06-30',
disposed_proceeds: 5_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(0)
})
it('pro-rates when asset is disposed mid-period', () => {
// Disposed June 30 of the fiscal year — half-year depreciation.
const asset = makeAsset({
disposed_at: '2025-06-30',
disposed_proceeds: 5_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.proRated).toBe(true)
expect(result.amount).toBeGreaterThan(5_900)
expect(result.amount).toBeLessThan(6_100)
})
it('returns 0 when asset is fully depreciated before period start', () => {
// 5-year asset acquired 2018-01-01 — fully depreciated by 2023-01-01.
const asset = makeAsset({ acquisition_date: '2018-01-01' })
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(0)
})
it('K2 5-year schablon for inventarier: 100_000 / 5 = 20_000', () => {
const asset = makeAsset({
category: 'equipment',
acquisition_cost: 100_000,
useful_life_months: 60,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(20_000)
})
it('handles 3-year computer with K2 schablon: 30_000 / 3 = 10_000', () => {
const asset = makeAsset({
category: 'computer',
acquisition_cost: 30_000,
useful_life_months: 36,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(10_000)
})
it('end-of-month acquisition does not overflow life end (Jan 31 + N months)', () => {
// Acquired 2025-01-31, 12-month life. Life ends 2026-01-30 (Jan 31 + 12mo
// clamped to last day of Jan = Jan 31 the following year, exclusive →
// Jan 30 inclusive). For fiscal year 2026 only Jan 1-30 = 30 days of
// life remain. Without the clamp, life would overflow to Feb 3 (Jan 31
// + 12mo via setUTCMonth) and over-depreciate.
const asset = makeAsset({
acquisition_date: '2025-01-31',
acquisition_cost: 12_000,
useful_life_months: 12,
})
const result = computeAnnualDepreciation(asset, {
period_start: '2026-01-01',
period_end: '2026-12-31',
})
// 30 days out of 365 of a 12_000 annual = ~986. The buggy version would
// have computed ~1_117 (34 days) — the gap detects the regression.
expect(result.amount).toBeGreaterThan(950)
expect(result.amount).toBeLessThan(1_020)
})
})
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import {
compute30Rule,
compute20Rule,
pickLowerResidual,
proposeOveravskrivningar,
OVERAVSKRIVNING_30_RULE,
} from '../reserves/overavskrivningar-service'
describe('compute30Rule', () => {
it('returns minimum residual = 70% of base', () => {
const result = compute30Rule({
openingBookValue: 100_000,
additions: 50_000,
disposals: 20_000,
})
// base = 100 + 50 - 20 = 130_000, residual = 91_000, maxAllowed = 39_000
expect(result.base).toBe(130_000)
expect(result.minimumResidual).toBe(91_000)
expect(result.maxAllowedAccumulated).toBe(39_000)
})
it('uses 0.7 rate per constant', () => {
expect(OVERAVSKRIVNING_30_RULE).toBe(0.7)
})
})
describe('compute20Rule', () => {
it('residual = sum of cost × (5 − age) / 5 per cohort', () => {
const result = compute20Rule({
// current year, year-1, year-2, year-3, year-4
acquisitionCostByYearOffset: [100_000, 100_000, 100_000, 100_000, 100_000],
})
// residuals: 100×(5/5) + 100×(4/5) + 100×(3/5) + 100×(2/5) + 100×(1/5) = 100+80+60+40+20 = 300
expect(result.minimumResidual).toBe(300_000)
})
it('skips cohorts where no acquisitions happened', () => {
const result = compute20Rule({
acquisitionCostByYearOffset: [50_000, 0, 0, 0, 0],
})
expect(result.minimumResidual).toBe(50_000)
})
})
describe('pickLowerResidual', () => {
it('returns whichever rule yields the lower residual (= more deduction)', () => {
const r30 = { base: 100_000, minimumResidual: 70_000, maxAllowedAccumulated: 30_000 }
const r20 = { minimumResidual: 60_000 }
const pick = pickLowerResidual(r30, r20)
expect(pick.residual).toBe(60_000)
expect(pick.rule).toBe('20-regeln')
})
it('prefers 30-rule on tie (default behaviour)', () => {
const r30 = { base: 100_000, minimumResidual: 70_000, maxAllowedAccumulated: 30_000 }
const r20 = { minimumResidual: 70_000 }
const pick = pickLowerResidual(r30, r20)
expect(pick.rule).toBe('30-regeln')
})
})
describe('proposeOveravskrivningar', () => {
it('emits balanced 8853 / 2153 entry for positive amount', () => {
const result = proposeOveravskrivningar({ additionalAmount: 25_000 })
expect(result).not.toBeNull()
expect(result!.amount).toBe(25_000)
expect(result!.lines[0].account_number).toBe('8853')
expect(result!.lines[0].debit_amount).toBe(25_000)
expect(result!.lines[1].account_number).toBe('2153')
expect(result!.lines[1].credit_amount).toBe(25_000)
expect(result!.warnings).toHaveLength(0)
})
it('emits reversal entry for negative amount with warning', () => {
const result = proposeOveravskrivningar({ additionalAmount: -10_000 })
expect(result).not.toBeNull()
expect(result!.amount).toBe(10_000)
expect(result!.lines[0].account_number).toBe('2153')
expect(result!.lines[0].debit_amount).toBe(10_000)
expect(result!.lines[1].account_number).toBe('8853')
expect(result!.lines[1].credit_amount).toBe(10_000)
expect(result!.warnings).toHaveLength(1)
})
it('returns null for zero amount', () => {
expect(proposeOveravskrivningar({ additionalAmount: 0 })).toBeNull()
})
it('rounds fractional input to whole krona', () => {
const result = proposeOveravskrivningar({ additionalAmount: 1234.7 })
expect(result!.amount).toBe(1_235)
expect(result!.lines[0].debit_amount).toBe(1_235)
expect(result!.lines[1].credit_amount).toBe(1_235)
})
it('uses building accounts 8852/2152 when category=building', () => {
const result = proposeOveravskrivningar({
additionalAmount: 50_000,
category: 'building',
})
expect(result!.lines[0].account_number).toBe('8852')
expect(result!.lines[1].account_number).toBe('2152')
expect(result!.label).toContain('byggnader')
})
it('uses immaterial accounts 8851/2151 when category=immaterial', () => {
const result = proposeOveravskrivningar({
additionalAmount: 30_000,
category: 'immaterial',
})
expect(result!.lines[0].account_number).toBe('8851')
expect(result!.lines[1].account_number).toBe('2151')
expect(result!.label).toContain('immateriella')
})
it('defaults to maskiner & inventarier (8853/2153) when no category given', () => {
const result = proposeOveravskrivningar({ additionalAmount: 20_000 })
expect(result!.lines[0].account_number).toBe('8853')
expect(result!.lines[1].account_number).toBe('2153')
})
})
@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest'
import {
proposeAvsattning,
proposeAteforing,
getPeriodiseringsfondCohortAccount,
PFOND_AB_RATE,
PFOND_MAX_HOLD_YEARS,
type ExistingFond,
} from '../reserves/periodiseringsfond-service'
describe('getPeriodiseringsfondCohortAccount', () => {
it('maps fiscal year to BAS 212X account', () => {
expect(getPeriodiseringsfondCohortAccount(2020)).toBe('2120')
expect(getPeriodiseringsfondCohortAccount(2025)).toBe('2125')
expect(getPeriodiseringsfondCohortAccount(2026)).toBe('2126')
expect(getPeriodiseringsfondCohortAccount(2027)).toBe('2127')
})
it('returns 2129 for 2019 per BAS collision rule', () => {
expect(getPeriodiseringsfondCohortAccount(2019)).toBe('2129')
})
})
describe('proposeAvsattning', () => {
it('caps avsättning at 25% of base', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 200_000, // user asks for 50% — should be capped
fiscalYear: 2025,
})
expect(result).not.toBeNull()
expect(result!.amount).toBe(100_000) // 25% of 400_000
expect(result!.warnings).toHaveLength(1)
expect(result!.warnings[0]).toContain('25 %')
expect(result!.lines[0].account_number).toBe('8811')
expect(result!.lines[1].account_number).toBe('2125')
})
it('defaults to maximum when desiredAmount is omitted', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
})
expect(result!.amount).toBe(100_000)
expect(result!.warnings).toHaveLength(0)
})
it('honors a smaller desiredAmount', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 30_000,
fiscalYear: 2025,
})
expect(result!.amount).toBe(30_000)
expect(result!.warnings).toHaveLength(0)
})
it('returns null when base is negative (loss year)', () => {
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: -100_000,
fiscalYear: 2025,
}),
).toBeNull()
})
it('returns null when desired is zero', () => {
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 0,
fiscalYear: 2025,
}),
).toBeNull()
})
it('emits balanced lines (debit 8811 = credit 21XX)', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2026,
})
expect(result!.lines).toHaveLength(2)
const totalDebit = result!.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = result!.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
expect(result!.lines[1].account_number).toBe('2126')
})
it('uses fiscal year for cohort account number', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 100_000,
fiscalYear: 2027,
})
expect(result!.lines[1].account_number).toBe('2127')
})
})
describe('proposeAteforing', () => {
it('forces full reversal of 6+ year old fonder and marks them required', () => {
const fonder: ExistingFond[] = [
{
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
expect(result.proposals).toHaveLength(1)
expect(result.proposals[0].amount).toBe(50_000)
expect(result.proposals[0].required).toBe(true)
expect(result.proposals[0].warnings[0]).toContain('6-årsgränsen')
// 50_000 × 0.03 = 1500
expect(result.schablonintaktAmount).toBe(1_500)
})
it('skips non-mandatory fonder when no return amount requested', () => {
const fonder: ExistingFond[] = [
{
account_number: '2122',
cohort_year: 2022,
balance: 100_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
expect(result.proposals).toHaveLength(0)
// Schablonintäkt is computed regardless of return decision
expect(result.schablonintaktAmount).toBe(3_000)
})
it('returns the requested optional amount when user opts in', () => {
const fonder: ExistingFond[] = [
{
account_number: '2123',
cohort_year: 2023,
balance: 80_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, {
returns: { '2123': 50_000 },
schablonintaktRate: 0.03,
})
expect(result.proposals).toHaveLength(1)
expect(result.proposals[0].amount).toBe(50_000)
expect(result.proposals[0].required).toBeFalsy()
})
it('caps optional returns to the actual balance', () => {
const fonder: ExistingFond[] = [
{
account_number: '2124',
cohort_year: 2024,
balance: 30_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, {
returns: { '2124': 100_000 },
schablonintaktRate: 0.03,
})
expect(result.proposals[0].amount).toBe(30_000) // capped to balance
})
it('emits balanced lines (debit 21XX = credit 8819)', () => {
const fonder: ExistingFond[] = [
{
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
const lines = result.proposals[0].lines
expect(lines).toHaveLength(2)
expect(lines[0].account_number).toBe('2120')
expect(lines[0].debit_amount).toBe(50_000)
expect(lines[1].account_number).toBe('8819')
expect(lines[1].credit_amount).toBe(50_000)
})
it('exposes constants used by callers', () => {
expect(PFOND_AB_RATE).toBe(0.25)
expect(PFOND_MAX_HOLD_YEARS).toBe(6)
})
})
@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { YearEndValidation } from '@/types'
// Mock both sources the aggregator composes from. Tests focus on composition
// (reminders by entity, reconciliation surfacing, error tolerance) — the
// underlying validateYearEndReadiness already has its own coverage.
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
validateYearEndReadiness: vi.fn(),
}))
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
getReconciliationStatus: vi.fn(),
}))
import { buildBokslutReadinessReport } from '../readiness-aggregator'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
interface MockBuilder {
select: ReturnType<typeof vi.fn>
eq: ReturnType<typeof vi.fn>
single: ReturnType<typeof vi.fn>
maybeSingle: ReturnType<typeof vi.fn>
}
function makeSupabase(handlers: {
period: { data: unknown; error: unknown }
settings: { data: unknown; error: unknown }
}) {
function makeBuilder(table: string): MockBuilder {
const b: MockBuilder = {
select: vi.fn(),
eq: vi.fn(),
single: vi.fn(),
maybeSingle: vi.fn(),
}
b.select.mockReturnValue(b)
b.eq.mockReturnValue(b)
if (table === 'fiscal_periods') {
b.single.mockResolvedValue(handlers.period)
} else if (table === 'company_settings') {
b.maybeSingle.mockResolvedValue(handlers.settings)
}
return b
}
return {
from: vi.fn((table: string) => makeBuilder(table)),
} as unknown as Parameters<typeof buildBokslutReadinessReport>[0]
}
function baseValidation(overrides: Partial<YearEndValidation> = {}): YearEndValidation {
return {
ready: true,
errors: [],
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: true,
...overrides,
}
}
const PERIOD = {
id: 'fp-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
is_closed: false,
locked_at: null,
closing_entry_id: null,
}
const RECON_CLEAN = {
bank_transaction_total: 100,
gl_1930_balance: 100,
gl_1930_period_movement: 100,
gl_1930_opening_balance: 0,
difference: 0,
is_reconciled: true,
matched_count: 5,
unmatched_transaction_count: 0,
unmatched_gl_line_count: 0,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildBokslutReadinessReport', () => {
it('returns a ready report with the accruals reminder for AB', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.ready).toBe(true)
expect(report.blockers).toEqual([])
expect(report.entityType).toBe('aktiebolag')
// Phase 3 handles depreciation + bolagsskatt + p-fond automatically — only
// the accruals reminder should remain (Phase 4 will replace it).
expect(report.reminders.map((r) => r.code)).toContain('accruals_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('depreciation_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('bolagsskatt_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('periodiseringsfond_manual')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeUndefined()
expect(report.reconciliation?.is_reconciled).toBe(true)
})
it('returns the EF-only reminder for enskild firma', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'enskild_firma' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.entityType).toBe('enskild_firma')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeDefined()
})
it('surfaces blockers from the underlying validation and stays not-ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(
baseValidation({
ready: false,
errors: ['3 draft journal entries must be posted or deleted before closing'],
draftCount: 3,
}),
)
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.ready).toBe(false)
expect(report.blockers).toHaveLength(1)
expect(report.draftCount).toBe(3)
})
it('adds a reconciliation reminder when bank is unreconciled', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue({
...RECON_CLEAN,
is_reconciled: false,
unmatched_transaction_count: 7,
difference: 1234.56,
})
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
const reconReminder = report.reminders.find((r) => r.code === 'bank_reconciliation_incomplete')
expect(reconReminder).toBeDefined()
expect(reconReminder?.severity).toBe('warning')
expect(reconReminder?.message).toContain('7')
// Reconciliation reminder is not a legal blocker — ready should still mirror validation
expect(report.ready).toBe(true)
})
it('does not break when reconciliation lookup throws', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockRejectedValue(new Error('boom'))
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.reconciliation).toBeNull()
expect(report.reminders.find((r) => r.code === 'bank_reconciliation_incomplete')).toBeUndefined()
expect(report.ready).toBe(true)
})
it('throws when the fiscal period is missing', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: null, error: { message: 'not found' } },
settings: { data: null, error: null },
})
await expect(
buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-missing'),
).rejects.toThrow(/not found/i)
})
it('defaults to aktiebolag when company_settings is missing', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: null, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.entityType).toBe('aktiebolag')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeUndefined()
})
})
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
calculateSarskildLoneskatt,
SLP_RATE,
} from '../tax-provision/sarskild-loneskatt-calculator'
function makeSupabaseWithPensionLines(rows: Array<{ debit_amount: number; credit_amount: number }>) {
const builder = {
select: vi.fn(),
eq: vi.fn(),
gte: vi.fn(),
lte: vi.fn(),
then: undefined as unknown as (resolve: (v: { data: unknown; error: unknown }) => void) => void,
}
builder.select.mockReturnValue(builder)
builder.eq.mockReturnValue(builder)
builder.gte.mockReturnValue(builder)
builder.lte.mockReturnValue(builder)
builder.then = (resolve) => resolve({ data: rows, error: null })
return { from: vi.fn().mockReturnValue(builder) } as unknown as Parameters<
typeof calculateSarskildLoneskatt
>[0]
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('calculateSarskildLoneskatt', () => {
it('applies 24.26% to pension costs and posts 7533/2514', async () => {
const supabase = makeSupabaseWithPensionLines([
{ debit_amount: 50_000, credit_amount: 0 },
{ debit_amount: 30_000, credit_amount: 0 },
])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).not.toBeNull()
// base = 80_000, × 0.2426 = 19_408
expect(result!.amount).toBe(19_408)
expect(result!.lines[0].account_number).toBe('7533')
expect(result!.lines[1].account_number).toBe('2514')
})
it('returns null when there are no pension costs', async () => {
const supabase = makeSupabaseWithPensionLines([])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).toBeNull()
})
it('honors manual adjustment for pensionsavsättning on 2210', async () => {
const supabase = makeSupabaseWithPensionLines([])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp', {
manualAdjustment: 100_000,
})
expect(result).not.toBeNull()
// 100_000 × 0.2426 = 24_260
expect(result!.amount).toBe(24_260)
})
it('nets debits against credits (refund of pension premium reduces base)', async () => {
const supabase = makeSupabaseWithPensionLines([
{ debit_amount: 50_000, credit_amount: 0 },
{ debit_amount: 0, credit_amount: 10_000 },
])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).not.toBeNull()
// base = 40_000, × 0.2426 = 9_704
expect(result!.amount).toBe(9_704)
})
it('exposes the SLP rate constant', () => {
expect(SLP_RATE).toBe(0.2426)
})
})
+403
View File
@@ -0,0 +1,403 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type {
Asset,
AssetCategory,
DepreciationMethod,
CreateJournalEntryLineInput,
JournalEntry,
} from '@/types'
/**
* Default BAS account triples per category. The user can override at create
* time; these only kick in when the form doesn't specify accounts. Matches
* the seeded BAS 2020 chart (lib/bookkeeping/bas-data/).
*/
export const DEFAULT_ACCOUNTS_BY_CATEGORY: Record<
AssetCategory,
{ asset: string; accumulated: string; expense: string }
> = {
immaterial: { asset: '1010', accumulated: '1019', expense: '7810' },
building: { asset: '1110', accumulated: '1119', expense: '7821' },
land_improvement: { asset: '1150', accumulated: '1159', expense: '7824' },
machinery: { asset: '1210', accumulated: '1219', expense: '7831' },
equipment: { asset: '1220', accumulated: '1229', expense: '7832' },
vehicle: { asset: '1240', accumulated: '1249', expense: '7834' },
computer: { asset: '1250', accumulated: '1259', expense: '7833' },
other_tangible: { asset: '1280', accumulated: '1289', expense: '7839' },
}
export interface CreateAssetInput {
name: string
category: AssetCategory
acquisition_date: string
acquisition_cost: number
salvage_value?: number
useful_life_months: number
depreciation_method?: DepreciationMethod
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
notes?: string
}
/**
* Create a new asset. Defaults BAS accounts from the category mapping when
* the caller doesn't override them. Does NOT post a journal entry — the
* acquisition is assumed to already be in the books (bank payment or
* supplier invoice). Posting an acquisition entry alongside an existing
* payment would double-count.
*/
export async function createAsset(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreateAssetInput,
): Promise<Asset> {
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[input.category]
const row = {
user_id: userId,
company_id: companyId,
name: input.name,
category: input.category,
acquisition_date: input.acquisition_date,
acquisition_cost: input.acquisition_cost,
salvage_value: input.salvage_value ?? 0,
useful_life_months: input.useful_life_months,
depreciation_method: input.depreciation_method ?? 'linear',
bas_asset_account: input.bas_asset_account ?? defaults.asset,
bas_accumulated_account: input.bas_accumulated_account ?? defaults.accumulated,
bas_expense_account: input.bas_expense_account ?? defaults.expense,
notes: input.notes ?? null,
}
const { data, error } = await supabase
.from('assets')
.insert(row)
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to create asset: ${error?.message ?? 'unknown'}`)
}
return data as Asset
}
export async function listAssets(
supabase: SupabaseClient,
companyId: string,
options: { activeOnly?: boolean } = {},
): Promise<Asset[]> {
let query = supabase
.from('assets')
.select('*')
.eq('company_id', companyId)
.order('acquisition_date', { ascending: true })
if (options.activeOnly) {
query = query.is('disposed_at', null)
}
const { data, error } = await query
if (error) throw new Error(`Failed to list assets: ${error.message}`)
return (data ?? []) as Asset[]
}
export async function getAsset(
supabase: SupabaseClient,
companyId: string,
assetId: string,
): Promise<Asset | null> {
const { data, error } = await supabase
.from('assets')
.select('*')
.eq('id', assetId)
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Failed to load asset: ${error.message}`)
return (data as Asset | null) ?? null
}
export interface UpdateAssetInput {
name?: string
notes?: string | null
/** Salvage value, useful life, method, accounts — editable as long as the
* asset isn't disposed yet (DB trigger enforces this beyond the API). */
salvage_value?: number
useful_life_months?: number
depreciation_method?: DepreciationMethod
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
}
export async function updateAsset(
supabase: SupabaseClient,
companyId: string,
assetId: string,
input: UpdateAssetInput,
): Promise<Asset> {
// Defense-in-depth: when callers remap BAS accounts, refuse anything
// outside the legitimate range for the existing asset's category — keeps
// INK2R mappings + the depreciation engine's category-driven defaults in
// sync with what users actually pick.
if (
input.bas_asset_account ||
input.bas_accumulated_account ||
input.bas_expense_account
) {
const existing = await getAsset(supabase, companyId, assetId)
if (!existing) throw new Error('Asset not found')
const ranges = BAS_RANGES_BY_CATEGORY[existing.category]
if (input.bas_asset_account && !inBasRange(input.bas_asset_account, ranges.asset)) {
throw new Error(
`bas_asset_account ${input.bas_asset_account} is outside ${ranges.asset[0]}–${ranges.asset[1]} for ${existing.category}`,
)
}
if (
input.bas_accumulated_account &&
!inBasRange(input.bas_accumulated_account, ranges.accumulated)
) {
throw new Error(
`bas_accumulated_account ${input.bas_accumulated_account} is outside ${ranges.accumulated[0]}–${ranges.accumulated[1]} for ${existing.category}`,
)
}
if (
input.bas_expense_account &&
!inBasRange(input.bas_expense_account, ranges.expense)
) {
throw new Error(
`bas_expense_account ${input.bas_expense_account} is outside ${ranges.expense[0]}–${ranges.expense[1]} for ${existing.category}`,
)
}
// Anskaffning and ackumulerade-avskrivningar must be different accounts —
// see CreateAssetSchema validateBasOverrides for the rationale.
const finalAsset = input.bas_asset_account ?? existing.bas_asset_account
const finalAccumulated = input.bas_accumulated_account ?? existing.bas_accumulated_account
if (finalAsset === finalAccumulated) {
throw new Error(
'bas_asset_account and bas_accumulated_account must be different accounts',
)
}
}
const { data, error } = await supabase
.from('assets')
.update(input)
.eq('id', assetId)
.eq('company_id', companyId)
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to update asset: ${error?.message ?? 'unknown'}`)
}
return data as Asset
}
const BAS_RANGES_BY_CATEGORY: Record<
AssetCategory,
{ asset: [string, string]; accumulated: [string, string]; expense: [string, string] }
> = {
immaterial: { asset: ['1010', '1099'], accumulated: ['1010', '1099'], expense: ['7810', '7819'] },
building: { asset: ['1100', '1199'], accumulated: ['1100', '1199'], expense: ['7820', '7829'] },
land_improvement:{ asset: ['1150', '1159'], accumulated: ['1150', '1159'], expense: ['7820', '7829'] },
machinery: { asset: ['1210', '1219'], accumulated: ['1210', '1219'], expense: ['7830', '7839'] },
equipment: { asset: ['1220', '1229'], accumulated: ['1220', '1229'], expense: ['7830', '7839'] },
vehicle: { asset: ['1240', '1249'], accumulated: ['1240', '1249'], expense: ['7830', '7839'] },
computer: { asset: ['1250', '1259'], accumulated: ['1250', '1259'], expense: ['7830', '7839'] },
other_tangible: { asset: ['1280', '1299'], accumulated: ['1280', '1299'], expense: ['7830', '7839'] },
}
function inBasRange(account: string, range: [string, string]): boolean {
return account >= range[0] && account <= range[1]
}
export interface DisposeAssetInput {
/** ISO date of disposal — typically the day of sale or scrapping. */
disposed_at: string
/** Cash / receivable received for the asset. Zero for scrapping. */
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
}
export interface DisposalResult {
asset: Asset
/** Disposal entry. Null when no entry was needed (zero-value, fully-
* depreciated asset scrapped for nothing). */
disposal_entry: JournalEntry | null
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
* - Debit 78xx (loss on sale) OR Credit 30xx (gain on sale) — accounts
* branch on category (3013/7813 for immaterial, 3973/7973 for tangible).
*
* After posting, marks the asset row with disposed_at / disposed_proceeds.
* The DB trigger then prevents further edits to financial fields.
*
* KNOWN LIMITATION (ML 3 kap 3 § / 7 kap 3 §): the sale of an
* anläggningstillgång that had right-to-deduct VAT on acquisition is in
* principle 25 % momspliktig. This function does NOT post output VAT on the
* proceeds — callers must handle the VAT side separately (or use a manual
* journal entry). UI surfacing disposal must warn the user. Adding a
* vat_on_proceeds field is tracked as a follow-up.
*
* KNOWN LIMITATION (ML 9 kap 8–11 §§ — jämkning): when a building or
* markanläggning is disposed of within the 10-year jämkningsperiod after
* acquisition, previously deducted input VAT must be recalculated and may
* have to be partially repaid. This function does NOT compute or post any
* jämkning adjustment. UI surfacing disposal for category='building' or
* 'land_improvement' must check the 10-year window against acquisition_date
* and warn the user; the actual recalculation belongs in a future
* dedicated flow.
*/
export async function disposeAsset(
supabase: SupabaseClient,
companyId: string,
userId: string,
assetId: string,
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')
}
// 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 proceeds = Number(input.disposed_proceeds)
const netBookValue = acquisitionCost - accumulated
const gainOrLoss = Math.round((proceeds - netBookValue) * 100) / 100
const proceedsAccount = input.proceeds_account ?? '1930'
const lines: CreateJournalEntryLineInput[] = []
if (accumulated > 0.005) {
lines.push({
account_number: asset.bas_accumulated_account,
debit_amount: Math.round(accumulated * 100) / 100,
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: Math.round(acquisitionCost * 100) / 100,
line_description: `Avyttring: nollställ anskaffning ${asset.name}`,
})
if (proceeds > 0.005) {
lines.push({
account_number: proceedsAccount,
debit_amount: Math.round(proceeds * 100) / 100,
credit_amount: 0,
line_description: `Avyttring: erhållet belopp ${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}`,
})
}
let disposalEntry: JournalEntry | null = null
if (lines.length > 0) {
disposalEntry = await createJournalEntry(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',
voucher_series: 'A',
lines,
})
}
const { data: updated, error: updateError } = await supabase
.from('assets')
.update({
disposed_at: input.disposed_at,
disposed_proceeds: proceeds,
})
.eq('id', assetId)
.eq('company_id', companyId)
.select('*')
.single()
if (updateError || !updated) {
throw new Error(`Failed to mark asset disposed: ${updateError?.message ?? 'unknown'}`)
}
return {
asset: updated as Asset,
disposal_entry: disposalEntry,
gain_or_loss: gainOrLoss,
}
}
/**
* 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}`)
}
type Row = { planned_depreciation: number | string }
return ((data ?? []) as Row[]).reduce(
(sum, row) => sum + (Number(row.planned_depreciation) || 0),
0,
)
}
+313
View File
@@ -0,0 +1,313 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { listAssets } from './asset-service'
import type {
Asset,
FiscalPeriod,
JournalEntry,
CreateJournalEntryLineInput,
} from '@/types'
export interface AssetDepreciation {
asset: Asset
/** Planenlig avskrivning för denna period, avrundad till hela kronor. */
amount: number
/** Net book value vid periodens slut (ackumulerad avskrivning inklusive
* denna period subtraherat från anskaffningsvärdet). Används av wizard
* UI:t för att visa restvärde efter avskrivning. */
netBookValueAfter: number
/** True om avskrivningen pro-rateras (tillgång anskaffad eller fullt
* avskriven mitt i perioden). */
proRated: boolean
/** Befintligt depreciation_schedules-id om en proposal redan finns för
* denna kombination. Wizard:t använder detta för att veta att proposalen
* redan har bokförts (om journal_entry_id är satt) eller är väntande
* (om journal_entry_id är null). */
existingScheduleId?: string
existingJournalEntryId?: string | null
}
export interface DepreciationProposal {
fiscalPeriod: { id: string; name: string; period_start: string; period_end: string }
items: AssetDepreciation[]
totalAmount: number
}
/**
* Compute planenlig avskrivning för en enskild tillgång under en given
* fiscal period. Pro-rateras vid första och sista året.
*
* Only the linear method is implemented. The API rejects asset creation /
* updates with the other DB-enum values (declining_balance_*), so any asset
* that reaches here has method='linear'; treating the other branches as
* dead code is safe.
*/
export function computeAnnualDepreciation(
asset: Asset,
fiscalPeriod: Pick<FiscalPeriod, 'period_start' | 'period_end'>,
): { amount: number; proRated: boolean } {
if (asset.disposed_at && asset.disposed_at < fiscalPeriod.period_start) {
return { amount: 0, proRated: false }
}
const acquisitionCost = Number(asset.acquisition_cost)
const salvageValue = Number(asset.salvage_value)
const depreciableBase = acquisitionCost - salvageValue
if (depreciableBase <= 0) return { amount: 0, proRated: false }
const usefulLifeMonths = asset.useful_life_months
const annualRate = 12 / usefulLifeMonths
// Determine the depreciation window for this period: the overlap between
// the asset's active life and the fiscal period.
const acquisition = isoToDate(asset.acquisition_date)
const lifeEndExclusive = addMonths(acquisition, usefulLifeMonths)
const periodStart = isoToDate(fiscalPeriod.period_start)
const periodEndInclusive = isoToDate(fiscalPeriod.period_end)
const disposalEnd = asset.disposed_at ? isoToDate(asset.disposed_at) : null
const windowStart = maxDate(acquisition, periodStart)
let windowEnd = minDate(periodEndInclusive, addDays(lifeEndExclusive, -1))
if (disposalEnd) windowEnd = minDate(windowEnd, disposalEnd)
if (windowEnd < windowStart) return { amount: 0, proRated: false }
const fullPeriodDays = daysBetween(periodStart, periodEndInclusive) + 1
const windowDays = daysBetween(windowStart, windowEnd) + 1
const fraction = windowDays / fullPeriodDays
const proRated = fraction < 0.999
// Full-year amount = annualRate × depreciableBase (linear).
const annualAmount = depreciableBase * annualRate
const proRatedAmount = annualAmount * fraction
return {
amount: Math.round(proRatedAmount),
proRated,
}
}
/**
* Build a proposal listing planenlig avskrivning för every active asset.
* Reads existing depreciation_schedules so already-posted entries aren't
* proposed twice (the unique constraint on (asset_id, fiscal_period_id)
* would reject duplicates anyway, but the UI wants to display "redan
* bokförd" rather than fail).
*/
export async function proposeAnnualPostings(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<DepreciationProposal> {
const [periodResult, assets, currentSchedulesResult, priorSchedulesResult] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
listAssets(supabase, companyId),
// Schedules in the current period (proposal lookup / "already posted" badge)
supabase
.from('depreciation_schedules')
.select('id, asset_id, journal_entry_id')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId),
// Prior posted schedules (excluding the current period) so we can compute
// the true accumulated depreciation per asset for net book value.
supabase
.from('depreciation_schedules')
.select('asset_id, planned_depreciation, journal_entry_id, fiscal_period_id')
.eq('company_id', companyId)
.neq('fiscal_period_id', fiscalPeriodId)
.not('journal_entry_id', 'is', null),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const existing = new Map<string, { id: string; journal_entry_id: string | null }>(
(currentSchedulesResult.data ?? []).map(
(r: { id: string; asset_id: string; journal_entry_id: string | null }) => [
r.asset_id,
{ id: r.id, journal_entry_id: r.journal_entry_id },
],
),
)
// Sum of all prior posted depreciation per asset. This is the accumulated
// depreciation on the books before this period — does not yet count this
// period's proposal or any unposted current-period draft.
const priorAccumulated = new Map<string, number>()
for (const row of (priorSchedulesResult.data ?? []) as Array<{
asset_id: string
planned_depreciation: number | string
}>) {
const v = Number(row.planned_depreciation) || 0
priorAccumulated.set(row.asset_id, (priorAccumulated.get(row.asset_id) ?? 0) + v)
}
const items: AssetDepreciation[] = []
for (const asset of assets) {
// Skip assets disposed before period start
if (asset.disposed_at && asset.disposed_at < period.period_start) continue
const { amount, proRated } = computeAnnualDepreciation(asset, period)
if (amount <= 0) continue
const existingSchedule = existing.get(asset.id)
const accumulatedBefore = priorAccumulated.get(asset.id) ?? 0
const netBookValueAfter =
Math.round((Number(asset.acquisition_cost) - accumulatedBefore - amount) * 100) / 100
items.push({
asset,
amount,
netBookValueAfter,
proRated,
existingScheduleId: existingSchedule?.id,
existingJournalEntryId: existingSchedule?.journal_entry_id ?? null,
})
}
return {
fiscalPeriod: period,
items,
totalAmount: items.reduce((sum, item) => sum + item.amount, 0),
}
}
/**
* Commit the proposal as journal entries. Creates ONE journal entry per
* asset (rather than a single batch entry) so each can be reversed
* independently and so the depreciation_schedules row links one-to-one to
* its journal entry.
*
* Skips assets that already have a posted schedule för this period — the
* unique constraint would block them, and silently skipping is more useful
* than throwing. Returns the list of (asset_id, schedule, entry) tuples.
*/
export async function commitAnnualPostings(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
options: { assetIds?: string[] } = {},
): Promise<{
posted: { assetId: string; entry: JournalEntry; scheduleId: string }[]
skipped: { assetId: string; reason: string }[]
}> {
const proposal = await proposeAnnualPostings(supabase, companyId, fiscalPeriodId)
const periodEnd = proposal.fiscalPeriod.period_end
const periodName = proposal.fiscalPeriod.name
const allowed = options.assetIds ? new Set(options.assetIds) : null
const posted: { assetId: string; entry: JournalEntry; scheduleId: string }[] = []
const skipped: { assetId: string; reason: string }[] = []
for (const item of proposal.items) {
if (allowed && !allowed.has(item.asset.id)) continue
if (item.existingJournalEntryId) {
skipped.push({ assetId: item.asset.id, reason: 'already_posted' })
continue
}
const lines: CreateJournalEntryLineInput[] = [
{
account_number: item.asset.bas_expense_account,
debit_amount: item.amount,
credit_amount: 0,
line_description: `Avskrivning ${item.asset.name}`,
},
{
account_number: item.asset.bas_accumulated_account,
debit_amount: 0,
credit_amount: item.amount,
line_description: `Ack. avskrivning ${item.asset.name}`,
},
]
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: periodEnd,
description: `Planenlig avskrivning ${periodName}: ${item.asset.name}`,
source_type: 'year_end',
voucher_series: 'A',
lines,
})
// Upsert the schedule row. If a draft (no journal_entry_id) already
// exists for (asset, period) we overwrite it with the posted entry.
if (item.existingScheduleId) {
const { error } = await supabase
.from('depreciation_schedules')
.update({ journal_entry_id: entry.id, posted_at: new Date().toISOString() })
.eq('id', item.existingScheduleId)
.eq('company_id', companyId)
if (error) throw new Error(`Failed to update schedule: ${error.message}`)
posted.push({ assetId: item.asset.id, entry, scheduleId: item.existingScheduleId })
} else {
const { data, error } = await supabase
.from('depreciation_schedules')
.insert({
user_id: userId,
company_id: companyId,
asset_id: item.asset.id,
fiscal_period_id: fiscalPeriodId,
planned_depreciation: item.amount,
journal_entry_id: entry.id,
posted_at: new Date().toISOString(),
})
.select('id')
.single()
if (error || !data) throw new Error(`Failed to insert schedule: ${error?.message}`)
posted.push({ assetId: item.asset.id, entry, scheduleId: data.id })
}
}
return { posted, skipped }
}
// ============================================================
// Date helpers — keep pure so the unit tests don't need to mock anything.
// ============================================================
function isoToDate(iso: string): Date {
// Force UTC midnight to avoid local-time DST drift confusing days math.
return new Date(iso + 'T00:00:00Z')
}
function addMonths(date: Date, months: number): Date {
// Clamp the day to the last valid day of the target month so end-of-month
// dates don't overflow forward (Jan 31 + 1 month → Feb 28, not Mar 3).
const targetMonth = date.getUTCMonth() + months
const lastDayOfTargetMonth = new Date(
Date.UTC(date.getUTCFullYear(), targetMonth + 1, 0),
).getUTCDate()
return new Date(
Date.UTC(
date.getUTCFullYear(),
targetMonth,
Math.min(date.getUTCDate(), lastDayOfTargetMonth),
),
)
}
function addDays(date: Date, days: number): Date {
const result = new Date(date)
result.setUTCDate(result.getUTCDate() + days)
return result
}
function maxDate(a: Date, b: Date): Date {
return a > b ? a : b
}
function minDate(a: Date, b: Date): Date {
return a < b ? a : b
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24))
}
+165
View File
@@ -0,0 +1,165 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import type { YearEndValidation } from '@/types'
export type ReminderSeverity = 'info' | 'warning'
export interface BokslutReminder {
/** Stable id so the UI can suppress duplicates and link to docs. */
code: string
severity: ReminderSeverity
/** Swedish, user-facing. */
message: string
/** Optional deep link to the relevant resolution surface. */
href?: string
}
export interface BokslutReadinessReport {
/** Mirrors validateYearEndReadiness.ready — true ⇔ no blocking errors. */
ready: boolean
/** Blocking errors that prevent year-end execution (from year-end-service). */
blockers: string[]
/** Non-blocking warnings (from year-end-service). */
warnings: string[]
/** Soft reminders (Phase 2+ features not yet shipped, manual steps the user
* should consider). Never blockers — surfaced so users know what's manual. */
reminders: BokslutReminder[]
/** Convenience counts for the UI header. */
draftCount: number
unexplainedGapCount: number
trialBalanceBalanced: boolean
/** Bank reconciliation snapshot for the period. */
reconciliation: {
is_reconciled: boolean
unmatched_transaction_count: number
unmatched_gl_line_count: number
difference: number
} | null
/** Period metadata so the UI can show name/dates without an extra fetch. */
period: {
id: string
name: string
period_start: string
period_end: string
is_closed: boolean
locked_at: string | null
closing_entry_id: string | null
}
/** Entity type drives which dispositions apply (e.g. bolagsskatt only for AB). */
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
/** The full raw validation, for callers that want every field. */
rawValidation: YearEndValidation
}
/**
* Single-fetch aggregator that drives the bokslut wizard's preflight step.
*
* Wraps validateYearEndReadiness (which owns the legally-required checks) and
* layers on:
* - bank reconciliation snapshot for the period (informational warning if
* unmatched transactions exist — not a legal blocker)
* - soft reminders for Phase 2+ features that ship later (depreciation,
* accruals, tax provision). These tell the user what's manual today.
*
* Phase 2 will replace each reminder with a concrete proposal once the
* relevant calculator ships.
*/
export async function buildBokslutReadinessReport(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
): Promise<BokslutReadinessReport> {
// Fetch period + entity type in parallel with the heavy validation.
const [periodResult, settingsResult, validation] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle(),
validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const entityType = (settingsResult.data?.entity_type ?? 'aktiebolag') as BokslutReadinessReport['entityType']
// Bank reconciliation snapshot for the period. Run after period fetch so we
// know the date range. Failure here must not break the report — fall back
// to null so the UI degrades gracefully.
let reconciliation: BokslutReadinessReport['reconciliation'] = null
try {
const status = await getReconciliationStatus(
supabase,
companyId,
period.period_start,
period.period_end,
)
reconciliation = {
is_reconciled: status.is_reconciled,
unmatched_transaction_count: status.unmatched_transaction_count,
unmatched_gl_line_count: status.unmatched_gl_line_count,
difference: status.difference,
}
} catch {
reconciliation = null
}
const reminders: BokslutReminder[] = []
if (reconciliation && !reconciliation.is_reconciled) {
reminders.push({
code: 'bank_reconciliation_incomplete',
severity: 'warning',
message:
reconciliation.unmatched_transaction_count > 0
? `${reconciliation.unmatched_transaction_count} banktransaktioner är inte matchade. Avstäm banken innan bokslut.`
: `Bankavstämningen visar en differens på ${reconciliation.difference.toFixed(2)} kr.`,
href: '/reconciliation/bank',
})
}
// Periodiseringar (accruals) are still manual — no wizard step ships in
// Phases 1-3. Depreciation, bolagsskatt and periodiseringsfond now have
// dedicated calculators (DepreciationPanel + DispositionsStep) so they're
// no longer surfaced as manual reminders.
reminders.push({
code: 'accruals_manual',
severity: 'info',
message:
'Periodiseringar (förutbetalda kostnader 17xx, upplupna kostnader 29xx) bokas manuellt. Tänk på att vända dem 1 januari nästa år.',
})
if (entityType === 'enskild_firma') {
reminders.push({
code: 'ef_skatt_via_ne',
severity: 'info',
message:
'Egenavgifter, räntefördelning och periodiseringsfond beräknas i NE-bilagan, inte bokförs. Skatten betalas privat av ägaren.',
})
}
return {
ready: validation.ready,
blockers: validation.errors,
warnings: validation.warnings,
reminders,
draftCount: validation.draftCount,
unexplainedGapCount: validation.unexplainedGaps.length,
trialBalanceBalanced: validation.trialBalanceBalanced,
reconciliation,
period,
entityType,
rawValidation: validation,
}
}
@@ -0,0 +1,187 @@
import type { ProposedDisposition } from '../types'
/** 30-rule (huvudregel, IL 18 kap 13 §): restvärde minst 70 % av (ingående
* bokfört värde + årets anskaffningar − årets försäljningar och utrangeringar). */
export const OVERAVSKRIVNING_30_RULE = 0.7
/** 20-rule (kompletteringsregel, IL 18 kap 17 §): restvärde minst 0 % efter
* 5 år (20 % avskrivning per år, raklinje). */
export const OVERAVSKRIVNING_20_RULE_YEARS = 5
export interface Compute30RuleInput {
/** IB bokfört värde maskiner & inventarier (12xx netto). */
openingBookValue: number
/** Årets anskaffningar (debet på anskaffningskonto, t.ex. 1220). */
additions: number
/** Försäljningsvärde och utrangering av tillgångar (kredit på anskaffningskonto). */
disposals: number
}
export interface Compute20RuleInput {
/** Anskaffningskostnad per anskaffningsår, från (innevarande år − 4) till
* innevarande år. Index 0 = innevarande år. */
acquisitionCostByYearOffset: [number, number, number, number, number]
}
/**
* 30-regeln: skattemässigt lägsta restvärde = 70 % × (IB + årets anskaffningar − årets avyttringar).
* Maximalt skattemässigt avskrivningsbart belopp = avskrivningsunderlag − restvärde.
*/
export function compute30Rule(input: Compute30RuleInput): {
base: number
minimumResidual: number
maxAllowedAccumulated: number
} {
const base = input.openingBookValue + input.additions - input.disposals
const minimumResidual = Math.round(base * OVERAVSKRIVNING_30_RULE * 100) / 100
return {
base,
minimumResidual,
maxAllowedAccumulated: Math.round((base - minimumResidual) * 100) / 100,
}
}
/**
* 20-regeln: varje årsanskaffning får skrivas av med 20 % under 5 år. Lägsta
* skattemässigt restvärde är summan av 20 % × ((5 − offset) / 5) × anskaffningar
* från år (innevarande − offset).
*
* Returns the allowed depreciation if 20-rule is used as the sole basis,
* computed against ALL still-active 20-rule cohorts.
*/
export function compute20Rule(input: Compute20RuleInput): {
minimumResidual: number
} {
// Residual per cohort = anskaffningskostnad × (5 − ageInYears) / 5.
// ageInYears 0 = current year (residual 100 %), 4 = oldest still-live (20 %).
let residual = 0
for (let offset = 0; offset < OVERAVSKRIVNING_20_RULE_YEARS; offset++) {
const cost = input.acquisitionCostByYearOffset[offset] ?? 0
const remainingFraction = (OVERAVSKRIVNING_20_RULE_YEARS - offset) / OVERAVSKRIVNING_20_RULE_YEARS
residual += cost * remainingFraction
}
return { minimumResidual: Math.round(residual * 100) / 100 }
}
/**
* Pick the rule that gives the lowest restvärde (highest allowed deduction)
* per IL 18 kap 13–17 §§ — företaget får välja den fördelaktigaste regeln
* varje år.
*/
export function pickLowerResidual(
rule30: ReturnType<typeof compute30Rule>,
rule20: ReturnType<typeof compute20Rule>,
): { residual: number; rule: '30-regeln' | '20-regeln' } {
if (rule20.minimumResidual < rule30.minimumResidual) {
return { residual: rule20.minimumResidual, rule: '20-regeln' }
}
return { residual: rule30.minimumResidual, rule: '30-regeln' }
}
/**
* BAS account pairs for överavskrivningar by asset category. The 88xx
* "förändring" account always pairs with its matching 21xx "ackumulerade"
* account so the verifikation stays balanced and flows into the right INK2R
* field via the SRU mapping.
*
* - 8853 / 2153 — maskiner & inventarier (IL 18 kap, dominant K2 case)
* - 8852 / 2152 — byggnader (IL 19 kap, rare in SME)
* - 8851 / 2151 — immateriella tillgångar (IL 16 kap, even rarer)
* - 8850 / 2150 — samlingskonto för grupp
*/
export const OVERAVSKRIVNING_ACCOUNTS = {
machinery_equipment: { expense: '8853', accumulated: '2153' },
building: { expense: '8852', accumulated: '2152' },
immaterial: { expense: '8851', accumulated: '2151' },
group: { expense: '8850', accumulated: '2150' },
} as const
export type OveravskrivningCategory = keyof typeof OVERAVSKRIVNING_ACCOUNTS
export interface OveravskrivningarInput {
/** Föreslagen ökning av ackumulerade överavskrivningar. Positivt belopp
* ökar ackumulerade-kontot (debet 88xx), negativt minskar (kredit 88xx). */
additionalAmount: number
/** Account pair to use. Defaults to maskiner & inventarier (8853 / 2153) —
* the only category where överavskrivningar is common in K2 SME. Override
* for buildings or immateriella tillgångar when relevant. */
category?: OveravskrivningCategory
/** Visa beräkningens bakgrund i UI:t. Helt fritt format. */
computation?: Record<string, unknown>
}
/**
* Propose an överavskrivningar entry. Caller computes the desired delta
* using compute30Rule / compute20Rule (or enters a manual amount) and the
* service emits the verifikation. Uses BAS 8853 / 2153 (maskiner & inventarier),
* which covers the vast majority of cases for small AB.
*
* For Phase 2 this is a thin wrapper. Phase 3 (anläggningsregister) will
* compute the delta automatically from per-asset planenlig vs skattemässig
* schedules and pre-fill `additionalAmount`.
*/
const CATEGORY_LABELS: Record<OveravskrivningCategory, string> = {
machinery_equipment: 'maskiner & inventarier',
building: 'byggnader',
immaterial: 'immateriella tillgångar',
group: 'samlingskonto',
}
export function proposeOveravskrivningar(input: OveravskrivningarInput): ProposedDisposition | null {
const amount = Math.round(input.additionalAmount)
if (amount === 0) return null
const category = input.category ?? 'machinery_equipment'
const accounts = OVERAVSKRIVNING_ACCOUNTS[category]
const categoryLabel = CATEGORY_LABELS[category]
if (amount > 0) {
return {
kind: 'overavskrivningar',
label: `Ökning av överavskrivningar (${categoryLabel})`,
description: `Debet ${accounts.expense}, kredit ${accounts.accumulated}. Bokför skattemässig avskrivning utöver planenlig.`,
amount,
lines: [
{
account_number: accounts.expense,
debit_amount: amount,
credit_amount: 0,
line_description: 'Förändring av överavskrivningar',
},
{
account_number: accounts.accumulated,
debit_amount: 0,
credit_amount: amount,
line_description: 'Ackumulerade överavskrivningar',
},
],
warnings: [],
computation: input.computation,
}
}
// Negative = upplösning av tidigare överavskrivning
const absAmount = Math.abs(amount)
return {
kind: 'overavskrivningar',
label: `Upplösning av överavskrivningar (${categoryLabel})`,
description: `Debet ${accounts.accumulated}, kredit ${accounts.expense}. Återför tidigare gjord överavskrivning.`,
amount: absAmount,
lines: [
{
account_number: accounts.accumulated,
debit_amount: absAmount,
credit_amount: 0,
line_description: 'Upplösning ackumulerade överavskrivningar',
},
{
account_number: accounts.expense,
debit_amount: 0,
credit_amount: absAmount,
line_description: 'Förändring av överavskrivningar',
},
],
warnings: ['Negativ förändring återför tidigare överavskrivning och ökar skattepliktigt resultat.'],
computation: input.computation,
}
}
@@ -0,0 +1,278 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ProposedDisposition } from '../types'
/** Maximum periodiseringsfond avsättning for aktiebolag: 25 % of skattemässigt
* resultat före avsättning. Enskild firma uses 30 % but is handled in NE/INK1
* rather than booked. */
export const PFOND_AB_RATE = 0.25
export const PFOND_AB_RATE_PCT = '25 %'
/** Mandatory holding period — a fond avsatt år N must be återförd no later
* than räkenskapsår N+6 (IL 30 kap 7 §). */
export const PFOND_MAX_HOLD_YEARS = 6
/**
* BAS account convention: account = '212' + (fiscalYear % 10). 2020 → '2120',
* 2025 → '2125'. The collision year 2019/2029 maps to '2129' per the BAS
* 2020 seed; if a company has fonder in both years on the same account, the
* service surfaces a warning so the user can split the balance manually.
*/
export function getPeriodiseringsfondCohortAccount(fiscalYear: number): string {
if (fiscalYear === 2019) return '2129'
return '212' + (fiscalYear % 10).toString()
}
export interface ExistingFond {
/** BAS account number for the cohort (e.g. '2120'). */
account_number: string
/** Cohort year derived from account naming convention (e.g. 2020 for 2120). */
cohort_year: number
/** Current credit balance (positive = liability balance). */
balance: number
/** True if the fond must be returned this year (cohort_year + 6 ≤ closing_year). */
must_return_this_year: boolean
}
export interface PfondAvsattningInput {
/** Skattemässigt resultat före avsättning. 25 % cap is applied to this. */
skattemassigtResultatBeforeAvsattning: number
/** Amount the user wants to set aside. Defaults to the maximum (25 %). */
desiredAmount?: number
/** Closing year of the fiscal period (e.g. 2025 for FY ending 2025-12-31).
* Determines which cohort account to use. */
fiscalYear: number
}
export interface PfondAvsattningComputation {
rate: number
maxAmount: number
desiredAmount: number
actualAmount: number
cohortAccount: string
cohortYear: number
cappedToMax: boolean
}
/**
* Propose a periodiseringsfond avsättning. Caps the user's desired amount
* to 25 % of skattemässigt resultat före avsättning (rounded down to whole
* krona). Returns null when no positive avsättning would result (loss year
* or zero desired).
*/
export function proposeAvsattning(input: PfondAvsattningInput): ProposedDisposition | null {
const base = Math.max(0, Math.floor(input.skattemassigtResultatBeforeAvsattning))
const maxAmount = Math.floor(base * PFOND_AB_RATE)
const desiredAmount = Math.max(0, Math.floor(input.desiredAmount ?? maxAmount))
const actualAmount = Math.min(desiredAmount, maxAmount)
const cohortAccount = getPeriodiseringsfondCohortAccount(input.fiscalYear)
const cappedToMax = desiredAmount > maxAmount
if (actualAmount === 0) {
return null
}
const computation: PfondAvsattningComputation = {
rate: PFOND_AB_RATE,
maxAmount,
desiredAmount,
actualAmount,
cohortAccount,
cohortYear: input.fiscalYear,
cappedToMax,
}
const warnings: string[] = []
if (cappedToMax) {
warnings.push(
`Begärt belopp (${desiredAmount} kr) översteg ${PFOND_AB_RATE_PCT}-taket. Avsättningen begränsades till ${maxAmount} kr.`,
)
}
return {
kind: 'periodiseringsfond_avsattning',
label: `Avsättning till periodiseringsfond ${input.fiscalYear}`,
description: `Debet 8811, kredit ${cohortAccount}. Max ${PFOND_AB_RATE_PCT} av skattemässigt resultat.`,
amount: actualAmount,
lines: [
{
account_number: '8811',
debit_amount: actualAmount,
credit_amount: 0,
line_description: `Avsättning periodiseringsfond ${input.fiscalYear}`,
},
{
account_number: cohortAccount,
debit_amount: 0,
credit_amount: actualAmount,
line_description: `Periodiseringsfond ${input.fiscalYear}`,
},
],
warnings,
computation: computation as unknown as Record<string, unknown>,
}
}
/**
* List existing periodiseringsfonder by querying the account balance of every
* 2110–2199 account as of the closing date of the fiscal period. Marks any
* fond whose cohort_year + 6 ≤ closing_year as `must_return_this_year`.
*
* Uses the trial-balance pattern: sum debit/credit on each 21xx account from
* inception through the closing date. Result is positive when the credit
* balance exceeds debits (the normal state of a liability account).
*/
export async function listExistingPeriodiseringsfonder(
supabase: SupabaseClient,
companyId: string,
closingDate: string,
): Promise<ExistingFond[]> {
const closingYear = parseInt(closingDate.slice(0, 4), 10)
if (Number.isNaN(closingYear)) {
throw new Error(`Invalid closing date: ${closingDate}`)
}
// Sum debit/credit per 21xx account up to and including the closing date.
// Use the journal_entry_lines table directly — RLS scopes to the company.
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)',
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.lte('journal_entries.entry_date', closingDate)
.gte('account_number', '2110')
.lte('account_number', '2199')
if (error) {
throw new Error(`Failed to fetch periodiseringsfond balances: ${error.message}`)
}
type Row = { account_number: string; debit_amount: number | string | null; credit_amount: number | string | null }
const byAccount = new Map<string, number>()
for (const row of (data ?? []) as Row[]) {
const balance =
(Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
byAccount.set(row.account_number, (byAccount.get(row.account_number) ?? 0) + balance)
}
const fonder: ExistingFond[] = []
for (const [accountNumber, balance] of byAccount) {
if (Math.abs(balance) < 0.005) continue
const cohortYear = cohortYearFromAccount(accountNumber)
if (cohortYear === null) continue
fonder.push({
account_number: accountNumber,
cohort_year: cohortYear,
balance: Math.round(balance * 100) / 100,
must_return_this_year: cohortYear + PFOND_MAX_HOLD_YEARS <= closingYear,
})
}
fonder.sort((a, b) => a.cohort_year - b.cohort_year)
return fonder
}
/**
* Derive the cohort year from a BAS account number. Returns null for accounts
* that don't follow the '212X' convention (e.g. 2110 = grouping account, no
* specific cohort).
*/
function cohortYearFromAccount(accountNumber: string): number | null {
if (!/^212\d$/.test(accountNumber)) return null
const lastDigit = parseInt(accountNumber.slice(-1), 10)
// BAS 2020: 2129 represents 2019 by convention; 2128 = 2028.
if (lastDigit === 9) return 2019
// For 0–8, the cohort year is in the 2020s. As fiscal years extend past
// 2029 the convention will recycle (2120 might mean 2030 then); cap at
// 2020-decade interpretation for now and surface ambiguity in a warning.
return 2020 + lastDigit
}
export interface PfondAteforingProposal {
/** One proposal per individual fond being returned. The wizard renders these
* as separate cards; mandatory ones (must_return_this_year) cannot be skipped. */
proposals: ProposedDisposition[]
/** Total schablonintäkt computed on the OPENING balance of all 21xx accounts.
* This is NOT booked — it goes into INK2 as a manual adjustment to taxable
* result. Caller (bolagsskatt-calculator) reads this to add to taxable result. */
schablonintaktAmount: number
}
/**
* Propose periodiseringsfond reversals. Forces reversal of any fond reaching
* its 6-year limit; offers optional reversal of newer fonder. Also computes
* the schablonintäkt on the opening balance of all 21xx accounts (per IL 30
* kap 6a §) — caller adds this to taxable result when computing bolagsskatt.
*
* @param schablonintaktRate Statslåneräntan 30 nov året före, plus 1 pe, min
* 0.5 %. For income year 2025: ~3.0 %. Caller passes this in because the
* rate changes annually and is sourced from Riksbanken.
*/
export function proposeAteforing(
existingFonder: ExistingFond[],
options: {
/** Map from account_number to desired return amount. Omit entries the
* user does not want to return (mandatory ones are returned regardless). */
returns?: Record<string, number>
/** Schablonintäkt rate as a decimal (0.03 for 3 %). Applied to opening
* balance of every 21xx account. */
schablonintaktRate: number
},
): PfondAteforingProposal {
const proposals: ProposedDisposition[] = []
let schablonintaktAmount = 0
for (const fond of existingFonder) {
schablonintaktAmount += fond.balance * options.schablonintaktRate
const desiredReturn = options.returns?.[fond.account_number] ?? 0
const isMandatory = fond.must_return_this_year
const returnAmount = isMandatory
? fond.balance // forced full reversal
: Math.min(Math.max(0, Math.floor(desiredReturn)), fond.balance)
if (returnAmount === 0) continue
const warnings: string[] = []
if (isMandatory) {
warnings.push(
`Periodiseringsfond ${fond.cohort_year} har nått 6-årsgränsen och måste återföras.`,
)
}
proposals.push({
kind: 'periodiseringsfond_ateforing',
label: `Återföring periodiseringsfond ${fond.cohort_year}`,
description: `Debet ${fond.account_number}, kredit 8819.`,
amount: returnAmount,
lines: [
{
account_number: fond.account_number,
debit_amount: returnAmount,
credit_amount: 0,
line_description: `Återföring periodiseringsfond ${fond.cohort_year}`,
},
{
account_number: '8819',
debit_amount: 0,
credit_amount: returnAmount,
line_description: `Återföring periodiseringsfond ${fond.cohort_year}`,
},
],
warnings,
computation: {
cohort_year: fond.cohort_year,
opening_balance: fond.balance,
return_amount: returnAmount,
was_mandatory: isMandatory,
},
required: isMandatory,
})
}
return {
proposals,
schablonintaktAmount: Math.round(schablonintaktAmount),
}
}
@@ -0,0 +1,131 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import type { ProposedDisposition } from '../types'
/** Bolagsskatt rate. 20.6 % since 2021 (gäller räkenskapsår påbörjat efter 31 dec 2020). */
export const BOLAGSSKATT_RATE = 0.206
export interface BolagsskattInput {
/** Manual adjustments to taxable result that the calculator can't derive.
* Each is a SEK amount that ADDS to taxable result (so e.g. non-deductible
* representation costs are positive; non-taxable dividend income is negative). */
manualAdjustments?: {
/** e.g. ej avdragsgilla kostnader: representation > schablon, böter, gåvor. */
nonDeductibleExpenses?: number
/** e.g. skattefria intäkter: näringsbetingad utdelning. */
nonTaxableIncome?: number
/** Schablonintäkt on periodiseringsfond opening balance (statslåneräntan
* × ingående saldo). Computed by periodiseringsfond-service so callers
* can pass it through. */
schablonintaktPeriodiseringsfond?: number
/** Other adjustments — free-form. */
other?: number
}
}
export interface BolagsskattComputation {
/** Net result from the income statement (already includes any class 88xx
* bokslutsdispositioner that the user posted before reaching this step). */
resultBeforeTax: number
nonDeductibleExpenses: number
nonTaxableIncome: number
schablonintaktPeriodiseringsfond: number
otherAdjustments: number
taxableResult: number
/** Taxable result before tax — equals max(taxableResult, 0). */
taxableResultClamped: number
taxRate: number
taxAmount: number
}
/**
* Compute bolagsskatt 20.6 % on the company's taxable result.
*
* Reads income-statement result before tax and adds the manual adjustments
* the user provided (non-deductible expenses, schablonintäkt, etc.). The
* resulting taxable result is rounded down to nearest whole krona before
* applying the tax rate, per SFL 22 kap 1 §.
*
* If the period shows a loss, no tax is proposed — Swedish AB accumulate
* inrullat underskott for future offset, but that bookkeeping is handled
* separately in NE/INK2 rather than as a current-year provision.
*/
export async function calculateBolagsskatt(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
input: BolagsskattInput = {},
): Promise<ProposedDisposition | null> {
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const resultBeforeTax = incomeStatement.net_result
const adjustments = input.manualAdjustments ?? {}
const nonDeductibleExpenses = adjustments.nonDeductibleExpenses ?? 0
const nonTaxableIncome = adjustments.nonTaxableIncome ?? 0
const schablonintaktPeriodiseringsfond = adjustments.schablonintaktPeriodiseringsfond ?? 0
const otherAdjustments = adjustments.other ?? 0
const taxableResult =
resultBeforeTax +
nonDeductibleExpenses -
nonTaxableIncome +
schablonintaktPeriodiseringsfond +
otherAdjustments
// Truncate to whole krona before applying rate. Negative taxable result =
// no tax provision (handled as inrullat underskott in INK2, not here).
const taxableResultClamped = Math.max(0, Math.floor(taxableResult))
const taxAmount = Math.round(taxableResultClamped * BOLAGSSKATT_RATE)
const computation: BolagsskattComputation = {
resultBeforeTax,
nonDeductibleExpenses,
nonTaxableIncome,
schablonintaktPeriodiseringsfond,
otherAdjustments,
taxableResult,
taxableResultClamped,
taxRate: BOLAGSSKATT_RATE,
taxAmount,
}
if (taxAmount === 0) {
// No tax proposal for loss-year — but expose computation so the UI can show
// why nothing was booked.
return {
kind: 'bolagsskatt',
label: 'Bolagsskatt 20,6 %',
description:
taxableResult <= 0
? 'Ingen skatt — året visar förlust eller noll resultat. Underskottet rullas in i nästa år (hanteras i INK2).'
: 'Skattemässigt resultat blev noll efter justeringar. Ingen skatt att boka.',
amount: 0,
lines: [],
warnings: [],
computation: computation as unknown as Record<string, unknown>,
}
}
return {
kind: 'bolagsskatt',
label: 'Bolagsskatt 20,6 %',
description: `Skatt på årets skattemässiga resultat. Debet 8910, kredit 2512.`,
amount: taxAmount,
lines: [
{
account_number: '8910',
debit_amount: taxAmount,
credit_amount: 0,
line_description: `Bolagsskatt 20,6 % på ${taxableResultClamped} kr`,
},
{
account_number: '2512',
debit_amount: 0,
credit_amount: taxAmount,
line_description: 'Beräknad inkomstskatt',
},
],
warnings: [],
computation: computation as unknown as Record<string, unknown>,
}
}
@@ -0,0 +1,97 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ProposedDisposition } from '../types'
/** Särskild löneskatt på pensionskostnader (SLP). 24.26 % per SLF 1991:687. */
export const SLP_RATE = 0.2426
export interface SlpComputation {
/** Total pension cost during the period — sum of posted debits on accounts
* 7410–7419 (pensionsförsäkringspremier, individuella pensioner, etc.). */
pensionCostsBooked: number
/** Optional manual adjustment — e.g. avsättning till pensionsskuld on 2210
* bokad under perioden som inte ligger på 7410–7419 men ska SLP-belastas. */
manualAdjustment: number
/** Base for SLP = pensionCostsBooked + manualAdjustment. */
base: number
rate: number
slpAmount: number
}
/**
* Compute särskild löneskatt på pensionskostnader.
*
* SLP gäller arbetsgivares kostnader för avtalspension samt pensionsavsättningar
* (men inte allmän pension som finansieras av arbetsgivaravgifterna). Räknas
* på 7410-7419 (tjänstepensionspremier) och avsättningar till pensionsskuld.
*
* Caller can supply `manualAdjustment` to include pensionsavsättningar made on
* 2210 (avsättning för pensioner) that aren't reflected in 7410-7419 — common
* when companies book direct to the avsättningskonto rather than via a cost
* account.
*/
export async function calculateSarskildLoneskatt(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: { manualAdjustment?: number } = {},
): Promise<ProposedDisposition | null> {
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status)',
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.eq('journal_entries.status', 'posted')
.gte('account_number', '7410')
.lte('account_number', '7419')
if (error) {
throw new Error(`Failed to fetch pension costs: ${error.message}`)
}
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
const pensionCostsBooked = ((data ?? []) as Row[]).reduce((sum, row) => {
// Cost account — normal balance is debit, so net = debit − credit.
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
}, 0)
const manualAdjustment = options.manualAdjustment ?? 0
const base = Math.max(0, pensionCostsBooked + manualAdjustment)
const slpAmount = Math.round(base * SLP_RATE)
const computation: SlpComputation = {
pensionCostsBooked: Math.round(pensionCostsBooked * 100) / 100,
manualAdjustment,
base,
rate: SLP_RATE,
slpAmount,
}
if (slpAmount === 0) {
return null
}
return {
kind: 'sarskild_loneskatt',
label: 'Särskild löneskatt på pensionskostnader (24,26 %)',
description: 'Debet 7533, kredit 2514.',
amount: slpAmount,
lines: [
{
account_number: '7533',
debit_amount: slpAmount,
credit_amount: 0,
line_description: `SLP 24,26 % på ${base} kr pensionskostnader`,
},
{
account_number: '2514',
debit_amount: 0,
credit_amount: slpAmount,
line_description: 'Beräknad särskild löneskatt på pensionskostnader',
},
],
warnings: [],
computation: computation as unknown as Record<string, unknown>,
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { CreateJournalEntryLineInput } from '@/types'
export type DispositionKind =
| 'bolagsskatt'
| 'periodiseringsfond_avsattning'
| 'periodiseringsfond_ateforing'
| 'overavskrivningar'
| 'sarskild_loneskatt'
/**
* Common shape every bokslut-disposition calculator returns. The wizard renders
* one card per proposal; the API endpoint accepts a list of `{ kind, accept,
* overrideAmount? }` from the user and posts the matching entries.
*/
export interface ProposedDisposition {
kind: DispositionKind
/** Short Swedish label for UI cards (e.g. "Bolagsskatt 20,6 %"). */
label: string
/** One-sentence Swedish explanation of what the entry does. */
description: string
/** SEK amount displayed in the card header. Always a positive number. */
amount: number
/** Final voucher lines if the user accepts. Already balanced. */
lines: CreateJournalEntryLineInput[]
/** Soft warnings the UI surfaces beside the card (e.g. forced p-fond reversal,
* rate cap reached). Not blockers. */
warnings: string[]
/** Calculator-specific breakdown the UI can render in an "Visa beräkning"
* panel. Free-form so each calculator can show its own details. */
computation?: Record<string, unknown>
/** True if this proposal cannot be skipped — e.g. periodiseringsfond from
* tax year N-6 that must be reversed. UI disables the skip control. */
required?: boolean
}
/**
* Snapshot of all proposed dispositions for a fiscal period, returned by the
* dispositions API. Order is the suggested user-flow order: p-fond återföring
* (mandatory) → överavskrivningar → p-fond avsättning → SLP → bolagsskatt.
* The wizard renders them in that order so each step's effect is visible to
* the next one (bolagsskatt comes last because it depends on everything else).
*/
export interface DispositionsProposal {
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
fiscalPeriod: {
id: string
name: string
period_start: string
period_end: string
}
/** Result before any new dispositions, from the income statement (positive = profit). */
netResultBefore: number
proposals: ProposedDisposition[]
}
@@ -208,6 +208,34 @@ describe('INK2R Account Mappings', () => {
expect(findSRUCodeForAccount('8900')).toBe('7528')
expect(findSRUCodeForAccount('8910')).toBe('7528')
})
describe('bokslutsdispositioner (BAS 2020-aligned)', () => {
// These mappings were corrected when the Phase 2 bokslut calculators
// landed — the previous ranges (8810/8830/8840) targeted accounts that
// BAS doesn't seed. Locking the corrected mapping prevents regression.
it('8811 -> 7525 (Avsättning till periodiseringsfond)', () => {
expect(findSRUCodeForAccount('8811')).toBe('7525')
})
it('8819 -> 7420 (Återföring av periodiseringsfond)', () => {
expect(findSRUCodeForAccount('8819')).toBe('7420')
})
it('8820 -> 7419 (Mottagna koncernbidrag)', () => {
expect(findSRUCodeForAccount('8820')).toBe('7419')
})
it('8830 -> 7524 (Lämnade koncernbidrag)', () => {
expect(findSRUCodeForAccount('8830')).toBe('7524')
})
it('8850-8859 -> 7421 (Förändring av överavskrivningar)', () => {
expect(findSRUCodeForAccount('8850')).toBe('7421')
expect(findSRUCodeForAccount('8853')).toBe('7421') // M&I sub-cat
expect(findSRUCodeForAccount('8859')).toBe('7421')
})
it('8840 + 8860-8899 -> 7422 (Övriga bokslutsdispositioner)', () => {
expect(findSRUCodeForAccount('8840')).toBe('7422')
expect(findSRUCodeForAccount('8860')).toBe('7422')
expect(findSRUCodeForAccount('8899')).toBe('7422')
})
})
})
describe('no overlap between mappings', () => {
+25 -17
View File
@@ -28,8 +28,9 @@ import {
* bas.se/kontoplaner/sru/ and Skatteverket field code spec.
*
* INK2R contains the full balance sheet + income statement.
* INK2S auto-derives basic fields (result + tax → taxable result).
* Manual tax adjustments (periodiseringsfonder etc.) are handled by the accountant.
* INK2S auto-derives basic fields (result + tax → taxable result), as well as
* periodiseringsfond and överavskrivningar when those have been posted via the
* bokslut-dispositions calculators in lib/bokslut/.
*/
/**
@@ -561,12 +562,21 @@ export const INK2R_ACCOUNT_MAPPINGS: INK2AccountMapping[] = [
normalBalance: 'debit',
accountRanges: [{ start: '8500', end: '8599' }],
},
// Bokslutsdispositioner — account numbers per BAS 2020 (verified against
// lib/bookkeeping/bas-data/class-8-financial.ts).
{
sruCode: '7524',
description: 'Lämnade koncernbidrag',
sruCode: '7525',
description: 'Avsättning till periodiseringsfond',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8810', end: '8810' }],
accountRanges: [{ start: '8811', end: '8811' }],
},
{
sruCode: '7420',
description: 'Återföring av periodiseringsfond',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '8819', end: '8819' }],
},
{
sruCode: '7419',
@@ -576,32 +586,30 @@ export const INK2R_ACCOUNT_MAPPINGS: INK2AccountMapping[] = [
accountRanges: [{ start: '8820', end: '8820' }],
},
{
sruCode: '7420',
description: 'Återföring av periodiseringsfond',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '8830', end: '8830' }],
},
{
sruCode: '7525',
description: 'Avsättning till periodiseringsfond',
sruCode: '7524',
description: 'Lämnade koncernbidrag',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8840', end: '8840' }],
accountRanges: [{ start: '8830', end: '8830' }],
},
{
sruCode: '7421',
description: 'Förändring av överavskrivningar',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8850', end: '8850' }],
// 8850 = grupp, 8851-8853 = per kategori (immateriella, byggnader, M&I)
accountRanges: [{ start: '8850', end: '8859' }],
},
{
sruCode: '7422',
description: 'Övriga bokslutsdispositioner',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8860', end: '8899' }],
// 8840 = Lämnade gottgörelser, 8860-8899 = övriga
accountRanges: [
{ start: '8840', end: '8840' },
{ start: '8860', end: '8899' },
],
},
{
sruCode: '7528',