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
172 changes: 172 additions & 0 deletions app/frontend/src/components/StatementImport.tsx
Original file line number Diff line number Diff line change
@@ -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<string[][]>();
const [columns, setColumns] = useState<StatementColumns>({ date: 0, description: 0, amount: 0 });
const [order, setOrder] = useState<DateOrder>('dmy');
const [spaceId, setSpaceId] = useState('');
const [currency, setCurrency] = useState(defaultCurrency);
const [skipped, setSkipped] = useState<Set<string>>(new Set());
const target = spaceId || (editable.find((space) => space.kind === 'personal') ?? editable[0])?.id;
const existing = useResource<Subscription[]>(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) => (
<label className="field-label">
{label}
<select onChange={(event) => setColumns({ ...columns, [key]: Number(event.currentTarget.value) })} value={columns[key]}>
{(rows?.[0] ?? []).map((title, index) => (
<option key={index} value={index}>
{title || `Column ${index + 1}`}
</option>
))}
</select>
</label>
);

return (
<Dialog onClose={onClose} title="Import a statement">
<div className="mt-5 grid gap-4">
<p className="m-0 text-sm text-muted">Choose a CSV export from your bank or card. It is read on this device and never uploaded.</p>
<input accept=".csv,text/csv" aria-label="Statement file" onChange={(event) => event.currentTarget.files?.[0] && void read(event.currentTarget.files[0])} type="file" />
{rows && (
<>
<div className="grid gap-3 sm:grid-cols-2">
{columnSelect('Date column', 'date')}
{columnSelect('Description column', 'description')}
{columnSelect('Amount column', 'amount')}
<label className="field-label">
Dates are written
<select onChange={(event) => setOrder(event.currentTarget.value as DateOrder)} value={order}>
{orders.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<p className="mono-sm m-0 text-faint">
{charges.length} charges read · {suggestions.length} look recurring
</p>
{suggestions.length > 0 && (
<ul className="m-0 grid list-none gap-0 p-0">
{suggestions.map((suggestion) => {
const known = tracked.has(suggestion.key);
return (
<li className="border-b border-line-soft py-2" key={suggestion.key}>
<label className="flex items-center gap-3">
<input
checked={!known && !skipped.has(suggestion.key)}
disabled={known}
onChange={(event) => {
const next = new Set(skipped);
if (event.currentTarget.checked) next.delete(suggestion.key);
else next.add(suggestion.key);
setSkipped(next);
}}
type="checkbox"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-ink">{suggestion.name}</span>
<span className="mono-sm block text-faint">
{[
`${suggestion.amount.toFixed(2)} ${cycleLabel(suggestion.repeatRule)}`,
`${suggestion.count} charges`,
known && 'already tracked in this space',
]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</label>
</li>
);
})}
</ul>
)}
<div className="grid gap-3 sm:grid-cols-2">
<label className="field-label">
Add to
<select onChange={(event) => setSpaceId(event.currentTarget.value)} value={target}>
{editable.map((space) => (
<option key={space.id} value={space.id}>
{space.name}
</option>
))}
</select>
</label>
<label className="field-label">
Currency
<input maxLength={3} onChange={(event) => setCurrency(event.currentTarget.value.toUpperCase())} value={currency} />
</label>
</div>
</>
)}
<div className="mt-1 flex justify-end gap-3">
<button className="secondary-button" onClick={onClose} type="button">
Cancel
</button>
<button
className="primary-button"
disabled={!target || chosen.length === 0 || adding.busy}
onClick={() =>
void adding
.run(async () => {
if (!/^[A-Z]{3}$/.test(currency)) throw new Error('Currencies are three-letter codes such as EUR or USD.');
for (const suggestion of chosen) {
await auth.request(`/spaces/${target}/subscriptions`, {
method: 'POST',
body: JSON.stringify({
name: suggestion.name,
amountMinor: toMinor(suggestion.amount.toFixed(2), currency),
currency,
repeatRule: suggestion.repeatRule,
startDate: suggestion.lastDate,
category: suggestion.category,
...(suggestion.cancelUrl ? { cancelUrl: suggestion.cancelUrl } : {}),
}),
});
}
onAdded();
return `Added ${chosen.length} subscription${chosen.length === 1 ? '' : 's'}.`;
}, 'Could not add the subscriptions')
.then((done) => done && onClose())
}
type="button"
>
{adding.busy ? 'Adding…' : `Add ${chosen.length} subscription${chosen.length === 1 ? '' : 's'}`}
</button>
</div>
</div>
</Dialog>
);
}
21 changes: 20 additions & 1 deletion app/frontend/src/pages/RecurringPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -39,10 +41,27 @@ export function RecurringPage() {
const spaces = useSpaces();
const summary = useResource<Summary>('/subscriptions/summary', true);
const action = useAction();
const [importing, setImporting] = useState(false);

return (
<section className="page-enter grid max-w-3xl gap-10">
<PageHeader title="Recurring" />
<PageHeader
actions={
spaces.data?.some((space) => space.role !== 'viewer') && (
<button className="secondary-button px-3 py-2 text-sm" onClick={() => setImporting(true)} type="button">
Import a statement
</button>
)
}
title="Recurring"
/>
{importing && (
<StatementImport
defaultCurrency={summary.data?.home?.currency ?? summary.data?.totals[0]?.currency ?? 'EUR'}
onAdded={summary.reload}
onClose={() => setImporting(false)}
/>
)}
{summary.error ? (
<LoadError error={summary.error} onRetry={summary.reload} />
) : !summary.data ? (
Expand Down
167 changes: 167 additions & 0 deletions app/frontend/src/statement.ts
Original file line number Diff line number Diff line change
@@ -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<string, Charge[]>();
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);
}
Loading
Loading