# PPOB App

## Deskripsi

Website PPOB (Payment Point Online Bank) untuk top up **pulsa**, **paket data**, **token listrik**, **voucher game** (Mobile Legends, Free Fire, PUBG Mobile), dan pembayaran **tagihan pascabayar** (BPJS Kesehatan, PDAM, PLN Pascabayar). User melakukan checkout produk, membayar via Duitku (payment gateway) atau saldo wallet internal, lalu sistem meneruskan order ke Digiflazz (supplier/H2H) untuk memproses produk digital ke nomor tujuan pelanggan. Khusus produk tagihan, ada langkah cek tagihan (inquiry) sebelum checkout.

## Tech Stack

- **Runtime**: Node.js
- **Framework**: Express.js
- **Database**: MySQL
- **ORM**: Sequelize (+ sequelize-cli untuk migration/seeder)
- **Auth**: JWT (jsonwebtoken) + bcrypt untuk hashing password
- **HTTP client**: axios (untuk memanggil API Digiflazz & Duitku)
- **Keamanan**: helmet (HTTP security headers), cors (whitelist origin), express-rate-limit (brute force/abuse protection), express-validator (validasi & sanitasi input)
- **Lainnya**: morgan, dotenv

## Struktur Folder

```
ppob-app/
├── server.js                  # Entry point, koneksi DB + start server
├── .env.example                # Template environment variable (tanpa value asli)
├── src/
│   ├── app.js                  # Setup express app, middleware global, mount routes
│   ├── config/
│   │   └── database.js         # Konfigurasi koneksi Sequelize per environment
│   ├── models/                 # Definisi model Sequelize (users, products, transactions, deposits, inquiries)
│   ├── migrations/             # Migration sequelize-cli
│   ├── seeders/                # Seeder sequelize-cli
│   ├── controllers/            # Handler request/response, memanggil services
│   ├── routes/                 # Definisi endpoint & mapping ke controller
│   ├── services/
│   │   ├── digiflazzService.js # Skeleton integrasi API Digiflazz
│   │   ├── duitkuService.js    # Skeleton integrasi API Duitku
│   │   ├── authService.js      # Logic register/login
│   │   ├── transactionService.js # Logic checkout & orkestrasi Duitku <-> Digiflazz
│   │   └── depositService.js   # Logic top up saldo wallet
│   ├── middleware/
│   │   ├── authMiddleware.js   # requireAuth (JWT) & requireRole
│   │   ├── errorMiddleware.js  # 404 handler & global error handler
│   │   ├── rateLimiter.js      # loginLimiter, registerLimiter, checkoutLimiter
│   │   └── validate.js         # jalankan hasil express-validator, balikin 422 kalau gagal
│   ├── validators/              # Rules express-validator per domain (auth, transaction, deposit, product)
│   └── utils/
│       ├── jwt.js               # sign/verify token
│       ├── response.js          # helper format response sukses/error
│       ├── invoice.js           # generate nomor invoice unik
│       └── logger.js            # audit trail checkout/deposit ke logs/transactions.log
```

## Model / Tabel

- **users** — akun pelanggan. Menyimpan `password` (bcrypt hash), `role` (`user`/`admin`), dan `balance` (saldo wallet).
- **products** — katalog produk PPOB hasil sinkronisasi dari Digiflazz price list (`sku`, `category`: pulsa/data/listrik/game/tagihan/lainnya, `basePrice`, `sellPrice`). Untuk `category = 'tagihan'`, `basePrice`/`sellPrice` hanya nilai referensi katalog — nominal aktual didapat dari tabel `inquiries`.
- **inquiries** — hasil cek tagihan (inquiry) untuk produk `category = 'tagihan'`, sebelum checkout. Menyimpan `inquiryId` (token unik, sekaligus dipakai sebagai `ref_id` ke Digiflazz), `customerName` & `billAmount` hasil inquiry, `status` (`pending`/`used`/`expired`), dan `expiresAt` (masa berlaku singkat).
- **transactions** — riwayat order produk. Menyimpan `paymentStatus` (status bayar) terpisah dari `deliveryStatus` (status pengiriman produk ke pelanggan), `inquiryId` (diisi hanya untuk produk tagihan, merujuk ke `inquiries.inquiryId`), plus field referensi ke Duitku & Digiflazz.
- **deposits** — riwayat top up saldo wallet via Duitku.

