Milestone 2: the full picture (20261007.12)

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>
This commit is contained in:
2026-10-08 10:59:17 -04:00
parent c3cf0f3eb8
commit 4ecd64132b
62 changed files with 9977 additions and 378 deletions

View File

@@ -178,6 +178,8 @@ export interface Transaction {
source: 'import' | 'manual';
bankCategory: string | null;
importId: number | null;
/** The subscription or bill this charge belongs to, if any. */
subscriptionId: number | null;
}
export interface ReviewItem extends Transaction {
@@ -253,3 +255,213 @@ export function formatCents(cents: number, options: { sign?: boolean; whole?: bo
if (cents < 0) return `−${text}`;
return options.sign && cents > 0 ? `+${text}` : text;
}
// ---------------------------------------------------------------------------
// Milestone 2: the full picture. Spending and budgets are positive numbers of
// cents; refunds reduce spending in the category they belong to.
export type Cadence = 'weekly' | 'monthly' | 'quarterly' | 'yearly';
export type SubscriptionKind = 'subscription' | 'bill';
export type SubscriptionStatus = 'suggested' | 'active' | 'trial' | 'paused' | 'cancelled' | 'dismissed';
export type Severity = 'info' | 'good' | 'warn' | 'bad';
export type BudgetScope = 'month' | 'rest' | 'year';
export const CADENCE_LABEL: Record<Cadence, string> = {
weekly: 'Every week',
monthly: 'Every month',
quarterly: 'Every 3 months',
yearly: 'Every year',
};
export const CADENCE_SHORT: Record<Cadence, string> = { weekly: '/wk', monthly: '/mo', quarterly: '/qtr', yearly: '/yr' };
export const SUBSCRIPTION_STATUS_LABEL: Record<SubscriptionStatus, string> = {
suggested: 'Found',
active: 'Active',
trial: 'Free trial',
paused: 'Paused',
cancelled: 'Cancelled',
dismissed: 'Not a subscription',
};
/** What a charge costs per month on average: a yearly plan counts as one twelfth. */
export function monthlyCost(cents: number, cadence: Cadence): number {
if (cadence === 'weekly') return Math.round((cents * 52) / 12);
if (cadence === 'quarterly') return Math.round(cents / 3);
if (cadence === 'yearly') return Math.round(cents / 12);
return cents;
}
/** "2026-10" plus n months. */
export function addMonths(month: string, n: number): string {
const [y, m] = month.split('-').map(Number);
const d = new Date(y!, m! - 1 + n, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
export function daysInMonth(month: string): number {
const [y, m] = month.split('-').map(Number);
return new Date(y!, m!, 0).getDate();
}
export function monthName(month: string, style: 'long' | 'short' = 'long', withYear = false): string {
const [y, m] = month.split('-').map(Number);
return new Date(y!, m! - 1, 1).toLocaleDateString('en-US', { month: style, ...(withYear ? { year: 'numeric' } : {}) });
}
export interface BudgetLine {
categoryId: number;
/** The amount for every month of the year, if one is set. */
monthly: number | null;
/** Amounts set for one particular month, January first; null where none is set. */
months: (number | null)[];
}
export interface BudgetYear {
year: number;
items: BudgetLine[];
}
export interface CategoryMonths {
categoryId: number;
/** Money spent (or, for income categories, received) each month. */
actual: number[];
budget: number[];
}
export interface MonthsReport {
months: string[];
categories: CategoryMonths[];
/** Transactions without a category yet. */
unfiled: { out: number[]; in: number[] };
totals: { spent: number[]; income: number[]; budgetSpend: number[]; budgetIncome: number[] };
/** The newest transaction date, which is how far the statements reach. */
dataThrough: string | null;
}
export interface Overview {
month: string;
/** True when this is the calendar month we're in; false when Home shows the latest month with data. */
isCurrent: boolean;
daysLeft: number;
dataThrough: string | null;
spent: number;
income: number;
budgetSpend: number;
budgetIncome: number;
hasBudgets: boolean;
/** Spending this month in categories without a budget, including what isn't filed yet. */
unbudgeted: number;
categories: { categoryId: number; spent: number; budget: number }[];
year: {
year: number;
spent: number;
budgetSoFar: number;
budgetYear: number;
/** Spending projected to December 31 at the current pace. */
projected: number;
income: number;
};
toReview: number;
}
export interface Subscription {
id: number;
name: string;
kind: SubscriptionKind;
merchant: string;
aliases: string[];
cadence: Cadence;
expectedCents: number;
monthlyCents: number;
accountId: number | null;
categoryId: number | null;
status: SubscriptionStatus;
confidence: 'likely' | 'confident';
source: 'detected' | 'catalog' | 'manual';
trialEndsOn: string | null;
cancelledOn: string | null;
firstChargeOn: string | null;
lastChargeOn: string | null;
nextExpectedOn: string | null;
manageUrl: string | null;
note: string | null;
alertsOn: boolean;
chargeCount: number;
}
export interface SubscriptionDetail extends Subscription {
charges: { id: number; date: string; amountCents: number; accountId: number }[];
/** Price steps, oldest first: "$10.99, then $11.99 from March". */
priceHistory: { from: string; amountCents: number }[];
}
export interface AlertItem {
id: number;
kind: string;
area: 'money' | 'subscriptions';
severity: Severity;
title: string;
body: string | null;
link: string | null;
createdAt: string;
read: boolean;
}
export interface AlertSettings {
/** A subscription's charge counts as a price change past this percentage... */
priceChangePct: number;
/** ...and this many cents, whichever is larger. */
priceChangeMinCents: number;
/** A category gets a heads-up at this share of its budget. */
budgetWarnPct: number;
/** A purchase is unusual at this many times the category's typical purchase. */
unusualMultiple: number;
}
export interface MerchantStat {
merchant: string;
spent: number;
count: number;
lastDate: string;
categoryId: number | null;
}
export interface BalanceSeries {
months: string[];
accounts: { accountId: number; balances: (number | null)[] }[];
}
export interface TagReport {
tag: string;
spent: number;
income: number;
count: number;
byCategory: { categoryId: number | null; cents: number }[];
}
export interface Rule {
id: number;
merchant: string;
categoryId: number | null;
renameTo: string | null;
source: 'learned' | 'manual';
hits: number;
createdAt: string;
lastUsedAt: string | null;
}
export interface TagInfo {
id: number;
name: string;
count: number;
}
export type AiFeature = 'categorize' | 'insights' | 'assistant' | 'agents';
export interface AiStatus {
/** Whether this server has an AI provider configured at all. */
providerConfigured: boolean;
provider: string | null;
features: { key: AiFeature; label: string; description: string; enabled: boolean }[];
}