953980c875
* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id A company with two same-currency cash accounts (e.g. checking 1930 + a savings account) saw every SEK transaction on every account, and the status card summed across both — reconciliation filtered transactions by CURRENCY while filtering GL lines by ACCOUNT (issue #604). Bind each bank transaction to the cash_accounts row it settled on: - New nullable transactions.cash_account_id FK (ON DELETE SET NULL — a bank transaction is räkenskapsinformation, BFL 7 kap, and must survive cash-account deletion) + a best-effort 4-pass backfill. - All reconciliation/transaction queries scope to the selected account with a NULL->currency fallback, so legacy/un-backfilled rows never disappear mid-backfill. - ingestTransactions stamps cash_account_id from the batch's settlementAccount; categorize + manualLink resolve and use it. - Bank leg now books to the transaction's actual settlement account via applySettlementAccount (no-op for 1930), so interest/fees on a savings/EUR account reconcile instead of mis-booking to 1930. - manualLink cross-checks the transaction's account and requires a voucher line on the selected account (no silent cross-account links). - BankReconciliationView: quick-book menu for any settlement account, in-flight request abort on account/date switch, 500-row truncation notice, per-account state reset. - pg-real coverage for the FK, all backfill passes, account-scoped query isolation, and cross-company isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): stop marking paid invoices and credit notes as overdue update_overdue_supplier_invoices() (the daily pg_cron job) flipped every past-due 'registered'/'approved' row to 'overdue' without looking at the outstanding balance. Credit notes — created 'registered', remaining 0, due today — got flipped the next day, surfacing as "Förfallen" with "kvar att betala 0 kr"; so did any fully-paid invoice left in 'registered'/'approved'. Guard the cron on remaining_amount > 0.005 (the "fully paid" threshold used by the payment/match paths) and is_credit_note = false, and backfill the rows already mis-flagged (credit notes -> 'registered', paid -> 'paid' with paid_at stamped only when missing). pg-real coverage for the guarded function and the one-off backfill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): refresh dokumentinkorg on realtime row changes The InvoiceInboxWorkspace only refetched on mount and on explicit in-component actions. When an inbox item was resolved out of band — the in-app agent sheet committing a staged create_supplier_invoice_from_inbox / book-direct op, the /pending page approving one, or another tab booking it — none of those paths called fetchItems(), so the booked underlag stayed in "Att göra" until a manual reload (issue #600). Add invoice_inbox_items to the supabase_realtime publication (mirrors the /pending fix in 20260520120100) and subscribe in the workspace, refetching the whole list on any change so derived status/counts/ordering stay authoritative. RLS scopes the channel to the user's company. fetchItems now preserves optimistic upload placeholders so a refetch firing mid-upload can't drop an in-flight row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(privacy): disclose EU AI inference via Amazon Bedrock (eu-north-1) Update the privacy policy and DPA to state that AI inference, when AI features are enabled, runs inside the EU via Amazon Bedrock (eu-north-1, Stockholm) using Anthropic's Claude models — no transfer to a third country, prompts not retained after the call or used for model training. Add AWS as a subprocessor row and refresh the "last updated" dates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): rename invoice_inbox_realtime to avoid version collision main's #617 shipped 20260605120000_transactions_original_description.sql — the same version this branch used for the inbox-realtime publication. The Supabase migration tracker keys on the numeric version, not the filename, so the preview branch failed with a duplicate-key error on supabase_migrations.schema_migrations (version 20260605120000 already exists). Rename to the unique version 20260605120500; the body (ALTER PUBLICATION) is order-independent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reconciliation): align run guard with status; harden filter interpolation Addresses PR review (greptile + compliance swarm): - The v1 and core bank/run routes rejected an unknown account uniformly, including the default '1930', while the status routes were lenient for '1930'. A company reconciling its primary SEK account without a cash_accounts row got 200 from status but 400 from run. Make run match status: '1930' falls back to currency-only scoping (cashAccountId undefined); non-default unknown accounts are still rejected. Adds a test. - /api/transactions accepts a user-supplied `currency` query param that was interpolated raw into a PostgREST .or() filter. Reject anything that isn't a 3-letter ISO code — RLS already scopes to the company, but an unsanitized value could otherwise malform/widen the filter. Assert currency/cashAccountId shape in scopeTransactionsToAccount as well. - categorize: log (instead of silently swallowing) a cash_accounts settlement-account lookup error, so a fall-back-to-1930 mis-booking is observable in the audit log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): correct backfill UPDATE..FROM join; idempotent realtime publication Two SQL errors that only surface on real Postgres (CI pg-real + Supabase preview) — the unit suite mocks Supabase, so neither was caught locally. - Backfill pass (a): `UPDATE transactions t ... FROM journal_entry_lines jel JOIN cash_accounts ca ON ca.company_id = t.company_id` referenced the UPDATE target `t` inside the FROM join's ON clause, which Postgres rejects ("invalid reference to FROM-clause entry for table t"). Move the company match to WHERE; the JOIN now relates jel<->ca only. Semantics unchanged. - invoice_inbox_realtime: `ALTER PUBLICATION ... ADD TABLE` is not idempotent (SQLSTATE 42710 if the table is already a member). The earlier version-collision push partially applied it on the Supabase preview branch, so the re-apply errored. Guard with a pg_publication_tables existence check. Both statements validated against a real Postgres: the single-line tx binds, the two-bank-line transfer stays NULL, and the publication add runs twice cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): backfill pass (c) uses array_agg, not min(uuid) Postgres has no min() aggregate for uuid, so pass (c)'s min(id) raised "function min(uuid) does not exist" on apply (CI pg-real + Supabase). The HAVING count(*) = 1 already guarantees one row per group, so (array_agg(id))[1] returns that single id. Validated the full backfill (all four passes) and the overdue migration against a real Postgres: every pass binds / falls through as intended, and the overdue guard + backfill produce the right statuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(compliance): add RoPA entry for Amazon Bedrock AI inference (GDPR Art.30) The privacy policy now discloses AI inference (transaction categorization + document/receipt OCR) via Amazon Bedrock as a processing activity, but .compliance/ropa.yaml had no matching Art.30 record. Add it: opt-in consent basis, EU-region (eu-north-1) inference with no third-country transfer, prompts not retained or used for model training. Mirrors the privacy-page disclosure shipped in this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
461 lines
16 KiB
TypeScript
461 lines
16 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]
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
if (rule.merchant_pattern) {
|
||
const merchantName = transaction.merchant_name || transaction.description || ''
|
||
try {
|
||
const regex = new RegExp(rule.merchant_pattern, 'i')
|
||
if (!regex.test(merchantName)) {
|
||
return false
|
||
}
|
||
} catch {
|
||
// Invalid regex, try simple includes
|
||
if (!merchantName.toLowerCase().includes(rule.merchant_pattern.toLowerCase())) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Description pattern matching
|
||
if (rule.description_pattern) {
|
||
try {
|
||
const regex = new RegExp(rule.description_pattern, 'i')
|
||
if (!regex.test(transaction.description)) {
|
||
return false
|
||
}
|
||
} catch {
|
||
if (!transaction.description.toLowerCase().includes(rule.description_pattern.toLowerCase())) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Amount threshold matching
|
||
const absAmount = Math.abs(transaction.amount)
|
||
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 {
|
||
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')
|
||
|
||
// Check capitalization threshold for equipment
|
||
const year = new Date(transaction.date).getFullYear()
|
||
const threshold = rule.capitalization_threshold ?? getCapitalizationThreshold(year)
|
||
if (absAmount > threshold && rule.capitalized_debit_account) {
|
||
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 || '',
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
rule,
|
||
debit_account: debitAccount,
|
||
credit_account: creditAccount,
|
||
risk_level: rule.risk_level,
|
||
confidence: rule.confidence_score,
|
||
requires_review: rule.requires_review,
|
||
default_private: rule.default_private,
|
||
vat_lines: vatLines,
|
||
description: 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
|
||
}
|
||
}
|
||
}
|