## Alur Transaksi

### A. Checkout dengan Duitku (`paymentMethod = duitku`)

1. **Checkout** — `POST /api/transactions/checkout`. User pilih produk, sistem membuat record `transactions` dengan `paymentStatus = unpaid`, `deliveryStatus = pending`, `invoiceNumber` unik (dipakai sebagai `merchantOrderId`).
2. **Duitku proses bayar** — sistem memanggil `duitkuService.createTransaction()` (Invoice API `/v2/inquiry`) untuk membuat transaksi pembayaran (VA/e-wallet/dll), menyimpan `duitkuReference` & `duitkuCheckoutUrl` (paymentUrl), lalu mengarahkan user ke halaman pembayaran Duitku.
3. **Callback masuk** — Duitku mengirim webhook ke `POST /api/callbacks/duitku` saat status pembayaran berubah. Signature **wajib** diverifikasi (`duitkuService.verifyCallbackSignature`, MD5 dari `merchantCode + amount + merchantOrderId + apiKey`) sebelum payload diproses.
4. **Order ke Digiflazz** — jika `resultCode = '00'` (sukses), sistem set `paymentStatus = paid` lalu memanggil `transactionService.processDigiflazzOrder()` → `digiflazzService.createTransaction()` menggunakan `invoiceNumber` sebagai `ref_id` (idempotent, mencegah double order).
5. **Callback status dari Digiflazz** — Digiflazz mengirim status akhir (Sukses/Gagal/Pending) ke `POST /api/callbacks/digiflazz`. Handler `transactionService.handleDigiflazzCallback()` mencocokkan `ref_id` dengan `invoiceNumber`, lalu update `deliveryStatus` + `digiflazzSn`.
6. **Update ke user** — jika `deliveryStatus = success`, user bisa melihat SN/token di detail transaksi (`GET /api/transactions/:invoiceNumber`). Jika `deliveryStatus = failed` padahal sudah dibayar, sistem otomatis refund saldo (`transactionService.refundIfNeeded`).

### B. Checkout dengan Saldo Wallet (`paymentMethod = wallet`)

1. Sistem cek saldo user dalam DB transaction dengan row lock (`LOCK.UPDATE`) untuk mencegah race condition saat checkout ganda bersamaan.
2. Jika saldo cukup, saldo langsung dipotong dan `paymentStatus = paid`, lalu langsung lanjut ke step "Order ke Digiflazz" di atas — tanpa perlu menunggu Duitku.

### C. Top Up Saldo (Deposit)

1. `POST /api/deposits` → buat record `deposits` + transaksi Duitku (`invoiceNumber` prefix `DEP-`).
2. Callback Duitku masuk ke endpoint yang sama (`/api/callbacks/duitku`), dibedakan lewat prefix `merchantOrderId` (`DEP-` vs `TRX-`).
3. Saat `resultCode = '00'`, `depositService.handleDuitkuCallback()` menambah `balance` user secara idempotent (hanya diproses jika deposit masih `pending`).

### D. Top Up Manual (testing only, tanpa Duitku)

`POST /api/deposits/topup` — menambah `balance` user secara langsung tanpa membuat transaksi pembayaran ke Duitku sama sekali. Dipakai selama API key Duitku belum aktif, supaya alur checkout wallet tetap bisa dites end-to-end. Endpoint ini **otomatis nonaktif (403) saat `NODE_ENV=production`** (lihat `depositController.topup`) — jangan hapus guard tersebut, dan jangan pernah expose endpoint sejenis ini di production karena bisa dipakai menambah saldo tanpa batas.

