first commit
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
```dotenv
|
||||||
|
PORT=
|
||||||
|
NODE_ENV=
|
||||||
|
|
||||||
|
# Midtrans Configuration
|
||||||
|
MIDTRANS_SERVER_KEY=
|
||||||
|
MIDTRANS_CLIENT_KEY=
|
||||||
|
MIDTRANS_IS_PRODUCTION=
|
||||||
|
|
||||||
|
MIDTRANS_EXPIRY_DURATION=
|
||||||
|
MIDTRANS_EXPIRY_UNIT=
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=
|
||||||
|
DB_USER=
|
||||||
|
DB_PASS=
|
||||||
|
DB_NAME=
|
||||||
|
DB_DIALECT=
|
||||||
|
```
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Debug logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor / OS files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
full_test_results.txt
|
||||||
|
test_error.txt
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
60
DEVELOPER_GUIDE.md
Normal file
60
DEVELOPER_GUIDE.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Midtrans Gateway Developer Guide
|
||||||
|
|
||||||
|
Dokumen ini berisi informasi krusial yang harus dipahami oleh developer yang akan memelihara atau mengembangkan project ini di masa depan.
|
||||||
|
|
||||||
|
## 1. Arsitektur & Pemetaan ID
|
||||||
|
Project ini menggunakan pemetaan ID yang unik untuk menjembatani sistem internal dengan Midtrans.
|
||||||
|
|
||||||
|
- **`order_id` (UUID)**: Ini adalah Primary Key (UUID) dari tabel `trans_orders`. Digunakan sebagai referensi internal di database kita.
|
||||||
|
- **`external_id` (String)**: Ini adalah string unik (misal: `INV-123`) yang dikirimkan ke Midtrans sebagai `order_id` mereka.
|
||||||
|
- **PENTING**: Saat menerima Webhook/Callback dari Midtrans, kita mencari data berdasarkan `external_id` terlebih dahulu untuk menemukan UUID internalnya.
|
||||||
|
|
||||||
|
## 2. Environment Variables (.env)
|
||||||
|
Pastikan variabel berikut terkonfigurasi:
|
||||||
|
- `MIDTRANS_SERVER_KEY` & `MIDTRANS_CLIENT_KEY`: Kredensial dari dashboard Midtrans.
|
||||||
|
- `MIDTRANS_IS_PRODUCTION`: `false` untuk Sandbox, `true` untuk Production.
|
||||||
|
- `MIDTRANS_EXPIRY_DURATION` & `MIDTRANS_EXPIRY_UNIT`: Mengontrol waktu kadaluarsa transaksi.
|
||||||
|
- `DB_*`: Konfigurasi database untuk Sequelize.
|
||||||
|
|
||||||
|
## 2. Fitur Utama
|
||||||
|
|
||||||
|
### Penanganan Webhook (Idempotensi)
|
||||||
|
Logika di `NotificationService` sudah dilengkapi dengan pengecekan status:
|
||||||
|
- Jika status di database sudah `PAID` atau `FAILED` dan Webhook mengirimkan status yang sama, sistem akan **mengabaikan (skip)** proses update untuk menghindari beban database ganda.
|
||||||
|
|
||||||
|
### Kontrol Kadaluarsa (Expiration)
|
||||||
|
Waktu kadaluarsa diatur secara terpusat melalui file `.env`:
|
||||||
|
- `MIDTRANS_EXPIRY_DURATION`: Nilai angka (default: 1).
|
||||||
|
- `MIDTRANS_EXPIRY_UNIT`: Satuan waktu (`hour`, `minute`, `day`).
|
||||||
|
- Implementasi menggunakan `custom_expiry` (Core API) dan `expiry` (Snap API).
|
||||||
|
|
||||||
|
## 3. Batasan API Midtrans (Gotchas)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **Refund API Limitations**
|
||||||
|
> Fitur refund melalui API Midtrans memiliki batasan berdasarkan metode pembayaran:
|
||||||
|
> - **Mendukung Refund API**: Credit Card, GoPay, QRIS, ShopeePay.
|
||||||
|
> - **TIDAK Mendukung Refund API**: Bank Transfer (VA Mandiri, BCA, BNI, BRI, dll). Untuk VA, refund harus dilakukan secara manual melalui transfer langsung ke pelanggan. Jika dipaksakan via API, Anda akan mendapatkan error **412 Precondition Failed**.
|
||||||
|
|
||||||
|
## 4. Database & Migrasi
|
||||||
|
Project ini menggunakan **Sequelize ORM**.
|
||||||
|
- **`models/migration.js`**: File ini adalah kernel untuk me-load semua model database. Ia memiliki logika fallback untuk mencari file konfigurasi database di beberapa lokasi (`db.config.js` atau `app/config/database.js`).
|
||||||
|
- Semua model didefinisikan di folder root `models/`.
|
||||||
|
|
||||||
|
## 5. Menambah Metode Pembayaran Baru
|
||||||
|
Jika ingin menambah metode pembayaran baru (misal: Alfagift):
|
||||||
|
1. Buat folder baru di `app/modules/[nama-metode]`.
|
||||||
|
2. Implementasikan `Service` yang memanggil `midtransCore.getCoreApi().charge(parameter)`.
|
||||||
|
3. Gunakan `paymentLogger.log(response, payload)` setelah memanggil API untuk sinkronisasi database.
|
||||||
|
4. Daftarkan route baru di `index.js`.
|
||||||
|
|
||||||
|
## 6. Struktur Folder & Kode
|
||||||
|
- `app/core/`: Inisialisasi SDK Midtrans.
|
||||||
|
- `app/modules/`: Logika per modul pembayaran (Bank Transfer, E-Wallet, dll).
|
||||||
|
- `models/migration.js`: Loader model Sequelize.
|
||||||
|
- `helpers/response.helper.js`: Standarisasi format JSON response.
|
||||||
|
|
||||||
|
## 5. Tips Pengembangan
|
||||||
|
- Gunakan **Midtrans Sandbox** untuk testing.
|
||||||
|
- Selalu periksa `signature_key` di `NotificationService` untuk memastikan request Webhook benar-benar datang dari Midtrans.
|
||||||
|
- Jika melakukan cleanup kode, jangan hapus baris `require` di bagian atas file Controller/Service karena itu krusial untuk dependensi modul.
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD [ "node", "index.js" ]
|
||||||
25
README.md
Normal file
25
README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Midtrans Gateway Node.js
|
||||||
|
|
||||||
|
Implementasi API Gateway untuk integrasi pembayaran Midtrans menggunakan Node.js, Express, dan Sequelize.
|
||||||
|
|
||||||
|
## Fitur Utama
|
||||||
|
- **Semua Metode Pembayaran**: Mendukung VA (Bank Transfer), E-Wallet, Credit Card, Direct Debit (KlikBCA, dll), CStore, dan PayLater.
|
||||||
|
- **Webhook Handling**: Penanganan notifikasi pembayaran otomatis dengan verifikasi signature.
|
||||||
|
- **Dynamic Expiry**: Pengaturan waktu kadaluarsa transaksi melalui konfigurasi `.env`.
|
||||||
|
- **Clean Architecture**: Kode terorganisir per modul fitur.
|
||||||
|
|
||||||
|
## Persiapan
|
||||||
|
1. Clone project.
|
||||||
|
2. Jalankan `npm install`.
|
||||||
|
3. Copy `.env.example` ke `.env` (atau buat manual) dan isi kredensial Midtrans serta Database Anda.
|
||||||
|
|
||||||
|
## Perintah
|
||||||
|
- `npm run dev`: Menjalankan server dalam mode development (dengan nodemon).
|
||||||
|
- `npm start`: Menjalankan server dalam mode production.
|
||||||
|
|
||||||
|
## Dokumentasi Teknis
|
||||||
|
Untuk detail teknis mengenai arsitektur, pemetaan ID, dan cara pengembangan lebih lanjut, silakan baca:
|
||||||
|
👉 **[DEVELOPER_GUIDE.md](./DEVELOPER_GUIDE.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
*Created for robust and scalable payment integration.*
|
||||||
18
app/config/database.js
Normal file
18
app/config/database.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const sequelize = new Sequelize(
|
||||||
|
process.env.DB_NAME,
|
||||||
|
process.env.DB_USER,
|
||||||
|
process.env.DB_PASS,
|
||||||
|
{
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
dialect: process.env.DB_DIALECT,
|
||||||
|
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||||
|
define: {
|
||||||
|
timestamps: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = sequelize;
|
||||||
22
app/config/midtrans.js
Normal file
22
app/config/midtrans.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const midtransClient = require('midtrans-client');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
class MidtransConfig {
|
||||||
|
static getSnapClient() {
|
||||||
|
return new midtransClient.Snap({
|
||||||
|
isProduction: process.env.MIDTRANS_IS_PRODUCTION === 'true',
|
||||||
|
serverKey: process.env.MIDTRANS_SERVER_KEY,
|
||||||
|
clientKey: process.env.MIDTRANS_CLIENT_KEY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCoreApiClient() {
|
||||||
|
return new midtransClient.CoreApi({
|
||||||
|
isProduction: process.env.MIDTRANS_IS_PRODUCTION === 'true',
|
||||||
|
serverKey: process.env.MIDTRANS_SERVER_KEY,
|
||||||
|
clientKey: process.env.MIDTRANS_CLIENT_KEY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MidtransConfig;
|
||||||
32
app/core/midtrans.service.js
Normal file
32
app/core/midtrans.service.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const midtransClient = require('midtrans-client');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
class MidtransCoreService {
|
||||||
|
constructor() {
|
||||||
|
this.isProduction = process.env.MIDTRANS_IS_PRODUCTION === 'true';
|
||||||
|
this.serverKey = process.env.MIDTRANS_SERVER_KEY;
|
||||||
|
this.clientKey = process.env.MIDTRANS_CLIENT_KEY;
|
||||||
|
|
||||||
|
this.coreApi = new midtransClient.CoreApi({
|
||||||
|
isProduction: this.isProduction,
|
||||||
|
serverKey: this.serverKey,
|
||||||
|
clientKey: this.clientKey
|
||||||
|
});
|
||||||
|
|
||||||
|
this.snap = new midtransClient.Snap({
|
||||||
|
isProduction: this.isProduction,
|
||||||
|
serverKey: this.serverKey,
|
||||||
|
clientKey: this.clientKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCoreApi() {
|
||||||
|
return this.coreApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnap() {
|
||||||
|
return this.snap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new MidtransCoreService();
|
||||||
24
app/core/payment.service.js
Normal file
24
app/core/payment.service.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const MidtransConfig = require('../../config/midtrans');
|
||||||
|
const coreApi = MidtransConfig.getCoreApiClient();
|
||||||
|
|
||||||
|
class BasePaymentService {
|
||||||
|
static async charge(payload) {
|
||||||
|
try {
|
||||||
|
const response = await coreApi.charge(payload);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Midtrans Charge Error:', error.ApiResponse || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getStatus(orderId) {
|
||||||
|
try {
|
||||||
|
return await coreApi.transaction.status(orderId);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BasePaymentService;
|
||||||
76
app/helpers/payment_logger.helper.js
Normal file
76
app/helpers/payment_logger.helper.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const db = require('../../models/migration');
|
||||||
|
|
||||||
|
class PaymentLoggerHelper {
|
||||||
|
async log(midtransResponse, requestPayload = null, type = 'PAYMENT') {
|
||||||
|
const orderIdInternal = requestPayload ? requestPayload.order_id : null;
|
||||||
|
const orderIdExternal = midtransResponse.order_id || (requestPayload ? requestPayload.external_id : null);
|
||||||
|
|
||||||
|
const grossAmount = midtransResponse.gross_amount || (requestPayload ? requestPayload.amount : null);
|
||||||
|
const transactionStatus = midtransResponse.transaction_status || 'pending';
|
||||||
|
const paymentType = midtransResponse.payment_type || (requestPayload ? 'snap' : null);
|
||||||
|
const transactionId = midtransResponse.transaction_id || null;
|
||||||
|
|
||||||
|
if (!orderIdInternal && !orderIdExternal) {
|
||||||
|
console.warn(`[PaymentLogger] Tidak ada ID (internal/external) ditemukan. Lewati pencatatan.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let order = null;
|
||||||
|
|
||||||
|
if (orderIdInternal) {
|
||||||
|
order = await db.Order.findByPk(orderIdInternal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order && orderIdExternal) {
|
||||||
|
order = await db.Order.findOne({
|
||||||
|
where: { orderID: orderIdExternal }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
console.warn(`[PaymentLogger] Order tidak ditemukan untuk Internal ID: ${orderIdInternal} atau External ID: ${orderIdExternal}.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mappedStatus = 'PENDING';
|
||||||
|
if (['settlement', 'capture'].includes(transactionStatus)) {
|
||||||
|
mappedStatus = 'SUCCESS';
|
||||||
|
} else if (['deny', 'cancel', 'expire'].includes(transactionStatus)) {
|
||||||
|
mappedStatus = 'FAILED';
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await db.Payment.create({
|
||||||
|
order_id: order.id,
|
||||||
|
user_id: order.user_id || null,
|
||||||
|
amount: order.total || grossAmount,
|
||||||
|
response_data: JSON.stringify(midtransResponse),
|
||||||
|
currency: 'IDR',
|
||||||
|
description: midtransResponse.status_message || `Payment for order ${orderIdExternal || order.id}`,
|
||||||
|
type: type,
|
||||||
|
channel_code: paymentType,
|
||||||
|
channel_name: paymentType,
|
||||||
|
channel_type: this._getChannelType(paymentType),
|
||||||
|
status: mappedStatus,
|
||||||
|
third_party: 'MIDTRANS',
|
||||||
|
external_id: orderIdExternal || transactionId
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[PaymentLogger] Berhasil mencatat pembayaran untuk Order: ${order.id} (External: ${orderIdExternal})`);
|
||||||
|
return payment;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PaymentLogger] Gagal mencatat pembayaran: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getChannelType(paymentType) {
|
||||||
|
if (!paymentType) return null;
|
||||||
|
if (paymentType.includes('va') || paymentType.includes('bank') || paymentType.includes('echannel')) return 'BANK';
|
||||||
|
if (['gopay', 'shopeepay', 'qris'].includes(paymentType)) return 'E_WALLET';
|
||||||
|
if (paymentType.includes('credit_card')) return 'CREDIT_CARD';
|
||||||
|
return 'QRIS';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new PaymentLoggerHelper();
|
||||||
24
app/helpers/response.helper.js
Normal file
24
app/helpers/response.helper.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
class ResponseHelper {
|
||||||
|
static success(res, message, data = {}, status = 200) {
|
||||||
|
return res.status(status).json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static error(res, message, status = 500, errors = null) {
|
||||||
|
const response = {
|
||||||
|
success: false,
|
||||||
|
message
|
||||||
|
};
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
response.errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(status).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ResponseHelper;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const bankTransferService = require('../services/bank-transfer.service');
|
||||||
|
const responseHelper = require('../../../helpers/response.helper');
|
||||||
|
|
||||||
|
class BankTransferController {
|
||||||
|
static async charge(req, res) {
|
||||||
|
try {
|
||||||
|
const { bank } = req.params;
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
if (!payload.order_id || !payload.external_id) {
|
||||||
|
return responseHelper.error(res, "order_id dan external_id wajib diisi", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bankTransferService.createTransaction(payload, bank.toLowerCase());
|
||||||
|
|
||||||
|
return responseHelper.success(res, `Transaksi Virtual Account ${bank.toUpperCase()} berhasil dibuat`, result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BankTransferController;
|
||||||
8
app/modules/bank-transfer/routes/bank-transfer.route.js
Normal file
8
app/modules/bank-transfer/routes/bank-transfer.route.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const bankTransferController = require('../controllers/bank-transfer.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/:bank', bankTransferController.charge);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
56
app/modules/bank-transfer/services/bank-transfer.service.js
Normal file
56
app/modules/bank-transfer/services/bank-transfer.service.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const midtransCore = require('../../../core/midtrans.service');
|
||||||
|
const paymentLogger = require('../../../helpers/payment_logger.helper');
|
||||||
|
const db = require('../../../../models/migration');
|
||||||
|
|
||||||
|
class BankTransferService {
|
||||||
|
async createTransaction(payload, bank) {
|
||||||
|
let { order_id, external_id, amount, customer_details } = payload;
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
let order = null;
|
||||||
|
if (order_id) order = await db.Order.findByPk(order_id);
|
||||||
|
if (!order && external_id) order = await db.Order.findOne({ where: { orderID: external_id } });
|
||||||
|
|
||||||
|
if (!order) throw new Error("Amount wajib diisi atau order_id/external_id tidak valid");
|
||||||
|
amount = order.total;
|
||||||
|
payload.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameter = {
|
||||||
|
payment_type: "bank_transfer",
|
||||||
|
transaction_details: {
|
||||||
|
order_id: external_id,
|
||||||
|
gross_amount: amount
|
||||||
|
},
|
||||||
|
bank_transfer: {
|
||||||
|
bank: bank
|
||||||
|
},
|
||||||
|
customer_details: customer_details,
|
||||||
|
custom_expiry: {
|
||||||
|
expiry_duration: parseInt(process.env.MIDTRANS_EXPIRY_DURATION || 24),
|
||||||
|
unit: process.env.MIDTRANS_EXPIRY_UNIT || 'hour'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bank === 'mandiri') {
|
||||||
|
parameter.payment_type = "echannel";
|
||||||
|
parameter.echannel = {
|
||||||
|
bill_info1: "Payment For:",
|
||||||
|
bill_info2: "Invoice " + (external_id || order_id)
|
||||||
|
};
|
||||||
|
delete parameter.bank_transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getCoreApi().charge(parameter);
|
||||||
|
|
||||||
|
await paymentLogger.log(response, payload);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Midtrans Error (${bank}): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new BankTransferService();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const creditCardService = require('../services/credit-card.service');
|
||||||
|
const responseHelper = require('../../../helpers/response.helper');
|
||||||
|
|
||||||
|
class CreditCardController {
|
||||||
|
static async charge(req, res) {
|
||||||
|
try {
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
if (!payload.credit_card_token) {
|
||||||
|
return responseHelper.error(res, "credit_card_token wajib disertakan (dapatkan dari frontend Midtrans JS)", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.order_id || !payload.external_id) {
|
||||||
|
return responseHelper.error(res, "order_id dan external_id wajib diisi", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await creditCardService.createTransaction(payload);
|
||||||
|
|
||||||
|
return responseHelper.success(res, "Transaksi Credit Card berhasil diinisialisasi", result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CreditCardController;
|
||||||
8
app/modules/credit-card/routes/credit-card.route.js
Normal file
8
app/modules/credit-card/routes/credit-card.route.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const creditCardController = require('../controllers/credit-card.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/charge', creditCardController.charge);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
48
app/modules/credit-card/services/credit-card.service.js
Normal file
48
app/modules/credit-card/services/credit-card.service.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const midtransCore = require('../../../core/midtrans.service');
|
||||||
|
const paymentLogger = require('../../../helpers/payment_logger.helper');
|
||||||
|
const db = require('../../../../models/migration');
|
||||||
|
|
||||||
|
class CreditCardService {
|
||||||
|
async createTransaction(payload) {
|
||||||
|
let { order_id, external_id, amount, customer_details, credit_card_token } = payload;
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
let order = null;
|
||||||
|
if (order_id) order = await db.Order.findByPk(order_id);
|
||||||
|
if (!order && external_id) order = await db.Order.findOne({ where: { orderID: external_id } });
|
||||||
|
|
||||||
|
if (!order) throw new Error("Amount wajib diisi atau order_id/external_id tidak valid");
|
||||||
|
amount = order.total;
|
||||||
|
payload.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameter = {
|
||||||
|
payment_type: "credit_card",
|
||||||
|
transaction_details: {
|
||||||
|
order_id: external_id,
|
||||||
|
gross_amount: amount
|
||||||
|
},
|
||||||
|
credit_card: {
|
||||||
|
token_id: credit_card_token,
|
||||||
|
authentication: true
|
||||||
|
},
|
||||||
|
customer_details: customer_details,
|
||||||
|
custom_expiry: {
|
||||||
|
expiry_duration: parseInt(process.env.MIDTRANS_EXPIRY_DURATION || 24),
|
||||||
|
unit: process.env.MIDTRANS_EXPIRY_UNIT || 'hour'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getCoreApi().charge(parameter);
|
||||||
|
|
||||||
|
await paymentLogger.log(response, payload);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Midtrans Credit Card Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CreditCardService();
|
||||||
24
app/modules/cstore/controllers/cstore.controller.js
Normal file
24
app/modules/cstore/controllers/cstore.controller.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const cstoreService = require('../services/cstore.service');
|
||||||
|
const responseHelper = require('../../../helpers/response.helper');
|
||||||
|
|
||||||
|
class CStoreController {
|
||||||
|
static async charge(req, res) {
|
||||||
|
try {
|
||||||
|
const { store } = req.params;
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
if (!payload.order_id || !payload.external_id) {
|
||||||
|
return responseHelper.error(res, "order_id dan external_id wajib diisi", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cstoreService.createTransaction(payload, store.toLowerCase());
|
||||||
|
|
||||||
|
return responseHelper.success(res, `Transaksi ${store.toUpperCase()} berhasil dibuat`, result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CStoreController;
|
||||||
8
app/modules/cstore/routes/cstore.route.js
Normal file
8
app/modules/cstore/routes/cstore.route.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const cstoreController = require('../controllers/cstore.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/:store', cstoreController.charge);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
57
app/modules/cstore/services/cstore.service.js
Normal file
57
app/modules/cstore/services/cstore.service.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const midtransCore = require('../../../core/midtrans.service');
|
||||||
|
const paymentLogger = require('../../../helpers/payment_logger.helper');
|
||||||
|
const db = require('../../../../models/migration');
|
||||||
|
|
||||||
|
class CStoreService {
|
||||||
|
async createTransaction(payload, store) {
|
||||||
|
let { order_id, external_id, amount, customer_details } = payload;
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
let order = null;
|
||||||
|
if (order_id) order = await db.Order.findByPk(order_id);
|
||||||
|
if (!order && external_id) order = await db.Order.findOne({ where: { orderID: external_id } });
|
||||||
|
|
||||||
|
if (!order) throw new Error("Amount wajib diisi atau order_id/external_id tidak valid");
|
||||||
|
amount = order.total;
|
||||||
|
payload.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameter = {
|
||||||
|
payment_type: store,
|
||||||
|
transaction_details: {
|
||||||
|
order_id: external_id,
|
||||||
|
gross_amount: amount
|
||||||
|
},
|
||||||
|
customer_details: customer_details,
|
||||||
|
custom_expiry: {
|
||||||
|
expiry_duration: parseInt(process.env.MIDTRANS_EXPIRY_DURATION || 24),
|
||||||
|
unit: process.env.MIDTRANS_EXPIRY_UNIT || 'hour'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (store === 'indomaret') {
|
||||||
|
parameter.indomaret = {
|
||||||
|
message: "Pembayaran untuk Invoice " + (external_id || order_id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store === 'alfamart') {
|
||||||
|
parameter.alfamart = {
|
||||||
|
alfamart_free_text_1: "Terima kasih telah berbelanja",
|
||||||
|
alfamart_free_text_2: "Simpan struk Anda"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getCoreApi().charge(parameter);
|
||||||
|
|
||||||
|
await paymentLogger.log(response, payload);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Midtrans Error (${store}): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CStoreService();
|
||||||
24
app/modules/e-wallet/controllers/e-wallet.controller.js
Normal file
24
app/modules/e-wallet/controllers/e-wallet.controller.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const eWalletService = require('../services/e-wallet.service');
|
||||||
|
const responseHelper = require('../../../helpers/response.helper');
|
||||||
|
|
||||||
|
class EWalletController {
|
||||||
|
static async charge(req, res) {
|
||||||
|
try {
|
||||||
|
const { type } = req.params;
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
if (!payload.order_id || !payload.external_id) {
|
||||||
|
return responseHelper.error(res, "order_id dan external_id wajib diisi", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await eWalletService.createTransaction(payload, type.toLowerCase());
|
||||||
|
|
||||||
|
return responseHelper.success(res, `Transaksi ${type.toUpperCase()} berhasil dibuat`, result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = EWalletController;
|
||||||
8
app/modules/e-wallet/routes/e-wallet.route.js
Normal file
8
app/modules/e-wallet/routes/e-wallet.route.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const eWalletController = require('../controllers/e-wallet.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/:type', eWalletController.charge);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
57
app/modules/e-wallet/services/e-wallet.service.js
Normal file
57
app/modules/e-wallet/services/e-wallet.service.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const midtransCore = require('../../../core/midtrans.service');
|
||||||
|
const paymentLogger = require('../../../helpers/payment_logger.helper');
|
||||||
|
const db = require('../../../../models/migration');
|
||||||
|
|
||||||
|
class EWalletService {
|
||||||
|
async createTransaction(payload, type) {
|
||||||
|
let { order_id, external_id, amount, customer_details } = payload;
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
let order = null;
|
||||||
|
if (order_id) order = await db.Order.findByPk(order_id);
|
||||||
|
if (!order && external_id) order = await db.Order.findOne({ where: { orderID: external_id } });
|
||||||
|
|
||||||
|
if (!order) throw new Error("Amount wajib diisi atau order_id/external_id tidak valid");
|
||||||
|
amount = order.total;
|
||||||
|
payload.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameter = {
|
||||||
|
payment_type: type,
|
||||||
|
transaction_details: {
|
||||||
|
order_id: external_id,
|
||||||
|
gross_amount: amount
|
||||||
|
},
|
||||||
|
customer_details: customer_details,
|
||||||
|
custom_expiry: {
|
||||||
|
expiry_duration: parseInt(process.env.MIDTRANS_EXPIRY_DURATION || 24),
|
||||||
|
unit: process.env.MIDTRANS_EXPIRY_UNIT || 'hour'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'gopay') {
|
||||||
|
parameter.gopay = {
|
||||||
|
enable_callback: true,
|
||||||
|
callback_url: "https://your-app.com/"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'shopeepay') {
|
||||||
|
parameter.shopeepay = {
|
||||||
|
callback_url: "https://your-app.com/"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getCoreApi().charge(parameter);
|
||||||
|
|
||||||
|
await paymentLogger.log(response, payload);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Midtrans Error (${type}): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new EWalletService();
|
||||||
19
app/modules/invoice/controllers/invoice.controller.js
Normal file
19
app/modules/invoice/controllers/invoice.controller.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
class InvoiceController {
|
||||||
|
static async getAll(req, res) {
|
||||||
|
try {
|
||||||
|
res.json({ message: "Mengambil semua data invoice" });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getById(req, res) {
|
||||||
|
try {
|
||||||
|
res.json({ message: `Mengambil data invoice dengan ID: ${req.params.id}` });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = InvoiceController;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const notificationService = require('../services/notification.service');
|
||||||
|
const responseHelper = require('../../../helpers/response.helper');
|
||||||
|
|
||||||
|
class NotificationController {
|
||||||
|
static async handle(req, res) {
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isValid = notificationService.verifySignature(payload);
|
||||||
|
if (!isValid) {
|
||||||
|
return responseHelper.error(res, "Invalid Signature Key", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await notificationService.processNotification(payload);
|
||||||
|
|
||||||
|
return responseHelper.success(res, "Notification Handled");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Webhook Error:", error.message);
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = NotificationController;
|
||||||
8
app/modules/notification/routes/notification.route.js
Normal file
8
app/modules/notification/routes/notification.route.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const notificationController = require('../controllers/notification.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/handling', notificationController.handle);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
93
app/modules/notification/services/notification.service.js
Normal file
93
app/modules/notification/services/notification.service.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const db = require('../../../../models/migration');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
verifySignature(payload) {
|
||||||
|
const { order_id, status_code, gross_amount, signature_key } = payload;
|
||||||
|
const serverKey = process.env.MIDTRANS_SERVER_KEY;
|
||||||
|
|
||||||
|
const data = order_id + status_code + gross_amount + serverKey;
|
||||||
|
const hash = crypto.createHash('sha512').update(data).digest('hex');
|
||||||
|
|
||||||
|
return hash === signature_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async processNotification(payload) {
|
||||||
|
const { order_id, transaction_status, fraud_status } = payload;
|
||||||
|
|
||||||
|
console.log(`[Notification] Processing Order ${order_id}: status=${transaction_status}, fraud=${fraud_status}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let payment = await db.Payment.findOne({
|
||||||
|
where: { external_id: order_id },
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
let order = null;
|
||||||
|
|
||||||
|
if (payment) {
|
||||||
|
order = await db.Order.findByPk(payment.order_id);
|
||||||
|
} else {
|
||||||
|
order = await db.Order.findOne({ where: { orderID: order_id } });
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
order = await db.Order.findByPk(order_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
console.warn(`[Notification] Data Order/Payment untuk ID ${order_id} tidak ditemukan.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderPaymentStatus = 'PENDING';
|
||||||
|
let orderStatus = order.status;
|
||||||
|
let paymentRecordStatus = 'PENDING';
|
||||||
|
|
||||||
|
if (transaction_status === 'settlement' || (transaction_status === 'capture' && fraud_status === 'accept')) {
|
||||||
|
orderPaymentStatus = 'PAID';
|
||||||
|
orderStatus = 'COMPLETED';
|
||||||
|
paymentRecordStatus = 'SUCCESS';
|
||||||
|
} else if (['deny', 'cancel', 'expire'].includes(transaction_status)) {
|
||||||
|
orderPaymentStatus = 'REJECT';
|
||||||
|
orderStatus = 'FAILED';
|
||||||
|
paymentRecordStatus = 'FAILED';
|
||||||
|
} else if (transaction_status === 'pending') {
|
||||||
|
orderPaymentStatus = 'PENDING';
|
||||||
|
paymentRecordStatus = 'PENDING';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.payment_status === orderPaymentStatus && order.status === orderStatus) {
|
||||||
|
console.log(`[Notification] Order ${order.id} sudah sinkron. Skip update.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await order.update({
|
||||||
|
payment_status: orderPaymentStatus,
|
||||||
|
status: orderStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
payment = await db.Payment.findOne({
|
||||||
|
where: { order_id: order.id },
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment) {
|
||||||
|
await payment.update({
|
||||||
|
status: paymentRecordStatus,
|
||||||
|
response_data: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Notification] SUCCESS: Order ${order.id} updated to ${orderStatus}, Payment updated to ${paymentRecordStatus}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Notification] Error: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new NotificationService();
|
||||||
24
app/modules/paylater/controllers/paylater.controller.js
Normal file
24
app/modules/paylater/controllers/paylater.controller.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const paylaterService = require('../services/paylater.service');
|
||||||
|
const responseHelper = require('../../../helpers/response.helper');
|
||||||
|
|
||||||
|
class PayLaterController {
|
||||||
|
static async charge(req, res) {
|
||||||
|
try {
|
||||||
|
const { type } = req.params;
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
if (!payload.order_id || !payload.external_id) {
|
||||||
|
return responseHelper.error(res, "order_id dan external_id wajib diisi", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await paylaterService.createTransaction(payload, type.toLowerCase());
|
||||||
|
|
||||||
|
return responseHelper.success(res, `Transaksi ${type.toUpperCase()} berhasil dibuat`, result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PayLaterController;
|
||||||
8
app/modules/paylater/routes/paylater.route.js
Normal file
8
app/modules/paylater/routes/paylater.route.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const paylaterController = require('../controllers/paylater.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/:type', paylaterController.charge);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
45
app/modules/paylater/services/paylater.service.js
Normal file
45
app/modules/paylater/services/paylater.service.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const midtransCore = require('../../../core/midtrans.service');
|
||||||
|
const paymentLogger = require('../../../helpers/payment_logger.helper');
|
||||||
|
const db = require('../../../../models/migration');
|
||||||
|
|
||||||
|
class PayLaterService {
|
||||||
|
async createTransaction(payload, type) {
|
||||||
|
let { order_id, external_id, amount, customer_details, item_details } = payload;
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
let order = null;
|
||||||
|
if (order_id) order = await db.Order.findByPk(order_id);
|
||||||
|
if (!order && external_id) order = await db.Order.findOne({ where: { orderID: external_id } });
|
||||||
|
|
||||||
|
if (!order) throw new Error("Amount wajib diisi atau order_id/external_id tidak valid");
|
||||||
|
amount = order.total;
|
||||||
|
payload.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameter = {
|
||||||
|
payment_type: type,
|
||||||
|
transaction_details: {
|
||||||
|
order_id: external_id,
|
||||||
|
gross_amount: amount
|
||||||
|
},
|
||||||
|
customer_details: customer_details,
|
||||||
|
item_details: item_details,
|
||||||
|
custom_expiry: {
|
||||||
|
expiry_duration: parseInt(process.env.MIDTRANS_EXPIRY_DURATION || 24),
|
||||||
|
unit: process.env.MIDTRANS_EXPIRY_UNIT || 'hour'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getCoreApi().charge(parameter);
|
||||||
|
|
||||||
|
await paymentLogger.log(response, payload);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Midtrans PayLater Error (${type}): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new PayLaterService();
|
||||||
23
app/modules/snap/controllers/snap.controller.js
Normal file
23
app/modules/snap/controllers/snap.controller.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const snapService = require('../services/snap.service');
|
||||||
|
const responseHelper = require('../../../helpers/response.helper');
|
||||||
|
|
||||||
|
class SnapController {
|
||||||
|
static async createToken(req, res) {
|
||||||
|
try {
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
if (!payload.order_id || !payload.external_id) {
|
||||||
|
return responseHelper.error(res, "order_id dan external_id wajib diisi", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await snapService.createTransaction(payload);
|
||||||
|
|
||||||
|
return responseHelper.success(res, "Snap Token berhasil dibuat", result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SnapController;
|
||||||
8
app/modules/snap/routes/snap.route.js
Normal file
8
app/modules/snap/routes/snap.route.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const snapController = require('../controllers/snap.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/token', snapController.createToken);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
45
app/modules/snap/services/snap.service.js
Normal file
45
app/modules/snap/services/snap.service.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const midtransCore = require('../../../core/midtrans.service');
|
||||||
|
const paymentLogger = require('../../../helpers/payment_logger.helper');
|
||||||
|
const db = require('../../../../models/migration');
|
||||||
|
|
||||||
|
class SnapService {
|
||||||
|
async createTransaction(payload) {
|
||||||
|
let { order_id, external_id, amount, customer_details, item_details } = payload;
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
let order = null;
|
||||||
|
if (order_id) order = await db.Order.findByPk(order_id);
|
||||||
|
if (!order && external_id) order = await db.Order.findOne({ where: { orderID: external_id } });
|
||||||
|
|
||||||
|
if (!order) throw new Error("Amount wajib diisi atau order_id/external_id tidak valid");
|
||||||
|
amount = order.total;
|
||||||
|
payload.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameter = {
|
||||||
|
transaction_details: {
|
||||||
|
order_id: external_id,
|
||||||
|
gross_amount: amount
|
||||||
|
},
|
||||||
|
customer_details: customer_details,
|
||||||
|
item_details: item_details,
|
||||||
|
usage_limit: 1,
|
||||||
|
expiry: {
|
||||||
|
duration: parseInt(process.env.MIDTRANS_EXPIRY_DURATION || 24),
|
||||||
|
unit: process.env.MIDTRANS_EXPIRY_UNIT || 'hour'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getSnap().createTransaction(parameter);
|
||||||
|
|
||||||
|
await paymentLogger.log(response, payload);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Midtrans Snap Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new SnapService();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const transactionService = require('../services/transaction.service');
|
||||||
|
const responseHelper = require('../../../helpers/response.helper');
|
||||||
|
|
||||||
|
class TransactionController {
|
||||||
|
static async getStatus(req, res) {
|
||||||
|
try {
|
||||||
|
const { order_id } = req.params;
|
||||||
|
const result = await transactionService.getStatus(order_id);
|
||||||
|
return responseHelper.success(res, "Status transaksi berhasil diambil", result);
|
||||||
|
} catch (error) {
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async refund(req, res) {
|
||||||
|
try {
|
||||||
|
const payload = req.body;
|
||||||
|
if (!payload.order_id || !payload.amount) {
|
||||||
|
return responseHelper.error(res, "order_id dan amount wajib diisi untuk refund", 400);
|
||||||
|
}
|
||||||
|
const result = await transactionService.refund(payload);
|
||||||
|
return responseHelper.success(res, "Permintaan refund berhasil diproses", result);
|
||||||
|
} catch (error) {
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async cancel(req, res) {
|
||||||
|
try {
|
||||||
|
const { order_id } = req.params;
|
||||||
|
const result = await transactionService.cancel(order_id);
|
||||||
|
return responseHelper.success(res, "Transaksi berhasil dibatalkan", result);
|
||||||
|
} catch (error) {
|
||||||
|
return responseHelper.error(res, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TransactionController;
|
||||||
10
app/modules/transaction/routes/transaction.route.js
Normal file
10
app/modules/transaction/routes/transaction.route.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const transactionController = require('../controllers/transaction.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/status/:order_id', transactionController.getStatus);
|
||||||
|
router.post('/refund', transactionController.refund);
|
||||||
|
router.post('/cancel/:order_id', transactionController.cancel);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
40
app/modules/transaction/services/transaction.service.js
Normal file
40
app/modules/transaction/services/transaction.service.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const midtransCore = require('../../../core/midtrans.service');
|
||||||
|
|
||||||
|
class TransactionService {
|
||||||
|
async getStatus(orderId) {
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getCoreApi().transaction.status(orderId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Gagal mengambil status transaksi: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refund(payload) {
|
||||||
|
const { order_id, amount, reason, refund_key } = payload;
|
||||||
|
|
||||||
|
const parameter = {
|
||||||
|
refund_key: refund_key || `ref-${order_id}-${Date.now()}`,
|
||||||
|
amount: amount,
|
||||||
|
reason: reason || "Customer request"
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getCoreApi().transaction.refund(order_id, parameter);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Gagal melakukan refund: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(orderId) {
|
||||||
|
try {
|
||||||
|
const response = await midtransCore.getCoreApi().transaction.cancel(orderId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Gagal membatalkan transaksi: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new TransactionService();
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/usr/src/app
|
||||||
|
- /usr/src/app/node_modules
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: midtrans_gateway
|
||||||
|
MYSQL_ROOT_PASSWORD:
|
||||||
|
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
49
index.js
Normal file
49
index.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
const bankTransferRoutes = require('./app/modules/bank-transfer/routes/bank-transfer.route');
|
||||||
|
const eWalletRoutes = require('./app/modules/e-wallet/routes/e-wallet.route');
|
||||||
|
const notificationRoutes = require('./app/modules/notification/routes/notification.route');
|
||||||
|
const cstoreRoutes = require('./app/modules/cstore/routes/cstore.route');
|
||||||
|
const creditCardRoutes = require('./app/modules/credit-card/routes/credit-card.route');
|
||||||
|
const paylaterRoutes = require('./app/modules/paylater/routes/paylater.route');
|
||||||
|
const snapRoutes = require('./app/modules/snap/routes/snap.route');
|
||||||
|
const transactionRoutes = require('./app/modules/transaction/routes/transaction.route');
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.use('/api/v1/bank-transfer', bankTransferRoutes);
|
||||||
|
app.use('/api/v1/e-wallet', eWalletRoutes);
|
||||||
|
app.use('/api/v1/notification', notificationRoutes);
|
||||||
|
app.use('/api/v1/cstore', cstoreRoutes);
|
||||||
|
app.use('/api/v1/credit-card', creditCardRoutes);
|
||||||
|
app.use('/api/v1/paylater', paylaterRoutes);
|
||||||
|
app.use('/api/v1/snap', snapRoutes);
|
||||||
|
app.use('/api/v1/transaction', transactionRoutes);
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send('Midtrans Gateway API is running...');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, res) => {
|
||||||
|
const responseHelper = require('./app/helpers/response.helper');
|
||||||
|
return responseHelper.error(res, "Endpoint tidak ditemukan", 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
const responseHelper = require('./app/helpers/response.helper');
|
||||||
|
|
||||||
|
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||||
|
return responseHelper.error(res, "Format JSON tidak valid: " + err.message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[GlobalError]", err);
|
||||||
|
return responseHelper.error(res, err.message || "Terjadi kesalahan internal pada server", err.status || 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server is running on port ${port}`);
|
||||||
|
});
|
||||||
13
jest.config.js
Normal file
13
jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
verbose: true,
|
||||||
|
collectCoverage: true,
|
||||||
|
coverageDirectory: 'coverage',
|
||||||
|
coveragePathIgnorePatterns: [
|
||||||
|
'/node_modules/',
|
||||||
|
'/tests/mocks/'
|
||||||
|
],
|
||||||
|
testMatch: [
|
||||||
|
'<rootDir>/tests/**/*.test.js'
|
||||||
|
]
|
||||||
|
};
|
||||||
661
midtrans-gateway.postman_collection.json
Normal file
661
midtrans-gateway.postman_collection.json
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"_postman_id": "midtrans-gateway",
|
||||||
|
"name": "Midtrans Gateway",
|
||||||
|
"description": "# 🏆 Midtrans Gateway\n\nKoleksi paling komprehensif yang mencakup **SELURUH** metode pembayaran Midtrans yang didukung oleh gateway ini secara modular.\n\n## 📋 Daftar Isi\n1. **Bank Transfer (Virtual Account)**: BCA, BNI, BRI, Mandiri, Permata, CIMB, Danamon, BSI.\n2. **E-Wallet & QRIS**: GoPay, ShopeePay, QRIS, DANA, Google Pay.\n3. **Retail Store (CStore)**: Indomaret, Alfamart.\n4. **Credit Card**: Secure Charge API.\n5. **PayLater (BNPL)**: Akulaku, Kredivo, Indodana.\n\n--- \n*Dokumentasi disusun oleh Senior Fullstack Engineer*",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "1. Bank Transfer (VA)",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "BCA Virtual Account",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-BCA-{{$timestamp}}\",\n \"amount\": 10000 // optional\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/bank-transfer/bca",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"bank-transfer",
|
||||||
|
"bca"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BNI Virtual Account",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-BNI-{{$timestamp}}\",\n \"amount\": 10000 // optional\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/bank-transfer/bni",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"bank-transfer",
|
||||||
|
"bni"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BRI Virtual Account",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-BRI-{{$timestamp}}\",\n \"amount\": 10000 // optional\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/bank-transfer/bri",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"bank-transfer",
|
||||||
|
"bri"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mandiri (E-Channel)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-MANDIRI-{{$timestamp}}\",\n \"amount\": 10000 // optional\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/bank-transfer/mandiri",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"bank-transfer",
|
||||||
|
"mandiri"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Permata Virtual Account",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-PERMATA-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/bank-transfer/permata",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"bank-transfer",
|
||||||
|
"permata"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CIMB Virtual Account",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-CIMB-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/bank-transfer/cimb",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"bank-transfer",
|
||||||
|
"cimb"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Danamon Virtual Account",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-DANAMON-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/bank-transfer/danamon",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"bank-transfer",
|
||||||
|
"danamon"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BSI Virtual Account",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-BSI-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/bank-transfer/bsi",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"bank-transfer",
|
||||||
|
"bsi"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2. E-Wallet",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "GoPay",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-GOPAY-{{$timestamp}}\",\n \"amount\": 10000 // optional\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/e-wallet/gopay",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"e-wallet",
|
||||||
|
"gopay"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShopeePay",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-SHOPEE-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/e-wallet/shopeepay",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"e-wallet",
|
||||||
|
"shopeepay"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "QRIS (Semua E-Wallet)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-QRIS-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/e-wallet/qris",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"e-wallet",
|
||||||
|
"qris"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DANA",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-DANA-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/e-wallet/dana",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"e-wallet",
|
||||||
|
"dana"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3. Retail Store",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Indomaret",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-INDO-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/cstore/indomaret",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"cstore",
|
||||||
|
"indomaret"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alfamart (Group)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-ALFA-{{$timestamp}}\",\n \"amount\": 10000\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/cstore/alfamart",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"cstore",
|
||||||
|
"alfamart"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4. PayLater (BNPL)",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Akulaku",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-AKU-{{$timestamp}}\",\n \"amount\": 100000,\n \"item_details\": [{ \"id\": \"1\", \"price\": 100000, \"quantity\": 1, \"name\": \"Test Item\" }],\n \"customer_details\": { \"first_name\": \"User\", \"email\": \"test@example.com\" }\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/paylater/akulaku",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"paylater",
|
||||||
|
"akulaku"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kredivo",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-KRE-{{$timestamp}}\",\n \"amount\": 100000,\n \"item_details\": [{ \"id\": \"1\", \"price\": 100000, \"quantity\": 1, \"name\": \"Test Item\" }]\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/paylater/kredivo",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"paylater",
|
||||||
|
"kredivo"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Indodana",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-IND-{{$timestamp}}\",\n \"amount\": 100000,\n \"item_details\": [{ \"id\": \"1\", \"price\": 100000, \"quantity\": 1, \"name\": \"Test Item\" }]\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/paylater/indodana",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"paylater",
|
||||||
|
"indodana"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "5. Credit Card",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Charge Credit Card",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-CC-{{$timestamp}}\",\n \"amount\": 500000,\n \"credit_card_token\": \"ISI_TOKEN_DARI_FRONTEND\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/credit-card/charge",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"credit-card",
|
||||||
|
"charge"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "6. Snap Modal (Tokenization)",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Snap Token",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"{{$guid}}\",\n \"external_id\": \"INV-SNAP-{{$timestamp}}\",\n \"amount\": 250000, // optional\n \"customer_details\": {\n \"first_name\": \"User\",\n \"email\": \"test@example.com\"\n },\n \"item_details\": [\n {\n \"id\": \"item1\",\n \"price\": 250000,\n \"quantity\": 1,\n \"name\": \"Product Snap Test\"\n }\n ]\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/snap/token",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"snap",
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "7. Transaction Management",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Check Status",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/transaction/status/ORDER_ID_ANDA",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"transaction",
|
||||||
|
"status",
|
||||||
|
"ORDER_ID_ANDA"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Refund Transaction",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"ORDER_ID_PENDING_REFUND\",\n \"amount\": 5000, // optional\n \"reason\": \"Refund requested by user\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/transaction/refund",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"transaction",
|
||||||
|
"refund"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cancel Transaction",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/transaction/cancel/ORDER_ID_ANDA",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"transaction",
|
||||||
|
"cancel",
|
||||||
|
"ORDER_ID_ANDA"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "8. Notification (Webhook)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"transaction_status\": \"settlement\",\n \"status_code\": \"200\",\n \"order_id\": \"INV-TEST-123\",\n \"gross_amount\": \"10000.00\",\n \"signature_key\": \"ISI_SIGNATURE_SESUAI_RUMUS_SHA512\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/v1/notification/handling",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"v1",
|
||||||
|
"notification",
|
||||||
|
"handling"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "base_url",
|
||||||
|
"value": "http://localhost:3000",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
models
Submodule
1
models
Submodule
Submodule models added at db19e8cdc9
5878
package-lock.json
generated
Normal file
5878
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "midtrans-gateway",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Midtrans Payment Gateway Node.js Implementation",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "nodemon index.js",
|
||||||
|
"test": "jest",
|
||||||
|
"test:coverage": "jest --coverage"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"midtrans",
|
||||||
|
"payment",
|
||||||
|
"gateway",
|
||||||
|
"node.js",
|
||||||
|
"express"
|
||||||
|
],
|
||||||
|
"author": "Senior Fullstack Engineer",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"midtrans-client": "^1.3.1",
|
||||||
|
"mysql2": "^3.0.0",
|
||||||
|
"sequelize": "^6.37.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"nodemon": "^3.0.0",
|
||||||
|
"supertest": "^7.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
tests/unit/bank-transfer.service.test.js
Normal file
56
tests/unit/bank-transfer.service.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const mockCharge = jest.fn();
|
||||||
|
jest.mock('../../app/core/midtrans.service', () => ({
|
||||||
|
getCoreApi: jest.fn().mockReturnValue({
|
||||||
|
charge: mockCharge,
|
||||||
|
transaction: { status: jest.fn(), refund: jest.fn(), cancel: jest.fn() }
|
||||||
|
}),
|
||||||
|
getSnap: jest.fn().mockReturnValue({ createTransaction: jest.fn() })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOrderFindByPk = jest.fn();
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Order: { findByPk: mockOrderFindByPk, findOne: jest.fn(), update: jest.fn() },
|
||||||
|
Payment: { findOne: jest.fn(), create: jest.fn(), update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bankTransferService = require('../../app/modules/bank-transfer/services/bank-transfer.service');
|
||||||
|
const db = require('../../models/migration');
|
||||||
|
|
||||||
|
describe('BankTransferService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
process.env.MIDTRANS_EXPIRY_DURATION = '1';
|
||||||
|
process.env.MIDTRANS_EXPIRY_UNIT = 'hour';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a bank transfer transaction (BCA) successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-001', amount: 10000 };
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
const result = await bankTransferService.createTransaction(payload, 'bca');
|
||||||
|
expect(mockCharge).toHaveBeenCalledWith(expect.objectContaining({ payment_type: 'bank_transfer' }));
|
||||||
|
expect(result.status_code).toBe('201');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a mandiri transaction (echannel) successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-001', amount: 10000 };
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
await bankTransferService.createTransaction(payload, 'mandiri');
|
||||||
|
expect(mockCharge).toHaveBeenCalledWith(expect.objectContaining({ payment_type: 'echannel' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to order total', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-001' };
|
||||||
|
mockOrderFindByPk.mockResolvedValue({ total: 50000 });
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
await bankTransferService.createTransaction(payload, 'bca');
|
||||||
|
expect(mockCharge).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
transaction_details: expect.objectContaining({ gross_amount: 50000 })
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if order not found and amount missing', async () => {
|
||||||
|
mockOrderFindByPk.mockResolvedValue(null);
|
||||||
|
await expect(bankTransferService.createTransaction({ order_id: 'wrong' }, 'bca'))
|
||||||
|
.rejects.toThrow('Amount wajib diisi');
|
||||||
|
});
|
||||||
|
});
|
||||||
45
tests/unit/credit-card.service.test.js
Normal file
45
tests/unit/credit-card.service.test.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const mockCharge = jest.fn();
|
||||||
|
jest.mock('../../app/core/midtrans.service', () => ({
|
||||||
|
getCoreApi: jest.fn().mockReturnValue({
|
||||||
|
charge: mockCharge,
|
||||||
|
transaction: { status: jest.fn(), refund: jest.fn(), cancel: jest.fn() }
|
||||||
|
}),
|
||||||
|
getSnap: jest.fn().mockReturnValue({ createTransaction: jest.fn() })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOrderFindByPk = jest.fn();
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Order: { findByPk: mockOrderFindByPk, findOne: jest.fn(), update: jest.fn() },
|
||||||
|
Payment: { findOne: jest.fn(), create: jest.fn(), update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const creditCardService = require('../../app/modules/credit-card/services/credit-card.service');
|
||||||
|
|
||||||
|
describe('CreditCardService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a credit card transaction successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-cc', external_id: 'INV-CC-001', amount: 500000, credit_card_token: 't-123' };
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201', transaction_status: 'capture' });
|
||||||
|
const result = await creditCardService.createTransaction(payload);
|
||||||
|
expect(mockCharge).toHaveBeenCalled();
|
||||||
|
expect(result.status_code).toBe('201');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to order total if amount is missing', async () => {
|
||||||
|
const payload = { order_id: 'u-cc', external_id: 'INV-CC-001', credit_card_token: 't-123' };
|
||||||
|
mockOrderFindByPk.mockResolvedValue({ total: 500000 });
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201', transaction_status: 'capture' });
|
||||||
|
|
||||||
|
await creditCardService.createTransaction(payload);
|
||||||
|
|
||||||
|
expect(mockOrderFindByPk).toHaveBeenCalledWith('u-cc');
|
||||||
|
expect(mockCharge).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
transaction_details: expect.objectContaining({
|
||||||
|
gross_amount: 500000
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
35
tests/unit/cstore.service.test.js
Normal file
35
tests/unit/cstore.service.test.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const mockCharge = jest.fn();
|
||||||
|
jest.mock('../../app/core/midtrans.service', () => ({
|
||||||
|
getCoreApi: jest.fn().mockReturnValue({
|
||||||
|
charge: mockCharge,
|
||||||
|
transaction: { status: jest.fn(), refund: jest.fn(), cancel: jest.fn() }
|
||||||
|
}),
|
||||||
|
getSnap: jest.fn().mockReturnValue({ createTransaction: jest.fn() })
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Order: { findByPk: jest.fn(), findOne: jest.fn(), update: jest.fn() },
|
||||||
|
Payment: { findOne: jest.fn(), create: jest.fn(), update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cstoreService = require('../../app/modules/cstore/services/cstore.service');
|
||||||
|
|
||||||
|
describe('CStoreService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an indomaret transaction successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-I-001', amount: 50000 };
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
const result = await cstoreService.createTransaction(payload, 'indomaret');
|
||||||
|
expect(mockCharge).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an alfamart transaction successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-A-001', amount: 30000 };
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
const result = await cstoreService.createTransaction(payload, 'alfamart');
|
||||||
|
expect(mockCharge).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
36
tests/unit/e-wallet.service.test.js
Normal file
36
tests/unit/e-wallet.service.test.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const mockCharge = jest.fn();
|
||||||
|
jest.mock('../../app/core/midtrans.service', () => ({
|
||||||
|
getCoreApi: jest.fn().mockReturnValue({
|
||||||
|
charge: mockCharge,
|
||||||
|
transaction: { status: jest.fn(), refund: jest.fn(), cancel: jest.fn() }
|
||||||
|
}),
|
||||||
|
getSnap: jest.fn().mockReturnValue({ createTransaction: jest.fn() })
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Order: { findByPk: jest.fn(), findOne: jest.fn(), update: jest.fn() },
|
||||||
|
Payment: { findOne: jest.fn(), create: jest.fn(), update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const eWalletService = require('../../app/modules/e-wallet/services/e-wallet.service');
|
||||||
|
|
||||||
|
describe('EWalletService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a gopay transaction successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-G-001', amount: 25000 };
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
const result = await eWalletService.createTransaction(payload, 'gopay');
|
||||||
|
expect(mockCharge).toHaveBeenCalled();
|
||||||
|
expect(result.status_code).toBe('201');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a shopeepay transaction successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-S-001', amount: 15000 };
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
const result = await eWalletService.createTransaction(payload, 'shopeepay');
|
||||||
|
expect(mockCharge).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
tests/unit/notification.controller.test.js
Normal file
53
tests/unit/notification.controller.test.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const notificationService = require('../../app/modules/notification/services/notification.service');
|
||||||
|
const responseHelper = require('../../app/helpers/response.helper');
|
||||||
|
|
||||||
|
jest.mock('../../app/modules/notification/services/notification.service');
|
||||||
|
jest.mock('../../app/helpers/response.helper');
|
||||||
|
|
||||||
|
const NotificationController = require('../../app/modules/notification/controllers/notification.controller');
|
||||||
|
|
||||||
|
describe('NotificationController', () => {
|
||||||
|
let mockReq, mockRes;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockRes = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle notification successfully', async () => {
|
||||||
|
mockReq = {
|
||||||
|
body: { order_id: 'I-123', transaction_status: 'settlement' }
|
||||||
|
};
|
||||||
|
|
||||||
|
notificationService.verifySignature.mockReturnValue(true);
|
||||||
|
notificationService.processNotification.mockResolvedValue();
|
||||||
|
|
||||||
|
await NotificationController.handle(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(notificationService.verifySignature).toHaveBeenCalledWith(mockReq.body);
|
||||||
|
expect(notificationService.processNotification).toHaveBeenCalledWith(mockReq.body);
|
||||||
|
expect(responseHelper.success).toHaveBeenCalledWith(mockRes, expect.stringContaining('Handled'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 if signature invalid', async () => {
|
||||||
|
mockReq = { body: { signature_key: 'invalid' } };
|
||||||
|
notificationService.verifySignature.mockReturnValue(false);
|
||||||
|
|
||||||
|
await NotificationController.handle(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(responseHelper.error).toHaveBeenCalledWith(mockRes, expect.stringContaining('Invalid Signature'), 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error on service error', async () => {
|
||||||
|
mockReq = { body: { order_id: 'I-123' } };
|
||||||
|
notificationService.verifySignature.mockReturnValue(true);
|
||||||
|
notificationService.processNotification.mockRejectedValue(new Error('DB Error'));
|
||||||
|
|
||||||
|
await NotificationController.handle(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(responseHelper.error).toHaveBeenCalledWith(mockRes, expect.stringContaining('DB Error'));
|
||||||
|
});
|
||||||
|
});
|
||||||
93
tests/unit/notification.service.test.js
Normal file
93
tests/unit/notification.service.test.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const mockPaymentFindOne = jest.fn();
|
||||||
|
const mockOrderFindByPk = jest.fn();
|
||||||
|
const mockOrderFindOne = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../../models/migration', () => ({
|
||||||
|
Payment: { findOne: mockPaymentFindOne, update: jest.fn() },
|
||||||
|
Order: { findByPk: mockOrderFindByPk, findOne: mockOrderFindOne, update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Payment: { findOne: mockPaymentFindOne, update: jest.fn() },
|
||||||
|
Order: { findByPk: mockOrderFindByPk, findOne: mockOrderFindOne, update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notificationService = require('../../app/modules/notification/services/notification.service');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
describe('NotificationService', () => {
|
||||||
|
const serverKey = 'SB-Mid-server-test';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
process.env.MIDTRANS_SERVER_KEY = serverKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifySignature', () => {
|
||||||
|
it('should return true for a valid signature', () => {
|
||||||
|
const payload = { order_id: 'I-123', status_code: '200', gross_amount: '10.00', signature_key: '' };
|
||||||
|
const data = payload.order_id + payload.status_code + payload.gross_amount + serverKey;
|
||||||
|
payload.signature_key = crypto.createHash('sha512').update(data).digest('hex');
|
||||||
|
expect(notificationService.verifySignature(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid signature', () => {
|
||||||
|
const payload = { order_id: 'I-123', status_code: '200', gross_amount: '10.00', signature_key: 'wrong' };
|
||||||
|
expect(notificationService.verifySignature(payload)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processNotification', () => {
|
||||||
|
it('should update order to COMPLETED on settlement', async () => {
|
||||||
|
const payload = { order_id: 'I-123', transaction_status: 'settlement', fraud_status: 'accept' };
|
||||||
|
const mockUpdateOrder = jest.fn();
|
||||||
|
const mockUpdatePayment = jest.fn();
|
||||||
|
const mockPayment = { order_id: 'u-123', update: mockUpdatePayment };
|
||||||
|
const mockOrder = { id: 'u-123', status: 'PENDING', payment_status: 'PENDING', update: mockUpdateOrder };
|
||||||
|
|
||||||
|
mockPaymentFindOne.mockResolvedValue(mockPayment);
|
||||||
|
mockOrderFindByPk.mockResolvedValue(mockOrder);
|
||||||
|
|
||||||
|
await notificationService.processNotification(payload);
|
||||||
|
expect(mockUpdateOrder).toHaveBeenCalledWith(expect.objectContaining({ status: 'COMPLETED', payment_status: 'PAID' }));
|
||||||
|
expect(mockUpdatePayment).toHaveBeenCalledWith(expect.objectContaining({ status: 'SUCCESS' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update order to FAILED on deny/cancel/expire', async () => {
|
||||||
|
const payload = { order_id: 'I-123', transaction_status: 'expire' };
|
||||||
|
const mockUpdate = jest.fn();
|
||||||
|
const mockOrder = { id: 'u-123', status: 'PENDING', update: mockUpdate };
|
||||||
|
|
||||||
|
mockPaymentFindOne.mockResolvedValue(null);
|
||||||
|
mockOrderFindOne.mockResolvedValue(mockOrder);
|
||||||
|
|
||||||
|
await notificationService.processNotification(payload);
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: 'FAILED', payment_status: 'REJECT' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update to PENDING on pending status', async () => {
|
||||||
|
const payload = { order_id: 'I-123', transaction_status: 'pending' };
|
||||||
|
const mockUpdate = jest.fn();
|
||||||
|
const mockOrder = { id: 'u-123', status: 'PENDING', payment_status: 'PENDING', update: mockUpdate };
|
||||||
|
|
||||||
|
mockPaymentFindOne.mockResolvedValue(null);
|
||||||
|
mockOrderFindOne.mockResolvedValue(mockOrder);
|
||||||
|
|
||||||
|
await notificationService.processNotification(payload);
|
||||||
|
// If already pending, it should skip update
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn if order not found', async () => {
|
||||||
|
const payload = { order_id: 'unknown' };
|
||||||
|
mockPaymentFindOne.mockResolvedValue(null);
|
||||||
|
mockOrderFindOne.mockResolvedValue(null);
|
||||||
|
mockOrderFindByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
|
await notificationService.processNotification(payload);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('tidak ditemukan'));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
tests/unit/paylater.service.test.js
Normal file
46
tests/unit/paylater.service.test.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const mockCharge = jest.fn();
|
||||||
|
jest.mock('../../app/core/midtrans.service', () => ({
|
||||||
|
getCoreApi: jest.fn().mockReturnValue({
|
||||||
|
charge: mockCharge,
|
||||||
|
transaction: { status: jest.fn(), refund: jest.fn(), cancel: jest.fn() }
|
||||||
|
}),
|
||||||
|
getSnap: jest.fn().mockReturnValue({ createTransaction: jest.fn() })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOrderFindByPk = jest.fn();
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Order: { findByPk: mockOrderFindByPk, findOne: jest.fn(), update: jest.fn() },
|
||||||
|
Payment: { findOne: jest.fn(), create: jest.fn(), update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const paylaterService = require('../../app/modules/paylater/services/paylater.service');
|
||||||
|
|
||||||
|
describe('PayLaterService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an akulaku transaction successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-AK-001', amount: 1000000 };
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
await paylaterService.createTransaction(payload, 'akulaku');
|
||||||
|
expect(mockCharge).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
payment_type: 'akulaku'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to order total if amount is missing', async () => {
|
||||||
|
const payload = { order_id: 'u-123', external_id: 'INV-AK-001' };
|
||||||
|
mockOrderFindByPk.mockResolvedValue({ total: 1000000 });
|
||||||
|
mockCharge.mockResolvedValue({ status_code: '201' });
|
||||||
|
|
||||||
|
await paylaterService.createTransaction(payload, 'kredivo');
|
||||||
|
|
||||||
|
expect(mockOrderFindByPk).toHaveBeenCalledWith('u-123');
|
||||||
|
expect(mockCharge).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
transaction_details: expect.objectContaining({
|
||||||
|
gross_amount: 1000000
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
63
tests/unit/payment_logger.helper.test.js
Normal file
63
tests/unit/payment_logger.helper.test.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const mockOrderFindByPk = jest.fn();
|
||||||
|
const mockOrderFindOne = jest.fn();
|
||||||
|
const mockPaymentCreate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Order: { findByPk: mockOrderFindByPk, findOne: mockOrderFindOne },
|
||||||
|
Payment: { create: mockPaymentCreate }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const paymentLogger = require('../../app/helpers/payment_logger.helper');
|
||||||
|
|
||||||
|
describe('PaymentLoggerHelper', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log a payment successfully', async () => {
|
||||||
|
const midtransResponse = {
|
||||||
|
order_id: 'INV-EXT-001',
|
||||||
|
gross_amount: '10000.00',
|
||||||
|
transaction_status: 'settlement',
|
||||||
|
payment_type: 'credit_card',
|
||||||
|
status_message: 'Success'
|
||||||
|
};
|
||||||
|
const requestPayload = { order_id: 'u-123' };
|
||||||
|
|
||||||
|
mockOrderFindByPk.mockResolvedValue({ id: 'u-123', total: 10000, user_id: 1 });
|
||||||
|
mockPaymentCreate.mockResolvedValue({ id: 'p-123' });
|
||||||
|
|
||||||
|
const result = await paymentLogger.log(midtransResponse, requestPayload);
|
||||||
|
|
||||||
|
expect(mockOrderFindByPk).toHaveBeenCalledWith('u-123');
|
||||||
|
expect(mockPaymentCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
status: 'SUCCESS',
|
||||||
|
channel_code: 'credit_card',
|
||||||
|
channel_type: 'CREDIT_CARD'
|
||||||
|
}));
|
||||||
|
expect(result.id).toBe('p-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map bank transfer types correctly', async () => {
|
||||||
|
const midtransResponse = { order_id: 'INV-EXT', payment_type: 'bank_transfer', transaction_status: 'pending' };
|
||||||
|
mockOrderFindOne.mockResolvedValue({ id: 'u-123', total: 10000 });
|
||||||
|
|
||||||
|
await paymentLogger.log(midtransResponse);
|
||||||
|
|
||||||
|
expect(mockPaymentCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
channel_type: 'BANK'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if order not found', async () => {
|
||||||
|
mockOrderFindByPk.mockResolvedValue(null);
|
||||||
|
mockOrderFindOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||||
|
const result = await paymentLogger.log({ order_id: 'none' });
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
32
tests/unit/response.helper.test.js
Normal file
32
tests/unit/response.helper.test.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const ResponseHelper = require('../../app/helpers/response.helper');
|
||||||
|
|
||||||
|
describe('ResponseHelper', () => {
|
||||||
|
let mockRes;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRes = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send success response', () => {
|
||||||
|
ResponseHelper.success(mockRes, 'Success test', { id: 1 });
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
message: 'Success test',
|
||||||
|
data: { id: 1 }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send error response', () => {
|
||||||
|
ResponseHelper.error(mockRes, 'Error test', 400, { field: 'required' });
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
message: 'Error test',
|
||||||
|
errors: { field: 'required' }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
28
tests/unit/snap.service.test.js
Normal file
28
tests/unit/snap.service.test.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const mockCreateTransaction = jest.fn();
|
||||||
|
jest.mock('../../app/core/midtrans.service', () => ({
|
||||||
|
getCoreApi: jest.fn(),
|
||||||
|
getSnap: jest.fn().mockReturnValue({
|
||||||
|
createTransaction: mockCreateTransaction
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Order: { findByPk: jest.fn(), findOne: jest.fn(), update: jest.fn() },
|
||||||
|
Payment: { findOne: jest.fn(), create: jest.fn(), update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const snapService = require('../../app/modules/snap/services/snap.service');
|
||||||
|
|
||||||
|
describe('SnapService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a snap transaction successfully', async () => {
|
||||||
|
const payload = { order_id: 'u-snap', external_id: 'INV-S-001', amount: 100000 };
|
||||||
|
mockCreateTransaction.mockResolvedValue({ token: 't-123', redirect_url: 'http://m.com' });
|
||||||
|
const result = await snapService.createTransaction(payload);
|
||||||
|
expect(mockCreateTransaction).toHaveBeenCalled();
|
||||||
|
expect(result.token).toBe('t-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
34
tests/unit/transaction.service.test.js
Normal file
34
tests/unit/transaction.service.test.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const mockStatus = jest.fn();
|
||||||
|
const mockRefund = jest.fn();
|
||||||
|
jest.mock('../../app/core/midtrans.service', () => ({
|
||||||
|
getCoreApi: jest.fn().mockReturnValue({
|
||||||
|
transaction: { status: mockStatus, refund: mockRefund, cancel: jest.fn() }
|
||||||
|
}),
|
||||||
|
getSnap: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../models/migration', () => ({
|
||||||
|
Order: { findByPk: jest.fn(), findOne: jest.fn(), update: jest.fn() },
|
||||||
|
Payment: { findOne: jest.fn(), create: jest.fn(), update: jest.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const transactionService = require('../../app/modules/transaction/services/transaction.service');
|
||||||
|
|
||||||
|
describe('TransactionService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get transaction status successfully', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ transaction_status: 'settlement' });
|
||||||
|
const result = await transactionService.getStatus('INV-123');
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith('INV-123');
|
||||||
|
expect(result.transaction_status).toBe('settlement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process refund successfully', async () => {
|
||||||
|
mockRefund.mockResolvedValue({ status_code: '200' });
|
||||||
|
const result = await transactionService.refund({ order_id: 'INV-123', external_id: 'INV-123', amount: 5000 });
|
||||||
|
expect(mockRefund).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user