Files
accounted/lib/bokslut/reserves/periodiseringsfond-service.ts
T
MattssonandClaude Fable 5 46b8e2bfea 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>
2026-07-17 18:06:41 +02:00

398 lines
16 KiB
TypeScript

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
* resultat före avsättning. Enskild firma uses 30 % but is handled in NE/INK1
* rather than booked. */
export const PFOND_AB_RATE = 0.25
export const PFOND_AB_RATE_PCT = '25 %'
/** Mandatory holding period: a fond avsatt år N must be återförd no later
* than räkenskapsår N+6 (IL 30 kap 7 §). */
export const PFOND_MAX_HOLD_YEARS = 6
/**
* 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
* 2020 seed; if a company has fonder in both years on the same account, the
* service surfaces a warning so the user can split the balance manually.
*/
export function getPeriodiseringsfondCohortAccount(fiscalYear: number): string {
if (fiscalYear === 2019) return '2129'
return '212' + (fiscalYear % 10).toString()
}
export interface ExistingFond {
/** BAS account number for the cohort (e.g. '2120'). */
account_number: string
/** Cohort year derived from account naming convention (e.g. 2020 for 2120). */
cohort_year: number
/** Current credit balance (positive = liability balance). */
balance: number
/** 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
}
export interface PfondAvsattningInput {
/** Skattemässigt resultat före avsättning. 25 % cap is applied to this. */
skattemassigtResultatBeforeAvsattning: number
/** Amount the user wants to set aside. Defaults to the maximum (25 %). */
desiredAmount?: number
/** Closing year of the fiscal period (e.g. 2025 for FY ending 2025-12-31).
* Determines which cohort account to use. */
fiscalYear: number
/** 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 {
rate: number
maxAmount: number
desiredAmount: number
actualAmount: number
cohortAccount: string
cohortYear: number
cappedToMax: boolean
alreadyProvisioned: number
}
/**
* Propose a periodiseringsfond avsättning. Caps the user's desired amount
* to 25 % of skattemässigt resultat före avsättning (rounded down to whole
* krona). Returns null when no positive avsättning would result (loss year
* or zero desired).
*/
export function proposeAvsattning(input: PfondAvsattningInput): ProposedDisposition | null {
const base = Math.max(0, Math.floor(input.skattemassigtResultatBeforeAvsattning))
const maxAmount = Math.floor(base * PFOND_AB_RATE)
// 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 > headroom
if (actualAmount === 0) {
return null
}
const computation: PfondAvsattningComputation = {
rate: PFOND_AB_RATE,
maxAmount,
desiredAmount,
actualAmount,
cohortAccount,
cohortYear: input.fiscalYear,
cappedToMax,
alreadyProvisioned,
}
const warnings: string[] = []
if (cappedToMax) {
warnings.push(
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.`,
)
}
return {
kind: 'periodiseringsfond_avsattning',
label: `Avsättning till periodiseringsfond ${input.fiscalYear}`,
description: `Debet 8811, kredit ${cohortAccount}. Max ${PFOND_AB_RATE_PCT} av skattemässigt resultat.`,
amount: actualAmount,
lines: [
{
account_number: '8811',
debit_amount: actualAmount,
credit_amount: 0,
line_description: `Avsättning periodiseringsfond ${input.fiscalYear}`,
},
{
account_number: cohortAccount,
debit_amount: 0,
credit_amount: actualAmount,
line_description: `Periodiseringsfond ${input.fiscalYear}`,
},
],
warnings,
computation: computation as unknown as Record<string, unknown>,
}
}
/**
* List existing periodiseringsfonder by querying the account balance of every
* 2110-2199 account as of the closing date of the fiscal period, 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.
*
* 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. 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)
// 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'),
})
} catch (err) {
throw new Error(
`Failed to fetch periodiseringsfond balances: ${err instanceof Error ? err.message : String(err)}`,
)
}
const byAccount = new Map<string, { closing: number; opening: number }>()
for (const row of data) {
const delta =
(Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
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, { 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: roundOre(closing),
opening_balance: roundOre(opening),
must_return_this_year: cohortYear + PFOND_MAX_HOLD_YEARS <= closingYear,
})
}
fonder.sort((a, b) => a.cohort_year - b.cohort_year)
return fonder
}
/**
* Derive the cohort year from a BAS account number. Returns null for accounts
* that don't follow the '212X' convention (e.g. 2110 = grouping account, no
* specific cohort).
*/
function cohortYearFromAccount(accountNumber: string): number | null {
if (!/^212\d$/.test(accountNumber)) return null
const lastDigit = parseInt(accountNumber.slice(-1), 10)
// BAS 2020: 2129 represents 2019 by convention; 2128 = 2028.
if (lastDigit === 9) return 2019
// For 0-8, the cohort year is in the 2020s. As fiscal years extend past
// 2029 the convention will recycle (2120 might mean 2030 then); cap at
// 2020-decade interpretation for now and surface ambiguity in a warning.
return 2020 + lastDigit
}
export interface PfondAteforingProposal {
/** One proposal per individual fond being returned. The wizard renders these
* as separate cards; mandatory ones (must_return_this_year) cannot be skipped. */
proposals: ProposedDisposition[]
/** Total schablonintäkt computed on the OPENING balance of all 21xx accounts.
* This is NOT booked: it goes into INK2 as a manual adjustment to taxable
* result. Caller (bolagsskatt-calculator) reads this to add to taxable result. */
schablonintaktAmount: number
}
/**
* Propose periodiseringsfond reversals. Forces reversal of any fond reaching
* its 6-year limit; offers optional reversal of newer fonder. Also computes
* the schablonintäkt on the OPENING balance of all 21xx accounts (per IL 30
* kap 6a §: 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 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[],
options: {
/** Map from account_number to desired return amount. Omit entries the
* user does not want to return (mandatory ones are returned regardless). */
returns?: Record<string, number>
/** Schablonintäkt rate as a decimal (0.03 for 3 %). Applied to the
* opening balance of every 21xx account. */
schablonintaktRate: number
},
): PfondAteforingProposal {
const proposals: ProposedDisposition[] = []
let schablonintaktAmount = 0
for (const fond of existingFonder) {
// 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
const returnAmount = isMandatory
? fond.balance // forced full reversal
: Math.min(Math.max(0, Math.floor(desiredReturn)), fond.balance)
if (returnAmount <= 0) continue
const warnings: string[] = []
if (isMandatory) {
warnings.push(
`Periodiseringsfond ${fond.cohort_year} har nått 6-årsgränsen och måste återföras.`,
)
}
proposals.push({
kind: 'periodiseringsfond_ateforing',
label: `Återföring periodiseringsfond ${fond.cohort_year}`,
description: `Debet ${fond.account_number}, kredit 8819.`,
amount: returnAmount,
lines: [
{
account_number: fond.account_number,
debit_amount: returnAmount,
credit_amount: 0,
line_description: `Återföring periodiseringsfond ${fond.cohort_year}`,
},
{
account_number: '8819',
debit_amount: 0,
credit_amount: returnAmount,
line_description: `Återföring periodiseringsfond ${fond.cohort_year}`,
},
],
warnings,
computation: {
cohort_year: fond.cohort_year,
opening_balance: fond.balance,
return_amount: returnAmount,
was_mandatory: isMandatory,
},
required: isMandatory,
})
}
return {
proposals,
schablonintaktAmount: Math.round(schablonintaktAmount),
}
}