### E. Checkout Produk Tagihan/Pascabayar (inquiry wajib)

Produk `category = 'tagihan'` (BPJS Kesehatan, PDAM, PLN Pascabayar, dst) tidak punya harga tetap — tiap pelanggan punya nominal tagihan berbeda, sehingga checkout **wajib** didahului langkah inquiry:

1. **Inquiry** — `POST /api/transactions/inquiry` dengan `{ productId, customerNumber }`. Handler `transactionService.createInquiry()` memvalidasi `product.category === 'tagihan'`, generate `inquiryId` (prefix `INQ-`), lalu panggil `digiflazzService.inquiryPostpaid()` (`commands: 'inq-pasca'`) — **tidak memotong saldo**. Hasilnya (`customerName`, `billAmount` = total tagihan termasuk admin, `adminFee` untuk breakdown) disimpan di tabel `inquiries` dengan `status = 'pending'` dan `expiresAt` 15 menit dari sekarang.
2. **Checkout** — `POST /api/transactions/checkout` untuk produk tagihan **wajib** menyertakan `inquiryId` dari langkah 1, selain `productId`, `customerNumber`, `paymentMethod`. `transactionService.checkout()` mencocokkan `inquiryId` dengan `userId`/`productId`/`customerNumber`, menolak jika tidak ditemukan, sudah dipakai (`status != 'pending'`), atau kedaluwarsa (`expiresAt` terlampaui) — mencegah user membayar dengan nominal yang sudah basi. Harga transaksi diambil dari `inquiry.billAmount`, **bukan** `product.sellPrice`. Inquiry ditandai `status = 'used'` begitu transaksi dibuat (mencegah dipakai dua kali).
3. **Order ke Digiflazz** — untuk produk tagihan, `transactionService.processDigiflazzOrder()` memanggil `digiflazzService.payPostpaid()` (`commands: 'pay-pasca'`) dengan `ref_id` = `transaction.inquiryId` (harus sama persis dengan `ref_id` yang dipakai saat inquiry — ketentuan Digiflazz untuk mengaitkan inquiry dengan pembayarannya), berbeda dari produk prepaid yang memakai `invoiceNumber`.
4. **Callback status** — `transactionService.handleDigiflazzCallback()` mencocokkan `ref_id` dari Digiflazz terhadap `invoiceNumber` **atau** `inquiryId` transaksi, karena keduanya bisa jadi `ref_id` tergantung jenis produk.

### F. Digiflazz Mock Mode (testing only, tanpa API key asli)

Set `DIGIFLAZZ_MOCK=true` di `.env` untuk mem-bypass seluruh panggilan HTTP asli ke Digiflazz (`createTransaction`, `inquiryPostpaid`, `payPostpaid`) dan memakai respons simulasi buatan `digiflazzService` sendiri — dipakai selama API key Digiflazz belum aktif, supaya alur *inquiry → checkout → deliveryStatus success* bisa dites end-to-end tanpa kredensial sungguhan:

- `createTransaction` (prepaid: pulsa/data/listrik/game) mock selalu mengembalikan `status: 'Sukses'` dengan `sn` acak.
- `inquiryPostpaid` (tagihan) mock menghasilkan `billAmount` yang **deterministik** dari hash `customerNumber` (nomor pelanggan yang sama selalu menghasilkan nominal tagihan yang sama), plus `customerName` dummy.
- `payPostpaid` (tagihan) mock selalu mengembalikan `status: 'Sukses'` dengan `sn` acak.

**Guard keamanan**: `MOCK_ENABLED` di `digiflazzService.js` dihitung dari `DIGIFLAZZ_MOCK === 'true' && NODE_ENV !== 'production'` — mode mock **tidak pernah aktif** saat `NODE_ENV=production`, walau `DIGIFLAZZ_MOCK` lupa di-set balik ke `false`. Jangan hapus guard ganda ini, mengikuti pola yang sama dengan `POST /api/deposits/topup` (alur D).

