Fix/fable design (#1063)

* fix(bokslut): make dispositions storno-safe and derive fond math from opening balances

A reversed year_end voucher kept its storno in the income statement while
the original was excluded (source_type asymmetry), inflating resultat fore
dispositioner by exactly the reversed amount, and the posted-only fond
balance produced a phantom negative 212X that leaked a bogus aterforing
proposal. Support case: a user double-booked periodiseringsfond, reversed
both correctly, and the dispositions page still showed wrong numbers.

- trial-balance excludeYearEndClosing now also excludes entries chained to
  reversed year_end entries via reverses_id/correction_of_id (grammar
  verified against staging PostgREST)
- listExistingPeriodiseringsfonder counts posted+reversed so storno pairs
  cancel, and returns opening balances per fond
- schablonintakt per IL 30 kap 6a: opening balance base, rate = SLR per
  closing year (1.96% FY2025, 2.55% FY2026), replacing the wrong SLR+1pp
  0.0355 constant
- avsattning 25% cap is year-total: already-provisioned current-cohort
  growth consumes headroom in both preview and commit, so re-running the
  flow can no longer double-book the fond
- SLP posts before avsattning (deductible, shrinks the cap base) and is
  posted-aware: no double proposal or double count on resumed runs
- sumPostedYearEndDispositions counts correction replacements of reversed
  year_end entries and exposes the SLP portion

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

* refactor(bokslut): use roundOre for new fond/disposition rounding

Satisfies the naive-ore-round ratchet that tightened on main; identical
arithmetic, pinned by the existing exact-value tests.

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

* fix(bokslut): address PR #1063 review findings

- computeProposal receives the already-validated period row: a transient
  DB failure can no longer silently skip a requested disposition (and two
  redundant per-item period fetches are gone)
- getSchablonintaktRate fails closed for unmapped years instead of
  falling back to the latest known rate: statutory rates are never
  guessed; POST rate override remains the escape hatch
- listExistingPeriodiseringsfonder is opening-balance-entry aware:
  a fond carried via the OB entry booked by year-end closing was counted
  twice (once from history, once from the OB entry); balances now derive
  from OB + current-period activity when an OB entry exists
- periodStart is validated as a real calendar date, not just a shape
- reversed year_end correction targets resolve company-wide in
  sumPostedYearEndDispositions, matching the trial balance exclusion

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-17 18:06:41 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 425674ff35
commit 46b8e2bfea
9 changed files with 772 additions and 126 deletions
+4
View File
@@ -206,6 +206,10 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-15] Issue #735 result-appropriation: did NOT run the mass 2099-to-2098 backfill. Prod audit showed the script read the FROZEN opening-balance 2099, not the CURRENT posted balance: 202 of 360 planned periods already carried their own disposition (mostly via SIE import) and 43 would have double-moved equity (worst case tens of MSEK on a single company), while periods without an OB entry fabricated balances via the cumulative-history fallback. Posted ONE verified entry for the reporting company (unblocking their arsredovisning) and rewrote the script to a current-balance-safe backfill: eligibility decided from current posted 2099, explicit OB entry required, already-disposed and ambiguous periods skipped to a manual-review report. planResultAppropriation/generateResultAppropriation untouched (steady-state year-end path, covered by pg-real tests); the safety lives in the sweep.
[2026-07-17] create_account now enforces first-digit vs account_type consistency (superRefine at the commit boundary + fail-fast in the MCP tool): class 8 legitimately allows both revenue and expense (financial items per the BAS catalog), classes 0/9 stay unconstrained (free-use per the BAS standard); prevents contradictory rows like 2999+expense whose derived account_class would misclassify balance sheet vs income statement. PR-review findings on set_voucher_note (posted-entry immutability, BFL 5:5 audit trail) were refuted, not fixed: the notes-only carve-out is migration 20260608120000's whole-row to_jsonb diff and audit_journal_entries already records old/new on every UPDATE.
[2026-07-17] Assistenten settings opens on Kunskap (konteringskarta) instead of Minne; dropped the nested Kompetens/Minne tab row inside Kunskap: it duplicated the top-level tabs one row above.
[2026-07-17] Support case (double-booked periodiseringsfond, reversed, but dispositions page inflated by exactly the reversed sum): the storno of a year_end entry carries source_type='storno', so excludeYearEndClosing dropped the reversed original while keeping its counter-entry. Fixed by chain exclusion (reverses_id/correction_of_id pointing at a reversed year_end entry), depth 1 only: correction-of-correction chains on year_end entries are not followed (rare, and pre-fix behavior was worse). PostgREST or=(col.is.null,col.not.in.(...)) grammar verified against staging, not just unit-mocked. No prod data repair needed: the user's own stornos had already netted the ledger to zero.
[2026-07-17] Periodiseringsfond schablonintakt + avsattning headroom now derive from 212X OPENING vs closing balances (IL 30 kap 6a: fond at beskattningsarets ingang) instead of cohort-year heuristics: one mechanism covers storno pairs, fonds fully aterforda during the year, shortened brutet rakenskapsar sharing a cohort account, and the 2129/2019 decade wrap. The 6-year forced-return flag stays cohort-based (2129 ambiguity accepted until FY2029 closings). Chose opening-balance snapshot over a fonds registry table: the ledger already carries the truth.
[2026-07-17] DEFAULT_SCHABLONINTAKT_RATE 0.0355 was SLR+1pp, which is the negativ rantefordelning formula, not IL 30 kap 6a (rate = SLR itself, floor 0.5%): replaced with a per-closing-year map (2025: 1.96%, 2026: 2.55%) per the swedish-year-end-closing reference. Unmapped years FAIL CLOSED (getSchablonintaktRate throws): a statutory rate is never guessed; the table is a mandatory annual update each December, and POST callers can override the rate per request as the escape hatch (CodeRabbit review on PR #1063 overturned the earlier latest-known fallback).
[2026-07-17] DISPOSITION_ORDER: SLP moved before avsattning (deductible SLP shrinks the 25% cap base per IL 30 kap 5); SLP commit + proposal are now posted-aware (skip when a 7533 year_end entry exists) closing the same double-booking hole the fond had. Duplicate avsattning POST with an explicit desiredAmount still books the remaining headroom (cap-safe, not intent-idempotent); declined request-idempotency keys as out of scope.
[2026-07-17] getActiveCompanyId now throws CompanyContextError('resolution_failed') on query failure instead of returning null (issue #1053): null was indistinguishable from "no companies" and every caller redirects that state to /onboarding, so a transient DB failure showed onboarded users the wizard. Chose throw-at-the-source over a degraded-flag return so all redirect sites are fixed at once; withRouteContext already try/catches the call. The Edge middleware copy keeps a degraded flag instead (middleware cannot throw usefully) and fails open.
[2026-07-17] Amount-less invoice rows (quantity 0 and unit price 0) render as text rows on PDF/detail/review via shared isTextLikeLine() instead of printing "0 / 0,00 SEK / 0,00 SEK" (issue #1053): users write free-text lines through the article picker's "Egen rad (fri text)" product row, not only the dedicated textrad button. Display-only; booking and validation semantics untouched.
[2026-07-17] Articles default sort is article_number (numeric-aware via Intl.Collator numeric, unnumbered last, name tiebreak) in both the register and the invoice editor picker, replacing name order (issue #1053): users number articles precisely to control listing order, matching Fortnox convention.
@@ -10,6 +10,8 @@ import {
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import { calculateSarskildLoneskatt } from '@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator'
import {
getPeriodiseringsfondCohortAccount,
getSchablonintaktRate,
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
@@ -24,32 +26,26 @@ import type { ProposedDisposition } from '@/lib/bokslut/types'
import type { JournalEntry } from '@/types'
/**
* Default schablonintäkt rate on periodiseringsfond (IL 30 kap 6a §).
* Statslåneräntan 30 november föregående år + 1 procentenhet, lägst 0.5 %.
* The schablonintäkt rate (IL 30 kap 6a §) defaults per fiscal year via
* getSchablonintaktRate (statslåneräntan 30 nov året före det kalenderår
* beskattningsåret går ut, lägst 0.5 %). Caller can override per request
* via `schablonintaktRate` in the POST body; a future Riksbanken
* integration will fetch the rate automatically.
*
* - inkomstår 2025: SLR 2024-11-30 = 1.96 % → 2.96 % (rounded to 3 %)
* - inkomstår 2026: SLR 2025-11-30 = 2.55 % → 3.55 %
*
* The default below is the FY2026 rate since that is the year customers are
* currently closing. Caller can override per request via `schablonintaktRate`
* in the POST body; a future Riksbanken integration will fetch the rate by
* the fiscal year automatically.
*/
const DEFAULT_SCHABLONINTAKT_RATE = 0.0355
/**
* Canonical bokslut order. Each calculator re-reads the trial balance to
* derive its base, so earlier items must post before later items see their
* effect: återföring → överavskrivningar → avsättning → SLP → bolagsskatt.
* The POST handler enforces this order regardless of how the client sends
* its items array, so the avsättning 25 % cap can never be evaluated
* against a stale (pre-återföring) net result.
* effect: återföring → överavskrivningar → SLP → avsättning → bolagsskatt.
* SLP posts before avsättning because it is deductible: the 25 % avsättning
* cap applies to the result AFTER the SLP cost (IL 30 kap 5 §). The POST
* handler enforces this order regardless of how the client sends its items
* array, so the avsättning cap can never be evaluated against a stale
* (pre-återföring, pre-SLP) net result.
*/
const DISPOSITION_ORDER: Record<string, number> = {
periodiseringsfond_ateforing: 0,
overavskrivningar: 1,
periodiseringsfond_avsattning: 2,
sarskild_loneskatt: 3,
sarskild_loneskatt: 2,
periodiseringsfond_avsattning: 3,
bolagsskatt: 4,
// K3 only: posts last because it depends on the closing 21xx balance,
// which only stabilises once avsättning / återföring have been applied.
@@ -112,7 +108,7 @@ const ItemSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('periodiseringsfond_ateforing'),
returns: z.record(z.string(), z.number().nonnegative()).default({}),
schablonintaktRate: z.number().min(0).max(0.2).default(DEFAULT_SCHABLONINTAKT_RATE),
schablonintaktRate: z.number().min(0).max(0.2).optional(),
}),
z.object({
kind: z.literal('overavskrivningar'),
@@ -147,7 +143,7 @@ export const POST = withRouteContext(
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
.select('id, name, period_start, period_end, opening_balance_entry_id, is_closed, locked_at, closing_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -177,7 +173,7 @@ export const POST = withRouteContext(
// produces correct amounts on top of what's already there. A future
// RPC-level wrapper (Phase 5+) will make this atomic.
for (const item of sortedItems) {
const proposal = await computeProposal(item, supabase, companyId, id, fiscalYear)
const proposal = await computeProposal(item, supabase, companyId, period, fiscalYear)
if (!proposal) continue
const entry = await createJournalEntry(supabase, companyId, user.id, {
@@ -202,13 +198,24 @@ export const POST = withRouteContext(
type PostItem = z.infer<typeof ItemSchema>
/** The period row the POST handler already validated. Passed through so the
* per-item computations never re-fetch it: a transient DB failure on a
* re-fetch must fail the request, not silently skip a disposition. */
interface ValidatedPeriod {
id: string
period_start: string
period_end: string
opening_balance_entry_id: string | null
}
async function computeProposal(
item: PostItem,
supabase: Parameters<typeof calculateBolagsskatt>[0],
companyId: string,
fiscalPeriodId: string,
period: ValidatedPeriod,
fiscalYear: number,
): Promise<ProposedDisposition | null> {
const fiscalPeriodId = period.id
switch (item.kind) {
case 'bolagsskatt': {
// Dispositioner are booked as source_type='year_end', which the income
@@ -224,14 +231,19 @@ async function computeProposal(
fiscalPeriodId,
)
return calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
resultBeforeTaxOverride: incomeStatement.net_result + dispositionsEffect,
resultBeforeTaxOverride: incomeStatement.net_result + dispositionsEffect.total,
manualAdjustments: item.manualAdjustments,
})
}
case 'sarskild_loneskatt':
case 'sarskild_loneskatt': {
// Already posted in this period (resumed run / duplicate POST): the
// calculator is not posted-aware and would book the full SLP again.
const posted = await sumPostedYearEndDispositions(supabase, companyId, fiscalPeriodId)
if (posted.slpPortion !== 0) return null
return calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId, {
manualAdjustment: item.manualAdjustment,
})
}
case 'periodiseringsfond_avsattning': {
// Re-derive the cap base from current state so the user can't sneak in
// a higher desiredAmount than 25 % of actual skattemässigt resultat.
@@ -240,44 +252,66 @@ async function computeProposal(
companyId,
fiscalPeriodId,
)
const { data: periodRow } = await supabase
.from('fiscal_periods')
.select('period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
const periodEnd = periodRow?.period_end ?? `${fiscalYear}-12-31`
const existing = await listExistingPeriodiseringsfonder(supabase, companyId, periodEnd)
const schablonintaktRate = item.schablonintaktRate ?? DEFAULT_SCHABLONINTAKT_RATE
const existing = await listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
period.period_start,
period.opening_balance_entry_id,
)
const schablonintaktRate = item.schablonintaktRate ?? getSchablonintaktRate(fiscalYear)
// Schablonintäkt applies to the fond balance at the START of the tax
// year (IL 30 kap 6a §): opening balances, regardless of what has
// been avsatt or återfört during the period.
const schablonintakt = existing.reduce(
(sum, f) => sum + f.balance * schablonintaktRate,
(sum, f) => sum + Math.max(0, f.opening_balance) * schablonintaktRate,
0,
)
const base = incomeStatement.net_result + Math.round(schablonintakt)
// A previous avsättning in this bokslut consumes 25 %-cap headroom:
// without this, re-running the flow books the fond twice. Measured as
// the current cohort ACCOUNT's growth during the period so a
// prior-year fond sharing the account (shortened brutet räkenskapsår,
// decade wrap) does not consume this year's headroom.
const currentCohort = existing.find(
(f) => f.account_number === getPeriodiseringsfondCohortAccount(fiscalYear),
)
const alreadyProvisioned = currentCohort
? Math.max(0, currentCohort.balance - Math.max(0, currentCohort.opening_balance))
: 0
// Dispositions posted earlier in this batch (återföring, över-
// avskrivningar, SLP: all sorted before avsättning) are year_end-typed
// and thus invisible in net_result, yet they move the cap base. Add
// their signed effect back, then add back any posted avsättning itself
// (the cap applies to the result BEFORE avsättning; headroom is
// handled via alreadyProvisioned).
const postedEffect = await sumPostedYearEndDispositions(
supabase,
companyId,
fiscalPeriodId,
)
const base =
incomeStatement.net_result + postedEffect.total + alreadyProvisioned
+ Math.round(schablonintakt)
return proposeAvsattning({
skattemassigtResultatBeforeAvsattning: base,
desiredAmount: item.desiredAmount,
fiscalYear,
alreadyProvisioned,
})
}
case 'periodiseringsfond_ateforing': {
// Recompute existing fonder server-side so the user can't return more
// than is on the books.
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (!period) return null
const existing = await listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
period.period_start,
period.opening_balance_entry_id,
)
const result = proposeAteforing(existing, {
returns: item.returns,
schablonintaktRate: item.schablonintaktRate,
schablonintaktRate: item.schablonintaktRate ?? getSchablonintaktRate(fiscalYear),
})
// Combine multiple cohort reversals into a single voucher with multiple
// lines so we don't blow up voucher numbering, but each fond is its own
@@ -107,31 +107,76 @@ describe('calculateBolagsskatt', () => {
expect(generateIncomeStatement).not.toHaveBeenCalled()
})
/** Table-keyed FIFO client: consumption order per table mirrors the
* two-step entry-lines fetch plus the reversed-ids lookup. */
function makeQueuedClient(queues: Record<string, { data: unknown; error: unknown }[]>) {
const makeBuilder = (table: string) => {
const handler: ProxyHandler<object> = {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve(queues[table]?.shift() ?? { data: [], error: null })
}
return () => new Proxy({}, handler)
},
}
return new Proxy({}, handler)
}
return { from: (table: string) => makeBuilder(table) } as unknown as Parameters<
typeof sumPostedYearEndDispositions
>[0]
}
it('sumPostedYearEndDispositions adds back periodiseringsfond + överavskrivning (class-88) + SLP, ignores tax/liability', async () => {
// Commit path: bolagsskatt is computed after the other dispositions are
// posted. They carry source_type='year_end' (excluded from the income
// statement), so the tax base must add their P&L effect back.
const rows = [
{ account_number: '8811', debit_amount: 150_000, credit_amount: 0 }, // avsättning −150k
{ account_number: '8811', debit_amount: 150_000, credit_amount: 0 }, // avsättning -150k
{ account_number: '8819', debit_amount: 0, credit_amount: 20_000 }, // återföring +20k
{ account_number: '8853', debit_amount: 39_000, credit_amount: 0 }, // överavskrivning −39k
{ account_number: '7533', debit_amount: 5_000, credit_amount: 0 }, // SLP −5k
{ account_number: '8853', debit_amount: 39_000, credit_amount: 0 }, // överavskrivning -39k
{ account_number: '7533', debit_amount: 5_000, credit_amount: 0 }, // SLP -5k
{ account_number: '8910', debit_amount: 123_600, credit_amount: 0 }, // skatt : ignored
{ account_number: '2124', debit_amount: 0, credit_amount: 150_000 }, // skuld : ignored
]
const result = { data: rows, error: null }
const handler: ProxyHandler<object> = {
get(_t, prop) {
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
return () => new Proxy({}, handler)
},
}
const client = { from: () => new Proxy({}, handler) } as unknown as Parameters<
typeof sumPostedYearEndDispositions
>[0]
const client = makeQueuedClient({
journal_entries: [
{ data: [{ id: 'ye-posted-1' }], error: null }, // entries step of the lines fetch
{ data: [], error: null }, // reversed year_end ids: none
],
journal_entry_lines: [{ data: rows, error: null }],
})
const effect = await sumPostedYearEndDispositions(client, 'co', 'fp')
expect(effect).toBe(-174_000) // -150k + 20k - 39k - 5k
expect(effect.total).toBe(-174_000) // -150k + 20k - 39k - 5k
expect(effect.slpPortion).toBe(-5_000)
})
it('sumPostedYearEndDispositions counts the replacement of a corrected year_end entry', async () => {
// A corrected disposition: the original is status='reversed' (invisible
// to the posted year_end fetch) and the effective booking lives on the
// correction entry (source_type='correction', correction_of_id → the
// original). Its P&L effect must be part of the tax base.
const client = makeQueuedClient({
journal_entries: [
{ data: [], error: null }, // posted year_end entries: none
{ data: [{ id: 'ye-rev-1' }], error: null }, // reversed year_end ids
{ data: [{ id: 'corr-1' }], error: null }, // corrections entries step
],
journal_entry_lines: [
{
data: [
{ account_number: '8811', debit_amount: 90_000, credit_amount: 0 },
{ account_number: '2125', debit_amount: 0, credit_amount: 90_000 },
],
error: null,
},
],
})
const effect = await sumPostedYearEndDispositions(client, 'co', 'fp')
expect(effect.total).toBe(-90_000)
expect(effect.slpPortion).toBe(0)
})
it('truncates taxable result to whole krona before applying tax', async () => {
@@ -5,6 +5,7 @@ import {
proposeAteforing,
listExistingPeriodiseringsfonder,
getPeriodiseringsfondCohortAccount,
getSchablonintaktRate,
PFOND_AB_RATE,
PFOND_MAX_HOLD_YEARS,
type ExistingFond,
@@ -28,6 +29,17 @@ describe('getPeriodiseringsfondCohortAccount', () => {
})
})
describe('getSchablonintaktRate', () => {
it('returns the SLR for known closing years (IL 30 kap 6a §: SLR itself, not SLR + 1 pp)', () => {
expect(getSchablonintaktRate(2025)).toBe(0.0196)
expect(getSchablonintaktRate(2026)).toBe(0.0255)
})
it('fails closed for unmapped years: a statutory rate is never guessed', () => {
expect(() => getSchablonintaktRate(2030)).toThrow(/not configured/)
})
})
describe('proposeAvsattning', () => {
it('caps avsättning at 25% of base', () => {
const result = proposeAvsattning({
@@ -100,6 +112,46 @@ describe('proposeAvsattning', () => {
})
expect(result!.lines[1].account_number).toBe('2127')
})
it('reduces headroom by an already-provisioned current-year fond', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
alreadyProvisioned: 60_000,
})
// 25% of 400_000 = 100_000 cap; 60_000 already booked -> 40_000 left
expect(result!.amount).toBe(40_000)
})
it('returns null when the year cap is already fully provisioned', () => {
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
alreadyProvisioned: 100_000,
}),
).toBeNull()
// Over-provisioned (legacy double booking) must not propose more either
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
alreadyProvisioned: 150_000,
}),
).toBeNull()
})
it('caps an explicit desiredAmount to the remaining headroom with a warning', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 80_000,
fiscalYear: 2025,
alreadyProvisioned: 60_000,
})
expect(result!.amount).toBe(40_000)
expect(result!.warnings).toHaveLength(1)
expect(result!.warnings[0]).toContain('redan avsatt')
})
})
describe('proposeAteforing', () => {
@@ -109,6 +161,7 @@ describe('proposeAteforing', () => {
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
opening_balance: 50_000,
must_return_this_year: true,
},
]
@@ -127,6 +180,7 @@ describe('proposeAteforing', () => {
account_number: '2122',
cohort_year: 2022,
balance: 100_000,
opening_balance: 100_000,
must_return_this_year: false,
},
]
@@ -142,6 +196,7 @@ describe('proposeAteforing', () => {
account_number: '2123',
cohort_year: 2023,
balance: 80_000,
opening_balance: 80_000,
must_return_this_year: false,
},
]
@@ -160,6 +215,7 @@ describe('proposeAteforing', () => {
account_number: '2124',
cohort_year: 2024,
balance: 30_000,
opening_balance: 30_000,
must_return_this_year: false,
},
]
@@ -176,6 +232,7 @@ describe('proposeAteforing', () => {
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
opening_balance: 50_000,
must_return_this_year: true,
},
]
@@ -188,6 +245,66 @@ describe('proposeAteforing', () => {
expect(lines[1].credit_amount).toBe(50_000)
})
it('never proposes a return for zero or negative closing balances', () => {
// A negative balance is a data anomaly (e.g. a debit-only view of a
// storno pair): proposing a negative återföring would produce an
// uncommittable entry.
const fonder: ExistingFond[] = [
{
account_number: '2125',
cohort_year: 2025,
balance: -396_459,
opening_balance: 0,
must_return_this_year: false,
},
{
account_number: '2120',
cohort_year: 2020,
balance: 0,
opening_balance: 0,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
expect(result.proposals).toHaveLength(0)
expect(result.schablonintaktAmount).toBe(0)
})
it('computes schablonintäkt on opening balances per IL 30 kap 6a §', () => {
const fonder: ExistingFond[] = [
// Avsatt in THIS bokslut: opening 0, no schablonintäkt
{
account_number: '2125',
cohort_year: 2025,
balance: 200_000,
opening_balance: 0,
must_return_this_year: false,
},
// Held all year: full schablonintäkt
{
account_number: '2123',
cohort_year: 2023,
balance: 100_000,
opening_balance: 100_000,
must_return_this_year: false,
},
// Fully återförd during the year: schablonintäkt still due
{
account_number: '2120',
cohort_year: 2020,
balance: 0,
opening_balance: 50_000,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
// 0 + 100_000 × 0.03 + 50_000 × 0.03 = 4_500
expect(result.schablonintaktAmount).toBe(4_500)
// The 2125/2120 fonder yield no return proposal (2125 not requested,
// 2120 balance already zero despite the mandatory flag)
expect(result.proposals).toHaveLength(0)
})
it('exposes constants used by callers', () => {
expect(PFOND_AB_RATE).toBe(0.25)
expect(PFOND_MAX_HOLD_YEARS).toBe(6)
@@ -206,7 +323,7 @@ describe('listExistingPeriodiseringsfonder', () => {
function makeChainableQuery() {
const calls: Array<[string, ...unknown[]]> = []
const q: Record<string, unknown> = {}
for (const m of ['eq', 'gte', 'lte'] as const) {
for (const m of ['eq', 'in', 'gte', 'lte'] as const) {
q[m] = vi.fn((...args: unknown[]) => {
calls.push([m, ...args])
return q
@@ -215,34 +332,39 @@ describe('listExistingPeriodiseringsfonder', () => {
return { q, calls }
}
it('calls fetchEntryLines with line columns only and no entry reattachment', async () => {
it('calls fetchEntryLines with entry dates attached for the opening-balance split', async () => {
mockFetchEntryLines.mockResolvedValue([])
const result = await listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31')
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2025-12-31', '2025-01-01',
)
expect(result).toEqual([])
expect(mockFetchEntryLines).toHaveBeenCalledTimes(1)
expect(mockFetchEntryLines).toHaveBeenCalledWith(
expect.objectContaining({
supabase,
entryColumns: 'id, entry_date',
lineColumns: 'account_number, debit_amount, credit_amount',
attachEntriesAs: null,
}),
)
})
it('scopes entries to company/posted/closing date and lines to the 21xx range', async () => {
it('scopes entries to company/posted+reversed/closing date and lines to the 21xx range', async () => {
mockFetchEntryLines.mockResolvedValue([])
await listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31')
await listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31', '2025-01-01')
const options = mockFetchEntryLines.mock.calls[0][0]
const entries = makeChainableQuery()
options.filterEntries(entries.q)
// Both statuses: a reversed original and its storno must cancel; a
// posted-only view would show the storno's debit as a phantom negative
// fond balance.
expect(entries.calls).toEqual([
['eq', 'company_id', 'company-1'],
['eq', 'status', 'posted'],
['in', 'status', ['posted', 'reversed']],
['lte', 'entry_date', '2025-12-31'],
])
@@ -255,20 +377,38 @@ describe('listExistingPeriodiseringsfonder', () => {
])
})
it('sums per-account balances (credit minus debit) and maps cohort years', async () => {
it('sums per-account balances (credit minus debit), splits opening vs closing, and maps cohort years', async () => {
mockFetchEntryLines.mockResolvedValue([
// 2125 across two rows: 100_000 credit, 20_000 debit -> 80_000
{ account_number: '2125', debit_amount: 0, credit_amount: 100_000 },
{ account_number: '2125', debit_amount: 20_000, credit_amount: 0 },
// Numeric strings and nulls coerce like the embed rows did
{ account_number: '2120', debit_amount: null, credit_amount: '50000' },
// 2125 avsatt during the period: closing 80_000, opening 0
{
account_number: '2125', debit_amount: 0, credit_amount: 100_000,
journal_entries: { entry_date: '2026-12-31' },
},
{
account_number: '2125', debit_amount: 20_000, credit_amount: 0,
journal_entries: { entry_date: '2026-12-31' },
},
// Numeric strings and nulls coerce like the embed rows did; booked in
// a prior period so it is part of the opening balance
{
account_number: '2120', debit_amount: null, credit_amount: '50000',
journal_entries: { entry_date: '2020-12-31' },
},
// 2129 is the 2019 collision account per the BAS 2020 seed
{ account_number: '2129', debit_amount: 0, credit_amount: 10_000 },
{
account_number: '2129', debit_amount: 0, credit_amount: 10_000,
journal_entries: { entry_date: '2019-12-31' },
},
// 2110 is a grouping account with no cohort: skipped
{ account_number: '2110', debit_amount: 0, credit_amount: 5_000 },
{
account_number: '2110', debit_amount: 0, credit_amount: 5_000,
journal_entries: { entry_date: '2020-12-31' },
},
])
const result = await listExistingPeriodiseringsfonder(supabase, 'company-1', '2026-12-31')
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2026-12-31', '2026-01-01',
)
// Sorted by cohort_year ascending
expect(result).toEqual([
@@ -276,30 +416,109 @@ describe('listExistingPeriodiseringsfonder', () => {
account_number: '2129',
cohort_year: 2019,
balance: 10_000,
opening_balance: 10_000,
must_return_this_year: true, // 2019 + 6 = 2025 <= 2026
},
{
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
opening_balance: 50_000,
must_return_this_year: true, // 2020 + 6 = 2026 <= 2026
},
{
account_number: '2125',
cohort_year: 2025,
balance: 80_000,
opening_balance: 0,
must_return_this_year: false, // 2025 + 6 = 2031 > 2026
},
])
})
it('does not double-count a fond carried via an opening-balance entry', async () => {
// Year-end closing of the previous period books an OB entry (dated at
// period_start) that carries the accumulated 21xx balances. Summing the
// OB entry together with the pre-period history would double the fond.
mockFetchEntryLines.mockResolvedValue([
// Prior-period avsättning: already carried inside the OB entry
{
account_number: '2124', debit_amount: 0, credit_amount: 60_000,
journal_entry_id: 'prior-1',
journal_entries: { entry_date: '2024-12-31' },
},
// The OB entry itself, dated exactly at period_start
{
account_number: '2124', debit_amount: 0, credit_amount: 60_000,
journal_entry_id: 'ob-1',
journal_entries: { entry_date: '2025-01-01' },
},
// Current-period partial återföring
{
account_number: '2124', debit_amount: 10_000, credit_amount: 0,
journal_entry_id: 'cur-1',
journal_entries: { entry_date: '2025-12-31' },
},
])
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2025-12-31', '2025-01-01', 'ob-1',
)
expect(result).toEqual([
{
account_number: '2124',
cohort_year: 2024,
balance: 50_000, // 60_000 carried - 10_000 returned, NOT 110_000
opening_balance: 60_000, // the OB entry, despite being dated at period_start
must_return_this_year: false,
},
])
})
it('rejects a well-shaped but impossible period start date', async () => {
await expect(
listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31', '2025-02-31'),
).rejects.toThrow('Invalid period start: 2025-02-31')
expect(mockFetchEntryLines).not.toHaveBeenCalled()
})
it('keeps a fond fully återförd during the period (closing 0, opening > 0)', async () => {
mockFetchEntryLines.mockResolvedValue([
{
account_number: '2121', debit_amount: 0, credit_amount: 40_000,
journal_entries: { entry_date: '2021-12-31' },
},
{
account_number: '2121', debit_amount: 40_000, credit_amount: 0,
journal_entries: { entry_date: '2026-12-31' },
},
])
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2026-12-31', '2026-01-01',
)
expect(result).toEqual([
{
account_number: '2121',
cohort_year: 2021,
balance: 0,
opening_balance: 40_000,
must_return_this_year: false, // 2021 + 6 = 2027 > 2026
},
])
})
it('drops near-zero balances below the 0.005 threshold', async () => {
mockFetchEntryLines.mockResolvedValue([
{ account_number: '2123', debit_amount: 1_000, credit_amount: 1_000.004 },
{ account_number: '2124', debit_amount: 0, credit_amount: 30_000 },
])
const result = await listExistingPeriodiseringsfonder(supabase, 'company-1', '2026-12-31')
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2026-12-31', '2026-01-01',
)
expect(result).toHaveLength(1)
expect(result[0].account_number).toBe('2124')
@@ -309,14 +528,21 @@ describe('listExistingPeriodiseringsfonder', () => {
mockFetchEntryLines.mockRejectedValue(new Error('boom'))
await expect(
listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31'),
listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31', '2025-01-01'),
).rejects.toThrow('Failed to fetch periodiseringsfond balances: boom')
})
it('rejects an unparseable closing date before querying', async () => {
await expect(
listExistingPeriodiseringsfonder(supabase, 'company-1', 'not-a-date'),
listExistingPeriodiseringsfonder(supabase, 'company-1', 'not-a-date', '2025-01-01'),
).rejects.toThrow('Invalid closing date: not-a-date')
expect(mockFetchEntryLines).not.toHaveBeenCalled()
})
it('rejects an unparseable period start before querying', async () => {
await expect(
listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31', 'not-a-date'),
).rejects.toThrow('Invalid period start: not-a-date')
expect(mockFetchEntryLines).not.toHaveBeenCalled()
})
})
+57 -12
View File
@@ -1,7 +1,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { calculateBolagsskatt } from './tax-provision/bolagsskatt-calculator'
import {
calculateBolagsskatt,
sumPostedYearEndDispositions,
} from './tax-provision/bolagsskatt-calculator'
import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator'
import {
computeLatentTax,
@@ -10,6 +13,8 @@ import {
proposeLatentTaxChange,
} from './tax-provision/latent-tax-calculator'
import {
getPeriodiseringsfondCohortAccount,
getSchablonintaktRate,
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
@@ -17,8 +22,6 @@ import {
import type { DispositionsProposal, ProposedDisposition } from './types'
import type { AccountingFramework } 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.
@@ -32,7 +35,7 @@ export async function buildDispositionsProposal(
): Promise<DispositionsProposal> {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.select('id, name, period_start, period_end, opening_balance_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
@@ -84,23 +87,65 @@ export async function buildDispositionsProposal(
const proposals: ProposedDisposition[] = []
const existingFonder = await listExistingPeriodiseringsfonder(supabase, companyId, period.period_end)
// Dispositions already POSTED in this period (a partially completed
// bokslut run) are excluded from resultBeforeTax like all year_end
// entries, but they do affect the taxable base: their signed P&L effect
// is folded into every base below so a re-visit previews the same
// amounts the commit path books.
const postedEffect = await sumPostedYearEndDispositions(
supabase,
companyId,
fiscalPeriodId,
)
const existingFonder = await listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
period.period_start,
period.opening_balance_entry_id,
)
const ateforing = proposeAteforing(existingFonder, {
schablonintaktRate: DEFAULT_SCHABLONINTAKT_RATE,
schablonintaktRate: getSchablonintaktRate(fiscalYear),
})
proposals.push(...ateforing.proposals)
const ateforingTotal = ateforing.proposals.reduce((sum, p) => sum + p.amount, 0)
// SLP already posted in this period (resumed run): don't re-propose it
// (that would book it twice) and don't subtract it twice below (its
// effect is already inside postedEffect.total).
const slp =
postedEffect.slpPortion !== 0
? null
: await calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId)
// An avsättning already booked in this bokslut eats into the 25 % cap;
// without this, revisiting the page after committing re-proposes the full
// avsättning and lets the user book it twice. Measured as the current
// cohort ACCOUNT's growth during the period (closing minus opening), so a
// prior-year fond that happens to share the account (shortened brutet
// räkenskapsår, decade wrap) does not consume this year's headroom.
const currentCohort = existingFonder.find(
(f) => f.account_number === getPeriodiseringsfondCohortAccount(fiscalYear),
)
const alreadyProvisioned = currentCohort
? Math.max(0, currentCohort.balance - Math.max(0, currentCohort.opening_balance))
: 0
// Cap base = skattemässigt resultat före avsättning: ledger result plus
// posted dispositions (with any posted avsättning added back: its
// headroom effect is alreadyProvisioned, not a base reduction), plus
// proposed återföringar and schablonintäkt, minus deductible SLP.
const taxableBeforeAvsattning =
resultBeforeTax +
ateforing.proposals.reduce((sum, p) => sum + p.amount, 0) +
ateforing.schablonintaktAmount
resultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal
+ ateforing.schablonintaktAmount - (slp?.amount ?? 0)
const avsattning = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
fiscalYear,
alreadyProvisioned,
})
if (avsattning) proposals.push(avsattning)
const slp = await calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId)
if (slp) proposals.push(slp)
// Bolagsskatt must be computed on the result AFTER the dispositions above.
@@ -112,9 +157,9 @@ export async function buildDispositionsProposal(
// − SLP (7533, kostnad)
// Without this, the previewed tax ignores the avsättning (tax too high) and
// diverges from what the sequential commit books and from ÅR/INK2.
const ateforingTotal = ateforing.proposals.reduce((sum, p) => sum + p.amount, 0)
const resultAfterDispositions =
resultBeforeTax + ateforingTotal - (avsattning?.amount ?? 0) - (slp?.amount ?? 0)
resultBeforeTax + postedEffect.total + ateforingTotal
- (avsattning?.amount ?? 0) - (slp?.amount ?? 0)
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
resultBeforeTaxOverride: resultAfterDispositions,
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { roundOre } from '@/lib/money'
import type { ProposedDisposition } from '../types'
/** Maximum periodiseringsfond avsättning for aktiebolag: 25 % of skattemässigt
@@ -12,6 +13,37 @@ export const PFOND_AB_RATE_PCT = '25 %'
* than räkenskapsår N+6 (IL 30 kap 7 §). */
export const PFOND_MAX_HOLD_YEARS = 6
/**
* Schablonintäkt rate on periodiseringsfonder for juridiska personer
* (IL 30 kap 6a §): statslåneräntan 30 November of the year preceding the
* calendar year in which the beskattningsår ends, floored at 0.5 %. Note:
* the rate is the SLR itself, NOT SLR + 1 procentenhet (that formula is
* negativ räntefördelning). Keyed by the closing calendar year.
*/
const SCHABLONINTAKT_RATE_BY_CLOSING_YEAR: Record<number, number> = {
2025: 0.0196, // SLR 2024-11-30 = 1.96 %
2026: 0.0255, // SLR 2025-11-30 = 2.55 %
}
/**
* Fail closed for unmapped years: a statutory rate must never be guessed.
* The table is an annual maintenance item (Riksbanken publishes the SLR on
* 30 November); a missing year surfaces loudly here instead of silently
* producing a wrong INK2S 4.6a adjustment. POST callers can still override
* the rate per request while the table update lands.
*/
export function getSchablonintaktRate(fiscalYear: number): number {
const rate = SCHABLONINTAKT_RATE_BY_CLOSING_YEAR[fiscalYear]
if (rate === undefined) {
throw new Error(
`Schablonintäkt rate for fiscal year ${fiscalYear} is not configured. `
+ 'Add the SLR (30 Nov of the preceding year, floor 0.5 %) to '
+ 'SCHABLONINTAKT_RATE_BY_CLOSING_YEAR in periodiseringsfond-service.ts.',
)
}
return rate
}
/**
* BAS account convention: account = '212' + (fiscalYear % 10). 2020 → '2120',
* 2025 → '2125'. The collision year 2019/2029 maps to '2129' per the BAS
@@ -30,6 +62,11 @@ export interface ExistingFond {
cohort_year: number
/** Current credit balance (positive = liability balance). */
balance: number
/** Credit balance at the START of the fiscal period (beskattningsårets
* ingång). This is the schablonintäkt base per IL 30 kap 6a §: a fond
* avsatt in this year's bokslut has opening 0 and yields none, while a
* fond fully återförd during the year still yields it in full. */
opening_balance: number
/** True if the fond must be returned this year (cohort_year + 6 ≤ closing_year). */
must_return_this_year: boolean
}
@@ -42,6 +79,10 @@ export interface PfondAvsattningInput {
/** Closing year of the fiscal period (e.g. 2025 for FY ending 2025-12-31).
* Determines which cohort account to use. */
fiscalYear: number
/** Balance already booked on this year's cohort account (a previous
* avsättning in the same bokslut). Reduces the remaining headroom under
* the 25 % cap so re-running the flow can never double-provision. */
alreadyProvisioned?: number
}
export interface PfondAvsattningComputation {
@@ -52,6 +93,7 @@ export interface PfondAvsattningComputation {
cohortAccount: string
cohortYear: number
cappedToMax: boolean
alreadyProvisioned: number
}
/**
@@ -63,10 +105,14 @@ export interface PfondAvsattningComputation {
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)
// The 25 % cap applies to the YEAR's total avsättning: anything already
// booked on this year's cohort account consumes headroom.
const alreadyProvisioned = Math.max(0, Math.floor(input.alreadyProvisioned ?? 0))
const headroom = Math.max(0, maxAmount - alreadyProvisioned)
const desiredAmount = Math.max(0, Math.floor(input.desiredAmount ?? headroom))
const actualAmount = Math.min(desiredAmount, headroom)
const cohortAccount = getPeriodiseringsfondCohortAccount(input.fiscalYear)
const cappedToMax = desiredAmount > maxAmount
const cappedToMax = desiredAmount > headroom
if (actualAmount === 0) {
return null
@@ -80,12 +126,15 @@ export function proposeAvsattning(input: PfondAvsattningInput): ProposedDisposit
cohortAccount,
cohortYear: input.fiscalYear,
cappedToMax,
alreadyProvisioned,
}
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.`,
alreadyProvisioned > 0
? `Begärt belopp (${desiredAmount} kr) översteg kvarvarande utrymme under ${PFOND_AB_RATE_PCT}-taket (${maxAmount} kr, varav ${alreadyProvisioned} kr redan avsatt). Avsättningen begränsades till ${headroom} kr.`
: `Begärt belopp (${desiredAmount} kr) översteg ${PFOND_AB_RATE_PCT}-taket. Avsättningen begränsades till ${maxAmount} kr.`,
)
}
@@ -115,42 +164,72 @@ export function proposeAvsattning(input: PfondAvsattningInput): ProposedDisposit
/**
* 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`.
* 2110-2199 account as of the closing date of the fiscal period, plus the
* balance at `periodStart` (the schablonintäkt base). Marks any fond whose
* cohort_year + 6 ≤ closing_year as `must_return_this_year`. Accounts where
* BOTH balances are zero are dropped; a fond fully återförd during the
* period is kept (closing 0, opening > 0) so its schablonintäkt survives.
*
* 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).
* When the period has an opening-balance entry (created by year-end closing
* of the previous period, dated at period_start), that entry ALREADY carries
* the accumulated 21xx balances: opening = the OB entry's lines, closing =
* OB + current-period activity, and pre-period entries are ignored (counting
* them alongside the OB entry would double every carried fond). Without an
* OB entry (first fiscal year, plain SIE history) the balances fall back to
* the full history sum, split at period_start.
*/
export async function listExistingPeriodiseringsfonder(
supabase: SupabaseClient,
companyId: string,
closingDate: string,
periodStart: string,
openingBalanceEntryId?: string | null,
): Promise<ExistingFond[]> {
const closingYear = parseInt(closingDate.slice(0, 4), 10)
if (Number.isNaN(closingYear)) {
throw new Error(`Invalid closing date: ${closingDate}`)
}
// Full validity parse: '2025-02-31' passes a shape regex but would silently
// misclassify every entry in the opening/closing split below.
const periodStartParsed = new Date(`${periodStart}T00:00:00Z`)
if (
!/^\d{4}-\d{2}-\d{2}$/.test(periodStart)
|| Number.isNaN(periodStartParsed.getTime())
|| periodStartParsed.toISOString().slice(0, 10) !== periodStart
) {
throw new Error(`Invalid period start: ${periodStart}`)
}
// Sum debit/credit per 21xx account up to and including the closing date.
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts): drives
// the query from journal_entries and paginates, instead of the old
// journal_entries!inner embed that scanned all tenants' lines and silently
// truncated at PostgREST's 1000-row cap.
type Row = { account_number: string; debit_amount: number | string | null; credit_amount: number | string | null }
// truncated at PostgREST's 1000-row cap. The parent entry_date is attached
// per line so opening balances need no second query.
type Row = {
account_number: string
debit_amount: number | string | null
credit_amount: number | string | null
journal_entry_id?: string
journal_entries?: { entry_date?: string }
}
let data: Row[]
try {
data = await fetchEntryLines<Row>({
supabase,
entryColumns: 'id, entry_date',
lineColumns: 'account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('status', 'posted')
// Storno semantics: a reversed entry stays in the ledger and its
// storno cancels it. Counting only 'posted' would see the storno's
// debit but not the reversed original's credit, producing a
// negative phantom balance on the fond account.
.in('status', ['posted', 'reversed'])
.lte('entry_date', closingDate),
filterLines: (q: EntryLinesQuery) =>
q.gte('account_number', '2110').lte('account_number', '2199'),
attachEntriesAs: null,
})
} catch (err) {
throw new Error(
@@ -158,22 +237,41 @@ export async function listExistingPeriodiseringsfonder(
)
}
const byAccount = new Map<string, number>()
const byAccount = new Map<string, { closing: number; opening: number }>()
for (const row of data) {
const balance =
const delta =
(Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
byAccount.set(row.account_number, (byAccount.get(row.account_number) ?? 0) + balance)
const entryDate = row.journal_entries?.entry_date
const rec = byAccount.get(row.account_number) ?? { closing: 0, opening: 0 }
if (openingBalanceEntryId) {
if (row.journal_entry_id === openingBalanceEntryId) {
// The OB entry IS the carried balance: opening and closing both.
rec.opening += delta
rec.closing += delta
} else if (typeof entryDate === 'string' && entryDate >= periodStart) {
// Current-period activity on top of the carried balance.
rec.closing += delta
}
// Pre-period entries: ignored, their net effect is inside the OB entry.
} else {
rec.closing += delta
if (typeof entryDate === 'string' && entryDate < periodStart) {
rec.opening += delta
}
}
byAccount.set(row.account_number, rec)
}
const fonder: ExistingFond[] = []
for (const [accountNumber, balance] of byAccount) {
if (Math.abs(balance) < 0.005) continue
for (const [accountNumber, { closing, opening }] of byAccount) {
if (Math.abs(closing) < 0.005 && Math.abs(opening) < 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,
balance: roundOre(closing),
opening_balance: roundOre(opening),
must_return_this_year: cohortYear + PFOND_MAX_HOLD_YEARS <= closingYear,
})
}
@@ -211,12 +309,16 @@ export interface PfondAteforingProposal {
/**
* 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.
* the schablonintäkt on the OPENING balance of all 21xx accounts (per IL 30
* kap 6a §: the base is the fond balance at beskattningsårets ingång, so a
* fond avsatt in this year's bokslut yields none and a fond fully återförd
* during the year still yields it in full): 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.
* @param schablonintaktRate Statslåneräntan 30 nov året före det kalenderår
* beskattningsåret går ut, min 0.5 % (IL 30 kap 6a §). Use
* {@link getSchablonintaktRate}; caller passes it in because the rate
* changes annually and can be overridden per request.
*/
export function proposeAteforing(
existingFonder: ExistingFond[],
@@ -224,8 +326,8 @@ export function proposeAteforing(
/** 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. */
/** Schablonintäkt rate as a decimal (0.03 for 3 %). Applied to the
* opening balance of every 21xx account. */
schablonintaktRate: number
},
): PfondAteforingProposal {
@@ -233,7 +335,15 @@ export function proposeAteforing(
let schablonintaktAmount = 0
for (const fond of existingFonder) {
schablonintaktAmount += fond.balance * options.schablonintaktRate
// Schablonintäkt runs on the opening balance regardless of what happens
// to the fond during the year.
schablonintaktAmount += Math.max(0, fond.opening_balance) * options.schablonintaktRate
// A fond with no (or negative) closing balance has nothing to return.
// Negative balances are a data anomaly (e.g. a debit-only view of a
// storno pair); proposing a negative återföring would produce an
// uncommittable entry.
if (fond.balance <= 0) continue
const desiredReturn = options.returns?.[fond.account_number] ?? 0
const isMandatory = fond.must_return_this_year
@@ -241,7 +351,7 @@ export function proposeAteforing(
? fond.balance // forced full reversal
: Math.min(Math.max(0, Math.floor(desiredReturn)), fond.balance)
if (returnAmount === 0) continue
if (returnAmount <= 0) continue
const warnings: string[] = []
if (isMandatory) {
@@ -1,5 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { roundOre } from '@/lib/money'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import type { ProposedDisposition } from '../types'
@@ -49,6 +51,16 @@ export interface BolagsskattComputation {
taxAmount: number
}
export interface PostedDispositionsEffect {
/** Signed P&L effect of every effective posted disposition (class 88 +
* 7533): avsättning lowers it, återföring raises it. */
total: number
/** The 7533 (särskild löneskatt) portion of `total`. Callers use it to
* detect an already-posted SLP so it is neither re-proposed nor
* double-counted on a resumed bokslut run. Negative when SLP is posted. */
slpPortion: number
}
/**
* Sum the P&L effect of bokslutsdispositioner already posted in this period.
*
@@ -59,15 +71,21 @@ export interface BolagsskattComputation {
* (bokslutsdispositioner) plus 7533 (SLP); tax (89xx) and the closing entry
* (8999/2099) are intentionally left out.
*
* Returns a signed SEK amount: avsättning (8811 debit) lowers it, återföring
* (8819 credit) raises it. Used by the commit path, where bolagsskatt is
* computed AFTER the other dispositions are posted.
* A corrected year_end entry is counted through its replacement: the
* original is status='reversed' (skipped here) and the income statement
* excludes both it and its storno/correction chain, so the posted
* correction entry (source_type='correction', correction_of_id → the
* original) is the effective disposition and must be summed. Depth-1 chains
* only: corrections of corrections of year_end entries are not followed.
*
* Used by the commit path, where bolagsskatt is computed AFTER the other
* dispositions are posted, and by the preview builder for resumed runs.
*/
export async function sumPostedYearEndDispositions(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<number> {
): Promise<PostedDispositionsEffect> {
type Row = {
account_number: string
debit_amount: number | string | null
@@ -87,18 +105,60 @@ export async function sumPostedYearEndDispositions(
.eq('source_type', 'year_end'),
attachEntriesAs: null,
})
// Replacements of corrected year_end entries (see docstring). Only
// reversed originals can be correction targets, so the id list is empty
// in the common case and the extra fetch is skipped. Targets are looked
// up COMPANY-WIDE (a current-period correction can point at a
// prior-period year_end entry when that period is locked) while the
// correction entries themselves stay scoped to this period, matching the
// trial balance's company-wide chain exclusion.
const reversedYearEndIds = (
await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('source_type', 'year_end')
.eq('status', 'reversed')
.order('id', { ascending: true })
.range(from, to)
)
).map((r) => r.id)
if (reversedYearEndIds.length > 0) {
const corrections = await fetchEntryLines<Row>({
supabase,
lineColumns: 'account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
.eq('source_type', 'correction')
.in('correction_of_id', reversedYearEndIds),
attachEntriesAs: null,
})
data = data.concat(corrections)
}
} catch (err) {
throw new Error(
`Failed to read posted dispositions: ${err instanceof Error ? err.message : String(err)}`,
)
}
let effect = 0
let slp = 0
for (const row of data) {
const acc = row.account_number
if (!(acc.startsWith('88') || acc === '7533')) continue
effect += (Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
const delta = (Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
effect += delta
if (acc === '7533') slp += delta
}
return {
total: roundOre(effect),
slpPortion: roundOre(slp),
}
return Math.round(effect * 100) / 100
}
/**
+85 -1
View File
@@ -11,7 +11,7 @@ let mockResults: Record<string, MockResult[]>
function makeBuilder(tableName: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'order', 'range']) {
for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'or', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
const consume = (): MockResult => {
@@ -539,6 +539,90 @@ describe('generateTrialBalance', () => {
expect(result.isBalanced).toBe(true)
})
// ── excludeYearEndClosing symmetry ───────────────────────────────
// A reversed year_end entry keeps status='reversed' and stays in the
// ledger; its storno carries source_type='storno'. Excluding on
// source_type alone drops the original but keeps the counter-entry,
// inflating the P&L by exactly the reversed amount. The filter must also
// exclude entries chained to year_end entries via reverses_id /
// correction_of_id.
it('excludes stornos and corrections chained to year_end entries', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entries: [
// 1st: the year_end entry-id fetch added for chain exclusion
{ data: [{ id: 'ye-1' }, { id: 'ye-2' }], error: null },
// 2nd: the entries step of the period-lines fetch
{ data: [{ id: 'entry-1' }], error: null },
],
journal_entry_lines: [
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
],
error: null,
},
],
chart_of_accounts: [{ data: [], error: null }],
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeYearEndClosing: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
const orCalls = builders.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => (b.or ? b.or.mock.calls.map((c: unknown[]) => c[0]) : []),
)
expect(orCalls).toContain('reverses_id.is.null,reverses_id.not.in.(ye-1,ye-2)')
expect(orCalls).toContain('correction_of_id.is.null,correction_of_id.not.in.(ye-1,ye-2)')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
})
it('skips the chain filters when the company has no year_end entries', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null },
error: null,
},
],
journal_entries: [
// year_end id fetch: none exist
{ data: [], error: null },
{ data: [{ id: 'entry-1' }], error: null },
],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeYearEndClosing: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
const orCalls = builders.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => (b.or ? b.or.mock.calls : []),
)
expect(orCalls).toHaveLength(0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
})
it('returns empty period activity when the range matches no lines', async () => {
mockResults = {
fiscal_periods: [
+40 -2
View File
@@ -61,6 +61,44 @@ export async function generateTrialBalance(
? options.dimensions
: undefined
// Year-end exclusion must be symmetric: a reversed year_end entry stays in
// the ledger (status='reversed') together with its storno, but the storno
// carries source_type='storno' (and a correction carries 'correction'), so
// filtering on source_type alone drops the original while keeping its
// counter-entry. That inflates the P&L by exactly the reversed amount.
// Fetch the reversed year_end entry ids (company-wide: a storno may land in
// a later period than the entry it reverses) and exclude anything chained
// to them via reverses_id / correction_of_id. Only status='reversed'
// originals can be storno/correction targets (reverseEntry flips the
// original's status atomically), which keeps the id list short: in the
// common no-reversal case the chain filters are skipped entirely.
let yearEndEntryIds: string[] = []
if (options?.excludeYearEndClosing) {
yearEndEntryIds = (
await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('source_type', 'year_end')
.eq('status', 'reversed')
.order('id', { ascending: true })
.range(from, to)
)
).map((r) => r.id)
}
const excludeYearEndChain = (query: EntryLinesQuery): EntryLinesQuery => {
let q = query.neq('source_type', 'year_end')
if (yearEndEntryIds.length > 0) {
const idList = `(${yearEndEntryIds.join(',')})`
// `.not('col','in',...)` alone would also drop NULL rows (NULL NOT IN
// (...) is NULL), i.e. every normal entry: OR in the null branch.
q = q.or(`reverses_id.is.null,reverses_id.not.in.${idList}`)
q = q.or(`correction_of_id.is.null,correction_of_id.not.in.${idList}`)
}
return q
}
// ── Opening balances (IB) at period_start ──────────────────────
const { balances: obBalances, obEntryId } = await getOpeningBalances(
supabase, companyId, period
@@ -105,7 +143,7 @@ export async function generateTrialBalance(
}
if (options?.excludeYearEndClosing) {
query = query.neq('source_type', 'year_end')
query = excludeYearEndChain(query)
}
return query
@@ -163,7 +201,7 @@ export async function generateTrialBalance(
}
if (options?.excludeYearEndClosing) {
query = query.neq('source_type', 'year_end')
query = excludeYearEndChain(query)
}
return query