Lucas's Portfolio
All projects

Mini PayPay Rewards App

A 3-tier mobile rewards platform (React Native (Expo) client, Express + Prisma REST API, and PostgreSQL) with JWT auth, idempotent point redemptions, and Expo push notifications.

  • TypeScript
  • React Native
  • Expo
  • Redux Toolkit
  • Node.js
  • Express
  • Prisma
  • PostgreSQL

Table of Contents

1. App Description
2. System Architecture
3. Consumed Technologies, Tools and Dependencies
4. Installation
    4.1. Run Database (Postgres)
    4.2. Run Backend App
    4.3. Run React Native (Expo) App
    4.4. Notes
        4.4.1. Push notifications doesn't work on Android Expo Go
        4.4.2. Backend tests
5. Demo Account Credentials
6. Managing the Source Code
    6.1. Client-side app (React Native (Expo) App)
    6.2. Server-side app (Express) (Node)
    6.3. Database structure (Postgres)
7. Usage
    7.1. Functional features of the App
    7.2. Non-functional features of the App
    7.3. Walkthrough of the Mini PayPay Rewards App
        7.3.1. Screenshots on iOS
        7.3.2. Screen recording on iOS
        7.3.3. Screenshots on Android
        7.3.4. Screen recording on Android
    7.4. API Documentation
8. Possible Infrastructure Improvements
9. Credits
10. License

Documentation

1. App Description -

Mini PayPay Rewards App is a 3-tier mobile rewards application that lets a signed-in user track a points balance, browse a rewards catalogue, and redeem rewards in exchange for points. The system is split across a React Native (Expo) mobile client, an Express + Prisma REST API, and a PostgreSQL database, plus an outbound integration with the Expo Push Notification service.

There are three primary screens in the mobile app, guarded behind authentication:

  1. Home
    Shows the user's current point balance ("Vault Points Available") and a five-row Activity Log preview of the most recent ledger entries. A "Redeem Now" button jumps straight to the Rewards tab and the "VIEW ALL" button opens the History tab.
  2. History
    A paginated transaction ledger (default 20 per page, with infinite scroll, fetching the next 20 once the user reaches the bottom). With pull-to-refresh re-fetches the user's balance and the latest transactions.
  3. Rewards
    The redeemable catalogue. Each card shows the reward image, stock state (IN STOCK, X LEFT, SOLD OUT), points cost, and a state-aware buttons (Claim Now, Locked with an "Insufficient Points" overlay, Notify Me when sold out).
    Tapping Claim Now opens a confirmation bottom sheet that walks through confirm => loading => success / error states. If the request is success, the API updates the user's balance, transaction history, and reward stock without a page reload, and a confirmation push notification is requested via Expo.

Other features include:

  • Secure JWT storage with proactive refresh
  • An offline banner controlled by NetInfo
  • A global "you're offline" alert routed through a Redux UI slice
  • Animated count-down on the rewards balance
  • Haptic feedback on auth events (login, logout)
  • Skeleton placeholders during initial data loads

2. System Architecture -

Figure 2.1: System Architecture Diagram

This system uses a 3-tier client-server architecture — presentation layer (mobile app), application logic layer (Express REST API), and data layer (PostgreSQL) — with JWT-authenticated HTTPS between the client and the API. Push notification delivery is handled to Expo's hosted notification service.

How data flows, end to end

Every meaningful action — logging in, opening the History tab, redeeming a reward — follows the same round-trip, just with different cargo:

  1. User interacts -
  • User engages with the app and the app captures the action. This is stored in the local store.
  1. App calls the backend -
  • A HTTPS client attaches the user's stored JWT and sends the requests to the backend.
  • If the token is within two minutes of expiring, it's refreshed beforehand.
  • If the device is offline the request doesn't go out, causing the offline banner to be visible on the UI and also an alert will shown if the user interacts with the app.
  1. Backend checks and executes -
  • The backend app verifies the JWT, validates the body and if authorized, the request is passed to the handle the business logic.
  • Sensitive operations like redemptions are handled in the database transaction, to manipulate the user's points and reward's stock.
  1. Database storage -
  • Postgres stores who the user is, every point earned or spend, the reward catalogue, and manipulate record for each redemption.
  • Foreign keys keep the four tables consistent, eg: deleting a user will cause their history to be deleted alongside, and it won't be possible to delete a reward someone has already claimed.
  1. Backend Response -
  • API returns JSON and the app forwards it to Redux. The screens that rely on the response will re-render automatically due to the state change.
  • After a successful redeem, the balance counts down to it's new value and the History and Rewards lists update. To update the UI, it don't require a manual reload.
  1. One async side trip
  • After the redemption is safely committed, the API will send a request to Expo to trigger a confirmation push notification.

3. Consumed Technologies, Tools and Dependencies -


4. Installation -

