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
amountmust 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
allowOverdraftis on, the limit isopts.overdraftLimit ?? fromWallet.overdraftLimit ?? 0- so anoverdraftLimitpersisted on the wallet document acts as that wallet's default.
Extension Points
| Hook | Purpose |
|---|---|
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)
| Endpoint | Purpose |
|---|---|
metadata | { defaultCurrency, availableCurrencies } from DEFAULT_CURRENCY / AVAILABLE_CURRENCIES |
signTransfer | Resolves the receiver, computes the fee, returns a 5-minute HS256 challenge token holding the whole quote |
transfer | Verifies that token and executes the main transfer plus every fee leg in one Mongo transaction |
updateOverdraftLimit / getOverdraftLimit | Set 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.