Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 5af1a249bbbef1db1b4c5ec8
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/subs.ts
// The subscription model and all the counting: billing cycles, next charge dates, monthly and yearly
// totals, what is coming up, merging two copies, and CSV in and out. Amounts are integers in the
// minor unit of the person's currency (cents for USD), so totals never pick up rounding errors.
import { addDays, addMonths, dayKey, dayNum, isDayKey, monthsBetween } from "./dates";
import { fromMinor, toMinor } from "./money";
export type Unit = "day" | "week" | "month" | "year";
export type Category =
| "video" | "music" | "software" | "storage" | "news" | "games" | "health" | "learning"
| "food" | "shopping" | "money" | "work" | "family" | "home" | "other";
export const CATEGORIES: Record<Category, string> = {
video: "TV and video",
music: "Music and audio",
software: "Apps and software",
storage: "Cloud storage",
news: "News and reading",
games: "Games",
health: "Health and fitness",
learning: "Learning",
food: "Food and delivery",
shopping: "Shopping and memberships",
money: "Money",
work: "Work tools",
family: "Family",
home: "Phone, internet and home",
other: "Other",
};
export interface Sub {
id: string;
name: string;
url: string;
amount: number; // minor units, charged every `every` `unit`s
unit: Unit;
every: number;
/** The next renewal (YYYY-MM-DD). For a free trial it is the day the trial ends and the first charge happens. */
next: string;
category: Category;
method: string; // a label like "Visa ending 4242"
notes: string;
trial: boolean;
unused: boolean; // the person marked it as something they barely use
cancelled: string; // "" or the day it was marked cancelled
at: number; // last change, ms, for merging copies from two devices
deleted?: boolean;
}
export const MAX_NAME = 80;
export const MAX_NOTES = 1000;
export const MAX_METHOD = 60;
export const MAX_URL = 300;
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function randomId(length = 14): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
const DAYS_PER: Record<Unit, number> = { day: 1, week: 7, month: 365.2425 / 12, year: 365.2425 };
/** Own keys only, so a link or file naming "__proto__" or "toString" is not a unit or category. */
export const isUnit = (v: unknown): v is Unit => typeof v === "string" && Object.prototype.hasOwnProperty.call(DAYS_PER, v);
export const isCategory = (v: unknown): v is Category => typeof v === "string" && Object.prototype.hasOwnProperty.call(CATEGORIES, v);
/** What it costs per month on average, in minor units (not rounded). */
export const perMonth = (s: Pick<Sub, "amount" | "unit" | "every">) => (s.amount * DAYS_PER.month) / (DAYS_PER[s.unit] * Math.max(1, s.every));
export const live = (s: Sub) => !s.deleted;
export const active = (s: Sub) => !s.deleted && !s.cancelled;
/** A free trial that has not ended yet (it ends on `next`, the day of the first charge). */
export const onTrial = (s: Sub, today = dayKey()) => s.trial && active(s) && s.next >= today;
/** The k-th charge from the stored date (k = 0 is `next` itself). */
function nth(s: Sub, k: number): string {
const step = Math.max(1, s.every);
if (s.unit === "day") return addDays(s.next, k * step);
if (s.unit === "week") return addDays(s.next, k * step * 7);
return addMonths(s.next, k * step * (s.unit === "year" ? 12 : 1));
}
/** The next charge on or after `from`. */
export function nextCharge(s: Sub, from = dayKey()): string {
if (s.next >= from) return s.next;
const step = Math.max(1, s.every);
let k: number;
if (s.unit === "day" || s.unit === "week") {
const days = step * (s.unit === "week" ? 7 : 1);
k = Math.ceil((dayNum(from) - dayNum(s.next)) / days);
} else {
k = Math.max(0, Math.floor(monthsBetween(s.next, from) / (step * (s.unit === "year" ? 12 : 1))));
}
while (nth(s, k) < from) k++;
return nth(s, k);
}
/** Every charge from `from` to `to` (both included). */
export function chargesBetween(s: Sub, from: string, to: string): string[] {
const out: string[] = [];
let d = nextCharge(s, from);
for (let i = 0; d <= to && i < 400; i++) {
out.push(d);
d = nextCharge(s, addDays(d, 1));
}
return out;
}
export function cycleLabel(s: Pick<Sub, "unit" | "every">): string {
if (s.every <= 1) return { day: "a day", week: "a week", month: "a month", year: "a year" }[s.unit];
return `every ${s.every} ${s.unit}s`;
}
export function cycleWord(s: Pick<Sub, "unit" | "every">): string {
if (s.every <= 1) return { day: "Daily", week: "Weekly", month: "Monthly", year: "Yearly" }[s.unit];
return `Every ${s.every} ${s.unit}s`;
}
export interface Totals {
month: number; // paying now, per month (minor units, rounded)
year: number;
count: number;
trialMonth: number; // what active trials will add per month once they end
trialCount: number;
byCategory: { category: Category; month: number; count: number }[];
}
export function totals(subs: Sub[], today = dayKey()): Totals {
let month = 0;
let trialMonth = 0;
let count = 0;
let trialCount = 0;
const cats = new Map<Category, { month: number; count: number }>();
for (const s of subs) {
if (!active(s)) continue;
const m = perMonth(s);
if (onTrial(s, today)) { trialMonth += m; trialCount++; continue; }
month += m;
count++;
const c = cats.get(s.category) || { month: 0, count: 0 };
c.month += m;
c.count++;
cats.set(s.category, c);
}
return {
month: Math.round(month),
year: Math.round(month * 12),
count,
trialMonth: Math.round(trialMonth),
trialCount,
byCategory: [...cats].map(([category, c]) => ({ category, month: Math.round(c.month), count: c.count })).sort((a, b) => b.month - a.month),
};
}
export interface Upcoming { sub: Sub; date: string; trialEnds: boolean }
/** Charges in the next `days` days, soonest first. A trial's end is its first charge. */
export function upcoming(subs: Sub[], days = 30, today = dayKey()): Upcoming[] {
const to = addDays(today, days);
const out: Upcoming[] = [];
for (const s of subs) {
if (!active(s)) continue;
for (const date of chargesBetween(s, today, to)) out.push({ sub: s, date, trialEnds: s.trial && date === s.next });
}
return out.sort((a, b) => a.date.localeCompare(b.date) || b.sub.amount - a.sub.amount);
}
/** The newer copy wins; a deletion wins once it is the newer change. */
export const mergeSub = (a: Sub, b: Sub): Sub => (a.at >= b.at ? a : b);
/** A deleted subscription keeps only its id and time, so another device does not bring it back. */
export const tombstone = (s: Sub): Sub => ({ ...blank(), id: s.id, name: "", next: s.next, at: Date.now(), deleted: true });
export function blank(today = dayKey()): Sub {
return { id: randomId(), name: "", url: "", amount: 0, unit: "month", every: 1, next: addMonths(today, 1), category: "other", method: "", notes: "", trial: false, unused: false, cancelled: "", at: Date.now() };
}
/** A sentence saying what is wrong with a subscription, or null when it can be saved. */
export function problem(s: Sub): string | null {
if (!s.name.trim()) return "Give it a name.";
if (s.name.length > MAX_NAME) return `Keep the name under ${MAX_NAME} characters.`;
if (!Number.isFinite(s.amount) || s.amount < 0) return "Enter the price, like 9.99.";
if (!Number.isSafeInteger(s.amount)) return "That price is too large.";
if (!isUnit(s.unit)) return "Pick how often it renews.";
if (!Number.isInteger(s.every) || s.every < 1 || s.every > 365) return "Repeat every 1 to 365.";
if (!isCategory(s.category)) return "Pick a category.";
if (!isDayKey(s.next)) return s.trial ? "Pick the day the trial ends." : "Pick the next renewal date.";
if (/\d[\d\s-]{11,}\d/.test(s.method)) return "That looks like a card number. Use a label like \"Visa ending 4242\" instead.";
if (s.method.length > MAX_METHOD) return `Keep the payment label under ${MAX_METHOD} characters.`;
if (s.url && !/^https?:\/\/\S+\.\S+$/.test(s.url)) return "The website should start with https://";
if (s.url.length > MAX_URL) return "That website address is too long.";
if (s.notes.length > MAX_NOTES) return `Keep notes under ${MAX_NOTES} characters.`;
return null;
}
// CSV. The export opens in any spreadsheet; the import reads it back, and also reads files from
// other trackers or a spreadsheet when their columns have names like name, price, billing and date.
const HEAD = ["name", "price", "currency", "billing", "next_renewal", "category", "payment_method", "free_trial", "barely_use", "cancelled_on", "website", "notes"];
export function toCsv(subs: Sub[], currency: string): string {
// A leading = + - or @ would run as a formula in a spreadsheet, so it gets a ' in front (read back by fromCsv).
const q = (raw: string) => {
const v = /^[=+\-@]/.test(raw) ? `'${raw}` : raw;
return /[",\n\r]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
};
const rows = [HEAD];
for (const s of subs) {
if (s.deleted) continue;
rows.push([s.name, fromMinor(s.amount, currency), currency, cycleWord(s).toLowerCase(), s.next, CATEGORIES[s.category], s.method, s.trial ? "yes" : "", s.unused ? "yes" : "", s.cancelled, s.url, s.notes]);
}
return rows.map((r) => r.map(q).join(",")).join("\r\n") + "\r\n";
}
/** RFC 4180 CSV (quotes, commas, semicolons or tabs, CRLF). */
export function parseCsv(text: string): string[][] {
const first = text.split(/\r?\n/, 1)[0] || "";
const count = (c: string) => first.split(c).length - 1;
const delim = count(";") > count(",") ? ";" : count("\t") > count(",") ? "\t" : ",";
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') { cell += '"'; i++; }
else if (c === '"') quoted = false;
else cell += c;
} else if (c === '"') quoted = true;
else if (c === delim) { row.push(cell); cell = ""; }
else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(cell); cell = "";
if (row.some((x) => x.trim())) rows.push(row);
row = [];
} else cell += c;
}
row.push(cell);
if (row.some((x) => x.trim())) rows.push(row);
return rows.map((r) => r.map((x) => x.trim().replace(/^/, "")));
}
const COLUMNS: Record<string, string[]> = {
name: ["name", "service", "subscription", "title", "merchant", "app"],
price: ["price", "amount", "cost", "fee"],
billing: ["billing", "cycle", "billing cycle", "frequency", "period", "interval", "renews every", "plan"],
next: ["next renewal", "next_renewal", "renewal", "renewal date", "next payment", "next payment date", "next billing date", "next charge", "due date", "next due", "date", "renews"],
category: ["category", "type", "group"],
method: ["payment method", "payment_method", "payment", "method", "card", "account"],
trial: ["free trial", "free_trial", "trial"],
unused: ["barely use", "barely_use", "unused"],
cancelled: ["cancelled on", "cancelled_on", "canceled on", "cancelled", "canceled"],
url: ["website", "url", "link", "site"],
notes: ["notes", "note", "comment", "comments", "description"],
};
/** "monthly", "every 3 months", "annual", "quarterly", "biweekly"... Null when it cannot tell. */
export function parseBilling(raw: string): { unit: Unit; every: number } | null {
const t = raw.toLowerCase().trim();
if (!t) return null;
const simple: Record<string, [Unit, number]> = {
daily: ["day", 1], day: ["day", 1], weekly: ["week", 1], week: ["week", 1], biweekly: ["week", 2], "bi-weekly": ["week", 2], fortnightly: ["week", 2],
monthly: ["month", 1], month: ["month", 1], mo: ["month", 1], quarterly: ["month", 3], quarter: ["month", 3],
"semi-annual": ["month", 6], "semi-annually": ["month", 6], semiannual: ["month", 6], "half-yearly": ["month", 6],
yearly: ["year", 1], year: ["year", 1], annual: ["year", 1], annually: ["year", 1], yr: ["year", 1],
};
if (simple[t]) return { unit: simple[t][0], every: simple[t][1] };
const m = t.match(/(?:every\s+)?(\d+)\s*(day|week|month|year)s?/);
if (m) return { unit: m[2] as Unit, every: Math.min(365, Math.max(1, Number(m[1]))) };
return null;
}
/** Turns "2026-11-03", "11/03/2026" or "03.11.2026" into YYYY-MM-DD (month first when it cannot tell, for US exports). */
export function parseDate(raw: string, dayFirst: boolean): string | null {
const s = raw.trim().replace(/[T\s].*$/, "");
const iso = s.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
const other = s.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](\d{2,4})$/);
let y: string, m: string, d: string;
if (iso) [, y, m, d] = iso;
else if (other) {
const [, a, b, c] = other;
y = c.length === 2 ? `20${c}` : c;
const first = Number(a) > 12 || (dayFirst && Number(b) <= 12);
[m, d] = first ? [b, a] : [a, b];
} else return null;
const out = `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
return isDayKey(out) ? out : null;
}
const yes = (v: string) => /^(y|yes|true|1|x)$/i.test(v.trim());
function categoryOf(raw: string): Category | null {
const t = raw.toLowerCase().trim();
if (!t) return null;
for (const [k, label] of Object.entries(CATEGORIES)) if (k === t || label.toLowerCase() === t) return k as Category;
const hints: [RegExp, Category][] = [
[/stream|video|tv|movie/, "video"], [/music|audio|podcast|radio/, "music"], [/storage|cloud|backup/, "storage"],
[/news|read|book|magazine/, "news"], [/game|gaming/, "games"], [/health|fit|gym|sport|medit/, "health"],
[/learn|course|educ|language/, "learning"], [/food|meal|deliver|grocer/, "food"], [/shop|member/, "shopping"],
[/financ|money|bank|budget|insur/, "money"], [/work|business|office|product/, "work"], [/famil|kid|baby/, "family"],
[/phone|internet|mobile|utilit|home|electric/, "home"], [/software|app|ai|tool/, "software"],
];
for (const [re, c] of hints) if (re.test(t)) return c;
return "other";
}
export interface ImportResult { subs: Sub[]; skipped: number; undated: number; note: string | null }
/** Reads a CSV into subscriptions. `guess` fills in the category and website for known services. */
export function fromCsv(text: string, currency: string, guess: (name: string) => { category: Category; url: string } | null, today = dayKey()): ImportResult {
const rows = parseCsv(text);
if (rows.length < 2) return { subs: [], skipped: 0, undated: 0, note: "That file has no rows under a header." };
const head = rows[0].map((h) => h.toLowerCase().replace(/[_]+/g, " ").replace(/\s+/g, " ").trim());
const col: Record<string, number> = {};
for (const [key, names] of Object.entries(COLUMNS)) {
const i = head.findIndex((h) => names.includes(h) || names.includes(h.replace(/ /g, "_")));
if (i >= 0) col[key] = i;
}
if (col.name === undefined || col.price === undefined) {
return { subs: [], skipped: rows.length - 1, undated: 0, note: "Subs needs a column for the name and one for the price (named, for example, name and price)." };
}
const get = (r: string[], k: string) => (col[k] === undefined ? "" : (r[col[k]] ?? "").replace(/^'(?=[=+\-@])/, ""));
const dates = rows.slice(1).map((r) => get(r, "next"));
const dayFirst = dates.some((v) => { const m = v.match(/^(\d{1,2})[-/.](\d{1,2})[-/.]/); return Boolean(m && Number(m[1]) > 12); });
const out: Sub[] = [];
let skipped = 0;
let undated = 0;
for (const r of rows.slice(1)) {
const name = get(r, "name").slice(0, MAX_NAME);
const amount = toMinor(get(r, "price").replace(/[^\d.,]/g, "").replace(/,(?=\d{3}\b)/g, "").replace(",", "."), currency);
if (!name || !Number.isFinite(amount)) { skipped++; continue; }
const billing = parseBilling(get(r, "billing")) || { unit: "month" as Unit, every: 1 };
const known = guess(name);
const s: Sub = {
...blank(today),
name,
amount,
...billing,
category: categoryOf(get(r, "category")) || known?.category || "other",
method: get(r, "method").slice(0, MAX_METHOD),
trial: yes(get(r, "trial")),
unused: yes(get(r, "unused")),
cancelled: parseDate(get(r, "cancelled"), dayFirst) || "",
url: /^https?:\/\//.test(get(r, "url")) ? get(r, "url").slice(0, MAX_URL) : known?.url || "",
notes: get(r, "notes").slice(0, MAX_NOTES),
};
const next = parseDate(get(r, "next"), dayFirst);
if (next) s.next = next;
else { s.next = nth({ ...s, next: today }, 1); undated++; }
if (problem(s)) { skipped++; continue; }
out.push(s);
}
return { subs: out, skipped, undated, note: null };
}
/** Days until a date, from today (negative when past). */
export const daysUntil = (key: string, today = dayKey()) => dayNum(key) - dayNum(today);
Change history
Point store links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync