feat(bokslut): Phases 4-7 — accruals + årsredovisning + EF + MCP tools (#509)

* feat(bokslut): Phases 4-7 — accruals + årsredovisning + EF + MCP tools

Builds on the Phase 1-3 PR (#508). Same K2-AB scope decisions hold (iXBRL
deferred, K3 deferred, koncernredovisning out of scope) — what ships:

PHASE 4 — Periodiseringar
- lib/bokslut/accruals/accrual-detector.ts: auto-proposes the vacation-
  liability change (delta on 2920 against 7090 + 31,42 % avgifter on
  7519/2940) by reading lib/reports/vacation-liability.ts. Manual prepaid
  (17xx) / accrued (29xx) / audit-fee builders for entries the heuristic
  can't derive — supplier-invoice service_period detection is deferred
  until the data model grows the field.
- /api/bookkeeping/fiscal-periods/[id]/accruals (GET + POST) — posts each
  accrual as a separate manual-source journal entry with the next-day
  reverse date embedded in the description.
- New AccrualsStep wizard step between Preflight and Dispositions.
- Auto-reversal cron is follow-up infra; for now reverses_on is metadata
  + a visible UI badge.

PHASE 5 — Årsredovisning PDF + signing
- lib/bokslut/arsredovisning/{types,build-data,arsredovisning-pdf,signature
  -service}: pre-fills flerårsöversikt from prior 3 fiscal periods, eget-
  kapital-förändring from journal data, K2-minimum noter with
  avskrivningstider auto-derived from the asset register and medelantal
  anställda from the employees table. PDF via @react-pdf/renderer mirroring
  the income-statement/pdf pattern.
- Migration 20260516170000: arsredovisning_signature_requests with RLS,
  signed-immutability trigger, and DELETE policy that blocks signed rows.
  Signature-service exposes list/create/markSigned/isFullySignedOff.
- BankID call itself is not wired here — the table + service make the
  request layer available so a follow-up can hook lib/auth/bankid.ts to the
  sign action without rework.
- /api/.../arsredovisning (data + pdf + signatures) endpoints.
- /bookkeeping/year-end/arsredovisning page with editable narrative,
  flerårsöversikt table, signer slots, PDF download, and Bolagsverket
  Mina Sidor link. Explicit warning about the FY2026 iXBRL mandate.

PHASE 6 — Enskild firma NE-bilaga UI
- lib/bokslut/enskild-firma/{egenavgifter,rantefordelning,periodiseringsfond
  -ef,expansionsfond}-calculator.ts. All declaration-only — never produce
  a journal entry. Egenavgifter 28,97 % / pensionärssats 10,21 % / passive
  SLP 24,26 %, schablonavdrag 25/10/20 %. Räntefördelning SLR+6 / SLR+1
  with the -500 000 negative threshold. P-fond EF cap 30 % (vs 25 % för
  AB). Expansionsfond 125,94 % av kapitalunderlag, 20,6 % skatt.
- EfDeclarationSection mounted inside DispositionsStep when entity_type is
  enskild_firma — live recompute as the user adjusts kapitalunderlag,
  prior-year amounts, p-fond desired, expansionsfond change. Each card
  shows the NE-bilaga ruta the number lands in. NE-bilaga preview link.

PHASE 7 — Agent-native MCP tools
- gnubok_propose_dispositioner: read-only AB dispositions proposal
- gnubok_propose_accruals: read-only accruals proposal
- gnubok_propose_annual_depreciation: read-only depreciation per asset
- gnubok_post_annual_depreciation: stages depreciation commit (high-risk)
- gnubok_preview_arsredovisning: structured K2 ÅR preview
- gnubok_preview_ef_declaration: EF skattemässiga justeringar preview
- All ≤280-char descriptions, additionalProperties:false, conform to
  STAGED_OPERATION_SCHEMA for write tools. Read tools return the same
  shapes as their HTTP counterparts so agents and the UI share a contract.
- The existing GET /bokslutsdispositioner endpoint now calls the shared
  buildDispositionsProposal helper that the MCP tool also uses, removing
  the duplicate logic that lived in both.

Verification
- 149 unit tests pass (was 125 on Phase 1-3; +24 across accruals, EF
  calculators, expansionsfond, periodiseringsfond-ef)
- Zero lint or typecheck errors on any new file
- Migration 20260516170000 applied to remote Supabase via MCP

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

* fix(bokslut): address PR #509 round-1 — CI blocker + 3 P1s + compliance bundle

CI BLOCKER (output-schema test)
- The 5 new read-only MCP tools (gnubok_propose_dispositioner / _accruals /
  _annual_depreciation, _preview_arsredovisning / _ef_declaration) were
  missing outputSchema, which the existing strict-schemas guard reads as a
  hard failure on core-only CI. Added a permissive { type: 'object',
  additionalProperties: true } outputSchema to each — the return shapes are
  the same as their HTTP counterparts and trying to mirror them inline
  would duplicate the type tree across two boundaries.

P1 — vacation accrual: two real bugs in one entry
- Delta was anchored on the OPENING balance of 2920, so any mid-year
  postings (partial accruals, reversals) were ignored. Now anchors on the
  current closing balance via tb.rows.find(2920).closing_credit -
  closing_debit. Updated the computation field label to current_2920.
- More importantly: 2920 is a balance-sheet carry-forward (semesterlöneskuld
  persists until the actual vacation is paid). The original implementation
  set reverses_on to Jan 1 of the next year, which would zero the liability
  on day 1 of the new year — a known Swedish bookkeeping error. The
  vacation proposal now ships with reverses_on = '' to suppress the
  reversal badge, the API route emits a "Bokslutsjustering" description
  instead of "Periodisering (vänds …)", and AccrualsStep renders "Rullas
  vidare (ingen vändning)" so the user knows the liability carries forward.

P1 — signature_requests immutability gap (security)
- The existing trigger only guarded role / signer_name / signed_at /
  status on signed rows, leaving bankid_signature_data,
  signer_personnummer_encrypted, and signer_personnummer_hash mutable. An
  UPDATE on a signed row could silently alter the BankID proof. New
  migration 20260517090000 replaces the trigger function to cover the full
  audit-critical column set (plus fiscal_period_id and company_id as
  belt-and-braces).

P1 — narrative edits never reached the PDF
- The /bookkeeping/year-end/arsredovisning page let the user edit
  description / important_events / resultatdisposition but the download
  link pointed at a plain GET that regenerated boilerplate. Wired the PDF
  endpoint to accept description / events / disposition as query params
  (length-capped) and the page now constructs the download URL with the
  current narrative state — only fields the user actually changed are
  included, keeping the URL short for the unchanged-defaults case.

Compliance quick wins
- Added period_lock check to gnubok_post_annual_depreciation MCP tool
  (matches the existing accruals POST guard).
- Added explicit fiscal-period ownership pre-check to the signatures POST
  route (RLS would reject anyway; the route layer just makes the 404
  envelope cleaner).
- Replaced free-text role on the signatures schema with an enum allowing
  only Styrelseledamot / Styrelseordförande / VD / Verkställande direktör.
- Added Cache-Control: no-store + Pragma: no-cache to the ÅR PDF response
  so the document (officer names + financials = personal data) isn't
  cached by any intermediary.
- Sanitized period_end in the PDF Content-Disposition header to dodge
  header-injection via stray chars (defensive — period_end is a date, but
  the cost is one regex).
- Softened the iXBRL warning text on the ÅR page: digital filing is
  proposed by Bolagsverket but not yet enacted; PDF is still valid today.

False positives I'm intentionally not chasing on this round
- Greptile P2 `sate` typo in the address city field — the rename would
  touch the type and every consumer; defer.
- Greptile P2 "computation field label" — already addressed as part of
  the P1 vacation fix above.
- Compliance V2.2 "silent skip" in accruals POST — the silent skip is in
  the UI's empty-row filter, not in the server route. Server validation
  already returns 400 via Zod for any invalid item.

Verification
- 154 unit tests pass (was 149; +5 from re-running the MCP strict-schemas
  + output-schema suites that now include the new tools)
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517090000 applied to remote Supabase via MCP

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

* fix(bokslut): address PR #509 round-2 — 3 real bugs + 5-item polish

3 real bugs from the round-1 Swedish review + Compliance Swarm re-eval:

- Soliditet inflated by obeskattade reserver. build-data.ts filtered equity
  with startsWith('20') || startsWith('21') — but 21xx (periodiseringsfonder,
  överavskrivningar) are partially deferred tax, not eget kapital. Splitting
  them out per K2 / ÅRL. Filter is now startsWith('20') only.

- Resultaträkning omitted bokslutsdispositioner + skatt rows. K2 RR must
  include 88xx (dispositioner) and 89xx (skatt) before "Årets resultat" per
  ÅRL 3:2 — without them, the printed RR doesn't reconcile to BS 2099 and
  the document is non-compliant for any AB that posted bolagsskatt or
  periodiseringsfond. flattenIncomeStatement now splits the financial
  sections on title (Bokslutsdispositioner / Skatter och årets resultat)
  and emits the K2-required intermediate subtotals: "Resultat efter
  finansiella poster" → dispositioner → "Resultat före skatt" → skatt →
  "Årets resultat".

- Accruals POST had no idempotency. Re-running the wizard (or a retried
  POST after a flaky network) would create duplicate accrual entries that
  distort both the balance sheet and trial balance. New
  findExistingAccrualEntry helper queries the period for an existing
  posted entry whose description matches the kind's stable prefix (or for
  manual prepaid/accrued, the user-supplied description). Duplicates land
  in a new `skipped` array in the response with a reference to the
  existing entry id, rather than producing a second posting.

5-item compliance polish:

- signer_name on the signatures schema now has .max(200) per GDPR Art.25.2
  data-minimization. Swedish personal names are well under that — the
  bound is a defense against an unbounded-string injection.

