From b5ea7eadfe49a152490995cc01672ce32d651d67 Mon Sep 17 00:00:00 2001 From: nedson202 Date: Sun, 8 Nov 2026 22:50:38 +0000 Subject: [PATCH] feat: amount search, trip transaction search, transfer account detail, IBAN rule condition - search transactions by amount on the Transactions page and inside a trip - trip cards say how far over what was put in a trip is - transfer rows show the accounts they joined (last four of the IBAN) - rules can match the other account's IBAN and now outrank transfer detection Co-Authored-By: Claude Sonnet 5.5 --- .../transaction-rules.schemas.test.ts | 17 +++++ .../transaction-rules.schemas.ts | 9 ++- .../transactions/components/SearchFilter.tsx | 2 +- .../transactions/routes/TransactionsPage.tsx | 2 + .../trips/components/TripTransactions.tsx | 74 +++++++++++++++---- .../trips/constants/search.constants.ts | 4 +- web/src/features/trips/routes/TripPage.tsx | 2 +- web/src/features/trips/routes/TripsPage.tsx | 5 +- web/src/features/trips/utils/tripBudgetUi.ts | 3 +- .../transactions/MakeRuleDialog.tsx | 10 ++- .../transactions/TransactionTableRow.tsx | 3 + .../contexts/workspace/processTransactions.ts | 13 +++- web/src/shared/utils/amountQuery.test.ts | 26 +++++++ web/src/shared/utils/amountQuery.ts | 31 ++++++++ .../utils/categorizer/categorizeWithRules.ts | 16 ++-- .../categorizer/transactionRules.test.ts | 29 ++++++++ .../utils/categorizer/transactionRules.ts | 27 +++++-- web/src/shared/utils/transferDetail.test.ts | 34 +++++++++ web/src/shared/utils/transferDetail.ts | 28 +++++++ 19 files changed, 300 insertions(+), 35 deletions(-) create mode 100644 api/src/transaction-rules/transaction-rules.schemas.test.ts create mode 100644 web/src/shared/utils/amountQuery.test.ts create mode 100644 web/src/shared/utils/amountQuery.ts create mode 100644 web/src/shared/utils/transferDetail.test.ts create mode 100644 web/src/shared/utils/transferDetail.ts diff --git a/api/src/transaction-rules/transaction-rules.schemas.test.ts b/api/src/transaction-rules/transaction-rules.schemas.test.ts new file mode 100644 index 0000000..5f3bb94 --- /dev/null +++ b/api/src/transaction-rules/transaction-rules.schemas.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { CreateRuleSchema } from './transaction-rules.schemas' + +const parse = (match: unknown) => CreateRuleSchema.safeParse({ match, category: 'Savings' }) + +describe('rule account condition', () => { + it('is enough on its own, and is stored without spaces or lower case', () => { + const r = parse({ iban: ' de89 3704 0044 0532 0130 00 ' }) + + expect(r.success && r.data.match.iban).toBe('DE89370400440532013000') + }) + + it('rejects something that is not an account number', () => { + expect(parse({ iban: 'not an iban!' }).success).toBe(false) + expect(parse({ iban: 'DE89' }).success).toBe(false) + }) +}) diff --git a/api/src/transaction-rules/transaction-rules.schemas.ts b/api/src/transaction-rules/transaction-rules.schemas.ts index 37eded2..917af50 100644 --- a/api/src/transaction-rules/transaction-rules.schemas.ts +++ b/api/src/transaction-rules/transaction-rules.schemas.ts @@ -12,8 +12,15 @@ const RuleMatchSchema = z words: words.optional(), /** The size of the payment, ignoring its sign */ amount: z.number().positive().max(1e9).optional(), + /** The other side's account number, spaces and case ignored (IBAN lengths run from 15 to 34) */ + iban: z + .string() + .trim() + .transform(v => v.replace(/\s+/g, '').toUpperCase()) + .pipe(z.string().regex(/^[A-Z0-9]{8,34}$/)) + .optional(), }) - .refine(m => Boolean(m.payee) || (m.words?.length ?? 0) > 0 || m.amount !== undefined, { + .refine(m => Boolean(m.payee) || (m.words?.length ?? 0) > 0 || m.amount !== undefined || Boolean(m.iban), { message: 'A rule needs at least one condition', }) diff --git a/web/src/features/transactions/components/SearchFilter.tsx b/web/src/features/transactions/components/SearchFilter.tsx index 6ad2719..9706cd8 100644 --- a/web/src/features/transactions/components/SearchFilter.tsx +++ b/web/src/features/transactions/components/SearchFilter.tsx @@ -45,7 +45,7 @@ export default function SearchFilter({ onSearchChange(e.target.value)} className="input w-full pl-10 pr-8" diff --git a/web/src/features/transactions/routes/TransactionsPage.tsx b/web/src/features/transactions/routes/TransactionsPage.tsx index cadfa48..9851d9c 100644 --- a/web/src/features/transactions/routes/TransactionsPage.tsx +++ b/web/src/features/transactions/routes/TransactionsPage.tsx @@ -14,6 +14,7 @@ import { inPeriod } from '@/shared/utils/summary' import { isLeftOutTransfer } from '@/shared/utils/transfers' import TransactionList from './TransactionList' import { commonTraits, netOf } from '../utils/listSummary' +import { matchesAmountQuery } from '@/shared/utils/amountQuery' import { formatCurrency } from '@/shared/utils/format' export default function TransactionsPage() { @@ -64,6 +65,7 @@ export default function TransactionsPage() { } if ( q && + !matchesAmountQuery(tx.amount, q) && !`${tx.description} ${tx.displayName || ''} ${tx.merchant || ''} ${tx.category || ''} ${tx.recipient || ''} ${tx.note || ''}` .toLowerCase() .includes(q) diff --git a/web/src/features/trips/components/TripTransactions.tsx b/web/src/features/trips/components/TripTransactions.tsx index 60379fe..78502f0 100644 --- a/web/src/features/trips/components/TripTransactions.tsx +++ b/web/src/features/trips/components/TripTransactions.tsx @@ -1,10 +1,11 @@ import Tooltip from '@/shared/components/Tooltip' import { useMemo, useState } from 'react' import { useNavigate } from '@tanstack/react-router' -import { Pencil, Trash2, X } from 'lucide-react' +import { Pencil, Search, Trash2, X } from 'lucide-react' import TransactionTable from '@/shared/components/transactions/TransactionTable' import type { TableBehaviour } from '@/shared/components/transactions/transactionTableTypes' import { useApp } from '@/shared/contexts/AppState' +import { matchesAmountQuery } from '@/shared/utils/amountQuery' import type { Trip, TripTransaction } from '../api/trips.api' import { useTagItems, useTripCategories, useTripSubcategories, useUntagItems } from '../hooks/trips-queries' import { FUNDING_COLOR, tripCategoryColor } from '../utils/tripCategoryColor' @@ -25,12 +26,15 @@ export default function TripTransactions({ rows: all, cat, sub, + q, }: { trip: Trip rows: TripTransaction[] /** Narrow to one category (and one subcategory of it), as picked in the breakdown */ cat?: string sub?: string + /** Narrow to rows whose text or amount matches */ + q?: string }) { const navigate = useNavigate() const { baseCurrency, pushToast } = useApp() @@ -40,18 +44,32 @@ export default function TripTransactions({ const untag = useUntagItems() const [selected, setSelected] = useState>(new Set()) const [editing, setEditing] = useState(null) - const rows = useMemo( - () => - cat - ? all.filter( - r => - r.kind === 'cost' && - (r.tripCategory ?? 'Other') === cat && - (sub === undefined || (sub === NOT_SPECIFIED ? !r.tripSubcategory : r.tripSubcategory === sub)), - ) - : all, - [all, cat, sub], - ) + const rows = useMemo(() => { + const text = q?.trim().toLowerCase() + + return all.filter(r => { + if (cat) { + const inCategory = + r.kind === 'cost' && + (r.tripCategory ?? 'Other') === cat && + (sub === undefined || (sub === NOT_SPECIFIED ? !r.tripSubcategory : r.tripSubcategory === sub)) + + if (!inCategory) { + return false + } + } + + return ( + !text || + matchesAmountQuery(r.amount, text) || + [r.description, r.recipient, r.tripCategory, r.tripSubcategory, r.place, r.paidBy] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(text) + ) + }) + }, [all, cat, sub, q]) const table = useMemo(() => rows.map(toTableRow), [rows]) const byRef = useMemo(() => new Map(rows.map(r => [r.ref, r])), [rows]) @@ -160,8 +178,36 @@ export default function TripTransactions({ ), } + const setQuery = (next: string) => + navigate({ + to: '/trips/$tripId', + params: { tripId: trip.id }, + search: { tab: 'transactions', cat, sub, q: next || undefined }, + replace: true, + }) + return ( <> +
+
{cat && (
Showing @@ -176,7 +222,7 @@ export default function TripTransactions({
)} - {cat && rows.length === 0 &&

Nothing here any more.

} + {(cat || q) && rows.length === 0 &&

Nothing matches.

} (typeof v === 'string' && v ? v : undefined) @@ -16,5 +18,5 @@ export function validateTripSearch(s: Record): TripSearch { const tab = s.tab === 'transactions' || s.tab === 'manage' ? s.tab : undefined const cat = tab === 'transactions' ? str(s.cat) : undefined - return { tab, cat, sub: cat ? str(s.sub) : undefined } + return { tab, cat, sub: cat ? str(s.sub) : undefined, q: tab === 'transactions' ? str(s.q) : undefined } } diff --git a/web/src/features/trips/routes/TripPage.tsx b/web/src/features/trips/routes/TripPage.tsx index 87ab73b..77fc516 100644 --- a/web/src/features/trips/routes/TripPage.tsx +++ b/web/src/features/trips/routes/TripPage.tsx @@ -69,7 +69,7 @@ export default function TripPage() { label: 'Transactions', count: transactions.length, content: transactions.length ? ( - + ) : ( empty ), diff --git a/web/src/features/trips/routes/TripsPage.tsx b/web/src/features/trips/routes/TripsPage.tsx index 194016c..997e800 100644 --- a/web/src/features/trips/routes/TripsPage.tsx +++ b/web/src/features/trips/routes/TripsPage.tsx @@ -26,6 +26,7 @@ function TripMoneyLine({ trip }: { trip: TripSummary }) { return Nothing spent yet } const over = budget !== undefined && money.spent > budget + const overFunds = budget === undefined && money.funded > 0 && money.remaining < 0 const caption = tripBudgetCaption({ budget, over, @@ -42,7 +43,9 @@ function TripMoneyLine({ trip }: { trip: TripSummary }) { {fmt(money.spent)} spent - {caption} + + {caption} + {budget !== undefined && budget > 0 && ( diff --git a/web/src/features/trips/utils/tripBudgetUi.ts b/web/src/features/trips/utils/tripBudgetUi.ts index 54563ab..6eb5533 100644 --- a/web/src/features/trips/utils/tripBudgetUi.ts +++ b/web/src/features/trips/utils/tripBudgetUi.ts @@ -28,7 +28,8 @@ export function tripBudgetCaption(options: { return `${money(budget - spent)} left of ${money(budget)}` } if (funded > 0) { - return `${money(remaining)} left` + // A negative "left" reads like a typo; say it plainly, the same as the breakdown's "Overspent by" + return remaining < 0 ? `${money(-remaining)} over what was put in` : `${money(remaining)} left` } return '' diff --git a/web/src/shared/components/transactions/MakeRuleDialog.tsx b/web/src/shared/components/transactions/MakeRuleDialog.tsx index 3ae9278..82adb07 100644 --- a/web/src/shared/components/transactions/MakeRuleDialog.tsx +++ b/web/src/shared/components/transactions/MakeRuleDialog.tsx @@ -6,7 +6,7 @@ import { formatCurrency } from '@/shared/utils/format' import type { Transaction } from '@/shared/types' /** - * Turns one transaction into a rule: it offers what was seen (payee, distinctive words, amount) and the person keeps + * Turns one transaction into a rule: it offers what was seen (payee, distinctive words, amount, the other account) and the person keeps * only what makes rows "like this". More ticks means a narrower rule, so a phone plan can be told from groceries. */ export default function MakeRuleDialog({ @@ -28,10 +28,12 @@ export default function MakeRuleDialog({ const [payee, setPayee] = useState(Boolean(seen.payee)) const [words, setWords] = useState(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()) +}