@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,340 @@
1
+ // to-planfi.mjs — the ONE shared mapper: Canonical Financial Profile →
2
+ // generate_financial_plan wire object. Every provider adapter funnels through
3
+ // here, so all Planfi domain logic lives once.
4
+ //
5
+ // Wire-contract ground truth (workers/ai-mcp/src/lib/mapper.ts, PlanRequest):
6
+ // - `stocks.current_value` is the TOTAL investable portfolio; the optional
7
+ // `account_balances` {taxable, traditional, roth} is its DECOMPOSITION for
8
+ // tax-aware decumulation — the engine core never adds account_balances to
9
+ // net worth on top of stocks (see src/lib/round-trip-harness.test.ts).
10
+ // - `education_account` passes through as the ENGINE shape — camelCase keys
11
+ // inside ({ enabled, initialBalance, monthlyContribution }).
12
+ // - There is NO `hsa_retirement` wire field, and no `account_balances.hsa`
13
+ // bucket. The only HSA wire surface is the per-earner CONTRIBUTION block
14
+ // earners[n].retirement_accounts.hsa = { coverage, annual }. An HSA
15
+ // BALANCE is therefore folded into the aggregate stocks total (warned).
16
+ //
17
+ // @typedef {import('./canonical').CanonicalFinancialProfile} CFP
18
+ // @typedef {import('./canonical').ImportWarning} ImportWarning
19
+ // @typedef {import('./canonical').NeedsInput} NeedsInput
20
+
21
+ import { warning } from './util.mjs';
22
+
23
+ const round = (n) => Math.round(money(n));
24
+ const round4 = (n) => Math.round(money(n) * 10000) / 10000;
25
+ /** Coerce any value to a finite, non-negative number (clamps junk/NaN/∞/neg → 0). */
26
+ function money(x) {
27
+ const n = typeof x === 'string' ? Number(x.replace(/[$,%\s]/g, '')) : Number(x);
28
+ return Number.isFinite(n) && n > 0 ? n : 0;
29
+ }
30
+
31
+ // IRS contribution limits — 2026 tax year, copied from the engine's
32
+ // src/lib/tax-limits.ts (TAX_ADVANTAGED_LIMITS_2026; 401(k) per IRS Notice
33
+ // 2025-67, IRA $7,500 base, HSA per Rev. Proc. 2025-19). Copied (not imported)
34
+ // so this package stays zero-dependency. NOTE: age-based catch-up contributions
35
+ // (401k +$8,000 at 50-59/64+, IRA +$1,100 at 50+, HSA +$1,000 at 55+) are NOT
36
+ // modeled — inferred contributions above the base limit are clamped and warned.
37
+ const LIMIT_401K = 24500;
38
+ const LIMIT_IRA = 7500;
39
+ const LIMIT_HSA_FAMILY = 8750;
40
+
41
+ /**
42
+ * @param {CFP} cfp
43
+ * @param {object} [opts]
44
+ * @param {string} [opts.defaultState='CA']
45
+ * @returns {{ plan: object, warnings: ImportWarning[], needsInput: NeedsInput[] }}
46
+ */
47
+ export function toPlanfiPlan(cfp, opts = {}) {
48
+ const { defaultState = 'CA' } = opts;
49
+ const warnings = [...(cfp?.meta?.warnings ?? [])];
50
+ /** @type {NeedsInput[]} */
51
+ const needs = [];
52
+ const need = (field, entry) => needs.push({ field, ...entry });
53
+ const accounts = Array.isArray(cfp?.accounts) ? cfp.accounts : [];
54
+ const owner = cfp?.owner ?? {};
55
+
56
+ // Zero recognized accounts is almost always a payload-shape problem, not a
57
+ // customer with no money — say so loudly. At batch scale (5k rows) this is
58
+ // the difference between "ok: 5000" hiding a systematic export-format error
59
+ // and a rollup line that names it.
60
+ if (accounts.length === 0) {
61
+ warnings.push(warning('IMPORT_EMPTY', 'warn',
62
+ `No accounts recognized in the ${cfp?.source ?? 'unknown'} payload — check the payload shape/format; the plan carries no imported balances.`));
63
+ }
64
+
65
+ const sumBy = (pred, val = (a) => a.balance) =>
66
+ accounts.filter(pred).reduce((n, a) => n + money(val(a)), 0);
67
+
68
+ // Negative balances (margin debit, overdraft) are clamped to $0 by money() —
69
+ // surface that instead of silently improving the picture.
70
+ for (const a of accounts) {
71
+ const raw = Number(a?.balance);
72
+ if (Number.isFinite(raw) && raw < 0) {
73
+ warnings.push(warning('NEGATIVE_BALANCE_CLAMPED', 'warn',
74
+ `Account "${a.name || a.id}" has a negative balance (${raw}) — clamped to $0 (negative asset balances are not modeled).`, a.id));
75
+ }
76
+ }
77
+
78
+ // ── cash + tax-treatment buckets ───────────────────────────────────────────
79
+ const cash = round(sumBy((a) => a.class === 'depository'));
80
+ const inv = (tt) => round(sumBy((a) => a.class === 'investment' && a.taxTreatment === tt));
81
+ const taxable = inv('taxable');
82
+ const traditional = inv('traditional');
83
+ const roth = inv('roth');
84
+ const education529 = inv('529');
85
+ const hsaBalance = inv('hsa');
86
+
87
+ // stocks = the TOTAL investable portfolio (taxable + traditional + roth, plus
88
+ // the HSA balance — see the header note); account_balances is the
89
+ // taxable/traditional/roth decomposition of it.
90
+ const stocksTotal = taxable + traditional + roth + hsaBalance;
91
+ if (hsaBalance > 0) {
92
+ warnings.push(warning('HSA_FOLDED_INTO_PORTFOLIO', 'info',
93
+ `HSA balance $${hsaBalance.toLocaleString()} is modeled inside the aggregate portfolio (stocks.current_value) — the wire schema has no dedicated HSA balance field. The engine's dedicated hsaRetirement block is NetWorthInput-only (documented next hop, alongside individualHoldings).`));
94
+ }
95
+
96
+ // ── inferred contributions (from transactions) ─────────────────────────────
97
+ // Taxable brokerage inflows → stocks.monthly_contribution.
98
+ const stocksMonthly = round(sumBy(
99
+ (a) => a.class === 'investment' && a.taxTreatment === 'taxable',
100
+ (a) => a.estMonthlyContribution ?? 0,
101
+ ));
102
+
103
+ // ── earners (multi-owner) ──────────────────────────────────────────────────
104
+ // Demographics/goals are things NO aggregator knows (they report balances,
105
+ // not birthdays or retirement plans) — missing ones become structured asks.
106
+ // Non-object members in a caller-supplied earners array (null, junk) are
107
+ // treated as empty contexts, not crashes (caught by the contract harness).
108
+ const earnerCtx = (Array.isArray(owner.earners) && owner.earners.length ? owner.earners : [owner])
109
+ .map((e) => (e && typeof e === 'object' ? e : {}));
110
+ const earners = earnerCtx.map((e, i) => {
111
+ const name = e.name || (i === 0 ? 'Primary' : `Earner ${i + 1}`);
112
+ const who = earnerCtx.length > 1 ? ` (${name})` : '';
113
+ const demo = (field, value, label, why) => {
114
+ if (Number.isFinite(value)) return true;
115
+ need(field, { earnerIndex: i, label: `${label}${who}`, why });
116
+ return false;
117
+ };
118
+ const earner = { name };
119
+ if (demo('age', e.age, 'Current age',
120
+ 'Aggregators report balances, not birthdays — the projection needs a starting age.')) earner.age = round(e.age);
121
+ if (demo('retirement_age', e.retirementAge, 'Target retirement age',
122
+ 'Retirement age is a goal, not an account attribute — no data provider can know it.')) earner.retirement_age = round(e.retirementAge);
123
+ if (demo('annual_salary', e.annualSalary, 'Annual salary',
124
+ 'Salary only flows through payroll-linked products (e.g. Plaid Income) — otherwise collect it.')) earner.annual_salary = round(e.annualSalary);
125
+ return earner;
126
+ });
127
+
128
+ // Sanity guard on the taxable inference: inferred inflows include transfers
129
+ // (rollovers/account moves look identical to contributions in transaction
130
+ // feeds), so an implausibly high figure vs known salary gets flagged.
131
+ const knownSalary = earners.reduce((n, e) => n + (Number.isFinite(e.annual_salary) ? e.annual_salary : 0), 0);
132
+ if (knownSalary > 0 && stocksMonthly * 12 > knownSalary * 0.5) {
133
+ warnings.push(warning('CONTRIBUTION_IMPLAUSIBLE', 'warn',
134
+ `Inferred taxable contributions ($${(stocksMonthly * 12).toLocaleString()}/yr) exceed 50% of known household salary ($${knownSalary.toLocaleString()}) — transaction-inflow inference may be counting rollovers/transfers as savings. Verify stocks.monthly_contribution.`));
135
+ }
136
+
137
+ // Per-earner retirement contributions inferred from that earner's accounts.
138
+ // Cap clips are WARNED (a silent clamp is silent wrongness). IRA traditional
139
+ // and Roth are accumulated separately and resolved to one wire `ira` block
140
+ // per earner below (the wire supports type 'traditional' | 'roth' | 'both').
141
+ const iraByEarner = earners.map(() => ({ traditional: 0, roth: 0 }));
142
+ const capWarn = (label, annual, limit) => {
143
+ if (annual <= limit) return round(annual);
144
+ warnings.push(warning('CONTRIBUTION_CLAMPED', 'warn',
145
+ `Inferred ${label} contribution $${round(annual).toLocaleString()}/yr exceeds the 2026 IRS limit $${limit.toLocaleString()} — clamped to the limit (rollovers/transfers may be inflating the inference; catch-up contributions are not modeled).`));
146
+ return limit;
147
+ };
148
+ for (const a of accounts.filter((x) => x.class === 'investment'
149
+ && (x.taxTreatment === 'traditional' || x.taxTreatment === 'roth' || x.taxTreatment === 'hsa'))) {
150
+ const monthly = money(a.estMonthlyContribution);
151
+ if (!monthly) continue;
152
+ const idx = Math.min(a.ownerIndex ?? 0, earners.length - 1);
153
+ const annual = monthly * 12;
154
+ if (a.taxTreatment === 'hsa') {
155
+ // Inferred HSA contribution → the owning earner's retirement_accounts.hsa
156
+ // block (the wire's only HSA surface). Coverage is unknowable from
157
+ // aggregator data; 'family' is assumed.
158
+ const ra = (earners[idx].retirement_accounts ??= {});
159
+ const prior = ra.hsa?.annual ?? 0;
160
+ ra.hsa = { coverage: 'family', annual: capWarn('HSA', prior + annual, LIMIT_HSA_FAMILY) };
161
+ warnings.push(warning('HSA_COVERAGE_ASSUMED', 'info',
162
+ `HSA contribution inferred at $${round(annual).toLocaleString()}/yr — coverage type assumed 'family' (aggregators don't report it).`, a.id));
163
+ } else if (/401k|403b|457b|tsp/.test(a.subtype || '')) {
164
+ const ra = (earners[idx].retirement_accounts ??= {});
165
+ ra.k401 = { employee_annual: capWarn('401(k)', (ra.k401?.employee_annual ?? 0) + annual, LIMIT_401K) };
166
+ } else {
167
+ // Everything else pre-tax/Roth retirement (ira, roth, sep, simple) → IRA bucket.
168
+ iraByEarner[idx][a.taxTreatment === 'roth' ? 'roth' : 'traditional'] += annual;
169
+ }
170
+ }
171
+ iraByEarner.forEach((ira, idx) => {
172
+ const total = ira.traditional + ira.roth;
173
+ if (total <= 0) return;
174
+ const ra = (earners[idx].retirement_accounts ??= {});
175
+ let type;
176
+ if (ira.traditional > 0 && ira.roth > 0) {
177
+ // The wire carries ONE ira block per earner; type 'both' models a 50/50
178
+ // split in the engine. Surface the real inferred split so a lopsided one
179
+ // isn't silently reshaped.
180
+ type = 'both';
181
+ warnings.push(warning('IRA_SPLIT_ASSUMED', 'info',
182
+ `Earner ${idx + 1} has both traditional ($${round(ira.traditional).toLocaleString()}/yr) and Roth ($${round(ira.roth).toLocaleString()}/yr) IRA contributions — emitted as one ira block with type 'both', which the engine models as a 50/50 split of the total.`));
183
+ } else {
184
+ type = ira.roth > 0 ? 'roth' : 'traditional';
185
+ }
186
+ ra.ira = { type, annual: capWarn('IRA', total, LIMIT_IRA) };
187
+ });
188
+
189
+ // ── real estate ────────────────────────────────────────────────────────────
190
+ // `property`-class accounts (e.g. MX PROPERTY) carry the home's MARKET VALUE;
191
+ // pair them to mortgages so we use a real value instead of asking the user.
192
+ const propertyPool = accounts
193
+ .filter((a) => a.class === 'property')
194
+ .map((a) => ({ name: a.name || '', value: money(a.balance) }));
195
+ const takeProperty = (mortgageName) => {
196
+ if (!propertyPool.length) return null;
197
+ let i = propertyPool.findIndex((p) => sharesToken(p.name, mortgageName));
198
+ if (i < 0) i = 0;
199
+ return propertyPool.splice(i, 1)[0];
200
+ };
201
+
202
+ const real_estate = [];
203
+ for (const a of accounts.filter((x) => x.class === 'loan' && /mortgage|home equity/.test(x.subtype || ''))) {
204
+ const L = a.liability ?? {};
205
+ const balance = money(a.balance);
206
+ let value = money(L.assetValue);
207
+ if (!value) { const p = takeProperty(a.name); if (p && p.value > 0) value = p.value; }
208
+ if (!value) {
209
+ // No real value available → ESTIMATE at 80% LTV from the mortgage balance
210
+ // (product decision: ask the user now, AVM integration later). The
211
+ // estimate is warned and the ask is retained in needsInput — the wire
212
+ // real_estate entry has no provenance flag, so warning + needsInput IS
213
+ // the provenance mechanism.
214
+ need('home_value', {
215
+ accountId: a.id,
216
+ ...(a.name ? { accountName: a.name } : {}),
217
+ label: `Home value for ${a.name || a.id}`,
218
+ why: 'The provider reported the mortgage but not the property’s market value — currently estimated at 80% LTV.',
219
+ });
220
+ if (balance > 0) {
221
+ warnings.push(warning('HOME_VALUE_ESTIMATED', 'warn',
222
+ `Home value for "${a.name || a.id}" ESTIMATED at 80% LTV from the mortgage balance ($${round(balance / 0.8).toLocaleString()}) — no market value in the source data; replace via needsInput home_value.`, a.id));
223
+ }
224
+ }
225
+ const current_value = round(value || balance / 0.8);
226
+ if (current_value <= 0) {
227
+ warnings.push(warning('MORTGAGE_SKIPPED', 'warn', `Mortgage "${a.name || a.id}" has no balance or home value — skipped.`, a.id));
228
+ continue;
229
+ }
230
+ const months = money(L.monthsRemaining);
231
+ real_estate.push({
232
+ name: a.name || 'Primary residence',
233
+ current_value,
234
+ annual_appreciation: 0.035,
235
+ mortgage: {
236
+ balance,
237
+ rate: round4(L.rate),
238
+ years_remaining: months ? Math.max(1, Math.round(months / 12)) : 30,
239
+ },
240
+ });
241
+ }
242
+ // Leftover property accounts with no mortgage → owned (paid-off) homes.
243
+ for (const p of propertyPool) {
244
+ const cv = round(p.value);
245
+ if (cv > 0) real_estate.push({ name: p.name || 'Property', current_value: cv, annual_appreciation: 0.035 });
246
+ }
247
+
248
+ // ── non-mortgage debts ─────────────────────────────────────────────────────
249
+ const debts = [];
250
+ for (const a of accounts.filter((x) => (x.class === 'loan' && !/mortgage|home equity/.test(x.subtype || '')) || x.class === 'credit')) {
251
+ const L = a.liability ?? {};
252
+ if (L.rate == null || !Number.isFinite(Number(L.rate))) {
253
+ // The wire schema requires a numeric rate, so 0 goes in the body — but a
254
+ // 0% debt is a silently-optimistic model, so the caller is told.
255
+ need('debt_rate', {
256
+ accountId: a.id,
257
+ ...(a.name ? { accountName: a.name } : {}),
258
+ label: `Interest rate (APR) for ${a.name || a.id}`,
259
+ why: 'The provider reported the balance but no APR — the debt is modeled at 0% (optimistic) until provided.',
260
+ });
261
+ warnings.push(warning('DEBT_RATE_MISSING', 'warn',
262
+ `Debt "${a.name || a.id}" has no APR in the source data — modeled at 0% until provided (see needsInput debt_rate).`, a.id));
263
+ }
264
+ debts.push({
265
+ name: a.name || labelFor(a),
266
+ balance: round(a.balance),
267
+ rate: round4(L.rate ?? 0),
268
+ min_payment: round(L.minPayment ?? 0),
269
+ ...(L.assetName ? { asset_name: L.assetName, asset_value: round(L.assetValue ?? 0) } : {}),
270
+ });
271
+ }
272
+
273
+ // ── speculative (crypto) ───────────────────────────────────────────────────
274
+ const cryptoValue = accounts
275
+ .filter((a) => a.class === 'investment')
276
+ .flatMap((a) => a.holdings ?? [])
277
+ .filter((h) => h.assetType === 'crypto')
278
+ .reduce((n, h) => n + money(h.value), 0);
279
+ const speculative = cryptoValue > 0
280
+ ? [{ name: 'Crypto holdings', current_value: round(cryptoValue), annual_growth_rate: 0.10 }]
281
+ : [];
282
+
283
+ // 529 → education_account. This block passes through the wire as the ENGINE
284
+ // shape (NetWorthInput['educationAccount']) — keys are camelCase INSIDE.
285
+ const eduMonthly = round(sumBy(
286
+ (a) => a.class === 'investment' && a.taxTreatment === '529',
287
+ (a) => a.estMonthlyContribution ?? 0,
288
+ ));
289
+
290
+ const plan = {
291
+ name: owner.name || `Imported plan (${cfp?.source ?? 'unknown'})`,
292
+ earners,
293
+ stocks: { current_value: stocksTotal, monthly_contribution: stocksMonthly, annual_return: 0.07 },
294
+ cash: { current_value: cash, monthly_contribution: 0, annual_return: 0.04 },
295
+ account_balances: { taxable, traditional, roth },
296
+ ...(real_estate.length ? { real_estate } : {}),
297
+ ...(debts.length ? { debts } : {}),
298
+ ...(speculative.length ? { speculative } : {}),
299
+ ...(education529 > 0 ? { education_account: { enabled: true, initialBalance: education529, monthlyContribution: eduMonthly } } : {}),
300
+ tax_settings: { state: owner.filingState || defaultState },
301
+ };
302
+ if (Number.isFinite(owner.desiredAnnualSpend)) {
303
+ plan.desired_annual_spend = round(owner.desiredAnnualSpend);
304
+ } else {
305
+ need('desired_annual_spend', {
306
+ label: 'Desired annual spending in retirement',
307
+ why: 'Target retirement spending is a goal the engine sizes the plan around — no account data implies it.',
308
+ });
309
+ }
310
+
311
+ // De-dup on (field, accountId, earnerIndex), preserving first-emission order.
312
+ // The emission order above is deterministic: earner demographics (in earner
313
+ // order), then home_value asks (account order), then debt_rate asks (account
314
+ // order), then plan-level goals.
315
+ const seen = new Set();
316
+ const needsInput = needs.filter((n) => {
317
+ const k = `${n.field}|${n.accountId ?? ''}|${n.earnerIndex ?? ''}`;
318
+ if (seen.has(k)) return false;
319
+ seen.add(k);
320
+ return true;
321
+ });
322
+
323
+ return { plan, warnings, needsInput };
324
+ }
325
+
326
+ function labelFor(a) {
327
+ const s = a.subtype || '';
328
+ if (/student/.test(s)) return 'Student loan';
329
+ if (/auto/.test(s)) return 'Auto loan';
330
+ if (a.class === 'credit') return 'Credit card';
331
+ return 'Loan';
332
+ }
333
+
334
+ /** Loose name match: do two names share a meaningful token (>3 chars)? */
335
+ function sharesToken(a, b) {
336
+ const toks = (s) => new Set(String(s).toLowerCase().split(/\W+/).filter((w) => w.length > 3));
337
+ const ta = toks(a); const tb = toks(b);
338
+ for (const w of ta) if (tb.has(w)) return true;
339
+ return false;
340
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,65 @@
1
+ // util.mjs — helpers shared by every adapter (and the mapper). Extracted so the
2
+ // adapters can't drift apart again: before this file existed, plaid.mjs and
3
+ // mx.mjs each carried private copies of monthsBetween whose fallback base dates
4
+ // had already diverged (2025-01-01 vs 2026-01-01). One implementation, one
5
+ // documented fallback.
6
+
7
+ /** Coerce to an array (anything else → []). */
8
+ export const arr = (x) => (Array.isArray(x) ? x : []);
9
+
10
+ /**
11
+ * Coerce to an array of OBJECTS: non-arrays → [], and non-object members
12
+ * (null, numbers, strings) are dropped. Use this at every provider-array
13
+ * boundary — a null member in accounts[]/holdings[]/transactions[] must not
14
+ * crash an adapter (the contract harness feeds exactly that).
15
+ */
16
+ export const objs = (x) => arr(x).filter((v) => v != null && typeof v === 'object');
17
+
18
+ /** Coerce to a finite number (anything else → 0). Preserves sign. */
19
+ export const num = (x) => (Number.isFinite(Number(x)) ? Number(x) : 0);
20
+
21
+ /** Provider rates are percentages (6.25) → fraction (0.0625). Non-numeric → undefined. */
22
+ export const pct = (x) => (Number.isFinite(Number(x)) ? Number(x) / 100 : undefined);
23
+
24
+ /** Group a list into a Map by key(x). */
25
+ export function groupBy(list, key) {
26
+ const m = new Map();
27
+ for (const x of list) { const k = key(x); (m.get(k) ?? m.set(k, []).get(k)).push(x); }
28
+ return m;
29
+ }
30
+
31
+ /**
32
+ * Whole months between two ISO dates (>= 1), or undefined when `toIso` is
33
+ * missing/unparseable. Fallback: when `fromIso` is missing or unparseable the
34
+ * base is NOW (Date.now()) — the snapshot being processed is assumed current.
35
+ * (Never a hardcoded year: a fixed base silently ages every remaining-term
36
+ * calculation as the calendar moves on.)
37
+ */
38
+ export function monthsBetween(fromIso, toIso) {
39
+ if (!toIso) return undefined;
40
+ const t = Date.parse(toIso);
41
+ if (!Number.isFinite(t)) return undefined;
42
+ const f = Date.parse(fromIso || '');
43
+ const base = Number.isFinite(f) ? f : Date.now();
44
+ return Math.max(1, Math.round((t - base) / (1000 * 60 * 60 * 24 * 30.44)));
45
+ }
46
+
47
+ /**
48
+ * Build a structured ImportWarning (see canonical.ts). One factory shared by
49
+ * every adapter and the mapper so the shape can't drift.
50
+ * @param {import('./canonical').WarningCode} code - stable machine-readable id
51
+ * @param {'info'|'warn'} severity
52
+ * @param {string} message - human-quality explanation
53
+ * @param {string} [accountId] - provider account id, when account-scoped
54
+ * @returns {import('./canonical').ImportWarning}
55
+ */
56
+ export const warning = (code, severity, message, accountId) =>
57
+ ({ code, severity, message, ...(accountId != null ? { accountId: String(accountId) } : {}) });
58
+
59
+ /**
60
+ * Default snapshot timestamp when the caller didn't supply one: NOW.
61
+ * (Previously `new Date(0)` — the 1970 epoch — which made monthsBetween compute
62
+ * mortgage terms from 1970: ~80-year `years_remaining` on every import that
63
+ * omitted `asOf`.)
64
+ */
65
+ export const defaultAsOf = () => new Date().toISOString();