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

@classytic/wallet

v0.4.0

Published

Stored-value WALLET engine — a spendable balance any subject (user, org, driver, merchant) holds: top-up, spend, P2P transfer, auth/hold/capture (foodpanda/Uber style), and points→cash redemption. Balance is a cache backed by an append-only entry ledger a

Readme

@classytic/wallet

Stored-value wallet engine. A spendable balance any subject (user, org, driver, merchant) holds: top-up, spend, P2P transfer (with fee legs), auth/hold/capture/refund (foodpanda/Uber style), and points→cash redemption. The balance is a cache backed by an append-only entry ledger as the source of truth — every mutation commits both atomically inside a transaction, is idempotent, and the ledger's immutability is enforced, not conventional.

gateway deposit ──▶ wallet.credit(id, { amount, type: 'topup' })
order checkout ───▶ wallet.debit(id, { amount, fee? })        // never overdraws
send to a friend ─▶ wallet.transfer(from, to, { amount, fee? }) // atomic, conserved
reserve at order ─▶ wallet.hold(id, { amount, expiresAt })    // available → held
on delivery ──────▶ wallet.capture(holdId, { amount })        // settle (partial ok)
buyer disputes ───▶ wallet.refundCapture(holdId, { amount })  // capped, atomic
points → cash ────▶ wallet.redeemPoints(id, { amount })       // host burns points first

Where it sits (the boundaries that matter)

A wallet is not any of its neighbours, and composes with them through host-wired bridges/events:

| Concern | Package | Relationship | |---|---|---| | Spendable balance (this) | @classytic/wallet | the operational "how much can I spend now" | | Double-entry bookkeeping | @classytic/ledger | the accounting system of record. Host-wired: a LedgerBridge subscribes to wallet events and posts the journal. The wallet never imports ledger. | | Fee / earning computation | @classytic/commission | plans, tiers, agreements, clawbacks — computes who earns what. The wallet's fee leg only moves a host-computed amount atomically. | | Payout obligations | @classytic/revenue | settlements owed to recipients (derived balances), not stored value. The wallet plugs in as a funding source via @classytic/wallet/payment-provider. | | Payout recipients / KYC | @classytic/payee | where to send money and whether the party may be paid | | Loyalty points | @classytic/loyalty | points are burned by the host, then redeemPoints credits the cash equivalent |

Bookkeeping is deliberately the host's duty — the wallet emits wallet:credited | debited | …; your LedgerBridge decides which GL accounts to debit/credit (e.g. Cash ↔ Customer Wallet Liability).

The model

  • WalletAccount — one per (owner, currency, scope). Balance cache: available, held, lifetimeIn, lifetimeOut (total owned = available + held). Status FSM active ⇄ frozen, → closed (close is CAS-guarded on a zero balance — no stranded funds, no orphaned holds).
  • WalletEntry — the immutable, append-only ledger. Every mutating repository op throws WalletEntryImmutableError (corrections are compensating adjustment/refund entries). Each entry records seq (per-wallet monotonic, unique-indexed — totally orders the ledger), balanceAfter, and actorId/actorKind (WHO moved the money, on the durable row). A unique (walletId, idempotencyKey) index makes every mutation safe to retry.
  • WalletHold — an auth/capture reservation (held → captured | released | expired), plus refundedAmount with a guarded cumulative cap. Expiry is a CAS sweep, never a TTL-delete, so the hold survives as a record.

The exact ledger invariant (holds are internal available ↔ held moves, so hold/release entries net to zero and are excluded):

sum(credits) − sum(debits)  over entries with type ∉ {hold, release}
  == balance.available + balance.held

Correctness

  • Never overdraws. A spend/hold is a single guarded findOneAndUpdate (available >= amount + $inc); N concurrent spends against K funds let exactly K through. No app-level lock.
  • Atomic. Balance cache + ledger entry (+ hold) commit in one MongoDB transaction (replica set required — asserted at boot).
  • Idempotent. Money-movement verbs accept an idempotencyKey; a retry returns the original entry instead of double-applying — and because entry + balance share the transaction, a duplicate-key abort rolls both back.
  • Refund-capped. sum(refunds) ≤ capturedAmount is a $expr-guarded CAS on the hold in the same transaction as the credit — concurrent/repeated refunds can never mint balance.
  • Money via primitives. Amounts are integer minor units, validated through @classytic/primitives/money; currencies are normalized ('bdt''BDT'); cross-currency ops are rejected.
  • Honest limits (rule 33): a single hot wallet serializes on its balance document under transaction write-conflicts — fine for consumer wallets; a very-high-TPS merchant float account needs sub-wallet sharding via scopeKey.

