(seen.words.slice(0, 1))
const [amount, setAmount] = useState(false)
+ const [iban, setIban] = useState(false)
const match: RuleMatch = {
...(payee && seen.payee ? { payee: seen.payee } : {}),
...(words.length > 0 ? { words } : {}),
...(amount ? { amount: seen.amount } : {}),
+ ...(iban && seen.iban ? { iban: seen.iban } : {}),
}
const valid = Object.keys(match).length > 0
@@ -59,6 +61,12 @@ export default function MakeRuleDialog({
Amount is {formatCurrency(seen.amount, tx.currency || currency)}
+ {seen.iban && (
+
+ )}
diff --git a/web/src/shared/contexts/workspace/processTransactions.ts b/web/src/shared/contexts/workspace/processTransactions.ts
index 2f62173..c36d100 100644
--- a/web/src/shared/contexts/workspace/processTransactions.ts
+++ b/web/src/shared/contexts/workspace/processTransactions.ts
@@ -1,6 +1,6 @@
import { applyAliases } from '@/shared/utils/aliases'
import { anonymizeTransactions, processWithoutAnonymization } from '@/shared/utils/anonymizer'
-import { categorizeWithRules } from '@/shared/utils/categorizer'
+import { categorizeWithRules, findRule } from '@/shared/utils/categorizer'
import { detectDoubleBookings } from '@/shared/utils/double-booking'
import { assignStableIds } from '@/shared/utils/ids'
import { detectRecurring } from '@/shared/utils/recurring'
@@ -10,7 +10,16 @@ export function processTransactions(raw: RawTransaction[], anonymize: boolean):
const base = anonymize ? anonymizeTransactions(raw).transactions : processWithoutAnonymization(raw)
const processed = assignStableIds(base, raw)
const categorized = categorizeWithRules(processed)
- const withDoubleBookings = detectDoubleBookings(categorized)
+ // Double-booking detection files every transfer between your accounts as Transfer, which would undo a rule of
+ // yours (the one that says a credit to your savings account is Savings). The rule's category is put back.
+ const ruled = new Map(categorized.filter(t => findRule(t)).map(t => [t.id, t]))
+ const withDoubleBookings = detectDoubleBookings(categorized).map(t => {
+ const rule = ruled.get(t.id)
+
+ return rule
+ ? { ...t, category: rule.category, categoryDetail: rule.categoryDetail, categorySource: rule.categorySource }
+ : t
+ })
return applyAliases(detectRecurring(withDoubleBookings))
}
diff --git a/web/src/shared/utils/amountQuery.test.ts b/web/src/shared/utils/amountQuery.test.ts
new file mode 100644
index 0000000..96bb0d8
--- /dev/null
+++ b/web/src/shared/utils/amountQuery.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest'
+import { matchesAmountQuery } from './amountQuery'
+
+describe('matchesAmountQuery', () => {
+ it('matches a decimal query exactly', () => {
+ expect(matchesAmountQuery(12.5, '12.50')).toBe(true)
+ expect(matchesAmountQuery(12.5, '12,5')).toBe(true)
+ expect(matchesAmountQuery(12.51, '12.50')).toBe(false)
+ })
+
+ it('matches a whole-number query across the whole euro', () => {
+ expect(matchesAmountQuery(12.99, '12')).toBe(true)
+ expect(matchesAmountQuery(120, '12')).toBe(false)
+ expect(matchesAmountQuery(13, '12')).toBe(false)
+ })
+
+ it('ignores a sign and a currency symbol', () => {
+ expect(matchesAmountQuery(12.5, '-12.50')).toBe(true)
+ expect(matchesAmountQuery(12.5, '€12.50')).toBe(true)
+ })
+
+ it('does not treat text as an amount', () => {
+ expect(matchesAmountQuery(7, '7-Eleven')).toBe(false)
+ expect(matchesAmountQuery(7, '')).toBe(false)
+ })
+})
diff --git a/web/src/shared/utils/amountQuery.ts b/web/src/shared/utils/amountQuery.ts
new file mode 100644
index 0000000..0e765f6
--- /dev/null
+++ b/web/src/shared/utils/amountQuery.ts
@@ -0,0 +1,31 @@
+import { moneyMinor } from '@/shared/utils/money'
+
+/**
+ * Reads a search box entry as an amount: "12", "12.50", "12,50", "€12.50", "-12.5".
+ * Returns null when it isn't one, so the text search still handles things like "7-Eleven".
+ * The sign is ignored: a row's amount is stored unsigned and its type says the direction.
+ */
+function parseAmountQuery(q: string): { minor: number; wholeOnly: boolean } | null {
+ const cleaned = q.trim().replace(/^[-+]?\s*[^\d\s.,-]?\s*/, '')
+
+ if (!/^\d+([.,]\d{1,2})?$/.test(cleaned)) {
+ return null
+ }
+
+ const value = Number(cleaned.replace(',', '.'))
+
+ return { minor: moneyMinor(value), wholeOnly: !/[.,]/.test(cleaned) }
+}
+
+/** A whole number ("12") matches 12.00 to 12.99; a decimal ("12.50") matches that amount exactly. */
+export function matchesAmountQuery(amount: number, q: string): boolean {
+ const parsed = parseAmountQuery(q)
+
+ if (!parsed) {
+ return false
+ }
+
+ const minor = moneyMinor(Math.abs(amount))
+
+ return parsed.wholeOnly ? Math.floor(minor / 100) * 100 === parsed.minor : minor === parsed.minor
+}
diff --git a/web/src/shared/utils/categorizer/categorizeWithRules.ts b/web/src/shared/utils/categorizer/categorizeWithRules.ts
index e2f1758..f58e00a 100644
--- a/web/src/shared/utils/categorizer/categorizeWithRules.ts
+++ b/web/src/shared/utils/categorizer/categorizeWithRules.ts
@@ -23,21 +23,25 @@ export function categorizeWithRules(transactions: Transaction[]): Transaction[]
)
return transactions.map(tx => {
- if (tx.isTransfer) {
+ // The person's own rule outranks the transfer guess: that is how a credit to your savings account becomes Savings
+ const custom = findRule(tx)
+
+ if (!custom && tx.isTransfer) {
return { ...tx, category: 'Transfer', categorySource: 'rule' as const }
}
- for (const pattern of TRANSFER_PATTERNS) {
- if (pattern.test(tx.description)) {
- return { ...tx, category: 'Transfer', isTransfer: true, categorySource: 'rule' as const }
+ if (!custom) {
+ for (const pattern of TRANSFER_PATTERNS) {
+ if (pattern.test(tx.description)) {
+ return { ...tx, category: 'Transfer', isTransfer: true, categorySource: 'rule' as const }
+ }
}
}
const learned = findLearned(tx)
+
// A rule the person made from a transaction is more specific than a merchant rule, so it goes first.
// A rule without a subcategory leaves it to the merchant rule when both agree on the category.
- const custom = findRule(tx)
-
if (custom) {
let categoryDetail = custom.detail
diff --git a/web/src/shared/utils/categorizer/transactionRules.test.ts b/web/src/shared/utils/categorizer/transactionRules.test.ts
index 33266fd..df746a2 100644
--- a/web/src/shared/utils/categorizer/transactionRules.test.ts
+++ b/web/src/shared/utils/categorizer/transactionRules.test.ts
@@ -83,6 +83,35 @@ describe('transaction rules', () => {
payee: 'Aldi',
words: ['Gebuehr', 'Kombi-Paket', 'Prepaid-Konto'],
amount: 8.99,
+ iban: '',
})
})
+
+ it('matches the other account whatever the spacing or case', () => {
+ const credit = {
+ recipient: 'Me',
+ recipientIban: 'de89 3704 0044 0532 0130 00',
+ description: 'Umbuchung',
+ amount: 200,
+ }
+
+ setTransactionRules([{ id: '1', category: 'Savings', match: { iban: 'DE89370400440532013000' } }])
+
+ expect(findRule(credit)?.category).toBe('Savings')
+ expect(findRule({ ...credit, recipientIban: 'DE00000000000000000000' })).toBeNull()
+ expect(findRule({ ...credit, recipientIban: undefined })).toBeNull()
+ })
+
+ it('lets a rule turn a transfer into Savings, but leaves other transfers alone', () => {
+ const base = { date: '2026-01-01', currency: 'EUR', rawData: {}, id: 'a', amount: 200, isTransfer: true }
+ const own = { ...base, description: 'Umbuchung', recipientIban: 'DE89370400440532013000' }
+ const other = { ...base, id: 'b', description: 'Umbuchung', recipientIban: 'DE00000000000000000000' }
+
+ setTransactionRules([{ id: '1', category: 'Savings', match: { iban: 'DE89370400440532013000' } }])
+
+ const [a, b] = categorizeWithRules([own, other] as never)
+
+ expect(a.category).toBe('Savings')
+ expect(b.category).toBe('Transfer')
+ })
})
diff --git a/web/src/shared/utils/categorizer/transactionRules.ts b/web/src/shared/utils/categorizer/transactionRules.ts
index 600416e..7b27f6e 100644
--- a/web/src/shared/utils/categorizer/transactionRules.ts
+++ b/web/src/shared/utils/categorizer/transactionRules.ts
@@ -7,6 +7,8 @@ export interface RuleMatch {
words?: string[]
/** The size of the payment, ignoring its sign */
amount?: number
+ /** The other side's account number: the one thing that tells your own savings account from any other transfer */
+ iban?: string
}
export interface TransactionRule {
id: string
@@ -29,9 +31,17 @@ export function removeLocalRule(id: string): void {
rules = rules.filter(r => r.id !== id)
}
-const conditionCount = (m: RuleMatch) => (m.payee ? 1 : 0) + (m.words?.length ?? 0) + (m.amount === undefined ? 0 : 1)
+const normalizeIban = (v: string) => v.replace(/\s+/g, '').toUpperCase()
-function matches(m: RuleMatch, tx: Pick): boolean {
+type RuleInput = Pick
+
+const conditionCount = (m: RuleMatch) =>
+ (m.payee ? 1 : 0) + (m.words?.length ?? 0) + (m.amount === undefined ? 0 : 1) + (m.iban ? 1 : 0)
+
+function matches(m: RuleMatch, tx: RuleInput): boolean {
+ if (m.iban && normalizeIban(tx.recipientIban ?? '') !== normalizeIban(m.iban)) {
+ return false
+ }
if (m.payee && !` ${normalizeMerchant(tx.recipient ?? '')} `.includes(` ${normalizeMerchant(m.payee)} `)) {
return false
}
@@ -49,7 +59,7 @@ function matches(m: RuleMatch, tx: Pick): TransactionRule | null {
+export function findRule(tx: RuleInput): TransactionRule | null {
let best: TransactionRule | null = null
for (const rule of rules) {
@@ -75,8 +85,8 @@ const FILLER = new Set([
'purchase',
])
-/** The conditions worth offering for a rule made from this transaction: its payee, a few distinctive words, its amount. */
-export function suggestRule(tx: Pick): Required {
+/** The conditions worth offering for a rule made from this transaction: its payee, a few distinctive words, its amount, the other account. */
+export function suggestRule(tx: RuleInput): Required {
const words = [
...new Set(
tx.description
@@ -85,5 +95,10 @@ export function suggestRule(tx: Pick): Transaction =>
+ ({
+ id: '1',
+ date: '2026-01-01',
+ description: 'x',
+ amount: 5,
+ currency: 'EUR',
+ type: 'expense',
+ rawData: {},
+ ...over,
+ }) as Transaction
+
+describe('transferDetail', () => {
+ it('names both ends of an outgoing transfer, masked', () => {
+ expect(
+ transferDetail(tx({ category: 'Transfer', recipientIban: 'DE89 3704 0044 0532 0130 00', accountLabel: '…5678' })),
+ ).toBe('To …3000 · from …5678')
+ })
+
+ it('flips the wording for an incoming one', () => {
+ expect(
+ transferDetail(tx({ category: 'Transfer', type: 'income', recipientIban: 'DE8937040044053201301234' })),
+ ).toBe('From …1234')
+ })
+
+ it('is empty for other rows and for a transfer with no account details', () => {
+ expect(transferDetail(tx({ category: 'Food', recipientIban: 'DE89' }))).toBeUndefined()
+ expect(transferDetail(tx({ category: 'Transfer' }))).toBeUndefined()
+ })
+})
diff --git a/web/src/shared/utils/transferDetail.ts b/web/src/shared/utils/transferDetail.ts
new file mode 100644
index 0000000..03ccf58
--- /dev/null
+++ b/web/src/shared/utils/transferDetail.ts
@@ -0,0 +1,28 @@
+import type { Transaction } from '@/shared/types'
+
+const tail = (iban: string) => `…${iban.replace(/\s+/g, '').slice(-4)}`
+
+/**
+ * Which two accounts a transfer between your own accounts joined, as a short line: "To …1234 · from …5678".
+ * Only the last four characters are shown, the way connected accounts are labelled, so a screenshot of the list
+ * doesn't give away a whole IBAN. Returns undefined when the row carries neither end.
+ */
+export function transferDetail(tx: Transaction): string | undefined {
+ if (tx.category !== 'Transfer') {
+ return undefined
+ }
+
+ const other = tx.recipientIban ? tail(tx.recipientIban) : tx.recipient || undefined
+ const own = tx.accountLabel || (tx.referenceAccount ? tail(tx.referenceAccount) : undefined)
+
+ if (!other && !own) {
+ return undefined
+ }
+
+ const out = tx.type === 'expense'
+ const parts = [other && `${out ? 'To' : 'From'} ${other}`, own && `${out ? 'from' : 'into'} ${own}`].filter(
+ Boolean,
+ ) as string[]
+
+ return parts.join(' · ').replace(/^./, c => c.toUpperCase())
+}