feat(accounts): merchants and payers as a league table

Expense and revenue accounts list this month per currency, share,
visits, average, twelve months, typical and the change against it, last
visit and category; quiet ones fold into one line behind i. 1 to 5 sort,
; groups by category with subtotals, x and s c set the category on all
of a merchant's transactions in the period.
This commit is contained in:
hh committed 2026-10-16 15:35:38 +00:00
1 parent 26903b0066
commit fffb770cac
7 files changed
+1352

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
import type { Account, Item } from "#lib/api/types.js";
import type { Range } from "#lib/dates.js";
import type { Flow } from "#lib/reports/flows.js";
import { typical, typicalToDay, versus } from "#lib/reports/typical.js";
import type { Targets } from "#lib/transactions/mass.js";
export type Kind = "expense" | "revenue";
export type SortKey = "month" | "year" | "visits" | "last" | "name";
export const SORT_KEYS: SortKey[] = ["month", "year", "visits", "last", "name"];
export const SPARK_MONTHS = 12;
const TOP = 5;
export interface Line {
amount: number;
base: number;
code: string;
months: number[];
ratio: number | null;
seen: number;
typical: number;
visits: number;
}
export interface Merchant {
category: string | null;
fresh: boolean;
id: string;
last: string;
lines: Line[];
name: string;
others: number;
quiet: boolean;
share: number;
targets: Targets;
value: number;
visits: number;
year: number;
}
export interface Total {
amount: number;
base: number;
code: string;
ratio: number | null;
}
export interface Section {
key: string;
label: string;
merchants: Merchant[];
share: number;
sums: Total[];
visits: number;
}
export interface Board {
fresh: number;
merchants: Merchant[];
past: number;
top: number;
totals: Total[];
}
export interface Context {
day: number | null;
kind: Kind;
months: string[];
past: string[];
range: Range;
rate: (code: string) => number;
}
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
function monthly(flows: Flow[]): Map<string, number> {
const map = new Map<string, number>();
for (const flow of flows) {
const month = flow.date.slice(0, 7);
map.set(month, (map.get(month) ?? 0) + flow.amount);
}
return map;
}
function lineOf(code: string, year: Flow[], now: Flow[], ctx: Context): Line {
const byMonth = monthly(year);
const amount = sum(now.map((flow) => flow.amount));
const base = ctx.day === null ? 0 : typicalToDay(year, ctx.past, ctx.day);
return {
amount,
base,
code,
months: ctx.months.slice(-SPARK_MONTHS).map((m) => byMonth.get(m) ?? 0),
ratio: ctx.day === null ? null : versus(amount, base),
seen: ctx.past.filter((m) => byMonth.has(m)).length,
typical: typical(byMonth, ctx.past),
visits: now.length,
};
}
function dominant(flows: Flow[]): [string | null, number] {
const counts = new Map<string, number>();
for (const flow of flows) {
const name = flow.category?.name ?? "";
counts.set(name, (counts.get(name) ?? 0) + 1);
}
const [top] = [...counts].sort((a, b) => b[1] - a[1]);
return [top?.[0] || null, Math.max(0, counts.size - 1)];
}
function targetsOf(flows: Flow[]): Targets {
const targets: Targets = new Map();
for (const flow of flows) {
const set = targets.get(flow.group) ?? new Set<string>();
set.add(flow.journal);
targets.set(flow.group, set);
}
return targets;
}
function bucket<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const item of items) {
const list = map.get(key(item));
if (list) {
list.push(item);
} else {
map.set(key(item), [item]);
}
}
return map;
}
const latest = (flows: Flow[]) =>
flows.reduce((last, flow) => (flow.date > last ? flow.date : last), "");
function merchantOf(
id: string,
name: string,
seen: string,
year: Flow[],
now: Flow[],
ctx: Context
): Omit<Merchant, "share"> {
const yearBy = bucket(year, (flow) => flow.currency);
const nowBy = bucket(now, (flow) => flow.currency);
const codes = new Set([...yearBy.keys(), ...nowBy.keys()]);
const quiet = now.length === 0;
const lines = [...codes]
.map((code) =>
lineOf(code, yearBy.get(code) ?? [], nowBy.get(code) ?? [], ctx)
)
.filter((line) => (quiet ? sum(line.months) > 0 : line.amount > 0))
.sort(
(a, b) =>
b.amount * ctx.rate(b.code) - a.amount * ctx.rate(a.code) ||
sum(b.months) * ctx.rate(b.code) - sum(a.months) * ctx.rate(a.code)
);
const [category, others] = dominant(quiet ? year : now);
const before = year.filter((flow) => flow.date < ctx.range.start);
return {
category,
fresh: !quiet && before.length === 0,
id,
last: latest(now) || seen || latest(year),
lines,
name,
others,
quiet,
targets: targetsOf(now),
value: sum(lines.map((line) => line.amount * ctx.rate(line.code))),
visits: now.length,
year: sum(lines.map((line) => sum(line.months) * ctx.rate(line.code))),
};
}
export function board(
accounts: Item<Account>[],
flows: Flow[],
periodFlows: Flow[],
ctx: Context
): Board {
const dir = ctx.kind === "expense" ? -1 : 1;
const inRange = (flow: Flow) =>
flow.date >= ctx.range.start && flow.date <= ctx.range.end;
const mine = flows.filter((flow) => flow.dir === dir);
const now = periodFlows.filter((flow) => flow.dir === dir && inRange(flow));
const yearBy = bucket(mine, (flow) => flow.other.id);
const nowBy = bucket(now, (flow) => flow.other.id);
const names = new Map(
[...mine, ...now].map((flow) => [flow.other.id, flow.other.name])
);
const seen = new Map<string, string>();
for (const { id, attributes: a } of accounts) {
if (a.type === ctx.kind && (a.active !== false || names.has(id))) {
names.set(id, a.name);
seen.set(id, a.last_activity?.slice(0, 10) ?? "");
}
}
const drafts = [...names].map(([id, name]) =>
merchantOf(
id,
name,
seen.get(id) ?? "",
yearBy.get(id) ?? [],
nowBy.get(id) ?? [],
ctx
)
);
const total = sum(drafts.map((m) => m.value)) || 1;
const merchants = drafts
.map((m) => ({ ...m, share: m.value / total }))
.sort(sortBy("month"));
return {
fresh: merchants.filter((m) => m.fresh).length,
merchants,
past: ctx.past.length,
top: sum(merchants.slice(0, TOP).map((m) => m.share)),
totals: totalsOf(mine, now, ctx),
};
}
function totalsOf(year: Flow[], now: Flow[], ctx: Context): Total[] {
const nowBy = bucket(now, (flow) => flow.currency);
const yearBy = bucket(year, (flow) => flow.currency);
return [...nowBy]
.map(([code, list]) => {
const amount = sum(list.map((flow) => flow.amount));
const base =
ctx.day === null
? 0
: typicalToDay(yearBy.get(code) ?? [], ctx.past, ctx.day);
return {
amount,
base,
code,
ratio: ctx.day === null ? null : versus(amount, base),
};
})
.sort((a, b) => b.amount * ctx.rate(b.code) - a.amount * ctx.rate(a.code));
}
const ORDER: Record<SortKey, (a: Merchant, b: Merchant) => number> = {
last: (a, b) => b.last.localeCompare(a.last),
month: (a, b) => b.value - a.value,
name: (a, b) => a.name.localeCompare(b.name),
visits: (a, b) => b.visits - a.visits,
year: (a, b) => b.year - a.year,
};
export const sortBy =
(key: SortKey) =>
(a: Merchant, b: Merchant): number =>
ORDER[key](a, b) || ORDER.last(a, b) || ORDER.name(a, b);
export function sumLines(merchants: Merchant[]): Total[] {
const map = new Map<string, number>();
for (const line of merchants.flatMap((m) => m.lines)) {
map.set(line.code, (map.get(line.code) ?? 0) + line.amount);
}
return [...map]
.filter(([, amount]) => amount > 0)
.map(([code, amount]) => ({ amount, base: 0, code, ratio: null }));
}
export function byCategory(merchants: Merchant[], none: string): Section[] {
return [...bucket(merchants, (m) => m.category ?? "")]
.map(([key, list]) => ({
key: key || "none",
label: key || none,
merchants: list,
share: sum(list.map((m) => m.share)),
sums: sumLines(list),
visits: sum(list.map((m) => m.visits)),
}))
.sort((a, b) => b.share - a.share || a.label.localeCompare(b.label));
}
const WHOLE_MONTH = 31;
export function monthDay(range: Range, now: string): number | null {
const month = range.start.slice(0, 7);
const last = new Date(
Number(month.slice(0, 4)),
Number(month.slice(5, 7)),
0
).getDate();
const whole =
range.start === `${month}-01` && range.end === `${month}-${last}`;
if (!whole || month > now.slice(0, 7)) {
return null;
}
return month === now.slice(0, 7) ? Number(now.slice(8, 10)) : WHOLE_MONTH;
}