feat(subscriptions,piggy-banks,groups): grouped subscriptions with yearly cost, inline piggy money, object groups

This commit is contained in:
hh committed 2026-10-16 03:38:31 +00:00
1 parent 8b3f7e78a3
commit 4a693a83c9
27 files changed
+3492 -1

No files matched your search

@@ -0,0 +1,261 @@
<script lang="ts">
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Bill, Item, One } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import GroupInput from "#lib/components/groups/GroupInput.svelte";
import { amountString } from "#lib/components/inputs/parse.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import Status from "#lib/components/misc/Status.svelte";
import { select, textarea } from "#lib/components/misc/styles.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { today } from "#lib/dates.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { FREQUENCIES, FREQUENCY_LABELS } from "./bills.js";
let { bill }: { bill?: Item<Bill> } = $props();
const initial = untrack(() => bill?.attributes);
const form = $state({
active: initial?.active ?? true,
amount_max: initial?.amount_max ?? "",
amount_min: initial?.amount_min ?? "",
currency_code: initial?.currency_code ?? session.primary?.code ?? "",
date: initial?.date?.slice(0, 10) ?? today(),
end_date: initial?.end_date?.slice(0, 10) ?? "",
extension_date: initial?.extension_date?.slice(0, 10) ?? "",
name: initial?.name ?? "",
notes: initial?.notes ?? "",
object_group_title: initial?.object_group_title ?? "",
repeat_freq: initial?.repeat_freq ?? "monthly",
skip: String(initial?.skip ?? 0),
});
const action = new Action();
const currencies = $derived(
session.currencies.filter(
(c) => c.attributes.enabled || c.attributes.code === form.currency_code
)
);
const decimals = $derived(
currencies.find((c) => c.attributes.code === form.currency_code)?.attributes
.decimal_places ?? 2
);
function body() {
const min = amountString(form.amount_min, decimals);
return {
active: form.active,
amount_max: amountString(form.amount_max, decimals) || min,
amount_min: min,
currency_code:
form.currency_code === initial?.currency_code
? undefined
: form.currency_code,
date: form.date,
end_date: form.end_date || undefined,
extension_date: form.extension_date || undefined,
name: form.name.trim(),
notes: form.notes.trim() || null,
object_group_title:
form.object_group_title.trim() || (bill ? "" : undefined),
repeat_freq: form.repeat_freq,
skip: Number(form.skip) || 0,
};
}
async function save() {
let id = bill?.id;
const ok = await action.run(async () => {
const saved = await api<One<Bill>>(
bill ? `/v1/bills/${bill.id}` : "/v1/bills",
{ body: body(), method: bill ? "PUT" : "POST" }
);
({ id } = saved.data);
});
if (ok) {
await invalidate();
await goto(`/subscriptions/show/${id}`);
}
}
async function remove() {
if (!bill) {
return;
}
const ok = await action.run(() =>
api(`/v1/bills/${bill.id}`, { method: "DELETE" })
);
if (ok) {
await goto("/subscriptions");
await invalidate();
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: bill ? "Update subscription" : "Store new subscription",
run: save,
},
]);
</script>
<form
class="flex flex-col gap-3"
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<div class="grid items-start gap-3 lg:grid-cols-2">
<Box body="flex flex-col gap-3 p-4" title="Mandatory fields">
<Field for="bill-name" label="Name" problem={action.field("name")}>
<Input id="bill-name" required bind:value={form.name} />
</Field>
<Field
for="bill-currency"
label="Currency"
problem={action.field("currency_code") ?? action.field("currency_id")}
>
<select
class={select}
id="bill-currency"
bind:value={form.currency_code}
>
{#each currencies as currency (currency.id)}
<option value={currency.attributes.code}>
{currency.attributes.name}
({currency.attributes.symbol})
</option>
{/each}
</select>
</Field>
<Field
for="bill-min"
label="Minimum amount"
problem={action.field("amount_min")}
>
<Input
class="num"
id="bill-min"
inputmode="decimal"
required
bind:value={form.amount_min}
/>
</Field>
<Field
for="bill-max"
help="Leave empty when the amount is always the same."
label="Maximum amount"
problem={action.field("amount_max")}
>
<Input
class="num"
id="bill-max"
inputmode="decimal"
bind:value={form.amount_max}
/>
</Field>
<Field
for="bill-date"
help="The first date this subscription is expected."
label="Date"
problem={action.field("date")}
>
<Input id="bill-date" required type="date" bind:value={form.date} />
</Field>
<Field
for="bill-repeat"
label="Repeats"
problem={action.field("repeat_freq")}
>
<select class={select} id="bill-repeat" bind:value={form.repeat_freq}>
{#each FREQUENCIES as frequency (frequency)}
<option value={frequency}>{FREQUENCY_LABELS[frequency]}</option>
{/each}
</select>
</Field>
<Field
for="bill-skip"
help="Skip 1 with monthly means every other month."
label="Skip"
problem={action.field("skip")}
>
<Input
class="num w-24"
id="bill-skip"
max="31"
min="0"
type="number"
bind:value={form.skip}
/>
</Field>
</Box>
<Box body="flex flex-col gap-3 p-4" title="Optional fields">
<Field
for="bill-end"
help="The subscription stops being expected after this date."
label="End date"
problem={action.field("end_date")}
>
<Input id="bill-end" type="date" bind:value={form.end_date} />
</Field>
<Field
for="bill-extension"
help="The date to extend or cancel the subscription."
label="Extension date"
problem={action.field("extension_date")}
>
<Input
id="bill-extension"
type="date"
bind:value={form.extension_date}
/>
</Field>
<Field
for="bill-group"
label="Group"
problem={action.field("object_group_title")}
>
<GroupInput id="bill-group" bind:value={form.object_group_title} />
</Field>
<Field for="bill-notes" label="Notes" problem={action.field("notes")}>
<textarea
class={textarea}
id="bill-notes"
rows="4"
bind:value={form.notes}
></textarea>
</Field>
{#if bill}
<Field for="bill-active" label="Active">
<input
class="mt-2 size-4 accent-(--ff-brand)"
id="bill-active"
type="checkbox"
bind:checked={form.active}
>
</Field>
{/if}
</Box>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button disabled={action.busy} type="submit">
{bill ? "Update subscription" : "Store new subscription"}
<span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
</Button>
<Status {action} />
{#if bill}
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
{/if}
</div>
</form>
@@ -0,0 +1,20 @@
<script lang="ts">
import { formatDate } from "#lib/format.js";
import { STATUS_TONE, type Status, statusText } from "./bills.js";
let { state }: { state: Status } = $props();
</script>
{#if state.kind === "paid"}
<span class="inline-flex flex-wrap gap-x-2">
{#each state.paid as paid (paid.transaction_journal_id)}
<a
class="text-income hover:underline"
href="/transactions/show/{paid.transaction_group_id}"
>✓ {formatDate(paid.date, "short")}</a
>
{/each}
</span>
{:else}
<span class={STATUS_TONE[state.kind]}>{statusText(state)}</span>
{/if}
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Cell } from "./bills.js";
let { cells, months }: { cells: Cell[]; months: string[] } = $props();
const LOOK: Record<Cell, string> = {
due: "border border-warn",
idle: "border border-cladd-fg-softest",
missed: "border border-expense bg-expense/15",
off: "bg-cladd-fg-softest/25 scale-50",
paid: "bg-income",
};
const TITLE: Record<Cell, string> = {
due: "expected, not paid yet",
idle: "expected",
missed: "expected, not paid",
off: "not expected",
paid: "paid",
};
</script>
<span class="inline-flex gap-[3px] align-middle">
{#each cells as cell, i (months[i])}
<span
class="size-2.5 rounded-[2px] {LOOK[cell]}"
title="{months[i]}: {TITLE[cell]}"
></span>
{/each}
</span>
+176
View File
@@ -0,0 +1,176 @@
import type { Bill, Item } from "#lib/api/types.js";
import { addMonths, daysBetween, type Range, today } from "#lib/dates.js";
import type { Money } from "#lib/format.js";
import { toNumber } from "#lib/format.js";
export const FREQUENCIES = [
"weekly",
"monthly",
"quarterly",
"half-year",
"yearly",
] as const;
export type Frequency = (typeof FREQUENCIES)[number];
const PER_YEAR: Record<Frequency, number> = {
"half-year": 2,
monthly: 12,
quarterly: 4,
weekly: 52.17,
yearly: 1,
};
const MONTHS_PER: Record<Frequency, number> = {
"half-year": 6,
monthly: 1,
quarterly: 3,
weekly: 0,
yearly: 12,
};
const UNIT: Record<Frequency, [string, string]> = {
"half-year": ["every half year", "half years"],
monthly: ["monthly", "months"],
quarterly: ["quarterly", "quarters"],
weekly: ["weekly", "weeks"],
yearly: ["yearly", "years"],
};
export const FREQUENCY_LABELS: Record<Frequency, string> = {
"half-year": "Every half year",
monthly: "Monthly",
quarterly: "Quarterly",
weekly: "Weekly",
yearly: "Yearly",
};
const freq = (bill: Bill): Frequency =>
(bill.repeat_freq ?? "monthly") as Frequency;
export const average = (bill: Bill) =>
(toNumber(bill.amount_min) + toNumber(bill.amount_max)) / 2;
export const timesPerYear = (bill: Bill) =>
PER_YEAR[freq(bill)] / ((bill.skip ?? 0) + 1);
export function repeats(bill: Bill): string {
const [single, plural] = UNIT[freq(bill)];
const every = (bill.skip ?? 0) + 1;
return every > 1 ? `every ${every} ${plural}` : single;
}
export const money = (bill: Bill, amount: number): Money => ({
amount,
code: bill.currency_code,
decimals: bill.currency_decimal_places,
symbol: bill.currency_symbol,
});
export const monthly = (bill: Bill) =>
money(bill, (average(bill) * timesPerYear(bill)) / 12);
export const yearly = (bill: Bill) =>
money(bill, average(bill) * timesPerYear(bill));
export const dueIn = (range: Range, bill: Bill) =>
(bill.pay_dates ?? [])
.map((date) => date.slice(0, 10))
.filter((date) => date >= range.start && date <= range.end);
export type Status =
| { kind: "inactive" }
| { kind: "paid"; paid: NonNullable<Bill["paid_dates"]> }
| { kind: "due" | "overdue"; date: string; days: number }
| { kind: "none" };
export function status(bill: Bill, range: Range): Status {
if (!bill.active) {
return { kind: "inactive" };
}
const paid = bill.paid_dates ?? [];
if (paid.length > 0) {
return { kind: "paid", paid };
}
const [date] = dueIn(range, bill);
if (!date) {
return { kind: "none" };
}
const days = daysBetween(today(), date);
return { date, days, kind: days < 0 ? "overdue" : "due" };
}
export function statusText(state: Status): string {
switch (state.kind) {
case "inactive":
return "Inactive";
case "none":
return "Not expected this period";
case "overdue":
return `Overdue ${-state.days} ${-state.days === 1 ? "day" : "days"}`;
case "due":
if (state.days === 0) {
return "Due today";
}
return `Due in ${state.days} ${state.days === 1 ? "day" : "days"}`;
default:
return "Paid";
}
}
export const STATUS_TONE: Record<Status["kind"], string> = {
due: "text-warn",
inactive: "text-cladd-fg-softer",
none: "text-cladd-fg-softer",
overdue: "text-expense",
paid: "text-income",
};
export const nextExpected = (bill: Bill) =>
bill.pay_dates?.[0]?.slice(0, 10) ?? bill.next_expected_match ?? null;
export type Cell = "paid" | "missed" | "due" | "off" | "idle";
export function expectedIn(bill: Bill, month: string): boolean {
const start = (bill.date ?? "").slice(0, 7);
const end = bill.end_date?.slice(0, 7);
if (!start || month < start || (end && month > end)) {
return false;
}
const step = MONTHS_PER[freq(bill)] * ((bill.skip ?? 0) + 1);
if (step === 0) {
return true;
}
const [y1, m1] = start.split("-").map(Number);
const [y2, m2] = month.split("-").map(Number);
return ((y2 - y1) * 12 + (m2 - m1)) % step === 0;
}
export function lastMonths(end: string, count = 12): string[] {
const first = addMonths(`${end.slice(0, 7)}-01`, 1 - count);
return Array.from({ length: count }, (_, i) =>
addMonths(first, i).slice(0, 7)
);
}
export function strip(bill: Bill, months: string[]): Cell[] {
const paid = new Set((bill.paid_dates ?? []).map((p) => p.date?.slice(0, 7)));
const day = today();
const now = day.slice(0, 7);
const late = (bill.pay_dates ?? []).some(
(date) => date.slice(0, 7) === now && date.slice(0, 10) < day
);
return months.map((month) => {
if (paid.has(month)) {
return "paid";
}
if (!(bill.active && expectedIn(bill, month))) {
return "off";
}
if (month > now) {
return "idle";
}
return month === now && !late ? "due" : "missed";
});
}
export const billKey = (item: Item<Bill>) => item.id;