diff --git a/app/frontend/src/components/StatementImport.tsx b/app/frontend/src/components/StatementImport.tsx new file mode 100644 index 0000000..cbe7c06 --- /dev/null +++ b/app/frontend/src/components/StatementImport.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react'; +import { Dialog } from '@project-graphite/ui'; +import { useAuth } from '../auth'; +import { useSpaces } from '../spaces'; +import { dateOrder, guessColumns, merchantKey, parseCsv, readCharges, suggestRecurring, type DateOrder, type StatementColumns } from '../statement'; +import { cycleLabel, toMinor, type Subscription } from '../subscriptions'; +import { useAction } from '../useAction'; +import { useResource } from '../useResource'; + +const orders: [DateOrder, string][] = [ + ['dmy', 'Day first (31/01/2026)'], + ['mdy', 'Month first (01/31/2026)'], + ['ymd', 'Year first (2026-01-31)'], +]; + +export function StatementImport({ defaultCurrency, onClose, onAdded }: { defaultCurrency: string; onClose: () => void; onAdded: () => void }) { + const auth = useAuth(); + const spaces = useSpaces(); + const adding = useAction(); + const editable = (spaces.data ?? []).filter((space) => space.role !== 'viewer'); + const [rows, setRows] = useState(); + const [columns, setColumns] = useState({ date: 0, description: 0, amount: 0 }); + const [order, setOrder] = useState('dmy'); + const [spaceId, setSpaceId] = useState(''); + const [currency, setCurrency] = useState(defaultCurrency); + const [skipped, setSkipped] = useState>(new Set()); + const target = spaceId || (editable.find((space) => space.kind === 'personal') ?? editable[0])?.id; + const existing = useResource(target ? `/spaces/${target}/subscriptions` : null, true); + const tracked = new Set((existing.data ?? []).map((subscription) => merchantKey(subscription.name))); + const charges = rows ? readCharges(rows, columns, order) : []; + const suggestions = suggestRecurring(charges); + const chosen = suggestions.filter((suggestion) => !tracked.has(suggestion.key) && !skipped.has(suggestion.key)); + + async function read(file: File) { + const parsed = parseCsv(await file.text()); + const guessed = guessColumns(parsed); + setRows(parsed); + setColumns(guessed); + setOrder(dateOrder(parsed.slice(1, 50).map((row) => row[guessed.date] ?? ''))); + } + + const columnSelect = (label: string, key: keyof StatementColumns) => ( + + ); + + return ( + +
+

Choose a CSV export from your bank or card. It is read on this device and never uploaded.

