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,16 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import { Input } from "#lib/components/ui/input/index.js";
|
||||
|
||||
let { id, value = $bindable("") }: { id: string; value: string } = $props();
|
||||
|
||||
const groups = createQuery(() => lists.objectGroups());
|
||||
</script>
|
||||
|
||||
<Input autocomplete="off" {id} list="{id}-list" bind:value />
|
||||
<datalist id="{id}-list">
|
||||
{#each groups.data ?? [] as group (group.id)}
|
||||
<option value={group.attributes.title}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import { formatMoney } from "#lib/format.js";
|
||||
import type { Total } from "./object-groups.js";
|
||||
|
||||
let {
|
||||
totals,
|
||||
tone = "plain",
|
||||
empty = "",
|
||||
}: {
|
||||
totals: Total[];
|
||||
tone?: "income" | "expense" | "plain";
|
||||
empty?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#each totals as total (total.code)}
|
||||
<div>
|
||||
{#if tone === "plain"}
|
||||
<span class="num">{formatMoney(total)}</span>
|
||||
{:else}
|
||||
<Amount
|
||||
amount={total.amount}
|
||||
code={total.code}
|
||||
decimals={total.decimals}
|
||||
symbol={total.symbol}
|
||||
{tone}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if empty}
|
||||
<span class="text-cladd-fg-softer">{empty}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { queryClient } from "#lib/api/lists.js";
|
||||
import type { Item } from "#lib/api/types.js";
|
||||
import type { Money } from "#lib/format.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
|
||||
interface Groupable {
|
||||
object_group_id?: string | null;
|
||||
object_group_order?: number | null;
|
||||
object_group_title?: string | null;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface Section<T> {
|
||||
id: string;
|
||||
items: T[];
|
||||
order: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const UNGROUPED = "(ungrouped)";
|
||||
|
||||
export function byObjectGroup<T extends Groupable>(
|
||||
items: Item<T>[]
|
||||
): Section<Item<T>>[] {
|
||||
const sections = new Map<string, Section<Item<T>>>();
|
||||
for (const item of items) {
|
||||
const { object_group_id, object_group_order, object_group_title } =
|
||||
item.attributes;
|
||||
const id = object_group_id ?? "";
|
||||
let section = sections.get(id);
|
||||
if (!section) {
|
||||
section = {
|
||||
id,
|
||||
items: [],
|
||||
order: id ? (object_group_order ?? 0) : -1,
|
||||
title: object_group_title ?? UNGROUPED,
|
||||
};
|
||||
sections.set(id, section);
|
||||
}
|
||||
section.items.push(item);
|
||||
}
|
||||
const list = [...sections.values()].sort((a, b) => a.order - b.order);
|
||||
for (const section of list) {
|
||||
section.items.sort(
|
||||
(a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export interface Total {
|
||||
amount: number;
|
||||
code: string;
|
||||
decimals: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
export function sumByCurrency(entries: Money[]): Total[] {
|
||||
const totals = new Map<string, Total>();
|
||||
for (const entry of entries) {
|
||||
const code = entry.code ?? "";
|
||||
const total = totals.get(code) ?? {
|
||||
amount: 0,
|
||||
code,
|
||||
decimals: entry.decimals ?? 2,
|
||||
symbol: entry.symbol ?? code,
|
||||
};
|
||||
total.amount += toNumber(entry.amount);
|
||||
totals.set(code, total);
|
||||
}
|
||||
return [...totals.values()];
|
||||
}
|
||||
|
||||
export const LINK = "text-brand hover:underline dark:text-(--ff-series-1)";
|
||||
|
||||
export function swapOrder<T extends { order?: number }>(
|
||||
key: readonly unknown[],
|
||||
a: Item<T>,
|
||||
b: Item<T>
|
||||
): number | undefined {
|
||||
const { order: first } = a.attributes;
|
||||
const { order: second } = b.attributes;
|
||||
queryClient.setQueryData<Item<T>[]>(key, (old) =>
|
||||
old?.map((item) => {
|
||||
if (item.id === a.id) {
|
||||
return { ...item, attributes: { ...item.attributes, order: second } };
|
||||
}
|
||||
if (item.id === b.id) {
|
||||
return { ...item, attributes: { ...item.attributes, order: first } };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
return second;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { invalidate } from "#lib/api/lists.js";
|
||||
import type { Item, PiggyBank } from "#lib/api/types.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import { evalAmount } from "#lib/components/inputs/parse.js";
|
||||
import { Action } from "#lib/components/misc/action.svelte.js";
|
||||
import Status from "#lib/components/misc/Status.svelte";
|
||||
import { Button } from "#lib/components/ui/button/index.js";
|
||||
import { Input } from "#lib/components/ui/input/index.js";
|
||||
import { formatMoney } from "#lib/format.js";
|
||||
import {
|
||||
changeMoney,
|
||||
limit,
|
||||
type Mode,
|
||||
piggyMoney,
|
||||
type Room,
|
||||
} from "./piggy.js";
|
||||
|
||||
let {
|
||||
piggy,
|
||||
mode,
|
||||
rooms,
|
||||
onclose,
|
||||
}: {
|
||||
piggy: Item<PiggyBank>;
|
||||
mode: Mode;
|
||||
rooms: Map<string, Room>;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
const action = new Action();
|
||||
const values = $state<Record<string, string>>({});
|
||||
|
||||
const links = $derived(
|
||||
(piggy.attributes.accounts ?? []).map((link) => {
|
||||
const id = link.account_id ?? "";
|
||||
return {
|
||||
id,
|
||||
max: limit(piggy.attributes, id, mode, rooms.get(id)),
|
||||
name: link.name ?? id,
|
||||
};
|
||||
})
|
||||
);
|
||||
const open = $derived(links.filter((l) => l.max > 0));
|
||||
|
||||
function onEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message: string) {
|
||||
action.failed = true;
|
||||
action.message = message;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const deltas: Record<string, number> = {};
|
||||
for (const link of open) {
|
||||
const raw = values[link.id]?.trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
const amount = evalAmount(raw);
|
||||
if (amount === null || amount <= 0) {
|
||||
return fail("Enter an amount above zero");
|
||||
}
|
||||
if (amount > link.max + 1e-9) {
|
||||
const max = formatMoney(piggyMoney(piggy.attributes, link.max));
|
||||
return fail(`${link.name} allows at most ${max}`);
|
||||
}
|
||||
deltas[link.id] = mode === "add" ? amount : -amount;
|
||||
}
|
||||
if (Object.keys(deltas).length === 0) {
|
||||
return fail("Enter an amount");
|
||||
}
|
||||
const ok = await action.run(
|
||||
() => changeMoney(piggy, deltas),
|
||||
mode === "add" ? "Added" : "Removed"
|
||||
);
|
||||
if (ok) {
|
||||
await invalidate();
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}}
|
||||
>
|
||||
<span class="font-semibold">
|
||||
{mode === "add" ? "Add money to" : "Remove money from"}
|
||||
{piggy.attributes.name}
|
||||
</span>
|
||||
{#each open as link, i (link.id)}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if open.length > 1}
|
||||
<span>{link.name}</span>
|
||||
{/if}
|
||||
<Input
|
||||
aria-label="Amount for {link.name}"
|
||||
class="num h-7 w-28"
|
||||
inputmode="decimal"
|
||||
onkeydown={onEscape}
|
||||
bind:value={values[link.id]}
|
||||
{@attach (node) => {
|
||||
if (i === 0) {
|
||||
node.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span class="text-[12px] text-cladd-fg-soft">
|
||||
up to
|
||||
<Amount {...piggyMoney(piggy.attributes, link.max)} tone="neutral" />
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-cladd-fg-soft">
|
||||
{mode === "add"
|
||||
? "You have no money to put in this piggy bank."
|
||||
: "There is no money in this piggy bank."}
|
||||
</span>
|
||||
{/each}
|
||||
{#if open.length > 0}
|
||||
<Button disabled={action.busy} size="sm" type="submit">
|
||||
{mode === "add" ? "Add" : "Remove"}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={onclose} size="sm" type="button" variant="ghost"
|
||||
>Cancel <span class="kbd">Esc</span></Button
|
||||
>
|
||||
<Status {action} />
|
||||
</form>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { untrack } from "svelte";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { invalidate, lists } from "#lib/api/lists.js";
|
||||
import type { Item, One, PiggyBank } 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";
|
||||
|
||||
let { piggy }: { piggy?: Item<PiggyBank> } = $props();
|
||||
|
||||
const initial = untrack(() => piggy?.attributes);
|
||||
const form = $state({
|
||||
currency_code: initial?.currency_code ?? session.primary?.code ?? "",
|
||||
name: initial?.name ?? "",
|
||||
notes: initial?.notes ?? "",
|
||||
object_group_title: initial?.object_group_title ?? "",
|
||||
start_date: initial?.start_date?.slice(0, 10) ?? today(),
|
||||
target_amount: initial?.target_amount ?? "",
|
||||
target_date: initial?.target_date?.slice(0, 10) ?? "",
|
||||
});
|
||||
const linked = $state<Record<string, string>>(
|
||||
Object.fromEntries(
|
||||
(initial?.accounts ?? []).map((a) => [
|
||||
a.account_id ?? "",
|
||||
a.current_amount ?? "0",
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
||||
const accounts = $derived(
|
||||
[...(assets.data ?? []), ...(liabilities.data ?? [])].filter(
|
||||
(a) =>
|
||||
a.id in linked ||
|
||||
(a.attributes.active &&
|
||||
a.attributes.currency_code === form.currency_code)
|
||||
)
|
||||
);
|
||||
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
|
||||
);
|
||||
|
||||
const action = new Action();
|
||||
|
||||
function toggle(id: string, on: boolean) {
|
||||
if (on) {
|
||||
linked[id] = "0";
|
||||
} else {
|
||||
delete linked[id];
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = (entries: [string, string][]) =>
|
||||
JSON.stringify(
|
||||
entries
|
||||
.map(([id, amount]) => [id, amountString(amount, decimals) || "0"])
|
||||
.sort()
|
||||
);
|
||||
const before = snapshot(Object.entries(linked));
|
||||
|
||||
function body() {
|
||||
const entries = Object.entries(linked);
|
||||
return {
|
||||
accounts:
|
||||
piggy && snapshot(entries) === before
|
||||
? undefined
|
||||
: entries.map(([id, amount]) => ({
|
||||
account_id: id,
|
||||
current_amount: amountString(amount, decimals) || "0",
|
||||
})),
|
||||
name: form.name.trim(),
|
||||
notes: form.notes.trim() || null,
|
||||
object_group_title:
|
||||
form.object_group_title.trim() ||
|
||||
(initial?.object_group_title ? "" : undefined),
|
||||
start_date: form.start_date || today(),
|
||||
target_amount: amountString(form.target_amount, decimals) || "0",
|
||||
target_date: form.target_date || null,
|
||||
transaction_currency_code:
|
||||
form.currency_code === initial?.currency_code
|
||||
? undefined
|
||||
: form.currency_code,
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
let id = piggy?.id;
|
||||
const ok = await action.run(async () => {
|
||||
const saved = await api<One<PiggyBank>>(
|
||||
piggy ? `/v1/piggy-banks/${piggy.id}` : "/v1/piggy-banks",
|
||||
{ body: body(), method: piggy ? "PUT" : "POST" }
|
||||
);
|
||||
({ id } = saved.data);
|
||||
});
|
||||
if (ok) {
|
||||
await invalidate();
|
||||
await goto(`/piggy-banks/show/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!piggy) {
|
||||
return;
|
||||
}
|
||||
const ok = await action.run(() =>
|
||||
api(`/v1/piggy-banks/${piggy.id}`, { method: "DELETE" })
|
||||
);
|
||||
if (ok) {
|
||||
await goto("/piggy-banks");
|
||||
await invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Form",
|
||||
input: true,
|
||||
keys: "mod+Enter",
|
||||
label: piggy ? "Update piggy bank" : "Store new piggy bank",
|
||||
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="piggy-name" label="Name" problem={action.field("name")}>
|
||||
<Input id="piggy-name" required bind:value={form.name} />
|
||||
</Field>
|
||||
<Field
|
||||
for="piggy-target"
|
||||
help="Leave empty to save without a target."
|
||||
label="Target amount"
|
||||
problem={action.field("target_amount")}
|
||||
>
|
||||
<Input
|
||||
class="num"
|
||||
id="piggy-target"
|
||||
inputmode="decimal"
|
||||
bind:value={form.target_amount}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
for="piggy-currency"
|
||||
help="Piggy banks can only save money in a single currency."
|
||||
label="Currency"
|
||||
problem={action.field("transaction_currency_code")}
|
||||
>
|
||||
<select
|
||||
class={select}
|
||||
id="piggy-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
|
||||
help="Only accounts in the selected currency are listed. The amount is what this piggy bank holds on each account."
|
||||
label="Save on account(s)"
|
||||
problem={action.field("accounts") ??
|
||||
Object.entries(action.errors).find(([k]) =>
|
||||
k.startsWith("accounts.")
|
||||
)?.[1][0]}
|
||||
>
|
||||
<div
|
||||
class="cladd-cut flex max-h-64 flex-col overflow-y-auto rounded-cladd-md py-1"
|
||||
>
|
||||
{#each accounts as account (account.id)}
|
||||
{@const on = account.id in linked}
|
||||
<div class="flex h-8 items-center gap-2 px-2.5">
|
||||
<label class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<input
|
||||
checked={on}
|
||||
class="size-4 accent-(--ff-brand)"
|
||||
onchange={(e) => toggle(account.id, e.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
>
|
||||
<span class="truncate">{account.attributes.name}</span>
|
||||
<span class="text-[12px] text-cladd-fg-softer"
|
||||
>{account.attributes.currency_code}</span
|
||||
>
|
||||
</label>
|
||||
{#if on}
|
||||
<Input
|
||||
aria-label="Saved on {account.attributes.name}"
|
||||
class="num h-7 w-28"
|
||||
inputmode="decimal"
|
||||
bind:value={linked[account.id]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-2.5 py-1.5 text-cladd-fg-soft">
|
||||
{assets.isPending
|
||||
? "Loading accounts"
|
||||
: "No asset account or liability uses this currency."}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</Field>
|
||||
</Box>
|
||||
<Box body="flex flex-col gap-3 p-4" title="Optional fields">
|
||||
{#if piggy}
|
||||
<Field
|
||||
for="piggy-start"
|
||||
label="Start date"
|
||||
problem={action.field("start_date")}
|
||||
>
|
||||
<Input id="piggy-start" type="date" bind:value={form.start_date} />
|
||||
</Field>
|
||||
{/if}
|
||||
<Field
|
||||
for="piggy-target-date"
|
||||
label="Target date"
|
||||
problem={action.field("target_date")}
|
||||
>
|
||||
<Input
|
||||
id="piggy-target-date"
|
||||
type="date"
|
||||
bind:value={form.target_date}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
for="piggy-group"
|
||||
label="Group"
|
||||
problem={action.field("object_group_title")}
|
||||
>
|
||||
<GroupInput id="piggy-group" bind:value={form.object_group_title} />
|
||||
</Field>
|
||||
<Field for="piggy-notes" label="Notes" problem={action.field("notes")}>
|
||||
<textarea
|
||||
class={textarea}
|
||||
id="piggy-notes"
|
||||
rows="4"
|
||||
bind:value={form.notes}
|
||||
></textarea>
|
||||
</Field>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button disabled={action.busy} type="submit">
|
||||
{piggy ? "Update piggy bank" : "Store new piggy bank"}
|
||||
<span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
|
||||
</Button>
|
||||
<Status {action} />
|
||||
{#if piggy}
|
||||
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { api } from "#lib/api/client.js";
|
||||
import type { Account, Item, One, PiggyBank } from "#lib/api/types.js";
|
||||
import type { Money } from "#lib/format.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
|
||||
export type Mode = "add" | "remove";
|
||||
|
||||
export interface Room {
|
||||
account: Item<Account> | undefined;
|
||||
balance: number;
|
||||
earmarked: number;
|
||||
free: number;
|
||||
}
|
||||
|
||||
export function rooms(
|
||||
piggies: Item<PiggyBank>[],
|
||||
accounts: Item<Account>[]
|
||||
): Map<string, Room> {
|
||||
const byId = new Map(accounts.map((a) => [a.id, a]));
|
||||
const result = new Map<string, Room>();
|
||||
for (const piggy of piggies) {
|
||||
for (const link of piggy.attributes.accounts ?? []) {
|
||||
const id = link.account_id ?? "";
|
||||
const account = byId.get(id);
|
||||
const room = result.get(id) ?? {
|
||||
account,
|
||||
balance: toNumber(account?.attributes.current_balance),
|
||||
earmarked: 0,
|
||||
free: 0,
|
||||
};
|
||||
room.earmarked += toNumber(link.current_amount);
|
||||
room.free = room.balance - room.earmarked;
|
||||
result.set(id, room);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function limit(
|
||||
piggy: PiggyBank,
|
||||
accountId: string,
|
||||
mode: Mode,
|
||||
room: Room | undefined
|
||||
): number {
|
||||
const link = piggy.accounts?.find((a) => a.account_id === accountId);
|
||||
if (mode === "remove") {
|
||||
return toNumber(link?.current_amount);
|
||||
}
|
||||
const free = Math.max(0, room?.free ?? 0);
|
||||
const target = toNumber(piggy.target_amount);
|
||||
return target > 0 ? Math.min(free, toNumber(piggy.left_to_save)) : free;
|
||||
}
|
||||
|
||||
export const piggyMoney = (piggy: PiggyBank, amount: unknown): Money => ({
|
||||
amount: amount as string,
|
||||
code: piggy.currency_code,
|
||||
decimals: piggy.currency_decimal_places,
|
||||
symbol: piggy.currency_symbol,
|
||||
});
|
||||
|
||||
export async function changeMoney(
|
||||
piggy: Item<PiggyBank>,
|
||||
deltas: Record<string, number>
|
||||
): Promise<Item<PiggyBank>> {
|
||||
const decimals = piggy.attributes.currency_decimal_places ?? 2;
|
||||
const accounts = (piggy.attributes.accounts ?? []).map((link) => ({
|
||||
account_id: link.account_id,
|
||||
current_amount: (
|
||||
toNumber(link.current_amount) + (deltas[link.account_id ?? ""] ?? 0)
|
||||
).toFixed(decimals),
|
||||
}));
|
||||
const saved = await api<One<PiggyBank>>(`/v1/piggy-banks/${piggy.id}`, {
|
||||
body: { accounts },
|
||||
method: "PUT",
|
||||
});
|
||||
const after = new Map(
|
||||
(saved.data.attributes.accounts ?? []).map((a) => [
|
||||
a.account_id,
|
||||
toNumber(a.current_amount),
|
||||
])
|
||||
);
|
||||
const refused = accounts.find(
|
||||
(a) =>
|
||||
!after.has(a.account_id) ||
|
||||
Math.abs((after.get(a.account_id) ?? 0) - toNumber(a.current_amount)) >
|
||||
10 ** -decimals / 2
|
||||
);
|
||||
if (refused) {
|
||||
throw new Error(
|
||||
"Firefly kept the old amount: there is not enough money on the account"
|
||||
);
|
||||
}
|
||||
return saved.data;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{@render actions?.()}
|
||||
<ol
|
||||
aria-label="Breadcrumbs"
|
||||
|
||||
@@ -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