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

+404 -104
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import Ban from "@lucide/svelte/icons/ban";
import Pin from "@lucide/svelte/icons/pin";
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js";
import type { BasicSummary, Bill, Item } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import type { Bill, Item } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import {
byObjectGroup,
@@ -14,15 +14,11 @@
swapOrder,
type Total,
} from "#lib/components/groups/object-groups.js";
import Totals from "#lib/components/groups/Totals.svelte";
import { errorText } from "#lib/components/misc/action.svelte.js";
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
average,
billKey,
dueIn,
lastMonths,
money,
monthly,
nextExpected,
repeats,
@@ -30,7 +26,11 @@
strip,
yearly,
} from "#lib/components/subscriptions/bills.js";
import CardMenu from "#lib/components/subscriptions/CardMenu.svelte";
import CardPicker from "#lib/components/subscriptions/CardPicker.svelte";
import Money from "#lib/components/subscriptions/Money.svelte";
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
import Runway from "#lib/components/subscriptions/Runway.svelte";
import Strip from "#lib/components/subscriptions/Strip.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import {
@@ -43,14 +43,41 @@
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { addDays, today } from "#lib/dates.js";
import { formatDate, formatNumber, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { manualCards } from "#lib/subscriptions/manual.svelte.js";
import { runwayQueries } from "#lib/subscriptions/queries.js";
import {
byBill,
byCard,
byCurrency,
creep,
dues,
HORIZONS,
type Horizon,
horizonEnd,
isYearly,
matches,
ownRates,
type Payment,
payerOf,
RENEWAL_DAYS,
STRIP_DAYS,
staleSince,
toCards,
} from "#lib/subscriptions/runway.js";
import { type Targets, updateGroups } from "#lib/transactions/mass.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const day = today();
const fetchStart = `${day.slice(0, 7)}-01`;
const fetchEnd = addDays(day, RENEWAL_DAYS);
const stripEnd = addDays(day, STRIP_DAYS);
const months = $derived(lastMonths(period.end));
const billsKey = $derived(["subscriptions", period.start, period.end]);
@@ -66,19 +93,50 @@
all<Bill>("/v1/bills", { end: period.end, start: `${months[0]}-01` }),
queryKey: ["subscriptions", "history", months[0], period.end],
}));
const summary = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
api<BasicSummary>("/v1/summary/basic", {
query: { end: period.end, start: period.start },
}),
queryKey: ["subscriptions", "summary", period.start, period.end],
}));
const groups = createQuery(() => lists.objectGroups());
const upcoming = createQuery(() => runwayQueries.bills(fetchStart, fetchEnd));
const paid = createQuery(() => runwayQueries.paid());
const unlinked = createQuery(() => runwayQueries.unlinked(day));
const assets = createQuery(() => lists.accounts("asset"));
const hideInactive = $derived(
page.url.searchParams.get("inactive") === "hide"
const params = $derived(page.url.searchParams);
const hideInactive = $derived(params.get("inactive") === "hide");
const horizon = $derived<Horizon>(
HORIZONS.find((h) => h.id === params.get("horizon"))?.id ?? "7"
);
const perCurrency = $derived(params.get("by") === "currency");
const cards = $derived(
toCards((assets.data ?? []).filter((a) => a.attributes.active !== false))
);
const cardList = $derived(
[...cards.values()].sort((a, b) => a.name.localeCompare(b.name))
);
const payments = $derived(byBill(paid.data ?? []));
const rates = $derived(ownRates(paid.data ?? []));
const lastOf = (bill: Item<Bill>): Payment | undefined =>
payments.get(bill.id)?.[0];
const payer = (bill: Item<Bill>) =>
payerOf(lastOf(bill), cards, manualCards.get(bill.id));
const allDues = $derived(dues(upcoming.data ?? [], day, payer, rates));
const within = (to: string) => allDues.filter((d) => d.date <= to);
const grouped = (to: string) =>
perCurrency ? byCurrency(within(to)) : byCard(within(to));
const end = $derived(horizonEnd(horizon, day));
const needs = $derived(grouped(end));
const stripNeeds = $derived(grouped(stripEnd));
const renewals = $derived(
within(fetchEnd).filter((d) => isYearly(d.bill.attributes))
);
const runwayStatus = $derived.by(() => {
const failed = [upcoming, paid].find((q) => q.error);
if (failed?.error) {
return failed.error.message;
}
return upcoming.isPending || paid.isPending ? "Loading" : "";
});
const visible = $derived(
(bills.data ?? []).filter((b) => !hideInactive || b.attributes.active)
);
@@ -92,6 +150,20 @@
const pastCells = $derived(
new Map((history.data ?? []).map((b) => [b.id, b.attributes]))
);
const found = $derived(
matches(bills.data ?? [], unlinked.data ?? [], lastOf, day)
);
const inactiveGroup = $derived.by(() => {
const counts = new Map<string, number>();
for (const { attributes: b } of bills.data ?? []) {
const title = b.object_group_title;
if (title) {
counts.set(title, (counts.get(title) ?? 0) + (b.active ? -1 : 1));
}
}
const [best] = [...counts].sort((a, b) => b[1] - a[1]);
return best && best[1] > 0 ? best[0] : null;
});
const activeOnly = (items: Item<Bill>[]) =>
items.filter((b) => b.attributes.active).map((b) => b.attributes);
@@ -100,47 +172,31 @@
const perYear = (items: Item<Bill>[]) =>
sumByCurrency(activeOnly(items).map(yearly));
function pick(prefix: string): Total[] {
return Object.values(summary.data ?? {})
.filter((e) => e.key?.startsWith(prefix) && e.monetary_value)
.map((e) => ({
amount: Math.abs(e.monetary_value ?? 0),
code: e.currency_code ?? "",
decimals: e.currency_decimal_places ?? 2,
symbol: e.currency_symbol ?? "",
}));
}
const paid = $derived(pick("bills-paid-in-"));
const unpaid = $derived(pick("bills-unpaid-in-"));
const expected = $derived(
sumByCurrency(
activeOnly(bills.data ?? []).map((b) =>
money(
b,
average(b) *
((b.paid_dates?.length ?? 0) + dueIn(period.range, b).length)
)
)
).filter((t) => t.amount > 0)
);
let menuFor = $state<Item<Bill> | null>(null);
let armed = $state(false);
let failure = $state("");
let linking = $state<Record<string, string>>({});
const arm = new Arm();
const show = (b: Item<Bill>) => goto(`/subscriptions/show/${b.id}`);
const edit = (b: Item<Bill>) => goto(`/subscriptions/edit/${b.id}`);
function toggleInactive() {
function setParam(name: string, value: string | null) {
const url = new URL(page.url.href);
if (hideInactive) {
url.searchParams.delete("inactive");
if (value === null) {
url.searchParams.delete(name);
} else {
url.searchParams.set("inactive", "hide");
url.searchParams.set(name, value);
}
goto(url, { replace: true });
}
const toggleInactive = () =>
setParam("inactive", hideInactive ? null : "hide");
const setHorizon = (id: Horizon) =>
setParam("horizon", id === "7" ? null : id);
const toggleMode = () => setParam("by", perCurrency ? null : "currency");
async function update(b: Item<Bill>, body: Record<string, unknown>) {
failure = "";
try {
@@ -176,9 +232,92 @@
await invalidate();
}
function deactivate(b: Item<Bill> | undefined) {
if (b?.attributes.active) {
arm.press(`off:${b.id}`, () => update(b, { active: false }));
}
}
function retire(b: Item<Bill> | undefined) {
const title = inactiveGroup;
if (b && title && b.attributes.object_group_title !== title) {
arm.press(`move:${b.id}`, () =>
update(b, { active: false, object_group_title: title })
);
}
}
async function link(b: Item<Bill> | undefined) {
const list = b ? found.get(b.id) : undefined;
if (!(b && list?.length) || linking[b.id]) {
return;
}
const targets: Targets = new Map();
for (const p of list) {
const set = targets.get(p.groupId) ?? new Set<string>();
set.add(p.journalId);
targets.set(p.groupId, set);
}
const say = (text: string) => (linking = { ...linking, [b.id]: text });
const outcome = await updateGroups(
targets,
() => ({ bill_id: b.id }),
(o) => say(`Linking ${o.done} of ${o.total}`)
);
await invalidate();
say(outcome.errors.length ? `Failed: ${outcome.errors.join("; ")}` : "");
}
const pick = (b: Item<Bill> | undefined) => {
if (b?.attributes.active) {
manualCards.picking = b.id;
}
};
const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({
edit,
extra: () => [
...HORIZONS.map((h) => ({
group: "Runway",
keys: h.key,
label:
h.id === "month" ? "Needed by end of month" : `Needed in ${h.label}`,
run: () => setHorizon(h.id),
})),
{
group: "Runway",
keys: "p",
label: perCurrency ? "Group by card" : "Group by currency",
run: toggleMode,
},
{
group: "Subscriptions",
keys: "a",
label: "Set paying card",
run: () => pick(nav.current),
},
{
group: "Subscriptions",
keys: "L",
label: "Link payments found for this subscription",
run: () => link(nav.current),
},
{
group: "Subscriptions",
keys: "D",
label: "Deactivate (press twice)",
run: () => deactivate(nav.current),
},
...(inactiveGroup
? [
{
group: "Subscriptions",
keys: "M",
label: `Move to ${inactiveGroup} (press twice)`,
run: () => retire(nav.current),
},
]
: []),
{
group: "Subscriptions",
keys: "J",
@@ -211,8 +350,18 @@
});
nav.use();
const head = "px-3 py-1.5 font-semibold text-[12px] text-cladd-fg-soft";
function rowTone(cursor: boolean, stale: boolean) {
if (cursor) {
return "bg-row-cursor";
}
return stale ? "bg-warn/10 hover:bg-row-hover" : "hover:bg-row-hover";
}
const head =
"whitespace-nowrap px-3 py-1.5 font-semibold text-[12px] text-cladd-fg-soft";
const cell = "px-3 py-1 align-middle";
const action =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-6 items-center gap-1.5 rounded-cladd-sm px-2 font-semibold text-[12px]";
</script>
<PageHeader crumbs={[{ label: "Subscriptions" }]} title="Subscriptions">
@@ -223,23 +372,27 @@
{/snippet}
</PageHeader>
<div class="mb-3 grid gap-3 sm:grid-cols-3">
{#each [
{ label: "Expected this period", totals: expected },
{ label: "Paid", totals: paid },
{ label: "Still to pay", totals: unpaid },
] as stat (stat.label)}
<Box body="px-3 py-2" title={stat.label}>
<div class="text-[15px]">
<Totals
empty={summary.isPending ? "" : "Nothing"}
tone={stat.label === "Paid" ? "income" : "expense"}
totals={stat.totals}
/>
</div>
</Box>
<Runway
byCurrency={perCurrency}
cards={cardList}
{day}
{end}
{horizon}
{needs}
onhorizon={setHorizon}
onmode={toggleMode}
{renewals}
status={runwayStatus}
strip={stripNeeds}
/>
{#snippet sums(
totals: Total[]
)}
{#each totals as total (total.code)}
<div><Money {...total} /></div>
{/each}
</div>
{/snippet}
<Box body="p-0" title="Subscriptions">
{#snippet tools()}
@@ -274,10 +427,12 @@
<tr class="border-cladd-outline border-b text-left">
<th class={head}>Name</th>
<th class="{head} text-right">Amount</th>
<th class="{head} hidden lg:table-cell">Price change</th>
<th class={head}>Paid this period</th>
<th class="{head} hidden md:table-cell">Next expected match</th>
<th class="{head} hidden md:table-cell">Repeats</th>
<th class="{head} hidden xl:table-cell">
<th class="{head} hidden md:table-cell">Card</th>
<th class="{head} hidden 2xl:table-cell">Repeats</th>
<th class="{head} hidden 2xl:table-cell">
<span class="inline-flex gap-[3px]">
{#each months as month (month)}
<span class="w-2.5 text-center text-[10px]"
@@ -295,38 +450,46 @@
<tr
class="border-cladd-outline border-t bg-cladd-surface-next/60"
>
<td class="{cell} font-semibold">
<td
class="{cell} whitespace-nowrap font-semibold text-cladd-fg"
>
{section.title}
<span class="ms-1 font-normal text-cladd-fg-softer"
>{section.items.length}</span
>
</td>
<td class="{cell} text-right font-semibold">
<Totals tone="expense" totals={monthTotals} />
<td class="{cell} text-right font-semibold text-cladd-fg">
{@render sums(monthTotals)}
</td>
<td class="hidden lg:table-cell"></td>
<td class="{cell} text-[12px] text-cladd-fg-soft">
{monthTotals.length > 0 ? "a month" : ""}
</td>
<td class="hidden md:table-cell"></td>
<td class="hidden md:table-cell"></td>
<td class="hidden xl:table-cell"></td>
<td class="hidden 2xl:table-cell"></td>
<td class="hidden 2xl:table-cell"></td>
<td
class="{cell} hidden text-right font-semibold lg:table-cell"
class="{cell} hidden text-right font-semibold text-cladd-fg lg:table-cell"
>
<Totals tone="expense" totals={perYear(section.items)} />
{@render sums(perYear(section.items))}
</td>
</tr>
{#each section.items as item, j (item.id)}
{@const i = offsets[s] + j}
{@const bill = item.attributes}
{@const ends = bill.end_date?.slice(0, 10)}
{@const last = lastOf(item)}
{@const who = payer(item)}
{@const change = creep(bill, payments.get(item.id) ?? [])}
{@const stale = staleSince(bill, last, day)}
{@const extra = found.get(item.id) ?? []}
{@const armedHere = arm.key?.endsWith(`:${item.id}`)}
<tr
class="h-7 border-cladd-outline/60 border-t {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"} {bill.active
? ""
: "text-cladd-fg-softer"}"
class="h-7 border-cladd-outline/60 border-t {rowTone(
nav.cursor === i,
stale !== null
)} {bill.active ? "text-cladd-fg" : "text-cladd-fg-softer"}"
id={nav.rowId(i)}
onclick={(e) => nav.click(i, e)}
oncontextmenu={() => {
@@ -334,9 +497,9 @@
nav.cursor = i;
}}
>
<td class={cell}>
<td class="{cell} md:whitespace-nowrap">
<a
class={bill.active ? LINK : "hover:underline"}
class="hover:underline"
href="/subscriptions/show/{item.id}"
>
{#if !bill.active}
@@ -348,41 +511,90 @@
{bill.name}
</a>
</td>
<td class="{cell} whitespace-nowrap text-right">
<Amount
amount={bill.amount_min}
code={bill.currency_code}
<td class="{cell} whitespace-nowrap text-right font-medium">
{#if toNumber(bill.amount_max) !==
toNumber(bill.amount_min)}
<span
class="num me-1.5 font-normal text-[12px] text-cladd-fg-softer max-md:hidden"
>{toNumber(bill.amount_min)}–{toNumber(
bill.amount_max
)}</span
>
{/if}
<Money
amount={(toNumber(bill.amount_min) +
toNumber(bill.amount_max)) /
2}
approx={toNumber(bill.amount_max) !==
toNumber(bill.amount_min)}
decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol}
tone={bill.active ? "expense" : "neutral"}
symbol={bill.currency_symbol ?? ""}
/>
{#if bill.amount_max !== bill.amount_min}
<span class="text-cladd-fg-softer">–</span>
<Amount
amount={bill.amount_max}
decimals={bill.currency_decimal_places}
tone={bill.active ? "expense" : "neutral"}
/>
</td>
<td class="{cell} hidden whitespace-nowrap lg:table-cell">
{#if change}
<span
class="num {change.to > change.from
? "text-expense"
: "text-income"}"
>{formatNumber(change.from, change.decimals)}
→ {formatNumber(change.to, change.decimals)}
<span class="text-cladd-fg-soft"
>since {formatDate(change.since, "short")}</span
></span
>
{/if}
</td>
<td class={cell}>
<PaidStatus state={status(bill, period.range)} />
<td class="{cell} md:whitespace-nowrap">
{#if stale !== null}
<span class="text-warn"
>{#if stale}
Unpaid since
<span class="whitespace-nowrap"
>{formatDate(stale, "short")}</span
>
{:else}
Never paid
{/if}</span
>
{:else}
<PaidStatus state={status(bill, period.range)} />
{/if}
</td>
<td class="{cell} num hidden md:table-cell">
{bill.active ? formatDate(nextExpected(bill)) : ""}
</td>
<td class="{cell} hidden whitespace-nowrap md:table-cell">
{#if manualCards.picking === item.id}
<CardPicker billId={item.id} cards={cardList} />
{:else if bill.active}
{#if who.card}
<span class="text-cladd-fg-soft"
>{who.card.name}</span
>
{#if who.manual}
<Pin
aria-label="Set by hand"
class="ms-0.5 inline size-3 align-[-1px] text-cladd-fg-softer"
/>
{/if}
{:else}
<span class="text-warn">card unknown</span>
{/if}
{/if}
</td>
<td class="{cell} hidden whitespace-nowrap 2xl:table-cell">
{repeats(bill)}
{#if ends}
<span
class={ends < today()
class={ends < day
? "text-expense"
: "text-cladd-fg-soft"}
>, ends {formatDate(ends)}</span
>
{/if}
</td>
<td class="{cell} hidden xl:table-cell">
<td class="{cell} hidden 2xl:table-cell">
<Strip
cells={strip(pastCells.get(item.id) ?? bill, months)}
{months}
@@ -390,30 +602,108 @@
</td>
<td class="{cell} hidden text-right lg:table-cell">
{#if bill.active}
<Amount {...yearly(bill)} tone="expense" />
<Money {...yearly(bill)} />
{/if}
</td>
</tr>
{#if extra.length > 0 || stale !== null || armedHere}
<tr class="border-0">
<td class="px-3 pb-1.5 text-[12px]" colspan="9">
<div
class="flex w-0 min-w-full flex-wrap items-center gap-x-3 gap-y-1 ps-4"
>
{#if extra.length > 0}
<span class="text-warn">Not linked:</span>
{#each extra as p (p.journalId)}
<a
class="text-cladd-fg hover:underline"
href="/transactions/show/{p.groupId}"
><span class="num text-cladd-fg-soft"
>{formatDate(p.date, "short")}</span
>
{p.description}
<Money {...p} class="font-medium" /></a
>
{/each}
<button
class="{action} text-cladd-fg"
disabled={Boolean(linking[item.id])}
onclick={() => link(item)}
type="button"
>
Link
{extra.length === 1
? "it"
: `all ${extra.length}`}<span
class="kbd max-sm:hidden"
>L</span
>
</button>
{#if linking[item.id]}
<span
aria-live="polite"
class="text-cladd-fg-soft"
>{linking[item.id]}</span
>
{/if}
{/if}
{#if stale !== null || armedHere}
<button
class="{action} {arm.key === `off:${item.id}`
? "cladd-color-red text-cladd-primary"
: "text-cladd-fg"}"
onclick={() => deactivate(item)}
type="button"
>
{arm.key === `off:${item.id}`
? "Press D again to deactivate"
: "Deactivate"}<span class="kbd max-sm:hidden"
>D</span
>
</button>
{#if inactiveGroup}
<button
class="{action} {arm.key === `move:${item.id}`
? "cladd-color-red text-cladd-primary"
: "text-cladd-fg"}"
onclick={() => retire(item)}
type="button"
>
{arm.key === `move:${item.id}`
? `Press M again to move to ${inactiveGroup}`
: `Move to ${inactiveGroup}`}<span
class="kbd max-sm:hidden"
>M</span
>
</button>
{/if}
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
{/each}
{#if sections.length > 1}
<tfoot>
<tr class="border-cladd-outline border-t-2">
<td class="{cell} font-semibold">
<td class="{cell} font-semibold text-cladd-fg">
Total, active subscriptions
</td>
<td class="{cell} text-right font-semibold">
<Totals tone="expense" totals={perMonth(rows)} />
<td class="{cell} text-right font-semibold text-cladd-fg">
{@render sums(perMonth(rows))}
</td>
<td class="hidden lg:table-cell"></td>
<td class="{cell} text-[12px] text-cladd-fg-soft">a month</td>
<td class="hidden md:table-cell"></td>
<td class="hidden md:table-cell"></td>
<td class="hidden xl:table-cell"></td>
<td class="hidden 2xl:table-cell"></td>
<td class="hidden 2xl:table-cell"></td>
<td
class="{cell} hidden text-right font-semibold lg:table-cell"
class="{cell} hidden text-right font-semibold text-cladd-fg lg:table-cell"
>
<Totals tone="expense" totals={perYear(rows)} />
{@render sums(perYear(rows))}
</td>
</tr>
</tfoot>
@@ -426,6 +716,9 @@
{@const target = menuFor}
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
{#if target.attributes.active}
<CardMenu billId={target.id} cards={cardList} />
{/if}
<ContextMenuSub>
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
<ContextMenuSubContent class="max-h-80 w-52">
@@ -447,6 +740,13 @@
>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
onSelect={() =>
update(target, { active: !target.attributes.active })}
>{target.attributes.active
? "Deactivate"
: "Activate"}</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>