Files
accounted/lib/bookkeeping/category-mapping.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

449 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types'
import { getVatRate, generateReverseChargeLines } from './vat-entries'
import { resolveSekAmount } from './currency-utils'
import { roundOre } from '@/lib/money'
import { byEntityType, ownerSettlementAccount } from '@/lib/company/entity-type'
/**
* Maps TransactionCategory to BAS accounts for journal entry creation
*
* Account mapping follows Swedish BAS Kontoplan:
* - 1xxx: Assets
* - 2xxx: Equity & Liabilities
* - 3xxx: Revenue
* - 4xxx: Cost of goods sold
* - 5xxx: External expenses
* - 6xxx: Other external expenses
* - 7xxx: Personnel costs
* - 8xxx: Financial items
*
* Key differences between entity types:
* - Enskild Firma: Uses 2013 (Eget uttag) for private withdrawals
* - Aktiebolag: Uses 2893 (Skuld till aktieägare) for owner transactions
*/
interface CategoryAccountMapping {
debitAccount: string
creditAccount: string
vatTreatment: string | null
vatDebitAccount: string | null
vatCreditAccount: string | null
}
// Default bank account - typically 1930 (Företagskonto/checkkonto)
const BANK_ACCOUNT = '1930'
// Private/owner transaction accounts live in lib/company/entity-type.ts
// (ownerSettlementAccount): EF 2013/2018, AB 2893, ideell förening 2890.
// Single source of truth for category -> expense account mapping
const EXPENSE_ACCOUNTS: Record<string, string> = {
expense_equipment: '5410', // Förbrukningsinventarier
expense_software: '5420', // Programvaror
expense_travel: '5890', // Övriga resekostnader (5800 är gruppkonto)
expense_office: '6110', // Kontorsförbrukning
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_representation: '6071', // Representation, avdragsgill
expense_consumables: '5460', // Förbrukningsvaror
expense_vehicle: '5611', // Drivmedel bil
expense_telecom: '6230', // Datakommunikation (6200 är gruppkonto)
expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
expense_other: '6991', // Övriga avdragsgilla kostnader
}
// Income account mapping
const INCOME_ACCOUNTS: Record<string, string> = {
income_services: '3001', // Försäljning tjänster 25%
income_products: '3001', // Försäljning varor 25% moms
income_other: '3999', // Övriga rörelseintäkter (3900 är gruppkonto)
}
/**
* Get the expense account for a category, with entity-specific overrides.
* Education (expense_education) differs: AB uses 7610, EF uses 6991.
*/
function getExpenseAccount(category: string, entityType: EntityType): string {
if (category === 'expense_education') {
// 7610 is a personnel cost: only a form with employees by default books
// education there; the others take the general external-cost account.
return byEntityType(entityType, {
aktiebolag: '7610',
enskild_firma: '6991',
ideell_forening: '6991',
})
}
return EXPENSE_ACCOUNTS[category] || '6991'
}
/**
* Get the income account for a category, resolving by VAT treatment.
* BAS mandates revenue account segregation by VAT rate:
* 3001=25%, 3002=12%, 3003=6%, 3305=Export, 3308=EU services, 3004=Exempt.
*/
function getIncomeAccount(category: string, vatTreatment?: VatTreatment): string {
// income_other always maps to 3999 regardless of VAT treatment (3900 är gruppkonto)
if (category === 'income_other') return '3999'
if (vatTreatment) {
switch (vatTreatment) {
case 'standard_25': return '3001'
case 'reduced_12': return '3002'
case 'reduced_6': return '3003'
case 'export': return '3305'
case 'reverse_charge': return '3308'
case 'exempt': return '3004'
}
}
// No vatTreatment provided: fall back to static mapping
return INCOME_ACCOUNTS[category] || '3999'
}
/**
* Get account mapping for a transaction category
*
* For expenses: Debit expense account, Credit bank (or private for non-business)
* For income: Debit bank, Credit revenue account
*/
export function getCategoryAccountMapping(
category: TransactionCategory,
amount: number,
isBusiness: boolean,
entityType: EntityType,
vatTreatment?: VatTreatment
): CategoryAccountMapping {
// Private/owner transactions use entity-specific accounts
// EF: 2013 for withdrawals (uttag), 2018 for deposits (insättningar)
// AB: 2893 for both directions; ideell förening: 2890 (no owner)
if (!isBusiness) {
const privateAccount = ownerSettlementAccount(entityType, amount < 0 ? 'withdrawal' : 'contribution')
return {
debitAccount: amount < 0 ? privateAccount : BANK_ACCOUNT,
creditAccount: amount < 0 ? BANK_ACCOUNT : privateAccount,
vatTreatment: null,
vatDebitAccount: null,
vatCreditAccount: null,
}
}
// Check if it's an expense category
if (category.startsWith('expense_')) {
const expenseAccount = getExpenseAccount(category, entityType)
// Bank fees, card fees, and currency exchange are VAT-exempt in Sweden
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
const isVatExempt = vatExemptCategories.includes(category)
// Representation defaults to reduced_12 (ML 13 kap 24-25 §§, max 300 SEK/person).
// Note: income tax deduction was abolished 2017 (IL 16 kap 2 §), but VAT deduction remains.
const resolvedVat = vatTreatment ?? (isVatExempt ? null : category === 'expense_representation' ? 'reduced_12' : 'standard_25')
if (amount > 0) {
// Incoming refund: bank receives money, expense account is reduced (credited).
// Ingående moms is reversed: credit 2641 instead of debit.
return {
debitAccount: BANK_ACCOUNT,
creditAccount: expenseAccount,
vatTreatment: resolvedVat,
vatDebitAccount: null,
vatCreditAccount: resolvedVat ? '2641' : null,
}
}
return {
debitAccount: expenseAccount,
creditAccount: BANK_ACCOUNT,
vatTreatment: resolvedVat,
vatDebitAccount: resolvedVat ? '2641' : null, // Debiterad ingående moms
vatCreditAccount: null,
}
}
// Check if it's an income category
if (category.startsWith('income_')) {
const incomeAccount = getIncomeAccount(category, vatTreatment)
// Use provided vatTreatment, or default to standard_25
const resolvedVat = vatTreatment ?? 'standard_25'
// Determine output VAT account based on rate
let outputVatAccount: string | null = null
switch (resolvedVat) {
case 'standard_25':
outputVatAccount = '2611' // Utgående moms försäljning 25%
break
case 'reduced_12':
outputVatAccount = '2621' // Utgående moms försäljning 12%
break
case 'reduced_6':
outputVatAccount = '2631' // Utgående moms försäljning 6%
break
default:
outputVatAccount = null
break
}
return {
debitAccount: BANK_ACCOUNT,
creditAccount: incomeAccount,
vatTreatment: resolvedVat,
vatDebitAccount: null,
vatCreditAccount: outputVatAccount,
}
}
// Uncategorized - default to misc expense/income based on amount
if (amount < 0) {
return {
debitAccount: '6991',
creditAccount: BANK_ACCOUNT,
vatTreatment: null,
vatDebitAccount: null,
vatCreditAccount: null,
}
} else {
return {
debitAccount: BANK_ACCOUNT,
creditAccount: '3999',
vatTreatment: null,
vatDebitAccount: null,
vatCreditAccount: null,
}
}
}
/**
* Build a MappingResult from a category selection
* Used by the categorization API to create journal entries
*
* `vatAmountOverride` is the underlag's actual VAT when it differs from the
* rate-derived amount: e.g. a restaurant receipt where dricks carries no
* moms, so the document's VAT is lower than rate × gross. It can only replace
* a rate-based VAT line (standard_25/reduced_12/reduced_6); it never applies
* to fictive reverse-charge VAT and never conjures a line for treatments
* without VAT. Zero is rejected: a document with no moms is an exempt supply
* and must be booked with vat_treatment "exempt" so the momsdeklaration sees
* the correct classification, not a rate-bearing treatment minus its VAT line.
*
* Currency: `vatAmountOverride` is denominated in the TRANSACTION's currency,
* exactly like `transaction.amount` (it is the figure printed on the underlag).
* All journal entry lines are SEK, so every VAT figure here goes through the
* same SEK resolution buildTransactionEntryLines applies to the gross. Before
* this, a 15.87 USD override validated against the USD gross but posted as
* 15.87 kr: the entry balanced (the net line absorbed the difference), so
* nothing downstream could detect the wrong 26xx figure.
*/
export function buildMappingResultFromCategory(
category: TransactionCategory,
transaction: Transaction,
isBusiness: boolean,
entityType: EntityType,
vatTreatment?: VatTreatment,
vatAmountOverride?: number | null
): MappingResult {
const mapping = getCategoryAccountMapping(category, transaction.amount, isBusiness, entityType, vatTreatment)
const vatLines: VatJournalLine[] = []
// Calculate VAT if applicable using the resolved treatment from mapping
const treatment = mapping.vatTreatment as VatTreatment | null
const hasVatOverride = vatAmountOverride !== undefined && vatAmountOverride !== null
// Journal entry lines are SEK; transaction.amount is in transaction.currency.
// The lenient resolver (not the OrNull sibling) is deliberate: it must agree
// with buildTransactionEntryLines, which nets these VAT lines against the
// same resolution of the gross. Disagreeing resolvers would unbalance the
// net line; agreeing ones keep legacy rateless rows exactly as before.
const absAmount = Math.abs(transaction.amount)
const absSekAmount = Math.abs(resolveSekAmount(
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
))
if (hasVatOverride) {
// Treatment compatibility first: an invalid override on reverse_charge is
// a treatment problem, not an amount problem: the agent should get the
// correction hint that matches the actual mistake.
if (!isBusiness || !treatment || treatment === 'reverse_charge' || getVatRate(treatment) <= 0) {
throw new Error(
`vat_amount cannot be combined with vat_treatment "${treatment ?? 'none'}": ` +
'it only overrides a rate-based VAT line (standard_25, reduced_12, reduced_6).'
)
}
// typeof re-check is deliberate: at commit time the override comes from
// jsonb params, so the TS signature doesn't guarantee a number at runtime.
if (typeof vatAmountOverride !== 'number' || !Number.isFinite(vatAmountOverride) || vatAmountOverride <= 0) {
throw new Error(
`vat_amount must be a positive number, got ${vatAmountOverride}. ` +
'For a document with no moms, use vat_treatment "exempt" instead of vat_amount 0.'
)
}
// 25% is the highest Swedish VAT rate, so rate-extraction at 25% bounds
// any legitimate document VAT: even on mixed-rate receipts. The bound and
// the override share the transaction's currency (both come off the
// underlag), so the comparison stays in that currency.
const maxVat = roundOre(absAmount * 0.25 / 1.25)
if (vatAmountOverride > maxVat) {
const currencyTag = transaction.currency && transaction.currency !== 'SEK'
? ` ${transaction.currency}` : ''
throw new Error(
`vat_amount ${vatAmountOverride} exceeds the maximum possible Swedish VAT on ${absAmount}${currencyTag} ` +
`(${maxVat}${currencyTag} at 25%). vat_amount is denominated in the transaction's currency, like belopp. ` +
`Check the underlag: the override must be the document's actual moms.`
)
}
}
if (isBusiness && treatment) {
const vatRate = getVatRate(treatment)
if (treatment === 'reverse_charge' && transaction.amount < 0) {
// EU reverse charge: fiktiv moms (offsetting entries), 25% of the SEK
// value: an EUR invoice's fiktiv moms posted off the EUR figure would
// understate 2614/2645 by the exchange rate.
const rcLines = generateReverseChargeLines(absSekAmount)
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) {
// Override: scale from transaction currency to SEK by the same ratio
// the gross resolved at (amount_sek embeds the bank's actual settlement
// rate, so a plain exchange_rate multiply could disagree with the gross
// line). absAmount > 0 is guaranteed here: a positive override on a
// zero-amount transaction already failed the maxVat bound above.
const vatAmount = hasVatOverride
? roundOre((vatAmountOverride as number) * (absSekAmount / absAmount))
: roundOre(absSekAmount * vatRate / (1 + vatRate))
if (vatAmount > 0 && transaction.amount < 0 && mapping.vatDebitAccount) {
// Expense: Ingående moms (deductible VAT)
vatLines.push({
account_number: mapping.vatDebitAccount,
debit_amount: vatAmount,
credit_amount: 0,
description: hasVatOverride ? 'Ingående moms (enligt underlag)' : `Ingående moms ${vatRate * 100}%`,
})
} else if (vatAmount > 0 && transaction.amount > 0 && mapping.vatCreditAccount) {
// Income output VAT, or expense refund reversing ingående moms (both credit vatCreditAccount).
// Distinguish by account: 2641 = reversed ingående moms, 2611/2621/2631 = utgående moms.
const isExpenseRefund = mapping.vatCreditAccount === '2641'
vatLines.push({
account_number: mapping.vatCreditAccount,
debit_amount: 0,
credit_amount: vatAmount,
description: isExpenseRefund
? (hasVatOverride ? 'Återföring ingående moms (enligt underlag)' : `Återföring ingående moms ${vatRate * 100}%`)
: (hasVatOverride ? 'Utgående moms (enligt underlag)' : `Utgående moms ${vatRate * 100}%`),
})
}
}
}
// Generate description
const categoryLabels: Record<TransactionCategory, string> = {
income_services: 'Tjänsteförsäljning',
income_products: 'Varuförsäljning',
income_other: 'Övrig intäkt',
expense_equipment: 'Förbrukningsinventarier',
expense_software: 'Programvara',
expense_travel: 'Resekostnad',
expense_office: 'Kontorskostnad',
expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänst',
expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Förbrukningsvaror',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling',
expense_other: 'Övrig kostnad',
private: 'Privat',
uncategorized: 'Okategoriserad',
}
const description = isBusiness
? `${categoryLabels[category] || category}: ${transaction.description}`
: `Privat: ${transaction.description}`
return {
rule: null,
debit_account: mapping.debitAccount,
credit_account: mapping.creditAccount,
risk_level: 'LOW',
confidence: 1.0, // User explicitly categorized
requires_review: false,
default_private: !isBusiness,
vat_lines: vatLines,
description,
}
}
/**
* Get the expense account number for a category
* Useful for creating mapping rules
*/
export function getExpenseAccountForCategory(category: TransactionCategory): string | null {
if (category === 'expense_education') return '6991'
return EXPENSE_ACCOUNTS[category] || null
}
/**
* Get the default account number for a category.
* For expense categories: returns the expense account (debit side).
* For income categories: returns the revenue account (credit side).
* For private/uncategorized: returns the entity-specific private or fallback account.
*/
export function getDefaultAccountForCategory(
category: TransactionCategory,
entityType: EntityType
): string {
if (category === 'private') {
return ownerSettlementAccount(entityType, 'withdrawal')
}
if (category.startsWith('expense_')) {
return getExpenseAccount(category, entityType)
}
if (category.startsWith('income_')) {
return INCOME_ACCOUNTS[category] || '3999'
}
// uncategorized
return '6991'
}
/**
* Get the default VAT treatment for a category.
* Bank fees, card fees, and currency exchange are VAT-exempt.
* All other business categories default to standard 25%.
*/
export function getDefaultVatTreatmentForCategory(
category: TransactionCategory
): VatTreatment | null {
if (category === 'private' || category === 'uncategorized') {
return null
}
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
if (vatExemptCategories.includes(category)) {
return null
}
// Representation defaults to reduced_12 (ML 13 kap 24-25 §§, max 300 SEK/person).
// Note: income tax deduction was abolished 2017 (IL 16 kap 2 §), but VAT deduction remains.
if (category === 'expense_representation') {
return 'reduced_12'
}
return 'standard_25'
}