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 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 type { Money } from "#lib/format.js";
|
||||||
import { toNumber } 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 =>
|
const freq = (bill: Bill): Frequency =>
|
||||||
(bill.repeat_freq ?? "monthly") as 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) =>
|
export const average = (bill: Bill) =>
|
||||||
(toNumber(bill.amount_min) + toNumber(bill.amount_max)) / 2;
|
(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";
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Ban from "@lucide/svelte/icons/ban";
|
import Ban from "@lucide/svelte/icons/ban";
|
||||||
|
import Pin from "@lucide/svelte/icons/pin";
|
||||||
import Plus from "@lucide/svelte/icons/plus";
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all, invalidate, lists } from "#lib/api/lists.js";
|
import { all, invalidate, lists } from "#lib/api/lists.js";
|
||||||
import type { BasicSummary, Bill, Item } from "#lib/api/types.js";
|
import type { Bill, Item } from "#lib/api/types.js";
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import {
|
import {
|
||||||
byObjectGroup,
|
byObjectGroup,
|
||||||
@@ -14,15 +14,11 @@
|
|||||||
swapOrder,
|
swapOrder,
|
||||||
type Total,
|
type Total,
|
||||||
} from "#lib/components/groups/object-groups.js";
|
} from "#lib/components/groups/object-groups.js";
|
||||||
import Totals from "#lib/components/groups/Totals.svelte";
|
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
|
||||||
import { errorText } from "#lib/components/misc/action.svelte.js";
|
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import {
|
import {
|
||||||
average,
|
|
||||||
billKey,
|
billKey,
|
||||||
dueIn,
|
|
||||||
lastMonths,
|
lastMonths,
|
||||||
money,
|
|
||||||
monthly,
|
monthly,
|
||||||
nextExpected,
|
nextExpected,
|
||||||
repeats,
|
repeats,
|
||||||
@@ -30,7 +26,11 @@
|
|||||||
strip,
|
strip,
|
||||||
yearly,
|
yearly,
|
||||||
} from "#lib/components/subscriptions/bills.js";
|
} 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 PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
|
||||||
|
import Runway from "#lib/components/subscriptions/Runway.svelte";
|
||||||
import Strip from "#lib/components/subscriptions/Strip.svelte";
|
import Strip from "#lib/components/subscriptions/Strip.svelte";
|
||||||
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
|
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -43,14 +43,41 @@
|
|||||||
ContextMenuSubTrigger,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "#lib/components/ui/context-menu/index.js";
|
} from "#lib/components/ui/context-menu/index.js";
|
||||||
import { today } from "#lib/dates.js";
|
import { addDays, today } from "#lib/dates.js";
|
||||||
import { formatDate } from "#lib/format.js";
|
import { formatDate, formatNumber, toNumber } from "#lib/format.js";
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.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 { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
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 months = $derived(lastMonths(period.end));
|
||||||
const billsKey = $derived(["subscriptions", period.start, 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` }),
|
all<Bill>("/v1/bills", { end: period.end, start: `${months[0]}-01` }),
|
||||||
queryKey: ["subscriptions", "history", months[0], period.end],
|
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 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(
|
const params = $derived(page.url.searchParams);
|
||||||
page.url.searchParams.get("inactive") === "hide"
|
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(
|
const visible = $derived(
|
||||||
(bills.data ?? []).filter((b) => !hideInactive || b.attributes.active)
|
(bills.data ?? []).filter((b) => !hideInactive || b.attributes.active)
|
||||||
);
|
);
|
||||||
@@ -92,6 +150,20 @@
|
|||||||
const pastCells = $derived(
|
const pastCells = $derived(
|
||||||
new Map((history.data ?? []).map((b) => [b.id, b.attributes]))
|
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>[]) =>
|
const activeOnly = (items: Item<Bill>[]) =>
|
||||||
items.filter((b) => b.attributes.active).map((b) => b.attributes);
|
items.filter((b) => b.attributes.active).map((b) => b.attributes);
|
||||||
@@ -100,47 +172,31 @@
|
|||||||
const perYear = (items: Item<Bill>[]) =>
|
const perYear = (items: Item<Bill>[]) =>
|
||||||
sumByCurrency(activeOnly(items).map(yearly));
|
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 menuFor = $state<Item<Bill> | null>(null);
|
||||||
let armed = $state(false);
|
let armed = $state(false);
|
||||||
let failure = $state("");
|
let failure = $state("");
|
||||||
|
let linking = $state<Record<string, string>>({});
|
||||||
|
const arm = new Arm();
|
||||||
|
|
||||||
const show = (b: Item<Bill>) => goto(`/subscriptions/show/${b.id}`);
|
const show = (b: Item<Bill>) => goto(`/subscriptions/show/${b.id}`);
|
||||||
const edit = (b: Item<Bill>) => goto(`/subscriptions/edit/${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);
|
const url = new URL(page.url.href);
|
||||||
if (hideInactive) {
|
if (value === null) {
|
||||||
url.searchParams.delete("inactive");
|
url.searchParams.delete(name);
|
||||||
} else {
|
} else {
|
||||||
url.searchParams.set("inactive", "hide");
|
url.searchParams.set(name, value);
|
||||||
}
|
}
|
||||||
goto(url, { replace: true });
|
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>) {
|
async function update(b: Item<Bill>, body: Record<string, unknown>) {
|
||||||
failure = "";
|
failure = "";
|
||||||
try {
|
try {
|
||||||
@@ -176,9 +232,92 @@
|
|||||||
await invalidate();
|
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>>({
|
const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({
|
||||||
edit,
|
edit,
|
||||||
extra: () => [
|
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",
|
group: "Subscriptions",
|
||||||
keys: "J",
|
keys: "J",
|
||||||
@@ -211,8 +350,18 @@
|
|||||||
});
|
});
|
||||||
nav.use();
|
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 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>
|
</script>
|
||||||
|
|
||||||
<PageHeader crumbs={[{ label: "Subscriptions" }]} title="Subscriptions">
|
<PageHeader crumbs={[{ label: "Subscriptions" }]} title="Subscriptions">
|
||||||
@@ -223,23 +372,27 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="mb-3 grid gap-3 sm:grid-cols-3">
|
<Runway
|
||||||
{#each [
|
byCurrency={perCurrency}
|
||||||
{ label: "Expected this period", totals: expected },
|
cards={cardList}
|
||||||
{ label: "Paid", totals: paid },
|
{day}
|
||||||
{ label: "Still to pay", totals: unpaid },
|
{end}
|
||||||
] as stat (stat.label)}
|
{horizon}
|
||||||
<Box body="px-3 py-2" title={stat.label}>
|
{needs}
|
||||||
<div class="text-[15px]">
|
onhorizon={setHorizon}
|
||||||
<Totals
|
onmode={toggleMode}
|
||||||
empty={summary.isPending ? "" : "Nothing"}
|
{renewals}
|
||||||
tone={stat.label === "Paid" ? "income" : "expense"}
|
status={runwayStatus}
|
||||||
totals={stat.totals}
|
strip={stripNeeds}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Box>
|
{#snippet sums(
|
||||||
|
totals: Total[]
|
||||||
|
)}
|
||||||
|
{#each totals as total (total.code)}
|
||||||
|
<div><Money {...total} /></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
<Box body="p-0" title="Subscriptions">
|
<Box body="p-0" title="Subscriptions">
|
||||||
{#snippet tools()}
|
{#snippet tools()}
|
||||||
@@ -274,10 +427,12 @@
|
|||||||
<tr class="border-cladd-outline border-b text-left">
|
<tr class="border-cladd-outline border-b text-left">
|
||||||
<th class={head}>Name</th>
|
<th class={head}>Name</th>
|
||||||
<th class="{head} text-right">Amount</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}>Paid this period</th>
|
||||||
<th class="{head} hidden md:table-cell">Next expected match</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 md:table-cell">Card</th>
|
||||||
<th class="{head} hidden xl:table-cell">
|
<th class="{head} hidden 2xl:table-cell">Repeats</th>
|
||||||
|
<th class="{head} hidden 2xl:table-cell">
|
||||||
<span class="inline-flex gap-[3px]">
|
<span class="inline-flex gap-[3px]">
|
||||||
{#each months as month (month)}
|
{#each months as month (month)}
|
||||||
<span class="w-2.5 text-center text-[10px]"
|
<span class="w-2.5 text-center text-[10px]"
|
||||||
@@ -295,38 +450,46 @@
|
|||||||
<tr
|
<tr
|
||||||
class="border-cladd-outline border-t bg-cladd-surface-next/60"
|
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}
|
{section.title}
|
||||||
<span class="ms-1 font-normal text-cladd-fg-softer"
|
<span class="ms-1 font-normal text-cladd-fg-softer"
|
||||||
>{section.items.length}</span
|
>{section.items.length}</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} text-right font-semibold">
|
<td class="{cell} text-right font-semibold text-cladd-fg">
|
||||||
<Totals tone="expense" totals={monthTotals} />
|
{@render sums(monthTotals)}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="hidden lg:table-cell"></td>
|
||||||
<td class="{cell} text-[12px] text-cladd-fg-soft">
|
<td class="{cell} text-[12px] text-cladd-fg-soft">
|
||||||
{monthTotals.length > 0 ? "a month" : ""}
|
{monthTotals.length > 0 ? "a month" : ""}
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden md:table-cell"></td>
|
<td class="hidden md:table-cell"></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
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{#each section.items as item, j (item.id)}
|
{#each section.items as item, j (item.id)}
|
||||||
{@const i = offsets[s] + j}
|
{@const i = offsets[s] + j}
|
||||||
{@const bill = item.attributes}
|
{@const bill = item.attributes}
|
||||||
{@const ends = bill.end_date?.slice(0, 10)}
|
{@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
|
<tr
|
||||||
class="h-7 border-cladd-outline/60 border-t {nav.cursor ===
|
class="h-7 border-cladd-outline/60 border-t {rowTone(
|
||||||
i
|
nav.cursor === i,
|
||||||
? "bg-row-cursor"
|
stale !== null
|
||||||
: "hover:bg-row-hover"} {bill.active
|
)} {bill.active ? "text-cladd-fg" : "text-cladd-fg-softer"}"
|
||||||
? ""
|
|
||||||
: "text-cladd-fg-softer"}"
|
|
||||||
id={nav.rowId(i)}
|
id={nav.rowId(i)}
|
||||||
onclick={(e) => nav.click(i, e)}
|
onclick={(e) => nav.click(i, e)}
|
||||||
oncontextmenu={() => {
|
oncontextmenu={() => {
|
||||||
@@ -334,9 +497,9 @@
|
|||||||
nav.cursor = i;
|
nav.cursor = i;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td class={cell}>
|
<td class="{cell} md:whitespace-nowrap">
|
||||||
<a
|
<a
|
||||||
class={bill.active ? LINK : "hover:underline"}
|
class="hover:underline"
|
||||||
href="/subscriptions/show/{item.id}"
|
href="/subscriptions/show/{item.id}"
|
||||||
>
|
>
|
||||||
{#if !bill.active}
|
{#if !bill.active}
|
||||||
@@ -348,41 +511,90 @@
|
|||||||
{bill.name}
|
{bill.name}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} whitespace-nowrap text-right">
|
<td class="{cell} whitespace-nowrap text-right font-medium">
|
||||||
<Amount
|
{#if toNumber(bill.amount_max) !==
|
||||||
amount={bill.amount_min}
|
toNumber(bill.amount_min)}
|
||||||
code={bill.currency_code}
|
<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}
|
decimals={bill.currency_decimal_places}
|
||||||
symbol={bill.currency_symbol}
|
symbol={bill.currency_symbol ?? ""}
|
||||||
tone={bill.active ? "expense" : "neutral"}
|
|
||||||
/>
|
/>
|
||||||
{#if bill.amount_max !== bill.amount_min}
|
</td>
|
||||||
<span class="text-cladd-fg-softer">–</span>
|
<td class="{cell} hidden whitespace-nowrap lg:table-cell">
|
||||||
<Amount
|
{#if change}
|
||||||
amount={bill.amount_max}
|
<span
|
||||||
decimals={bill.currency_decimal_places}
|
class="num {change.to > change.from
|
||||||
tone={bill.active ? "expense" : "neutral"}
|
? "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}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class={cell}>
|
<td class="{cell} md:whitespace-nowrap">
|
||||||
<PaidStatus state={status(bill, period.range)} />
|
{#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>
|
||||||
<td class="{cell} num hidden md:table-cell">
|
<td class="{cell} num hidden md:table-cell">
|
||||||
{bill.active ? formatDate(nextExpected(bill)) : ""}
|
{bill.active ? formatDate(nextExpected(bill)) : ""}
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} hidden whitespace-nowrap md:table-cell">
|
<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)}
|
{repeats(bill)}
|
||||||
{#if ends}
|
{#if ends}
|
||||||
<span
|
<span
|
||||||
class={ends < today()
|
class={ends < day
|
||||||
? "text-expense"
|
? "text-expense"
|
||||||
: "text-cladd-fg-soft"}
|
: "text-cladd-fg-soft"}
|
||||||
>, ends {formatDate(ends)}</span
|
>, ends {formatDate(ends)}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} hidden xl:table-cell">
|
<td class="{cell} hidden 2xl:table-cell">
|
||||||
<Strip
|
<Strip
|
||||||
cells={strip(pastCells.get(item.id) ?? bill, months)}
|
cells={strip(pastCells.get(item.id) ?? bill, months)}
|
||||||
{months}
|
{months}
|
||||||
@@ -390,30 +602,108 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="{cell} hidden text-right lg:table-cell">
|
<td class="{cell} hidden text-right lg:table-cell">
|
||||||
{#if bill.active}
|
{#if bill.active}
|
||||||
<Amount {...yearly(bill)} tone="expense" />
|
<Money {...yearly(bill)} />
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
{/each}
|
{/each}
|
||||||
{#if sections.length > 1}
|
{#if sections.length > 1}
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="border-cladd-outline border-t-2">
|
<tr class="border-cladd-outline border-t-2">
|
||||||
<td class="{cell} font-semibold">
|
<td class="{cell} font-semibold text-cladd-fg">
|
||||||
Total, active subscriptions
|
Total, active subscriptions
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} text-right font-semibold">
|
<td class="{cell} text-right font-semibold text-cladd-fg">
|
||||||
<Totals tone="expense" totals={perMonth(rows)} />
|
{@render sums(perMonth(rows))}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="hidden lg:table-cell"></td>
|
||||||
<td class="{cell} text-[12px] text-cladd-fg-soft">a month</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 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
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -426,6 +716,9 @@
|
|||||||
{@const target = menuFor}
|
{@const target = menuFor}
|
||||||
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
|
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
|
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
|
||||||
|
{#if target.attributes.active}
|
||||||
|
<CardMenu billId={target.id} cards={cardList} />
|
||||||
|
{/if}
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
|
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
|
||||||
<ContextMenuSubContent class="max-h-80 w-52">
|
<ContextMenuSubContent class="max-h-80 w-52">
|
||||||
@@ -447,6 +740,13 @@
|
|||||||
>
|
>
|
||||||
</ContextMenuSubContent>
|
</ContextMenuSubContent>
|
||||||
</ContextMenuSub>
|
</ContextMenuSub>
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
update(target, { active: !target.attributes.active })}
|
||||||
|
>{target.attributes.active
|
||||||
|
? "Deactivate"
|
||||||
|
: "Activate"}</ContextMenuItem
|
||||||
|
>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
|
|||||||
Reference in new issue
Block a user