- Audit-fee accrual: 6420 is BAS-specific to lagstadgad revision. Bokslut
  fees for a non-revisionspliktigt bolag (liability_account = 2991) now
  debit 6590 (övriga externa tjänster) instead — Skatteverket may query a
  6420 debit when there is no revisor i bolaget.

- Räntefördelning ne_ruta label: was 'R30 / INK1 kapital' (confusing —
  INK1 is a separate form). Positive now reads 'R30 (avdrag i
  näringsverksamhet)' with the INK1 T4 cross-reference moved to the
  description; negative reads 'R30 (tillägg till resultat)'.

- gnubok_post_annual_depreciation MCP tool now checks the caller's
  company_members role and throws on viewer. RLS would reject the
  underlying INSERT anyway; failing fast here produces a cleaner error
  than the cascaded RLS rejection. Mirrors the HTTP route's
  { requireWrite: true } guard.

- Signature DELETE policy now blocks both 'signed' AND 'declined' rows. A
  declined signature is auditable evidence (board member refused) and is
  material under ABL 8 kap. New migration 20260517100000.

Bot-flagged items I'm deliberately not chasing on this round:
- V8.2.1 × 2 cross-tenant findings on ÅR routes — same false-positive
  class I've responded to repeatedly: buildArsredovisningData internally
  filters by company_id, bot can't see past the route handler.
- V4.5 / V2.2 MCP arg Zod redundancy — MCP server's central handler
  validates against each tool's inputSchema.
- Narrative-in-URL GDPR concerns — proper fix is POST + body or
  server-side persistence; tracked as follow-up.
- `sate` typo (Greptile P2) — type-wide rename, deferred.
- Vacation avgifter age-tier rate split — inherited from upstream
  generateVacationLiability which doesn't expose age; needs upstream
  work.
- Medelantal anställda proper monthly average — needs salary-run
  aggregation across the year, follow-up.
- K2 noter aktiekapital + fastställelseintyg blocks — real K2 gaps,
  tracked as follow-up.

Verification
- 94 tests pass (subset for bokslut + MCP suites; full Phase 1-7 suite
  unchanged in scope)
- Zero typecheck errors on any touched file
- Zero new lint errors on any touched file (the 2 server.ts warnings are
  pre-existing)
