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:
1 parent
153a8cb191
commit
68f64893f7
10 files changed
+1425
-105
No files matched your search
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { addDays, parse } from "#lib/dates.js";
|
||||
import { compactMoney, currentLocale, formatDate } from "#lib/format.js";
|
||||
import { type Need, STRIP_DAYS, UNKNOWN } from "#lib/subscriptions/runway.js";
|
||||
import Money from "./Money.svelte";
|
||||
|
||||
let { needs, day, end }: { needs: Need[]; day: string; end: string } =
|
||||
$props();
|
||||
|
||||
const THOUSAND = 1000;
|
||||
const SUNDAY = 0;
|
||||
const SATURDAY = 6;
|
||||
|
||||
const days = $derived(
|
||||
Array.from({ length: STRIP_DAYS + 1 }, (_, i) => addDays(day, i))
|
||||
);
|
||||
|
||||
const rows = $derived(
|
||||
needs.map((need) => {
|
||||
const main = need.currency;
|
||||
let running = 0;
|
||||
const cells = days.map((date) => {
|
||||
const list = need.dues.filter((d) => d.date === date);
|
||||
const code = main?.code ?? list[0]?.pay.code;
|
||||
const amount = list
|
||||
.filter((d) => d.pay.code === code)
|
||||
.reduce((acc, d) => acc + d.pay.amount, 0);
|
||||
running += amount;
|
||||
return {
|
||||
amount,
|
||||
count: list.length,
|
||||
date,
|
||||
fill: need.balance ? Math.min(1, running / need.balance) : 0,
|
||||
label: compactMoney(amount < THOUSAND ? Math.round(amount) : amount),
|
||||
names: list.map((d) => d.bill.attributes.name).join(", "),
|
||||
over: need.short !== null && date >= need.short.date,
|
||||
};
|
||||
});
|
||||
return { cells, need };
|
||||
})
|
||||
);
|
||||
|
||||
function dayTone(date: string, i: number) {
|
||||
if (i === 0) {
|
||||
return "font-semibold text-cladd-fg";
|
||||
}
|
||||
return [SUNDAY, SATURDAY].includes(parse(date).getDay())
|
||||
? "text-cladd-fg-softest"
|
||||
: "text-cladd-fg-softer";
|
||||
}
|
||||
const month = $derived(
|
||||
new Intl.DateTimeFormat(currentLocale(), { month: "short" })
|
||||
);
|
||||
const monthMark = (date: string, i: number) =>
|
||||
i === 0 || date.endsWith("-01") ? month.format(parse(date)) : "";
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid min-w-[60rem] pb-2 text-[11px]"
|
||||
style:grid-template-columns="minmax(8rem, 11rem) repeat({days.length}, minmax(1.5rem, 1fr)) minmax(6.5rem, 8rem)"
|
||||
>
|
||||
<div class="sticky left-0 bg-cladd-surface"></div>
|
||||
{#each days as date, i (date)}
|
||||
<div
|
||||
class="ps-0.5 pt-2 font-semibold text-cladd-fg-soft {date > end
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
>
|
||||
{monthMark(date, i)}
|
||||
</div>
|
||||
{/each}
|
||||
<div></div>
|
||||
<div class="sticky left-0 bg-cladd-surface"></div>
|
||||
{#each days as date, i (date)}
|
||||
<div
|
||||
class="num pb-1 text-center {dayTone(date, i)} {date > end
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
>
|
||||
{Number(date.slice(8))}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="pe-3 pb-1 text-right text-cladd-fg-softer">
|
||||
by {formatDate(days.at(-1), "short")}
|
||||
</div>
|
||||
{#each rows as row (row.need.id)}
|
||||
<div
|
||||
class="sticky left-0 z-10 flex h-7 items-center truncate border-cladd-outline/60 border-t bg-cladd-surface ps-3 pe-2 font-medium text-[12px] {row
|
||||
.need.id === UNKNOWN
|
||||
? "text-warn"
|
||||
: "text-cladd-fg"}"
|
||||
>
|
||||
<span class="truncate">{row.need.name}</span>
|
||||
</div>
|
||||
{#each row.cells as cell (cell.date)}
|
||||
<div
|
||||
class="relative h-7 border-cladd-outline/60 border-t border-l {cell.date >
|
||||
end
|
||||
? "opacity-50"
|
||||
: ""} {cell.over && cell.date === row.need.short?.date
|
||||
? "outline-2 outline-expense -outline-offset-2"
|
||||
: ""}"
|
||||
title={cell.names
|
||||
? `${formatDate(cell.date, "day")}: ${cell.names}`
|
||||
: undefined}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 {cell.over
|
||||
? "bg-expense/25"
|
||||
: "bg-brand/15"}"
|
||||
style:height="{cell.over ? 100 : cell.fill * 100}%"
|
||||
></div>
|
||||
{#if cell.count}
|
||||
<span
|
||||
class="num relative flex h-full items-center justify-center font-semibold {cell.over
|
||||
? "text-expense"
|
||||
: "text-cladd-fg"}"
|
||||
>{cell.label}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div
|
||||
class="flex h-7 items-center justify-end gap-1 whitespace-nowrap border-cladd-outline/60 border-t border-l pe-3 font-semibold text-[12px] {row
|
||||
.need.short
|
||||
? "text-expense"
|
||||
: "text-cladd-fg"}"
|
||||
>
|
||||
{#each row.need.totals as total, i (total.code)}
|
||||
{i > 0 ? "+" : ""}<Money {...total} />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "#lib/components/ui/context-menu/index.js";
|
||||
import { manualCards } from "#lib/subscriptions/manual.svelte.js";
|
||||
import type { Card } from "#lib/subscriptions/runway.js";
|
||||
|
||||
let { billId, cards }: { billId: string; cards: Card[] } = $props();
|
||||
|
||||
const chosen = $derived(manualCards.get(billId));
|
||||
</script>
|
||||
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Paying card</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent class="max-h-80 w-60">
|
||||
{#each cards as card (card.id)}
|
||||
<ContextMenuItem onSelect={() => manualCards.set(billId, card.id)}>
|
||||
<span class="flex-1 truncate">{card.name}</span>
|
||||
<span class="text-[11px] text-cladd-fg-softer">{card.code}</span>
|
||||
{#if chosen === card.id}
|
||||
<Check class="size-3.5" />
|
||||
{/if}
|
||||
</ContextMenuItem>
|
||||
{/each}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={!chosen}
|
||||
onSelect={() => manualCards.set(billId, null)}
|
||||
>Card of the last payment</ContextMenuItem
|
||||
>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import SuggestInput from "#lib/components/transactions/SuggestInput.svelte";
|
||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||
import { manualCards } from "#lib/subscriptions/manual.svelte.js";
|
||||
import type { Card } from "#lib/subscriptions/runway.js";
|
||||
import type { Suggestion } from "#lib/transactions/query.js";
|
||||
|
||||
const AUTO = "\u0000";
|
||||
|
||||
let { billId, cards }: { billId: string; cards: Card[] } = $props();
|
||||
|
||||
let text = $state("");
|
||||
let input = $state<HTMLInputElement>();
|
||||
|
||||
const suggestions = $derived.by<Suggestion[]>(() => {
|
||||
const found = fuzzyFilter(cards, text, (c) => c.name, 20).map((c) => ({
|
||||
hint: c.code,
|
||||
id: c.id,
|
||||
insert: c.id,
|
||||
label: c.name,
|
||||
}));
|
||||
return text.trim()
|
||||
? found
|
||||
: [
|
||||
{ id: AUTO, insert: AUTO, label: "Card of the last payment" },
|
||||
...found,
|
||||
];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
input?.focus();
|
||||
});
|
||||
|
||||
const cancel = () => (manualCards.picking = "");
|
||||
</script>
|
||||
|
||||
<SuggestInput
|
||||
aria-label="Paying card"
|
||||
class="w-56"
|
||||
id="bill-card-{billId}"
|
||||
onaccept={(item) => {
|
||||
manualCards.set(billId, item.insert === AUTO ? null : item.insert);
|
||||
return true;
|
||||
}}
|
||||
onblur={cancel}
|
||||
oncancel={cancel}
|
||||
onsubmit={cancel}
|
||||
placeholder="Paying card"
|
||||
{suggestions}
|
||||
bind:input
|
||||
bind:value={text}
|
||||
/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { formatNumber, toNumber } from "#lib/format.js";
|
||||
|
||||
let {
|
||||
amount,
|
||||
symbol,
|
||||
decimals,
|
||||
approx = false,
|
||||
class: className = "",
|
||||
}: {
|
||||
amount: number | string | null | undefined;
|
||||
symbol?: string | null;
|
||||
decimals?: number | null;
|
||||
approx?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<span class="num {className}"
|
||||
><span class="font-normal text-cladd-fg-softer"
|
||||
>{approx ? "~" : ""}{symbol ?? ""}</span
|
||||
>{formatNumber(Math.abs(toNumber(amount)), decimals ?? 2)}</span
|
||||
>
|
||||
@@ -0,0 +1,249 @@
|
||||
<script lang="ts">
|
||||
import type { Bill, Item } from "#lib/api/types.js";
|
||||
import { LINK } from "#lib/components/groups/object-groups.js";
|
||||
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "#lib/components/ui/context-menu/index.js";
|
||||
import { currentLocale, formatDate } from "#lib/format.js";
|
||||
import {
|
||||
type Card,
|
||||
type Due,
|
||||
HORIZONS,
|
||||
type Horizon,
|
||||
type Need,
|
||||
RENEWAL_DAYS,
|
||||
sum,
|
||||
UNKNOWN,
|
||||
} from "#lib/subscriptions/runway.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import Calendar from "./Calendar.svelte";
|
||||
import CardMenu from "./CardMenu.svelte";
|
||||
import Money from "./Money.svelte";
|
||||
|
||||
let {
|
||||
needs,
|
||||
strip,
|
||||
renewals,
|
||||
cards,
|
||||
day,
|
||||
end,
|
||||
horizon,
|
||||
byCurrency,
|
||||
status,
|
||||
onhorizon,
|
||||
onmode,
|
||||
}: {
|
||||
needs: Need[];
|
||||
strip: Need[];
|
||||
renewals: Due[];
|
||||
cards: Card[];
|
||||
day: string;
|
||||
end: string;
|
||||
horizon: Horizon;
|
||||
byCurrency: boolean;
|
||||
status: string;
|
||||
onhorizon: (horizon: Horizon) => void;
|
||||
onmode: () => void;
|
||||
} = $props();
|
||||
|
||||
let menuFor = $state<Item<Bill> | null>(null);
|
||||
|
||||
const monthName = $derived(
|
||||
new Intl.DateTimeFormat(currentLocale(), { month: "long" }).format(
|
||||
new Date(`${day}T12:00:00`)
|
||||
)
|
||||
);
|
||||
const label = (id: Horizon, text: string) =>
|
||||
id === "month" ? `Rest of ${monthName}` : text;
|
||||
const renewalTotals = $derived(sum(renewals.map((d) => d.native)));
|
||||
function oncontextmenu(event: MouseEvent) {
|
||||
const id = (event.target as Element)
|
||||
.closest("[data-bill]")
|
||||
?.getAttribute("data-bill");
|
||||
menuFor =
|
||||
needs.flatMap((n) => n.dues).find((d) => d.bill.id === id)?.bill ?? null;
|
||||
}
|
||||
|
||||
const segment = (on: boolean) =>
|
||||
buttonVariants({ size: "xs", variant: on ? "default" : "outline" });
|
||||
</script>
|
||||
|
||||
<section class="cladd-surface cladd-outline mb-3 rounded-cladd-lg">
|
||||
<header
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-2 border-cladd-outline border-b px-3 py-2"
|
||||
>
|
||||
<h2 class="me-auto font-semibold text-[15px] text-cladd-fg">
|
||||
Needed by {formatDate(end, "day")}
|
||||
</h2>
|
||||
<fieldset class="flex flex-wrap gap-1">
|
||||
<legend class="sr-only">Horizon</legend>
|
||||
{#each HORIZONS as h (h.id)}
|
||||
<button
|
||||
aria-pressed={horizon === h.id}
|
||||
class={segment(horizon === h.id)}
|
||||
onclick={() => onhorizon(h.id)}
|
||||
type="button"
|
||||
>
|
||||
{label(h.id, h.label)}<span class="kbd max-sm:hidden">{h.key}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</fieldset>
|
||||
<fieldset class="flex gap-1">
|
||||
<legend class="sr-only">Group by</legend>
|
||||
<button
|
||||
aria-pressed={!byCurrency}
|
||||
class={segment(!byCurrency)}
|
||||
onclick={() => byCurrency && onmode()}
|
||||
type="button"
|
||||
>
|
||||
By card
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={byCurrency}
|
||||
class={segment(byCurrency)}
|
||||
onclick={() => byCurrency || onmode()}
|
||||
type="button"
|
||||
>
|
||||
By currency<span class="kbd max-sm:hidden">p</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
</header>
|
||||
|
||||
{#if status}
|
||||
<p class="px-3 py-6 text-cladd-fg-soft">{status}</p>
|
||||
{:else if needs.length === 0}
|
||||
<p class="px-3 py-4 text-cladd-fg-soft">
|
||||
No subscription is due by {formatDate(end, "day")}.
|
||||
</p>
|
||||
{:else}
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger {oncontextmenu}>
|
||||
<div
|
||||
class="grid gap-3 p-3 [grid-template-columns:repeat(auto-fill,minmax(16rem,1fr))]"
|
||||
>
|
||||
{#each needs as need (need.id)}
|
||||
<article
|
||||
class="flex flex-col rounded-cladd-md border px-3 py-2.5 {need.short
|
||||
? "border-expense"
|
||||
: "border-cladd-outline"}"
|
||||
>
|
||||
<h3
|
||||
class="truncate font-semibold text-[13px] {need.id === UNKNOWN
|
||||
? "text-warn"
|
||||
: "text-cladd-fg"}"
|
||||
>
|
||||
{need.name}
|
||||
</h3>
|
||||
{#each need.totals as total (total.code)}
|
||||
<div
|
||||
class="font-semibold text-[26px] text-cladd-fg leading-tight"
|
||||
>
|
||||
<Money {...total} />
|
||||
</div>
|
||||
{/each}
|
||||
{#if need.short && need.currency}
|
||||
<div class="font-semibold text-[14px] text-expense">
|
||||
Short
|
||||
<Money
|
||||
amount={need.short.amount}
|
||||
decimals={need.currency.decimals}
|
||||
symbol={need.currency.symbol}
|
||||
/>
|
||||
on {formatDate(need.short.date, "short")}
|
||||
</div>
|
||||
{/if}
|
||||
{#if need.balance !== null && need.currency}
|
||||
<div class="text-cladd-fg-soft">
|
||||
{byCurrency ? "On paying cards" : "Balance"}
|
||||
<Money
|
||||
amount={need.balance}
|
||||
class="text-cladd-fg"
|
||||
decimals={need.currency.decimals}
|
||||
symbol={need.currency.symbol}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<ul class="mt-2 border-cladd-outline border-t pt-1 text-[12px]">
|
||||
{#each need.dues as due, i (`${due.bill.id}:${due.date}:${i}`)}
|
||||
<li
|
||||
class="flex h-6 items-center gap-2"
|
||||
data-bill={due.bill.id}
|
||||
>
|
||||
<span
|
||||
class="num w-12 shrink-0 {due.overdue
|
||||
? "text-expense"
|
||||
: "text-cladd-fg-soft"}"
|
||||
>{due.overdue
|
||||
? "overdue"
|
||||
: formatDate(due.date, "short")}</span
|
||||
>
|
||||
<a
|
||||
class="min-w-0 flex-1 truncate text-cladd-fg hover:underline"
|
||||
href="/subscriptions/show/{due.bill.id}"
|
||||
>{due.bill.attributes.name}</a
|
||||
>
|
||||
<Money
|
||||
{...due.pay}
|
||||
approx={due.approx}
|
||||
class="font-medium text-cladd-fg"
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="w-56">
|
||||
{#if menuFor}
|
||||
{@const target = menuFor}
|
||||
<ContextMenuItem
|
||||
onSelect={() => goto(`/subscriptions/show/${target.id}`)}
|
||||
>Show</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem
|
||||
onSelect={() => goto(`/subscriptions/edit/${target.id}`)}
|
||||
>Edit</ContextMenuItem
|
||||
>
|
||||
<ContextMenuSeparator />
|
||||
<CardMenu billId={target.id} {cards} />
|
||||
{/if}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
|
||||
{#if !status && renewals.length > 0}
|
||||
<p
|
||||
class="flex flex-wrap items-baseline gap-x-2 border-cladd-outline border-t px-3 py-2"
|
||||
>
|
||||
<span class="font-semibold text-cladd-fg"
|
||||
>Annual renewals in {RENEWAL_DAYS} days:</span
|
||||
>
|
||||
<span class="font-semibold text-cladd-fg"
|
||||
>{renewals.length},
|
||||
{#each renewalTotals as total, i (total.code)}
|
||||
{i > 0 ? " + " : ""}<Money {...total} />
|
||||
{/each}</span
|
||||
>
|
||||
{#each renewals as due (due.bill.id)}
|
||||
<span class="text-cladd-fg-soft"
|
||||
><a class={LINK} href="/subscriptions/show/{due.bill.id}"
|
||||
>{due.bill.attributes.name}</a
|
||||
>
|
||||
{formatDate(due.date, "short")}</span
|
||||
>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !status && strip.length > 0}
|
||||
<div class="border-cladd-outline border-t">
|
||||
<Calendar {day} {end} needs={strip} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Bill, Item } from "#lib/api/types.js";
|
||||
import { addMonths, daysBetween, type Range, today } from "#lib/dates.js";
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
daysBetween,
|
||||
type Range,
|
||||
today,
|
||||
} from "#lib/dates.js";
|
||||
import type { Money } from "#lib/format.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
|
||||
@@ -47,6 +53,23 @@ export const FREQUENCY_LABELS: Record<Frequency, string> = {
|
||||
const freq = (bill: Bill): Frequency =>
|
||||
(bill.repeat_freq ?? "monthly") as Frequency;
|
||||
|
||||
const DAYS_PER_MONTH = 30.4;
|
||||
const WEEK = 7;
|
||||
|
||||
export const cycleDays = (bill: Bill) =>
|
||||
(MONTHS_PER[freq(bill)] * DAYS_PER_MONTH || WEEK) * ((bill.skip ?? 0) + 1);
|
||||
|
||||
export function twoCyclesAfter(bill: Bill, from: string): string {
|
||||
const cycles = 2 * ((bill.skip ?? 0) + 1);
|
||||
const months = MONTHS_PER[freq(bill)];
|
||||
return months === 0
|
||||
? addDays(from, WEEK * cycles)
|
||||
: addMonths(from, months * cycles);
|
||||
}
|
||||
|
||||
export const isRange = (bill: Bill) =>
|
||||
toNumber(bill.amount_min) !== toNumber(bill.amount_max);
|
||||
|
||||
export const average = (bill: Bill) =>
|
||||
(toNumber(bill.amount_min) + toNumber(bill.amount_max)) / 2;
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const PREFIX = "ffui.bill-card.";
|
||||
|
||||
function read(): Record<string, string> {
|
||||
const ids: Record<string, string> = {};
|
||||
if (typeof localStorage === "undefined") {
|
||||
return ids;
|
||||
}
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(PREFIX)) {
|
||||
ids[key.slice(PREFIX.length)] = localStorage.getItem(key) ?? "";
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
class ManualCards {
|
||||
ids = $state<Record<string, string>>(read());
|
||||
picking = $state("");
|
||||
|
||||
get(billId: string): string | null {
|
||||
return this.ids[billId] || null;
|
||||
}
|
||||
|
||||
set(billId: string, cardId: string | null) {
|
||||
if (cardId) {
|
||||
localStorage.setItem(PREFIX + billId, cardId);
|
||||
} else {
|
||||
localStorage.removeItem(PREFIX + billId);
|
||||
}
|
||||
this.ids = Object.fromEntries(
|
||||
Object.entries({ ...this.ids, [billId]: cardId ?? "" }).filter(
|
||||
([, id]) => id
|
||||
)
|
||||
);
|
||||
this.picking = "";
|
||||
}
|
||||
}
|
||||
|
||||
export const manualCards = new ManualCards();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all } from "#lib/api/lists.js";
|
||||
import type { Bill, Item, Page, TransactionGroup } from "#lib/api/types.js";
|
||||
import { addDays } from "#lib/dates.js";
|
||||
import { toPayments } from "./runway.js";
|
||||
|
||||
const SEARCH_LIMIT = 500;
|
||||
const UNLINKED_DAYS = 92;
|
||||
|
||||
async function search(query: string): Promise<Item<TransactionGroup>[]> {
|
||||
const read = (page: number) =>
|
||||
api<Page<TransactionGroup>>("/v1/search/transactions", {
|
||||
query: { limit: SEARCH_LIMIT, page, query },
|
||||
});
|
||||
const first = await read(1);
|
||||
const pages = first.meta.pagination?.total_pages ?? 1;
|
||||
const rest = await Promise.all(
|
||||
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => read(i + 2))
|
||||
);
|
||||
const seen = new Map<string, Item<TransactionGroup>>();
|
||||
for (const group of [first, ...rest].flatMap((page) => page.data)) {
|
||||
seen.set(group.id, group);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
export const runwayQueries = {
|
||||
bills: (start: string, end: string) => ({
|
||||
queryFn: () => all<Bill>("/v1/bills", { end, start }),
|
||||
queryKey: ["subscriptions", "runway", start, end],
|
||||
}),
|
||||
paid: () => ({
|
||||
queryFn: async () => toPayments(await search("has_any_bill:true")),
|
||||
queryKey: ["subscriptions", "payments"],
|
||||
}),
|
||||
unlinked: (day: string) => {
|
||||
const after = addDays(day, -UNLINKED_DAYS);
|
||||
return {
|
||||
queryFn: async () =>
|
||||
toPayments(
|
||||
await search(`has_no_bill:true type:withdrawal date_after:${after}`)
|
||||
).filter((payment) => !payment.billId),
|
||||
queryKey: ["subscriptions", "unlinked", after],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user