Quick start

import mongoose from 'mongoose';
import { createWallet, ensureWalletReady } from '@classytic/wallet';

const wallet = await createWallet({
  connection: mongoose.connection,           // must be a replica set
  tenant: { fieldType: 'objectId' },         // or `false` for platform-wide consumer wallets
  // eventTransport: new RedisEventTransport({ url }),
  // outbox: hostOutboxStore,                // durable event delivery (P8)
  // freezePolicy: 'block-out',              // or 'block-all' (AML-style)
  // limitWindowOffsetMinutes: 360,          // daily caps reset at Dhaka midnight
  // modules: { tamperEvidence: true },      // sha256 entry hash chain
});
await ensureWalletReady(wallet);             // materialise collections (+ indexes in dev)
const w = wallet.services.wallet;

const acc = await w.openAccount({ ownerRef: userId, ownerModel: 'user', currency: 'BDT' }, ctx);
await w.credit(String(acc._id), { amount: 50_000, currency: 'BDT', type: 'topup', sourceModel: 'Gateway', sourceRef: tx }, ctx);
await w.debit(String(acc._id), { amount: 12_000, currency: 'BDT', sourceModel: 'Order', sourceRef: ord }, ctx);
const { balance } = await w.getBalance(String(acc._id), ctx); // { available: 38000, held: 0, ... }

Service verbs

| Verb | Purpose | |---|---| | openAccount(input, ctx) | Idempotent open (one per owner+currency+scope). | | credit / refund / redeemPoints(id, input, ctx) | Money in (top-up / reversal / points→cash). | | debit(id, input, ctx) | Spend (guarded, never overdraws). Optional fee leg. | | transfer(from, to, input, ctx) | Atomic P2P; optional fee leg; returns the linked entry pair. Self-transfers rejected. | | hold(id, input, ctx) | Reserve available → held (auth), optional expiresAt. | | capture(holdId, { amount? }, ctx) | Settle a hold (full or partial; remainder returns). | | release(holdId, ctx) | Cancel a hold; return the reservation. | | refundCapture(holdId, { amount?, idempotencyKey? }, ctx) | Reverse (part of) a captured hold — cumulative-capped. | | expireDueHolds({ now, limit }, ctx) | Sweeper (host cron): CAS-expire lapsed holds, return the money. Tenant-scoped — a platform cron iterates orgs. | | freeze / unfreeze / close(id, ctx) | Account status. Close requires a zero balance. | | getBalance / listEntries / statement(id, ctx) | Reads (statement = keyset cursor, stable at any ledger size). | | reconcile(id, ctx, { heal? }) | Audit cache vs ledger. Heal is a version CAS — refused (never clobbers) if the wallet mutated mid-reconcile. | | verifyLedgerIntegrity(id, ctx) | Walk + verify the hash chain (tamperEvidence module). | | bulkCredit(items, ctx) | Concurrency-bounded mass promo/airdrop, per-item idempotent. |

Fee legs (send-money / cash-out shape)

