Files
accounted/lib/bookkeeping/__tests__/booking-templates.test.ts
T
Jakob WennbergandClaude Opus 4.8 953980c875 Per-account bank reconciliation + overdue/inbox/privacy fixes (#619)
* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id

A company with two same-currency cash accounts (e.g. checking 1930 + a
savings account) saw every SEK transaction on every account, and the
status card summed across both — reconciliation filtered transactions by
CURRENCY while filtering GL lines by ACCOUNT (issue #604).

Bind each bank transaction to the cash_accounts row it settled on:

- New nullable transactions.cash_account_id FK (ON DELETE SET NULL —
  a bank transaction is räkenskapsinformation, BFL 7 kap, and must
  survive cash-account deletion) + a best-effort 4-pass backfill.
- All reconciliation/transaction queries scope to the selected account
  with a NULL->currency fallback, so legacy/un-backfilled rows never
  disappear mid-backfill.
- ingestTransactions stamps cash_account_id from the batch's
  settlementAccount; categorize + manualLink resolve and use it.
- Bank leg now books to the transaction's actual settlement account via
  applySettlementAccount (no-op for 1930), so interest/fees on a
  savings/EUR account reconcile instead of mis-booking to 1930.
- manualLink cross-checks the transaction's account and requires a
  voucher line on the selected account (no silent cross-account links).
- BankReconciliationView: quick-book menu for any settlement account,
  in-flight request abort on account/date switch, 500-row truncation
  notice, per-account state reset.
- pg-real coverage for the FK, all backfill passes, account-scoped
  query isolation, and cross-company isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): stop marking paid invoices and credit notes as overdue

update_overdue_supplier_invoices() (the daily pg_cron job) flipped every
past-due 'registered'/'approved' row to 'overdue' without looking at the
outstanding balance. Credit notes — created 'registered', remaining 0,
due today — got flipped the next day, surfacing as "Förfallen" with
"kvar att betala 0 kr"; so did any fully-paid invoice left in
'registered'/'approved'.

Guard the cron on remaining_amount > 0.005 (the "fully paid" threshold
used by the payment/match paths) and is_credit_note = false, and backfill
the rows already mis-flagged (credit notes -> 'registered', paid ->
'paid' with paid_at stamped only when missing). pg-real coverage for the
guarded function and the one-off backfill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoice-inbox): refresh dokumentinkorg on realtime row changes

The InvoiceInboxWorkspace only refetched on mount and on explicit
in-component actions. When an inbox item was resolved out of band — the
in-app agent sheet committing a staged create_supplier_invoice_from_inbox
/ book-direct op, the /pending page approving one, or another tab booking
it — none of those paths called fetchItems(), so the booked underlag
stayed in "Att göra" until a manual reload (issue #600).

Add invoice_inbox_items to the supabase_realtime publication (mirrors the
/pending fix in 20260520120100) and subscribe in the workspace, refetching
the whole list on any change so derived status/counts/ordering stay
authoritative. RLS scopes the channel to the user's company. fetchItems
now preserves optimistic upload placeholders so a refetch firing
mid-upload can't drop an in-flight row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(privacy): disclose EU AI inference via Amazon Bedrock (eu-north-1)

Update the privacy policy and DPA to state that AI inference, when AI
features are enabled, runs inside the EU via Amazon Bedrock (eu-north-1,
Stockholm) using Anthropic's Claude models — no transfer to a third
country, prompts not retained after the call or used for model training.
Add AWS as a subprocessor row and refresh the "last updated" dates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migrations): rename invoice_inbox_realtime to avoid version collision

main's #617 shipped 20260605120000_transactions_original_description.sql —
the same version this branch used for the inbox-realtime publication. The
Supabase migration tracker keys on the numeric version, not the filename, so
the preview branch failed with a duplicate-key error on
supabase_migrations.schema_migrations (version 20260605120000 already
exists). Rename to the unique version 20260605120500; the body
(ALTER PUBLICATION) is order-independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reconciliation): align run guard with status; harden filter interpolation

Addresses PR review (greptile + compliance swarm):

- The v1 and core bank/run routes rejected an unknown account uniformly,
  including the default '1930', while the status routes were lenient for
  '1930'. A company reconciling its primary SEK account without a
  cash_accounts row got 200 from status but 400 from run. Make run match
  status: '1930' falls back to currency-only scoping (cashAccountId
  undefined); non-default unknown accounts are still rejected. Adds a test.
