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,551 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// TrustLedger — match.js
|
|
4
|
+
//
|
|
5
|
+
// T-22.2: the EXACT-then-FUZZY transaction matcher.
|
|
6
|
+
//
|
|
7
|
+
// Reconciliation is, at its core, a bipartite matching problem: each line on
|
|
8
|
+
// one statement (e.g. the BANK) should correspond to one — or, for a batched
|
|
9
|
+
// deposit, SEVERAL — lines on the other (e.g. the QuickBooks/rent ledger). The
|
|
10
|
+
// real world makes this hard in three recurring ways a property manager hits
|
|
11
|
+
// EVERY month:
|
|
12
|
+
//
|
|
13
|
+
// 1. TIMING. A tenant's payment posts to the ledger on the 1st but clears the
|
|
14
|
+
// bank on the 2nd or 3rd. Same money, different date. We need a tolerant
|
|
15
|
+
// date window, not an equality test.
|
|
16
|
+
//
|
|
17
|
+
// 2. SPLIT / BATCHED DEPOSITS. The manager walks three rent checks to the
|
|
18
|
+
// bank and they land as ONE $4,500 bank credit, while the ledger has three
|
|
19
|
+
// separate $1,500 tenant payments. One bank line must match a SET of
|
|
20
|
+
// ledger lines whose amounts sum to it.
|
|
21
|
+
//
|
|
22
|
+
// 3. NSF REVERSALS. A deposit bounces; the bank posts a NEGATIVE reversal of
|
|
23
|
+
// the exact (or near) amount. Both the original and the reversal must be
|
|
24
|
+
// reconciled so the net effect is visible, not silently netted away.
|
|
25
|
+
//
|
|
26
|
+
// This module is a PURE, DETERMINISTIC function of its two input arrays and the
|
|
27
|
+
// options. No clock, no randomness, no I/O. Given the same inputs it returns
|
|
28
|
+
// byte-identical output regardless of the ORDER of either list — a property the
|
|
29
|
+
// tests assert directly, because a reconciliation a broker signs must be
|
|
30
|
+
// reproducible.
|
|
31
|
+
//
|
|
32
|
+
// Return shape:
|
|
33
|
+
// {
|
|
34
|
+
// matched: [ { a, b, confidence, kind } , ... ],
|
|
35
|
+
// unmatchedA: [ <record>, ... ],
|
|
36
|
+
// unmatchedB: [ <record>, ... ],
|
|
37
|
+
// }
|
|
38
|
+
// where in each pairing exactly ONE side is a single record and the other side
|
|
39
|
+
// MAY be an array (the split). `a` always refers to a record (or array) drawn
|
|
40
|
+
// from list A, `b` from list B. `confidence` is a 0..1 number; `kind` is a short
|
|
41
|
+
// machine string explaining WHY it matched ("exact", "amount+window",
|
|
42
|
+
// "split", "nsf-reversal").
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Tunables (all overridable via opts)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const DEFAULTS = Object.freeze({
|
|
49
|
+
// How many calendar days the two sides of a pairing may differ by and still
|
|
50
|
+
// be considered the same event. 0 => same-day only.
|
|
51
|
+
dateToleranceDays: 3,
|
|
52
|
+
|
|
53
|
+
// Amounts are integer cents and must match EXACTLY in magnitude — money does
|
|
54
|
+
// not "almost" reconcile. This is a guard against a future caller passing a
|
|
55
|
+
// float tolerance; we keep it 0 and reject non-zero with a clear error rather
|
|
56
|
+
// than silently fuzzing dollars.
|
|
57
|
+
amountToleranceCents: 0,
|
|
58
|
+
|
|
59
|
+
// Largest number of B-records allowed to combine into one A-record (or vice
|
|
60
|
+
// versa) for a split/batched deposit. Bounds the combinatorial search so a
|
|
61
|
+
// pathological month can never blow up; a single deposit batching more than
|
|
62
|
+
// this many tenant payments is vanishingly rare and better surfaced as an
|
|
63
|
+
// exception than matched by brute force.
|
|
64
|
+
maxSplitParts: 6,
|
|
65
|
+
|
|
66
|
+
// Largest WINDOWED, same-sign candidate POOL we will brute-force a split over
|
|
67
|
+
// for a single record. maxSplitParts bounds the DEPTH of the search; this
|
|
68
|
+
// bounds its BREADTH. When many unmatched same-sign lines cluster inside one
|
|
69
|
+
// date window (the real-world shape on the 1st-5th of a month, when most rent
|
|
70
|
+
// posts), C(n, k) explodes even though each subset is shallow. Above this
|
|
71
|
+
// pool size we DECLINE the brute-force split and leave the record as an
|
|
72
|
+
// exception for a human — the same "surface it rather than guess" philosophy
|
|
73
|
+
// as maxSplitParts, applied to the dimension that actually blows up. A real
|
|
74
|
+
// batched deposit almost never draws from more than a couple dozen same-day,
|
|
75
|
+
// same-sign candidates; a pool larger than this is itself a signal the file
|
|
76
|
+
// needs a human eye, not a 2-minute combinatorial grind.
|
|
77
|
+
maxSplitCandidates: 24,
|
|
78
|
+
|
|
79
|
+
// Minimum memo similarity (0..1) required to LOWER confidence vs. raise it;
|
|
80
|
+
// memo never blocks a same-amount/in-window match, it only modulates the
|
|
81
|
+
// reported confidence so a reviewer can sort by it.
|
|
82
|
+
memoWeight: 0.25,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Small deterministic helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
// Whole-day difference between two "YYYY-MM-DD" strings (absolute value).
|
|
90
|
+
// Pure calendar arithmetic via UTC epoch days — no timezone, no Date-locale.
|
|
91
|
+
function dayDiff(isoA, isoB) {
|
|
92
|
+
return Math.abs(epochDay(isoA) - epochDay(isoB));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function epochDay(iso) {
|
|
96
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
97
|
+
if (!m) throw new MatchError(`bad date in record: "${iso}"`);
|
|
98
|
+
const y = Number(m[1]);
|
|
99
|
+
const mo = Number(m[2]);
|
|
100
|
+
const d = Number(m[3]);
|
|
101
|
+
// Days from a fixed civil epoch (Howard Hinnant's algorithm). Deterministic,
|
|
102
|
+
// leap-correct, and independent of the host Date implementation.
|
|
103
|
+
const yy = mo <= 2 ? y - 1 : y;
|
|
104
|
+
const era = Math.floor((yy >= 0 ? yy : yy - 399) / 400);
|
|
105
|
+
const yoe = yy - era * 400;
|
|
106
|
+
const doy = Math.floor((153 * (mo > 2 ? mo - 3 : mo + 9) + 2) / 5) + d - 1;
|
|
107
|
+
const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
|
|
108
|
+
return era * 146097 + doe - 719468;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Normalize a memo for comparison: lowercase, collapse whitespace, drop
|
|
112
|
+
// punctuation. Deterministic.
|
|
113
|
+
function normMemo(s) {
|
|
114
|
+
return String(s == null ? "" : s)
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
.replace(/[^a-z0-9 ]+/g, " ")
|
|
117
|
+
.replace(/\s+/g, " ")
|
|
118
|
+
.trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Token-set Jaccard similarity of two memos, 0..1. Empty-vs-empty => 1 (no
|
|
122
|
+
// information either way is treated as neutral-high so blank memos don't punish
|
|
123
|
+
// an otherwise-perfect amount/date match). Empty-vs-nonempty => 0.
|
|
124
|
+
function memoSimilarity(a, b) {
|
|
125
|
+
const na = normMemo(a);
|
|
126
|
+
const nb = normMemo(b);
|
|
127
|
+
if (na === "" && nb === "") return 1;
|
|
128
|
+
if (na === "" || nb === "") return 0;
|
|
129
|
+
const sa = new Set(na.split(" "));
|
|
130
|
+
const sb = new Set(nb.split(" "));
|
|
131
|
+
let inter = 0;
|
|
132
|
+
for (const t of sa) if (sb.has(t)) inter++;
|
|
133
|
+
const union = sa.size + sb.size - inter;
|
|
134
|
+
return union === 0 ? 0 : inter / union;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Errors
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
class MatchError extends Error {
|
|
142
|
+
constructor(message) {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = "MatchError";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Stable identity / ordering
|
|
150
|
+
//
|
|
151
|
+
// To be order-independent yet deterministic we (1) tag every input record with
|
|
152
|
+
// its original index, (2) sort a STABLE working copy by an intrinsic key
|
|
153
|
+
// (date, amount, memo, original index), and (3) always iterate that sorted copy.
|
|
154
|
+
// Two callers passing the same multiset of records in different orders therefore
|
|
155
|
+
// walk the SAME sequence of decisions and produce the same pairings.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
function sortKey(rec) {
|
|
159
|
+
// Intrinsic, input-order-free key. Original index is the FINAL tiebreak only
|
|
160
|
+
// for genuinely identical records (same date/amount/memo), where the choice
|
|
161
|
+
// cannot affect amounts and is therefore reconciliation-neutral.
|
|
162
|
+
return [rec.date, pad(rec.amount), normMemo(rec.memo)];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Zero-pad a signed integer so lexical sort == numeric sort.
|
|
166
|
+
function pad(n) {
|
|
167
|
+
const neg = n < 0;
|
|
168
|
+
const s = String(Math.abs(n)).padStart(15, "0");
|
|
169
|
+
// "-" sorts before "0" in ASCII, but we want -100 < -1 < 0 < 1; invert the
|
|
170
|
+
// magnitude ordering for negatives by mapping to a complement.
|
|
171
|
+
return neg ? "0:" + complement(s) : "1:" + s;
|
|
172
|
+
}
|
|
173
|
+
function complement(digits) {
|
|
174
|
+
// Nine's-complement so larger magnitude negatives sort first.
|
|
175
|
+
let out = "";
|
|
176
|
+
for (const c of digits) out += String(9 - Number(c));
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cmpKey(a, b) {
|
|
181
|
+
const ka = a.__key;
|
|
182
|
+
const kb = b.__key;
|
|
183
|
+
for (let i = 0; i < ka.length; i++) {
|
|
184
|
+
if (ka[i] < kb[i]) return -1;
|
|
185
|
+
if (ka[i] > kb[i]) return 1;
|
|
186
|
+
}
|
|
187
|
+
// Final, stable tiebreak on original index.
|
|
188
|
+
return a.__idx - b.__idx;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Wrap input records with stable metadata WITHOUT mutating the caller's objects.
|
|
192
|
+
function prepare(list, label) {
|
|
193
|
+
if (!Array.isArray(list)) {
|
|
194
|
+
throw new MatchError(`${label} must be an array of records`);
|
|
195
|
+
}
|
|
196
|
+
return list.map((rec, i) => {
|
|
197
|
+
if (rec == null || typeof rec !== "object") {
|
|
198
|
+
throw new MatchError(`${label}[${i}] is not a record object`);
|
|
199
|
+
}
|
|
200
|
+
if (typeof rec.amount !== "number" || !Number.isInteger(rec.amount)) {
|
|
201
|
+
throw new MatchError(`${label}[${i}].amount must be integer cents`);
|
|
202
|
+
}
|
|
203
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(rec.date || ""))) {
|
|
204
|
+
throw new MatchError(`${label}[${i}].date must be "YYYY-MM-DD"`);
|
|
205
|
+
}
|
|
206
|
+
const w = {
|
|
207
|
+
__idx: i,
|
|
208
|
+
__rec: rec,
|
|
209
|
+
date: rec.date,
|
|
210
|
+
amount: rec.amount,
|
|
211
|
+
memo: rec.memo == null ? "" : String(rec.memo),
|
|
212
|
+
used: false,
|
|
213
|
+
};
|
|
214
|
+
w.__key = sortKey(w);
|
|
215
|
+
return w;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Confidence model
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
// Combine the date closeness and memo similarity into a 0..1 confidence for a
|
|
224
|
+
// same-amount pairing. Exact same-day with matching memo => ~1.0; a far-but-in-
|
|
225
|
+
// window date and unrelated memo => lower but still > a floor that keeps it
|
|
226
|
+
// above "unmatched". Monotonic and deterministic.
|
|
227
|
+
function confidenceFor(dDiff, tol, memoSim, memoWeight) {
|
|
228
|
+
// Date component: 1 at 0 days, linearly down to a 0.5 floor at the tolerance
|
|
229
|
+
// edge (in-window is never "low confidence on date alone").
|
|
230
|
+
const dateComp = tol === 0 ? 1 : 1 - 0.5 * (dDiff / tol);
|
|
231
|
+
// Blend in memo similarity with its configured weight.
|
|
232
|
+
const c = (1 - memoWeight) * dateComp + memoWeight * memoSim;
|
|
233
|
+
// Clamp and round to 4 dp so output is stable/printable.
|
|
234
|
+
return Math.round(Math.min(1, Math.max(0, c)) * 10000) / 10000;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Core
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
function reconcile(listA, listB, opts = {}) {
|
|
242
|
+
const cfg = { ...DEFAULTS, ...(opts || {}) };
|
|
243
|
+
if (cfg.amountToleranceCents !== 0) {
|
|
244
|
+
throw new MatchError(
|
|
245
|
+
"amountToleranceCents must be 0: money reconciles exactly, only DATE is fuzzy"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (!Number.isInteger(cfg.dateToleranceDays) || cfg.dateToleranceDays < 0) {
|
|
249
|
+
throw new MatchError("dateToleranceDays must be a non-negative integer");
|
|
250
|
+
}
|
|
251
|
+
if (!Number.isInteger(cfg.maxSplitParts) || cfg.maxSplitParts < 1) {
|
|
252
|
+
throw new MatchError("maxSplitParts must be a positive integer");
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
!Number.isInteger(cfg.maxSplitCandidates) ||
|
|
256
|
+
cfg.maxSplitCandidates < 1
|
|
257
|
+
) {
|
|
258
|
+
throw new MatchError("maxSplitCandidates must be a positive integer");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const A = prepare(listA, "listA").sort(cmpKey);
|
|
262
|
+
const B = prepare(listB, "listB").sort(cmpKey);
|
|
263
|
+
|
|
264
|
+
const matched = [];
|
|
265
|
+
|
|
266
|
+
// -- Pass 1: EXACT (same amount AND same date). --------------------------
|
|
267
|
+
// Most lines reconcile here. Index B by an exact (date,amount) bucket so the
|
|
268
|
+
// pass is linear; consume greedily in stable sorted order so the choice among
|
|
269
|
+
// identical candidates is deterministic.
|
|
270
|
+
pairOneToOne(A, B, matched, {
|
|
271
|
+
tol: 0,
|
|
272
|
+
cfg,
|
|
273
|
+
kind: "exact",
|
|
274
|
+
sameDateOnly: true,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// -- Pass 2: AMOUNT + DATE WINDOW (timing differences, incl. NSF). -------
|
|
278
|
+
// Same magnitude, date within tolerance. An equal-and-opposite amount within
|
|
279
|
+
// the window is the signature of an NSF reversal and is labeled as such.
|
|
280
|
+
pairOneToOne(A, B, matched, {
|
|
281
|
+
tol: cfg.dateToleranceDays,
|
|
282
|
+
cfg,
|
|
283
|
+
kind: "amount+window",
|
|
284
|
+
sameDateOnly: false,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// -- Pass 3: SPLIT / BATCHED deposits. -----------------------------------
|
|
288
|
+
// One remaining record on one side whose amount equals the SUM of a small set
|
|
289
|
+
// of remaining records on the other side, all within the date window of the
|
|
290
|
+
// single side. Try A-singletons against B-subsets, then B-singletons against
|
|
291
|
+
// A-subsets, so both "bank batched the ledger" and "ledger batched the bank"
|
|
292
|
+
// are covered.
|
|
293
|
+
pairSplits(A, B, matched, cfg, /*aIsSingle=*/ true);
|
|
294
|
+
pairSplits(B, A, matched, cfg, /*aIsSingle=*/ false);
|
|
295
|
+
|
|
296
|
+
// Whatever is still unused is a genuine exception the broker must investigate.
|
|
297
|
+
const unmatchedA = A.filter((w) => !w.used)
|
|
298
|
+
.sort(cmpKey)
|
|
299
|
+
.map((w) => w.__rec);
|
|
300
|
+
const unmatchedB = B.filter((w) => !w.used)
|
|
301
|
+
.sort(cmpKey)
|
|
302
|
+
.map((w) => w.__rec);
|
|
303
|
+
|
|
304
|
+
// Sort matched deterministically by the A-side key then B-side key so output
|
|
305
|
+
// order is independent of pass order and input order.
|
|
306
|
+
matched.sort((x, y) => {
|
|
307
|
+
const ka = firstKey(x.__akeys);
|
|
308
|
+
const kb = firstKey(y.__akeys);
|
|
309
|
+
if (ka < kb) return -1;
|
|
310
|
+
if (ka > kb) return 1;
|
|
311
|
+
const kc = firstKey(x.__bkeys);
|
|
312
|
+
const kd = firstKey(y.__bkeys);
|
|
313
|
+
if (kc < kd) return -1;
|
|
314
|
+
if (kc > kd) return 1;
|
|
315
|
+
return 0;
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
matched: matched.map(stripMeta),
|
|
320
|
+
unmatchedA,
|
|
321
|
+
unmatchedB,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function firstKey(keys) {
|
|
326
|
+
// keys is an array of __key arrays; compare by their min for stable ordering.
|
|
327
|
+
let best = null;
|
|
328
|
+
for (const k of keys) {
|
|
329
|
+
const s = k.join("");
|
|
330
|
+
if (best === null || s < best) best = s;
|
|
331
|
+
}
|
|
332
|
+
return best == null ? "" : best;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function stripMeta(m) {
|
|
336
|
+
return { a: m.a, b: m.b, confidence: m.confidence, kind: m.kind };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Pass 1 & 2: one-to-one pairing
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
function pairOneToOne(A, B, matched, { tol, cfg, kind, sameDateOnly }) {
|
|
344
|
+
// Bucket unused B by amount magnitude AND by signed amount so we can find both
|
|
345
|
+
// equal and equal-and-opposite (NSF) partners quickly.
|
|
346
|
+
const bByAmount = new Map(); // signed amount -> array of unused B (sorted)
|
|
347
|
+
for (const w of B) {
|
|
348
|
+
if (w.used) continue;
|
|
349
|
+
if (!bByAmount.has(w.amount)) bByAmount.set(w.amount, []);
|
|
350
|
+
bByAmount.get(w.amount).push(w);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const a of A) {
|
|
354
|
+
if (a.used) continue;
|
|
355
|
+
|
|
356
|
+
// Candidate B records: same signed amount (normal) OR equal-and-opposite
|
|
357
|
+
// (NSF reversal). We prefer a same-sign partner; only treat opposite-sign as
|
|
358
|
+
// an NSF reversal, which we label distinctly.
|
|
359
|
+
const sameSign = bByAmount.get(a.amount) || [];
|
|
360
|
+
const oppSign = a.amount !== 0 ? bByAmount.get(-a.amount) || [] : [];
|
|
361
|
+
|
|
362
|
+
let chosen = null;
|
|
363
|
+
let chosenIsNsf = false;
|
|
364
|
+
let chosenDiff = Infinity;
|
|
365
|
+
|
|
366
|
+
// Helper to scan a candidate list and pick the best (smallest date diff,
|
|
367
|
+
// then highest memo similarity, then stable key) within the window.
|
|
368
|
+
const scan = (cands, isNsf) => {
|
|
369
|
+
for (const b of cands) {
|
|
370
|
+
if (b.used) continue;
|
|
371
|
+
const dd = dayDiff(a.date, b.date);
|
|
372
|
+
if (sameDateOnly && dd !== 0) continue;
|
|
373
|
+
if (!sameDateOnly && dd > tol) continue;
|
|
374
|
+
if (chosen === null) {
|
|
375
|
+
chosen = b;
|
|
376
|
+
chosenIsNsf = isNsf;
|
|
377
|
+
chosenDiff = dd;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// Prefer non-NSF over NSF, then smaller date diff, then stable key.
|
|
381
|
+
const better =
|
|
382
|
+
(chosenIsNsf && !isNsf) ||
|
|
383
|
+
(chosenIsNsf === isNsf && dd < chosenDiff) ||
|
|
384
|
+
(chosenIsNsf === isNsf && dd === chosenDiff && cmpKey(b, chosen) < 0);
|
|
385
|
+
if (better) {
|
|
386
|
+
chosen = b;
|
|
387
|
+
chosenIsNsf = isNsf;
|
|
388
|
+
chosenDiff = dd;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
scan(sameSign, false);
|
|
394
|
+
// Only consider an NSF reversal when no clean same-sign partner was found
|
|
395
|
+
// OR the NSF is strictly closer in date — but a same-sign exact match always
|
|
396
|
+
// wins. We bias toward same-sign by scanning it first and only letting NSF
|
|
397
|
+
// replace it when nothing same-sign qualified.
|
|
398
|
+
if (chosen === null) scan(oppSign, true);
|
|
399
|
+
|
|
400
|
+
if (chosen) {
|
|
401
|
+
a.used = true;
|
|
402
|
+
chosen.used = true;
|
|
403
|
+
const memoSim = memoSimilarity(a.memo, chosen.memo);
|
|
404
|
+
const conf = confidenceFor(chosenDiff, tol, memoSim, cfg.memoWeight);
|
|
405
|
+
matched.push({
|
|
406
|
+
a: a.__rec,
|
|
407
|
+
b: chosen.__rec,
|
|
408
|
+
confidence: chosenIsNsf ? Math.min(conf, 0.95) : conf,
|
|
409
|
+
kind: chosenIsNsf ? "nsf-reversal" : kind,
|
|
410
|
+
__akeys: [a.__key],
|
|
411
|
+
__bkeys: [chosen.__key],
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// Pass 3: split / batched-deposit pairing
|
|
419
|
+
//
|
|
420
|
+
// For each unused single record S on the "single" side, search the unused
|
|
421
|
+
// records on the other side for a SUBSET whose amounts sum EXACTLY to S.amount
|
|
422
|
+
// and that all fall within S's date window. Deterministic subset search bounded
|
|
423
|
+
// by cfg.maxSplitParts, candidates pre-filtered to the window and ordered by
|
|
424
|
+
// stable key so the FIRST exact-sum subset found is reproducible.
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
function pairSplits(single, many, matched, cfg, singleIsA) {
|
|
428
|
+
for (const s of single) {
|
|
429
|
+
if (s.used) continue;
|
|
430
|
+
if (s.amount === 0) continue; // a zero line never needs a multi-part sum
|
|
431
|
+
|
|
432
|
+
// Eligible parts: unused, same SIGN as the single (a positive deposit is the
|
|
433
|
+
// sum of positive payments), and within the date window of the single.
|
|
434
|
+
const parts = many
|
|
435
|
+
.filter(
|
|
436
|
+
(w) =>
|
|
437
|
+
!w.used &&
|
|
438
|
+
sameSign(w.amount, s.amount) &&
|
|
439
|
+
dayDiff(s.date, w.date) <= cfg.dateToleranceDays
|
|
440
|
+
)
|
|
441
|
+
.sort(cmpKey);
|
|
442
|
+
|
|
443
|
+
// BREADTH guard. findSubsetSum bounds DEPTH (maxSplitParts) but C(n, k)
|
|
444
|
+
// still explodes when the windowed pool n is large — exactly what happens
|
|
445
|
+
// when dozens of same-sign lines cluster on one date. Rather than grind for
|
|
446
|
+
// minutes on a single reconciliation, we decline the brute-force split once
|
|
447
|
+
// the pool exceeds maxSplitCandidates and leave the single as an exception.
|
|
448
|
+
// This is deterministic (pool size is order-independent) and mirrors the
|
|
449
|
+
// maxSplitParts "surface it rather than guess" philosophy. We only skip the
|
|
450
|
+
// SPLIT search; the record still appears in the unmatched output below.
|
|
451
|
+
if (parts.length > cfg.maxSplitCandidates) continue;
|
|
452
|
+
|
|
453
|
+
const combo = findSubsetSum(parts, s.amount, cfg.maxSplitParts);
|
|
454
|
+
if (!combo) continue;
|
|
455
|
+
|
|
456
|
+
// Commit.
|
|
457
|
+
s.used = true;
|
|
458
|
+
for (const p of combo) p.used = true;
|
|
459
|
+
|
|
460
|
+
// Confidence: worst (largest) date gap among parts drives the date
|
|
461
|
+
// component; memo similarity averaged across parts.
|
|
462
|
+
let maxDiff = 0;
|
|
463
|
+
let memoAcc = 0;
|
|
464
|
+
for (const p of combo) {
|
|
465
|
+
maxDiff = Math.max(maxDiff, dayDiff(s.date, p.date));
|
|
466
|
+
memoAcc += memoSimilarity(s.memo, p.memo);
|
|
467
|
+
}
|
|
468
|
+
const memoSim = combo.length ? memoAcc / combo.length : 0;
|
|
469
|
+
const conf = confidenceFor(
|
|
470
|
+
maxDiff,
|
|
471
|
+
cfg.dateToleranceDays,
|
|
472
|
+
memoSim,
|
|
473
|
+
cfg.memoWeight
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const partsRecs = combo.map((p) => p.__rec);
|
|
477
|
+
const partsKeys = combo.map((p) => p.__key);
|
|
478
|
+
matched.push({
|
|
479
|
+
a: singleIsA ? s.__rec : partsRecs,
|
|
480
|
+
b: singleIsA ? partsRecs : s.__rec,
|
|
481
|
+
confidence: Math.round(Math.min(conf, 0.99) * 10000) / 10000,
|
|
482
|
+
kind: "split",
|
|
483
|
+
__akeys: singleIsA ? [s.__key] : partsKeys,
|
|
484
|
+
__bkeys: singleIsA ? partsKeys : [s.__key],
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function sameSign(x, y) {
|
|
490
|
+
return (x >= 0 && y >= 0) || (x < 0 && y < 0);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Find a subset of `parts` (each {amount}) summing EXACTLY to `target`, using
|
|
494
|
+
// 2..maxParts elements, returning the elements or null. Deterministic: explores
|
|
495
|
+
// in the stable order `parts` is already sorted in and returns the first hit,
|
|
496
|
+
// preferring FEWER parts (shallower combinations) first.
|
|
497
|
+
function findSubsetSum(parts, target, maxParts) {
|
|
498
|
+
// We require at least 2 parts — a 1-part "split" is just a one-to-one match
|
|
499
|
+
// that the earlier passes already had their chance at.
|
|
500
|
+
const n = parts.length;
|
|
501
|
+
const cap = Math.min(maxParts, n);
|
|
502
|
+
|
|
503
|
+
// Try increasing subset sizes so the smallest valid combination wins.
|
|
504
|
+
for (let size = 2; size <= cap; size++) {
|
|
505
|
+
const pick = new Array(size);
|
|
506
|
+
const res = recurse(0, 0, 0, target);
|
|
507
|
+
if (res) return res;
|
|
508
|
+
|
|
509
|
+
// Depth-first choose `size` indices in increasing order summing to target.
|
|
510
|
+
function recurse(start, depth, sum) {
|
|
511
|
+
if (depth === size) {
|
|
512
|
+
return sum === target ? pick.slice() : null;
|
|
513
|
+
}
|
|
514
|
+
const remaining = size - depth;
|
|
515
|
+
for (let i = start; i <= n - remaining; i++) {
|
|
516
|
+
const next = sum + parts[i].amount;
|
|
517
|
+
// Prune: with same-sign positive parts an overshoot can never recover,
|
|
518
|
+
// so skip this index. `parts` is ordered by (date, amount) — not purely
|
|
519
|
+
// by amount — so we `continue` past this candidate rather than `break`
|
|
520
|
+
// out, since a smaller-amount part may still appear at a later index.
|
|
521
|
+
if (target >= 0 && next > target && parts[i].amount > 0) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// Symmetric prune for negative targets (sum of negative parts).
|
|
525
|
+
if (target < 0 && next < target && parts[i].amount < 0) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
pick[depth] = parts[i];
|
|
529
|
+
const found = recurse(i + 1, depth + 1, next);
|
|
530
|
+
if (found) return found;
|
|
531
|
+
}
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
module.exports = {
|
|
539
|
+
reconcile,
|
|
540
|
+
MatchError,
|
|
541
|
+
DEFAULTS,
|
|
542
|
+
// exported for focused tests / reuse
|
|
543
|
+
dayDiff,
|
|
544
|
+
memoSimilarity,
|
|
545
|
+
findSubsetSum: (parts, target, maxParts = DEFAULTS.maxSplitParts) =>
|
|
546
|
+
findSubsetSum(
|
|
547
|
+
parts.map((p) => ({ amount: p.amount, __key: [], __idx: 0 })),
|
|
548
|
+
target,
|
|
549
|
+
maxParts
|
|
550
|
+
),
|
|
551
|
+
};
|