@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.
- package/AGENTS.md +54 -0
- package/CHANGELOG.md +215 -0
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/bin/planfi-import.mjs +549 -0
- package/docs/ADAPTER_GUIDE.md +244 -0
- package/fixtures/csv-sandbox.mjs +112 -0
- package/fixtures/fdx-sandbox.mjs +67 -0
- package/fixtures/finicity-sandbox.mjs +62 -0
- package/fixtures/mx-sandbox.mjs +37 -0
- package/fixtures/ofx-sandbox.mjs +196 -0
- package/fixtures/plaid-sandbox.mjs +56 -0
- package/package.json +69 -0
- package/planfi-import.d.ts +270 -0
- package/src/adapters/_template.mjs +112 -0
- package/src/adapters/csv.mjs +763 -0
- package/src/adapters/fdx.mjs +303 -0
- package/src/adapters/finicity.mjs +243 -0
- package/src/adapters/mx.mjs +159 -0
- package/src/adapters/ofx.mjs +324 -0
- package/src/adapters/plaid.mjs +140 -0
- package/src/canonical.ts +185 -0
- package/src/classify.mjs +72 -0
- package/src/contributions.mjs +65 -0
- package/src/index.mjs +42 -0
- package/src/to-planfi.mjs +340 -0
- package/src/util.mjs +65 -0
|
@@ -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();
|