npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@maxs05/node-buxfer

v0.3.1

Published

Typed Node.js SDK for the Buxfer personal finance API, with an agent-ready helper layer

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, fuzzy search, merchant discovery over noisy statement text, getContext bundles, dry-run for every write, a duplicate check on add.
  • 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; only uploadStatement is 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-buxfer

Quickstart

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 transactions is paginated (100 rows per page, 1-based page). transactions.list() returns one page as Transaction[]; listPage() adds { total, page }; iterate() and listAll({ 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: true for the full record), a name miss returns null, an id miss throws NOT_FOUND, several matches throw RESOLUTION_AMBIGUOUS with 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 searchByTerms

mode: '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 raw fetch URLs from your own transport wrapper.
  • onAudit(event) receives one event per wire attempt. path never has a query string, and mutation params are scrubbed and bounded (strings over 200 characters are truncated).
  • Redirects are never followed (REDIRECT_BLOCKED).
  • redact(value) (root export) returns a deep copy without token and password keys. Returned data is never altered by default; only auth.login returns a credential.
  • Untrusted content: bank descriptions, names and tags are text written by third parties. For agent use, markUntrusted, markUntrustedFields and stripUntrusted from @maxs05/node-buxfer/untrusted wrap 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, budgetName and contactName on /transactions but silently returns the unfiltered ledger. The SDK throws VALIDATION_FAILED naming the working alternative (tagId for a budget's tag, contactId) instead of returning wrong data.
  • No get-by-id and no text search. resolve is a bounded client-side scan: one request for non-paginated resources, and for transactions up to 500 records / 5 pages (client option resolution), narrowed by options.filters. Hitting the cap without a match throws RESOLUTION_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 throws AUTH_EXPIRED.
  • Buxfer returns 400 for every failure; the SDK maps the vendor type to a stable code.
  • numTransactions is a numeric string on the wire; the SDK returns total as 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 (and capabilities.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) validates name and args against the registry before any request and refuses with a typed PolicyError: UNKNOWN_OPERATION, UNKNOWN_ARGUMENT, MISSING_ARGUMENT, NOT_INVOKABLE (auth.login, paging forms), CONFIRMATION_REQUIRED (delete and uploadStatement without confirm: true). It marks third-party text as untrusted by default and returns { operation, result, impact?, correlationId }. planInvoke runs the same checks with a forced dry run. Show the plan, get the user's confirmation, then call invokeOperation with confirm: true.
  • Write-safety primitives. Build the preview/apply flow on planInvoke + invokeOperation({ confirm: true }), the drift check on writeSnapshot (compare its hash to DryRunResult.snapshotHash before re-applying), the cooldown/duplicate ledger on writeTargetKey, and "did it land" on classifyWriteOutcome.
  • Fail-closed version check. Pin the CAPABILITY_PLAN_HASHES your 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.

See examples/governance.ts.

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.