@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,185 @@
1
+ /**
2
+ * canonical.ts — the Canonical Financial Profile (CFP): the provider-neutral
3
+ * contract every import adapter emits and the single Planfi mapper consumes.
4
+ *
5
+ * See docs/IMPORT_ADAPTERS.md for the architecture. This file is the contract
6
+ * (types only, zero dependencies) so it can anchor the open-source SDK.
7
+ *
8
+ * Plaid/MX/OFX raw ──SourceAdapter.normalize()──► CanonicalFinancialProfile
9
+ * CanonicalFinancialProfile ──toPlanfiPlan()──► generate_financial_plan wire
10
+ */
11
+
12
+ /**
13
+ * Broad account family, provider-independent.
14
+ *
15
+ * Enum alignment: this maps 1:1 onto the FDX (Financial Data Exchange)
16
+ * account SHAPES — depositAccount → 'depository', investmentAccount /
17
+ * annuityAccount → 'investment', loanAccount → 'loan', locAccount /
18
+ * lineOfCredit → 'credit'. FDX has no property/real-estate shape, so
19
+ * 'property' has no FDX counterpart (it exists for MX PROPERTY accounts).
20
+ */
21
+ export type AccountClass = 'depository' | 'investment' | 'loan' | 'credit' | 'property';
22
+
23
+ /**
24
+ * Stable machine-readable warning codes (v0.2.0). Codes are append-only: a
25
+ * released code never changes meaning, so callers can switch on them safely.
26
+ * Human `message` text may improve between versions; `code` will not.
27
+ */
28
+ export type WarningCode =
29
+ | 'CLASSIFICATION_GUESSED' // account type/subtype ambiguous → treatment guessed
30
+ | 'NO_COST_BASIS' // institution reported a holding without cost basis
31
+ | 'COARSE_INFERENCE' // contribution inference ran on unlabeled transactions
32
+ | 'CONTRIBUTION_CLAMPED' // inferred contribution exceeded the IRS limit → clamped
33
+ | 'CONTRIBUTION_IMPLAUSIBLE' // inferred savings rate implausibly high vs known salary
34
+ | 'HSA_FOLDED_INTO_PORTFOLIO' // HSA balance folded into stocks total (no wire HSA balance field)
35
+ | 'HSA_COVERAGE_ASSUMED' // HSA coverage type unknowable → assumed 'family'
36
+ | 'IRA_SPLIT_ASSUMED' // trad+Roth IRA contributions → one 'both' block (engine models 50/50)
37
+ | 'HOME_VALUE_ESTIMATED' // no property value in source → estimated at 80% LTV
38
+ | 'MORTGAGE_SKIPPED' // mortgage had no balance or home value → dropped
39
+ | 'NEGATIVE_BALANCE_CLAMPED' // negative asset balance clamped to $0
40
+ | 'DEBT_RATE_MISSING' // debt has no APR in source → modeled at 0%
41
+ | 'CSV_UNMAPPED_COLUMNS' // CSV columns not matched by any dialect → best-effort generic mapping
42
+ | 'CSV_TRANSACTIONS_ONLY' // transactions-only export (e.g. YNAB) — the tool carries no balances — pair with a balances file
43
+ | 'IMPORT_EMPTY'; // zero accounts recognized in the payload — likely a format/shape problem — the plan was built from nothing
44
+
45
+ /** One structured warning. Emitted by adapters (via CFP meta) and the mapper. */
46
+ export interface ImportWarning {
47
+ code: WarningCode;
48
+ /** 'info' = lossless modeling note; 'warn' = a value may be wrong — verify. */
49
+ severity: 'info' | 'warn';
50
+ /** Human-readable explanation, safe to show to an end user. */
51
+ message: string;
52
+ /** Provider account id the warning refers to, when account-scoped. */
53
+ accountId?: string;
54
+ }
55
+
56
+ /** Fields an aggregator cannot supply — collect them from the user. */
57
+ export type NeedsInputField =
58
+ | 'age'
59
+ | 'retirement_age'
60
+ | 'annual_salary'
61
+ | 'desired_annual_spend'
62
+ | 'home_value'
63
+ | 'debt_rate';
64
+
65
+ /**
66
+ * One structured ask. De-duplicated on (field, accountId, earnerIndex) and
67
+ * emitted in deterministic order (earner demographics, then per-account asks
68
+ * in account order, then plan-level goals).
69
+ */
70
+ export interface NeedsInput {
71
+ field: NeedsInputField;
72
+ /** Provider account id, for account-scoped asks (home_value, debt_rate). */
73
+ accountId?: string;
74
+ accountName?: string;
75
+ /** 0-based earner index, for demographic asks in multi-earner households. */
76
+ earnerIndex?: number;
77
+ /** Short human label, ready for a form ("Home value for Home mortgage"). */
78
+ label: string;
79
+ /** One sentence: why the import couldn't supply this. */
80
+ why: string;
81
+ }
82
+
83
+ /**
84
+ * Tax treatment of an investment/holding bucket. `na` = not applicable (debt, cash).
85
+ *
86
+ * Enum alignment: the FDX `accountType` vocabulary partitions cleanly into
87
+ * these treatments — IRA/401K/403B/457/KEOGH/SEPIRA/SIMPLEIRA → 'traditional',
88
+ * ROTH/ROTH401K → 'roth', HSA → 'hsa', 529 → '529', BROKERAGE → 'taxable';
89
+ * TDA/ANNUITY lean 'traditional' at low confidence (warned).
90
+ */
91
+ export type TaxTreatment = 'taxable' | 'traditional' | 'roth' | 'hsa' | '529' | 'na';
92
+
93
+ /**
94
+ * Enum alignment: FDX InvestmentHolding.holdingType overlaps — STOCK →
95
+ * 'equity', MUTUALFUND → 'mutual_fund', BOND → 'bond', CASH/CD/MONEYMARKET →
96
+ * 'cash', DIGITALASSET → 'crypto', OPTION/ANNUITY/OTHER → 'other'. 'etf' is
97
+ * finer-grained than FDX (which files ETFs under STOCK unless the institution
98
+ * sends an explicit ETF type, which the fdx adapter also accepts).
99
+ */
100
+ export type AssetType = 'equity' | 'etf' | 'mutual_fund' | 'bond' | 'cash' | 'crypto' | 'other';
101
+
102
+ /** One security position inside an investment account. */
103
+ export interface CanonicalHolding {
104
+ ticker?: string;
105
+ name?: string;
106
+ quantity?: number;
107
+ /** Market value of the position at `asOf`. */
108
+ value?: number;
109
+ /** Total cost basis (engine-only on import — see docs Gaps). */
110
+ costBasis?: number;
111
+ assetType: AssetType;
112
+ }
113
+
114
+ /** Loan/credit detail attached to a `loan` or `credit` account. */
115
+ export interface LiabilityDetail {
116
+ /** APR as a fraction, e.g. 0.0625 for 6.25%. */
117
+ rate?: number;
118
+ minPayment?: number;
119
+ monthsRemaining?: number;
120
+ originationPrincipal?: number;
121
+ /** The asset securing the debt (property/vehicle), when known. */
122
+ assetName?: string;
123
+ assetValue?: number;
124
+ }
125
+
126
+ export interface CanonicalAccount {
127
+ /** Stable provider account id — the key for dedup/reconcile across refreshes. */
128
+ id: string;
129
+ institution?: string;
130
+ name?: string;
131
+ class: AccountClass;
132
+ /** Provider subtype normalized to lowercase, e.g. '401k', 'roth ira', 'mortgage'. */
133
+ subtype?: string;
134
+ taxTreatment?: TaxTreatment;
135
+ /** Asset value, or outstanding principal for a liability. */
136
+ balance: number;
137
+ currency?: string;
138
+ holdings?: CanonicalHolding[];
139
+ liability?: LiabilityDetail;
140
+ /** Which earner (0-based) owns this account, for joint households. */
141
+ ownerIndex?: number;
142
+ /** Inferred monthly contribution into this account (from transactions). */
143
+ estMonthlyContribution?: number;
144
+ }
145
+
146
+ /**
147
+ * Planning context aggregators usually CAN'T supply (age, goals, salary).
148
+ * Populated from onboarding and merged over the imported accounts. Fields left
149
+ * undefined surface in the mapper's `needsInput` list.
150
+ */
151
+ export interface OwnerContext {
152
+ age?: number;
153
+ retirementAge?: number;
154
+ annualSalary?: number;
155
+ desiredAnnualSpend?: number;
156
+ /** Two-letter US state for tax settings. */
157
+ filingState?: string;
158
+ /** Per-earner overrides for joint households. */
159
+ earners?: Array<Partial<OwnerContext> & { name?: string }>;
160
+ }
161
+
162
+ export interface CanonicalFinancialProfile {
163
+ /** Adapter source id: 'plaid' | 'mx' | 'finicity' | 'fdx' | 'csv' | 'ofx' | ... */
164
+ source: string;
165
+ /** ISO timestamp of the underlying snapshot. */
166
+ asOf: string;
167
+ owner: OwnerContext;
168
+ accounts: CanonicalAccount[];
169
+ meta: {
170
+ /** Structured notes: guessed classifications, dropped/partial data. */
171
+ warnings: ImportWarning[];
172
+ /** Raw provider entities that couldn't be mapped — never silently dropped. */
173
+ unmapped: unknown[];
174
+ };
175
+ }
176
+
177
+ /**
178
+ * The contract implemented once per provider. `normalize` does ONLY that
179
+ * provider's quirk-mapping into the CFP; all Planfi domain logic lives
180
+ * downstream in `toPlanfiPlan`, written once and shared across every adapter.
181
+ */
182
+ export interface SourceAdapter<Raw = unknown> {
183
+ readonly source: string;
184
+ normalize(raw: Raw): CanonicalFinancialProfile;
185
+ }
@@ -0,0 +1,72 @@
1
+ // classify.mjs — map a provider account (type + subtype) to the canonical
2
+ // { class, taxTreatment }. Provider-neutral: adapters pass already-lowercased
3
+ // type/subtype strings. Returns a confidence so ambiguous guesses can warn.
4
+ //
5
+ // @typedef {import('./canonical').AccountClass} AccountClass
6
+ // @typedef {import('./canonical').TaxTreatment} TaxTreatment
7
+
8
+ /** Roth-flavored investment subtypes. */
9
+ const ROTH = /roth/;
10
+ /** Pre-tax retirement subtypes (traditional treatment). */
11
+ const PRETAX = /401k|403b|457b|401a|\bira\b|sep|simple|keogh|thrift|tsp|retirement/;
12
+ /** Education / 529. */
13
+ const EDU_529 = /529|education\s?savings/;
14
+ /** Explicitly taxable investment subtypes. */
15
+ const TAXABLE_INV = /brokerage|mutual fund|cash management|stock plan|crypto|ugma|utma|other/;
16
+
17
+ /**
18
+ * @param {string} type - provider account family (e.g. Plaid 'investment'|'depository'|'loan'|'credit')
19
+ * @param {string} [subtype]
20
+ * @returns {{ accountClass: AccountClass, taxTreatment: TaxTreatment, confidence: 'high'|'medium'|'low' }}
21
+ */
22
+ export function classify(type, subtype = '') {
23
+ const t = String(type || '').trim().toLowerCase();
24
+ const s = String(subtype || '').trim().toLowerCase();
25
+
26
+ if (t === 'loan') return { accountClass: 'loan', taxTreatment: 'na', confidence: 'high' };
27
+ if (t === 'credit') return { accountClass: 'credit', taxTreatment: 'na', confidence: 'high' };
28
+
29
+ if (t === 'depository') {
30
+ if (s === 'hsa') return { accountClass: 'investment', taxTreatment: 'hsa', confidence: 'high' };
31
+ return { accountClass: 'depository', taxTreatment: 'na', confidence: 'high' };
32
+ }
33
+
34
+ if (t === 'investment' || t === 'brokerage') {
35
+ if (s === 'hsa') return { accountClass: 'investment', taxTreatment: 'hsa', confidence: 'high' };
36
+ if (EDU_529.test(s)) return { accountClass: 'investment', taxTreatment: '529', confidence: 'high' };
37
+ // Ambiguous subtypes — treatment is a GUESS, so low confidence (→ warning):
38
+ // 'non-taxable brokerage' (Plaid): tax-deferred-ish wrapper with unclear
39
+ // treatment — do NOT let the 'brokerage' substring claim it as taxable
40
+ // at high confidence.
41
+ // 'pension': a defined-benefit pension is an income STREAM, not an
42
+ // investable balance — bucketing its "balance" as a traditional account
43
+ // is a coarse approximation that must surface to the caller.
44
+ if (/non-taxable brokerage/.test(s)) return { accountClass: 'investment', taxTreatment: 'taxable', confidence: 'low' };
45
+ if (/pension/.test(s)) return { accountClass: 'investment', taxTreatment: 'traditional', confidence: 'low' };
46
+ // 'tax-deferred' (Finicity investmentTaxDeferred, annuity wrappers): the
47
+ // provider says pre-tax but not WHICH wrapper — traditional treatment is
48
+ // the right lean, at low confidence so the guess surfaces to the caller.
49
+ if (/tax[- ]deferred/.test(s)) return { accountClass: 'investment', taxTreatment: 'traditional', confidence: 'low' };
50
+ if (ROTH.test(s)) return { accountClass: 'investment', taxTreatment: 'roth', confidence: 'high' };
51
+ if (PRETAX.test(s)) return { accountClass: 'investment', taxTreatment: 'traditional', confidence: 'high' };
52
+ if (TAXABLE_INV.test(s)) return { accountClass: 'investment', taxTreatment: 'taxable', confidence: 'high' };
53
+ // Unknown investment subtype → assume taxable but flag it.
54
+ return { accountClass: 'investment', taxTreatment: 'taxable', confidence: 'low' };
55
+ }
56
+
57
+ // Unknown top-level type — treat as taxable investment, low confidence.
58
+ return { accountClass: 'investment', taxTreatment: 'taxable', confidence: 'low' };
59
+ }
60
+
61
+ /** Map a provider security type → canonical assetType. Handles Plaid + MX vocab. */
62
+ export function classifyAsset(securityType = '') {
63
+ const s = String(securityType || '').trim().toLowerCase();
64
+ if (s === 'etf') return 'etf';
65
+ if (s === 'mutual fund') return 'mutual_fund';
66
+ if (s === 'equity' || s === 'stock' || s === 'common stock') return 'equity';
67
+ if (s === 'fixed income' || s === 'bond') return 'bond';
68
+ if (s === 'cash' || s === 'cash equivalent') return 'cash';
69
+ if (s.startsWith('crypto')) return 'crypto';
70
+ if (s === 'derivative') return 'other';
71
+ return 'other';
72
+ }
@@ -0,0 +1,65 @@
1
+ // contributions.mjs — infer a monthly contribution rate per account from Plaid
2
+ // investment transactions. Aggregators report balances, not savings rates, so
3
+ // projections need this signal or they understate growth.
4
+ //
5
+ // Heuristic: sum the money flowing INTO the account (deposits / contributions /
6
+ // transfers-in / payroll) over the observed window, then divide by the window
7
+ // length in months. Sign-agnostic (uses magnitude) to survive Plaid's debit/
8
+ // credit convention differences across products. Conservative: unknown → 0.
9
+ //
10
+ // Deliberately EXCLUDED: dividend/interest — that is investment GROWTH, already
11
+ // modeled by the plan's annual_return; counting it as a contribution would
12
+ // double-count it. Transfers ARE counted (a recurring ACH transfer-in is the
13
+ // most common real savings pattern), which means one-off rollovers can inflate
14
+ // the figure — the shared mapper (to-planfi.mjs) sanity-checks the inferred
15
+ // totals (vs known salary / IRS limits) and warns when they look inflated.
16
+
17
+ const IN_SUBTYPE = /contribution|deposit|transfer|payroll/i;
18
+ const GROWTH_SUBTYPE = /dividend|interest/i;
19
+
20
+ /**
21
+ * @param {Array<{account_id?:string, type?:string, subtype?:string, amount?:number, date?:string}>} txns
22
+ * @param {object} [opts]
23
+ * @param {number} [opts.windowMonths] - override the inferred window
24
+ * @returns {number} estimated monthly contribution (>= 0)
25
+ */
26
+ export function inferMonthlyContribution(txns, opts = {}) {
27
+ const list = (Array.isArray(txns) ? txns : []).filter(isInflow);
28
+ if (!list.length) return 0;
29
+ const total = list.reduce((s, t) => s + Math.abs(Number(t.amount) || 0), 0);
30
+ const months = opts.windowMonths || spanMonths(txns) || 12;
31
+ const monthly = total / months;
32
+ return Number.isFinite(monthly) && monthly > 0 ? Math.round(monthly) : 0;
33
+ }
34
+
35
+ /** Group investment transactions by account_id → inferred monthly contribution. */
36
+ export function contributionsByAccount(txns, opts = {}) {
37
+ const byAcct = new Map();
38
+ for (const t of Array.isArray(txns) ? txns : []) {
39
+ const k = t.account_id;
40
+ if (!k) continue;
41
+ (byAcct.get(k) ?? byAcct.set(k, []).get(k)).push(t);
42
+ }
43
+ const out = {};
44
+ for (const [acct, list] of byAcct) out[acct] = inferMonthlyContribution(list, opts);
45
+ return out;
46
+ }
47
+
48
+ /** A transaction that represents NEW money entering the account (not growth). */
49
+ function isInflow(t) {
50
+ const sub = String(t?.subtype ?? '');
51
+ if (GROWTH_SUBTYPE.test(sub)) return false; // dividends/interest = growth, not savings
52
+ if (IN_SUBTYPE.test(sub)) return true;
53
+ // Plaid credits cash into the account as a negative `amount` on a 'cash' type.
54
+ return String(t?.type ?? '') === 'cash' && Number(t?.amount) < 0;
55
+ }
56
+
57
+ /** Months spanned by the transaction dates (min→max), min 1. */
58
+ function spanMonths(txns) {
59
+ const times = (Array.isArray(txns) ? txns : [])
60
+ .map((t) => Date.parse(t?.date ?? ''))
61
+ .filter(Number.isFinite);
62
+ if (times.length < 2) return 0;
63
+ const span = (Math.max(...times) - Math.min(...times)) / (1000 * 60 * 60 * 24 * 30.44);
64
+ return Math.max(1, Math.round(span));
65
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,42 @@
1
+ // planfi-import — aggregator data → Planfi plans, via a canonical model.
2
+ // See docs/IMPORT_ADAPTERS.md. To ADD an adapter, follow docs/ADAPTER_GUIDE.md
3
+ // (and AGENTS.md for the invariants) — every adapter must be registered here
4
+ // in ADAPTERS + the named exports, typed in planfi-import.d.ts, and covered by
5
+ // a fixture in test/helpers/fixture-registry.mjs; test/adapter-contract.test.mjs
6
+ // enforces all of that.
7
+
8
+ export { classify, classifyAsset } from './classify.mjs';
9
+ export { inferMonthlyContribution, contributionsByAccount } from './contributions.mjs';
10
+ export { toPlanfiPlan } from './to-planfi.mjs';
11
+ export { plaidAdapter } from './adapters/plaid.mjs';
12
+ export { mxAdapter } from './adapters/mx.mjs';
13
+ export { finicityAdapter } from './adapters/finicity.mjs';
14
+ export { fdxAdapter } from './adapters/fdx.mjs';
15
+ export { csvAdapter } from './adapters/csv.mjs';
16
+ export { ofxAdapter } from './adapters/ofx.mjs';
17
+
18
+ import { plaidAdapter } from './adapters/plaid.mjs';
19
+ import { mxAdapter } from './adapters/mx.mjs';
20
+ import { finicityAdapter } from './adapters/finicity.mjs';
21
+ import { fdxAdapter } from './adapters/fdx.mjs';
22
+ import { csvAdapter } from './adapters/csv.mjs';
23
+ import { ofxAdapter } from './adapters/ofx.mjs';
24
+ import { toPlanfiPlan } from './to-planfi.mjs';
25
+
26
+ /** Registry of source adapters by id. */
27
+ export const ADAPTERS = { plaid: plaidAdapter, mx: mxAdapter, finicity: finicityAdapter, fdx: fdxAdapter, csv: csvAdapter, ofx: ofxAdapter };
28
+
29
+ /**
30
+ * One-call import: raw provider payload → { plan, warnings, needsInput, cfp }.
31
+ * @param {string} source - adapter id ('plaid' | 'mx' | 'finicity' | 'fdx' | 'csv' | 'ofx')
32
+ * @param {object} raw - provider-native payload
33
+ * @param {object} [opts] - forwarded to toPlanfiPlan (e.g. defaultState)
34
+ * @returns {{ plan: object, warnings: import('./canonical').ImportWarning[], needsInput: import('./canonical').NeedsInput[], cfp: import('./canonical').CanonicalFinancialProfile }}
35
+ */
36
+ export function importToPlan(source, raw, opts) {
37
+ const adapter = ADAPTERS[source];
38
+ if (!adapter) throw new Error(`No import adapter for source "${source}". Known: ${Object.keys(ADAPTERS).join(', ')}`);
39
+ const cfp = adapter.normalize(raw);
40
+ const { plan, warnings, needsInput } = toPlanfiPlan(cfp, opts);
41
+ return { plan, warnings, needsInput, cfp };
42
+ }