feat: add fs and path dependencies; update server logging and webhook handling
This commit is contained in:
parent
e70fd7be81
commit
405f150724
|
|
@ -16,7 +16,9 @@
|
|||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"fs": "^0.0.1-security",
|
||||
"midtrans-client": "^1.4.3",
|
||||
"path": "^0.12.7",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.66.0",
|
||||
|
|
@ -3318,6 +3320,12 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs": {
|
||||
"version": "0.0.1-security",
|
||||
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
|
||||
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -4331,6 +4339,16 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path": {
|
||||
"version": "0.12.7",
|
||||
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"process": "^0.11.1",
|
||||
"util": "^0.10.3"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
|
|
@ -4538,6 +4556,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
|
|
@ -5351,6 +5378,15 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
|
||||
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
@ -5358,6 +5394,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util/node_modules/inherits": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@
|
|||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"fs": "^0.0.1-security",
|
||||
"midtrans-client": "^1.4.3",
|
||||
"path": "^0.12.7",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.66.0",
|
||||
|
|
|
|||
441
server/index.cjs
441
server/index.cjs
|
|
@ -1,11 +1,6 @@
|
|||
/**
|
||||
* Simaya Midtrans Payment Server
|
||||
*
|
||||
* Backend Express.js server untuk integrasi pembayaran Midtrans dengan sistem ERP.
|
||||
* Mendukung CORE API dan SNAP modes dengan unified webhook handler.
|
||||
*
|
||||
* @author Simaya Team
|
||||
* @version 2.0.0
|
||||
* SIMAYA MIDTRANS PAYMENT SERVER V2.0
|
||||
* EXPRESS BACKEND UNTUK INTEGRASI MIDTRANS (CORE + SNAP) DENGAN ERP
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
|
|
@ -14,13 +9,14 @@ const dotenv = require('dotenv')
|
|||
const midtransClient = require('midtrans-client')
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
dotenv.config()
|
||||
|
||||
// ============================================================================
|
||||
// TRANSACTION LOGGER
|
||||
// TRANSACTION LOGGER - PAYMENT LIFECYCLE TRACKING
|
||||
// ============================================================================
|
||||
// Shared logger functionality untuk tracking payment lifecycle
|
||||
const TransactionLogger = {
|
||||
logPaymentInit: (mode, orderId, amount) => logInfo(`[${mode}] payment.init`, { orderId, amount }),
|
||||
logPaymentSuccess: (mode, orderId, transactionId) => logInfo(`[${mode}] payment.success`, { orderId, transactionId }),
|
||||
|
|
@ -47,7 +43,7 @@ if (!serverKey || !clientKey) {
|
|||
console.warn('[Midtrans] Missing server/client keys in environment variables')
|
||||
}
|
||||
|
||||
// Initialize Midtrans Core API client
|
||||
// INITIALIZE MIDTRANS CORE API INSTANCE
|
||||
const core = new midtransClient.CoreApi({
|
||||
isProduction,
|
||||
serverKey,
|
||||
|
|
@ -58,10 +54,7 @@ const core = new midtransClient.CoreApi({
|
|||
// ERP INTEGRATION CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse boolean-like environment variable values
|
||||
* Supports: true/false, 1/0, yes/no, on/off
|
||||
*/
|
||||
// PARSE BOOLEAN VALUES (TRUE/FALSE, 1/0, YES/NO, ON/OFF)
|
||||
function parseEnable(v) {
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim().toLowerCase()
|
||||
|
|
@ -72,9 +65,7 @@ function parseEnable(v) {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse comma-separated list from environment variable
|
||||
*/
|
||||
// PARSE COMMA-SEPARATED LIST
|
||||
function parseList(value) {
|
||||
if (!value) return []
|
||||
return String(value)
|
||||
|
|
@ -83,19 +74,19 @@ function parseList(value) {
|
|||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// ERP notification settings
|
||||
// ERP NOTIFICATION SETTINGS
|
||||
const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || ''
|
||||
const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF)
|
||||
const ERP_CLIENT_SECRET = process.env.ERP_CLIENT_SECRET || process.env.ERP_CLIENT_ID || ''
|
||||
|
||||
// Support multiple ERP endpoints (comma-separated)
|
||||
// MULTIPLE ERP ENDPOINTS SUPPORT (COMMA-SEPARATED)
|
||||
const ERP_NOTIFICATION_URLS = (() => {
|
||||
const multi = parseList(process.env.ERP_NOTIFICATION_URLS)
|
||||
if (multi.length > 0) return multi
|
||||
return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : []
|
||||
})()
|
||||
|
||||
// In-memory tracking untuk prevent duplicate notifications
|
||||
// IN-MEMORY TRACKING UNTUK PREVENT DUPLICATE NOTIFICATIONS
|
||||
const notifiedOrders = new Set()
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -106,18 +97,44 @@ const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
|
|||
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
|
||||
const LOG_EXPOSE_API = parseEnable(process.env.LOG_EXPOSE_API)
|
||||
const LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10)
|
||||
const LOG_TO_FILE = parseEnable(process.env.LOG_TO_FILE ?? 'true') // Default enabled
|
||||
const LOG_TO_CONSOLE = parseEnable(process.env.LOG_TO_CONSOLE ?? 'false') // Default disabled
|
||||
const recentLogs = []
|
||||
|
||||
/**
|
||||
* Check if message should be logged based on configured level
|
||||
*/
|
||||
// LOG DIRECTORY SETUP
|
||||
const LOG_DIR = path.join(__dirname, 'logs')
|
||||
if (!fs.existsSync(LOG_DIR)) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// GET LOG FILENAME (LOGS_DDMMYYYY.LOG)
|
||||
function getLogFilename() {
|
||||
const now = new Date()
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const year = now.getFullYear()
|
||||
return `LOGS_${day}${month}${year}.log`
|
||||
}
|
||||
|
||||
// WRITE LOG TO FILE
|
||||
function writeToLogFile(logEntry) {
|
||||
if (!LOG_TO_FILE) return
|
||||
|
||||
try {
|
||||
const logFilePath = path.join(LOG_DIR, getLogFilename())
|
||||
const logLine = `${logEntry}\n`
|
||||
fs.appendFileSync(logFilePath, logLine, 'utf8')
|
||||
} catch (e) {
|
||||
console.error('[LOG_FILE_ERROR]', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// CHECK IF LEVEL SHOULD BE LOGGED
|
||||
function shouldLog(level) {
|
||||
return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current timestamp in Jakarta timezone (WIB/UTC+7)
|
||||
*/
|
||||
// GET TIMESTAMP IN JAKARTA TIMEZONE (WIB/UTC+7)
|
||||
function ts() {
|
||||
const now = new Date()
|
||||
const jakartaOffset = 7 * 60 // minutes
|
||||
|
|
@ -125,16 +142,12 @@ function ts() {
|
|||
return localTime.toISOString().replace('Z', '+07:00')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize object untuk logging (deep clone)
|
||||
*/
|
||||
// SANITIZE OBJECT FOR LOGGING (DEEP CLONE)
|
||||
function sanitize(obj) {
|
||||
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive fields dalam payload (card numbers, CVV, tokens, etc.)
|
||||
*/
|
||||
// MASK SENSITIVE FIELDS (CARD_NUMBER, CVV, TOKEN_ID, SERVER_KEY)
|
||||
function maskPayload(obj) {
|
||||
const o = sanitize(obj)
|
||||
const maskKeys = ['card_number', 'cvv', 'token_id', 'server_key']
|
||||
|
|
@ -150,26 +163,36 @@ function maskPayload(obj) {
|
|||
return mask(o)
|
||||
}
|
||||
|
||||
/**
|
||||
* Core logging function dengan in-memory buffer
|
||||
*/
|
||||
// CORE LOGGING FUNCTION (FILE + CONSOLE + MEMORY BUFFER)
|
||||
function log(level, msg, meta) {
|
||||
if (!shouldLog(level)) return
|
||||
const line = `[${ts()}] [${level}] ${msg}`
|
||||
|
||||
const timestamp = ts()
|
||||
const levelUpper = level.toUpperCase().padEnd(5, ' ')
|
||||
|
||||
try {
|
||||
const entry = { ts: ts(), level, msg, meta: sanitize(meta) }
|
||||
const entry = { ts: timestamp, level, msg, meta: sanitize(meta) }
|
||||
recentLogs.push(entry)
|
||||
if (recentLogs.length > LOG_BUFFER_SIZE) recentLogs.shift()
|
||||
} catch {}
|
||||
|
||||
let logLine = `[${timestamp}] [${levelUpper}] ${msg}`
|
||||
if (meta) {
|
||||
const data = typeof meta === 'string' ? meta : JSON.stringify(meta)
|
||||
console.log(line, data)
|
||||
} else {
|
||||
console.log(line)
|
||||
const metaStr = typeof meta === 'string' ? meta : JSON.stringify(meta)
|
||||
logLine += ` | ${metaStr}`
|
||||
}
|
||||
|
||||
if (LOG_TO_FILE) {
|
||||
writeToLogFile(logLine)
|
||||
}
|
||||
|
||||
if (LOG_TO_CONSOLE) {
|
||||
console.log(logLine)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LOG_ERROR]', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience logging functions
|
||||
const logDebug = (m, meta) => log('debug', m, meta)
|
||||
const logInfo = (m, meta) => log('info', m, meta)
|
||||
const logWarn = (m, meta) => log('warn', m, meta)
|
||||
|
|
@ -179,14 +202,11 @@ const logError = (m, meta) => log('error', m, meta)
|
|||
// REQUEST TRACKING MIDDLEWARE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate unique request ID untuk tracking
|
||||
*/
|
||||
// GENERATE UNIQUE REQUEST ID
|
||||
function newReqId() {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
// Request/response logging middleware
|
||||
app.use((req, res, next) => {
|
||||
req.id = newReqId()
|
||||
logInfo('req.start', { id: req.id, method: req.method, url: req.originalUrl })
|
||||
|
|
@ -216,24 +236,19 @@ const PAYMENT_LINK_SECRET = process.env.PAYMENT_LINK_SECRET || ''
|
|||
const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '1440', 10)
|
||||
const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
|
||||
|
||||
// In-memory storage
|
||||
const activeOrders = new Map() // order_id -> expire_at
|
||||
const orderMerchantId = new Map() // order_id -> mercant_id (untuk dynamic ERP notification)
|
||||
// IN-MEMORY STORAGE
|
||||
const activeOrders = new Map()
|
||||
const orderMerchantId = new Map()
|
||||
|
||||
/**
|
||||
* Check if running in development environment
|
||||
*/
|
||||
// CHECK IF DEVELOPMENT ENVIRONMENT
|
||||
function isDevEnv() {
|
||||
return (process.env.NODE_ENV || '').toLowerCase() !== 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify external API key dari request header
|
||||
*/
|
||||
// VERIFY EXTERNAL API KEY FROM REQUEST HEADER
|
||||
function verifyExternalKey(req) {
|
||||
const key = (req.headers['x-api-key'] || req.headers['X-API-KEY'] || '').toString()
|
||||
if (EXTERNAL_API_KEY) return key === EXTERNAL_API_KEY
|
||||
// Allow if not configured only in dev for easier local testing
|
||||
return isDevEnv()
|
||||
}
|
||||
|
||||
|
|
@ -241,62 +256,50 @@ function verifyExternalKey(req) {
|
|||
// PAYMENT LINK TOKEN UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Encode data to base64url format (URL-safe)
|
||||
*/
|
||||
// ENCODE TO BASE64URL (URL-SAFE)
|
||||
function base64UrlEncode(buf) {
|
||||
return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64url format to string
|
||||
*/
|
||||
// DECODE BASE64URL TO STRING
|
||||
function base64UrlDecode(str) {
|
||||
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
|
||||
const s = str.replace(/-/g, '+').replace(/_/g, '/') + pad
|
||||
return Buffer.from(s, 'base64').toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute HMAC-SHA256 signature untuk payment link token
|
||||
*/
|
||||
// COMPUTE HMAC-SHA256 SIGNATURE FOR PAYMENT LINK
|
||||
function computeTokenSignature(orderId, nominal, expireAt) {
|
||||
const canonical = `${String(orderId)}|${String(nominal)}|${String(expireAt)}`
|
||||
return crypto.createHmac('sha256', PAYMENT_LINK_SECRET || 'dev-secret').update(canonical).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create signed payment link token
|
||||
* @returns {string} Base64url-encoded signed token
|
||||
*/
|
||||
// CREATE SIGNED PAYMENT LINK TOKEN (RETURNS BASE64URL STRING)
|
||||
function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) {
|
||||
const v = 1 // Token version
|
||||
const v = 1 // TOKEN VERSION
|
||||
const sig = computeTokenSignature(order_id, nominal, expire_at)
|
||||
const payload = { v, order_id, nominal, expire_at, sig, customer, allowed_methods }
|
||||
return base64UrlEncode(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve dan validate payment link token
|
||||
* @returns {object} { error?, payload? }
|
||||
*/
|
||||
// RESOLVE AND VALIDATE PAYMENT LINK TOKEN (RETURNS {ERROR?, PAYLOAD?})
|
||||
function resolvePaymentLinkToken(token) {
|
||||
try {
|
||||
const json = JSON.parse(base64UrlDecode(token))
|
||||
const { order_id, nominal, expire_at, sig } = json || {}
|
||||
|
||||
// Validate required fields
|
||||
// VALIDATE REQUIRED FIELDS
|
||||
if (!order_id || !nominal || !expire_at || !sig) {
|
||||
return { error: 'INVALID_TOKEN' }
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
// VERIFY SIGNATURE
|
||||
const expected = computeTokenSignature(order_id, nominal, expire_at)
|
||||
if (String(sig) !== String(expected)) {
|
||||
return { error: 'INVALID_SIGNATURE' }
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
// CHECK EXPIRATION
|
||||
if (Date.now() > Number(expire_at)) {
|
||||
return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } }
|
||||
}
|
||||
|
|
@ -420,7 +423,7 @@ app.post('/api/payments/charge', async (req, res) => {
|
|||
logInfo('charge.request', { id: req.id, payment_type: pt })
|
||||
logDebug('charge.payload', maskPayload(req.body))
|
||||
|
||||
// Idempotency guard: if an order is already pending in Midtrans, block re-charge for the same order_id
|
||||
// IDEMPOTENCY GUARD: IF AN ORDER IS ALREADY PENDING IN MIDTRANS, BLOCK RE-CHARGE FOR THE SAME ORDER_ID
|
||||
const orderId = req?.body?.transaction_details?.order_id || req?.body?.order_id || ''
|
||||
if (orderId) {
|
||||
try {
|
||||
|
|
@ -500,14 +503,14 @@ app.post('/api/payments/snap/token', async (req, res) => {
|
|||
app.get('/api/payments/:orderId/status', async (req, res) => {
|
||||
const { orderId } = req.params
|
||||
try {
|
||||
// midtrans-client exposes transaction helper via CoreApi
|
||||
// MIDTRANS-CLIENT EXPOSES TRANSACTION HELPER VIA COREAPI
|
||||
logInfo('status.request', { id: req.id, orderId })
|
||||
const status = await core.transaction.status(orderId)
|
||||
logInfo('status.success', { id: req.id, orderId, transaction_status: status?.transaction_status })
|
||||
// Respond immediately with status
|
||||
// RESPOND IMMEDIATELY WITH STATUS
|
||||
res.json(status)
|
||||
|
||||
// Fallback: selain webhook, jika status di sini sudah sukses (settlement/capture+accept), kirim notifikasi ke ERP
|
||||
// FALLBACK: SELAIN WEBHOOK, JIKA STATUS DI SINI SUDAH SUKSES (SETTLEMENT/CAPTURE+ACCEPT), KIRIM NOTIFIKASI KE ERP
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
if (isSuccessfulMidtransStatus(status)) {
|
||||
|
|
@ -515,7 +518,8 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
|
|||
if (!notifiedOrders.has(orderId)) {
|
||||
activeOrders.delete(orderId)
|
||||
logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status })
|
||||
const ok = await notifyERP({ orderId, nominal })
|
||||
const mercantId = resolveMercantId(orderId)
|
||||
const ok = await notifyERP({ orderId, nominal, mercantId })
|
||||
if (ok) {
|
||||
notifiedOrders.add(orderId)
|
||||
} else {
|
||||
|
|
@ -552,28 +556,20 @@ app.post('/createtransaksi', async (req, res) => {
|
|||
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'X-API-KEY invalid' })
|
||||
}
|
||||
|
||||
// Skema baru:
|
||||
// {
|
||||
// mercant_id, timestamp, deskripsi, nominal,
|
||||
// nama, no_telepon, email,
|
||||
// item: [{ item_id, nama, harga, qty }, ...]
|
||||
// }
|
||||
const mercantId = req?.body?.mercant_id
|
||||
const nominalRaw = req?.body?.nominal
|
||||
const items = Array.isArray(req?.body?.item) ? req.body.item : []
|
||||
const primaryItemId = items?.[0]?.item_id
|
||||
// Mapping order_id: gunakan "mercant_id:item_id" bila keduanya tersedia,
|
||||
// jika tidak, fallback ke item_id atau mercant_id atau field order_id/item_id yang disediakan.
|
||||
|
||||
const order_id = String(
|
||||
(primaryItemId && mercantId) ? `${mercantId}:${primaryItemId}` :
|
||||
(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
|
||||
)
|
||||
// Simpan mercant_id per order agar dapat digunakan saat notifikasi ERP
|
||||
|
||||
if (mercantId) {
|
||||
try { orderMerchantId.set(order_id, mercantId) } catch {}
|
||||
}
|
||||
|
||||
// Bentuk customer dari field nama/no_telepon/email
|
||||
const customer = {
|
||||
name: req?.body?.nama,
|
||||
phone: req?.body?.no_telepon,
|
||||
|
|
@ -590,19 +586,17 @@ app.post('/createtransaksi', async (req, res) => {
|
|||
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
|
||||
const expire_at = now + ttlMin * 60 * 1000
|
||||
|
||||
// Block jika sudah selesai
|
||||
if (notifiedOrders.has(order_id)) {
|
||||
logWarn('createtransaksi.completed', { order_id })
|
||||
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
||||
}
|
||||
// Block jika ada link aktif belum expired
|
||||
|
||||
const existing = activeOrders.get(order_id)
|
||||
if (existing && existing > now) {
|
||||
logWarn('createtransaksi.active_exists', { order_id })
|
||||
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
|
||||
}
|
||||
|
||||
// Guard tambahan: cek ke Midtrans apakah order_id sudah memiliki transaksi pending
|
||||
try {
|
||||
const status = await core.transaction.status(order_id)
|
||||
const s = (status?.transaction_status || '').toLowerCase()
|
||||
|
|
@ -615,7 +609,6 @@ app.post('/createtransaksi', async (req, res) => {
|
|||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// Jika 404/not found, lanjut membuat payment link; error lain tetap diteruskan
|
||||
const msg = (e?.message || '').toLowerCase()
|
||||
if (msg.includes('not found') || msg.includes('404')) {
|
||||
logDebug('createtransaksi.midtrans_status_not_found', { order_id })
|
||||
|
|
@ -629,7 +622,6 @@ app.post('/createtransaksi', async (req, res) => {
|
|||
activeOrders.set(order_id, expire_at)
|
||||
logInfo('createtransaksi.issued', { order_id, expire_at })
|
||||
|
||||
// Respons mengikuti format yang diminta
|
||||
res.json({ status: '200', messages: 'SUCCESS', data: { url } })
|
||||
} catch (e) {
|
||||
logError('createtransaksi.error', { id: req.id, message: e?.message })
|
||||
|
|
@ -652,7 +644,6 @@ function verifyWebhookSignature(body, mode) {
|
|||
const grossAmount = body?.gross_amount
|
||||
|
||||
if (mode === 'SNAP') {
|
||||
// Snap signature: order_id + status_code + gross_amount + server_key
|
||||
const signature = req.headers['x-signature'] || req.headers['signature'] || body?.signature
|
||||
if (!signature) return false
|
||||
|
||||
|
|
@ -660,7 +651,6 @@ function verifyWebhookSignature(body, mode) {
|
|||
const expectedSignature = crypto.createHash('sha512').update(data).digest('hex')
|
||||
return signature === expectedSignature
|
||||
} else {
|
||||
// Core signature: signature_key field in body
|
||||
const signatureKey = (body?.signature_key || '').toLowerCase()
|
||||
const expectedSig = computeMidtransSignature(orderId, statusCode, grossAmount, serverKey)
|
||||
return expectedSig && signatureKey === expectedSig
|
||||
|
|
@ -673,26 +663,58 @@ function verifyWebhookSignature(body, mode) {
|
|||
|
||||
/**
|
||||
* Map Midtrans transaction status to internal status
|
||||
* Handles all possible Midtrans webhook events correctly
|
||||
*
|
||||
* @param {object} body - Full webhook body (needed for fraud_status check)
|
||||
* @param {string} mode - 'CORE' or 'SNAP'
|
||||
* @returns {string} Internal status: 'completed', 'pending', 'failed', 'refunded', 'challenge', 'unknown'
|
||||
*/
|
||||
function mapStatusToInternal(transactionStatus, mode) {
|
||||
// Unified status mapping - both Core and Snap use similar status values
|
||||
function mapStatusToInternal(body, mode) {
|
||||
const transactionStatus = body?.transaction_status
|
||||
const fraudStatus = body?.fraud_status
|
||||
const status = (transactionStatus || '').toLowerCase()
|
||||
const fraud = (fraudStatus || '').toLowerCase()
|
||||
|
||||
switch (status) {
|
||||
case 'settlement':
|
||||
case 'capture': // for cards with fraud_status=accept
|
||||
return 'completed'
|
||||
|
||||
case 'capture':
|
||||
if (fraud === 'accept') {
|
||||
return 'completed'
|
||||
} else if (fraud === 'challenge') {
|
||||
return 'challenge'
|
||||
} else {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
case 'pending':
|
||||
return 'pending'
|
||||
|
||||
case 'deny':
|
||||
return 'failed'
|
||||
|
||||
case 'cancel':
|
||||
return 'failed'
|
||||
|
||||
case 'expire':
|
||||
return 'failed'
|
||||
|
||||
case 'failure':
|
||||
return 'failed'
|
||||
|
||||
case 'refund':
|
||||
case 'partial_refund':
|
||||
return 'refunded'
|
||||
|
||||
case 'chargeback':
|
||||
return 'chargeback'
|
||||
|
||||
default:
|
||||
logWarn(`[${mode}] webhook.unknown_status`, { transaction_status: transactionStatus })
|
||||
logWarn(`[${mode}] webhook.unknown_status`, {
|
||||
transaction_status: transactionStatus,
|
||||
fraud_status: fraudStatus
|
||||
})
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
|
@ -711,9 +733,9 @@ function updateLedger(orderId, data) {
|
|||
source: data.source
|
||||
})
|
||||
|
||||
// Here you would typically update a database
|
||||
// For now, we'll just log the ledger update
|
||||
// Future: integrate with Epic 5 ledger system
|
||||
// HERE YOU WOULD TYPICALLY UPDATE A DATABASE
|
||||
// FOR NOW, WE'LL JUST LOG THE LEDGER UPDATE
|
||||
// FUTURE: INTEGRATE WITH EPIC 5 LEDGER SYSTEM
|
||||
|
||||
} catch (e) {
|
||||
logError('ledger.update.error', { order_id: orderId, message: e?.message })
|
||||
|
|
@ -731,18 +753,13 @@ async function processPaymentCompletion(orderId, internalStatus, mode, body) {
|
|||
internal_status: internalStatus,
|
||||
transaction_status: body?.transaction_status
|
||||
})
|
||||
|
||||
// Shared business logic for successful payments
|
||||
if (internalStatus === 'completed') {
|
||||
const grossAmount = body?.gross_amount
|
||||
const nominal = String(grossAmount || '')
|
||||
|
||||
// Prevent duplicate notifications
|
||||
if (!notifiedOrders.has(orderId)) {
|
||||
// Mark order inactive upon completion
|
||||
activeOrders.delete(orderId)
|
||||
|
||||
// Notify ERP systems
|
||||
const mercantId = resolveMercantId(orderId)
|
||||
const ok = await notifyERP({ orderId, nominal, mercantId })
|
||||
|
||||
|
|
@ -789,7 +806,7 @@ function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
|||
function isSuccessfulMidtransStatus(body) {
|
||||
const s = (body?.transaction_status || '').toLowerCase()
|
||||
const fraud = (body?.fraud_status || '').toLowerCase()
|
||||
// Success for most methods: settlement; Card: capture with fraud_status=accept also success
|
||||
// SUCCESS FOR MOST METHODS: SETTLEMENT; CARD: CAPTURE WITH FRAUD_STATUS=ACCEPT ALSO SUCCESS
|
||||
if (s === 'settlement') return true
|
||||
if (s === 'capture' && fraud === 'accept') return true
|
||||
return false
|
||||
|
|
@ -876,11 +893,11 @@ function postJson(url, data, extraHeaders = {}) {
|
|||
/**
|
||||
* Compute ERP notification signature (SHA-512)
|
||||
*/
|
||||
async function computeErpSignature(mercantId, statusCode, nominal, clientId) {
|
||||
function computeErpSignature(mercantId, statusCode, nominal, clientId) {
|
||||
try {
|
||||
const raw = String(mercantId) + String(statusCode) + String(nominal) + String(clientId)
|
||||
const sign = crypto.createHash('sha512').update(raw).digest('hex')
|
||||
return sign;
|
||||
return sign
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
|
@ -913,7 +930,7 @@ async function notifyERP({ orderId, nominal, mercantId }) {
|
|||
logInfo('erp.notify.skip', { reason: 'disabled' })
|
||||
return false
|
||||
}
|
||||
// Untuk notifikasi dinamis, hanya URL dan client secret yang wajib
|
||||
|
||||
if (ERP_NOTIFICATION_URLS.length === 0 || !ERP_CLIENT_SECRET) {
|
||||
logWarn('erp.notify.missing_config', { urlsCount: ERP_NOTIFICATION_URLS.length, hasClientSecret: !!ERP_CLIENT_SECRET })
|
||||
return false
|
||||
|
|
@ -924,7 +941,7 @@ async function notifyERP({ orderId, nominal, mercantId }) {
|
|||
logWarn('erp.notify.skip', { orderId, reason: 'missing_mercant_id' })
|
||||
return false
|
||||
}
|
||||
const signature = await computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_SECRET)
|
||||
const signature = computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_SECRET)
|
||||
|
||||
const payload = {
|
||||
mercant_id: mId,
|
||||
|
|
@ -932,7 +949,7 @@ async function notifyERP({ orderId, nominal, mercantId }) {
|
|||
nominal: nominal,
|
||||
signature: signature,
|
||||
}
|
||||
// Tambahan debug: pastikan signature benar-benar ikut terkirim
|
||||
|
||||
logDebug('erp.notify.payload.outgoing', {
|
||||
mercant_id: mId,
|
||||
status_code: statusCode,
|
||||
|
|
@ -973,29 +990,32 @@ async function notifyERP({ orderId, nominal, mercantId }) {
|
|||
* Unified webhook endpoint for Midtrans notifications
|
||||
* Handles both CORE and SNAP mode webhooks
|
||||
* POST /api/payments/notification
|
||||
*
|
||||
* IMPORTANT: Returns 200 only after ERP notification succeeds (synchronous).
|
||||
* If ERP notification fails, returns error so Midtrans will retry.
|
||||
*/
|
||||
app.post('/api/payments/notification', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {}
|
||||
const orderId = body?.order_id
|
||||
|
||||
// Mode detection: Snap has payment_type, transaction_time, settlement_time
|
||||
const isSnap = body?.payment_type && body?.transaction_time && body?.settlement_time
|
||||
const mode = isSnap ? 'SNAP' : 'CORE'
|
||||
|
||||
TransactionLogger.logWebhookReceived(mode, orderId, body?.transaction_status)
|
||||
|
||||
// Unified signature verification
|
||||
const signatureValid = verifyWebhookSignature(body, mode)
|
||||
if (!signatureValid) {
|
||||
logError(`[${mode}] webhook.signature.invalid`, { order_id: orderId })
|
||||
return res.status(400).send('Invalid signature')
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: 'INVALID_SIGNATURE',
|
||||
message: 'Invalid signature',
|
||||
statusCode: 400
|
||||
})
|
||||
}
|
||||
|
||||
// Unified status mapping
|
||||
const internalStatus = mapStatusToInternal(body?.transaction_status, mode)
|
||||
const internalStatus = mapStatusToInternal(body, mode)
|
||||
|
||||
// Update ledger with source indicator
|
||||
updateLedger(orderId, {
|
||||
status: internalStatus,
|
||||
source: mode === 'SNAP' ? 'snap_webhook' : 'core_webhook',
|
||||
|
|
@ -1003,15 +1023,121 @@ app.post('/api/payments/notification', async (req, res) => {
|
|||
payload: body
|
||||
})
|
||||
|
||||
// Acknowledge quickly
|
||||
res.json({ ok: true })
|
||||
if (internalStatus === 'completed') {
|
||||
const grossAmount = body?.gross_amount
|
||||
const nominal = String(grossAmount || '')
|
||||
|
||||
// Trigger shared business logic (ERP unfreeze, etc.)
|
||||
await processPaymentCompletion(orderId, internalStatus, mode, body)
|
||||
if (notifiedOrders.has(orderId)) {
|
||||
logInfo(`[${mode}] webhook.already_notified`, { order_id: orderId })
|
||||
return res.json({
|
||||
ok: true,
|
||||
status: 'already_notified',
|
||||
message: 'Payment already processed and ERP notified',
|
||||
statusCode: 200
|
||||
})
|
||||
}
|
||||
|
||||
activeOrders.delete(orderId)
|
||||
const mercantId = resolveMercantId(orderId)
|
||||
|
||||
logInfo(`[${mode}] webhook.notifying_erp`, { order_id: orderId, mercant_id: mercantId })
|
||||
const erpSuccess = await notifyERP({ orderId, nominal, mercantId })
|
||||
|
||||
if (erpSuccess) {
|
||||
notifiedOrders.add(orderId)
|
||||
logInfo(`[${mode}] webhook.erp_success`, { order_id: orderId })
|
||||
return res.json({
|
||||
ok: true,
|
||||
status: 'erp_notified',
|
||||
message: 'Payment completed and ERP notified successfully',
|
||||
statusCode: 200
|
||||
})
|
||||
} else {
|
||||
logError(`[${mode}] webhook.erp_failed`, { order_id: orderId })
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
status: 'erp_failed',
|
||||
error: 'ERP_NOTIFICATION_FAILED',
|
||||
message: 'Failed to notify ERP, Midtrans will retry',
|
||||
statusCode: 500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
else if (internalStatus === 'challenge') {
|
||||
logWarn(`[${mode}] webhook.challenge`, {
|
||||
order_id: orderId,
|
||||
transaction_status: body?.transaction_status,
|
||||
fraud_status: body?.fraud_status
|
||||
})
|
||||
return res.status(202).json({
|
||||
ok: false,
|
||||
status: 'challenge_pending_review',
|
||||
message: 'Transaction under review, awaiting final status',
|
||||
statusCode: 202
|
||||
})
|
||||
}
|
||||
else if (internalStatus === 'chargeback') {
|
||||
logError(`[${mode}] webhook.chargeback`, {
|
||||
order_id: orderId,
|
||||
transaction_status: body?.transaction_status
|
||||
})
|
||||
return res.status(409).json({
|
||||
ok: false,
|
||||
status: 'chargeback',
|
||||
message: 'Transaction charged back by customer',
|
||||
statusCode: 409
|
||||
})
|
||||
}
|
||||
else if (internalStatus === 'pending') {
|
||||
logInfo(`[${mode}] webhook.pending`, {
|
||||
order_id: orderId,
|
||||
transaction_status: body?.transaction_status
|
||||
})
|
||||
return res.status(202).json({
|
||||
ok: false,
|
||||
status: 'pending',
|
||||
message: 'Payment pending, awaiting customer action',
|
||||
statusCode: 202
|
||||
})
|
||||
}
|
||||
else if (internalStatus === 'failed') {
|
||||
logWarn(`[${mode}] webhook.failed`, {
|
||||
order_id: orderId,
|
||||
transaction_status: body?.transaction_status,
|
||||
fraud_status: body?.fraud_status
|
||||
})
|
||||
return res.status(400).json({
|
||||
ok: true,
|
||||
status: 'failed',
|
||||
message: 'Payment failed, no further action needed',
|
||||
statusCode: 400
|
||||
})
|
||||
}
|
||||
else {
|
||||
logWarn(`[${mode}] webhook.non_completed`, {
|
||||
order_id: orderId,
|
||||
internal_status: internalStatus,
|
||||
transaction_status: body?.transaction_status,
|
||||
fraud_status: body?.fraud_status
|
||||
})
|
||||
return res.status(202).json({
|
||||
ok: false,
|
||||
status: internalStatus,
|
||||
message: 'Non-completed status, monitoring for updates',
|
||||
statusCode: 202
|
||||
})
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logError('webhook.error', { message: e?.message })
|
||||
try { res.status(500).json({ error: 'WEBHOOK_ERROR' }) } catch {}
|
||||
logError('webhook.error', { message: e?.message, stack: e?.stack })
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
status: 'error',
|
||||
error: 'WEBHOOK_ERROR',
|
||||
message: e?.message,
|
||||
statusCode: 500
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -1064,18 +1190,50 @@ if (LOG_EXPOSE_API) {
|
|||
/**
|
||||
* Manually trigger ERP notification for testing
|
||||
* POST /api/test/notify-erp
|
||||
* Body: { orderId, nominal, mercant_id }
|
||||
*/
|
||||
app.post('/api/test/notify-erp', async (req, res) => {
|
||||
try {
|
||||
const { orderId, nominal, mercant_id } = req.body || {}
|
||||
if (!orderId && !mercant_id) {
|
||||
return res.status(400).json({ error: 'BAD_REQUEST', message: 'Provide orderId or mercant_id and nominal' })
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Provide orderId or mercant_id and nominal',
|
||||
statusCode: 400
|
||||
})
|
||||
}
|
||||
|
||||
logInfo('test.notify.trigger', { orderId, nominal, mercant_id })
|
||||
const erpSuccess = await notifyERP({ orderId, nominal: String(nominal || ''), mercantId: mercant_id })
|
||||
|
||||
if (erpSuccess) {
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
status: 'erp_notified',
|
||||
message: 'ERP notification sent successfully',
|
||||
statusCode: 200,
|
||||
data: { orderId, nominal, mercant_id }
|
||||
})
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
status: 'erp_failed',
|
||||
error: 'ERP_NOTIFICATION_FAILED',
|
||||
message: 'Failed to notify ERP - check logs for details',
|
||||
statusCode: 500,
|
||||
data: { orderId, nominal, mercant_id }
|
||||
})
|
||||
}
|
||||
const ok = await notifyERP({ orderId, nominal: String(nominal || ''), mercantId: mercant_id })
|
||||
return res.json({ ok })
|
||||
} catch (e) {
|
||||
logError('test.notify.error', { message: e?.message })
|
||||
return res.status(500).json({ error: 'TEST_NOTIFY_ERROR', message: e?.message || 'Notify test failed' })
|
||||
logError('test.notify.error', { message: e?.message, stack: e?.stack })
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
status: 'error',
|
||||
error: 'TEST_NOTIFY_ERROR',
|
||||
message: e?.message || 'Notify test failed',
|
||||
statusCode: 500
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1086,13 +1244,22 @@ if (LOG_EXPOSE_API) {
|
|||
|
||||
const port = process.env.PORT || 8000
|
||||
app.listen(port, () => {
|
||||
// ALWAYS SHOW STARTUP IN CONSOLE
|
||||
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)
|
||||
console.log(`[server] Logging: file=${LOG_TO_FILE} console=${LOG_TO_CONSOLE}`)
|
||||
if (LOG_TO_FILE) {
|
||||
console.log(`[server] Log file: ${path.join(LOG_DIR, getLogFilename())}`)
|
||||
}
|
||||
console.log('[server] Ready to accept requests')
|
||||
|
||||
logInfo('server.started', {
|
||||
port,
|
||||
isProduction,
|
||||
enabledMethods: Object.keys(ENABLE).filter(k => ENABLE[k]),
|
||||
erpEnabled: ERP_ENABLE_NOTIF,
|
||||
erpEndpoints: ERP_NOTIFICATION_URLS.length
|
||||
erpEndpoints: ERP_NOTIFICATION_URLS.length,
|
||||
logToFile: LOG_TO_FILE,
|
||||
logToConsole: LOG_TO_CONSOLE,
|
||||
logFile: getLogFilename()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue