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:
co-authored by
Claude Fable 5
parent
425674ff35
commit
46b8e2bfea
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user