The repository contains three independent projects under Workspace/:

FolderWhat it is
mini-pay-pay-rewards_db-config/Docker Compose file that boots PostgreSQL 16
mini-paypay-rewards_api/Express + Prisma REST API
mini-paypay-rewards_app/Expo / React Native mobile app

Prerequisites — install these on your machine before you start:

  • Node 20 or newer (LTS) and npm
  • Docker Desktop
  • Expo Go on a physical iOS or Android device, or the iOS Simulator / an Android emulator on your machine

The database needs to be up before the API and the API needs to be up before the app.

4.1. Run Database (Postgres) -

# Ensure docker is running beforehand
cd Workspace/mini-pay-pay-rewards_db
docker compose up -d

4.2. Run Backend App -

cd ../mini-paypay-rewards_api
npm install                           # install dependencies
cp .env.example .env                  # copy environment variables
npx prisma generate                   # generate types Prisma client
npx prisma migrate dev --name init    # apply schema to the running database
npx prisma db seed                    # populate database - insert demo users, rewards, ledger entries
npm run dev                           # API is running and is listening on http://localhost:4000

4.3. Run React Native (Expo) App -

cd ../mini-paypay-rewards_app
npm install                           # install dependencies
npm start
4.4. Notes
4.4.1. Push notifications doesn't work on Android Expo Go -
  • Expo has removed remote-push support from Android Expo Go in SDK 53.
  • Use a development build or test on iOS Expo Go.
4.4.2. Backend tests -
  • From mini-paypay-rewards_api/, run npm test to execute the Jest unit tests for the redemption service.

5. Demo Account Credentials

EmailPassword
lucas@test.comTesting2$
sam@test.comTesting3$
william@test.comTesting4$
  • Each demo user starts with a balance of 24,850 points and 25 ledger entries.

6. Managing the Source Code -

6.1. Client-side app (React Native (Expo) App)

Client-side app's source code is located in the following directory =>

  Workspace -> mini-paypay-rewards_app [client]

Source code structure =>

Figure 6.1: Client-side app (React Native (Expo)) Source Code Structure

6.2. Server-side app (Express) (Node)

Server-side app's source code is located in the following directory =>

  Workspace -> mini-paypay-rewards_api [server]

Source code structure =>

Figure 6.2: Server-side app (Express) (Node) Source Code Structure

6.3. Database structure (Postgres)

Database model definitions are located in the following directory =>

  Workspace -> mini-paypay-rewards_api/prisma/schema.prisma [db model definitions]

Database Schema =>

Figure 6.3: Database Schema Diagram


7. Usage -

7.1. Functional features of the App -

  • Authentication -
    • Email + password login access to the backend; the JWT is stored in expo-secure-store together with its decoded expiry.
    • Tokens are auto-refreshed when within two minutes of expiring; expired sessions are cleared and the user is directed to the Login screen.
    • Logout clears the token locally and requests the backend to clear the device's push token.
  • Home screen -
    • Shows the user's current balance.
    • Renders an Activity Log preview of the five most recent ledger entries.
  • History screen -
    • Paginated transaction list (default 20 per page). The next 20 will fetch automatically when the user scrolls to the end.
    • Pull-to-refresh, re-fetches the user's balance and the latest transactions.
  • Rewards screen -
    • Catalogue of active rewards with category filter chips (All / Lifestyle / Travel).
    • Each card shows image, name, description, points cost, and a stock badge (IN STOCK, X LEFT, or SOLD OUT).
    • State-aware buttons: Claim Now, Locked (with an "Insufficient Points" overlay), or Notify Me when sold out.
    • Tapping Claim Now opens a confirmation bottom sheet that walks through confirm => loading => success / error flow.
    • On a successful redeem, the balance, transaction history, and reward stock all update in place without full reload and the balance counts down to its new value.
  • Push notifications -
    • The device registers an Expo push token after login and the API stores it on users.push_token.
    • The API fires a confirmation notification after a successful reward redeem ("Redemption confirmed — reward name").
  • Offline handling -
    • NetInfo-driven banner appears at the top of every screen when the device is offline.
    • Write actions (e.g. Claim Now) are disabled while offline.
    • Any request that fails without a response, a global "You're offline" alert modal is shown.
  • Idempotent redemption -
    • POST /rewards/:id/redeem requires an X-Idempotency-Key header, replaying the same key returns the original redemption instead of charging twice.
    • Stock is decremented inside the same transaction, with a conditional updateMany that rejects the write if a concurrent request already took the last unit.

