Files
accounted/lib/bookkeeping/__tests__/template-library.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
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>
2026-07-04 15:58:06 +02:00

297 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { applyTemplate, convertLibraryToBookingTemplate, getTemplateScope, LIBRARY_TEMPLATE_PREFIX, TEMPLATE_CATEGORY_LABELS } from '../template-library'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
function makeLibraryTemplate(lines: BookingTemplateLibraryLine[], overrides: Partial<BookingTemplateLibrary> = {}): BookingTemplateLibrary {
return {
id: 'tpl-1',
company_id: 'co-1',
team_id: null,
created_by: 'user-1',
name: 'Test template',
description: '',
category: 'other',
entity_type: 'all',
lines,
is_system: false,
is_active: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
describe('applyTemplate', () => {
it('creates simple two-line debit/credit entries', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '1630', label: 'Skattekonto', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
const result = applyTemplate(lines, 10000)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
account_number: '1630',
debit_amount: '10000.00',
credit_amount: '',
line_description: 'Skattekonto',
})
expect(result[1]).toEqual({
account_number: '1930',
debit_amount: '',
credit_amount: '10000.00',
line_description: 'Företagskonto',
})
})
it('calculates VAT correctly for reverse charge (EU purchase)', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '2614', label: 'Utgående moms', side: 'credit', type: 'vat', vat_rate: 0.25 },
{ account: '2645', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
// Total payment is 10000 SEK (no VAT on the payment itself for reverse charge)
const result = applyTemplate(lines, 10000)
expect(result).toHaveLength(4)
// Business line = 10000 * 1.0
expect(result[0].debit_amount).toBe('10000.00')
// VAT = 10000 * 0.25 / (1 + 0.25) = 2000
expect(result[1].credit_amount).toBe('2000.00')
expect(result[2].debit_amount).toBe('2000.00')
// Settlement = 10000
expect(result[3].credit_amount).toBe('10000.00')
})
it('handles representation with 25% input VAT', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
// Total paid = 1250 (1000 + 250 VAT)
const result = applyTemplate(lines, 1250)
expect(result).toHaveLength(3)
// Business = 1250 (the representation cost at full ratio)
expect(result[0].debit_amount).toBe('1250.00')
// VAT = 1250 * 0.25 / 1.25 = 250
expect(result[1].debit_amount).toBe('250.00')
// Settlement = 1250
expect(result[2].credit_amount).toBe('1250.00')
})
it('rounds monetary values to 2 decimal places', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1.0 },
]
// 333.33 should produce clean rounding
const result = applyTemplate(lines, 333.33)
expect(result[0].debit_amount).toBe('333.33')
// 333.33 * 0.25 / 1.25 = 66.666 → 66.67
expect(result[1].debit_amount).toBe('66.67')
expect(result[2].credit_amount).toBe('333.33')
})
})
describe('getTemplateScope', () => {
it('identifies system templates', () => {
expect(getTemplateScope({ is_system: true, team_id: null, company_id: null })).toBe('system')
})
it('identifies team templates', () => {
expect(getTemplateScope({ is_system: false, team_id: 'team-1', company_id: null })).toBe('team')
})
it('identifies company templates', () => {
expect(getTemplateScope({ is_system: false, team_id: null, company_id: 'comp-1' })).toBe('company')
})
})
describe('TEMPLATE_CATEGORY_LABELS', () => {
it('has labels for all categories', () => {
expect(Object.keys(TEMPLATE_CATEGORY_LABELS)).toHaveLength(9)
expect(TEMPLATE_CATEGORY_LABELS.eu_trade).toBe('EU-handel')
expect(TEMPLATE_CATEGORY_LABELS.tax_account).toBe('Skattekonto')
})
})
describe('convertLibraryToBookingTemplate', () => {
it('converts a simple 2-line business + settlement template', () => {
const tpl = makeLibraryTemplate([
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1 },
])
const result = convertLibraryToBookingTemplate(tpl)
expect(result).not.toBeNull()
expect(result!.id).toBe(`${LIBRARY_TEMPLATE_PREFIX}tpl-1`)
expect(result!.direction).toBe('expense')
expect(result!.debit_account).toBe('6072')
expect(result!.credit_account).toBe('1930')
expect(result!.vat_treatment).toBeNull()
})
it('identifies direction "income" when business line is on credit', () => {
const tpl = makeLibraryTemplate([
{ account: '3001', label: 'Försäljning', side: 'credit', type: 'business', ratio: 1 },
{ account: '1930', label: 'Företagskonto', side: 'debit', type: 'settlement', ratio: 1 },
])
const result = convertLibraryToBookingTemplate(tpl)
expect(result).not.toBeNull()
expect(result!.direction).toBe('income')
expect(result!.debit_account).toBe('1930')
expect(result!.credit_account).toBe('3001')
})
it.each([
[0.25, 'standard_25'],
[0.12, 'reduced_12'],
[0.06, 'reduced_6'],
] as const)('extracts VAT treatment for rate %f', (rate, treatment) => {
const tpl = makeLibraryTemplate([
{ account: '4010', label: 'Varor', side: 'debit', type: 'business', ratio: 1 },
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: rate },
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
])
const result = convertLibraryToBookingTemplate(tpl)
expect(result).not.toBeNull()
expect(result!.vat_treatment).toBe(treatment)
expect(result!.vat_rate).toBe(rate)
})
it('detects reverse charge via 2614 fictitious output VAT', () => {
const tpl = makeLibraryTemplate([
{ account: '4056', label: 'EU-varor', side: 'debit', type: 'business', ratio: 1 },
{ account: '2614', label: 'Utg. moms omv.', side: 'credit', type: 'vat', vat_rate: 0.25 },
{ account: '2645', label: 'Ing. moms omv.', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
])
const result = convertLibraryToBookingTemplate(tpl)
expect(result).not.toBeNull()
expect(result!.vat_treatment).toBe('reverse_charge')
})
it('returns null when there are 2 business lines', () => {
const tpl = makeLibraryTemplate([
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 0.5 },
{ account: '6073', label: 'B', side: 'debit', type: 'business', ratio: 0.5 },
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
])
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
it('returns null when there is no settlement line', () => {
const tpl = makeLibraryTemplate([
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 1 },
{ account: '2641', label: 'Moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
])
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
it('returns null when business and settlement are on the same side', () => {
const tpl = makeLibraryTemplate([
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 1 },
{ account: '1930', label: 'Bank', side: 'debit', type: 'settlement', ratio: 1 },
])
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
it('returns null when lines is not an array', () => {
const tpl = makeLibraryTemplate([], { lines: null as unknown as BookingTemplateLibraryLine[] })
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
// Real-world shape from before the editor defaulted new lines to 'vat': users
// would tap "add line" twice and end up with three lines all typed 'business'
// (the dropdown default at the time). The converter rightly rejects this;
// the transaction picker now still surfaces these templates and routes the
// click to the manual booking editor instead of hiding them.
it('returns null when every line is typed "business" (pre-#589 default)', () => {
const tpl = makeLibraryTemplate([
{ account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 1 },
{ account: '2640', label: 'Ingående moms', side: 'debit', type: 'business', ratio: 0.25 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'business', ratio: 1 },
])
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
})
describe('applyTemplate on shapes the converter rejects', () => {
// The transaction picker's fallback for unconvertible templates is to open
// the manual booking dialog with initialLines = applyTemplate(raw.lines, |amount|).
// These tests pin that path: even when the shape is too rich for the simple
// debit/credit summary, applyTemplate still produces a usable FormLine[].
it('still produces lines for a split-expense template (two business legs)', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 0.7 },
{ account: '6991', label: 'Övrigt', side: 'debit', type: 'business', ratio: 0.3 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1 },
]
const result = applyTemplate(lines, 1000)
expect(result).toHaveLength(3)
expect(result[0].debit_amount).toBe('700.00')
expect(result[1].debit_amount).toBe('300.00')
expect(result[2].credit_amount).toBe('1000.00')
})
it('still produces lines when every leg is typed "business"', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 1 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'business', ratio: 1 },
]
const result = applyTemplate(lines, 250)
expect(result).toHaveLength(2)
expect(result[0].debit_amount).toBe('250.00')
expect(result[1].credit_amount).toBe('250.00')
})
})
describe('library mall books its literal accounts (regression)', () => {
// A user's "Inbetalning från kund" mall is D 1930 (bank) / K 1510
// (kundfordran). The QuickReview fast path used to reduce a library template
// to a category + one account_override and book D 6991 / K 1930: or, with a
// VAT line, D 1930 / K 1930 / K 2611: silently dropping the chosen accounts.
// The transaction picker now routes EVERY library template through the
// journal-entry editor, whose lines come from applyTemplate. These tests pin
// the guarantee the editor path relies on: applyTemplate books exactly the
// accounts/sides the user defined, and never re-derives a counter account.
const AMOUNT = 5000
const customerPaymentLines: BookingTemplateLibraryLine[] = [
{ account: '1930', label: 'Inbetalning', side: 'debit', type: 'settlement', ratio: 1 },
{ account: '1510', label: 'Kundfordran', side: 'credit', type: 'business', ratio: 1 },
]
it('books exactly D 1930 / K 1510 with no re-derived accounts', () => {
const result = applyTemplate(customerPaymentLines, AMOUNT)
expect(result).toEqual([
{ account_number: '1930', debit_amount: '5000.00', credit_amount: '', line_description: 'Inbetalning' },
{ account_number: '1510', debit_amount: '', credit_amount: '5000.00', line_description: 'Kundfordran' },
])
// The accounts the lossy fast path used to inject must never appear.
const accounts = result.map((l) => l.account_number)
expect(accounts).not.toContain('6991')
expect(accounts).not.toContain('3001')
expect(accounts).not.toContain('2611')
})
it('is blind to business/settlement tagging: same accounts either way', () => {
// The old converter keyed "direction" (and thus the whole booking) off which
// leg was tagged business vs settlement. applyTemplate must not: swapping the
// tags leaves the same accounts on the same sides.
const swappedTags: BookingTemplateLibraryLine[] = [
{ account: '1930', label: 'Inbetalning', side: 'debit', type: 'business', ratio: 1 },
{ account: '1510', label: 'Kundfordran', side: 'credit', type: 'settlement', ratio: 1 },
]
expect(applyTemplate(swappedTags, AMOUNT)).toEqual(applyTemplate(customerPaymentLines, AMOUNT))
})
it('stays balanced (sum debit === sum credit)', () => {
const result = applyTemplate(customerPaymentLines, 4999.99)
const sumDebit = result.reduce((s, l) => s + (parseFloat(l.debit_amount || '0') || 0), 0)
const sumCredit = result.reduce((s, l) => s + (parseFloat(l.credit_amount || '0') || 0), 0)
expect(sumDebit).toBeCloseTo(sumCredit, 2)
expect(sumDebit).toBeCloseTo(4999.99, 2)
})
})