Files
accounted/lib/bookkeeping/booking-templates.ts
T
Jakob Wennberg 66a4027f1e feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup
- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:19:56 +01:00

1624 lines
53 KiB
TypeScript

import type {
TransactionCategory,
MappingResult,
VatJournalLine,
Transaction,
EntityType,
VatTreatment,
RiskLevel,
} from '@/types'
import { getVatRate, generateReverseChargeLines, generateInputVatLine } from './vat-entries'
// ============================================================
// Types
// ============================================================
export type TemplateGroup =
| 'premises'
| 'vehicle'
| 'it_software'
| 'office_supplies'
| 'marketing'
| 'travel'
| 'representation'
| 'insurance'
| 'professional_services'
| 'bank_finance'
| 'telecom'
| 'education'
| 'personnel'
| 'revenue'
| 'financial'
| 'private_transfers'
| 'equipment'
export interface BookingTemplate {
id: string
name_sv: string
name_en: string
group: TemplateGroup
direction: 'expense' | 'income' | 'transfer'
entity_applicability: 'all' | 'enskild_firma' | 'aktiebolag'
debit_account: string
credit_account: string
debit_account_ab?: string
credit_account_ab?: string
vat_treatment: VatTreatment | null
vat_rate: number
deductibility: 'full' | 'non_deductible' | 'conditional'
deductibility_note_sv?: string
special_rules_sv?: string
mcc_codes: number[]
keywords: string[]
risk_level: RiskLevel
requires_review: boolean
impact_score: number
auto_match_confidence: number
default_private: boolean
fallback_category: TransactionCategory
description_sv: string
common: boolean
requires_vat_registration_data?: boolean
}
export interface TemplateGroupInfo {
group: TemplateGroup
label_sv: string
label_en: string
templates: BookingTemplate[]
}
export interface TemplateMatch {
template: BookingTemplate
confidence: number
}
// ============================================================
// Template Group Labels
// ============================================================
const GROUP_LABELS: Record<TemplateGroup, { sv: string; en: string }> = {
premises: { sv: 'Lokalkostnader', en: 'Premises' },
vehicle: { sv: 'Fordon', en: 'Vehicle' },
it_software: { sv: 'IT & Programvara', en: 'IT & Software' },
office_supplies: { sv: 'Kontorsmaterial', en: 'Office Supplies' },
marketing: { sv: 'Marknadsföring', en: 'Marketing' },
travel: { sv: 'Resor & Transport', en: 'Travel & Transport' },
representation: { sv: 'Representation', en: 'Representation' },
insurance: { sv: 'Försäkringar', en: 'Insurance' },
professional_services: { sv: 'Professionella tjänster', en: 'Professional Services' },
bank_finance: { sv: 'Bank & Finans', en: 'Banking & Finance' },
telecom: { sv: 'Telekom & Internet', en: 'Telecom & Internet' },
education: { sv: 'Utbildning', en: 'Education' },
personnel: { sv: 'Personal', en: 'Personnel' },
revenue: { sv: 'Intäkter', en: 'Revenue' },
financial: { sv: 'Finansiella poster', en: 'Financial Items' },
private_transfers: { sv: 'Privata transaktioner', en: 'Private Transfers' },
equipment: { sv: 'Inventarier & Utrustning', en: 'Equipment' },
}
// ============================================================
// Template Data
// ============================================================
export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
// --- PREMISES (3) ---
{
id: 'premises_rent',
name_sv: 'Lokalhyra',
name_en: 'Office rent',
group: 'premises',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5010',
credit_account: '1930',
vat_treatment: 'exempt',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Lokalhyra är momsfri om hyresvärden inte är frivilligt momsregistrerad',
mcc_codes: [],
keywords: ['hyra', 'lokal', 'kontor', 'rent', 'office space', 'kontorslokal', 'coworking', 'kontorshotell', 'wework', 'norrsken', 'regus'],
risk_level: 'NONE',
requires_review: false,
impact_score: 10,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_office',
description_sv: 'Månadshyra för kontors- eller affärslokal',
common: true,
},
{
id: 'premises_rent_vat',
name_sv: 'Lokalhyra (momsbelagd)',
name_en: 'Office rent (with VAT)',
group: 'premises',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5010',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
special_rules_sv: 'Gäller när hyresvärden är frivilligt momsregistrerad',
mcc_codes: [],
keywords: ['hyra', 'lokal', 'moms', 'kontorshyra'],
risk_level: 'NONE',
requires_review: false,
impact_score: 8,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_office',
description_sv: 'Lokalhyra med moms (frivilligt momsregistrerad hyresvärd)',
common: false,
},
{
id: 'premises_electricity',
name_sv: 'El & Uppvärmning',
name_en: 'Electricity & Heating',
group: 'premises',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5020',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [4900],
keywords: ['el', 'electricity', 'vattenfall', 'eon', 'fortum', 'ellevio', 'kraftbolag', 'elnät', 'värme', 'fjärrvärme', 'uppvärmning', 'vatten', 'avlopp'],
risk_level: 'NONE',
requires_review: false,
impact_score: 8,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_office',
description_sv: 'El, uppvärmning och vatten för kontors- eller affärslokal',
common: true,
},
// --- VEHICLE (4) ---
{
id: 'vehicle_fuel',
name_sv: 'Drivmedel & Laddning',
name_en: 'Fuel & EV Charging',
group: 'vehicle',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5611',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
special_rules_sv: 'Drivmedel till firmabil, ej privat körning',
mcc_codes: [5541, 5542, 5552, 5983],
keywords: ['bensin', 'diesel', 'drivmedel', 'fuel', 'tank', 'okq8', 'circle k', 'preem', 'st1', 'shell', 'ingo', 'laddning', 'elbil', 'charging', 'tesla', 'ionity', 'recharge'],
risk_level: 'LOW',
requires_review: false,
impact_score: 8,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Drivmedel (bensin/diesel) eller laddning (elbil) för tjänstefordon',
common: true,
},
{
id: 'vehicle_leasing',
name_sv: 'Billeasing',
name_en: 'Car leasing',
group: 'vehicle',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5615',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'conditional',
deductibility_note_sv: 'Max 50% momsavdrag för personbil',
special_rules_sv: 'Personbil: halvt momsavdrag. Lastbil/lätt lastbil: fullt avdrag.',
mcc_codes: [7512, 7513],
keywords: ['leasing', 'billeasing', 'car lease', 'leasingavgift'],
risk_level: 'LOW',
requires_review: false,
impact_score: 7,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Leasingavgift för tjänstefordon',
common: false,
},
{
id: 'vehicle_repairs',
name_sv: 'Reparation & Service fordon',
name_en: 'Vehicle repairs & service',
group: 'vehicle',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5613',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [5511, 5521, 5531, 5532, 5533, 7531, 7534, 7535, 7538, 7542],
keywords: ['bilverkstad', 'service', 'reparation', 'däck', 'mekonomen', 'autoexperten', 'bilprovning', 'besiktning'],
risk_level: 'NONE',
requires_review: false,
impact_score: 6,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Reparation, service och underhåll av fordon',
common: false,
},
{
id: 'vehicle_parking',
name_sv: 'Parkering & Vägtull',
name_en: 'Parking & Road tolls',
group: 'vehicle',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5614',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [7521, 7523, 4784],
keywords: ['parkering', 'parking', 'p-avgift', 'easypark', 'apcoa', 'q-park', 'aimo', 'trängselskatt', 'vägtull', 'toll', 'brobizz'],
risk_level: 'NONE',
requires_review: false,
impact_score: 7,
auto_match_confidence: 0.90,
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Parkeringsavgift och trängselskatt vid tjänsteärende',
common: false,
},
// --- IT & SOFTWARE (3) ---
{
id: 'it_saas_subscription',
name_sv: 'Programvara / SaaS',
name_en: 'Software / SaaS subscription',
group: 'it_software',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5420',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [5734, 5817, 5818],
keywords: ['software', 'saas', 'programvara', 'licens', 'subscription', 'app', 'microsoft', 'google', 'adobe', 'slack', 'notion', 'figma', 'github', 'atlassian', 'jira', 'antivirus', 'vpn', 'norton', '1password', 'lastpass', 'bitwarden', 'spotify', 'crm', 'hubspot', 'salesforce', 'pipedrive', 'asana', 'monday', 'trello', 'basecamp', 'clickup', 'linear', 'dropbox', 'onedrive', 'icloud', 'backup'],
risk_level: 'NONE',
requires_review: false,
impact_score: 10,
auto_match_confidence: 0.90,
default_private: false,
fallback_category: 'expense_software',
description_sv: 'Programvarulicens eller SaaS-prenumeration (svensk leverantör med moms)',
common: false,
},
{
id: 'it_saas_eu',
name_sv: 'Programvara / SaaS (omvänd moms)',
name_en: 'Software / SaaS (reverse charge)',
group: 'it_software',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5420',
credit_account: '1930',
vat_treatment: 'reverse_charge',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Utländsk leverantör (EU/USA) med omvänd skattskyldighet',
mcc_codes: [5734, 5817, 5818],
keywords: ['software', 'saas', 'eu', 'ireland', 'reverse charge', 'omvänd moms', 'openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot', 'google cloud', 'aws', 'azure'],
risk_level: 'LOW',
requires_review: false,
impact_score: 9,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_software',
description_sv: 'Programvara från utländsk leverantör med omvänd skattskyldighet',
common: true,
requires_vat_registration_data: true,
},
{
id: 'it_cloud_hosting',
name_sv: 'Molntjänster / Hosting',
name_en: 'Cloud services / Hosting',
group: 'it_software',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5421',
credit_account: '1930',
vat_treatment: 'reverse_charge',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Ofta EU/utländsk leverantör med omvänd skattskyldighet',
mcc_codes: [4816],
keywords: ['aws', 'azure', 'google cloud', 'gcp', 'hosting', 'server', 'cloud', 'vercel', 'heroku', 'digitalocean', 'cloudflare', 'hetzner', 'domän', 'domain', 'dns', 'loopia', 'binero', 'godaddy'],
risk_level: 'NONE',
requires_review: false,
impact_score: 9,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_software',
description_sv: 'Molnbaserade tjänster, webbhotell, serverhosting och domännamn',
common: true,
},
// --- OFFICE SUPPLIES (2) ---
{
id: 'office_supplies_general',
name_sv: 'Kontorsmaterial',
name_en: 'Office supplies',
group: 'office_supplies',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6110',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [5111, 5112, 5943, 5944],
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror', 'kontor', 'tryck', 'print', 'trycksaker', 'kopiering', 'visitkort'],
risk_level: 'NONE',
requires_review: false,
impact_score: 7,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_office',
description_sv: 'Kontorsmaterial, trycksaker och förbrukningsvaror',
common: true,
},
{
id: 'office_postage',
name_sv: 'Porto & Frakt',
name_en: 'Postage & Shipping',
group: 'office_supplies',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6250',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
special_rules_sv: 'Posttjänster kan vara momsfria, frakttjänster har normalt 25% moms',
mcc_codes: [4215, 4211],
keywords: ['porto', 'postnord', 'frakt', 'shipping', 'dhl', 'ups', 'fedex', 'bring', 'paket', 'schenker', 'transport'],
risk_level: 'NONE',
requires_review: false,
impact_score: 6,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_office',
description_sv: 'Porto och fraktkostnader',
common: true,
},
// --- MARKETING (3) ---
{
id: 'marketing_online_ads_eu',
name_sv: 'Annonsering EU (omvänd moms)',
name_en: 'Online ads EU (reverse charge)',
group: 'marketing',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5910',
credit_account: '1930',
vat_treatment: 'reverse_charge',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Google/Meta fakturerar ofta från Irland → omvänd skattskyldighet',
mcc_codes: [7311],
keywords: ['google ads', 'facebook ads', 'meta ads', 'instagram ads', 'linkedin ads', 'annons', 'advertising', 'adwords', 'kampanj', 'seo', 'sem', 'sökmotoroptimering', 'reklam'],
risk_level: 'NONE',
requires_review: false,
impact_score: 9,
auto_match_confidence: 0.90,
default_private: false,
fallback_category: 'expense_marketing',
description_sv: 'Digital annonsering från EU-leverantör (Google/Meta från Irland)',
common: true,
requires_vat_registration_data: true,
},
{
id: 'marketing_online_ads_domestic',
name_sv: 'Annonsering (svensk moms)',
name_en: 'Online ads (domestic VAT)',
group: 'marketing',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5910',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
special_rules_sv: 'Svensk leverantör med momsregistrering',
mcc_codes: [7311],
keywords: ['annons', 'reklam', 'advertising', 'kampanj', 'blocket', 'eniro'],
risk_level: 'NONE',
requires_review: false,
impact_score: 7,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_marketing',
description_sv: 'Digital annonsering från svensk leverantör med 25% moms',
common: false,
},
{
id: 'marketing_design',
name_sv: 'Design & Reklam',
name_en: 'Design & Promotion',
group: 'marketing',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5920',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [7333, 7332, 7829],
keywords: ['design', 'grafisk', 'logotyp', 'webb', 'website', 'logo', 'branding', 'grafiker', 'foto', 'fotograf', 'video', 'film', 'canva', 'hootsuite', 'social media', 'sociala medier', 'mailchimp', 'nyhetsbrev'],
risk_level: 'NONE',
requires_review: false,
impact_score: 5,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_marketing',
description_sv: 'Grafisk design, reklam, foto/video och marknadsföringsverktyg',
common: false,
},
// --- TRAVEL (3) ---
{
id: 'travel_transport',
name_sv: 'Resor & Transport',
name_en: 'Travel & Transport',
group: 'travel',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5810',
credit_account: '1930',
vat_treatment: 'reduced_6',
vat_rate: 0.06,
deductibility: 'full',
special_rules_sv: 'Persontransport har 6% moms (flyg, tåg, taxi)',
mcc_codes: [3000, 3001, 3002, 3003, 4511, 4011, 4111, 4112, 4131, 4121],
keywords: ['flyg', 'sas', 'norwegian', 'bra', 'flight', 'tåg', 'train', 'sj', 'sl', 'västtrafik', 'skånetrafiken', 'kollektivtrafik', 'taxi', 'uber', 'bolt', 'cab', 'hyrbil', 'rental car', 'europcar', 'hertz'],
risk_level: 'LOW',
requires_review: false,
impact_score: 7,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Resor i tjänsten: flyg, tåg, taxi, hyrbil (6% moms)',
common: true,
},
{
id: 'travel_international',
name_sv: 'Utrikesresa',
name_en: 'International travel',
group: 'travel',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5810',
credit_account: '1930',
vat_treatment: 'export',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Utrikesflyg och internationella resor är momsfria',
mcc_codes: [3000, 3001, 3002, 3003, 4511],
keywords: ['utrikes', 'international', 'airport', 'utlandsflyg'],
risk_level: 'LOW',
requires_review: false,
impact_score: 5,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Utrikesresor i tjänsten (momsfritt)',
common: false,
},
{
id: 'travel_hotel',
name_sv: 'Hotell',
name_en: 'Hotel',
group: 'travel',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5820',
credit_account: '1930',
vat_treatment: 'reduced_12',
vat_rate: 0.12,
deductibility: 'full',
special_rules_sv: 'Logi har 12% moms. Frukost särredovisas med 12% moms.',
mcc_codes: [3501, 3502, 3503, 3504, 7011],
keywords: ['hotell', 'hotel', 'logi', 'övernattning', 'scandic', 'elite', 'best western', 'booking', 'airbnb'],
risk_level: 'LOW',
requires_review: false,
impact_score: 6,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_travel',
description_sv: 'Hotellövernattning i tjänsten (12% moms)',
common: true,
},
// --- REPRESENTATION (3) ---
{
id: 'representation_external',
name_sv: 'Extern representation',
name_en: 'External representation',
group: 'representation',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6071',
credit_account: '1930',
vat_treatment: 'reduced_12',
vat_rate: 0.12,
deductibility: 'conditional',
deductibility_note_sv: 'Avdragsgill moms max 46 kr/person. Representationskostnad max 300 kr/person exkl moms (IL 16 kap 2§)',
special_rules_sv: 'Dokumentera: syfte, deltagare, företag. Momsavdrag max 300 kr/person.',
mcc_codes: [5812, 5813, 5814],
keywords: ['representation', 'lunch', 'middag', 'restaurang', 'restaurant', 'kund', 'kundmöte', 'gåva', 'present', 'representationsgåva'],
risk_level: 'HIGH',
requires_review: true,
impact_score: 7,
auto_match_confidence: 0.70,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Representation med kund/affärspartner (dokumentera noggrant)',
common: true,
},
{
id: 'representation_internal',
name_sv: 'Intern representation',
name_en: 'Internal representation',
group: 'representation',
direction: 'expense',
entity_applicability: 'all',
debit_account: '7622',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'conditional',
deductibility_note_sv: 'Max 60 kr/person',
special_rules_sv: 'Personalfest, intern lunch etc. Momsfritt. Max 60 kr/person för avdragsrätt.',
mcc_codes: [5812, 5813, 5814],
keywords: ['personalfest', 'intern representation', 'teamlunch', 'personallunch', 'fika', 'julfest', 'after work', 'intern lunch'],
risk_level: 'LOW',
requires_review: false,
impact_score: 5,
auto_match_confidence: 0.70,
default_private: false,
fallback_category: 'expense_representation',
description_sv: 'Intern representation (personalfest, teamlunch)',
common: true,
},
{
id: 'representation_conference',
name_sv: 'Konferens & Mässa',
name_en: 'Conference & Trade show',
group: 'representation',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5990',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [7941, 7922],
keywords: ['konferens', 'mässa', 'conference', 'trade show', 'event', 'utställning', 'biljett'],
risk_level: 'LOW',
requires_review: false,
impact_score: 4,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'expense_education',
description_sv: 'Avgifter för konferenser och mässor',
common: false,
},
// --- INSURANCE (3) ---
{
id: 'insurance_business',
name_sv: 'Företagsförsäkring',
name_en: 'Business insurance',
group: 'insurance',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6310',
credit_account: '1930',
vat_treatment: 'exempt',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Försäkringspremier är momsfria',
mcc_codes: [6300],
keywords: ['försäkring', 'insurance', 'företagsförsäkring', 'ansvarsförsäkring', 'konsultförsäkring', 'if', 'trygg-hansa', 'länsförsäkringar', 'folksam', 'fordonsförsäkring', 'bilförsäkring'],
risk_level: 'NONE',
requires_review: false,
impact_score: 6,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Företags-, ansvars- och fordonsförsäkring (momsfritt)',
common: true,
},
{
id: 'insurance_pension_ef',
name_sv: 'Pensionsförsäkring (EF)',
name_en: 'Pension insurance (EF)',
group: 'insurance',
direction: 'expense',
entity_applicability: 'enskild_firma',
debit_account: '6530',
credit_account: '1930',
vat_treatment: 'exempt',
vat_rate: 0,
deductibility: 'conditional',
deductibility_note_sv: 'Avdragsgill i NE-deklarationen, inte i bokföringen direkt',
special_rules_sv: 'EF: Pensionssparande dras av i NE-blanketten, inte som kostnad i rörelsen.',
mcc_codes: [],
keywords: ['pension', 'pensionsförsäkring', 'itp', 'avanza pension', 'spp'],
risk_level: 'MEDIUM',
requires_review: true,
impact_score: 4,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Pensionssparande för enskild firma (granska avdragsregel)',
common: false,
},
{
id: 'insurance_pension_ab',
name_sv: 'Pensionsförsäkring (AB)',
name_en: 'Pension insurance (AB)',
group: 'insurance',
direction: 'expense',
entity_applicability: 'aktiebolag',
debit_account: '7410',
credit_account: '1930',
vat_treatment: 'exempt',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'AB: Pensionskostnad är en avdragsgill personalkostnad',
mcc_codes: [],
keywords: ['pension', 'pensionsförsäkring', 'itp', 'tjänstepension'],
risk_level: 'NONE',
requires_review: false,
impact_score: 5,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Tjänstepension för anställda i aktiebolag',
common: false,
},
// --- PROFESSIONAL SERVICES (2) ---
{
id: 'prof_accounting',
name_sv: 'Redovisning & Juridik',
name_en: 'Accounting & Legal services',
group: 'professional_services',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6530',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [8931, 8111],
keywords: ['redovisning', 'bokföring', 'revisor', 'accounting', 'redovisningsbyrå', 'advokat', 'juridisk', 'legal', 'lawyer', 'jurist', 'fortnox', 'visma', 'bokio', 'dooer'],
risk_level: 'NONE',
requires_review: false,
impact_score: 8,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_professional_services',
description_sv: 'Redovisning, bokföring, revision och juridiska tjänster',
common: true,
},
{
id: 'prof_consulting',
name_sv: 'Konsulttjänster',
name_en: 'Consulting services',
group: 'professional_services',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6550',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [7392],
keywords: ['konsult', 'consulting', 'rådgivning', 'advisory', 'management'],
risk_level: 'NONE',
requires_review: false,
impact_score: 6,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_professional_services',
description_sv: 'Konsultarvoden och rådgivningstjänster',
common: true,
},
// --- BANK & FINANCE (5) ---
{
id: 'bank_fees',
name_sv: 'Bankavgifter',
name_en: 'Bank fees',
group: 'bank_finance',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6570',
credit_account: '1930',
vat_treatment: 'exempt',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Banktjänster är momsfria',
mcc_codes: [6010, 6011, 6012],
keywords: ['bankavgift', 'bank fee', 'kontoavgift', 'årsavgift', 'månadsavgift', 'kortavgift', 'zettle', 'izettle', 'stripe', 'klarna', 'swish', 'betalterminal', 'nets'],
risk_level: 'NONE',
requires_review: false,
impact_score: 9,
auto_match_confidence: 0.90,
default_private: false,
fallback_category: 'expense_bank_fees',
description_sv: 'Bankavgifter, kontoavgifter och kortavgifter',
common: true,
},
{
id: 'bank_interest_income',
name_sv: 'Ränteintäkt',
name_en: 'Interest income',
group: 'bank_finance',
direction: 'income',
entity_applicability: 'all',
debit_account: '1930',
credit_account: '8310',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
mcc_codes: [],
keywords: ['ränta', 'ränteinkomst', 'interest income', 'sparränta'],
risk_level: 'NONE',
requires_review: false,
impact_score: 5,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'income_other',
description_sv: 'Ränteintäkter på bankkonto eller placeringar',
common: false,
},
{
id: 'bank_interest_expense',
name_sv: 'Räntekostnad',
name_en: 'Interest expense',
group: 'bank_finance',
direction: 'expense',
entity_applicability: 'all',
debit_account: '8410',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
mcc_codes: [],
keywords: ['ränta', 'räntekostnad', 'interest expense', 'låneränta'],
risk_level: 'NONE',
requires_review: false,
impact_score: 5,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Räntekostnad på lån eller kredit',
common: false,
},
{
id: 'bank_currency_loss',
name_sv: 'Valutakursförlust',
name_en: 'Currency exchange loss',
group: 'bank_finance',
direction: 'expense',
entity_applicability: 'all',
debit_account: '7960',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
mcc_codes: [6051],
keywords: ['valuta', 'currency', 'växling', 'kursförlust', 'exchange'],
risk_level: 'NONE',
requires_review: false,
impact_score: 4,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_currency_exchange',
description_sv: 'Valutakursförluster vid betalning i utländsk valuta',
common: false,
},
{
id: 'bank_currency_gain',
name_sv: 'Valutakursvinst',
name_en: 'Currency exchange gain',
group: 'bank_finance',
direction: 'income',
entity_applicability: 'all',
debit_account: '1930',
credit_account: '3960',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
mcc_codes: [],
keywords: ['valuta', 'currency', 'kursvinst', 'exchange gain'],
risk_level: 'NONE',
requires_review: false,
impact_score: 3,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'income_other',
description_sv: 'Valutakursvinster vid betalning i utländsk valuta',
common: false,
},
// --- TELECOM (2) ---
{
id: 'telecom_mobile',
name_sv: 'Mobilabonnemang',
name_en: 'Mobile subscription',
group: 'telecom',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6211',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'conditional',
deductibility_note_sv: 'Blandad användning: bara yrkesmässig del avdragsgill',
mcc_codes: [4812, 4813, 4814],
keywords: ['mobil', 'tele2', 'telia', 'tre', 'telenor', 'hallon', 'comviq', 'mobilabonnemang', 'telefon', 'fast telefon', 'ip-telefoni', 'voip'],
risk_level: 'LOW',
requires_review: false,
impact_score: 8,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Telefon och mobilabonnemang (granska yrkesmässig andel)',
common: true,
},
{
id: 'telecom_internet',
name_sv: 'Internetanslutning',
name_en: 'Internet connection',
group: 'telecom',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6230',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [4816],
keywords: ['internet', 'bredband', 'fiber', 'broadband', 'bahnhof', 'telia', 'comhem'],
risk_level: 'NONE',
requires_review: false,
impact_score: 7,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Internetanslutning för kontor',
common: false,
},
// --- EDUCATION (2) ---
{
id: 'education_course',
name_sv: 'Kurs / Utbildning',
name_en: 'Course / Training',
group: 'education',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6991',
credit_account: '1930',
debit_account_ab: '7610',
vat_treatment: 'exempt',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Utbildningstjänster är momsfria. AB: konto 7610, EF: konto 6991.',
mcc_codes: [8220, 8241, 8244, 8249, 8299, 5815, 5816],
keywords: ['kurs', 'utbildning', 'course', 'training', 'workshop', 'certifiering', 'certification', 'udemy', 'coursera', 'pluralsight', 'linkedin learning', 'online course'],
risk_level: 'NONE',
requires_review: false,
impact_score: 6,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_education',
description_sv: 'Yrkesrelaterade kurser och utbildningar',
common: false,
},
{
id: 'education_membership',
name_sv: 'Branschförening / Medlemskap',
name_en: 'Trade association / Membership',
group: 'education',
direction: 'expense',
entity_applicability: 'all',
debit_account: '6980',
credit_account: '1930',
vat_treatment: 'exempt',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Föreningsavgifter är normalt momsfria',
mcc_codes: [8641, 8651, 8661, 8699],
keywords: ['medlemskap', 'membership', 'förening', 'branschorganisation', 'förbund', 'svenskt näringsliv', 'företagarna'],
risk_level: 'NONE',
requires_review: false,
impact_score: 4,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_education',
description_sv: 'Medlemsavgift i branschorganisation eller yrkesförening',
common: false,
},
// --- PERSONNEL (3) ---
{
id: 'personnel_salary',
name_sv: 'Lön (netto)',
name_en: 'Salary (net)',
group: 'personnel',
direction: 'expense',
entity_applicability: 'aktiebolag',
debit_account: '7210',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'OBS: Denna mall bokför nettolön. Personalskatt (2710) och arbetsgivaravgifter (2731) måste bokföras separat.',
mcc_codes: [],
keywords: ['lön', 'salary', 'nettolön', 'löneutbetalning'],
risk_level: 'NONE',
requires_review: true,
impact_score: 8,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Nettolön till anställd',
common: true,
},
{
id: 'personnel_employer_tax',
name_sv: 'Arbetsgivaravgifter',
name_en: 'Employer social contributions',
group: 'personnel',
direction: 'expense',
entity_applicability: 'aktiebolag',
debit_account: '2731',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Betalning av arbetsgivaravgift-skuld. Kostnad (D: 7510 / K: 2731) bokförs vid lönekörning.',
mcc_codes: [],
keywords: ['arbetsgivaravgift', 'sociala avgifter', 'employer tax', 'skattekonto'],
risk_level: 'NONE',
requires_review: true,
impact_score: 7,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Betalning av arbetsgivaravgifter',
common: true,
},
{
id: 'personnel_preliminary_tax',
name_sv: 'Preliminärskatt (AB)',
name_en: 'Preliminary tax (AB)',
group: 'personnel',
direction: 'expense',
entity_applicability: 'aktiebolag',
debit_account: '2510',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
mcc_codes: [],
keywords: ['preliminärskatt', 'skatt', 'tax', 'skatteverket', 'skattekonto'],
risk_level: 'NONE',
requires_review: true,
impact_score: 7,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Preliminärskatt till Skatteverket',
common: false,
},
// --- REVENUE (4) ---
{
id: 'revenue_standard_25',
name_sv: 'Försäljning 25% moms',
name_en: 'Revenue 25% VAT',
group: 'revenue',
direction: 'income',
entity_applicability: 'all',
debit_account: '1930',
credit_account: '3001',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [],
keywords: ['konsult', 'tjänst', 'service', 'arvode', 'fee', 'faktura', 'försäljning', 'vara', 'produkt', 'product', 'sale'],
risk_level: 'NONE',
requires_review: false,
impact_score: 10,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'income_services',
description_sv: 'Intäkter från tjänste- eller varuförsäljning med 25% moms',
common: true,
},
{
id: 'revenue_reduced_12',
name_sv: 'Försäljning 12% moms (livsmedel/logi)',
name_en: 'Revenue 12% VAT (food/accommodation)',
group: 'revenue',
direction: 'income',
entity_applicability: 'all',
debit_account: '1930',
credit_account: '3002',
vat_treatment: 'reduced_12',
vat_rate: 0.12,
deductibility: 'full',
mcc_codes: [],
keywords: ['livsmedel', 'mat', 'food', 'restaurang', 'logi'],
risk_level: 'NONE',
requires_review: false,
impact_score: 6,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'income_products',
description_sv: 'Intäkter från livsmedelsförsäljning eller logi med 12% moms',
common: true,
},
{
id: 'revenue_eu_services',
name_sv: 'Tjänsteförsäljning EU (omvänd moms)',
name_en: 'Service revenue EU (reverse charge)',
group: 'revenue',
direction: 'income',
entity_applicability: 'all',
debit_account: '1930',
credit_account: '3308',
vat_treatment: 'reverse_charge',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Tjänsteförsäljning till EU-företag → omvänd skattskyldighet, rapportera i ruta 39',
mcc_codes: [],
keywords: ['eu', 'export', 'eu service', 'reverse charge', 'utlandsförsäljning'],
risk_level: 'LOW',
requires_review: false,
impact_score: 6,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'income_services',
description_sv: 'Tjänsteförsäljning till EU-företag (momsfritt, ruta 39)',
common: true,
requires_vat_registration_data: true,
},
{
id: 'revenue_export',
name_sv: 'Export utanför EU',
name_en: 'Export outside EU',
group: 'revenue',
direction: 'income',
entity_applicability: 'all',
debit_account: '1930',
credit_account: '3305',
vat_treatment: 'export',
vat_rate: 0,
deductibility: 'full',
special_rules_sv: 'Export utanför EU → momsfritt, rapportera i ruta 40',
mcc_codes: [],
keywords: ['export', 'utland', 'usa', 'utanför eu', 'non-eu'],
risk_level: 'LOW',
requires_review: false,
impact_score: 5,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'income_services',
description_sv: 'Export av varor/tjänster utanför EU (momsfritt, ruta 40)',
common: true,
},
// --- FINANCIAL (2) ---
{
id: 'financial_loan_repayment',
name_sv: 'Amortering lån',
name_en: 'Loan repayment',
group: 'financial',
direction: 'transfer',
entity_applicability: 'all',
debit_account: '2350',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
mcc_codes: [],
keywords: ['amortering', 'lån', 'loan', 'repayment', 'avbetalning'],
risk_level: 'NONE',
requires_review: true,
impact_score: 5,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Amortering av banklån',
common: false,
},
{
id: 'financial_tax_account',
name_sv: 'Insättning skattekonto',
name_en: 'Tax account deposit',
group: 'financial',
direction: 'transfer',
entity_applicability: 'all',
debit_account: '1630',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'full',
mcc_codes: [],
keywords: ['skattekonto', 'skatteverket', 'tax account', 'f-skatt', 'moms inbetalning'],
risk_level: 'NONE',
requires_review: true,
impact_score: 8,
auto_match_confidence: 0.85,
default_private: false,
fallback_category: 'expense_other',
description_sv: 'Insättning på skattekonto hos Skatteverket',
common: true,
},
// --- PRIVATE TRANSFERS (5) ---
{
id: 'private_withdrawal_ef',
name_sv: 'Eget uttag (EF)',
name_en: 'Owner withdrawal (EF)',
group: 'private_transfers',
direction: 'transfer',
entity_applicability: 'enskild_firma',
debit_account: '2013',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'non_deductible',
mcc_codes: [],
keywords: ['eget uttag', 'privat', 'withdrawal', 'egen insättning'],
risk_level: 'NONE',
requires_review: false,
impact_score: 10,
auto_match_confidence: 0.85,
default_private: true,
fallback_category: 'private',
description_sv: 'Privat uttag från företagskonto (enskild firma)',
common: true,
},
{
id: 'private_deposit_ef',
name_sv: 'Egen insättning (EF)',
name_en: 'Owner deposit (EF)',
group: 'private_transfers',
direction: 'transfer',
entity_applicability: 'enskild_firma',
debit_account: '1930',
credit_account: '2018',
vat_treatment: null,
vat_rate: 0,
deductibility: 'non_deductible',
mcc_codes: [],
keywords: ['egen insättning', 'insättning', 'deposit', 'tillskott'],
risk_level: 'NONE',
requires_review: false,
impact_score: 8,
auto_match_confidence: 0.80,
default_private: true,
fallback_category: 'private',
description_sv: 'Egen insättning till företagskonto (enskild firma)',
common: true,
},
{
id: 'shareholder_loan_received',
name_sv: 'Lån från ägare (AB)',
name_en: 'Shareholder loan received (AB)',
group: 'private_transfers',
direction: 'transfer',
entity_applicability: 'aktiebolag',
debit_account: '1930',
credit_account: '2393',
vat_treatment: null,
vat_rate: 0,
deductibility: 'non_deductible',
mcc_codes: [],
keywords: ['aktieägare', 'lån', 'shareholder', 'skuld till ägare', 'insättning', 'tillskott'],
risk_level: 'LOW',
requires_review: true,
impact_score: 6,
auto_match_confidence: 0.75,
default_private: false,
fallback_category: 'income_other',
description_sv: 'Ägare lånar pengar till bolaget (skuld till ägare)',
common: true,
},
{
id: 'shareholder_loan_disbursed',
name_sv: 'Fordran på ägare (AB)',
name_en: 'Shareholder loan disbursed (AB)',
group: 'private_transfers',
direction: 'transfer',
entity_applicability: 'aktiebolag',
debit_account: '1680',
credit_account: '1930',
vat_treatment: null,
vat_rate: 0,
deductibility: 'non_deductible',
mcc_codes: [],
keywords: ['aktieägare', 'lån till ägare', 'shareholder loan', 'fordran ägare'],
risk_level: 'HIGH',
requires_review: true,
impact_score: 6,
auto_match_confidence: 0.70,
default_private: true,
fallback_category: 'private',
description_sv: 'Bolaget betalar ut till ägare (fordran på ägare)',
common: false,
},
{
id: 'private_expense',
name_sv: 'Privat kostnad',
name_en: 'Private expense',
group: 'private_transfers',
direction: 'expense',
entity_applicability: 'all',
debit_account: '2013',
credit_account: '1930',
debit_account_ab: '2893',
vat_treatment: null,
vat_rate: 0,
deductibility: 'non_deductible',
mcc_codes: [],
keywords: ['privat', 'private', 'personlig', 'personal'],
risk_level: 'NONE',
requires_review: false,
impact_score: 9,
auto_match_confidence: 0.85,
default_private: true,
fallback_category: 'private',
description_sv: 'Privat kostnad betald från företagskonto',
common: false,
},
// --- EQUIPMENT (2) ---
{
id: 'equipment_small',
name_sv: 'Förbrukningsinventarie',
name_en: 'Consumable equipment',
group: 'equipment',
direction: 'expense',
entity_applicability: 'all',
debit_account: '5410',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
special_rules_sv: 'Under halva prisbasbeloppet = förbrukningsinventarie. Inkluderar IT-utrustning, möbler, verktyg.',
mcc_codes: [5045, 5065, 5200, 5251, 5261, 5072, 5712, 5021, 5732],
keywords: ['tangentbord', 'mus', 'headset', 'adapter', 'monitor', 'skärm', 'dator', 'laptop', 'macbook', 'möbler', 'stol', 'skrivbord', 'ikea', 'verktyg', 'maskin', 'biltema', 'jula', 'bauhaus', 'mobil', 'telefon', 'iphone'],
risk_level: 'LOW',
requires_review: false,
impact_score: 7,
auto_match_confidence: 0.80,
default_private: false,
fallback_category: 'expense_equipment',
description_sv: 'Inventarier under halva prisbasbeloppet (IT-utrustning, möbler, verktyg)',
common: true,
},
{
id: 'equipment_capital',
name_sv: 'Inventarie (aktivering)',
name_en: 'Capital equipment (capitalize)',
group: 'equipment',
direction: 'expense',
entity_applicability: 'all',
debit_account: '1250',
credit_account: '1930',
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
special_rules_sv: 'Över halva prisbasbeloppet: aktivera och skriv av. Avskrivning konto 7832.',
mcc_codes: [],
keywords: ['inventarie', 'anläggningstillgång', 'capital', 'aktivering', 'avskrivning'],
risk_level: 'LOW',
requires_review: true,
impact_score: 4,
auto_match_confidence: 0.70,
default_private: false,
fallback_category: 'expense_equipment',
description_sv: 'Inventarie som ska aktiveras och skrivas av',
common: false,
},
]
// ============================================================
// Lookup Indexes (built once at module load)
// ============================================================
const templateById = new Map<string, BookingTemplate>()
const templatesByGroup = new Map<TemplateGroup, BookingTemplate[]>()
const templatesByMcc = new Map<number, BookingTemplate[]>()
for (const t of BOOKING_TEMPLATES) {
templateById.set(t.id, t)
const groupList = templatesByGroup.get(t.group) || []
groupList.push(t)
templatesByGroup.set(t.group, groupList)
for (const mcc of t.mcc_codes) {
const mccList = templatesByMcc.get(mcc) || []
mccList.push(t)
templatesByMcc.set(mcc, mccList)
}
}
// ============================================================
// Public API
// ============================================================
/** O(1) lookup by template ID */
export function getTemplateById(id: string): BookingTemplate | undefined {
return templateById.get(id)
}
/** Get all templates in a group */
export function getTemplatesByGroup(group: TemplateGroup): BookingTemplate[] {
return templatesByGroup.get(group) || []
}
/** Get templates matching a specific MCC code */
export function getTemplatesByMcc(mcc: number): BookingTemplate[] {
return templatesByMcc.get(mcc) || []
}
/** Get all groups with labels and their templates */
export function getTemplateGroups(): TemplateGroupInfo[] {
const groups: TemplateGroupInfo[] = []
for (const [group, labels] of Object.entries(GROUP_LABELS)) {
const g = group as TemplateGroup
groups.push({
group: g,
label_sv: labels.sv,
label_en: labels.en,
templates: templatesByGroup.get(g) || [],
})
}
return groups
}
/**
* Fuzzy search templates by name, keywords, or description.
* Optionally filter by entity type.
*/
export function searchTemplates(query: string, entityType?: EntityType): BookingTemplate[] {
if (!query.trim()) return []
const q = query.toLowerCase()
const tokens = q.split(/\s+/).filter(Boolean)
return BOOKING_TEMPLATES.filter((t) => {
// Filter by entity applicability
if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) {
return false
}
// Check if all tokens match somewhere
return tokens.every((token) =>
t.name_sv.toLowerCase().includes(token) ||
t.name_en.toLowerCase().includes(token) ||
t.description_sv.toLowerCase().includes(token) ||
t.keywords.some((kw) => kw.toLowerCase().includes(token)) ||
t.id.includes(token)
)
})
}
/**
* Get common templates, filtered by entity type and direction.
*/
export function getCommonTemplates(
entityType?: EntityType,
direction?: 'expense' | 'income' | 'transfer'
): BookingTemplate[] {
return BOOKING_TEMPLATES.filter((t) => {
if (!t.common) return false
if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) return false
if (direction && t.direction !== direction) return false
return true
})
}
/**
* Get advanced (non-common) templates, filtered by entity type and direction.
*/
export function getAdvancedTemplates(
entityType?: EntityType,
direction?: 'expense' | 'income' | 'transfer'
): BookingTemplate[] {
return BOOKING_TEMPLATES.filter((t) => {
if (t.common) return false
if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) return false
if (direction && t.direction !== direction) return false
return true
})
}
/**
* Validate that a template is valid for the given entity type.
*/
export function validateTemplateForEntity(
template: BookingTemplate,
entityType: EntityType
): { valid: boolean; error?: string } {
if (template.entity_applicability === 'all') return { valid: true }
if (template.entity_applicability === entityType) return { valid: true }
return {
valid: false,
error: `Template "${template.name_sv}" is only valid for ${template.entity_applicability}. Your entity type is ${entityType}.`,
}
}
/**
* Multi-signal matching against a transaction.
* Returns top matches sorted by confidence descending.
*/
export function findMatchingTemplates(
transaction: Transaction,
entityType?: EntityType
): TemplateMatch[] {
const results: TemplateMatch[] = []
const isExpense = transaction.amount < 0
const isIncome = transaction.amount > 0
const descLower = (transaction.description || '').toLowerCase()
const merchantLower = (transaction.merchant_name || '').toLowerCase()
const searchText = `${descLower} ${merchantLower}`
for (const t of BOOKING_TEMPLATES) {
// Filter entity applicability
if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) {
continue
}
// Filter direction
if (t.direction === 'expense' && !isExpense) continue
if (t.direction === 'income' && !isIncome) continue
// 'transfer' templates match both directions
let score = 0
// MCC exact match: +0.4
if (transaction.mcc_code && t.mcc_codes.includes(transaction.mcc_code)) {
score += 0.4
}
// Keyword matches in description + merchant: +0.3 (proportional)
if (t.keywords.length > 0) {
let matchedKeywords = 0
for (const kw of t.keywords) {
if (searchText.includes(kw.toLowerCase())) {
matchedKeywords++
}
}
if (matchedKeywords > 0) {
score += 0.3 * Math.min(matchedKeywords / Math.min(t.keywords.length, 3), 1)
}
}
// Direction match bonus: +0.1 (only if there's already a signal)
if (score > 0 && ((t.direction === 'expense' && isExpense) || (t.direction === 'income' && isIncome))) {
score += 0.1
}
if (score > 0) {
const confidence = Math.round(score * t.auto_match_confidence * 100) / 100
results.push({ template: t, confidence })
}
}
return results
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 10)
}
/**
* Convert a booking template into a MappingResult.
* Follows the same pattern as buildMappingResultFromCategory in category-mapping.ts.
*/
export function buildMappingResultFromTemplate(
template: BookingTemplate,
transaction: Transaction,
entityType: EntityType = 'enskild_firma'
): MappingResult {
const isExpense = transaction.amount < 0
const isBusiness = !template.default_private
// Resolve entity-specific accounts
let debitAccount = template.debit_account
let creditAccount = template.credit_account
if (entityType === 'aktiebolag') {
if (template.debit_account_ab) debitAccount = template.debit_account_ab
if (template.credit_account_ab) creditAccount = template.credit_account_ab
}
// Generate VAT lines
const vatLines: VatJournalLine[] = []
if (isBusiness && template.vat_treatment && template.deductibility !== 'non_deductible') {
const vatRate = getVatRate(template.vat_treatment)
if (template.vat_treatment === 'reverse_charge' && isExpense) {
// EU reverse charge: fiktiv moms (offsetting entries)
const absAmount = Math.abs(transaction.amount)
const rcLines = generateReverseChargeLines(absAmount)
for (const rcl of rcLines) {
vatLines.push({
account_number: rcl.account_number,
debit_amount: rcl.debit_amount,
credit_amount: rcl.credit_amount,
description: rcl.line_description || '',
})
}
} else if (vatRate > 0 && isExpense) {
// Input VAT deduction
const absAmount = Math.abs(transaction.amount)
const vatLine = generateInputVatLine(absAmount, vatRate)
if (vatLine) {
vatLines.push({
account_number: vatLine.account_number,
debit_amount: vatLine.debit_amount,
credit_amount: vatLine.credit_amount,
description: vatLine.line_description || '',
})
}
} else if (vatRate > 0 && !isExpense) {
// Output VAT (income)
const grossAmount = Math.abs(transaction.amount)
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
let vatAccount: string
switch (template.vat_treatment) {
case 'standard_25': vatAccount = '2611'; break
case 'reduced_12': vatAccount = '2621'; break
case 'reduced_6': vatAccount = '2631'; break
default: vatAccount = '2611'
}
vatLines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: vatAmount,
description: `Utgående moms ${vatRate * 100}%`,
})
}
}
// Build description
const description = `${template.name_sv}: ${transaction.description}`
return {
rule: null,
template_id: template.id,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: template.risk_level,
confidence: 1.0, // User explicitly selected template
requires_review: template.requires_review,
default_private: template.default_private,
vat_lines: vatLines,
description,
}
}