diff --git a/src/lib/components/editor/RefField.svelte b/src/lib/components/editor/RefField.svelte
index a0b0acb..5b04c02 100644
--- a/src/lib/components/editor/RefField.svelte
+++ b/src/lib/components/editor/RefField.svelte
@@ -10,18 +10,21 @@
id,
invalid = false,
placeholder = "",
+ onpick,
}: {
value: Ref;
search: (query: string) => Option[];
id: string;
invalid?: boolean;
placeholder?: string;
+ onpick?: (option: Option) => void;
} = $props();
+ import { createQuery } from "@tanstack/svelte-query";
+ import type { Item, TransactionGroup } from "#lib/api/types.js";
+ import Place from "./Place.svelte";
+ import SameDays from "./SameDays.svelte";
+ import { loadPlace, placeOf, visitOf, visitsIn } from "./usual.js";
+
+ const OWN = {
+ deposit: "destination",
+ transfer: "source",
+ withdrawal: "source",
+ } as const;
+ const TONE: Record = {
+ deposit: "text-income",
+ transfer: "text-transfer",
+ withdrawal: "text-expense",
+ };
+
+ let { group }: { group: Item } = $props();
+
+ const splits = $derived(group.attributes.transactions);
+ const first = $derived(splits[0]);
+ const day = $derived(first.date.slice(0, 10));
+ const currency = $derived({
+ code: first.currency_code,
+ decimals: first.currency_decimal_places,
+ symbol: first.currency_symbol,
+ });
+ const currencies = $derived(
+ [
+ currency,
+ {
+ code: first.foreign_currency_code,
+ decimals: first.foreign_currency_decimal_places,
+ symbol: first.foreign_currency_symbol,
+ },
+ ].filter((option) => option.code)
+ );
+ const place = $derived(placeOf(splits));
+ const own = $derived.by(() => {
+ const side = OWN[first.type as keyof typeof OWN];
+ const id = side ? first[`${side}_id`] : null;
+ return side && id ? { id, name: first[`${side}_name`] ?? "" } : null;
+ });
+
+ const history = createQuery(() => ({
+ enabled: place !== null,
+ queryFn: () =>
+ place ? loadPlace(place, group.id, day) : Promise.resolve([]),
+ queryKey: ["receipt", "place", group.id, place?.id, day],
+ }));
+
+ const best = $derived.by(() => {
+ if (!place) {
+ return null;
+ }
+ const options = currencies.flatMap((option) => {
+ const current = visitOf(group, place, option.code ?? "");
+ return current
+ ? [
+ {
+ currency: option,
+ current,
+ past: visitsIn(history.data ?? [], place, option.code ?? ""),
+ },
+ ]
+ : [];
+ });
+ return options.reduce<(typeof options)[number] | null>(
+ (top, option) =>
+ top && top.past.length >= option.past.length ? top : option,
+ null
+ );
+ });
+
+
+{#if best || own}
+
+ {#if place && best}
+
+ {/if}
+ {#if own}
+
+ {/if}
+
+{/if}
diff --git a/src/lib/components/receipt/Extras.svelte b/src/lib/components/receipt/Extras.svelte
new file mode 100644
index 0000000..e318070
--- /dev/null
+++ b/src/lib/components/receipt/Extras.svelte
@@ -0,0 +1,235 @@
+
+
+{#if count > 0}
+
+ {#if linked.length}
+
+
+ {#each linked as link (link.id)}
+ -
+ {link.phrase}
+ {#if link.group}
+ {link.description}
+ {:else}
+ {link.description}
+ {/if}
+ {#if link.amount}
+
+ {/if}
+ {#if link.notes}
+ ({link.notes})
+ {/if}
+ removeLink(link.id)}
+ >
+ Remove link
+
+
+ {/each}
+
+
+ {/if}
+
+ {#if files.length}
+
+
+
+ {/if}
+
+ {#if piggyEvents.length}
+
+
+ {#each piggyEvents as piggyEvent (piggyEvent.id)}
+ {@const piggy = piggyOf(piggyEvent)}
+ -
+ {#if piggy.id}
+ {piggy.name}
+ {:else}
+ Piggy bank
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+{/if}
diff --git a/src/lib/components/receipt/FieldEditor.svelte b/src/lib/components/receipt/FieldEditor.svelte
new file mode 100644
index 0000000..9bfb4a6
--- /dev/null
+++ b/src/lib/components/receipt/FieldEditor.svelte
@@ -0,0 +1,382 @@
+
+
+
diff --git a/src/lib/components/receipt/Notes.svelte b/src/lib/components/receipt/Notes.svelte
new file mode 100644
index 0000000..aaa6622
--- /dev/null
+++ b/src/lib/components/receipt/Notes.svelte
@@ -0,0 +1,59 @@
+
+
+{#snippet line(
+ parts: Inline[]
+)}
+ {#each parts as part, index (index)}
+ {#if part.kind === "link"}
+ {part.text}
+ {:else if part.kind === "bold"}
+ {part.text}
+ {:else if part.kind === "code"}
+ {part.text}
+ {:else}
+ {part.text}
+ {/if}
+ {/each}
+{/snippet}
+
+
+ {#each parsed as block, index (index)}
+ {#if block.kind === "head"}
+
{@render line(block.line)}
+ {:else if block.kind === "list"}
+
+ {#each block.items as item, i (i)}
+ {@render line(item)}
+ {/each}
+
+ {:else}
+
+ {#each block.lines as row, i (i)}
+ {#if i > 0}
+
+ {/if}
+ {@render line(row)}
+ {/each}
+
+ {/if}
+ {/each}
+
diff --git a/src/lib/components/receipt/Place.svelte b/src/lib/components/receipt/Place.svelte
new file mode 100644
index 0000000..19ec8b5
--- /dev/null
+++ b/src/lib/components/receipt/Place.svelte
@@ -0,0 +1,186 @@
+
+
+
+ {#if loading}
+ Loading visits...
+ {:else if !past.length}
+
+ {place.from
+ ? "First transfer on this route."
+ : `First time at ${place.name}.`}
+
+ {:else}
+
+ {#if usual}
+ usual
+ {money(usual.median)}
+ {#if usual.every !== null}
+ ·
+ {cadence(usual.every)}
+ {/if}
+ {#if said}
+ ·
+ this one {said.text}
+ {/if}
+ {:else}
+ {past.length === 1 ? "One visit" : `${past.length} visits`}
+ before this one
+ {/if}
+
+
+
+ {formatDate(visits[0].date)}
+ {formatDate(visits.at(-1)?.date)}
+
+
+
+ {#each recent as visit (visit.group)}
+ (active = visit)}
+ onpointerleave={() => (active = null)}
+ >
+ |
+ {formatDate(visit.date, "short")}
+ |
+
+ {visit.description}
+ |
+
+ {money(visit.amount)}
+ |
+
+ {/each}
+
+
+ {/if}
+
diff --git a/src/lib/components/receipt/SameDays.svelte b/src/lib/components/receipt/SameDays.svelte
new file mode 100644
index 0000000..b21a0ca
--- /dev/null
+++ b/src/lib/components/receipt/SameDays.svelte
@@ -0,0 +1,158 @@
+
+
+{#snippet more()}
+ All {rows.length} on {account.name} from {formatDate(start, "short")} to
+ {formatDate(end, "short")}
+{/snippet}
+
+
+ {#if query.isPending}
+ Loading {account.name}...
+ {:else if query.error}
+ {query.error.message}
+ {:else}
+
+
+
+ | Date |
+ Description |
+ Amount |
+
+ Balance after
+ |
+
+
+
+ {#each shown as row (row.key)}
+
+ |
+ {formatDate(row.date, "short")}
+ |
+
+ {#if row.self}
+ {row.description}
+ {:else}
+ {row.description}
+ {/if}
+ |
+
+
+ |
+
+ {row.balance === null || row.balance === undefined
+ ? ""
+ : formatMoney({ ...currency, amount: row.balance })}
+ |
+
+ {/each}
+
+
+ {/if}
+
diff --git a/src/lib/components/receipt/fields.ts b/src/lib/components/receipt/fields.ts
new file mode 100644
index 0000000..dad6f4e
--- /dev/null
+++ b/src/lib/components/receipt/fields.ts
@@ -0,0 +1,76 @@
+import type { Split } from "#lib/api/types.js";
+import {
+ isEditableType,
+ SHARED,
+ type Side,
+} from "#lib/components/editor/model.js";
+
+export type FieldName =
+ | "date"
+ | "description"
+ | "amount"
+ | "source"
+ | "destination"
+ | "category"
+ | "budget"
+ | "tags"
+ | "bill"
+ | "notes";
+
+export interface Row {
+ field: FieldName;
+ index: number;
+ key: string;
+}
+
+export const FIELD_LABEL: Record = {
+ amount: "amount",
+ bill: "subscription",
+ budget: "budget",
+ category: "category",
+ date: "date",
+ description: "description",
+ destination: "destination account",
+ notes: "notes",
+ source: "source account",
+ tags: "tags",
+};
+
+const SIDES: Side[] = ["source", "destination"];
+
+export const rowKey = (field: FieldName, index: number) => `${index}:${field}`;
+
+export function rowsOf(splits: Split[]): Row[] {
+ const type = splits[0]?.type ?? "";
+ if (!isEditableType(type)) {
+ return [];
+ }
+ const many = splits.length > 1;
+ const withdrawal = type === "withdrawal";
+ const locked = splits.some((split) => split.reconciled);
+ const shared = SIDES.filter((side) => SHARED[type][side]);
+ const top: FieldName[] = many
+ ? ["date", ...(locked ? [] : shared)]
+ : [
+ "date",
+ "description",
+ ...(locked ? [] : (["amount", ...SIDES] as FieldName[])),
+ ];
+ const own = (split: Split): FieldName[] => [
+ ...(many ? (["description"] as const) : []),
+ ...(many && !split.reconciled
+ ? ["amount" as const, ...SIDES.filter((side) => !SHARED[type][side])]
+ : []),
+ "category",
+ ...(withdrawal ? (["budget"] as const) : []),
+ "tags",
+ ...(withdrawal ? (["bill"] as const) : []),
+ "notes",
+ ];
+ return [
+ ...top.map((field) => ({ field, index: 0 })),
+ ...splits.flatMap((split, index) =>
+ own(split).map((field) => ({ field, index }))
+ ),
+ ].map((row) => ({ ...row, key: rowKey(row.field, row.index) }));
+}
diff --git a/src/lib/components/receipt/match.ts b/src/lib/components/receipt/match.ts
new file mode 100644
index 0000000..a68a2a8
--- /dev/null
+++ b/src/lib/components/receipt/match.ts
@@ -0,0 +1,92 @@
+import { api } from "#lib/api/client.js";
+import { all } from "#lib/api/lists.js";
+import type {
+ Bill,
+ Item,
+ Page,
+ Split,
+ TransactionGroup,
+} from "#lib/api/types.js";
+import { addDays, addMonths } from "#lib/dates.js";
+import { toNumber } from "#lib/format.js";
+import { journalId } from "#lib/transactions/split.js";
+
+const CENT = 0.005;
+
+export interface Suggestion {
+ bill: Item;
+ journal: string;
+ month: string;
+ paid: string | null;
+}
+
+export const openSplits = (splits: Split[]) =>
+ splits.filter(
+ (split) =>
+ split.type === "withdrawal" && !split.bill_id && split.destination_id
+ );
+
+function priceIn(split: Split, code: string): number | null {
+ if (split.currency_code === code) {
+ return Math.abs(toNumber(split.amount));
+ }
+ return split.foreign_currency_code === code && split.foreign_amount
+ ? Math.abs(toNumber(split.foreign_amount))
+ : null;
+}
+
+function fits(bill: Bill, split: Split): boolean {
+ const price = priceIn(split, bill.currency_code ?? "");
+ return (
+ bill.active === true &&
+ price !== null &&
+ price >= toNumber(bill.amount_min) - CENT &&
+ price <= toNumber(bill.amount_max) + CENT &&
+ (bill.date ?? "").slice(0, 10) <= split.date.slice(0, 10)
+ );
+}
+
+const width = (bill: Item) =>
+ toNumber(bill.attributes.amount_max) - toNumber(bill.attributes.amount_min);
+
+async function lastDestination(billId: string): Promise {
+ const page = await api>(
+ `/v1/bills/${billId}/transactions`,
+ { query: { limit: 1 } }
+ );
+ return page.data[0]?.attributes.transactions[0]?.destination_id ?? null;
+}
+
+export async function suggest(splits: Split[]): Promise {
+ const open = openSplits(splits);
+ if (!open.length) {
+ return [];
+ }
+ const month = `${open[0].date.slice(0, 7)}-01`;
+ const bills = await all("/v1/bills", {
+ end: addDays(addMonths(month, 1), -1),
+ start: month,
+ });
+ const candidates = open.map((split) => ({
+ bills: bills.filter((bill) => fits(bill.attributes, split)),
+ split,
+ }));
+ const ids = [
+ ...new Set(candidates.flatMap((c) => c.bills.map((bill) => bill.id))),
+ ];
+ const last = new Map(
+ await Promise.all(
+ ids.map(async (id) => [id, await lastDestination(id)] as const)
+ )
+ );
+ return candidates.flatMap(({ bills: options, split }) => {
+ const [bill] = options
+ .filter((option) => last.get(option.id) === split.destination_id)
+ .sort((a, b) => width(a) - width(b));
+ if (!bill) {
+ return [];
+ }
+ const paid = bill.attributes.paid_dates?.[0]?.date?.slice(0, 10) ?? null;
+ return [{ bill, journal: journalId(split), month, paid }];
+ });
+}
diff --git a/src/lib/components/receipt/notes.ts b/src/lib/components/receipt/notes.ts
new file mode 100644
index 0000000..efcfafd
--- /dev/null
+++ b/src/lib/components/receipt/notes.ts
@@ -0,0 +1,67 @@
+export type Inline =
+ | { kind: "text" | "bold" | "code"; text: string }
+ | { kind: "link"; text: string; href: string };
+
+export type Block =
+ | { kind: "head"; line: Inline[] }
+ | { kind: "list"; ordered: boolean; items: Inline[][] }
+ | { kind: "para"; lines: Inline[][] };
+
+const TOKEN =
+ /\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>()]*[^\s<>().,;:!?'"])/g;
+const BULLET = /^\s*(?:[-*•]|(\d+)[.)])\s+(.*)$/;
+const HEAD = /^#{1,6}\s+(.*)$/;
+const LINES = /\r?\n/;
+
+export function inline(text: string): Inline[] {
+ const out: Inline[] = [];
+ let last = 0;
+ for (const match of text.matchAll(TOKEN)) {
+ const [whole, bold, code, label, href, url] = match;
+ if (match.index > last) {
+ out.push({ kind: "text", text: text.slice(last, match.index) });
+ }
+ if (bold) {
+ out.push({ kind: "bold", text: bold });
+ } else if (code) {
+ out.push({ kind: "code", text: code });
+ } else {
+ out.push({ href: href ?? url, kind: "link", text: label ?? url });
+ }
+ last = match.index + whole.length;
+ }
+ if (last < text.length) {
+ out.push({ kind: "text", text: text.slice(last) });
+ }
+ return out;
+}
+
+export function blocks(notes: string): Block[] {
+ const result: Block[] = [];
+ let open: Block | null = null;
+ for (const raw of notes.split(LINES)) {
+ const line = raw.trimEnd();
+ const head = line.match(HEAD);
+ const bullet = line.match(BULLET);
+ if (!line.trim()) {
+ open = null;
+ } else if (head) {
+ result.push({ kind: "head", line: inline(head[1]) });
+ open = null;
+ } else if (bullet) {
+ const ordered = Boolean(bullet[1]);
+ if (open?.kind !== "list" || open.ordered !== ordered) {
+ open = { items: [], kind: "list", ordered };
+ result.push(open);
+ }
+ open.items.push(inline(bullet[2]));
+ } else {
+ if (open?.kind !== "para") {
+ open = { kind: "para", lines: [] };
+ result.push(open);
+ }
+ open.lines.push(inline(line));
+ }
+ }
+ return result;
+}
diff --git a/src/lib/components/receipt/usual.ts b/src/lib/components/receipt/usual.ts
new file mode 100644
index 0000000..6d09953
--- /dev/null
+++ b/src/lib/components/receipt/usual.ts
@@ -0,0 +1,171 @@
+import { api } from "#lib/api/client.js";
+import type { Item, Page, Split, TransactionGroup } from "#lib/api/types.js";
+import { addMonths, daysBetween } from "#lib/dates.js";
+import { isCash } from "#lib/transactions/split.js";
+
+const COUNT = 12;
+const FETCH = 25;
+const MIN_COUNT = 3;
+const LOOKBACK_MONTHS = 60;
+const PERCENT = 100;
+const ABOUT = 5;
+const NOTABLE = 15;
+
+type Side = "source" | "destination";
+
+export interface Place {
+ from: string | null;
+ id: string;
+ name: string;
+ side: Side;
+ type: string;
+}
+
+export interface Visit {
+ amount: number;
+ date: string;
+ description: string;
+ group: string;
+}
+
+export interface Usual {
+ change: number;
+ every: number | null;
+ median: number;
+}
+
+const COUNTER: Record = {
+ deposit: "source",
+ transfer: "destination",
+ withdrawal: "destination",
+};
+
+const CADENCE: [number, number, string][] = [
+ [0, 1.5, "daily"],
+ [5, 9, "weekly"],
+ [12, 17, "every two weeks"],
+ [26, 35, "monthly"],
+ [80, 100, "quarterly"],
+ [170, 195, "every half year"],
+ [345, 385, "yearly"],
+];
+
+export function placeOf(splits: Split[]): Place | null {
+ const [first] = splits;
+ const side = first ? COUNTER[first.type] : undefined;
+ const id = side ? first[`${side}_id`] : null;
+ if (!(side && id) || isCash(first[`${side}_type`])) {
+ return null;
+ }
+ return {
+ from: first.type === "transfer" ? first.source_id : null,
+ id,
+ name: first[`${side}_name`] ?? "",
+ side,
+ type: first.type,
+ };
+}
+
+function priceIn(split: Split, code: string): number | null {
+ if (split.currency_code === code) {
+ return Math.abs(Number(split.amount));
+ }
+ return split.foreign_currency_code === code && split.foreign_amount
+ ? Math.abs(Number(split.foreign_amount))
+ : null;
+}
+
+export function visitOf(
+ group: Item,
+ place: Place,
+ code: string
+): Visit | null {
+ const splits = group.attributes.transactions.filter(
+ (split) =>
+ split[`${place.side}_id`] === place.id &&
+ (!place.from || split.source_id === place.from)
+ );
+ const prices = splits.map((split) => priceIn(split, code));
+ if (!splits.length || prices.includes(null)) {
+ return null;
+ }
+ return {
+ amount: prices.reduce((sum, price) => sum + (price ?? 0), 0),
+ date: splits[0].date.slice(0, 10),
+ description:
+ group.attributes.group_title || splits[0].description || `#${group.id}`,
+ group: group.id,
+ };
+}
+
+export async function loadPlace(
+ place: Place,
+ groupId: string,
+ end: string
+): Promise- []> {
+ const page = place.from
+ ? await api>("/v1/search/transactions", {
+ query: {
+ limit: FETCH,
+ query: `type:transfer source_account_id:${place.from} destination_account_id:${place.id} date_before:${end}`,
+ },
+ })
+ : await api>(
+ `/v1/accounts/${place.id}/transactions`,
+ {
+ query: {
+ end,
+ limit: FETCH,
+ start: addMonths(end, -LOOKBACK_MONTHS),
+ type: place.type,
+ },
+ }
+ );
+ return page.data.filter((group) => group.id !== groupId);
+}
+
+export const visitsIn = (
+ groups: Item[],
+ place: Place,
+ code: string
+) =>
+ groups
+ .map((group) => visitOf(group, place, code))
+ .filter((visit): visit is Visit => visit !== null)
+ .slice(0, COUNT);
+
+function median(values: number[]) {
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+}
+
+export function usualOf(current: Visit, past: Visit[]): Usual | null {
+ if (past.length < MIN_COUNT) {
+ return null;
+ }
+ const typical = median(past.map((visit) => visit.amount));
+ const dates = [current, ...past].map((visit) => visit.date).sort();
+ const gaps = dates.slice(1).map((date, i) => daysBetween(dates[i], date));
+ return {
+ change: typical ? Math.round((current.amount / typical - 1) * PERCENT) : 0,
+ every: gaps.length ? median(gaps) : null,
+ median: typical,
+ };
+}
+
+export function cadence(days: number): string {
+ return (
+ CADENCE.find(([low, high]) => days >= low && days <= high)?.[2] ??
+ `every ${Math.round(days)} days`
+ );
+}
+
+export function verdict(change: number) {
+ if (Math.abs(change) < ABOUT) {
+ return { text: "about usual", warn: false };
+ }
+ return change > 0
+ ? { text: `${change}% above usual`, warn: change >= NOTABLE }
+ : { text: `${-change}% below usual`, warn: false };
+}
diff --git a/src/routes/transactions/edit/[id=id]/+page.svelte b/src/routes/transactions/edit/[id=id]/+page.svelte
index 8c6d587..daf98f8 100644
--- a/src/routes/transactions/edit/[id=id]/+page.svelte
+++ b/src/routes/transactions/edit/[id=id]/+page.svelte
@@ -4,6 +4,7 @@
import type { One, TransactionGroup } from "#lib/api/types.js";
import Editor from "#lib/components/editor/Editor.svelte";
import { fromGroup, isEditableType } from "#lib/components/editor/model.js";
+ import Context from "#lib/components/receipt/Context.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { page } from "$app/state";
@@ -41,6 +42,9 @@
>
{:else if group.data}
-
+
+
+
+
{/if}
{/key}
diff --git a/src/routes/transactions/show/[id=id]/+page.svelte b/src/routes/transactions/show/[id=id]/+page.svelte
index 5c73425..368bb53 100644
--- a/src/routes/transactions/show/[id=id]/+page.svelte
+++ b/src/routes/transactions/show/[id=id]/+page.svelte
@@ -2,35 +2,56 @@
import Check from "@lucide/svelte/icons/check";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import Copy from "@lucide/svelte/icons/copy";
- import Paperclip from "@lucide/svelte/icons/paperclip";
import Pencil from "@lucide/svelte/icons/pencil";
import { createQuery } from "@tanstack/svelte-query";
import { DropdownMenu } from "bits-ui";
+ import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
- import { all, invalidate, lists } from "#lib/api/lists.js";
+ import { invalidate } from "#lib/api/lists.js";
import type {
- Attachment,
+ Bill,
Item,
- LinkType,
One,
- Page,
- PiggyBankEvent,
Split,
TransactionGroup,
- TransactionLink,
} from "#lib/api/types.js";
- import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
+ import { Catalog } from "#lib/components/editor/catalog.svelte.js";
+ import {
+ META_DATE_LABEL,
+ META_DATES,
+ type TxType,
+ } from "#lib/components/editor/model.js";
+ import Context from "#lib/components/receipt/Context.svelte";
+ import Extras from "#lib/components/receipt/Extras.svelte";
+ import FieldEditor from "#lib/components/receipt/FieldEditor.svelte";
+ import {
+ FIELD_LABEL,
+ type FieldName,
+ type Row,
+ rowKey,
+ rowsOf,
+ } from "#lib/components/receipt/fields.js";
+ import { openSplits, suggest } from "#lib/components/receipt/match.js";
+ import Notes from "#lib/components/receipt/Notes.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
+ import {
+ average,
+ money as billMoney,
+ isRange,
+ repeats,
+ } from "#lib/components/subscriptions/bills.js";
import TypeIcon from "#lib/components/transactions/TypeIcon.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
import DropdownMenuSeparator from "#lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte";
- import { formatDate, formatMoney, formatNumber } from "#lib/format.js";
+ import { formatDate, formatDateTime, formatMoney } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
+ import { ListNav } from "#lib/list-nav.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
+ import { type Patch, updateGroups } from "#lib/transactions/mass.js";
import {
accountUrl,
cloneUrl,
@@ -49,151 +70,122 @@
} from "#lib/transactions/split.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
- import { loadPast, placesOf, usualOf, verdict } from "./usual.js";
- interface Linked {
- amount: Signed | null;
- description: string;
- group: string | null;
- id: string;
- notes: string;
- phrase: string;
- }
-
- type Event = Item & {
- links?: Record;
+ const OWN: Record = {
+ deposit: "destination",
+ transfer: "source",
+ withdrawal: "source",
};
-
- const DATE_FIELDS = [
- ["interest_date", "Interest date"],
- ["book_date", "Book date"],
- ["process_date", "Processing date"],
- ["due_date", "Due date"],
- ["payment_date", "Payment date"],
- ["invoice_date", "Invoice date"],
+ const TONE: Record = {
+ expense: "text-expense",
+ income: "text-income",
+ neutral: "text-cladd-fg",
+ transfer: "text-transfer",
+ };
+ const PICKS: [string, FieldName][] = [
+ ["s c", "category"],
+ ["s b", "budget"],
+ ["s t", "tags"],
+ ["s s", "bill"],
+ ];
+ const REFERENCES = [
+ ["external_id", "external ID"],
+ ["internal_reference", "reference"],
+ ["sepa_ct_id", "SEPA end to end ID"],
+ ["sepa_db", "SEPA mandate"],
+ ["sepa_ci", "SEPA creditor"],
] as const;
-
- const META_FIELDS = [
- ["external_id", "External ID"],
- ["internal_reference", "Internal reference"],
- ["sepa_batch_id", "SEPA batch ID"],
- ["sepa_ct_id", "SEPA end to end identifier"],
- ["sepa_ct_op", "SEPA opposing account identifier"],
- ["sepa_db", "SEPA mandate identifier"],
- ["sepa_country", "SEPA country"],
- ["sepa_cc", "SEPA clearing code"],
- ["sepa_ep", "SEPA external purpose"],
- ["sepa_ci", "SEPA creditor identifier"],
- ] as const;
-
- const PIGGY_URI = /piggy-banks\/(\d+)/;
- const BYTES_KB = 1024;
+ const ERROR_PREFIX = /^#\d+: /;
+ const PLAIN =
+ "rounded-[4px] text-cladd-fg-softer hover:text-cladd-fg hover:underline";
let confirm = $state();
let deleteError = $state("");
+ let editing = $state(null);
+ let linking = $state("");
+ let linked = $state>(new Set());
const id = $derived(page.params.id ?? "");
-
const groupQuery = createQuery(() => ({
queryFn: () => api>(`/v1/transactions/${id}`),
queryKey: ["transaction", id],
}));
const group = $derived(groupQuery.data?.data);
const splits = $derived(group?.attributes.transactions ?? []);
+ const first = $derived(splits[0]);
const type = $derived(group ? groupType(group) : "withdrawal");
const title = $derived(group ? groupTitle(group) : "");
- const editable = $derived(
+ const many = $derived(splits.length > 1);
+ const actionable = $derived(
["withdrawal", "deposit", "transfer", "reconciliation"].includes(type)
);
- const journals = $derived(splits.map(journalId));
+ const rows = $derived(rowsOf(splits));
+ const rowIndex = $derived(new Map(rows.map((row, i) => [row.key, i])));
+ const catalog = new Catalog();
- const attachmentsQuery = createQuery(() => ({
- queryFn: () =>
- api>(`/v1/transactions/${id}/attachments`, {
- query: { limit: 500 },
- }),
- queryKey: ["transaction-attachments", id],
- }));
- const eventsQuery = createQuery(() => ({
- queryFn: () =>
- api>(`/v1/transactions/${id}/piggy-bank-events`, {
- query: { limit: 500 },
- }),
- queryKey: ["transaction-piggy-events", id],
- }));
- const piggies = createQuery(() => ({
- ...lists.piggyBanks(),
- enabled: (eventsQuery.data?.data.length ?? 0) > 0,
+ const unlinked = $derived(openSplits(splits));
+ const matchQuery = createQuery(() => ({
+ enabled: unlinked.length > 0,
+ queryFn: () => suggest(splits),
+ queryKey: [
+ "receipt",
+ "bill-match",
+ id,
+ unlinked.map((split) => `${journalId(split)}:${split.amount}`).join(","),
+ ],
}));
+ const suggestions = $derived(unlinked.length ? (matchQuery.data ?? []) : []);
- async function loadLinks(ids: string[]): Promise> {
- const [types, ...pages] = await Promise.all([
- all("/v1/link-types"),
- ...ids.map((journal) =>
- api>(`/v1/transaction-journals/${journal}/links`)
- ),
- ]);
- const result: Record = {};
- await Promise.all(
- ids.map(async (journal, index) => {
- result[journal] = await Promise.all(
- pages[index].data.map(async (link) => {
- const { inward_id, outward_id, link_type_id, notes } =
- link.attributes;
- const source = inward_id === journal;
- const other = source ? outward_id : inward_id;
- const linkType = types.find((t) => t.id === link_type_id);
- const found = await api>(
- `/v1/transaction-journals/${other}`
- ).catch(() => null);
- const split = found?.data.attributes.transactions.find(
- (s) => journalId(s) === other
- );
- return {
- amount: split ? signedAmount(split) : null,
- description: split?.description ?? `#${other}`,
- group: found?.data.id ?? null,
- id: link.id,
- notes: notes ?? "",
- phrase:
- (source
- ? linkType?.attributes.outward
- : linkType?.attributes.inward) ?? "linked to",
- };
- })
- );
- })
- );
- return result;
- }
+ const nav = new ListNav({
+ id: "receipt",
+ items: () => rows,
+ key: (row) => row.key,
+ open: openRow,
+ });
+ nav.use();
- const places = $derived(placesOf(splits));
- const groupDay = $derived(splits[0]?.date.slice(0, 10) ?? "");
- const pastQuery = createQuery(() => ({
- enabled: places.length > 0,
- queryFn: () => loadPast(places, id, groupDay),
- queryKey: ["transaction-usual", id, places.join(","), groupDay],
- }));
-
- const linksQuery = createQuery(() => ({
- enabled: journals.length > 0,
- queryFn: () => loadLinks(journals),
- queryKey: ["transaction-links", id, journals.join(",")],
- }));
-
- function uniqueAccounts(side: "source" | "destination") {
- const seen = new Map();
- for (const split of splits) {
- const accountId = split[`${side}_id`] ?? "";
- seen.set(accountId, {
- cash: isCash(split[`${side}_type`]),
- id: accountId,
- name: split[`${side}_name`] ?? "",
+ $effect.pre(() => {
+ if (id) {
+ untrack(() => {
+ editing = null;
+ linked = new Set();
+ linking = "";
+ nav.cursor = -1;
});
}
- return [...seen.values()];
+ });
+
+ const own = $derived(OWN[type]);
+ function balanceOf(side: "source" | "destination") {
+ const values = splits
+ .map((split) => split[`${side}_balance_after`])
+ .filter((value) => value !== null && value !== undefined)
+ .map(Number);
+ if (!(values.length && first)) {
+ return null;
+ }
+ const foreign =
+ side === "destination" &&
+ type === "transfer" &&
+ Boolean(first.foreign_currency_code);
+ return formatMoney({
+ amount:
+ side === "destination" ? Math.max(...values) : Math.min(...values),
+ code: foreign ? first.foreign_currency_code : first.currency_code,
+ decimals: foreign
+ ? first.foreign_currency_decimal_places
+ : first.currency_decimal_places,
+ symbol: foreign ? first.foreign_currency_symbol : first.currency_symbol,
+ });
}
+ const balances = $derived(
+ (type === "transfer" ? ["source", "destination"] : [own])
+ .filter((side): side is "source" | "destination" => Boolean(side))
+ .map(balanceOf)
+ .filter((value) => value !== null)
+ );
+
const foreignTotals = $derived.by(() => {
const sums = new Map();
for (const split of splits) {
@@ -211,35 +203,80 @@
return [...sums.values()];
});
- function attachmentsFor(split: Split) {
- return (attachmentsQuery.data?.data ?? []).filter(
- (a) => a.attributes.attachable_id === journalId(split)
- );
+ const counterSide = $derived<"source" | "destination" | null>(
+ { deposit: "source", withdrawal: "destination" }[type as string] as
+ | "source"
+ | "destination"
+ | null
+ );
+
+ function openRow(row: Row) {
+ nav.cursor = rowIndex.get(row.key) ?? nav.cursor;
+ editing = row.key;
}
- function eventsFor(split: Split) {
- return ((eventsQuery.data?.data ?? []) as Event[]).filter(
- (e) =>
- (e.attributes as { transaction_journal_id?: string })
- .transaction_journal_id === journalId(split)
- );
+ function openField(field: FieldName, index: number) {
+ const at = rowIndex.get(rowKey(field, index));
+ if (at !== undefined) {
+ openRow(rows[at]);
+ }
}
- function piggyOf(event: Event) {
- const uri = Object.values(event.links ?? {}).find((l) => l?.uri)?.uri;
- const piggyId = uri?.match(PIGGY_URI)?.[1];
- const name = (piggies.data ?? []).find((p) => p.id === piggyId)?.attributes
- .name;
- return { id: piggyId, name: name ?? (piggyId ? `#${piggyId}` : "") };
+ async function save(
+ row: Row,
+ patch: Patch,
+ all: boolean
+ ): Promise {
+ const split = splits[row.index];
+ const outcome = await updateGroups(
+ new Map([[id, all ? null : new Set([journalId(split)])]]),
+ () => patch,
+ () => undefined
+ );
+ if (outcome.errors.length) {
+ return outcome.errors[0].replace(ERROR_PREFIX, "");
+ }
+ await invalidate();
+ editing = null;
+ return null;
+ }
+
+ async function link() {
+ if (!suggestions.length || linking === "Linking") {
+ return;
+ }
+ linking = "Linking";
+ const bills = new Map(suggestions.map((s) => [s.journal, s.bill.id]));
+ const outcome = await updateGroups(
+ new Map([[id, new Set(bills.keys())]]),
+ (split) => ({ bill_id: bills.get(journalId(split)) }),
+ () => undefined
+ );
+ if (outcome.errors.length) {
+ linking = outcome.errors[0].replace(ERROR_PREFIX, "");
+ return;
+ }
+ await invalidate();
+ linked = new Set(bills.keys());
+ linking = "";
}
function dateLabel(value: string | undefined) {
if (!value) {
return "";
}
+ const day = value.slice(0, 10);
const time = value.slice(11, 16);
- const day = formatDate(value.slice(0, 10), "long");
- return time && time !== "00:00" ? `${day}, ${time}` : day;
+ const label = `${formatDate(day, "day")} ${day.slice(0, 4)}`;
+ return time && time !== "00:00" ? `${label}, ${time}` : label;
+ }
+
+ function billPrice(bill: Item) {
+ const a = bill.attributes;
+ const value = formatMoney(
+ billMoney(a, isRange(a) ? average(a) : Number(a.amount_min))
+ );
+ return `${isRange(a) ? "~" : ""}${value} ${repeats(a)}`;
}
async function remove() {
@@ -253,11 +290,6 @@
}
}
- async function removeLink(linkId: string) {
- await api(`/v1/transaction-links/${linkId}`, { method: "DELETE" });
- await linksQuery.refetch();
- }
-
function oldUi(path = page.url.pathname) {
location.assign(oldUiUrl(new URL(path, location.origin).href));
}
@@ -266,66 +298,297 @@
["withdrawal", "deposit", "transfer"].filter((t) => t !== type)
);
- useKeys(() =>
- group && editable
- ? [
- {
- group: "Transaction",
- keys: "e",
- label: "Edit transaction",
- run: () => goto(editUrl(id)),
- },
- {
- group: "Transaction",
- keys: "c",
- label: "Clone transaction",
- run: () => goto(cloneUrl(group)),
- },
- {
- group: "Transaction",
- keys: "d",
- label: "Delete transaction (press twice)",
- run: () => confirm?.press(),
- },
- ]
- : []
- );
+ useKeys(() => {
+ if (!(group && actionable)) {
+ return [];
+ }
+ const at = nav.current?.index ?? 0;
+ return [
+ {
+ group: "Transaction",
+ keys: "e",
+ label: "Edit every field",
+ run: () => goto(editUrl(id)),
+ },
+ ...(type === "reconciliation"
+ ? []
+ : [
+ {
+ group: "Transaction",
+ keys: "c",
+ label: "Clone transaction",
+ run: () => goto(cloneUrl(group)),
+ },
+ ]),
+ {
+ group: "Transaction",
+ keys: "d",
+ label: "Delete transaction (press twice)",
+ run: () => confirm?.press(),
+ },
+ ...(suggestions.length
+ ? [
+ {
+ group: "Transaction",
+ keys: "L",
+ label: `Link to the subscription ${suggestions[0].bill.attributes.name}`,
+ run: link,
+ },
+ ]
+ : []),
+ ...PICKS.filter(([, field]) => rowIndex.has(rowKey(field, at))).map(
+ ([keys, field]) => ({
+ group: "Transaction",
+ keys,
+ label: `Change the ${FIELD_LABEL[field]}`,
+ run: () => openField(field, at),
+ })
+ ),
+ ];
+ });
{#snippet money(
- value: Signed
+ value: Signed,
+ size = ""
)}
-
+ {formatMoney(value, value.tone !== "transfer")}
{/snippet}
-{#snippet accountLink(
- accountId: string | null | undefined,
- name: string | null | undefined,
- type: string | null | undefined
+{#snippet account(
+ split: Split,
+ side: "source" | "destination"
)}
- {#if isCash(type)}
+ {#if isCash(split[`${side}_type`])}
(cash)
{:else}
- {name}{split[`${side}_name`]}
{/if}
{/snippet}
-{#snippet row(
- label: string
+{#snippet empty(
+ text: string,
+ field: FieldName,
+ index: number
)}
- |
- {label}
- |
+ {#if rowIndex.has(rowKey(field, index))}
+
+ {:else}
+ {text}
+ {/if}
+{/snippet}
+
+{#snippet view(
+ field: FieldName,
+ split: Split,
+ index: number
+)}
+ {#if field === "date"}
+ {dateLabel(split.date)}
+ {:else if field === "description"}
+ {split.description}
+ {:else if field === "amount"}
+ {@const foreign = signedForeign(split)}
+ {@render money(signedAmount(split))}
+ {#if foreign}
+ {formatMoney(foreign)}
+ {/if}
+ {:else if field === "source" || field === "destination"}
+ {@render account(split, field)}
+ {:else if field === "category"}
+ {#if split.category_id}
+ category
+ {split.category_name}
+ {:else}
+ {@render empty("no category", field, index)}
+ {/if}
+ {:else if field === "budget"}
+ {#if split.budget_id}
+ budget
+ {split.budget_name}
+ {:else}
+ {@render empty("no budget", field, index)}
+ {/if}
+ {:else if field === "tags"}
+ {#if split.tags?.length}
+ {split.tags.length === 1 ? "tag" : "tags"}
+ {#each split.tags as tag, i (tag)}
+ {#if i > 0}
+ ,
+ {/if}
+ {tag}
+ {/each}
+ {:else}
+ {@render empty("no tags", field, index)}
+ {/if}
+ {:else if field === "bill"}
+ {#if split.bill_id}
+ subscription
+ {split.bill_name}
+ {#if linked.has(journalId(split))}
+ linked
+ {/if}
+ {:else}
+ {@render empty("no subscription", field, index)}
+ {/if}
+ {:else if split.notes}
+
+ {:else}
+ {@render empty("Add notes", field, index)}
+ {/if}
+{/snippet}
+
+{#snippet cell(
+ field: FieldName,
+ index: number
+)}
+ {@const split = splits[index]}
+ {@const at = rowIndex.get(rowKey(field, index))}
+ {#if at !== undefined && editing === rows[at].key}
+ (editing = null)}
+ onsave={(patch, all) => save(rows[at], patch, all)}
+ row={rows[at]}
+ {split}
+ type={type as TxType}
+ />
+ {:else}
+
+ {@render view(field, split, index)}
+
+ {/if}
+{/snippet}
+
+{#snippet suggestion(
+ split: Split
+)}
+ {@const found = suggestions.find((s) => s.journal === journalId(split))}
+ {#if found}
+
+
Looks like the subscription
+
{found.bill.attributes.name}
+
({billPrice(found.bill)},
+ {found.paid
+ ? `already paid ${formatDate(found.paid, "short")}`
+ : `unpaid for ${formatDate(found.month, "month")}`}).
+ {#if linking && linking !== "Linking"}
+
{linking}
+ {:else}
+
+ {/if}
+
+ {/if}
+{/snippet}
+
+{#snippet classes(
+ split: Split,
+ index: number
+)}
+ {@const fields = (
+ split.type === "withdrawal"
+ ? ["category", "budget", "tags", "bill"]
+ : ["category", "tags"]
+ ) as FieldName[]}
+
+ {#each fields as field, i (field)}
+
+ {@render cell(field, index)}
+ {#if i < fields.length - 1}
+ ·
+ {/if}
+
+ {/each}
+
+ {#if !split.bill_id}
+ {@render suggestion(split)}
+ {/if}
+ {@const dates = META_DATES.filter((key) => split[key])}
+ {@const refs = REFERENCES.filter(([key]) => split[key])}
+ {#if dates.length || refs.length || split.external_url || split.recurrence_id}
+
+ {#each dates as key (key)}
+
{META_DATE_LABEL[key].toLowerCase()}
+ {formatDate(split[key])}
+ {/each}
+ {#each refs as [key, label] (key)}
+
{label}
+ {split[key]}
+ {/each}
+ {#if split.recurrence_id}
+
recurring, {split.recurrence_count}
+ of {split.recurrence_total || "∞"}
+ {/if}
+ {#if split.external_url}
+
{split.external_url}
+ {/if}
+
+ {/if}
{/snippet}
{#snippet actions()}
{#if group}
- {#if editable}
+ {#if actionable}
@@ -349,7 +611,7 @@
{/if}
- Delete d
+ Delete d d
{/if}
@@ -359,7 +621,7 @@
More
- {#if editable && type !== "reconciliation"}
+ {#if actionable && type !== "reconciliation"}
{#each others as target (target)}
@@ -399,402 +661,165 @@
{#if groupQuery.isPending}
Loading transaction...
-{:else if groupQuery.error || !group}
+{:else if groupQuery.error || !group || !first}
{groupQuery.error?.message ?? "This transaction does not exist."}
{:else}
-
-
-
-
-
- {@render row("ID")}
- | #{id} |
-
-
- {@render row("Type")}
- |
- {TYPE_LABEL[type] ?? type}
- |
-
-
- {@render row("Description")}
- | {title} |
-
-
- {@render row("Date")}
- | {dateLabel(splits[0]?.date)} |
-
-
-
- {#if deleteError}
- {deleteError}
- {/if}
-
-
-
-
-
-
- {@render row("Source account(s)")}
- |
- {#each uniqueAccounts("source") as acc, i (acc.id)}
- {#if i > 0}
- ,
- {/if}
- {@render accountLink(
- acc.id,
- acc.name,
- acc.cash ? "Cash account" : ""
- )}
- {/each}
- |
-
-
- {@render row("Destination account(s)")}
- |
- {#each uniqueAccounts("destination") as acc, i (acc.id)}
- {#if i > 0}
- ,
- {/if}
- {@render accountLink(
- acc.id,
- acc.name,
- acc.cash ? "Cash account" : ""
- )}
- {/each}
- |
-
-
- {@render row("Total amount")}
- |
-
- {#each sumByCurrency(splits) as sum (sum.code)}
- {@render money(sum)}
- {/each}
- {#each foreignTotals as sum (sum.code)}
- ({formatMoney(sum)})
- {/each}
-
- |
-
-
-
-
-
-
- {#if splits.length > 1}
- Splits
- {/if}
-
-
- {#each splits as split, index (journalId(split))}
- {@const foreign = signedForeign(split)}
- {@const files = attachmentsFor(split)}
- {@const events = eventsFor(split)}
- {@const links = linksQuery.data?.[journalId(split)] ?? []}
- {@const usual = usualOf(split, pastQuery.data)}
-
-
- {#snippet tools()}
-
+
+
+
+
+ {TYPE_LABEL[type] ?? type}
+ ·
+ {@render cell("date", 0)}
+ {#if splits.some((split) => split.reconciled)}
+ ·
+ reconciled
- {split.description}
- {#if split.reconciled}
-
- {/if}
- {#if splits.length > 1}
- {index + 1}
- / {splits.length}{deleteError}
+ {/if}
+
+
+
+
+ {#if many}
+ {title}
+ {:else}
+ {@render cell("description", 0)}
+ {/if}
+
+
+ {#if many}
+ {#each sumByCurrency(splits) as sum (sum.code)}
+ {@render money(sum)}
+ {/each}
+ {#each foreignTotals as sum (sum.code)}
+
{formatMoney(sum)}
- {/if}
-
- {/snippet}
-
-
-
- |
-
- {@render accountLink(
- split.source_id,
- split.source_name,
- split.source_type
- )}
- →
- {@render money(signedAmount(split))}
- {#if foreign}
- ({formatMoney(foreign)})
- {/if}
- →
- {@render accountLink(
- split.destination_id,
- split.destination_name,
- split.destination_type
- )}
-
- |
-
- {#if usual}
- {@const said = verdict(usual.change)}
-
- {@render row("Usual here")}
- |
-
-
- median of the
- last {usual.count}
- ·
- {said.text}
-
- |
-
- {/if}
-
- {@render row("Transaction journal ID")}
- | #{journalId(split)} |
-
-
- {@render row("Category")}
- |
- {#if split.category_id}
- {split.category_name}
- {:else}
- (no category)
- {/if}
- |
-
- {#if split.type === "withdrawal"}
-
- {@render row("Budget")}
- |
- {#if split.budget_id}
- {split.budget_name}
- {:else}
- (no budget)
- {/if}
- |
-
- {#if split.bill_id}
-
- {@render row("Subscription")}
- |
- {split.bill_name}
- |
-
- {/if}
- {/if}
- {#each DATE_FIELDS as [field, label] (field)}
- {#if split[field]}
-
- {@render row(label)}
- | {formatDate(split[field])} |
-
- {/if}
{/each}
- {#each META_FIELDS as [field, label] (field)}
- {#if split[field]}
-
- {@render row(label)}
- | {split[field]} |
-
- {/if}
- {/each}
- {#if split.external_url}
-
- {@render row("External URL")}
- |
- {split.external_url}
- |
-
- {/if}
- {#if split.notes}
-
- {@render row("Notes")}
- |
- {split.notes}
- |
-
- {/if}
- {#if split.recurrence_id}
-
- {@render row("Recurring transaction")}
- |
- {split.recurrence_count}
- of {split.recurrence_total || "∞"}
- |
-
- {/if}
- {#if split.tags?.length}
-
- {@render row("Tags")}
- |
- {#each split.tags as tag, i (tag)}
- {#if i > 0}
- ,
- {/if}
- {tag}
- {/each}
- |
-
- {/if}
-
-
-
+ {:else}
+ {@render cell("amount", 0)}
+ {/if}
+
+
- {#if links.length > 0}
-
-
- {#each links as link (link.id)}
- -
- {link.phrase}
- {#if link.group}
- "{link.description}"
- {:else}
- "{link.description}"
- {/if}
- {#if link.amount}
- ({@render money(link.amount)})
- {/if}
- {#if link.notes}
- ({link.notes})
- {/if}
- removeLink(link.id)}
- >
- Remove link
-
-
- {/each}
-
-
- {/if}
+
+
+ {#if many && counterSide === "source"}
+ {@render account(first, "source")}
+ {#if splits.some((s) => s.source_id !== first.source_id)}
+ and others
+ {/if}
+ {:else}
+ {@render cell("source", 0)}
+ {/if}
+ →
+ {#if many && counterSide === "destination"}
+ {@render account(first, "destination")}
+ {#if splits.some(
+ (s) => s.destination_id !== first.destination_id
+ )}
+ and others
+ {/if}
+ {:else}
+ {@render cell("destination", 0)}
+ {/if}
+
+ {#if balances.length}
+
+ {balances.length > 1 ? "balances after" : "balance after"}
+ {balances.join(", ")}
+
+ {/if}
+
- {#if files.length > 0}
-
-
-
- {/if}
-
- {#if events.length > 0}
-
-
- {#each events as piggyEvent (piggyEvent.id)}
- {@const piggy = piggyOf(piggyEvent)}
- -
- {#if piggy.id}
- {piggy.name}
- {:else}
- Piggy bank
- {/if}
-
-
- {/each}
-
-
+ {#if !many}
+ {@render classes(first, 0)}
{/if}
- {/each}
+
+ {#if !many}
+
+ {@render cell("notes", 0)}
+
+ {:else}
+
+ {#each splits as split, index (journalId(split))}
+ -
+ {index + 1}
+
+
+
+ {@render cell("description", index)}
+
+
+ {@render cell("amount", index)}
+
+
+ {#if counterSide}
+
+ {counterSide === "destination" ? "to" : "from"}
+ {@render cell(counterSide, index)}
+
+ {/if}
+ {@render classes(split, index)}
+
+ {@render cell("notes", index)}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+ transaction #{id}
+ {splits.length > 1 ? "journals" : "journal"}
+ {splits.map((split) => `#${journalId(split)}`).join(", ")}
+ {#if group.attributes.created_at}
+ created {formatDateTime(group.attributes.created_at)}
+ {/if}
+ {#if group.attributes.updated_at &&
+ group.attributes.updated_at !== group.attributes.created_at}
+ updated {formatDateTime(group.attributes.updated_at)}
+ {/if}
+
{/if}
diff --git a/src/routes/transactions/show/[id=id]/usual.ts b/src/routes/transactions/show/[id=id]/usual.ts
deleted file mode 100644
index d1ba209..0000000
--- a/src/routes/transactions/show/[id=id]/usual.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { api } from "#lib/api/client.js";
-import type { Page, Split, TransactionGroup } from "#lib/api/types.js";
-import { addMonths } from "#lib/dates.js";
-import { isCash } from "#lib/transactions/split.js";
-
-const COUNT = 10;
-const MIN_COUNT = 3;
-const LOOKBACK_MONTHS = 60;
-const PERCENT = 100;
-
-export interface Usual {
- change: number;
- count: number;
- median: number;
- query: string;
-}
-
-export const placesOf = (splits: Split[]) => [
- ...new Set(
- splits
- .filter(
- (split) =>
- split.type === "withdrawal" &&
- split.destination_id &&
- !isCash(split.destination_type)
- )
- .map((split) => split.destination_id ?? "")
- ),
-];
-
-export async function loadPast(
- places: string[],
- groupId: string,
- end: string
-): Promise
> {
- const pages = await Promise.all(
- places.map((place) =>
- api>(`/v1/accounts/${place}/transactions`, {
- query: {
- end,
- limit: COUNT + 1,
- start: addMonths(end, -LOOKBACK_MONTHS),
- type: "withdrawal",
- },
- })
- )
- );
- return Object.fromEntries(
- places.map((place, i) => [
- place,
- pages[i].data
- .filter((group) => group.id !== groupId)
- .flatMap((group) =>
- group.attributes.transactions.filter(
- (split) => split.destination_id === place
- )
- ),
- ])
- );
-}
-
-function median(values: number[]) {
- const sorted = [...values].sort((a, b) => a - b);
- const mid = Math.floor(sorted.length / 2);
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
-}
-
-export function usualOf(
- split: Split,
- past: Record | undefined
-): Usual | null {
- const same = (past?.[split.destination_id ?? ""] ?? [])
- .filter((item) => item.currency_code === split.currency_code)
- .slice(0, COUNT);
- if (same.length < MIN_COUNT) {
- return null;
- }
- const typical = median(same.map((item) => Math.abs(Number(item.amount))));
- return {
- change: Math.round((Number(split.amount) / typical - 1) * PERCENT),
- count: same.length,
- median: typical,
- query: `destination_account_id:${split.destination_id} currency_is:${split.currency_code} date_before:${split.date.slice(0, 10)}`,
- };
-}
-
-const ABOUT = 5;
-const NOTABLE = 15;
-
-export function verdict(change: number) {
- if (Math.abs(change) < ABOUT) {
- return { text: "about usual", warn: false };
- }
- return change > 0
- ? { text: `+${change}% above usual`, warn: change >= NOTABLE }
- : { text: `${-change}% below usual`, warn: false };
-}