70845edf69
* feat(transactions): structured transaction_method instead of channel-in-the-name
Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:
- transactions.transaction_method (text + CHECK closed vocabulary: card,
transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
bank_transaction_code / proprietary_bank_transaction_code evidence
columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
explicit source method (Stripe txn.type) > trailing Swedish channel
phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
as original_description; dedup is untouched (external_id is date+öre,
the content bridge reads original_description and is prefix-based, and
a trailing strip leaves a prefix). Enable Banking passes the codes
through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
(+ MCC and Stripe prefixes) and strips unedited titles; user-edited
titles are never rewritten.
- mapping-engine also matches original_description so user rules written
against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
"Betalsätt: Kortköp" etc. (sv/en), making every classified row
expandable.
A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): anchor counterparty-template identity on original_description
Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.
Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.
Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): review follow-ups on method classification
- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
refinement on the proprietary code beats a bare family match on the
ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
that only match original_description, including the invalid-regex
substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
mapping to 'fee'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): scope method classification to feed rows + adjective guard
Multi-bank risk hardening before the backfill ships:
- Feed-row scope: classification and title stripping now require a real
import feed (import_source present, not manual/mcp), both at the
ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
every backfill statement. User-authored titles like "Egen insättning"
on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
is skipped, so "Egen insättning" stays whole even on bank-feed rows;
the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
phrase means no method and no rewrite, so the worst case for any bank
whose vocabulary we have not seen is the status quo.
Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations after rebase
Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): bump transaction_method versions past prod's max
Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker
- TransactionInboxCard: row-level Enter/Space handling now ignores events
bubbling from nested controls, so keyboard activation of Bokför / the
overflow menu is no longer cancelled by the (now much more common)
expandable row.
- Feed predicate parity with isImportedTransaction(): a live
bank_connection_id marks a feed row even when import_source is unset
(the oldest PSD2 rows predate that column), in both the ingest
classifier and every backfill statement: those legacy rows now get
classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail
Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): enforce, not assume, original_description preservation in the title strip
The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record the compliance-review triage of the backfill's booked-row title strip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
568 lines
20 KiB
TypeScript
568 lines
20 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
generateInputVatLine,
|
|
generateReverseChargeLines,
|
|
generateReverseChargeBasisLines,
|
|
} from './vat-entries'
|
|
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)
|
|
if (transfer) {
|
|
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 {
|
|
// NOTE: this is the amount in the transaction's own currency. It still feeds
|
|
// the VAT line generation below, which understates ingående moms on non-SEK
|
|
// rows (a known separate defect, tracked on its own: fixing it changes
|
|
// posted VAT amounts). The capitalization check below deliberately does not
|
|
// use it: an account-selection decision must run on a SEK value.
|
|
const absAmount = Math.abs(transaction.amount)
|
|
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(absAmount, 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(absAmount, 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(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 || '',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|