@plan-fi/imports 0.6.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.
@@ -0,0 +1,159 @@
1
+ // mx.mjs — MX Platform → Canonical Financial Profile.
2
+ //
3
+ // Consumes MX API entities (already fetched by the caller):
4
+ // /users/{u}/accounts → accounts[]
5
+ // /users/{u}/holdings → holdings[]
6
+ // /users/{u}/transactions → transactions[] (optional; drives contributions)
7
+ // MX encodes the account family in `type` (CHECKING, INVESTMENT, MORTGAGE,
8
+ // PROPERTY, …) and refines investment tax treatment in `subtype`. We translate
9
+ // MX's vocabulary into the generic (type, subtype) that classify() understands,
10
+ // then reuse the same classifier + the same shared to-planfi mapper.
11
+ //
12
+ // MX advantage over Plaid: a PROPERTY account carries the home's MARKET VALUE,
13
+ // so mortgages can be paired to a real value instead of asking the user.
14
+ //
15
+ // @typedef {import('../canonical').CanonicalFinancialProfile} CFP
16
+ // @typedef {import('../canonical').SourceAdapter} SourceAdapter
17
+
18
+ import { classify, classifyAsset } from '../classify.mjs';
19
+ import { contributionsByAccount } from '../contributions.mjs';
20
+ import { arr, objs, num, pct, groupBy, monthsBetween, defaultAsOf, warning } from '../util.mjs';
21
+
22
+ // MX credit-transaction categories/descriptions that are savings INFLOWS
23
+ // (counted) vs investment GROWTH (excluded — already modeled by annual_return).
24
+ const MX_INFLOW = /transfer|deposit|contribution|payroll|direct dep/i;
25
+ const MX_GROWTH = /dividend|interest|capital gain|reinvest/i;
26
+
27
+ // MX top-level `type` → generic { type, subtype? } that classify() consumes.
28
+ const MX_TYPE = {
29
+ CHECKING: ['depository', 'checking'],
30
+ SAVINGS: ['depository', 'savings'],
31
+ MONEY_MARKET: ['depository', 'money market'],
32
+ CD: ['depository', 'cd'],
33
+ CASH: ['depository', 'cash'],
34
+ PREPAID: ['depository', 'cash'],
35
+ INVESTMENT: ['investment', undefined],
36
+ LOAN: ['loan', undefined],
37
+ MORTGAGE: ['loan', 'mortgage'],
38
+ CREDIT_CARD: ['credit', 'credit card'],
39
+ LINE_OF_CREDIT: ['credit', 'line of credit'],
40
+ PROPERTY: ['property', undefined],
41
+ };
42
+
43
+ /** @implements {SourceAdapter} */
44
+ export const mxAdapter = {
45
+ source: 'mx',
46
+ /**
47
+ * @param {object} raw - { accounts, holdings, transactions, owner, asOf }
48
+ * @returns {CFP}
49
+ */
50
+ normalize(raw) {
51
+ // Total function: null/primitive payloads normalize to an empty profile
52
+ // (a default parameter only covers `undefined` — the contract harness
53
+ // caught the null case throwing).
54
+ raw = raw && typeof raw === 'object' ? raw : {};
55
+ const warnings = [];
56
+ const unmapped = [];
57
+ const accountsIn = objs(raw.accounts);
58
+ const holdingsByAccount = groupBy(objs(raw.holdings), (h) => h.account_guid);
59
+
60
+ // Contributions: MX CREDITs into investment accounts are candidate inflows.
61
+ // Filter by category/description so growth (dividends/interest/reinvest)
62
+ // isn't double-counted as savings; when a credit carries NO category or
63
+ // description we count it but warn once that the inference is coarse.
64
+ const invGuids = new Set(accountsIn.filter((a) => up(a.type) === 'INVESTMENT').map((a) => a.guid));
65
+ let sawUnlabeledCredit = false;
66
+ const normTxns = objs(raw.transactions)
67
+ .filter((t) => {
68
+ if (!invGuids.has(t.account_guid) || up(t.type) !== 'CREDIT') return false;
69
+ const label = `${t.category ?? ''} ${t.description ?? ''} ${t.top_level_category ?? ''}`.trim();
70
+ if (!label) { sawUnlabeledCredit = true; return true; } // no signal → coarse include
71
+ if (MX_GROWTH.test(label)) return false; // dividends/interest = growth
72
+ return MX_INFLOW.test(label); // labeled but neither → exclude
73
+ })
74
+ .map((t) => ({ account_id: t.account_guid, subtype: 'contribution', amount: -Math.abs(num(t.amount)), date: t.date || t.transacted_at }));
75
+ if (sawUnlabeledCredit) {
76
+ warnings.push(warning('COARSE_INFERENCE', 'warn',
77
+ 'MX contribution inference is coarse: some investment-account credits carry no category/description, so ALL such unlabeled credits were counted as contributions (may include dividends or rollovers). Verify inferred contribution rates.'));
78
+ }
79
+ const contribByAccount = contributionsByAccount(normTxns);
80
+
81
+ const accounts = accountsIn.map((a) => {
82
+ const [genType, genSub] = MX_TYPE[up(a.type)] ?? ['investment', undefined];
83
+ // MX investment subtype (401K, ROTH_IRA, HSA, 529…) → words classify() knows.
84
+ const subtype = genSub ?? mxSubtype(a.subtype) ?? inferLoanSubtype(a.name);
85
+ const { accountClass, taxTreatment, confidence } = classify(genType === 'property' ? 'investment' : genType, subtype);
86
+ const cls = genType === 'property' ? 'property' : accountClass;
87
+ if (confidence === 'low' && cls !== 'property') {
88
+ warnings.push(warning('CLASSIFICATION_GUESSED', 'warn',
89
+ `MX account "${a.name ?? a.guid}" (${a.type}/${a.subtype ?? ''}) classification guessed → ${cls}/${taxTreatment}.`, a.guid));
90
+ }
91
+
92
+ const acct = {
93
+ id: a.guid,
94
+ institution: a.institution_code,
95
+ name: a.name,
96
+ class: cls,
97
+ subtype: String(subtype ?? '').toLowerCase(),
98
+ taxTreatment: cls === 'property' ? 'na' : taxTreatment,
99
+ balance: num(a.balance) || num(a.market_value) || num(a.available_balance) || 0,
100
+ currency: a.currency_code ?? 'USD',
101
+ ownerIndex: Number.isInteger(a.owner_index) ? a.owner_index : 0,
102
+ ...(contribByAccount[a.guid] ? { estMonthlyContribution: contribByAccount[a.guid] } : {}),
103
+ };
104
+
105
+ if (cls === 'investment') {
106
+ const hs = holdingsByAccount.get(a.guid) ?? [];
107
+ acct.holdings = hs.map((h) => {
108
+ if (h.cost_basis == null) {
109
+ warnings.push(warning('NO_COST_BASIS', 'info',
110
+ `Holding ${h.symbol ?? h.description ?? h.guid} has no cost basis (MX did not report it).`, a.guid));
111
+ }
112
+ return {
113
+ ticker: h.symbol ?? undefined,
114
+ name: h.description ?? undefined,
115
+ quantity: num(h.shares),
116
+ value: num(h.market_value),
117
+ costBasis: h.cost_basis == null ? undefined : num(h.cost_basis),
118
+ assetType: classifyAsset(h.holding_type),
119
+ };
120
+ });
121
+ }
122
+ if (cls === 'loan' || cls === 'credit') {
123
+ acct.liability = {
124
+ rate: pct(a.interest_rate ?? a.apr),
125
+ minPayment: num(a.minimum_payment) || undefined,
126
+ originationPrincipal: num(a.original_balance) || undefined,
127
+ monthsRemaining: monthsBetween(raw.asOf, a.maturity_date),
128
+ ...(subtype === 'mortgage' ? { assetName: a.name || 'Home' } : {}),
129
+ };
130
+ }
131
+ return acct;
132
+ });
133
+
134
+ return {
135
+ source: 'mx',
136
+ // Default snapshot time is NOW (not the 1970 epoch — see util.mjs).
137
+ asOf: raw.asOf || defaultAsOf(),
138
+ owner: { ...(raw.owner ?? {}) },
139
+ accounts,
140
+ meta: { warnings, unmapped },
141
+ };
142
+ },
143
+ };
144
+
145
+ // ── helpers ─────────────────────────────────────────────────────────────────
146
+ // (arr/num/pct/groupBy/monthsBetween live in ../util.mjs, shared with plaid.mjs.)
147
+ const up = (x) => String(x ?? '').trim().toUpperCase();
148
+ /** MX investment subtype enum → classify()-friendly words. */
149
+ function mxSubtype(sub) {
150
+ if (!sub) return undefined;
151
+ return String(sub).toLowerCase().replace(/_/g, ' ');
152
+ }
153
+ function inferLoanSubtype(name) {
154
+ const n = String(name ?? '').toLowerCase();
155
+ if (/student/.test(n)) return 'student';
156
+ if (/auto|car|vehicle/.test(n)) return 'auto';
157
+ if (/mortgage|home/.test(n)) return 'mortgage';
158
+ return undefined;
159
+ }
@@ -0,0 +1,324 @@
1
+ // ofx.mjs — OFX files → Canonical Financial Profile. The other KEYLESS path:
2
+ // OFX (Open Financial Exchange) is the "Download → Quicken/Money" format that
3
+ // nearly every US bank/broker still exports, in two syntaxes:
4
+ // - OFX 1.x: SGML — a `KEY:VALUE` header block, then tags whose LEAVES ARE
5
+ // NEVER CLOSED (`<BALAMT>1234.56` on its own line)
6
+ // - OFX 2.x: XML — an XML prolog, then the same tag vocabulary fully closed
7
+ //
8
+ // Input contract: { content: string (either syntax), owner, asOf }.
9
+ //
10
+ // The tolerant parser here handles both with one pass (see parseOfx): open
11
+ // tags with trailing text are leaves; close tags pop tolerantly (a close with
12
+ // no matching open is ignored, unclosed aggregates are fine). It never throws.
13
+ //
14
+ // What is extracted (per the OFX 2.2 spec sections noted):
15
+ // - BANKMSGSRSV1 → STMTRS (11.4): BANKACCTFROM.ACCTTYPE (CHECKING/SAVINGS/
16
+ // MONEYMRKT/CD/CREDITLINE) + LEDGERBAL.BALAMT → depository accounts
17
+ // (CREDITLINE → credit class); STMTTRN transactions are bank cash flow,
18
+ // NOT savings-rate signal, so they are ignored for contribution inference.
19
+ // - CREDITCARDMSGSRSV1 → CCSTMTRS (11.4.3): card balances. OFX REPORTS CARD
20
+ // BALANCES NEGATIVE (amount owed as a negative ledger balance) — the
21
+ // adapter normalizes to POSITIVE amount owed (|BALAMT|), covered by a test.
22
+ // - INVSTMTMSGSRSV1 → INVSTMTRS (13.9): INVACCTFROM, INVPOSLIST positions
23
+ // (POSSTOCK/POSMF/POSDEBT/POSOTHER → the INVPOS inside each: SECID lookup
24
+ // against SECLISTMSGSRSV1 for ticker/name, UNITS, MKTVAL), INVBAL.AVAILCASH,
25
+ // and INVTRANLIST for contribution inference: INVBANKTRAN deposits are
26
+ // candidate contributions (same growth-exclusion rules as the siblings —
27
+ // INCOME/REINVEST records are growth, unlabeled deposits are counted
28
+ // coarsely + COARSE_INFERENCE); BUY*/SELL* records are internal to the
29
+ // account and never counted.
30
+ //
31
+ // Honesty rules: OFX CARRIES NO TAX-TREATMENT INFO — an investment statement
32
+ // says "brokerage at broker X", never "this is a Roth IRA". Every investment
33
+ // account is therefore classified taxable at LOW confidence with a
34
+ // CLASSIFICATION_GUESSED warning; nothing is fabricated. OFX also carries no
35
+ // account names — accounts are labeled from type + masked ACCTID.
36
+ //
37
+ // Only OFX quirk-handling lives here; ALL Planfi domain logic stays in
38
+ // to-planfi.mjs, shared with every other adapter.
39
+ //
40
+ // @typedef {import('../canonical').CanonicalFinancialProfile} CFP
41
+ // @typedef {import('../canonical').SourceAdapter} SourceAdapter
42
+
43
+ import { classify } from '../classify.mjs';
44
+ import { contributionsByAccount } from '../contributions.mjs';
45
+ import { arr, num, defaultAsOf, warning } from '../util.mjs';
46
+
47
+ // Same inflow/growth split as the CSV/MX/Finicity adapters, plus OFX TRNTYPEs.
48
+ const OFX_INFLOW = /transfer|deposit|contribution|payroll|direct dep|\bdep\b|credit|xfer/i;
49
+ const OFX_GROWTH = /dividend|interest|capital gain|reinvest|\bdiv\b|\bint\b/i;
50
+
51
+ /** @implements {SourceAdapter} */
52
+ export const ofxAdapter = {
53
+ source: 'ofx',
54
+ /**
55
+ * @param {object} raw - { content: string (OFX 1.x SGML or 2.x XML), owner, asOf }
56
+ * @returns {CFP}
57
+ */
58
+ normalize(raw) {
59
+ // Total function: null/primitive payloads normalize to an empty profile
60
+ // (a default parameter only covers `undefined` — the contract harness
61
+ // caught the null case throwing).
62
+ raw = raw && typeof raw === 'object' ? raw : {};
63
+ const warnings = [];
64
+ const unmapped = [];
65
+ const accounts = [];
66
+ const root = parseOfx(raw.content);
67
+ let statementAsOf; // best DTASOF seen, used when the caller passed no asOf
68
+
69
+ // ── security list (SECLISTMSGSRSV1) — UNIQUEID → { ticker, name, type } ──
70
+ const securities = new Map();
71
+ for (const wrapTag of ['STOCKINFO', 'MFINFO', 'DEBTINFO', 'OPTINFO', 'OTHERINFO']) {
72
+ for (const info of findAll(root, wrapTag)) {
73
+ const uid = val(info, 'UNIQUEID');
74
+ if (!uid) continue;
75
+ securities.set(uid, {
76
+ ticker: val(info, 'TICKER'),
77
+ name: val(info, 'SECNAME'),
78
+ assetType: { STOCKINFO: 'equity', MFINFO: 'mutual_fund', DEBTINFO: 'bond', OPTINFO: 'other', OTHERINFO: 'other' }[wrapTag],
79
+ });
80
+ }
81
+ }
82
+
83
+ // ── bank statements (STMTRS) ─────────────────────────────────────────────
84
+ for (const stmt of findAll(root, 'STMTRS')) {
85
+ const acctFrom = find(stmt, 'BANKACCTFROM') ?? stmt;
86
+ const acctId = val(acctFrom, 'ACCTID') || `bank:${accounts.length}`;
87
+ const acctType = (val(acctFrom, 'ACCTTYPE') || 'CHECKING').toUpperCase();
88
+ const ledger = find(stmt, 'LEDGERBAL');
89
+ statementAsOf ??= ofxDateIso(val(ledger, 'DTASOF'));
90
+ const balance = num(val(ledger, 'BALAMT') ?? val(find(stmt, 'AVAILBAL'), 'BALAMT'));
91
+ if (acctType === 'CREDITLINE') {
92
+ // A credit line under the bank message set is revolving debt, not cash.
93
+ accounts.push({
94
+ id: acctId,
95
+ name: `Credit line ${mask(acctId)}`,
96
+ class: 'credit',
97
+ subtype: 'line of credit',
98
+ taxTreatment: 'na',
99
+ balance: Math.abs(balance),
100
+ currency: val(stmt, 'CURDEF') || 'USD',
101
+ ownerIndex: 0,
102
+ liability: { rate: undefined }, // OFX bank statements carry no APR
103
+ });
104
+ continue;
105
+ }
106
+ const { accountClass, taxTreatment } = classify('depository', acctType.toLowerCase().replace('moneymrkt', 'money market'));
107
+ accounts.push({
108
+ id: acctId,
109
+ name: `${title(acctType)} ${mask(acctId)}`,
110
+ class: accountClass,
111
+ subtype: acctType.toLowerCase(),
112
+ taxTreatment,
113
+ balance,
114
+ currency: val(stmt, 'CURDEF') || 'USD',
115
+ ownerIndex: 0,
116
+ });
117
+ }
118
+
119
+ // ── credit card statements (CCSTMTRS) ────────────────────────────────────
120
+ for (const stmt of findAll(root, 'CCSTMTRS')) {
121
+ const acctId = val(find(stmt, 'CCACCTFROM') ?? stmt, 'ACCTID') || `card:${accounts.length}`;
122
+ const ledger = find(stmt, 'LEDGERBAL');
123
+ statementAsOf ??= ofxDateIso(val(ledger, 'DTASOF'));
124
+ // OFX reports the amount owed as a NEGATIVE ledger balance; the canonical
125
+ // model (and the shared mapper) wants outstanding principal as a
126
+ // positive number — normalize with |x| so a debt can't vanish.
127
+ const owed = Math.abs(num(val(ledger, 'BALAMT')));
128
+ accounts.push({
129
+ id: acctId,
130
+ name: `Credit card ${mask(acctId)}`,
131
+ class: 'credit',
132
+ subtype: 'credit card',
133
+ taxTreatment: 'na',
134
+ balance: owed,
135
+ currency: val(stmt, 'CURDEF') || 'USD',
136
+ ownerIndex: 0,
137
+ liability: { rate: undefined }, // OFX card statements carry no APR → mapper asks via needsInput
138
+ });
139
+ }
140
+
141
+ // ── investment statements (INVSTMTRS) ────────────────────────────────────
142
+ let sawUnlabeledDeposit = false;
143
+ const normTxns = [];
144
+ for (const stmt of findAll(root, 'INVSTMTRS')) {
145
+ const acctFrom = find(stmt, 'INVACCTFROM') ?? stmt;
146
+ const acctId = val(acctFrom, 'ACCTID') || `inv:${accounts.length}`;
147
+ const broker = val(acctFrom, 'BROKERID');
148
+ statementAsOf ??= ofxDateIso(val(stmt, 'DTASOF'));
149
+
150
+ const holdings = [];
151
+ for (const posTag of ['POSSTOCK', 'POSMF', 'POSDEBT', 'POSOPT', 'POSOTHER']) {
152
+ for (const pos of findAll(stmt, posTag)) {
153
+ const uid = val(pos, 'UNIQUEID');
154
+ const sec = (uid && securities.get(uid)) || {};
155
+ if (uid && !securities.get(uid)) {
156
+ unmapped.push({ ofx: posTag, uniqueId: uid, reason: 'SECID not found in SECLISTMSGSRSV1' });
157
+ }
158
+ holdings.push({
159
+ ticker: sec.ticker || undefined,
160
+ name: sec.name || undefined,
161
+ quantity: num(val(pos, 'UNITS')),
162
+ value: num(val(pos, 'MKTVAL')),
163
+ costBasis: undefined, // OFX positions carry no cost basis
164
+ assetType: sec.assetType
165
+ ?? { POSSTOCK: 'equity', POSMF: 'mutual_fund', POSDEBT: 'bond', POSOPT: 'other', POSOTHER: 'other' }[posTag],
166
+ });
167
+ }
168
+ }
169
+ if (holdings.length) {
170
+ // Structural to the format (not per-institution like the API adapters),
171
+ // so ONE info note per account instead of one per holding.
172
+ warnings.push(warning('NO_COST_BASIS', 'info',
173
+ `${holdings.length} holding(s) in ${mask(acctId)} imported without cost basis — OFX position records don't carry one.`, acctId));
174
+ }
175
+
176
+ const availCash = num(val(find(stmt, 'INVBAL'), 'AVAILCASH'));
177
+ const balance = holdings.reduce((n, h) => n + (Number.isFinite(h.value) ? h.value : 0), 0) + availCash;
178
+
179
+ // OFX has no tax-treatment vocabulary → taxable at LOW confidence, warned.
180
+ const { accountClass, taxTreatment } = classify('investment', undefined);
181
+ warnings.push(warning('CLASSIFICATION_GUESSED', 'warn',
182
+ `OFX investment account ${mask(acctId)}${broker ? ` at ${broker}` : ''}: OFX carries no tax-treatment info (no way to tell a Roth IRA from a brokerage) — classified ${accountClass}/${taxTreatment} at low confidence. Reclassify if it is a retirement account.`, acctId));
183
+
184
+ accounts.push({
185
+ id: acctId,
186
+ institution: broker || undefined,
187
+ name: `Investment ${mask(acctId)}`,
188
+ class: accountClass,
189
+ subtype: '',
190
+ taxTreatment,
191
+ balance,
192
+ currency: val(stmt, 'CURDEF') || 'USD',
193
+ ownerIndex: 0,
194
+ holdings,
195
+ });
196
+
197
+ // Contribution inference: money moving INTO the investment account from
198
+ // outside = INVBANKTRAN deposits. INCOME/REINVEST are growth (excluded);
199
+ // BUY*/SELL* are internal (never counted).
200
+ for (const bankTran of findAll(stmt, 'INVBANKTRAN')) {
201
+ const trn = find(bankTran, 'STMTTRN') ?? bankTran;
202
+ const amount = num(val(trn, 'TRNAMT'));
203
+ if (!(amount > 0)) continue;
204
+ const label = `${val(trn, 'TRNTYPE') ?? ''} ${val(trn, 'NAME') ?? ''} ${val(trn, 'MEMO') ?? ''}`.trim();
205
+ if (!label) { sawUnlabeledDeposit = true; }
206
+ else if (OFX_GROWTH.test(label)) continue;
207
+ else if (!OFX_INFLOW.test(label)) continue;
208
+ normTxns.push({
209
+ account_id: acctId,
210
+ subtype: 'contribution',
211
+ amount: -Math.abs(amount),
212
+ date: ofxDateIso(val(trn, 'DTPOSTED')),
213
+ });
214
+ }
215
+ }
216
+ if (sawUnlabeledDeposit) {
217
+ warnings.push(warning('COARSE_INFERENCE', 'warn',
218
+ 'OFX contribution inference is coarse: some investment-account deposits carry no TRNTYPE/NAME/MEMO, so ALL such unlabeled deposits were counted as contributions (may include dividends or rollovers). Verify inferred contribution rates.'));
219
+ }
220
+ const contribByAccount = contributionsByAccount(normTxns);
221
+ for (const a of accounts) {
222
+ if (contribByAccount[a.id]) a.estMonthlyContribution = contribByAccount[a.id];
223
+ }
224
+
225
+ return {
226
+ source: 'ofx',
227
+ // Prefer the caller's asOf, then the statement's own DTASOF, then NOW.
228
+ asOf: raw.asOf || statementAsOf || defaultAsOf(),
229
+ owner: { ...(raw.owner ?? {}) },
230
+ accounts,
231
+ meta: { warnings, unmapped },
232
+ };
233
+ },
234
+ };
235
+
236
+ // ── tolerant OFX parser ──────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Parse OFX text (1.x SGML or 2.x XML) into a tag tree. One pass, one rule
240
+ * set for both syntaxes:
241
+ * - `<TAG>text` → a LEAF (SGML leaves are never closed; XML's later
242
+ * `</TAG>` finds nothing open and is ignored)
243
+ * - `<TAG>` with no trailing text → an AGGREGATE (pushed on the stack)
244
+ * - `</TAG>` → pops back to the matching open aggregate; ignored when
245
+ * nothing matches (hostile/truncated input never throws)
246
+ * The `OFXHEADER:...` SGML header block / XML prolog before `<OFX>` is
247
+ * skipped; when no `<OFX>` tag exists the whole text is scanned anyway.
248
+ * @param {string} text
249
+ * @returns {{tag: string, value?: string, children: object[]}}
250
+ */
251
+ export function parseOfx(text) {
252
+ let s = String(text ?? '');
253
+ const ofxStart = s.search(/<OFX>/i);
254
+ if (ofxStart >= 0) s = s.slice(ofxStart);
255
+ const root = { tag: 'ROOT', children: [] };
256
+ const stack = [root];
257
+ const re = /<(\/?)([A-Za-z0-9_.]+)[^>]*>([^<]*)/g;
258
+ let m;
259
+ while ((m = re.exec(s))) {
260
+ const closing = m[1] === '/';
261
+ const tag = m[2].toUpperCase();
262
+ const trailing = decodeEntities(m[3]).trim();
263
+ if (closing) {
264
+ // Pop back to the matching open aggregate; tolerate mismatches.
265
+ for (let i = stack.length - 1; i >= 1; i--) {
266
+ if (stack[i].tag === tag) { stack.length = i; break; }
267
+ }
268
+ } else if (trailing) {
269
+ stack[stack.length - 1].children.push({ tag, value: trailing, children: [] });
270
+ } else {
271
+ const node = { tag, children: [] };
272
+ stack[stack.length - 1].children.push(node);
273
+ stack.push(node);
274
+ }
275
+ }
276
+ return root;
277
+ }
278
+
279
+ const decodeEntities = (s) => String(s)
280
+ .replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&amp;/gi, '&').replace(/&nbsp;/gi, ' ');
281
+
282
+ /** Depth-first search: first descendant with `tag`, or undefined. */
283
+ export function find(node, tag) {
284
+ if (!node) return undefined;
285
+ for (const c of node.children ?? []) {
286
+ if (c.tag === tag) return c;
287
+ const hit = find(c, tag);
288
+ if (hit) return hit;
289
+ }
290
+ return undefined;
291
+ }
292
+
293
+ /** Depth-first search: ALL descendants with `tag` (document order). */
294
+ export function findAll(node, tag) {
295
+ const out = [];
296
+ const walk = (n) => {
297
+ for (const c of n.children ?? []) {
298
+ if (c.tag === tag) out.push(c);
299
+ walk(c);
300
+ }
301
+ };
302
+ if (node) walk(node);
303
+ return out;
304
+ }
305
+
306
+ /** Leaf value of the first descendant `tag` under `node`, or undefined. */
307
+ const val = (node, tag) => (node ? find(node, tag)?.value : undefined);
308
+
309
+ /**
310
+ * OFX dates are `YYYYMMDD[HHMMSS[.XXX]][ [gmt offset:TZ] ]` — take the digits
311
+ * and build a UTC ISO string. Unparseable → undefined (never a fabricated date).
312
+ */
313
+ export function ofxDateIso(v) {
314
+ const m = String(v ?? '').match(/^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2}))?/);
315
+ if (!m) return undefined;
316
+ const t = Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0));
317
+ return Number.isFinite(t) && +m[2] >= 1 && +m[2] <= 12 && +m[3] >= 1 && +m[3] <= 31
318
+ ? new Date(t).toISOString()
319
+ : undefined;
320
+ }
321
+
322
+ /** "1234567890" → "••7890" — OFX has no account names, only ids. */
323
+ const mask = (id) => `••${String(id ?? '').slice(-4)}`;
324
+ const title = (s) => String(s ?? '').charAt(0) + String(s ?? '').slice(1).toLowerCase();
@@ -0,0 +1,140 @@
1
+ // plaid.mjs — Plaid → Canonical Financial Profile.
2
+ //
3
+ // Consumes the merged results of the Plaid product endpoints:
4
+ // /accounts/get → accounts[] (+ balances)
5
+ // /investments/holdings/get → holdings[] + securities[]
6
+ // /liabilities/get → liabilities.{mortgage,student,credit}[]
7
+ // /income (optional) → owner.annualSalary
8
+ // It maps ONLY Plaid's quirks into the CFP; all Planfi logic lives in
9
+ // to-planfi.mjs. Nothing is fabricated — missing fields become warnings.
10
+ //
11
+ // @typedef {import('../canonical').CanonicalFinancialProfile} CFP
12
+ // @typedef {import('../canonical').SourceAdapter} SourceAdapter
13
+
14
+ import { classify, classifyAsset } from '../classify.mjs';
15
+ import { contributionsByAccount } from '../contributions.mjs';
16
+ import { arr, objs, num, pct, groupBy, monthsBetween, defaultAsOf, warning } from '../util.mjs';
17
+
18
+ /** @implements {SourceAdapter} */
19
+ export const plaidAdapter = {
20
+ source: 'plaid',
21
+ /**
22
+ * @param {object} raw - { accounts, holdings, securities, liabilities, income, owner, asOf }
23
+ * @returns {CFP}
24
+ */
25
+ normalize(raw) {
26
+ // Total function: null/primitive payloads normalize to an empty profile
27
+ // (a default parameter only covers `undefined` — the contract harness
28
+ // caught the null case throwing).
29
+ raw = raw && typeof raw === 'object' ? raw : {};
30
+ const warnings = [];
31
+ const unmapped = [];
32
+ const accountsIn = objs(raw.accounts);
33
+ const holdingsIn = objs(raw.holdings);
34
+ const securitiesIn = objs(raw.securities);
35
+ const liab = raw.liabilities ?? {};
36
+
37
+ const secById = new Map(securitiesIn.map((s) => [s.security_id, s]));
38
+ const holdingsByAccount = groupBy(holdingsIn, (h) => h.account_id);
39
+ // Inferred monthly contributions from investment transactions (if provided).
40
+ const contribByAccount = contributionsByAccount(objs(raw.investmentTransactions ?? raw.investment_transactions));
41
+
42
+ // liability detail keyed by account_id
43
+ const liabByAccount = new Map();
44
+ for (const m of objs(liab.mortgage)) {
45
+ liabByAccount.set(m.account_id, {
46
+ rate: pct(m.interest_rate?.percentage),
47
+ minPayment: num(m.next_monthly_payment) || num(m.last_payment_amount),
48
+ originationPrincipal: num(m.origination_principal_amount),
49
+ assetName: m.property_address?.street ? `Home — ${m.property_address.city ?? ''}`.trim() : 'Home',
50
+ monthsRemaining: monthsBetween(raw.asOf, m.maturity_date),
51
+ });
52
+ }
53
+ for (const st of objs(liab.student)) {
54
+ liabByAccount.set(st.account_id, {
55
+ rate: pct(st.interest_rate_percentage),
56
+ minPayment: num(st.minimum_payment_amount),
57
+ });
58
+ }
59
+ for (const cc of objs(liab.credit)) {
60
+ liabByAccount.set(cc.account_id, {
61
+ rate: pct(cc.aprs?.[0]?.apr_percentage),
62
+ minPayment: num(cc.minimum_payment_amount) || num(cc.last_statement_balance) * 0.02,
63
+ });
64
+ }
65
+
66
+ const accounts = accountsIn.map((a) => {
67
+ const { accountClass, taxTreatment, confidence } = classify(a.type, a.subtype);
68
+ if (confidence === 'low') {
69
+ warnings.push(warning('CLASSIFICATION_GUESSED', 'warn',
70
+ `Account "${a.name ?? a.account_id}" (${a.type}/${a.subtype}) classification guessed → ${accountClass}/${taxTreatment}.`, a.account_id));
71
+ }
72
+ const bal = a.balances ?? {};
73
+ // asset accounts use `current`; liabilities also carry `current` = amount owed.
74
+ const balance = num(bal.current) || num(bal.available) || 0;
75
+
76
+ const acct = {
77
+ id: a.account_id,
78
+ institution: a.institution_name,
79
+ name: a.official_name || a.name,
80
+ class: accountClass,
81
+ subtype: String(a.subtype ?? '').toLowerCase(),
82
+ taxTreatment,
83
+ balance,
84
+ currency: bal.iso_currency_code ?? 'USD',
85
+ // Which earner owns it (0/1). Caller may set from Plaid /identity name
86
+ // matching; defaults to the primary earner.
87
+ ownerIndex: Number.isInteger(a.owner_index) ? a.owner_index : 0,
88
+ ...(contribByAccount[a.account_id] ? { estMonthlyContribution: contribByAccount[a.account_id] } : {}),
89
+ };
90
+
91
+ if (accountClass === 'investment') {
92
+ const hs = holdingsByAccount.get(a.account_id) ?? [];
93
+ acct.holdings = hs.map((h) => {
94
+ const sec = secById.get(h.security_id) ?? {};
95
+ if (h.cost_basis == null) {
96
+ warnings.push(warning('NO_COST_BASIS', 'info',
97
+ `Holding ${sec.ticker_symbol ?? sec.name ?? h.security_id} has no cost basis (institution did not report it).`, a.account_id));
98
+ }
99
+ return {
100
+ ticker: sec.ticker_symbol ?? undefined,
101
+ name: sec.name ?? undefined,
102
+ quantity: num(h.quantity),
103
+ value: num(h.institution_value),
104
+ costBasis: h.cost_basis == null ? undefined : num(h.cost_basis),
105
+ assetType: classifyAsset(sec.type),
106
+ };
107
+ });
108
+ }
109
+ if (accountClass === 'loan' || accountClass === 'credit') {
110
+ acct.liability = liabByAccount.get(a.account_id) ?? { rate: undefined };
111
+ }
112
+ return acct;
113
+ });
114
+
115
+ // owner context: Plaid Income can supply salary; everything else onboarding.
116
+ const owner = { ...(raw.owner ?? {}) };
117
+ const salary = plaidAnnualIncome(raw.income);
118
+ if (salary != null && owner.annualSalary == null) owner.annualSalary = salary;
119
+
120
+ return {
121
+ source: 'plaid',
122
+ // Default snapshot time is NOW (not the 1970 epoch — see util.mjs).
123
+ asOf: raw.asOf || defaultAsOf(),
124
+ owner,
125
+ accounts,
126
+ meta: { warnings, unmapped },
127
+ };
128
+ },
129
+ };
130
+
131
+ // ── helpers ─────────────────────────────────────────────────────────────────
132
+ // (arr/num/pct/groupBy/monthsBetween live in ../util.mjs, shared with mx.mjs.)
133
+ /** Plaid Income: sum the primary income stream(s) to an annual figure. */
134
+ function plaidAnnualIncome(income) {
135
+ if (!income) return null;
136
+ const streams = objs(income.income_streams ?? income.bank_income?.[0]?.income_sources);
137
+ if (!streams.length) return null;
138
+ const monthly = streams.reduce((n, s) => n + num(s.monthly_income), 0);
139
+ return monthly > 0 ? Math.round(monthly * 12) : null;
140
+ }