diff --git a/DECISIONS.md b/DECISIONS.md index c5887e40..073a589a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -206,6 +206,10 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts index 4ab31ef7..0e769895 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts @@ -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 = { 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 +/** 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[0], companyId: string, - fiscalPeriodId: string, + period: ValidatedPeriod, fiscalYear: number, ): Promise { + 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 diff --git a/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts b/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts index 3868c44f..5b5d94e2 100644 --- a/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts +++ b/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts @@ -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) { + const makeBuilder = (table: string) => { + const handler: ProxyHandler = { + 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 = { - 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 () => { diff --git a/lib/bokslut/__tests__/periodiseringsfond-service.test.ts b/lib/bokslut/__tests__/periodiseringsfond-service.test.ts index 2cb7522f..dd01a8c6 100644 --- a/lib/bokslut/__tests__/periodiseringsfond-service.test.ts +++ b/lib/bokslut/__tests__/periodiseringsfond-service.test.ts @@ -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 = {} - 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() + }) }) diff --git a/lib/bokslut/dispositions-proposal-builder.ts b/lib/bokslut/dispositions-proposal-builder.ts index e9ef8ff2..75eed00a 100644 --- a/lib/bokslut/dispositions-proposal-builder.ts +++ b/lib/bokslut/dispositions-proposal-builder.ts @@ -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 { 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, diff --git a/lib/bokslut/reserves/periodiseringsfond-service.ts b/lib/bokslut/reserves/periodiseringsfond-service.ts index 49c557e9..e4d6cf1d 100644 --- a/lib/bokslut/reserves/periodiseringsfond-service.ts +++ b/lib/bokslut/reserves/periodiseringsfond-service.ts @@ -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 = { + 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 { 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({ 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() + const byAccount = new Map() 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 - /** 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) { diff --git a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts index 55a5ff9b..a69ea23f 100644 --- a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts +++ b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts @@ -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 { +): Promise { 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({ + 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 } /** diff --git a/lib/reports/__tests__/trial-balance.test.ts b/lib/reports/__tests__/trial-balance.test.ts index 1e13e99b..2151d6b4 100644 --- a/lib/reports/__tests__/trial-balance.test.ts +++ b/lib/reports/__tests__/trial-balance.test.ts @@ -11,7 +11,7 @@ let mockResults: Record function makeBuilder(tableName: string) { const b: Record = {} - 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: [ diff --git a/lib/reports/trial-balance.ts b/lib/reports/trial-balance.ts index 7a732257..51ac6953 100644 --- a/lib/reports/trial-balance.ts +++ b/lib/reports/trial-balance.ts @@ -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