feat(accounts): the account page as a statement, all accounts instead of the audit report
Asset and liability pages open with opening plus income and money moved in, minus spending and money moved out, equals closing, then thirteen months of in and out next to the daily balance, and the statement with its balance column, day subtotals on ; and the last reconciled date. The audit report becomes one table of all accounts for the period.
This commit is contained in:
1 parent
b10a6bd21a
commit
f3a597d39c
11 files changed
+1005
-435
No files matched your search
@@ -0,0 +1,222 @@
|
||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
contains,
|
||||
eachDay,
|
||||
type Range,
|
||||
} from "#lib/dates.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
|
||||
export type Flow =
|
||||
| "income"
|
||||
| "movedIn"
|
||||
| "spent"
|
||||
| "movedOut"
|
||||
| "opening"
|
||||
| "adjusted";
|
||||
export type Side = "asset" | "credit" | "debit";
|
||||
export type Tone = "income" | "expense" | "transfer" | "neutral";
|
||||
|
||||
export interface Move {
|
||||
amount: number;
|
||||
date: string;
|
||||
flow: Flow;
|
||||
reconciled: boolean;
|
||||
}
|
||||
|
||||
export interface Anchor {
|
||||
balance: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface Term {
|
||||
flow: Flow;
|
||||
label: string;
|
||||
tone: Tone;
|
||||
up: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
type Part = Omit<Term, "up">;
|
||||
|
||||
export type Sums = Record<Flow, number>;
|
||||
|
||||
export const MONTHS = 13;
|
||||
export const FOUR: Flow[] = ["income", "movedIn", "spent", "movedOut"];
|
||||
const FLOWS: Flow[] = [...FOUR, "opening", "adjusted"];
|
||||
const REAL = new Set(["withdrawal", "deposit"]);
|
||||
const OPENING = new Set(["opening balance", "liability credit"]);
|
||||
const CENT = 0.005;
|
||||
const PRECISION = 1e6;
|
||||
|
||||
const clean = (value: number) => Math.round(value * PRECISION) / PRECISION;
|
||||
|
||||
const WORDS: Record<Side, { into: string; out: string }> = {
|
||||
asset: { into: "Income", out: "Spent" },
|
||||
credit: { into: "Lent", out: "Got back" },
|
||||
debit: { into: "Paid back", out: "Borrowed" },
|
||||
};
|
||||
|
||||
export const sideOf = (account: Account, liability: boolean): Side => {
|
||||
if (!liability) {
|
||||
return "asset";
|
||||
}
|
||||
return account.liability_direction === "debit" ? "debit" : "credit";
|
||||
};
|
||||
|
||||
export const signOf = (side: Side) => (side === "debit" ? -1 : 1);
|
||||
|
||||
function flowOf(type: string, into: boolean): Flow {
|
||||
if (type === "transfer") {
|
||||
return into ? "movedIn" : "movedOut";
|
||||
}
|
||||
if (REAL.has(type)) {
|
||||
return into ? "income" : "spent";
|
||||
}
|
||||
return OPENING.has(type) ? "opening" : "adjusted";
|
||||
}
|
||||
|
||||
export function moves(
|
||||
id: string,
|
||||
code: string | null | undefined,
|
||||
groups: Item<TransactionGroup>[]
|
||||
): Move[] {
|
||||
const list: Move[] = [];
|
||||
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 amount = Math.abs(
|
||||
toNumber(foreign ? split.foreign_amount : split.amount)
|
||||
);
|
||||
list.push({
|
||||
amount: into ? amount : -amount,
|
||||
date: split.date.slice(0, 10),
|
||||
flow: flowOf(split.type, into),
|
||||
reconciled: Boolean(split.reconciled),
|
||||
});
|
||||
}
|
||||
}
|
||||
return list.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
export function sums(list: Move[], range: Range) {
|
||||
const total = Object.fromEntries(FLOWS.map((f) => [f, 0])) as Sums;
|
||||
let rows = 0;
|
||||
for (const m of list) {
|
||||
if (contains(range, m.date)) {
|
||||
total[m.flow] += m.amount;
|
||||
rows += 1;
|
||||
}
|
||||
}
|
||||
for (const flow of FLOWS) {
|
||||
total[flow] = clean(total[flow]);
|
||||
}
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
export function balanceAt(list: Move[], anchor: Anchor, day: string): number {
|
||||
let value = anchor.balance;
|
||||
for (const m of list) {
|
||||
if (m.date > anchor.date && m.date <= day) {
|
||||
value += m.amount;
|
||||
} else if (m.date <= anchor.date && m.date > day) {
|
||||
value -= m.amount;
|
||||
}
|
||||
}
|
||||
return clean(value);
|
||||
}
|
||||
|
||||
export function daily(list: Move[], anchor: Anchor, range: Range) {
|
||||
const byDay = new Map<string, number>();
|
||||
for (const m of list) {
|
||||
byDay.set(m.date, (byDay.get(m.date) ?? 0) + m.amount);
|
||||
}
|
||||
let value = balanceAt(list, anchor, addDays(range.start, -1));
|
||||
return eachDay(range).map((day) => {
|
||||
value = clean(value + (byDay.get(day) ?? 0));
|
||||
return { x: day, y: value };
|
||||
});
|
||||
}
|
||||
|
||||
export function monthsOf(range: Range, list: Move[]) {
|
||||
return Array.from({ length: MONTHS }, (_, i) => {
|
||||
const start = addMonths(range.start, i);
|
||||
return {
|
||||
month: start,
|
||||
total: sums(list, { end: addDays(addMonths(start, 1), -1), start }).total,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function chartRange(period: Range, now: string): Range {
|
||||
const current = `${now.slice(0, 7)}-01`;
|
||||
const asked = `${period.end.slice(0, 7)}-01`;
|
||||
const recent = asked <= current && asked >= addMonths(current, 1 - MONTHS);
|
||||
const last = recent ? current : asked;
|
||||
return {
|
||||
end: addDays(addMonths(last, 1), -1),
|
||||
start: addMonths(last, 1 - MONTHS),
|
||||
};
|
||||
}
|
||||
|
||||
export function terms(side: Side, total: Sums): Term[] {
|
||||
const s = signOf(side);
|
||||
const words = WORDS[side];
|
||||
const asset = side === "asset";
|
||||
const into: Part = {
|
||||
flow: "income",
|
||||
label: words.into,
|
||||
tone: asset ? "income" : "expense",
|
||||
value: s * total.income,
|
||||
};
|
||||
const movedIn: Part = {
|
||||
flow: "movedIn",
|
||||
label: "Moved in",
|
||||
tone: "transfer",
|
||||
value: s * total.movedIn,
|
||||
};
|
||||
const out: Part = {
|
||||
flow: "spent",
|
||||
label: words.out,
|
||||
tone: asset ? "expense" : "income",
|
||||
value: s * total.spent,
|
||||
};
|
||||
const movedOut: Part = {
|
||||
flow: "movedOut",
|
||||
label: "Moved out",
|
||||
tone: "transfer",
|
||||
value: s * total.movedOut,
|
||||
};
|
||||
const four =
|
||||
s > 0 ? [into, movedIn, out, movedOut] : [out, movedOut, into, movedIn];
|
||||
const extra: Part[] = [
|
||||
{
|
||||
flow: "opening",
|
||||
label: "Opening balance",
|
||||
tone: "neutral",
|
||||
value: s * total.opening,
|
||||
},
|
||||
{
|
||||
flow: "adjusted",
|
||||
label: "Reconciled",
|
||||
tone: "neutral",
|
||||
value: s * total.adjusted,
|
||||
},
|
||||
];
|
||||
return [
|
||||
...four.map((t, i) => ({ ...t, up: i < 2 })),
|
||||
...extra
|
||||
.filter((t) => Math.abs(t.value) > CENT)
|
||||
.map((t) => ({ ...t, up: t.value > 0 })),
|
||||
];
|
||||
}
|
||||
|
||||
export function reconciledThrough(list: Move[]): string | null {
|
||||
return list.findLast((m) => m.reconciled)?.date ?? null;
|
||||
}
|
||||
@@ -48,6 +48,7 @@
|
||||
typeOf,
|
||||
} from "./links.js";
|
||||
import PeriodList from "./PeriodList.svelte";
|
||||
import Statement from "./Statement.svelte";
|
||||
|
||||
const SPECIAL = new Set(["initial-balance", "reconciliation"]);
|
||||
const BUTTON =
|
||||
@@ -71,6 +72,9 @@
|
||||
const type = $derived(a ? typeOf(a) : "asset");
|
||||
const item = $derived(account.data?.data);
|
||||
const debt = $derived(a?.liability_type === "debt");
|
||||
const statement = $derived(
|
||||
!everything && (type === "asset" || type === "liabilities")
|
||||
);
|
||||
|
||||
const first = createQuery(() => ({
|
||||
enabled: everything,
|
||||
@@ -112,7 +116,7 @@
|
||||
return days <= 3000 ? "1M" : "1Y";
|
||||
});
|
||||
const chart = createQuery(() => ({
|
||||
enabled: !everything || first.isSuccess,
|
||||
enabled: !statement && (!everything || first.isSuccess),
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
api<ChartSet[]>("/v1/chart/account/overview", {
|
||||
@@ -143,7 +147,7 @@
|
||||
Number(page.url.searchParams.get("page") ?? "1") || 1
|
||||
);
|
||||
const dated = createQuery(() => ({
|
||||
enabled: !everything,
|
||||
enabled: !(everything || statement),
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(
|
||||
@@ -302,6 +306,11 @@
|
||||
<p class="pb-3 text-expense">{failure}</p>
|
||||
{/if}
|
||||
|
||||
{#if statement}
|
||||
{#if item}
|
||||
<Statement {item} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="grid gap-3 {debt
|
||||
@@ -317,7 +326,9 @@
|
||||
: "Chart"}
|
||||
>
|
||||
<div
|
||||
class="transition-opacity {chart.isPlaceholderData ? "opacity-60" : ""}"
|
||||
class="transition-opacity {chart.isPlaceholderData
|
||||
? "opacity-60"
|
||||
: ""}"
|
||||
>
|
||||
{#if chart.isPending || (everything && first.isPending)}
|
||||
<div class="h-[300px]"></div>
|
||||
@@ -422,3 +433,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||
import { owed } from "#lib/accounts/ledger.js";
|
||||
import { moves, sums } from "#lib/accounts/statement.js";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { addDays } from "#lib/dates.js";
|
||||
import { formatMoney, type Money, toNumber } from "#lib/format.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { pages, reportQueries } from "#lib/reports/queries.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { showUrl, typeOf } from "./links.js";
|
||||
|
||||
interface Line {
|
||||
closing: number;
|
||||
cur: Omit<Money, "amount">;
|
||||
id: string;
|
||||
income: number;
|
||||
liability: boolean;
|
||||
moved: number;
|
||||
name: string;
|
||||
opening: number;
|
||||
other: number;
|
||||
rows: number;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
const CENT = 0.005;
|
||||
const NUM = "num whitespace-nowrap text-right";
|
||||
|
||||
let {
|
||||
accounts = [],
|
||||
start,
|
||||
end,
|
||||
}: { accounts?: string[]; start: string; end: string } = $props();
|
||||
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
||||
const balances = createQuery(() => ({
|
||||
...reportQueries.balances(start, end),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
const ledger = createQuery(() => ({
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
pages<TransactionGroup>("/v1/transactions", {
|
||||
end: end > start ? end : addDays(start, 1),
|
||||
start,
|
||||
type: "all",
|
||||
}),
|
||||
queryKey: ["accounts-audit", start, end],
|
||||
}));
|
||||
|
||||
const range = $derived({ end, start });
|
||||
const ready = $derived(
|
||||
Boolean(assets.data && liabilities.data && balances.data && ledger.data)
|
||||
);
|
||||
const loading = $derived(
|
||||
balances.isPlaceholderData || ledger.isPlaceholderData
|
||||
);
|
||||
|
||||
function flip(a: Account): number {
|
||||
const debt = owed(a);
|
||||
const balance = toNumber(a.current_balance);
|
||||
return Math.abs(debt) > CENT && Math.sign(balance) === -Math.sign(debt)
|
||||
? -1
|
||||
: 1;
|
||||
}
|
||||
|
||||
function lineOf(item: Item<Account>, liability: boolean): Line {
|
||||
const a = item.attributes;
|
||||
const sign = liability ? flip(a) : 1;
|
||||
const { rows, total } = sums(
|
||||
moves(item.id, a.currency_code, ledger.data ?? []),
|
||||
range
|
||||
);
|
||||
return {
|
||||
closing: sign * (balances.data?.after.get(item.id) ?? 0),
|
||||
cur: {
|
||||
code: a.currency_code,
|
||||
decimals: a.currency_decimal_places,
|
||||
symbol: a.currency_symbol,
|
||||
},
|
||||
id: item.id,
|
||||
income: total.income,
|
||||
liability,
|
||||
moved: total.movedIn + total.movedOut,
|
||||
name: a.name,
|
||||
opening: sign * (balances.data?.before.get(item.id) ?? 0),
|
||||
other: total.opening + total.adjusted,
|
||||
rows,
|
||||
spent: total.spent,
|
||||
};
|
||||
}
|
||||
|
||||
const lines = $derived.by(() => {
|
||||
if (!ready) {
|
||||
return [];
|
||||
}
|
||||
const chosen = new Set(accounts);
|
||||
return [...(assets.data ?? []), ...(liabilities.data ?? [])]
|
||||
.filter((item) =>
|
||||
chosen.size > 0 ? chosen.has(item.id) : item.attributes.active
|
||||
)
|
||||
.map((item) => lineOf(item, typeOf(item.attributes) === "liabilities"));
|
||||
});
|
||||
const byActivity = (x: Line, y: Line) =>
|
||||
y.rows - x.rows || x.name.localeCompare(y.name);
|
||||
const busy = $derived(lines.filter((l) => l.rows > 0));
|
||||
const sections = $derived(
|
||||
[
|
||||
{
|
||||
key: "asset",
|
||||
lines: busy.filter((l) => !l.liability).sort(byActivity),
|
||||
title: "Asset accounts",
|
||||
words: ["Income", "Spent"],
|
||||
},
|
||||
{
|
||||
key: "liability",
|
||||
lines: busy.filter((l) => l.liability).sort(byActivity),
|
||||
title: "Liabilities",
|
||||
words: ["In", "Out"],
|
||||
},
|
||||
].filter((s) => s.lines.length > 0)
|
||||
);
|
||||
const ordered = $derived(sections.flatMap((s) => s.lines));
|
||||
const idle = $derived(
|
||||
lines
|
||||
.filter((l) => l.rows === 0)
|
||||
.sort((x, y) => x.name.localeCompare(y.name))
|
||||
);
|
||||
const adjusted = $derived(lines.some((l) => Math.abs(l.other) > CENT));
|
||||
const href = (id: string) => `${showUrl(id)}/${start}/${end}`;
|
||||
|
||||
const nav = new ListNav<Line>({
|
||||
id: "all-accounts",
|
||||
items: () => ordered,
|
||||
key: (line) => line.id,
|
||||
open: (line) => goto(href(line.id)),
|
||||
});
|
||||
nav.use();
|
||||
|
||||
const money = (line: Line, amount: number, signed = false) =>
|
||||
formatMoney({ ...line.cur, amount }, signed);
|
||||
function tone(value: number, positive: string, negative = positive) {
|
||||
if (Math.abs(value) < CENT) {
|
||||
return "text-cladd-fg-softer";
|
||||
}
|
||||
return value > 0 ? positive : negative;
|
||||
}
|
||||
|
||||
const real = (line: Line, value: number) =>
|
||||
line.liability
|
||||
? tone(value, "text-cladd-fg")
|
||||
: tone(value, "text-income", "text-expense");
|
||||
</script>
|
||||
|
||||
<Box body="p-0 overflow-x-auto" title="Accounts">
|
||||
<table
|
||||
class="w-full min-w-[38rem] border-collapse md:min-w-[46rem] text-[13px] transition-opacity {loading
|
||||
? "opacity-60"
|
||||
: ""}"
|
||||
>
|
||||
{#each sections as section (section.key)}
|
||||
<thead>
|
||||
<tr
|
||||
class="border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft [&>th]:h-8 [&>th]:whitespace-nowrap [&>th]:px-2 [&>th]:font-medium {section.key ===
|
||||
"liability"
|
||||
? "border-t"
|
||||
: ""}"
|
||||
>
|
||||
<th class="text-cladd-fg">{section.title}</th>
|
||||
<th class="w-14 max-md:hidden">Currency</th>
|
||||
<th class="w-32 text-right">Opening</th>
|
||||
<th class="w-28 text-right">{section.words[0]}</th>
|
||||
<th class="w-28 text-right">{section.words[1]}</th>
|
||||
<th class="w-28 text-right">Moved</th>
|
||||
{#if adjusted}
|
||||
<th class="w-28 text-right">Adjusted</th>
|
||||
{/if}
|
||||
<th class="w-32 text-right">Closing</th>
|
||||
<th class="w-14 text-right max-md:hidden">Rows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each section.lines as line (line.id)}
|
||||
{@const index = ordered.indexOf(line)}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline/60 border-b [&>td]:px-2 {nav.cursor ===
|
||||
index
|
||||
? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]"
|
||||
: "hover:bg-row-hover"}"
|
||||
id={nav.rowId(index)}
|
||||
onclick={(event) => nav.click(index, event)}
|
||||
>
|
||||
<td class="max-w-0 truncate max-md:min-w-32">
|
||||
<a class="hover:underline" href={href(line.id)} title={line.name}
|
||||
>{line.name}</a
|
||||
>
|
||||
</td>
|
||||
<td class="text-cladd-fg-soft max-md:hidden">{line.cur.code}</td>
|
||||
<td class={NUM}>{money(line, line.opening)}</td>
|
||||
<td class="{NUM} {real(line, line.income)}">
|
||||
{money(line, line.income, true)}
|
||||
</td>
|
||||
<td class="{NUM} {real(line, line.spent)}">
|
||||
{money(line, line.spent, true)}
|
||||
</td>
|
||||
<td class="{NUM} {tone(line.moved, "text-transfer")}">
|
||||
{money(line, line.moved, true)}
|
||||
</td>
|
||||
{#if adjusted}
|
||||
<td class="{NUM} {tone(line.other, "text-cladd-fg")}">
|
||||
{money(line, line.other, true)}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="{NUM} font-semibold">{money(line, line.closing)}</td>
|
||||
<td class="{NUM} text-cladd-fg-soft max-md:hidden">{line.rows}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{/each}
|
||||
{#if idle.length > 0}
|
||||
<tbody>
|
||||
<tr class="text-[12px] text-cladd-fg-softer [&>td]:px-2 [&>td]:py-1.5">
|
||||
<td colspan={adjusted ? 9 : 8}>
|
||||
No activity:
|
||||
{#each idle as line (line.id)}
|
||||
<a
|
||||
class="hover:text-cladd-fg hover:underline [&:not(:last-child)]:after:content-[',_']"
|
||||
href={href(line.id)}
|
||||
>{line.name}</a
|
||||
>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{/if}
|
||||
</table>
|
||||
{#if !ready}
|
||||
<p class="px-3 py-6 text-center text-cladd-fg-soft">Loading accounts...</p>
|
||||
{:else if lines.length === 0}
|
||||
<p class="px-3 py-6 text-center text-cladd-fg-soft">
|
||||
No accounts selected.
|
||||
</p>
|
||||
{/if}
|
||||
</Box>
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||
import { owed } from "#lib/accounts/ledger.js";
|
||||
import {
|
||||
balanceAt,
|
||||
chartRange,
|
||||
daily,
|
||||
monthsOf,
|
||||
moves,
|
||||
reconciledThrough,
|
||||
sideOf,
|
||||
signOf,
|
||||
sums,
|
||||
terms,
|
||||
} from "#lib/accounts/statement.js";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all } from "#lib/api/lists.js";
|
||||
import type { Account, Item, One, TransactionGroup } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||
import { addDays, addMonths, contains, today } from "#lib/dates.js";
|
||||
import {
|
||||
compactMoney,
|
||||
currentLocale,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
toNumber,
|
||||
} from "#lib/format.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
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 StatementHeader from "./StatementHeader.svelte";
|
||||
import StatementMonths from "./StatementMonths.svelte";
|
||||
|
||||
const MOVED_KEY = "ffui.account.moved";
|
||||
const DAYS_KEY = "ffui.account.days";
|
||||
const TOGGLE =
|
||||
"inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg";
|
||||
const LINK = "text-brand dark:text-(--ff-series-1) hover:underline";
|
||||
|
||||
let { item }: { item: Item<Account> } = $props();
|
||||
|
||||
let moved = $state(localStorage.getItem(MOVED_KEY) !== "0");
|
||||
let days = $state(localStorage.getItem(DAYS_KEY) === "1");
|
||||
|
||||
const now = today();
|
||||
const id = $derived(item.id);
|
||||
const a = $derived(item.attributes);
|
||||
const liability = $derived(typeOf(a) === "liabilities");
|
||||
const side = $derived(sideOf(a, liability));
|
||||
const sign = $derived(signOf(side));
|
||||
const cur = $derived({
|
||||
code: a.currency_code,
|
||||
decimals: a.currency_decimal_places,
|
||||
symbol: a.currency_symbol,
|
||||
});
|
||||
const chart = $derived(chartRange(period.range, now));
|
||||
const span = $derived({
|
||||
end: [chart.end, period.end, now].sort().at(-1) ?? now,
|
||||
start: [chart.start, period.start].sort()[0],
|
||||
});
|
||||
|
||||
const current = createQuery(() => ({
|
||||
enabled: !liability,
|
||||
queryFn: () =>
|
||||
api<One<Account>>(`/v1/accounts/${id}`, { query: { date: now } }),
|
||||
queryKey: ["account", id, now],
|
||||
}));
|
||||
const history = createQuery(() =>
|
||||
liability
|
||||
? {
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/accounts/${id}/transactions`),
|
||||
queryKey: ["friend-history", id],
|
||||
}
|
||||
: {
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/accounts/${id}/transactions`, span),
|
||||
queryKey: ["account-history", id, span.start, span.end],
|
||||
}
|
||||
);
|
||||
|
||||
const anchor = $derived.by(() => {
|
||||
if (liability) {
|
||||
return { balance: owed(a), date: now };
|
||||
}
|
||||
const value = current.data?.data.attributes.current_balance;
|
||||
return value === undefined ? null : { balance: toNumber(value), date: now };
|
||||
});
|
||||
const ready = $derived(
|
||||
Boolean(anchor && history.data) && !history.isPlaceholderData
|
||||
);
|
||||
const list = $derived(
|
||||
history.data ? moves(id, a.currency_code, history.data) : []
|
||||
);
|
||||
const at = (day: string) =>
|
||||
anchor ? sign * balanceAt(list, anchor, day) : 0;
|
||||
const opening = $derived(at(addDays(period.start, -1)));
|
||||
const closing = $derived(at(period.end));
|
||||
const parts = $derived(terms(side, sums(list, period.range).total));
|
||||
const months = $derived(monthsOf(chart, list));
|
||||
const line = $derived(
|
||||
anchor
|
||||
? daily(list, anchor, chart).map((p) => ({ x: p.x, y: sign * p.y }))
|
||||
: []
|
||||
);
|
||||
const groups = $derived(
|
||||
(history.data ?? []).filter((g) =>
|
||||
g.attributes.transactions.some((s) => contains(period.range, s.date))
|
||||
)
|
||||
);
|
||||
const through = $derived(reconciledThrough(list));
|
||||
const monthEnd = (month: string) => addDays(addMonths(month, 1), -1);
|
||||
const selected = $derived(
|
||||
period.start.endsWith("-01") && period.end === monthEnd(period.start)
|
||||
? period.start
|
||||
: null
|
||||
);
|
||||
const thisMonth = `${now.slice(0, 7)}-01`;
|
||||
const lineTitle = $derived(
|
||||
side === "asset" ? "Balance" : DIRECTION_LABEL[side]
|
||||
);
|
||||
const series = $derived([
|
||||
{
|
||||
area: true,
|
||||
format: (value: number) => formatMoney({ ...cur, amount: value }),
|
||||
key: "balance",
|
||||
label: lineTitle,
|
||||
points: line,
|
||||
},
|
||||
]);
|
||||
const monthYear = (value: string) =>
|
||||
new Date(`${value.slice(0, 10)}T12:00:00`).toLocaleDateString(
|
||||
currentLocale(),
|
||||
{ month: "short", year: "2-digit" }
|
||||
);
|
||||
|
||||
function pick(month: string) {
|
||||
goto(`${showUrl(id)}/${month}/${monthEnd(month)}`);
|
||||
}
|
||||
|
||||
function toggleMoved() {
|
||||
moved = !moved;
|
||||
localStorage.setItem(MOVED_KEY, moved ? "1" : "0");
|
||||
}
|
||||
|
||||
function toggleDays() {
|
||||
days = !days;
|
||||
localStorage.setItem(DAYS_KEY, days ? "1" : "0");
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
...(item.attributes.liability_type === "debt"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
group: "Account",
|
||||
keys: "t",
|
||||
label: moved ? "Hide moved money" : "Show moved money",
|
||||
run: toggleMoved,
|
||||
},
|
||||
]),
|
||||
{
|
||||
group: "Account",
|
||||
keys: ";",
|
||||
label: days ? "Hide day subtotals" : "Show day subtotals",
|
||||
run: toggleDays,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<StatementHeader
|
||||
{closing}
|
||||
{cur}
|
||||
empty={!(anchor && history.data)}
|
||||
end={period.end}
|
||||
loading={!ready}
|
||||
{opening}
|
||||
{side}
|
||||
start={period.start}
|
||||
terms={parts}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid gap-3 {a.liability_type === "debt"
|
||||
? "xl:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]"
|
||||
: "xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"}"
|
||||
>
|
||||
{#if a.liability_type === "debt"}
|
||||
<FriendPanel {item} />
|
||||
{:else}
|
||||
<Box title="In and out by month">
|
||||
{#snippet tools()}
|
||||
<button class={TOGGLE} onclick={toggleMoved} type="button">
|
||||
{moved ? "Hide moved" : "Show moved"}
|
||||
<span class="kbd">t</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
<div class="transition-opacity {ready ? "" : "opacity-60"}">
|
||||
<StatementMonths
|
||||
{cur}
|
||||
{months}
|
||||
{moved}
|
||||
onselect={pick}
|
||||
partial={thisMonth}
|
||||
{selected}
|
||||
{side}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
{/if}
|
||||
<Box title={lineTitle}>
|
||||
<div class="transition-opacity {ready ? "" : "opacity-60"}">
|
||||
{#if line.length > 0}
|
||||
<LineChart
|
||||
axisFormat={(value) => compactMoney(value, cur.symbol ?? "")}
|
||||
height={236}
|
||||
label="{lineTitle} of {a.name} by day"
|
||||
marker={contains(chart, now) ? now : undefined}
|
||||
{series}
|
||||
toggle={false}
|
||||
xFormat={monthYear}
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-[264px]"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{#if a.notes}
|
||||
<Box title="Notes">
|
||||
<p class="whitespace-pre-wrap text-cladd-fg">{a.notes}</p>
|
||||
</Box>
|
||||
{/if}
|
||||
|
||||
<Box body="p-0" title="Statement for {period.label}">
|
||||
{#snippet tools()}
|
||||
<span class="num text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||
>{groups.length}
|
||||
{groups.length === 1 ? "transaction" : "transactions"}</span
|
||||
>
|
||||
<button
|
||||
aria-pressed={days}
|
||||
class="{TOGGLE} {days ? "bg-cladd-surface-next text-cladd-fg" : ""}"
|
||||
onclick={toggleDays}
|
||||
type="button"
|
||||
>
|
||||
Day subtotals
|
||||
<span class="kbd">;</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
<TransactionTable
|
||||
context={{ accountId: id }}
|
||||
empty="No transactions in this period."
|
||||
{groups}
|
||||
id="account-tx"
|
||||
loading={history.isFetching}
|
||||
section={days ? (group) => sectionOf("day", group) : undefined}
|
||||
/>
|
||||
{#snippet footer()}
|
||||
{#if canReconcile(item)}
|
||||
<span class="text-cladd-fg-soft">
|
||||
{through
|
||||
? `Reconciled through ${formatDate(through)}`
|
||||
: `Not reconciled since ${formatDate(span.start, "month")}`}
|
||||
</span>
|
||||
<a class={LINK} href={reconcileUrl(id)}
|
||||
>Reconcile {period.label} <span class="kbd">R</span></a
|
||||
>
|
||||
{/if}
|
||||
<a class="{LINK} ml-auto" href="{showUrl(id)}/all"
|
||||
>Show all transactions without grouping them by date</a
|
||||
>
|
||||
{/snippet}
|
||||
</Box>
|
||||
</div>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
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,
|
||||
opening,
|
||||
closing,
|
||||
side,
|
||||
cur,
|
||||
start,
|
||||
end,
|
||||
loading = false,
|
||||
empty = false,
|
||||
}: {
|
||||
terms: Term[];
|
||||
opening: number;
|
||||
closing: number;
|
||||
side: Side;
|
||||
cur: Omit<Money, "amount">;
|
||||
start: string;
|
||||
end: string;
|
||||
loading?: boolean;
|
||||
empty?: boolean;
|
||||
} = $props();
|
||||
|
||||
const TONE = {
|
||||
expense: "text-expense",
|
||||
income: "text-income",
|
||||
neutral: "text-cladd-fg",
|
||||
transfer: "text-transfer",
|
||||
};
|
||||
|
||||
const word = $derived(
|
||||
side === "asset"
|
||||
? { close: "Closing", open: "Opening" }
|
||||
: { close: DIRECTION_LABEL[side], open: DIRECTION_LABEL[side] }
|
||||
);
|
||||
const change = $derived(closing - opening);
|
||||
const changeTone = $derived.by(() => {
|
||||
if (side !== "asset" || Math.abs(change) < 0.005) {
|
||||
return "text-cladd-fg-soft";
|
||||
}
|
||||
return change > 0 ? "text-income" : "text-expense";
|
||||
});
|
||||
const items = $derived([
|
||||
{
|
||||
key: "open",
|
||||
label: `${word.open}, ${formatDate(addDays(start, -1), "short")}`,
|
||||
op: "",
|
||||
tone: "text-cladd-fg",
|
||||
value: opening,
|
||||
},
|
||||
...terms.map((t) => ({
|
||||
key: t.flow,
|
||||
label: t.label,
|
||||
op: t.up ? "+" : "−",
|
||||
tone: Math.abs(t.value) < 0.005 ? "text-cladd-fg-soft" : TONE[t.tone],
|
||||
value: Math.abs(t.value),
|
||||
})),
|
||||
{
|
||||
key: "close",
|
||||
label: `${word.close}, ${formatDate(end, "short")}`,
|
||||
op: "=",
|
||||
tone: "text-cladd-fg",
|
||||
value: closing,
|
||||
},
|
||||
]);
|
||||
const money = (amount: number, signed = false) =>
|
||||
empty ? " " : formatMoney({ ...cur, amount }, signed);
|
||||
</script>
|
||||
|
||||
<section
|
||||
aria-busy={loading}
|
||||
aria-label="Balance change"
|
||||
class="cladd-surface cladd-outline rounded-cladd-lg px-3 py-3 transition-opacity lg:px-4 {loading
|
||||
? "opacity-60"
|
||||
: ""}"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-baseline gap-x-2 gap-y-1 lg:flex lg:flex-wrap lg:items-end lg:gap-x-5 lg:gap-y-3"
|
||||
>
|
||||
{#each items as item (item.key)}
|
||||
<div
|
||||
class="col-span-3 grid grid-cols-subgrid items-baseline lg:grid-cols-[auto_auto] lg:gap-x-2 {item.key ===
|
||||
"close"
|
||||
? "border-cladd-outline max-lg:mt-1 max-lg:border-t max-lg:pt-1"
|
||||
: ""}"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="text-center text-[18px] text-cladd-fg-softer leading-tight lg:col-start-1 lg:row-start-2 lg:text-[22px] {item.op
|
||||
? ""
|
||||
: "lg:hidden"}"
|
||||
>{item.op}</span
|
||||
>
|
||||
<span
|
||||
class="truncate text-[13px] text-cladd-fg-soft lg:col-start-2 lg:row-start-1 lg:text-[12px]"
|
||||
>{item.label}</span
|
||||
>
|
||||
<span
|
||||
class="num text-right font-semibold text-[18px] leading-tight tracking-[-0.02em] lg:col-start-2 lg:row-start-2 lg:text-left lg:text-[22px] {item.tone}"
|
||||
>{money(item.value)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<div
|
||||
class="col-span-3 grid grid-cols-subgrid items-baseline lg:ml-auto lg:flex lg:flex-col lg:items-end"
|
||||
>
|
||||
<span class="col-start-2 text-[13px] text-cladd-fg-soft lg:text-[12px]"
|
||||
>Change</span
|
||||
>
|
||||
<span class="num text-right font-semibold text-[15px] {changeTone}"
|
||||
>{money(change, true)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { type Side, type Sums, terms } from "#lib/accounts/statement.js";
|
||||
import Columns from "#lib/components/charts/Columns.svelte";
|
||||
import {
|
||||
type LegendItem,
|
||||
type StackRow,
|
||||
tones,
|
||||
wash,
|
||||
} from "#lib/components/charts/chart.js";
|
||||
import Legend from "#lib/components/charts/Legend.svelte";
|
||||
import {
|
||||
compactMoney,
|
||||
currentLocale,
|
||||
formatMoney,
|
||||
type Money,
|
||||
} from "#lib/format.js";
|
||||
|
||||
const NARROW = 560;
|
||||
const COLOR = {
|
||||
expense: tones.expense,
|
||||
income: tones.income,
|
||||
neutral: "var(--cladd-fg-softer)",
|
||||
transfer: wash(tones.transfer, 55),
|
||||
};
|
||||
|
||||
let {
|
||||
months,
|
||||
side,
|
||||
cur,
|
||||
moved,
|
||||
selected,
|
||||
partial,
|
||||
onselect,
|
||||
}: {
|
||||
months: { month: string; total: Sums }[];
|
||||
side: Side;
|
||||
cur: Omit<Money, "amount">;
|
||||
moved: boolean;
|
||||
selected: string | null;
|
||||
partial: string | null;
|
||||
onselect: (month: string) => void;
|
||||
} = $props();
|
||||
|
||||
let hover = $state<string | null>(null);
|
||||
let width = $state(0);
|
||||
|
||||
const name = (month: string, style: "narrow" | "short" | "long") =>
|
||||
new Date(`${month.slice(0, 7)}-15T12:00:00`).toLocaleDateString(
|
||||
currentLocale(),
|
||||
style === "long" ? { month: "long", year: "numeric" } : { month: style }
|
||||
);
|
||||
const format = (value: number) =>
|
||||
formatMoney({ ...cur, amount: value }, true);
|
||||
|
||||
const parts = $derived(
|
||||
months.map((m) => ({
|
||||
month: m.month,
|
||||
terms: terms(side, m.total).slice(0, 4),
|
||||
}))
|
||||
);
|
||||
const keys = $derived(
|
||||
(parts[0]?.terms ?? [])
|
||||
.filter((t) => moved || t.tone !== "transfer")
|
||||
.map((t) => ({ color: COLOR[t.tone], key: t.flow, label: t.label }))
|
||||
);
|
||||
const rows = $derived<StackRow[]>(
|
||||
parts.map((p) => ({
|
||||
key: p.month,
|
||||
label: name(p.month, width < NARROW ? "narrow" : "short"),
|
||||
values: Object.fromEntries(p.terms.map((t) => [t.flow, t.value])),
|
||||
}))
|
||||
);
|
||||
const read = $derived(hover ?? selected ?? rows.at(-1)?.key ?? null);
|
||||
const row = $derived(rows.find((r) => r.key === read));
|
||||
const legend = $derived<LegendItem[]>(
|
||||
keys.map((k) => ({
|
||||
...k,
|
||||
shape: "rect",
|
||||
value: format(row?.values[k.key] ?? 0),
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2" bind:clientWidth={width}>
|
||||
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<span class="w-[8.5rem] shrink-0 text-[12px] text-cladd-fg"
|
||||
>{read ? name(read, "long") : ""}</span
|
||||
>
|
||||
<Legend class="min-w-0 flex-1" items={legend} />
|
||||
</div>
|
||||
<Columns
|
||||
axisFormat={(value) => compactMoney(value, cur.symbol ?? "")}
|
||||
{format}
|
||||
height={208}
|
||||
{keys}
|
||||
label="Money in and out by month"
|
||||
legend={false}
|
||||
onselect={(r) => onselect(r.key)}
|
||||
{partial}
|
||||
{rows}
|
||||
{selected}
|
||||
bind:active={hover}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,246 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
||||
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
|
||||
import ArrowRight from "@lucide/svelte/icons/arrow-right";
|
||||
import Calculator from "@lucide/svelte/icons/calculator";
|
||||
import Star from "@lucide/svelte/icons/star";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import type { Account } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { addDays } from "#lib/dates.js";
|
||||
import { formatDate, formatDateTime } from "#lib/format.js";
|
||||
import { type AuditColumn, ledger } from "#lib/reports/audit.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
id,
|
||||
account,
|
||||
before,
|
||||
after,
|
||||
columns,
|
||||
}: {
|
||||
id: string;
|
||||
account: Account | undefined;
|
||||
before: number | undefined;
|
||||
after: number | undefined;
|
||||
columns: AuditColumn[];
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const code = $derived(account?.currency_code ?? "");
|
||||
const query = createQuery(() => reportQueries.ledger(id, p.start, p.end));
|
||||
const rows = $derived(
|
||||
query.data && before !== undefined
|
||||
? ledger(query.data, id, code, before)
|
||||
: undefined
|
||||
);
|
||||
const show = (key: AuditColumn) => columns.includes(key);
|
||||
const ICONS = {
|
||||
deposit: ArrowRight,
|
||||
"opening balance": Star,
|
||||
reconciliation: Calculator,
|
||||
transfer: ArrowLeftRight,
|
||||
withdrawal: ArrowLeft,
|
||||
} as const;
|
||||
const META_DATES = [
|
||||
"interest_date",
|
||||
"book_date",
|
||||
"process_date",
|
||||
"due_date",
|
||||
"payment_date",
|
||||
"invoice_date",
|
||||
] as const;
|
||||
const dateColumns = $derived(META_DATES.filter((key) => show(key)));
|
||||
</script>
|
||||
|
||||
<Box body="p-0" href="/accounts/show/{id}" title={account?.name ?? `#${id}`}>
|
||||
{#if !rows}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="px-3 py-3">
|
||||
No activity was recorded on account
|
||||
<a
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
href="/accounts/show/{id}"
|
||||
>{account?.name}</a
|
||||
>
|
||||
between {formatDate(p.start, "long")} and {formatDate(p.end, "long")}.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="px-3 py-2 text-[13px]">
|
||||
Account balance of <strong>{account?.name}</strong> at the end of
|
||||
{formatDate(addDays(p.start, -1), "long")}
|
||||
was:
|
||||
<span class="num font-semibold">{money(code, before ?? 0)}</span>
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-left text-[12px] text-cladd-fg-soft">
|
||||
{#if show("icon")}
|
||||
<th class="h-7 w-8 px-2"><span class="sr-only">Type</span></th>
|
||||
{/if}
|
||||
{#if show("description")}
|
||||
<th class="h-7 px-3 font-medium">Description</th>
|
||||
{/if}
|
||||
{#if show("balance_before")}
|
||||
<th class="h-7 px-3 text-right font-medium">Balance before</th>
|
||||
{/if}
|
||||
{#if show("amount")}
|
||||
<th class="h-7 px-3 text-right font-medium">Amount</th>
|
||||
{/if}
|
||||
{#if show("balance_after")}
|
||||
<th class="h-7 px-3 text-right font-medium">Balance after</th>
|
||||
{/if}
|
||||
{#if show("date")}
|
||||
<th class="h-7 px-3 font-medium">Date</th>
|
||||
{/if}
|
||||
{#if show("from")}
|
||||
<th class="h-7 px-3 font-medium">From</th>
|
||||
{/if}
|
||||
{#if show("to")}
|
||||
<th class="h-7 px-3 font-medium">To</th>
|
||||
{/if}
|
||||
{#if show("budget")}
|
||||
<th class="h-7 px-3 font-medium">Budget</th>
|
||||
{/if}
|
||||
{#if show("category")}
|
||||
<th class="h-7 px-3 font-medium">Category</th>
|
||||
{/if}
|
||||
{#if show("bill")}
|
||||
<th class="h-7 px-3 font-medium">Subscription</th>
|
||||
{/if}
|
||||
{#if show("created_at")}
|
||||
<th class="h-7 px-3 font-medium">Created at</th>
|
||||
{/if}
|
||||
{#if show("updated_at")}
|
||||
<th class="h-7 px-3 font-medium">Updated at</th>
|
||||
{/if}
|
||||
{#if show("notes")}
|
||||
<th class="h-7 px-3 font-medium">Notes</th>
|
||||
{/if}
|
||||
{#each dateColumns as key (key)}
|
||||
<th class="h-7 px-3 font-medium">
|
||||
{key.replace("_date", "").replace("process", "processing")}
|
||||
date
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.split.transaction_journal_id)}
|
||||
{@const s = row.split}
|
||||
{@const Icon =
|
||||
ICONS[s.type as keyof typeof ICONS] ?? ArrowLeftRight}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-t align-top hover:bg-row-hover"
|
||||
>
|
||||
{#if show("icon")}
|
||||
<td class="px-2 py-1.5 text-cladd-fg-soft">
|
||||
<Icon aria-label={s.type} class="size-3.5" />
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("description")}
|
||||
<td class="max-w-[22rem] px-3 py-1">
|
||||
<a
|
||||
class="hover:underline"
|
||||
href="/transactions/show/{row.group}"
|
||||
>
|
||||
{#if row.title}
|
||||
{row.title}
|
||||
({s.description})
|
||||
{:else}
|
||||
{s.description}
|
||||
{/if}
|
||||
</a>
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("balance_before")}
|
||||
<td
|
||||
class="num whitespace-nowrap px-3 py-1 text-right text-cladd-fg-soft"
|
||||
>
|
||||
{money(code, row.before)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("amount")}
|
||||
<td
|
||||
class="num whitespace-nowrap px-3 py-1 text-right {row.amount <
|
||||
0
|
||||
? "text-expense"
|
||||
: "text-income"}"
|
||||
>
|
||||
{money(code, row.amount)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("balance_after")}
|
||||
<td class="num whitespace-nowrap px-3 py-1 text-right">
|
||||
{money(code, row.after)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("date")}
|
||||
<td class="num whitespace-nowrap px-3 py-1">
|
||||
{formatDate(s.date, "medium")}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("from")}
|
||||
<td class="max-w-[12rem] truncate px-3 py-1">
|
||||
<a class="hover:underline" href="/accounts/show/{s.source_id}"
|
||||
>{s.source_name}</a
|
||||
>
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("to")}
|
||||
<td class="max-w-[12rem] truncate px-3 py-1">
|
||||
<a
|
||||
class="hover:underline"
|
||||
href="/accounts/show/{s.destination_id}"
|
||||
>{s.destination_name}</a
|
||||
>
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("budget")}
|
||||
<td class="px-3 py-1">{s.budget_name ?? ""}</td>
|
||||
{/if}
|
||||
{#if show("category")}
|
||||
<td class="px-3 py-1">{s.category_name ?? ""}</td>
|
||||
{/if}
|
||||
{#if show("bill")}
|
||||
<td class="px-3 py-1">{s.bill_name ?? ""}</td>
|
||||
{/if}
|
||||
{#if show("created_at")}
|
||||
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
|
||||
{formatDateTime(row.created)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("updated_at")}
|
||||
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
|
||||
{formatDateTime(row.updated)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("notes")}
|
||||
<td
|
||||
class="max-w-[24rem] whitespace-pre-line px-3 py-1 text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
{s.notes ?? ""}
|
||||
</td>
|
||||
{/if}
|
||||
{#each dateColumns as key (key)}
|
||||
<td class="num whitespace-nowrap px-3 py-1">
|
||||
{formatDate(s[key] ?? null, "medium")}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="border-cladd-outline border-t px-3 py-2 text-[13px]">
|
||||
Account balance of <strong>{account?.name}</strong> at the end of
|
||||
{formatDate(p.end, "long")}
|
||||
was:
|
||||
<span class="num font-semibold">{money(code, after ?? 0)}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</Box>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import {
|
||||
AUDIT_COLUMNS,
|
||||
type AuditColumn,
|
||||
DEFAULT_AUDIT,
|
||||
} from "#lib/reports/audit.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { navigate, useReport } from "#lib/reports/report.svelte.js";
|
||||
import { page } from "$app/state";
|
||||
import AuditAccount from "./AuditAccount.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
|
||||
|
||||
const columns = $derived.by(() => {
|
||||
const raw = page.url.searchParams.get("cols");
|
||||
if (raw === null) {
|
||||
return DEFAULT_AUDIT;
|
||||
}
|
||||
const keys = new Set(raw.split(","));
|
||||
return AUDIT_COLUMNS.map((c) => c.key).filter((key) => keys.has(key));
|
||||
});
|
||||
const accounts = $derived(
|
||||
report.selected
|
||||
.map((id) => ({ account: report.byId().get(id), id }))
|
||||
.filter((a) => a.account)
|
||||
.sort((a, b) =>
|
||||
(a.account?.name ?? "").localeCompare(b.account?.name ?? "")
|
||||
)
|
||||
);
|
||||
|
||||
function toggle(key: AuditColumn, on: boolean) {
|
||||
const next = AUDIT_COLUMNS.map((c) => c.key).filter((k) =>
|
||||
k === key ? on : columns.includes(k)
|
||||
);
|
||||
navigate({ cols: next.join(",") });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<Box body="px-3 py-2" title="Options">
|
||||
<fieldset class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<legend class="sr-only">Visible columns</legend>
|
||||
{#each AUDIT_COLUMNS as column (column.key)}
|
||||
<label class="flex h-7 items-center gap-1.5 text-[13px]">
|
||||
<input
|
||||
checked={columns.includes(column.key)}
|
||||
class="size-3.5 accent-[var(--ff-brand)]"
|
||||
onchange={(event) =>
|
||||
toggle(column.key, event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
>
|
||||
{column.label}
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</Box>
|
||||
{#if !report.accountsReady}
|
||||
<p class="text-cladd-fg-soft">Loading</p>
|
||||
{/if}
|
||||
{#each accounts as item (item.id)}
|
||||
<AuditAccount
|
||||
account={item.account}
|
||||
after={balances.data?.after.get(item.id)}
|
||||
before={balances.data?.before.get(item.id)}
|
||||
{columns}
|
||||
id={item.id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import AllAccounts from "#lib/components/accounts/AllAccounts.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { formatRange } from "#lib/format.js";
|
||||
import type { ReportParams } from "#lib/reports/params.js";
|
||||
import { createReport, provideReport } from "#lib/reports/report.svelte.js";
|
||||
import AuditReport from "./AuditReport.svelte";
|
||||
import DefaultReport from "./DefaultReport.svelte";
|
||||
import DimensionReport from "./DimensionReport.svelte";
|
||||
import ParamBar from "./ParamBar.svelte";
|
||||
@@ -14,7 +14,7 @@
|
||||
provideReport(report);
|
||||
|
||||
const SUBTITLES: Record<string, string> = {
|
||||
audit: "Transaction history account overview",
|
||||
audit: "All accounts",
|
||||
budget: "Budget report",
|
||||
category: "Category report",
|
||||
default: "Default financial report",
|
||||
@@ -44,7 +44,11 @@
|
||||
{#if params.type === "default"}
|
||||
<DefaultReport />
|
||||
{:else if params.type === "audit"}
|
||||
<AuditReport />
|
||||
<AllAccounts
|
||||
accounts={report.selected}
|
||||
end={params.end}
|
||||
start={params.start}
|
||||
/>
|
||||
{:else}
|
||||
{#key params.type}
|
||||
<DimensionReport />
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
|
||||
export interface LedgerRow {
|
||||
after: number;
|
||||
amount: number;
|
||||
before: number;
|
||||
created: string | null;
|
||||
group: string;
|
||||
split: Split;
|
||||
title: string | null;
|
||||
updated: string | null;
|
||||
}
|
||||
|
||||
export const AUDIT_COLUMNS = [
|
||||
{ key: "icon", label: "Icon" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "balance_before", label: "Balance before" },
|
||||
{ key: "amount", label: "Amount" },
|
||||
{ key: "balance_after", label: "Balance after" },
|
||||
{ key: "date", label: "Date" },
|
||||
{ key: "from", label: "From" },
|
||||
{ key: "to", label: "To" },
|
||||
{ key: "budget", label: "Budget" },
|
||||
{ key: "category", label: "Category" },
|
||||
{ key: "bill", label: "Subscription" },
|
||||
{ key: "created_at", label: "Created at" },
|
||||
{ key: "updated_at", label: "Updated at" },
|
||||
{ key: "notes", label: "Notes" },
|
||||
{ key: "interest_date", label: "Interest date" },
|
||||
{ key: "book_date", label: "Book date" },
|
||||
{ key: "process_date", label: "Processing date" },
|
||||
{ key: "due_date", label: "Due date" },
|
||||
{ key: "payment_date", label: "Payment date" },
|
||||
{ key: "invoice_date", label: "Invoice date" },
|
||||
] as const;
|
||||
|
||||
export type AuditColumn = (typeof AUDIT_COLUMNS)[number]["key"];
|
||||
|
||||
export const DEFAULT_AUDIT: AuditColumn[] = [
|
||||
"icon",
|
||||
"description",
|
||||
"balance_before",
|
||||
"amount",
|
||||
"balance_after",
|
||||
"date",
|
||||
"to",
|
||||
];
|
||||
|
||||
function signed(split: Split, account: string, code: string | undefined) {
|
||||
const foreign =
|
||||
split.foreign_amount !== null &&
|
||||
split.foreign_amount !== undefined &&
|
||||
split.foreign_currency_code === code &&
|
||||
split.currency_code !== code;
|
||||
const value = Number(foreign ? split.foreign_amount : split.amount);
|
||||
return String(split.source_id) === account ? -value : value;
|
||||
}
|
||||
|
||||
export function ledger(
|
||||
groups: Item<TransactionGroup>[],
|
||||
account: string,
|
||||
code: string | undefined,
|
||||
start: number
|
||||
): LedgerRow[] {
|
||||
const rows = groups.flatMap((group) =>
|
||||
group.attributes.transactions
|
||||
.filter(
|
||||
(split) =>
|
||||
String(split.source_id) === account ||
|
||||
String(split.destination_id) === account
|
||||
)
|
||||
.map((split) => ({ group, split }))
|
||||
);
|
||||
rows.sort(
|
||||
(a, b) =>
|
||||
a.split.date.localeCompare(b.split.date) ||
|
||||
Number(a.split.transaction_journal_id) -
|
||||
Number(b.split.transaction_journal_id)
|
||||
);
|
||||
let balance = start;
|
||||
return rows.map(({ group, split }) => {
|
||||
const amount = signed(split, account, code);
|
||||
const before = balance;
|
||||
balance += amount;
|
||||
return {
|
||||
after: balance,
|
||||
amount,
|
||||
before,
|
||||
created: group.attributes.created_at ?? null,
|
||||
group: group.id,
|
||||
split,
|
||||
title: group.attributes.group_title ?? null,
|
||||
updated: group.attributes.updated_at ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
import { ledger } from "./audit.js";
|
||||
import {
|
||||
amountOf,
|
||||
bucketRange,
|
||||
@@ -171,16 +170,3 @@ test("currency exchanges between selected accounts are kept apart", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("audit ledger runs the balance forward in the account currency", () => {
|
||||
const rows = ledger(groups, "1", "UAH", 1000);
|
||||
expect(rows.map((row) => [row.amount, row.after])).toEqual([
|
||||
[-100, 900],
|
||||
[-50, 850],
|
||||
[-4000, -3150],
|
||||
[300, -2850],
|
||||
[-20, -2870],
|
||||
]);
|
||||
const usd = ledger(groups, "2", "USD", 0);
|
||||
expect(usd.map((row) => row.amount)).toEqual([100]);
|
||||
});
|
||||
Reference in new issue
Block a user