tallyd 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1493 -2
- package/dist/accounts/chart.d.ts +96 -0
- package/dist/accounts/chart.js +244 -0
- package/dist/accounts/index.d.ts +3 -0
- package/dist/accounts/index.js +3 -0
- package/dist/accounts/standard.d.ts +12 -0
- package/dist/accounts/standard.js +95 -0
- package/dist/accounts/types.d.ts +20 -0
- package/dist/accounts/types.js +40 -0
- package/dist/cli/args.d.ts +45 -0
- package/dist/cli/args.js +147 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +25 -0
- package/dist/cli/run.d.ts +34 -0
- package/dist/cli/run.js +1660 -0
- package/dist/dashboard/charts.d.ts +38 -0
- package/dist/dashboard/charts.js +173 -0
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.js +3 -0
- package/dist/dashboard/model.d.ts +137 -0
- package/dist/dashboard/model.js +190 -0
- package/dist/dashboard/render.d.ts +23 -0
- package/dist/dashboard/render.js +231 -0
- package/dist/dashboard/script.d.ts +28 -0
- package/dist/dashboard/script.js +704 -0
- package/dist/dashboard/styles.d.ts +14 -0
- package/dist/dashboard/styles.js +726 -0
- package/dist/demo/disposal.d.ts +80 -0
- package/dist/demo/disposal.js +215 -0
- package/dist/demo/evidence.d.ts +153 -0
- package/dist/demo/evidence.js +348 -0
- package/dist/demo/foreign.d.ts +29 -0
- package/dist/demo/foreign.js +113 -0
- package/dist/demo/foreignReport.d.ts +10 -0
- package/dist/demo/foreignReport.js +89 -0
- package/dist/demo/generator.d.ts +108 -0
- package/dist/demo/generator.js +445 -0
- package/dist/demo/group.d.ts +70 -0
- package/dist/demo/group.js +195 -0
- package/dist/demo/groupPeriods.d.ts +27 -0
- package/dist/demo/groupPeriods.js +125 -0
- package/dist/demo/groupReport.d.ts +13 -0
- package/dist/demo/groupReport.js +87 -0
- package/dist/demo/ingest.d.ts +6 -0
- package/dist/demo/ingest.js +61 -0
- package/dist/demo/main.d.ts +6 -0
- package/dist/demo/main.js +7 -0
- package/dist/demo/mainDisposal.d.ts +1 -0
- package/dist/demo/mainDisposal.js +2 -0
- package/dist/demo/mainEvidence.d.ts +8 -0
- package/dist/demo/mainEvidence.js +9 -0
- package/dist/demo/mainForeign.d.ts +6 -0
- package/dist/demo/mainForeign.js +7 -0
- package/dist/demo/mainGroup.d.ts +6 -0
- package/dist/demo/mainGroup.js +7 -0
- package/dist/demo/mainGroupPeriods.d.ts +6 -0
- package/dist/demo/mainGroupPeriods.js +7 -0
- package/dist/demo/mainIngest.d.ts +6 -0
- package/dist/demo/mainIngest.js +7 -0
- package/dist/demo/mainMidYear.d.ts +6 -0
- package/dist/demo/mainMidYear.js +7 -0
- package/dist/demo/mainReconcile.d.ts +6 -0
- package/dist/demo/mainReconcile.js +7 -0
- package/dist/demo/mainStatements.d.ts +6 -0
- package/dist/demo/mainStatements.js +7 -0
- package/dist/demo/midYear.d.ts +51 -0
- package/dist/demo/midYear.js +162 -0
- package/dist/demo/month.d.ts +13 -0
- package/dist/demo/month.js +142 -0
- package/dist/demo/random.d.ts +48 -0
- package/dist/demo/random.js +94 -0
- package/dist/demo/receivables.d.ts +36 -0
- package/dist/demo/receivables.js +104 -0
- package/dist/demo/reconcile.d.ts +20 -0
- package/dist/demo/reconcile.js +104 -0
- package/dist/demo/report.d.ts +6 -0
- package/dist/demo/report.js +26 -0
- package/dist/demo/statement.d.ts +13 -0
- package/dist/demo/statement.js +51 -0
- package/dist/demo/statements.d.ts +6 -0
- package/dist/demo/statements.js +29 -0
- package/dist/demo/supplierRun.d.ts +46 -0
- package/dist/demo/supplierRun.js +94 -0
- package/dist/demo/unrealisedEvidence.d.ts +51 -0
- package/dist/demo/unrealisedEvidence.js +183 -0
- package/dist/fx/average.d.ts +60 -0
- package/dist/fx/average.js +139 -0
- package/dist/fx/document.d.ts +60 -0
- package/dist/fx/document.js +205 -0
- package/dist/fx/exposure.d.ts +108 -0
- package/dist/fx/exposure.js +262 -0
- package/dist/fx/index.d.ts +8 -0
- package/dist/fx/index.js +8 -0
- package/dist/fx/rate.d.ts +77 -0
- package/dist/fx/rate.js +200 -0
- package/dist/fx/revaluation.d.ts +92 -0
- package/dist/fx/revaluation.js +197 -0
- package/dist/fx/settlement.d.ts +84 -0
- package/dist/fx/settlement.js +162 -0
- package/dist/fx/table.d.ts +120 -0
- package/dist/fx/table.js +271 -0
- package/dist/fx/translate.d.ts +126 -0
- package/dist/fx/translate.js +258 -0
- package/dist/group/accounts.d.ts +74 -0
- package/dist/group/accounts.js +179 -0
- package/dist/group/acquisition.d.ts +324 -0
- package/dist/group/acquisition.js +416 -0
- package/dist/group/aggregate.d.ts +143 -0
- package/dist/group/aggregate.js +288 -0
- package/dist/group/comparative.d.ts +102 -0
- package/dist/group/comparative.js +166 -0
- package/dist/group/consolidate.d.ts +196 -0
- package/dist/group/consolidate.js +635 -0
- package/dist/group/disposal.d.ts +181 -0
- package/dist/group/disposal.js +233 -0
- package/dist/group/document.d.ts +41 -0
- package/dist/group/document.js +326 -0
- package/dist/group/index.d.ts +12 -0
- package/dist/group/index.js +12 -0
- package/dist/group/intercompany.d.ts +136 -0
- package/dist/group/intercompany.js +238 -0
- package/dist/group/interest.d.ts +109 -0
- package/dist/group/interest.js +224 -0
- package/dist/group/movement.d.ts +206 -0
- package/dist/group/movement.js +432 -0
- package/dist/group/structure.d.ts +151 -0
- package/dist/group/structure.js +327 -0
- package/dist/group/timeline.d.ts +109 -0
- package/dist/group/timeline.js +169 -0
- package/dist/group/unrealised.d.ts +165 -0
- package/dist/group/unrealised.js +314 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ledger/close.d.ts +75 -0
- package/dist/ledger/close.js +142 -0
- package/dist/ledger/date.d.ts +48 -0
- package/dist/ledger/date.js +141 -0
- package/dist/ledger/entry.d.ts +150 -0
- package/dist/ledger/entry.js +264 -0
- package/dist/ledger/index.d.ts +6 -0
- package/dist/ledger/index.js +6 -0
- package/dist/ledger/ledger.d.ts +101 -0
- package/dist/ledger/ledger.js +308 -0
- package/dist/ledger/serialise.d.ts +68 -0
- package/dist/ledger/serialise.js +204 -0
- package/dist/ledger/trialBalance.d.ts +49 -0
- package/dist/ledger/trialBalance.js +103 -0
- package/dist/money/currency.d.ts +37 -0
- package/dist/money/currency.js +67 -0
- package/dist/money/index.d.ts +3 -0
- package/dist/money/index.js +3 -0
- package/dist/money/money.d.ts +106 -0
- package/dist/money/money.js +300 -0
- package/dist/money/rounding.d.ts +41 -0
- package/dist/money/rounding.js +117 -0
- package/dist/reconcile/accuracy.d.ts +65 -0
- package/dist/reconcile/accuracy.js +162 -0
- package/dist/reconcile/assignment.d.ts +83 -0
- package/dist/reconcile/assignment.js +326 -0
- package/dist/reconcile/bankView.d.ts +65 -0
- package/dist/reconcile/bankView.js +86 -0
- package/dist/reconcile/bridge.d.ts +76 -0
- package/dist/reconcile/bridge.js +129 -0
- package/dist/reconcile/candidates.d.ts +84 -0
- package/dist/reconcile/candidates.js +174 -0
- package/dist/reconcile/decisions.d.ts +116 -0
- package/dist/reconcile/decisions.js +168 -0
- package/dist/reconcile/index.d.ts +12 -0
- package/dist/reconcile/index.js +12 -0
- package/dist/reconcile/matcher.d.ts +89 -0
- package/dist/reconcile/matcher.js +306 -0
- package/dist/reconcile/memory.d.ts +120 -0
- package/dist/reconcile/memory.js +281 -0
- package/dist/reconcile/posting.d.ts +185 -0
- package/dist/reconcile/posting.js +481 -0
- package/dist/reconcile/scoring.d.ts +98 -0
- package/dist/reconcile/scoring.js +319 -0
- package/dist/reconcile/similarity.d.ts +88 -0
- package/dist/reconcile/similarity.js +272 -0
- package/dist/reconcile/subsetSum.d.ts +61 -0
- package/dist/reconcile/subsetSum.js +136 -0
- package/dist/reports/ageing.d.ts +75 -0
- package/dist/reports/ageing.js +149 -0
- package/dist/reports/balanceSheet.d.ts +57 -0
- package/dist/reports/balanceSheet.js +107 -0
- package/dist/reports/incomeStatement.d.ts +59 -0
- package/dist/reports/incomeStatement.js +132 -0
- package/dist/reports/index.d.ts +4 -0
- package/dist/reports/index.js +4 -0
- package/dist/reports/period.d.ts +37 -0
- package/dist/reports/period.js +78 -0
- package/dist/statement/amount.d.ts +55 -0
- package/dist/statement/amount.js +211 -0
- package/dist/statement/columns.d.ts +39 -0
- package/dist/statement/columns.js +221 -0
- package/dist/statement/csv.d.ts +50 -0
- package/dist/statement/csv.js +185 -0
- package/dist/statement/dates.d.ts +52 -0
- package/dist/statement/dates.js +204 -0
- package/dist/statement/duplicates.d.ts +51 -0
- package/dist/statement/duplicates.js +101 -0
- package/dist/statement/import.d.ts +52 -0
- package/dist/statement/import.js +172 -0
- package/dist/statement/index.d.ts +20 -0
- package/dist/statement/index.js +32 -0
- package/dist/statement/line.d.ts +60 -0
- package/dist/statement/line.js +73 -0
- package/dist/statement/ofx.d.ts +47 -0
- package/dist/statement/ofx.js +199 -0
- package/package.json +72 -4
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two charts, drawn as SVG by hand.
|
|
3
|
+
*
|
|
4
|
+
* Not a library, because the whole artefact is one file that has to open from
|
|
5
|
+
* a file path on a machine with no network — and because two charts do not
|
|
6
|
+
* justify shipping a charting engine inside a bookkeeping report.
|
|
7
|
+
*
|
|
8
|
+
* Both answer a question rather than decorating a panel. The cash position
|
|
9
|
+
* line answers "when was the account tight"; the confidence distribution
|
|
10
|
+
* answers "how much of this did the matcher actually know". Neither carries a
|
|
11
|
+
* legend: each is a single series, and the heading names it, so a legend box
|
|
12
|
+
* would be a second label for the same thing.
|
|
13
|
+
*
|
|
14
|
+
* Colour does no identifying work here. One series takes the categorical blue;
|
|
15
|
+
* the distribution is ordinal, so it steps one hue light to dark — never a
|
|
16
|
+
* hue per bar, which would imply four unrelated categories.
|
|
17
|
+
*/
|
|
18
|
+
import type { SeriesPoint } from "./model.js";
|
|
19
|
+
export interface ChartOptions {
|
|
20
|
+
readonly width?: number;
|
|
21
|
+
readonly height?: number;
|
|
22
|
+
readonly exponent: number;
|
|
23
|
+
readonly symbol: string;
|
|
24
|
+
}
|
|
25
|
+
/** Cash position across the period, as a line with a soft fill beneath it. */
|
|
26
|
+
export declare function cashPositionChart(points: readonly SeriesPoint[], options: ChartOptions): string;
|
|
27
|
+
/**
|
|
28
|
+
* Distribution of match confidence, as horizontal bars.
|
|
29
|
+
*
|
|
30
|
+
* Horizontal because the labels are words: rotated axis text is a readability
|
|
31
|
+
* cost paid for nothing when there are four categories.
|
|
32
|
+
*/
|
|
33
|
+
export declare function confidenceChart(bands: readonly {
|
|
34
|
+
readonly label: string;
|
|
35
|
+
readonly count: number;
|
|
36
|
+
}[], options?: {
|
|
37
|
+
readonly width?: number;
|
|
38
|
+
}): string;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two charts, drawn as SVG by hand.
|
|
3
|
+
*
|
|
4
|
+
* Not a library, because the whole artefact is one file that has to open from
|
|
5
|
+
* a file path on a machine with no network — and because two charts do not
|
|
6
|
+
* justify shipping a charting engine inside a bookkeeping report.
|
|
7
|
+
*
|
|
8
|
+
* Both answer a question rather than decorating a panel. The cash position
|
|
9
|
+
* line answers "when was the account tight"; the confidence distribution
|
|
10
|
+
* answers "how much of this did the matcher actually know". Neither carries a
|
|
11
|
+
* legend: each is a single series, and the heading names it, so a legend box
|
|
12
|
+
* would be a second label for the same thing.
|
|
13
|
+
*
|
|
14
|
+
* Colour does no identifying work here. One series takes the categorical blue;
|
|
15
|
+
* the distribution is ordinal, so it steps one hue light to dark — never a
|
|
16
|
+
* hue per bar, which would imply four unrelated categories.
|
|
17
|
+
*/
|
|
18
|
+
const escapeAttribute = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
19
|
+
function majorUnits(minor, exponent) {
|
|
20
|
+
return minor / 10 ** exponent;
|
|
21
|
+
}
|
|
22
|
+
function formatAxisMoney(minor, exponent, symbol) {
|
|
23
|
+
const value = majorUnits(minor, exponent);
|
|
24
|
+
const abs = Math.abs(value);
|
|
25
|
+
if (abs >= 1000)
|
|
26
|
+
return `${symbol}${(value / 1000).toFixed(abs >= 10_000 ? 0 : 1)}k`;
|
|
27
|
+
return `${symbol}${value.toFixed(0)}`;
|
|
28
|
+
}
|
|
29
|
+
function formatMoney(minor, exponent, symbol) {
|
|
30
|
+
const negative = minor < 0;
|
|
31
|
+
const digits = Math.abs(minor).toString().padStart(exponent + 1, "0");
|
|
32
|
+
const whole = digits.slice(0, digits.length - exponent);
|
|
33
|
+
const fraction = exponent === 0 ? "" : `.${digits.slice(digits.length - exponent)}`;
|
|
34
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
35
|
+
return `${negative ? "-" : ""}${symbol}${grouped}${fraction}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Round a range outward to friendly ticks. Axes that stop at 23,143.58 read as
|
|
39
|
+
* an accident; axes that stop at 25,000 read as a decision.
|
|
40
|
+
*/
|
|
41
|
+
function niceTicks(min, max, count = 4) {
|
|
42
|
+
if (min === max) {
|
|
43
|
+
const pad = Math.abs(min) || 1;
|
|
44
|
+
min -= pad;
|
|
45
|
+
max += pad;
|
|
46
|
+
}
|
|
47
|
+
const rawStep = (max - min) / count;
|
|
48
|
+
const magnitude = 10 ** Math.floor(Math.log10(Math.abs(rawStep) || 1));
|
|
49
|
+
const normalised = rawStep / magnitude;
|
|
50
|
+
const step = (normalised >= 5 ? 10 : normalised >= 2 ? 5 : normalised >= 1 ? 2 : 1) * magnitude;
|
|
51
|
+
const start = Math.floor(min / step) * step;
|
|
52
|
+
const end = Math.ceil(max / step) * step;
|
|
53
|
+
const ticks = [];
|
|
54
|
+
for (let value = start; value <= end + step / 2; value += step) {
|
|
55
|
+
ticks.push(Math.round(value));
|
|
56
|
+
}
|
|
57
|
+
return ticks;
|
|
58
|
+
}
|
|
59
|
+
/** Cash position across the period, as a line with a soft fill beneath it. */
|
|
60
|
+
export function cashPositionChart(points, options) {
|
|
61
|
+
const width = options.width ?? 640;
|
|
62
|
+
const height = options.height ?? 200;
|
|
63
|
+
const padLeft = 52;
|
|
64
|
+
const padRight = 12;
|
|
65
|
+
const padTop = 12;
|
|
66
|
+
const padBottom = 26;
|
|
67
|
+
if (points.length === 0) {
|
|
68
|
+
return `<div class="empty"><strong>No movements</strong>Nothing has been posted to this account in the period.</div>`;
|
|
69
|
+
}
|
|
70
|
+
const values = points.map((point) => point.minor);
|
|
71
|
+
const ticks = niceTicks(Math.min(0, ...values), Math.max(0, ...values));
|
|
72
|
+
const low = Math.min(...ticks);
|
|
73
|
+
const high = Math.max(...ticks);
|
|
74
|
+
const plotWidth = width - padLeft - padRight;
|
|
75
|
+
const plotHeight = height - padTop - padBottom;
|
|
76
|
+
// Position by date, not by index. Spacing ten movements evenly across a
|
|
77
|
+
// month draws three days and thirteen days the same width, which is a lie
|
|
78
|
+
// about the one axis the chart exists to show.
|
|
79
|
+
const day = (iso) => {
|
|
80
|
+
const [year, month, dayOfMonth] = iso.split("-").map(Number);
|
|
81
|
+
return Math.floor(Date.UTC(year, month - 1, dayOfMonth) / 86_400_000);
|
|
82
|
+
};
|
|
83
|
+
const firstDay = day(points[0].date);
|
|
84
|
+
const lastDay = day(points[points.length - 1].date);
|
|
85
|
+
const span = lastDay - firstDay;
|
|
86
|
+
const x = (index) => {
|
|
87
|
+
if (points.length === 1 || span === 0)
|
|
88
|
+
return padLeft + plotWidth / 2;
|
|
89
|
+
const offset = day(points[index].date) - firstDay;
|
|
90
|
+
return padLeft + (offset / span) * plotWidth;
|
|
91
|
+
};
|
|
92
|
+
const y = (value) => padTop + plotHeight - ((value - low) / (high - low || 1)) * plotHeight;
|
|
93
|
+
const line = points.map((point, index) => `${index === 0 ? "M" : "L"}${x(index).toFixed(1)},${y(point.minor).toFixed(1)}`).join(" ");
|
|
94
|
+
const area = `${line} L${x(points.length - 1).toFixed(1)},${y(low).toFixed(1)} L${x(0).toFixed(1)},${y(low).toFixed(1)} Z`;
|
|
95
|
+
const gridLines = ticks
|
|
96
|
+
.map((tick) => `<line x1="${padLeft}" x2="${width - padRight}" y1="${y(tick).toFixed(1)}" y2="${y(tick).toFixed(1)}" />`)
|
|
97
|
+
.join("");
|
|
98
|
+
const axisLabels = ticks
|
|
99
|
+
.map((tick) => `<text x="${padLeft - 8}" y="${(y(tick) + 3).toFixed(1)}" text-anchor="end">${escapeAttribute(formatAxisMoney(tick, options.exponent, options.symbol))}</text>`)
|
|
100
|
+
.join("");
|
|
101
|
+
const first = points[0];
|
|
102
|
+
const last = points[points.length - 1];
|
|
103
|
+
const dateLabels = [
|
|
104
|
+
`<text x="${padLeft}" y="${height - 6}" text-anchor="start">${escapeAttribute(first.date)}</text>`,
|
|
105
|
+
points.length > 1
|
|
106
|
+
? `<text x="${width - padRight}" y="${height - 6}" text-anchor="end">${escapeAttribute(last.date)}</text>`
|
|
107
|
+
: "",
|
|
108
|
+
].join("");
|
|
109
|
+
const markers = points
|
|
110
|
+
.map((point, index) => `<circle class="marker" data-index="${index}" cx="${x(index).toFixed(1)}" cy="${y(point.minor).toFixed(1)}" r="4"><title>${escapeAttribute(`${point.date} · ${formatMoney(point.minor, options.exponent, options.symbol)}`)}</title></circle>`)
|
|
111
|
+
.join("");
|
|
112
|
+
// Wide invisible hit areas: a 4px dot is a poor target, and the tooltip
|
|
113
|
+
// should follow the pointer across the whole column, not just the mark.
|
|
114
|
+
const hitAreas = points
|
|
115
|
+
.map((point, index) => {
|
|
116
|
+
const here = x(index);
|
|
117
|
+
const before = index === 0 ? padLeft : (x(index - 1) + here) / 2;
|
|
118
|
+
const after = index === points.length - 1 ? width - padRight : (here + x(index + 1)) / 2;
|
|
119
|
+
const left = Math.max(padLeft, before);
|
|
120
|
+
const right = Math.min(width - padRight, after);
|
|
121
|
+
return `<rect class="hit" x="${left.toFixed(1)}" y="${padTop}" width="${(right - left).toFixed(1)}" height="${plotHeight}" fill="transparent" data-x="${x(index).toFixed(1)}" data-y="${y(point.minor).toFixed(1)}" data-label="${escapeAttribute(`${point.date} ${formatMoney(point.minor, options.exponent, options.symbol)}`)}" />`;
|
|
122
|
+
})
|
|
123
|
+
.join("");
|
|
124
|
+
return `<div class="chart" data-chart="cash">
|
|
125
|
+
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Cash position across the period">
|
|
126
|
+
<g class="grid">${gridLines}</g>
|
|
127
|
+
<path class="series-area" d="${area}" />
|
|
128
|
+
<path class="series-line" d="${line}" />
|
|
129
|
+
<g class="axis">${axisLabels}${dateLabels}</g>
|
|
130
|
+
${markers}
|
|
131
|
+
<line class="crosshair" y1="${padTop}" y2="${padTop + plotHeight}" x1="0" x2="0" style="display:none" />
|
|
132
|
+
${hitAreas}
|
|
133
|
+
</svg>
|
|
134
|
+
<div class="tooltip" role="status" aria-live="polite"></div>
|
|
135
|
+
</div>`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Distribution of match confidence, as horizontal bars.
|
|
139
|
+
*
|
|
140
|
+
* Horizontal because the labels are words: rotated axis text is a readability
|
|
141
|
+
* cost paid for nothing when there are four categories.
|
|
142
|
+
*/
|
|
143
|
+
export function confidenceChart(bands, options = {}) {
|
|
144
|
+
const width = options.width ?? 340;
|
|
145
|
+
const rowHeight = 32;
|
|
146
|
+
const labelWidth = 66;
|
|
147
|
+
const countWidth = 28;
|
|
148
|
+
const height = bands.length * rowHeight;
|
|
149
|
+
const total = bands.reduce((sum, band) => sum + band.count, 0);
|
|
150
|
+
if (total === 0) {
|
|
151
|
+
return `<div class="empty"><strong>Nothing matched</strong>No pair scored high enough to appear.</div>`;
|
|
152
|
+
}
|
|
153
|
+
const most = Math.max(...bands.map((band) => band.count));
|
|
154
|
+
const barWidth = width - labelWidth - countWidth;
|
|
155
|
+
const rows = bands
|
|
156
|
+
.map((band, index) => {
|
|
157
|
+
const y = index * rowHeight;
|
|
158
|
+
const length = most === 0 ? 0 : (band.count / most) * barWidth;
|
|
159
|
+
const fill = `var(--seq-${4 - index})`;
|
|
160
|
+
const share = total === 0 ? 0 : Math.round((band.count / total) * 100);
|
|
161
|
+
return `<g>
|
|
162
|
+
<text class="bar-label" x="0" y="${y + 19}">${escapeAttribute(band.label)}</text>
|
|
163
|
+
<rect class="bar" x="${labelWidth}" y="${y + 8}" width="${Math.max(length, band.count > 0 ? 3 : 0).toFixed(1)}" height="14" rx="4" fill="${fill}"><title>${escapeAttribute(`${band.count} ${band.count === 1 ? "match" : "matches"} · ${share}%`)}</title></rect>
|
|
164
|
+
<text class="bar-label" x="${width}" y="${y + 19}" text-anchor="end">${band.count}</text>
|
|
165
|
+
</g>`;
|
|
166
|
+
})
|
|
167
|
+
.join("");
|
|
168
|
+
return `<div class="chart" data-chart="confidence">
|
|
169
|
+
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Distribution of match confidence">
|
|
170
|
+
${rows}
|
|
171
|
+
</svg>
|
|
172
|
+
</div>`;
|
|
173
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { type AmountView, type LineView, type MatchView, type AccountView, type PostingView, type SeriesPoint, type DashboardData, type DashboardInput, dashboardData, } from "./model.js";
|
|
2
|
+
export { escapeHtml, embedJson, renderDashboard } from "./render.js";
|
|
3
|
+
export { cashPositionChart, confidenceChart, type ChartOptions } from "./charts.js";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page needs to know.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard is a single HTML file with its data baked in, so this module
|
|
5
|
+
* flattens a ledger, a statement and a reconciliation into something that
|
|
6
|
+
* survives `JSON.stringify` — plain strings and numbers, no `Money`, no
|
|
7
|
+
* `bigint`, no class instances.
|
|
8
|
+
*
|
|
9
|
+
* Amounts cross that boundary twice: once as a decimal string for display, and
|
|
10
|
+
* once as a count of minor units for arithmetic in the browser. Keeping both is
|
|
11
|
+
* deliberate. The browser has to re-add the bridge every time a match is
|
|
12
|
+
* accepted, and doing that on formatted strings — or on floats parsed from them
|
|
13
|
+
* — is how a reconciliation ends up out by a penny in the one place a penny
|
|
14
|
+
* matters.
|
|
15
|
+
*/
|
|
16
|
+
import { Money } from "../money/money.js";
|
|
17
|
+
import type { Ledger } from "../ledger/ledger.js";
|
|
18
|
+
import type { StatementLine } from "../statement/line.js";
|
|
19
|
+
import type { BookLine } from "../reconcile/bankView.js";
|
|
20
|
+
import type { Match, ReconciliationResult } from "../reconcile/matcher.js";
|
|
21
|
+
import { type DecisionPayload } from "../reconcile/decisions.js";
|
|
22
|
+
import type { Proposal } from "../reconcile/posting.js";
|
|
23
|
+
export interface AmountView {
|
|
24
|
+
/** For display: `-1850.00`. */
|
|
25
|
+
readonly text: string;
|
|
26
|
+
/** For arithmetic: signed minor units, as a JSON-safe number. */
|
|
27
|
+
readonly minor: number;
|
|
28
|
+
}
|
|
29
|
+
export interface LineView {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly date: string;
|
|
32
|
+
readonly description: string;
|
|
33
|
+
readonly amount: AmountView;
|
|
34
|
+
/** `book` or `bank`, so the page can label a line without being told twice. */
|
|
35
|
+
readonly side: "book" | "bank";
|
|
36
|
+
readonly reference: string | null;
|
|
37
|
+
}
|
|
38
|
+
export interface MatchView {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly kind: Match["kind"];
|
|
41
|
+
readonly confidence: string;
|
|
42
|
+
readonly score: number;
|
|
43
|
+
readonly book: readonly LineView[];
|
|
44
|
+
readonly statement: readonly LineView[];
|
|
45
|
+
readonly reasons: readonly string[];
|
|
46
|
+
readonly amount: AmountView;
|
|
47
|
+
/**
|
|
48
|
+
* What deciding this suggestion should write to the decisions file, minus
|
|
49
|
+
* the verdict and the date. Built here rather than in the browser so the
|
|
50
|
+
* shape is type-checked and tested; see `reconcile/decisions.ts`.
|
|
51
|
+
*/
|
|
52
|
+
readonly decision: readonly DecisionPayload[];
|
|
53
|
+
}
|
|
54
|
+
export interface AccountView {
|
|
55
|
+
readonly code: string;
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly type: string;
|
|
58
|
+
readonly depth: number;
|
|
59
|
+
readonly balance: AmountView;
|
|
60
|
+
readonly postings: number;
|
|
61
|
+
}
|
|
62
|
+
export interface PostingView {
|
|
63
|
+
readonly entryId: string;
|
|
64
|
+
readonly date: string;
|
|
65
|
+
readonly narration: string;
|
|
66
|
+
readonly account: string;
|
|
67
|
+
readonly amount: AmountView;
|
|
68
|
+
readonly contra: readonly string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A journal entry a statement line implies, flattened for the page.
|
|
72
|
+
*
|
|
73
|
+
* One of these exists for *every* statement line, not only the ones currently
|
|
74
|
+
* unmatched, because the page has to be able to show what a rejection implies
|
|
75
|
+
* the moment it is made. Which of them are live is decided in the browser from
|
|
76
|
+
* the leftovers, which is state the browser already maintains.
|
|
77
|
+
*/
|
|
78
|
+
export interface ImpliedEntryView {
|
|
79
|
+
readonly lineId: string;
|
|
80
|
+
readonly date: string;
|
|
81
|
+
readonly description: string;
|
|
82
|
+
readonly amount: AmountView;
|
|
83
|
+
readonly outcome: Proposal["outcome"];
|
|
84
|
+
readonly account: string | null;
|
|
85
|
+
readonly accountName: string | null;
|
|
86
|
+
readonly rule: string | null;
|
|
87
|
+
readonly narration: string;
|
|
88
|
+
readonly reason: string;
|
|
89
|
+
}
|
|
90
|
+
export interface SeriesPoint {
|
|
91
|
+
readonly date: string;
|
|
92
|
+
readonly minor: number;
|
|
93
|
+
}
|
|
94
|
+
export interface DashboardData {
|
|
95
|
+
readonly generatedFor: string;
|
|
96
|
+
readonly account: string;
|
|
97
|
+
readonly accountName: string;
|
|
98
|
+
readonly currency: string;
|
|
99
|
+
readonly currencySymbol: string;
|
|
100
|
+
readonly exponent: number;
|
|
101
|
+
readonly period: {
|
|
102
|
+
readonly from: string;
|
|
103
|
+
readonly to: string;
|
|
104
|
+
};
|
|
105
|
+
readonly matched: readonly MatchView[];
|
|
106
|
+
readonly suggested: readonly MatchView[];
|
|
107
|
+
readonly unmatchedBook: readonly LineView[];
|
|
108
|
+
readonly unmatchedStatement: readonly LineView[];
|
|
109
|
+
/** Closing balances the bridge starts from, in minor units. */
|
|
110
|
+
readonly bankClosingMinor: number;
|
|
111
|
+
readonly bookClosingMinor: number;
|
|
112
|
+
readonly accounts: readonly AccountView[];
|
|
113
|
+
readonly postings: readonly PostingView[];
|
|
114
|
+
readonly cashPosition: readonly SeriesPoint[];
|
|
115
|
+
readonly confidence: readonly {
|
|
116
|
+
readonly label: string;
|
|
117
|
+
readonly count: number;
|
|
118
|
+
}[];
|
|
119
|
+
readonly trialBalanceBalanced: boolean;
|
|
120
|
+
readonly statementFormat: string;
|
|
121
|
+
/** What each statement line would imply if nothing in the books explained it. */
|
|
122
|
+
readonly implied: readonly ImpliedEntryView[];
|
|
123
|
+
}
|
|
124
|
+
export interface DashboardInput {
|
|
125
|
+
readonly ledger: Ledger;
|
|
126
|
+
readonly account: string;
|
|
127
|
+
readonly books: readonly BookLine[];
|
|
128
|
+
readonly statement: readonly StatementLine[];
|
|
129
|
+
readonly result: ReconciliationResult;
|
|
130
|
+
readonly bankClosingBalance: Money;
|
|
131
|
+
readonly bookClosingBalance: Money;
|
|
132
|
+
readonly statementFormat: string;
|
|
133
|
+
readonly generatedFor?: string;
|
|
134
|
+
/** One proposal per statement line, in any order. */
|
|
135
|
+
readonly implied?: readonly Proposal[];
|
|
136
|
+
}
|
|
137
|
+
export declare function dashboardData(input: DashboardInput): DashboardData;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page needs to know.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard is a single HTML file with its data baked in, so this module
|
|
5
|
+
* flattens a ledger, a statement and a reconciliation into something that
|
|
6
|
+
* survives `JSON.stringify` — plain strings and numbers, no `Money`, no
|
|
7
|
+
* `bigint`, no class instances.
|
|
8
|
+
*
|
|
9
|
+
* Amounts cross that boundary twice: once as a decimal string for display, and
|
|
10
|
+
* once as a count of minor units for arithmetic in the browser. Keeping both is
|
|
11
|
+
* deliberate. The browser has to re-add the bridge every time a match is
|
|
12
|
+
* accepted, and doing that on formatted strings — or on floats parsed from them
|
|
13
|
+
* — is how a reconciliation ends up out by a penny in the one place a penny
|
|
14
|
+
* matters.
|
|
15
|
+
*/
|
|
16
|
+
import { Money } from "../money/money.js";
|
|
17
|
+
import { debitSign } from "../accounts/types.js";
|
|
18
|
+
import { significantReasons } from "../reconcile/matcher.js";
|
|
19
|
+
import { decisionPayloads } from "../reconcile/decisions.js";
|
|
20
|
+
import { trialBalance } from "../ledger/trialBalance.js";
|
|
21
|
+
function amountOf(money) {
|
|
22
|
+
return { text: money.toDecimalString(), minor: Number(money.minorUnits) };
|
|
23
|
+
}
|
|
24
|
+
function bookLineView(line) {
|
|
25
|
+
return {
|
|
26
|
+
id: line.id,
|
|
27
|
+
date: line.date,
|
|
28
|
+
description: line.description,
|
|
29
|
+
amount: amountOf(line.amount),
|
|
30
|
+
side: "book",
|
|
31
|
+
reference: line.reference,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function statementLineView(line) {
|
|
35
|
+
return {
|
|
36
|
+
id: line.id,
|
|
37
|
+
date: line.date,
|
|
38
|
+
description: line.description,
|
|
39
|
+
amount: amountOf(line.amount),
|
|
40
|
+
side: "bank",
|
|
41
|
+
reference: line.reference,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function matchView(match, index, bucket) {
|
|
45
|
+
const minor = match.statement.reduce((sum, line) => sum + Number(line.amount.minorUnits), 0);
|
|
46
|
+
const currency = match.statement[0]?.amount.currency ?? match.book[0]?.amount.currency;
|
|
47
|
+
const exponent = currency?.exponent ?? 2;
|
|
48
|
+
const amount = { text: formatMinor(minor, exponent), minor };
|
|
49
|
+
return {
|
|
50
|
+
id: `${bucket}-${index}`,
|
|
51
|
+
kind: match.kind,
|
|
52
|
+
confidence: match.scored.confidence,
|
|
53
|
+
score: Number(match.scored.score.toFixed(4)),
|
|
54
|
+
book: match.book.map(bookLineView),
|
|
55
|
+
statement: match.statement.map(statementLineView),
|
|
56
|
+
reasons: significantReasons(match),
|
|
57
|
+
amount,
|
|
58
|
+
decision: decisionPayloads({
|
|
59
|
+
statementDescriptions: match.statement.map((line) => line.description),
|
|
60
|
+
bookDescriptions: match.book.map((line) => line.description),
|
|
61
|
+
context: {
|
|
62
|
+
amount: amount.text,
|
|
63
|
+
date: match.statement[0]?.date ?? match.book[0]?.date ?? "",
|
|
64
|
+
kind: match.kind,
|
|
65
|
+
confidence: match.scored.confidence,
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function formatMinor(minor, exponent) {
|
|
71
|
+
const negative = minor < 0;
|
|
72
|
+
const digits = Math.abs(minor).toString().padStart(exponent + 1, "0");
|
|
73
|
+
const whole = digits.slice(0, digits.length - exponent);
|
|
74
|
+
const fraction = exponent === 0 ? "" : `.${digits.slice(digits.length - exponent)}`;
|
|
75
|
+
return `${negative ? "-" : ""}${whole}${fraction}`;
|
|
76
|
+
}
|
|
77
|
+
/** Running balance of the bank account, one point per day it moved. */
|
|
78
|
+
function cashPosition(lines) {
|
|
79
|
+
const byDate = new Map();
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
byDate.set(line.date, (byDate.get(line.date) ?? 0) + Number(line.amount.minorUnits));
|
|
82
|
+
}
|
|
83
|
+
const dates = [...byDate.keys()].sort();
|
|
84
|
+
const points = [];
|
|
85
|
+
let running = 0;
|
|
86
|
+
for (const date of dates) {
|
|
87
|
+
running += byDate.get(date) ?? 0;
|
|
88
|
+
points.push({ date, minor: running });
|
|
89
|
+
}
|
|
90
|
+
return points;
|
|
91
|
+
}
|
|
92
|
+
const CONFIDENCE_ORDER = ["exact", "high", "medium", "low"];
|
|
93
|
+
/** A grouping account's balance is the sum of its subtree, on its normal side. */
|
|
94
|
+
function rolledUpNatural(ledger, chart, code, type) {
|
|
95
|
+
const total = chart
|
|
96
|
+
.subtree(code)
|
|
97
|
+
.reduce((sum, node) => sum + ledger.balanceOf(node.code).minorUnits, 0n);
|
|
98
|
+
const signed = debitSign(type) === 1 ? total : -total;
|
|
99
|
+
return Money.ofMinor(signed, ledger.balanceOf(code).currency);
|
|
100
|
+
}
|
|
101
|
+
export function dashboardData(input) {
|
|
102
|
+
const currency = input.bankClosingBalance.currency;
|
|
103
|
+
const dates = [
|
|
104
|
+
...input.books.map((line) => line.date),
|
|
105
|
+
...input.statement.map((line) => line.date),
|
|
106
|
+
].sort();
|
|
107
|
+
const from = (dates[0] ?? "");
|
|
108
|
+
const to = (dates[dates.length - 1] ?? "");
|
|
109
|
+
const counts = new Map();
|
|
110
|
+
for (const match of [...input.result.matched, ...input.result.suggested]) {
|
|
111
|
+
counts.set(match.scored.confidence, (counts.get(match.scored.confidence) ?? 0) + 1);
|
|
112
|
+
}
|
|
113
|
+
// An account nothing has ever touched is noise in a report about what
|
|
114
|
+
// happened. A grouping account earns its row only when something below it
|
|
115
|
+
// did, and then it shows the rolled-up total rather than its own zero.
|
|
116
|
+
const chart = input.ledger.chart;
|
|
117
|
+
const accounts = [];
|
|
118
|
+
for (const account of chart?.list() ?? []) {
|
|
119
|
+
const detail = input.ledger.accountBalance(account.code);
|
|
120
|
+
const isLeaf = account.children.length === 0;
|
|
121
|
+
const subtreePostings = isLeaf
|
|
122
|
+
? detail.postingCount
|
|
123
|
+
: chart
|
|
124
|
+
.subtree(account.code)
|
|
125
|
+
.reduce((sum, node) => sum + input.ledger.accountBalance(node.code).postingCount, 0);
|
|
126
|
+
if (subtreePostings === 0)
|
|
127
|
+
continue;
|
|
128
|
+
const balance = isLeaf
|
|
129
|
+
? detail.natural
|
|
130
|
+
: rolledUpNatural(input.ledger, chart, account.code, account.type);
|
|
131
|
+
accounts.push({
|
|
132
|
+
code: account.code,
|
|
133
|
+
name: account.name,
|
|
134
|
+
type: account.type,
|
|
135
|
+
depth: account.depth,
|
|
136
|
+
balance: amountOf(balance),
|
|
137
|
+
postings: subtreePostings,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const postings = [];
|
|
141
|
+
for (const entry of input.ledger.chronological()) {
|
|
142
|
+
for (const posting of entry.postings) {
|
|
143
|
+
postings.push({
|
|
144
|
+
entryId: entry.id,
|
|
145
|
+
date: entry.date,
|
|
146
|
+
narration: entry.narration,
|
|
147
|
+
account: posting.account,
|
|
148
|
+
amount: amountOf(posting.amount),
|
|
149
|
+
contra: [
|
|
150
|
+
...new Set(entry.postings.filter((p) => p.account !== posting.account).map((p) => p.account)),
|
|
151
|
+
].sort(),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
generatedFor: input.generatedFor ?? "tallyd",
|
|
157
|
+
account: input.account,
|
|
158
|
+
accountName: input.ledger.chart?.find(input.account)?.name ?? input.account,
|
|
159
|
+
currency: currency.code,
|
|
160
|
+
currencySymbol: currency.symbol,
|
|
161
|
+
exponent: currency.exponent,
|
|
162
|
+
period: { from: from === "" ? "—" : from, to: to === "" ? "—" : to },
|
|
163
|
+
matched: input.result.matched.map((match, index) => matchView(match, index, "m")),
|
|
164
|
+
suggested: input.result.suggested.map((match, index) => matchView(match, index, "s")),
|
|
165
|
+
unmatchedBook: input.result.unmatchedBook.map(bookLineView),
|
|
166
|
+
unmatchedStatement: input.result.unmatchedStatement.map(statementLineView),
|
|
167
|
+
bankClosingMinor: Number(input.bankClosingBalance.minorUnits),
|
|
168
|
+
bookClosingMinor: Number(input.bookClosingBalance.minorUnits),
|
|
169
|
+
accounts,
|
|
170
|
+
postings,
|
|
171
|
+
cashPosition: cashPosition(input.books),
|
|
172
|
+
confidence: CONFIDENCE_ORDER.map((label) => ({ label, count: counts.get(label) ?? 0 })),
|
|
173
|
+
trialBalanceBalanced: trialBalance(input.ledger).balanced,
|
|
174
|
+
statementFormat: input.statementFormat,
|
|
175
|
+
implied: (input.implied ?? []).map((proposal) => ({
|
|
176
|
+
lineId: proposal.line.id,
|
|
177
|
+
date: proposal.line.date,
|
|
178
|
+
description: proposal.line.description,
|
|
179
|
+
amount: amountOf(proposal.line.amount),
|
|
180
|
+
outcome: proposal.outcome,
|
|
181
|
+
account: proposal.account,
|
|
182
|
+
accountName: proposal.account === null
|
|
183
|
+
? null
|
|
184
|
+
: (input.ledger.chart?.find(proposal.account)?.name ?? proposal.account),
|
|
185
|
+
rule: proposal.rule?.id ?? null,
|
|
186
|
+
narration: proposal.entry?.narration ?? proposal.line.description,
|
|
187
|
+
reason: proposal.reason,
|
|
188
|
+
})),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembling the page.
|
|
3
|
+
*
|
|
4
|
+
* One file, no network. Styles and script are inlined, the data is embedded as
|
|
5
|
+
* JSON, and nothing is fetched — because the person who most needs to look at a
|
|
6
|
+
* reconciliation is often the one who cannot install anything, and a dashboard
|
|
7
|
+
* that needs a CDN is a dashboard that fails on a train.
|
|
8
|
+
*
|
|
9
|
+
* Escaping is done here rather than trusted anywhere: bank descriptors are
|
|
10
|
+
* attacker-adjacent text in the sense that matters, which is that they contain
|
|
11
|
+
* whatever the payer typed into a reference field.
|
|
12
|
+
*/
|
|
13
|
+
import type { DashboardData } from "./model.js";
|
|
14
|
+
export declare function escapeHtml(value: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* JSON safe to sit inside a `<script>` element.
|
|
17
|
+
*
|
|
18
|
+
* `</script>` anywhere in the data would close the block early — a narration
|
|
19
|
+
* reading "see </script> for details" is unlikely but the failure is total, and
|
|
20
|
+
* the fix is three replacements.
|
|
21
|
+
*/
|
|
22
|
+
export declare function embedJson(value: unknown): string;
|
|
23
|
+
export declare function renderDashboard(data: DashboardData): string;
|