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}