diff --git a/src/lib/accounts/statement.ts b/src/lib/accounts/statement.ts new file mode 100644 index 0000000..10fa1f3 --- /dev/null +++ b/src/lib/accounts/statement.ts @@ -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; + +export type Sums = Record; + +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 = { + 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[] +): 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(); + 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; +} diff --git a/src/lib/components/accounts/AccountShow.svelte b/src/lib/components/accounts/AccountShow.svelte index 241daf0..7f101e5 100644 --- a/src/lib/components/accounts/AccountShow.svelte +++ b/src/lib/components/accounts/AccountShow.svelte @@ -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("/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( @@ -302,123 +306,131 @@

{failure}

{/if} -
-
- {#if debt && item} - - {/if} - + {/if} +{:else} +
+
-
+ {/if} + - {#if chart.isPending || (everything && first.isPending)} -
- {:else if chart.isError} -

{chart.error.message}

- {:else if series.length === 0} -

- No balance to draw. -

- {:else} - - {/if} -
- -
- - {#if !everything} -
- - - -
- {/if} - - {#if a?.notes} - -

{a.notes}

-
- {/if} - -
- - {#snippet tools()} - {total} - transactions - {#if everything} - - {/if} - {/snippet} - - {#snippet footer()} - {#if everything} - Show the current period and overview - - {:else} - Show all transactions without grouping them by date - {/if} - {/snippet} - - {#if !everything && a} -
- + {#if chart.isPending || (everything && first.isPending)} +
+ {:else if chart.isError} +

{chart.error.message}

+ {:else if series.length === 0} +

+ No balance to draw. +

+ {:else} + + {/if} +
+ +
+ + {#if !everything} +
+ + +
{/if} + + {#if a?.notes} + +

{a.notes}

+
+ {/if} + +
+ + {#snippet tools()} + {total} + transactions + {#if everything} + + {/if} + {/snippet} + + {#snippet footer()} + {#if everything} + Show the current period and overview + + {:else} + Show all transactions without grouping them by date + {/if} + {/snippet} + + {#if !everything && a} +
+ +
+ {/if} +
-
+{/if} diff --git a/src/lib/components/accounts/AllAccounts.svelte b/src/lib/components/accounts/AllAccounts.svelte new file mode 100644 index 0000000..4335a87 --- /dev/null +++ b/src/lib/components/accounts/AllAccounts.svelte @@ -0,0 +1,248 @@ + + + + + {#each sections as section (section.key)} + + + + + + + + + {#if adjusted} + + {/if} + + + + + + {#each section.lines as line (line.id)} + {@const index = ordered.indexOf(line)} + nav.click(index, event)} + > + + + + + + + {#if adjusted} + + {/if} + + + + {/each} + + {/each} + {#if idle.length > 0} + + + + + + {/if} +
{section.title}CurrencyOpening{section.words[0]}{section.words[1]}MovedAdjustedClosingRows
+ {line.name} + {line.cur.code}{money(line, line.opening)} + {money(line, line.income, true)} + + {money(line, line.spent, true)} + + {money(line, line.moved, true)} + + {money(line, line.other, true)} + {money(line, line.closing)}{line.rows}
+ No activity: + {#each idle as line (line.id)} + {line.name} + {/each} +
+ {#if !ready} +

Loading accounts...

+ {:else if lines.length === 0} +

+ No accounts selected. +

+ {/if} +
diff --git a/src/lib/components/accounts/Statement.svelte b/src/lib/components/accounts/Statement.svelte new file mode 100644 index 0000000..3f766a8 --- /dev/null +++ b/src/lib/components/accounts/Statement.svelte @@ -0,0 +1,289 @@ + + +
+ + +
+ {#if a.liability_type === "debt"} + + {:else} + + {#snippet tools()} + + {/snippet} +
+ +
+
+ {/if} + +
+ {#if line.length > 0} + 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} +
+ {/if} +
+
+
+ + {#if a.notes} + +

{a.notes}

+
+ {/if} + + + {#snippet tools()} + {groups.length} + {groups.length === 1 ? "transaction" : "transactions"} + + {/snippet} + sectionOf("day", group) : undefined} + /> + {#snippet footer()} + {#if canReconcile(item)} + + {through + ? `Reconciled through ${formatDate(through)}` + : `Not reconciled since ${formatDate(span.start, "month")}`} + + Reconcile {period.label} R + {/if} + Show all transactions without grouping them by date + {/snippet} + +
diff --git a/src/lib/components/accounts/StatementHeader.svelte b/src/lib/components/accounts/StatementHeader.svelte new file mode 100644 index 0000000..dfb5c1a --- /dev/null +++ b/src/lib/components/accounts/StatementHeader.svelte @@ -0,0 +1,120 @@ + + +
+
+ {#each items as item (item.key)} +
+ + {item.label} + {money(item.value)} +
+ {/each} +
+ Change + {money(change, true)} +
+
+
diff --git a/src/lib/components/accounts/StatementMonths.svelte b/src/lib/components/accounts/StatementMonths.svelte new file mode 100644 index 0000000..2b1895f --- /dev/null +++ b/src/lib/components/accounts/StatementMonths.svelte @@ -0,0 +1,104 @@ + + +
+
+ {read ? name(read, "long") : ""} + +
+ 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} + /> +
diff --git a/src/lib/components/reports/AuditAccount.svelte b/src/lib/components/reports/AuditAccount.svelte deleted file mode 100644 index af3a2fa..0000000 --- a/src/lib/components/reports/AuditAccount.svelte +++ /dev/null @@ -1,246 +0,0 @@ - - - - {#if !rows} -

Loading

- {:else if rows.length === 0} -

- No activity was recorded on account - {account?.name} - between {formatDate(p.start, "long")} and {formatDate(p.end, "long")}. -

- {:else} -

- Account balance of {account?.name} at the end of - {formatDate(addDays(p.start, -1), "long")} - was: - {money(code, before ?? 0)} -

-
- - - - {#if show("icon")} - - {/if} - {#if show("description")} - - {/if} - {#if show("balance_before")} - - {/if} - {#if show("amount")} - - {/if} - {#if show("balance_after")} - - {/if} - {#if show("date")} - - {/if} - {#if show("from")} - - {/if} - {#if show("to")} - - {/if} - {#if show("budget")} - - {/if} - {#if show("category")} - - {/if} - {#if show("bill")} - - {/if} - {#if show("created_at")} - - {/if} - {#if show("updated_at")} - - {/if} - {#if show("notes")} - - {/if} - {#each dateColumns as key (key)} - - {/each} - - - - {#each rows as row (row.split.transaction_journal_id)} - {@const s = row.split} - {@const Icon = - ICONS[s.type as keyof typeof ICONS] ?? ArrowLeftRight} - - {#if show("icon")} - - {/if} - {#if show("description")} - - {/if} - {#if show("balance_before")} - - {/if} - {#if show("amount")} - - {/if} - {#if show("balance_after")} - - {/if} - {#if show("date")} - - {/if} - {#if show("from")} - - {/if} - {#if show("to")} - - {/if} - {#if show("budget")} - - {/if} - {#if show("category")} - - {/if} - {#if show("bill")} - - {/if} - {#if show("created_at")} - - {/if} - {#if show("updated_at")} - - {/if} - {#if show("notes")} - - {/if} - {#each dateColumns as key (key)} - - {/each} - - {/each} - -
TypeDescriptionBalance beforeAmountBalance afterDateFromToBudgetCategorySubscriptionCreated atUpdated atNotes - {key.replace("_date", "").replace("process", "processing")} - date -
- - - - {#if row.title} - {row.title} - ({s.description}) - {:else} - {s.description} - {/if} - - - {money(code, row.before)} - - {money(code, row.amount)} - - {money(code, row.after)} - - {formatDate(s.date, "medium")} - - {s.source_name} - - {s.destination_name} - {s.budget_name ?? ""}{s.category_name ?? ""}{s.bill_name ?? ""} - {formatDateTime(row.created)} - - {formatDateTime(row.updated)} - - {s.notes ?? ""} - - {formatDate(s[key] ?? null, "medium")} -
-
-

- Account balance of {account?.name} at the end of - {formatDate(p.end, "long")} - was: - {money(code, after ?? 0)} -

- {/if} -
diff --git a/src/lib/components/reports/AuditReport.svelte b/src/lib/components/reports/AuditReport.svelte deleted file mode 100644 index 30e3f9f..0000000 --- a/src/lib/components/reports/AuditReport.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - -
- -
- Visible columns - {#each AUDIT_COLUMNS as column (column.key)} - - {/each} -
-
- {#if !report.accountsReady} -

Loading

- {/if} - {#each accounts as item (item.id)} - - {/each} -
diff --git a/src/lib/components/reports/ReportPage.svelte b/src/lib/components/reports/ReportPage.svelte index f9f4a23..f8602e2 100644 --- a/src/lib/components/reports/ReportPage.svelte +++ b/src/lib/components/reports/ReportPage.svelte @@ -1,9 +1,9 @@