26e29f47bc
* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
580 lines
21 KiB
TypeScript
580 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'
|
|
import { ownerSettlementAccount } from '@/lib/company/entity-type'
|
|
|
|
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
|
|
)
|
|
// 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
|
|
)
|
|
} 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 = ownerSettlementAccount(entityType, 'withdrawal')
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|