Catatan: nodemon hanya memantau file berekstensi `.js/.json/dst`, **bukan** `.env` — setelah mengubah `.env`, restart manual (`npm run dev` ulang) supaya perubahan terbaca.

## Lapisan Keamanan Infrastruktur

Selain aturan di bawah, ada beberapa proteksi level middleware yang berlaku untuk seluruh API:

- **Rate limiting** (`express-rate-limit`, `src/middleware/rateLimiter.js`): `loginLimiter` dan `registerLimiter` masing-masing 5 request/menit/IP (instance terpisah — gagal login berkali-kali tidak ikut mengunci endpoint register untuk IP yang sama), `checkoutLimiter` 20 request/menit/IP di `POST /api/transactions/checkout`. Response saat kena limit: `429` dengan pesan "Terlalu banyak percobaan...".
- **Helmet** (`app.use(helmet())` di `src/app.js`): set header keamanan standar (`X-Frame-Options`, `X-Content-Type-Options`, `Strict-Transport-Security`, sembunyikan `X-Powered-By`, dst) secara otomatis.
- **CORS whitelist** (`src/app.js`): hanya origin yang terdaftar di `ALLOWED_ORIGINS` (.env, dipisah koma) yang diizinkan — **tidak memakai wildcard `*`**. Request tanpa header `Origin` (server-to-server, curl, callback gateway) tetap lolos karena kebijakan CORS hanya relevan untuk browser. Origin di luar whitelist ditolak `403`.
- **`trust proxy`** (`src/app.js`, env `TRUST_PROXY`): default `false`. Hanya set `true` di `.env` kalau app benar-benar berjalan di belakang reverse proxy/load balancer terpercaya (Nginx, ALB, dst) — dibutuhkan supaya `req.ip` (dipakai rate limiter & audit log) terbaca dari `X-Forwarded-For` yang benar. Kalau app diakses langsung tanpa proxy, JANGAN diaktifkan — bisa disalahgunakan untuk spoof IP dan melewati rate limit.
- **Validasi & sanitasi input** (`express-validator`, `src/validators/*.js` + `src/middleware/validate.js`): setiap endpoint yang menerima body/query/param dari user (register, login, checkout, inquiry, deposit, topup, list/detail produk) divalidasi ketat sebelum masuk controller — tipe data, format, whitelist enum (`paymentMethod`, `category`), dan regex whitelist karakter untuk field bebas seperti `customerNumber` (hanya huruf/angka/spasi) supaya karakter aneh (mis. percobaan SQL injection) ditolak `422` sebelum menyentuh Sequelize. Karena semua query DB lewat Sequelize ORM (parameterized query, tidak ada raw SQL), validasi ini jadi lapisan pertahanan tambahan, bukan satu-satunya proteksi SQL injection.
- **Audit log transaksi** (`src/utils/logger.js`): setiap checkout, deposit, top up manual, dan callback masuk (Duitku/Digiflazz) dicatat sebagai JSON Lines ke `logs/transactions.log` (di-gitignore) plus console — berisi `userId`, `ip`, `timestamp`, `invoiceNumber`, dan status akhir. Kolom `ipAddress` juga disimpan langsung di tabel `transactions` dan `deposits` supaya bisa di-query dari DB tanpa buka file log.

## Aturan Keamanan

