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,1486 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// TrustLedger — reconcile.js
|
|
4
|
+
//
|
|
5
|
+
// T-22.3: the THREE-BALANCE check + exception classification.
|
|
6
|
+
//
|
|
7
|
+
// A real-estate broker who holds client money in a trust account is the legal
|
|
8
|
+
// custodian of that money. In every US state the broker must be able to prove,
|
|
9
|
+
// on demand, that THREE numbers agree:
|
|
10
|
+
//
|
|
11
|
+
// 1. BANK balance — what the bank statement says the trust account holds.
|
|
12
|
+
// 2. BOOK balance — what the broker's own ledger (QuickBooks) says it holds.
|
|
13
|
+
// 3. SUB-LEDGER — the SUM of every individual beneficiary's balance (each
|
|
14
|
+
// tenant's deposit/credit, each owner's held funds). The trust account is
|
|
15
|
+
// pooled, so the bank holds ONE number that must equal the sum of all the
|
|
16
|
+
// little per-beneficiary numbers underneath it.
|
|
17
|
+
//
|
|
18
|
+
// This is the "three-way reconciliation" the product sells. When all three tie
|
|
19
|
+
// out, the broker is clean. When they DON'T, the gap is the audit finding, and
|
|
20
|
+
// it is almost always explained by a small set of well-known RECONCILING ITEMS
|
|
21
|
+
// (timing) or genuine EXCEPTIONS (a real shortage/overage, an owner draw that
|
|
22
|
+
// touched a tenant's money, a bounced deposit, a security deposit that was not
|
|
23
|
+
// segregated). This module computes the three balances, decides whether they
|
|
24
|
+
// tie out, and CLASSIFIES every reconciling item / exception so a human sees
|
|
25
|
+
// exactly what to fix.
|
|
26
|
+
//
|
|
27
|
+
// PURE + DETERMINISTIC. Given the same inputs (and the upstream match.js result)
|
|
28
|
+
// it returns byte-identical output regardless of input order. No clock, no I/O,
|
|
29
|
+
// no randomness — the same property the matcher has, for the same reason: a
|
|
30
|
+
// reconciliation a broker signs and an auditor reads must be reproducible.
|
|
31
|
+
//
|
|
32
|
+
// -------------------------------------------------------------------------
|
|
33
|
+
// Sign convention (inherited from ingest.js):
|
|
34
|
+
// amount > 0 => money INTO the trust account (deposit, rent, owner funding)
|
|
35
|
+
// amount < 0 => money OUT of the trust account (check, draw, fee, refund)
|
|
36
|
+
// A "balance" here is therefore a running net of signed amounts plus a known
|
|
37
|
+
// opening balance.
|
|
38
|
+
// -------------------------------------------------------------------------
|
|
39
|
+
//
|
|
40
|
+
// Return shape:
|
|
41
|
+
// {
|
|
42
|
+
// balances: {
|
|
43
|
+
// bank: <int cents>, // opening + bank activity
|
|
44
|
+
// book: <int cents>, // opening + book activity
|
|
45
|
+
// subledger: <int cents>, // sum of per-beneficiary balances
|
|
46
|
+
// adjustedBank: <int cents>, // bank +/- outstanding items
|
|
47
|
+
// reconciled: <int cents>, // the single number all three should hit
|
|
48
|
+
// },
|
|
49
|
+
// tiesOut: <bool>, // do all three agree after reconciling items?
|
|
50
|
+
// exceptions: [
|
|
51
|
+
// { type, severity, amount, label, detail, records } , ...
|
|
52
|
+
// ],
|
|
53
|
+
// }
|
|
54
|
+
//
|
|
55
|
+
// `type` is one of EXCEPTION (a stable machine string). `severity` is "info"
|
|
56
|
+
// for benign timing items that EXPLAIN a gap vs. "error" for items that mean
|
|
57
|
+
// the trust account is actually out of trust (a shortage, commingling, an
|
|
58
|
+
// unsegregated deposit). `label` is a short human caption; `detail` a sentence.
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Enums
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const EXCEPTION = Object.freeze({
|
|
65
|
+
OUTSTANDING_DEPOSIT: "outstanding_deposit", // in book, not yet on bank (in transit)
|
|
66
|
+
OUTSTANDING_CHECK: "outstanding_check", // written in book, not yet cleared bank
|
|
67
|
+
NSF_REVERSAL: "nsf_reversal", // a bounced deposit reversed by the bank
|
|
68
|
+
OWNER_DRAW: "owner_draw", // owner pulled funds (must not dip into tenant money)
|
|
69
|
+
OWNER_OVERDRAW: "owner_overdraw", // owner drew MORE than their own contributed capital (tenant money)
|
|
70
|
+
SECURITY_DEPOSIT_SEGREGATION: "security_deposit_segregation", // deposit not held separately
|
|
71
|
+
AMBIGUOUS_DEPOSIT: "ambiguous_deposit", // a book deposit whose beneficiary type can't be determined
|
|
72
|
+
TIMING: "timing", // generic date-window timing difference
|
|
73
|
+
UNRECONCILED_BANK: "unreconciled_bank", // a bank line nothing explains
|
|
74
|
+
UNRECONCILED_BOOK: "unreconciled_book", // a book line nothing explains
|
|
75
|
+
SUBLEDGER_OUT_OF_BALANCE: "subledger_out_of_balance", // sum-of-tenants != book
|
|
76
|
+
NEGATIVE_TENANT_LEDGER: "negative_tenant_ledger", // an individual beneficiary balance is below zero
|
|
77
|
+
BANK_BOOK_MISMATCH: "bank_book_mismatch", // adjusted bank != book
|
|
78
|
+
CONTINUITY_BREAK: "continuity_break", // this period's opening != prior period's signed ending
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const SEVERITY = Object.freeze({
|
|
82
|
+
INFO: "info", // a benign, self-clearing reconciling item (timing)
|
|
83
|
+
WARNING: "warning", // needs a human eye but may be legitimate
|
|
84
|
+
ERROR: "error", // trust account is out of trust: a real finding
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Map each exception type to its default severity. Timing/outstanding items are
|
|
88
|
+
// the normal, expected reconciling items (INFO). A draw, an unsegregated
|
|
89
|
+
// security deposit, an NSF, or any balance that fails to tie is a real finding.
|
|
90
|
+
const DEFAULT_SEVERITY = Object.freeze({
|
|
91
|
+
[EXCEPTION.OUTSTANDING_DEPOSIT]: SEVERITY.INFO,
|
|
92
|
+
[EXCEPTION.OUTSTANDING_CHECK]: SEVERITY.INFO,
|
|
93
|
+
[EXCEPTION.TIMING]: SEVERITY.INFO,
|
|
94
|
+
[EXCEPTION.NSF_REVERSAL]: SEVERITY.WARNING,
|
|
95
|
+
[EXCEPTION.OWNER_DRAW]: SEVERITY.WARNING,
|
|
96
|
+
// An owner that DRAWS more than its OWN contributed capital paid itself out of
|
|
97
|
+
// other beneficiaries' trust money (the excess). That is a conversion of trust
|
|
98
|
+
// funds and leaves the account out of trust REGARDLESS of whether the pooled
|
|
99
|
+
// sum still ties via the owner's negative control bucket, so it is an
|
|
100
|
+
// ERROR-grade finding by default. A state MAY re-grade it via policy.
|
|
101
|
+
[EXCEPTION.OWNER_OVERDRAW]: SEVERITY.ERROR,
|
|
102
|
+
[EXCEPTION.SECURITY_DEPOSIT_SEGREGATION]: SEVERITY.ERROR,
|
|
103
|
+
// A deposit whose beneficiary type we cannot determine (no recognizable
|
|
104
|
+
// keyword, not an explicitly-labeled rent/receipt) is a WARNING by default: it
|
|
105
|
+
// MIGHT be an un-segregated security deposit hiding as a generic deposit, so a
|
|
106
|
+
// human must look — but absent a security-deposit signal we do NOT escalate it
|
|
107
|
+
// to the out-of-trust ERROR a confirmed unsegregated deposit gets. A state MAY
|
|
108
|
+
// re-grade it via policy.
|
|
109
|
+
[EXCEPTION.AMBIGUOUS_DEPOSIT]: SEVERITY.WARNING,
|
|
110
|
+
[EXCEPTION.UNRECONCILED_BANK]: SEVERITY.WARNING,
|
|
111
|
+
[EXCEPTION.UNRECONCILED_BOOK]: SEVERITY.WARNING,
|
|
112
|
+
[EXCEPTION.SUBLEDGER_OUT_OF_BALANCE]: SEVERITY.ERROR,
|
|
113
|
+
// An individual beneficiary whose own sub-ledger balance is NEGATIVE means the
|
|
114
|
+
// trust account is short for that beneficiary — money the broker holds in trust
|
|
115
|
+
// FOR that person is not actually there (it was spent, or used to cover another
|
|
116
|
+
// beneficiary's shortfall). That is out of trust REGARDLESS of whether the
|
|
117
|
+
// pooled SUM still ties to the book, so it is an ERROR-grade finding by default.
|
|
118
|
+
[EXCEPTION.NEGATIVE_TENANT_LEDGER]: SEVERITY.ERROR,
|
|
119
|
+
[EXCEPTION.BANK_BOOK_MISMATCH]: SEVERITY.ERROR,
|
|
120
|
+
// A broken roll-forward means the books do not actually continue from the
|
|
121
|
+
// signed prior period — an out-of-trust-grade finding by default. A state MAY
|
|
122
|
+
// re-grade a documented timing roll-forward difference to a warning via policy.
|
|
123
|
+
[EXCEPTION.CONTINUITY_BREAK]: SEVERITY.ERROR,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Errors
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
class ReconcileError extends Error {
|
|
131
|
+
constructor(message) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = "ReconcileError";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Small helpers
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function sumAmounts(records) {
|
|
142
|
+
let t = 0;
|
|
143
|
+
for (const r of records) {
|
|
144
|
+
if (!Number.isInteger(r.amount)) {
|
|
145
|
+
throw new ReconcileError("record.amount must be integer cents");
|
|
146
|
+
}
|
|
147
|
+
t += r.amount;
|
|
148
|
+
}
|
|
149
|
+
return t;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isOwnerDraw(rec) {
|
|
153
|
+
const t = `${rec.memo || ""} ${rec.party || ""} ${rec.kind || ""}`.toLowerCase();
|
|
154
|
+
// An owner DRAW is money OUT, attributed to the owner (not a tenant/vendor).
|
|
155
|
+
return rec.amount < 0 && /\bowner\b|\bdraw\b|disbursement to owner|owner distribution/.test(t);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isSecurityDeposit(rec) {
|
|
159
|
+
const t = `${rec.memo || ""} ${rec.party || ""} ${rec.kind || ""}`.toLowerCase();
|
|
160
|
+
return /security deposit|sec dep|sec\.? deposit|damage deposit|\bdeposit held\b/.test(t);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isNsf(rec) {
|
|
164
|
+
if (rec.kind === "nsf") return true;
|
|
165
|
+
const t = `${rec.memo || ""} ${rec.kind || ""}`.toLowerCase();
|
|
166
|
+
return /\bnsf\b|returned|bounced|insufficient|reversal/.test(t);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A CLOSED allowlist of purpose keywords that make a book inflow's beneficiary
|
|
170
|
+
// type RECOGNIZABLE. If a deposit's memo/kind matches ANY of these, we know what
|
|
171
|
+
// the money is (rent, an owner contribution, a refund, a fee credit, a payment
|
|
172
|
+
// installment, an explicit transfer, etc.), so it is NOT ambiguous. Security-
|
|
173
|
+
// deposit keywords are handled separately by isSecurityDeposit (which produces
|
|
174
|
+
// the ERROR-grade segregation finding) and so are intentionally NOT here — a
|
|
175
|
+
// recognizable security deposit must never be downgraded to a mere "recognized"
|
|
176
|
+
// inflow. Keeping the list CLOSED means a genuinely-unlabeled "Deposit - 12B
|
|
177
|
+
// Smith" stays LOUD instead of being silently swept into a generic bucket.
|
|
178
|
+
const RECOGNIZED_DEPOSIT_PURPOSE =
|
|
179
|
+
/\brent\b|\brents?\b|lease|tenant payment|\bpayment\b|\bpaid\b|partial|installment|instalment|\bowner\b|contribution|capital|reserve|distribution|\bdraw\b|\brefund\b|reimburs|\bfee\b|charge|interest|\bnsf\b|returned|bounced|reversal|transfer|segregat|escrow|in transit|in-transit|operating|management|commission|\bproceeds\b|payoff|\bach\b|wire|adjustment|correction|chargeback/;
|
|
180
|
+
|
|
181
|
+
// An EXPLICIT per-record marker that LABELS the deposit, so a labeled deposit /
|
|
182
|
+
// rent receipt is never flagged as ambiguous even if its free-text memo happens
|
|
183
|
+
// to lack a recognized keyword. Honored markers (any one suffices):
|
|
184
|
+
// * rec.kind === "rent" — an explicit rent receipt
|
|
185
|
+
// * rec.depositType is a non-empty str — the beneficiary type was stated
|
|
186
|
+
// * rec.ambiguous === false — the caller asserts it is determined
|
|
187
|
+
// * rec.expected === true — an expected/known line
|
|
188
|
+
// A marker is a deliberate, structured assertion by the producer of the row —
|
|
189
|
+
// distinct from us GUESSING from free text — so it is authoritative here.
|
|
190
|
+
function hasExplicitDepositLabel(rec) {
|
|
191
|
+
if (rec.kind === "rent") return true;
|
|
192
|
+
if (typeof rec.depositType === "string" && rec.depositType.trim() !== "") {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (rec.ambiguous === false) return true;
|
|
196
|
+
if (rec.expected === true) return true;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A book deposit whose BENEFICIARY TYPE cannot be determined: a deposit-scale
|
|
201
|
+
// INFLOW that calls itself a "deposit" (the word, or kind === "deposit") but
|
|
202
|
+
// carries NO recognized purpose keyword and is NOT an explicitly-labeled
|
|
203
|
+
// rent/receipt — so we cannot tell whether it is rent, an owner contribution, or
|
|
204
|
+
// an un-segregated security deposit hiding as a generic deposit. We REQUIRE a
|
|
205
|
+
// party (an attributed beneficiary) so a bare bank-statement "Deposit" line with
|
|
206
|
+
// no counterparty is not over-flagged. A record that already matches
|
|
207
|
+
// isSecurityDeposit is NOT ambiguous — it is a recognized security deposit and is
|
|
208
|
+
// handled (as an ERROR) by classifySecurityDeposits; flagging it here too would
|
|
209
|
+
// double-count the same row. PURE: free-text classification only — no fs, no
|
|
210
|
+
// http, no ethers, no clock.
|
|
211
|
+
function isAmbiguousDeposit(rec) {
|
|
212
|
+
if (!rec) return false;
|
|
213
|
+
if (!Number.isInteger(rec.amount) || rec.amount <= 0) return false; // inflow only
|
|
214
|
+
if (hasExplicitDepositLabel(rec)) return false; // labeled => determined
|
|
215
|
+
if (isSecurityDeposit(rec)) return false; // recognized sec dep => not ambiguous
|
|
216
|
+
if (isOwnerDraw(rec) || isNsf(rec)) return false; // recognized otherwise
|
|
217
|
+
const party = String(rec.party || "").trim();
|
|
218
|
+
if (party === "") return false; // unattributed bare line: don't over-flag
|
|
219
|
+
const t = `${rec.memo || ""} ${rec.kind || ""}`.toLowerCase();
|
|
220
|
+
// It must call itself a deposit (the only signal we have), ...
|
|
221
|
+
const callsItselfDeposit = /\bdeposit\b/.test(t) || rec.kind === "deposit";
|
|
222
|
+
if (!callsItselfDeposit) return false;
|
|
223
|
+
// ... and offer NO recognized purpose to disambiguate it.
|
|
224
|
+
if (RECOGNIZED_DEPOSIT_PURPOSE.test(t)) return false;
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The canonical, order-independent per-BENEFICIARY key for a record's party.
|
|
229
|
+
// Mirrors the sub-ledger's own normalization (tenantBalances uses the SAME
|
|
230
|
+
// `String(party).trim()` convention) so a deposit and a segregation transfer
|
|
231
|
+
// that name the same tenant bucket together. Case-folded + whitespace-collapsed
|
|
232
|
+
// so "Jones (4B)" and "jones (4b)" are ONE beneficiary. An empty/absent party
|
|
233
|
+
// normalizes to "" — the sentinel for "no attributable beneficiary", which the
|
|
234
|
+
// segregation matcher treats as covering NOTHING on its own. PURE.
|
|
235
|
+
function partyKey(party) {
|
|
236
|
+
return String(party == null ? "" : party)
|
|
237
|
+
.trim()
|
|
238
|
+
.toLowerCase()
|
|
239
|
+
.replace(/\s+/g, " ");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A canonical, order-independent sort key for a record (date, amount, memo).
|
|
243
|
+
function recKey(r) {
|
|
244
|
+
return `${r.date}|${String(r.amount).padStart(16, "0")}|${(r.memo || "")
|
|
245
|
+
.toLowerCase()
|
|
246
|
+
.trim()}|${(r.party || "").toLowerCase().trim()}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function pushException(out, ex) {
|
|
250
|
+
out.push({
|
|
251
|
+
type: ex.type,
|
|
252
|
+
severity: ex.severity || DEFAULT_SEVERITY[ex.type] || SEVERITY.WARNING,
|
|
253
|
+
amount: ex.amount,
|
|
254
|
+
label: ex.label,
|
|
255
|
+
detail: ex.detail,
|
|
256
|
+
records: ex.records || [],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Core
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
// Inputs:
|
|
265
|
+
// bank: NormalizedRecord[] (source = bank)
|
|
266
|
+
// book: NormalizedRecord[] (source = quickbooks)
|
|
267
|
+
// tenants: either
|
|
268
|
+
// - NormalizedRecord[] (rent-roll rows; we net per party), OR
|
|
269
|
+
// - { [party]: <balanceCents> } (precomputed per-tenant balances)
|
|
270
|
+
// opts:
|
|
271
|
+
// opening: { bank, book } opening balances in cents (default 0/0)
|
|
272
|
+
// matchResult: the object returned by match.reconcile(bank, book) — used to
|
|
273
|
+
// identify which lines are reconciling items (outstanding /
|
|
274
|
+
// in-transit) vs. genuinely unexplained exceptions. Optional;
|
|
275
|
+
// if omitted we classify purely from the records themselves.
|
|
276
|
+
// toleranceCents: how far the three may differ and still "tie out" (default 0;
|
|
277
|
+
// money ties to the penny — a non-zero tolerance must be a
|
|
278
|
+
// deliberate caller choice).
|
|
279
|
+
function reconcile(bank, book, tenants, opts = {}) {
|
|
280
|
+
if (!Array.isArray(bank)) throw new ReconcileError("bank must be an array");
|
|
281
|
+
if (!Array.isArray(book)) throw new ReconcileError("book must be an array");
|
|
282
|
+
const cfg = {
|
|
283
|
+
opening: { bank: 0, book: 0 },
|
|
284
|
+
matchResult: null,
|
|
285
|
+
toleranceCents: 0,
|
|
286
|
+
...opts,
|
|
287
|
+
};
|
|
288
|
+
cfg.opening = { bank: 0, book: 0, ...(opts.opening || {}) };
|
|
289
|
+
if (!Number.isInteger(cfg.toleranceCents) || cfg.toleranceCents < 0) {
|
|
290
|
+
throw new ReconcileError("toleranceCents must be a non-negative integer");
|
|
291
|
+
}
|
|
292
|
+
if (
|
|
293
|
+
!Number.isInteger(cfg.opening.bank) ||
|
|
294
|
+
!Number.isInteger(cfg.opening.book)
|
|
295
|
+
) {
|
|
296
|
+
throw new ReconcileError("opening balances must be integer cents");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// -- The three raw balances. ---------------------------------------------
|
|
300
|
+
const bankBalance = cfg.opening.bank + sumAmounts(bank);
|
|
301
|
+
const bookBalance = cfg.opening.book + sumAmounts(book);
|
|
302
|
+
const subBalances = tenantBalances(tenants);
|
|
303
|
+
const subledgerBalance = Object.values(subBalances).reduce((a, b) => a + b, 0);
|
|
304
|
+
|
|
305
|
+
const exceptions = [];
|
|
306
|
+
|
|
307
|
+
// -- Identify reconciling items from the matcher (timing in/out of bank). -
|
|
308
|
+
// Anything in the BOOK that the matcher could not pair to a BANK line is
|
|
309
|
+
// "outstanding": the book knows about it but the bank doesn't yet. A positive
|
|
310
|
+
// such item is a deposit in transit; a negative one is an outstanding check.
|
|
311
|
+
// Anything on the BANK with no book partner is a bank-only line (a fee the
|
|
312
|
+
// bookkeeper hasn't recorded, or an NSF reversal) — also a reconciling item,
|
|
313
|
+
// classified by what it is.
|
|
314
|
+
let outstandingDeposits = 0;
|
|
315
|
+
let outstandingChecks = 0;
|
|
316
|
+
|
|
317
|
+
// The caller runs match.reconcile(bank, book), so listA == bank (=> unmatchedA)
|
|
318
|
+
// and listB == book (=> unmatchedB).
|
|
319
|
+
const unmatchedBank = matcherUnmatched(cfg.matchResult, "unmatchedA", bank, book, "bank");
|
|
320
|
+
const unmatchedBook = matcherUnmatched(cfg.matchResult, "unmatchedB", bank, book, "book");
|
|
321
|
+
|
|
322
|
+
// Book-only lines => outstanding items (book ahead of bank).
|
|
323
|
+
for (const r of [...unmatchedBook].sort((x, y) => cmp(recKey(x), recKey(y)))) {
|
|
324
|
+
if (isNsf(r)) {
|
|
325
|
+
pushException(exceptions, {
|
|
326
|
+
type: EXCEPTION.NSF_REVERSAL,
|
|
327
|
+
amount: r.amount,
|
|
328
|
+
label: "NSF / returned-item reversal (book)",
|
|
329
|
+
detail:
|
|
330
|
+
"A bounced or returned item recorded in the book; confirm the bank " +
|
|
331
|
+
"posted the matching reversal and the tenant balance was re-debited.",
|
|
332
|
+
records: [r],
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (r.amount > 0) {
|
|
337
|
+
outstandingDeposits += r.amount;
|
|
338
|
+
pushException(exceptions, {
|
|
339
|
+
type: EXCEPTION.OUTSTANDING_DEPOSIT,
|
|
340
|
+
amount: r.amount,
|
|
341
|
+
label: "Deposit in transit",
|
|
342
|
+
detail:
|
|
343
|
+
"Recorded in the book but not yet on the bank statement; expected to " +
|
|
344
|
+
"clear in the next few days (timing).",
|
|
345
|
+
records: [r],
|
|
346
|
+
});
|
|
347
|
+
} else if (r.amount < 0) {
|
|
348
|
+
outstandingChecks += r.amount; // negative
|
|
349
|
+
pushException(exceptions, {
|
|
350
|
+
type: isOwnerDraw(r) ? EXCEPTION.OWNER_DRAW : EXCEPTION.OUTSTANDING_CHECK,
|
|
351
|
+
amount: r.amount,
|
|
352
|
+
label: isOwnerDraw(r) ? "Outstanding owner draw" : "Outstanding check",
|
|
353
|
+
detail: isOwnerDraw(r)
|
|
354
|
+
? "An owner draw written in the book that has not yet cleared the bank; " +
|
|
355
|
+
"verify it does not draw against tenant or security-deposit funds."
|
|
356
|
+
: "A check written in the book that has not yet cleared the bank (timing).",
|
|
357
|
+
records: [r],
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Bank-only lines => the bank knows something the book doesn't yet.
|
|
363
|
+
for (const r of [...unmatchedBank].sort((x, y) => cmp(recKey(x), recKey(y)))) {
|
|
364
|
+
if (isNsf(r)) {
|
|
365
|
+
pushException(exceptions, {
|
|
366
|
+
type: EXCEPTION.NSF_REVERSAL,
|
|
367
|
+
amount: r.amount,
|
|
368
|
+
label: "NSF / returned-item reversal (bank)",
|
|
369
|
+
detail:
|
|
370
|
+
"The bank reversed a deposit that bounced; the book must record the " +
|
|
371
|
+
"reversal and re-debit the tenant's sub-ledger.",
|
|
372
|
+
records: [r],
|
|
373
|
+
});
|
|
374
|
+
} else {
|
|
375
|
+
pushException(exceptions, {
|
|
376
|
+
type: EXCEPTION.UNRECONCILED_BANK,
|
|
377
|
+
amount: r.amount,
|
|
378
|
+
label: "Unreconciled bank line",
|
|
379
|
+
detail:
|
|
380
|
+
"A bank transaction with no matching book entry; record it in the " +
|
|
381
|
+
"book or explain it before signing.",
|
|
382
|
+
records: [r],
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// -- Adjusted bank balance: bank +/- the outstanding (in-transit) items. --
|
|
388
|
+
// adjustedBank = bank + deposits-in-transit + outstanding-checks(negative).
|
|
389
|
+
// After this adjustment the bank should equal the book.
|
|
390
|
+
const adjustedBank = bankBalance + outstandingDeposits + outstandingChecks;
|
|
391
|
+
|
|
392
|
+
// -- Classify owner draws and security-deposit segregation across ALL book
|
|
393
|
+
// activity (not only the unmatched), since these are policy findings about
|
|
394
|
+
// what the money WAS, independent of whether the line cleared. -----------
|
|
395
|
+
classifyOwnerDraws(book, subBalances, exceptions, cfg.toleranceCents);
|
|
396
|
+
classifySecurityDeposits(book, bank, exceptions);
|
|
397
|
+
// A deposit whose beneficiary type can't be determined is a LOUD WARNING, but
|
|
398
|
+
// only AFTER classifySecurityDeposits has had its say: isAmbiguousDeposit
|
|
399
|
+
// excludes anything isSecurityDeposit recognizes, so a confirmed un-segregated
|
|
400
|
+
// security deposit raises ONLY the ERROR-grade segregation finding (no
|
|
401
|
+
// double-count), while a previously-silent generic-looking deposit is no
|
|
402
|
+
// longer swept under the rug.
|
|
403
|
+
classifyAmbiguousDeposits(book, exceptions);
|
|
404
|
+
|
|
405
|
+
// -- The three-way tie-out. ----------------------------------------------
|
|
406
|
+
// After reconciling items, adjustedBank should equal book, and book should
|
|
407
|
+
// equal the sum of the sub-ledgers.
|
|
408
|
+
const tol = cfg.toleranceCents;
|
|
409
|
+
const bankBookGap = adjustedBank - bookBalance;
|
|
410
|
+
const bookSubGap = bookBalance - subledgerBalance;
|
|
411
|
+
|
|
412
|
+
if (Math.abs(bankBookGap) > tol) {
|
|
413
|
+
pushException(exceptions, {
|
|
414
|
+
type: EXCEPTION.BANK_BOOK_MISMATCH,
|
|
415
|
+
amount: bankBookGap,
|
|
416
|
+
label: "Adjusted bank does not equal book",
|
|
417
|
+
detail:
|
|
418
|
+
`After outstanding items the bank balance (${adjustedBank}) and the ` +
|
|
419
|
+
`book balance (${bookBalance}) differ by ${bankBookGap} cents; an ` +
|
|
420
|
+
"unrecorded transaction or a posting error remains.",
|
|
421
|
+
records: [],
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (Math.abs(bookSubGap) > tol) {
|
|
425
|
+
pushException(exceptions, {
|
|
426
|
+
type: EXCEPTION.SUBLEDGER_OUT_OF_BALANCE,
|
|
427
|
+
amount: bookSubGap,
|
|
428
|
+
label: "Sum of tenant sub-ledgers does not equal book",
|
|
429
|
+
detail:
|
|
430
|
+
`The book balance (${bookBalance}) and the sum of all beneficiary ` +
|
|
431
|
+
`sub-ledger balances (${subledgerBalance}) differ by ${bookSubGap} ` +
|
|
432
|
+
"cents; the trust account is out of trust until this is resolved.",
|
|
433
|
+
records: [],
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// -- Per-beneficiary negative-ledger check (T-41.1). ----------------------
|
|
438
|
+
// ORTHOGONAL to the pooled SUBLEDGER_OUT_OF_BALANCE check above: the SUM of all
|
|
439
|
+
// sub-ledgers can tie perfectly to the book while an INDIVIDUAL beneficiary's
|
|
440
|
+
// balance is negative — one tenant's surplus masking another tenant's deficit
|
|
441
|
+
// in the pooled total. A negative individual ledger means the broker is holding
|
|
442
|
+
// LESS than zero in trust for that person: their money was spent or used to
|
|
443
|
+
// cover someone else. That is out of trust on its own, so flag it regardless of
|
|
444
|
+
// whether the SUM ties. This can only ADD findings (it never removes one), so
|
|
445
|
+
// it is strictly non-looser. A control/sink account is excluded only when it is
|
|
446
|
+
// STRUCTURALLY marked (`controlAccount: true`, authoritative) or its name leads
|
|
447
|
+
// with a control word; a real beneficiary whose name merely contains a control
|
|
448
|
+
// token in a non-leading position is no longer silently dropped.
|
|
449
|
+
classifyNegativeTenantLedgers(subBalances, tol, exceptions, controlAccountKeys(tenants));
|
|
450
|
+
|
|
451
|
+
const tiesOut =
|
|
452
|
+
Math.abs(bankBookGap) <= tol && Math.abs(bookSubGap) <= tol;
|
|
453
|
+
|
|
454
|
+
// Stable, deterministic ordering of exceptions: by severity (errors first),
|
|
455
|
+
// then type, then amount, then a record key — independent of detection order.
|
|
456
|
+
exceptions.sort(compareExceptions);
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
balances: {
|
|
460
|
+
bank: bankBalance,
|
|
461
|
+
book: bookBalance,
|
|
462
|
+
subledger: subledgerBalance,
|
|
463
|
+
adjustedBank,
|
|
464
|
+
reconciled: tiesOut ? bookBalance : null,
|
|
465
|
+
},
|
|
466
|
+
tiesOut,
|
|
467
|
+
exceptions,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Build a CONTINUITY_BREAK exception from a non-zero roll-forward gap. PURE.
|
|
472
|
+
// `cont` is the structured result of close.checkContinuity(priorClose, opening):
|
|
473
|
+
// { ok, bankGap, bookGap }. The exception carries the bank gap in `amount` (the
|
|
474
|
+
// headline number) and BOTH gaps + the prior period label in `detail`, so an
|
|
475
|
+
// auditor reads exactly which leg failed to roll forward and by how much. The
|
|
476
|
+
// severity is the DEFAULT_SEVERITY for the type (error) unless the caller's
|
|
477
|
+
// policy later overrides it — it flows through the SAME applyPolicy path as
|
|
478
|
+
// every other exception. Returns null when there is no gap (ok), so the caller
|
|
479
|
+
// can simply skip a null.
|
|
480
|
+
function buildContinuityException(cont, priorPeriodLabel) {
|
|
481
|
+
if (!cont || cont.ok) return null;
|
|
482
|
+
const bankGap = Number.isInteger(cont.bankGap) ? cont.bankGap : 0;
|
|
483
|
+
const bookGap = Number.isInteger(cont.bookGap) ? cont.bookGap : 0;
|
|
484
|
+
const priorName =
|
|
485
|
+
priorPeriodLabel == null || String(priorPeriodLabel).trim() === ""
|
|
486
|
+
? "the prior period"
|
|
487
|
+
: `prior period "${String(priorPeriodLabel)}"`;
|
|
488
|
+
return {
|
|
489
|
+
type: EXCEPTION.CONTINUITY_BREAK,
|
|
490
|
+
severity: DEFAULT_SEVERITY[EXCEPTION.CONTINUITY_BREAK],
|
|
491
|
+
amount: bankGap,
|
|
492
|
+
label: "Roll-forward continuity break",
|
|
493
|
+
detail:
|
|
494
|
+
`This period's opening balances do not roll forward from ${priorName}: ` +
|
|
495
|
+
`the bank opening differs from the prior ending by ${bankGap} cents and ` +
|
|
496
|
+
`the book opening differs by ${bookGap} cents. A non-zero gap means a ` +
|
|
497
|
+
"period was skipped, edited, or re-keyed and the chain of custody over " +
|
|
498
|
+
"the trust money is broken; reconcile the opening to the prior signed " +
|
|
499
|
+
"ending before relying on this packet.",
|
|
500
|
+
records: [],
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function cmp(a, b) {
|
|
505
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// The canonical, stable severity-first ordering of an exception list: errors
|
|
509
|
+
// before warnings before info, then by type, then by amount, then by a record
|
|
510
|
+
// key — independent of detection order. Exported (and reused by policy.js's
|
|
511
|
+
// applyPolicy) so the order an auditor reads is computed in ONE place and a
|
|
512
|
+
// freshly-escalated ERROR re-sorts to the top of the table the same way a
|
|
513
|
+
// natively-detected one does. Pure: a comparator over two exceptions.
|
|
514
|
+
const SEV_RANK = Object.freeze({ error: 0, warning: 1, info: 2 });
|
|
515
|
+
function compareExceptions(a, b) {
|
|
516
|
+
const sa = SEV_RANK[a.severity] ?? 3;
|
|
517
|
+
const sb = SEV_RANK[b.severity] ?? 3;
|
|
518
|
+
if (sa !== sb) return sa - sb;
|
|
519
|
+
if (a.type !== b.type) return cmp(a.type, b.type);
|
|
520
|
+
if (a.amount !== b.amount) return a.amount - b.amount;
|
|
521
|
+
const ka = a.records && a.records[0] ? recKey(a.records[0]) : "";
|
|
522
|
+
const kb = b.records && b.records[0] ? recKey(b.records[0]) : "";
|
|
523
|
+
return cmp(ka, kb);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Net the rent-roll rows into a per-beneficiary balance map, or accept a
|
|
527
|
+
// precomputed { party -> cents } map directly.
|
|
528
|
+
function tenantBalances(tenants) {
|
|
529
|
+
if (tenants == null) return {};
|
|
530
|
+
if (!Array.isArray(tenants)) {
|
|
531
|
+
// Precomputed balance map.
|
|
532
|
+
const out = {};
|
|
533
|
+
for (const [k, v] of Object.entries(tenants)) {
|
|
534
|
+
if (!Number.isInteger(v)) {
|
|
535
|
+
throw new ReconcileError(`tenant balance for "${k}" must be integer cents`);
|
|
536
|
+
}
|
|
537
|
+
out[k] = v;
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
const out = {};
|
|
542
|
+
for (const r of tenants) {
|
|
543
|
+
const party = String(r.party || "unknown").trim() || "unknown";
|
|
544
|
+
if (!Number.isInteger(r.amount)) {
|
|
545
|
+
throw new ReconcileError("tenant record.amount must be integer cents");
|
|
546
|
+
}
|
|
547
|
+
out[party] = (out[party] || 0) + r.amount;
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Pull the matcher's unmatched list for one side, falling back to "everything"
|
|
553
|
+
// when no matchResult was supplied (so the function still works standalone).
|
|
554
|
+
function matcherUnmatched(matchResult, key, bank, book, side) {
|
|
555
|
+
if (matchResult && Array.isArray(matchResult[key])) {
|
|
556
|
+
return matchResult[key];
|
|
557
|
+
}
|
|
558
|
+
// No matcher result: derive a best-effort reconciling set by cancelling out
|
|
559
|
+
// bank/book lines that share the same (amount) so only the genuine residue
|
|
560
|
+
// remains. This keeps reconcile() useful on its own while staying deterministic.
|
|
561
|
+
if (!matchResult) {
|
|
562
|
+
return residue(side === "bank" ? bank : book, side === "bank" ? book : bank);
|
|
563
|
+
}
|
|
564
|
+
return [];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Multiset difference by amount: return the records in `self` whose amounts are
|
|
568
|
+
// not cancelled by an equal amount in `other`. Deterministic.
|
|
569
|
+
function residue(self, other) {
|
|
570
|
+
const otherCounts = new Map();
|
|
571
|
+
for (const r of other) {
|
|
572
|
+
otherCounts.set(r.amount, (otherCounts.get(r.amount) || 0) + 1);
|
|
573
|
+
}
|
|
574
|
+
const out = [];
|
|
575
|
+
for (const r of [...self].sort((x, y) => cmp(recKey(x), recKey(y)))) {
|
|
576
|
+
const c = otherCounts.get(r.amount) || 0;
|
|
577
|
+
if (c > 0) {
|
|
578
|
+
otherCounts.set(r.amount, c - 1);
|
|
579
|
+
} else {
|
|
580
|
+
out.push(r);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return out;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Owner draws (T-22.3 + T-42.1). TWO findings, computed in ONE pass over the
|
|
587
|
+
// owner activity in the book:
|
|
588
|
+
//
|
|
589
|
+
// 1. OWNER_DRAW (warning, per line) — every owner-draw line is classified so a
|
|
590
|
+
// human confirms it was paid only from the owner's OWN funds.
|
|
591
|
+
//
|
|
592
|
+
// 2. OWNER_OVERDRAW (ERROR, per owner account) — when an owner DRAWS MORE than
|
|
593
|
+
// that owner CONTRIBUTED in this period's book, the EXCESS is tenant money:
|
|
594
|
+
// the owner paid themselves out of someone else's trust funds. This is the
|
|
595
|
+
// single most-prosecuted residential-PM trust violation (conversion), and
|
|
596
|
+
// before T-42.1 it was a SILENT PASS whenever the owner was modeled as a
|
|
597
|
+
// control-account sub-ledger party — the pooled SUM still ties to the book
|
|
598
|
+
// via the owner's negative bucket, and the EPIC-41 negative-ledger check
|
|
599
|
+
// deliberately EXCLUDES control/owner accounts (an owner's negative is
|
|
600
|
+
// structural WHILE it stays within the owner's own contributed capital).
|
|
601
|
+
// That EPIC-41 exclusion was UNBOUNDED: it also swallowed the negative
|
|
602
|
+
// BEYOND contributed capital. OWNER_OVERDRAW is the precise INVERSE: EPIC-41
|
|
603
|
+
// keeps ignoring the negative WITHIN contributed capital (the owner
|
|
604
|
+
// legitimately deploying their OWN funds, which every existing owner-draw
|
|
605
|
+
// test exercises and which stays PASS), and this check catches only the
|
|
606
|
+
// negative BEYOND it.
|
|
607
|
+
//
|
|
608
|
+
// Per owner account (keyed by the draw's party, case-folded):
|
|
609
|
+
// C = contributed capital = the sum of that account's OWN positive book inflows
|
|
610
|
+
// in this period (the basis the owner is entitled to draw against).
|
|
611
|
+
// D = total draws = the sum of |amount| of that account's owner-draw
|
|
612
|
+
// lines in the book.
|
|
613
|
+
// B = the account's sub-ledger balance (how negative the owner actually went).
|
|
614
|
+
// The over-capital excess is `D - C`, BOUNDED by the owner's actual negative
|
|
615
|
+
// `-B` so we never claim more tenant money than is genuinely missing. We only
|
|
616
|
+
// assess overdraw when the owner ESTABLISHED an in-period contribution basis
|
|
617
|
+
// (`C > 0`): absent any in-period contribution, the sub-ledger negative is
|
|
618
|
+
// treated as legitimate OPENING owner capital being deployed (the EPIC-41
|
|
619
|
+
// boundary) and is NOT second-guessed from a name. `toleranceCents` is honored
|
|
620
|
+
// (an excess at or below tolerance is not flagged). This can only ADD a finding,
|
|
621
|
+
// never remove one, so it is STRICTLY non-looser. PURE + order-independent.
|
|
622
|
+
function classifyOwnerDraws(book, subBalances, exceptions, toleranceCents) {
|
|
623
|
+
const tol = Number.isInteger(toleranceCents) && toleranceCents >= 0 ? toleranceCents : 0;
|
|
624
|
+
|
|
625
|
+
// Per-owner-account accumulation of draws (D) and contributed capital (C),
|
|
626
|
+
// keyed by the case-folded party so a deposit and a draw that name the same
|
|
627
|
+
// owner account aggregate together. Order-independent.
|
|
628
|
+
const draws = new Map(); // ownerKey -> total |draw amount|
|
|
629
|
+
const capital = new Map(); // ownerKey -> total positive book inflow
|
|
630
|
+
const drawRecords = new Map(); // ownerKey -> the owner-draw record sorted first (for naming)
|
|
631
|
+
|
|
632
|
+
const sortedBook = [...book].sort((x, y) => cmp(recKey(x), recKey(y)));
|
|
633
|
+
|
|
634
|
+
for (const r of sortedBook) {
|
|
635
|
+
if (isOwnerDraw(r)) {
|
|
636
|
+
const key = partyKey(r.party);
|
|
637
|
+
draws.set(key, (draws.get(key) || 0) + Math.abs(r.amount));
|
|
638
|
+
if (!drawRecords.has(key)) drawRecords.set(key, r);
|
|
639
|
+
// Already emitted as OUTSTANDING owner draw above if it was unmatched; emit
|
|
640
|
+
// the policy-level OWNER_DRAW classification here once, deduped by record.
|
|
641
|
+
if (
|
|
642
|
+
exceptions.some(
|
|
643
|
+
(e) => e.type === EXCEPTION.OWNER_DRAW && e.records[0] && recKey(e.records[0]) === recKey(r)
|
|
644
|
+
)
|
|
645
|
+
) {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
pushException(exceptions, {
|
|
649
|
+
type: EXCEPTION.OWNER_DRAW,
|
|
650
|
+
amount: r.amount,
|
|
651
|
+
label: "Owner draw",
|
|
652
|
+
detail:
|
|
653
|
+
"A disbursement to the property owner; confirm it is paid only from " +
|
|
654
|
+
"that owner's own funds and never from tenant or security-deposit money.",
|
|
655
|
+
records: [r],
|
|
656
|
+
});
|
|
657
|
+
} else if (Number.isInteger(r.amount) && r.amount > 0) {
|
|
658
|
+
// A positive book inflow attributed to a party is that party's contributed
|
|
659
|
+
// capital basis (only consulted for parties that also have owner draws).
|
|
660
|
+
const key = partyKey(r.party);
|
|
661
|
+
if (key !== "") capital.set(key, (capital.get(key) || 0) + r.amount);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// The owner's sub-ledger balance per case-folded key (an owner account may be
|
|
666
|
+
// spelled with different casing in the sub-ledger than in a book line; fold).
|
|
667
|
+
const ownerBalance = new Map();
|
|
668
|
+
for (const [party, bal] of Object.entries(subBalances)) {
|
|
669
|
+
if (!Number.isInteger(bal)) continue;
|
|
670
|
+
const key = partyKey(party);
|
|
671
|
+
ownerBalance.set(key, (ownerBalance.get(key) || 0) + bal);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// OWNER_OVERDRAW: per owner account, in a stable key-sorted order.
|
|
675
|
+
for (const key of [...draws.keys()].sort(cmp)) {
|
|
676
|
+
const D = draws.get(key) || 0;
|
|
677
|
+
const C = capital.get(key) || 0;
|
|
678
|
+
if (C <= 0) continue; // no in-period basis: EPIC-41 boundary, not second-guessed
|
|
679
|
+
const overCapital = D - C;
|
|
680
|
+
if (overCapital <= 0) continue; // drew at or below contributed capital — fine
|
|
681
|
+
// Bound by how negative the owner actually went, so we never claim more
|
|
682
|
+
// tenant money than is genuinely missing (e.g. opening owner capital covered
|
|
683
|
+
// part of it). When no owner bucket exists, the full over-capital amount is
|
|
684
|
+
// the unbacked excess.
|
|
685
|
+
const B = ownerBalance.has(key) ? ownerBalance.get(key) : -overCapital;
|
|
686
|
+
const shortfall = B < 0 ? -B : 0;
|
|
687
|
+
const excess = Math.min(overCapital, shortfall);
|
|
688
|
+
if (excess <= tol) continue; // within tolerance (or fully backed by capital)
|
|
689
|
+
|
|
690
|
+
const r = drawRecords.get(key);
|
|
691
|
+
const who = beneficiaryLabel(r ? r.party : "");
|
|
692
|
+
pushException(exceptions, {
|
|
693
|
+
type: EXCEPTION.OWNER_OVERDRAW,
|
|
694
|
+
amount: excess, // the EXCESS (tenant money consumed), a positive cents figure
|
|
695
|
+
label: "Owner draw exceeds contributed capital",
|
|
696
|
+
detail:
|
|
697
|
+
`Owner account ${who} drew ${fmtCentsForDetail(D)} against only ` +
|
|
698
|
+
`${fmtCentsForDetail(C)} of its OWN contributed capital, so ` +
|
|
699
|
+
`${fmtCentsForDetail(excess)} of the draw was paid out of other ` +
|
|
700
|
+
"beneficiaries' trust money. An owner may be disbursed only from their " +
|
|
701
|
+
"own funds; paying an owner out of tenant or security-deposit money is a " +
|
|
702
|
+
"conversion of trust funds and leaves the account out of trust. Restore " +
|
|
703
|
+
`${fmtCentsForDetail(excess)} to the trust account before relying on this packet.`,
|
|
704
|
+
records: r ? [r] : [],
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// A segregation movement is an OUTFLOW whose memo/kind references segregation /
|
|
710
|
+
// transfer of the deposit. PURE: free-text classification only.
|
|
711
|
+
function isSegregationMove(r) {
|
|
712
|
+
const t = `${r.memo || ""} ${r.kind || ""}`.toLowerCase();
|
|
713
|
+
return (
|
|
714
|
+
r.amount < 0 &&
|
|
715
|
+
(/segregat|transfer to (security|escrow|trust)|to security deposit account|escrow/.test(t) ||
|
|
716
|
+
(isSecurityDeposit(r) && /transfer|segregat|escrow/.test(t)))
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Tokenize a string into lowercase WHOLE word tokens (alphanumeric runs). The
|
|
721
|
+
// boundary is /[^a-z0-9]+/ so punctuation, spaces, and unit markers separate
|
|
722
|
+
// tokens. Used to make beneficiary-name matching WORD-BOUNDED instead of a raw
|
|
723
|
+
// substring test. PURE.
|
|
724
|
+
function nameTokens(s) {
|
|
725
|
+
return String(s == null ? "" : s)
|
|
726
|
+
.toLowerCase()
|
|
727
|
+
.split(/[^a-z0-9]+/)
|
|
728
|
+
.filter((t) => t !== "");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Build a name-matching index over the beneficiary keys ONCE per reconcile pass.
|
|
732
|
+
// The index lets a transfer's memo be matched against beneficiary names in time
|
|
733
|
+
// proportional to the MEMO length (not the number of beneficiaries D), closing
|
|
734
|
+
// the O(D × T) algorithmic-complexity blowup the per-transfer O(D) scan caused on
|
|
735
|
+
// the untrusted-upload endpoint. Structure: a Map from each key's FIRST token to
|
|
736
|
+
// the list of { key, tokens } whose name begins with that token. A memo can then
|
|
737
|
+
// be probed by walking its tokens once and, at each position, checking only the
|
|
738
|
+
// keys that share that starting token. PURE.
|
|
739
|
+
function buildDepositNameIndex(depositKeys) {
|
|
740
|
+
const byFirstToken = new Map();
|
|
741
|
+
for (const key of depositKeys) {
|
|
742
|
+
if (key === "") continue;
|
|
743
|
+
const tokens = nameTokens(key);
|
|
744
|
+
if (tokens.length === 0) continue; // a key with no word characters cannot be named in a memo
|
|
745
|
+
const first = tokens[0];
|
|
746
|
+
let bucket = byFirstToken.get(first);
|
|
747
|
+
if (!bucket) {
|
|
748
|
+
bucket = [];
|
|
749
|
+
byFirstToken.set(first, bucket);
|
|
750
|
+
}
|
|
751
|
+
bucket.push({ key, tokens });
|
|
752
|
+
}
|
|
753
|
+
return byFirstToken;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Does `memoTokens` contain `keyTokens` as a CONTIGUOUS run starting at index i?
|
|
757
|
+
function tokensMatchAt(memoTokens, i, keyTokens) {
|
|
758
|
+
if (i + keyTokens.length > memoTokens.length) return false;
|
|
759
|
+
for (let j = 0; j < keyTokens.length; j++) {
|
|
760
|
+
if (memoTokens[i + j] !== keyTokens[j]) return false;
|
|
761
|
+
}
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Attribute a segregation transfer to the BENEFICIARY whose deposit it covers.
|
|
766
|
+
// Trust law segregates EACH tenant's deposit SEPARATELY, so a transfer's coverage
|
|
767
|
+
// must be pinned to one beneficiary — it can NEVER spill its excess onto another
|
|
768
|
+
// tenant's un-segregated deposit. Two signals, in priority order:
|
|
769
|
+
// 1. The transfer's own `party` field, if it names a beneficiary that actually
|
|
770
|
+
// holds a security deposit (the structured, authoritative signal).
|
|
771
|
+
// 2. Failing that, a beneficiary NAME appearing in the transfer's memo (so a
|
|
772
|
+
// "Transfer Jones security deposit to escrow" line still attributes even
|
|
773
|
+
// with no party column).
|
|
774
|
+
// A transfer that matches NEITHER is GENERIC (returns "") — it provides only a
|
|
775
|
+
// residual pool that the existing same-amount mirror tests rely on, and is the
|
|
776
|
+
// fail-loud sentinel for an unattributable transfer (it can clear at most a
|
|
777
|
+
// still-uncovered deposit, never silently absorb one tenant's shortage into
|
|
778
|
+
// another's surplus).
|
|
779
|
+
//
|
|
780
|
+
// The memo fallback is WORD-BOUNDED: a key matches only as a contiguous run of
|
|
781
|
+
// WHOLE memo tokens, never as an incidental substring. A raw `memo.includes(key)`
|
|
782
|
+
// mis-pins ordinary surnames to standard segregation vocabulary — "escrow"
|
|
783
|
+
// contains "crow", "transfer" contains "tran" — silently stranding generic
|
|
784
|
+
// coverage on, or falsely clearing, an unrelated real tenant. Whole-token
|
|
785
|
+
// matching makes "Transfer to escrow" attribute to NOBODY (no token equals a
|
|
786
|
+
// beneficiary key), leaving it correctly in the generic residual pool.
|
|
787
|
+
//
|
|
788
|
+
// `index` is the prebuilt Map from buildDepositNameIndex (first token -> keys),
|
|
789
|
+
// so attribution is O(memo tokens), not O(beneficiaries). Among all matching
|
|
790
|
+
// keys the LONGEST (most tokens, then longest string) wins, deterministically, so
|
|
791
|
+
// "jones (4b)" beats a bare "jones". PURE + deterministic.
|
|
792
|
+
function attributeSegregation(transfer, depositKeys, index) {
|
|
793
|
+
const own = partyKey(transfer.party);
|
|
794
|
+
if (own !== "" && depositKeys.has(own)) return own;
|
|
795
|
+
const memoTokens = nameTokens(transfer.memo);
|
|
796
|
+
if (memoTokens.length === 0) return "";
|
|
797
|
+
let best = "";
|
|
798
|
+
let bestTokenLen = 0;
|
|
799
|
+
for (let i = 0; i < memoTokens.length; i++) {
|
|
800
|
+
const bucket = index.get(memoTokens[i]);
|
|
801
|
+
if (!bucket) continue;
|
|
802
|
+
for (const { key, tokens } of bucket) {
|
|
803
|
+
if (!tokensMatchAt(memoTokens, i, tokens)) continue;
|
|
804
|
+
// Longest match wins: more tokens first, then longer key string, so the
|
|
805
|
+
// tie-break is total and order-independent across the bucket.
|
|
806
|
+
if (
|
|
807
|
+
tokens.length > bestTokenLen ||
|
|
808
|
+
(tokens.length === bestTokenLen && key.length > best.length)
|
|
809
|
+
) {
|
|
810
|
+
best = key;
|
|
811
|
+
bestTokenLen = tokens.length;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return best;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Security-deposit segregation: every security-deposit RECEIPT recorded in the
|
|
819
|
+
// book must have a corresponding movement OUT to a segregated account (or be
|
|
820
|
+
// flagged as held separately). If a security-deposit inflow is sitting in the
|
|
821
|
+
// operating/pooled book with no offsetting segregation transfer, that is a
|
|
822
|
+
// compliance finding in many states.
|
|
823
|
+
//
|
|
824
|
+
// PER-BENEFICIARY MATCHING (T-40.1). Trust law requires EACH tenant's deposit be
|
|
825
|
+
// held SEPARATELY, so coverage is matched PER BENEFICIARY — never from a single
|
|
826
|
+
// pooled total. Concretely: a segregation transfer attributed to tenant X covers
|
|
827
|
+
// ONLY X's deposits; its excess does NOT spill onto another tenant Y's
|
|
828
|
+
// un-segregated deposit (the false-negative the pooled FIFO produced). This can
|
|
829
|
+
// only ADD or RE-ATTRIBUTE a finding versus the old pooled sum — never remove a
|
|
830
|
+
// real one — so it is STRICTLY non-looser.
|
|
831
|
+
function classifySecurityDeposits(book, bank, exceptions) {
|
|
832
|
+
// Find security-deposit inflows in the book, grouped by beneficiary key.
|
|
833
|
+
const secDeposits = [...book]
|
|
834
|
+
.filter((r) => isSecurityDeposit(r) && r.amount > 0)
|
|
835
|
+
.sort((x, y) => cmp(recKey(x), recKey(y)));
|
|
836
|
+
if (secDeposits.length === 0) return;
|
|
837
|
+
|
|
838
|
+
// The set of beneficiary keys that actually hold a security deposit — the only
|
|
839
|
+
// keys a transfer may attribute to. The name index is built ONCE here so the
|
|
840
|
+
// per-transfer memo fallback is O(memo length), not O(beneficiaries).
|
|
841
|
+
const depositKeys = new Set(secDeposits.map((r) => partyKey(r.party)));
|
|
842
|
+
const depositNameIndex = buildDepositNameIndex(depositKeys);
|
|
843
|
+
|
|
844
|
+
// CRITICAL: count each segregation transfer from ONE authoritative source —
|
|
845
|
+
// the BOOK — never from both book and bank. A single real segregation transfer
|
|
846
|
+
// is recorded twice (once in QuickBooks, once on the bank statement) because it
|
|
847
|
+
// is the same money movement seen from two sources. Summing across both sources
|
|
848
|
+
// double-counts one $X transfer as $2X of coverage, which can SILENTLY CLEAR a
|
|
849
|
+
// genuinely un-segregated security deposit — a false negative on the flagship
|
|
850
|
+
// finding this product exists to catch. The bank-side copy is the mirror of the
|
|
851
|
+
// same movement (match.js pairs them); it adds no NEW segregation, so it must
|
|
852
|
+
// NOT add coverage. `bank` is therefore intentionally unused for the sum.
|
|
853
|
+
void bank;
|
|
854
|
+
|
|
855
|
+
// Bucket each book segregation move's coverage by the beneficiary it is
|
|
856
|
+
// attributed to. A GENERIC (unattributable) transfer (key "") goes into a
|
|
857
|
+
// residual pool applied ONLY to deposits no attributed transfer covered — it
|
|
858
|
+
// can clear at most a still-uncovered deposit, never silently net one tenant's
|
|
859
|
+
// shortage against another tenant's surplus.
|
|
860
|
+
const coveredByParty = new Map(); // key -> cents available
|
|
861
|
+
let genericPool = 0;
|
|
862
|
+
for (const r of [...book]
|
|
863
|
+
.filter(isSegregationMove)
|
|
864
|
+
.sort((x, y) => cmp(recKey(x), recKey(y)))) {
|
|
865
|
+
const key = attributeSegregation(r, depositKeys, depositNameIndex);
|
|
866
|
+
const cents = Math.abs(r.amount);
|
|
867
|
+
if (key === "") {
|
|
868
|
+
genericPool += cents;
|
|
869
|
+
} else {
|
|
870
|
+
coveredByParty.set(key, (coveredByParty.get(key) || 0) + cents);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Apply each beneficiary's OWN coverage to that beneficiary's deposits first;
|
|
875
|
+
// any deposit still short then draws from the GENERIC residual pool (preserving
|
|
876
|
+
// the long-standing behavior for transfers that name no tenant). A deposit that
|
|
877
|
+
// remains short after both is flagged — attributed to the RIGHT beneficiary.
|
|
878
|
+
const stillShort = [];
|
|
879
|
+
for (const r of secDeposits) {
|
|
880
|
+
const key = partyKey(r.party);
|
|
881
|
+
let need = r.amount;
|
|
882
|
+
const own = coveredByParty.get(key) || 0;
|
|
883
|
+
const fromOwn = Math.min(own, need);
|
|
884
|
+
coveredByParty.set(key, own - fromOwn);
|
|
885
|
+
need -= fromOwn;
|
|
886
|
+
if (need > 0) stillShort.push({ rec: r, need });
|
|
887
|
+
}
|
|
888
|
+
// Generic pool covers whatever remains, in the same deterministic order.
|
|
889
|
+
for (const { rec: r, need } of stillShort) {
|
|
890
|
+
if (genericPool >= need) {
|
|
891
|
+
genericPool -= need;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
// The genuinely-UNCOVERED amount is what the generic residual pool could not
|
|
895
|
+
// cover (`need` minus whatever this draw consumed) — never the full deposit
|
|
896
|
+
// when the pool partially covered it. This is the number at risk in trust.
|
|
897
|
+
const uncovered = need - genericPool;
|
|
898
|
+
genericPool = 0; // a partial generic draw is consumed; the rest is a finding
|
|
899
|
+
// NAME the at-risk beneficiary so the finding (and the report row) says WHO is
|
|
900
|
+
// exposed, not just that "a" deposit is un-segregated. A deposit with no party
|
|
901
|
+
// attributed falls back to an explicit "(unattributed beneficiary)" sentinel
|
|
902
|
+
// rather than an empty string, so the sentence never reads as a dangling name.
|
|
903
|
+
const who = beneficiaryLabel(r.party);
|
|
904
|
+
pushException(exceptions, {
|
|
905
|
+
type: EXCEPTION.SECURITY_DEPOSIT_SEGREGATION,
|
|
906
|
+
amount: r.amount,
|
|
907
|
+
label: "Security deposit not segregated",
|
|
908
|
+
detail:
|
|
909
|
+
`Security deposit for ${who} is not segregated: ${fmtCentsForDetail(uncovered)} ` +
|
|
910
|
+
"of this receipt has no matching transfer to a segregated deposit account. " +
|
|
911
|
+
"Many states require each beneficiary's security deposit be held separately " +
|
|
912
|
+
"from operating trust funds; transfer the uncovered amount to a segregated " +
|
|
913
|
+
`account attributed to ${who}.`,
|
|
914
|
+
records: [r],
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// A human-readable beneficiary label for a finding sentence. Uses the record's
|
|
920
|
+
// raw party string verbatim when present (so "Jones (4B)" reads naturally), and a
|
|
921
|
+
// loud, explicit sentinel when the deposit names no beneficiary — never an empty
|
|
922
|
+
// string that would leave the sentence dangling. PURE.
|
|
923
|
+
function beneficiaryLabel(party) {
|
|
924
|
+
const p = String(party == null ? "" : party).trim();
|
|
925
|
+
return p === "" ? "an unattributed beneficiary" : p;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Format integer cents as a signed dollar string ("$1,234.56") for embedding in a
|
|
929
|
+
// finding's detail sentence. reconcile.js is the pure core and intentionally does
|
|
930
|
+
// NOT depend on report.js, so this mirrors report.fmtCents's grouping locally with
|
|
931
|
+
// no new dependency. Deterministic; throws on non-integer input (no float money).
|
|
932
|
+
function fmtCentsForDetail(cents) {
|
|
933
|
+
if (!Number.isInteger(cents)) {
|
|
934
|
+
throw new ReconcileError("fmtCentsForDetail requires integer cents");
|
|
935
|
+
}
|
|
936
|
+
const neg = cents < 0;
|
|
937
|
+
const abs = Math.abs(cents);
|
|
938
|
+
const dollars = Math.floor(abs / 100);
|
|
939
|
+
const rem = abs % 100;
|
|
940
|
+
const grouped = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
941
|
+
const body = `$${grouped}.${String(rem).padStart(2, "0")}`;
|
|
942
|
+
return neg ? `-${body}` : body;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// The CLOSED set of control / sink account designations whose negative balance
|
|
946
|
+
// is STRUCTURAL, not a tenant shortage. The pooled trust account holds
|
|
947
|
+
// beneficiaries' money, but the per-party sub-ledger map also carries a few
|
|
948
|
+
// non-beneficiary control accounts whose balance is legitimately negative:
|
|
949
|
+
// * the OWNER's-own-funds line — an owner funds the account and DRAWS against
|
|
950
|
+
// their OWN money, so a negative owner position is expected (it is the
|
|
951
|
+
// owner's capital being deployed, not a tenant's trust money vanishing); and
|
|
952
|
+
// * an ESCROW / SEGREGATED / TRUST sink, or an OPERATING / RESERVE / SUSPENSE
|
|
953
|
+
// control line, which RECEIVES the offsetting outflow (the money that left
|
|
954
|
+
// the pooled side to be held separately), so it nets negative by design.
|
|
955
|
+
const CONTROL_ACCOUNT_TOKENS = Object.freeze([
|
|
956
|
+
"owner",
|
|
957
|
+
"owners",
|
|
958
|
+
"escrow",
|
|
959
|
+
"segregated",
|
|
960
|
+
"trust",
|
|
961
|
+
"operating",
|
|
962
|
+
"reserve",
|
|
963
|
+
"suspense",
|
|
964
|
+
]);
|
|
965
|
+
|
|
966
|
+
// Does a free-text party name READ as a control-account DESIGNATION? A negative
|
|
967
|
+
// balance on a control/sink account is structural; a negative balance on any
|
|
968
|
+
// OTHER party is a real beneficiary whose trust money is gone, so this is the
|
|
969
|
+
// ONLY thing that suppresses a negative_tenant_ledger finding when the caller
|
|
970
|
+
// gave us no structured marker.
|
|
971
|
+
//
|
|
972
|
+
// NARROWLY ANCHORED to the LEADING token (the account-designation position):
|
|
973
|
+
// only when the FIRST whole-word token of the name is a control word
|
|
974
|
+
// ("Owner ...", "Escrow ...", "Reserve ...") do we treat the line as a control
|
|
975
|
+
// account. The previous rule matched a control token ANYWHERE in the name, which
|
|
976
|
+
// silently OVER-EXCLUDED real beneficiaries whose name merely contains a control
|
|
977
|
+
// word — "Smith (OWNER)", "Jones Family Trust", "Tenant 12 Reserve St" — and so
|
|
978
|
+
// swallowed exactly the shortage T-41.1 exists to surface. The leading-token
|
|
979
|
+
// anchor keeps the genuine designations ("Owner Acme", "Escrow") excluded while
|
|
980
|
+
// no longer dropping a beneficiary whose unit/address/entity name happens to
|
|
981
|
+
// carry a control token in a NON-leading position.
|
|
982
|
+
//
|
|
983
|
+
// LIMITATION (documented in docs/TRUSTLEDGER.md): a name heuristic cannot tell a
|
|
984
|
+
// real company named "Operating Co LLC" from an "Operating" control account, so a
|
|
985
|
+
// leading control word is still treated as a designation. A real beneficiary
|
|
986
|
+
// whose name LEADS with a control word — and any control account the broker wants
|
|
987
|
+
// recognized unambiguously — must carry the STRUCTURED `controlAccount` marker,
|
|
988
|
+
// which is authoritative over this guess (see controlAccountKeys / the
|
|
989
|
+
// per-row/per-balance marker). Still WORD-BOUNDED so an ordinary surname that
|
|
990
|
+
// merely CONTAINS one of these tokens ("Owens", "Crowell") is never mistaken for
|
|
991
|
+
// a control account. PURE.
|
|
992
|
+
function isControlAccountParty(party) {
|
|
993
|
+
const tokens = nameTokens(party);
|
|
994
|
+
if (tokens.length === 0) return false;
|
|
995
|
+
return CONTROL_ACCOUNT_TOKENS.includes(tokens[0]);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Mirror the EXACT per-party normalization tenantBalances uses for the array
|
|
999
|
+
// (rent-roll) form, so a control-account marker keys to the SAME sub-ledger
|
|
1000
|
+
// bucket the balance lives under. PURE.
|
|
1001
|
+
function normTenantParty(party) {
|
|
1002
|
+
return String(party == null ? "unknown" : party).trim() || "unknown";
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// The set of sub-ledger party keys the CALLER has STRUCTURALLY asserted are
|
|
1006
|
+
// control / sink accounts (not beneficiaries), via an explicit `controlAccount:
|
|
1007
|
+
// true` marker. This is a deliberate, structured assertion by the producer of
|
|
1008
|
+
// the data — distinct from us GUESSING from the free-text name — and is therefore
|
|
1009
|
+
// AUTHORITATIVE: a marked party is excluded from the negative-ledger finding no
|
|
1010
|
+
// matter what its name reads like, and (because the marker only ever ADDS to the
|
|
1011
|
+
// exclusion set, never removes the name heuristic) it can only make the check
|
|
1012
|
+
// MORE permissive about structural negatives, never flag fewer real shortages
|
|
1013
|
+
// than the name heuristic alone would. Mirrors how hasExplicitDepositLabel
|
|
1014
|
+
// prefers a structured assertion over a free-text guess.
|
|
1015
|
+
//
|
|
1016
|
+
// The marker is honored on the ARRAY (rent-roll) form, where each row may carry
|
|
1017
|
+
// `controlAccount: true`; ANY such row marks its party's bucket. The precomputed
|
|
1018
|
+
// `{ party: cents }` map form has no per-key slot for a marker, so a bare map
|
|
1019
|
+
// falls back to the name heuristic alone (a caller who needs to mark a control
|
|
1020
|
+
// account in the map form should supply rows or rely on the leading-token name).
|
|
1021
|
+
// Returns a Set of normalized party keys. PURE + order-independent.
|
|
1022
|
+
function controlAccountKeys(tenants) {
|
|
1023
|
+
const keys = new Set();
|
|
1024
|
+
if (!Array.isArray(tenants)) return keys;
|
|
1025
|
+
for (const r of tenants) {
|
|
1026
|
+
if (r && r.controlAccount === true) {
|
|
1027
|
+
keys.add(normTenantParty(r.party));
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return keys;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Per-beneficiary negative-ledger check (T-41.1). Flag EACH individual
|
|
1034
|
+
// beneficiary whose own sub-ledger balance is negative beyond tolerance — i.e.
|
|
1035
|
+
// the broker is holding LESS than zero in trust for that person, because their
|
|
1036
|
+
// money was spent or used to cover another beneficiary's shortfall. This is
|
|
1037
|
+
// ORTHOGONAL to the pooled SUBLEDGER_OUT_OF_BALANCE check: the SUM of all
|
|
1038
|
+
// sub-ledgers can tie perfectly to the book (one tenant's surplus masking
|
|
1039
|
+
// another's deficit) while an individual ledger is negative, so this check fires
|
|
1040
|
+
// independently of the SUM. A control/sink account is NOT a beneficiary and is
|
|
1041
|
+
// excluded — its negative balance is structural, not a shortage. A control
|
|
1042
|
+
// account is recognized by EITHER an authoritative STRUCTURED `controlAccount`
|
|
1043
|
+
// marker (`controlKeys`, preferred) OR, absent a marker, a NARROWLY-ANCHORED
|
|
1044
|
+
// leading-token name heuristic (owner/escrow/segregated/trust/operating/reserve/
|
|
1045
|
+
// suspense in the FIRST name token) — see isControlAccountParty for why the
|
|
1046
|
+
// heuristic is anchored to the leading token and its documented limitation.
|
|
1047
|
+
// `toleranceCents` is honored: a balance is flagged only when it is below
|
|
1048
|
+
// `-toleranceCents`, so a caller's deliberate penny-tolerance applies the SAME
|
|
1049
|
+
// way it does to the SUM checks. Deterministic + order-independent: beneficiaries
|
|
1050
|
+
// are flagged in a stable key-sorted order. This can only ADD findings, never
|
|
1051
|
+
// remove one (strictly non-looser). PURE.
|
|
1052
|
+
function classifyNegativeTenantLedgers(subBalances, toleranceCents, exceptions, controlKeys) {
|
|
1053
|
+
const tol = Number.isInteger(toleranceCents) && toleranceCents >= 0 ? toleranceCents : 0;
|
|
1054
|
+
const control = controlKeys instanceof Set ? controlKeys : new Set();
|
|
1055
|
+
const parties = Object.keys(subBalances).sort(cmp);
|
|
1056
|
+
for (const party of parties) {
|
|
1057
|
+
const bal = subBalances[party];
|
|
1058
|
+
if (!Number.isInteger(bal)) continue; // tenantBalances already validated; guard anyway
|
|
1059
|
+
if (bal >= -tol) continue; // zero/positive (or within tolerance) is fine
|
|
1060
|
+
// A structured marker is authoritative; absent one, fall back to the
|
|
1061
|
+
// narrowly-anchored leading-token name heuristic.
|
|
1062
|
+
if (control.has(party) || isControlAccountParty(party)) continue; // structural negative, not a shortage
|
|
1063
|
+
const who = beneficiaryLabel(party);
|
|
1064
|
+
pushException(exceptions, {
|
|
1065
|
+
type: EXCEPTION.NEGATIVE_TENANT_LEDGER,
|
|
1066
|
+
amount: bal, // the negative balance itself (signed), so the row says how short
|
|
1067
|
+
label: "Beneficiary ledger is negative",
|
|
1068
|
+
detail:
|
|
1069
|
+
`The individual trust ledger for ${who} is negative (${fmtCentsForDetail(bal)}): ` +
|
|
1070
|
+
"the broker is holding less than zero in trust for this beneficiary, so " +
|
|
1071
|
+
"their money has been spent or used to cover another beneficiary's " +
|
|
1072
|
+
"shortfall. A negative individual ledger is out of trust even when the " +
|
|
1073
|
+
"pooled sum of all sub-ledgers still ties to the book; restore the " +
|
|
1074
|
+
"beneficiary's balance to at least zero before relying on this packet.",
|
|
1075
|
+
records: [],
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Ambiguous deposits: every book INFLOW that calls itself a deposit but whose
|
|
1081
|
+
// beneficiary type we cannot determine (no recognized purpose keyword, not an
|
|
1082
|
+
// explicitly-labeled rent/receipt) becomes a LOUD, gradable WARNING finding
|
|
1083
|
+
// rather than passing silently as a generic deposit. A record that already
|
|
1084
|
+
// surfaced as a SECURITY_DEPOSIT_SEGREGATION finding is NOT re-flagged here:
|
|
1085
|
+
// isAmbiguousDeposit already excludes anything isSecurityDeposit recognizes, so
|
|
1086
|
+
// there is no double-count of the same row across the two findings. PURE +
|
|
1087
|
+
// deterministic: order-independent (sorted by record key) free-text classification.
|
|
1088
|
+
function classifyAmbiguousDeposits(book, exceptions) {
|
|
1089
|
+
for (const r of [...book]
|
|
1090
|
+
.filter(isAmbiguousDeposit)
|
|
1091
|
+
.sort((x, y) => cmp(recKey(x), recKey(y)))) {
|
|
1092
|
+
pushException(exceptions, {
|
|
1093
|
+
type: EXCEPTION.AMBIGUOUS_DEPOSIT,
|
|
1094
|
+
amount: r.amount,
|
|
1095
|
+
label: "Ambiguous deposit (beneficiary type undetermined)",
|
|
1096
|
+
detail:
|
|
1097
|
+
"A book deposit with no recognizable beneficiary-type keyword (it is " +
|
|
1098
|
+
"not clearly rent, an owner contribution, or a labeled security " +
|
|
1099
|
+
"deposit). Confirm what it is: an un-segregated security deposit hiding " +
|
|
1100
|
+
"as a generic deposit is an out-of-trust finding. Label the row " +
|
|
1101
|
+
"(e.g. kind \"rent\", a security-deposit memo, or an explicit deposit " +
|
|
1102
|
+
"type) so this resolves.",
|
|
1103
|
+
records: [r],
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
// Triage (T-43.1): classify every finding by ROOT-CAUSE CLASS, roll it up by
|
|
1110
|
+
// dollar impact, and name the single most-important thing to fix.
|
|
1111
|
+
// ---------------------------------------------------------------------------
|
|
1112
|
+
//
|
|
1113
|
+
// A FAIL verdict today is a COUNT, not a cause: "N exception(s) [X error, Y
|
|
1114
|
+
// warning, Z info]". A broker reading that cannot tell the make-or-break thing
|
|
1115
|
+
// at first contact — is the trust account GENUINELY OUT OF TRUST (the product
|
|
1116
|
+
// delivering its core value), or did the TOOL simply fail to reconcile/classify
|
|
1117
|
+
// THEIR DATA (a data-shape gap to fix and re-run)? `triage` answers exactly that
|
|
1118
|
+
// question. It is PURE, DETERMINISTIC, ORDER-INDEPENDENT, mutates nothing, and
|
|
1119
|
+
// performs NO I/O — the same property the rest of this core has, for the same
|
|
1120
|
+
// reason: a diagnosis a broker acts on and an auditor reads must be reproducible.
|
|
1121
|
+
//
|
|
1122
|
+
// The FOUR root-cause classes (named in STRATEGY.md "## Direction", EPIC-43):
|
|
1123
|
+
// * out_of_trust — a real shortage/commingling/conversion. The trust
|
|
1124
|
+
// account is genuinely out of trust; the product's core
|
|
1125
|
+
// finding. This is what a pilot broker must read as
|
|
1126
|
+
// "fix the trust account", NOT "the tool is broken".
|
|
1127
|
+
// * data_completeness — the tool could not fully reconcile/classify the data:
|
|
1128
|
+
// an unmatched line, an undetermined deposit type, a
|
|
1129
|
+
// residual bank/book gap. A data-shape gap to fix and
|
|
1130
|
+
// re-run — NOT (yet) evidence the money is gone.
|
|
1131
|
+
// * needs_review — a real movement that may be legitimate but a human
|
|
1132
|
+
// must eyeball (an owner draw within capital, an NSF).
|
|
1133
|
+
// * timing — a benign, self-clearing reconciling item (a deposit
|
|
1134
|
+
// in transit, an outstanding check). Expected; explains
|
|
1135
|
+
// a gap rather than being a finding.
|
|
1136
|
+
//
|
|
1137
|
+
// `ROOT_CAUSE_CLASS` is a CLOSED enum and `CLASS_OF` maps EVERY `EXCEPTION` type
|
|
1138
|
+
// to exactly one class. An exhaustiveness guard runs AT LOAD TIME (below): if a
|
|
1139
|
+
// new EXCEPTION type is ever added without a class — or a class points at a name
|
|
1140
|
+
// not in ROOT_CAUSE_CLASS — the module throws on require, so an unclassified
|
|
1141
|
+
// finding is a BUILD error, never a silently-misrouted one at runtime.
|
|
1142
|
+
|
|
1143
|
+
const ROOT_CAUSE_CLASS = Object.freeze({
|
|
1144
|
+
OUT_OF_TRUST: "out_of_trust",
|
|
1145
|
+
DATA_COMPLETENESS: "data_completeness",
|
|
1146
|
+
NEEDS_REVIEW: "needs_review",
|
|
1147
|
+
TIMING: "timing",
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// The order a human reads the classes in: most-urgent first. Used as the stable
|
|
1151
|
+
// tie-break for the headline and to order the per-class roll-up array, so the
|
|
1152
|
+
// table always leads with the class that decides the verdict.
|
|
1153
|
+
const CLASS_RANK = Object.freeze(
|
|
1154
|
+
Object.assign(Object.create(null), {
|
|
1155
|
+
[ROOT_CAUSE_CLASS.OUT_OF_TRUST]: 0,
|
|
1156
|
+
[ROOT_CAUSE_CLASS.DATA_COMPLETENESS]: 1,
|
|
1157
|
+
[ROOT_CAUSE_CLASS.NEEDS_REVIEW]: 2,
|
|
1158
|
+
[ROOT_CAUSE_CLASS.TIMING]: 3,
|
|
1159
|
+
})
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
// Every EXCEPTION type -> its root-cause class. EXHAUSTIVE by construction (the
|
|
1163
|
+
// load-time guard below proves it). The rationale for each non-obvious mapping:
|
|
1164
|
+
// * BANK_BOOK_MISMATCH is DIRECTIONAL — its class is NOT a static entry here
|
|
1165
|
+
// but is decided per-exception by classOfException() (below) from the SIGN of
|
|
1166
|
+
// the residual gap (amount = adjustedBank - book):
|
|
1167
|
+
// - amount < 0 (beyond tolerance): the bank holds LESS cash than the books
|
|
1168
|
+
// say it should — a genuine shortage, the textbook out-of-trust case (the
|
|
1169
|
+
// money is not in the account). Routed to OUT_OF_TRUST.
|
|
1170
|
+
// - amount >= 0: the bank holds MORE than the books record — an UNRECORDED
|
|
1171
|
+
// DEPOSIT / posting omission to write down, the benign "fix this one item
|
|
1172
|
+
// and re-run" data tidy-up. Routed to DATA_COMPLETENESS.
|
|
1173
|
+
// A single static CLASS_OF entry could not express this, and the bank-SHORT
|
|
1174
|
+
// direction routed to DATA_COMPLETENESS would emit a confidently-wrong,
|
|
1175
|
+
// reassuring "FIX YOUR DATA" headline over a real missing-cash shortage — so
|
|
1176
|
+
// BANK_BOOK_MISMATCH is deliberately absent from CLASS_OF and handled in
|
|
1177
|
+
// classOfException, which the load-time guard treats as a valid mapping.
|
|
1178
|
+
// * CONTINUITY_BREAK is OUT_OF_TRUST: a broken roll-forward means the chain of
|
|
1179
|
+
// custody over the trust money is broken — an out-of-trust-grade integrity
|
|
1180
|
+
// failure, not a mere data tidy-up.
|
|
1181
|
+
// * AMBIGUOUS_DEPOSIT is DATA_COMPLETENESS: the tool could not determine the
|
|
1182
|
+
// deposit's beneficiary type. It MIGHT hide an un-segregated security deposit
|
|
1183
|
+
// (which, once labeled, would surface as out_of_trust) — but as-is it is a
|
|
1184
|
+
// classification gap the broker resolves by labeling the row, so it belongs
|
|
1185
|
+
// with the other "fix-my-data" findings, not pre-judged as out of trust.
|
|
1186
|
+
//
|
|
1187
|
+
// NOTE on the table's PROTOTYPE: this is the ONE lookup table that takes an
|
|
1188
|
+
// untrusted key (ex.type, from a possibly hand-built/forged model). It is built
|
|
1189
|
+
// on a NULL prototype so that a forged `ex.type` of an Object.prototype member
|
|
1190
|
+
// name ("__proto__", "constructor", "hasOwnProperty", "toString", ...) resolves
|
|
1191
|
+
// to `undefined` (the rejected-unknown-type path) rather than inheriting a
|
|
1192
|
+
// garbage prototype value and bypassing the strict-rejection guard. CLASS_RANK /
|
|
1193
|
+
// CLASS_LABEL are keyed by our own ROOT_CAUSE_CLASS values (never untrusted
|
|
1194
|
+
// input) but are built the same way for consistency.
|
|
1195
|
+
const CLASS_OF = Object.freeze(
|
|
1196
|
+
Object.assign(Object.create(null), {
|
|
1197
|
+
[EXCEPTION.OUTSTANDING_DEPOSIT]: ROOT_CAUSE_CLASS.TIMING,
|
|
1198
|
+
[EXCEPTION.OUTSTANDING_CHECK]: ROOT_CAUSE_CLASS.TIMING,
|
|
1199
|
+
[EXCEPTION.TIMING]: ROOT_CAUSE_CLASS.TIMING,
|
|
1200
|
+
[EXCEPTION.NSF_REVERSAL]: ROOT_CAUSE_CLASS.NEEDS_REVIEW,
|
|
1201
|
+
[EXCEPTION.OWNER_DRAW]: ROOT_CAUSE_CLASS.NEEDS_REVIEW,
|
|
1202
|
+
[EXCEPTION.OWNER_OVERDRAW]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
|
|
1203
|
+
[EXCEPTION.SECURITY_DEPOSIT_SEGREGATION]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
|
|
1204
|
+
[EXCEPTION.AMBIGUOUS_DEPOSIT]: ROOT_CAUSE_CLASS.DATA_COMPLETENESS,
|
|
1205
|
+
[EXCEPTION.UNRECONCILED_BANK]: ROOT_CAUSE_CLASS.DATA_COMPLETENESS,
|
|
1206
|
+
[EXCEPTION.UNRECONCILED_BOOK]: ROOT_CAUSE_CLASS.DATA_COMPLETENESS,
|
|
1207
|
+
[EXCEPTION.SUBLEDGER_OUT_OF_BALANCE]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
|
|
1208
|
+
[EXCEPTION.NEGATIVE_TENANT_LEDGER]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
|
|
1209
|
+
[EXCEPTION.CONTINUITY_BREAK]: ROOT_CAUSE_CLASS.OUT_OF_TRUST,
|
|
1210
|
+
})
|
|
1211
|
+
);
|
|
1212
|
+
|
|
1213
|
+
// DIRECTIONAL exception types whose class depends on per-exception data (the sign
|
|
1214
|
+
// of the residual gap), NOT a static CLASS_OF entry. These are CLASSIFIED, just
|
|
1215
|
+
// not via the flat table — the load-time guard treats membership here as a valid
|
|
1216
|
+
// mapping so the closed-table discipline still holds (every EXCEPTION type is
|
|
1217
|
+
// EITHER in CLASS_OF OR here; never neither, never both).
|
|
1218
|
+
const DIRECTIONAL_TYPES = Object.freeze(
|
|
1219
|
+
Object.assign(Object.create(null), {
|
|
1220
|
+
[EXCEPTION.BANK_BOOK_MISMATCH]: true,
|
|
1221
|
+
})
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
// Resolve the root-cause class of ONE exception. For a static type this is the
|
|
1225
|
+
// CLASS_OF entry; for a DIRECTIONAL type (BANK_BOOK_MISMATCH) it is decided from
|
|
1226
|
+
// the sign of amount = adjustedBank - book. Returns undefined for an unknown type
|
|
1227
|
+
// (the rejected-unknown-type path) — uses an own-property lookup so a forged
|
|
1228
|
+
// prototype-key type can never inherit a bogus class. PURE: a function of
|
|
1229
|
+
// (ex.type, ex.amount) only.
|
|
1230
|
+
function classOfException(ex) {
|
|
1231
|
+
const type = ex && ex.type;
|
|
1232
|
+
if (type === EXCEPTION.BANK_BOOK_MISMATCH) {
|
|
1233
|
+
// amount = adjustedBank - book. NEGATIVE => bank holds LESS than the books say
|
|
1234
|
+
// (cash is missing relative to the records) => a genuine out-of-trust
|
|
1235
|
+
// shortage. NON-NEGATIVE => bank holds MORE (an unrecorded deposit/posting
|
|
1236
|
+
// omission) => a benign data-completeness item to record and re-run. We read
|
|
1237
|
+
// ex.amount through the SAME integer-cents discipline exceptionImpact uses, so
|
|
1238
|
+
// a non-integer (float) amount is a hard error here too, never coerced to pick
|
|
1239
|
+
// a direction. Zero is impossible past tolerance (the finding would not fire),
|
|
1240
|
+
// but is classed DATA_COMPLETENESS for totality (a non-negative, non-short gap).
|
|
1241
|
+
const a = ex.amount;
|
|
1242
|
+
if (!Number.isInteger(a)) {
|
|
1243
|
+
throw new ReconcileError("triage: exception.amount must be integer cents");
|
|
1244
|
+
}
|
|
1245
|
+
return a < 0 ? ROOT_CAUSE_CLASS.OUT_OF_TRUST : ROOT_CAUSE_CLASS.DATA_COMPLETENESS;
|
|
1246
|
+
}
|
|
1247
|
+
// own-property lookup on the null-prototype table: an unknown / forged
|
|
1248
|
+
// prototype-key type resolves to undefined and is rejected by the caller.
|
|
1249
|
+
return CLASS_OF[type];
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// LOAD-TIME EXHAUSTIVENESS GUARD. Proves, on require, that:
|
|
1253
|
+
// 1. EVERY EXCEPTION type has a class (none falls through unclassified), and
|
|
1254
|
+
// 2. EVERY mapped class is a real ROOT_CAUSE_CLASS member (no typo'd target),
|
|
1255
|
+
// 3. EVERY ROOT_CAUSE_CLASS member has a CLASS_RANK (the read order is total).
|
|
1256
|
+
// Any violation is a BUILD error (thrown at module load), never a silent runtime
|
|
1257
|
+
// mis-route — the same closed-table discipline the entitlement table uses.
|
|
1258
|
+
(function assertTriageExhaustive() {
|
|
1259
|
+
const classValues = new Set(Object.values(ROOT_CAUSE_CLASS));
|
|
1260
|
+
for (const type of Object.values(EXCEPTION)) {
|
|
1261
|
+
const directional = DIRECTIONAL_TYPES[type] === true;
|
|
1262
|
+
const inTable = Object.prototype.hasOwnProperty.call(CLASS_OF, type);
|
|
1263
|
+
// EXACTLY-ONE: every EXCEPTION type is classified by EITHER the static table
|
|
1264
|
+
// OR the directional classifier, never neither (a fall-through) and never
|
|
1265
|
+
// both (an ambiguous mapping). Either is a BUILD error.
|
|
1266
|
+
if (!inTable && !directional) {
|
|
1267
|
+
throw new ReconcileError(
|
|
1268
|
+
`triage: EXCEPTION type "${type}" has no root-cause class (neither CLASS_OF nor a directional classifier covers it)`
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
if (inTable && directional) {
|
|
1272
|
+
throw new ReconcileError(
|
|
1273
|
+
`triage: EXCEPTION type "${type}" is BOTH a static CLASS_OF entry and a directional type (ambiguous mapping)`
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
if (inTable) {
|
|
1277
|
+
const cls = CLASS_OF[type];
|
|
1278
|
+
if (!classValues.has(cls)) {
|
|
1279
|
+
throw new ReconcileError(
|
|
1280
|
+
`triage: EXCEPTION type "${type}" maps to unknown class "${cls}"`
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
} else {
|
|
1284
|
+
// A directional type must yield a real class for BOTH sign directions, so
|
|
1285
|
+
// neither direction can silently route to a bogus class.
|
|
1286
|
+
for (const probe of [-1, 1]) {
|
|
1287
|
+
const cls = classOfException({ type, amount: probe });
|
|
1288
|
+
if (!classValues.has(cls)) {
|
|
1289
|
+
throw new ReconcileError(
|
|
1290
|
+
`triage: directional EXCEPTION type "${type}" maps to unknown class "${cls}" for amount ${probe}`
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
for (const cls of classValues) {
|
|
1297
|
+
if (CLASS_RANK[cls] === undefined) {
|
|
1298
|
+
throw new ReconcileError(`triage: root-cause class "${cls}" has no CLASS_RANK`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
})();
|
|
1302
|
+
|
|
1303
|
+
// A short, human caption + a one-line explanation per class, for the headline.
|
|
1304
|
+
const CLASS_LABEL = Object.freeze(
|
|
1305
|
+
Object.assign(Object.create(null), {
|
|
1306
|
+
[ROOT_CAUSE_CLASS.OUT_OF_TRUST]: "Out of trust",
|
|
1307
|
+
[ROOT_CAUSE_CLASS.DATA_COMPLETENESS]: "Fix the data",
|
|
1308
|
+
[ROOT_CAUSE_CLASS.NEEDS_REVIEW]: "Needs review",
|
|
1309
|
+
[ROOT_CAUSE_CLASS.TIMING]: "Timing",
|
|
1310
|
+
})
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
// Abs-cents impact of one exception. Money figures are integer cents; a
|
|
1314
|
+
// non-integer amount is a hard error (the same no-float-money discipline the
|
|
1315
|
+
// rest of this core enforces) rather than being silently coerced. We sum the
|
|
1316
|
+
// ABSOLUTE value because impact is "how many dollars this finding touches",
|
|
1317
|
+
// independent of inflow/outflow sign — a -$500 negative ledger and a +$500
|
|
1318
|
+
// unreconciled deposit each represent $500 of exposure to weigh.
|
|
1319
|
+
function exceptionImpact(ex) {
|
|
1320
|
+
const a = ex && ex.amount;
|
|
1321
|
+
if (!Number.isInteger(a)) {
|
|
1322
|
+
throw new ReconcileError("triage: exception.amount must be integer cents");
|
|
1323
|
+
}
|
|
1324
|
+
return Math.abs(a);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// triage(model) — classify the findings in a reconcile result OR a buildPacket
|
|
1328
|
+
// model by root cause, roll them up, and name the top thing to fix. Accepts
|
|
1329
|
+
// anything carrying an `exceptions` array of { type, severity, amount } (both
|
|
1330
|
+
// the raw reconcile() result and the report.buildPacket() model qualify), so it
|
|
1331
|
+
// is a pure read-only lens over the EXISTING classified findings — it consumes
|
|
1332
|
+
// the array, never re-derives or re-classifies the underlying records.
|
|
1333
|
+
//
|
|
1334
|
+
// Returns (a NEW object; `model` is never mutated):
|
|
1335
|
+
// {
|
|
1336
|
+
// classes: [ // one row per class that has >=1 finding,
|
|
1337
|
+
// { // in CLASS_RANK (most-urgent-first) order
|
|
1338
|
+
// class: <ROOT_CAUSE_CLASS>,
|
|
1339
|
+
// label: <short caption>,
|
|
1340
|
+
// count: <int>, // findings in this class
|
|
1341
|
+
// absImpact: <int cents>, // summed ABS-cents impact of the class
|
|
1342
|
+
// }, ...
|
|
1343
|
+
// ],
|
|
1344
|
+
// totals: { count, absImpact }, // across ALL findings
|
|
1345
|
+
// outOfTrust: <bool>, // is there >=1 out_of_trust finding?
|
|
1346
|
+
// dataIncomplete: <bool>, // is there >=1 data_completeness finding?
|
|
1347
|
+
// topClass: <ROOT_CAUSE_CLASS|null>, // the class to fix first
|
|
1348
|
+
// headline: <string>, // ONE unambiguous sentence: out-of-trust
|
|
1349
|
+
// // vs. fix-my-data vs. clean
|
|
1350
|
+
// }
|
|
1351
|
+
//
|
|
1352
|
+
// The `headline` is the make-or-break distinction: it says "OUT OF TRUST" only
|
|
1353
|
+
// when there is a genuine out_of_trust finding, says "fix your data and re-run"
|
|
1354
|
+
// when the only blockers are data_completeness gaps, and says the books are
|
|
1355
|
+
// clean when there is nothing in either bucket — so a pilot broker reads a FAIL
|
|
1356
|
+
// correctly at first contact instead of as "the tool is broken".
|
|
1357
|
+
function triage(model) {
|
|
1358
|
+
if (!model || !Array.isArray(model.exceptions)) {
|
|
1359
|
+
throw new ReconcileError("triage requires a model with an exceptions array");
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Accumulate per class. Iterate the (unordered) exceptions and fold into a map
|
|
1363
|
+
// keyed by class — addition + a count is commutative, so the roll-up is
|
|
1364
|
+
// order-independent regardless of how the exceptions array is sorted.
|
|
1365
|
+
const byClass = new Map(); // class -> { count, absImpact }
|
|
1366
|
+
let totalCount = 0;
|
|
1367
|
+
let totalImpact = 0;
|
|
1368
|
+
for (const ex of model.exceptions) {
|
|
1369
|
+
// classOfException resolves the class via the static null-prototype CLASS_OF
|
|
1370
|
+
// table (own-property lookup) OR the directional classifier (BANK_BOOK_MISMATCH
|
|
1371
|
+
// by sign). A forged prototype-key type ("__proto__", "constructor", ...)
|
|
1372
|
+
// resolves to undefined here and is rejected below, never inheriting a bogus
|
|
1373
|
+
// class and bypassing the guard.
|
|
1374
|
+
const cls = classOfException(ex);
|
|
1375
|
+
if (cls === undefined) {
|
|
1376
|
+
// An exception of an unknown type would be silently dropped from the
|
|
1377
|
+
// roll-up — exactly the kind of silent miscount this module exists to
|
|
1378
|
+
// prevent. Fail loud instead. (The load-time guard makes this unreachable
|
|
1379
|
+
// for the built-in EXCEPTION set; it defends a hand-built/forged model.)
|
|
1380
|
+
throw new ReconcileError(
|
|
1381
|
+
`triage: exception of unknown type "${ex.type}" cannot be classified`
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
const impact = exceptionImpact(ex);
|
|
1385
|
+
const agg = byClass.get(cls) || { count: 0, absImpact: 0 };
|
|
1386
|
+
agg.count += 1;
|
|
1387
|
+
agg.absImpact += impact;
|
|
1388
|
+
byClass.set(cls, agg);
|
|
1389
|
+
totalCount += 1;
|
|
1390
|
+
totalImpact += impact;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Emit the per-class rows in CLASS_RANK order (most-urgent first), only for
|
|
1394
|
+
// classes that actually have a finding. Deterministic + order-independent.
|
|
1395
|
+
const classes = [...byClass.keys()]
|
|
1396
|
+
.sort((a, b) => CLASS_RANK[a] - CLASS_RANK[b])
|
|
1397
|
+
.map((cls) => ({
|
|
1398
|
+
class: cls,
|
|
1399
|
+
label: CLASS_LABEL[cls],
|
|
1400
|
+
count: byClass.get(cls).count,
|
|
1401
|
+
absImpact: byClass.get(cls).absImpact,
|
|
1402
|
+
}));
|
|
1403
|
+
|
|
1404
|
+
const outOfTrust = byClass.has(ROOT_CAUSE_CLASS.OUT_OF_TRUST);
|
|
1405
|
+
const dataIncomplete = byClass.has(ROOT_CAUSE_CLASS.DATA_COMPLETENESS);
|
|
1406
|
+
|
|
1407
|
+
// The top class to fix first = the present class with the lowest CLASS_RANK
|
|
1408
|
+
// (out_of_trust before data_completeness before needs_review before timing).
|
|
1409
|
+
// null when there are no findings at all. `classes` is already rank-sorted, so
|
|
1410
|
+
// the first row is the top class.
|
|
1411
|
+
const topClass = classes.length > 0 ? classes[0].class : null;
|
|
1412
|
+
|
|
1413
|
+
return {
|
|
1414
|
+
classes,
|
|
1415
|
+
totals: { count: totalCount, absImpact: totalImpact },
|
|
1416
|
+
outOfTrust,
|
|
1417
|
+
dataIncomplete,
|
|
1418
|
+
topClass,
|
|
1419
|
+
headline: buildHeadline(byClass, outOfTrust, dataIncomplete),
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Build the ONE unambiguous headline sentence. PURE. The distinction the pilot
|
|
1424
|
+
// turns on is out_of_trust vs. fix-my-data, so the sentence LEADS with whichever
|
|
1425
|
+
// applies and never blurs the two:
|
|
1426
|
+
// * ANY out_of_trust finding => "OUT OF TRUST" leads (the core product
|
|
1427
|
+
// verdict), even if data-completeness gaps also exist — a genuine shortage
|
|
1428
|
+
// is never softened into a mere data note.
|
|
1429
|
+
// * else ANY data_completeness => "the tool could not fully reconcile your
|
|
1430
|
+
// data" — a fixable data-shape gap, explicitly NOT an out-of-trust claim.
|
|
1431
|
+
// * else (only needs_review / timing, or nothing) => the account is NOT shown
|
|
1432
|
+
// out of trust; remaining items are review/timing notes.
|
|
1433
|
+
function buildHeadline(byClass, outOfTrust, dataIncomplete) {
|
|
1434
|
+
if (outOfTrust) {
|
|
1435
|
+
const c = byClass.get(ROOT_CAUSE_CLASS.OUT_OF_TRUST);
|
|
1436
|
+
const also = dataIncomplete
|
|
1437
|
+
? " There are also data-completeness gaps to fix, but the out-of-trust finding is the priority."
|
|
1438
|
+
: "";
|
|
1439
|
+
return (
|
|
1440
|
+
`OUT OF TRUST: ${countNoun(c.count, "finding")} totaling ${fmtCentsForDetail(c.absImpact)} ` +
|
|
1441
|
+
`show the trust account is genuinely out of trust. Restore the trust account before relying on this packet.` +
|
|
1442
|
+
also
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
if (dataIncomplete) {
|
|
1446
|
+
const c = byClass.get(ROOT_CAUSE_CLASS.DATA_COMPLETENESS);
|
|
1447
|
+
return (
|
|
1448
|
+
`FIX YOUR DATA: the trust account is NOT shown out of trust — the tool could not fully reconcile ` +
|
|
1449
|
+
`your data (${countNoun(c.count, "item")} totaling ${fmtCentsForDetail(c.absImpact)}). ` +
|
|
1450
|
+
`Resolve these data gaps and re-run; this is not (yet) evidence the money is gone.`
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
const review = byClass.get(ROOT_CAUSE_CLASS.NEEDS_REVIEW);
|
|
1454
|
+
const timing = byClass.get(ROOT_CAUSE_CLASS.TIMING);
|
|
1455
|
+
if (review || timing) {
|
|
1456
|
+
return (
|
|
1457
|
+
`NO OUT-OF-TRUST FINDING: the trust account is not shown out of trust and the data reconciled. ` +
|
|
1458
|
+
`${countNoun((review ? review.count : 0) + (timing ? timing.count : 0), "item")} remain as ` +
|
|
1459
|
+
`review/timing notes for a human to confirm.`
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
return "NO FINDINGS: every line reconciled and nothing is out of trust.";
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// "1 finding" / "2 findings" — a count noun that pluralizes deterministically.
|
|
1466
|
+
function countNoun(n, noun) {
|
|
1467
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
module.exports = {
|
|
1471
|
+
reconcile,
|
|
1472
|
+
ReconcileError,
|
|
1473
|
+
EXCEPTION,
|
|
1474
|
+
SEVERITY,
|
|
1475
|
+
DEFAULT_SEVERITY,
|
|
1476
|
+
// exported for focused tests / reuse
|
|
1477
|
+
tenantBalances,
|
|
1478
|
+
compareExceptions,
|
|
1479
|
+
buildContinuityException,
|
|
1480
|
+
isAmbiguousDeposit,
|
|
1481
|
+
// T-43.1 triage
|
|
1482
|
+
triage,
|
|
1483
|
+
ROOT_CAUSE_CLASS,
|
|
1484
|
+
CLASS_OF,
|
|
1485
|
+
classOfException,
|
|
1486
|
+
};
|