- /api/transactions accepts a user-supplied `currency` query param that was
  interpolated raw into a PostgREST .or() filter. Reject anything that isn't
  a 3-letter ISO code — RLS already scopes to the company, but an
  unsanitized value could otherwise malform/widen the filter. Assert
  currency/cashAccountId shape in scopeTransactionsToAccount as well.
- categorize: log (instead of silently swallowing) a cash_accounts
  settlement-account lookup error, so a fall-back-to-1930 mis-booking is
  observable in the audit log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migrations): correct backfill UPDATE..FROM join; idempotent realtime publication

Two SQL errors that only surface on real Postgres (CI pg-real + Supabase
preview) — the unit suite mocks Supabase, so neither was caught locally.

- Backfill pass (a): `UPDATE transactions t ... FROM journal_entry_lines jel
  JOIN cash_accounts ca ON ca.company_id = t.company_id` referenced the UPDATE
  target `t` inside the FROM join's ON clause, which Postgres rejects ("invalid
  reference to FROM-clause entry for table t"). Move the company match to WHERE;
  the JOIN now relates jel<->ca only. Semantics unchanged.
- invoice_inbox_realtime: `ALTER PUBLICATION ... ADD TABLE` is not idempotent
  (SQLSTATE 42710 if the table is already a member). The earlier
  version-collision push partially applied it on the Supabase preview branch, so
  the re-apply errored. Guard with a pg_publication_tables existence check.

Both statements validated against a real Postgres: the single-line tx binds, the
two-bank-line transfer stays NULL, and the publication add runs twice cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migrations): backfill pass (c) uses array_agg, not min(uuid)

Postgres has no min() aggregate for uuid, so pass (c)'s min(id) raised
"function min(uuid) does not exist" on apply (CI pg-real + Supabase). The
HAVING count(*) = 1 already guarantees one row per group, so (array_agg(id))[1]
returns that single id.

Validated the full backfill (all four passes) and the overdue migration against
a real Postgres: every pass binds / falls through as intended, and the overdue
guard + backfill produce the right statuses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(compliance): add RoPA entry for Amazon Bedrock AI inference (GDPR Art.30)

The privacy policy now discloses AI inference (transaction categorization +
document/receipt OCR) via Amazon Bedrock as a processing activity, but
.compliance/ropa.yaml had no matching Art.30 record. Add it: opt-in consent
basis, EU-region (eu-north-1) inference with no third-country transfer, prompts
not retained or used for model training. Mirrors the privacy-page disclosure
shipped in this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:26:13 +02:00

