Files
accounted/lib/bookkeeping/mapping-engine.ts
T
Jakob Wennberg 4e1eb3d662 fix(cash-accounts): never propose or accept an orphaned twin ledger as counter-account; match and re-point across sibling ledgers (#1643) (#2010)
* fix(cash-accounts): never propose or accept an orphaned cash-account ledger as counter-account (#1643)

A broken bank reconnect leaves cash_accounts rows that share the live
account's IBAN (held by a revoked connection, or demoted to manual by the
#916 fix). Three consequences are fixed here:

- Problem 4 (silent mis-booking): the own-account transfer detector paired
  with such an orphan and proposed its ledger as the counter-account, and a
  counterparty template learned from that result replayed as 1940/1931 in
  the booking dialog. The detector now tolerates several rows on one IBAN,
  never pairs with the transaction's own row, a disabled row, or a revoked
  holder; the mapping engine drops a "transfer" whose counter equals the
  settlement account; suggest-categories withholds learned suggestions that
  reference an orphaned ledger; and both commit paths (POST
  /api/transactions/[id]/categorize, categorizeMatchedTransaction) reject
  with the new TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT (400). Orphans are
  only refused in the COUNTER position: a stranded row still settles on its
  own ledger, and a manual account without a live IBAN twin is never
  treated as orphaned, so transfers between two live accounts keep booking.
- Problem 1 (match dialog): the ranked unmatched-entries path also offers
  vouchers booked on sibling ledgers of the same IBAN, and manualLink
  accepts a voucher line on a sibling ledger. When it does, the same locked
  UPDATE re-points transactions.cash_account_id to the live sibling row
  (currency-gated, like PATCH /api/transactions/[id]/cash-account) so the
  account-keyed reconciliation does not count a cross-account link as an
  imbalance on both ledgers.
- Problem 3 (naming): allocatePsd2LedgerAccount names the chart account
  BAS-style (BAS reference name for a standard slot, else "Bankkonto
  <CUR>") instead of the ASPSP-reported holder name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address review findings on the orphaned-ledger guards (#1643)

One in-memory topology (cash_accounts rows + bank_connections status) now
defines "live", "orphaned" and "same physical account" for the transfer
detector, the match/link flows and every commit guard, so a proposal is
never made that a guard later rejects.

- Finding 1/4/6 (own IBAN as counterparty): findPairableCashAccountByIban
  treats the transaction's own IBAN as "not a transfer": every same-currency
  row on that IBAN is the same physical account, whichever is live, so
  interest stamped with the own IBAN never pairs with a twin (two active
  rows, a demoted-manual twin, or a live twin of a stranded row). Only a
  pocket in another currency on that IBAN can still pair. guardCounterLegs
  refuses a same-IBAN same-currency twin in the counter position on every
  commit path, even when both rows are active.
- Finding 3: with several surviving candidates (currency pockets with no
  discriminator, or two active twins) the finder returns null instead of
  picking the lowest ledger, which is what the pre-PR lookup did.
- Finding 5: the finder drops every row in the orphaned set, the same
  predicate the commit guards use (demoted-manual twins included).
- Finding 9: "live" means enabled + connection status 'active'; an
  expired/error twin of a live row is orphaned, a lone expired connection
  (re-auth window) is not.
- Finding 2: siblings are keyed on (normalized IBAN, currency) in
  describeCashAccountSiblings and the unmatched-entries route, so a SEK
  transaction can no longer link to a voucher whose only bank leg is on the
  EUR pocket of the same IBAN; manualLink rejects that as before.
- Finding 8: manualLink re-points a row only when the voucher sits on the
  LIVE sibling and the own row is not live; the reverse direction links
  without moving the row.
- Finding 7: the v1 REST categorize route runs the same guardCounterLegs
  check after account_override and returns
  TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. MCP stages through
  categorizeMatchedTransaction, already covered.
- Finding 10: a learned template whose stale 19xx leg is a twin of the
  settlement row is rewritten to the settlement account (it is the bank
  leg, not the counter) instead of refused; suggest-categories exempts each
  transaction's own settlement ledger before withholding a suggestion. The
  error message now covers both the twin and the disconnected case.

Tests pin each behavior (service, detector, manualLink, unmatched-entries,
dashboard and v1 categorize routes, suggest-categories).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address round-2 review findings (#1643)

1+3. Orphan derivation keyed on (IBAN, currency): loadCashAccountTopology
   now keys the live twin on normalized IBAN plus currency (the rule every
   other "same physical account" check in the PR already used), so a
   manual or deselected GBP/EUR pocket beside a live SEK pocket of a
   multi-currency account is never orphaned, still pairs in the transfer
   detector and is accepted as counter at commit. Twin computation is
   shared (twinLedgersOf).
2. suggest-categories mirrors guardCounterLegs: a learned 19xx leg that is
   a twin of the transaction's own row is rewritten to the settlement
   ledger in the offered suggestion instead of being withheld; only a true
   counter-position orphan (or a twin that would book the settlement
   ledger against itself) is withheld. One topology load per batch
   (loadCounterLegTopology).
4. The free-form dialog path (POST /api/transactions/[id]/book) gets a
   line-level guard (guardBookedCounterLines): a 19xx line that is a twin
   of the transaction's own row or an orphaned ledger, alongside the
   settlement leg, is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT.
   Only runs when the lines touch two distinct 19xx ledgers. The twin
   rewrite in suggest-categories (2) covers the both-active shape before
   the dialog is even opened.
5. manualLink re-points the row onto the sibling ledger the voucher was
   booked on whenever the sibling is live or the own row is not (both-live
   twins and both-dead rows included); only a live row whose voucher sits
   on a dead sibling links without moving. unmatched-entries now uses
   describeCashAccountSiblings and does not offer dead-sibling vouchers to
   a live row.

DECISIONS.md: the PR's existing review follow-up line amended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address round-3 review findings (#1643)

1. Revoked-held rows are no longer orphaned unconditionally. A row whose
   connection is revoked is orphaned only under the twin rule (not live
   AND a live row shares its normalized IBAN + currency), so a
   disconnected-but-real account (the company's only 1930, or two real
   accounts on one revoked connection) stays pairable by the transfer
   detector and bookable as counter on every guarded path. Tests cover
   the no-twin case for getOrphanedCounterLedgers,
   findPairableCashAccountByIban, detectOwnAccountTransfer,
   guardCounterLegs and guardBookedCounterLines; the existing revoked
   tests now use a twin shape.
2. manualLink / unmatched-entries decide the re-point on the destination:
   a new shouldRepointToSibling moves onto a live sibling, or onto a dead
   one only when the own row's holder is gone (released: bank_connection_id
   null or revoked) and no sibling is live. An expired/error/pending own
   row links without moving. SiblingCashAccount gains `released`. Tests:
   expired own row + demoted twin links without moving and the twin's
   vouchers are not offered.
3. loadCounterLegTopology is exercised directly: settlement ledger and
   twins, other-currency pocket, null/unknown ids, cache, orphan set
   equal to guardCounterLegs' refusals on the same fixture, lookup failure.
4. guardBookedCounterLines docstring and the /book route comment now state
   that only the two-cash-legs shape is inspected; a single hand-typed
   19xx line is not (covering it would cost a cash_accounts lookup on
   every ordinary booking). DECISIONS.md lines amended accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address round-4 review findings (#1643)

1. Same-connection re-registration twins (the dominant prod shape): two
   enabled rows on one active connection sharing (IBAN, currency) are now
   told apart by balance_updated_at; only the most recently synced row is
   live, the other is a stale twin (orphaned as a counter, never a
   re-point destination, and the transfer detector pairs with the syncing
   row alone). Rows with no stamp or the same stamp both stay live.
2. POST /book: a single 19xx line that is a sibling ledger the row should
   move to (the live twin of a stranded row) re-points cash_account_id in
   the same locked UPDATE that links the voucher, mirroring manualLink.
   guardBookedCounterLines returns { refusedLedger, repointCashAccountId };
   an ordinary booking pays one PK read of the own row.
3. manualLink refuses the link (success:false, Swedish error) when the
   voucher sits only on a dead sibling instead of writing a cross-account
   link with a server-side warn; the REST and MCP link callers reach it
   without the unmatched-entries filter.
4. manualLink judges a voucher touching several sibling ledgers on the
   best of them (a live sibling, else the first the row may move to)
   instead of the first line PostgREST returns.

Tests pinned in lib/cash-accounts, lib/reconciliation and the /book route;
the two DECISIONS.md lines for #1643 amended in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): address round-5 review findings (#1643)

1/2/5. Same-connection twin liveness no longer ranks on
   cash_accounts.balance_updated_at (a connect-time snapshot the sync
   never refreshes, inverted on prod in 4 of 5 stamped groups). The live
   row is the one whose external_uid the bank still lists in
   bank_connections.accounts_data (rewritten on every sync); no listing,
   both listed or neither listed keeps both rows live (round-3 behavior).
   getConnectionStatuses selects accounts_data in the same query.
3. guardBookedCounterLines single-19xx-line shape: a twin the row may not
   move to (dead or disabled) is refused with
   TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT instead of posting the only
   bank leg on the dead ledger; an unrelated 19xx line still posts as
   typed. Route test added.
4. Disabled cash_accounts rows are never siblings, so neither manualLink
   nor /book re-points a transaction onto a deselected row; a voucher
   booked only there is refused as a cross-account link.
6. PR body rewritten to the final rules; DECISIONS.md round-4 line
   amended (signal correction, /book refusal, disabled siblings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): never treat a null external_uid as listed by the bank (#1643)

CashAccount.external_uid is nullable in the shared type; the same-connection
twin rule now skips null uids instead of passing them to Set.has, which
failed the strict type check in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(cash-accounts): drop the same-connection twin liveness rule; both rows stay live (#1643)

Two enabled rows on one active bank connection sharing (IBAN, currency)
are no longer ranked. Round 4 ranked on cash_accounts.balance_updated_at
and round 5 on external_uid presence in bank_connections.accounts_data;
each was verified against prod and each was contradicted by it (ingest
routes by the accounts_data entry's ledger_account, which in two groups
points at the OLD row, so the "stale" row is the one still being fed).

Restores the round-3 behavior: neither twin is orphaned, the transfer
finder returns null when both survive, no guard refuses either, and
shouldRepointToSibling treats both as live siblings. No replacement
signal; how to model the shape is a founder decision (PR #2010 review).
getConnectionStatuses no longer selects accounts_data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:26:47 +02:00

579 lines
21 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import {
generateInputVatLine,
generateReverseChargeLines,
generateReverseChargeBasisLines,
} from './vat-entries'
// Aliased: this file already has a local resolveSekAmountOrNull(transaction)
// for account-selection decisions, which refuses when unconvertible.
import { resolveSekAmount as resolveSekAmountLenient } from './currency-utils'
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
import {
findCounterpartyTemplate,
buildMappingResultFromCounterpartyTemplate,
} from './counterparty-templates'
import { detectOwnAccountTransfer } from './own-account-detector'
import type {
MappingRule,
MappingResult,
Transaction,
EntityType,
VatJournalLine,
} from '@/types'
import { createLogger } from '@/lib/logger'
const log = createLogger('mapping-engine')
// Half of prisbasbelopp per year (used for capitalization threshold)
const PRISBASBELOPP_HALVES: Record<number, number> = {
2024: 28650, // PBB 57,300
2025: 29400, // PBB 58,800
2026: 29600, // PBB 59,200
}
const LATEST_KNOWN_YEAR = 2026
function getCapitalizationThreshold(year: number): number {
const threshold = PRISBASBELOPP_HALVES[year]
if (threshold) return threshold
log.warn(`No prisbasbelopp for ${year}, using ${LATEST_KNOWN_YEAR} value`)
return PRISBASBELOPP_HALVES[LATEST_KNOWN_YEAR]
}
/**
* Resolve the SEK value of a transaction, or null when it cannot be
* established.
*
* Deliberately stricter than `resolveSekAmount()` in currency-utils: that
* helper falls back to the raw foreign amount for legacy rows, which is
* tolerable for a line amount (the entry still balances against itself) but
* never for a comparison against a SEK limit. Both the halva-prisbasbeloppet
* threshold and a rule's amount_min/amount_max band are SEK figures; feeding
* them an unconverted foreign number silently picks the wrong branch. When no
* rate is known we return null so the caller declines instead of guessing.
*/
function resolveSekAmountOrNull(transaction: Transaction): number | null {
const currency = (transaction.currency || 'SEK').toUpperCase()
if (currency === 'SEK') return transaction.amount
if (transaction.amount_sek != null) {
return Math.round(transaction.amount_sek * 100) / 100
}
if (transaction.exchange_rate != null && transaction.exchange_rate > 0) {
return Math.round(transaction.amount * transaction.exchange_rate * 100) / 100
}
return null
}
/**
* Evaluate all mapping rules against a transaction and return the best match
*
* Evaluation order (by priority):
* 1. User override rules (priority 1-49)
* 2. MCC code rules (priority 50-69)
* 3. Merchant name pattern rules (priority 70-89)
* 4. Amount threshold rules (priority 90-99)
* 5. Counterparty templates (learned from history, fuzzy matching)
* 6. Static booking templates (keyword/MCC matching)
* 7. Default fallback (uncategorized)
*/
export async function evaluateMappingRules(
supabase: SupabaseClient,
companyId: string,
transaction: Transaction,
entityType?: EntityType,
settlementAccount?: string
): Promise<MappingResult> {
const bankAccount = settlementAccount || '1930'
// Pre-step: detect intra-company transfers. When the counterparty IBAN
// matches another cash_accounts row for the same company, book both legs
// as a transfer between the two ledger accounts instead of running the
// priority rules (which would mis-categorize the outflow as an expense).
try {
const transfer = await detectOwnAccountTransfer(supabase, companyId, transaction)
// A "transfer" whose counter leg is the settlement account itself is not a
// transfer (debit == credit on one ledger): it happens when the bank stamps
// the account's own IBAN as counterparty (interest, fees) and a sibling
// cash_accounts row still carries that IBAN (issue #1643). Fall through to
// normal categorization instead of proposing a cash ledger as the counter.
if (transfer && transfer.counterLedgerAccount !== bankAccount) {
const isFx =
(transaction.currency || '').toUpperCase() !==
(transfer.counterCurrency || '').toUpperCase()
return buildOwnAccountTransferResult(
transaction,
bankAccount,
transfer.counterLedgerAccount,
isFx,
)
}
} catch (err) {
// Non-fatal: falling through to normal categorization is correct when
// the detector fails. We log so an unexpected upstream error is visible.
log.warn('own-account transfer detection failed', {
companyId,
transactionId: transaction.id,
error: err instanceof Error ? err.message : String(err),
})
}
// Fetch all active rules (user-specific + system defaults), ordered by priority
const { data: rules, error } = await supabase
.from('mapping_rules')
.select('*')
.eq('is_active', true)
.or(`company_id.eq.${companyId},company_id.is.null`)
.order('priority', { ascending: true })
if (error || !rules || rules.length === 0) {
// Try counterparty templates before static template fallback
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType)
if (counterpartyResult) return applySettlementAccount(counterpartyResult, bankAccount)
const templateResult = evaluateTemplateRules(transaction, entityType)
if (templateResult) return applySettlementAccount(templateResult, bankAccount)
return getDefaultResult(transaction, bankAccount)
}
// Evaluate each rule in priority order
for (const rule of rules as MappingRule[]) {
if (matchesRule(rule, transaction)) {
return applySettlementAccount(buildResult(rule, transaction, entityType), bankAccount)
}
}
// Try counterparty templates before static template fallback
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType)
if (counterpartyResult) return applySettlementAccount(counterpartyResult, bankAccount)
// Try template-based matching before default fallback
const templateResult = evaluateTemplateRules(transaction, entityType)
if (templateResult) return applySettlementAccount(templateResult, bankAccount)
return getDefaultResult(transaction, bankAccount)
}
/**
* Evaluate booking templates as a fallback when no DB mapping rule matches.
* Returns the best template match if confidence >= 0.3, otherwise null.
*/
function evaluateTemplateRules(
transaction: Transaction,
entityType?: EntityType
): MappingResult | null {
const matches = findMatchingTemplates(transaction, entityType)
if (matches.length === 0 || matches[0].confidence < 0.3) return null
const best = matches[0]
const result = buildMappingResultFromTemplate(
best.template,
transaction,
entityType || 'enskild_firma'
)
// Override the confidence with the auto-match confidence (not 1.0)
result.confidence = best.confidence
return result
}
/**
* Evaluate counterparty templates as a fallback when no DB mapping rule matches.
* Source-aware threshold: auto_learned needs 0.6 (require more evidence),
* user_approved/sie_import use 0.4 (human has validated the pattern).
*/
async function evaluateCounterpartyTemplates(
supabase: SupabaseClient,
companyId: string,
transaction: Transaction,
entityType?: EntityType
): Promise<MappingResult | null> {
try {
const match = await findCounterpartyTemplate(supabase, companyId, transaction)
if (!match) return null
const threshold = match.template.source === 'auto_learned' ? 0.6 : 0.4
if (match.confidence < threshold) return null
return buildMappingResultFromCounterpartyTemplate(
match,
transaction,
entityType || 'enskild_firma'
)
} catch {
// Non-critical: fall through to next fallback
return null
}
}
/**
* Check if a transaction matches a mapping rule
*/
function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
// MCC code matching
if (rule.mcc_codes && rule.mcc_codes.length > 0) {
if (!transaction.mcc_code || !rule.mcc_codes.includes(transaction.mcc_code)) {
return false
}
}
// Merchant name pattern matching (case-insensitive). The description leg
// also tests original_description: since the ingest boundary started
// stripping the trailing channel phrase off the working title
// (classifyTransactionMethod), a rule written against the bank's full text
// ("Överföring via internet") only matches the immutable original.
if (rule.merchant_pattern) {
const merchantName = transaction.merchant_name || transaction.description || ''
const originalName = transaction.original_description || ''
try {
const regex = new RegExp(rule.merchant_pattern, 'i')
if (!regex.test(merchantName) && !(originalName && regex.test(originalName))) {
return false
}
} catch {
// Invalid regex, try simple includes
const needle = rule.merchant_pattern.toLowerCase()
if (
!merchantName.toLowerCase().includes(needle) &&
!originalName.toLowerCase().includes(needle)
) {
return false
}
}
}
// Description pattern matching: the working title OR the full bank original
// (see the merchant_pattern note above).
if (rule.description_pattern) {
const originalDescription = transaction.original_description || ''
try {
const regex = new RegExp(rule.description_pattern, 'i')
if (
!regex.test(transaction.description) &&
!(originalDescription && regex.test(originalDescription))
) {
return false
}
} catch {
const needle = rule.description_pattern.toLowerCase()
if (
!transaction.description.toLowerCase().includes(needle) &&
!originalDescription.toLowerCase().includes(needle)
) {
return false
}
}
}
// Amount threshold matching.
//
// `mapping_rules` has no currency column, so an amount_min/amount_max band
// can only mean SEK: the ledger currency. Compare it against the SEK value
// of the transaction, never the raw foreign amount (a 3000 EUR row would
// otherwise slip through a "max 5000" band that was written to mean kronor).
// When a non-SEK row carries neither amount_sek nor an exchange_rate the
// band is not evaluable, so the rule does not apply: skipping is the honest
// outcome, matching on an unconverted number is not.
if (rule.amount_min != null || rule.amount_max != null) {
const sekAmount = resolveSekAmountOrNull(transaction)
if (sekAmount === null) {
log.warn('mapping rule with a SEK amount band skipped: transaction has no SEK value', {
transactionId: transaction.id,
currency: transaction.currency,
ruleId: rule.id,
ruleName: rule.rule_name,
amountMin: rule.amount_min,
amountMax: rule.amount_max,
})
return false
}
const absAmount = Math.abs(sekAmount)
if (rule.amount_min != null && absAmount < rule.amount_min) {
return false
}
if (rule.amount_max != null && absAmount > rule.amount_max) {
return false
}
}
return true
}
/**
* Build a MappingResult from a matched rule
*/
function buildResult(rule: MappingRule, transaction: Transaction, entityType?: EntityType): MappingResult {
// VAT figures land on journal entry lines, which are always SEK, so they
// are derived from the SEK value of the transaction. The LENIENT resolver
// is deliberate: buildTransactionEntryLines resolves the gross with the
// same ladder, so the VAT lines and the bank leg can never disagree (a
// rateless legacy row degrades to today's behavior on both sides instead
// of unbalancing the net line). Mirrors buildMappingResultFromCategory.
const absSekAmount = Math.abs(resolveSekAmountLenient(
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
))
const isExpense = transaction.amount < 0
let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930')
const creditAccount = rule.credit_account || (isExpense ? '1930' : '3900')
// Capitalization threshold for equipment (IL 18 kap 4 §, "inventarier av
// mindre värde"): below half prisbasbelopp it may be expensed straight to a
// 54xx förbrukningsinventarie account, above it must be capitalised to 12xx
// and depreciated.
//
// The threshold is a SEK figure, so the amount compared against it has to be
// SEK as well. Comparing a raw 3000 EUR laptop (about 34 500 kr) against the
// 29 600 kr limit for 2026 reads as "under the limit" and expenses a
// capital asset to 5410: no avskrivning, wrong balansräkning, and a
// skattemässig error. Only rules that can actually capitalise are affected.
let capitalizationUndecided = false
if (rule.capitalized_debit_account) {
const year = new Date(transaction.date).getFullYear()
const threshold = rule.capitalization_threshold ?? getCapitalizationThreshold(year)
const sekAmount = resolveSekAmountOrNull(transaction)
if (sekAmount === null) {
// Non-SEK row with neither amount_sek nor an exchange_rate. Both
// branches are a guess and a wrong guess is a depreciation and tax
// error, so decline to auto-classify and hand it to the user instead
// of faking a conversion.
capitalizationUndecided = true
log.warn('capitalization threshold undecidable: transaction has no SEK value', {
transactionId: transaction.id,
currency: transaction.currency,
ruleId: rule.id,
ruleName: rule.rule_name,
})
} else if (Math.abs(sekAmount) > threshold) {
debitAccount = rule.capitalized_debit_account
}
}
// If default_private, use entity-specific private account
if (rule.default_private && isExpense) {
debitAccount = entityType === 'aktiebolag' ? '2893' : '2013'
}
// Generate VAT lines if applicable
const vatLines: VatJournalLine[] = []
if (isExpense && !rule.default_private && rule.vat_treatment) {
if (rule.vat_treatment === 'reverse_charge') {
// Reverse charge: emit BOTH the fiktiv-moms pair (2645/2614) AND the
// basbelopp pair (44xx|45xx / 4598). The basbelopp pair populates
// momsdeklaration rutor 20-24; without it Skatteverket rejects with
// FK004. Mapping rules don't carry supplier-country today, so we
// default to EU services: the most common reverse-charge scenario.
const rcRate = 0.25
const rcLines = generateReverseChargeLines(absSekAmount, rcRate, false)
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 || '',
})
}
// Skip basbelopp emission if the rule already books to a basis account.
if (!/^4[45]\d{2}$/.test(debitAccount)) {
const basisLines = generateReverseChargeBasisLines(absSekAmount, rcRate, 'eu_business')
for (const bl of basisLines) {
vatLines.push({
account_number: bl.account_number,
debit_amount: bl.debit_amount,
credit_amount: bl.credit_amount,
description: bl.line_description || '',
})
}
}
} else if (rule.vat_treatment === 'standard_25' || rule.vat_treatment === 'reduced_12' || rule.vat_treatment === 'reduced_6') {
const vatRate =
rule.vat_treatment === 'standard_25' ? 0.25
: rule.vat_treatment === 'reduced_12' ? 0.12
: 0.06
const vatLine = generateInputVatLine(absSekAmount, vatRate)
if (vatLine) {
vatLines.push({
account_number: vatLine.account_number,
debit_amount: vatLine.debit_amount,
credit_amount: vatLine.credit_amount,
description: vatLine.line_description || '',
})
}
}
}
// An undecidable capitalization check must not auto-post: drop the
// confidence under the auto-book bar (0.8 in lib/transactions/ingest.ts),
// force review, and say why in the suggestion label so the user sees the
// missing rate rather than a silently expensed asset.
return {
rule,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: rule.risk_level,
confidence: capitalizationUndecided
? Math.min(rule.confidence_score, 0.3)
: rule.confidence_score,
requires_review: capitalizationUndecided ? true : rule.requires_review,
default_private: rule.default_private,
vat_lines: vatLines,
description: capitalizationUndecided
? `${rule.rule_name} (granska: växelkurs saknas, går inte att avgöra om beloppet överstiger halva prisbasbeloppet)`
: rule.rule_name,
}
}
/**
* Default result when no rule matches (uncategorized)
*/
function getDefaultResult(transaction: Transaction, bankAccount = '1930'): MappingResult {
const isExpense = transaction.amount < 0
return {
rule: null,
debit_account: isExpense ? '6991' : bankAccount,
credit_account: isExpense ? bankAccount : '3900',
risk_level: 'MEDIUM',
confidence: 0.1,
requires_review: true,
default_private: false,
vat_lines: [],
description: 'Obokförd transaktion',
}
}
/**
* Build a MappingResult for a detected own-account transfer.
*
* For an outflow (negative amount): debit the counter account, credit this
* side's settlement account. The counter side will book the mirror entry when
* its row is ingested.
*
* For an inflow (positive amount): debit this side's settlement account,
* credit the counter account.
*
* Confidence is high (0.95) because IBAN match against the company's own
* cash_accounts is an exact identity check, not a heuristic.
*
* `isFx` flips `requires_review` to true when the two legs sit on different
* currencies (e.g. SEK 1930 → EUR 1932). A cross-currency leg generally
* realises a kursvinst/kursförlust on 3960/7960 (ÅRL 4 kap 10 §) that the
* two-line transfer entry doesn't capture: a human must confirm the FX gain
* or loss line rather than auto-booking a potentially incomplete entry.
* Same-currency transfers stay auto-bookable.
*/
function buildOwnAccountTransferResult(
transaction: Transaction,
bankAccount: string,
counterAccount: string,
isFx: boolean = false,
): MappingResult {
const isOutflow = transaction.amount < 0
return {
rule: null,
debit_account: isOutflow ? counterAccount : bankAccount,
credit_account: isOutflow ? bankAccount : counterAccount,
risk_level: isFx ? 'MEDIUM' : 'LOW',
confidence: isFx ? 0.7 : 0.95,
requires_review: isFx,
default_private: false,
vat_lines: [],
description: isFx
? 'Överföring mellan egna konton (FX: granska kursvinst/förlust)'
: 'Överföring mellan egna konton',
}
}
/**
* Replace any default 1930 references in a mapping result with the actual settlement account.
* This allows mapping rules and templates that don't explicitly set a bank account
* to work correctly with secondary bank accounts (e.g. 1931).
*/
export function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult {
if (bankAccount === '1930') return result
return {
...result,
debit_account: result.debit_account === '1930' ? bankAccount : result.debit_account,
credit_account: result.credit_account === '1930' ? bankAccount : result.credit_account,
}
}
/**
* Save a user-level mapping rule learned from categorization.
*
* When userDescription is provided, the rule gets:
* - source: 'user_description' (instead of 'auto')
* - priority: 5 (beats auto-learned at 10)
* - confidence_score: 0.98
* - The original user text and template_id stored for UI display
*
* User-described rules for the same merchant replace prior user-described rules
* (latest description wins).
*/
export async function saveUserMappingRule(
supabase: SupabaseClient,
companyId: string,
merchantName: string,
debitAccount: string,
creditAccount: string,
isPrivate: boolean,
userDescription?: string,
templateId?: string
): Promise<void> {
// Escape special regex characters in merchant name
const escapedMerchant = merchantName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
if (userDescription) {
// Delete existing user_description rule for this merchant (latest wins)
await supabase
.from('mapping_rules')
.delete()
.eq('company_id', companyId)
.eq('merchant_pattern', escapedMerchant)
.eq('source', 'user_description')
const { error } = await supabase.from('mapping_rules').insert({
company_id: companyId,
rule_name: `Described: ${merchantName}`,
rule_type: 'merchant_name',
priority: 5,
merchant_pattern: escapedMerchant,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.98,
source: 'user_description',
user_description: userDescription,
template_id: templateId || null,
})
if (error) {
// Silently fail: saving learned rules is non-critical
}
} else {
const { error } = await supabase.from('mapping_rules').insert({
company_id: companyId,
rule_name: `Learned: ${merchantName}`,
rule_type: 'merchant_name',
priority: 10,
merchant_pattern: escapedMerchant,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.95,
source: 'auto',
})
if (error) {
// Silently fail: saving learned rules is non-critical
}
}
}