Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
148 lines
5.8 KiB
TypeScript
148 lines
5.8 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('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: '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: '8910', debit_amount: 123_600, credit_amount: 0 }, // skatt : ignored
|
||
{ account_number: '2124', debit_amount: 0, credit_amount: 150_000 }, // skuld : ignored
|
||
]
|
||
const result = { data: rows, error: null }
|
||
const handler: ProxyHandler<object> = {
|
||
get(_t, prop) {
|
||
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
|
||
return () => new Proxy({}, handler)
|
||
},
|
||
}
|
||
const client = { from: () => new Proxy({}, handler) } as unknown as Parameters<
|
||
typeof sumPostedYearEndDispositions
|
||
>[0]
|
||
|
||
const effect = await sumPostedYearEndDispositions(client, 'co', 'fp')
|
||
expect(effect).toBe(-174_000) // -150k + 20k - 39k - 5k
|
||
})
|
||
|
||
it('truncates taxable result to whole krona 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')
|
||
|
||
// floor(100_999.99) = 100_999, × 0.206 = 20805.794 → round = 20_806
|
||
expect(result!.amount).toBe(20_806)
|
||
})
|
||
})
|