From 54895b9305a60629e2dccd125bd3a35e40328376 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 18:22:01 +0000 Subject: [PATCH] feat(accounts): debts in general words, actions named by what they record Cards are titled by the account name with the position on its own line: owes you, you owe, settled, and paid back more than borrowed when a debt crossed zero against its direction, so one account per person covers both directions. Actions read Record repayment, Lend more, Pay back, Borrow more and Offset against another debt, which says what it moves. No more friends in the copy; the dashboard box is Debts. --- src/lib/accounts/ledger.ts | 145 ++++++++++------ .../components/accounts/AccountShow.svelte | 4 +- .../{FriendCard.svelte => DebtCard.svelte} | 164 +++++++++++------- src/lib/components/accounts/DebtPanel.svelte | 61 +++++++ .../components/accounts/FriendPanel.svelte | 66 ------- src/lib/components/accounts/Ledger.svelte | 119 +++++-------- src/lib/components/accounts/MonthBars.svelte | 30 ++-- src/lib/components/accounts/Statement.svelte | 18 +- .../accounts/StatementHeader.svelte | 22 +-- src/lib/components/accounts/links.ts | 4 +- src/lib/components/dashboard/DebtsBox.svelte | 55 +++--- 11 files changed, 352 insertions(+), 336 deletions(-) rename src/lib/components/accounts/{FriendCard.svelte => DebtCard.svelte} (50%) create mode 100644 src/lib/components/accounts/DebtPanel.svelte delete mode 100644 src/lib/components/accounts/FriendPanel.svelte diff --git a/src/lib/accounts/ledger.ts b/src/lib/accounts/ledger.ts index a97fa20..c408e87 100644 --- a/src/lib/accounts/ledger.ts +++ b/src/lib/accounts/ledger.ts @@ -1,6 +1,6 @@ import type { Account, Item, TransactionGroup } from "#lib/api/types.js"; import { addMonths, daysBetween, today } from "#lib/dates.js"; -import { toNumber } from "#lib/format.js"; +import { formatMoney, toNumber } from "#lib/format.js"; export interface Movement { amount: number; @@ -13,16 +13,18 @@ export interface Movement { } export interface Month { - gave: number; - got: number; month: string; + received: number; + sent: number; } +export type Stance = "owed" | "owe" | "settled"; + const OPENING = new Set(["opening balance", "liability credit"]); const ASSET = "Asset account"; const DUST = 0.005; -export const isFriend = (item: Item) => +export const isDebt = (item: Item) => item.attributes.liability_type === "debt"; export function owed(account: Account): number { @@ -30,6 +32,17 @@ export function owed(account: Account): number { return account.liability_direction === "debit" ? -debt : debt; } +export function stance(account: Account): Stance { + const position = owed(account); + if (position > DUST) { + return "owed"; + } + return position < -DUST ? "owe" : "settled"; +} + +export const overpaid = (account: Account) => + toNumber(account.debt_amount) < -DUST; + export function movements( account: Item, groups: Item[] @@ -94,9 +107,9 @@ export function averageDays(list: Movement[], credit: boolean): number | 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), + received: 0, + sent: 0, })); const index = new Map(buckets.map((b, i) => [b.month, i])); for (const m of list) { @@ -105,9 +118,9 @@ export function months(list: Movement[], count = 12): Month[] { continue; } if (m.into) { - bucket.gave += m.amount; + bucket.sent += m.amount; } else { - bucket.got += m.amount; + bucket.received += m.amount; } } return buckets; @@ -139,50 +152,77 @@ const lastSaid = (list: Movement[], into: boolean) => const plain = (account: Account, value: number) => Math.abs(value).toFixed(account.currency_decimal_places ?? 2); -interface Action { +const money = (account: Account, value: number) => + formatMoney({ + amount: Math.abs(value), + code: account.currency_code, + decimals: account.currency_decimal_places, + symbol: account.currency_symbol, + }); + +export interface Action { + hint: string; href: string; label: string; + line?: string; } export interface Actions { - lend: Action; - offset: Action; - settle: Action | null; + more: Action; + offset: Action | null; + repay: Action | null; } -function settleAction(item: Item, list: Movement[], asset?: string) { +interface Move { + amount?: string; + asset?: string; + fallback: string; + label: string; + toThem: boolean; +} + +function moveAction(item: Item, list: Movement[], move: Move) { const a = item.attributes; - const position = owed(a); - if (Math.abs(position) < DUST) { + const said = overpaid(a) ? undefined : lastSaid(list, move.toThem); + return { + hint: `${move.label}: ${a.name}`, + href: createUrl(move.toThem ? "withdrawal" : "deposit", { + amount: move.amount, + description: said ?? move.fallback, + destination: move.toThem ? item.id : move.asset, + source: move.toThem ? move.asset : item.id, + }), + label: move.label, + }; +} + +function repayAction(item: Item, list: Movement[], asset?: string) { + const a = item.attributes; + const side = stance(a); + if (side === "settled") { 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}`, - }; + const toThem = side === "owe"; + return moveAction(item, list, { + amount: plain(a, owed(a)), + asset, + fallback: toThem ? "Paid back" : "Repayment", + label: toThem ? "Pay back" : "Record repayment", + toThem, + }); } -function lendAction(item: Item, list: Movement[], asset?: string) { +function moreAction(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}`, - }; + const side = stance(a); + const toThem = + side === "settled" ? a.liability_direction !== "debit" : side === "owed"; + return moveAction(item, list, { + asset, + fallback: toThem ? "Lent" : "Borrowed", + label: toThem ? "Lend more" : "Borrow more", + toThem, + }); } function offsetAction(item: Item, debts: Item[]) { @@ -197,19 +237,22 @@ function offsetAction(item: Item, debts: Item[]) { .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; + if (!other) { + return null; + } + const amount = Math.min(Math.abs(position), Math.abs(owed(other.attributes))); + const [from, to] = position > 0 ? [item, other] : [other, item]; + const label = "Offset against another debt"; return { + hint: `${label}: ${a.name}`, href: createUrl("transfer", { - amount: amount ? plain(a, amount) : undefined, - description: `Offset ${names}`, - destination: out ? other?.id : item.id, - source: out ? item.id : other?.id, + amount: plain(a, amount), + description: `Offset ${from.attributes.name} against ${to.attributes.name}`, + destination: to.id, + source: from.id, }), - label: `Offset ${a.name} against ${other?.attributes.name ?? "another debt"}`, + label, + line: `Moves ${money(a, amount)} from ${from.attributes.name} to ${to.attributes.name} so both debts shrink.`, }; } @@ -221,8 +264,8 @@ export function actionsFor( ): Actions { const asset = counterAsset(list, assets, item.attributes.currency_code); return { - lend: lendAction(item, list, asset), + more: moreAction(item, list, asset), offset: offsetAction(item, debts), - settle: settleAction(item, list, asset), + repay: repayAction(item, list, asset), }; } diff --git a/src/lib/components/accounts/AccountShow.svelte b/src/lib/components/accounts/AccountShow.svelte index 9944a2e..51760ac 100644 --- a/src/lib/components/accounts/AccountShow.svelte +++ b/src/lib/components/accounts/AccountShow.svelte @@ -33,7 +33,7 @@ import { goto } from "$app/navigation"; import { page } from "$app/state"; import Breakdown from "./Breakdown.svelte"; - import FriendPanel from "./FriendPanel.svelte"; + import DebtPanel from "./DebtPanel.svelte"; import { canReconcile, createUrl, @@ -322,7 +322,7 @@ : ""}" > {#if debt && item} - + {/if} import { + type Action, type Actions, averageDays, type Movement, months, + overpaid, owed, + stance, } from "#lib/accounts/ledger.js"; import type { Account, Item } from "#lib/api/types.js"; import Amount from "#lib/components/Amount.svelte"; @@ -16,6 +19,7 @@ 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"; + const PRIMARY = `${BUTTON} cladd-color-green text-cladd-primary`; let { item, @@ -23,6 +27,7 @@ actions, keys, rows = 5, + named = true, current = false, id, onfocusin, @@ -30,8 +35,9 @@ item: Item; list: Movement[] | undefined; actions: Actions | null; - keys: { lend: string; offset: string; settle: string }; + keys: Record; rows?: number; + named?: boolean; current?: boolean; id?: string; onfocusin?: () => void; @@ -39,30 +45,20 @@ const a = $derived(item.attributes); const credit = $derived(a.liability_direction !== "debit"); - const position = $derived(owed(a)); + const side = $derived(stance(a)); + const amount = $derived(Math.abs(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 sent = $derived(bars.reduce((s, m) => s + m.sent, 0)); + const received = $derived(bars.reduce((s, m) => s + m.received, 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()); @@ -73,6 +69,19 @@ } +{#snippet button( + action: Action, + key: string, + primary = false +)} + {action.label} {key} +{/snippet} +
-

- {#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))} -

+ {#if named} +

+ {a.name} +

+ {/if} + {#if side === "settled"} +

+ Settled +

+ {:else} +

+ {side === "owed" ? "Owes you" : "You owe"} + {format(amount)} +

+ {/if} + {#if overpaid(a)} +

+ {credit ? "Paid you back" : "You paid back"} + {format(amount)} + more than borrowed +

+ {/if}
-
-
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 last} +
+
Last movement
+
{formatDate(last.date)}, {ago(last.date)}
+ {#if average === null} +
No repayments yet
+ {:else} +
+ {credit ? "Usually pays back in" : "You usually pay back in"} +
+
+ {average} + {average === 1 ? "day" : "days"} +
+ {/if} + {#if received > 0} +
Received in 12 months
+
{format(received)}
+ {/if} + {#if sent > 0} +
Sent in 12 months
+
{format(sent)}
+ {/if} +
+ {/if} - + {#if received + sent > 0} + + {/if} {#if rows > 0} @@ -139,6 +176,7 @@ @@ -157,26 +195,20 @@ {#if actions} {/if} diff --git a/src/lib/components/accounts/DebtPanel.svelte b/src/lib/components/accounts/DebtPanel.svelte new file mode 100644 index 0000000..41d67ad --- /dev/null +++ b/src/lib/components/accounts/DebtPanel.svelte @@ -0,0 +1,61 @@ + + + diff --git a/src/lib/components/accounts/FriendPanel.svelte b/src/lib/components/accounts/FriendPanel.svelte deleted file mode 100644 index b54af40..0000000 --- a/src/lib/components/accounts/FriendPanel.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/src/lib/components/accounts/Ledger.svelte b/src/lib/components/accounts/Ledger.svelte index d443ddf..f704178 100644 --- a/src/lib/components/accounts/Ledger.svelte +++ b/src/lib/components/accounts/Ledger.svelte @@ -2,10 +2,13 @@ import Plus from "@lucide/svelte/icons/plus"; import { createQuery } from "@tanstack/svelte-query"; import { + type Action, actionsFor, - isFriend, + isDebt, movements, owed, + type Stance, + stance, } from "#lib/accounts/ledger.js"; import { api } from "#lib/api/client.js"; import { all, lists } from "#lib/api/lists.js"; @@ -17,13 +20,14 @@ } from "#lib/api/types.js"; import Amount from "#lib/components/Amount.svelte"; import Box from "#lib/components/Box.svelte"; + import Money from "#lib/components/Money.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte"; import { today } from "#lib/dates.js"; - import { formatDate, formatMoney, toNumber } from "#lib/format.js"; + import { formatDate, toNumber } from "#lib/format.js"; import type { Binding } from "#lib/keys.svelte.js"; import { ListNav } from "#lib/list-nav.svelte.js"; import { goto } from "$app/navigation"; - import FriendCard from "./FriendCard.svelte"; + import DebtCard from "./DebtCard.svelte"; import { editUrl, LIABILITY_LABEL, @@ -32,7 +36,9 @@ TYPE_ONE, } from "./links.js"; - const KEYS = { lend: "+", offset: "o", settle: "s" }; + const KEYS = { more: "+", offset: "o", repay: "s" }; + const ALIASES = { more: "", offset: "=", repay: "S" }; + const ORDER: Stance[] = ["owed", "owe", "settled"]; const CREATE = "/accounts/create/liabilities"; const liabilities = createQuery(() => ({ @@ -53,9 +59,9 @@ })); const assets = createQuery(() => lists.accounts("asset")); - const debts = $derived((liabilities.data ?? []).filter(isFriend)); + const debts = $derived((liabilities.data ?? []).filter(isDebt)); const others = $derived( - (liabilities.data ?? []).filter((item) => !isFriend(item)) + (liabilities.data ?? []).filter((item) => !isDebt(item)) ); const history = createQuery(() => ({ enabled: debts.length > 0, @@ -79,26 +85,20 @@ queryKey: ["ledger-history", debts.map((d) => d.id).join(",")], })); - const rank = (item: Item) => { - const position = owed(item.attributes); - if (position > 0) { - return 0; - } - return position < 0 ? 1 : 2; - }; + const rank = (item: Item) => ORDER.indexOf(stance(item.attributes)); const lastDate = (item: Item) => history.data?.get(item.id)?.[0]?.date ?? ""; - const friends = $derived( + const cards = $derived( debts.toSorted( (x, y) => rank(x) - rank(y) || lastDate(y).localeCompare(lastDate(x)) ) ); - const items = $derived([...friends, ...others]); + const items = $derived([...cards, ...others]); const index = $derived(new Map(items.map((item, i) => [item.id, i]))); const plans = $derived( new Map( - friends.map((item) => [ + cards.map((item) => [ item.id, history.data && assets.data ? actionsFor( @@ -140,25 +140,26 @@ } return [...map.values()]; }); - const owedToMe = $derived(sums.filter((s) => s.owed > 0)); - const iOwe = $derived(sums.filter((s) => s.owe > 0)); + const owedToYou = $derived(sums.filter((s) => s.owed > 0)); + const youOwe = $derived(sums.filter((s) => s.owe > 0)); function current(): Item | undefined { return nav.current; } - function act(kind: "settle" | "lend" | "offset") { - const plan = plans.get(current()?.id ?? ""); - const target = plan?.[kind]; - if (target) { - goto(target.href); + function bind(kind: keyof typeof KEYS, action?: Action | null): Binding[] { + if (!action) { + return []; } + const run = () => goto(action.href); + const main = { group: "Debts", keys: KEYS[kind], label: action.hint, run }; + const alias = ALIASES[kind]; + return alias ? [main, { ...main, hidden: true, keys: alias }] : [main]; } const nav = new ListNav>({ edit: (item) => goto(editUrl(item.id)), extra: (): Binding[] => { - const name = current()?.attributes.name ?? "a friend"; const plan = plans.get(current()?.id ?? ""); return [ { @@ -172,38 +173,9 @@ } }, }, - { - group: "Friends", - keys: "s", - label: plan?.settle?.label ?? `Settle up with ${name}`, - run: () => act("settle"), - }, - { - group: "Friends", - hidden: true, - keys: "S", - label: "Settle up", - run: () => act("settle"), - }, - { - group: "Friends", - keys: "+", - label: plan?.lend.label ?? `Lend more to ${name}`, - run: () => act("lend"), - }, - { - group: "Friends", - keys: "o", - label: plan?.offset.label ?? `Offset ${name} against another debt`, - run: () => act("offset"), - }, - { - group: "Friends", - hidden: true, - keys: "=", - label: "Offset", - run: () => act("offset"), - }, + ...bind("repay", plan?.repay), + ...bind("more", plan?.more), + ...bind("offset", plan?.offset), { group: "Accounts", keys: "c", @@ -233,19 +205,18 @@ )}
{label} - {#each list as sum (sum.code)} - {formatMoney({ ...sum, amount: sum[key] })} - {:else} - 0 - {/each} + + {#if list.length > 0} + ({ ...sum, amount: sum[key] }))} + tone={key === "owed" ? "income" : "expense"} + /> + {:else} + 0 + {/if} +
{/snippet} @@ -272,13 +243,13 @@
- {@render totals("Owed to me", owedToMe, "owed")} - {@render totals("I owe", iOwe, "owe")} + {@render totals("Owed to you", owedToYou, "owed")} + {@render totals("You owe", youOwe, "owe")}
- {#each friends as item (item.id)} + {#each cards as item (item.id)} {@const i = index.get(item.id) ?? -1} - -

No debts with friends yet

+

No debts yet

Create a new liability diff --git a/src/lib/components/accounts/MonthBars.svelte b/src/lib/components/accounts/MonthBars.svelte index d52e71b..d3addd9 100644 --- a/src/lib/components/accounts/MonthBars.svelte +++ b/src/lib/components/accounts/MonthBars.svelte @@ -4,17 +4,15 @@ let { months, - gave, - got, format, }: { months: Month[]; - gave: string; - got: string; format: (value: number) => string; } = $props(); - const max = $derived(Math.max(1, ...months.flatMap((m) => [m.gave, m.got]))); + const max = $derived( + Math.max(1, ...months.flatMap((m) => [m.sent, m.received])) + ); const letter = (month: string) => new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), { month: "narrow", @@ -26,37 +24,29 @@ }); const height = (value: number) => value > 0 ? `max(2px, ${(value / max) * 100}%)` : "0"; + const title = (m: Month) => + `${name(m.month)}: received ${format(m.received)}, sent ${format(m.sent)}`;