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 type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||||
import { addMonths, daysBetween, today } from "#lib/dates.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 {
|
export interface Movement {
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -13,16 +13,18 @@ export interface Movement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Month {
|
export interface Month {
|
||||||
gave: number;
|
|
||||||
got: number;
|
|
||||||
month: string;
|
month: string;
|
||||||
|
received: number;
|
||||||
|
sent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Stance = "owed" | "owe" | "settled";
|
||||||
|
|
||||||
const OPENING = new Set(["opening balance", "liability credit"]);
|
const OPENING = new Set(["opening balance", "liability credit"]);
|
||||||
const ASSET = "Asset account";
|
const ASSET = "Asset account";
|
||||||
const DUST = 0.005;
|
const DUST = 0.005;
|
||||||
|
|
||||||
export const isFriend = (item: Item<Account>) =>
|
export const isDebt = (item: Item<Account>) =>
|
||||||
item.attributes.liability_type === "debt";
|
item.attributes.liability_type === "debt";
|
||||||
|
|
||||||
export function owed(account: Account): number {
|
export function owed(account: Account): number {
|
||||||
@@ -30,6 +32,17 @@ export function owed(account: Account): number {
|
|||||||
return account.liability_direction === "debit" ? -debt : debt;
|
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(
|
export function movements(
|
||||||
account: Item<Account>,
|
account: Item<Account>,
|
||||||
groups: Item<TransactionGroup>[]
|
groups: Item<TransactionGroup>[]
|
||||||
@@ -94,9 +107,9 @@ export function averageDays(list: Movement[], credit: boolean): number | null {
|
|||||||
export function months(list: Movement[], count = 12): Month[] {
|
export function months(list: Movement[], count = 12): Month[] {
|
||||||
const first = addMonths(`${today().slice(0, 7)}-01`, 1 - count);
|
const first = addMonths(`${today().slice(0, 7)}-01`, 1 - count);
|
||||||
const buckets = Array.from({ length: count }, (_, i) => ({
|
const buckets = Array.from({ length: count }, (_, i) => ({
|
||||||
gave: 0,
|
|
||||||
got: 0,
|
|
||||||
month: addMonths(first, i).slice(0, 7),
|
month: addMonths(first, i).slice(0, 7),
|
||||||
|
received: 0,
|
||||||
|
sent: 0,
|
||||||
}));
|
}));
|
||||||
const index = new Map(buckets.map((b, i) => [b.month, i]));
|
const index = new Map(buckets.map((b, i) => [b.month, i]));
|
||||||
for (const m of list) {
|
for (const m of list) {
|
||||||
@@ -105,9 +118,9 @@ export function months(list: Movement[], count = 12): Month[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (m.into) {
|
if (m.into) {
|
||||||
bucket.gave += m.amount;
|
bucket.sent += m.amount;
|
||||||
} else {
|
} else {
|
||||||
bucket.got += m.amount;
|
bucket.received += m.amount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buckets;
|
return buckets;
|
||||||
@@ -139,50 +152,77 @@ const lastSaid = (list: Movement[], into: boolean) =>
|
|||||||
const plain = (account: Account, value: number) =>
|
const plain = (account: Account, value: number) =>
|
||||||
Math.abs(value).toFixed(account.currency_decimal_places ?? 2);
|
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;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
line?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Actions {
|
export interface Actions {
|
||||||
lend: Action;
|
more: Action;
|
||||||
offset: Action;
|
offset: Action | null;
|
||||||
settle: 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 a = item.attributes;
|
||||||
const position = owed(a);
|
const said = overpaid(a) ? undefined : lastSaid(list, move.toThem);
|
||||||
if (Math.abs(position) < DUST) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const into = position < 0;
|
const toThem = side === "owe";
|
||||||
return {
|
return moveAction(item, list, {
|
||||||
href: createUrl(into ? "withdrawal" : "deposit", {
|
amount: plain(a, owed(a)),
|
||||||
amount: plain(a, position),
|
asset,
|
||||||
description:
|
fallback: toThem ? "Paid back" : "Repayment",
|
||||||
lastSaid(list, into) ??
|
label: toThem ? "Pay back" : "Record repayment",
|
||||||
(into ? `Paid back ${a.name}` : `${a.name} paid back`),
|
toThem,
|
||||||
destination: into ? item.id : asset,
|
});
|
||||||
source: into ? asset : item.id,
|
|
||||||
}),
|
|
||||||
label: `Settle up with ${a.name}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lendAction(item: Item<Account>, list: Movement[], asset?: string) {
|
function moreAction(item: Item<Account>, list: Movement[], asset?: string) {
|
||||||
const a = item.attributes;
|
const a = item.attributes;
|
||||||
const credit = a.liability_direction !== "debit";
|
const side = stance(a);
|
||||||
return {
|
const toThem =
|
||||||
href: createUrl(credit ? "withdrawal" : "deposit", {
|
side === "settled" ? a.liability_direction !== "debit" : side === "owed";
|
||||||
description:
|
return moveAction(item, list, {
|
||||||
lastSaid(list, credit) ??
|
asset,
|
||||||
(credit ? `Lent ${a.name}` : `Borrowed from ${a.name}`),
|
fallback: toThem ? "Lent" : "Borrowed",
|
||||||
destination: credit ? item.id : asset,
|
label: toThem ? "Lend more" : "Borrow more",
|
||||||
source: credit ? asset : item.id,
|
toThem,
|
||||||
}),
|
});
|
||||||
label: credit ? `Lend more to ${a.name}` : `Borrow more from ${a.name}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function offsetAction(item: Item<Account>, debts: Item<Account>[]) {
|
function offsetAction(item: Item<Account>, debts: Item<Account>[]) {
|
||||||
@@ -197,19 +237,22 @@ function offsetAction(item: Item<Account>, debts: Item<Account>[]) {
|
|||||||
.sort(
|
.sort(
|
||||||
(x, y) => Math.abs(owed(y.attributes)) - Math.abs(owed(x.attributes))
|
(x, y) => Math.abs(owed(y.attributes)) - Math.abs(owed(x.attributes))
|
||||||
);
|
);
|
||||||
const amount = other
|
if (!other) {
|
||||||
? Math.min(Math.abs(position), Math.abs(owed(other.attributes)))
|
return null;
|
||||||
: position;
|
}
|
||||||
const out = position >= 0;
|
const amount = Math.min(Math.abs(position), Math.abs(owed(other.attributes)));
|
||||||
const names = other ? `${a.name} and ${other.attributes.name}` : a.name;
|
const [from, to] = position > 0 ? [item, other] : [other, item];
|
||||||
|
const label = "Offset against another debt";
|
||||||
return {
|
return {
|
||||||
|
hint: `${label}: ${a.name}`,
|
||||||
href: createUrl("transfer", {
|
href: createUrl("transfer", {
|
||||||
amount: amount ? plain(a, amount) : undefined,
|
amount: plain(a, amount),
|
||||||
description: `Offset ${names}`,
|
description: `Offset ${from.attributes.name} against ${to.attributes.name}`,
|
||||||
destination: out ? other?.id : item.id,
|
destination: to.id,
|
||||||
source: out ? item.id : other?.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 {
|
): Actions {
|
||||||
const asset = counterAsset(list, assets, item.attributes.currency_code);
|
const asset = counterAsset(list, assets, item.attributes.currency_code);
|
||||||
return {
|
return {
|
||||||
lend: lendAction(item, list, asset),
|
more: moreAction(item, list, asset),
|
||||||
offset: offsetAction(item, debts),
|
offset: offsetAction(item, debts),
|
||||||
settle: settleAction(item, list, asset),
|
repay: repayAction(item, list, asset),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import Breakdown from "./Breakdown.svelte";
|
import Breakdown from "./Breakdown.svelte";
|
||||||
import FriendPanel from "./FriendPanel.svelte";
|
import DebtPanel from "./DebtPanel.svelte";
|
||||||
import {
|
import {
|
||||||
canReconcile,
|
canReconcile,
|
||||||
createUrl,
|
createUrl,
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
: ""}"
|
: ""}"
|
||||||
>
|
>
|
||||||
{#if debt && item}
|
{#if debt && item}
|
||||||
<FriendPanel {item} />
|
<DebtPanel {item} />
|
||||||
{/if}
|
{/if}
|
||||||
<Box
|
<Box
|
||||||
title={a
|
title={a
|
||||||
|
|||||||
+98
-66
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
|
type Action,
|
||||||
type Actions,
|
type Actions,
|
||||||
averageDays,
|
averageDays,
|
||||||
type Movement,
|
type Movement,
|
||||||
months,
|
months,
|
||||||
|
overpaid,
|
||||||
owed,
|
owed,
|
||||||
|
stance,
|
||||||
} from "#lib/accounts/ledger.js";
|
} from "#lib/accounts/ledger.js";
|
||||||
import type { Account, Item } from "#lib/api/types.js";
|
import type { Account, Item } from "#lib/api/types.js";
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
@@ -16,6 +19,7 @@
|
|||||||
|
|
||||||
const BUTTON =
|
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";
|
"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 {
|
let {
|
||||||
item,
|
item,
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
actions,
|
actions,
|
||||||
keys,
|
keys,
|
||||||
rows = 5,
|
rows = 5,
|
||||||
|
named = true,
|
||||||
current = false,
|
current = false,
|
||||||
id,
|
id,
|
||||||
onfocusin,
|
onfocusin,
|
||||||
@@ -30,8 +35,9 @@
|
|||||||
item: Item<Account>;
|
item: Item<Account>;
|
||||||
list: Movement[] | undefined;
|
list: Movement[] | undefined;
|
||||||
actions: Actions | null;
|
actions: Actions | null;
|
||||||
keys: { lend: string; offset: string; settle: string };
|
keys: Record<keyof Actions, string>;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
named?: boolean;
|
||||||
current?: boolean;
|
current?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
onfocusin?: () => void;
|
onfocusin?: () => void;
|
||||||
@@ -39,30 +45,20 @@
|
|||||||
|
|
||||||
const a = $derived(item.attributes);
|
const a = $derived(item.attributes);
|
||||||
const credit = $derived(a.liability_direction !== "debit");
|
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({
|
const cur = $derived({
|
||||||
code: a.currency_code,
|
code: a.currency_code,
|
||||||
decimals: a.currency_decimal_places,
|
decimals: a.currency_decimal_places,
|
||||||
symbol: a.currency_symbol,
|
symbol: a.currency_symbol,
|
||||||
});
|
});
|
||||||
const format = (value: number) => formatMoney({ ...cur, amount: value });
|
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 bars = $derived(list ? months(list) : []);
|
||||||
const gave = $derived(bars.reduce((s, m) => s + m.gave, 0));
|
const sent = $derived(bars.reduce((s, m) => s + m.sent, 0));
|
||||||
const got = $derived(bars.reduce((s, m) => s + m.got, 0));
|
const received = $derived(bars.reduce((s, m) => s + m.received, 0));
|
||||||
const average = $derived(list ? averageDays(list, credit) : null);
|
const average = $derived(list ? averageDays(list, credit) : null);
|
||||||
const last = $derived(list?.[0]);
|
const last = $derived(list?.[0]);
|
||||||
const shown = $derived(list?.slice(0, rows) ?? []);
|
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) {
|
function ago(date: string) {
|
||||||
const days = daysBetween(date, today());
|
const days = daysBetween(date, today());
|
||||||
@@ -73,6 +69,19 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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
|
<article
|
||||||
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {current
|
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {current
|
||||||
? "outline-2 outline-(--ff-brand)"
|
? "outline-2 outline-(--ff-brand)"
|
||||||
@@ -82,42 +91,70 @@
|
|||||||
>
|
>
|
||||||
<div class="flex flex-1 flex-col gap-3 p-3">
|
<div class="flex flex-1 flex-col gap-3 p-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-[15px] text-cladd-fg leading-snug">
|
{#if named}
|
||||||
{#if position < 0}
|
<h3 class="truncate font-semibold text-[15px] leading-snug">
|
||||||
I owe
|
<a class="hover:underline" href={showUrl(item.id)} title={a.name}
|
||||||
{/if}
|
>{a.name}</a
|
||||||
<a class="font-semibold hover:underline" href={showUrl(item.id)}
|
>
|
||||||
>{a.name}</a
|
</h3>
|
||||||
>
|
{/if}
|
||||||
{#if position > 0}
|
{#if side === "settled"}
|
||||||
owes me
|
<p class="font-semibold text-[28px] text-cladd-fg-soft leading-tight">
|
||||||
{:else if position === 0}
|
Settled
|
||||||
and I are even
|
</p>
|
||||||
{/if}
|
{:else}
|
||||||
</h3>
|
<p class="flex flex-wrap items-baseline gap-x-2">
|
||||||
<p class="num font-semibold text-[28px] leading-tight {tone}">
|
<span class="text-[15px] text-cladd-fg-soft"
|
||||||
{format(Math.abs(position))}
|
>{side === "owed" ? "Owes you" : "You owe"}</span
|
||||||
</p>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<dl
|
{#if last}
|
||||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-[13px]"
|
<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">
|
<dt class="text-cladd-fg-soft">Last movement</dt>
|
||||||
{last ? `${formatDate(last.date)}, ${ago(last.date)}` : "none yet"}
|
<dd class="text-right">{formatDate(last.date)}, {ago(last.date)}</dd>
|
||||||
</dd>
|
{#if average === null}
|
||||||
<dt class="text-cladd-fg-soft">{words.pays}</dt>
|
<dt class="col-span-2 text-cladd-fg-soft">No repayments yet</dt>
|
||||||
<dd class="text-right">
|
{:else}
|
||||||
{average === null ? "nothing yet" : `${average} days on average`}
|
<dt class="text-cladd-fg-soft">
|
||||||
</dd>
|
{credit ? "Usually pays back in" : "You usually pay back in"}
|
||||||
<dt class="text-cladd-fg-soft">{words.gave} in 12 months</dt>
|
</dt>
|
||||||
<dd class="num text-right text-expense">{format(gave)}</dd>
|
<dd class="text-right">
|
||||||
<dt class="text-cladd-fg-soft">{words.got} in 12 months</dt>
|
{average}
|
||||||
<dd class="num text-right text-income">{format(got)}</dd>
|
{average === 1 ? "day" : "days"}
|
||||||
</dl>
|
</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}
|
{#if rows > 0}
|
||||||
<table class="w-full table-fixed border-collapse text-[13px]">
|
<table class="w-full table-fixed border-collapse text-[13px]">
|
||||||
@@ -139,6 +176,7 @@
|
|||||||
<Amount
|
<Amount
|
||||||
{...cur}
|
{...cur}
|
||||||
amount={m.into ? -m.amount : m.amount}
|
amount={m.into ? -m.amount : m.amount}
|
||||||
|
exact
|
||||||
signed
|
signed
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -157,26 +195,20 @@
|
|||||||
|
|
||||||
{#if actions}
|
{#if actions}
|
||||||
<footer
|
<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}
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<a
|
{#if actions.repay}
|
||||||
aria-label={actions.settle.label}
|
{@render button(actions.repay, keys.repay, true)}
|
||||||
class="{BUTTON} cladd-color-green text-cladd-primary"
|
{/if}
|
||||||
href={actions.settle.href}
|
{@render button(actions.more, keys.more)}
|
||||||
>Settle up <span class="kbd">{keys.settle}</span></a
|
{#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}
|
{/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>
|
</footer>
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</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 Plus from "@lucide/svelte/icons/plus";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import {
|
import {
|
||||||
|
type Action,
|
||||||
actionsFor,
|
actionsFor,
|
||||||
isFriend,
|
isDebt,
|
||||||
movements,
|
movements,
|
||||||
owed,
|
owed,
|
||||||
|
type Stance,
|
||||||
|
stance,
|
||||||
} from "#lib/accounts/ledger.js";
|
} from "#lib/accounts/ledger.js";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all, lists } from "#lib/api/lists.js";
|
import { all, lists } from "#lib/api/lists.js";
|
||||||
@@ -17,13 +20,14 @@
|
|||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import Money from "#lib/components/Money.svelte";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import { today } from "#lib/dates.js";
|
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 type { Binding } from "#lib/keys.svelte.js";
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import FriendCard from "./FriendCard.svelte";
|
import DebtCard from "./DebtCard.svelte";
|
||||||
import {
|
import {
|
||||||
editUrl,
|
editUrl,
|
||||||
LIABILITY_LABEL,
|
LIABILITY_LABEL,
|
||||||
@@ -32,7 +36,9 @@
|
|||||||
TYPE_ONE,
|
TYPE_ONE,
|
||||||
} from "./links.js";
|
} 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 CREATE = "/accounts/create/liabilities";
|
||||||
|
|
||||||
const liabilities = createQuery(() => ({
|
const liabilities = createQuery(() => ({
|
||||||
@@ -53,9 +59,9 @@
|
|||||||
}));
|
}));
|
||||||
const assets = createQuery(() => lists.accounts("asset"));
|
const assets = createQuery(() => lists.accounts("asset"));
|
||||||
|
|
||||||
const debts = $derived((liabilities.data ?? []).filter(isFriend));
|
const debts = $derived((liabilities.data ?? []).filter(isDebt));
|
||||||
const others = $derived(
|
const others = $derived(
|
||||||
(liabilities.data ?? []).filter((item) => !isFriend(item))
|
(liabilities.data ?? []).filter((item) => !isDebt(item))
|
||||||
);
|
);
|
||||||
const history = createQuery(() => ({
|
const history = createQuery(() => ({
|
||||||
enabled: debts.length > 0,
|
enabled: debts.length > 0,
|
||||||
@@ -79,26 +85,20 @@
|
|||||||
queryKey: ["ledger-history", debts.map((d) => d.id).join(",")],
|
queryKey: ["ledger-history", debts.map((d) => d.id).join(",")],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const rank = (item: Item<Account>) => {
|
const rank = (item: Item<Account>) => ORDER.indexOf(stance(item.attributes));
|
||||||
const position = owed(item.attributes);
|
|
||||||
if (position > 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return position < 0 ? 1 : 2;
|
|
||||||
};
|
|
||||||
const lastDate = (item: Item<Account>) =>
|
const lastDate = (item: Item<Account>) =>
|
||||||
history.data?.get(item.id)?.[0]?.date ?? "";
|
history.data?.get(item.id)?.[0]?.date ?? "";
|
||||||
const friends = $derived(
|
const cards = $derived(
|
||||||
debts.toSorted(
|
debts.toSorted(
|
||||||
(x, y) => rank(x) - rank(y) || lastDate(y).localeCompare(lastDate(x))
|
(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 index = $derived(new Map(items.map((item, i) => [item.id, i])));
|
||||||
|
|
||||||
const plans = $derived(
|
const plans = $derived(
|
||||||
new Map(
|
new Map(
|
||||||
friends.map((item) => [
|
cards.map((item) => [
|
||||||
item.id,
|
item.id,
|
||||||
history.data && assets.data
|
history.data && assets.data
|
||||||
? actionsFor(
|
? actionsFor(
|
||||||
@@ -140,25 +140,26 @@
|
|||||||
}
|
}
|
||||||
return [...map.values()];
|
return [...map.values()];
|
||||||
});
|
});
|
||||||
const owedToMe = $derived(sums.filter((s) => s.owed > 0));
|
const owedToYou = $derived(sums.filter((s) => s.owed > 0));
|
||||||
const iOwe = $derived(sums.filter((s) => s.owe > 0));
|
const youOwe = $derived(sums.filter((s) => s.owe > 0));
|
||||||
|
|
||||||
function current(): Item<Account> | undefined {
|
function current(): Item<Account> | undefined {
|
||||||
return nav.current;
|
return nav.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function act(kind: "settle" | "lend" | "offset") {
|
function bind(kind: keyof typeof KEYS, action?: Action | null): Binding[] {
|
||||||
const plan = plans.get(current()?.id ?? "");
|
if (!action) {
|
||||||
const target = plan?.[kind];
|
return [];
|
||||||
if (target) {
|
|
||||||
goto(target.href);
|
|
||||||
}
|
}
|
||||||
|
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>>({
|
const nav = new ListNav<Item<Account>>({
|
||||||
edit: (item) => goto(editUrl(item.id)),
|
edit: (item) => goto(editUrl(item.id)),
|
||||||
extra: (): Binding[] => {
|
extra: (): Binding[] => {
|
||||||
const name = current()?.attributes.name ?? "a friend";
|
|
||||||
const plan = plans.get(current()?.id ?? "");
|
const plan = plans.get(current()?.id ?? "");
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -172,38 +173,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...bind("repay", plan?.repay),
|
||||||
group: "Friends",
|
...bind("more", plan?.more),
|
||||||
keys: "s",
|
...bind("offset", plan?.offset),
|
||||||
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"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
group: "Accounts",
|
group: "Accounts",
|
||||||
keys: "c",
|
keys: "c",
|
||||||
@@ -233,19 +205,18 @@
|
|||||||
)}
|
)}
|
||||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
<span class="text-[13px] text-cladd-fg-soft">{label}</span>
|
<span class="text-[13px] text-cladd-fg-soft">{label}</span>
|
||||||
{#each list as sum (sum.code)}
|
<span class="text-[24px] leading-tight">
|
||||||
<span
|
{#if list.length > 0}
|
||||||
class="num font-semibold text-[24px] leading-tight {key === "owed"
|
<Money
|
||||||
? "text-income"
|
class="font-semibold"
|
||||||
: "text-expense"}"
|
inline
|
||||||
>{formatMoney({ ...sum, amount: sum[key] })}</span
|
parts={list.map((sum) => ({ ...sum, amount: sum[key] }))}
|
||||||
>
|
tone={key === "owed" ? "income" : "expense"}
|
||||||
{:else}
|
/>
|
||||||
<span
|
{:else}
|
||||||
class="num font-semibold text-[24px] text-cladd-fg-soft leading-tight"
|
<span class="num font-semibold text-cladd-fg-soft">0</span>
|
||||||
>0</span
|
{/if}
|
||||||
>
|
</span>
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -272,13 +243,13 @@
|
|||||||
<section
|
<section
|
||||||
class="cladd-surface cladd-outline grid gap-3 rounded-cladd-lg p-3 md:grid-cols-2"
|
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("Owed to you", owedToYou, "owed")}
|
||||||
{@render totals("I owe", iOwe, "owe")}
|
{@render totals("You owe", youOwe, "owe")}
|
||||||
</section>
|
</section>
|
||||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<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}
|
{@const i = index.get(item.id) ?? -1}
|
||||||
<FriendCard
|
<DebtCard
|
||||||
actions={plans.get(item.id) ?? null}
|
actions={plans.get(item.id) ?? null}
|
||||||
current={nav.cursor === i}
|
current={nav.cursor === i}
|
||||||
id={nav.rowId(i)}
|
id={nav.rowId(i)}
|
||||||
@@ -293,7 +264,7 @@
|
|||||||
<div
|
<div
|
||||||
class="cladd-surface cladd-outline flex flex-col items-center gap-3 rounded-cladd-lg px-3 py-10 text-center"
|
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}
|
<a class="text-brand underline dark:text-(--ff-series-1)" href={CREATE}
|
||||||
>Create a new liability</a
|
>Create a new liability</a
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,17 +4,15 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
months,
|
months,
|
||||||
gave,
|
|
||||||
got,
|
|
||||||
format,
|
format,
|
||||||
}: {
|
}: {
|
||||||
months: Month[];
|
months: Month[];
|
||||||
gave: string;
|
|
||||||
got: string;
|
|
||||||
format: (value: number) => string;
|
format: (value: number) => string;
|
||||||
} = $props();
|
} = $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) =>
|
const letter = (month: string) =>
|
||||||
new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), {
|
new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), {
|
||||||
month: "narrow",
|
month: "narrow",
|
||||||
@@ -26,37 +24,29 @@
|
|||||||
});
|
});
|
||||||
const height = (value: number) =>
|
const height = (value: number) =>
|
||||||
value > 0 ? `max(2px, ${(value / max) * 100}%)` : "0";
|
value > 0 ? `max(2px, ${(value / max) * 100}%)` : "0";
|
||||||
|
const title = (m: Month) =>
|
||||||
|
`${name(m.month)}: received ${format(m.received)}, sent ${format(m.sent)}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
aria-label="{got} and {gave.toLowerCase()} per month"
|
aria-label="Received and sent per month"
|
||||||
class="grid grid-cols-12 gap-x-1"
|
class="grid grid-cols-12 gap-x-1"
|
||||||
role="img"
|
role="img"
|
||||||
>
|
>
|
||||||
{#each months as m (m.month)}
|
{#each months as m (m.month)}
|
||||||
<div
|
<div class="flex h-8 items-end" title={title(m)}>
|
||||||
class="flex h-8 items-end"
|
|
||||||
title="{name(m.month)}: {got.toLowerCase()} {format(
|
|
||||||
m.got
|
|
||||||
)}, {gave.toLowerCase()} {format(m.gave)}"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="w-full rounded-t-[2px] bg-income"
|
class="w-full rounded-t-[2px] bg-income"
|
||||||
style:height={height(m.got)}
|
style:height={height(m.received)}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="col-span-12 h-px bg-cladd-outline"></div>
|
<div class="col-span-12 h-px bg-cladd-outline"></div>
|
||||||
{#each months as m (m.month)}
|
{#each months as m (m.month)}
|
||||||
<div
|
<div class="flex h-8 items-start" title={title(m)}>
|
||||||
class="flex h-8 items-start"
|
|
||||||
title="{name(m.month)}: {got.toLowerCase()} {format(
|
|
||||||
m.got
|
|
||||||
)}, {gave.toLowerCase()} {format(m.gave)}"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="w-full rounded-b-[2px] bg-expense"
|
class="w-full rounded-b-[2px] bg-expense"
|
||||||
style:height={height(m.gave)}
|
style:height={height(m.sent)}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -31,14 +31,8 @@
|
|||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { sectionOf } from "#lib/transactions/ledger.js";
|
import { sectionOf } from "#lib/transactions/ledger.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import FriendPanel from "./FriendPanel.svelte";
|
import DebtPanel from "./DebtPanel.svelte";
|
||||||
import {
|
import { canReconcile, reconcileUrl, showUrl, typeOf } from "./links.js";
|
||||||
canReconcile,
|
|
||||||
DIRECTION_LABEL,
|
|
||||||
reconcileUrl,
|
|
||||||
showUrl,
|
|
||||||
typeOf,
|
|
||||||
} from "./links.js";
|
|
||||||
import StatementHeader from "./StatementHeader.svelte";
|
import StatementHeader from "./StatementHeader.svelte";
|
||||||
import StatementMonths from "./StatementMonths.svelte";
|
import StatementMonths from "./StatementMonths.svelte";
|
||||||
|
|
||||||
@@ -81,7 +75,7 @@
|
|||||||
? {
|
? {
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
all<TransactionGroup>(`/v1/accounts/${id}/transactions`),
|
all<TransactionGroup>(`/v1/accounts/${id}/transactions`),
|
||||||
queryKey: ["friend-history", id],
|
queryKey: ["debt-history", id],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
@@ -128,9 +122,7 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
const thisMonth = `${now.slice(0, 7)}-01`;
|
const thisMonth = `${now.slice(0, 7)}-01`;
|
||||||
const lineTitle = $derived(
|
const lineTitle = $derived(side === "asset" ? "Balance" : "Debt balance");
|
||||||
side === "asset" ? "Balance" : DIRECTION_LABEL[side]
|
|
||||||
);
|
|
||||||
const series = $derived([
|
const series = $derived([
|
||||||
{
|
{
|
||||||
area: true,
|
area: true,
|
||||||
@@ -199,7 +191,7 @@
|
|||||||
: "xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"}"
|
: "xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"}"
|
||||||
>
|
>
|
||||||
{#if a.liability_type === "debt"}
|
{#if a.liability_type === "debt"}
|
||||||
<FriendPanel {item} />
|
<DebtPanel {item} />
|
||||||
{:else}
|
{:else}
|
||||||
<Box title="In and out by month">
|
<Box title="In and out by month">
|
||||||
{#snippet tools()}
|
{#snippet tools()}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import type { Side, Term } from "#lib/accounts/statement.js";
|
import type { Side, Term } from "#lib/accounts/statement.js";
|
||||||
import { addDays } from "#lib/dates.js";
|
import { addDays } from "#lib/dates.js";
|
||||||
import { formatDate, formatMoney, type Money } from "#lib/format.js";
|
import { formatDate, formatMoney, type Money } from "#lib/format.js";
|
||||||
import { DIRECTION_LABEL } from "./links.js";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
terms,
|
terms,
|
||||||
@@ -33,11 +32,14 @@
|
|||||||
transfer: "text-transfer",
|
transfer: "text-transfer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const word = $derived(
|
function word(value: number, asset: string) {
|
||||||
side === "asset"
|
if (side === "asset") {
|
||||||
? { close: "Closing", open: "Opening" }
|
return asset;
|
||||||
: { close: DIRECTION_LABEL[side], open: DIRECTION_LABEL[side] }
|
}
|
||||||
);
|
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 change = $derived(closing - opening);
|
||||||
const changeTone = $derived.by(() => {
|
const changeTone = $derived.by(() => {
|
||||||
if (side !== "asset" || Math.abs(change) < 0.005) {
|
if (side !== "asset" || Math.abs(change) < 0.005) {
|
||||||
@@ -48,10 +50,10 @@
|
|||||||
const items = $derived([
|
const items = $derived([
|
||||||
{
|
{
|
||||||
key: "open",
|
key: "open",
|
||||||
label: `${word.open}, ${formatDate(addDays(start, -1), "short")}`,
|
label: `${word(opening, "Opening")}, ${formatDate(addDays(start, -1), "short")}`,
|
||||||
op: "",
|
op: "",
|
||||||
tone: "text-cladd-fg",
|
tone: "text-cladd-fg",
|
||||||
value: opening,
|
value: shown(opening),
|
||||||
},
|
},
|
||||||
...terms.map((t) => ({
|
...terms.map((t) => ({
|
||||||
key: t.flow,
|
key: t.flow,
|
||||||
@@ -62,10 +64,10 @@
|
|||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
key: "close",
|
key: "close",
|
||||||
label: `${word.close}, ${formatDate(end, "short")}`,
|
label: `${word(closing, "Closing")}, ${formatDate(end, "short")}`,
|
||||||
op: "=",
|
op: "=",
|
||||||
tone: "text-cladd-fg",
|
tone: "text-cladd-fg",
|
||||||
value: closing,
|
value: shown(closing),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const money = (amount: number, signed = false) =>
|
const money = (amount: number, signed = false) =>
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export const LIABILITY_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DIRECTION_LABEL: Record<string, string> = {
|
export const DIRECTION_LABEL: Record<string, string> = {
|
||||||
credit: "Owed to me",
|
credit: "Owed to you",
|
||||||
debit: "I owe",
|
debit: "You owe",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PERIOD_LABEL: Record<string, string> = {
|
export const PERIOD_LABEL: Record<string, string> = {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
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 { editUrl, showUrl } from "#lib/components/accounts/links.js";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
||||||
import {
|
import { formatMoney } from "#lib/format.js";
|
||||||
formatPrimary,
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
inPrimaryTotal,
|
|
||||||
ratesQuery,
|
|
||||||
} from "#lib/dashboard/primary.svelte.js";
|
|
||||||
import { formatMoney, toNumber } from "#lib/format.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { accountsQuery, byCurrency } from "./data.js";
|
import { accountsQuery, byCurrency } from "./data.js";
|
||||||
@@ -18,14 +15,10 @@
|
|||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const rates = createQuery(() => ratesQuery());
|
|
||||||
|
|
||||||
const groups = $derived(
|
const groups = $derived(
|
||||||
byCurrency(
|
byCurrency(
|
||||||
(liabilities.data ?? []).filter(
|
(liabilities.data ?? []).filter(
|
||||||
(a) =>
|
(a) => isDebt(a) && stance(a.attributes) !== "settled"
|
||||||
a.attributes.liability_type === "debt" &&
|
|
||||||
toNumber(a.attributes.debt_amount) !== 0
|
|
||||||
),
|
),
|
||||||
(a) => a.attributes.currency_code ?? ""
|
(a) => a.attributes.currency_code ?? ""
|
||||||
).map(([code, list]) => {
|
).map(([code, list]) => {
|
||||||
@@ -36,15 +29,12 @@
|
|||||||
symbol: first.currency_symbol ?? code,
|
symbol: first.currency_symbol ?? code,
|
||||||
};
|
};
|
||||||
const rows = list
|
const rows = list
|
||||||
.map((a) => {
|
.map((a) => ({
|
||||||
const due = Math.abs(toNumber(a.attributes.debt_amount));
|
id: a.id,
|
||||||
return {
|
key: `debt-${a.id}`,
|
||||||
id: a.id,
|
name: a.attributes.name,
|
||||||
key: `debt-${a.id}`,
|
value: owed(a.attributes),
|
||||||
name: a.attributes.name,
|
}))
|
||||||
value: a.attributes.liability_direction === "credit" ? due : -due,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((x, y) => y.value - x.value);
|
.sort((x, y) => y.value - x.value);
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
@@ -57,18 +47,24 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nets = $derived.by(() => {
|
const nets = $derived.by(() => {
|
||||||
const total = inPrimaryTotal(
|
const total = display.total(
|
||||||
groups.map((g) => ({ amount: g.net, code: g.code })),
|
groups.map((g) => ({ ...g.cur, amount: g.net }))
|
||||||
rates.data
|
|
||||||
);
|
);
|
||||||
if (total === null) {
|
if (total === null) {
|
||||||
return groups.map((g) => ({
|
return groups.map((g) => ({
|
||||||
code: g.code,
|
code: g.code,
|
||||||
net: g.net,
|
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, () =>
|
const column = useColumn(4, () =>
|
||||||
@@ -91,12 +87,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if groups.length > 0}
|
{#if groups.length > 0}
|
||||||
<Box
|
<Box body="p-0" footer={more} href="/accounts/liabilities" title="Debts">
|
||||||
body="p-0"
|
|
||||||
footer={more}
|
|
||||||
href="/accounts/liabilities"
|
|
||||||
title="Debts with friends"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 transition-opacity {liabilities.isPlaceholderData
|
class="flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 transition-opacity {liabilities.isPlaceholderData
|
||||||
? "opacity-60"
|
? "opacity-60"
|
||||||
@@ -111,7 +102,7 @@
|
|||||||
: "text-expense"}"
|
: "text-expense"}"
|
||||||
>{g.text}</span
|
>{g.text}</span
|
||||||
>
|
>
|
||||||
{g.net > 0 ? "owed to me" : "I owe"}</span
|
{g.net > 0 ? "owed to you" : "owed by you"}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in new issue
Block a user