fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base (#1253)
* fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base sumPostedYearEndDispositions reconstructs resultat fore skatt for the tax calculation, because generateIncomeStatement excludes every source_type='year_end' entry. It summed class 88 and 7533 but not 78xx, so planenlig avskrivning posted by the bokslut flow (lib/bokslut/assets/depreciation-engine.ts) was dropped from the income statement and never added back. The bolagsskatt base and the periodiseringsfond 25 % cap were therefore computed on an overstated result: tax too high by roughly 20.6 % of the depreciation. Also exclude the period's final bokslutsverifikation from the fetch. It carries source_type='year_end' as well and reverses every P&L account, 78xx/88xx/7533 included (verified against production closing entries), so once the year is closed it would cancel the add-back this function exists to produce. That hazard already applied to 88xx and 7533; the fix closes it for all three rather than widening it. Scope is deliberately the tax base only. Making the standalone resultatrakning show bokslut entries is a separate, larger change: the same exclusion is duplicated in the kpi_report_aggregates RPC, it moves displayed profit for every company that ran the bokslut flow, and it means removing the add-back at four call sites. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): scope the closing-entry lookup to the company and fail loudly Review (CodeRabbit + the compliance swarm, ASVS V8.2.1) flagged the new fiscal_periods read in sumPostedYearEndDispositions on two counts, both fair. It filtered only on the period id while every sibling query in the same function carries the tenant scope. Primary key or not, service-role paths have no RLS to fall back on and the repo's rule is to filter company_id explicitly, so it now does. It also discarded the query error. That mattered more than it looks: a failed read fell through to closingEntryId = null, which silently re-admits the closing verifikat's 78xx/88xx reversals and understates the tax base, i.e. exactly the failure this lookup was added to prevent. It now throws, and the surrounding catch turns it into the existing 'Failed to read posted dispositions' error. A wrong bolagsskatt is worse than a loud failure. Two regression tests: the lookup carries both eq filters, and a lookup failure propagates instead of degrading to a wrong number. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
49ff234954
commit
579f48752e
@@ -629,3 +629,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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 <x> 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.
|
||||
|
||||
@@ -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<object> = {
|
||||
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<object> = {
|
||||
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<object> = {
|
||||
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<typeof sumPostedYearEndDispositions>[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<object> = {
|
||||
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
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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<Row>({
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user