subs-free.openapps.xyz

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.
Owned app · rev 1ae4feb2bebf
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.
← All files
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
1ae4feb2be · 18 hours ago
Bind agent address in policy
99bbab3f0c · 19 hours ago
Use OpenApps service URLs and add contribution strip
781c3c23e5 · 19 hours ago
Bounded source sync
048e3257a5 · 1 day ago
Bounded source sync
7a358a3be6 · 1 day ago
Bounded source sync
0c833fcb0e · 1 day ago