Wallet & Ledger

An integer-cent, double-entry wallet with idempotency, overdraft, refunds, and pluggable fees.

lib/wallet.ts (class Wallet) implements an integer-cent, double-entry wallet system with idempotency, overdraft, refunds, and pluggable fees. Wallets are per { tenant, currency }. All ledger entries are written in debit + credit pairs inside a MongoDB transaction.

Wallet is a library you can call from your own routes, and thunder-core also ships wallet routes on top of it.

Neither wallet router is in a default seeded policy, so you must grant wallets / walletLedgers permissions explicitly. See Access Control.

Usage

import { Wallet } from "@plugins/Huruf-Tech/thunder-core/lib/wallet.ts";

// Transfer 1000 cents between two tenants' wallets in a currency.
await Wallet.transfer({
  fromTenant, toTenant,        // ObjectId
  currency: "usd",
  amount: 1000,                // positive integer, in cents
  idempotencyKey,              // optional; auto-derived if omitted
  reference,                   // optional; auto-generated via References
  allowOverdraft, overdraftLimit,
  foreignRefType, foreignRef,  // link to an external object
  purpose, description, metadata,
  tags,                        // optional string[] stamped on both ledger legs
  checkWalletExist,            // if true, fail instead of lazily creating a wallet
  databaseSession,             // optional existing Mongo session
});

// Refund an existing transaction by its reference.
await Wallet.refund({ refToRefund, /* ...subset of transfer opts */ });

Read lib/wallet.ts, schemas/wallet.ts, and schemas/walletLedger.ts for the full option and field set.

Rules

  • amount must be a positive, non-zero integer (cents). Self-transfers are rejected.
  • Idempotency is enforced by a unique index; the auto key incorporates currency / amount / minute / from / to / description / foreignRef.
  • Overdraft is off by default. When allowOverdraft is on, the limit is opts.overdraftLimit ?? fromWallet.overdraftLimit ?? 0 - so an overdraftLimit persisted on the wallet document acts as that wallet's default.

Extension Points

HookPurpose
Wallet.beforeTransferApproval?Optional static hook. Receives { fromWallet, toWallet, transferOpts, overdraftApplied, minimumAllowedBalance }; return false to reject.
Wallet.onCalculateFee?Optional static hook returning { total, breakdown: Array<{ tenant, name, amount }> }.
Wallet.calculateFee(opts)Public. Calls onCalculateFee when set, otherwise returns { total: 0, breakdown: [] }.
Wallet.resolve()Finds or lazily creates a wallet for a tenant + currency. Opens its own transaction when no databaseSession is passed. Both wallets are resolved before the transfer transaction opens.

calculateFee only computes the fee - it does not move money. Charging the breakdown is the caller's job; see routes/wallets.ts for the pattern.

Wallet Routes

thunder-core ships two routers over the library. Both are read-only createCRUD (create / update / delete disabled) isolated to the active tenant.

routes/wallets.ts (scope wallets)

EndpointPurpose
metadata{ defaultCurrency, availableCurrencies } from DEFAULT_CURRENCY / AVAILABLE_CURRENCIES
signTransferResolves the receiver, computes the fee, returns a 5-minute HS256 challenge token holding the whole quote
transferVerifies that token and executes the main transfer plus every fee leg in one Mongo transaction
updateOverdraftLimit / getOverdraftLimitSet and read a wallet's overdraftLimit by { userId, tenantId } (the setter upserts the wallet)

The two-step signed transfer is the part worth understanding. signTransfer produces a token (via OAuth2.signJWT) that contains the entire quote, so the sender cannot change it between the two calls - and the token's challengeId doubles as the idempotency key.

The receiver query parameter is <ref>[:<tenantId>], where <ref> is an ObjectId, an email, or a phoneNumber. Without a tenant id it targets the receiver's owned tenant. Fee legs that are zero, negative or self-directed are skipped.

routes/walletLedgers.ts (scope walletLedgers)

Read-only createCRUD over the ledger, isolated to the active tenant.

References (sequential counters)

lib/references.ts (class References): References.inc(label, prefix, databaseSession?) atomically increments a named counter in the references collection and returns prefix + count (e.g. TX1, TX2). Pass an existing Mongo ClientSession as the third argument to enlist the increment in a surrounding transaction. It is used to generate human-readable wallet transfer references.


On this page