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>
189 lines
5.9 KiB
TypeScript
189 lines
5.9 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import {
|
||
proposeAvsattning,
|
||
proposeAteforing,
|
||
getPeriodiseringsfondCohortAccount,
|
||
PFOND_AB_RATE,
|
||
PFOND_MAX_HOLD_YEARS,
|
||
type ExistingFond,
|
||
} from '../reserves/periodiseringsfond-service'
|
||
|
||
describe('getPeriodiseringsfondCohortAccount', () => {
|
||
it('maps fiscal year to BAS 212X account', () => {
|
||
expect(getPeriodiseringsfondCohortAccount(2020)).toBe('2120')
|
||
expect(getPeriodiseringsfondCohortAccount(2025)).toBe('2125')
|
||
expect(getPeriodiseringsfondCohortAccount(2026)).toBe('2126')
|
||
expect(getPeriodiseringsfondCohortAccount(2027)).toBe('2127')
|
||
})
|
||
|
||
it('returns 2129 for 2019 per BAS collision rule', () => {
|
||
expect(getPeriodiseringsfondCohortAccount(2019)).toBe('2129')
|
||
})
|
||
})
|
||
|
||
describe('proposeAvsattning', () => {
|
||
it('caps avsättning at 25% of base', () => {
|
||
const result = proposeAvsattning({
|
||
skattemassigtResultatBeforeAvsattning: 400_000,
|
||
desiredAmount: 200_000, // user asks for 50%: should be capped
|
||
fiscalYear: 2025,
|
||
})
|
||
expect(result).not.toBeNull()
|
||
expect(result!.amount).toBe(100_000) // 25% of 400_000
|
||
expect(result!.warnings).toHaveLength(1)
|
||
expect(result!.warnings[0]).toContain('25 %')
|
||
expect(result!.lines[0].account_number).toBe('8811')
|
||
expect(result!.lines[1].account_number).toBe('2125')
|
||
})
|
||
|
||
it('defaults to maximum when desiredAmount is omitted', () => {
|
||
const result = proposeAvsattning({
|
||
skattemassigtResultatBeforeAvsattning: 400_000,
|
||
fiscalYear: 2025,
|
||
})
|
||
expect(result!.amount).toBe(100_000)
|
||
expect(result!.warnings).toHaveLength(0)
|
||
})
|
||
|
||
it('honors a smaller desiredAmount', () => {
|
||
const result = proposeAvsattning({
|
||
skattemassigtResultatBeforeAvsattning: 400_000,
|
||
desiredAmount: 30_000,
|
||
fiscalYear: 2025,
|
||
})
|
||
expect(result!.amount).toBe(30_000)
|
||
expect(result!.warnings).toHaveLength(0)
|
||
})
|
||
|
||
it('returns null when base is negative (loss year)', () => {
|
||
expect(
|
||
proposeAvsattning({
|
||
skattemassigtResultatBeforeAvsattning: -100_000,
|
||
fiscalYear: 2025,
|
||
}),
|
||
).toBeNull()
|
||
})
|
||
|
||
it('returns null when desired is zero', () => {
|
||
expect(
|
||
proposeAvsattning({
|
||
skattemassigtResultatBeforeAvsattning: 400_000,
|
||
desiredAmount: 0,
|
||
fiscalYear: 2025,
|
||
}),
|
||
).toBeNull()
|
||
})
|
||
|
||
it('emits balanced lines (debit 8811 = credit 21XX)', () => {
|
||
const result = proposeAvsattning({
|
||
skattemassigtResultatBeforeAvsattning: 400_000,
|
||
fiscalYear: 2026,
|
||
})
|
||
expect(result!.lines).toHaveLength(2)
|
||
const totalDebit = result!.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||
const totalCredit = result!.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||
expect(totalDebit).toBe(totalCredit)
|
||
expect(result!.lines[1].account_number).toBe('2126')
|
||
})
|
||
|
||
it('uses fiscal year for cohort account number', () => {
|
||
const result = proposeAvsattning({
|
||
skattemassigtResultatBeforeAvsattning: 100_000,
|
||
fiscalYear: 2027,
|
||
})
|
||
expect(result!.lines[1].account_number).toBe('2127')
|
||
})
|
||
})
|
||
|
||
describe('proposeAteforing', () => {
|
||
it('forces full reversal of 6+ year old fonder and marks them required', () => {
|
||
const fonder: ExistingFond[] = [
|
||
{
|
||
account_number: '2120',
|
||
cohort_year: 2020,
|
||
balance: 50_000,
|
||
must_return_this_year: true,
|
||
},
|
||
]
|
||
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
|
||
expect(result.proposals).toHaveLength(1)
|
||
expect(result.proposals[0].amount).toBe(50_000)
|
||
expect(result.proposals[0].required).toBe(true)
|
||
expect(result.proposals[0].warnings[0]).toContain('6-årsgränsen')
|
||
// 50_000 × 0.03 = 1500
|
||
expect(result.schablonintaktAmount).toBe(1_500)
|
||
})
|
||
|
||
it('skips non-mandatory fonder when no return amount requested', () => {
|
||
const fonder: ExistingFond[] = [
|
||
{
|
||
account_number: '2122',
|
||
cohort_year: 2022,
|
||
balance: 100_000,
|
||
must_return_this_year: false,
|
||
},
|
||
]
|
||
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
|
||
expect(result.proposals).toHaveLength(0)
|
||
// Schablonintäkt is computed regardless of return decision
|
||
expect(result.schablonintaktAmount).toBe(3_000)
|
||
})
|
||
|
||
it('returns the requested optional amount when user opts in', () => {
|
||
const fonder: ExistingFond[] = [
|
||
{
|
||
account_number: '2123',
|
||
cohort_year: 2023,
|
||
balance: 80_000,
|
||
must_return_this_year: false,
|
||
},
|
||
]
|
||
const result = proposeAteforing(fonder, {
|
||
returns: { '2123': 50_000 },
|
||
schablonintaktRate: 0.03,
|
||
})
|
||
expect(result.proposals).toHaveLength(1)
|
||
expect(result.proposals[0].amount).toBe(50_000)
|
||
expect(result.proposals[0].required).toBeFalsy()
|
||
})
|
||
|
||
it('caps optional returns to the actual balance', () => {
|
||
const fonder: ExistingFond[] = [
|
||
{
|
||
account_number: '2124',
|
||
cohort_year: 2024,
|
||
balance: 30_000,
|
||
must_return_this_year: false,
|
||
},
|
||
]
|
||
const result = proposeAteforing(fonder, {
|
||
returns: { '2124': 100_000 },
|
||
schablonintaktRate: 0.03,
|
||
})
|
||
expect(result.proposals[0].amount).toBe(30_000) // capped to balance
|
||
})
|
||
|
||
it('emits balanced lines (debit 21XX = credit 8819)', () => {
|
||
const fonder: ExistingFond[] = [
|
||
{
|
||
account_number: '2120',
|
||
cohort_year: 2020,
|
||
balance: 50_000,
|
||
must_return_this_year: true,
|
||
},
|
||
]
|
||
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
|
||
const lines = result.proposals[0].lines
|
||
expect(lines).toHaveLength(2)
|
||
expect(lines[0].account_number).toBe('2120')
|
||
expect(lines[0].debit_amount).toBe(50_000)
|
||
expect(lines[1].account_number).toBe('8819')
|
||
expect(lines[1].credit_amount).toBe(50_000)
|
||
})
|
||
|
||
it('exposes constants used by callers', () => {
|
||
expect(PFOND_AB_RATE).toBe(0.25)
|
||
expect(PFOND_MAX_HOLD_YEARS).toBe(6)
|
||
})
|
||
})
|