saksh-easy-wallet
v2.0.0
Published
Production-ready multi-currency wallet library with atomic transactions, idempotency, and race-condition protection.
Maintainers
Readme
saksh-easy-wallet
Production-ready multi-currency wallet library for Node.js + MongoDB.
Atomic operations, idempotency, full ACID fund transfers, and zero race conditions.
👨💻 Author
Susheel Kumar — [email protected]
Available for freelance and full-time remote work
✨ Features
| Feature | Detail |
|---|---|
| ⚛️ Atomic operations | $inc + conditional filter — no double-spend ever |
| 🔄 ACID fund transfers | Full MongoDB session/transaction with automatic rollback |
| 🔑 Idempotency | Duplicate requests within 24 h return cached result |
| 🔁 Auto retry | Write-conflict errors retried up to 3× with backoff |
| 💱 Multi-currency | Unlimited currencies per wallet, zero config |
| 💸 Transaction fees | Built-in fee collection routed to admin wallet |
| 📋 Audit log | Every operation persisted with console fallback |
| 📡 Event emitter | credit / debit events for real-time hooks |
| ♻️ Backward compatible | All legacy saksh* methods still work unchanged |
📋 Requirements
- Node.js >= 14.0.0
- MongoDB >= 4.0 with replica set (required for fund transfers)
- Mongoose >= 6.0.0 (peer dependency)
One-time MongoDB replica set setup
# Start mongod as a replica set
mongod --replSet rs0
# In a new terminal, initialise (run once only)
mongosh --eval "rs.initiate()"Single operations (credit, debit, getBalance) work without a replica set.
OnlyfundTransferrequires one.
🚀 Installation
npm install saksh-easy-wallet⚙️ Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| ADMIN_EMAIL | Recommended | [email protected] | Receives collected transaction fees |
| MONGODB_URI | Yes | — | Your MongoDB connection string |
🎯 Quick Start
const SakshWallet = require('saksh-easy-wallet');
const mongoose = require('mongoose');
async function main() {
// Connect — include replicaSet param for fund transfers
await mongoose.connect('mongodb://localhost:27017/wallet?replicaSet=rs0');
const wallet = new SakshWallet();
// Create indexes once at startup
await wallet.ensureIndexes();
// Credit
await wallet.credit('[email protected]', 500, 'USD', 'txn_001', 'Initial deposit');
// Check balance
const bal = await wallet.getBalance('[email protected]', 'USD');
console.log(bal); // { email: '[email protected]', currency: 'USD', balance: 500 }
// Transfer (atomic — rolls back if anything fails)
await wallet.fundTransfer(
'[email protected]',
'[email protected]',
100, 'USD', 'txn_002', 'Payment for services',
1 // $1 transaction fee → credited to ADMIN_EMAIL
);
}
main();📚 API Reference
new SakshWallet()
Creates a wallet instance. Reads ADMIN_EMAIL from process.env.
const wallet = new SakshWallet();
wallet.setAdmin('[email protected]'); // override at runtimecredit(email, amount, currency, referenceId, description, [transactionFee], [session])
Add funds to a wallet.
const result = await wallet.credit(
'[email protected]',
100,
'USD',
'ref_001',
'Top-up'
);
// { message: 'Credited 100 USD.', balance: 100, transaction: {...} }debit(email, amount, currency, referenceId, description, [transactionFee], [session])
Remove funds. Returns { message: 'Insufficient funds' } if balance is too low.
const result = await wallet.debit(
'[email protected]',
40,
'USD',
'ref_002',
'Purchase',
0.5 // $0.50 fee
);fundTransfer(senderEmail, receiverEmail, amount, currency, referenceId, description, [transactionFee])
Atomic transfer between two wallets. Uses a MongoDB session — if either side fails, everything rolls back.
const result = await wallet.fundTransfer(
'[email protected]',
'[email protected]',
75, 'USD', 'transfer_001', 'Invoice payment', 0.5
);
// {
// message: 'Transferred 75 USD from alice to bob.',
// senderBalance: 424.5,
// receiverBalance: 75,
// transaction: { senderTransaction: {...}, receiverTransaction: {...} }
// }⚠️ Throws if sender has insufficient funds. Uses idempotency — retrying with the same
referenceId, sender, receiver, amount, and currency returns the cached result.
getBalance(email, currency)
const { balance } = await wallet.getBalance('[email protected]', 'USD');getBalanceSummary(email)
Returns all currency balances for a user.
const summary = await wallet.getBalanceSummary('[email protected]');
// { email: '...', balance: { USD: 460, EUR: 200 } }transactionReport(email)
Transaction history, newest first.
const history = await wallet.transactionReport('[email protected]');
// [{ type, amount, currency, referenceId, description, transactionFee, date }, ...]setAdmin(adminEmail)
Override the admin email that receives transaction fees.
wallet.setAdmin('[email protected]');ensureIndexes()
Create all database indexes. Call once at app startup.
await wallet.ensureIndexes();Events
SakshWallet extends EventEmitter. Listen to 'credit' and 'debit' events:
wallet.on('credit', ({ email, amount, currency, newBalance, referenceId }) => {
console.log(`+${amount} ${currency} → ${email} (new balance: ${newBalance})`);
});
wallet.on('debit', ({ email, amount, currency, newBalance, referenceId }) => {
console.log(`-${amount} ${currency} ← ${email} (new balance: ${newBalance})`);
});🔑 Idempotency
fundTransfer is automatically idempotent. The idempotency key combines:
referenceId + senderEmail + receiverEmail + amount + currency
If the exact same request is retried within 24 hours, the cached result is returned — the balance is not modified again.
// First call — executes the transfer
await wallet.fundTransfer('[email protected]', '[email protected]', 50, 'USD', 'pay_001', 'Work');
// Retry (e.g. after a network error) — returns same result, no double charge
await wallet.fundTransfer('[email protected]', '[email protected]', 50, 'USD', 'pay_001', 'Work');🔧 Advanced Usage
Batch operations with a shared session
const session = await mongoose.startSession();
session.startTransaction();
try {
await wallet.credit('[email protected]', 100, 'USD', 'batch_1', 'Salary', 0, session);
await wallet.credit('[email protected]', 200, 'USD', 'batch_2', 'Salary', 0, session);
await session.commitTransaction();
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}Extending with custom fee logic
class MyWallet extends SakshWallet {
async smartCredit(email, amount, currency, referenceId, description, userTier) {
const fee = userTier === 'premium' ? 0 : amount * 0.01;
return this.credit(email, amount, currency, referenceId, description, fee);
}
}🛡️ Error Handling
try {
await wallet.debit('[email protected]', 9999, 'USD', 'ref_x', 'Big buy');
} catch (err) {
switch (err.message) {
case 'Insufficient funds':
// debit returns this as a value, not a throw — but fundTransfer throws it
break;
case 'Invalid email address':
case 'Amount must be a positive finite number':
case 'Currency must be a non-empty string':
// validation errors
break;
default:
// DB or network error
console.error(err);
}
}📊 Database Collections
| Collection | Purpose |
|---|---|
| walletusers | One doc per user — holds currency→balance map |
| wallettransactions | Immutable ledger of every credit/debit |
| logs | Operational logs with console fallback |
| idempotencies | 24-hour TTL cache of completed operations |
🐛 Troubleshooting
MongoError: Transaction numbers are only allowed on replica sets
Start mongod with --replSet rs0 and run rs.initiate() once.
OverwriteModelError: Cannot overwrite model once compiled
All models are registered with mongoose.models.X || mongoose.model(...) guards. If you still see this, you likely have two different versions of mongoose installed.
High-concurrency write conflicts
The library retries automatically up to 3 times with exponential backoff (50ms, 100ms, 150ms). If conflicts persist under extreme load, check your MongoDB connection pool size.
🧪 Testing
Install mongodb-memory-server for in-process testing:
// tests/wallet.test.js
const { MongoMemoryReplSet } = require('mongodb-memory-server');
const mongoose = require('mongoose');
const SakshWallet = require('saksh-easy-wallet');
let replSet, wallet;
beforeAll(async () => {
replSet = await MongoMemoryReplSet.create({ replSet: { count: 1 } });
await mongoose.connect(replSet.getUri());
wallet = new SakshWallet();
await wallet.ensureIndexes();
});
afterAll(async () => {
await mongoose.disconnect();
await replSet.stop();
});
test('credit and debit', async () => {
await wallet.credit('[email protected]', 100, 'USD', 'r1', 'Deposit');
const { balance } = await wallet.getBalance('[email protected]', 'USD');
expect(balance).toBe(100);
const result = await wallet.debit('[email protected]', 40, 'USD', 'r2', 'Purchase');
expect(result.balance).toBe(60);
});
test('insufficient funds', async () => {
const result = await wallet.debit('[email protected]', 9999, 'USD', 'r3', 'Too much');
expect(result.message).toBe('Insufficient funds');
});
test('atomic fund transfer', async () => {
await wallet.credit('[email protected]', 200, 'USD', 'r4', 'Load');
const transfer = await wallet.fundTransfer(
'[email protected]', '[email protected]',
50, 'USD', 'r5', 'Payment'
);
expect(transfer.senderBalance).toBe(150);
expect(transfer.receiverBalance).toBe(50);
});
test('idempotent transfer', async () => {
const first = await wallet.fundTransfer('[email protected]', '[email protected]', 10, 'USD', 'r5', 'Payment');
const second = await wallet.fundTransfer('[email protected]', '[email protected]', 10, 'USD', 'r5', 'Payment');
// Balance unchanged on second call
expect(first.senderBalance).toBe(second.senderBalance);
});Run with:
npm test📄 License
MIT — free for commercial use. See LICENSE.
🤝 Contact & Support
| | | |---|---| | Author | Susheel Kumar | | Email | [email protected] | | Availability | Open for freelance and full-time remote positions |
Built with care by Susheel Kumar — [email protected]
