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>
838 lines
27 KiB
TypeScript
838 lines
27 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createMockSupabase, makeTransaction } from '@/tests/helpers'
|
|
|
|
// Mock Supabase
|
|
const { supabase: mockSupabase, mockResult } = createMockSupabase()
|
|
|
|
// Mock booking-templates (needed by evaluateMappingRules)
|
|
vi.mock('../booking-templates', () => ({
|
|
findMatchingTemplates: vi.fn().mockReturnValue([]),
|
|
buildMappingResultFromTemplate: vi.fn(),
|
|
}))
|
|
|
|
// Mock counterparty-templates (needed by evaluateMappingRules)
|
|
vi.mock('../counterparty-templates', () => ({
|
|
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
|
|
buildMappingResultFromCounterpartyTemplate: vi.fn(),
|
|
}))
|
|
|
|
describe('mapping-engine', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('saveUserMappingRule', () => {
|
|
it('saves auto-learned rule without user description', async () => {
|
|
const { saveUserMappingRule } = await import('../mapping-engine')
|
|
|
|
mockResult({ data: null, error: null })
|
|
|
|
await saveUserMappingRule(mockSupabase as never, 'user-1', 'ICA Maxi', '5410', '1930', false)
|
|
|
|
// Verify insert was called via supabase.from().insert()
|
|
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
|
|
})
|
|
|
|
it('saves user-described rule with priority 5 and confidence 0.98', async () => {
|
|
const { saveUserMappingRule } = await import('../mapping-engine')
|
|
|
|
mockResult({ data: null, error: null })
|
|
|
|
await saveUserMappingRule(
|
|
mockSupabase as never,
|
|
'user-1',
|
|
'Restaurant XYZ',
|
|
'6071',
|
|
'1930',
|
|
false,
|
|
'business lunch with client',
|
|
'restaurant_dining'
|
|
)
|
|
|
|
// Verify from was called (first for delete, then for insert)
|
|
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
|
|
})
|
|
|
|
it('does not throw on insert error (non-critical)', async () => {
|
|
const { saveUserMappingRule } = await import('../mapping-engine')
|
|
|
|
mockResult({ data: null, error: { message: 'DB error' } })
|
|
|
|
// Should not throw
|
|
await expect(
|
|
saveUserMappingRule(mockSupabase as never, 'user-1', 'ICA Maxi', '5410', '1930', false)
|
|
).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('escapes special regex characters in merchant name', async () => {
|
|
const { saveUserMappingRule } = await import('../mapping-engine')
|
|
|
|
mockResult({ data: null, error: null })
|
|
|
|
// Merchant name with regex special chars
|
|
await saveUserMappingRule(mockSupabase as never, 'user-1', 'Test (Pty) Ltd.', '5410', '1930', false)
|
|
|
|
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
|
|
})
|
|
})
|
|
|
|
describe('evaluateMappingRules', () => {
|
|
// Full mapping_rules row for pattern-matching tests; override per case.
|
|
const makeRule = (overrides: Record<string, unknown>) => ({
|
|
id: 'rule-1',
|
|
user_id: null,
|
|
rule_name: 'Pattern rule',
|
|
rule_type: 'description',
|
|
priority: 100,
|
|
mcc_codes: null,
|
|
merchant_pattern: null,
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: '6540',
|
|
credit_account: '1930',
|
|
vat_treatment: null,
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'LOW',
|
|
default_private: false,
|
|
requires_review: false,
|
|
confidence_score: 0.9,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: null,
|
|
is_active: true,
|
|
source: 'system',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
...overrides,
|
|
})
|
|
|
|
it('matches a description_pattern that only appears in original_description', async () => {
|
|
// The ingest boundary strips the trailing channel phrase off the working
|
|
// title; a rule written against the bank's full text must keep firing
|
|
// via the immutable original.
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
merchant_name: null,
|
|
description: 'Vercel Jul',
|
|
original_description: 'Vercel Jul Överföring via internet',
|
|
})
|
|
mockResult({
|
|
data: [makeRule({ description_pattern: 'Överföring via internet' })],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
expect(result.debit_account).toBe('6540')
|
|
})
|
|
|
|
it('matches a merchant_pattern that only appears in original_description', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
merchant_name: null,
|
|
description: 'SPOTIFY AB',
|
|
original_description: 'SPOTIFY AB Kortköp',
|
|
})
|
|
mockResult({
|
|
data: [makeRule({ rule_type: 'merchant_name', merchant_pattern: 'Kortköp' })],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
expect(result.debit_account).toBe('6540')
|
|
})
|
|
|
|
it('invalid-regex substring fallback also scans original_description', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
// 'fee (2026' is an invalid regex (unclosed group) and a literal
|
|
// substring of the original only.
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
merchant_name: null,
|
|
description: 'Stripe: Billing - Usage Fee',
|
|
original_description: 'Stripe: Billing - Usage Fee (2026-07-26)',
|
|
})
|
|
mockResult({
|
|
data: [makeRule({ description_pattern: 'fee (2026' })],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
expect(result.debit_account).toBe('6540')
|
|
})
|
|
|
|
it('does not match when the pattern appears in neither description nor original', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
merchant_name: null,
|
|
description: 'Vercel Jul',
|
|
original_description: 'Vercel Jul Överföring via internet',
|
|
})
|
|
mockResult({
|
|
data: [makeRule({ description_pattern: 'Kortköp' })],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
expect(result.debit_account).toBe('6991') // default expense fallback
|
|
})
|
|
|
|
it('returns default result when no rules match (expense)', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
|
|
mockResult({ data: [], error: null })
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('6991')
|
|
expect(result.credit_account).toBe('1930')
|
|
expect(result.confidence).toBe(0.1)
|
|
expect(result.requires_review).toBe(true)
|
|
})
|
|
|
|
it('returns VAT-neutral 3900 as default income account (not 3001)', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({ amount: 500, merchant_name: 'Unknown' })
|
|
mockResult({ data: [], error: null })
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('1930')
|
|
expect(result.credit_account).toBe('3900')
|
|
expect(result.requires_review).toBe(true)
|
|
})
|
|
|
|
it('uses 2893 for default_private with aktiebolag entity type', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({ amount: -500, merchant_name: 'Private Purchase' })
|
|
mockResult({
|
|
data: [
|
|
{
|
|
id: 'rule-private',
|
|
user_id: null,
|
|
rule_name: 'Private fallback',
|
|
rule_type: 'merchant_name',
|
|
priority: 100,
|
|
mcc_codes: null,
|
|
merchant_pattern: 'Private',
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: null,
|
|
credit_account: null,
|
|
vat_treatment: null,
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'LOW',
|
|
default_private: true,
|
|
requires_review: false,
|
|
confidence_score: 0.8,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: null,
|
|
is_active: true,
|
|
source: 'system',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'aktiebolag')
|
|
expect(result.debit_account).toBe('2893')
|
|
expect(result.default_private).toBe(true)
|
|
})
|
|
|
|
it('uses 2013 for default_private with enskild_firma entity type', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({ amount: -500, merchant_name: 'Private Purchase' })
|
|
mockResult({
|
|
data: [
|
|
{
|
|
id: 'rule-private',
|
|
user_id: null,
|
|
rule_name: 'Private fallback',
|
|
rule_type: 'merchant_name',
|
|
priority: 100,
|
|
mcc_codes: null,
|
|
merchant_pattern: 'Private',
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: null,
|
|
credit_account: null,
|
|
vat_treatment: null,
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'LOW',
|
|
default_private: true,
|
|
requires_review: false,
|
|
confidence_score: 0.8,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: null,
|
|
is_active: true,
|
|
source: 'system',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
|
expect(result.debit_account).toBe('2013')
|
|
})
|
|
|
|
it('applies year-based capitalization threshold from prisbasbelopp', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
// 2024 threshold = 28,650. This amount exceeds it.
|
|
const tx = makeTransaction({
|
|
amount: -30000,
|
|
date: '2024-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
|
|
mockResult({
|
|
data: [
|
|
{
|
|
id: 'rule-cap',
|
|
user_id: null,
|
|
rule_name: 'Equipment',
|
|
rule_type: 'merchant_name',
|
|
priority: 50,
|
|
mcc_codes: null,
|
|
merchant_pattern: 'Equipment',
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_treatment: null,
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'LOW',
|
|
default_private: false,
|
|
requires_review: false,
|
|
confidence_score: 0.9,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: '1250',
|
|
is_active: true,
|
|
source: 'system',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
// 30,000 > 28,650 (2024 half-PBB) → should capitalize to 1250
|
|
expect(result.debit_account).toBe('1250')
|
|
})
|
|
|
|
it('uses 2025 threshold for 2025 transactions', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
// 2025 threshold = 29,400. Amount of 29,000 is below it.
|
|
const tx = makeTransaction({
|
|
amount: -29000,
|
|
date: '2025-03-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
|
|
mockResult({
|
|
data: [
|
|
{
|
|
id: 'rule-cap',
|
|
user_id: null,
|
|
rule_name: 'Equipment',
|
|
rule_type: 'merchant_name',
|
|
priority: 50,
|
|
mcc_codes: null,
|
|
merchant_pattern: 'Equipment',
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_treatment: null,
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'LOW',
|
|
default_private: false,
|
|
requires_review: false,
|
|
confidence_score: 0.9,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: '1250',
|
|
is_active: true,
|
|
source: 'system',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
// 29,000 < 29,400 (2025 half-PBB) → should NOT capitalize
|
|
expect(result.debit_account).toBe('5410')
|
|
})
|
|
|
|
it('matches merchant_pattern rule', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -299,
|
|
merchant_name: 'ICA Maxi',
|
|
description: 'ICA MAXI STOCKHOLM',
|
|
})
|
|
|
|
mockResult({
|
|
data: [
|
|
{
|
|
id: 'rule-1',
|
|
user_id: 'user-1',
|
|
rule_name: 'Learned: ICA Maxi',
|
|
rule_type: 'merchant_name',
|
|
priority: 10,
|
|
mcc_codes: null,
|
|
merchant_pattern: 'ICA Maxi',
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_treatment: null,
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'NONE',
|
|
default_private: false,
|
|
requires_review: false,
|
|
confidence_score: 0.95,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: null,
|
|
is_active: true,
|
|
source: 'auto',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('5410')
|
|
expect(result.credit_account).toBe('1930')
|
|
expect(result.confidence).toBe(0.95)
|
|
})
|
|
|
|
it('emits both fiktiv-moms and basbelopp lines for reverse_charge rules', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -1000,
|
|
merchant_name: 'AWS',
|
|
description: 'AWS EU-WEST-1',
|
|
})
|
|
|
|
mockResult({
|
|
data: [
|
|
{
|
|
id: 'rule-rc',
|
|
user_id: 'user-1',
|
|
rule_name: 'AWS reverse charge',
|
|
rule_type: 'merchant_name',
|
|
priority: 10,
|
|
mcc_codes: null,
|
|
merchant_pattern: 'AWS',
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: '5421',
|
|
credit_account: '1930',
|
|
vat_treatment: 'reverse_charge',
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'LOW',
|
|
default_private: false,
|
|
requires_review: false,
|
|
confidence_score: 0.9,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: null,
|
|
is_active: true,
|
|
source: 'system',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
// Fiktiv-moms pair + basbelopp pair = 4 lines (FK004 guard)
|
|
expect(result.vat_lines).toHaveLength(4)
|
|
expect(result.vat_lines[0].account_number).toBe('2645')
|
|
expect(result.vat_lines[0].debit_amount).toBe(250)
|
|
expect(result.vat_lines[1].account_number).toBe('2614')
|
|
expect(result.vat_lines[1].credit_amount).toBe(250)
|
|
expect(result.vat_lines[2].account_number).toBe('4535')
|
|
expect(result.vat_lines[2].debit_amount).toBe(1000)
|
|
expect(result.vat_lines[3].account_number).toBe('4598')
|
|
expect(result.vat_lines[3].credit_amount).toBe(1000)
|
|
})
|
|
|
|
it('skips basbelopp emission when rule already debits a basis account', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -1000,
|
|
merchant_name: 'AWS',
|
|
})
|
|
|
|
mockResult({
|
|
data: [
|
|
{
|
|
id: 'rule-rc-basis',
|
|
user_id: 'user-1',
|
|
rule_name: 'AWS RC to basis',
|
|
rule_type: 'merchant_name',
|
|
priority: 10,
|
|
mcc_codes: null,
|
|
merchant_pattern: 'AWS',
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: '4535',
|
|
credit_account: '1930',
|
|
vat_treatment: 'reverse_charge',
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'LOW',
|
|
default_private: false,
|
|
requires_review: false,
|
|
confidence_score: 0.9,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: null,
|
|
is_active: true,
|
|
source: 'system',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
// Only fiktiv-moms pair: basbelopp already covered by the expense line
|
|
expect(result.vat_lines).toHaveLength(2)
|
|
expect(result.vat_lines[0].account_number).toBe('2645')
|
|
expect(result.vat_lines[1].account_number).toBe('2614')
|
|
})
|
|
})
|
|
|
|
// Both the halva-prisbasbeloppet threshold (IL 18 kap 4 §) and a rule's
|
|
// amount_min/amount_max band are SEK figures. Before the fix they were
|
|
// compared against the raw transaction amount, so a 3000 EUR laptop
|
|
// (about 34 500 kr) read as under the 29 600 kr limit for 2026 and was
|
|
// expensed to 5410 instead of capitalised to 1250.
|
|
describe('SEK thresholds on foreign-currency transactions', () => {
|
|
function makeRule(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'rule-cap',
|
|
user_id: null,
|
|
rule_name: 'Equipment',
|
|
rule_type: 'merchant_name',
|
|
priority: 50,
|
|
mcc_codes: null,
|
|
merchant_pattern: 'Equipment',
|
|
description_pattern: null,
|
|
amount_min: null,
|
|
amount_max: null,
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_treatment: null,
|
|
vat_debit_account: null,
|
|
vat_credit_account: null,
|
|
risk_level: 'LOW',
|
|
default_private: false,
|
|
requires_review: false,
|
|
confidence_score: 0.9,
|
|
capitalization_threshold: null,
|
|
capitalized_debit_account: '1250',
|
|
is_active: true,
|
|
source: 'system',
|
|
user_description: null,
|
|
template_id: null,
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
it('capitalizes a SEK 34 500 laptop (over the 2026 half-PBB of 29 600)', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -34500,
|
|
currency: 'SEK',
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({ data: [makeRule()], error: null })
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('1250')
|
|
expect(result.requires_review).toBe(false)
|
|
})
|
|
|
|
it('expenses a SEK 20 000 laptop (under the 2026 half-PBB of 29 600)', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -20000,
|
|
currency: 'SEK',
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({ data: [makeRule()], error: null })
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('5410')
|
|
expect(result.requires_review).toBe(false)
|
|
})
|
|
|
|
it('capitalizes a 3000 EUR laptop when an exchange_rate is present', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
// 3000 EUR * 11.5 = 34 500 kr, over the 29 600 kr limit.
|
|
const tx = makeTransaction({
|
|
amount: -3000,
|
|
currency: 'EUR',
|
|
amount_sek: null,
|
|
exchange_rate: 11.5,
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({ data: [makeRule()], error: null })
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('1250')
|
|
expect(result.requires_review).toBe(false)
|
|
})
|
|
|
|
it('capitalizes a 3000 EUR laptop when amount_sek is pre-computed', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -3000,
|
|
currency: 'EUR',
|
|
amount_sek: -34500,
|
|
exchange_rate: null,
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({ data: [makeRule()], error: null })
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('1250')
|
|
})
|
|
|
|
it('does not silently expense a 3000 EUR laptop with no rate: declines to auto-classify', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -3000,
|
|
currency: 'EUR',
|
|
amount_sek: null,
|
|
exchange_rate: null,
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({ data: [makeRule()], error: null })
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
// The rule alone would auto-book (confidence 0.9, requires_review false).
|
|
// Without a SEK value the capitalization branch is a guess, so the
|
|
// result must land under the 0.8 auto-book bar and explain itself.
|
|
expect(result.requires_review).toBe(true)
|
|
expect(result.confidence).toBeLessThan(0.8)
|
|
expect(result.description).toContain('växelkurs saknas')
|
|
})
|
|
|
|
it('leaves rules that cannot capitalize untouched when no rate is available', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -3000,
|
|
currency: 'EUR',
|
|
amount_sek: null,
|
|
exchange_rate: null,
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({ data: [makeRule({ capitalized_debit_account: null })], error: null })
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
// No capitalization decision to make: the missing rate is irrelevant.
|
|
expect(result.debit_account).toBe('5410')
|
|
expect(result.requires_review).toBe(false)
|
|
expect(result.confidence).toBe(0.9)
|
|
})
|
|
|
|
it('evaluates a user amount band against SEK, not the foreign amount', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
// 3000 EUR = 34 500 kr, well over the rule's 5 000 kr ceiling. The raw
|
|
// amount (3000) would have slipped under it.
|
|
const tx = makeTransaction({
|
|
amount: -3000,
|
|
currency: 'EUR',
|
|
amount_sek: null,
|
|
exchange_rate: 11.5,
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({
|
|
data: [
|
|
makeRule({
|
|
rule_name: 'Small equipment',
|
|
amount_max: 5000,
|
|
capitalized_debit_account: null,
|
|
}),
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
// No match: falls through to the uncategorized default.
|
|
expect(result.rule).toBeNull()
|
|
expect(result.debit_account).toBe('6991')
|
|
expect(result.requires_review).toBe(true)
|
|
})
|
|
|
|
it('matches a user amount band when the SEK value falls inside it', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
// 100 EUR * 11.5 = 1 150 kr, inside the 1 000-5 000 kr band.
|
|
const tx = makeTransaction({
|
|
amount: -100,
|
|
currency: 'EUR',
|
|
amount_sek: null,
|
|
exchange_rate: 11.5,
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({
|
|
data: [
|
|
makeRule({
|
|
rule_name: 'Small equipment',
|
|
amount_min: 1000,
|
|
amount_max: 5000,
|
|
capitalized_debit_account: null,
|
|
}),
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('5410')
|
|
expect(result.confidence).toBe(0.9)
|
|
})
|
|
|
|
it('skips an amount-band rule when the transaction has no SEK value', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -3000,
|
|
currency: 'EUR',
|
|
amount_sek: null,
|
|
exchange_rate: null,
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({
|
|
data: [
|
|
makeRule({
|
|
rule_name: 'Small equipment',
|
|
amount_max: 5000,
|
|
capitalized_debit_account: null,
|
|
}),
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
// The band is unevaluable, so the rule does not apply. The transaction
|
|
// lands in the uncategorized default where the user picks it up.
|
|
expect(result.rule).toBeNull()
|
|
expect(result.requires_review).toBe(true)
|
|
})
|
|
|
|
it('still applies SEK amount bands unchanged for SEK transactions', async () => {
|
|
const { evaluateMappingRules } = await import('../mapping-engine')
|
|
|
|
const tx = makeTransaction({
|
|
amount: -3000,
|
|
currency: 'SEK',
|
|
date: '2026-06-15',
|
|
merchant_name: 'Equipment Store',
|
|
})
|
|
mockResult({
|
|
data: [
|
|
makeRule({
|
|
rule_name: 'Small equipment',
|
|
amount_min: 1000,
|
|
amount_max: 5000,
|
|
capitalized_debit_account: null,
|
|
}),
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
|
|
|
expect(result.debit_account).toBe('5410')
|
|
})
|
|
})
|
|
})
|