Skip to content

Repository files navigation

E-Wallet REST API

Node Express PostgreSQL Redis Tests License

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.


Table of Contents


Features

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.


Tech Stack

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

Architecture

                     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.


Transfer Flow

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.


Getting Started

Prerequisites

  • Node.js 18 or newer
  • PostgreSQL 14 or newer
  • Redis 6 or newer (on Windows, Memurai is a drop-in alternative)

Installation

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 migrate
Windows (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 migrate

If 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.

Running

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.


API Reference

All endpoints except /register, /login, and /refresh require:

Authorization: Bearer {access_token}

Success responses follow { "status": "SUCCESS", "result": { ... } }. Failures return { "message": "..." }.

Register

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.

Login

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.

Top Up

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.

Payment

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.

Transfer

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.

Report Transactions

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.

Update Profile

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.


Testing

npm test              # 26 tests
npm run test:coverage # with coverage report

Tests 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.


Design Decisions

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.


Project Structure

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.


License

MIT

Author

Muhammad Rienchy Razak Simatupang GitHub · LinkedIn

About

Production-minded digital wallet REST API: auth, top-up, payments, P2P transfers with asynchronous settlement via a background worker.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages