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.
This commit is contained in:
1 parent
4936258f32
commit
54895b9305
11 files changed
+352
-336
No files matched your search
+94
-51
@@ -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<Account>) =>
|
||||
export const isDebt = (item: Item<Account>) =>
|
||||
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<Account>,
|
||||
groups: Item<TransactionGroup>[]
|
||||
@@ -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<Account>, list: Movement[], asset?: string) {
|
||||
interface Move {
|
||||
amount?: string;
|
||||
asset?: string;
|
||||
fallback: string;
|
||||
label: string;
|
||||
toThem: boolean;
|
||||
}
|
||||
|
||||
function moveAction(item: Item<Account>, 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<Account>, 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<Account>, list: Movement[], asset?: string) {
|
||||
function moreAction(item: Item<Account>, 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<Account>, debts: Item<Account>[]) {
|
||||
@@ -197,19 +237,22 @@ function offsetAction(item: Item<Account>, debts: Item<Account>[]) {
|
||||
.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),
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
<FriendPanel {item} />
|
||||
<DebtPanel {item} />
|
||||
{/if}
|
||||
<Box
|
||||
title={a
|
||||
|
||||
+98
-66
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
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<Account>;
|
||||
list: Movement[] | undefined;
|
||||
actions: Actions | null;
|
||||
keys: { lend: string; offset: string; settle: string };
|
||||
keys: Record<keyof Actions, string>;
|
||||
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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet button(
|
||||
action: Action,
|
||||
key: string,
|
||||
primary = false
|
||||
)}
|
||||
<a
|
||||
aria-label={action.hint}
|
||||
class={primary ? PRIMARY : BUTTON}
|
||||
href={action.href}
|
||||
>{action.label} <span class="kbd">{key}</span></a
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<article
|
||||
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {current
|
||||
? "outline-2 outline-(--ff-brand)"
|
||||
@@ -82,42 +91,70 @@
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-3 p-3">
|
||||
<div>
|
||||
<h3 class="text-[15px] text-cladd-fg leading-snug">
|
||||
{#if position < 0}
|
||||
I owe
|
||||
{/if}
|
||||
<a class="font-semibold hover:underline" href={showUrl(item.id)}
|
||||
>{a.name}</a
|
||||
>
|
||||
{#if position > 0}
|
||||
owes me
|
||||
{:else if position === 0}
|
||||
and I are even
|
||||
{/if}
|
||||
</h3>
|
||||
<p class="num font-semibold text-[28px] leading-tight {tone}">
|
||||
{format(Math.abs(position))}
|
||||
</p>
|
||||
{#if named}
|
||||
<h3 class="truncate font-semibold text-[15px] leading-snug">
|
||||
<a class="hover:underline" href={showUrl(item.id)} title={a.name}
|
||||
>{a.name}</a
|
||||
>
|
||||
</h3>
|
||||
{/if}
|
||||
{#if side === "settled"}
|
||||
<p class="font-semibold text-[28px] text-cladd-fg-soft leading-tight">
|
||||
Settled
|
||||
</p>
|
||||
{:else}
|
||||
<p class="flex flex-wrap items-baseline gap-x-2">
|
||||
<span class="text-[15px] text-cladd-fg-soft"
|
||||
>{side === "owed" ? "Owes you" : "You owe"}</span
|
||||
>
|
||||
<span
|
||||
class="num font-semibold text-[28px] leading-tight {side === "owed"
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>{format(amount)}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{#if overpaid(a)}
|
||||
<p class="text-[13px] text-cladd-fg-soft">
|
||||
{credit ? "Paid you back" : "You paid back"}
|
||||
<span class="num">{format(amount)}</span>
|
||||
more than borrowed
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<dl
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-[13px]"
|
||||
>
|
||||
<dt class="text-cladd-fg-soft">Last movement</dt>
|
||||
<dd class="text-right">
|
||||
{last ? `${formatDate(last.date)}, ${ago(last.date)}` : "none yet"}
|
||||
</dd>
|
||||
<dt class="text-cladd-fg-soft">{words.pays}</dt>
|
||||
<dd class="text-right">
|
||||
{average === null ? "nothing yet" : `${average} days on average`}
|
||||
</dd>
|
||||
<dt class="text-cladd-fg-soft">{words.gave} in 12 months</dt>
|
||||
<dd class="num text-right text-expense">{format(gave)}</dd>
|
||||
<dt class="text-cladd-fg-soft">{words.got} in 12 months</dt>
|
||||
<dd class="num text-right text-income">{format(got)}</dd>
|
||||
</dl>
|
||||
{#if last}
|
||||
<dl
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-[13px]"
|
||||
>
|
||||
<dt class="text-cladd-fg-soft">Last movement</dt>
|
||||
<dd class="text-right">{formatDate(last.date)}, {ago(last.date)}</dd>
|
||||
{#if average === null}
|
||||
<dt class="col-span-2 text-cladd-fg-soft">No repayments yet</dt>
|
||||
{:else}
|
||||
<dt class="text-cladd-fg-soft">
|
||||
{credit ? "Usually pays back in" : "You usually pay back in"}
|
||||
</dt>
|
||||
<dd class="text-right">
|
||||
{average}
|
||||
{average === 1 ? "day" : "days"}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if received > 0}
|
||||
<dt class="text-cladd-fg-soft">Received in 12 months</dt>
|
||||
<dd class="num text-right text-income">{format(received)}</dd>
|
||||
{/if}
|
||||
{#if sent > 0}
|
||||
<dt class="text-cladd-fg-soft">Sent in 12 months</dt>
|
||||
<dd class="num text-right text-expense">{format(sent)}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
<MonthBars {format} gave={words.gave} got={words.got} months={bars} />
|
||||
{#if received + sent > 0}
|
||||
<MonthBars {format} months={bars} />
|
||||
{/if}
|
||||
|
||||
{#if rows > 0}
|
||||
<table class="w-full table-fixed border-collapse text-[13px]">
|
||||
@@ -139,6 +176,7 @@
|
||||
<Amount
|
||||
{...cur}
|
||||
amount={m.into ? -m.amount : m.amount}
|
||||
exact
|
||||
signed
|
||||
/>
|
||||
</td>
|
||||
@@ -157,26 +195,20 @@
|
||||
|
||||
{#if actions}
|
||||
<footer
|
||||
class="flex flex-wrap items-center gap-1.5 border-cladd-outline border-t px-3 py-2"
|
||||
class="flex flex-col gap-1.5 border-cladd-outline border-t px-3 py-2"
|
||||
>
|
||||
{#if actions.settle}
|
||||
<a
|
||||
aria-label={actions.settle.label}
|
||||
class="{BUTTON} cladd-color-green text-cladd-primary"
|
||||
href={actions.settle.href}
|
||||
>Settle up <span class="kbd">{keys.settle}</span></a
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if actions.repay}
|
||||
{@render button(actions.repay, keys.repay, true)}
|
||||
{/if}
|
||||
{@render button(actions.more, keys.more)}
|
||||
{#if actions.offset}
|
||||
{@render button(actions.offset, keys.offset)}
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions.offset?.line}
|
||||
<p class="text-[12px] text-cladd-fg-soft">{actions.offset.line}</p>
|
||||
{/if}
|
||||
<a aria-label={actions.lend.label} class={BUTTON} href={actions.lend.href}
|
||||
>{credit ? "Lend more" : "Borrow more"}
|
||||
<span class="kbd">{keys.lend}</span></a
|
||||
>
|
||||
<a
|
||||
aria-label={actions.offset.label}
|
||||
class={BUTTON}
|
||||
href={actions.offset.href}
|
||||
>Offset <span class="kbd">{keys.offset}</span></a
|
||||
>
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import {
|
||||
type Action,
|
||||
actionsFor,
|
||||
isDebt,
|
||||
movements,
|
||||
} from "#lib/accounts/ledger.js";
|
||||
import { all, lists } from "#lib/api/lists.js";
|
||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||
import type { Binding } from "#lib/keys.svelte.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import DebtCard from "./DebtCard.svelte";
|
||||
|
||||
const KEYS = { more: "+", offset: "=", repay: "S" };
|
||||
|
||||
let { item }: { item: Item<Account> } = $props();
|
||||
|
||||
const history = createQuery(() => ({
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/accounts/${item.id}/transactions`),
|
||||
queryKey: ["debt-history", item.id],
|
||||
}));
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
||||
|
||||
const list = $derived(
|
||||
history.data ? movements(item, history.data) : undefined
|
||||
);
|
||||
const plan = $derived(
|
||||
list && assets.data && liabilities.data
|
||||
? actionsFor(
|
||||
item,
|
||||
list,
|
||||
assets.data,
|
||||
liabilities.data.filter((d) => isDebt(d) && d.attributes.active)
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const bind = (action: Action | null | undefined, keys: string): Binding[] =>
|
||||
action
|
||||
? [
|
||||
{
|
||||
group: "Debt",
|
||||
keys,
|
||||
label: action.hint,
|
||||
run: () => goto(action.href),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
useKeys(() => [
|
||||
...bind(plan?.repay, KEYS.repay),
|
||||
...bind(plan?.more, KEYS.more),
|
||||
...bind(plan?.offset, KEYS.offset),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<DebtCard actions={plan} {item} keys={KEYS} {list} named={false} rows={0} />
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { actionsFor, isFriend, movements } from "#lib/accounts/ledger.js";
|
||||
import { all, lists } from "#lib/api/lists.js";
|
||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import FriendCard from "./FriendCard.svelte";
|
||||
|
||||
const KEYS = { lend: "+", offset: "=", settle: "S" };
|
||||
|
||||
let { item }: { item: Item<Account> } = $props();
|
||||
|
||||
const history = createQuery(() => ({
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/accounts/${item.id}/transactions`),
|
||||
queryKey: ["friend-history", item.id],
|
||||
}));
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
||||
|
||||
const list = $derived(
|
||||
history.data ? movements(item, history.data) : undefined
|
||||
);
|
||||
const plan = $derived(
|
||||
list && assets.data && liabilities.data
|
||||
? actionsFor(
|
||||
item,
|
||||
list,
|
||||
assets.data,
|
||||
liabilities.data.filter((d) => isFriend(d) && d.attributes.active)
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
useKeys(() =>
|
||||
plan
|
||||
? [
|
||||
...(plan.settle
|
||||
? [
|
||||
{
|
||||
group: "Friend",
|
||||
keys: KEYS.settle,
|
||||
label: plan.settle.label,
|
||||
run: () => plan.settle && goto(plan.settle.href),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
group: "Friend",
|
||||
keys: KEYS.lend,
|
||||
label: plan.lend.label,
|
||||
run: () => goto(plan.lend.href),
|
||||
},
|
||||
{
|
||||
group: "Friend",
|
||||
keys: KEYS.offset,
|
||||
label: plan.offset.label,
|
||||
run: () => goto(plan.offset.href),
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
</script>
|
||||
|
||||
<FriendCard actions={plan} {item} keys={KEYS} {list} rows={0} />
|
||||
@@ -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<Account>) => {
|
||||
const position = owed(item.attributes);
|
||||
if (position > 0) {
|
||||
return 0;
|
||||
}
|
||||
return position < 0 ? 1 : 2;
|
||||
};
|
||||
const rank = (item: Item<Account>) => ORDER.indexOf(stance(item.attributes));
|
||||
const lastDate = (item: Item<Account>) =>
|
||||
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<Account> | 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<Item<Account>>({
|
||||
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 @@
|
||||
)}
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<span class="text-[13px] text-cladd-fg-soft">{label}</span>
|
||||
{#each list as sum (sum.code)}
|
||||
<span
|
||||
class="num font-semibold text-[24px] leading-tight {key === "owed"
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>{formatMoney({ ...sum, amount: sum[key] })}</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
class="num font-semibold text-[24px] text-cladd-fg-soft leading-tight"
|
||||
>0</span
|
||||
>
|
||||
{/each}
|
||||
<span class="text-[24px] leading-tight">
|
||||
{#if list.length > 0}
|
||||
<Money
|
||||
class="font-semibold"
|
||||
inline
|
||||
parts={list.map((sum) => ({ ...sum, amount: sum[key] }))}
|
||||
tone={key === "owed" ? "income" : "expense"}
|
||||
/>
|
||||
{:else}
|
||||
<span class="num font-semibold text-cladd-fg-soft">0</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -272,13 +243,13 @@
|
||||
<section
|
||||
class="cladd-surface cladd-outline grid gap-3 rounded-cladd-lg p-3 md:grid-cols-2"
|
||||
>
|
||||
{@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")}
|
||||
</section>
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each friends as item (item.id)}
|
||||
{#each cards as item (item.id)}
|
||||
{@const i = index.get(item.id) ?? -1}
|
||||
<FriendCard
|
||||
<DebtCard
|
||||
actions={plans.get(item.id) ?? null}
|
||||
current={nav.cursor === i}
|
||||
id={nav.rowId(i)}
|
||||
@@ -293,7 +264,7 @@
|
||||
<div
|
||||
class="cladd-surface cladd-outline flex flex-col items-center gap-3 rounded-cladd-lg px-3 py-10 text-center"
|
||||
>
|
||||
<p class="font-semibold text-[15px]">No debts with friends yet</p>
|
||||
<p class="font-semibold text-[15px]">No debts yet</p>
|
||||
<a class="text-brand underline dark:text-(--ff-series-1)" href={CREATE}
|
||||
>Create a new liability</a
|
||||
>
|
||||
|
||||
@@ -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)}`;
|
||||
</script>
|
||||
|
||||
<div
|
||||
aria-label="{got} and {gave.toLowerCase()} per month"
|
||||
aria-label="Received and sent per month"
|
||||
class="grid grid-cols-12 gap-x-1"
|
||||
role="img"
|
||||
>
|
||||
{#each months as m (m.month)}
|
||||
<div
|
||||
class="flex h-8 items-end"
|
||||
title="{name(m.month)}: {got.toLowerCase()} {format(
|
||||
m.got
|
||||
)}, {gave.toLowerCase()} {format(m.gave)}"
|
||||
>
|
||||
<div class="flex h-8 items-end" title={title(m)}>
|
||||
<div
|
||||
class="w-full rounded-t-[2px] bg-income"
|
||||
style:height={height(m.got)}
|
||||
style:height={height(m.received)}
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="col-span-12 h-px bg-cladd-outline"></div>
|
||||
{#each months as m (m.month)}
|
||||
<div
|
||||
class="flex h-8 items-start"
|
||||
title="{name(m.month)}: {got.toLowerCase()} {format(
|
||||
m.got
|
||||
)}, {gave.toLowerCase()} {format(m.gave)}"
|
||||
>
|
||||
<div class="flex h-8 items-start" title={title(m)}>
|
||||
<div
|
||||
class="w-full rounded-b-[2px] bg-expense"
|
||||
style:height={height(m.gave)}
|
||||
style:height={height(m.sent)}
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -31,14 +31,8 @@
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { sectionOf } from "#lib/transactions/ledger.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import FriendPanel from "./FriendPanel.svelte";
|
||||
import {
|
||||
canReconcile,
|
||||
DIRECTION_LABEL,
|
||||
reconcileUrl,
|
||||
showUrl,
|
||||
typeOf,
|
||||
} from "./links.js";
|
||||
import DebtPanel from "./DebtPanel.svelte";
|
||||
import { canReconcile, reconcileUrl, showUrl, typeOf } from "./links.js";
|
||||
import StatementHeader from "./StatementHeader.svelte";
|
||||
import StatementMonths from "./StatementMonths.svelte";
|
||||
|
||||
@@ -81,7 +75,7 @@
|
||||
? {
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/accounts/${id}/transactions`),
|
||||
queryKey: ["friend-history", id],
|
||||
queryKey: ["debt-history", id],
|
||||
}
|
||||
: {
|
||||
placeholderData: keepPreviousData,
|
||||
@@ -128,9 +122,7 @@
|
||||
: null
|
||||
);
|
||||
const thisMonth = `${now.slice(0, 7)}-01`;
|
||||
const lineTitle = $derived(
|
||||
side === "asset" ? "Balance" : DIRECTION_LABEL[side]
|
||||
);
|
||||
const lineTitle = $derived(side === "asset" ? "Balance" : "Debt balance");
|
||||
const series = $derived([
|
||||
{
|
||||
area: true,
|
||||
@@ -199,7 +191,7 @@
|
||||
: "xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"}"
|
||||
>
|
||||
{#if a.liability_type === "debt"}
|
||||
<FriendPanel {item} />
|
||||
<DebtPanel {item} />
|
||||
{:else}
|
||||
<Box title="In and out by month">
|
||||
{#snippet tools()}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { Side, Term } from "#lib/accounts/statement.js";
|
||||
import { addDays } from "#lib/dates.js";
|
||||
import { formatDate, formatMoney, type Money } from "#lib/format.js";
|
||||
import { DIRECTION_LABEL } from "./links.js";
|
||||
|
||||
let {
|
||||
terms,
|
||||
@@ -33,11 +32,14 @@
|
||||
transfer: "text-transfer",
|
||||
};
|
||||
|
||||
const word = $derived(
|
||||
side === "asset"
|
||||
? { close: "Closing", open: "Opening" }
|
||||
: { close: DIRECTION_LABEL[side], open: DIRECTION_LABEL[side] }
|
||||
);
|
||||
function word(value: number, asset: string) {
|
||||
if (side === "asset") {
|
||||
return asset;
|
||||
}
|
||||
return (side === "credit") === value >= 0 ? "Owed to you" : "You owe";
|
||||
}
|
||||
|
||||
const shown = (value: number) => (side === "asset" ? value : Math.abs(value));
|
||||
const change = $derived(closing - opening);
|
||||
const changeTone = $derived.by(() => {
|
||||
if (side !== "asset" || Math.abs(change) < 0.005) {
|
||||
@@ -48,10 +50,10 @@
|
||||
const items = $derived([
|
||||
{
|
||||
key: "open",
|
||||
label: `${word.open}, ${formatDate(addDays(start, -1), "short")}`,
|
||||
label: `${word(opening, "Opening")}, ${formatDate(addDays(start, -1), "short")}`,
|
||||
op: "",
|
||||
tone: "text-cladd-fg",
|
||||
value: opening,
|
||||
value: shown(opening),
|
||||
},
|
||||
...terms.map((t) => ({
|
||||
key: t.flow,
|
||||
@@ -62,10 +64,10 @@
|
||||
})),
|
||||
{
|
||||
key: "close",
|
||||
label: `${word.close}, ${formatDate(end, "short")}`,
|
||||
label: `${word(closing, "Closing")}, ${formatDate(end, "short")}`,
|
||||
op: "=",
|
||||
tone: "text-cladd-fg",
|
||||
value: closing,
|
||||
value: shown(closing),
|
||||
},
|
||||
]);
|
||||
const money = (amount: number, signed = false) =>
|
||||
|
||||
@@ -31,8 +31,8 @@ export const LIABILITY_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const DIRECTION_LABEL: Record<string, string> = {
|
||||
credit: "Owed to me",
|
||||
debit: "I owe",
|
||||
credit: "Owed to you",
|
||||
debit: "You owe",
|
||||
};
|
||||
|
||||
export const PERIOD_LABEL: Record<string, string> = {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||
import { isDebt, owed, stance } from "#lib/accounts/ledger.js";
|
||||
import { editUrl, showUrl } from "#lib/components/accounts/links.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
||||
import {
|
||||
formatPrimary,
|
||||
inPrimaryTotal,
|
||||
ratesQuery,
|
||||
} from "#lib/dashboard/primary.svelte.js";
|
||||
import { formatMoney, toNumber } from "#lib/format.js";
|
||||
import { formatMoney } from "#lib/format.js";
|
||||
import { display } from "#lib/money/display.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { accountsQuery, byCurrency } from "./data.js";
|
||||
@@ -18,14 +15,10 @@
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
|
||||
const rates = createQuery(() => ratesQuery());
|
||||
|
||||
const groups = $derived(
|
||||
byCurrency(
|
||||
(liabilities.data ?? []).filter(
|
||||
(a) =>
|
||||
a.attributes.liability_type === "debt" &&
|
||||
toNumber(a.attributes.debt_amount) !== 0
|
||||
(a) => isDebt(a) && stance(a.attributes) !== "settled"
|
||||
),
|
||||
(a) => a.attributes.currency_code ?? ""
|
||||
).map(([code, list]) => {
|
||||
@@ -36,15 +29,12 @@
|
||||
symbol: first.currency_symbol ?? code,
|
||||
};
|
||||
const rows = list
|
||||
.map((a) => {
|
||||
const due = Math.abs(toNumber(a.attributes.debt_amount));
|
||||
return {
|
||||
id: a.id,
|
||||
key: `debt-${a.id}`,
|
||||
name: a.attributes.name,
|
||||
value: a.attributes.liability_direction === "credit" ? due : -due,
|
||||
};
|
||||
})
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
key: `debt-${a.id}`,
|
||||
name: a.attributes.name,
|
||||
value: owed(a.attributes),
|
||||
}))
|
||||
.sort((x, y) => y.value - x.value);
|
||||
return {
|
||||
code,
|
||||
@@ -57,18 +47,24 @@
|
||||
);
|
||||
|
||||
const nets = $derived.by(() => {
|
||||
const total = inPrimaryTotal(
|
||||
groups.map((g) => ({ amount: g.net, code: g.code })),
|
||||
rates.data
|
||||
const total = display.total(
|
||||
groups.map((g) => ({ ...g.cur, amount: g.net }))
|
||||
);
|
||||
if (total === null) {
|
||||
return groups.map((g) => ({
|
||||
code: g.code,
|
||||
net: g.net,
|
||||
text: formatMoney({ amount: Math.abs(g.net), ...g.cur }),
|
||||
text: formatMoney({ ...g.cur, amount: Math.abs(g.net) }),
|
||||
}));
|
||||
}
|
||||
return [{ code: "all", net: total, text: formatPrimary(Math.abs(total)) }];
|
||||
const text = formatMoney({ ...total, amount: Math.abs(total.amount) });
|
||||
return [
|
||||
{
|
||||
code: "all",
|
||||
net: total.amount,
|
||||
text: total.converted ? `≈${text}` : text,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const column = useColumn(4, () =>
|
||||
@@ -91,12 +87,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#if groups.length > 0}
|
||||
<Box
|
||||
body="p-0"
|
||||
footer={more}
|
||||
href="/accounts/liabilities"
|
||||
title="Debts with friends"
|
||||
>
|
||||
<Box body="p-0" footer={more} href="/accounts/liabilities" title="Debts">
|
||||
<div
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 transition-opacity {liabilities.isPlaceholderData
|
||||
? "opacity-60"
|
||||
@@ -111,7 +102,7 @@
|
||||
: "text-expense"}"
|
||||
>{g.text}</span
|
||||
>
|
||||
{g.net > 0 ? "owed to me" : "I owe"}</span
|
||||
{g.net > 0 ? "owed to you" : "owed by you"}</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
Reference in new issue
Block a user