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:
hh committed 2026-10-16 18:22:01 +00:00
1 parent 4936258f32
commit 54895b9305
11 files changed
+352 -336

No files matched your search

+23 -32
View File
@@ -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}