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)}`;