1266 lines
43 KiB
JavaScript
1266 lines
43 KiB
JavaScript
/**
|
|
* SIMAYA MIDTRANS PAYMENT SERVER V2.0
|
|
* EXPRESS BACKEND UNTUK INTEGRASI MIDTRANS (CORE + SNAP) DENGAN ERP
|
|
*/
|
|
|
|
const express = require('express')
|
|
const cors = require('cors')
|
|
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 - PAYMENT LIFECYCLE TRACKING
|
|
// ============================================================================
|
|
const TransactionLogger = {
|
|
logPaymentInit: (mode, orderId, amount) => logInfo(`[${mode}] payment.init`, { orderId, amount }),
|
|
logPaymentSuccess: (mode, orderId, transactionId) => logInfo(`[${mode}] payment.success`, { orderId, transactionId }),
|
|
logPaymentError: (mode, orderId, error) => logError(`[${mode}] payment.error`, { orderId, error: error.message }),
|
|
logWebhookReceived: (mode, orderId, status) => logInfo(`[${mode}] webhook.received`, { orderId, status }),
|
|
logWebhookProcessed: (mode, orderId, internalStatus) => logInfo(`[${mode}] webhook.processed`, { orderId, internalStatus })
|
|
}
|
|
|
|
// ============================================================================
|
|
// EXPRESS APP SETUP
|
|
// ============================================================================
|
|
const app = express()
|
|
app.use(cors())
|
|
app.use(express.json())
|
|
|
|
// ============================================================================
|
|
// MIDTRANS CONFIGURATION
|
|
// ============================================================================
|
|
const isProduction = process.env.MIDTRANS_IS_PRODUCTION === 'true'
|
|
const serverKey = process.env.MIDTRANS_SERVER_KEY || ''
|
|
const clientKey = process.env.MIDTRANS_CLIENT_KEY || ''
|
|
|
|
if (!serverKey || !clientKey) {
|
|
console.warn('[Midtrans] Missing server/client keys in environment variables')
|
|
}
|
|
|
|
// INITIALIZE MIDTRANS CORE API INSTANCE
|
|
const core = new midtransClient.CoreApi({
|
|
isProduction,
|
|
serverKey,
|
|
clientKey,
|
|
})
|
|
|
|
// ============================================================================
|
|
// ERP INTEGRATION CONFIGURATION
|
|
// ============================================================================
|
|
|
|
// PARSE BOOLEAN VALUES (TRUE/FALSE, 1/0, YES/NO, ON/OFF)
|
|
function parseEnable(v) {
|
|
if (typeof v === 'string') {
|
|
const s = v.trim().toLowerCase()
|
|
return s === 'true' || s === '1' || s === 'yes' || s === 'on'
|
|
}
|
|
if (typeof v === 'boolean') return v
|
|
if (typeof v === 'number') return v === 1
|
|
return true
|
|
}
|
|
|
|
// PARSE COMMA-SEPARATED LIST
|
|
function parseList(value) {
|
|
if (!value) return []
|
|
return String(value)
|
|
.split(',')
|
|
.map(s => s.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
// 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 || ''
|
|
|
|
// 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
|
|
const notifiedOrders = new Set()
|
|
|
|
// ============================================================================
|
|
// LOGGING UTILITIES
|
|
// ============================================================================
|
|
|
|
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 = []
|
|
|
|
// 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 TIMESTAMP IN JAKARTA TIMEZONE (WIB/UTC+7)
|
|
function ts() {
|
|
const now = new Date()
|
|
const jakartaOffset = 7 * 60 // minutes
|
|
const localTime = new Date(now.getTime() + jakartaOffset * 60 * 1000)
|
|
return localTime.toISOString().replace('Z', '+07:00')
|
|
}
|
|
|
|
// SANITIZE OBJECT FOR LOGGING (DEEP CLONE)
|
|
function sanitize(obj) {
|
|
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
|
|
}
|
|
|
|
// 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']
|
|
function mask(o2) {
|
|
if (!o2 || typeof o2 !== 'object') return o2
|
|
for (const k of Object.keys(o2)) {
|
|
const v = o2[k]
|
|
if (maskKeys.includes(k)) o2[k] = v ? '***' : v
|
|
else if (typeof v === 'object') o2[k] = mask(v)
|
|
}
|
|
return o2
|
|
}
|
|
return mask(o)
|
|
}
|
|
|
|
// CORE LOGGING FUNCTION (FILE + CONSOLE + MEMORY BUFFER)
|
|
function log(level, msg, meta) {
|
|
if (!shouldLog(level)) return
|
|
|
|
const timestamp = ts()
|
|
const levelUpper = level.toUpperCase().padEnd(5, ' ')
|
|
|
|
try {
|
|
const entry = { ts: timestamp, level, msg, meta: sanitize(meta) }
|
|
recentLogs.push(entry)
|
|
if (recentLogs.length > LOG_BUFFER_SIZE) recentLogs.shift()
|
|
|
|
let logLine = `[${timestamp}] [${levelUpper}] ${msg}`
|
|
if (meta) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
const logDebug = (m, meta) => log('debug', m, meta)
|
|
const logInfo = (m, meta) => log('info', m, meta)
|
|
const logWarn = (m, meta) => log('warn', m, meta)
|
|
const logError = (m, meta) => log('error', m, meta)
|
|
|
|
// ============================================================================
|
|
// REQUEST TRACKING MIDDLEWARE
|
|
// ============================================================================
|
|
|
|
// GENERATE UNIQUE REQUEST ID
|
|
function newReqId() {
|
|
return Math.random().toString(36).slice(2) + Date.now().toString(36)
|
|
}
|
|
|
|
app.use((req, res, next) => {
|
|
req.id = newReqId()
|
|
logInfo('req.start', { id: req.id, method: req.method, url: req.originalUrl })
|
|
res.on('finish', () => {
|
|
logInfo('req.end', { id: req.id, statusCode: res.statusCode })
|
|
})
|
|
next()
|
|
})
|
|
|
|
// ============================================================================
|
|
// PAYMENT METHOD TOGGLES
|
|
// ============================================================================
|
|
|
|
const ENABLE = {
|
|
bank_transfer: parseEnable(process.env.ENABLE_BANK_TRANSFER),
|
|
credit_card: parseEnable(process.env.ENABLE_CREDIT_CARD),
|
|
gopay: parseEnable(process.env.ENABLE_GOPAY),
|
|
cstore: parseEnable(process.env.ENABLE_CSTORE),
|
|
}
|
|
|
|
// ============================================================================
|
|
// PAYMENT LINK CONFIGURATION
|
|
// ============================================================================
|
|
|
|
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || ''
|
|
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()
|
|
const orderMerchantId = new Map()
|
|
|
|
// CHECK IF DEVELOPMENT ENVIRONMENT
|
|
function isDevEnv() {
|
|
return (process.env.NODE_ENV || '').toLowerCase() !== 'production'
|
|
}
|
|
|
|
// 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
|
|
return isDevEnv()
|
|
}
|
|
|
|
// ============================================================================
|
|
// PAYMENT LINK TOKEN UTILITIES
|
|
// ============================================================================
|
|
|
|
// ENCODE TO BASE64URL (URL-SAFE)
|
|
function base64UrlEncode(buf) {
|
|
return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
|
}
|
|
|
|
// 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 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 BASE64URL STRING)
|
|
function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) {
|
|
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 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
|
|
if (!order_id || !nominal || !expire_at || !sig) {
|
|
return { error: 'INVALID_TOKEN' }
|
|
}
|
|
|
|
// VERIFY SIGNATURE
|
|
const expected = computeTokenSignature(order_id, nominal, expire_at)
|
|
if (String(sig) !== String(expected)) {
|
|
return { error: 'INVALID_SIGNATURE' }
|
|
}
|
|
|
|
// CHECK EXPIRATION
|
|
if (Date.now() > Number(expire_at)) {
|
|
return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } }
|
|
}
|
|
|
|
return { payload: json }
|
|
} catch (e) {
|
|
return { error: 'TOKEN_PARSE_ERROR', message: e?.message }
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// API ENDPOINTS - HEALTH & CONFIG
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Health check endpoint
|
|
* GET /api/health
|
|
*/
|
|
app.get('/api/health', (_req, res) => {
|
|
logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey })
|
|
res.json({ ok: true, env: { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey } })
|
|
})
|
|
|
|
/**
|
|
* Get runtime configuration
|
|
* GET /api/config
|
|
*/
|
|
app.get('/api/config', (_req, res) => {
|
|
const payload = {
|
|
paymentToggles: { ...ENABLE },
|
|
midtransEnv: isProduction ? 'production' : 'sandbox',
|
|
clientKey,
|
|
}
|
|
logDebug('config.get', payload)
|
|
res.json(payload)
|
|
})
|
|
|
|
/**
|
|
* Get recent logs (dev/debug only)
|
|
* GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
|
|
*/
|
|
app.get('/api/logs', (req, res) => {
|
|
if (!LOG_EXPOSE_API) {
|
|
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
|
}
|
|
const limit = Math.max(1, Math.min(1000, parseInt(String(req.query.limit || '100'), 10)))
|
|
const level = String(req.query.level || '').toLowerCase()
|
|
const q = String(req.query.q || '').toLowerCase()
|
|
let items = recentLogs
|
|
if (level) items = items.filter((e) => e.level === level)
|
|
if (q) items = items.filter((e) => {
|
|
try {
|
|
const m = e.meta ? JSON.stringify(e.meta).toLowerCase() : ''
|
|
return e.msg.toLowerCase().includes(q) || m.includes(q)
|
|
} catch { return false }
|
|
})
|
|
const sliced = items.slice(Math.max(0, items.length - limit))
|
|
res.json({ count: sliced.length, items: sliced })
|
|
})
|
|
|
|
/**
|
|
* Update payment toggles at runtime (dev only)
|
|
* POST /api/config
|
|
*/
|
|
app.post('/api/config', (req, res) => {
|
|
const isDev = process.env.NODE_ENV !== 'production'
|
|
if (!isDev) return res.status(403).json({ error: 'FORBIDDEN', message: 'Runtime config updates disabled in production.' })
|
|
const t = req?.body?.paymentToggles
|
|
if (t && typeof t === 'object') {
|
|
if (typeof t.bank_transfer !== 'undefined') ENABLE.bank_transfer = parseEnable(t.bank_transfer)
|
|
if (typeof t.credit_card !== 'undefined') ENABLE.credit_card = parseEnable(t.credit_card)
|
|
if (typeof t.gopay !== 'undefined') ENABLE.gopay = parseEnable(t.gopay)
|
|
if (typeof t.cstore !== 'undefined') ENABLE.cstore = parseEnable(t.cstore)
|
|
}
|
|
const result = { paymentToggles: { ...ENABLE } }
|
|
logInfo('config.post', result)
|
|
res.json(result)
|
|
})
|
|
|
|
// ============================================================================
|
|
// API ENDPOINTS - PAYMENT LINKS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Resolve payment link token
|
|
* GET /api/payment-links/:token
|
|
*/
|
|
app.get('/api/payment-links/:token', (req, res) => {
|
|
const { token } = req.params
|
|
const result = resolvePaymentLinkToken(token)
|
|
if (result.error === 'TOKEN_EXPIRED') {
|
|
logWarn('paymentlink.expired', { order_id: result.payload?.order_id })
|
|
return res.status(410).json({ error: result.error, ...result.payload })
|
|
}
|
|
if (result.error) {
|
|
logWarn('paymentlink.invalid', { error: result.error })
|
|
if (isDevEnv()) {
|
|
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
|
|
const fallback = { order_id: token, nominal: 150000, expire_at: Date.now() + ttlMin * 60 * 1000 }
|
|
logInfo('paymentlink.dev.fallback', { order_id: fallback.order_id })
|
|
return res.json(fallback)
|
|
}
|
|
return res.status(400).json({ error: result.error })
|
|
}
|
|
const p = result.payload
|
|
logInfo('paymentlink.resolve.success', { order_id: p.order_id, expire_at: p.expire_at })
|
|
res.json({ order_id: p.order_id, nominal: p.nominal, customer: p.customer, expire_at: p.expire_at, allowed_methods: p.allowed_methods })
|
|
})
|
|
|
|
// ============================================================================
|
|
// API ENDPOINTS - PAYMENT OPERATIONS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Create payment transaction via Midtrans Core API
|
|
* POST /api/payments/charge
|
|
*/
|
|
app.post('/api/payments/charge', async (req, res) => {
|
|
try {
|
|
const pt = req?.body?.payment_type
|
|
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
|
|
const orderId = req?.body?.transaction_details?.order_id || req?.body?.order_id || ''
|
|
if (orderId) {
|
|
try {
|
|
const st = await core.transaction.status(orderId)
|
|
const ts = (st?.transaction_status || '').toLowerCase()
|
|
if (ts === 'pending') {
|
|
logWarn('charge.blocked.pending_exists', { id: req.id, order_id: orderId })
|
|
return res.status(409).json({
|
|
error: 'ORDER_ACTIVE',
|
|
message: 'Order sudah memiliki transaksi pending di Midtrans; tidak dapat membuat ulang. Gunakan instruksi pembayaran yang ada atau buat order baru.',
|
|
status: st,
|
|
})
|
|
}
|
|
} catch (e) {
|
|
const msg = (e?.message || '').toLowerCase()
|
|
if (msg.includes('not found') || msg.includes('404')) {
|
|
logDebug('charge.status_not_found', { order_id: orderId })
|
|
} else {
|
|
logDebug('charge.status_check_error', { order_id: orderId, message: e?.message })
|
|
}
|
|
}
|
|
}
|
|
const isBankType = pt === 'bank_transfer' || pt === 'echannel' || pt === 'permata'
|
|
if (isBankType && !ENABLE.bank_transfer) {
|
|
logWarn('charge.blocked', { id: req.id, reason: 'bank_transfer disabled' })
|
|
return res.status(403).json({ error: 'PAYMENT_TYPE_DISABLED', message: 'Bank transfer is disabled by environment configuration.' })
|
|
}
|
|
if (pt === 'credit_card' && !ENABLE.credit_card) {
|
|
logWarn('charge.blocked', { id: req.id, reason: 'credit_card disabled' })
|
|
return res.status(403).json({ error: 'PAYMENT_TYPE_DISABLED', message: 'Credit card is disabled by environment configuration.' })
|
|
}
|
|
if (pt === 'gopay' && !ENABLE.gopay) {
|
|
logWarn('charge.blocked', { id: req.id, reason: 'gopay disabled' })
|
|
return res.status(403).json({ error: 'PAYMENT_TYPE_DISABLED', message: 'GoPay/QRIS is disabled by environment configuration.' })
|
|
}
|
|
if (pt === 'cstore' && !ENABLE.cstore) {
|
|
logWarn('charge.blocked', { id: req.id, reason: 'cstore disabled' })
|
|
return res.status(403).json({ error: 'PAYMENT_TYPE_DISABLED', message: 'Convenience Store is disabled by environment configuration.' })
|
|
}
|
|
const chargeResponse = await core.charge(req.body)
|
|
TransactionLogger.logPaymentInit('CORE', chargeResponse?.order_id, chargeResponse?.gross_amount)
|
|
logInfo('charge.success', { id: req.id, order_id: chargeResponse?.order_id, status_code: chargeResponse?.status_code })
|
|
res.json(chargeResponse)
|
|
} catch (e) {
|
|
const msg = e?.message || 'Charge failed'
|
|
TransactionLogger.logPaymentError('CORE', req?.body?.transaction_details?.order_id || req?.body?.order_id, { message: msg })
|
|
logError('charge.error', { id: req.id, message: msg })
|
|
res.status(400).json({ error: 'CHARGE_ERROR', message: msg })
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Generate Snap token for hosted payment interface
|
|
* POST /api/payments/snap/token
|
|
*/
|
|
app.post('/api/payments/snap/token', async (req, res) => {
|
|
try {
|
|
const snap = new midtransClient.Snap({
|
|
isProduction,
|
|
serverKey,
|
|
clientKey
|
|
})
|
|
|
|
const token = await snap.createTransaction(req.body)
|
|
TransactionLogger.logPaymentInit('SNAP', req.body.transaction_details?.order_id, req.body.transaction_details?.gross_amount)
|
|
res.json({ token })
|
|
} catch (e) {
|
|
TransactionLogger.logPaymentError('SNAP', req.body?.transaction_details?.order_id, e)
|
|
res.status(400).json({ error: 'SNAP_TOKEN_ERROR', message: e.message })
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Check payment status by order ID
|
|
* GET /api/payments/:orderId/status
|
|
*/
|
|
app.get('/api/payments/:orderId/status', async (req, res) => {
|
|
const { orderId } = req.params
|
|
try {
|
|
// 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
|
|
res.json(status)
|
|
|
|
// FALLBACK: SELAIN WEBHOOK, JIKA STATUS DI SINI SUDAH SUKSES (SETTLEMENT/CAPTURE+ACCEPT), KIRIM NOTIFIKASI KE ERP
|
|
setImmediate(async () => {
|
|
try {
|
|
if (isSuccessfulMidtransStatus(status)) {
|
|
const nominal = String(status?.gross_amount || '')
|
|
if (!notifiedOrders.has(orderId)) {
|
|
activeOrders.delete(orderId)
|
|
logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status })
|
|
const mercantId = resolveMercantId(orderId)
|
|
const ok = await notifyERP({ orderId, nominal, mercantId })
|
|
if (ok) {
|
|
notifiedOrders.add(orderId)
|
|
} else {
|
|
logWarn('erp.notify.defer', { orderId, reason: 'post_failed_or_missing_data' })
|
|
}
|
|
} else {
|
|
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
|
|
}
|
|
}
|
|
} catch (e) {
|
|
logError('status.notify.error', { orderId, message: e?.message })
|
|
}
|
|
})
|
|
} catch (e) {
|
|
const msg = e?.message || 'Status check failed'
|
|
logError('status.error', { id: req.id, orderId, message: msg })
|
|
res.status(400).json({ error: 'STATUS_ERROR', message: msg })
|
|
}
|
|
})
|
|
|
|
// ============================================================================
|
|
// API ENDPOINTS - ERP INTEGRATION
|
|
// ============================================================================
|
|
|
|
/**
|
|
* External ERP endpoint to create payment link
|
|
* POST /createtransaksi
|
|
* Requires: X-API-KEY header
|
|
*/
|
|
app.post('/createtransaksi', async (req, res) => {
|
|
try {
|
|
if (!verifyExternalKey(req)) {
|
|
logWarn('createtransaksi.unauthorized', { id: req.id })
|
|
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'X-API-KEY invalid' })
|
|
}
|
|
|
|
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
|
|
|
|
const order_id = String(
|
|
(primaryItemId && mercantId) ? `${mercantId}:${primaryItemId}` :
|
|
(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
|
|
)
|
|
|
|
if (mercantId) {
|
|
try { orderMerchantId.set(order_id, mercantId) } catch {}
|
|
}
|
|
|
|
const customer = {
|
|
name: req?.body?.nama,
|
|
phone: req?.body?.no_telepon,
|
|
email: req?.body?.email,
|
|
}
|
|
const allowed_methods = req?.body?.allowed_methods
|
|
|
|
if (!order_id || typeof nominalRaw === 'undefined') {
|
|
logWarn('createtransaksi.bad_request', { id: req.id, hasOrderId: !!order_id, hasNominal: typeof nominalRaw !== 'undefined' })
|
|
return res.status(400).json({ error: 'BAD_REQUEST', message: 'order_id (mercant_id atau item[0].item_id) dan nominal wajib ada' })
|
|
}
|
|
const nominal = Number(nominalRaw)
|
|
const now = Date.now()
|
|
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
|
|
const expire_at = now + ttlMin * 60 * 1000
|
|
|
|
if (notifiedOrders.has(order_id)) {
|
|
logWarn('createtransaksi.completed', { order_id })
|
|
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
|
}
|
|
|
|
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' })
|
|
}
|
|
|
|
try {
|
|
const status = await core.transaction.status(order_id)
|
|
const s = (status?.transaction_status || '').toLowerCase()
|
|
if (s === 'pending') {
|
|
logWarn('createtransaksi.midtrans_pending', { order_id })
|
|
return res.status(409).json({
|
|
error: 'ORDER_ACTIVE',
|
|
message: 'Order sudah memiliki transaksi pending di Midtrans; gunakan instruksi pembayaran yang ada atau buat order baru.',
|
|
status: { order_id: status?.order_id, status_code: status?.status_code, status_message: status?.status_message, payment_type: status?.payment_type },
|
|
})
|
|
}
|
|
} catch (e) {
|
|
const msg = (e?.message || '').toLowerCase()
|
|
if (msg.includes('not found') || msg.includes('404')) {
|
|
logDebug('createtransaksi.midtrans_status_not_found', { order_id })
|
|
} else {
|
|
logDebug('createtransaksi.midtrans_status_check_error', { order_id, message: e?.message })
|
|
}
|
|
}
|
|
|
|
const token = createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods })
|
|
const url = `${PAYMENT_LINK_BASE}/${token}`
|
|
activeOrders.set(order_id, expire_at)
|
|
logInfo('createtransaksi.issued', { order_id, expire_at })
|
|
|
|
res.json({ status: '200', messages: 'SUCCESS', data: { url } })
|
|
} catch (e) {
|
|
logError('createtransaksi.error', { id: req.id, message: e?.message })
|
|
res.status(500).json({ error: 'CREATE_ERROR', message: e?.message || 'Internal error' })
|
|
}
|
|
})
|
|
|
|
// ============================================================================
|
|
// WEBHOOK UTILITIES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Verify webhook signature from Midtrans
|
|
* Supports both CORE and SNAP modes
|
|
*/
|
|
function verifyWebhookSignature(body, mode) {
|
|
try {
|
|
const orderId = body?.order_id
|
|
const statusCode = body?.status_code
|
|
const grossAmount = body?.gross_amount
|
|
|
|
if (mode === 'SNAP') {
|
|
const signature = req.headers['x-signature'] || req.headers['signature'] || body?.signature
|
|
if (!signature) return false
|
|
|
|
const data = `${orderId}${statusCode}${grossAmount}${serverKey}`
|
|
const expectedSignature = crypto.createHash('sha512').update(data).digest('hex')
|
|
return signature === expectedSignature
|
|
} else {
|
|
const signatureKey = (body?.signature_key || '').toLowerCase()
|
|
const expectedSig = computeMidtransSignature(orderId, statusCode, grossAmount, serverKey)
|
|
return expectedSig && signatureKey === expectedSig
|
|
}
|
|
} catch (e) {
|
|
logError('webhook.signature.error', { mode, message: e?.message })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(body, mode) {
|
|
const transactionStatus = body?.transaction_status
|
|
const fraudStatus = body?.fraud_status
|
|
const status = (transactionStatus || '').toLowerCase()
|
|
const fraud = (fraudStatus || '').toLowerCase()
|
|
|
|
switch (status) {
|
|
case 'settlement':
|
|
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,
|
|
fraud_status: fraudStatus
|
|
})
|
|
return 'unknown'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update payment ledger (in-memory for now)
|
|
* TODO: Integrate with database in production
|
|
*/
|
|
function updateLedger(orderId, data) {
|
|
// Simple in-memory ledger for now - can be enhanced to use database
|
|
// In production, this would update a proper ledger/payment database
|
|
try {
|
|
logInfo(`[${data.source}] ledger.update`, {
|
|
order_id: orderId,
|
|
status: data.status,
|
|
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
|
|
|
|
} catch (e) {
|
|
logError('ledger.update.error', { order_id: orderId, message: e?.message })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process payment completion and trigger ERP notifications
|
|
* Unified handler for both CORE and SNAP modes
|
|
*/
|
|
async function processPaymentCompletion(orderId, internalStatus, mode, body) {
|
|
try {
|
|
logInfo(`[${mode}] payment.process`, {
|
|
order_id: orderId,
|
|
internal_status: internalStatus,
|
|
transaction_status: body?.transaction_status
|
|
})
|
|
if (internalStatus === 'completed') {
|
|
const grossAmount = body?.gross_amount
|
|
const nominal = String(grossAmount || '')
|
|
|
|
if (!notifiedOrders.has(orderId)) {
|
|
activeOrders.delete(orderId)
|
|
|
|
const mercantId = resolveMercantId(orderId)
|
|
const ok = await notifyERP({ orderId, nominal, mercantId })
|
|
|
|
if (ok) {
|
|
notifiedOrders.add(orderId)
|
|
logInfo(`[${mode}] erp.notify.success`, { order_id: orderId })
|
|
} else {
|
|
logWarn(`[${mode}] erp.notify.failed`, { order_id: orderId })
|
|
}
|
|
} else {
|
|
logInfo(`[${mode}] erp.notify.skip`, { order_id: orderId, reason: 'already_notified' })
|
|
}
|
|
} else {
|
|
logInfo(`[${mode}] payment.non_success`, {
|
|
order_id: orderId,
|
|
internal_status: internalStatus,
|
|
transaction_status: body?.transaction_status
|
|
})
|
|
}
|
|
|
|
} catch (e) {
|
|
logError(`[${mode}] payment.process.error`, {
|
|
order_id: orderId,
|
|
message: e?.message
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute Midtrans webhook signature (SHA-512)
|
|
*/
|
|
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
|
try {
|
|
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
|
|
return crypto.createHash('sha512').update(raw).digest('hex')
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if Midtrans status indicates successful payment
|
|
*/
|
|
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
|
|
if (s === 'settlement') return true
|
|
if (s === 'capture' && fraud === 'accept') return true
|
|
return false
|
|
}
|
|
|
|
// ============================================================================
|
|
// HTTP UTILITIES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Custom HTTP Error class
|
|
*/
|
|
class HttpError extends Error {
|
|
constructor(statusCode, body) {
|
|
super(`HTTP ${statusCode}`)
|
|
this.statusCode = statusCode
|
|
this.body = body
|
|
}
|
|
}
|
|
|
|
/**
|
|
* HTTP POST request helper with JSON payload
|
|
* Returns promise with status and body
|
|
*/
|
|
function postJson(url, data, extraHeaders = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const u = new URL(url)
|
|
const body = JSON.stringify(data)
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
...extraHeaders,
|
|
}
|
|
logDebug('http.post', {
|
|
url,
|
|
headers: sanitize(headers),
|
|
body_length: Buffer.byteLength(body),
|
|
body_preview: body.slice(0, 256),
|
|
})
|
|
const opts = {
|
|
method: 'POST',
|
|
hostname: u.hostname,
|
|
path: u.pathname + (u.search || ''),
|
|
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
headers,
|
|
}
|
|
const req = (u.protocol === 'https:' ? https : require('http')).request(opts, (res) => {
|
|
let chunks = ''
|
|
res.on('data', (d) => { chunks += d.toString() })
|
|
res.on('end', () => {
|
|
const info = {
|
|
url,
|
|
statusCode: res.statusCode,
|
|
body_length: chunks.length,
|
|
body_preview: chunks.slice(0, 256),
|
|
}
|
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
logDebug('http.response', info)
|
|
resolve({ status: res.statusCode, body: chunks })
|
|
} else {
|
|
logWarn('http.response.error', info)
|
|
reject(new HttpError(res.statusCode, chunks))
|
|
}
|
|
})
|
|
})
|
|
req.on('error', (e) => {
|
|
logError('http.request.error', { url, message: e?.message })
|
|
reject(e)
|
|
})
|
|
req.write(body)
|
|
req.end()
|
|
} catch (e) {
|
|
logError('http.build.error', { url, message: e?.message })
|
|
reject(e)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// ERP NOTIFICATION UTILITIES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Compute ERP notification signature (SHA-512)
|
|
*/
|
|
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
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve mercant_id from order_id
|
|
* Strategy:
|
|
* 1. Check in-memory map from createtransaksi
|
|
* 2. Parse "mercant_id:item_id" pattern
|
|
* 3. Return empty string if not found
|
|
*/
|
|
function resolveMercantId(orderId) {
|
|
try {
|
|
if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId)
|
|
if (typeof orderId === 'string' && orderId.includes(':')) {
|
|
const [m] = orderId.split(':')
|
|
if (m) return m
|
|
}
|
|
} catch {}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* Send payment notification to ERP system(s)
|
|
* Supports multiple endpoints via ERP_NOTIFICATION_URLS
|
|
*/
|
|
async function notifyERP({ orderId, nominal, mercantId }) {
|
|
if (!ERP_ENABLE_NOTIF) {
|
|
logInfo('erp.notify.skip', { reason: 'disabled' })
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
const statusCode = '200'
|
|
const mId = mercantId || resolveMercantId(orderId)
|
|
if (!mId) {
|
|
logWarn('erp.notify.skip', { orderId, reason: 'missing_mercant_id' })
|
|
return false
|
|
}
|
|
const signature = computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_SECRET)
|
|
|
|
const payload = {
|
|
mercant_id: mId,
|
|
status_code: statusCode,
|
|
nominal: nominal,
|
|
signature: signature,
|
|
}
|
|
|
|
logDebug('erp.notify.payload.outgoing', {
|
|
mercant_id: mId,
|
|
status_code: statusCode,
|
|
nominal: nominal,
|
|
signature_present: typeof signature !== 'undefined',
|
|
signature_length: (typeof signature === 'string') ? signature.length : -1,
|
|
})
|
|
logInfo('erp.notify.start', { orderId, urls: ERP_NOTIFICATION_URLS })
|
|
const results = await Promise.allSettled(
|
|
ERP_NOTIFICATION_URLS.map(async (url) => {
|
|
try {
|
|
const res = await postJson(url, payload)
|
|
logInfo('erp.notify.success', { orderId, url, status: res.status })
|
|
return true
|
|
} catch (e) {
|
|
logError('erp.notify.error', {
|
|
orderId,
|
|
url,
|
|
status: e?.statusCode,
|
|
message: e?.message,
|
|
body: typeof e?.body === 'string' ? e.body.slice(0, 256) : undefined,
|
|
})
|
|
return false
|
|
}
|
|
})
|
|
)
|
|
const okCount = results.reduce((acc, r) => acc + (r.status === 'fulfilled' && r.value ? 1 : 0), 0)
|
|
const failCount = ERP_NOTIFICATION_URLS.length - okCount
|
|
logInfo('erp.notify.summary', { orderId, okCount, failCount, total: ERP_NOTIFICATION_URLS.length })
|
|
return okCount > 0
|
|
}
|
|
|
|
// ============================================================================
|
|
// WEBHOOK ENDPOINT
|
|
// ============================================================================
|
|
|
|
/**
|
|
* 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
|
|
const isSnap = body?.payment_type && body?.transaction_time && body?.settlement_time
|
|
const mode = isSnap ? 'SNAP' : 'CORE'
|
|
|
|
TransactionLogger.logWebhookReceived(mode, orderId, body?.transaction_status)
|
|
|
|
const signatureValid = verifyWebhookSignature(body, mode)
|
|
if (!signatureValid) {
|
|
logError(`[${mode}] webhook.signature.invalid`, { order_id: orderId })
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'INVALID_SIGNATURE',
|
|
message: 'Invalid signature',
|
|
statusCode: 400
|
|
})
|
|
}
|
|
|
|
const internalStatus = mapStatusToInternal(body, mode)
|
|
|
|
updateLedger(orderId, {
|
|
status: internalStatus,
|
|
source: mode === 'SNAP' ? 'snap_webhook' : 'core_webhook',
|
|
last_updated: new Date().toISOString(),
|
|
payload: body
|
|
})
|
|
|
|
if (internalStatus === 'completed') {
|
|
const grossAmount = body?.gross_amount
|
|
const nominal = String(grossAmount || '')
|
|
|
|
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, stack: e?.stack })
|
|
return res.status(500).json({
|
|
ok: false,
|
|
status: 'error',
|
|
error: 'WEBHOOK_ERROR',
|
|
message: e?.message,
|
|
statusCode: 500
|
|
})
|
|
}
|
|
})
|
|
|
|
// ============================================================================
|
|
// DEV/TEST ENDPOINTS (only when LOG_EXPOSE_API=true)
|
|
// ============================================================================
|
|
|
|
if (LOG_EXPOSE_API) {
|
|
/**
|
|
* Echo endpoint for testing ERP notifications
|
|
* POST /api/echo
|
|
*/
|
|
app.post('/api/echo', async (req, res) => {
|
|
try {
|
|
const body = req.body || {}
|
|
const sig = body?.signature
|
|
logDebug('erp.mock.receive', {
|
|
has_signature: typeof sig !== 'undefined',
|
|
signature_length: (typeof sig === 'string') ? sig.length : -1,
|
|
body_length: JSON.stringify(body).length,
|
|
})
|
|
return res.json({ ok: true, received: body })
|
|
} catch (e) {
|
|
logError('erp.mock.error', { message: e?.message })
|
|
return res.status(500).json({ error: 'ECHO_ERROR', message: e?.message || 'Echo failed' })
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Second echo endpoint for multi-URL testing
|
|
* POST /api/echo2
|
|
*/
|
|
app.post('/api/echo2', async (req, res) => {
|
|
try {
|
|
const body = req.body || {}
|
|
const sig = body?.signature
|
|
logDebug('erp.mock.receive', {
|
|
endpoint: 'echo2',
|
|
has_signature: typeof sig !== 'undefined',
|
|
signature_length: (typeof sig === 'string') ? sig.length : -1,
|
|
body_length: JSON.stringify(body).length,
|
|
})
|
|
return res.json({ ok: true, received: body })
|
|
} catch (e) {
|
|
logError('erp.mock.error', { endpoint: 'echo2', message: e?.message })
|
|
return res.status(500).json({ error: 'ECHO2_ERROR', message: e?.message || 'Echo2 failed' })
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 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({
|
|
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 }
|
|
})
|
|
}
|
|
} catch (e) {
|
|
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
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// SERVER STARTUP
|
|
// ============================================================================
|
|
|
|
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,
|
|
logToFile: LOG_TO_FILE,
|
|
logToConsole: LOG_TO_CONSOLE,
|
|
logFile: getLogFilename()
|
|
})
|
|
})
|