A production-minded REST API for a digital wallet, covering registration, authentication, balance top-up, payments, peer-to-peer transfers, transaction reporting, and profile management.
Transfers are settled asynchronously by a dedicated background worker, which keeps the request path fast while guaranteeing that money is never lost between two accounts.
- Features
- Tech Stack
- Architecture
- Transfer Flow
- Getting Started
- API Reference
- Testing
- Design Decisions
- Project Structure
| Feature | Endpoint | Notes |
|---|---|---|
| Register | POST /register |
UUID user id, phone number as unique key |
| Login | POST /login |
JWT access and refresh tokens |
| Top Up | POST /topup |
Atomic balance credit |
| Payment | POST /pay |
Rejects overdrafts |
| Transfer | POST /transfer |
Queued, settled by background worker |
| Report Transactions | GET /transactions |
Full history, newest first |
| Update Profile | PUT /profile |
Unique key protected from modification |
Additional endpoints beyond the original scope: GET /profile, POST /refresh, GET /health.
| Layer | Choice | Why |
|---|---|---|
| Runtime | Node.js 18+ | |
| Framework | Express 4 | Minimal, well understood |
| Database | PostgreSQL | Row-level locking for concurrent balance updates |
| ORM | Sequelize 6 | Schema as code, plus versioned migrations |
| Queue | BullMQ + Redis | Reliable retries and dead-letter visibility |
| Monitoring | Bull Board | Live dashboard at /admin/queues |
| Auth | JWT (access + refresh) | Stateless, standard |
| Validation | Joi | Declarative schemas per endpoint |
| Testing | Jest + Supertest | 26 tests against an in-memory database |
HTTP
|
+--------v---------+
| Express API |
+--------+---------+
|
+--------------+--------------+
| |
+-------v--------+ +--------v--------+
| PostgreSQL | | Redis (BullMQ) |
| users | | transfer-queue |
| transactions | +--------+--------+
+-------^--------+ |
| +--------v--------+
+--------------------+ Transfer Worker |
| (separate proc) |
+--------+--------+
|
+--------v--------+
| Bull Board |
| /admin/queues |
+-----------------+
The API process and the worker process are deliberately separate. The API never performs the recipient credit itself, and the worker never handles HTTP. Either can be scaled or restarted independently.
Client API DB Queue Worker
| | | | |
|--POST /transfer--->| | | |
| |--BEGIN------------>| | |
| | lock sender row | | |
| | check balance | | |
| | debit sender | | |
| | insert PENDING | | |
| |--COMMIT----------->| | |
| |--------------------|--enqueue job----->| |
|<---201 Created-----| | |--pick up job--->|
| | | | |
| | |<--credit recipient----------------- |
| | |<--mark SUCCESS--------------------- |
If the worker fails: BullMQ retries three times with exponential backoff. Once retries are exhausted, the sender is automatically refunded and the transaction is marked FAILED. Funds never vanish mid-flight.
If the job is replayed: the settlement routine checks the transaction status first and exits without crediting twice.
- Node.js 18 or newer
- PostgreSQL 14 or newer
- Redis 6 or newer (on Windows, Memurai is a drop-in alternative)
macOS / Linux
git clone https://github.com/MRRzkS/E-Wallet-REST-API.git
cd E-Wallet-REST-API
npm install
cp .env.example .env # then fill in your credentials
createdb ewallet
npm run migrateWindows (Command Prompt)
git clone https://github.com/MRRzkS/E-Wallet-REST-API.git
cd E-Wallet-REST-API
npm install
copy .env.example .env :: then fill in your credentials
createdb -U postgres ewallet
npm run migrateIf createdb is not recognised, add C:\Program Files\PostgreSQL\<version>\bin to your system PATH and open a new terminal. Alternatively, create the database through pgAdmin.
For Redis, install Memurai and verify with memurai-cli ping, which should answer PONG.
Two processes are required. Transfers will stay PENDING forever without the worker.
# Terminal 1 - API server
npm start
# Terminal 2 - background worker
npm run worker| Service | URL |
|---|---|
| API | http://localhost:3000 |
| Queue dashboard | http://localhost:3000/admin/queues |
| Health check | http://localhost:3000/health |
A ready-to-import Postman collection is included as postman_collection.json.
All endpoints except /register, /login, and /refresh require:
Authorization: Bearer {access_token}
Success responses follow { "status": "SUCCESS", "result": { ... } }. Failures return { "message": "..." }.
POST /register
{
"first_name": "Guntur",
"last_name": "Saputro",
"phone_number": "0811255501",
"address": "Jl. Kebon Sirih No. 1",
"pin": "123456"
}Response 201
{
"status": "SUCCESS",
"result": {
"user_id": "bc1c823e-b0fb-4b20-88c0-dff25e283252",
"first_name": "Guntur",
"last_name": "Saputro",
"phone_number": "0811255501",
"address": "Jl. Kebon Sirih No. 1",
"created_date": "2021-04-01 22:21:20"
}
}Errors: 409 phone number already registered, 400 validation failure.
POST /login
{ "phone_number": "0811255501", "pin": "123456" }Response 200
{
"status": "SUCCESS",
"result": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
}Errors: 401 phone number and pin doesn't match.
POST /topup
{ "amount": 500000 }Response 201
{
"status": "SUCCESS",
"result": {
"top_up_id": "201ddde1-f797-484b-b1a0-07d1190e790a",
"amount_top_up": 500000,
"balance_before": 0,
"balance_after": 500000,
"created_date": "2021-04-01 22:21:21"
}
}Errors: 401 unauthenticated, 400 non-positive amount.
POST /pay
{ "amount": 100000, "remarks": "Pulsa Telkomsel 100k" }Response 201
{
"status": "SUCCESS",
"result": {
"payment_id": "13bcb11c-111e-4a65-9afd-90a86a01cd21",
"amount": 100000,
"remarks": "Pulsa Telkomsel 100k",
"balance_before": 500000,
"balance_after": 400000,
"created_date": "2021-04-01 22:22:00"
}
}Errors: 400 balance is not enough, 401 unauthenticated.
POST /transfer
{
"target_user": "b7342e8e-e8e7-4a5d-873e-b1b1bfcdeddb",
"amount": 30000,
"remarks": "Hadiah Ultah"
}Response 201
{
"status": "SUCCESS",
"result": {
"transfer_id": "a7d39cf6-44b6-41fc-b3e9-7b16df5321c5",
"amount": 30000,
"remarks": "Hadiah Ultah",
"balance_before": 400000,
"balance_after": 370000,
"created_date": "2021-04-01 22:23:20"
}
}The sender is debited immediately; the recipient is credited by the worker moments later. Errors: 400 balance is not enough or self-transfer, 404 target user not found, 401 unauthenticated.
GET /transactions
Optional query parameters: limit (default 50, max 100), offset (default 0).
Response 200
{
"status": "SUCCESS",
"result": [
{
"transfer_id": "a7d39cf6-44b6-41fc-b3e9-7b16df5321c5",
"status": "SUCCESS",
"user_id": "bc1c823e-b0fb-4b20-88c0-dff25e283252",
"transaction_type": "DEBIT",
"amount": 30000,
"remarks": "Hadiah Ultah",
"balance_before": 400000,
"balance_after": 370000,
"created_date": "2021-04-01 22:23:20"
},
{
"payment_id": "13bcb11c-111e-4a65-9afd-90a86a01cd21",
"status": "SUCCESS",
"user_id": "bc1c823e-b0fb-4b20-88c0-dff25e283252",
"transaction_type": "DEBIT",
"amount": 10000,
"remarks": "Pulsa Telkomsel 100k",
"balance_before": 500000,
"balance_after": 400000,
"created_date": "2021-04-01 22:22:00"
}
]
}The identifier key is named after the transaction kind: top_up_id, payment_id, or transfer_id.
PUT /profile
{
"first_name": "Tom",
"last_name": "Araya",
"address": "Jl. Diponegoro No. 215"
}Response 200
{
"status": "SUCCESS",
"result": {
"user_id": "bc1c823e-b0fb-4b20-88c0-dff25e283252",
"first_name": "Tom",
"last_name": "Araya",
"address": "Jl. Diponegoro No. 215",
"updated_date": "2021-04-01 23:00:20"
}
}Sending phone_number returns 400: it is the unique key and is rejected explicitly rather than silently ignored.
npm test # 26 tests
npm run test:coverage # with coverage reportTests run against an in-memory SQLite database with the queue mocked, so neither PostgreSQL nor Redis needs to be running.
Coverage includes the happy path for every endpoint plus the cases that matter most: overdraft rejection, duplicate registration, unauthenticated access, transfer settlement, replayed jobs, and refund after exhausted retries.
Transfers debit synchronously, credit asynchronously. The sender's balance is reduced inside the request transaction so the response reports a truthful balance_after, and so the same funds cannot be spent twice while the job waits in the queue. Only the recipient credit is deferred.
Idempotent settlement. The queue jobId is the transfer_id, so a retried HTTP request cannot create a duplicate job. The settlement routine also re-checks transaction status, so a job replayed after a crash finds the transfer already settled and exits.
Automatic refund. If the recipient disappears, or if all retries are exhausted, the sender's balance is restored and a compensating transaction is recorded. There is no state in which money is deducted but never delivered.
Row-level locking. Every balance mutation runs inside a database transaction using SELECT ... FOR UPDATE, preventing two concurrent requests from reading the same balance and overwriting each other's result.
PIN handling. PINs are hashed with bcrypt through a model hook, and the User model's defaultScope excludes the column entirely, so a PIN cannot leak into a response through an oversight.
Uniform auth failures. An unregistered phone number and an incorrect PIN produce identical responses, so the API does not disclose which numbers exist.
src/
config/ database, Redis, and environment configuration
models/ Sequelize models (User, Transaction)
validators/ Joi schemas, one per endpoint
middlewares/ authentication, validation, rate limiting, error handling
services/ business logic, isolated from HTTP
controllers/ thin HTTP adapters over the services
routes/ endpoint definitions
jobs/ transfer queue definition
workers/ background process that settles transfers
utils/ ApiError, logger, formatters
migrations/ versioned database schema
tests/ Jest test suites
Controllers contain no business logic; services contain no HTTP concerns. This separation is what makes the test suite able to exercise transfer settlement directly, without going through the queue.
MIT