* 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>
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
|
||
vi.mock('@/lib/reports/income-statement', () => ({
|
||
generateIncomeStatement: vi.fn(),
|
||
}))
|
||
|
||
import {
|
||
calculateBolagsskatt,
|
||
BOLAGSSKATT_RATE,
|
||
sumPostedYearEndDispositions,
|
||
} from '../tax-provision/bolagsskatt-calculator'
|
||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||
|
||
const NOOP_CLIENT = {} as Parameters<typeof calculateBolagsskatt>[0]
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
describe('calculateBolagsskatt', () => {
|
||
it('applies 20.6% to positive result and posts 8910/2512', async () => {
|
||
vi.mocked(generateIncomeStatement).mockResolvedValue({
|
||
net_result: 500_000,
|
||
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
|
||
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result!.amount).toBe(Math.round(500_000 * BOLAGSSKATT_RATE)) // 103000
|
||
const debit = result!.lines.find((l) => l.account_number === '8910')!
|
||
const credit = result!.lines.find((l) => l.account_number === '2512')!
|
||
expect(debit.debit_amount).toBe(103_000)
|
||
expect(credit.credit_amount).toBe(103_000)
|
||
})
|
||
|
||
it('returns a zero-amount proposal for loss year (no entry posted)', async () => {
|
||
vi.mocked(generateIncomeStatement).mockResolvedValue({
|
||
net_result: -50_000,
|
||
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
|
||
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result!.amount).toBe(0)
|
||
expect(result!.lines).toEqual([])
|
||
expect(result!.description).toContain('förlust')
|
||
})
|
||
|
||
it('adds non-deductible expenses to taxable result', async () => {
|
||
vi.mocked(generateIncomeStatement).mockResolvedValue({
|
||
net_result: 100_000,
|
||
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
|
||
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
|
||
manualAdjustments: { nonDeductibleExpenses: 50_000 },
|
||
})
|
||
|
||
// (100_000 + 50_000) × 0.206 = 30_900
|
||
expect(result!.amount).toBe(30_900)
|
||
})
|
||
|
||
it('subtracts non-taxable income from taxable result', async () => {
|
||
vi.mocked(generateIncomeStatement).mockResolvedValue({
|
||
net_result: 100_000,
|
||
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
|
||
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
|
||
manualAdjustments: { nonTaxableIncome: 40_000 },
|
||
})
|
||
|
||
// (100_000 - 40_000) × 0.206 = 12_360
|
||
expect(result!.amount).toBe(12_360)
|
||
})
|
||
|
||
it('adds schablonintäkt on periodiseringsfond to taxable result', async () => {
|
||
vi.mocked(generateIncomeStatement).mockResolvedValue({
|
||
net_result: 200_000,
|
||
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
|
||
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
|
||
manualAdjustments: { schablonintaktPeriodiseringsfond: 3_000 },
|
||
})
|
||
|
||
// (200_000 + 3_000) × 0.206 = 41_818
|
||
expect(result!.amount).toBe(41_818)
|
||
})
|
||
|
||
it('uses resultBeforeTaxOverride and does NOT read the income statement', async () => {
|
||
// Preview mode: the dispositions builder passes the post-disposition base
|
||
// directly. The income statement (pre-disposition) must not be consulted.
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
|
||
resultBeforeTaxOverride: 750_000,
|
||
})
|
||
|
||
expect(result!.amount).toBe(Math.round(750_000 * BOLAGSSKATT_RATE)) // 154_500
|
||
expect(generateIncomeStatement).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('combines resultBeforeTaxOverride with manual adjustments', async () => {
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
|
||
resultBeforeTaxOverride: 750_000,
|
||
manualAdjustments: { schablonintaktPeriodiseringsfond: 3_000 },
|
||
})
|
||
|
||
// (750_000 + 3_000) × 0.206 = 155_118
|
||
expect(result!.amount).toBe(155_118)
|
||
expect(generateIncomeStatement).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('calculates Miles tax after both non-deductible account addbacks', async () => {
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
|
||
resultBeforeTaxOverride: 592_722.21,
|
||
manualAdjustments: { nonDeductibleExpenses: 2_994 + 2_250 },
|
||
})
|
||
|
||
expect(result?.computation?.taxableResultClamped).toBe(597_960)
|
||
expect(result?.amount).toBe(123_180)
|
||
})
|
||
|
||
/** Table-keyed FIFO client: consumption order per table mirrors the
|
||
* two-step entry-lines fetch plus the reversed-ids lookup. */
|
||
function makeQueuedClient(queues: Record<string, { data: unknown; error: unknown }[]>) {
|
||
const makeBuilder = (table: string) => {
|
||
const handler: ProxyHandler<object> = {
|
||
get(_t, prop) {
|
||
if (prop === 'then') {
|
||
return (resolve: (v: unknown) => void) =>
|
||
resolve(queues[table]?.shift() ?? { data: [], error: null })
|
||
}
|
||
return () => new Proxy({}, handler)
|
||
},
|
||
}
|
||
return new Proxy({}, handler)
|
||
}
|
||
return { from: (table: string) => makeBuilder(table) } as unknown as Parameters<
|
||
typeof sumPostedYearEndDispositions
|
||
>[0]
|
||
}
|
||
|
||
it('sumPostedYearEndDispositions adds back periodiseringsfond + överavskrivning (class-88) + SLP + 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. 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
|
||
]
|
||
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.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
|
||
// 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)
|
||
expect(effect.taxProvisionPortion).toBe(0)
|
||
})
|
||
|
||
it('rounds taxable result down to a whole 10 SEK before applying tax', async () => {
|
||
vi.mocked(generateIncomeStatement).mockResolvedValue({
|
||
net_result: 100_999.99,
|
||
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
|
||
|
||
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
|
||
|
||
expect(result?.computation?.taxableResultClamped).toBe(100_990)
|
||
expect(result!.amount).toBe(20_804)
|
||
})
|
||
})
|