diff --git a/DECISIONS.md b/DECISIONS.md index ad792194..f9bedd45 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -629,3 +629,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-27] SKV 403 classification (#1155 item 1): the MuleSoft APIGW body "The required scopes are not authorized" is checked BEFORE the token-scope patterns and maps to ACCESS_DENIED (user mode) / SYSTEM_AUTH_FAILED with a subscription message (system mode). Substring matching on 'required scope' collided with it and produced MISSING_SCOPE, which is a RECONSENT code, so every reconnect re-flagged the token row. Token-scope detection is now a positive match on invalid_scope or SKV's documented "required scope has been requested" sentence, not a loose substring. [2026-07-27] Docs export (#1247): scripts/export-docs-to-website.mts stubs `server-only` via a Module._load hook instead of untangling the import chain. The chain is real (lib/api/v1/load-routes -> every v1 route -> lib/init -> posthog-observability -> posthog-server) and the script only reads exported markdown builders, so breaking the chain would mean restructuring route imports for a build-time script's benefit. [2026-07-27] Webhook delivery latency (#1201): implemented option (a), an emit-triggered kick of the existing dispatchDueDeliveries, not option (b), an authenticated SSE stream over event_log. The kick is a new lib/webhooks/dispatch-kick.ts wired into fanOutToWebhooks plus the two routes that enqueue a delivery directly (the :test verb and the manual delivery retry), and it does NOT close #1201: the issue asks for a realtime stream for API consumers and that remains open. Three constraints shaped it. It is never awaited: eventBus.emit is awaited at ~99 call sites including journal_entry.committed, and each delivery can burn a 10 s receiver timeout, so awaiting would put a stranger's HTTP endpoint on the critical path of committing a verifikat. It is coalesced per function instance, because a bulk operation emits once per row and would otherwise schedule one claim round trip per row. Its batch size is 5 rather than the cron's 50, because this work runs on the tail of a user-facing request. The SKIP LOCKED claim keeps a kick and the cron from claiming the same row at the same moment, but that is a claim-time guarantee only: the RPC autocommits before any POST, so a later cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an earlier serial loop. Delivery stays at-least-once as the public docs already promise, and the kick's batch of 5 opens a narrower window than the cron's existing batch of 50 against the same 20 s stuck threshold; an early draft of this change claimed double delivery was impossible, which was wrong and is now corrected in the code comments. Scheduling uses next/server after() with a deferred-microtask fallback outside a request scope, mirroring the enable-banking callback; the fallback must stay deferred rather than inline, or the coalescing flag clears before the next kick in the same tick can see it. No API_V1_VERSION bump: no new event types and no payload change, only latency. +[2026-07-27] Bolagsskatt add-back (#1051): sumPostedYearEndDispositions now adds back 78xx planenlig avskrivning alongside 88xx and 7533, and excludes fiscal_periods.closing_entry_id from its fetch. Shipping only this "Stage 1" half of the issue: it corrects the tax base and the periodiseringsfond 25 % cap with no migration and no displayed-figure change. The issue's other half (making /rapporter show bokslut entries by moving generateIncomeStatement to excludeFinalClosingEntry) is deliberately NOT done here: it duplicates the exclusion in the kpi_report_aggregates RPC (so it needs a migration plus a pg test), it changes displayed profit for every company that ran the bokslut flow, and it requires removing the add-back at four call sites, including the one that caused the original too-high-tax customer bug. The closing-entry exclusion is part of Stage 1 rather than a follow-up because closing verifikat do carry 78xx/88xx/7533 reversal lines on production, so without it the new add-back silently cancels itself once the year is closed. The issue's stated constraint that source_type='year_end' is load-bearing for the iXBRL RR/BR split is stale: build-input.ts and arsredovisning/build-data.ts already moved to excludeFinalClosingEntry. diff --git a/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts b/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts index 4e6a602c..7e55be9a 100644 --- a/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts +++ b/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts @@ -137,15 +137,20 @@ describe('calculateBolagsskatt', () => { >[0] } - it('sumPostedYearEndDispositions adds back periodiseringsfond + överavskrivning (class-88) + SLP, ignores tax/liability', async () => { + it('sumPostedYearEndDispositions adds back periodiseringsfond + överavskrivning (class-88) + SLP + planenlig avskrivning (78xx), 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. + // statement), so the tax base must add their P&L effect back. Planenlig + // avskrivning booked from the bokslut flow carries the same source_type + // and must be added back on the same grounds (#1051); its 12xx + // ack.-avskrivning counter-leg is a balance-sheet account and must not. const rows = [ { 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: '7833', debit_amount: 3_716, credit_amount: 0 }, // avskrivning -3716 + { account_number: '1229', debit_amount: 0, credit_amount: 3_716 }, // ack. avskr.: ignored { account_number: '8910', debit_amount: 123_600, credit_amount: 0 }, // skatt : ignored { account_number: '2124', debit_amount: 0, credit_amount: 150_000 }, // skuld : ignored ] @@ -158,11 +163,164 @@ describe('calculateBolagsskatt', () => { }) const effect = await sumPostedYearEndDispositions(client, 'co', 'fp') - expect(effect.total).toBe(-174_000) // -150k + 20k - 39k - 5k + expect(effect.total).toBe(-177_716) // -150k + 20k - 39k - 5k - 3 716 expect(effect.slpPortion).toBe(-5_000) expect(effect.taxProvisionPortion).toBe(123_600) }) + it('sumPostedYearEndDispositions ignores the 12xx counter-leg of a depreciation voucher', async () => { + // Only the 78xx cost leg belongs in the tax base. If the 1229 credit were + // summed too the voucher would net to zero and the add-back would vanish. + const client = makeQueuedClient({ + journal_entries: [ + { data: [{ id: 'ye-depr-1' }], error: null }, + { data: [], error: null }, + ], + journal_entry_lines: [ + { + data: [ + { account_number: '7832', debit_amount: 12_500, credit_amount: 0 }, + { account_number: '1229', debit_amount: 0, credit_amount: 12_500 }, + ], + error: null, + }, + ], + }) + + const effect = await sumPostedYearEndDispositions(client, 'co', 'fp') + expect(effect.total).toBe(-12_500) + expect(effect.slpPortion).toBe(0) + expect(effect.taxProvisionPortion).toBe(0) + }) + + it('sumPostedYearEndDispositions excludes the final closing verifikat from the fetch', async () => { + // The bokslutsverifikation is source_type='year_end' as well and reverses + // every P&L account (verified on production: closing entries do carry + // 78xx, 88xx and 7533 lines). Counting it would cancel the add-back once + // the year is closed, so it must be filtered out at the entry level. + const calls: Array<[string, unknown[]]> = [] + const makeRecorder = (table: string) => { + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve( + table === 'fiscal_periods' + ? { data: { closing_entry_id: 'closing-1' }, error: null } + : { data: [], error: null }, + ) + } + return (...args: unknown[]) => { + if (table === 'journal_entries') calls.push([String(prop), args]) + return new Proxy({}, handler) + } + }, + } + return new Proxy({}, handler) + } + const client = { from: (table: string) => makeRecorder(table) } as unknown as Parameters< + typeof sumPostedYearEndDispositions + >[0] + + await sumPostedYearEndDispositions(client, 'co', 'fp') + + expect(calls).toContainEqual(['neq', ['id', 'closing-1']]) + }) + + it('sumPostedYearEndDispositions scopes the closing-entry lookup to the company', async () => { + // Every sibling query in this function carries the tenant scope and + // service-role paths have no RLS to fall back on, so the fiscal_periods + // read must filter company_id even though id is the primary key. + const calls: Array<[string, unknown[]]> = [] + const makeRecorder = (table: string) => { + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve( + table === 'fiscal_periods' + ? { data: { closing_entry_id: null }, error: null } + : { data: [], error: null }, + ) + } + return (...args: unknown[]) => { + if (table === 'fiscal_periods') calls.push([String(prop), args]) + return new Proxy({}, handler) + } + }, + } + return new Proxy({}, handler) + } + const client = { from: (table: string) => makeRecorder(table) } as unknown as Parameters< + typeof sumPostedYearEndDispositions + >[0] + + await sumPostedYearEndDispositions(client, 'co', 'fp') + + expect(calls).toContainEqual(['eq', ['company_id', 'co']]) + expect(calls).toContainEqual(['eq', ['id', 'fp']]) + }) + + it('sumPostedYearEndDispositions throws when the closing-entry lookup fails', async () => { + // Swallowing the error would fall through to closingEntryId = null, which + // silently re-admits the closing entry's 78xx/88xx reversals and + // understates the tax base. A wrong bolagsskatt is worse than a failure. + const client = { + from: (table: string) => { + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve( + table === 'fiscal_periods' + ? { data: null, error: { message: 'permission denied' } } + : { data: [], error: null }, + ) + } + return () => new Proxy({}, handler) + }, + } + return new Proxy({}, handler) + }, + } as unknown as Parameters[0] + + await expect(sumPostedYearEndDispositions(client, 'co', 'fp')).rejects.toThrow( + /Failed to read posted dispositions/, + ) + }) + + it('sumPostedYearEndDispositions applies no closing-entry filter for an unclosed period', async () => { + // fiscal_periods.closing_entry_id is null until the year is closed; the + // fetch must then run unfiltered rather than with a null-id predicate. + const calls: Array<[string, unknown[]]> = [] + const makeRecorder = (table: string) => { + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve( + table === 'fiscal_periods' + ? { data: { closing_entry_id: null }, error: null } + : { data: [], error: null }, + ) + } + return (...args: unknown[]) => { + if (table === 'journal_entries') calls.push([String(prop), args]) + return new Proxy({}, handler) + } + }, + } + return new Proxy({}, handler) + } + const client = { from: (table: string) => makeRecorder(table) } as unknown as Parameters< + typeof sumPostedYearEndDispositions + >[0] + + await sumPostedYearEndDispositions(client, 'co', 'fp') + + expect(calls.some(([method]) => method === 'neq')).toBe(false) + }) + 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 diff --git a/lib/bokslut/__tests__/k3-framework-dispositions.test.ts b/lib/bokslut/__tests__/k3-framework-dispositions.test.ts index b3498a2a..ceee86b0 100644 --- a/lib/bokslut/__tests__/k3-framework-dispositions.test.ts +++ b/lib/bokslut/__tests__/k3-framework-dispositions.test.ts @@ -43,6 +43,7 @@ function makeSupabase(opts: { select: () => ({ eq: () => ({ eq: () => ({ + // The builder's own period read. single: () => Promise.resolve({ data: { @@ -53,6 +54,11 @@ function makeSupabase(opts: { }, error: null, }), + // sumPostedYearEndDispositions reads closing_entry_id with + // .eq('id').eq('company_id').maybeSingle() to exclude the final + // bokslutsverifikation from the add-back (#1051). + maybeSingle: () => + Promise.resolve({ data: { closing_entry_id: null }, error: null }), }), }), }), diff --git a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts index ee08ff36..287c617c 100644 --- a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts +++ b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts @@ -53,8 +53,9 @@ export interface BolagsskattComputation { } export interface PostedDispositionsEffect { - /** Signed P&L effect of every effective posted disposition (class 88 + - * 7533): avsättning lowers it, återföring raises it. */ + /** Signed P&L effect of every effective posted year-end P&L booking (class + * 88 + 7533 + 78xx planenlig avskrivning): a cost lowers it, a å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 @@ -66,14 +67,28 @@ export interface PostedDispositionsEffect { } /** - * Sum the P&L effect of bokslutsdispositioner already posted in this period. + * Sum the P&L effect of bokslut-flow postings already made in this period. * * Dispositioner (periodiseringsfond avsättning/återföring, SLP, över- - * avskrivningar) are booked with source_type='year_end', which - * generateIncomeStatement EXCLUDES: so net_result alone overstates resultat - * före skatt. The tax base must add them back. We sum class 88 - * (bokslutsdispositioner) plus 7533 (SLP); tax (89xx) and the closing entry - * (8999/2099) are intentionally left out. + * avskrivningar) AND planenlig avskrivning are booked with + * source_type='year_end', which generateIncomeStatement EXCLUDES: so + * net_result alone overstates resultat före skatt. The tax base must add them + * back. We sum class 88 (bokslutsdispositioner), 7533 (SLP) and class 78 + * (planenlig avskrivning, posted by lib/bokslut/assets/depreciation-engine.ts); + * tax (89xx) is intentionally left out because the base is resultat FÖRE + * skatt, and the final closing entry is excluded outright (see below). + * + * 78xx is included since #1051: without it the bolagsskatt base and the + * periodiseringsfond 25 % cap are computed on a resultat före skatt that + * silently omits every depreciation krona booked from the bokslut flow. + * Only the 78xx cost leg moves the base; its 12xx ack.-avskrivning + * counter-leg is a balance-sheet account and is ignored here. + * + * The period's final bokslutsverifikation (fiscal_periods.closing_entry_id) + * is excluded from the fetch. It also carries source_type='year_end' and it + * reverses every P&L account, 78xx / 88xx / 7533 included, so counting it + * would cancel the very add-back this function exists to produce once the + * year is closed. * * A corrected year_end entry is counted through its replacement: the * original is status='reversed' (skipped here) and the income statement @@ -98,15 +113,38 @@ export async function sumPostedYearEndDispositions( // Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts). let data: Row[] try { + // The final bokslutsverifikation is source_type='year_end' too and + // reverses every P&L account. Excluding it keeps the add-back intact + // after the year is closed (see docstring). + // + // company_id is filtered even though id is the primary key: every sibling + // query in this function carries the tenant scope, and service-role paths + // have no RLS to fall back on. A read failure must NOT fall through to + // closingEntryId = null either: that silently re-admits the closing + // entry's 78xx/88xx reversals and understates the tax base, which is the + // exact failure this fetch exists to prevent. Fail loudly instead; the + // surrounding catch turns it into 'Failed to read posted dispositions'. + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('closing_entry_id') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .maybeSingle() + if (periodError) throw periodError + const closingEntryId = (period as { closing_entry_id?: string | null } | null) + ?.closing_entry_id ?? null + data = await fetchEntryLines({ supabase, lineColumns: 'account_number, debit_amount, credit_amount', - filterEntries: (q: EntryLinesQuery) => - q + filterEntries: (q: EntryLinesQuery) => { + const base = q .eq('company_id', companyId) .eq('fiscal_period_id', fiscalPeriodId) .eq('status', 'posted') - .eq('source_type', 'year_end'), + .eq('source_type', 'year_end') + return closingEntryId ? base.neq('id', closingEntryId) : base + }, attachEntriesAs: null, }) @@ -156,7 +194,10 @@ export async function sumPostedYearEndDispositions( for (const row of data) { const acc = row.account_number const delta = (Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0) - if (acc.startsWith('88') || acc === '7533') { + // Deliberately NOT a class 3-8 sweep: a correction entry pulled in below + // can carry any account, and only these three groups are bokslut-flow + // P&L postings the income statement dropped. + if (acc.startsWith('88') || acc.startsWith('78') || acc === '7533') { effect += delta if (acc === '7533') slp += delta }