diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..bdf3dfe
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# ==========================================
+# payNEXT Environment Variables Template
+# ==========================================
+# COPY THIS FILE AND RENAME IT TO .env
+# Command: cp .env.example .env
+
+# Remote Shared Database
+DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/paynext_db?sslmode=require
+
+# Optional split variables if your backend uses them
+DB_HOST=your-db-host.supabase.com
+DB_PORT=5432
+DB_USER=postgres
+DB_PASSWORD=change_me
+DB_NAME=postgres
+DB_SSL=true
+
+# API Secrets
+JWT_SECRET=choose_your_own_local_jwt_secret
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 03a2e3f..d6cb8a0 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -2,14 +2,9 @@ name: payNEXT CI/CD
on:
push:
- branches:
- - devs
- - release
- - main
+ branches: [devs, release, main]
pull_request:
- branches:
- - release
- - main
+ branches: [release, main]
workflow_dispatch:
concurrency:
@@ -20,43 +15,38 @@ jobs:
validate:
name: Validate Project
runs-on: ubuntu-latest
-
steps:
- - name: Checkout code
- uses: actions/checkout@v4
+ - uses: actions/checkout@v4
- name: Check required files
run: |
- echo "Checking required payNEXT files..."
test -f docker-compose.yml
test -f web/Dockerfile
test -f web/nginx.conf
test -f web/public/index.html
- echo "All required files exist."
+ test -f db/init.sql
- name: Validate Docker Compose file
- run: |
- docker compose -f docker-compose.yml config > /dev/null
- echo "Docker Compose file is valid."
+ run: docker compose -f docker-compose.yml config > /dev/null
+
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
-
needs: validate
-
if: github.ref == 'refs/heads/main'
-
environment:
name: production
-
- env:
+ env:
SERVER_IP: ${{ secrets.SERVER_IP }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
-
+ DB_HOST: ${{ secrets.DB_HOST }}
+ DB_USER: ${{ secrets.DB_USER }}
+ DB_NAME: ${{ secrets.DB_NAME }}
+ DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
steps:
- - name: Checkout code
- uses: actions/checkout@v4
+ - uses: actions/checkout@v4
- name: Setup SSH
run: |
@@ -67,33 +57,36 @@ jobs:
ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- name: Create deployment folder on server
- run: |
- ssh "$SSH_USER@$SERVER_IP" "mkdir -p /opt/paynext"
+ run: ssh "$SSH_USER@$SERVER_IP" "mkdir -p /opt/paynext"
- - name: Package application files
+ - name: Build server .env on the runner
run: |
- tar --exclude='./.git' --exclude='./.github' -czf paynext.tgz docker-compose.yml web
-
- - name: Copy package to server
+ {
+ echo "DB_HOST=$DB_HOST"
+ echo "DB_PORT=5432"
+ echo "DB_USER=$DB_USER"
+ echo "DB_PASSWORD=$DB_PASSWORD"
+ echo "DB_NAME=$DB_NAME"
+ echo "DB_SSL=true"
+ echo "JWT_SECRET=$JWT_SECRET"
+ } > server.env
+
+ - name: Copy app and .env to server
run: |
+ tar --exclude='./.git' --exclude='./.github' -czf paynext.tgz docker-compose.yml web db
scp paynext.tgz "$SSH_USER@$SERVER_IP":/tmp/paynext.tgz
+ scp server.env "$SSH_USER@$SERVER_IP":/opt/paynext/.env
+ rm -f server.env paynext.tgz
- name: Deploy using Docker
run: |
ssh "$SSH_USER@$SERVER_IP" << 'EOF'
set -e
-
cd /opt/paynext
-
docker compose down --remove-orphans || true
-
- rm -rf web docker-compose.yml
-
+ rm -rf web db docker-compose.yml
tar -xzf /tmp/paynext.tgz
-
- rm /tmp/paynext.tgz
-
+ rm -f /tmp/paynext.tgz
docker compose up -d --build
-
docker image prune -f
EOF
diff --git a/.gitignore b/.gitignore
index f7227db..edc5428 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,10 @@ node_modules/
*.log
.DS_Store
dist/
-build/
\ No newline at end of file
+build/get-docker.sh
+
+# Block all .env files everywhere
+**/.env
+.env
+.agents
+skills-lock.json
\ No newline at end of file
diff --git a/7.View History_8.Request Money_9.Payment Gateway.json b/7.View History_8.Request Money_9.Payment Gateway.json
new file mode 100644
index 0000000..2f317d1
--- /dev/null
+++ b/7.View History_8.Request Money_9.Payment Gateway.json
@@ -0,0 +1,191 @@
+{
+ "info": {
+ "name": "PayNEXT - Assignment 3 (Shihab)",
+ "_postman_id": "60b57ecf-2ff8-4463-ac9d-ff5e4765d135",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "variable": [
+ {
+ "key": "token",
+ "value": ""
+ }
+ ],
+ "item": [
+ {
+ "name": "0. Authentication",
+ "item": [
+ {
+ "name": "REGISTER User (POST)",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "var jsonData = pm.response.json();",
+ "if(jsonData.data && jsonData.data.token) {",
+ " pm.collectionVariables.set(\"token\", jsonData.data.token);",
+ "}"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"email\": \"test1@example.com\",\n \"password\": \"123456\",\n \"fullName\": \"Test User\",\n \"phone\": \"01712345678\"\n}"
+ },
+ "url": {
+ "raw": "http://localhost/api/v1/auth/register",
+ "host": [
+ "localhost"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "auth",
+ "register"
+ ]
+ }
+ }
+ },
+ {
+ "name": "LOGIN User (POST)",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "var jsonData = pm.response.json();",
+ "if(jsonData.data && jsonData.data.token) {",
+ " pm.collectionVariables.set(\"token\", jsonData.data.token);",
+ "}"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"email\": \"test1@example.com\",\n \"password\": \"123456\"\n}"
+ },
+ "url": {
+ "raw": "http://localhost/api/v1/auth/login",
+ "host": [
+ "localhost"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "auth",
+ "login"
+ ]
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "1. Transaction History",
+ "item": [
+ {
+ "name": "GET History",
+ "request": {
+ "method": "GET",
+ "header": [
+ {
+ "key": "Authorization",
+ "value": "Bearer {{token}}"
+ }
+ ],
+ "url": {
+ "raw": "http://localhost/api/v1/history",
+ "host": [
+ "localhost"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "history"
+ ]
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "2. Merchant Payment Gateway (CRUD)",
+ "item": [
+ {
+ "name": "CREATE Merchant (POST)",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{token}}"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"name\": \"Daraz Online\"\n}"
+ },
+ "url": {
+ "raw": "http://localhost/api/v1/merchants",
+ "host": [
+ "localhost"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "merchants"
+ ]
+ }
+ }
+ },
+ {
+ "name": "READ All Merchants (GET)",
+ "request": {
+ "method": "GET",
+ "header": [
+ {
+ "key": "Authorization",
+ "value": "Bearer {{token}}"
+ }
+ ],
+ "url": {
+ "raw": "http://localhost/api/v1/merchants",
+ "host": [
+ "localhost"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "merchants"
+ ]
+ }
+ }
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 138414a..c3cc33c 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,12 @@
+
+
+

+
# payNEXT
-payNEXT is a digital wallet platform.
+**payNEXT is a digital wallet platform.**
+
+
## Local Development
@@ -21,3 +27,5 @@ When changes are pushed to the `main` branch, GitHub Actions will:
```text
http://104.214.170.158
```
+
+payNEXT - Powering Your Next Move. Next Generation Digital Wallet.
diff --git a/assets/payNEXT_logo.png b/assets/payNEXT_logo.png
new file mode 100644
index 0000000..e713d5c
Binary files /dev/null and b/assets/payNEXT_logo.png differ
diff --git a/web/.dockerignore b/client/.dockerignore
similarity index 100%
rename from web/.dockerignore
rename to client/.dockerignore
diff --git a/web/Dockerfile b/client/Dockerfile
similarity index 100%
rename from web/Dockerfile
rename to client/Dockerfile
diff --git a/web/nginx.conf b/client/nginx.conf
similarity index 58%
rename from web/nginx.conf
rename to client/nginx.conf
index adcbd85..4d9d082 100644
--- a/web/nginx.conf
+++ b/client/nginx.conf
@@ -11,6 +11,13 @@ server {
add_header Content-Type text/plain;
}
+ location /api/ {
+ proxy_pass http://api:3000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/web/public/index.html b/client/public/index.html
similarity index 100%
rename from web/public/index.html
rename to client/public/index.html
diff --git a/db/db_schema.svg b/db/db_schema.svg
new file mode 100644
index 0000000..3eee0cd
--- /dev/null
+++ b/db/db_schema.svg
@@ -0,0 +1,20 @@
+
\ No newline at end of file
diff --git a/db/init.sql b/db/init.sql
new file mode 100644
index 0000000..80c694e
--- /dev/null
+++ b/db/init.sql
@@ -0,0 +1,120 @@
+-- Enable UUID generation
+CREATE EXTENSION IF NOT EXISTS "pgcrypto";
+
+-- Custom Data Types (Enums)
+CREATE TYPE user_status AS ENUM ('active', 'suspended', 'deleted');
+CREATE TYPE wallet_status AS ENUM ('active', 'frozen', 'closed');
+CREATE TYPE transaction_type AS ENUM ('top_up', 'withdrawal', 'transfer_in', 'transfer_out', 'request_in', 'request_out');
+CREATE TYPE transaction_status AS ENUM ('pending', 'completed', 'failed', 'cancelled');
+CREATE TYPE request_status AS ENUM ('pending', 'paid', 'declined', 'cancelled');
+
+-- 1. USERS TABLE
+CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email VARCHAR(255) UNIQUE NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ full_name VARCHAR(100) NOT NULL,
+ phone VARCHAR(20),
+ status user_status DEFAULT 'active',
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 2. WALLETS TABLE
+CREATE TABLE wallets (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ wallet_number VARCHAR(50) UNIQUE NOT NULL,
+ currency VARCHAR(3) DEFAULT 'USD',
+ balance NUMERIC(15, 2) DEFAULT 0.00 CHECK (balance >= 0),
+ status wallet_status DEFAULT 'active',
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 3. MASTER LEDGER (TRANSACTIONS)
+CREATE TABLE transactions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE CASCADE,
+ type transaction_type NOT NULL,
+ amount NUMERIC(15, 2) NOT NULL CHECK (amount > 0),
+ balance_after NUMERIC(15, 2) NOT NULL,
+ reference_id UUID, -- Links to transfers, requests, or gateway logs
+ description TEXT,
+ status transaction_status DEFAULT 'completed',
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 4. INTERNAL P2P TRANSFERS
+CREATE TABLE transfers (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ from_wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE CASCADE,
+ to_wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE CASCADE,
+ amount NUMERIC(15, 2) NOT NULL CHECK (amount > 0),
+ currency VARCHAR(3) NOT NULL,
+ status transaction_status DEFAULT 'completed',
+ note TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 5. PAYMENT REQUESTS
+CREATE TABLE payment_requests (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ requester_wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE CASCADE,
+ payer_wallet_id UUID REFERENCES wallets(id) ON DELETE SET NULL,
+ payer_email VARCHAR(255), -- Used if requesting from an unregistered email
+ amount NUMERIC(15, 2) NOT NULL CHECK (amount > 0),
+ currency VARCHAR(3) NOT NULL,
+ status request_status DEFAULT 'pending',
+ note TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 6. PAYMENT GATEWAY LOGS (Stripe, PayPal, Mock Bank)
+CREATE TABLE gateway_transactions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE CASCADE,
+ provider VARCHAR(50) NOT NULL, -- 'stripe', 'razorpay', 'mock'
+ provider_transaction_id VARCHAR(255),
+ type VARCHAR(20) NOT NULL CHECK (type IN ('top_up', 'withdrawal')),
+ amount NUMERIC(15, 2) NOT NULL CHECK (amount > 0),
+ currency VARCHAR(3) NOT NULL,
+ status transaction_status DEFAULT 'pending',
+ metadata JSONB, -- Stores raw JSON response from the payment gateway
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Indexes for fast querying
+CREATE INDEX idx_wallets_user_id ON wallets(user_id);
+CREATE INDEX idx_transactions_wallet_id ON transactions(wallet_id);
+CREATE INDEX idx_transactions_type ON transactions(type);
+CREATE INDEX idx_transfers_from_wallet ON transfers(from_wallet_id);
+CREATE INDEX idx_transfers_to_wallet ON transfers(to_wallet_id);
+CREATE INDEX idx_payment_requests_requester ON payment_requests(requester_wallet_id);
+CREATE INDEX idx_gateway_wallet ON gateway_transactions(wallet_id);
+
+-- 7. MERCHANTS
+CREATE TABLE merchants (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ api_key VARCHAR(255) UNIQUE NOT NULL,
+ status user_status DEFAULT 'active',
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 8. MERCHANT PAYMENTS
+CREATE TABLE merchant_payments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ merchant_id UUID NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
+ wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE CASCADE,
+ amount NUMERIC(15, 2) NOT NULL CHECK (amount > 0),
+ currency VARCHAR(3) NOT NULL,
+ status transaction_status DEFAULT 'completed',
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_merchant_payments_merchant ON merchant_payments(merchant_id);
+CREATE INDEX idx_merchant_payments_wallet ON merchant_payments(wallet_id);
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index de02b67..4b5a759 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,8 +5,26 @@ services:
restart: unless-stopped
ports:
- "80:80"
+ depends_on:
+ - api
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
interval: 30s
timeout: 5s
retries: 3
+
+ api:
+ build: ./api
+ container_name: paynext-api
+ restart: unless-stopped
+ env_file:
+ - .env
+ expose:
+ - "3000"
+
+ adminer:
+ image: adminer
+ container_name: paynext-adminer
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
diff --git a/server/.dockerignore b/server/.dockerignore
new file mode 100644
index 0000000..8e480e4
--- /dev/null
+++ b/server/.dockerignore
@@ -0,0 +1,3 @@
+node_modules
+npm-debug.log
+.env
\ No newline at end of file
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000..3ef35fc
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,19 @@
+# ==========================================
+# payNEXT Environment Variables Template
+# ==========================================
+# COPY THIS FILE AND RENAME IT TO .env
+# Command: cp .env.example .env
+
+# Remote Shared Database
+DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/paynext_db?sslmode=require
+
+# Optional split variables if your backend uses them
+DB_HOST=your-db-host.supabase.com
+DB_PORT=3000
+DB_USER=postgres
+DB_PASSWORD=change_me
+DB_NAME=postgres
+DB_SSL=true
+
+# API Secrets
+JWT_SECRET=choose_your_own_local_jwt_secret
\ No newline at end of file
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..542be05
--- /dev/null
+++ b/server/Dockerfile
@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /app
+COPY package*.json ./
+RUN npm install --omit=dev
+COPY . .
+EXPOSE 3000
+CMD ["node", "src/server.js"]
\ No newline at end of file
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 0000000..da6094b
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,1114 @@
+{
+ "name": "paynext-api",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "paynext-api",
+ "version": "1.0.0",
+ "dependencies": {
+ "bcryptjs": "^2.4.3",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "jsonwebtoken": "^9.0.2",
+ "pg": "^8.12.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/bcryptjs": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
+ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..41c82e1
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "paynext-api",
+ "version": "1.0.0",
+ "main": "src/server.js",
+ "scripts": {
+ "start": "node src/server.js",
+ "dev": "node --watch src/server.js"
+ },
+ "dependencies": {
+ "bcryptjs": "^2.4.3",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "jsonwebtoken": "^9.0.2",
+ "pg": "^8.12.0"
+ }
+}
diff --git a/server/src/app.js b/server/src/app.js
new file mode 100644
index 0000000..daa1419
--- /dev/null
+++ b/server/src/app.js
@@ -0,0 +1,13 @@
+const express = require("express");
+const routes = require("./routes");
+const errorHandler = require("./middleware/errorHandler");
+
+const app = express();
+app.use(express.json());
+app.use("/api/v1", routes);
+app.use((req, res) =>
+ res.status(404).json({ success: false, error: "Route not found" }),
+);
+app.use(errorHandler);
+
+module.exports = app;
diff --git a/server/src/config/db.js b/server/src/config/db.js
new file mode 100644
index 0000000..c9171df
--- /dev/null
+++ b/server/src/config/db.js
@@ -0,0 +1,18 @@
+require("dotenv").config();
+const { Pool } = require("pg");
+
+const isNeon = (process.env.DB_HOST || "").endsWith(".neon.tech");
+
+const pool = new Pool({
+ host: process.env.DB_HOST || "localhost",
+ port: Number(process.env.DB_PORT || 5432),
+ user: process.env.DB_USER || "paynext",
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_NAME || "paynext_db",
+ ssl:
+ process.env.DB_SSL === "true" || isNeon
+ ? { rejectUnauthorized: false }
+ : false,
+});
+
+module.exports = pool;
diff --git a/server/src/controllers/authController.js b/server/src/controllers/authController.js
new file mode 100644
index 0000000..4aa75e9
--- /dev/null
+++ b/server/src/controllers/authController.js
@@ -0,0 +1,70 @@
+const bcrypt = require("bcryptjs");
+const userModel = require("../models/userModel");
+const walletModel = require("../models/walletModel");
+const { signToken } = require("../middleware/auth");
+
+const register = async (req, res, next) => {
+ try {
+ const { email, password, fullName, phone } = req.body;
+ if (!email || !password || !fullName)
+ return res.status(400).json({ success: false, error: "email, password and fullName are required" });
+ if (password.length < 6)
+ return res.status(400).json({ success: false, error: "Password must be at least 6 characters" });
+
+ const existing = await userModel.findByEmail(email.toLowerCase());
+ if (existing)
+ return res.status(409).json({ success: false, error: "Email already registered" });
+
+ const passwordHash = await bcrypt.hash(password, 10);
+ const user = await userModel.create({
+ email: email.toLowerCase(),
+ passwordHash,
+ fullName,
+ phone,
+ });
+
+ const wallet = await walletModel.create(user.id, "USD");
+ const token = signToken(user);
+
+ res.status(201).json({
+ success: true,
+ message: "Registered successfully",
+ data: { user, wallet, token },
+ });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const login = async (req, res, next) => {
+ try {
+ const { email, password } = req.body;
+ const user = await userModel.findByEmail((email || "").toLowerCase());
+ if (!user)
+ return res.status(401).json({ success: false, error: "Invalid credentials" });
+
+ const ok = await bcrypt.compare(password || "", user.password_hash);
+ if (!ok)
+ return res.status(401).json({ success: false, error: "Invalid credentials" });
+
+ const token = signToken(user);
+ const { password_hash, ...safeUser } = user;
+
+ res.json({ success: true, message: "Login successful", data: { user: safeUser, token } });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const me = async (req, res, next) => {
+ try {
+ const user = await userModel.findById(req.user.sub);
+ if (!user)
+ return res.status(404).json({ success: false, error: "User not found" });
+ res.json({ success: true, data: user });
+ } catch (e) {
+ next(e);
+ }
+};
+
+module.exports = { register, login, me };
diff --git a/server/src/controllers/merchantController.js b/server/src/controllers/merchantController.js
new file mode 100644
index 0000000..f43df2b
--- /dev/null
+++ b/server/src/controllers/merchantController.js
@@ -0,0 +1,107 @@
+const merchantModel = require("../models/merchantModel");
+const walletModel = require("../models/walletModel");
+const { ApiError, withTransaction, lockWallet, setBalance, logTx } = require("../services/moneyService");
+
+const createMerchant = async (req, res, next) => {
+ try {
+ if (!req.body.name) throw new ApiError("Merchant name is required", 400);
+ const merchant = await merchantModel.create({ name: req.body.name });
+ res.status(201).json({ success: true, message: "Merchant created", data: merchant });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const getAllMerchants = async (req, res, next) => {
+ try {
+ const merchants = await merchantModel.findAll();
+ res.json({ success: true, count: merchants.length, data: merchants });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const getMerchantById = async (req, res, next) => {
+ try {
+ const merchant = await merchantModel.findById(req.params.id);
+ if (!merchant) throw new ApiError("Merchant not found", 404);
+ res.json({ success: true, data: merchant });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const updateMerchant = async (req, res, next) => {
+ try {
+ const { name, status } = req.body;
+ if (!name || !status) throw new ApiError("Name and status are required", 400);
+ const merchant = await merchantModel.update(req.params.id, name, status);
+ if (!merchant) throw new ApiError("Merchant not found", 404);
+ res.json({ success: true, message: "Merchant updated", data: merchant });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const deleteMerchant = async (req, res, next) => {
+ try {
+ const merchant = await merchantModel.remove(req.params.id);
+ if (!merchant) throw new ApiError("Merchant not found", 404);
+ res.json({ success: true, message: "Merchant deleted", data: merchant });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const payMerchant = async (req, res, next) => {
+ try {
+ const { merchantId, amount } = req.body;
+ if (!merchantId || !amount || amount <= 0) {
+ throw new ApiError("Valid merchantId and amount > 0 are required", 400);
+ }
+
+ const data = await withTransaction(async (client) => {
+ // 1. Verify merchant exists
+ const merchant = await merchantModel.findById(merchantId);
+ if (!merchant) throw new ApiError("Merchant not found", 404);
+ if (merchant.status !== 'active') throw new ApiError("Merchant is not active", 400);
+
+ // 2. Lock the user's wallet
+ const wallets = await walletModel.listByUser(req.user.sub);
+ if (!wallets || wallets.length === 0) throw new ApiError("No wallet found", 404);
+
+ const userWallet = await lockWallet(client, wallets[0].id);
+ const paymentAmount = Number(amount);
+
+ if (Number(userWallet.balance) < paymentAmount) {
+ throw new ApiError("Insufficient balance to pay merchant", 400);
+ }
+
+ // 3. Deduct balance
+ const newBalance = Number(userWallet.balance) - paymentAmount;
+ await setBalance(client, userWallet, newBalance);
+
+ // 4. Log in master ledger
+ await logTx(client, {
+ walletId: userWallet.id,
+ type: 'transfer_out',
+ amount: paymentAmount,
+ balanceAfter: newBalance,
+ description: `Payment to merchant: ${merchant.name}`
+ });
+
+ // 5. Log in merchant payments table
+ const paymentLog = await merchantModel.logPayment(
+ client, merchant.id, userWallet.id, paymentAmount, userWallet.currency
+ );
+
+ return { paymentLog, newBalance };
+ });
+
+ res.json({ success: true, message: "Payment successful", data });
+ } catch (e) {
+ next(e);
+ }
+};
+
+module.exports = { createMerchant, getAllMerchants, getMerchantById, updateMerchant, deleteMerchant, payMerchant };
diff --git a/server/src/controllers/moneyController.js b/server/src/controllers/moneyController.js
new file mode 100644
index 0000000..5a2df55
--- /dev/null
+++ b/server/src/controllers/moneyController.js
@@ -0,0 +1,154 @@
+const {
+ ApiError,
+ withTransaction,
+ lockWallet,
+ setBalance,
+ logTx,
+ gatewayLog,
+} = require("../services/moneyService");
+
+const topUp = async (req, res, next) => {
+ try {
+ const amount = Number(req.body.amount);
+ if (!amount || amount <= 0) throw new ApiError("amount must be greater than 0", 400);
+
+ const data = await withTransaction(async (client) => {
+ const wallet = await lockWallet(client, req.params.id);
+ if (!wallet) throw new ApiError("Wallet not found", 404);
+ if (wallet.user_id !== req.user.sub) throw new ApiError("Not your wallet", 403);
+ if (wallet.status !== "active") throw new ApiError("Wallet is not active", 400);
+
+ const provider = req.body.provider || "mock";
+ const gw = await gatewayLog(client, {
+ walletId: wallet.id,
+ provider,
+ providerRef: "MOCK-" + Date.now(),
+ type: "top_up",
+ amount,
+ currency: wallet.currency,
+ status: "completed",
+ });
+
+ const newBalance = Number(wallet.balance) + amount;
+ await setBalance(client, wallet, newBalance);
+ const tx = await logTx(client, {
+ walletId: wallet.id,
+ type: "top_up",
+ amount,
+ balanceAfter: newBalance,
+ referenceId: gw.id,
+ description: req.body.description || `Top-up via ${provider}`,
+ });
+
+ return { newBalance: newBalance.toFixed(2), transaction: tx, gateway: gw };
+ });
+
+ res.status(201).json({ success: true, message: "Top-up successful", data });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const withdraw = async (req, res, next) => {
+ try {
+ const amount = Number(req.body.amount);
+ if (!amount || amount <= 0) throw new ApiError("amount must be greater than 0", 400);
+
+ const data = await withTransaction(async (client) => {
+ const wallet = await lockWallet(client, req.params.id);
+ if (!wallet) throw new ApiError("Wallet not found", 404);
+ if (wallet.user_id !== req.user.sub) throw new ApiError("Not your wallet", 403);
+ if (wallet.status !== "active") throw new ApiError("Wallet is not active", 400);
+ if (Number(wallet.balance) < amount) throw new ApiError("Insufficient balance", 400);
+
+ const provider = req.body.provider || "mock";
+ const gw = await gatewayLog(client, {
+ walletId: wallet.id,
+ provider,
+ providerRef: "MOCK-" + Date.now(),
+ type: "withdrawal",
+ amount,
+ currency: wallet.currency,
+ status: "completed",
+ });
+
+ const newBalance = Number(wallet.balance) - amount;
+ await setBalance(client, wallet, newBalance);
+ const tx = await logTx(client, {
+ walletId: wallet.id,
+ type: "withdrawal",
+ amount,
+ balanceAfter: newBalance,
+ referenceId: gw.id,
+ description: req.body.description || `Withdrawal via ${provider}`,
+ });
+
+ return { newBalance: newBalance.toFixed(2), transaction: tx, gateway: gw };
+ });
+
+ res.status(201).json({ success: true, message: "Withdrawal successful", data });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const sendMoney = async (req, res, next) => {
+ try {
+ const amount = Number(req.body.amount);
+ const { fromWalletId, toWalletNumber, note } = req.body;
+ if (!amount || amount <= 0) throw new ApiError("amount must be greater than 0", 400);
+ if (!fromWalletId || !toWalletNumber)
+ throw new ApiError("fromWalletId and toWalletNumber are required", 400);
+
+ const data = await withTransaction(async (client) => {
+ const from = await lockWallet(client, fromWalletId);
+ if (!from) throw new ApiError("Sender wallet not found", 404);
+ if (from.user_id !== req.user.sub) throw new ApiError("Not your wallet", 403);
+
+ const toRes = await client.query(
+ "SELECT * FROM wallets WHERE wallet_number = $1",
+ [toWalletNumber]
+ );
+ const to = toRes.rows[0];
+ if (!to) throw new ApiError("Receiver wallet not found", 404);
+ if (to.id === from.id) throw new ApiError("Cannot send to the same wallet", 400);
+ if (from.status !== "active" || to.status !== "active")
+ throw new ApiError("Wallet is not active", 400);
+ if (from.currency !== to.currency) throw new ApiError("Currency mismatch", 400);
+ if (Number(from.balance) < amount) throw new ApiError("Insufficient balance", 400);
+
+ const lockedTo = await lockWallet(client, to.id);
+
+ const tr = await client.query(
+ `INSERT INTO transfers (from_wallet_id, to_wallet_id, amount, currency, note)
+ VALUES ($1, $2, $3, $4, $5) RETURNING *`,
+ [from.id, to.id, amount, from.currency, note || null]
+ );
+ const transfer = tr.rows[0];
+
+ const fromBalance = Number(from.balance) - amount;
+ const toBalance = Number(lockedTo.balance) + amount;
+ await setBalance(client, from, fromBalance);
+ await setBalance(client, lockedTo, toBalance);
+
+ await logTx(client, {
+ walletId: from.id, type: "transfer_out", amount,
+ balanceAfter: fromBalance, referenceId: transfer.id,
+ description: note || `Sent to ${to.wallet_number}`,
+ });
+ await logTx(client, {
+ walletId: to.id, type: "transfer_in", amount,
+ balanceAfter: toBalance, referenceId: transfer.id,
+ description: note || `Received from ${from.wallet_number}`,
+ });
+
+ return { transfer, fromBalance: fromBalance.toFixed(2), toBalance: toBalance.toFixed(2) };
+ });
+
+ res.status(201).json({ success: true, message: "Transfer successful", data });
+ } catch (e) {
+ next(e);
+ }
+};
+
+module.exports = { topUp, withdraw, sendMoney };
diff --git a/server/src/controllers/requestController.js b/server/src/controllers/requestController.js
new file mode 100644
index 0000000..65ca93a
--- /dev/null
+++ b/server/src/controllers/requestController.js
@@ -0,0 +1,169 @@
+const walletModel = require("../models/walletModel");
+const paymentRequestModel = require("../models/paymentRequestModel");
+const {
+ ApiError,
+ withTransaction,
+ lockWallet,
+ setBalance,
+ logTx,
+} = require("../services/moneyService");
+
+const createRequest = async (req, res, next) => {
+ try {
+ const amount = Number(req.body.amount);
+ const { requesterWalletId, payerEmail, note } = req.body;
+ if (!amount || amount <= 0)
+ throw new ApiError("amount must be greater than 0", 400);
+ if (!requesterWalletId || !payerEmail)
+ throw new ApiError("requesterWalletId and payerEmail are required", 400);
+
+ const wallet = await walletModel.findById(requesterWalletId);
+ if (!wallet) throw new ApiError("Wallet not found", 404);
+ if (wallet.user_id !== req.user.sub)
+ throw new ApiError("Not your wallet", 403);
+
+ const request = await paymentRequestModel.create({
+ requesterWalletId: wallet.id,
+ payerEmail: payerEmail.toLowerCase(),
+ amount,
+ currency: wallet.currency,
+ note: note || null,
+ });
+
+ res
+ .status(201)
+ .json({ success: true, message: "Payment request sent", data: request });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const myRequests = async (req, res, next) => {
+ try {
+ const rows = await paymentRequestModel.listForUser(
+ req.user.sub,
+ req.user.email,
+ );
+ res.json({ success: true, count: rows.length, data: rows });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const approveRequest = async (req, res, next) => {
+ try {
+ const { fromWalletId } = req.body;
+ if (!fromWalletId) throw new ApiError("fromWalletId is required", 400);
+
+ const data = await withTransaction(async (client) => {
+ const request = await paymentRequestModel.findByIdForUpdate(
+ client,
+ req.params.id,
+ );
+ if (!request) throw new ApiError("Payment request not found", 404);
+ if (request.status !== "pending")
+ throw new ApiError("Request is not pending", 400);
+ if (request.payer_email !== req.user.email)
+ throw new ApiError("Only the payer can approve this request", 403);
+
+ const payerWallet = await lockWallet(client, fromWalletId);
+ if (!payerWallet) throw new ApiError("Payer wallet not found", 404);
+ if (payerWallet.user_id !== req.user.sub)
+ throw new ApiError("Not your wallet", 403);
+
+ const amount = Number(request.amount);
+ if (Number(payerWallet.balance) < amount)
+ throw new ApiError("Insufficient balance", 400);
+
+ const requesterWallet = await lockWallet(
+ client,
+ request.requester_wallet_id,
+ );
+
+ const payerBalance = Number(payerWallet.balance) - amount;
+ const requesterBalance = Number(requesterWallet.balance) + amount;
+ await setBalance(client, payerWallet, payerBalance);
+ await setBalance(client, requesterWallet, requesterBalance);
+
+ await logTx(client, {
+ walletId: payerWallet.id,
+ type: "request_out",
+ amount,
+ balanceAfter: payerBalance,
+ referenceId: request.id,
+ description: request.note || "Paid request",
+ });
+ await logTx(client, {
+ walletId: requesterWallet.id,
+ type: "request_in",
+ amount,
+ balanceAfter: requesterBalance,
+ referenceId: request.id,
+ description: request.note || "Request paid",
+ });
+
+ const paid = await paymentRequestModel.markPaid(
+ client,
+ request.id,
+ payerWallet.id,
+ );
+ return { paymentRequest: paid };
+ });
+
+ res.json({ success: true, message: "Payment request paid", data });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const declineRequest = async (req, res, next) => {
+ try {
+ const declined = await paymentRequestModel.decline(
+ req.params.id,
+ req.user.email,
+ );
+ if (!declined)
+ return res
+ .status(404)
+ .json({ success: false, error: "Pending request not found for you" });
+ res.json({ success: true, message: "Request declined", data: declined });
+ } catch (e) {
+ next(e);
+ }
+};
+const getRequestById = async (req, res, next) => {
+ try {
+ const request = await paymentRequestModel.findById(req.params.id);
+ if (!request) throw new ApiError("Payment request not found", 404);
+ res.json({ success: true, data: request });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const updateRequest = async (req, res, next) => {
+ try {
+ const { amount, note } = req.body;
+ if (!amount || amount <= 0) throw new ApiError("Amount must be > 0", 400);
+
+ const updated = await paymentRequestModel.update(req.params.id, amount, note, req.user.email);
+ if (!updated) throw new ApiError("Pending request not found for you", 404);
+
+ res.json({ success: true, message: "Payment request updated", data: updated });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const deleteRequest = async (req, res, next) => {
+ try {
+ const deleted = await paymentRequestModel.remove(req.params.id, req.user.email);
+ if (!deleted) throw new ApiError("Pending request not found for you", 404);
+
+ res.json({ success: true, message: "Payment request deleted", data: deleted });
+ } catch (e) {
+ next(e);
+ }
+};
+
+module.exports = { createRequest, myRequests, approveRequest, declineRequest, getRequestById, updateRequest, deleteRequest };
diff --git a/server/src/controllers/transactionController.js b/server/src/controllers/transactionController.js
new file mode 100644
index 0000000..7689419
--- /dev/null
+++ b/server/src/controllers/transactionController.js
@@ -0,0 +1,23 @@
+const transactionModel = require("../models/transactionModel");
+const walletModel = require("../models/walletModel");
+
+const getHistory = async (req, res, next) => {
+ try {
+ const { limit = 10, offset = 0 } = req.query;
+
+ // Find the user's wallet to get transactions
+ const wallets = await walletModel.listByUser(req.user.sub);
+ if (!wallets || wallets.length === 0) {
+ return res.status(404).json({ success: false, error: "Wallet not found for this user" });
+ }
+ const wallet = wallets[0]; // Get the primary wallet
+
+ const transactions = await transactionModel.listByWallet(wallet.id, limit, offset);
+
+ res.json({ success: true, count: transactions.length, data: transactions });
+ } catch (e) {
+ next(e);
+ }
+};
+
+module.exports = { getHistory };
diff --git a/server/src/controllers/walletController.js b/server/src/controllers/walletController.js
new file mode 100644
index 0000000..9f56287
--- /dev/null
+++ b/server/src/controllers/walletController.js
@@ -0,0 +1,84 @@
+const walletModel = require("../models/walletModel");
+const transactionModel = require("../models/transactionModel");
+
+async function loadOwnWallet(req) {
+ const wallet = await walletModel.findById(req.params.id);
+ if (!wallet) return { error: "Wallet not found", status: 404 };
+ if (wallet.user_id !== req.user.sub)
+ return { error: "Not your wallet", status: 403 };
+ return { wallet };
+}
+
+const createWallet = async (req, res, next) => {
+ try {
+ const wallet = await walletModel.create(
+ req.user.sub,
+ req.body.currency || "USD",
+ );
+ res
+ .status(201)
+ .json({ success: true, message: "Wallet created", data: wallet });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const myWallets = async (req, res, next) => {
+ try {
+ const wallets = await walletModel.listByUser(req.user.sub);
+ res.json({ success: true, count: wallets.length, data: wallets });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const getWallet = async (req, res, next) => {
+ try {
+ const { wallet, error, status } = await loadOwnWallet(req);
+ if (error) return res.status(status).json({ success: false, error });
+ res.json({ success: true, data: wallet });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const getBalance = async (req, res, next) => {
+ try {
+ const { wallet, error, status } = await loadOwnWallet(req);
+ if (error) return res.status(status).json({ success: false, error });
+ res.json({
+ success: true,
+ data: {
+ walletId: wallet.id,
+ walletNumber: wallet.wallet_number,
+ currency: wallet.currency,
+ balance: wallet.balance,
+ },
+ });
+ } catch (e) {
+ next(e);
+ }
+};
+
+const getTransactions = async (req, res, next) => {
+ try {
+ const { wallet, error, status } = await loadOwnWallet(req);
+ if (error) return res.status(status).json({ success: false, error });
+
+ const limit = Math.min(Number(req.query.limit) || 20, 100);
+ const offset = Number(req.query.offset) || 0;
+
+ const rows = await transactionModel.listByWallet(wallet.id, limit, offset);
+ res.json({ success: true, count: rows.length, data: rows });
+ } catch (e) {
+ next(e);
+ }
+};
+
+module.exports = {
+ createWallet,
+ myWallets,
+ getWallet,
+ getBalance,
+ getTransactions,
+};
diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js
new file mode 100644
index 0000000..080e1a9
--- /dev/null
+++ b/server/src/middleware/auth.js
@@ -0,0 +1,24 @@
+const jwt = require("jsonwebtoken");
+
+function signToken(user) {
+ return jwt.sign({ sub: user.id, email: user.email }, process.env.JWT_SECRET, {
+ expiresIn: "7d",
+ });
+}
+
+function requireAuth(req, res, next) {
+ const header = req.headers.authorization || "";
+ const token = header.startsWith("Bearer ") ? header.slice(7) : null;
+ if (!token)
+ return res.status(401).json({ success: false, error: "Missing token" });
+ try {
+ req.user = jwt.verify(token, process.env.JWT_SECRET);
+ next();
+ } catch {
+ return res
+ .status(401)
+ .json({ success: false, error: "Invalid or expired token" });
+ }
+}
+
+module.exports = { signToken, requireAuth };
diff --git a/server/src/middleware/errorHandler.js b/server/src/middleware/errorHandler.js
new file mode 100644
index 0000000..f560675
--- /dev/null
+++ b/server/src/middleware/errorHandler.js
@@ -0,0 +1,8 @@
+module.exports = (err, req, res, next) => {
+ console.error(err);
+ const status = err.status || 500;
+ res.status(status).json({
+ success: false,
+ error: status === 500 ? "Internal server error" : err.message,
+ });
+};
diff --git a/server/src/models/merchantModel.js b/server/src/models/merchantModel.js
new file mode 100644
index 0000000..d999d95
--- /dev/null
+++ b/server/src/models/merchantModel.js
@@ -0,0 +1,46 @@
+const pool = require("../config/db");
+const crypto = require("crypto");
+
+async function create({ name }) {
+ const apiKey = "pk_" + crypto.randomBytes(16).toString("hex");
+ const r = await pool.query(
+ "INSERT INTO merchants (name, api_key) VALUES ($1, $2) RETURNING *",
+ [name, apiKey]
+ );
+ return r.rows[0];
+}
+
+async function findAll() {
+ const r = await pool.query("SELECT * FROM merchants ORDER BY created_at DESC");
+ return r.rows;
+}
+
+async function findById(id) {
+ const r = await pool.query("SELECT * FROM merchants WHERE id = $1", [id]);
+ return r.rows[0];
+}
+
+async function update(id, name, status) {
+ const r = await pool.query(
+ "UPDATE merchants SET name = $1, status = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING *",
+ [name, status, id]
+ );
+ return r.rows[0];
+}
+
+async function remove(id) {
+ const r = await pool.query("DELETE FROM merchants WHERE id = $1 RETURNING *", [id]);
+ return r.rows[0];
+}
+
+// Log a payment in the database (Called during a transaction)
+async function logPayment(client, merchantId, walletId, amount, currency) {
+ const r = await client.query(
+ `INSERT INTO merchant_payments (merchant_id, wallet_id, amount, currency)
+ VALUES ($1, $2, $3, $4) RETURNING *`,
+ [merchantId, walletId, amount, currency]
+ );
+ return r.rows[0];
+}
+
+module.exports = { create, findAll, findById, update, remove, logPayment };
diff --git a/server/src/models/paymentRequestModel.js b/server/src/models/paymentRequestModel.js
new file mode 100644
index 0000000..3398c72
--- /dev/null
+++ b/server/src/models/paymentRequestModel.js
@@ -0,0 +1,84 @@
+const pool = require("../config/db");
+
+async function create({
+ requesterWalletId,
+ payerEmail,
+ amount,
+ currency,
+ note,
+}) {
+ const r = await pool.query(
+ `INSERT INTO payment_requests (requester_wallet_id, payer_email, amount, currency, note)
+ VALUES ($1, $2, $3, $4, $5) RETURNING *`,
+ [requesterWalletId, payerEmail, amount, currency, note],
+ );
+ return r.rows[0];
+}
+
+async function listForUser(userId, email) {
+ const r = await pool.query(
+ `SELECT pr.*, w.wallet_number AS requester_wallet_number
+ FROM payment_requests pr
+ JOIN wallets w ON w.id = pr.requester_wallet_id
+ WHERE w.user_id = $1 OR pr.payer_email = $2
+ ORDER BY pr.created_at DESC`,
+ [userId, email],
+ );
+ return r.rows;
+}
+
+async function decline(id, payerEmail) {
+ const r = await pool.query(
+ `UPDATE payment_requests
+ SET status = 'declined', updated_at = CURRENT_TIMESTAMP
+ WHERE id = $1 AND payer_email = $2 AND status = 'pending' RETURNING *`,
+ [id, payerEmail],
+ );
+ return r.rows[0];
+}
+
+// Used inside a DB transaction (client), not standalone
+async function findByIdForUpdate(client, id) {
+ const r = await client.query(
+ "SELECT * FROM payment_requests WHERE id = $1 FOR UPDATE",
+ [id],
+ );
+ return r.rows[0];
+}
+
+async function markPaid(client, id, payerWalletId) {
+ const r = await client.query(
+ `UPDATE payment_requests
+ SET status = 'paid', payer_wallet_id = $1, updated_at = CURRENT_TIMESTAMP
+ WHERE id = $2 RETURNING *`,
+ [payerWalletId, id],
+ );
+ return r.rows[0];
+}
+
+
+async function findById(id) {
+ const r = await pool.query("SELECT * FROM payment_requests WHERE id = $1", [id]);
+ return r.rows[0];
+}
+
+async function update(id, amount, note, email) {
+ const r = await pool.query(
+ `UPDATE payment_requests
+ SET amount = $1, note = $2, updated_at = CURRENT_TIMESTAMP
+ WHERE id = $3 AND payer_email = $4 AND status = 'pending' RETURNING *`,
+ [amount, note, id, email]
+ );
+ return r.rows[0];
+}
+
+async function remove(id, email) {
+ const r = await pool.query(
+ `DELETE FROM payment_requests
+ WHERE id = $1 AND payer_email = $2 AND status = 'pending' RETURNING *`,
+ [id, email]
+ );
+ return r.rows[0];
+}
+
+module.exports = { create, listForUser, decline, findByIdForUpdate, markPaid, findById, update, remove };
diff --git a/server/src/models/transactionModel.js b/server/src/models/transactionModel.js
new file mode 100644
index 0000000..39cd020
--- /dev/null
+++ b/server/src/models/transactionModel.js
@@ -0,0 +1,11 @@
+const pool = require("../config/db");
+
+async function listByWallet(walletId, limit, offset) {
+ const r = await pool.query(
+ "SELECT * FROM transactions WHERE wallet_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
+ [walletId, limit, offset],
+ );
+ return r.rows;
+}
+
+module.exports = { listByWallet };
diff --git a/server/src/models/userModel.js b/server/src/models/userModel.js
new file mode 100644
index 0000000..f02b456
--- /dev/null
+++ b/server/src/models/userModel.js
@@ -0,0 +1,26 @@
+const pool = require("../config/db");
+
+async function findByEmail(email) {
+ const r = await pool.query("SELECT * FROM users WHERE email = $1", [email]);
+ return r.rows[0];
+}
+
+async function findById(id) {
+ const r = await pool.query(
+ "SELECT id, email, full_name, phone, status, created_at FROM users WHERE id = $1",
+ [id],
+ );
+ return r.rows[0];
+}
+
+async function create({ email, passwordHash, fullName, phone }) {
+ const r = await pool.query(
+ `INSERT INTO users (email, password_hash, full_name, phone)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id, email, full_name, phone, status, created_at`,
+ [email, passwordHash, fullName, phone],
+ );
+ return r.rows[0];
+}
+
+module.exports = { findByEmail, findById, create };
diff --git a/server/src/models/walletModel.js b/server/src/models/walletModel.js
new file mode 100644
index 0000000..8de29f0
--- /dev/null
+++ b/server/src/models/walletModel.js
@@ -0,0 +1,26 @@
+const pool = require("../config/db");
+
+async function create(userId, currency = "USD") {
+ const walletNumber = "PAYNX-" + Math.floor(100000 + Math.random() * 900000);
+ const r = await pool.query(
+ `INSERT INTO wallets (user_id, wallet_number, currency)
+ VALUES ($1, $2, $3) RETURNING *`,
+ [userId, walletNumber, currency],
+ );
+ return r.rows[0];
+}
+
+async function listByUser(userId) {
+ const r = await pool.query(
+ "SELECT * FROM wallets WHERE user_id = $1 ORDER BY created_at",
+ [userId],
+ );
+ return r.rows;
+}
+
+async function findById(id) {
+ const r = await pool.query("SELECT * FROM wallets WHERE id = $1", [id]);
+ return r.rows[0];
+}
+
+module.exports = { create, listByUser, findById };
diff --git a/server/src/routes/authRoutes.js b/server/src/routes/authRoutes.js
new file mode 100644
index 0000000..2b93097
--- /dev/null
+++ b/server/src/routes/authRoutes.js
@@ -0,0 +1,10 @@
+const express = require("express");
+const router = express.Router();
+const authController = require("../controllers/authController");
+const { requireAuth } = require("../middleware/auth");
+
+router.post("/register", authController.register);
+router.post("/login", authController.login);
+router.get("/me", requireAuth, authController.me);
+
+module.exports = router;
diff --git a/server/src/routes/index.js b/server/src/routes/index.js
new file mode 100644
index 0000000..ee55c37
--- /dev/null
+++ b/server/src/routes/index.js
@@ -0,0 +1,30 @@
+// api/src/routes/index.js (Master Router)
+const express = require("express");
+const router = express.Router();
+
+const authRoutes = require("./authRoutes");
+const walletRoutes = require("./walletRoutes");
+const moneyRoutes = require("./moneyRoutes");
+const requestRoutes = require("./requestRoutes");
+const transactionRoutes = require("./transactionRoutes");
+const merchantRoutes = require("./merchantRoutes");
+
+router.get("/health", (req, res) =>
+ res.json({
+ success: true,
+ service: "paynext-api",
+ time: new Date().toISOString(),
+ }),
+);
+
+// Mount team routes
+router.use("/auth", authRoutes);
+router.use("/wallets", walletRoutes);
+router.use("/transfers", moneyRoutes);
+router.use("/payment-requests", requestRoutes);
+
+// Mount your new routes
+router.use("/history", transactionRoutes);
+router.use("/merchants", merchantRoutes);
+
+module.exports = router;
diff --git a/server/src/routes/merchantRoutes.js b/server/src/routes/merchantRoutes.js
new file mode 100644
index 0000000..2c74612
--- /dev/null
+++ b/server/src/routes/merchantRoutes.js
@@ -0,0 +1,16 @@
+const express = require("express");
+const router = express.Router();
+const merchantController = require("../controllers/merchantController");
+const { requireAuth } = require("../middleware/auth");
+
+// CRUD Routes for Merchants
+router.post("/", requireAuth, merchantController.createMerchant);
+router.get("/", requireAuth, merchantController.getAllMerchants);
+router.get("/:id", requireAuth, merchantController.getMerchantById);
+router.put("/:id", requireAuth, merchantController.updateMerchant);
+router.delete("/:id", requireAuth, merchantController.deleteMerchant);
+
+// Action Route
+router.post("/pay", requireAuth, merchantController.payMerchant);
+
+module.exports = router;
diff --git a/server/src/routes/moneyRoutes.js b/server/src/routes/moneyRoutes.js
new file mode 100644
index 0000000..87e5417
--- /dev/null
+++ b/server/src/routes/moneyRoutes.js
@@ -0,0 +1,9 @@
+const express = require("express");
+const router = express.Router();
+const moneyController = require("../controllers/moneyController");
+const { requireAuth } = require("../middleware/auth");
+
+// Note: top-up and withdraw are on the wallet router, but sendMoney is here
+router.post("/", requireAuth, moneyController.sendMoney);
+
+module.exports = router;
\ No newline at end of file
diff --git a/server/src/routes/requestRoutes.js b/server/src/routes/requestRoutes.js
new file mode 100644
index 0000000..d8fcb7c
--- /dev/null
+++ b/server/src/routes/requestRoutes.js
@@ -0,0 +1,15 @@
+const express = require("express");
+const router = express.Router();
+const requestController = require("../controllers/requestController");
+const { requireAuth } = require("../middleware/auth");
+
+router.post("/", requireAuth, requestController.createRequest);
+router.get("/", requireAuth, requestController.myRequests);
+router.get("/:id", requireAuth, requestController.getRequestById); // NEW (Read Single)
+router.put("/:id", requireAuth, requestController.updateRequest); // NEW (Update)
+router.delete("/:id", requireAuth, requestController.deleteRequest); // NEW (Delete)
+
+router.post("/:id/approve", requireAuth, requestController.approveRequest);
+router.post("/:id/decline", requireAuth, requestController.declineRequest);
+
+module.exports = router;
diff --git a/server/src/routes/transactionRoutes.js b/server/src/routes/transactionRoutes.js
new file mode 100644
index 0000000..ea82615
--- /dev/null
+++ b/server/src/routes/transactionRoutes.js
@@ -0,0 +1,8 @@
+const express = require("express");
+const router = express.Router();
+const transactionController = require("../controllers/transactionController");
+const { requireAuth } = require("../middleware/auth");
+
+router.get("/", requireAuth, transactionController.getHistory);
+
+module.exports = router;
diff --git a/server/src/routes/walletRoutes.js b/server/src/routes/walletRoutes.js
new file mode 100644
index 0000000..293c733
--- /dev/null
+++ b/server/src/routes/walletRoutes.js
@@ -0,0 +1,16 @@
+const express = require("express");
+const router = express.Router();
+const walletController = require("../controllers/walletController");
+const moneyController = require("../controllers/moneyController");
+const { requireAuth } = require("../middleware/auth");
+
+router.post("/", requireAuth, walletController.createWallet);
+router.get("/", requireAuth, walletController.myWallets);
+router.get("/:id", requireAuth, walletController.getWallet);
+router.get("/:id/balance", requireAuth, walletController.getBalance);
+router.get("/:id/transactions", requireAuth, walletController.getTransactions);
+
+router.post("/:id/top-up", requireAuth, moneyController.topUp);
+router.post("/:id/withdraw", requireAuth, moneyController.withdraw);
+
+module.exports = router;
diff --git a/server/src/server.js b/server/src/server.js
new file mode 100644
index 0000000..4edc2e2
--- /dev/null
+++ b/server/src/server.js
@@ -0,0 +1,5 @@
+require("dotenv").config();
+const app = require("./app");
+
+const port = process.env.PORT || 3000;
+app.listen(port, () => console.log(`payNEXT API listening on port ${port}`));
diff --git a/server/src/services/moneyService.js b/server/src/services/moneyService.js
new file mode 100644
index 0000000..dd92431
--- /dev/null
+++ b/server/src/services/moneyService.js
@@ -0,0 +1,71 @@
+const pool = require("../config/db");
+
+class ApiError extends Error {
+ constructor(message, status) {
+ super(message);
+ this.status = status;
+ }
+}
+
+async function withTransaction(fn) {
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+ const result = await fn(client);
+ await client.query("COMMIT");
+ return result;
+ } catch (e) {
+ await client.query("ROLLBACK");
+ throw e;
+ } finally {
+ client.release();
+ }
+}
+
+async function lockWallet(client, id) {
+ const r = await client.query(
+ "SELECT * FROM wallets WHERE id = $1 FOR UPDATE",
+ [id],
+ );
+ return r.rows[0];
+}
+
+async function setBalance(client, wallet, balance) {
+ await client.query(
+ "UPDATE wallets SET balance = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2",
+ [balance, wallet.id],
+ );
+}
+
+async function logTx(
+ client,
+ { walletId, type, amount, balanceAfter, referenceId, description },
+) {
+ const r = await client.query(
+ `INSERT INTO transactions (wallet_id, type, amount, balance_after, reference_id, description)
+ VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
+ [walletId, type, amount, balanceAfter, referenceId, description],
+ );
+ return r.rows[0];
+}
+
+async function gatewayLog(
+ client,
+ { walletId, provider, providerRef, type, amount, currency, status },
+) {
+ const r = await client.query(
+ `INSERT INTO gateway_transactions (wallet_id, provider, provider_transaction_id, type, amount, currency, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
+ [walletId, provider, providerRef, type, amount, currency, status],
+ );
+ return r.rows[0];
+}
+
+module.exports = {
+ ApiError,
+ withTransaction,
+ lockWallet,
+ setBalance,
+ logTx,
+ gatewayLog,
+};