7.2. Non-functional features of the App -

  • Type safety -
    • TypeScript across the mobile app, API, Prisma schema, and seed scripts.
  • Data consistency -
    • Atomic Prisma $transactions for redemptions; FK cascade rules keep the four tables coherent.
  • Resilience -
    • JWT refresh skew, secure on-device token storage, offline detection + global alert, idempotency keys to make retries safe.
  • Modularity -
    • Clean separation of UI / hooks / Redux slices / services on the client, and middleware / routes / services / lib on the server.
    • Generic ConfirmationModal and AlertModal components are reused across flows.
  • Performance -
    • Image caching via expo-image, native-driver animations (count-down balance, three-dots loader, skeletons), single-flight token refresh, and parallel refreshMe + fetchLedger after redemption.
  • Testability -
    • Backend redemption logic extracted into a pure service so it can be unit-tested with a mocked Prisma client (Jest + ts-jest).
  • Code quality -
    • ESLint + typescript-eslint + Prettier configured on both projects.
    • Consistent naming for slices, thunks, and routes.
  • Accessibility -
    • Uses react-native-safe-area-context to respect device notches and home indicators.
    • Large react native hit slops on icon buttons to increase the tappable area.
    • Haptic feedback on auth events.
  • Observability -
    • Failed background actions (e.g. clearing the push token on logout) are logged with console.error so they're visible in the Metro logs without breaking the user flow.

7.3. Walkthrough of the Mini PayPay Rewards App -

7.3.1. Screenshots on iOS =>

Figure 7.3.1.1: Login screen - Initial state

Figure 7.3.1.2: Login screen - Incorrect credentials entry error modal

Figure 7.3.1.3: Home screen - Initial state after successful login

Figure 7.3.1.4: History screen - Initial state

Figure 7.3.1.5: Rewards screen - Initial state

7.3.2. Screen recording on iOS =>

App walkthrough:


7.3.3. Screenshots on Android =>

Figure 7.3.3.1: Login screen - Initial state

Figure 7.3.3.2: Login screen - Incorrect credentials entry error modal

Figure 7.3.3.3: Home screen - Initial state after successful login

Figure 7.3.3.4: History screen - Initial state

Figure 7.3.3.5: Rewards screen - Initial state

7.3.4. Screen recording on Android =>

App walkthrough:

7.4. API Documentation -

All routes are served by the Express API.

  • Base URL (dev): http://localhost:4000
  • Auth: Authorization: Bearer <jwt> on every endpoint except POST /auth/login and GET /health.
  • Content type: JSON request/response unless noted.
  • Snippets are for Node.js 20+ / TypeScript using the built-in fetch. Replace BASE and token with your own values.
const BASE = 'http://localhost:4000';
let token = ''; // set after POST /auth/login below
7.4.1. GET /health

Health check. No auth, no body. Returns { ok: true }.

const res = await fetch(`${BASE}/health`);
const body = await res.json(); // { ok: true }
7.4.2. POST /auth/login

Returns signed JWT when email and password is passed. Validated with zod.

const res = await fetch(`${BASE}/auth/login`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'lucas@test.com', password: 'Testing2$' }),
});
const { token: jwt } = await res.json() as { token: string };
token = jwt;

Errors: 400 (wrong request input) invalid input, 401 (unauthorized) invalid email or password.

7.4.3. POST /auth/refresh

Generates a new JWT for the authenticated user. Used by the mobile app's request interceptor when the existing token is within 2 minutes of expiry.

