first commit

This commit is contained in:
2026-02-18 16:03:43 +07:00
commit c52ecd6514
54 changed files with 8270 additions and 0 deletions

19
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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();

View 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;

View 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();

View 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;

View File

@@ -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;

View 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;

View 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();

View File

@@ -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;

View 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;

View 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();

View 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;

View 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;

View 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();

View 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;

View 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;

View 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();

View 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;

View File

@@ -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;

View 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;

View 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();

View 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;

View 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;

View 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();

View 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;

View 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;

View 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();

View File

@@ -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;

View 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;

View 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
View 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
View 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
View 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'
]
};

View 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

Submodule models added at db19e8cdc9

5878
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View 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"
}
}

View 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');
});
});

View 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
})
}));
});
});

View 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();
});
});

View 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();
});
});

View 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'));
});
});

View 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();
});
});
});

View 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
})
}));
});
});

View 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();
});
});

View 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' }
}));
});
});

View 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');
});
});

View 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();
});
});