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:
- 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. - 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. - 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,Lockedwith an "Insufficient Points" overlay,Notify Mewhen sold out).
TappingClaim Nowopens a confirmation bottom sheet that walks throughconfirm => loading => success / errorstates. 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:
- User interacts -
- User engages with the app and the app captures the action. This is stored in the local store.
- 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.
- 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.
- 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.
- 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.
- 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 -
-
Languages
- TypeScript across mobile app, API, and seed/migration scripts
-
Mobile app — frameworks & runtime
- Expo (SDK 54) on top of React Native 0.81
- React 19 with React Hooks
- Expo Router — file-based navigation with
(auth)/(app)route groups
-
Mobile app — key dependencies
- Redux Toolkit +
react-redux—auth/ledger/rewards/uislices - Axios — REST client with request/response interceptors
react-hook-form— login form validationexpo-secure-store— JWT + expiry persistence (iOS Keychain / Android Keystore)expo-notifications+expo-device— push notification registration & handling@react-native-community/netinfo— offline detection (drives the banner & disabled write actions)expo-haptics— login / logout feedbackexpo-image+expo-blur— reward image caching and modal blur@expo/vector-icons(Ionicons)react-native-safe-area-context,react-native-reanimated,react-native-gesture-handler,react-native-screensbase-64— JWT payload decoding for client-side expiry checks
- Redux Toolkit +
-
API — frameworks & runtime
- Node with Express 5
- Prisma 7 +
@prisma/adapter-pg+pg
-
API — key dependencies
jsonwebtoken— sign / verify JWTsbcryptjs— password hashingzod— request body validationcors,dotenv— middleware & env loading- Expo Push API — fired via
fetch()from the API after a successful redemption
-
Database
- PostgreSQL 16 (run locally via Docker Compose)
- Tables:
users,point_ledger,rewards,redemptions
-
Dev tooling
tsx+nodemon— API hot reload- Jest +
ts-jest— backend unit tests (redemption service) - ESLint +
typescript-eslint+ Prettier - Visual Studio Code
4. Installation -
The repository contains three independent projects under Workspace/:
| Folder | What 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/, runnpm testto execute the Jest unit tests for the redemption service.
5. Demo Account Credentials
| Password | |
|---|---|
lucas@test.com | Testing2$ |
sam@test.com | Testing3$ |
william@test.com | Testing4$ |
- 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-storetogether 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.
- Email + password login access to the backend; the JWT is stored in
- 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, orSOLD OUT). - State-aware buttons:
Claim Now,Locked(with an "Insufficient Points" overlay), orNotify Mewhen sold out. - Tapping
Claim Nowopens a confirmation bottom sheet that walks throughconfirm => loading => success / errorflow. - 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").
- The device registers an Expo push token after login and the API stores it on
- 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/redeemrequires anX-Idempotency-Keyheader, replaying the same key returns the original redemption instead of charging twice.- Stock is decremented inside the same transaction, with a conditional
updateManythat 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.
- Atomic Prisma
- 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
ConfirmationModalandAlertModalcomponents 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 parallelrefreshMe + fetchLedgerafter redemption.
- Image caching via
- 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.
- ESLint +
- Accessibility -
- Uses
react-native-safe-area-contextto respect device notches and home indicators. - Large react native hit slops on icon buttons to increase the tappable area.
- Haptic feedback on auth events.
- Uses
- Observability -
- Failed background actions (e.g. clearing the push token on logout) are logged with
console.errorso they're visible in the Metro logs without breaking the user flow.
- Failed background actions (e.g. clearing the push token on logout) are logged with
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 exceptPOST /auth/loginandGET /health. - Content type: JSON request/response unless noted.
- Snippets are for Node.js 20+ / TypeScript using the built-in
fetch. ReplaceBASEandtokenwith 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 param | Default | Notes |
|---|---|---|
page | 1 | 1-based page index |
limit | 20 | Capped 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:
| HTTP | Code | Meaning |
|---|---|---|
400 | — | X-Idempotency-Key missing or too short |
402 | INSUFFICIENT_POINTS | balance < pointsCost |
404 | REWARD_UNAVAILABLE | reward not found or isActive = false |
409 | OUT_OF_STOCK | concurrent redemption took the last unit |
409 | IDEMPOTENCY_CONFLICT | the key was already used by a different user/reward |
8. Possible Infrastructure Improvements
- Add rate limiting to,
POST /auth/loginwithexpress-rate-limit. - Add Husky and lint-staged, so before committing, it can run ESLint and Prettier on the changed files.
- Add CI pipeline like Github Actions CI, run,
npm install && npm test && npm run lintfor the API and APP directories. - 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. - 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.
- Add biometric unlock. After the first login, allow the user to enable faceID or fingerprint for reauthentication without the need for the password.
- Add crash and error reporting with Sentry or Bugsnag.
- Extend unit testing scope. Create unit tests for login success and failure, balance calculation correctness, transactions pagination and idempotency conflict.
- Add enums to the API and APP project
- Add data function layer to hold database operations instead of directly in the endpoint route.
- Add refresh token rotation. Currently
auth/refreshaccepts 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). - 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.balanceand keeping it up-to-date according to the ledger entries. - 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,
- Functional Requirements
- Tech stack
- 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