feat(subscriptions,piggy-banks,groups): grouped subscriptions with yearly cost, inline piggy money, object groups
This commit is contained in:
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>
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user