Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions api/src/transaction-rules/transaction-rules.schemas.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
9 changes: 8 additions & 1 deletion api/src/transaction-rules/transaction-rules.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})

Expand Down
2 changes: 1 addition & 1 deletion web/src/features/transactions/components/SearchFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default function SearchFilter({
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-midnight-400" />
<input
type="text"
placeholder="Search transactions..."
placeholder="Search transactions or amounts..."
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
className="input w-full pl-10 pr-8"
Expand Down
2 changes: 2 additions & 0 deletions web/src/features/transactions/routes/TransactionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down
74 changes: 60 additions & 14 deletions web/src/features/trips/components/TripTransactions.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
Expand All @@ -40,18 +44,32 @@ export default function TripTransactions({
const untag = useUntagItems()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [editing, setEditing] = useState<TripTransaction | null>(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])

Expand Down Expand Up @@ -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 (
<>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-midnight-400" aria-hidden="true" />
<input
type="text"
placeholder="Search transactions or amounts..."
aria-label="Search this trip's transactions"
value={q ?? ''}
onChange={e => setQuery(e.target.value)}
className="input w-full pl-10 pr-8"
/>
{q && (
<button
aria-label="Clear search"
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-midnight-400 hover:text-ink"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{cat && (
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-midnight-300">
<span>Showing</span>
Expand All @@ -176,7 +222,7 @@ export default function TripTransactions({
</button>
</div>
)}
{cat && rows.length === 0 && <p className="card text-midnight-300">Nothing here any more.</p>}
{(cat || q) && rows.length === 0 && <p className="card text-midnight-300">Nothing matches.</p>}
<TransactionTable
transactions={table}
selectedIds={selected}
Expand Down
4 changes: 3 additions & 1 deletion web/src/features/trips/constants/search.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface TripSearch {
cat?: string
/** ...and only this subcategory of it (NOT_SPECIFIED for the ones without) */
sub?: string
/** Text or amount typed in the search box on the Transactions tab */
q?: string
}

const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined)
Expand All @@ -16,5 +18,5 @@ export function validateTripSearch(s: Record<string, unknown>): 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 }
}
2 changes: 1 addition & 1 deletion web/src/features/trips/routes/TripPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export default function TripPage() {
label: 'Transactions',
count: transactions.length,
content: transactions.length ? (
<TripTransactions trip={trip} rows={transactions} cat={search.cat} sub={search.sub} />
<TripTransactions trip={trip} rows={transactions} cat={search.cat} sub={search.sub} q={search.q} />
) : (
empty
),
Expand Down
5 changes: 4 additions & 1 deletion web/src/features/trips/routes/TripsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function TripMoneyLine({ trip }: { trip: TripSummary }) {
return <span className="mt-auto block pt-3 text-sm text-midnight-500">Nothing spent yet</span>
}
const over = budget !== undefined && money.spent > budget
const overFunds = budget === undefined && money.funded > 0 && money.remaining < 0
const caption = tripBudgetCaption({
budget,
over,
Expand All @@ -42,7 +43,9 @@ function TripMoneyLine({ trip }: { trip: TripSummary }) {
<span className="text-lg font-semibold text-ink tabular-nums">
{fmt(money.spent)} <span className="text-sm font-normal text-midnight-400">spent</span>
</span>
<span className={`text-sm tabular-nums ${over ? 'text-expense' : 'text-midnight-300'}`}>{caption}</span>
<span className={`text-sm tabular-nums ${over || overFunds ? 'text-expense' : 'text-midnight-300'}`}>
{caption}
</span>
</span>
{budget !== undefined && budget > 0 && (
<span className="mt-1.5 block h-1.5 rounded bg-midnight-800" aria-hidden>
Expand Down
3 changes: 2 additions & 1 deletion web/src/features/trips/utils/tripBudgetUi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
Expand Down
10 changes: 9 additions & 1 deletion web/src/shared/components/transactions/MakeRuleDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -28,10 +28,12 @@ export default function MakeRuleDialog({
const [payee, setPayee] = useState(Boolean(seen.payee))
const [words, setWords] = useState<string[]>(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

Expand Down Expand Up @@ -59,6 +61,12 @@ export default function MakeRuleDialog({
<Checkbox checked={amount} onChange={setAmount} ariaLabel="Amount is the same" />
Amount is <span className="font-medium text-ink">{formatCurrency(seen.amount, tx.currency || currency)}</span>
</label>
{seen.iban && (
<label className="flex cursor-pointer items-center gap-3 text-sm text-midnight-100">
<Checkbox checked={iban} onChange={setIban} ariaLabel="Other account is the same" />
Other account is <span className="font-medium text-ink">{`…${seen.iban.slice(-4)}`}</span>
</label>
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<button className="btn-secondary text-sm" onClick={onClose}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { transferDetail } from '@/shared/utils/transferDetail'
import { ChevronDown } from 'lucide-react'
import Tooltip from '@/shared/components/Tooltip'
import { Transaction } from '@/shared/types'
Expand Down Expand Up @@ -55,6 +56,7 @@ export default function TransactionTableRow({
behaviour = {},
}: TransactionTableRowProps) {
const platform = shopBehindProcessor(tx) ? tx.recipient : undefined
const transfer = transferDetail(tx)
const colorOf = behaviour.colorOf ?? getCategoryColor
const [noteOpen, setNoteOpen] = useState(false)
const [ruleFor, setRuleFor] = useState<{ category: string; detail?: string } | null>(null)
Expand Down Expand Up @@ -108,6 +110,7 @@ export default function TransactionTableRow({
onClose={() => setNoteOpen(false)}
onSave={note => onUpdateTransaction(tx.id, { note })}
/>
{transfer && <span className="block truncate text-xs text-midnight-500">{transfer}</span>}
{behaviour.meta?.(tx)}
</div>
<div className="flex items-center gap-1 shrink-0">
Expand Down
13 changes: 11 additions & 2 deletions web/src/shared/contexts/workspace/processTransactions.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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))
}
26 changes: 26 additions & 0 deletions web/src/shared/utils/amountQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
31 changes: 31 additions & 0 deletions web/src/shared/utils/amountQuery.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading