@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,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
+ }