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
@@ -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