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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user