@maxs05/node-buxfer
v0.3.1
Published
Typed Node.js SDK for the Buxfer personal finance API, with an agent-ready helper layer
Maintainers
Readme
@maxs05/node-buxfer
A typed, zero-dependency Node.js SDK for the Buxfer personal-finance API, with a helper layer built for AI agents and MCP servers.
- Node >= 24, native
fetch, no runtime dependencies. Dual ESM and CommonJS builds with full type declarations. - Every Buxfer endpoint: auth, transactions, accounts, tags, budgets, loans, contacts, groups, reminders.
- Helpers the vendor API lacks: exact
resolve, fuzzysearch, merchant discovery over noisy statement text,getContextbundles, dry-run for every write, a duplicate check onadd. - Agent-ready: a machine-readable capability registry, a governance path (
invokeOperation/planInvoke), untrusted-content markers, scrubbed errors and audit events.
Status: pre-1.0. The read side is verified against the live API. The response shapes of the write endpoints (
add,edit,delete) are live-verified; onlyuploadStatementis still assumed from the vendor docs: see docs/live-verification.md. Pin an exact version and test writes on a throwaway account first.
Install
npm install @maxs05/node-buxferQuickstart
import { Buxfer } from '@maxs05/node-buxfer';
// Email + password (the client logs in lazily on the first call), or a token you already hold.
const buxfer = new Buxfer({ email: process.env.BUXFER_EMAIL, password: process.env.BUXFER_PASSWORD });
// const buxfer = new Buxfer({ token: process.env.BUXFER_TOKEN });
const wallet = await buxfer.accounts.resolve('Wallet'); // summary, or null on a name miss
const page = await buxfer.transactions.list({ accountId: wallet?.id, page: 1 }); // one page, newest first
const result = await buxfer.transactions.search('woolies'); // fuzzy merchant search (see below)Read credentials from the environment or a secret store, never from source. The constructor makes no request; it
throws ConfigError (CONFIG_INVALID) if neither a token nor an email + password pair is present. CommonJS works
the same way: const { Buxfer } = require('@maxs05/node-buxfer').
See examples/ for runnable-style files and docs/API.md for every signature.
Resources
Each resource is mounted on the client under its plural name. Primitives keep the vendor verb (list, add,
edit, delete, uploadStatement, login).
| Client class | Mounted as | Methods | Example |
|--------------|------------|---------|---------|
| AuthClient | buxfer.auth | login | const { token } = await buxfer.auth.login() (returns a credential; handle it as a secret) |
| TransactionClient | buxfer.transactions | list, listPage, iterate, listAll, resolve, getContext, terms, searchByTerms, search, add, edit, delete, uploadStatement | await buxfer.transactions.list({ accountId: 12345, startDate: '2026-01-01', endDate: '2026-01-31' }) |
| AccountClient | buxfer.accounts | list, resolve, search, getContext | await buxfer.accounts.getContext('Wallet', { limit: 10 }) |
| TagClient | buxfer.tags | list, resolve, search | await buxfer.tags.resolve('groceries') |
| BudgetClient | buxfer.budgets | list, resolve, search, getContext | await buxfer.budgets.getContext('Food') |
| LoanClient | buxfer.loans | list, resolve, search, getContext | await buxfer.loans.resolve({ entity: 'Alex Example', type: 'contact' }) |
| ContactClient | buxfer.contacts | list, resolve, search | await buxfer.contacts.resolve({ email: '[email protected]' }) |
| GroupClient | buxfer.groups | list, resolve, search | await buxfer.groups.search('house') |
| ReminderClient | buxfer.reminders | list, resolve | await buxfer.reminders.resolve('Rent') |
Plus the cross-resource searchAcrossResources(buxfer, query) from @maxs05/node-buxfer/operations.
Conventions:
- Only
transactionsis paginated (100 rows per page, 1-basedpage).transactions.list()returns one page asTransaction[];listPage()adds{ total, page };iterate()andlistAll({ maxRecords })walk pages. - Everything else is a single request returning the complete list.
resolve(identifier): a number or digit string is an id, any other string an exact (case- and space-normalised) name; object forms such as{ name },{ email }or{ entity, type }are accepted where listed in the API reference. A unique match returns a compact summary (expand: truefor the full record), a name miss returnsnull, an id miss throwsNOT_FOUND, several matches throwRESOLUTION_AMBIGUOUSwith the candidate ids.search(query)ranks fuzzy matches (see the scoring ladder in the API reference) as{ results: [{ item, score, matchKind, matchedField }], scanned, scanTruncated }.- Every method takes
{ timeoutMs, signal, correlationId }. Ids are integers.
Fuzzy merchant discovery
Buxfer has no description filter, and statement text is noisy (VISA PURCHASE 09OCT COLES 0870). The discovery
tier maps a loose word ("woolies", a typo, pasted statement text) to the real merchants in your data, then
retrieves exactly the transactions you confirm. The user's word never reaches the vendor; the SDK scans your
transactions client-side and matches them with a pure, in-process engine.
Workflow: terms -> confirm -> searchByTerms.
// 1. Discover: distinct merchant terms ranked against a query (aliases are yours; no dictionary ships).
const found = await buxfer.transactions.terms({ query: 'woolies', aliases: { woolworths: ['woolies'] } });
// found.terms[i] = { key, brand, keys, label, variants, count, byType, sums, lastDate, score, confidence, matchKind, ... }
// 2. Confirm: show found.terms to the user (or have an agent choose). Keep the chosen keys.
const keys = found.terms.filter((t) => t.confidence === 'exact').flatMap((t) => t.keys);
// 3. Retrieve exactly those transactions (exact key membership, no second fuzzy pass).
const result = await buxfer.transactions.searchByTerms(keys, { normaliserVersion: found.normaliserVersion });Aliases are yours: a ready-made, tested Australian map is in examples/au-aliases.ts (woolies, maccas, hj, 7-11...). Alias keys are a term key or brand (the first non-stop word: Hungry Jack's is hungry).
Or in one call:
const r = await buxfer.transactions.search('woolies', { aliases: { woolworths: ['woolies'] } });
if (r.mode === 'matched') console.log(r.matchedKeys, r.count, r.sums, r.transactions);
else console.log(r.candidates); // 'needsConfirmation': hand these to the user, then call searchByTermsmode: 'needsConfirmation' means the SDK refused to guess. It is returned when the best score is below 70, when
two or more different brands score within 15 points of the best, when the query is only generic words (the,
payment, refund, ...), or when nothing matched on a complete scan (candidates: []). Otherwise
mode: 'matched' carries every top-scoring candidate that shares the top brand.
Behaviour to know:
| Topic | Behaviour |
|-------|-----------|
| Window | Default: the last 12 months (startDate and endDate both sent). window: { months } (1-120) or window: 'all', or give both filters.startDate and filters.endDate, which win. The echoed report.window.defaulted is true only when the SDK computed the dates. "Today" uses the client timeZone (default UTC); endDate = today excludes future-dated rows. |
| Scan cap | maxRecords default 5,000 per call, clamped by the client option scanCeiling (default 20,000). 2 pages in flight, 60 s deadlineMs. A deadline or cap yields the newest contiguous prefix with report.truncated: true and report.reason. A page that fails aborts the whole scan: no partial holes are returned. |
| Cost | About ceil(rows / 100) + 1 requests on a cold cache (the scan plus one accounts.list for currencies). |
| Cache | On by default, per client instance, 4 hours, bounded to 50,000 records, cleared on any successful write through this client. fresh: true bypasses and replaces the entry; client option cache: false disables it. report.cached and report.fetchedAt say what you got. Pass a createSharedScanCache() instance as cache to share one store across clients (the cache then outlives a client): each client only touches its own tenant's namespace (the credential email, or a stable hash of the token for token-only clients), and a write invalidates only that tenant. The store passively bounds the number of namespaces at maxNamespaces (default 128): when a new namespace would put the store over the cap, it first sweeps expired entries (per-resource TTL) and drops the now-empty namespaces, then evicts the least-recently-accessed namespaces until it is back under the cap — eviction is a cache miss only, so an evicted tenant's next scan simply re-fetches. Each namespace is still bounded by maxRecords, and clearNamespace()/clear() prune explicitly; the email and the token of the same account are different namespaces (as are case/whitespace variants of the email), so a write in one form invalidates only that form. |
| Staleness | Rows added outside this client (bank sync, the Buxfer web app) can be missing from discovery for up to 4 hours. Pass fresh: true whenever the user says new transactions should exist. |
| Sums | Per currency ({ currency, amount, expenseAmount, count }), transfers excluded by default (types: { include, exclude }). Sums are null on a truncated scan unless allowPartial: true (then sumsScope: 'scanned'). The sign convention of vendor amounts is unverified, so sums are raw values. |
| Truncated and empty | A query that matches nothing on a truncated scan throws RESOLUTION_TRUNCATED rather than implying the merchant does not exist. Narrow the window or filters. |
| Keys | A key is a readable normalised statement string (woolworths sydney). Pass the returned normaliserVersion back with the keys; a stale version is VALIDATION_FAILED. |
Safe mutations
| Safeguard | Detail |
|-----------|--------|
| Dry run | { dryRun: true } on add, edit, delete and uploadStatement never sends the write. It validates, may issue reads for checks, and returns a DryRunResult (wouldApply, checks, diff, impact, a scrubbed request, warnings, plus targetKey and snapshot/snapshotHash of its precondition reads — the drift check for a later apply). |
| Duplicate check | transactions.add looks for an identical same-day row in the account (equal absolute amount and equal normalised description) when accountId and date are given. Default duplicateCheck: 'error' throws DUPLICATE_FOUND (the existing id is in resourceIds) and sends nothing. 'return-existing' returns the existing record; 'off' skips the lookup. Costs one extra request per live add. |
| Confirmation | uploadStatement refuses to run without confirm: true (POLICY_DENIED, no request). Statements are capped at 1 MiB. Through invokeOperation, delete also requires confirm: true (CONFIRMATION_REQUIRED); the SDK method delete(id) is the explicit primitive. |
| No auto-retry | Writes are retried only on HTTP 429 (the request was not processed). A timeout, network error or 5xx on a write is surfaced with retryable: false and a suggested action to check whether it landed. Buxfer has no idempotency key, so nothing here claims idempotency. |
| Write-safety primitives | For an agent that applies writes, ./operations exports writeSnapshot(buxfer, op, args) (the write's precondition reads as { reads, state, hash } — the same reads a dry-run performs, so the hash equals DryRunResult.snapshotHash), writeTargetKey(op, args) (the natural key for cooldowns and the duplicate ledger) and classifyWriteOutcome(errOrResult) (not_sent \| rejected \| accepted \| accepted_failed \| succeeded \| unknown). |
| Cache | Any successful write clears the discovery scan cache (only this client's tenant namespace when the cache is a shared store). |
const plan = await buxfer.transactions.add({ description: 'Coffee', amount: 4.5, accountId: 12345, date: '2026-01-15' }, { dryRun: true });
if (plan.wouldApply) await buxfer.transactions.add({ description: 'Coffee', amount: 4.5, accountId: 12345, date: '2026-01-15' });Error handling
Every error the SDK throws is a BuxferError carrying code, category, retryable, operation, httpStatus,
vendorError (scrubbed), resourceIds, suggestedAction and correlationId (client-side only, never sent to
Buxfer).
import { isBuxferError } from '@maxs05/node-buxfer';
try {
await buxfer.accounts.resolve(12345);
} catch (error) {
if (!isBuxferError(error)) throw error; // do not use instanceof: it is unreliable across subpaths
if (error.code === 'NOT_FOUND') { /* ... */ }
else if (error.retryable) { /* back off and retry */ }
else console.error(error.suggestedAction, error.correlationId);
}Branch on code or category, not on message text. The full code table is in docs/API.md.
Security notes
- The Buxfer token travels in the URL query string on every call (including POSTs); that is how the vendor API
works. The SDK scrubs credentials from everything observable: error messages,
vendorError, body previews, audit events, dry-run requests and any URL it surfaces. Do not log rawfetchURLs from your own transport wrapper. onAudit(event)receives one event per wire attempt.pathnever has a query string, and mutationparamsare scrubbed and bounded (strings over 200 characters are truncated).- Redirects are never followed (
REDIRECT_BLOCKED). redact(value)(root export) returns a deep copy withouttokenandpasswordkeys. Returned data is never altered by default; onlyauth.loginreturns a credential.- Untrusted content: bank descriptions, names and tags are text written by third parties. For agent use,
markUntrusted,markUntrustedFieldsandstripUntrustedfrom@maxs05/node-buxfer/untrustedwrap such text in<untrusted_content>markers so a model treats it as data. The marker is a convention, not a sanitiser.
Vendor quirks that reach callers
- Ignored filters are rejected. Buxfer accepts
budgetId,budgetNameandcontactNameon/transactionsbut silently returns the unfiltered ledger. The SDK throwsVALIDATION_FAILEDnaming the working alternative (tagIdfor a budget's tag,contactId) instead of returning wrong data. - No get-by-id and no text search.
resolveis a bounded client-side scan: one request for non-paginated resources, and for transactions up to 500 records / 5 pages (client optionresolution), narrowed byoptions.filters. Hitting the cap without a match throwsRESOLUTION_TRUNCATED. - Ids are integers. Loans have no id: they are keyed by
entity+type('contact' | 'group'). - There is no 401: an expired token comes back as HTTP 400
Access denied. With credentials held, the SDK logs in again once and replays the request; otherwise it throwsAUTH_EXPIRED. - Buxfer returns
400for every failure; the SDK maps the vendortypeto a stable code. numTransactionsis a numeric string on the wire; the SDK returnstotalas a number.
Subpath exports
| Import | Contents |
|--------|----------|
| @maxs05/node-buxfer | Buxfer, BuxferOptions, RECOMMENDED_CACHE (the one-knob cache: 'recommended' preset), createSharedScanCache (shared cross-client scan cache store), isBuxferError, credentialShapeProblem, redact |
| @maxs05/node-buxfer/resources | the nine resource client classes |
| @maxs05/node-buxfer/types | all public types (type-only) |
| @maxs05/node-buxfer/errors | BuxferError and subclasses, ERROR_CODES, isBuxferError |
| @maxs05/node-buxfer/capabilities | CAPABILITIES, getCapability, CAPABILITY_GROUPS, CAPABILITY_PLAN_HASH, CAPABILITY_PLAN_HASHES |
| @maxs05/node-buxfer/operations | invokeOperation, planInvoke, searchAcrossResources, REFUSAL_CODES, and the write-safety primitives writeSnapshot, writeTargetKey, classifyWriteOutcome, snapshotOfReads |
| @maxs05/node-buxfer/untrusted | markUntrusted, markUntrustedFields, stripUntrusted, UNTRUSTED_FIELDS |
| @maxs05/node-buxfer/mcp | the generated MCP tool catalog |
Because each entry is bundled separately, instanceof across subpaths is unreliable: use isBuxferError and
error.code. The package also ships capabilities.json (and its schema) at the package root.
Consuming it from an MCP server
The SDK ships the pieces a server needs and holds no MCP types itself.
- Registry.
CAPABILITIES(andcapabilities.json) describe all 35 operations: purpose, input schema, effect (read | write | destructive), flags (requiresApproval,sensitive), hazards (the fixed §7.1 vocabulary on mutations), dry-run support, errors, usage and examples. - Governance path.
invokeOperation(buxfer, name, args, options)validatesnameandargsagainst the registry before any request and refuses with a typedPolicyError:UNKNOWN_OPERATION,UNKNOWN_ARGUMENT,MISSING_ARGUMENT,NOT_INVOKABLE(auth.login, paging forms),CONFIRMATION_REQUIRED(deleteanduploadStatementwithoutconfirm: true). It marks third-party text as untrusted by default and returns{ operation, result, impact?, correlationId }.planInvokeruns the same checks with a forced dry run. Show the plan, get the user's confirmation, then callinvokeOperationwithconfirm: true. - Write-safety primitives. Build the preview/apply flow on
planInvoke+invokeOperation({ confirm: true }), the drift check onwriteSnapshot(compare its hash toDryRunResult.snapshotHashbefore re-applying), the cooldown/duplicate ledger onwriteTargetKey, and "did it land" onclassifyWriteOutcome. - Fail-closed version check. Pin the
CAPABILITY_PLAN_HASHESyour server was built against and refuse to start on a mismatch, so a registry change never goes live unreviewed. - The governance path does not replace your own input validation, access control, rate limiting or user consent.
Development
npm ci
npm run build # tsup: dual ESM/CJS into dist/
npm run typecheck # tsc --noEmit over src, test and examples
npm run lint # eslint
npm test # vitest
npm run coverage # vitest with coverage
npm run capabilities:build # regenerate capabilities.json and src/capabilities.ts from capabilities.plan.json
npm run capabilities:check # registry vs code drift check
npm run verify:pack # inspect the packed tarball
npm run probe:live # read-only live API shape probe (needs credentials in env; see docs/live-verification.md)Tests never hit the live API: the transport is the injectable fetch option. Fixtures contain placeholder data
only.
Licence
MIT. See LICENSE.
