@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,303 @@
|
|
|
1
|
+
// fdx.mjs — FDX (Financial Data Exchange) → Canonical Financial Profile.
|
|
2
|
+
//
|
|
3
|
+
// FDX is the US open-banking standard (the CFPB §1033 personal-financial-data
|
|
4
|
+
// rule names it; Akoya speaks it natively; most large US institutions publish
|
|
5
|
+
// FDX-conformant APIs). Consumes FDX API entities (already fetched by the
|
|
6
|
+
// caller, same contract style as the Plaid/MX/Finicity adapters):
|
|
7
|
+
// GET /accounts → accounts[] — FDX Account entities. The FDX wire
|
|
8
|
+
// wraps each account in its shape key ({ depositAccount: {…} },
|
|
9
|
+
// { investmentAccount: {…} }, { loanAccount: {…} }, { locAccount: {…} },
|
|
10
|
+
// { lineOfCredit: {…} }, { annuityAccount: {…} }); this adapter accepts
|
|
11
|
+
// both the wrapped form and already-flattened entities. The wrapper key
|
|
12
|
+
// itself is a class signal, used as the fallback when `accountType` is
|
|
13
|
+
// a value we don't recognize.
|
|
14
|
+
// GET /accounts/{id} → investment `holdings[]`; the caller flattens
|
|
15
|
+
// holdings from all investment accounts into one `holdings[]` array,
|
|
16
|
+
// each tagged with its `accountId` (inline `investmentAccount.holdings`
|
|
17
|
+
// are read too).
|
|
18
|
+
// GET /accounts/{id}/transactions → transactions[] (optional; drives
|
|
19
|
+
// contribution inference — wrapped { investmentTransaction: {…} } or
|
|
20
|
+
// flat, each tagged with `accountId`).
|
|
21
|
+
//
|
|
22
|
+
// FDX ↔ CFP field correspondence (the vocabulary this adapter translates):
|
|
23
|
+
// accountId → CanonicalAccount.id
|
|
24
|
+
// nickname / productName → CanonicalAccount.name
|
|
25
|
+
// accountType (enum) → class + subtype via FDX_TYPE below
|
|
26
|
+
// currency.currencyCode → CanonicalAccount.currency
|
|
27
|
+
// depositAccount.currentBalance → balance (asset)
|
|
28
|
+
// investmentAccount.currentValue → balance (asset)
|
|
29
|
+
// loanAccount.principalBalance → balance (outstanding principal)
|
|
30
|
+
// locAccount.currentBalance → balance (amount owed)
|
|
31
|
+
// loan/loc interestRate → liability.rate (percentage → fraction)
|
|
32
|
+
// nextPaymentAmount /
|
|
33
|
+
// minimumPaymentAmount → liability.minPayment
|
|
34
|
+
// originalPrincipal → liability.originationPrincipal
|
|
35
|
+
// maturityDate → liability.monthsRemaining (vs asOf)
|
|
36
|
+
// InvestmentHolding.symbol → holding.ticker
|
|
37
|
+
// InvestmentHolding.securityName → holding.name
|
|
38
|
+
// InvestmentHolding.units → holding.quantity
|
|
39
|
+
// InvestmentHolding.marketValue → holding.value
|
|
40
|
+
// InvestmentHolding.costBasis → holding.costBasis (never fabricated)
|
|
41
|
+
// InvestmentHolding.holdingType → holding.assetType via FDX_HOLDING_TYPE
|
|
42
|
+
//
|
|
43
|
+
// Assumptions verified against the FDX data-structure conventions (noted
|
|
44
|
+
// because sign conventions bite):
|
|
45
|
+
// - LIABILITY BALANCES ARE POSITIVE amounts owed: `principalBalance` on a
|
|
46
|
+
// LoanAccount is the outstanding principal, `currentBalance` on a
|
|
47
|
+
// LocAccount/credit card is the amount owed. The adapter takes |balance|
|
|
48
|
+
// for loan/credit classes so an institution sign quirk can't zero out a
|
|
49
|
+
// debt downstream (the shared mapper clamps negative *asset* balances to
|
|
50
|
+
// $0 — correct for assets, wrong for debts).
|
|
51
|
+
// - DATES/TIMESTAMPS ARE ISO 8601 strings (FDX Timestamp/DateString) — no
|
|
52
|
+
// epoch conversion needed (contrast: Finicity sends epoch seconds).
|
|
53
|
+
// - TRANSACTION AMOUNTS: FDX carries `debitCreditMemo` ('DEBIT'|'CREDIT')
|
|
54
|
+
// next to `amount`. A CREDIT (or, absent the memo, a positive amount) into
|
|
55
|
+
// an investment account is a candidate contribution; DEBITs never are.
|
|
56
|
+
// - A depositAccount's `interestRate` is a savings YIELD, not a debt APR —
|
|
57
|
+
// it is deliberately NOT mapped to liability.rate.
|
|
58
|
+
// - FDX has NO property/real-estate account entity, so mortgages can't be
|
|
59
|
+
// paired with a market value; the shared mapper estimates at 80% LTV and
|
|
60
|
+
// asks for the real value via needsInput (same as Plaid/Finicity).
|
|
61
|
+
//
|
|
62
|
+
// Only FDX's vocabulary is translated here; ALL Planfi domain logic stays in
|
|
63
|
+
// to-planfi.mjs, shared with every other adapter.
|
|
64
|
+
//
|
|
65
|
+
// @typedef {import('../canonical').CanonicalFinancialProfile} CFP
|
|
66
|
+
// @typedef {import('../canonical').SourceAdapter} SourceAdapter
|
|
67
|
+
|
|
68
|
+
import { classify, classifyAsset } from '../classify.mjs';
|
|
69
|
+
import { contributionsByAccount } from '../contributions.mjs';
|
|
70
|
+
import { arr, objs, num, pct, groupBy, monthsBetween, defaultAsOf, warning } from '../util.mjs';
|
|
71
|
+
|
|
72
|
+
// FDX credit labels that are savings INFLOWS (counted) vs investment GROWTH
|
|
73
|
+
// (excluded — already modeled by annual_return). Same split as the siblings.
|
|
74
|
+
const FDX_INFLOW = /transfer|deposit|contribution|payroll|direct dep|\bdep\b|xfer/i;
|
|
75
|
+
const FDX_GROWTH = /dividend|interest|capital gain|reinvest|\bdiv\b|\bint\b/i;
|
|
76
|
+
|
|
77
|
+
// FDX `accountType` enum → generic [type, subtype?] that classify() consumes.
|
|
78
|
+
// Keys are UPPERCASE (the FDX convention: CHECKING, 401K, MORTGAGE, …).
|
|
79
|
+
const FDX_TYPE = {
|
|
80
|
+
// depository
|
|
81
|
+
CHECKING: ['depository', 'checking'],
|
|
82
|
+
SAVINGS: ['depository', 'savings'],
|
|
83
|
+
CD: ['depository', 'cd'],
|
|
84
|
+
MONEYMARKET: ['depository', 'money market'],
|
|
85
|
+
// investment — taxable wrappers
|
|
86
|
+
BROKERAGE: ['investment', 'brokerage'],
|
|
87
|
+
// investment — named retirement wrappers (classify() knows these words)
|
|
88
|
+
IRA: ['investment', 'ira'],
|
|
89
|
+
ROTH: ['investment', 'roth ira'],
|
|
90
|
+
ROTH401K: ['investment', 'roth 401k'],
|
|
91
|
+
'401K': ['investment', '401k'],
|
|
92
|
+
'403B': ['investment', '403b'],
|
|
93
|
+
457: ['investment', '457b'],
|
|
94
|
+
KEOGH: ['investment', 'keogh'],
|
|
95
|
+
SEPIRA: ['investment', 'sep ira'],
|
|
96
|
+
SIMPLEIRA: ['investment', 'simple ira'],
|
|
97
|
+
// Tax-Deferred Annuity: pre-tax wrapper of unknown flavor → 'tax-deferred'
|
|
98
|
+
// hints traditional at LOW confidence in classify() (surfaces as a guess).
|
|
99
|
+
TDA: ['investment', 'tax-deferred'],
|
|
100
|
+
ANNUITY: ['investment', 'tax-deferred'],
|
|
101
|
+
// education + health
|
|
102
|
+
529: ['investment', '529'],
|
|
103
|
+
HSA: ['investment', 'hsa'],
|
|
104
|
+
// loans
|
|
105
|
+
MORTGAGE: ['loan', 'mortgage'],
|
|
106
|
+
HOMEEQUITYLOAN: ['loan', 'home equity'],
|
|
107
|
+
LOAN: ['loan', undefined],
|
|
108
|
+
AUTOLOAN: ['loan', 'auto'],
|
|
109
|
+
STUDENTLOAN: ['loan', 'student'],
|
|
110
|
+
PERSONALLOAN: ['loan', 'personal'],
|
|
111
|
+
// revolving credit
|
|
112
|
+
CREDITCARD: ['credit', 'credit card'],
|
|
113
|
+
LINEOFCREDIT: ['credit', 'line of credit'],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// FDX account-shape wrapper key → fallback [type, subtype?] when accountType
|
|
117
|
+
// is missing/unrecognized (the shape itself still tells the account family).
|
|
118
|
+
const FDX_CONTAINER = {
|
|
119
|
+
depositAccount: ['depository', undefined],
|
|
120
|
+
investmentAccount: ['investment', undefined],
|
|
121
|
+
annuityAccount: ['investment', 'tax-deferred'],
|
|
122
|
+
loanAccount: ['loan', undefined],
|
|
123
|
+
locAccount: ['credit', 'line of credit'],
|
|
124
|
+
lineOfCredit: ['credit', 'line of credit'],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// FDX InvestmentHolding.holdingType → words classifyAsset() understands.
|
|
128
|
+
const FDX_HOLDING_TYPE = {
|
|
129
|
+
STOCK: 'stock',
|
|
130
|
+
ETF: 'etf',
|
|
131
|
+
MUTUALFUND: 'mutual fund',
|
|
132
|
+
BOND: 'bond',
|
|
133
|
+
CD: 'cash equivalent',
|
|
134
|
+
CASH: 'cash',
|
|
135
|
+
MONEYMARKET: 'cash equivalent',
|
|
136
|
+
DIGITALASSET: 'cryptocurrency',
|
|
137
|
+
OPTION: 'derivative',
|
|
138
|
+
ANNUITY: 'other',
|
|
139
|
+
OTHER: 'other',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** @implements {SourceAdapter} */
|
|
143
|
+
export const fdxAdapter = {
|
|
144
|
+
source: 'fdx',
|
|
145
|
+
/**
|
|
146
|
+
* @param {object} raw - { accounts, holdings, transactions, owner, asOf }
|
|
147
|
+
* @returns {CFP}
|
|
148
|
+
*/
|
|
149
|
+
normalize(raw) {
|
|
150
|
+
raw = raw && typeof raw === 'object' ? raw : {};
|
|
151
|
+
const warnings = [];
|
|
152
|
+
const unmapped = [];
|
|
153
|
+
|
|
154
|
+
// First pass: unwrap the FDX shape containers so classification and the
|
|
155
|
+
// contribution pass see one flat entity per account.
|
|
156
|
+
const entities = arr(raw.accounts).map((e, i) => unwrapAccount(e, i));
|
|
157
|
+
const holdingsByAccount = groupBy(objs(raw.holdings), (h) => String(h.accountId));
|
|
158
|
+
|
|
159
|
+
// Contributions: CREDITs into investment accounts are candidate inflows.
|
|
160
|
+
// Filter by transactionType/category/description so growth (dividends/
|
|
161
|
+
// interest/reinvest) isn't double-counted as savings; a credit with NO
|
|
162
|
+
// usable label is counted but flagged once as coarse.
|
|
163
|
+
const invIds = new Set(entities
|
|
164
|
+
.filter(({ acct, container }) => (fdxType(acct.accountType) ?? FDX_CONTAINER[container] ?? ['investment'])[0] === 'investment')
|
|
165
|
+
.map(({ id }) => id));
|
|
166
|
+
let sawUnlabeledCredit = false;
|
|
167
|
+
const normTxns = arr(raw.transactions)
|
|
168
|
+
.map(unwrapTransaction)
|
|
169
|
+
.filter((t) => {
|
|
170
|
+
if (!invIds.has(String(t.accountId))) return false;
|
|
171
|
+
const memo = up(t.debitCreditMemo);
|
|
172
|
+
if (memo === 'DEBIT') return false; // money out is never a contribution
|
|
173
|
+
const amount = num(t.totalAmount) || num(t.amount);
|
|
174
|
+
if (!(Math.abs(amount) > 0) || (memo !== 'CREDIT' && !(amount > 0))) return false;
|
|
175
|
+
const label = `${t.transactionType ?? ''} ${t.category ?? ''} ${t.description ?? ''} ${t.memo ?? ''}`.trim();
|
|
176
|
+
if (!label) { sawUnlabeledCredit = true; return true; } // no signal → coarse include
|
|
177
|
+
if (FDX_GROWTH.test(label)) return false; // dividends/interest = growth
|
|
178
|
+
return FDX_INFLOW.test(label); // labeled but neither → exclude
|
|
179
|
+
})
|
|
180
|
+
.map((t) => ({
|
|
181
|
+
account_id: String(t.accountId),
|
|
182
|
+
subtype: 'contribution',
|
|
183
|
+
amount: -Math.abs(num(t.totalAmount) || num(t.amount)),
|
|
184
|
+
date: t.postedTimestamp ?? t.transactionTimestamp ?? t.date, // ISO 8601 per FDX
|
|
185
|
+
}));
|
|
186
|
+
if (sawUnlabeledCredit) {
|
|
187
|
+
warnings.push(warning('COARSE_INFERENCE', 'warn',
|
|
188
|
+
'FDX contribution inference is coarse: some investment-account credits carry no transactionType/description, so ALL such unlabeled credits were counted as contributions (may include dividends or rollovers). Verify inferred contribution rates.'));
|
|
189
|
+
}
|
|
190
|
+
const contribByAccount = contributionsByAccount(normTxns);
|
|
191
|
+
|
|
192
|
+
const accounts = entities.map(({ acct: a, container, id }) => {
|
|
193
|
+
const mapped = fdxType(a.accountType);
|
|
194
|
+
const [genType, genSub] = mapped ?? FDX_CONTAINER[container] ?? ['investment', undefined];
|
|
195
|
+
const subtype = genSub ?? inferLoanSubtype(a.nickname ?? a.productName);
|
|
196
|
+
const { accountClass, taxTreatment, confidence } = classify(genType, subtype);
|
|
197
|
+
if (confidence === 'low' || !mapped) {
|
|
198
|
+
warnings.push(warning('CLASSIFICATION_GUESSED', 'warn',
|
|
199
|
+
`FDX account "${a.nickname ?? a.productName ?? id}" (accountType "${a.accountType ?? '?'}") classification guessed → ${accountClass}/${taxTreatment}.`, id));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Liability balances are positive amounts owed per FDX conventions;
|
|
203
|
+
// |x| defends against institutions that report them negative (see header).
|
|
204
|
+
const isDebt = accountClass === 'loan' || accountClass === 'credit';
|
|
205
|
+
const rawBalance = num(a.principalBalance) || num(a.currentValue)
|
|
206
|
+
|| num(a.currentBalance) || num(a.balance) || num(a.availableBalance) || 0;
|
|
207
|
+
const balance = isDebt ? Math.abs(rawBalance) : rawBalance;
|
|
208
|
+
|
|
209
|
+
const out = {
|
|
210
|
+
id,
|
|
211
|
+
institution: a.fiName ?? (a.institutionId != null ? String(a.institutionId) : undefined),
|
|
212
|
+
name: a.nickname ?? a.productName ?? undefined,
|
|
213
|
+
class: accountClass,
|
|
214
|
+
subtype: String(subtype ?? '').toLowerCase(),
|
|
215
|
+
taxTreatment,
|
|
216
|
+
balance,
|
|
217
|
+
currency: (typeof a.currency === 'string' ? a.currency : a.currency?.currencyCode) ?? 'USD',
|
|
218
|
+
// Which earner owns it (0/1). FDX doesn't attribute accounts to
|
|
219
|
+
// household members; the caller sets ownerIndex (e.g. from the
|
|
220
|
+
// customer records behind the consent grant). Defaults to primary.
|
|
221
|
+
ownerIndex: Number.isInteger(a.ownerIndex) ? a.ownerIndex : 0,
|
|
222
|
+
...(contribByAccount[id] ? { estMonthlyContribution: contribByAccount[id] } : {}),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (accountClass === 'investment') {
|
|
226
|
+
const hs = [...(holdingsByAccount.get(id) ?? []), ...objs(a.holdings)];
|
|
227
|
+
out.holdings = hs.map((h) => {
|
|
228
|
+
if (h.costBasis == null) {
|
|
229
|
+
warnings.push(warning('NO_COST_BASIS', 'info',
|
|
230
|
+
`Holding ${h.symbol ?? h.securityName ?? h.holdingId ?? '?'} has no cost basis (the FDX source did not report it).`, id));
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
ticker: h.symbol ?? undefined,
|
|
234
|
+
name: h.securityName ?? h.holdingName ?? h.description ?? undefined,
|
|
235
|
+
quantity: num(h.units),
|
|
236
|
+
value: num(h.marketValue),
|
|
237
|
+
costBasis: h.costBasis == null ? undefined : num(h.costBasis),
|
|
238
|
+
assetType: classifyAsset(FDX_HOLDING_TYPE[up(h.holdingType)] ?? h.holdingType),
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (isDebt) {
|
|
243
|
+
out.liability = {
|
|
244
|
+
// interestRate is only read on debt shapes — a depositAccount's
|
|
245
|
+
// interestRate is a savings yield, not an APR (see header).
|
|
246
|
+
rate: pct(a.interestRate ?? a.interestRatePercentage ?? a.apr),
|
|
247
|
+
minPayment: num(a.minimumPaymentAmount ?? a.nextPaymentAmount ?? a.payment) || undefined,
|
|
248
|
+
originationPrincipal: num(a.originalPrincipal ?? a.originalLoanAmount ?? a.creditLine) || undefined,
|
|
249
|
+
monthsRemaining: monthsBetween(raw.asOf, a.maturityDate ?? a.payoffDate),
|
|
250
|
+
...(subtype === 'mortgage' ? { assetName: a.nickname || a.productName || 'Home' } : {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
source: 'fdx',
|
|
258
|
+
// Default snapshot time is NOW (not the 1970 epoch — see util.mjs).
|
|
259
|
+
asOf: raw.asOf || defaultAsOf(),
|
|
260
|
+
owner: { ...(raw.owner ?? {}) },
|
|
261
|
+
accounts,
|
|
262
|
+
meta: { warnings, unmapped },
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
268
|
+
// (arr/num/pct/groupBy/monthsBetween/warning live in ../util.mjs, shared with
|
|
269
|
+
// every other adapter.)
|
|
270
|
+
const up = (x) => String(x ?? '').trim().toUpperCase();
|
|
271
|
+
/** Own-property FDX_TYPE lookup (a hostile accountType like "constructor" must miss). */
|
|
272
|
+
const fdxType = (t) => (Object.hasOwn(FDX_TYPE, up(t)) ? FDX_TYPE[up(t)] : undefined);
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Unwrap one FDX account entity: { depositAccount: {…} } → its inner object +
|
|
276
|
+
* which container it came in (a class signal). Flat entities pass through.
|
|
277
|
+
*/
|
|
278
|
+
function unwrapAccount(entity, index) {
|
|
279
|
+
let acct = entity && typeof entity === 'object' ? entity : {};
|
|
280
|
+
let container;
|
|
281
|
+
for (const key of Object.keys(FDX_CONTAINER)) {
|
|
282
|
+
if (acct[key] && typeof acct[key] === 'object') { container = key; acct = acct[key]; break; }
|
|
283
|
+
}
|
|
284
|
+
const id = String(acct.accountId ?? acct.id ?? `fdx:${index}`);
|
|
285
|
+
return { acct, container, id };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Unwrap one FDX transaction ({ investmentTransaction: {…} } or flat). */
|
|
289
|
+
function unwrapTransaction(t) {
|
|
290
|
+
if (!t || typeof t !== 'object') return {};
|
|
291
|
+
for (const key of ['investmentTransaction', 'depositTransaction', 'loanTransaction', 'locTransaction']) {
|
|
292
|
+
if (t[key] && typeof t[key] === 'object') return t[key];
|
|
293
|
+
}
|
|
294
|
+
return t;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function inferLoanSubtype(name) {
|
|
298
|
+
const n = String(name ?? '').toLowerCase();
|
|
299
|
+
if (/student/.test(n)) return 'student';
|
|
300
|
+
if (/auto|car|vehicle/.test(n)) return 'auto';
|
|
301
|
+
if (/mortgage|home/.test(n)) return 'mortgage';
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// finicity.mjs — Finicity (Mastercard Open Banking) → Canonical Financial Profile.
|
|
2
|
+
//
|
|
3
|
+
// Consumes Finicity API entities (already fetched + merged by the caller, the
|
|
4
|
+
// same contract style as the Plaid and MX adapters):
|
|
5
|
+
// GET /aggregation/v1/customers/{customerId}/accounts
|
|
6
|
+
// → accounts[] (each account carries `type`, `balance`, and — for loans,
|
|
7
|
+
// mortgages and cards — a `detail` object with interestRate / payment /
|
|
8
|
+
// maturity fields when the institution reports them)
|
|
9
|
+
// GET /aggregation/v1/customers/{customerId}/accounts/{accountId}
|
|
10
|
+
// → account details incl. investment `position[]`; the caller flattens
|
|
11
|
+
// positions from all investment accounts into one `positions[]` array,
|
|
12
|
+
// each tagged with its `accountId`
|
|
13
|
+
// GET /aggregation/v3/customers/{customerId}/transactions
|
|
14
|
+
// → transactions[] (optional; drives contribution inference — Finicity
|
|
15
|
+
// transactions carry a `categorization.category` plus, on investment
|
|
16
|
+
// accounts, an `investmentTransactionType`)
|
|
17
|
+
//
|
|
18
|
+
// Assumptions verified against the Finicity API docs (note them because sign
|
|
19
|
+
// conventions bite):
|
|
20
|
+
// - LIABILITY BALANCES ARE POSITIVE on the account record: a mortgage's
|
|
21
|
+
// `balance` is the outstanding principal, a creditCard's `balance` is the
|
|
22
|
+
// amount owed. Some institutions have been observed reporting card
|
|
23
|
+
// balances negative; the adapter takes |balance| for loan/credit classes
|
|
24
|
+
// so a sign quirk can't zero out a debt downstream (the shared mapper
|
|
25
|
+
// clamps negative *asset* balances to $0 with a warning — correct for
|
|
26
|
+
// assets, wrong for debts).
|
|
27
|
+
// - TRANSACTION AMOUNTS are signed from the account holder's perspective:
|
|
28
|
+
// deposits into an account are POSITIVE, withdrawals negative.
|
|
29
|
+
// - TRANSACTION DATES (`transactedDate`/`postedDate`) are epoch SECONDS,
|
|
30
|
+
// not ISO strings — normalized here before contribution inference.
|
|
31
|
+
// - Finicity has NO property/real-estate account type, so mortgages can't
|
|
32
|
+
// be paired with a market value; the shared mapper estimates at 80% LTV
|
|
33
|
+
// and asks for the real value via needsInput (same as Plaid).
|
|
34
|
+
//
|
|
35
|
+
// Only Finicity's vocabulary is translated here; ALL Planfi domain logic
|
|
36
|
+
// stays in to-planfi.mjs, shared with every other adapter.
|
|
37
|
+
//
|
|
38
|
+
// @typedef {import('../canonical').CanonicalFinancialProfile} CFP
|
|
39
|
+
// @typedef {import('../canonical').SourceAdapter} SourceAdapter
|
|
40
|
+
|
|
41
|
+
import { classify, classifyAsset } from '../classify.mjs';
|
|
42
|
+
import { contributionsByAccount } from '../contributions.mjs';
|
|
43
|
+
import { arr, objs, num, pct, groupBy, monthsBetween, defaultAsOf, warning } from '../util.mjs';
|
|
44
|
+
|
|
45
|
+
// Finicity credit categorization/labels that are savings INFLOWS (counted) vs
|
|
46
|
+
// investment GROWTH (excluded — already modeled by annual_return). Same split
|
|
47
|
+
// the MX adapter applies to its category/description labels.
|
|
48
|
+
const FIN_INFLOW = /transfer|deposit|contribution|payroll|direct dep/i;
|
|
49
|
+
const FIN_GROWTH = /dividend|interest|capital gain|reinvest/i;
|
|
50
|
+
|
|
51
|
+
// Finicity account `type` → generic [type, subtype?] that classify() consumes.
|
|
52
|
+
// Keys are lowercased (Finicity sends camelCase like `investmentTaxDeferred`).
|
|
53
|
+
const FIN_TYPE = {
|
|
54
|
+
// depository
|
|
55
|
+
checking: ['depository', 'checking'],
|
|
56
|
+
savings: ['depository', 'savings'],
|
|
57
|
+
cd: ['depository', 'cd'],
|
|
58
|
+
moneymarket: ['depository', 'money market'],
|
|
59
|
+
// investment — generic wrappers
|
|
60
|
+
investment: ['investment', undefined],
|
|
61
|
+
brokerageaccount: ['investment', 'brokerage'],
|
|
62
|
+
// Pre-tax wrapper of unknown flavor → 'tax-deferred' hints traditional at
|
|
63
|
+
// LOW confidence in classify() (no finer signal than "deferred").
|
|
64
|
+
investmenttaxdeferred: ['investment', 'tax-deferred'],
|
|
65
|
+
// investment — named retirement wrappers (classify() knows these words)
|
|
66
|
+
ira: ['investment', 'ira'],
|
|
67
|
+
roth: ['investment', 'roth ira'],
|
|
68
|
+
'401k': ['investment', '401k'],
|
|
69
|
+
'403b': ['investment', '403b'],
|
|
70
|
+
simpleira: ['investment', 'simple ira'],
|
|
71
|
+
sepira: ['investment', 'sep ira'],
|
|
72
|
+
keogh: ['investment', 'keogh'],
|
|
73
|
+
rollover: ['investment', 'rollover ira'],
|
|
74
|
+
// education
|
|
75
|
+
'529plan': ['investment', '529'],
|
|
76
|
+
'529': ['investment', '529'],
|
|
77
|
+
educationira: ['investment', 'education savings'], // Coverdell ESA → 529 treatment
|
|
78
|
+
// health
|
|
79
|
+
hsa: ['investment', 'hsa'],
|
|
80
|
+
// loans
|
|
81
|
+
mortgage: ['loan', 'mortgage'],
|
|
82
|
+
homeequityloan: ['loan', 'home equity'],
|
|
83
|
+
loan: ['loan', undefined],
|
|
84
|
+
studentloan: ['loan', 'student'],
|
|
85
|
+
studentloangroup: ['loan', 'student'],
|
|
86
|
+
studentloanaccount: ['loan', 'student'],
|
|
87
|
+
autoloan: ['loan', 'auto'],
|
|
88
|
+
// revolving credit
|
|
89
|
+
creditcard: ['credit', 'credit card'],
|
|
90
|
+
lineofcredit: ['credit', 'line of credit'],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** @implements {SourceAdapter} */
|
|
94
|
+
export const finicityAdapter = {
|
|
95
|
+
source: 'finicity',
|
|
96
|
+
/**
|
|
97
|
+
* @param {object} raw - { accounts, positions, transactions, owner, asOf }
|
|
98
|
+
* @returns {CFP}
|
|
99
|
+
*/
|
|
100
|
+
normalize(raw) {
|
|
101
|
+
// Total function: null/primitive payloads normalize to an empty profile
|
|
102
|
+
// (a default parameter only covers `undefined` — the contract harness
|
|
103
|
+
// caught the null case throwing).
|
|
104
|
+
raw = raw && typeof raw === 'object' ? raw : {};
|
|
105
|
+
const warnings = [];
|
|
106
|
+
const unmapped = [];
|
|
107
|
+
const accountsIn = objs(raw.accounts);
|
|
108
|
+
const positionsByAccount = groupBy(objs(raw.positions), (p) => p.accountId);
|
|
109
|
+
|
|
110
|
+
// Contributions: deposits (positive amounts) into investment accounts are
|
|
111
|
+
// candidate inflows. Finicity carries categorization on most transactions;
|
|
112
|
+
// filter growth (dividends/interest/reinvest) out the same way MX does.
|
|
113
|
+
// A deposit with NO usable label is counted but flagged once as coarse.
|
|
114
|
+
const invIds = new Set(accountsIn
|
|
115
|
+
.filter((a) => (finType(a.type) ?? ['investment'])[0] === 'investment')
|
|
116
|
+
.map((a) => String(a.id)));
|
|
117
|
+
let sawUnlabeledDeposit = false;
|
|
118
|
+
const normTxns = objs(raw.transactions)
|
|
119
|
+
.filter((t) => {
|
|
120
|
+
if (!invIds.has(String(t.accountId)) || !(num(t.amount) > 0)) return false;
|
|
121
|
+
const label = `${t.investmentTransactionType ?? ''} ${t.categorization?.category ?? ''} ${t.description ?? ''} ${t.memo ?? ''}`.trim();
|
|
122
|
+
if (!label) { sawUnlabeledDeposit = true; return true; } // no signal → coarse include
|
|
123
|
+
if (FIN_GROWTH.test(label)) return false; // dividends/interest = growth
|
|
124
|
+
return FIN_INFLOW.test(label); // labeled but neither → exclude
|
|
125
|
+
})
|
|
126
|
+
.map((t) => ({
|
|
127
|
+
account_id: String(t.accountId),
|
|
128
|
+
subtype: 'contribution',
|
|
129
|
+
amount: -Math.abs(num(t.amount)),
|
|
130
|
+
date: finDate(t.transactedDate ?? t.postedDate ?? t.date),
|
|
131
|
+
}));
|
|
132
|
+
if (sawUnlabeledDeposit) {
|
|
133
|
+
warnings.push(warning('COARSE_INFERENCE', 'warn',
|
|
134
|
+
'Finicity contribution inference is coarse: some investment-account deposits carry no categorization/description, so ALL such unlabeled deposits were counted as contributions (may include dividends or rollovers). Verify inferred contribution rates.'));
|
|
135
|
+
}
|
|
136
|
+
const contribByAccount = contributionsByAccount(normTxns);
|
|
137
|
+
|
|
138
|
+
const accounts = accountsIn.map((a) => {
|
|
139
|
+
const id = String(a.id);
|
|
140
|
+
const mapped = finType(a.type);
|
|
141
|
+
const [genType, genSub] = mapped ?? ['investment', undefined];
|
|
142
|
+
const subtype = genSub ?? inferLoanSubtype(a.name);
|
|
143
|
+
const { accountClass, taxTreatment, confidence } = classify(genType, subtype);
|
|
144
|
+
if (confidence === 'low' || !mapped) {
|
|
145
|
+
warnings.push(warning('CLASSIFICATION_GUESSED', 'warn',
|
|
146
|
+
`Finicity account "${a.name ?? id}" (type "${a.type ?? '?'}") classification guessed → ${accountClass}/${taxTreatment}.`, id));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Liability balances are positive per the Finicity docs; |x| defends
|
|
150
|
+
// against institutions that report cards negative (see header).
|
|
151
|
+
const isDebt = accountClass === 'loan' || accountClass === 'credit';
|
|
152
|
+
const rawBalance = num(a.balance) || num(a.detail?.availableBalanceAmount) || 0;
|
|
153
|
+
const balance = isDebt ? Math.abs(rawBalance) : rawBalance;
|
|
154
|
+
|
|
155
|
+
const acct = {
|
|
156
|
+
id,
|
|
157
|
+
institution: a.institutionId != null ? String(a.institutionId) : undefined,
|
|
158
|
+
name: a.name,
|
|
159
|
+
class: accountClass,
|
|
160
|
+
subtype: String(subtype ?? '').toLowerCase(),
|
|
161
|
+
taxTreatment,
|
|
162
|
+
balance,
|
|
163
|
+
currency: a.currency ?? 'USD',
|
|
164
|
+
// Which earner owns it (0/1). Finicity doesn't attribute accounts to
|
|
165
|
+
// household members; the caller sets ownerIndex (e.g. from customer
|
|
166
|
+
// records). Defaults to the primary earner.
|
|
167
|
+
ownerIndex: Number.isInteger(a.ownerIndex) ? a.ownerIndex : 0,
|
|
168
|
+
...(contribByAccount[id] ? { estMonthlyContribution: contribByAccount[id] } : {}),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (accountClass === 'investment') {
|
|
172
|
+
const ps = positionsByAccount.get(a.id) ?? positionsByAccount.get(id) ?? [];
|
|
173
|
+
acct.holdings = ps.map((p) => {
|
|
174
|
+
if (p.costBasis == null) {
|
|
175
|
+
warnings.push(warning('NO_COST_BASIS', 'info',
|
|
176
|
+
`Holding ${p.symbol ?? p.description ?? p.id} has no cost basis (Finicity did not report it).`, id));
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
ticker: p.symbol ?? undefined,
|
|
180
|
+
name: p.description ?? p.fundName ?? undefined,
|
|
181
|
+
quantity: num(p.units ?? p.quantity),
|
|
182
|
+
value: num(p.marketValue),
|
|
183
|
+
costBasis: p.costBasis == null ? undefined : num(p.costBasis),
|
|
184
|
+
assetType: classifyAsset(p.securityType),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (isDebt) {
|
|
189
|
+
// Loan/card detail lives on account.detail (populated by the account-
|
|
190
|
+
// details call). Field names vary by product; try the documented ones.
|
|
191
|
+
const d = a.detail ?? {};
|
|
192
|
+
acct.liability = {
|
|
193
|
+
rate: pct(d.interestRate ?? d.interestRatePercent ?? a.interestRate),
|
|
194
|
+
minPayment: num(d.payment ?? d.nextPayment ?? d.paymentMinAmount ?? d.minimumPaymentAmount) || undefined,
|
|
195
|
+
originationPrincipal: num(d.originalLoanAmount ?? d.creditLimit) || undefined,
|
|
196
|
+
monthsRemaining: monthsBetween(raw.asOf, finDateIso(d.maturityDate ?? d.payoffDate ?? d.endDate)),
|
|
197
|
+
...(subtype === 'mortgage' ? { assetName: a.name || 'Home' } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return acct;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
source: 'finicity',
|
|
205
|
+
// Default snapshot time is NOW (not the 1970 epoch — see util.mjs).
|
|
206
|
+
asOf: raw.asOf || defaultAsOf(),
|
|
207
|
+
owner: { ...(raw.owner ?? {}) },
|
|
208
|
+
accounts,
|
|
209
|
+
meta: { warnings, unmapped },
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
215
|
+
// (arr/num/pct/groupBy/monthsBetween/warning live in ../util.mjs, shared with
|
|
216
|
+
// the plaid + mx adapters.)
|
|
217
|
+
const low = (x) => String(x ?? '').trim().toLowerCase();
|
|
218
|
+
/** Own-property FIN_TYPE lookup (a hostile `type` like "constructor" must miss). */
|
|
219
|
+
const finType = (t) => (Object.hasOwn(FIN_TYPE, low(t)) ? FIN_TYPE[low(t)] : undefined);
|
|
220
|
+
|
|
221
|
+
/** Finicity dates are epoch SECONDS; also accept ISO strings. → ISO or undefined. */
|
|
222
|
+
function finDateIso(v) {
|
|
223
|
+
if (v == null || v === '') return undefined;
|
|
224
|
+
const n = Number(v);
|
|
225
|
+
if (Number.isFinite(n) && n > 0) {
|
|
226
|
+
// Guard the ECMAScript date range (±8.64e15 ms): an absurd epoch value
|
|
227
|
+
// must yield undefined, not a thrown RangeError from toISOString()
|
|
228
|
+
// (caught by the contract harness's scrambled-fixture battery).
|
|
229
|
+
const ms = n * 1000;
|
|
230
|
+
return ms <= 8.64e15 ? new Date(ms).toISOString() : undefined;
|
|
231
|
+
}
|
|
232
|
+
return Number.isFinite(Date.parse(v)) ? String(v) : undefined;
|
|
233
|
+
}
|
|
234
|
+
/** Same, but for transaction dates fed into contribution inference. */
|
|
235
|
+
const finDate = finDateIso;
|
|
236
|
+
|
|
237
|
+
function inferLoanSubtype(name) {
|
|
238
|
+
const n = String(name ?? '').toLowerCase();
|
|
239
|
+
if (/student/.test(n)) return 'student';
|
|
240
|
+
if (/auto|car|vehicle/.test(n)) return 'auto';
|
|
241
|
+
if (/mortgage|home/.test(n)) return 'mortgage';
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|