849 lines
31 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { makeTransaction } from '@/tests/helpers'
import {
BOOKING_TEMPLATES,
getTemplateById,
getTemplatesByGroup,
getTemplatesByMcc,
getTemplateGroups,
searchTemplates,
findMatchingTemplates,
buildMappingResultFromTemplate,
getCommonTemplates,
getAdvancedTemplates,
validateTemplateForEntity,
stripBankNoise,
type BookingTemplate,
} from '../booking-templates'
import { applySettlementAccount } from '../mapping-engine'
// ============================================================
// Template Data Integrity
// ============================================================
describe('BOOKING_TEMPLATES data integrity', () => {
it('has exactly 60 templates', () => {
expect(BOOKING_TEMPLATES).toHaveLength(60)
})
it('all template IDs are unique', () => {
const ids = BOOKING_TEMPLATES.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('all templates have valid required fields', () => {
for (const t of BOOKING_TEMPLATES) {
expect(t.id).toBeTruthy()
expect(t.name_sv).toBeTruthy()
expect(t.name_en).toBeTruthy()
expect(t.group).toBeTruthy()
expect(['expense', 'income', 'transfer']).toContain(t.direction)
expect(['all', 'enskild_firma', 'aktiebolag']).toContain(t.entity_applicability)
expect(t.debit_account).toMatch(/^\d{4}$/)
expect(t.credit_account).toMatch(/^\d{4}$/)
expect(['full', 'non_deductible', 'conditional']).toContain(t.deductibility)
expect(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH']).toContain(t.risk_level)
expect(typeof t.requires_review).toBe('boolean')
expect(t.impact_score).toBeGreaterThanOrEqual(1)
expect(t.impact_score).toBeLessThanOrEqual(10)
expect(t.auto_match_confidence).toBeGreaterThanOrEqual(0.5)
expect(t.auto_match_confidence).toBeLessThanOrEqual(1.0)
expect(typeof t.default_private).toBe('boolean')
expect(t.fallback_category).toBeTruthy()
expect(t.description_sv).toBeTruthy()
expect(typeof t.common).toBe('boolean')
expect(Array.isArray(t.mcc_codes)).toBe(true)
expect(Array.isArray(t.keywords)).toBe(true)
expect(t.keywords.length).toBeGreaterThan(0)
}
})
it('all AB-specific override accounts are valid 4-digit strings', () => {
for (const t of BOOKING_TEMPLATES) {
if (t.debit_account_ab) {
expect(t.debit_account_ab).toMatch(/^\d{4}$/)
}
if (t.credit_account_ab) {
expect(t.credit_account_ab).toMatch(/^\d{4}$/)
}
}
})
it('vat_rate is consistent with vat_treatment', () => {
for (const t of BOOKING_TEMPLATES) {
if (t.vat_treatment === 'standard_25') {
expect(t.vat_rate).toBe(0.25)
} else if (t.vat_treatment === 'reduced_12') {
expect(t.vat_rate).toBe(0.12)
} else if (t.vat_treatment === 'reduced_6') {
expect(t.vat_rate).toBe(0.06)
} else if (t.vat_treatment === 'reverse_charge' || t.vat_treatment === 'export' || t.vat_treatment === 'exempt' || t.vat_treatment === null) {
expect(t.vat_rate).toBe(0)
}
}
})
})
// ============================================================
// Lookup Functions
// ============================================================
describe('getTemplateById', () => {
it('returns correct template for known ID', () => {
const t = getTemplateById('it_saas_subscription')
expect(t).toBeDefined()
expect(t!.name_sv).toBe('Programvara / SaaS')
expect(t!.debit_account).toBe('5420')
})
it('returns undefined for unknown ID', () => {
expect(getTemplateById('nonexistent')).toBeUndefined()
})
})
describe('getTemplatesByGroup', () => {
it('returns templates for the premises group', () => {
const templates = getTemplatesByGroup('premises')
expect(templates.length).toBeGreaterThan(0)
for (const t of templates) {
expect(t.group).toBe('premises')
}
})
it('returns empty array for non-existent group', () => {
expect(getTemplatesByGroup('nonexistent' as never)).toEqual([])
})
})
describe('getTemplatesByMcc', () => {
it('returns templates for MCC 5541 (fuel)', () => {
const templates = getTemplatesByMcc(5541)
expect(templates.length).toBeGreaterThan(0)
expect(templates.some((t) => t.id === 'vehicle_fuel')).toBe(true)
})
it('returns empty array for unknown MCC', () => {
expect(getTemplatesByMcc(9999)).toEqual([])
})
})
describe('getTemplateGroups', () => {
it('returns all 17 groups', () => {
const groups = getTemplateGroups()
expect(groups).toHaveLength(17)
for (const g of groups) {
expect(g.group).toBeTruthy()
expect(g.label_sv).toBeTruthy()
expect(g.label_en).toBeTruthy()
expect(Array.isArray(g.templates)).toBe(true)
}
})
it('every template is in exactly one group', () => {
const groups = getTemplateGroups()
const allTemplates = groups.flatMap((g) => g.templates)
expect(allTemplates).toHaveLength(60)
})
})
// ============================================================
// Search
// ============================================================
describe('searchTemplates', () => {
it('finds templates by Swedish name', () => {
const results = searchTemplates('lokalhyra')
expect(results.length).toBeGreaterThan(0)
expect(results.some((t) => t.id === 'premises_rent')).toBe(true)
})
it('finds templates by English name', () => {
const results = searchTemplates('software')
expect(results.length).toBeGreaterThan(0)
expect(results.some((t) => t.id === 'it_saas_subscription')).toBe(true)
})
it('finds templates by keywords', () => {
const results = searchTemplates('spotify')
expect(results.length).toBeGreaterThan(0)
})
it('returns empty for empty query', () => {
expect(searchTemplates('')).toEqual([])
})
it('filters by entity type', () => {
const results = searchTemplates('pension', 'enskild_firma')
// Should include EF-specific and 'all', but not AB-only
for (const t of results) {
expect(t.entity_applicability).not.toBe('aktiebolag')
}
})
it('supports multi-token search', () => {
const results = searchTemplates('annonsering EU')
expect(results.some((t) => t.id === 'marketing_online_ads_eu')).toBe(true)
})
})
// ============================================================
// findMatchingTemplates
// ============================================================
describe('findMatchingTemplates', () => {
it('matches by MCC code with high confidence', () => {
const tx = makeTransaction({
amount: -500,
mcc_code: 5541,
description: 'Gas station',
merchant_name: 'OKQ8',
})
const matches = findMatchingTemplates(tx)
expect(matches.length).toBeGreaterThan(0)
expect(matches[0].template.id).toBe('vehicle_fuel')
expect(matches[0].confidence).toBeGreaterThan(0.3)
})
it('matches by keywords in description', () => {
const tx = makeTransaction({
amount: -299,
description: 'Google Ads campaign',
merchant_name: 'Google',
})
const matches = findMatchingTemplates(tx)
expect(matches.some((m) => m.template.id === 'marketing_online_ads_eu')).toBe(true)
})
it('returns empty for a transaction with no signals', () => {
const tx = makeTransaction({
amount: -100,
description: 'XYZ123ABC',
mcc_code: null,
merchant_name: null,
})
const matches = findMatchingTemplates(tx)
expect(matches).toEqual([])
})
it('filters by entity type', () => {
const tx = makeTransaction({
amount: -5000,
description: 'Löneutbetalning',
})
const matches = findMatchingTemplates(tx, 'enskild_firma')
// Personnel salary is AB-only, should not appear
for (const m of matches) {
expect(m.template.entity_applicability).not.toBe('aktiebolag')
}
})
it('does not match expense templates for positive amounts', () => {
const tx = makeTransaction({
amount: 1000,
description: 'Bensin okq8',
mcc_code: 5541,
})
const matches = findMatchingTemplates(tx)
// vehicle_fuel is an expense template, should not match positive amount
expect(matches.every((m) => m.template.direction !== 'expense')).toBe(true)
})
it('returns max 10 results', () => {
const tx = makeTransaction({
amount: -100,
description: 'software subscription cloud hosting domain',
mcc_code: 5817,
})
const matches = findMatchingTemplates(tx)
expect(matches.length).toBeLessThanOrEqual(10)
})
it('results are sorted by confidence descending', () => {
const tx = makeTransaction({
amount: -999,
description: 'Google cloud hosting',
mcc_code: 4816,
})
const matches = findMatchingTemplates(tx)
for (let i = 1; i < matches.length; i++) {
expect(matches[i - 1].confidence).toBeGreaterThanOrEqual(matches[i].confidence)
}
})
})
// ============================================================
// stripBankNoise — protects the matcher from bank-prefix noise
// ============================================================
describe('stripBankNoise', () => {
it('strips "överföring via internet" before keyword matching', () => {
expect(stripBankNoise('milersättning april överföring via internet')).toBe(
'milersättning april'
)
})
it('strips "autogiro" suffix', () => {
expect(stripBankNoise('elräkning autogiro')).toBe('elräkning')
})
it('strips "bg-betalning" and "bgmax"', () => {
expect(stripBankNoise('vattenfall bg-betalning')).toBe('vattenfall')
expect(stripBankNoise('kund x bgmax')).toBe('kund x')
})
it('strips multiple noise phrases at once', () => {
expect(stripBankNoise('swish till anna överföring')).toBe('anna')
})
it('preserves real merchant text after stripping', () => {
expect(stripBankNoise('kortköp ica supermarket')).toBe('ica supermarket')
})
it('is a no-op when no noise phrases are present', () => {
expect(stripBankNoise('lokalhyra mars 2026')).toBe('lokalhyra mars 2026')
})
})
// ============================================================
// Milersättning / traktamente routing — regression guards for
// the "Överföring via internet" → 6230 Internet miscategorization
// ============================================================
describe('milersättning routing', () => {
it('matches personnel_mileage_taxfree (7331) for "milersättning"', () => {
const tx = makeTransaction({
amount: -1496,
description: 'milersättning april',
})
const matches = findMatchingTemplates(tx)
expect(matches[0]?.template.id).toBe('personnel_mileage_taxfree')
expect(matches[0]?.template.debit_account).toBe('7331')
})
it('does NOT match telecom_internet when description contains "överföring via internet"', () => {
const tx = makeTransaction({
amount: -1496,
description: 'milersättning april Överföring via internet',
})
const matches = findMatchingTemplates(tx)
// The fragile substring match used to fire telecom_internet (6230 + 25% VAT)
// because the bank suffix contains the word "internet". After stripping
// bank noise, only milersättning should match.
expect(matches.find((m) => m.template.id === 'telecom_internet')).toBeUndefined()
expect(matches[0]?.template.id).toBe('personnel_mileage_taxfree')
})
it('emits no VAT lines for milersättning (employee reimbursement, not a purchase)', () => {
const template = getTemplateById('personnel_mileage_taxfree')
expect(template).toBeDefined()
const tx = makeTransaction({ amount: -1496 })
const result = buildMappingResultFromTemplate(template!, tx, 'aktiebolag')
expect(result.debit_account).toBe('7331')
expect(result.vat_lines).toHaveLength(0)
})
it('"körersättning" also matches personnel_mileage_taxfree', () => {
const tx = makeTransaction({
amount: -1850,
description: 'körersättning Q1',
})
const matches = findMatchingTemplates(tx)
expect(matches[0]?.template.id).toBe('personnel_mileage_taxfree')
})
it('still resolves real internet bills via the telecom_internet template', () => {
// Make sure we didn't break legitimate internet-bill matching by adding
// noise stripping. The merchant signal carries the match.
const tx = makeTransaction({
amount: -399,
description: 'Bahnhof bredband mars',
merchant_name: 'Bahnhof',
})
const matches = findMatchingTemplates(tx)
expect(matches[0]?.template.id).toBe('telecom_internet')
})
})
describe('traktamente routing', () => {
it('matches personnel_per_diem_sweden_taxfree (7321) for "traktamente"', () => {
const tx = makeTransaction({
amount: -290,
description: 'traktamente Stockholm april',
})
const matches = findMatchingTemplates(tx)
expect(matches[0]?.template.id).toBe('personnel_per_diem_sweden_taxfree')
expect(matches[0]?.template.debit_account).toBe('7321')
})
it('matches utlandstraktamente → 7323', () => {
const tx = makeTransaction({
amount: -800,
description: 'utlandstraktamente Tyskland',
})
const matches = findMatchingTemplates(tx)
expect(matches[0]?.template.id).toBe('personnel_per_diem_abroad_taxfree')
expect(matches[0]?.template.debit_account).toBe('7323')
})
it('emits no VAT lines for traktamente', () => {
const template = getTemplateById('personnel_per_diem_sweden_taxfree')
expect(template).toBeDefined()
const tx = makeTransaction({ amount: -290 })
const result = buildMappingResultFromTemplate(template!, tx, 'aktiebolag')
expect(result.debit_account).toBe('7321')
expect(result.vat_lines).toHaveLength(0)
})
})
// ============================================================
// buildMappingResultFromTemplate
// ============================================================
describe('buildMappingResultFromTemplate', () => {
const getTemplate = (id: string): BookingTemplate => {
const t = getTemplateById(id)
if (!t) throw new Error(`Template not found: ${id}`)
return t
}
it('produces valid MappingResult for expense with 25% VAT', () => {
const template = getTemplate('it_saas_subscription')
const tx = makeTransaction({ amount: -1250 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('5420')
expect(result.credit_account).toBe('1930')
expect(result.template_id).toBe('it_saas_subscription')
expect(result.rule).toBeNull()
expect(result.confidence).toBe(1.0)
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(250) // 1250 * 0.25 / 1.25 = 250
})
it('produces valid MappingResult for expense with 12% VAT', () => {
const template = getTemplate('travel_hotel')
const tx = makeTransaction({ amount: -1120 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('5820')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(120) // 1120 * 0.12 / 1.12 = 120
})
it('produces valid MappingResult for expense with 6% VAT', () => {
const template = getTemplate('travel_transport')
const tx = makeTransaction({ amount: -530 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(30) // 530 * 0.06 / 1.06 = 30
})
it('produces reverse charge lines for EU purchases (fiktiv moms + basbelopp)', () => {
const template = getTemplate('it_saas_eu')
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
// Four lines: fiktiv-moms pair + basbelopp pair. Without the basbelopp
// pair the deklaration is rejected with FK004 (ruta 30-32 without 20-24).
expect(result.vat_lines).toHaveLength(4)
// Fiktiv ingående moms (EU: 2645)
expect(result.vat_lines[0].account_number).toBe('2645')
expect(result.vat_lines[0].debit_amount).toBe(250)
// Fiktiv utgående moms (25%: 2614)
expect(result.vat_lines[1].account_number).toBe('2614')
expect(result.vat_lines[1].credit_amount).toBe(250)
// Basbelopp EU services 25% → ruta 21
expect(result.vat_lines[2].account_number).toBe('4535')
expect(result.vat_lines[2].debit_amount).toBe(1000)
// Motkonto basbelopp
expect(result.vat_lines[3].account_number).toBe('4598')
expect(result.vat_lines[3].credit_amount).toBe(1000)
})
it('defaults to eu_business supplier type when not set on template', () => {
// it_cloud_hosting has no explicit reverse_charge_supplier_type
const template = getTemplate('it_cloud_hosting')
const tx = makeTransaction({ amount: -800 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(4)
// Defaults to EU services → 4535
expect(result.vat_lines[2].account_number).toBe('4535')
expect(result.vat_lines[2].debit_amount).toBe(800)
})
it('uses 4531 basbelopp for non-EU supplier type', () => {
const template: BookingTemplate = {
...getTemplate('it_cloud_hosting'),
reverse_charge_supplier_type: 'non_eu_business',
}
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(4)
expect(result.vat_lines[0].account_number).toBe('2645') // non-EU still uses 2645
expect(result.vat_lines[2].account_number).toBe('4531') // non-EU services → ruta 22
})
it('uses 4425 basbelopp and 2647 for domestic (swedish) reverse charge', () => {
const template: BookingTemplate = {
...getTemplate('it_cloud_hosting'),
reverse_charge_supplier_type: 'swedish_business',
}
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(4)
// Domestic RC uses 2647 (ML 16 kap) for the input pair
expect(result.vat_lines[0].account_number).toBe('2647')
// Domestic services (byggtjänster) → 4425, ruta 24
expect(result.vat_lines[2].account_number).toBe('4425')
})
it('skips basbelopp emission when template already debits a basis account', () => {
const template: BookingTemplate = {
...getTemplate('it_cloud_hosting'),
debit_account: '4535', // user-customized template that books directly to basis
}
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
// Only the fiktiv-moms pair — basbelopp would double-count.
expect(result.vat_lines).toHaveLength(2)
expect(result.vat_lines[0].account_number).toBe('2645')
expect(result.vat_lines[1].account_number).toBe('2614')
})
it('produces no VAT lines for exempt expenses', () => {
const template = getTemplate('premises_rent')
const tx = makeTransaction({ amount: -10000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(0)
})
it('produces no VAT lines for non-deductible templates', () => {
const template = getTemplate('private_withdrawal_ef')
const tx = makeTransaction({ amount: -5000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(0)
expect(result.default_private).toBe(true)
})
it('produces output VAT lines for income with 25% VAT', () => {
const template = getTemplate('revenue_standard_25')
const tx = makeTransaction({ amount: 12500 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('1930')
expect(result.credit_account).toBe('3001')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2611')
expect(result.vat_lines[0].credit_amount).toBe(2500) // 12500 * 0.25 / 1.25 = 2500
})
it('produces output VAT lines for income with 12% VAT', () => {
const template = getTemplate('revenue_reduced_12')
const tx = makeTransaction({ amount: 1120 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2621')
expect(result.vat_lines[0].credit_amount).toBe(120)
})
it('resolves AB-specific accounts for aktiebolag', () => {
const template = getTemplate('education_course')
const tx = makeTransaction({ amount: -5000 })
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(efResult.debit_account).toBe('6991')
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
expect(abResult.debit_account).toBe('7610')
})
it('resolves AB-specific private account', () => {
const template = getTemplate('private_expense')
const tx = makeTransaction({ amount: -300 })
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(efResult.debit_account).toBe('2013')
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
expect(abResult.debit_account).toBe('2893')
})
it('includes template_id in the MappingResult', () => {
const template = getTemplate('bank_fees')
const tx = makeTransaction({ amount: -49 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.template_id).toBe('bank_fees')
expect(result.rule).toBeNull()
})
it('sets description with template name and transaction description', () => {
const template = getTemplate('vehicle_fuel')
const tx = makeTransaction({ amount: -800, description: 'OKQ8 tankstation' })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.description).toBe('Drivmedel & Laddning: OKQ8 tankstation')
})
// Foreign-currency transactions: the mall must always emit SEK amounts
// (issue #442). Previously buildMappingResultFromTemplate used
// Math.abs(transaction.amount) — which is in the source currency — to
// compute VAT lines, producing a verifikation in mixed currencies.
it('emits SEK amounts when transaction currency is USD (issue #442)', () => {
const template = getTemplate('it_saas_subscription') // 25% input VAT
const tx = makeTransaction({
amount: -125, // -125 USD
currency: 'USD',
amount_sek: -1250, // pre-converted to SEK
exchange_rate: 10,
})
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
// VAT line debit must be 250 SEK (1250 * 0.25 / 1.25), not 25 USD
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(250)
})
it('falls back to amount * exchange_rate when amount_sek is missing', () => {
const template = getTemplate('it_saas_subscription')
const tx = makeTransaction({
amount: -100,
currency: 'EUR',
amount_sek: null,
exchange_rate: 11.5,
})
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
// 100 * 11.5 = 1150 SEK; 1150 * 0.25 / 1.25 = 230 SEK
expect(result.vat_lines[0].debit_amount).toBe(230)
})
it('emits SEK amounts for EU reverse-charge on a non-SEK transaction', () => {
const template = getTemplate('it_saas_eu')
const tx = makeTransaction({
amount: -100,
currency: 'USD',
amount_sek: -1000,
exchange_rate: 10,
})
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
// Fiktiv-moms pair sized at 25% of 1000 SEK = 250, not 25 USD
expect(result.vat_lines).toHaveLength(4)
expect(result.vat_lines[0].debit_amount).toBe(250) // 2645
expect(result.vat_lines[1].credit_amount).toBe(250) // 2614
expect(result.vat_lines[2].debit_amount).toBe(1000) // 4535 basbelopp
expect(result.vat_lines[3].credit_amount).toBe(1000) // 4608
})
it('emits SEK amounts for output VAT on non-SEK income', () => {
const template = getTemplate('revenue_standard_25')
const tx = makeTransaction({
amount: 100,
currency: 'USD',
amount_sek: 1000,
exchange_rate: 10,
})
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
// Output VAT at 25% on 1000 SEK = 200, not 20 USD
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2611')
expect(result.vat_lines[0].credit_amount).toBe(200)
})
})
// ============================================================
// Template Curation Helpers
// ============================================================
describe('getCommonTemplates', () => {
it('returns only templates with common: true', () => {
const common = getCommonTemplates()
expect(common.length).toBeGreaterThan(0)
for (const t of common) {
expect(t.common).toBe(true)
}
})
it('filters by entity type', () => {
const efCommon = getCommonTemplates('enskild_firma')
for (const t of efCommon) {
expect(t.entity_applicability).not.toBe('aktiebolag')
}
})
it('filters by direction', () => {
const expenses = getCommonTemplates(undefined, 'expense')
for (const t of expenses) {
expect(t.direction).toBe('expense')
}
})
})
describe('getAdvancedTemplates', () => {
it('returns only templates with common: false', () => {
const advanced = getAdvancedTemplates()
expect(advanced.length).toBeGreaterThan(0)
for (const t of advanced) {
expect(t.common).toBe(false)
}
})
it('common + advanced = all templates (for a given entity/direction)', () => {
const common = getCommonTemplates()
const advanced = getAdvancedTemplates()
expect(common.length + advanced.length).toBe(BOOKING_TEMPLATES.length)
})
})
describe('validateTemplateForEntity', () => {
it('accepts template with entity_applicability "all"', () => {
const template = getTemplateById('premises_rent')!
const result = validateTemplateForEntity(template, 'aktiebolag')
expect(result.valid).toBe(true)
})
it('accepts EF template for EF entity', () => {
const template = getTemplateById('private_withdrawal_ef')!
const result = validateTemplateForEntity(template, 'enskild_firma')
expect(result.valid).toBe(true)
})
it('rejects EF template for AB entity', () => {
const template = getTemplateById('private_withdrawal_ef')!
const result = validateTemplateForEntity(template, 'aktiebolag')
expect(result.valid).toBe(false)
expect(result.error).toContain('enskild_firma')
})
it('rejects AB template for EF entity', () => {
const template = getTemplateById('personnel_salary')!
const result = validateTemplateForEntity(template, 'enskild_firma')
expect(result.valid).toBe(false)
expect(result.error).toContain('aktiebolag')
})
})
// ============================================================
// New/Split Templates
// ============================================================
describe('new and split templates', () => {
it('has marketing_online_ads_eu with reverse_charge', () => {
const t = getTemplateById('marketing_online_ads_eu')
expect(t).toBeDefined()
expect(t!.vat_treatment).toBe('reverse_charge')
expect(t!.common).toBe(true)
expect(t!.requires_vat_registration_data).toBe(true)
})
it('has marketing_online_ads_domestic with standard_25', () => {
const t = getTemplateById('marketing_online_ads_domestic')
expect(t).toBeDefined()
expect(t!.vat_treatment).toBe('standard_25')
expect(t!.common).toBe(false)
})
it('has representation_internal with account 7622', () => {
const t = getTemplateById('representation_internal')
expect(t).toBeDefined()
expect(t!.debit_account).toBe('7622')
expect(t!.vat_treatment).toBeNull()
expect(t!.common).toBe(true)
})
it('has shareholder_loan_received (AB, D:1930 K:2393)', () => {
const t = getTemplateById('shareholder_loan_received')
expect(t).toBeDefined()
expect(t!.debit_account).toBe('1930')
expect(t!.credit_account).toBe('2393')
expect(t!.entity_applicability).toBe('aktiebolag')
expect(t!.common).toBe(true)
})
it('has shareholder_loan_disbursed (AB, D:1680 K:1930)', () => {
const t = getTemplateById('shareholder_loan_disbursed')
expect(t).toBeDefined()
expect(t!.debit_account).toBe('1680')
expect(t!.credit_account).toBe('1930')
expect(t!.entity_applicability).toBe('aktiebolag')
expect(t!.common).toBe(false)
})
it('personnel_employer_tax uses debit account 2731 (liability clearing)', () => {
const t = getTemplateById('personnel_employer_tax')
expect(t).toBeDefined()
expect(t!.debit_account).toBe('2731')
})
it('personnel_salary has special_rules_sv warning', () => {
const t = getTemplateById('personnel_salary')
expect(t).toBeDefined()
expect(t!.special_rules_sv).toContain('nettolön')
expect(t!.requires_review).toBe(true)
})
it('representation_external has updated deductibility note with VAT cap', () => {
const t = getTemplateById('representation_external')
expect(t).toBeDefined()
expect(t!.deductibility_note_sv).toContain('46 kr/person')
})
})
// ============================================================
// applySettlementAccount — bank-leg routing for non-1930 accounts
// ============================================================
describe('applySettlementAccount (bank-leg routing)', () => {
it('routes the bank_interest_income debit leg to the transaction settlement account', () => {
const template = getTemplateById('bank_interest_income')
expect(template).toBeDefined()
const tx = makeTransaction({ amount: 50, currency: 'SEK' })
const base = buildMappingResultFromTemplate(template!, tx, 'enskild_firma')
// Template hardcodes 1930 as the bank leg.
expect(base.debit_account).toBe('1930')
expect(base.credit_account).toBe('8310')
// Interest that landed on a savings account mapped to 1931 must debit 1931,
// not 1930 — otherwise the real bank transaction never reconciles.
const routed = applySettlementAccount(base, '1931')
expect(routed.debit_account).toBe('1931')
expect(routed.credit_account).toBe('8310')
})
it('routes the bank_fees credit leg to the transaction settlement account', () => {
const template = getTemplateById('bank_fees')
expect(template).toBeDefined()
const tx = makeTransaction({ amount: -29, currency: 'SEK' })
const base = buildMappingResultFromTemplate(template!, tx, 'enskild_firma')
expect(base.debit_account).toBe('6570')
expect(base.credit_account).toBe('1930')
const routed = applySettlementAccount(base, '1931')
expect(routed.debit_account).toBe('6570')
expect(routed.credit_account).toBe('1931')
})
it('is a no-op when the settlement account is 1930 (legacy/unresolved rows)', () => {
const template = getTemplateById('bank_interest_income')!
const tx = makeTransaction({ amount: 50, currency: 'SEK' })
const base = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
const routed = applySettlementAccount(base, '1930')
expect(routed.debit_account).toBe('1930')
expect(routed.credit_account).toBe('8310')
})
})