1. **Jangan expose API key.** Semua kredensial (`DIGIFLAZZ_API_KEY`, `DUITKU_API_KEY`, `JWT_SECRET`, dll) hanya boleh berada di `.env` (di-gitignore), tidak pernah di-hardcode di source code atau dikirim ke response/log. `.env.example` hanya berisi placeholder.
2. **Validasi signature webhook.** Endpoint `/api/callbacks/duitku` wajib memverifikasi field `signature` di body (MD5 dari `merchantCode + amount + merchantOrderId + apiKey`, dibandingkan dengan `timingSafeEqual`) sebelum memproses payload apa pun — request dengan signature tidak valid ditolak dengan 400. Endpoint `/api/callbacks/digiflazz` tidak memiliki signature bawaan, sehingga wajib diamankan tambahan lewat whitelist IP Digiflazz di reverse proxy/firewall saat production.
3. **Cek saldo sebelum transaksi.** Untuk `paymentMethod = wallet`, saldo user divalidasi dan dipotong dalam satu database transaction dengan row lock (`SELECT ... FOR UPDATE`) sebelum order diteruskan ke Digiflazz, guna mencegah saldo minus akibat race condition pada request bersamaan.
4. **Idempotency order Digiflazz.** `ref_id` yang dikirim ke Digiflazz = `invoiceNumber` transaksi internal, sehingga retry/duplikasi request tidak akan membuat order dobel di sisi supplier.
5. **Refund otomatis.** Jika pembayaran sudah `paid` tapi order ke Digiflazz gagal (`deliveryStatus = failed`), saldo user dikembalikan otomatis dan `paymentStatus` diubah menjadi `refunded`.
6. **Password hashing.** Password user di-hash dengan bcrypt (`SALT_ROUNDS = 10`) sebelum disimpan; endpoint yang mengembalikan data user (`/me`, hasil login) tidak pernah menyertakan field password (`defaultScope` model `User` meng-exclude `password`).
7. **Autentikasi & otorisasi.** Semua endpoint transaksi/deposit memerlukan JWT valid (`requireAuth`); endpoint admin (misal sync produk) memerlukan role `admin` (`requireRole`).
8. **Endpoint testing tidak boleh bocor ke production.** `POST /api/deposits/topup` (top up manual tanpa Duitku, lihat alur D di atas) mengecek `NODE_ENV === 'production'` di awal handler dan menolak dengan 403 jika benar — pola ini wajib dipertahankan untuk endpoint bypass serupa yang dibuat untuk kebutuhan development. Pola yang sama dipakai `DIGIFLAZZ_MOCK` (alur F): guard `NODE_ENV !== 'production'` dihitung di level service, bukan cuma di controller, supaya tidak ada jalur yang lupa mengeceknya.
9. **Integritas harga produk tagihan.** Nominal yang dibayar untuk produk `category = 'tagihan'` HARUS berasal dari `Inquiry.billAmount` milik `inquiryId` yang divalidasi (cocok `userId`/`productId`/`customerNumber`, `status = 'pending'`, belum `expiresAt`) — jangan pernah menerima nominal tagihan dari input klien secara langsung, karena itu membuka celah user membayar lebih kecil dari tagihan sebenarnya.
10. **JWT expiry.** Token JWT punya masa berlaku (`JWT_EXPIRES_IN`, default `1d`) — lihat `src/utils/jwt.js`. Jangan hilangkan opsi `expiresIn` saat sign token; token tanpa expiry berarti valid selamanya walau akun dinonaktifkan setelahnya.
11. **Rate limiting, CORS, dan validasi input wajib tetap terpasang.** Lihat bagian "Lapisan Keamanan Infrastruktur" di atas — endpoint auth/checkout baru yang dibuat di kemudian hari harus tetap dipasangi limiter & validator yang sesuai, bukan cuma endpoint yang sudah ada saat ini.

## Setup

1. `cp .env.example .env` lalu isi kredensial database, JWT secret, serta API key Digiflazz & Duitku yang sebenarnya (`DIGIFLAZZ_MODE` dan `DUITKU_MODE` diisi `sandbox` untuk testing, `production` saat live).
2. `npm install`
3. Buat database MySQL sesuai `DB_NAME` di `.env`.
4. Jalankan migration (`npm run db:migrate`) setelah migration files dibuat, atau gunakan `sequelize.sync()` untuk development awal.
5. `npm run dev` untuk menjalankan server dengan nodemon.
