Skip to content

jcjp/valorem-challenge

Repository files navigation

Digital Wallet API

A REST API for digital wallet top-up via PayId webhook integration. Receives payment notifications, maintains per-user wallet balances, and exposes balance and transaction history endpoints. Built with Express, TypeScript, and SQLite.


Features

  • Receives PayId payment webhooks and credits user wallets atomically
  • HMAC request authentication with constant-time comparison (timing-attack safe)
  • Idempotent payment processing — duplicate transaction IDs are detected and ignored
  • Balance stored as integer cents to avoid floating-point rounding errors
  • Transaction history ordered by most recent first
  • In-process SQLite database via better-sqlite3 — zero external dependencies at runtime

Tech Stack

Layer Library
Runtime Node.js 24, TypeScript
Framework Express 5
Database SQLite via better-sqlite3
Query builder sumak
Testing Jest 30 + ts-jest (ESM mode) + Supertest

Project Structure

src/
├── server.ts                  # Entry point — starts HTTP server
├── app.ts                     # Express app factory
├── types/
│   └── index.ts               # Shared TypeScript interfaces
├── db/
│   ├── client.ts              # SQLite connection + sumak query builder
│   └── migrate.ts             # Creates tables on startup
├── middleware/
│   ├── hmacAuth.ts            # HMAC-SHA256 authentication
│   ├── rawBody.ts             # Captures raw body for HMAC verification
│   └── validate.ts            # Webhook payload schema validation
├── repositories/
│   ├── walletRepository.ts    # Wallet DB operations
│   └── transactionRepository.ts # Transaction DB operations
├── services/
│   └── walletService.ts       # Business logic, atomic payment processing
├── controllers/
│   ├── walletController.ts    # GET /wallets handlers
│   └── webhookController.ts   # POST /webhook/payment handler
└── routes/
    ├── wallet.ts
    └── webhook.ts
tests/
├── hmac.test.ts               # Unit tests — HMAC computation
├── wallet.test.ts             # Integration tests — wallet & transactions
├── webhook.test.ts            # Integration tests — payment webhook
└── helpers/
    ├── db.ts                  # In-memory DB reset between tests
    └── hmac.ts                # HMAC signing helper for test requests

Getting Started

Prerequisites

  • Node.js 18+
  • pnpm

Install

pnpm install

Environment Variables

Variable Default Description
PORT XXXX HTTP port
HMAC_KEY (dev key) Key for HMAC verification
DB_PATH ./wallet.db Path to the SQLite database file

Create a .env file or export variables directly:

export HMAC_KEY=
export PORT=

Run (development)

npx ts-node src/server.ts

Run (production)

pnpm run build
node dist/server.js

API Reference

GET /health

Returns service health status.

Response

{ "status": "ok" }

POST /webhook/payment

Receives a PayId payment notification and credits the user's wallet.

Authentication

Requires an Authorization header with an HMAC signature of the raw request body:

Authorization: HMAC_SHA256 <hex_signature>

Compute the signature:

HMAC_KEY="your_64_char_hex_key"
BODY='{"transactions":[...]}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$HMAC_KEY | awk '{print $2}')

Request body

{
  "transactions": [
    {
      "id": "tx-001",
      "created_at": "2024-01-10T10:00:00.000Z",
      "updated_at": "2024-01-10T10:00:00.001Z",
      "description": "Credit of $50.00",
      "type": "deposit",
      "type_method": "npp_payin",
      "state": "successful",
      "user_id": "user-123",
      "user_name": "Jane Smith",
      "amount": "50.00",
      "currency": "AUD",
      "debit_credit": "credit"
    }
  ]
}

Required fields per transaction: id, user_id, user_name, amount (positive number string), currency

Response 200 OK

{
  "success": true,
  "data": [
    {
      "transaction_id": "tx-001",
      "user_id": "user-123",
      "is_duplicate": false,
      "wallet_balance_cents": 5000
    }
  ]
}

is_duplicate: true is returned when a transaction ID has already been processed. The wallet is not credited again.

Error responses

Status Condition
400 Missing or invalid transactions array, or a required field is absent/invalid
401 Missing Authorization header, wrong scheme, or HMAC mismatch

GET /wallets/:userId

Returns the wallet for a user.

Response 200 OK

{
  "success": true,
  "data": {
    "id": "a1b2c3d4-...",
    "user_id": "user-123",
    "balance_cents": 5000,
    "created_at": "2024-01-10T10:00:00.000Z",
    "updated_at": "2024-01-10T10:00:00.001Z"
  }
}

Response 404 Not Found

{
  "success": false,
  "error": "Wallet not found for this user"
}

Wallets are created automatically on the first payment received for a user.


GET /wallets/:userId/transactions

Returns all transactions for a user, ordered by created_at descending (most recent first). Returns an empty array if the user has no transactions.

Response 200 OK

{
  "success": true,
  "data": [
    {
      "id": "tx-001",
      "user_id": "user-123",
      "amount_cents": 5000,
      "currency": "AUD",
      "type": "deposit",
      "type_method": "npp_payin",
      "state": "successful",
      "description": "Credit of $50.00",
      "debit_credit": "credit",
      "user_name": "Jane Smith",
      "created_at": "2024-01-10T10:00:00.000Z",
      "updated_at": "2024-01-10T10:00:00.001Z"
    }
  ]
}

Testing

vp run test
# or
npm test

All tests use an in-memory SQLite database (set via DB_PATH=:memory:) that is reset before each test. No external services required.

Test coverage (25 tests across 3 suites)

Suite Tests Coverage
hmac.test.ts 5 HMAC computation, timing-safe equality
wallet.test.ts 8 Wallet balance, transaction listing, ordering, isolation
webhook.test.ts 12 Auth, validation, processing, idempotency, atomicity

See TEST_RESULTS.md for the full test run log.


Schema

wallets

Column Type Notes
id TEXT UUID, primary key
user_id TEXT Unique per user
balance_cents INTEGER Running total in cents
created_at TEXT ISO 8601
updated_at TEXT ISO 8601, updated on each credit

transactions

Column Type Notes
id TEXT Primary key — used for idempotency
user_id TEXT
amount_cents INTEGER Stored in cents
currency TEXT e.g. AUD
type TEXT e.g. deposit
type_method TEXT e.g. npp_payin
state TEXT e.g. successful
description TEXT
debit_credit TEXT e.g. credit
user_name TEXT
created_at TEXT ISO 8601
updated_at TEXT ISO 8601

Notable Implementation Details

Atomic payment processing Wallet creation, balance credit, and transaction insert are wrapped in a single SQLite transaction. Concurrent duplicate requests cannot partially apply a payment.

Idempotency via INSERT OR IGNORE Transactions are inserted with INSERT OR IGNORE. If a transaction ID already exists, the insert is a no-op and the existing wallet balance is returned with is_duplicate: true.

HMAC timing safety Signature comparison uses crypto.timingSafeEqual to prevent timing oracle attacks. The raw request body is captured before Express parses the JSON so the exact bytes the sender signed are compared.

Balance as integer cents amount strings from the webhook (e.g. "18.99") are converted to cents via Math.round(parseFloat(amount) * 100) to avoid IEEE 754 floating-point accumulation errors.


Changelog

See CHANGELOG.md for a full history of changes and architectural decisions.

About

Digital wallet REST API with PayId webhook integration, HMAC auth, idempotent payment processing, and SQLite-backed balance tracking

Topics

Resources

Stars

Watchers

Forks

Contributors