Budgets: one amount per category for every month with any month set on its own, filled from recent spending in one step and copied into the next year. Home shows what's left to spend, year pacing, insights, balances and upcoming charges. Insights covers year pacing, money in and out, budget against actual, categories by month, merchants, balances over time and tags. Subscriptions and bills are found across every account (rhythm and price matching plus a built-in catalog), confirmed with one tap, and watched for price changes, missed charges, charges after cancelling, double billing, renewals and trials. Alerts are worked out again whenever the data changes. Also: category, rule, tag and alert settings; Excel and CSV export from a small built-in writer; the AI module designed in and switched off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
6.6 KiB
TypeScript
126 lines
6.6 KiB
TypeScript
// Made-up statements for nine months of 2026, shaped like the real exports
|
|
// (Discover, Chase and Bank of America checking). Nothing here is real data.
|
|
// They carry the patterns milestone 2 has to find: subscriptions with a price
|
|
// change, a cancelled service that charges again, one billed on two cards, a
|
|
// yearly membership, variable bills, and an unusually large purchase.
|
|
|
|
function random(seed: number) {
|
|
return () => {
|
|
seed |= 0;
|
|
seed = (seed + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
const us = (iso: string) => `${iso.slice(5, 7)}/${iso.slice(8, 10)}/${iso.slice(0, 4)}`;
|
|
const day = (month: number, d: number) => `2026-${pad(month)}-${pad(d)}`;
|
|
const money = (cents: number) => (cents / 100).toFixed(2);
|
|
const quoted = (cents: number) => `"${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}"`;
|
|
|
|
interface Row {
|
|
date: string;
|
|
description: string;
|
|
/** Money out negative. */
|
|
cents: number;
|
|
category?: string;
|
|
}
|
|
|
|
function everyDay(months: number[], fn: (date: string, weekday: number) => void) {
|
|
for (const m of months) {
|
|
const last = new Date(2026, m, 0).getDate();
|
|
for (let d = 1; d <= last; d++) fn(day(m, d), new Date(2026, m - 1, d).getDay());
|
|
}
|
|
}
|
|
|
|
const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
|
|
export function discoverRows(): Row[] {
|
|
const r = random(7);
|
|
const rows: Row[] = [];
|
|
for (const m of MONTHS) {
|
|
rows.push({ date: day(m, 12), description: 'NETFLIX.COM 866-579-7172 CA', cents: m <= 6 ? -1549 : -1799, category: 'Services' });
|
|
rows.push({ date: day(m, 3), description: 'SPOTIFY USA 877-778-1161 NY', cents: -1199, category: 'Services' });
|
|
rows.push({ date: day(m, 20), description: 'HULU 877-8244858 CA', cents: -799, category: 'Services' });
|
|
rows.push({ date: day(m, 25), description: 'MICRO CENTER #045 CHARLOTTE NC', cents: -Math.round(4000 + r() * 4000), category: 'Merchandise' });
|
|
rows.push({ date: day(m, 8), description: 'JOES DINER #12 CHARLOTTE NC', cents: -Math.round(2500 + r() * 3500), category: 'Restaurants' });
|
|
rows.push({ date: day(m, 22), description: 'JOES DINER #12 CHARLOTTE NC', cents: -Math.round(2500 + r() * 3500), category: 'Restaurants' });
|
|
rows.push({ date: day(m, 28), description: 'INTERNET PAYMENT - THANK YOU', cents: 60000, category: 'Payments and Credits' });
|
|
}
|
|
everyDay(MONTHS, (date, weekday) => {
|
|
if (weekday === 6) rows.push({ date, description: 'KROGER #123 CHARLOTTE NC', cents: -Math.round(4000 + r() * 10000), category: 'Supermarkets' });
|
|
if (weekday === 2 || weekday === 4) rows.push({ date, description: 'STARBUCKS #4567 CHARLOTTE NC', cents: -545, category: 'Restaurants' });
|
|
});
|
|
rows.push({ date: day(3, 10), description: 'COSTCO *ANNUAL RENEWAL 800-774-2678 WA', cents: -6500, category: 'Warehouse Clubs' });
|
|
rows.push({ date: day(9, 15), description: 'BEST BUY #321 CHARLOTTE NC', cents: -48000, category: 'Merchandise' });
|
|
return rows.sort((a, b) => a.date.localeCompare(b.date));
|
|
}
|
|
|
|
export function chaseRows(): Row[] {
|
|
const r = random(11);
|
|
const rows: Row[] = [];
|
|
everyDay(MONTHS, (date, weekday) => {
|
|
if (weekday === 1) rows.push({ date, description: 'SHELL OIL 57444 CHARLOTTE NC', cents: -Math.round(3000 + r() * 2500), category: 'Gas' });
|
|
if (weekday === 5 && r() < 0.6) rows.push({ date, description: `AMAZON MKTPL*${Math.floor(r() * 1e9).toString(36).toUpperCase()}`, cents: -Math.round(1500 + r() * 6000), category: 'Shopping' });
|
|
});
|
|
for (const m of MONTHS) rows.push({ date: day(m, 27), description: 'AUTOMATIC PAYMENT - THANK', cents: 40000, category: '' });
|
|
rows.push({ date: day(8, 5), description: 'DISNEY PLUS 888-905-7888 CA', cents: -1599, category: 'Entertainment' });
|
|
rows.push({ date: day(9, 5), description: 'DISNEY PLUS 888-905-7888 CA', cents: -1599, category: 'Entertainment' });
|
|
// The same Netflix plan, accidentally on a second card.
|
|
rows.push({ date: day(9, 14), description: 'NETFLIX.COM 866-579-7172 CA', cents: -1799, category: 'Entertainment' });
|
|
return rows.sort((a, b) => a.date.localeCompare(b.date));
|
|
}
|
|
|
|
export function checkingRows(): Row[] {
|
|
const r = random(23);
|
|
const rows: Row[] = [];
|
|
for (let d = new Date(2026, 0, 9); d.getMonth() < 9; d.setDate(d.getDate() + 14)) {
|
|
rows.push({ date: d.toLocaleDateString('en-CA'), description: 'ACME CORP DES:PAYROLL ID:XXXXX12345 INDN:DOE,JANE CO ID:XXXXX12345 PPD', cents: 245000 });
|
|
}
|
|
for (const m of MONTHS) {
|
|
rows.push({ date: day(m, 1), description: 'ROCKET MORTGAGE DES:MTG PYMT ID:XXXXX77777 INDN:JANE DOE CO ID:XXXXX77777 PPD', cents: -185000 });
|
|
rows.push({ date: day(m, 22), description: 'DUKE ENERGY DES:DUKEENERGY ID:XXXXX33333 INDN:JANE DOE CO ID:XXXXX33333 WEB', cents: -Math.round(9500 + r() * 8500) });
|
|
rows.push({ date: day(m, 28), description: 'DISCOVER DES:E-PAYMENT ID:XXXXX99999 INDN:JANE DOE CO ID:XXXXX99999 WEB', cents: -60000 });
|
|
rows.push({ date: day(m, 27), description: 'CHASE CREDIT CRD DES:EPAY ID:XXXXX55555 INDN:JANE DOE CO ID:XXXXX55555 WEB', cents: -40000 });
|
|
}
|
|
return rows.sort((a, b) => a.date.localeCompare(b.date));
|
|
}
|
|
|
|
export function discoverCsv(rows = discoverRows()): string {
|
|
return ['Trans. Date,Post Date,Description,Amount,Category', ...rows.map((x) => `${us(x.date)},${us(x.date)},${x.description},${money(-x.cents)},${x.category ?? ''}`)].join('\n') + '\n';
|
|
}
|
|
|
|
export function chaseCsv(rows = chaseRows()): string {
|
|
return (
|
|
[
|
|
'Transaction Date,Post Date,Description,Category,Type,Amount,Memo',
|
|
...rows.map((x) => `${us(x.date)},${us(x.date)},${x.description},${x.category ?? ''},${x.cents > 0 ? 'Payment' : 'Sale'},${money(x.cents)},`),
|
|
].join('\n') + '\n'
|
|
);
|
|
}
|
|
|
|
export function checkingCsv(rows = checkingRows(), opening = 500000): string {
|
|
let balance = opening;
|
|
const lines = rows.map((x) => {
|
|
balance += x.cents;
|
|
return `${us(x.date)},"${x.description}",${quoted(x.cents)},${quoted(balance)}`;
|
|
});
|
|
const credits = rows.filter((x) => x.cents > 0).reduce((a, x) => a + x.cents, 0);
|
|
const debits = rows.filter((x) => x.cents < 0).reduce((a, x) => a + x.cents, 0);
|
|
return (
|
|
[
|
|
'Description,,Summary Amt.',
|
|
`Beginning balance as of 01/01/2026,,${quoted(opening)}`,
|
|
`Total credits,,${quoted(credits)}`,
|
|
`Total debits,,${quoted(debits)}`,
|
|
`Ending balance as of 09/30/2026,,${quoted(balance)}`,
|
|
'',
|
|
'Date,Description,Amount,Running Bal.',
|
|
`01/01/2026,Beginning balance as of 01/01/2026,,${quoted(opening)}`,
|
|
...lines,
|
|
].join('\n') + '\n'
|
|
);
|
|
}
|