From 6f39343e0727851a0da8246f7eb48443b1f9c28c Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 15:05:56 +0000 Subject: [PATCH] feat(accounts): friends ledger for debts, settle up and offset, asset accounts by currency Liabilities show one card per friend with who owes whom, last movement, average days to pay back and twelve months of lending. s settles up, + lends more, o offsets two debts through the prefilled editor. Asset accounts group by currency with totals, inactive ones behind i. --- src/lib/accounts/ledger.ts | 228 +++++++++++ .../components/accounts/AccountShow.svelte | 65 +-- .../components/accounts/AccountsIndex.svelte | 158 +++++-- src/lib/components/accounts/FriendCard.svelte | 182 +++++++++ .../components/accounts/FriendPanel.svelte | 66 +++ src/lib/components/accounts/Ledger.svelte | 384 ++++++++++++++++++ src/lib/components/accounts/MonthBars.svelte | 68 ++++ .../accounts/[type=accounttype]/+page.svelte | 7 +- .../create/[type=txtype]/+page.svelte | 9 +- 9 files changed, 1103 insertions(+), 64 deletions(-) create mode 100644 src/lib/accounts/ledger.ts create mode 100644 src/lib/components/accounts/FriendCard.svelte create mode 100644 src/lib/components/accounts/FriendPanel.svelte create mode 100644 src/lib/components/accounts/Ledger.svelte create mode 100644 src/lib/components/accounts/MonthBars.svelte diff --git a/src/lib/accounts/ledger.ts b/src/lib/accounts/ledger.ts new file mode 100644 index 0000000..a97fa20 --- /dev/null +++ b/src/lib/accounts/ledger.ts @@ -0,0 +1,228 @@ +import type { Account, Item, TransactionGroup } from "#lib/api/types.js"; +import { addMonths, daysBetween, today } from "#lib/dates.js"; +import { toNumber } from "#lib/format.js"; + +export interface Movement { + amount: number; + counter: { id: string; name: string; type: string }; + date: string; + description: string; + group: string; + into: boolean; + opening: boolean; +} + +export interface Month { + gave: number; + got: number; + month: string; +} + +const OPENING = new Set(["opening balance", "liability credit"]); +const ASSET = "Asset account"; +const DUST = 0.005; + +export const isFriend = (item: Item) => + item.attributes.liability_type === "debt"; + +export function owed(account: Account): number { + const debt = toNumber(account.debt_amount); + return account.liability_direction === "debit" ? -debt : debt; +} + +export function movements( + account: Item, + groups: Item[] +): Movement[] { + const { id } = account; + const code = account.attributes.currency_code; + const list: Movement[] = []; + for (const group of groups) { + for (const split of group.attributes.transactions) { + const into = split.destination_id === id; + if (!(into || split.source_id === id)) { + continue; + } + const foreign = + split.currency_code !== code && split.foreign_currency_code === code; + const side = into ? "source" : "destination"; + list.push({ + amount: Math.abs( + toNumber(foreign ? split.foreign_amount : split.amount) + ), + counter: { + id: split[`${side}_id`] ?? "", + name: split[`${side}_name`] ?? "", + type: split[`${side}_type`] ?? "", + }, + date: split.date.slice(0, 10), + description: split.description, + group: group.id, + into, + opening: OPENING.has(split.type), + }); + } + } + return list.sort((a, b) => b.date.localeCompare(a.date)); +} + +export function averageDays(list: Movement[], credit: boolean): number | null { + const open: { amount: number; date: string }[] = []; + let weighted = 0; + let total = 0; + for (const m of list.toReversed()) { + if (m.into === credit) { + open.push({ amount: m.amount, date: m.date }); + continue; + } + let left = m.amount; + while (left > DUST && open.length > 0) { + const [head] = open; + const used = Math.min(left, head.amount); + weighted += used * daysBetween(head.date, m.date); + total += used; + left -= used; + head.amount -= used; + if (head.amount < DUST) { + open.shift(); + } + } + } + return total > 0 ? Math.round(weighted / total) : null; +} + +export function months(list: Movement[], count = 12): Month[] { + const first = addMonths(`${today().slice(0, 7)}-01`, 1 - count); + const buckets = Array.from({ length: count }, (_, i) => ({ + gave: 0, + got: 0, + month: addMonths(first, i).slice(0, 7), + })); + const index = new Map(buckets.map((b, i) => [b.month, i])); + for (const m of list) { + const bucket = buckets[index.get(m.date.slice(0, 7)) ?? -1]; + if (!bucket || m.opening) { + continue; + } + if (m.into) { + bucket.gave += m.amount; + } else { + bucket.got += m.amount; + } + } + return buckets; +} + +const createUrl = (type: string, query: Record) => + `/transactions/create/${type}?${new URLSearchParams( + Object.entries(query).filter((entry): entry is [string, string] => + Boolean(entry[1]) + ) + )}`; + +function counterAsset( + list: Movement[], + assets: Item[], + code: string | undefined +): string | undefined { + const usable = assets + .filter((a) => a.attributes.active && a.attributes.currency_code === code) + .map((a) => a.id); + return ( + list.find((m) => usable.includes(m.counter.id))?.counter.id ?? usable[0] + ); +} + +const lastSaid = (list: Movement[], into: boolean) => + list.find((m) => m.into === into && m.counter.type === ASSET)?.description; + +const plain = (account: Account, value: number) => + Math.abs(value).toFixed(account.currency_decimal_places ?? 2); + +interface Action { + href: string; + label: string; +} + +export interface Actions { + lend: Action; + offset: Action; + settle: Action | null; +} + +function settleAction(item: Item, list: Movement[], asset?: string) { + const a = item.attributes; + const position = owed(a); + if (Math.abs(position) < DUST) { + return null; + } + const into = position < 0; + return { + href: createUrl(into ? "withdrawal" : "deposit", { + amount: plain(a, position), + description: + lastSaid(list, into) ?? + (into ? `Paid back ${a.name}` : `${a.name} paid back`), + destination: into ? item.id : asset, + source: into ? asset : item.id, + }), + label: `Settle up with ${a.name}`, + }; +} + +function lendAction(item: Item, list: Movement[], asset?: string) { + const a = item.attributes; + const credit = a.liability_direction !== "debit"; + return { + href: createUrl(credit ? "withdrawal" : "deposit", { + description: + lastSaid(list, credit) ?? + (credit ? `Lent ${a.name}` : `Borrowed from ${a.name}`), + destination: credit ? item.id : asset, + source: credit ? asset : item.id, + }), + label: credit ? `Lend more to ${a.name}` : `Borrow more from ${a.name}`, + }; +} + +function offsetAction(item: Item, debts: Item[]) { + const a = item.attributes; + const position = owed(a); + const [other] = debts + .filter( + (d) => + d.attributes.currency_code === a.currency_code && + owed(d.attributes) * position < 0 + ) + .sort( + (x, y) => Math.abs(owed(y.attributes)) - Math.abs(owed(x.attributes)) + ); + const amount = other + ? Math.min(Math.abs(position), Math.abs(owed(other.attributes))) + : position; + const out = position >= 0; + const names = other ? `${a.name} and ${other.attributes.name}` : a.name; + return { + href: createUrl("transfer", { + amount: amount ? plain(a, amount) : undefined, + description: `Offset ${names}`, + destination: out ? other?.id : item.id, + source: out ? item.id : other?.id, + }), + label: `Offset ${a.name} against ${other?.attributes.name ?? "another debt"}`, + }; +} + +export function actionsFor( + item: Item, + list: Movement[], + assets: Item[], + debts: Item[] +): Actions { + const asset = counterAsset(list, assets, item.attributes.currency_code); + return { + lend: lendAction(item, list, asset), + offset: offsetAction(item, debts), + settle: settleAction(item, list, asset), + }; +} diff --git a/src/lib/components/accounts/AccountShow.svelte b/src/lib/components/accounts/AccountShow.svelte index e51f1e3..241daf0 100644 --- a/src/lib/components/accounts/AccountShow.svelte +++ b/src/lib/components/accounts/AccountShow.svelte @@ -32,6 +32,7 @@ import { goto } from "$app/navigation"; import { page } from "$app/state"; import Breakdown from "./Breakdown.svelte"; + import FriendPanel from "./FriendPanel.svelte"; import { canReconcile, createUrl, @@ -69,6 +70,7 @@ const a = $derived(account.data?.data.attributes); const type = $derived(a ? typeOf(a) : "asset"); const item = $derived(account.data?.data); + const debt = $derived(a?.liability_type === "debt"); const first = createQuery(() => ({ enabled: everything, @@ -301,34 +303,45 @@ {/if}
- -
+ {/if} + - {#if chart.isPending || (everything && first.isPending)} -
- {:else if chart.isError} -

{chart.error.message}

- {:else if series.length === 0} -

No balance to draw.

- {:else} - - {/if} -
-
+
+ {#if chart.isPending || (everything && first.isPending)} +
+ {:else if chart.isError} +

{chart.error.message}

+ {:else if series.length === 0} +

+ No balance to draw. +

+ {:else} + + {/if} +
+ +
{#if !everything}
diff --git a/src/lib/components/accounts/AccountsIndex.svelte b/src/lib/components/accounts/AccountsIndex.svelte index fcdea2a..cfdf707 100644 --- a/src/lib/components/accounts/AccountsIndex.svelte +++ b/src/lib/components/accounts/AccountsIndex.svelte @@ -13,6 +13,7 @@ import Sparkline from "#lib/components/charts/Sparkline.svelte"; import { balanceRange, + byCurrency, type ChartSet, seriesPoints, } from "#lib/components/dashboard/data.js"; @@ -123,15 +124,33 @@ let armed = $state(false); let failure = $state(""); + const ROLE_RANK: Record = { + cashWalletAsset: 0, + ccAsset: 1, + defaultAsset: 2, + savingAsset: 4, + sharedAsset: 3, + }; + const perCurrency = $derived(type === "asset" && active); + const showInactive = $derived( + perCurrency && page.url.searchParams.has("inactive") + ); + const accounts = createQuery(() => ({ placeholderData: keepPreviousData, queryFn: () => all("/v1/accounts", { ...balanceRange(period.start, period.end), - active: String(active), + ...(showInactive ? {} : { active: String(active) }), type, }), - queryKey: ["accounts-index", type, active, period.start, period.end], + queryKey: [ + "accounts-index", + type, + showInactive ? "all" : active, + period.start, + period.end, + ], })); const others = createQuery(() => ({ queryFn: () => @@ -228,8 +247,12 @@ const sorted = $derived.by(() => { const list = [...matching]; if (!sortKey) { + const rank = (item: Item) => + perCurrency ? (ROLE_RANK[item.attributes.account_role ?? ""] ?? 5) : 0; return list.sort( - (x, y) => (x.attributes.order ?? 0) - (y.attributes.order ?? 0) + (x, y) => + rank(x) - rank(y) || + (x.attributes.order ?? 0) - (y.attributes.order ?? 0) ); } const dir = descending ? -1 : 1; @@ -243,9 +266,15 @@ }); }); const grouped = $derived( - sorted.some((item) => item.attributes.object_group_title) + perCurrency || sorted.some((item) => item.attributes.object_group_title) ); const groups = $derived.by(() => { + if (perCurrency) { + return byCurrency( + sorted, + (item) => item.attributes.currency_code ?? "" + ).map(([code, items]) => ({ items, title: code })); + } if (!grouped) { return [{ items: sorted, title: "" }]; } @@ -327,6 +356,18 @@ } }, }, + ...(perCurrency + ? [ + { + group: "Accounts", + keys: "i", + label: showInactive + ? "Hide inactive accounts" + : "Show inactive accounts", + run: () => goto(inactiveHref(), { replace: true }), + }, + ] + : []), ], id: "accounts", items: () => rows, @@ -335,6 +376,18 @@ }); nav.use(); + function inactiveHref() { + const url = new URL(page.url.href); + if (showInactive) { + url.searchParams.delete("inactive"); + } else { + url.searchParams.set("inactive", "1"); + } + url.searchParams.set("start", period.start); + url.searchParams.set("end", period.end); + return url.pathname + url.search; + } + function onfilterkey(event: KeyboardEvent) { if (event.key === "Escape" && filter) { filter = ""; @@ -417,6 +470,47 @@ } +{#snippet sums( + t: Total, + label: string, + head = false +)} + {#each columns as col, c (col.key)} + + {#if c === 0} + {label} + {#if head} + + {/if} + {:else if col.key === "balance"} + {formatMoney({ ...t, amount: t.balance })} + {:else if col.key === "due"} + {formatMoney({ ...t, amount: t.due })} + {:else if col.key === "difference"} + + {/if} + + {/each} +{/snippet} + {#each groups as group (group.title)} - {#if grouped} + {#if perCurrency} + {@const t = totals.find((x) => x.code === group.title)} + + {#if t} + {@render sums(t, group.title, true)} + {/if} + + {:else if grouped} nav.click(i, event)} @@ -603,38 +706,11 @@ {/if} - {#if totals.length > 0} + {#if totals.length > 0 && !perCurrency} {#each totals as t (t.code)} - {#each columns as col, c (col.key)} - - {#if c === 0} - Total in {t.code} - {:else if col.key === "balance"} - {formatMoney({ ...t, amount: t.balance })} - {:else if col.key === "due"} - {formatMoney({ ...t, amount: t.due })} - {:else if col.key === "difference"} - - {/if} - - {/each} + {@render sums(t, `Total in ${t.code}`)} {/each} @@ -690,7 +766,17 @@ {#if failure} {failure} {/if} - {#if active && otherCount > 0} + {#if perCurrency && (showInactive || otherCount > 0)} + {showInactive + ? "Hide inactive accounts" + : `Show ${otherCount} inactive ${otherCount === 1 ? "account" : "accounts"}`} + i + {:else if active && otherCount > 0} + import { + type Actions, + averageDays, + type Movement, + months, + owed, + } from "#lib/accounts/ledger.js"; + import type { Account, Item } from "#lib/api/types.js"; + import Amount from "#lib/components/Amount.svelte"; + import { daysBetween, today } from "#lib/dates.js"; + import { formatDate, formatMoney } from "#lib/format.js"; + import { showUrl as transactionUrl } from "#lib/transactions/split.js"; + import { showUrl } from "./links.js"; + import MonthBars from "./MonthBars.svelte"; + + const BUTTON = + "cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-fg"; + + let { + item, + list, + actions, + keys, + rows = 5, + current = false, + id, + onfocusin, + }: { + item: Item; + list: Movement[] | undefined; + actions: Actions | null; + keys: { lend: string; offset: string; settle: string }; + rows?: number; + current?: boolean; + id?: string; + onfocusin?: () => void; + } = $props(); + + const a = $derived(item.attributes); + const credit = $derived(a.liability_direction !== "debit"); + const position = $derived(owed(a)); + const cur = $derived({ + code: a.currency_code, + decimals: a.currency_decimal_places, + symbol: a.currency_symbol, + }); + const format = (value: number) => formatMoney({ ...cur, amount: value }); + const words = $derived( + credit + ? { gave: "Lent", got: "Got back", pays: "Pays back in" } + : { gave: "Paid back", got: "Borrowed", pays: "I pay back in" } + ); + const bars = $derived(list ? months(list) : []); + const gave = $derived(bars.reduce((s, m) => s + m.gave, 0)); + const got = $derived(bars.reduce((s, m) => s + m.got, 0)); + const average = $derived(list ? averageDays(list, credit) : null); + const last = $derived(list?.[0]); + const shown = $derived(list?.slice(0, rows) ?? []); + const tone = $derived.by(() => { + if (position > 0) { + return "text-income"; + } + return position < 0 ? "text-expense" : "text-cladd-fg-soft"; + }); + + function ago(date: string) { + const days = daysBetween(date, today()); + if (days === 0) { + return "today"; + } + return days === 1 ? "yesterday" : `${days} days ago`; + } + + +
+
+
+

+ {#if position < 0} + I owe + {/if} + {a.name} + {#if position > 0} + owes me + {:else if position === 0} + and I are even + {/if} +

+

+ {format(Math.abs(position))} +

+
+ +
+
Last movement
+
+ {last ? `${formatDate(last.date)}, ${ago(last.date)}` : "none yet"} +
+
{words.pays}
+
+ {average === null ? "nothing yet" : `${average} days on average`} +
+
{words.gave} in 12 months
+
{format(gave)}
+
{words.got} in 12 months
+
{format(got)}
+
+ + + + {#if rows > 0} + + + {#each shown as m, i (`${m.group}-${i}`)} + + + + + + {:else} + + + + {/each} + +
+ {formatDate(m.date, "short")} + + {m.opening ? "Opening balance" : m.description} + + +
+ {list ? "No movements yet" : ""} +
+ {/if} +
+ + {#if actions} + + {/if} +
diff --git a/src/lib/components/accounts/FriendPanel.svelte b/src/lib/components/accounts/FriendPanel.svelte new file mode 100644 index 0000000..b54af40 --- /dev/null +++ b/src/lib/components/accounts/FriendPanel.svelte @@ -0,0 +1,66 @@ + + + diff --git a/src/lib/components/accounts/Ledger.svelte b/src/lib/components/accounts/Ledger.svelte new file mode 100644 index 0000000..d443ddf --- /dev/null +++ b/src/lib/components/accounts/Ledger.svelte @@ -0,0 +1,384 @@ + + +{#snippet totals( + label: string, + list: Sum[], + key: "owe" | "owed" +)} +
+ {label} + {#each list as sum (sum.code)} + {formatMoney({ ...sum, amount: sum[key] })} + {:else} + 0 + {/each} +
+{/snippet} + + + {#snippet actions()} + + + Create a new liability + + {/snippet} + + +{#if liabilities.isPending} +
+{:else if liabilities.isError} +

{liabilities.error.message}

+{:else} +
+ {#if debts.length > 0} +
+ {@render totals("Owed to me", owedToMe, "owed")} + {@render totals("I owe", iOwe, "owe")} +
+
+ {#each friends as item (item.id)} + {@const i = index.get(item.id) ?? -1} + (nav.cursor = i)} + /> + {/each} +
+ {:else} +
+

No debts with friends yet

+ Create a new liability +
+ {/if} + + {#if others.length > 0} + +
+ + + + + + + + + + + + {#each others as item (item.id)} + {@const a = item.attributes} + {@const i = index.get(item.id) ?? -1} + nav.click(i, event)} + > + + + + + + + {/each} + +
Name + Type of liability + + Interest + + Last activity + + Amount due +
+ {a.name} + + {LIABILITY_LABEL[a.liability_type ?? ""] ?? ""} + + {toNumber(a.interest)}% + {PERIOD_LABEL[a.interest_period ?? ""] ?? + a.interest_period} + + {a.last_activity + ? formatDate(a.last_activity.slice(0, 10)) + : "never"} + + +
+
+
+ {/if} + + {#if (inactive.data?.meta.pagination?.total ?? 0) > 0} + {@const count = inactive.data?.meta.pagination?.total ?? 0} + {count} + inactive {count === 1 ? "liability" : "liabilities"} + {/if} +
+{/if} diff --git a/src/lib/components/accounts/MonthBars.svelte b/src/lib/components/accounts/MonthBars.svelte new file mode 100644 index 0000000..d52e71b --- /dev/null +++ b/src/lib/components/accounts/MonthBars.svelte @@ -0,0 +1,68 @@ + + + diff --git a/src/routes/accounts/[type=accounttype]/+page.svelte b/src/routes/accounts/[type=accounttype]/+page.svelte index a06d464..d2537c5 100644 --- a/src/routes/accounts/[type=accounttype]/+page.svelte +++ b/src/routes/accounts/[type=accounttype]/+page.svelte @@ -1,11 +1,16 @@ {#key type} - + {#if type === "liabilities"} + + {:else} + + {/if} {/key} diff --git a/src/routes/transactions/create/[type=txtype]/+page.svelte b/src/routes/transactions/create/[type=txtype]/+page.svelte index 29b45f6..908ed3b 100644 --- a/src/routes/transactions/create/[type=txtype]/+page.svelte +++ b/src/routes/transactions/create/[type=txtype]/+page.svelte @@ -22,6 +22,13 @@ }); const draftKey = $derived(page.url.searchParams.get("draft")); + function blank() { + const form = emptyForm(type); + form.splits[0].amount = page.url.searchParams.get("amount") ?? ""; + form.splits[0].description = page.url.searchParams.get("description") ?? ""; + return form; + } + const clone = createQuery(() => ({ enabled: Boolean(cloneId), queryFn: () => api>(`/v1/transactions/${cloneId}`), @@ -42,6 +49,6 @@ could not be loaded, so this is an empty form.

{/if} - + {/if} {/key}