The host computes the fee (flat, tiered, per-agreement — that's @classytic/commission); the wallet moves it atomically:

await w.transfer(sender, receiver, {
  amount: 5_000, currency: 'BDT',
  fee: { amount: 50, toWalletId: platformFeeWalletId, reason: 'send money fee' },
}, ctx);
// payer −5050, receiver +5000, fee wallet +50 — ONE transaction, conserved.
// Paired `fee` entries share the op's transferId; velocity counts amount+fee;
// the wallet:transferred event carries feeAmount/feeWalletId.

Fees stay inside the closed loop (a wallet→wallet liability move), so the ledger mapping returns [] for fee legs — book fee revenue when you settle the fee wallet out.

Host-transaction composition

Pass ctx.session and every verb joins your transaction instead of opening its own:

await session.withTransaction(async () => {
  await orders.markPaid(orderId, { session });
  await w.debit(walletId, { amount, currency: 'BDT', sourceModel: 'Order', sourceRef: orderId }, { ...ctx, session });
}); // both commit or neither does

Event delivery in joined mode: outbox rows save in-session (atomic with YOUR commit — the relay delivers, strict P8). Without an outbox, events publish when the verb returns, i.e. before your commit — wire an outbox for strict semantics.

Events

wallet:account.opened | frozen | unfrozen | closed · wallet:credited | debited | transferred · wallet:held | captured | released · wallet:hold.expired. Zod-source walletEventDefinitions for arc's EventRegistry. Subscribe glob-style; drop in any arc EventTransport; wire a LedgerBridge here for bookkeeping.

Spend limits (velocity control)

Give a wallet per-KYC-tier caps and they're enforced atomically on every money-out, against rolling day/month counters:

await w.setLimits(walletId, {
  perTxnMax: 50_000,        // ৳500
  dailyAmountMax: 200_000,  // ৳2,000/day
  dailyCountMax: 20,
  monthlyAmountMax: 2_000_000,
  monthlyCountMax: 200,
}, ctx);
// or open with them: openAccount({ ..., limits: {...} })

await w.debit(walletId, { amount: 60_000, currency: 'BDT' }, ctx); // throws LimitExceededError

The check + counter record run inside the wallet transaction, so N concurrent spends can't collectively slip past a cap (no app-level lock). A wallet with no limits is unrestricted. Set limitWindowOffsetMinutes: 360 so "daily" means Dhaka-midnight-to-midnight, not UTC.

Freeze semantics

freezePolicy: 'block-out' (default): spending stops, incoming credits still land (bank-lien style). 'block-all': credits blocked too (AML-freeze style). Under both policies, hold release/expiry still works on a frozen wallet — the sweeper can never deadlock, and reserved money is never trapped.

Tamper evidence (opt-in module)

const wallet = await createWallet({ connection, modules: { tamperEvidence: true } });
// every entry: hash = sha256(walletId|seq|type|direction|amount|currency|balanceAfter|prevHash)
const report = await w.verifyLedgerIntegrity(walletId, ctx);
// { valid, checkedEntries, brokenAtSeq?, detail?, headMatches }

Rewriting any historical amount, reordering, or truncating the tail — even via raw DB access — breaks the chain, and the report says exactly where. This is the web3-style verifiable-history property without a blockchain: it proves integrity, not authorship — pair it with DB access control. Off by default (zero overhead; one extra in-transaction write per mutation when on).

Bookkeeping (ledger mapping)

The wallet is the operational balance layer; double-entry bookkeeping is the host's, wired to @classytic/ledger. The wallet ships the mapping so you don't hand-roll it — @classytic/wallet/ledger-mapping, entry-type aware:

import { walletEventToJournalLines } from '@classytic/wallet/ledger-mapping';

// in your host's subscriber to wallet:* events:
const lines = walletEventToJournalLines(event, {
  walletLiabilityAccount: acc.walletLiability, // platform owes users
  fundingAccount: acc.cash,                    // top-ups land here
  clearingAccount: acc.walletClearing,         // spends flow out here
  loyaltyFundingAccount: acc.loyaltyLiability, // points→cash conversions (optional)
});
if (lines.length) await ledger.repositories.journalEntry.post({ items: lines, ... }, ctx);

Top-ups debit funding; refunds debit clearing (reversal of a money-out leg, so funding still reconciles against your gateway); points redemptions debit the loyalty account; spends/captures debit the wallet liability. Holds, releases, transfers, and fee legs return [] (internal moves). Every result is balanced.

As a payment provider

@classytic/wallet/payment-provider — the wallet as a funding source for @classytic/revenue (authorize → capture → refund, structurally the PaymentProvider contract from @classytic/primitives/payment-gateway). Refunds are cumulative-capped; pass options.idempotencyKey (your refund record id) to make retries exact.

License

MIT