const res = await fetch(`${BASE}/auth/refresh`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}` },
});
const { token: refreshed } = await res.json() as { token: string };
7.4.4. GET /users/me

Returns the current user's profile and computed point balance (sum of point_ledger.delta).

const res = await fetch(`${BASE}/users/me`, {
  headers: { Authorization: `Bearer ${token}` },
});
const { user, balance } = await res.json() as {
  user: { id: string; email: string; name: string; pushToken: string | null; createdAt: string };
  balance: number;
};
7.4.5. GET /users/me/transactions

Page-based ledger history, newest first. Defaults to 20 per page.

Query paramDefaultNotes
page11-based page index
limit20Capped at 50
const params = new URLSearchParams({ page: '1', limit: '20' });
const res = await fetch(`${BASE}/users/me/transactions?${params}`, {
  headers: { Authorization: `Bearer ${token}` },
});
const { transactions, page, limit, total, hasMore } = await res.json() as {
  transactions: Array<{
    id: string;
    delta: number;
    reason: string;
    category: 'purchase' | 'dining' | 'travel' | 'reward' | 'redemption';
    source: string | null;
    createdAt: string;
  }>;
  page: number;
  limit: number;
  total: number;
  hasMore: boolean;
};
7.4.6. GET /users/me/redemptions

Paginated redemption history with the joined reward details.

const params = new URLSearchParams({ page: '1', limit: '20' });
const res = await fetch(`${BASE}/users/me/redemptions?${params}`, {
  headers: { Authorization: `Bearer ${token}` },
});
const { redemptions } = await res.json();
// redemptions: [{ id, rewardId, pointsCost, idempotencyKey, createdAt,
//                 reward: { id, name, description, imageUrl, category } }, ...]
7.4.7. POST /users/me/push-token

Stores (or clears, by sending null) the user's Expo push token. Validated with zod. Push token is required for send push notifications via Expo.

// Register after login:
await fetch(`${BASE}/users/me/push-token`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ pushToken: 'ExponentPushToken[xxxxxxxxxxxxxxxxx]' }),
});

// Clear on logout:
await fetch(`${BASE}/users/me/push-token`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ pushToken: null }),
});
// returns => { ok: true }
7.4.8. GET /rewards

Lists active rewards with stockRemaining > 0, ordered by pointsCost ascending.
Optional ?category=lifestyle|travel filter.

const res = await fetch(`${BASE}/rewards?category=lifestyle`, {
  headers: { Authorization: `Bearer ${token}` },
});
const { rewards } = await res.json() as {
  rewards: Array<{
    id: string;
    name: string;
    description: string;
    imageUrl: string | null;
    pointsCost: number;
    stockRemaining: number;
    isActive: boolean;
    category: string;
  }>;
};
7.4.9. POST /rewards/:id/redeem

Redeems a reward for the authenticated user.
Requires an X-Idempotency-Key header (≥ 8 chars). Replaying the same key for the same user/reward returns the original redemption instead of charging again.
After a fresh redemption, the API also fires a confirmation Expo push notification.

import { randomUUID } from 'node:crypto';

const rewardId = '<reward-id>';
const idempotencyKey = randomUUID();

const res = await fetch(`${BASE}/rewards/${rewardId}/redeem`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Idempotency-Key': idempotencyKey,
  },
});
// 201 =>
// {
//   redemption: { id, rewardId, pointsCost, idempotencyKey, createdAt,
//                 reward: { id, name, ... } },
//   balance: 16350
// }
if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`Redeem failed (${res.status}): ${error}`);
}
const { redemption, balance } = await res.json();

Replaying the same key returns 200 with alreadyExisted: true on the server side.

Error responses:

HTTPCodeMeaning
400X-Idempotency-Key missing or too short
402INSUFFICIENT_POINTSbalance < pointsCost
404REWARD_UNAVAILABLEreward not found or isActive = false
409OUT_OF_STOCKconcurrent redemption took the last unit
409IDEMPOTENCY_CONFLICTthe key was already used by a different user/reward

8. Possible Infrastructure Improvements

  1. Add rate limiting to, POST /auth/login with express-rate-limit.
  2. Add Husky and lint-staged, so before committing, it can run ESLint and Prettier on the changed files.
  3. Add CI pipeline like Github Actions CI, run, npm install && npm test && npm run lint for the API and APP directories.
  4. Constants module on the API and APP projects to hold static values like, POINTS_TO_USD, MIN_REFRESH_MS, MILESTONE_REASON, REFRESH_SKEW_MS, MIN_LOADER_MS, in one place instead of defining them in multiple instances.
  5. Validate the process.env with zod on API startup, to ensure the essential environment variables are defined, and the API refuses to start instead of crashing on the first request.
  6. Add biometric unlock. After the first login, allow the user to enable faceID or fingerprint for reauthentication without the need for the password.
  7. Add crash and error reporting with Sentry or Bugsnag.
  8. Extend unit testing scope. Create unit tests for login success and failure, balance calculation correctness, transactions pagination and idempotency conflict.
  9. Add enums to the API and APP project
  10. Add data function layer to hold database operations instead of directly in the endpoint route.
  11. Add refresh token rotation. Currently auth/refresh accepts an unexpired access token to generate another. If the token leaks, an attacker can keep refreshing it continuously. Maintain a short-lived access token and a long-lived refresh token. Verify the refresh token to allow generating a new access token. Store the access token only in the client side and the refresh token only in the database (clearing this will allow to terminate new access token generation).
  12. Store user balance in DB. Currently the user's balance is calculated via, SUM(point_ledger.delta). This will become slow and apply load on the API when the user reaches a large entry count like 100k. This can be eased by adding a column to the database, like, users.balance and keeping it up-to-date according to the ledger entries.
  13. Create a Dockerfile for the API project. This will allow to startup the database and API together.

9. Credits -

This project was built as a sample rewarding system with React Native and ExpressJS to showcase the skill set for SouyPay. The following was provided by SouyPay,

  1. Functional Requirements
  2. Tech stack
  3. High-fidelity Design

The project was developed using the best practices and guidance with the use of legitimate online documentation (docs), YouTube videos and AI tools like ChatGPT and Cursor.
For learning purposes, other developers' source codes were reviewed on sample applications they built and documented online.
Documentation and source code in this repository was developed by H.V.L.Hasanka. The app logo was designed by Freepik.


10. License -

Copyright (c) 2026 H.V.L.Hasanka
Licensed under MIT License