feat(subscriptions): cash runway per card, price changes, stale bills, unlinked payments

The page opens with how much each paying card needs by a date and where
it falls short, a 30-day calendar per card and the annual renewals ahead.
The table shows price changes, the paying card, stale subscriptions to
deactivate in place and payments that look like a subscription, L links
them.
This commit is contained in:
hh committed 2026-10-16 15:13:35 +00:00
1 parent 153a8cb191
commit 68f64893f7
10 files changed
+1425 -105

No files matched your search

+414
View File
@@ -0,0 +1,414 @@
import type { Account, Bill, Item, TransactionGroup } from "#lib/api/types.js";
import {
average,
cycleDays,
isRange,
twoCyclesAfter,
} from "#lib/components/subscriptions/bills.js";
import { addDays, addMonths, daysBetween } from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
const EARLY_SHARE = 3;
const CENT = 0.005;
export interface Currency {
code: string;
decimals: number;
symbol: string;
}
export interface Sum extends Currency {
amount: number;
}
export interface Payment extends Currency {
amount: number;
billId: string;
date: string;
description: string;
destinationId: string;
foreign: number;
foreignCode: string;
groupId: string;
journalId: string;
sourceId: string;
sourceName: string;
}
export interface Card extends Currency {
balance: number | null;
id: string;
name: string;
}
export interface Payer {
card: Card | null;
last: Payment | undefined;
manual: boolean;
}
export interface Due {
approx: boolean;
bill: Item<Bill>;
card: Card | null;
date: string;
native: Sum;
overdue: boolean;
pay: Sum;
}
export interface Need {
balance: number | null;
currency: Currency | null;
dues: Due[];
id: string;
name: string;
short: { amount: number; date: string } | null;
totals: Sum[];
}
export const HORIZONS = [
{ id: "7", key: "1", label: "7 days" },
{ id: "14", key: "2", label: "14 days" },
{ id: "month", key: "3", label: "Rest of month" },
{ id: "30", key: "4", label: "30 days" },
] as const;
export type Horizon = (typeof HORIZONS)[number]["id"];
export const UNKNOWN = "unknown";
export const STRIP_DAYS = 30;
export const RENEWAL_DAYS = 60;
export function horizonEnd(horizon: Horizon, day: string): string {
if (horizon === "month") {
return addDays(addMonths(`${day.slice(0, 7)}-01`, 1), -1);
}
return addDays(day, Number(horizon));
}
export const billCurrency = (bill: Bill): Currency => ({
code: bill.currency_code ?? "",
decimals: bill.currency_decimal_places ?? 2,
symbol: bill.currency_symbol ?? bill.currency_code ?? "",
});
export function toPayments(groups: Item<TransactionGroup>[]): Payment[] {
return groups
.flatMap((group) =>
group.attributes.transactions.map((split) => ({
amount: Math.abs(toNumber(split.amount)),
billId: split.bill_id ?? "",
code: split.currency_code ?? "",
date: split.date.slice(0, 10),
decimals: split.currency_decimal_places ?? 2,
description: split.description,
destinationId: split.destination_id ?? "",
foreign: Math.abs(toNumber(split.foreign_amount)),
foreignCode: split.foreign_currency_code ?? "",
groupId: group.id,
journalId: split.transaction_journal_id ?? "",
sourceId: split.source_id ?? "",
sourceName: split.source_name ?? "",
symbol: split.currency_symbol ?? split.currency_code ?? "",
}))
)
.sort((a, b) => b.date.localeCompare(a.date));
}
export function byBill(payments: Payment[]): Map<string, Payment[]> {
const map = new Map<string, Payment[]>();
for (const payment of payments) {
map.set(payment.billId, [...(map.get(payment.billId) ?? []), payment]);
}
return map;
}
export const toCards = (accounts: Item<Account>[]) =>
new Map<string, Card>(
accounts.map(({ id, attributes: a }) => [
id,
{
balance: toNumber(a.current_balance),
code: a.currency_code ?? "",
decimals: a.currency_decimal_places ?? 2,
id,
name: a.name,
symbol: a.currency_symbol ?? a.currency_code ?? "",
},
])
);
export function payerOf(
last: Payment | undefined,
cards: Map<string, Card>,
manual: string | null
): Payer {
const chosen = manual ? cards.get(manual) : undefined;
if (chosen) {
return { card: chosen, last, manual: true };
}
if (!last) {
return { card: null, last, manual: false };
}
const card = cards.get(last.sourceId) ?? {
balance: null,
code: last.code,
decimals: last.decimals,
id: last.sourceId,
name: last.sourceName,
symbol: last.symbol,
};
return { card, last, manual: false };
}
export function ownRates(payments: Payment[]): Map<string, number> {
const rates = new Map<string, number>();
for (const p of payments) {
if (p.foreign > 0 && p.foreignCode && p.foreignCode !== p.code) {
const forward = `${p.foreignCode}>${p.code}`;
const back = `${p.code}>${p.foreignCode}`;
rates.set(forward, rates.get(forward) ?? p.amount / p.foreign);
rates.set(back, rates.get(back) ?? p.foreign / p.amount);
}
}
return rates;
}
function rateFor(
from: string,
payer: Payer,
rates: Map<string, number>
): number | null {
const to = payer.card?.code ?? from;
if (from === to) {
return 1;
}
const { last } = payer;
if (last && last.code === to && last.foreignCode === from && last.foreign) {
return last.amount / last.foreign;
}
return rates.get(`${from}>${to}`) ?? null;
}
export function priceIn(payment: Payment, code: string): number | null {
if (payment.code === code) {
return payment.amount;
}
return payment.foreignCode === code && payment.foreign
? payment.foreign
: null;
}
function occurrences(bill: Bill, day: string) {
const paid = (bill.paid_dates ?? []).map((p) => (p.date ?? "").slice(0, 10));
const early = cycleDays(bill) / EARLY_SHARE;
const end = bill.end_date?.slice(0, 10);
return (bill.pay_dates ?? [])
.map((raw) => raw.slice(0, 10))
.filter((date) => !end || date <= end)
.filter(
(date) =>
date < day ||
!paid.some((p) => p <= date && daysBetween(p, date) < early)
)
.map((date) => ({ date: date < day ? day : date, overdue: date < day }));
}
export function dues(
bills: Item<Bill>[],
day: string,
payer: (bill: Item<Bill>) => Payer,
rates: Map<string, number>
): Due[] {
return bills
.filter((bill) => bill.attributes.active)
.flatMap((bill) => {
const native: Sum = {
...billCurrency(bill.attributes),
amount: average(bill.attributes),
};
const who = payer(bill);
const rate = rateFor(native.code, who, rates);
const pay: Sum =
rate !== null && who.card
? { ...who.card, amount: native.amount * rate }
: native;
return occurrences(bill.attributes, day).map(({ date, overdue }) => ({
approx: isRange(bill.attributes) || (rate !== null && rate !== 1),
bill,
card: who.card,
date,
native,
overdue,
pay,
}));
})
.sort((a, b) => a.date.localeCompare(b.date));
}
export function sum(entries: Sum[]): Sum[] {
const totals = new Map<string, Sum>();
for (const entry of entries) {
const total = totals.get(entry.code) ?? { ...entry, amount: 0 };
total.amount += entry.amount;
totals.set(entry.code, total);
}
return [...totals.values()];
}
function shortfall(list: Due[], balance: number | null, code: string) {
if (balance === null) {
return null;
}
let running = 0;
let date = "";
for (const due of list) {
if (due.pay.code === code) {
running += due.pay.amount;
if (!date && running > balance + CENT) {
({ date } = due);
}
}
}
return date ? { amount: running - balance, date } : null;
}
function rank(a: Need, b: Need): number {
const order = (n: Need) => {
if (n.short) {
return 0;
}
return n.id === UNKNOWN ? 2 : 1;
};
return order(a) - order(b) || a.name.localeCompare(b.name);
}
function need(
id: string,
name: string,
list: Due[],
currency: Currency | null,
balance: number | null
): Need {
return {
balance,
currency,
dues: list,
id,
name,
short: currency ? shortfall(list, balance, currency.code) : null,
totals: sum(list.map((d) => d.pay)),
};
}
function grouped(list: Due[], key: (due: Due) => string) {
const map = new Map<string, Due[]>();
for (const due of list) {
map.set(key(due), [...(map.get(key(due)) ?? []), due]);
}
return map;
}
export function byCard(list: Due[]): Need[] {
return [...grouped(list, (d) => d.card?.id ?? UNKNOWN)]
.map(([id, items]) => {
const { card } = items[0];
return card
? need(id, card.name, items, card, card.balance)
: need(id, "Card unknown", items, null, null);
})
.sort(rank);
}
export function byCurrency(list: Due[]): Need[] {
return [...grouped(list, (d) => d.pay.code)]
.map(([code, items]) => {
const paying = new Map<string, number>();
for (const { card } of items) {
if (card?.code === code && card.balance !== null) {
paying.set(card.id, card.balance);
}
}
const balance = paying.size
? [...paying.values()].reduce((total, b) => total + b, 0)
: null;
return need(code, code, items, items[0].pay, balance);
})
.sort(rank);
}
export interface Creep extends Currency {
from: number;
since: string;
to: number;
}
export function creep(bill: Bill, history: Payment[]): Creep | null {
if (isRange(bill)) {
return null;
}
const currency = billCurrency(bill);
const prices = history
.map((p) => ({ amount: priceIn(p, currency.code), date: p.date }))
.filter((p): p is { amount: number; date: string } => p.amount !== null);
const [latest] = prices;
const change = prices.findIndex(
(p) => Math.abs(p.amount - (latest?.amount ?? 0)) > CENT
);
if (!latest || change < 1) {
return null;
}
return {
...currency,
from: prices[change].amount,
since: prices[change - 1].date,
to: latest.amount,
};
}
export function staleSince(
bill: Bill,
last: Payment | undefined,
day: string
): string | null {
const from = last?.date ?? bill.date?.slice(0, 10);
if (!(bill.active && from) || twoCyclesAfter(bill, from) >= day) {
return null;
}
return last?.date ?? "";
}
export function matches(
bills: Item<Bill>[],
candidates: Payment[],
last: (bill: Item<Bill>) => Payment | undefined,
day: string
): Map<string, Payment[]> {
const found = new Map<string, Payment[]>();
const active = bills.filter((b) => b.attributes.active && last(b));
for (const payment of candidates.filter((c) => c.date <= day)) {
const fits = active
.map((bill) => {
const a = bill.attributes;
const price = priceIn(payment, a.currency_code ?? "");
const min = toNumber(a.amount_min);
const max = toNumber(a.amount_max);
const ok =
price !== null &&
last(bill)?.destinationId === payment.destinationId &&
price >= min - CENT &&
price <= max + CENT &&
payment.date >= (a.date ?? "").slice(0, 10);
return { bill, ok, width: max - min };
})
.filter((f) => f.ok)
.sort((x, y) => x.width - y.width);
const best = fits[0]?.bill;
if (best) {
found.set(best.id, [...(found.get(best.id) ?? []), payment]);
}
}
return found;
}
export const isYearly = (bill: Bill) => bill.repeat_freq === "yearly";