+ event.currentTarget.files?.[0] && void read(event.currentTarget.files[0])} type="file" /> + {rows && ( + <> +
+ {columnSelect('Date column', 'date')} + {columnSelect('Description column', 'description')} + {columnSelect('Amount column', 'amount')} + +
+

+ {charges.length} charges read · {suggestions.length} look recurring +

+ {suggestions.length > 0 && ( +
    + {suggestions.map((suggestion) => { + const known = tracked.has(suggestion.key); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + +
+ + )} +
+ + +
+
+
+ ); +} diff --git a/app/frontend/src/pages/RecurringPage.tsx b/app/frontend/src/pages/RecurringPage.tsx index bd4d9e7..0b6beb8 100644 --- a/app/frontend/src/pages/RecurringPage.tsx +++ b/app/frontend/src/pages/RecurringPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Link } from 'react-router'; import { EmptyState, ListSkeleton, PageHeader } from '@project-graphite/ui'; import { useAuth } from '../auth'; @@ -6,6 +7,7 @@ import { money, shortDate, type Subscription } from '../subscriptions'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; import { LoadError } from '../components/LoadError'; +import { StatementImport } from '../components/StatementImport'; interface Total { currency: string; @@ -39,10 +41,27 @@ export function RecurringPage() { const spaces = useSpaces(); const summary = useResource('/subscriptions/summary', true); const action = useAction(); + const [importing, setImporting] = useState(false); return (
- + space.role !== 'viewer') && ( + + ) + } + title="Recurring" + /> + {importing && ( + setImporting(false)} + /> + )} {summary.error ? ( ) : !summary.data ? ( diff --git a/app/frontend/src/statement.ts b/app/frontend/src/statement.ts new file mode 100644 index 0000000..20524fc --- /dev/null +++ b/app/frontend/src/statement.ts @@ -0,0 +1,167 @@ +import { catalogue } from './catalogue'; + +export interface StatementColumns { + date: number; + description: number; + amount: number; +} + +export type DateOrder = 'ymd' | 'dmy' | 'mdy'; + +export interface Charge { + date: string; + description: string; + amount: number; +} + +export interface Suggestion { + key: string; + name: string; + amount: number; + repeatRule: string; + lastDate: string; + count: number; + category: string; + cancelUrl: string | null; +} + +export function parseCsv(text: string) { + const firstLine = text.split(/\r?\n/, 1)[0] ?? ''; + const delimiter = [',', ';', '\t'].reduce((best, candidate) => (firstLine.split(candidate).length > firstLine.split(best).length ? candidate : best)); + const rows: string[][] = []; + let row: string[] = []; + let cell = ''; + let quoted = false; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]!; + if (quoted) { + if (character === '"' && text[index + 1] === '"') { + cell += '"'; + index += 1; + } else if (character === '"') quoted = false; + else cell += character; + } else if (character === '"') quoted = true; + else if (character === delimiter) { + row.push(cell.trim()); + cell = ''; + } else if (character === '\n' || character === '\r') { + if (character === '\r' && text[index + 1] === '\n') index += 1; + row.push(cell.trim()); + if (row.some(Boolean)) rows.push(row); + row = []; + cell = ''; + } else cell += character; + } + row.push(cell.trim()); + if (row.some(Boolean)) rows.push(row); + return rows; +} + +const datePattern = /^(\d{1,4})[./-](\d{1,2})[./-](\d{1,4})/; + +export function dateOrder(values: string[]): DateOrder { + const parts = values.map((value) => datePattern.exec(value)).filter((match) => match !== null); + if (parts.some((match) => match[1]!.length === 4)) return 'ymd'; + if (parts.some((match) => Number(match[2]) > 12)) return 'mdy'; + return 'dmy'; +} + +export function parseDate(value: string, order: DateOrder) { + const match = datePattern.exec(value); + if (!match) return null; + const [first, second, third] = [match[1]!, match[2]!, match[3]!].map(Number) as [number, number, number]; + const [year, month, day] = order === 'ymd' ? [first, second, third] : order === 'dmy' ? [third, second, first] : [third, first, second]; + const fullYear = year < 100 ? 2000 + year : year; + const date = new Date(Date.UTC(fullYear, month - 1, day)); + if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null; + return date.toISOString().slice(0, 10); +} + +export function parseAmount(value: string) { + const negative = /^\(.*\)$/.test(value.trim()) || /-\s*[\d.,]+|[\d.,]+\s*-$/.test(value); + const digits = value.replace(/[^\d.,]/g, ''); + if (!/\d/.test(digits)) return null; + const lastDot = digits.lastIndexOf('.'); + const lastComma = digits.lastIndexOf(','); + const decimal = lastDot > lastComma ? '.' : lastComma > -1 && digits.length - lastComma - 1 <= 2 ? ',' : '.'; + const normalised = digits.replace(decimal === '.' ? /,/g : /\./g, '').replace(',', '.'); + const amount = Number(normalised); + if (!Number.isFinite(amount)) return null; + return negative ? -amount : amount; +} + +export function guessColumns(rows: string[][]): StatementColumns { + const [header = [], ...body] = rows; + const sample = body.slice(0, 20); + const column = (pattern: RegExp, fits: (value: string) => boolean) => { + const named = header.findIndex((title) => pattern.test(title)); + if (named > -1) return named; + return header.findIndex((_title, index) => sample.length > 0 && sample.every((row) => fits(row[index] ?? ''))); + }; + const date = column(/date|booked|posted|datum/i, (value) => datePattern.test(value)); + const amount = column(/amount|^value$|debit|withdrawal|betrag|montant/i, (value) => parseAmount(value) !== null && !datePattern.test(value)); + const description = column(/description|payee|merchant|details|narrative|counterparty|name|memo|reference|beschreibung|libell/i, (value) => /[a-z]{3}/i.test(value)); + return { date: Math.max(date, 0), description: Math.max(description, 0), amount: Math.max(amount, 0) }; +} + +export function readCharges(rows: string[][], columns: StatementColumns, order: DateOrder): Charge[] { + const entries = rows + .slice(1) + .map((row) => ({ date: parseDate(row[columns.date] ?? '', order), description: row[columns.description] ?? '', amount: parseAmount(row[columns.amount] ?? '') })) + .filter((entry): entry is Charge => entry.date !== null && entry.amount !== null && entry.amount !== 0 && entry.description !== ''); + const outgoing = entries.some((entry) => entry.amount < 0) ? entries.filter((entry) => entry.amount < 0) : entries; + return outgoing.map((entry) => ({ ...entry, amount: Math.abs(entry.amount) })); +} + +const fillerWords = new Set(['card', 'payment', 'purchase', 'pos', 'debit', 'credit', 'visa', 'mastercard', 'direct', 'dd', 'sepa', 'ref', 'to', 'from', 'www', 'com', 'net', 'the', 'inc', 'ltd', 'llc']); + +export function merchantKey(description: string) { + return description + .toLowerCase() + .replace(/[^a-z0-9+]+/g, ' ') + .split(' ') + .filter((word) => word && !/\d/.test(word) && !fillerWords.has(word)) + .slice(0, 2) + .join(' '); +} + +const cycles: [number, number, string][] = [ + [6, 8, 'FREQ=WEEKLY'], + [26, 35, 'FREQ=MONTHLY'], + [85, 96, 'FREQ=MONTHLY;INTERVAL=3'], + [355, 376, 'FREQ=YEARLY'], +]; + +const median = (values: number[]) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!; +const titleCase = (text: string) => text.replace(/\b[a-z]/g, (letter) => letter.toUpperCase()); + +export function suggestRecurring(charges: Charge[]): Suggestion[] { + const groups = new Map(); + for (const charge of charges) { + const key = merchantKey(charge.description); + if (key) groups.set(key, [...(groups.get(key) ?? []), charge]); + } + const suggestions: Suggestion[] = []; + for (const [key, group] of groups) { + const byDay = [...new Map(group.map((charge) => [charge.date, charge])).values()].sort((a, b) => a.date.localeCompare(b.date)); + if (byDay.length < 2) continue; + const gaps = byDay.slice(1).map((charge, index) => (Date.parse(charge.date) - Date.parse(byDay[index]!.date)) / 86_400_000); + const cycle = cycles.find(([low, high]) => median(gaps) >= low && median(gaps) <= high); + if (!cycle || (byDay.length < 3 && cycle[2] !== 'FREQ=YEARLY')) continue; + const typical = median(byDay.map((charge) => charge.amount)); + if (byDay.some((charge) => Math.abs(charge.amount - typical) > typical * 0.25)) continue; + const last = byDay.at(-1)!; + const known = catalogue.find((service) => merchantKey(service.name) && `${key} `.startsWith(`${merchantKey(service.name)} `)); + suggestions.push({ + key, + name: known?.name ?? titleCase(key), + amount: last.amount, + repeatRule: cycle[2], + lastDate: last.date, + count: byDay.length, + category: known?.category ?? 'other', + cancelUrl: known?.cancelUrl ?? null, + }); + } + return suggestions.sort((a, b) => b.amount - a.amount); +} diff --git a/app/frontend/test/statement.spec.tsx b/app/frontend/test/statement.spec.tsx new file mode 100644 index 0000000..3011fce --- /dev/null +++ b/app/frontend/test/statement.spec.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { dateOrder, guessColumns, merchantKey, parseAmount, parseCsv, parseDate, readCharges, suggestRecurring } from '../src/statement'; + +describe('bank statements', () => { + it('reads quoted cells, either delimiter and both line endings', () => { + expect(parseCsv('Date;Payee;Amount\r\n01/02/2026;"Shop; Co ""Ltd""";-1,50\r\n\r\n')).toEqual([ + ['Date', 'Payee', 'Amount'], + ['01/02/2026', 'Shop; Co "Ltd"', '-1,50'], + ]); + expect(parseCsv('a,b\n1,"two\nlines"')).toEqual([ + ['a', 'b'], + ['1', 'two\nlines'], + ]); + }); + + it('reads dates in the order the statement uses and amounts in either decimal style', () => { + expect(dateOrder(['2026-01-31'])).toBe('ymd'); + expect(dateOrder(['01/31/2026', '02/01/2026'])).toBe('mdy'); + expect(dateOrder(['31/01/2026'])).toBe('dmy'); + expect(parseDate('31.01.26', 'dmy')).toBe('2026-01-31'); + expect(parseDate('02/30/2026', 'mdy')).toBeNull(); + expect(parseAmount('-1.234,56 €')).toBe(-1234.56); + expect(parseAmount('$1,234.56')).toBe(1234.56); + expect(parseAmount('(12.99)')).toBe(-12.99); + expect(parseAmount('12,99')).toBe(12.99); + expect(parseAmount('n/a')).toBeNull(); + }); + + it('finds the columns by name, or by what they hold', () => { + expect(guessColumns([['Value date', 'Details', 'Amount', 'Balance']])).toEqual({ date: 0, description: 1, amount: 2 }); + expect(guessColumns([['a', 'b', 'c'], ['2026-01-01', '-9.99', 'Spotify'], ['2026-02-01', '-9.99', 'Spotify']])).toEqual({ date: 0, description: 2, amount: 1 }); + }); + + it('suggests charges that repeat at a steady rhythm and price', () => { + const rows = parseCsv( + [ + 'Date,Description,Amount', + '2026-01-03,NETFLIX.COM 866-579,-15.49', + '2026-02-03,NETFLIX.COM 866-579,-15.49', + '2026-03-03,NETFLIX.COM 866-579,-17.99', + '2026-01-05,Gym Club Downtown,-30.00', + '2026-01-12,Gym Club Downtown,-30.00', + '2026-01-19,Gym Club Downtown,-30.00', + '2026-01-10,Corner Bakery,-4.10', + '2026-01-11,Corner Bakery,-12.80', + '2026-02-28,Corner Bakery,-3.00', + '2026-01-20,Salary,2500.00', + '2026-02-20,Salary,2500.00', + '2026-03-20,Salary,2500.00', + '2025-03-01,Maxwell Cafe,-3.00', + '2026-03-01,Maxwell Cafe,-3.00', + ].join('\n'), + ); + const charges = readCharges(rows, guessColumns(rows), 'ymd'); + expect(charges.every((charge) => charge.amount > 0)).toBe(true); + expect(charges.some((charge) => charge.description === 'Salary')).toBe(false); + expect(merchantKey('NETFLIX.COM 866-579')).toBe('netflix'); + expect(suggestRecurring(charges)).toEqual([ + { key: 'gym club', name: 'Gym Club', amount: 30, repeatRule: 'FREQ=WEEKLY', lastDate: '2026-01-19', count: 3, category: 'other', cancelUrl: null }, + { + key: 'netflix', + name: 'Netflix', + amount: 17.99, + repeatRule: 'FREQ=MONTHLY', + lastDate: '2026-03-03', + count: 3, + category: 'streaming', + cancelUrl: 'https://www.netflix.com/cancelplan', + }, + { key: 'maxwell cafe', name: 'Maxwell Cafe', amount: 3, repeatRule: 'FREQ=YEARLY', lastDate: '2026-03-01', count: 2, category: 'other', cancelUrl: null }, + ]); + }); +}); diff --git a/app/frontend/test/subscriptions.spec.tsx b/app/frontend/test/subscriptions.spec.tsx index b9b97b0..3850567 100644 --- a/app/frontend/test/subscriptions.spec.tsx +++ b/app/frontend/test/subscriptions.spec.tsx @@ -186,4 +186,55 @@ describe('Recurring', () => { expect(container.textContent).toContain('ECB rates of'); expect(container.textContent).toContain('Not included, with no rate available: XAU.'); }); + + it('suggests subscriptions from a statement read in the browser and skips ones already tracked', async () => { + const created: unknown[] = []; + const fetchMock = serve((path, init) => { + if (path === '/subscriptions/summary') { + return json({ home: null, totals: [{ currency: 'EUR', yearlyMinor: 11_988, monthlyMinor: 999 }], categories: [], upcoming: [music], trials: [], stillWorthIt: [] }); + } + if (path === '/spaces/home/subscriptions' && init?.method === 'POST') { + created.push(JSON.parse(String(init.body))); + return json(music, 201); + } + if (path === '/spaces/home/subscriptions') return json([music]); + return undefined; + }); + await render('/recurring'); + + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'Import a statement')!.click()); + const statement = [ + 'Booked;Payee;Amount', + '03/01/2026;NETFLIX.COM 866;-15,49', + '03/02/2026;NETFLIX.COM 866;-15,49', + '03/03/2026;NETFLIX.COM 866;-15,49', + '14/01/2026;Music Ltd;-9,99', + '14/02/2026;Music Ltd;-9,99', + '14/03/2026;Music Ltd;-9,99', + ].join('\n'); + const input = document.querySelector('input[aria-label="Statement file"]')!; + Object.defineProperty(input, 'files', { value: [new File([statement], 'statement.csv', { type: 'text/csv' })] }); + await act(async () => { + input.dispatchEvent(new Event('change', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + const dialog = document.querySelector('dialog')!; + expect(dialog.textContent).toContain('6 charges read · 2 look recurring'); + expect(dialog.textContent).toContain('already tracked in this space'); + expect(fetchMock.mock.calls.some(([path]) => String(path).includes('statement'))).toBe(false); + await act(async () => [...dialog.querySelectorAll('button')].find((button) => button.textContent === 'Add 1 subscription')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + expect(created).toEqual([ + { + name: 'Netflix', + amountMinor: 1549, + currency: 'EUR', + repeatRule: 'FREQ=MONTHLY', + startDate: '2026-03-03', + category: 'streaming', + cancelUrl: 'https://www.netflix.com/cancelplan', + }, + ]); + expect(container.textContent).toContain('Added 1 subscription.'); + }); });