@classytic/fin-io
v0.6.1
Published
Financial data interchange — parse and emit OFX, CAMT.053, MT940, CSV, IIF, Xero, Plaid into one canonical shape. Ledger-agnostic, ESM-first, tree-shakable, streaming-capable.
Readme
@classytic/fin-io
Parse and emit OFX / CAMT.053 / MT940 / CSV / IIF, plus live REST clients for QuickBooks Online, Xero, and Plaid — all normalized into one canonical shape.
Tree-shakable subpath exports. ESM-first. Zero runtime deps outside what each subpath needs.
Install
npm install @classytic/fin-ioOptional peers (per subpath): @classytic/mongokit, @classytic/primitives, @classytic/revenue.
Quickstart
import { parseOfx } from '@classytic/fin-io/formats/ofx';
const result = parseOfx(buffer);
if (result.ok) {
for (const stmt of result.data) console.log(stmt.account.iban, stmt.transactions.length);
}Subpaths
| Import | What you get |
|---|---|
| @classytic/fin-io | Canonical types + Money/date helpers |
| @classytic/fin-io/core | HttpTransport, TokenStore, FinIoError, write primitives |
| @classytic/fin-io/formats/{ofx,camt053,mt940,csv,iif} | File parsers (pure, no network) |
| @classytic/fin-io/providers/quickbooks | QBO OAuth + REST + CDC + reports.trialBalance() + writes |
| @classytic/fin-io/providers/xero | Xero OAuth + REST + reports.trialBalance() + writes |
| @classytic/fin-io/providers/plaid | Plaid Link + /transactions/sync + webhook parser |
| @classytic/fin-io/providers/plaid/auth | /auth/get — ACH/EFT/BACS/IBAN numbers |
| @classytic/fin-io/providers/plaid/balance | Real-time /accounts/balance/get + validatePayment |
| @classytic/fin-io/providers/plaid/identity | /identity/get + /identity/match |
| @classytic/fin-io/providers/plaid/processor | Stripe / Dwolla / Galileo processor tokens |
| @classytic/fin-io/providers/plaid/webhook-verify | ES256 JWT verifier (zero-dep) |
| @classytic/fin-io/adapters/mongokit | Optional mongokit upsert helper |
Plaid — agent integration guide
fin-io's Plaid layer is curated, not a full SDK. Pick the right subpath for the job; use Plaid's official SDK alongside it for products we don't ship.
Pick the right subpath
| You want to… | Use | Notes |
|---|---|---|
| Ingest a bank feed | providers/plaid | linkToken.create + transactions.sync + parsePlaidTransactions |
| Show / authorize against real balance | providers/plaid/balance | NEVER use cached /accounts/get for payment decisions |
| KYC owner-match for ACH | providers/plaid/identity | summarize returns a per-account pass/fail verdict |
| Hand ACH to Stripe / Dwolla / etc. | providers/plaid/processor | No raw routing/account in your system |
| Originate ACH yourself | providers/plaid/auth | Accepts the PCI/NACHA burden |
| Verify webhook | providers/plaid/webhook-verify | JWT + body hash + staleness, all in Node node:crypto |
Minimal flow
import { createPlaidClient, PLAID_PRODUCTS } from '@classytic/fin-io/providers/plaid';
const plaid = createPlaidClient({
clientId: process.env.PLAID_CLIENT_ID!,
secret: process.env.PLAID_SANDBOX_SECRET!,
env: 'sandbox',
store: myTokenStore, // production: real DB-backed TokenStore
checkpointStore: mySyncStore, // production: real DB-backed SyncCheckpointStore
});
// 1. Mint a Link token for the FE.
const { link_token } = await plaid.linkToken.create({
user: { clientUserId: userId },
clientName: 'My App',
products: [PLAID_PRODUCTS.transactions],
countryCodes: ['US'],
transactions: { daysRequested: 180 }, // optional; set at Link, not at sync
});
// 2. FE opens Link, returns public_token. BE exchanges + persists:
const { access_token, item_id } = await plaid.publicToken.exchange(publicToken);
await plaid.savePlaidItem(`${orgId}:plaid:${item_id}`, access_token, item_id);
// 3. Sync transactions; commit cursor atomically with your delta writes.
await plaid.transactions.sync({
storeKey: `${orgId}:plaid:${item_id}`,
async onComplete({ result, saveCheckpoint }) {
await db.tx(async () => {
await applyDeltas(result);
await saveCheckpoint(); // skip to replay next time (safe default)
});
},
});
// 4. (Optional) Surface connection health on the FE.
const { item, status } = await plaid.item.get({ storeKey: `${orgId}:plaid:${item_id}` });
if (item.consent_expiration_time) warnUserBefore(item.consent_expiration_time);
// `item.error` is non-null when the Item needs attention (ITEM_LOGIN_REQUIRED, etc.)
// → mint an update-mode Link token with `linkToken.create({ user, accessToken })`.
// 5. (Optional) "Sync now" button — wire to `transactions.refresh`. Add-on product.
await plaid.transactions.refresh({ storeKey: `${orgId}:plaid:${item_id}` });
// Returns a request_id; deltas arrive via webhook + the next `transactions.sync`.Constants
Plaid-shaped constants are mirrored from Plaid's official OpenAPI spec
(plaid/plaid-openapi, 2020-09-14.yml) and re-exported from the Plaid root:
import {
PLAID_PRODUCTS, PLAID_COUNTRY_CODES, PLAID_LANGUAGES,
PLAID_ACCOUNT_TYPES, PLAID_ACCOUNT_SUBTYPES,
PLAID_SANDBOX_INSTITUTIONS, PLAID_ERROR_CODES,
PLAID_TRANSACTIONS_UPDATE_STATUS,
} from '@classytic/fin-io/providers/plaid';
import { PLAID_PROCESSORS } from '@classytic/fin-io/providers/plaid/processor';Each Plaid call sends Plaid-Version: 2020-09-14. Override per-client via apiVersion. Local constant source: _constants.ts. Official spec: https://github.com/plaid/plaid-openapi/blob/master/2020-09-14.yml.
Sharp edges
validatePaymentfails closed: depository-only, currency must match,availablebalance required (opt in tocurrentfallback). All flagged viareasonon the response.item.remove({ storeKey })deletes BOTH the token AND the sync checkpoint — never reuse a cursor across re-linked Items.onComplete({ saveCheckpoint })is the ONLY hook. Don't callsaveCheckpoint? The cursor stays put and next sync replays. Crash-safe by default.linkToken.createexactly one of:products(initial) /accessToken(update) /transfer.authorizationId(Transfer action). Anything else →ValidationErrorbefore the network call.
Plaid product coverage
| Product | Status |
|---|---|
| Link, /transactions/sync, /transactions/refresh, Accounts, Item lifecycle (item.get / item.remove) | ✅ providers/plaid |
| Auth, Balance, Identity, Processor tokens, Webhook verify | ✅ own subpath each |
| Transfer (/transfer/*) | ⏳ planned providers/plaid/transfer |
| Payment Initiation (/payment_initiation/*) | ⏳ planned providers/plaid/payment-initiation |
| Investments / Liabilities / Income | ❌ use Plaid's official SDK |
/transactions/getis intentionally not surfaced — Plaid has deprecated it in favor of/transactions/sync. New consumers usetransactions.sync({ storeKey, onComplete }); cutover from a legacy ingest usestransactions.bootstrapCursorAtNowto skip historical replay.
QBO / Xero write capabilities
Both expose client.write.<entity>.{create, update, delete, dryRun, batch} for canonical migrations, plus raw.{create, update} for vendor-shaped escape hatches. dryRun({ mode: 'local' }) validates without network; mode: 'server' round-trips a throw-away record. Pass idempotencyKey for retry-safe writes.
What it does NOT do
- No double-entry / accounting logic →
@classytic/ledger - No tax computation →
packages/tax/ - No payment file generation (PAIN.001, NACHA) → future
@classytic/payments-io - No PDF / OCR
- No generic
upsert()— passcreate/updateafter resolving provider IDs
See CHANGELOG.md for releases.
