@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 firstWhere 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 FSMactive ⇄ 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 throwsWalletEntryImmutableError(corrections are compensatingadjustment/refundentries). Each entry recordsseq(per-wallet monotonic, unique-indexed — totally orders the ledger),balanceAfter, andactorId/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), plusrefundedAmountwith 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.heldCorrectness
- 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) ≤ capturedAmountis 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 doesEvent 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 LimitExceededErrorThe 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
