verifyhash 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 +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- package/trustledger/valueproof.js +350 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// TrustLedger — report.js
|
|
4
|
+
//
|
|
5
|
+
// T-22.4: the audit-ready reconciliation PACKET.
|
|
6
|
+
//
|
|
7
|
+
// This is the demoable core value of the whole product: a broker hands the tool
|
|
8
|
+
// their three real monthly files and watches the three numbers that must legally
|
|
9
|
+
// agree tie out (or not), then files the dated packet this module emits as the
|
|
10
|
+
// evidence of the reconciliation.
|
|
11
|
+
//
|
|
12
|
+
// This module is the PRESENTATION layer over the deterministic pipeline:
|
|
13
|
+
//
|
|
14
|
+
// ingest.js -> parse the three files into NormalizedRecord[]
|
|
15
|
+
// match.js -> pair bank<->book lines (exact + fuzzy + split)
|
|
16
|
+
// reconcile.js -> the THREE-balance check + classified exception list
|
|
17
|
+
// report.js -> render it all into a dated, audit-ready packet
|
|
18
|
+
//
|
|
19
|
+
// We emit the packet as **HTML + CSV**:
|
|
20
|
+
// * HTML — a single self-contained, print-to-PDF-ready document a broker can
|
|
21
|
+
// open in any browser and "Print -> Save as PDF" to file with their
|
|
22
|
+
// records. No binary PDF/xlsx library is pulled in (those heavy deps
|
|
23
|
+
// are explicitly deferred to v2): HTML prints to PDF and CSV opens in
|
|
24
|
+
// any spreadsheet, with zero new dependencies and zero install risk.
|
|
25
|
+
// * CSV — the exception list as a spreadsheet, so a bookkeeper can work the
|
|
26
|
+
// findings line by line.
|
|
27
|
+
//
|
|
28
|
+
// DETERMINISTIC. Given the same inputs (and an explicit report date) this module
|
|
29
|
+
// returns byte-identical output. It takes NO clock of its own — the caller MUST
|
|
30
|
+
// pass `reportDate` (a "YYYY-MM-DD" string). A reconciliation a broker signs and
|
|
31
|
+
// an auditor reads must be reproducible to the byte; a hidden `new Date()` would
|
|
32
|
+
// make the same inputs produce a different file every run, which is the opposite
|
|
33
|
+
// of audit-defensible. The CLI passes today's date explicitly so the human sees
|
|
34
|
+
// a dated packet while the function itself stays pure.
|
|
35
|
+
//
|
|
36
|
+
// HONEST POSTURE: the packet leads with a prominent disclaimer that the tool
|
|
37
|
+
// AIDS reconciliation but the broker remains the responsible custodian. It does
|
|
38
|
+
// not, and cannot, replace the broker's legal duty or a CPA's review.
|
|
39
|
+
|
|
40
|
+
const ingest = require("./ingest");
|
|
41
|
+
const match = require("./match");
|
|
42
|
+
const reconcile = require("./reconcile");
|
|
43
|
+
const policyMod = require("./policy");
|
|
44
|
+
const close = require("./close");
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Errors
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
class ReportError extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "ReportError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Money formatting (integer cents -> "$1,234.56", signed)
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function fmtCents(cents) {
|
|
62
|
+
if (!Number.isInteger(cents)) {
|
|
63
|
+
throw new ReportError("fmtCents requires integer cents");
|
|
64
|
+
}
|
|
65
|
+
const neg = cents < 0;
|
|
66
|
+
const abs = Math.abs(cents);
|
|
67
|
+
const dollars = Math.floor(abs / 100);
|
|
68
|
+
const rem = abs % 100;
|
|
69
|
+
// Group the integer dollars with commas, deterministically.
|
|
70
|
+
const grouped = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
71
|
+
const body = `$${grouped}.${String(rem).padStart(2, "0")}`;
|
|
72
|
+
return neg ? `-${body}` : body;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// HTML-escape a string for safe interpolation into the document body.
|
|
76
|
+
function esc(s) {
|
|
77
|
+
return String(s == null ? "" : s)
|
|
78
|
+
.replace(/&/g, "&")
|
|
79
|
+
.replace(/</g, "<")
|
|
80
|
+
.replace(/>/g, ">")
|
|
81
|
+
.replace(/"/g, """)
|
|
82
|
+
.replace(/'/g, "'");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// CSV-escape one field per RFC-4180: wrap in quotes and double any quote when
|
|
86
|
+
// the field contains a comma, quote, CR, or LF. Deterministic.
|
|
87
|
+
function csvField(s) {
|
|
88
|
+
const v = String(s == null ? "" : s);
|
|
89
|
+
if (/[",\r\n]/.test(v)) {
|
|
90
|
+
return `"${v.replace(/"/g, '""')}"`;
|
|
91
|
+
}
|
|
92
|
+
return v;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A short, human label for an amount sign direction.
|
|
96
|
+
function direction(cents) {
|
|
97
|
+
if (cents > 0) return "in";
|
|
98
|
+
if (cents < 0) return "out";
|
|
99
|
+
return "zero";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// The disclaimer (single source of truth, reused in HTML + CSV).
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
const DISCLAIMER_LINES = Object.freeze([
|
|
107
|
+
"This reconciliation packet is a TOOL THAT AIDS reconciliation. The broker " +
|
|
108
|
+
"remains the legal trust-account custodian and is solely responsible for " +
|
|
109
|
+
"the accuracy and completeness of the trust-account records and for " +
|
|
110
|
+
"compliance with all applicable state trust-fund rules.",
|
|
111
|
+
"TrustLedger reconciles the files it is given; it cannot see transactions " +
|
|
112
|
+
"absent from those files, cannot judge whether a transaction is itself " +
|
|
113
|
+
"proper, and does not constitute legal, accounting, or audit advice.",
|
|
114
|
+
"Review every exception below and have a qualified CPA or your state " +
|
|
115
|
+
"regulator review this packet before relying on it.",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const DISCLAIMER_TEXT = DISCLAIMER_LINES.join(" ");
|
|
119
|
+
|
|
120
|
+
// An EXTRA disclaimer sentence appended ONLY when a per-state policy governed the
|
|
121
|
+
// run. It states plainly that the PASS/FAIL verdict reflects the SELECTED policy's
|
|
122
|
+
// reviewed severities — and that the policy itself is still NOT legal advice and
|
|
123
|
+
// must be reviewed by a CPA/counsel (cross-references the P-5 #1/#2 disclaimers).
|
|
124
|
+
// Templated with the governing policy's state label so the packet names WHAT
|
|
125
|
+
// governed it.
|
|
126
|
+
function policyDisclaimerLine(stateLabel) {
|
|
127
|
+
return (
|
|
128
|
+
`This run's PASS/FAIL verdict reflects the SELECTED trust-rule policy ` +
|
|
129
|
+
`"${stateLabel}", which overrides the built-in baseline severities. ` +
|
|
130
|
+
"Selecting a policy does NOT make this packet legal advice: the policy " +
|
|
131
|
+
"itself — its per-state severities and statute citations — is a DRAFT that " +
|
|
132
|
+
"a qualified CPA or legal counsel must review and adopt for your " +
|
|
133
|
+
"jurisdiction before you rely on this verdict."
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Build the deterministic packet MODEL from the three normalized record sets.
|
|
139
|
+
//
|
|
140
|
+
// This is the pure heart: ingest is done by the caller (so the caller controls
|
|
141
|
+
// file I/O), and this runs match + reconcile and assembles every number/row the
|
|
142
|
+
// renderers print. Returned model is JSON-serializable and order-stable.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
// `priorClose` (optional): a validated period-close artifact (close.js) this run
|
|
146
|
+
// chains FORWARD from. When present, buildPacket runs close.checkContinuity over
|
|
147
|
+
// the prior ending vs. THIS period's opening and, on a non-zero gap, raises a
|
|
148
|
+
// CONTINUITY_BREAK exception that flows through the SAME applyPolicy / counts /
|
|
149
|
+
// verdict / report path as every other exception (and is policy-overridable).
|
|
150
|
+
// `emitClosePath` (optional): the caller-named path the emitted close was/will be
|
|
151
|
+
// written to, referenced in the output so the artifact is traceable. Neither is
|
|
152
|
+
// supplied unless `--prior-close`/`--emit-close` is used; without `priorClose` no
|
|
153
|
+
// continuity exception is ever raised and the model is byte-for-byte unchanged.
|
|
154
|
+
function buildPacket({
|
|
155
|
+
bank,
|
|
156
|
+
book,
|
|
157
|
+
rentroll,
|
|
158
|
+
reportDate,
|
|
159
|
+
period,
|
|
160
|
+
opening,
|
|
161
|
+
toleranceCents,
|
|
162
|
+
policy,
|
|
163
|
+
priorClose,
|
|
164
|
+
emitClosePath,
|
|
165
|
+
}) {
|
|
166
|
+
if (!Array.isArray(bank)) throw new ReportError("bank must be a NormalizedRecord[]");
|
|
167
|
+
if (!Array.isArray(book)) throw new ReportError("book must be a NormalizedRecord[]");
|
|
168
|
+
if (!Array.isArray(rentroll)) throw new ReportError("rentroll must be a NormalizedRecord[]");
|
|
169
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(reportDate || ""))) {
|
|
170
|
+
throw new ReportError('reportDate must be a "YYYY-MM-DD" string');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 1) Pair bank<->book lines.
|
|
174
|
+
const matchResult = match.reconcile(bank, book);
|
|
175
|
+
|
|
176
|
+
// Resolve the reconcile tolerance. A per-state policy MAY declare its own
|
|
177
|
+
// `toleranceCents` (some statutes permit a de-minimis rounding band; most
|
|
178
|
+
// require an exact tie, i.e. 0). PRECEDENCE: a policy's toleranceCents, when
|
|
179
|
+
// present, OVERRIDES the CLI/default tolerance — the broker's reviewed
|
|
180
|
+
// jurisdiction rule, not an ad-hoc command-line value, governs how close the
|
|
181
|
+
// three balances must come. This matters because the tolerance feeds tiesOut
|
|
182
|
+
// and the bank/book + book/sub mismatch exceptions, which in turn drive the
|
|
183
|
+
// PASS/FAIL verdict; computing them under a tolerance DIFFERENT from the
|
|
184
|
+
// policy the packet names would be a silent discrepancy. Without a policy
|
|
185
|
+
// toleranceCents the CLI/default value is used unchanged (byte-for-byte the
|
|
186
|
+
// prior behaviour).
|
|
187
|
+
const cliTolerance = Number.isInteger(toleranceCents) ? toleranceCents : 0;
|
|
188
|
+
const effectiveTolerance =
|
|
189
|
+
policy && Number.isInteger(policy.toleranceCents)
|
|
190
|
+
? policy.toleranceCents
|
|
191
|
+
: cliTolerance;
|
|
192
|
+
|
|
193
|
+
// Normalize the opening this run actually used (integer cents both legs), so it
|
|
194
|
+
// is the SAME value fed to reconcile AND checked for continuity against the
|
|
195
|
+
// prior close — no chance of checking a different opening than was computed on.
|
|
196
|
+
const usedOpening = {
|
|
197
|
+
bank: opening && Number.isInteger(opening.bank) ? opening.bank : 0,
|
|
198
|
+
book: opening && Number.isInteger(opening.book) ? opening.book : 0,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// 2) The three-balance check + classified exceptions.
|
|
202
|
+
const rawRec = reconcile.reconcile(bank, book, rentroll, {
|
|
203
|
+
matchResult,
|
|
204
|
+
opening: usedOpening,
|
|
205
|
+
toleranceCents: effectiveTolerance,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// 2a) Roll-forward continuity. When this run chains from a prior period's close,
|
|
209
|
+
// CHECK that this period's opening equals the prior period's signed ending,
|
|
210
|
+
// penny-exact. A non-zero gap is a CONTINUITY_BREAK exception INJECTED into
|
|
211
|
+
// the reconcile result BEFORE the policy is applied, so it flows through the
|
|
212
|
+
// SAME severity override, counts, verdict, exit code, and report path as
|
|
213
|
+
// every native exception. Without a priorClose this is skipped entirely and
|
|
214
|
+
// the result is byte-for-byte unchanged. The prior close is a validated
|
|
215
|
+
// close artifact (the CLI reads it via close.readClose); we re-validate here
|
|
216
|
+
// so buildPacket is safe to call directly.
|
|
217
|
+
let continuity = null;
|
|
218
|
+
let priorMeta = null;
|
|
219
|
+
if (priorClose != null) {
|
|
220
|
+
const prior = close.readClose(priorClose);
|
|
221
|
+
priorMeta = {
|
|
222
|
+
period: prior.period,
|
|
223
|
+
reportDate: prior.reportDate,
|
|
224
|
+
ending: { bank: prior.ending.bank, book: prior.ending.book },
|
|
225
|
+
};
|
|
226
|
+
const cont = close.checkContinuity(prior, usedOpening);
|
|
227
|
+
continuity = {
|
|
228
|
+
checked: true,
|
|
229
|
+
ok: cont.ok === true,
|
|
230
|
+
bankGap: Number.isInteger(cont.bankGap) ? cont.bankGap : 0,
|
|
231
|
+
bookGap: Number.isInteger(cont.bookGap) ? cont.bookGap : 0,
|
|
232
|
+
priorPeriod: prior.period,
|
|
233
|
+
priorReportDate: prior.reportDate,
|
|
234
|
+
priorEnding: { bank: prior.ending.bank, book: prior.ending.book },
|
|
235
|
+
thisOpening: { bank: usedOpening.bank, book: usedOpening.book },
|
|
236
|
+
};
|
|
237
|
+
const contEx = reconcile.buildContinuityException(cont, prior.period);
|
|
238
|
+
if (contEx) {
|
|
239
|
+
rawRec.exceptions.push(contEx);
|
|
240
|
+
// Re-sort so the freshly-injected exception lands in the canonical
|
|
241
|
+
// severity-first order, exactly as a natively-detected one would. (When a
|
|
242
|
+
// policy is present, applyPolicy re-sorts again under the overridden
|
|
243
|
+
// severities; this keeps the no-policy path correctly ordered too.)
|
|
244
|
+
rawRec.exceptions.sort(reconcile.compareExceptions);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 2b) Apply the reviewed per-state policy (if any) over the reconcile result
|
|
249
|
+
// BEFORE anything reads severities. applyPolicy is PURE and, with no
|
|
250
|
+
// policy, returns the SAME object reference — so the no-policy path is
|
|
251
|
+
// byte-for-byte the built-in baseline. With a policy, the overridden
|
|
252
|
+
// severities (and citations) flow into the counts, the PASS/FAIL verdict,
|
|
253
|
+
// the exit code, and the rendered report uniformly.
|
|
254
|
+
const rec = policyMod.applyPolicy(rawRec, policy || null);
|
|
255
|
+
const policyMeta = policy
|
|
256
|
+
? {
|
|
257
|
+
state: policy.state,
|
|
258
|
+
// The reconcile tolerance this policy imposed, when it declared one (else
|
|
259
|
+
// null — the CLI/default tolerance governed). Surfaced so the packet
|
|
260
|
+
// names the actual band the verdict was computed under, never implying a
|
|
261
|
+
// stricter/looser tie than was applied.
|
|
262
|
+
toleranceCents: Number.isInteger(policy.toleranceCents)
|
|
263
|
+
? policy.toleranceCents
|
|
264
|
+
: null,
|
|
265
|
+
// Surface only the overrides that actually changed a present exception,
|
|
266
|
+
// each with its citation when the policy supplies one, so the report can
|
|
267
|
+
// ground the verdict in the rule it rests on.
|
|
268
|
+
overrides: Object.keys(policy.severities)
|
|
269
|
+
.sort()
|
|
270
|
+
.map((type) => ({
|
|
271
|
+
type,
|
|
272
|
+
severity: policy.severities[type],
|
|
273
|
+
citation:
|
|
274
|
+
policy.citations && policy.citations[type] != null
|
|
275
|
+
? policy.citations[type]
|
|
276
|
+
: null,
|
|
277
|
+
})),
|
|
278
|
+
}
|
|
279
|
+
: null;
|
|
280
|
+
|
|
281
|
+
// 3) Per-beneficiary sub-ledger balances (sorted by party for stable output).
|
|
282
|
+
const subBalances = reconcile.tenantBalances(rentroll);
|
|
283
|
+
const beneficiaries = Object.keys(subBalances)
|
|
284
|
+
.sort()
|
|
285
|
+
.map((party) => ({ party, balance: subBalances[party] }));
|
|
286
|
+
|
|
287
|
+
// 4) Exception rows flattened for the CSV/table (records summarized, not raw).
|
|
288
|
+
const exceptions = rec.exceptions.map((e) => ({
|
|
289
|
+
type: e.type,
|
|
290
|
+
severity: e.severity,
|
|
291
|
+
// A policy override may attach a statute/rule citation to the exception; the
|
|
292
|
+
// renderers surface it so the verdict is grounded in the rule. null when the
|
|
293
|
+
// governing policy (or the baseline) supplies none.
|
|
294
|
+
citation: e.citation != null ? e.citation : null,
|
|
295
|
+
amount: e.amount,
|
|
296
|
+
direction: direction(e.amount),
|
|
297
|
+
label: e.label,
|
|
298
|
+
detail: e.detail,
|
|
299
|
+
recordCount: e.records.length,
|
|
300
|
+
records: e.records.map((r) => ({
|
|
301
|
+
date: r.date,
|
|
302
|
+
amount: r.amount,
|
|
303
|
+
memo: r.memo || "",
|
|
304
|
+
party: r.party || "",
|
|
305
|
+
source: r.source || "",
|
|
306
|
+
})),
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
// A small severity roll-up so the summary line / header can show counts.
|
|
310
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
311
|
+
for (const e of exceptions) {
|
|
312
|
+
if (counts[e.severity] === undefined) counts[e.severity] = 0;
|
|
313
|
+
counts[e.severity] += 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// T-43.2: the ROOT-CAUSE triage over THIS run's classified findings. Computed
|
|
317
|
+
// by the pure reconcile-core lens (reconcile.triage) over the SAME post-policy
|
|
318
|
+
// exceptions the verdict/counts read, so the packet, --json, and the CLI verdict
|
|
319
|
+
// line all surface ONE consistent diagnosis: is this a genuine OUT-OF-TRUST
|
|
320
|
+
// finding (the product's core value) or did the tool merely fail to fully
|
|
321
|
+
// reconcile/classify the data (a data-shape gap to fix and re-run)? PURELY
|
|
322
|
+
// additive — it never changes the PASS/FAIL verdict, the counts, or the exit
|
|
323
|
+
// code; it only makes the existing FAIL legible at first contact.
|
|
324
|
+
const triage = reconcile.triage({ exceptions: rec.exceptions });
|
|
325
|
+
|
|
326
|
+
const matchSummary = {
|
|
327
|
+
matched: matchResult.matched.length,
|
|
328
|
+
unmatchedBank: matchResult.unmatchedA.length,
|
|
329
|
+
unmatchedBook: matchResult.unmatchedB.length,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// PASS only when the three balances tie out AND there is no ERROR-severity
|
|
333
|
+
// exception. An out-of-trust finding (commingling, unsegregated deposit) is a
|
|
334
|
+
// FAIL even if the arithmetic happens to net to zero — the gate must protect
|
|
335
|
+
// the beneficiaries, not just the totals. This counts.error gate is
|
|
336
|
+
// exception-TYPE-agnostic: it sees only the post-policy severity, so a policy
|
|
337
|
+
// that escalates a normally-WARNING finding (e.g. an `ambiguous_deposit` whose
|
|
338
|
+
// beneficiary type a state requires be established) to ERROR flips this verdict
|
|
339
|
+
// to FAIL on the SAME files, while with no policy that finding stays a WARNING
|
|
340
|
+
// and does NOT by itself fail a firm with otherwise-clean, tying-out books.
|
|
341
|
+
const pass = rec.tiesOut && counts.error === 0;
|
|
342
|
+
|
|
343
|
+
// Disclaimer: the three baseline lines verbatim (so the no-policy packet is
|
|
344
|
+
// byte-for-byte unchanged), plus ONE extra line naming the governing policy
|
|
345
|
+
// whenever one was selected.
|
|
346
|
+
const disclaimer = DISCLAIMER_LINES.slice();
|
|
347
|
+
if (policyMeta) disclaimer.push(policyDisclaimerLine(policyMeta.state));
|
|
348
|
+
|
|
349
|
+
// The opening balances this period rolled FORWARD from (the prior period's
|
|
350
|
+
// asserted ending, or 0/0 for the first period). Surfaced on the model so the
|
|
351
|
+
// period-close artifact (close.js) can derive a continuity-checkable opening
|
|
352
|
+
// PURELY from the packet, and so the packet is self-describing about what it
|
|
353
|
+
// started from. Same untrusted-hint posture as every other input.
|
|
354
|
+
const openingBalances = {
|
|
355
|
+
bank: opening && Number.isInteger(opening.bank) ? opening.bank : 0,
|
|
356
|
+
book: opening && Number.isInteger(opening.book) ? opening.book : 0,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
schema: "trustledger.reconciliation-packet/v1",
|
|
361
|
+
reportDate,
|
|
362
|
+
period: period || null,
|
|
363
|
+
opening: openingBalances,
|
|
364
|
+
// The prior period this run chained from + the roll-forward check (null when
|
|
365
|
+
// no --prior-close was supplied). Names the prior period and shows the
|
|
366
|
+
// roll-forward (prior ending -> this opening) so an auditor sees the chain.
|
|
367
|
+
continuity,
|
|
368
|
+
priorClose: priorMeta,
|
|
369
|
+
// The caller-named path THIS run's close was/will be emitted to (null when no
|
|
370
|
+
// --emit-close). Referenced so the emitted artifact is traceable from the
|
|
371
|
+
// packet; the actual write happens in the CLI, only to this caller-named path.
|
|
372
|
+
emitClose: emitClosePath == null ? null : String(emitClosePath),
|
|
373
|
+
// The policy (state label + the applied overrides/citations) that governed
|
|
374
|
+
// this run, or null for the built-in baseline. Names WHICH policy decided the
|
|
375
|
+
// verdict so the report and --json are self-describing.
|
|
376
|
+
policy: policyMeta,
|
|
377
|
+
disclaimer,
|
|
378
|
+
pass,
|
|
379
|
+
tiesOut: rec.tiesOut,
|
|
380
|
+
balances: rec.balances,
|
|
381
|
+
counts,
|
|
382
|
+
// The root-cause triage (T-43.2): the headline + per-class roll-up that names
|
|
383
|
+
// the make-or-break thing to fix first. Additive: every existing field is
|
|
384
|
+
// unchanged, so a consumer that ignores `triage` is byte-for-byte unaffected.
|
|
385
|
+
triage,
|
|
386
|
+
matchSummary,
|
|
387
|
+
beneficiaries,
|
|
388
|
+
exceptions,
|
|
389
|
+
inputs: {
|
|
390
|
+
bankRecords: bank.length,
|
|
391
|
+
bookRecords: book.length,
|
|
392
|
+
rentrollRecords: rentroll.length,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// The one-line PASS/FAIL summary (used by the CLI for the human + as the basis
|
|
399
|
+
// of the exit code). Deterministic string for a given model.
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
function summaryLine(model) {
|
|
403
|
+
const b = model.balances;
|
|
404
|
+
const verdict = model.pass ? "PASS" : "FAIL";
|
|
405
|
+
const tie = model.tiesOut ? "tie out" : "DO NOT tie out";
|
|
406
|
+
return (
|
|
407
|
+
`${verdict}: three-way reconciliation ${tie} ` +
|
|
408
|
+
`(bank-adjusted ${fmtCents(b.adjustedBank)}, book ${fmtCents(b.book)}, ` +
|
|
409
|
+
`sub-ledger ${fmtCents(b.subledger)}); ` +
|
|
410
|
+
`${model.exceptions.length} exception(s) ` +
|
|
411
|
+
`[${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info]`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// T-43.2: the ROOT-CAUSE triage headline, printed by the CLI as a SECOND line
|
|
416
|
+
// AFTER summaryLine (which stays byte-for-byte the existing first line). It names
|
|
417
|
+
// the make-or-break distinction at first contact — a genuine OUT-OF-TRUST finding
|
|
418
|
+
// vs. a data-shape gap the broker fixes and re-runs vs. nothing to fix. The model
|
|
419
|
+
// already carries the computed triage object (buildPacket -> reconcile.triage), so
|
|
420
|
+
// this is a pure read of `model.triage.headline`; it never re-derives or changes
|
|
421
|
+
// the verdict. Falls back to recomputing from the exceptions if an older model
|
|
422
|
+
// (pre-triage) is passed, so the helper is safe to call on any packet model.
|
|
423
|
+
function triageHeadline(model) {
|
|
424
|
+
const t =
|
|
425
|
+
model && model.triage && typeof model.triage.headline === "string"
|
|
426
|
+
? model.triage
|
|
427
|
+
: reconcile.triage({ exceptions: (model && model.exceptions) || [] });
|
|
428
|
+
return `Triage: ${t.headline}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// HTML renderer — single self-contained, print-to-PDF-ready document.
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
function sevBadge(sev) {
|
|
436
|
+
const color =
|
|
437
|
+
sev === "error" ? "#b00020" : sev === "warning" ? "#8a6d00" : "#0a6b2f";
|
|
438
|
+
return `<span class="sev sev-${esc(sev)}" style="color:${color}">${esc(sev.toUpperCase())}</span>`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// T-43.2: the "fix first" triage callout + per-class roll-up. Rendered right under
|
|
442
|
+
// the verdict so a broker reads the make-or-break distinction at first contact: a
|
|
443
|
+
// genuine OUT-OF-TRUST finding (the product's core value) vs. a data-shape gap to
|
|
444
|
+
// fix and re-run vs. only review/timing notes. The callout's tone follows the top
|
|
445
|
+
// class — RED when out of trust, AMBER when the only blockers are data gaps (and
|
|
446
|
+
// it states plainly this is NOT an out-of-trust finding), GREEN otherwise. The
|
|
447
|
+
// roll-up table lists each present class (most-urgent first, the SAME CLASS_RANK
|
|
448
|
+
// order reconcile.triage emits) with its finding count and total dollar impact.
|
|
449
|
+
// EVERY attacker-controllable value (the headline, the class labels) is HTML-
|
|
450
|
+
// escaped via esc(). Reads model.triage verbatim — it never re-derives a verdict.
|
|
451
|
+
function renderTriageSection(model) {
|
|
452
|
+
const t = model.triage;
|
|
453
|
+
if (!t) return ""; // older/forged model with no triage: render nothing (additive)
|
|
454
|
+
// Tone: out_of_trust => fail (red), data_completeness-only => warn (amber),
|
|
455
|
+
// else (review/timing/none) => clean (green). Mirrors triage.headline exactly.
|
|
456
|
+
const tone = t.outOfTrust ? "fail" : t.dataIncomplete ? "warn" : "clean";
|
|
457
|
+
const rows = (t.classes || [])
|
|
458
|
+
.map(
|
|
459
|
+
(c) =>
|
|
460
|
+
`<tr><td>${esc(c.label)}</td>` +
|
|
461
|
+
`<td class="num">${esc(String(c.count))}</td>` +
|
|
462
|
+
`<td class="num">${esc(fmtCents(c.absImpact))}</td></tr>`
|
|
463
|
+
)
|
|
464
|
+
.join("\n");
|
|
465
|
+
const rollup = rows
|
|
466
|
+
? `<table>
|
|
467
|
+
<thead><tr><th>Fix first →</th><th class="num">Findings</th><th class="num">Impact</th></tr></thead>
|
|
468
|
+
<tbody>
|
|
469
|
+
${rows}
|
|
470
|
+
</tbody>
|
|
471
|
+
</table>`
|
|
472
|
+
: "";
|
|
473
|
+
return `
|
|
474
|
+
<div class="triage triage-${tone}">
|
|
475
|
+
<strong>Fix first.</strong> ${esc(t.headline)}
|
|
476
|
+
</div>
|
|
477
|
+
${rollup}
|
|
478
|
+
`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// The roll-forward continuity section: only rendered when this run chained from a
|
|
482
|
+
// prior period's close (--prior-close). Names the prior period and shows the
|
|
483
|
+
// roll-forward (prior ending -> this opening) for both the bank and book legs so
|
|
484
|
+
// an auditor can walk the chain. When the gap is non-zero the matching
|
|
485
|
+
// CONTINUITY_BREAK row appears in the Exceptions table; this section shows the
|
|
486
|
+
// arithmetic that produced it.
|
|
487
|
+
function renderContinuitySection(model) {
|
|
488
|
+
const c = model.continuity;
|
|
489
|
+
if (!c || !c.checked) return "";
|
|
490
|
+
const priorName =
|
|
491
|
+
c.priorPeriod == null ? c.priorReportDate : `${c.priorPeriod} (${c.priorReportDate})`;
|
|
492
|
+
const okLine = c.ok
|
|
493
|
+
? `<p class="none">The roll-forward is clean: this period opened exactly where
|
|
494
|
+
the prior period closed.</p>`
|
|
495
|
+
: `<p style="color:#b00020"><strong>Roll-forward break:</strong> the bank
|
|
496
|
+
opening differs from the prior ending by
|
|
497
|
+
<strong>${esc(fmtCents(c.bankGap))}</strong> and the book opening differs by
|
|
498
|
+
<strong>${esc(fmtCents(c.bookGap))}</strong>. See the CONTINUITY_BREAK exception
|
|
499
|
+
below.</p>`;
|
|
500
|
+
const rows = [
|
|
501
|
+
["Bank", c.priorEnding.bank, c.thisOpening.bank, c.bankGap],
|
|
502
|
+
["Book", c.priorEnding.book, c.thisOpening.book, c.bookGap],
|
|
503
|
+
]
|
|
504
|
+
.map(
|
|
505
|
+
([leg, end, open, gap]) =>
|
|
506
|
+
`<tr><td>${esc(leg)}</td><td class="num">${esc(fmtCents(end))}</td>` +
|
|
507
|
+
`<td class="num">${esc(fmtCents(open))}</td><td class="num">${esc(
|
|
508
|
+
fmtCents(gap)
|
|
509
|
+
)}</td></tr>`
|
|
510
|
+
)
|
|
511
|
+
.join("\n");
|
|
512
|
+
const emitNote = model.emitClose
|
|
513
|
+
? `<p class="meta">This run's period close was written to
|
|
514
|
+
<strong>${esc(model.emitClose)}</strong>.</p>`
|
|
515
|
+
: "";
|
|
516
|
+
return `
|
|
517
|
+
<h2>Period continuity (roll-forward)</h2>
|
|
518
|
+
<p>This reconciliation chained forward from <strong>${esc(priorName)}</strong>.
|
|
519
|
+
The trust chain requires that this period's OPENING balances equal the prior
|
|
520
|
+
period's signed ENDING balances, to the penny.</p>
|
|
521
|
+
${okLine}
|
|
522
|
+
<table>
|
|
523
|
+
<thead><tr><th>Leg</th><th class="num">Prior ending</th><th class="num">This opening</th><th class="num">Gap</th></tr></thead>
|
|
524
|
+
<tbody>
|
|
525
|
+
${rows}
|
|
526
|
+
</tbody>
|
|
527
|
+
</table>
|
|
528
|
+
${emitNote}
|
|
529
|
+
`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function renderHTML(model) {
|
|
533
|
+
const b = model.balances;
|
|
534
|
+
const passClass = model.pass ? "pass" : "fail";
|
|
535
|
+
const verdict = model.pass ? "PASS — three-way reconciliation ties out" : "FAIL — see exceptions";
|
|
536
|
+
|
|
537
|
+
const balanceRows = [
|
|
538
|
+
["Bank balance (per statement)", b.bank],
|
|
539
|
+
["Outstanding / in-transit adjustments", b.adjustedBank - b.bank],
|
|
540
|
+
["Adjusted bank balance", b.adjustedBank],
|
|
541
|
+
["Book balance (per ledger)", b.book],
|
|
542
|
+
["Sub-ledger total (sum of beneficiaries)", b.subledger],
|
|
543
|
+
]
|
|
544
|
+
.map(
|
|
545
|
+
([label, cents]) =>
|
|
546
|
+
`<tr><td>${esc(label)}</td><td class="num">${esc(fmtCents(cents))}</td></tr>`
|
|
547
|
+
)
|
|
548
|
+
.join("\n");
|
|
549
|
+
|
|
550
|
+
const benRows = model.beneficiaries
|
|
551
|
+
.map(
|
|
552
|
+
(x) =>
|
|
553
|
+
`<tr><td>${esc(x.party)}</td><td class="num">${esc(fmtCents(x.balance))}</td></tr>`
|
|
554
|
+
)
|
|
555
|
+
.join("\n");
|
|
556
|
+
|
|
557
|
+
const exRows = model.exceptions.length
|
|
558
|
+
? model.exceptions
|
|
559
|
+
.map((e) => {
|
|
560
|
+
const recs = e.records
|
|
561
|
+
.map(
|
|
562
|
+
(r) =>
|
|
563
|
+
`<div class="rec">${esc(r.date)} · ${esc(fmtCents(r.amount))} · ` +
|
|
564
|
+
`${esc(r.party || "—")} · ${esc(r.memo || "—")} ` +
|
|
565
|
+
`<span class="src">[${esc(r.source || "?")}]</span></div>`
|
|
566
|
+
)
|
|
567
|
+
.join("\n");
|
|
568
|
+
const cite = e.citation
|
|
569
|
+
? `<div class="cite"><strong>Policy citation:</strong> ${esc(e.citation)}</div>`
|
|
570
|
+
: "";
|
|
571
|
+
return (
|
|
572
|
+
`<tr>` +
|
|
573
|
+
`<td>${sevBadge(e.severity)}</td>` +
|
|
574
|
+
`<td>${esc(e.label)}</td>` +
|
|
575
|
+
`<td class="num">${esc(fmtCents(e.amount))}</td>` +
|
|
576
|
+
`<td>${esc(e.detail)}${cite}${recs ? `<div class="recs">${recs}</div>` : ""}</td>` +
|
|
577
|
+
`</tr>`
|
|
578
|
+
);
|
|
579
|
+
})
|
|
580
|
+
.join("\n")
|
|
581
|
+
: `<tr><td colspan="4" class="none">No exceptions — every line reconciled.</td></tr>`;
|
|
582
|
+
|
|
583
|
+
const disclaimerHTML = model.disclaimer
|
|
584
|
+
.map((p) => `<p>${esc(p)}</p>`)
|
|
585
|
+
.join("\n");
|
|
586
|
+
|
|
587
|
+
// Governing-policy section: only rendered when a per-state policy was selected.
|
|
588
|
+
// Names the policy and lists each severity override with its citation, so the
|
|
589
|
+
// verdict is auditable back to the rule it rests on.
|
|
590
|
+
let policySection = "";
|
|
591
|
+
if (model.policy) {
|
|
592
|
+
const ovRows = model.policy.overrides
|
|
593
|
+
.map(
|
|
594
|
+
(o) =>
|
|
595
|
+
`<tr><td>${esc(o.type)}</td><td>${sevBadge(o.severity)}</td>` +
|
|
596
|
+
`<td>${o.citation ? esc(o.citation) : '<span class="src">(no citation supplied)</span>'}</td></tr>`
|
|
597
|
+
)
|
|
598
|
+
.join("\n");
|
|
599
|
+
const tolNote =
|
|
600
|
+
model.policy.toleranceCents != null
|
|
601
|
+
? ` The three balances were reconciled under this policy's tolerance of
|
|
602
|
+
<strong>${esc(fmtCents(model.policy.toleranceCents))}</strong> (the band within
|
|
603
|
+
which the balances are treated as tying out).`
|
|
604
|
+
: "";
|
|
605
|
+
policySection = `
|
|
606
|
+
<h2>Governing policy</h2>
|
|
607
|
+
<p>This reconciliation was scored under the per-state trust-rule policy
|
|
608
|
+
<strong>${esc(model.policy.state)}</strong>, which overrides the built-in
|
|
609
|
+
baseline severities as follows. The PASS/FAIL verdict above reflects these
|
|
610
|
+
reviewed severities.${tolNote}</p>
|
|
611
|
+
<table>
|
|
612
|
+
<thead><tr><th>Exception type</th><th>Severity</th><th>Citation</th></tr></thead>
|
|
613
|
+
<tbody>
|
|
614
|
+
${ovRows || '<tr><td colspan="3" class="none">No overrides.</td></tr>'}
|
|
615
|
+
</tbody>
|
|
616
|
+
</table>
|
|
617
|
+
`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return `<!doctype html>
|
|
621
|
+
<html lang="en">
|
|
622
|
+
<head>
|
|
623
|
+
<meta charset="utf-8">
|
|
624
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
625
|
+
<title>TrustLedger Reconciliation Packet — ${esc(model.reportDate)}</title>
|
|
626
|
+
<style>
|
|
627
|
+
:root { color-scheme: light; }
|
|
628
|
+
body { font: 14px/1.5 -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
|
629
|
+
color: #1a1a1a; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
|
|
630
|
+
h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
|
|
631
|
+
h2 { font-size: 1.1rem; border-bottom: 2px solid #eee; padding-bottom: .25rem; margin-top: 2rem; }
|
|
632
|
+
.meta { color: #555; font-size: .9rem; }
|
|
633
|
+
.verdict { display: inline-block; padding: .4rem .8rem; border-radius: 6px;
|
|
634
|
+
font-weight: 700; margin: 1rem 0; }
|
|
635
|
+
.verdict.pass { background: #e6f4ea; color: #0a6b2f; border: 1px solid #0a6b2f; }
|
|
636
|
+
.verdict.fail { background: #fdeaea; color: #b00020; border: 1px solid #b00020; }
|
|
637
|
+
.triage { border-radius: 6px; padding: .6rem .9rem; margin: 1rem 0; font-size: .95rem;
|
|
638
|
+
border-left-width: 5px; border-left-style: solid; }
|
|
639
|
+
.triage-fail { background: #fdeaea; color: #7a0016; border-left-color: #b00020; }
|
|
640
|
+
.triage-warn { background: #fff7ea; color: #6b3a00; border-left-color: #e6b800; }
|
|
641
|
+
.triage-clean { background: #e6f4ea; color: #0a4d22; border-left-color: #0a6b2f; }
|
|
642
|
+
table { border-collapse: collapse; width: 100%; margin: .5rem 0 1rem; }
|
|
643
|
+
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #eee; vertical-align: top; }
|
|
644
|
+
th { background: #fafafa; font-weight: 600; }
|
|
645
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
646
|
+
.sev { font-weight: 700; font-size: .8rem; }
|
|
647
|
+
.recs { margin-top: .35rem; }
|
|
648
|
+
.rec { font-size: .82rem; color: #444; }
|
|
649
|
+
.src { color: #999; }
|
|
650
|
+
.cite { font-size: .82rem; color: #6b3a00; background: #fff7ea; border-left: 3px solid #e6b800;
|
|
651
|
+
padding: .25rem .5rem; margin: .35rem 0; }
|
|
652
|
+
.none { color: #0a6b2f; }
|
|
653
|
+
.disclaimer { background: #fffbe6; border: 1px solid #e6d77a; border-radius: 6px;
|
|
654
|
+
padding: .75rem 1rem; margin: 1rem 0; font-size: .9rem; }
|
|
655
|
+
.disclaimer p { margin: .4rem 0; }
|
|
656
|
+
footer { margin-top: 2rem; color: #888; font-size: .8rem; border-top: 1px solid #eee; padding-top: .5rem; }
|
|
657
|
+
@media print { body { margin: 0; max-width: none; } h2 { page-break-after: avoid; } tr { page-break-inside: avoid; } }
|
|
658
|
+
</style>
|
|
659
|
+
</head>
|
|
660
|
+
<body>
|
|
661
|
+
<h1>TrustLedger — Three-Way Trust-Account Reconciliation</h1>
|
|
662
|
+
<p class="meta">Report date: <strong>${esc(model.reportDate)}</strong>${
|
|
663
|
+
model.period ? ` · Period: <strong>${esc(model.period)}</strong>` : ""
|
|
664
|
+
} · Policy: <strong>${esc(
|
|
665
|
+
model.policy ? model.policy.state : "Built-in baseline severities"
|
|
666
|
+
)}</strong></p>
|
|
667
|
+
|
|
668
|
+
<div class="verdict ${passClass}">${esc(verdict)}</div>
|
|
669
|
+
${renderTriageSection(model)}
|
|
670
|
+
<div class="disclaimer">
|
|
671
|
+
<strong>Disclaimer.</strong>
|
|
672
|
+
${disclaimerHTML}
|
|
673
|
+
</div>
|
|
674
|
+
${policySection}
|
|
675
|
+
${renderContinuitySection(model)}
|
|
676
|
+
<h2>The three balances</h2>
|
|
677
|
+
<p>The trust account is in balance only when the adjusted bank balance, the book
|
|
678
|
+
balance, and the sum of the beneficiary sub-ledgers all agree.</p>
|
|
679
|
+
<table>
|
|
680
|
+
<thead><tr><th>Balance</th><th class="num">Amount</th></tr></thead>
|
|
681
|
+
<tbody>
|
|
682
|
+
${balanceRows}
|
|
683
|
+
</tbody>
|
|
684
|
+
</table>
|
|
685
|
+
<p>Reconciled balance:
|
|
686
|
+
<strong>${esc(b.reconciled == null ? "— (does not tie out)" : fmtCents(b.reconciled))}</strong>.</p>
|
|
687
|
+
|
|
688
|
+
<h2>Beneficiary sub-ledger</h2>
|
|
689
|
+
<table>
|
|
690
|
+
<thead><tr><th>Beneficiary</th><th class="num">Balance</th></tr></thead>
|
|
691
|
+
<tbody>
|
|
692
|
+
${benRows || '<tr><td colspan="2" class="none">No beneficiaries in the rent roll.</td></tr>'}
|
|
693
|
+
</tbody>
|
|
694
|
+
</table>
|
|
695
|
+
|
|
696
|
+
<h2>Exceptions (${model.exceptions.length})</h2>
|
|
697
|
+
<p>${model.counts.error} error · ${model.counts.warning} warning · ${model.counts.info} info.
|
|
698
|
+
Errors mean the trust account may be out of trust and must be resolved before signing.</p>
|
|
699
|
+
<table>
|
|
700
|
+
<thead><tr><th>Severity</th><th>Finding</th><th class="num">Amount</th><th>Detail</th></tr></thead>
|
|
701
|
+
<tbody>
|
|
702
|
+
${exRows}
|
|
703
|
+
</tbody>
|
|
704
|
+
</table>
|
|
705
|
+
|
|
706
|
+
<h2>Match summary</h2>
|
|
707
|
+
<table>
|
|
708
|
+
<tbody>
|
|
709
|
+
<tr><td>Bank/book lines matched</td><td class="num">${esc(String(model.matchSummary.matched))}</td></tr>
|
|
710
|
+
<tr><td>Unmatched bank lines</td><td class="num">${esc(String(model.matchSummary.unmatchedBank))}</td></tr>
|
|
711
|
+
<tr><td>Unmatched book lines</td><td class="num">${esc(String(model.matchSummary.unmatchedBook))}</td></tr>
|
|
712
|
+
<tr><td>Input records (bank / book / rent roll)</td><td class="num">${esc(
|
|
713
|
+
String(model.inputs.bankRecords)
|
|
714
|
+
)} / ${esc(String(model.inputs.bookRecords))} / ${esc(String(model.inputs.rentrollRecords))}</td></tr>
|
|
715
|
+
</tbody>
|
|
716
|
+
</table>
|
|
717
|
+
|
|
718
|
+
<footer>
|
|
719
|
+
Generated by TrustLedger ${esc(model.schema)}. Deterministic for the given inputs and report date.
|
|
720
|
+
To file as PDF, open this file in a browser and choose Print → Save as PDF.
|
|
721
|
+
</footer>
|
|
722
|
+
</body>
|
|
723
|
+
</html>
|
|
724
|
+
`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
// CSV renderer — the exception list as a spreadsheet (one row per record, with
|
|
729
|
+
// summary rows above). Always ends with a trailing newline. Deterministic.
|
|
730
|
+
// ---------------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
function renderExceptionsCSV(model) {
|
|
733
|
+
const lines = [];
|
|
734
|
+
const row = (...fields) => lines.push(fields.map(csvField).join(","));
|
|
735
|
+
|
|
736
|
+
row(
|
|
737
|
+
"severity",
|
|
738
|
+
"type",
|
|
739
|
+
"label",
|
|
740
|
+
"amount_cents",
|
|
741
|
+
"amount",
|
|
742
|
+
"direction",
|
|
743
|
+
"record_date",
|
|
744
|
+
"record_amount",
|
|
745
|
+
"record_party",
|
|
746
|
+
"record_memo",
|
|
747
|
+
"record_source",
|
|
748
|
+
"detail",
|
|
749
|
+
"policy_citation"
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
for (const e of model.exceptions) {
|
|
753
|
+
const cite = e.citation || "";
|
|
754
|
+
if (e.records.length === 0) {
|
|
755
|
+
row(
|
|
756
|
+
e.severity,
|
|
757
|
+
e.type,
|
|
758
|
+
e.label,
|
|
759
|
+
String(e.amount),
|
|
760
|
+
fmtCents(e.amount),
|
|
761
|
+
e.direction,
|
|
762
|
+
"",
|
|
763
|
+
"",
|
|
764
|
+
"",
|
|
765
|
+
"",
|
|
766
|
+
"",
|
|
767
|
+
e.detail,
|
|
768
|
+
cite
|
|
769
|
+
);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
for (const r of e.records) {
|
|
773
|
+
row(
|
|
774
|
+
e.severity,
|
|
775
|
+
e.type,
|
|
776
|
+
e.label,
|
|
777
|
+
String(e.amount),
|
|
778
|
+
fmtCents(e.amount),
|
|
779
|
+
e.direction,
|
|
780
|
+
r.date,
|
|
781
|
+
fmtCents(r.amount),
|
|
782
|
+
r.party,
|
|
783
|
+
r.memo,
|
|
784
|
+
r.source,
|
|
785
|
+
e.detail,
|
|
786
|
+
cite
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return lines.join("\n") + "\n";
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// A second CSV: the three balances + beneficiary sub-ledger, so the broker can
|
|
794
|
+
// open the headline numbers in a spreadsheet too. Deterministic.
|
|
795
|
+
function renderBalancesCSV(model) {
|
|
796
|
+
const b = model.balances;
|
|
797
|
+
const lines = [];
|
|
798
|
+
const row = (...fields) => lines.push(fields.map(csvField).join(","));
|
|
799
|
+
row("section", "label", "amount_cents", "amount");
|
|
800
|
+
// Name the governing policy so the spreadsheet is self-describing too.
|
|
801
|
+
row(
|
|
802
|
+
"policy",
|
|
803
|
+
model.policy ? model.policy.state : "Built-in baseline severities",
|
|
804
|
+
"",
|
|
805
|
+
""
|
|
806
|
+
);
|
|
807
|
+
row("balance", "bank", String(b.bank), fmtCents(b.bank));
|
|
808
|
+
row("balance", "adjusted_bank", String(b.adjustedBank), fmtCents(b.adjustedBank));
|
|
809
|
+
row("balance", "book", String(b.book), fmtCents(b.book));
|
|
810
|
+
row("balance", "subledger", String(b.subledger), fmtCents(b.subledger));
|
|
811
|
+
row(
|
|
812
|
+
"balance",
|
|
813
|
+
"reconciled",
|
|
814
|
+
b.reconciled == null ? "" : String(b.reconciled),
|
|
815
|
+
b.reconciled == null ? "" : fmtCents(b.reconciled)
|
|
816
|
+
);
|
|
817
|
+
for (const x of model.beneficiaries) {
|
|
818
|
+
row("beneficiary", x.party, String(x.balance), fmtCents(x.balance));
|
|
819
|
+
}
|
|
820
|
+
// Roll-forward continuity rows (only when this run chained from a prior close),
|
|
821
|
+
// so the spreadsheet names the prior period and shows the chain too.
|
|
822
|
+
const c = model.continuity;
|
|
823
|
+
if (c && c.checked) {
|
|
824
|
+
const priorLabel =
|
|
825
|
+
c.priorPeriod == null ? c.priorReportDate : `${c.priorPeriod} (${c.priorReportDate})`;
|
|
826
|
+
row("continuity", "prior_period", "", priorLabel);
|
|
827
|
+
row("continuity", "prior_ending_bank", String(c.priorEnding.bank), fmtCents(c.priorEnding.bank));
|
|
828
|
+
row("continuity", "prior_ending_book", String(c.priorEnding.book), fmtCents(c.priorEnding.book));
|
|
829
|
+
row("continuity", "this_opening_bank", String(c.thisOpening.bank), fmtCents(c.thisOpening.bank));
|
|
830
|
+
row("continuity", "this_opening_book", String(c.thisOpening.book), fmtCents(c.thisOpening.book));
|
|
831
|
+
row("continuity", "bank_gap", String(c.bankGap), fmtCents(c.bankGap));
|
|
832
|
+
row("continuity", "book_gap", String(c.bookGap), fmtCents(c.bookGap));
|
|
833
|
+
row("continuity", "roll_forward_ok", c.ok ? "1" : "0", c.ok ? "clean" : "BREAK");
|
|
834
|
+
}
|
|
835
|
+
if (model.emitClose) {
|
|
836
|
+
row("close", "emitted_to", "", model.emitClose);
|
|
837
|
+
}
|
|
838
|
+
return lines.join("\n") + "\n";
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
// Filenames — DATED and deterministic. The packet writes a stable set of files
|
|
843
|
+
// into the caller-chosen directory; the names embed the report date so multiple
|
|
844
|
+
// months can coexist in one directory without collision.
|
|
845
|
+
// ---------------------------------------------------------------------------
|
|
846
|
+
|
|
847
|
+
function packetFilenames(reportDate) {
|
|
848
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(reportDate || ""))) {
|
|
849
|
+
throw new ReportError('reportDate must be a "YYYY-MM-DD" string');
|
|
850
|
+
}
|
|
851
|
+
const stamp = reportDate; // already filesystem-safe (no slashes)
|
|
852
|
+
return {
|
|
853
|
+
html: `reconciliation-${stamp}.html`,
|
|
854
|
+
exceptionsCsv: `reconciliation-${stamp}-exceptions.csv`,
|
|
855
|
+
balancesCsv: `reconciliation-${stamp}-balances.csv`,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Render every artifact for a model. Returns { filename: contents } so the
|
|
860
|
+
// caller does the actual writes (keeping this module I/O-free and testable).
|
|
861
|
+
function renderPacket(model) {
|
|
862
|
+
const names = packetFilenames(model.reportDate);
|
|
863
|
+
return {
|
|
864
|
+
[names.html]: renderHTML(model),
|
|
865
|
+
[names.exceptionsCsv]: renderExceptionsCSV(model),
|
|
866
|
+
[names.balancesCsv]: renderBalancesCSV(model),
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
module.exports = {
|
|
871
|
+
ReportError,
|
|
872
|
+
DISCLAIMER_LINES,
|
|
873
|
+
DISCLAIMER_TEXT,
|
|
874
|
+
buildPacket,
|
|
875
|
+
summaryLine,
|
|
876
|
+
triageHeadline,
|
|
877
|
+
renderHTML,
|
|
878
|
+
renderExceptionsCSV,
|
|
879
|
+
renderBalancesCSV,
|
|
880
|
+
renderPacket,
|
|
881
|
+
packetFilenames,
|
|
882
|
+
// small helpers exported for focused tests / reuse
|
|
883
|
+
fmtCents,
|
|
884
|
+
csvField,
|
|
885
|
+
// re-export the pipeline pieces so a caller can ingest with one require
|
|
886
|
+
ingest,
|
|
887
|
+
};
|