@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,112 @@
1
+ // _template.mjs — COPY-ME adapter skeleton. NOT REGISTERED in ADAPTERS on
2
+ // purpose (the leading underscore is the convention for "not a real source").
3
+ //
4
+ // How to use (the full step-by-step recipe lives in docs/ADAPTER_GUIDE.md):
5
+ // 1. Copy this file to src/adapters/<source>.mjs and rename `templateAdapter`
6
+ // → `<source>Adapter`, `source: '_template'` → your adapter id.
7
+ // 2. Fill in the TODOs below — translate ONLY your provider's vocabulary
8
+ // into the Canonical Financial Profile. NO Planfi domain logic here;
9
+ // that all lives in to-planfi.mjs, shared by every adapter.
10
+ // 3. Register it (index.mjs exports + ADAPTERS, planfi-import.d.ts, the CLI
11
+ // source list in bin/planfi-import.mjs), add fixtures/<source>-sandbox.mjs
12
+ // exporting `<source>Raw`, register that in
13
+ // test/helpers/fixture-registry.mjs, and add a fuzz generator.
14
+ // 4. Run `node --test` — test/adapter-contract.test.mjs runs every
15
+ // registered adapter through the identical battery.
16
+ //
17
+ // AS SHIPPED this skeleton returns an empty-but-STRUCTURALLY-VALID CFP: it
18
+ // passes the structural validator (validateCFP) but would FAIL the contract
19
+ // harness's fixture-content floor (a registered adapter's fixture must
20
+ // produce accounts and exercise at least one warning path) — that failure is
21
+ // the to-do list. A guide-consistency test in adapter-contract.test.mjs
22
+ // asserts exactly this behavior, so the template can't silently rot.
23
+ //
24
+ // Invariants (AGENTS.md is the authoritative list):
25
+ // - NEVER fabricate values. Missing cost basis stays undefined + a
26
+ // NO_COST_BASIS info warning; a guessed classification is a
27
+ // CLASSIFICATION_GUESSED warning; a value only the user can know becomes
28
+ // a needsInput ask (emitted by the shared mapper, not by you).
29
+ // - Warning codes come from the append-only catalog in src/canonical.ts
30
+ // (WarningCode). Build them with warning() from util.mjs — never ad-hoc.
31
+ // - Zero runtime dependencies. Only node built-ins and sibling modules.
32
+ //
33
+ // @typedef {import('../canonical').CanonicalFinancialProfile} CFP
34
+ // @typedef {import('../canonical').SourceAdapter} SourceAdapter
35
+
36
+ import { classify, classifyAsset } from '../classify.mjs';
37
+ import { contributionsByAccount } from '../contributions.mjs';
38
+ import { arr, num, pct, groupBy, monthsBetween, defaultAsOf, warning } from '../util.mjs';
39
+
40
+ // TODO: map YOUR provider's account-type vocabulary → the generic
41
+ // [type, subtype?] pair that classify() consumes. See the classification
42
+ // cheat sheet in docs/ADAPTER_GUIDE.md for the words classify() understands.
43
+ // Example:
44
+ // const MY_TYPE = {
45
+ // CHECKING: ['depository', 'checking'],
46
+ // '401K': ['investment', '401k'],
47
+ // MORTGAGE: ['loan', 'mortgage'],
48
+ // CREDITCARD: ['credit', 'credit card'],
49
+ // };
50
+
51
+ /** @implements {SourceAdapter} */
52
+ export const templateAdapter = {
53
+ source: '_template', // TODO: your adapter id (lowercase, matches the file name)
54
+
55
+ /**
56
+ * Translate one raw provider payload → a Canonical Financial Profile.
57
+ * MUST be a total function: any input (null, junk, truncated data) returns
58
+ * a valid CFP — never throw. MUST be deterministic: same input, same output
59
+ * (the only allowed nondeterminism is defaultAsOf() when raw.asOf is absent).
60
+ *
61
+ * @param {object} raw - { accounts, holdings?, transactions?, owner, asOf }
62
+ * (document YOUR provider's exact shape here: which
63
+ * API endpoints the caller merges into each key)
64
+ * @returns {CFP}
65
+ */
66
+ normalize(raw) {
67
+ // Hostile-input floor: null/undefined/primitive payloads normalize to an
68
+ // empty profile instead of throwing (property access on null throws!).
69
+ raw = raw && typeof raw === 'object' ? raw : {};
70
+
71
+ const warnings = []; // structured ImportWarning[] — use warning(code, sev, msg, accountId?)
72
+ const unmapped = []; // raw entities you could NOT map — push them here, never drop silently
73
+
74
+ // TODO 1: normalize transactions FIRST if your provider has them, and run
75
+ // contributionsByAccount() to infer per-account monthly savings:
76
+ // - only money flowing INTO investment accounts counts
77
+ // - dividends/interest/reinvest are GROWTH → exclude
78
+ // - an unlabeled credit is counted but flagged once: COARSE_INFERENCE
79
+ // (Copy the pattern from src/adapters/fdx.mjs or finicity.mjs.)
80
+ const contribByAccount = contributionsByAccount([]);
81
+
82
+ // TODO 2: map every provider account → a CanonicalAccount:
83
+ // const accounts = arr(raw.accounts).map((a) => { ... });
84
+ // - id: String(provider id) — stable, required
85
+ // - class/taxTreatment: classify(genType, subtype); confidence 'low'
86
+ // (or an unrecognized provider type) → push CLASSIFICATION_GUESSED
87
+ // - balance: finite number; for loan/credit take Math.abs(x) — liability
88
+ // balances are positive outstanding principal in the CFP
89
+ // - holdings (investment only): ticker/name/quantity/value/costBasis
90
+ // (costBasis missing → undefined + NO_COST_BASIS info warning) and
91
+ // assetType via classifyAsset()
92
+ // - liability (loan/credit only): rate as a FRACTION via pct(),
93
+ // minPayment, originationPrincipal, monthsRemaining via
94
+ // monthsBetween(raw.asOf, maturityDate), assetName for mortgages
95
+ // - ownerIndex: which earner owns it (0-based; default 0)
96
+ // - estMonthlyContribution: from contribByAccount[id] when present
97
+ const accounts = [];
98
+ void classify; void classifyAsset; void num; void pct; void groupBy;
99
+ void monthsBetween; void warning; void contribByAccount; // (drop these once the TODOs are filled in)
100
+
101
+ return {
102
+ source: '_template', // keep in sync with this.source
103
+ // Prefer the caller's snapshot time; default is NOW (never the 1970 epoch).
104
+ asOf: raw.asOf || defaultAsOf(),
105
+ // Owner context (ages, goals, salary) passes through untouched — the
106
+ // shared mapper turns what's missing into structured needsInput asks.
107
+ owner: { ...(raw.owner ?? {}) },
108
+ accounts,
109
+ meta: { warnings, unmapped },
110
+ };
111
+ },
112
+ };