A robust, multi-currency virtual wallet backend built with Node.js, PostgreSQL, and Redis. This system is engineered to handle high-concurrency transfers while maintaining strict data integrity and financial accuracy.
While NoSQL databases scale horizontally easily, they often struggle with complex multi-row transactions.
- The Choice: PostgreSQL.
- The Reasoning: In a wallet, a "Transfer" is not one event; it is a multi-step process (Check balance -> Debit A -> Credit B -> Log Ledger). PostgreSQL's ACID compliance ensures that if the system fails at step 3, step 1 and 2 are rolled back automatically. Using a Relational DB allows us to use Strict Schema Constraints to prevent "negative balances" at the database level, providing a second layer of defense beyond the application logic.
- The Choice: Node.js (Asynchronous I/O).
- The Reasoning: Financial APIs are "I/O bound," meaning the CPU spends most of its time waiting for the database or cache. Node's non-blocking event loop allows a single instance to handle thousands of concurrent "Balance Check" requests without getting stuck behind a slow "Heavy Transfer" request. It provides the high concurrency needed for a global wallet.
- The Choice: Redis (In-memory KV Store).
- The Reasoning: Checking if a transaction is a duplicate by querying a Postgres table with millions of rows is expensive (Disk I/O). By using Redis with a 12-hour TTL (Time-To-Live), we create a high-speed "Shield." 99% of duplicate retries are caught in Redis in <1ms, never even touching the primary database. This preserves the Database's resources for actual money-moving operations.
Instead of a single architecture, we split the logic based on the Account Type:
- User-to-User: Strong Consistency. Users expect to see their money gone or arrived immediately. We use row-level locking here.
- System-to-User: Eventual Consistency. Because the
SYSTEMaccount touches every transaction, locking it would create a "Bottleneck." We use a Ledger-first approach where the system balance is recalculated in the background. This allows the system to scale to 10,000+ transactions per second without the "System Row" becoming a point of contention.
We do not treat the balance column as the "Source of Truth." It is treated as a Calculated Cache. The true state of the system is the sum of the ledger_entries. This allows us to:
- Audit: Detect if a bug ever "created" money out of thin air.
- Recover: If the accounts table is corrupted, we can rebuild it perfectly from the ledger history.
To prevent "Double Spending" while maintaining high performance, the system uses a Hybrid Locking Strategy:
- For Users: Uses Pessimistic Locking (
SELECT ... FOR UPDATE) to ensure that a user's balance cannot be manipulated by two simultaneous requests. - For System Accounts: Uses Eventual Consistency. To avoid making the System Account a bottleneck (as it participates in almost every transaction), the system records ledger entries instantly but updates the balance via a background worker.
Deadlocks are avoided by enforcing a Strict Locking Order. In every transfer, the system sorts the Account IDs and locks them in a consistent sequence (e.g., smaller ID first). This ensures that two users transferring to each other simultaneously can never "lock each other out."
The system implements a Unique Transaction Guard:
- Every transfer requires an
idempotencyKey. - A unique constraint on the
ledger_entriestable ensures that if a network retry occurs, the system will never process the same transaction twice or "mint" extra money.
The system follows a strict ledger-based architecture. Every balance change is backed by a debit and credit entry.
- 5-Minute Sync: An incremental cron job updates the system balance to keep it "fresh."
- 24-Hour Audit: A full reconciliation script sums the entire ledger history daily to reset the system balance to the Absolute Truth, fixing any micro-discrepancies.
- Runtime: Node.js (ES Modules)
- Database: PostgreSQL (Relational integrity & MVCC)
- Cache: Redis (Storing Idempontency Key 12hr, and this avoid hitting db for invalid transections.)
- Background Tasks:
node-cronfor scheduled reconciliation
- Docker and Docker Compose
- Clone the repository.
- Build and start the containers:
docker-compose up --build
-
Check Balance: GET
/wallet/balance/:ownerId- Returns all currency accounts (COIN, GEM, etc.) associated with a single owner ID.
-
Transfer Funds: POST
/wallet/transfer{ "fromId": "UUID", "toId": "UUID", "amount": 50, "idempotencyKey": "unique-uuid-123" }- In this method, there are total 4 ways of transection.
- User to User -> (Sending gift cards)
- User to System -> (Buying any subscription or spending virtual currency)
- System to User -> (offering gifts or rewards to user)
- System to User -> (Buying virtual currency with real money)
- In all of above 4 methods 75% times System account used for transection, this is the reason of not locking system account everytime.
- In this method, there are total 4 ways of transection.
-
Transection History: GET
/wallet/history/:ownerId?limit=20&offset=0Returns a paginated list of all ledger movements for an owner.offset: how many transection rows to skip before start counting limit: how many transection rows to return in single request
[NOTE] I implemented only 2 virtual currencies for now. and a single user will have a separate accout for each type of currency.