- Migration 20260517100000 applied to remote Supabase via MCP

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-17 14:50:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 05078c9d8e
commit fd4274787c
32 changed files with 3829 additions and 98 deletions
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest'
import {
proposeAuditFee,
proposeManualPrepaid,
proposeManualAccrued,
} from '../accruals/accrual-detector'
describe('proposeAuditFee', () => {
it('defaults to 2992 (revision)', () => {
const r = proposeAuditFee({ amount: 18_000, closingDate: '2025-12-31' })
expect(r).not.toBeNull()
expect(r!.lines[0].account_number).toBe('6420')
expect(r!.lines[1].account_number).toBe('2992')
expect(r!.reverses_on).toBe('2026-01-01')
})
it('uses 2991 when liabilityAccount=2991 (bokslut)', () => {
const r = proposeAuditFee({
amount: 12_000,
closingDate: '2026-12-31',
liabilityAccount: '2991',
})
expect(r).not.toBeNull()
expect(r!.lines[1].account_number).toBe('2991')
expect(r!.reverses_on).toBe('2027-01-01')
})
it('returns null on zero / negative amount', () => {
expect(proposeAuditFee({ amount: 0, closingDate: '2025-12-31' })).toBeNull()
})
})
describe('proposeManualPrepaid', () => {
it('enforces 17xx prepaid account range', () => {
expect(() =>
proposeManualPrepaid({
amount: 1000,
expenseAccount: '6310',
prepaidAccount: '1810', // not in 17xx
description: 'x',
closingDate: '2025-12-31',
}),
).toThrow(/17xx/)
})
it('emits balanced 17xx / cost-account entry that reverses Jan 1', () => {
const r = proposeManualPrepaid({
amount: 12_000,
expenseAccount: '6310',
prepaidAccount: '1730',
description: 'Försäkring 2026',
closingDate: '2025-12-31',
})
expect(r).not.toBeNull()
expect(r!.lines[0].account_number).toBe('1730')
expect(r!.lines[0].debit_amount).toBe(12_000)
expect(r!.lines[1].account_number).toBe('6310')
expect(r!.lines[1].credit_amount).toBe(12_000)
expect(r!.reverses_on).toBe('2026-01-01')
})
})
describe('proposeManualAccrued', () => {
it('enforces 29xx accrued account range', () => {
expect(() =>
proposeManualAccrued({
amount: 1000,
expenseAccount: '5010',
accruedAccount: '1990', // not in 29xx
description: 'x',
closingDate: '2025-12-31',
}),
).toThrow(/29xx/)
})
it('emits balanced entry that reverses Jan 1', () => {
const r = proposeManualAccrued({
amount: 5_000,
expenseAccount: '5010',
accruedAccount: '2990',
description: 'Hyra dec',
closingDate: '2025-12-31',
})
expect(r).not.toBeNull()
expect(r!.lines[0].account_number).toBe('5010')
expect(r!.lines[1].account_number).toBe('2990')
expect(r!.reverses_on).toBe('2026-01-01')
})
})
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { proposeEfPfondAvsattning, proposeEfPfondAteforing } from '../enskild-firma/periodiseringsfond-ef'
import { calculateExpansionsfondChange } from '../enskild-firma/expansionsfond-calculator'
describe('EF periodiseringsfond — avsättning', () => {
it('caps at 30 % of surplus (vs 25 % for AB)', () => {
const r = proposeEfPfondAvsattning({ surplus: 100_000, fiscalYear: 2026, desiredAmount: 50_000 })
expect(r).not.toBeNull()
// max = 30 000, desired 50 000 → capped to 30 000
expect(r!.amount).toBe(30_000)
expect(r!.warnings[0]).toContain('30')
})
it('returns null on zero / negative surplus', () => {
expect(proposeEfPfondAvsattning({ surplus: 0, fiscalYear: 2026 })).toBeNull()
expect(proposeEfPfondAvsattning({ surplus: -1, fiscalYear: 2026 })).toBeNull()
})
})
describe('EF periodiseringsfond — återföring', () => {
it('forces full reversal of 6+ year old fonder', () => {
const items = proposeEfPfondAteforing({
existingFonder: [{ cohort_year: 2020, balance: 50_000 }],
closingYear: 2026,
})
expect(items).toHaveLength(1)
expect(items[0].amount).toBe(50_000)
expect(items[0].warnings[0]).toContain('6-årsgränsen')
})
it('caps optional returns to the booked balance', () => {
const items = proposeEfPfondAteforing({
existingFonder: [{ cohort_year: 2023, balance: 20_000 }],
closingYear: 2026,
returns: { 2023: 100_000 },
})
expect(items[0].amount).toBe(20_000)
})
})
describe('Expansionsfond', () => {
it('caps avsättning at 125.94 % of kapitalunderlag', () => {
const r = calculateExpansionsfondChange({
kapitalunderlag: 100_000,
existingBalance: 0,
desiredChange: 200_000, // would exceed 125 940 cap
})
expect(r).not.toBeNull()
expect(r!.amount).toBe(125_940)
expect(r!.warnings[0]).toContain('125,94')
})
it('limits återföring to existing balance', () => {
const r = calculateExpansionsfondChange({
kapitalunderlag: 100_000,
existingBalance: 30_000,
desiredChange: -50_000,
})
expect(r).not.toBeNull()
expect(r!.kind).toBe('expansionsfond_ateforing')
expect(r!.amount).toBe(30_000)
})
it('returns null when desiredChange is 0', () => {
expect(
calculateExpansionsfondChange({ kapitalunderlag: 100_000, desiredChange: 0 }),
).toBeNull()
})
})
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { calculateEgenavgifter } from '../enskild-firma/egenavgifter-calculator'
describe('calculateEgenavgifter', () => {
it('full rate: 25 % schablonavdrag on positive surplus', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 200_000,
category: 'full',
})
// No prior-year adjustments → net = 200 000, schablon 25 % = 50 000
expect(r.amount).toBe(50_000)
expect(r.ne_ruta).toBe('R43')
})
it('pensioner: 10 % schablonavdrag', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 200_000,
category: 'pensioner',
})
expect(r.amount).toBe(20_000)
})
it('passive: 20 % schablonavdrag (SLP base)', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 200_000,
category: 'passive',
})
expect(r.amount).toBe(40_000)
})
it('honors prior-year add-back / actual deduct', () => {
// Net surplus = 200 000 + 30 000 (R40) − 25 000 (R41) = 205 000
// Schablon 25 % = 51 250 → floor → 51 250
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 200_000,
category: 'full',
priorYearSchablonavdrag: 30_000,
priorYearActualCharged: 25_000,
})
expect(r.amount).toBe(51_250)
})
it('returns 0 amount on loss year and emits warning', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: -10_000,
category: 'full',
})
expect(r.amount).toBe(0)
expect(r.warnings).toContainEqual(expect.stringMatching(/inget överskott/i))
})
it('surfaces the 7.5 % nedsättning hint for active surplus > 40 000', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 100_000,
category: 'full',
})
expect(r.warnings.some((w) => /7,5/.test(w))).toBe(true)
})
})
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { calculateRantefordelning, NEGATIVE_THRESHOLD } from '../enskild-firma/rantefordelning-calculator'
describe('calculateRantefordelning', () => {
it('positive: SLR+6 pe × kapitalunderlag', () => {
// SLR 2025-11-30 = 2.55 %, +6 = 8.55 %. 1 000 000 × 0.0855 = 85 500
const r = calculateRantefordelning({ kapitalunderlag: 1_000_000 })
expect(r).not.toBeNull()
expect(r!.kind).toBe('rantefordelning_positive')
expect(r!.amount).toBe(85_500)
})
it('returns null between 0 and the -500 000 negative threshold', () => {
expect(calculateRantefordelning({ kapitalunderlag: 0 })).toBeNull()
expect(calculateRantefordelning({ kapitalunderlag: -100_000 })).toBeNull()
expect(calculateRantefordelning({ kapitalunderlag: NEGATIVE_THRESHOLD })).toBeNull()
})
it('negative: SLR+1 pe × |kapitalunderlag| when under -500 000', () => {
// -600 000 < -500 000. 600 000 × (0.0255 + 0.01) = 21 300
const r = calculateRantefordelning({ kapitalunderlag: -600_000 })
expect(r).not.toBeNull()
expect(r!.kind).toBe('rantefordelning_negative')
expect(r!.amount).toBe(21_300)
expect(r!.warnings[0]).toContain('-500 000')
})
it('honors a custom SLR rate', () => {
// For inkomstår 2025: SLR = 1.96 %. Positive at +6 = 7.96 %. On 100 000 = 7 960
const r = calculateRantefordelning({ kapitalunderlag: 100_000, slrRate: 0.0196 })
expect(r).not.toBeNull()
expect(r!.amount).toBe(7_960)
})
})
+314
View File
@@ -0,0 +1,314 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import type { AccrualProposal, AccrualsProposal } from './types'
/** Sociala avgifter on accrued salary/vacation (K3 BFNAR 2012:1 ch.19,
* K2 BFNAR 2016:10 ch.16). Same rate as the regular AGI calculation. */
export const AVGIFTER_RATE_ON_ACCRUED = 0.3142
/**
* Compute the next-day ISO date for auto-reversal. The accrual is reversed
* on the first day of the period following the closing date.
*/
function nextDayIso(closingDate: string): string {
const d = new Date(closingDate + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + 1)
return d.toISOString().slice(0, 10)
}
/**
* Propose an adjustment of semesterlöneskuld (vacation pay liability).
*
* Two correctness rules drive this entry (per BFNAR 2016:10 ch.16 and the
* vacation-liability report):
*
* 1. The delta is anchored against the **current closing balance** of
* 2920 (after any mid-year partial accruals / reversals), not the
* opening balance. Anchoring on opening would over- or understate the
* adjustment by the sum of in-period movements.
*
* 2. Semesterlöneskuld is a balance-sheet carry-forward (2920 / 2940
* persist until the vacation is actually paid). The bokslut delta is
* a normal posting that does NOT reverse on Jan 1 — reversing would
* zero the liability on day 1 of the new year, which is a known
* Swedish bookkeeping error. Hence `reverses_on` is empty for this
* proposal; the wizard / UI suppresses the reversal badge accordingly.
*
* Returns null when delta is zero (no entry to propose).
*/
export async function proposeVacationLiabilityChange(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: { closingDate: string },
): Promise<AccrualProposal | null> {
const closingYear = parseInt(options.closingDate.slice(0, 4), 10)
if (Number.isNaN(closingYear)) {
throw new Error(`Invalid closing date: ${options.closingDate}`)
}
const [report, tb] = await Promise.all([
generateVacationLiability(supabase, companyId, closingYear),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
])
// Current closing balance (what 2920 should be at year-end)
const targetLiability = Math.round(report.totals.accruedAmount)
// Anchor on the CURRENT closing balance, not opening — captures any
// mid-year accruals / reversals that have already touched 2920.
const row2920 = tb.rows.find((r) => r.account_number === '2920')
const currentLiability = row2920
? Math.round((row2920.closing_credit - row2920.closing_debit) * 100) / 100
: 0
const deltaLiability = targetLiability - currentLiability
if (Math.abs(deltaLiability) < 1) {
return null
}
// Round each side to whole krona so the entry stays balanced after
// rounding. Compute avgifter on the same rounded delta.
const deltaInt = Math.round(deltaLiability)
const avgifterDelta = Math.round(deltaInt * AVGIFTER_RATE_ON_ACCRUED)
// Positive delta = more vacation liability accrued → debit 7090 expense.
// Negative delta = vacation taken / liability released → credit 7090.
const isIncrease = deltaInt > 0
const lines = [
{
account_number: '7090',
debit_amount: isIncrease ? Math.abs(deltaInt) : 0,
credit_amount: isIncrease ? 0 : Math.abs(deltaInt),
line_description: 'Förändring av semesterlöneskuld',
},
{
account_number: '2920',
debit_amount: isIncrease ? 0 : Math.abs(deltaInt),
credit_amount: isIncrease ? Math.abs(deltaInt) : 0,
line_description: 'Upplupna semesterlöner',
},
{
account_number: '7519',
debit_amount: isIncrease ? Math.abs(avgifterDelta) : 0,
credit_amount: isIncrease ? 0 : Math.abs(avgifterDelta),
line_description: 'Sociala avgifter på upplupen semester (31,42 %)',
},
{
account_number: '2940',
debit_amount: isIncrease ? 0 : Math.abs(avgifterDelta),
credit_amount: isIncrease ? Math.abs(avgifterDelta) : 0,
line_description: 'Upplupna sociala avgifter på semester',
},
]
const totalAmount = Math.abs(deltaInt) + Math.abs(avgifterDelta)
return {
kind: 'vacation_liability_change',
label: isIncrease
? `Ökning av semesterlöneskuld (${Math.abs(deltaInt)} kr + avgifter)`
: `Minskning av semesterlöneskuld (${Math.abs(deltaInt)} kr + avgifter)`,
description:
'Justering av 2920 mot 7090 plus 31,42 % sociala avgifter på 2940 mot 7519. Saldot på 2920 rullas vidare till nästa år (ingen vändning).',
amount: totalAmount,
lines,
// Empty string = no reversal. UI / commit handler treats this differently
// from the periodisering case (which has a real reverses_on date).
reverses_on: '',
warnings: [],
computation: {
current_2920: currentLiability,
closing_target: targetLiability,
delta: deltaInt,
avgifter_rate: AVGIFTER_RATE_ON_ACCRUED,
avgifter_delta: avgifterDelta,
employee_rows: report.rows.length,
},
}
}
export interface AuditFeeInput {
/** Estimated audit fee for the fiscal year being closed. */
amount: number
/** Closing date — used to derive the reversal date. */
closingDate: string
/** Account to credit on the liability side. Defaults to 2992 (revision),
* use 2991 for bokslut-fee accrual. */
liabilityAccount?: '2991' | '2992'
}
/**
* Propose accrual of audit / bookkeeping fee that will be invoiced after
* year-end. Standard BFL practice: accrue the cost in the period it relates
* to, reverse on Jan 1 when the actual invoice arrives.
*/
export function proposeAuditFee(input: AuditFeeInput): AccrualProposal | null {
const amount = Math.round(input.amount)
if (amount <= 0) return null
const liabilityAccount = input.liabilityAccount ?? '2992'
const isBokslut = liabilityAccount === '2991'
// BAS 2026: 6420 = "Revisionsarvode" (lagstadgad revision specifically).
// Bokslutskostnader for a non-revisionspliktigt bolag belong on 6590
// (övriga externa tjänster) — Skatteverket may query a 6420 debit
// without a corresponding revisor i bolaget.
const expenseAccount = isBokslut ? '6590' : '6420'
return {
kind: 'audit_fee',
label: isBokslut ? 'Beräknat arvode för bokslut' : 'Beräknat arvode för revision',
description: `Debet ${expenseAccount}, kredit ${liabilityAccount}. Vänds vid faktura nästa år.`,
amount,
lines: [
{
account_number: expenseAccount,
debit_amount: amount,
credit_amount: 0,
line_description: isBokslut ? 'Beräknat bokslutarvode' : 'Beräknat revisionsarvode',
},
{
account_number: liabilityAccount,
debit_amount: 0,
credit_amount: amount,
line_description: isBokslut ? 'Beräknat arvode bokslut' : 'Beräknat arvode revision',
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
export interface ManualPrepaidInput {
/** Amount of the cost that relates to NEXT year and should be reclassified
* to a 17xx prepaid account. */
amount: number
/** Cost account being relieved (e.g. 6310 företagsförsäkringar). */
expenseAccount: string
/** Target prepaid account (e.g. 1730 förutbetalda försäkringspremier).
* Must be in the 17xx interimsfordringar range. */
prepaidAccount: string
/** Period this prepaid covers — used in the line description. */
description: string
closingDate: string
}
/**
* Manual prepaid expense reclassification. Debit 17xx, credit the expense
* account by the portion that hasn't been consumed yet.
*
* The caller chooses which expense and prepaid accounts to use because
* heuristic detection from supplier invoices isn't reliable (no service-
* period field on the invoice model — see types/index.ts SupplierInvoice).
* A future heuristic detector can replace this when the data model grows
* service_period_start/_end fields.
*/
export function proposeManualPrepaid(input: ManualPrepaidInput): AccrualProposal | null {
if (!/^17\d{2}$/.test(input.prepaidAccount)) {
throw new Error(`prepaidAccount must be in 17xx range, got ${input.prepaidAccount}`)
}
const amount = Math.round(input.amount)
if (amount <= 0) return null
return {
kind: 'manual_prepaid_expense',
label: `Förutbetald kostnad: ${input.description}`,
description: `Debet ${input.prepaidAccount}, kredit ${input.expenseAccount}. Vänds vid årsskiftet.`,
amount,
lines: [
{
account_number: input.prepaidAccount,
debit_amount: amount,
credit_amount: 0,
line_description: `Förutbetald: ${input.description}`,
},
{
account_number: input.expenseAccount,
debit_amount: 0,
credit_amount: amount,
line_description: `Periodisering ut: ${input.description}`,
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
export interface ManualAccruedInput {
amount: number
/** Cost account being charged (e.g. 5010 hyra lokal). */
expenseAccount: string
/** Target accrued-cost account (e.g. 2990 övriga upplupna kostnader).
* Must be in the 29xx interimsskulder range. */
accruedAccount: string
description: string
closingDate: string
}
/**
* Manual accrued cost. Debit the expense account, credit 29xx for the
* portion incurred but not yet invoiced. Mirrors `proposeManualPrepaid`
* but in the opposite direction.
*/
export function proposeManualAccrued(input: ManualAccruedInput): AccrualProposal | null {
if (!/^29\d{2}$/.test(input.accruedAccount)) {
throw new Error(`accruedAccount must be in 29xx range, got ${input.accruedAccount}`)
}
const amount = Math.round(input.amount)
if (amount <= 0) return null
return {
kind: 'manual_accrued_expense',
label: `Upplupen kostnad: ${input.description}`,
description: `Debet ${input.expenseAccount}, kredit ${input.accruedAccount}. Vänds vid årsskiftet.`,
amount,
lines: [
{
account_number: input.expenseAccount,
debit_amount: amount,
credit_amount: 0,
line_description: `Periodisering in: ${input.description}`,
},
{
account_number: input.accruedAccount,
debit_amount: 0,
credit_amount: amount,
line_description: `Upplupen: ${input.description}`,
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
/**
* Build a snapshot of automatically-detectable accrual proposals for the
* wizard's preflight. Today this is just the vacation-liability delta;
* future versions can add salary-accrued-but-unpaid, supplier-invoice-period
* detection, etc. Manual prepaid/accrued cards are added via the UI form
* (the API endpoint accepts them but they're not in the auto-proposal).
*/
export async function buildAccrualsProposal(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<AccrualsProposal> {
const { data: period, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (error || !period) throw new Error('Fiscal period not found')
const proposals: AccrualProposal[] = []
const vacation = await proposeVacationLiabilityChange(supabase, companyId, fiscalPeriodId, {
closingDate: period.period_end,
})
if (vacation) proposals.push(vacation)
return {
fiscalPeriod: period,
proposals,
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { CreateJournalEntryLineInput } from '@/types'
export type AccrualKind =
| 'vacation_liability_change'
| 'audit_fee'
| 'social_fees_on_accrued_salary'
| 'manual_prepaid_expense'
| 'manual_accrued_expense'
/**
* A single accrual proposal the wizard renders as one card. Mirrors the
* shape of `ProposedDisposition` (lib/bokslut/types.ts) so the wizard UI
* patterns stay consistent across accruals and dispositioner.
*/
export interface AccrualProposal {
kind: AccrualKind
/** Short Swedish label for UI cards. */
label: string
/** One-sentence Swedish explanation. */
description: string
/** SEK amount displayed in the card header. Always positive. */
amount: number
/** Final voucher lines if the user accepts. Already balanced. */
lines: CreateJournalEntryLineInput[]
/** Date the entry should be reversed on (typically Jan 1 of next FY).
* Phase 4 ships this as metadata; the actual auto-reversal cron is
* follow-up infra. UI surfaces the date so users know to reverse manually
* in the meantime. */
reverses_on: string
/** Soft warnings the UI surfaces beside the card. Never blockers. */
warnings: string[]
/** Calculator-specific breakdown for the "Visa beräkning" panel. */
computation?: Record<string, unknown>
}
/**
* Snapshot of accrual proposals for a fiscal period.
*/
export interface AccrualsProposal {
fiscalPeriod: {
id: string
name: string
period_start: string
period_end: string
}
proposals: AccrualProposal[]
}
@@ -0,0 +1,288 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
import type { ArsredovisningData } from './types'
const styles = StyleSheet.create({
page: {
paddingTop: 50,
paddingHorizontal: 50,
paddingBottom: 60,
fontSize: 10,
fontFamily: 'Helvetica',
},
pageHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
fontSize: 8,
color: '#555',
borderBottomWidth: 0.5,
borderBottomColor: '#aaa',
paddingBottom: 6,
},
pageFooter: {
position: 'absolute',
bottom: 30,
left: 50,
right: 50,
fontSize: 8,
color: '#888',
textAlign: 'center',
},
title: {
fontSize: 24,
fontFamily: 'Helvetica-Bold',
marginTop: 40,
marginBottom: 10,
},
subtitle: {
fontSize: 12,
color: '#444',
marginBottom: 50,
},
sectionTitle: {
fontSize: 13,
fontFamily: 'Helvetica-Bold',
marginTop: 20,
marginBottom: 10,
},
paragraph: {
marginBottom: 8,
lineHeight: 1.4,
},
noteBody: {
marginBottom: 4,
lineHeight: 1.4,
},
tableHeader: {
flexDirection: 'row',
fontFamily: 'Helvetica-Bold',
fontSize: 9,
borderBottomWidth: 0.5,
borderBottomColor: '#888',
paddingBottom: 4,
marginBottom: 4,
},
tableRow: {
flexDirection: 'row',
paddingVertical: 2,
},
tableRowTotal: {
flexDirection: 'row',
paddingVertical: 3,
borderTopWidth: 0.5,
borderTopColor: '#888',
fontFamily: 'Helvetica-Bold',
},
colLabel: {
flex: 1,
},
colLabelIndent: {
flex: 1,
paddingLeft: 12,
},
colAmount: {
width: 100,
textAlign: 'right',
},
signatureLine: {
flexDirection: 'row',
marginTop: 30,
alignItems: 'flex-end',
},
signatureSlot: {
flex: 1,
marginRight: 20,
borderBottomWidth: 0.5,
borderBottomColor: '#333',
paddingBottom: 2,
},
})
function fmt(amount: number): string {
// sv-SE thousands grouping, no decimals — typical for K2 ÅR.
return Math.round(amount).toLocaleString('sv-SE')
}
function PageChrome({
data,
pageLabel,
}: {
data: ArsredovisningData
pageLabel?: string
}) {
return (
<>
<View style={styles.pageHeader} fixed>
<Text>
{data.company.name} · {data.company.org_number}
</Text>
<Text>Årsredovisning {data.fiscal_period.name}</Text>
</View>
<Text style={styles.pageFooter} fixed>
{pageLabel ?? ''}
</Text>
</>
)
}
export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
return (
<Document>
{/* Cover */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Försättssida" />
<View>
<Text style={styles.title}>Årsredovisning</Text>
<Text style={styles.subtitle}>
för räkenskapsåret {data.fiscal_period.period_start} — {data.fiscal_period.period_end}
</Text>
<Text style={styles.paragraph}>{data.company.name}</Text>
<Text style={styles.paragraph}>Organisationsnummer: {data.company.org_number}</Text>
{data.company.sate && (
<Text style={styles.paragraph}>Säte: {data.company.sate}</Text>
)}
</View>
</Page>
{/* Förvaltningsberättelse */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Förvaltningsberättelse" />
<Text style={styles.sectionTitle}>Förvaltningsberättelse</Text>
<Text style={styles.sectionTitle}>Verksamhet</Text>
<Text style={styles.paragraph}>{data.forvaltningsberattelse.description}</Text>
<Text style={styles.sectionTitle}>Väsentliga händelser under räkenskapsåret</Text>
<Text style={styles.paragraph}>{data.forvaltningsberattelse.important_events}</Text>
{data.forvaltningsberattelse.kontrollbalans_required && (
<>
<Text style={styles.sectionTitle}>Kontrollbalansräkning</Text>
<Text style={styles.paragraph}>
Kontrollbalansräkning har upprättats under räkenskapsåret enligt ABL 25 kap.
</Text>
</>
)}
<Text style={styles.sectionTitle}>Flerårsöversikt (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>År</Text>
<Text style={styles.colAmount}>Nettoomsättning</Text>
<Text style={styles.colAmount}>Resultat e.fin.poster</Text>
<Text style={styles.colAmount}>Soliditet (%)</Text>
</View>
{data.forvaltningsberattelse.flerarsoversikt.map((row) => (
<View key={row.year} style={styles.tableRow}>
<Text style={styles.colLabel}>{row.year}</Text>
<Text style={styles.colAmount}>{fmt(row.net_revenue)}</Text>
<Text style={styles.colAmount}>{fmt(row.result_after_financial)}</Text>
<Text style={styles.colAmount}>
{row.soliditet_pct === null ? '—' : row.soliditet_pct.toFixed(1)}
</Text>
</View>
))}
<Text style={styles.sectionTitle}>Förändring av eget kapital (kr)</Text>
{data.forvaltningsberattelse.egen_kapital_changes.map((r) => (
<View key={r.label} style={styles.tableRow}>
<Text style={styles.colLabel}>{r.label}</Text>
<Text style={styles.colAmount}>{fmt(r.amount)}</Text>
</View>
))}
<Text style={styles.sectionTitle}>Förslag till resultatdisposition</Text>
<Text style={styles.paragraph}>{data.forvaltningsberattelse.resultatdisposition}</Text>
</Page>
{/* Resultaträkning */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Resultaträkning" />
<Text style={styles.sectionTitle}>Resultaträkning (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>Post</Text>
<Text style={styles.colAmount}>{data.fiscal_period.name}</Text>
</View>
{data.resultatrakning.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={styles.colLabel}>{line.label}</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
</Page>
{/* Balansräkning */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Balansräkning" />
<Text style={styles.sectionTitle}>Tillgångar (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>Post</Text>
<Text style={styles.colAmount}>{data.fiscal_period.period_end}</Text>
</View>
{data.balansrakning.assets.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={line.indent ? styles.colLabelIndent : styles.colLabel}>
{line.label}
</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<View style={styles.tableRowTotal}>
<Text style={styles.colLabel}>Summa tillgångar</Text>
<Text style={styles.colAmount}>{fmt(data.balansrakning.total_assets)}</Text>
</View>
<Text style={styles.sectionTitle}>Eget kapital och skulder (kr)</Text>
{data.balansrakning.equity_liabilities.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={line.indent ? styles.colLabelIndent : styles.colLabel}>
{line.label}
</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<View style={styles.tableRowTotal}>
<Text style={styles.colLabel}>Summa eget kapital och skulder</Text>
<Text style={styles.colAmount}>{fmt(data.balansrakning.total_equity_liabilities)}</Text>
</View>
</Page>
{/* Noter */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Noter" />
<Text style={styles.sectionTitle}>Noter</Text>
{data.noter.map((note) => (
<View key={note.number} style={{ marginBottom: 16 }}>
<Text style={{ fontFamily: 'Helvetica-Bold', marginBottom: 4 }}>
Not {note.number} — {note.title}
</Text>
<Text style={styles.noteBody}>{note.body}</Text>
</View>
))}
</Page>
{/* Underskrifter */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Underskrifter" />
<Text style={styles.sectionTitle}>Underskrifter</Text>
<Text style={styles.paragraph}>
{data.company.sate ? `${data.company.sate}, ` : ''}
{data.fiscal_period.period_end}
</Text>
{(data.signatures.length > 0
? data.signatures
: [
{ role: 'Styrelseledamot', name: '', signed_at: null },
{ role: 'Styrelseledamot', name: '', signed_at: null },
]
).map((sig, i) => (
<View key={i} style={styles.signatureLine}>
<View style={styles.signatureSlot}>
<Text>{sig.name || ' '}</Text>
</View>
<Text style={{ width: 120 }}>{sig.role}</Text>
</View>
))}
</Page>
</Document>
)
}
+404
View File
@@ -0,0 +1,404 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
ArsredovisningData,
EgenKapitalRow,
FlerarsoversiktRow,
IncomeStatementLine,
BalanceSheetLine,
NoteEntry,
} from './types'
import type { BalanceSheetSection, IncomeStatementSection } from '@/types'
/**
* Pre-populate the K2 årsredovisning data for a fiscal period. Loads:
* - Income statement + balance sheet for the current period
* - Up to 3 prior periods for the flerårsöversikt
* - Asset register so noter can list avskrivningstider per category
* - Active employees count for medelantal anställda
* - Equity-account movements for förändring av eget kapital
*
* Manually-authored fields (description, important_events,
* resultatdisposition, ställda säkerheter, eventualförpliktelser) are
* pre-filled with sensible boilerplate the user can replace. The narrative
* editor in the UI persists overrides via /api/.../arsredovisning POST.
*/
export async function buildArsredovisningData(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
overrides: Partial<ArsredovisningData['forvaltningsberattelse']> = {},
): Promise<ArsredovisningData> {
const [periodResult, settingsResult, periodList, incomeStatement, balanceSheet] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, previous_period_id, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('company_name, org_number, address')
.eq('company_id', companyId)
.maybeSingle(),
fetchAllRows(({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.range(from, to),
),
generateIncomeStatement(supabase, companyId, fiscalPeriodId),
generateBalanceSheet(supabase, companyId, fiscalPeriodId),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const settings = settingsResult.data
const companyName = settings?.company_name ?? 'Bolaget'
const orgNumber = settings?.org_number ?? ''
type AddressShape = { city?: string | null; postal_city?: string | null } | null
const addressUnknown = (settings as { address?: AddressShape } | null)?.address ?? null
const sate =
(addressUnknown && (addressUnknown.city ?? addressUnknown.postal_city)) || null
const flerarsoversikt = await buildFlerarsoversikt(
supabase,
companyId,
fiscalPeriodId,
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
)
const egen_kapital_changes = buildEquityChanges(balanceSheet.equity_liability_sections)
const noter = await buildK2Noter(supabase, companyId)
const resultatrakning = flattenIncomeStatement(incomeStatement)
const balansrakning = flattenBalanceSheet(balanceSheet)
return {
company: {
name: companyName,
org_number: orgNumber,
sate,
},
fiscal_period: {
id: period.id,
name: period.name,
period_start: period.period_start,
period_end: period.period_end,
},
forvaltningsberattelse: {
description:
overrides.description ??
`${companyName} bedriver verksamhet enligt verksamhetsbeskrivningen i bolagsordningen.`,
important_events:
overrides.important_events ??
'Inga väsentliga händelser utöver löpande verksamhet har inträffat under räkenskapsåret.',
kontrollbalans_required: overrides.kontrollbalans_required ?? false,
flerarsoversikt,
egen_kapital_changes,
resultatdisposition:
overrides.resultatdisposition ??
'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
},
resultatrakning,
balansrakning,
noter,
signatures: [], // populated by signature-flow service in a later phase step
}
}
interface PeriodRow {
id: string
name: string
period_start: string
period_end: string
}
async function buildFlerarsoversikt(
supabase: SupabaseClient,
companyId: string,
currentPeriodId: string,
allPeriods: PeriodRow[],
): Promise<FlerarsoversiktRow[]> {
// Take the current period + 3 prior (oldest first).
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
const currentIdx = sorted.findIndex((p) => p.id === currentPeriodId)
if (currentIdx === -1) return []
const slice = sorted.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
const rows: FlerarsoversiktRow[] = []
for (const p of slice) {
try {
const [is, tb] = await Promise.all([
generateIncomeStatement(supabase, companyId, p.id),
generateTrialBalance(supabase, companyId, p.id),
])
// Nettoomsättning = sum of revenue sections (revenue is normally credit).
const netRevenue = is.total_revenue
const resultAfterFinancial = is.total_revenue - is.total_expenses + is.total_financial
const totalAssets = tb.rows
.filter((r) => r.account_class === 1)
.reduce((s, r) => s + (r.closing_debit - r.closing_credit), 0)
const eqLiab = tb.rows
.filter((r) => r.account_class === 2)
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
// Soliditet: eget kapital uses 20xx ONLY. 21xx (periodiseringsfonder,
// överavskrivningar) are obeskattade reserver — partially deferred tax,
// not equity. K2 / ÅRL splits them out. Including 21xx here would
// inflate soliditet for any AB that posts dispositions.
const equity = tb.rows
.filter((r) => r.account_number.startsWith('20'))
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
const soliditet =
totalAssets > 0 ? Math.round((equity / totalAssets) * 1000) / 10 : null
// Avoid the unused-variable warning while leaving eqLiab computed for
// future "Skulder" column expansion.
void eqLiab
rows.push({
year: p.name,
net_revenue: Math.round(netRevenue),
result_after_financial: Math.round(resultAfterFinancial),
soliditet_pct: soliditet,
})
} catch {
// Prior periods may lack continuity if SIE import was partial. Skip
// rather than blocking the whole årsredovisning.
rows.push({
year: p.name,
net_revenue: 0,
result_after_financial: 0,
soliditet_pct: null,
})
}
}
return rows
}
function buildEquityChanges(sections: BalanceSheetSection[]): EgenKapitalRow[] {
const equity: EgenKapitalRow[] = []
for (const section of sections) {
for (const row of section.rows) {
if (
row.account_number.startsWith('20') ||
row.account_number.startsWith('21')
) {
equity.push({
label: `${row.account_number} ${row.account_name}`,
amount: row.amount,
})
}
}
}
return equity
}
async function buildK2Noter(
supabase: SupabaseClient,
companyId: string,
): Promise<NoteEntry[]> {
const notes: NoteEntry[] = []
notes.push({
number: 1,
title: 'Redovisnings- och värderingsprinciper',
body:
'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd BFNAR 2016:10 Årsredovisning i mindre företag (K2).',
})
// Avskrivningstider — derive from asset register
const assets = await listAssets(supabase, companyId)
if (assets.length > 0) {
const byCategory = new Map<string, Set<number>>()
for (const a of assets) {
if (a.disposed_at) continue
const years = Math.round(a.useful_life_months / 12)
if (!byCategory.has(a.category)) byCategory.set(a.category, new Set())
byCategory.get(a.category)!.add(years)
}
if (byCategory.size > 0) {
const lines: string[] = ['Avskrivningar görs linjärt över bedömd nyttjandeperiod:']
const categoryLabels: Record<string, string> = {
immaterial: 'Immateriella anläggningstillgångar',
building: 'Byggnader',
land_improvement: 'Markanläggningar',
machinery: 'Maskiner',
equipment: 'Inventarier',
vehicle: 'Fordon',
computer: 'Datorer',
other_tangible: 'Övriga materiella anläggningstillgångar',
}
for (const [cat, yearsSet] of byCategory.entries()) {
const yrs = Array.from(yearsSet).sort((a, b) => a - b)
const yrsLabel = yrs.length === 1 ? `${yrs[0]} år` : `${yrs[0]}–${yrs[yrs.length - 1]} år`
lines.push(`• ${categoryLabels[cat] ?? cat}: ${yrsLabel}`)
}
notes.push({
number: 2,
title: 'Avskrivningar',
body: lines.join('\n'),
})
}
}
// Medelantal anställda — count active employees as a proxy
const { count: employeeCount } = await supabase
.from('employees')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_active', true)
if ((employeeCount ?? 0) > 0) {
notes.push({
number: notes.length + 1,
title: 'Medelantal anställda',
body: `Under räkenskapsåret har medeltalet anställda uppgått till ${employeeCount}.`,
})
}
notes.push({
number: notes.length + 1,
title: 'Ställda säkerheter och eventualförpliktelser',
body: 'Inga.',
})
return notes
}
function flattenIncomeStatement(is: {
revenue_sections: IncomeStatementSection[]
total_revenue: number
expense_sections: IncomeStatementSection[]
total_expenses: number
financial_sections: IncomeStatementSection[]
total_financial: number
net_result: number
}): IncomeStatementLine[] {
const lines: IncomeStatementLine[] = []
for (const s of is.revenue_sections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
lines.push({ label: 'Summa rörelseintäkter', amount: is.total_revenue, is_total: true })
for (const s of is.expense_sections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: -r.amount })
}
}
lines.push({
label: 'Rörelseresultat',
amount: is.total_revenue - is.total_expenses,
is_total: true,
})
// Split financial sections so the RR follows the K2 / ÅRL 3:2 structure:
// financial items (80–87) → "Resultat efter finansiella poster" →
// bokslutsdispositioner (88) → "Resultat före skatt" → skatt (89) →
// "Årets resultat". Without the dispositioner + skatt rows the document
// is non-compliant for any AB that posted bolagsskatt or
// periodiseringsfond, and the RR doesn't reconcile to BS 2099.
const finItems = is.financial_sections.filter(
(s) => !/bokslutsdisposition|skatter och årets resultat/i.test(s.title),
)
const dispositionsSections = is.financial_sections.filter((s) =>
/bokslutsdisposition/i.test(s.title),
)
const skattSections = is.financial_sections.filter((s) =>
/skatter och årets resultat/i.test(s.title),
)
for (const s of finItems) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
const finSubtotal = finItems.reduce((sum, s) => sum + s.subtotal, 0)
const resAfterFinancial = is.total_revenue - is.total_expenses + finSubtotal
lines.push({
label: 'Resultat efter finansiella poster',
amount: Math.round(resAfterFinancial * 100) / 100,
is_total: true,
})
if (dispositionsSections.length > 0) {
for (const s of dispositionsSections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
const dispositionsSubtotal = dispositionsSections.reduce((sum, s) => sum + s.subtotal, 0)
lines.push({
label: 'Resultat före skatt',
amount: Math.round((resAfterFinancial + dispositionsSubtotal) * 100) / 100,
is_total: true,
})
} else {
// No dispositioner posted — keep the simpler "Resultat före skatt" row
// immediately after the finansnetto totals so the RR still has the
// pre-tax subtotal expected by ÅRL.
lines.push({
label: 'Resultat före skatt',
amount: Math.round(resAfterFinancial * 100) / 100,
is_total: true,
})
}
if (skattSections.length > 0) {
for (const s of skattSections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
}
lines.push({ label: 'Årets resultat', amount: is.net_result, is_total: true })
return lines
}
function flattenBalanceSheet(bs: {
asset_sections: BalanceSheetSection[]
total_assets: number
equity_liability_sections: BalanceSheetSection[]
total_equity_liabilities: number
}): {
assets: BalanceSheetLine[]
total_assets: number
equity_liabilities: BalanceSheetLine[]
total_equity_liabilities: number
} {
const assetLines: BalanceSheetLine[] = []
for (const s of bs.asset_sections) {
assetLines.push({ label: s.title, amount: s.subtotal, is_total: true, indent: 0 })
for (const r of s.rows) {
assetLines.push({
label: `${r.account_number} ${r.account_name}`,
amount: r.amount,
indent: 1,
})
}
}
const eqLines: BalanceSheetLine[] = []
for (const s of bs.equity_liability_sections) {
eqLines.push({ label: s.title, amount: s.subtotal, is_total: true, indent: 0 })
for (const r of s.rows) {
eqLines.push({
label: `${r.account_number} ${r.account_name}`,
amount: r.amount,
indent: 1,
})
}
}
return {
assets: assetLines,
total_assets: bs.total_assets,
equity_liabilities: eqLines,
total_equity_liabilities: bs.total_equity_liabilities,
}
}
@@ -0,0 +1,110 @@
import type { SupabaseClient } from '@supabase/supabase-js'
export interface SignatureRequest {
id: string
user_id: string
company_id: string
fiscal_period_id: string
role: string
signer_name: string
status: 'pending' | 'signed' | 'declined'
signed_at: string | null
created_at: string
updated_at: string
}
export interface CreateSignatureRequestInput {
role: string
signer_name: string
}
/**
* List signature requests for a fiscal period's årsredovisning.
*/
export async function listSignatureRequests(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<SignatureRequest[]> {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.select('id, user_id, company_id, fiscal_period_id, role, signer_name, status, signed_at, created_at, updated_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.order('created_at', { ascending: true })
if (error) throw new Error(`Failed to list signature requests: ${error.message}`)
return (data ?? []) as SignatureRequest[]
}
/**
* Create one signature request per styrelseledamot / VD. The BankID call
* itself isn't wired here — that ships in a follow-up that uses
* lib/auth/bankid helpers to sign and write the result back via
* markSignatureSigned(). For now this just records who is supposed to sign.
*/
export async function createSignatureRequest(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
input: CreateSignatureRequestInput,
): Promise<SignatureRequest> {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.insert({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
role: input.role,
signer_name: input.signer_name,
status: 'pending',
})
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to create signature request: ${error?.message ?? 'unknown'}`)
}
return data as SignatureRequest
}
/**
* Mark a request as signed. Used by the (future) BankID completion handler.
* The DB trigger blocks further edits once signed, so this is a one-way
* transition.
*/
export async function markSignatureSigned(
supabase: SupabaseClient,
companyId: string,
requestId: string,
options: { bankidSignatureData?: Record<string, unknown> } = {},
): Promise<SignatureRequest> {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.update({
status: 'signed',
signed_at: new Date().toISOString(),
bankid_signature_data: options.bankidSignatureData ?? null,
})
.eq('id', requestId)
.eq('company_id', companyId)
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to mark signature signed: ${error?.message ?? 'unknown'}`)
}
return data as SignatureRequest
}
/**
* True when every signature request for the period is signed. The UI uses
* this to enable the "Ladda ner fastställd PDF" button and to gate filing.
*/
export async function isFullySignedOff(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<boolean> {
const requests = await listSignatureRequests(supabase, companyId, fiscalPeriodId)
if (requests.length === 0) return false
return requests.every((r) => r.status === 'signed')
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Structured data for a K2 årsredovisning. Generated server-side from
* income statement + balance sheet + asset register + salary data; passed
* to the @react-pdf/renderer template + the in-app preview.
*/
export interface FlerarsoversiktRow {
/** Fiscal-year name (e.g. "2025"). */
year: string
net_revenue: number
result_after_financial: number
/** Soliditet = eget kapital / totala tillgångar, in percent. */
soliditet_pct: number | null
}
export interface EgenKapitalRow {
label: string
/** Single SEK number — positive = credit balance (typical for equity). */
amount: number
}
export interface NoteEntry {
/** Note number per K2 convention (1 = redovisningsprinciper). */
number: number
/** Short Swedish title. */
title: string
/** Note body — supports newlines. Generated from data when possible
* (avskrivningstider from asset register, medelantal from salary),
* manual otherwise. */
body: string
}
export interface IncomeStatementLine {
label: string
amount: number
/** True for total / subtotal lines. */
is_total?: boolean
}
export interface BalanceSheetLine {
label: string
amount: number
is_total?: boolean
/** Indent depth for nested grouping (0 = top, 1 = subgroup). */
indent?: number
}
export interface ArsredovisningData {
company: {
name: string
org_number: string
sate: string | null
}
fiscal_period: {
id: string
name: string
period_start: string
period_end: string
}
forvaltningsberattelse: {
/** Beskrivning av verksamheten (företaget kan editera). */
description: string
/** Viktiga händelser (företaget kan editera). */
important_events: string
/** Har kontrollbalansräkning upprättats? */
kontrollbalans_required: boolean
flerarsoversikt: FlerarsoversiktRow[]
/** Förändring av eget kapital. */
egen_kapital_changes: EgenKapitalRow[]
/** Styrelsens förslag till resultatdisposition (manual input). */
resultatdisposition: string
}
resultatrakning: IncomeStatementLine[]
balansrakning: {
assets: BalanceSheetLine[]
total_assets: number
equity_liabilities: BalanceSheetLine[]
total_equity_liabilities: number
}
noter: NoteEntry[]
/** Underskrifter — names of board members + VD. Filled by signature flow. */
signatures: {
role: string
name: string
signed_at: string | null
}[]
}
@@ -0,0 +1,90 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { calculateBolagsskatt } from './tax-provision/bolagsskatt-calculator'
import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator'
import {
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
} from './reserves/periodiseringsfond-service'
import type { DispositionsProposal, ProposedDisposition } from './types'
const DEFAULT_SCHABLONINTAKT_RATE = 0.0355
/**
* Shared core of the GET /bokslutsdispositioner endpoint, lifted out so the
* MCP tool can call the same builder without duplicating the proposal logic.
* The API route and the MCP tool both hand its output to the caller, who
* picks which proposals to commit via the POST endpoint.
*/
export async function buildDispositionsProposal(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<DispositionsProposal> {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = (settings?.entity_type ?? 'aktiebolag') as DispositionsProposal['entityType']
if (entityType !== 'aktiebolag') {
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
return {
entityType,
fiscalPeriod: period,
netResultBefore: incomeStatement.net_result,
proposals: [],
}
}
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const resultBeforeTax = incomeStatement.net_result
const proposals: ProposedDisposition[] = []
const existingFonder = await listExistingPeriodiseringsfonder(supabase, companyId, period.period_end)
const ateforing = proposeAteforing(existingFonder, {
schablonintaktRate: DEFAULT_SCHABLONINTAKT_RATE,
})
proposals.push(...ateforing.proposals)
const taxableBeforeAvsattning =
resultBeforeTax +
ateforing.proposals.reduce((sum, p) => sum + p.amount, 0) +
ateforing.schablonintaktAmount
const avsattning = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
fiscalYear,
})
if (avsattning) proposals.push(avsattning)
const slp = await calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId)
if (slp) proposals.push(slp)
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
manualAdjustments: {
schablonintaktPeriodiseringsfond: ateforing.schablonintaktAmount,
},
})
if (bolagsskatt) proposals.push(bolagsskatt)
return {
entityType,
fiscalPeriod: period,
netResultBefore: resultBeforeTax,
proposals,
}
}
@@ -0,0 +1,89 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { calculateEgenavgifter, type EgenavgiftCategory } from './egenavgifter-calculator'
import { calculateRantefordelning } from './rantefordelning-calculator'
import { proposeEfPfondAvsattning } from './periodiseringsfond-ef'
import { calculateExpansionsfondChange } from './expansionsfond-calculator'
import type { EfDeclarationItem } from './types'
export interface EfDeclarationPreviewInput {
category?: EgenavgiftCategory
kapitalunderlag?: number
priorYearSchablonavdrag?: number
priorYearActualCharged?: number
pfondDesiredAmount?: number
expansionsfondExistingBalance?: number
expansionsfondDesiredChange?: number
}
export interface EfDeclarationPreview {
fiscalPeriod: {
id: string
name: string
period_start: string
period_end: string
}
bookedSurplus: number
items: EfDeclarationItem[]
}
/**
* Server-side mirror of the EfDeclarationSection client logic. The MCP tool
* (Phase 7) calls this so agents can preview the same numbers without
* round-tripping through the browser. Inputs default to "no adjustment"
* which produces just the egenavgifter line.
*/
export async function computeEfDeclarationPreview(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
input: EfDeclarationPreviewInput = {},
): Promise<EfDeclarationPreview> {
const { data: period, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (error || !period) throw new Error('Fiscal period not found')
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const bookedSurplus = incomeStatement.net_result
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const items: EfDeclarationItem[] = []
const eg = calculateEgenavgifter({
surplusBeforeEgenavgifter: bookedSurplus,
category: input.category,
priorYearSchablonavdrag: input.priorYearSchablonavdrag,
priorYearActualCharged: input.priorYearActualCharged,
})
items.push(eg)
const r = calculateRantefordelning({ kapitalunderlag: input.kapitalunderlag ?? 0 })
if (r) items.push(r)
const surplusAfterEg = bookedSurplus - eg.amount
const pfond = proposeEfPfondAvsattning({
surplus: surplusAfterEg,
fiscalYear,
desiredAmount: input.pfondDesiredAmount,
})
if (pfond) items.push(pfond)
if (input.expansionsfondDesiredChange && input.expansionsfondDesiredChange !== 0) {
const exp = calculateExpansionsfondChange({
kapitalunderlag: input.kapitalunderlag ?? 0,
existingBalance: input.expansionsfondExistingBalance,
desiredChange: input.expansionsfondDesiredChange,
})
if (exp) items.push(exp)
}
return {
fiscalPeriod: period,
bookedSurplus,
items,
}
}
@@ -0,0 +1,101 @@
import type { EfDeclarationItem } from './types'
/** Full egenavgifter rate (born 1959 or later, active business, 7 karensdagar). */
export const EGENAVGIFTER_FULL = 0.2897
/** Reduced rate for pensioners born 1938-1958. */
export const EGENAVGIFTER_PENSIONER = 0.1021
/** Passive business uses SLP rate instead. */
export const SLP_FOR_PASSIVE = 0.2426
/** Schablonavdrag percentages (R43 in NE-bilaga). */
export const SCHABLONAVDRAG_FULL = 0.25
export const SCHABLONAVDRAG_PENSIONER = 0.10
export const SCHABLONAVDRAG_PASSIVE = 0.20
export type EgenavgiftCategory = 'full' | 'pensioner' | 'passive'
export interface EgenavgifterInput {
/** Överskott före egenavgifter, från NE-bilaga (R41 sum-up basis). */
surplusBeforeEgenavgifter: number
/** Vilken kategori. Defaults to 'full'. */
category?: EgenavgiftCategory
/** Föregående års schablonavdrag — läggs tillbaka i R40. Defaults to 0. */
priorYearSchablonavdrag?: number
/** Föregående års faktiska egenavgifter — dras av i R41. Defaults to 0. */
priorYearActualCharged?: number
}
export interface EgenavgifterComputation {
category: EgenavgiftCategory
surplusBeforeEgenavgifter: number
priorYearSchablonavdrag: number
priorYearActualCharged: number
/** R40 + (-R41) net: surplusBeforeEgenavgifter + priorYearSchablonavdrag - priorYearActualCharged. */
netSurplusForSchablon: number
schablonavdragRate: number
/** R43: schablonavdrag applied to net surplus. */
schablonavdrag: number
egenavgifterRate: number
/** Estimated egenavgifter for the year — for planning only; the exact
* amount is set by Skatteverket. */
estimatedEgenavgifter: number
}
/**
* Compute the NE-bilaga R40-R43 series for egenavgifter.
*
* NEVER produces a journal entry — egenavgifter for enskild firma are paid
* personally by the owner via Inkomstdeklaration 1, not by the business.
*/
export function calculateEgenavgifter(input: EgenavgifterInput): EfDeclarationItem {
const category = input.category ?? 'full'
const r1 = (x: number) => Math.round(x * 100) / 100
const ratesByCategory = {
full: { schablon: SCHABLONAVDRAG_FULL, avgifter: EGENAVGIFTER_FULL },
pensioner: { schablon: SCHABLONAVDRAG_PENSIONER, avgifter: EGENAVGIFTER_PENSIONER },
passive: { schablon: SCHABLONAVDRAG_PASSIVE, avgifter: SLP_FOR_PASSIVE },
} as const
const rates = ratesByCategory[category]
const priorSchablon = input.priorYearSchablonavdrag ?? 0
const priorActual = input.priorYearActualCharged ?? 0
const netSurplus = Math.max(
0,
input.surplusBeforeEgenavgifter + priorSchablon - priorActual,
)
const schablonavdrag = Math.floor(netSurplus * rates.schablon)
const estimatedEgenavgifter = Math.round(netSurplus * rates.avgifter)
const warnings: string[] = []
if (category === 'full' && netSurplus > 40_000) {
warnings.push(
'För aktiv näringsverksamhet med överskott > 40 000 kr ges en automatisk nedsättning av egenavgifterna (7,5 %, max 15 000 kr/år) av Skatteverket.',
)
}
if (input.surplusBeforeEgenavgifter <= 0) {
warnings.push('Inget överskott att beräkna egenavgifter på.')
}
const computation: EgenavgifterComputation = {
category,
surplusBeforeEgenavgifter: r1(input.surplusBeforeEgenavgifter),
priorYearSchablonavdrag: r1(priorSchablon),
priorYearActualCharged: r1(priorActual),
netSurplusForSchablon: netSurplus,
schablonavdragRate: rates.schablon,
schablonavdrag,
egenavgifterRate: rates.avgifter,
estimatedEgenavgifter,
}
return {
kind: 'egenavgifter',
label: 'Egenavgifter — schablonavdrag',
description: `Schablonavdrag ${(rates.schablon * 100).toFixed(0)} % av nettoöverskott. Faktiska avgifter beräknas av Skatteverket.`,
amount: schablonavdrag,
ne_ruta: 'R43',
computation: computation as unknown as Record<string, unknown>,
warnings,
}
}
@@ -0,0 +1,99 @@
import type { EfDeclarationItem } from './types'
/** Expansionsfondsskatt 20.6 % (samma som bolagsskatten). */
export const EXPANSIONSFOND_TAX_RATE = 0.206
/** Max avsättning = 125,94 % av kapitalunderlag (IL 34 kap). */
export const EXPANSIONSFOND_MAX_OF_KAPITALUNDERLAG = 1.2594
export interface ExpansionsfondInput {
/** Kapitalunderlag vid årets slut (samma underlag som för positiv
* räntefördelning, IL 34 kap 6 §). */
kapitalunderlag: number
/** Tidigare kvarstående avsättning till expansionsfond — utgör utgångsläget
* för årets bedömning. */
existingBalance?: number
/** Önskad ändring (positivt = avsättning, negativt = återföring).
* Defaults to 0 (no change). */
desiredChange?: number
}
export interface ExpansionsfondComputation {
kapitalunderlag: number
maxTotalBalance: number
existingBalance: number
desiredChange: number
/** Begränsat ändringsbelopp efter takkontroll. */
actualChange: number
newBalance: number
/** 20.6 % skatt på årets nettoökning. Tillgodoräknas vid framtida återföring. */
taxOnChange: number
}
/**
* Compute the change in expansionsfond.
*
* Mechanism: en enskild näringsidkare betalar 20,6 % expansionsfondsskatt på
* avsatt belopp ENA året. När fonden återförs blir beloppet inkomst av
* näringsverksamhet samma år som återföringen — men de 20,6 % redan betalat
* tillgodoräknas mot årets skatt. Tax-only mechanism, NEVER booked.
*/
export function calculateExpansionsfondChange(
input: ExpansionsfondInput,
): EfDeclarationItem | null {
const maxTotalBalance = Math.floor(
Math.max(0, input.kapitalunderlag) * EXPANSIONSFOND_MAX_OF_KAPITALUNDERLAG,
)
const existingBalance = Math.max(0, Math.floor(input.existingBalance ?? 0))
const desiredChange = Math.round(input.desiredChange ?? 0)
if (desiredChange === 0) return null
let actualChange = desiredChange
// Cap avsättning so the new total doesn't exceed the kapitalunderlag-based cap.
if (desiredChange > 0) {
const room = Math.max(0, maxTotalBalance - existingBalance)
actualChange = Math.min(desiredChange, room)
} else {
// Återföring can't go below zero.
actualChange = -Math.min(Math.abs(desiredChange), existingBalance)
}
const newBalance = existingBalance + actualChange
const taxOnChange = Math.round(actualChange * EXPANSIONSFOND_TAX_RATE)
const warnings: string[] = []
if (desiredChange > 0 && actualChange < desiredChange) {
warnings.push(
`Begärt belopp (${desiredChange} kr) översteg taket på 125,94 % av kapitalunderlaget. Avsättningen begränsades till ${actualChange} kr.`,
)
}
if (desiredChange < 0 && Math.abs(actualChange) < Math.abs(desiredChange)) {
warnings.push(
`Återföringen begränsades till befintligt saldo (${existingBalance} kr).`,
)
}
const computation: ExpansionsfondComputation = {
kapitalunderlag: input.kapitalunderlag,
maxTotalBalance,
existingBalance,
desiredChange,
actualChange,
newBalance,
taxOnChange,
}
return {
kind: actualChange > 0 ? 'expansionsfond_avsattning' : 'expansionsfond_ateforing',
label:
actualChange > 0
? 'Expansionsfond — avsättning'
: 'Expansionsfond — återföring',
description:
actualChange > 0
? `Avsättning ${actualChange} kr. Skatt 20,6 % (${Math.abs(taxOnChange)} kr) betalas i år.`
: `Återföring ${Math.abs(actualChange)} kr. Tidigare betald skatt (${Math.abs(taxOnChange)} kr) tillgodoräknas.`,
amount: Math.abs(actualChange),
ne_ruta: actualChange > 0 ? 'R34' : 'R33',
computation: computation as unknown as Record<string, unknown>,
warnings,
}
}
@@ -0,0 +1,91 @@
import type { EfDeclarationItem } from './types'
/** EF får sätta av max 30 % av överskott (vs 25 % för AB). */
export const PFOND_EF_RATE = 0.30
/** Same 6-year mandatory reversal as AB (IL 30 kap 7 §). */
export const PFOND_EF_MAX_HOLD_YEARS = 6
export interface EfPfondAvsattningInput {
/** Skattemässigt överskott efter alla andra justeringar. */
surplus: number
/** Året då avsättningen görs. */
fiscalYear: number
/** Önskat belopp; defaults to maximum. */
desiredAmount?: number
}
export function proposeEfPfondAvsattning(input: EfPfondAvsattningInput): EfDeclarationItem | null {
const base = Math.max(0, Math.floor(input.surplus))
const max = Math.floor(base * PFOND_EF_RATE)
const desired = Math.max(0, Math.floor(input.desiredAmount ?? max))
const amount = Math.min(desired, max)
if (amount === 0) return null
const warnings: string[] = []
if (desired > max) {
warnings.push(
`Begärt belopp (${desired} kr) översteg 30 %-taket. Avsättningen begränsades till ${max} kr.`,
)
}
return {
kind: 'periodiseringsfond_avsattning',
label: `Periodiseringsfond ${input.fiscalYear} — avsättning`,
description: `Max 30 % av skattemässigt överskott. Sätts av i NE-bilaga R30 (uppskjuten skatt). Bokförs inte.`,
amount,
ne_ruta: 'R30',
computation: {
surplus: input.surplus,
rate: PFOND_EF_RATE,
maxAmount: max,
desiredAmount: desired,
actualAmount: amount,
fiscalYear: input.fiscalYear,
},
warnings,
}
}
export interface EfExistingFond {
/** Vilket år fonden avsattes. */
cohort_year: number
/** Aktuellt saldo (positivt). */
balance: number
}
export interface EfPfondAteforingInput {
existingFonder: EfExistingFond[]
closingYear: number
/** Per-cohort återföringsbelopp (kan vara delar av saldot). Mandatory
* cohorts återförs alltid till fullo. */
returns?: Record<number, number>
}
export function proposeEfPfondAteforing(
input: EfPfondAteforingInput,
): EfDeclarationItem[] {
const items: EfDeclarationItem[] = []
for (const fond of input.existingFonder) {
const isMandatory = fond.cohort_year + PFOND_EF_MAX_HOLD_YEARS <= input.closingYear
const requested = input.returns?.[fond.cohort_year] ?? 0
const amount = isMandatory ? fond.balance : Math.min(Math.max(0, requested), fond.balance)
if (amount === 0) continue
items.push({
kind: 'periodiseringsfond_ateforing',
label: `Periodiseringsfond ${fond.cohort_year} — återföring`,
description: 'Återförs i NE-bilaga R29.',
amount,
ne_ruta: 'R29',
computation: {
cohort_year: fond.cohort_year,
opening_balance: fond.balance,
return_amount: amount,
was_mandatory: isMandatory,
},
warnings: isMandatory
? [`Periodiseringsfond ${fond.cohort_year} har nått 6-årsgränsen och måste återföras.`]
: [],
})
}
return items
}
@@ -0,0 +1,100 @@
import type { EfDeclarationItem } from './types'
/** Räntefördelning rates per IL 33 kap. Statslåneräntan from 30 november
* året före, plus tilläggspoäng. SLR for 2026 = 2.55 %. */
export const POSITIVE_RANTEFORDELNING_ADD = 0.06 // SLR + 6 pe → 8.55 % för 2026
export const NEGATIVE_RANTEFORDELNING_ADD = 0.01 // SLR + 1 pe → 3.55 % för 2026
/** Negativ räntefördelning triggas vid kapitalunderlag mer negativt än
* -500 000 kr (IL 33 kap 4 §). */
export const NEGATIVE_THRESHOLD = -500_000
export interface RantefordelningInput {
/** Justerat eget kapital i näringsverksamheten vid föregående års utgång.
* Positivt = kapitalöverskott (positiv räntefördelning möjlig); negativt
* = kapitalunderskott (negativ räntefördelning obligatorisk > -500 000). */
kapitalunderlag: number
/** SLR 30 november föregående år. Default = 0.0255 (för inkomstår 2026,
* SLR 2025-11-30 = 2,55 %). Override per år tills Riksbanken-integrationen
* ligger på plats. */
slrRate?: number
}
export interface RantefordelningComputation {
kapitalunderlag: number
slrRate: number
positiveRate: number
positiveBase: number
/** Maximalt belopp att räntefördela till kapital. Voluntarily. */
positiveAmount: number
negativeRate: number
negativeBase: number
/** Obligatorisk negativ räntefördelning (> -500 000). */
negativeAmount: number
}
/**
* Compute räntefördelning for an enskild firma. Returns at most one of
* positive or negative — the two are mutually exclusive based on sign of
* kapitalunderlag.
*
* NEVER produces a journal entry — räntefördelning is a tax-only mechanism
* (Inkomstdeklaration 1, kapitalinkomst-fältet).
*/
export function calculateRantefordelning(
input: RantefordelningInput,
): EfDeclarationItem | null {
const slrRate = input.slrRate ?? 0.0255
const positiveRate = Math.max(0.005, slrRate + POSITIVE_RANTEFORDELNING_ADD)
const negativeRate = Math.max(0.005, slrRate + NEGATIVE_RANTEFORDELNING_ADD)
const isPositive = input.kapitalunderlag > 0
const isNegativeMandatory = input.kapitalunderlag < NEGATIVE_THRESHOLD
if (!isPositive && !isNegativeMandatory) {
return null
}
// Use Math.round (not floor/ceil) to absorb IEEE 754 representation error:
// 1 000 000 × (0.0255 + 0.06) evaluates to 85499.99999... in JS, and floor()
// would shave a krona off the user's deduction for a non-economic reason.
const computation: RantefordelningComputation = {
kapitalunderlag: input.kapitalunderlag,
slrRate,
positiveRate,
positiveBase: isPositive ? input.kapitalunderlag : 0,
positiveAmount: isPositive ? Math.round(input.kapitalunderlag * positiveRate) : 0,
negativeRate,
negativeBase: isNegativeMandatory ? Math.abs(input.kapitalunderlag) : 0,
negativeAmount: isNegativeMandatory
? Math.round(Math.abs(input.kapitalunderlag) * negativeRate)
: 0,
}
if (isPositive) {
return {
kind: 'rantefordelning_positive',
label: 'Positiv räntefördelning (frivillig)',
description: `${(positiveRate * 100).toFixed(2)} % på kapitalunderlag ${input.kapitalunderlag.toLocaleString('sv-SE')} kr. Avdrag i NE R30; motsvarande belopp redovisas som inkomst av kapital på Inkomstdeklaration 1 (T4).`,
amount: computation.positiveAmount,
// NE-bilagan har bara fältet R30 — INK1 är ett separat formulär. Tidigare
// sammanslagningen 'R30 / INK1 kapital' fick användare att leta efter ett
// ruta-namn som inte finns på NE.
ne_ruta: 'R30 (avdrag i näringsverksamhet)',
computation: computation as unknown as Record<string, unknown>,
warnings: [],
}
}
return {
kind: 'rantefordelning_negative',
label: 'Negativ räntefördelning (obligatorisk)',
description: `${(negativeRate * 100).toFixed(2)} % på kapitalunderskott. Tillägg till resultat i näringsverksamhet eftersom verksamheten lånat av privata medel.`,
amount: computation.negativeAmount,
ne_ruta: 'R30 (tillägg till resultat)',
computation: computation as unknown as Record<string, unknown>,
warnings: [
'Negativ räntefördelning är obligatorisk när kapitalunderlaget är mer negativt än -500 000 kr.',
],
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* EF tax mechanisms are declaration-only — they NEVER produce journal
* entries. The shapes here are intentionally distinct from the AB
* `ProposedDisposition` to make the booking distinction visible at the
* type level: every EF calculator returns an `EfDeclarationItem` and the
* UI / NE-bilaga consumes them, but the bokkeeping engine never sees them.
*/
export type EfDeclarationKind =
| 'egenavgifter'
| 'rantefordelning_positive'
| 'rantefordelning_negative'
| 'periodiseringsfond_avsattning'
| 'periodiseringsfond_ateforing'
| 'expansionsfond_avsattning'
| 'expansionsfond_ateforing'
export interface EfDeclarationItem {
kind: EfDeclarationKind
/** Short Swedish label for UI cards. */
label: string
/** One-sentence Swedish explanation. */
description: string
/** SEK amount displayed. Always positive. */
amount: number
/** NE-bilaga ruta this affects (e.g. "R30", "R34", "R43"). Surfaced so
* users know where the number lands when they file. */
ne_ruta: string
/** Calculator-specific breakdown for the "Visa beräkning" panel. */
computation: Record<string, unknown>
/** Soft warnings. Never blockers. */
warnings: string[]
}