/** * 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() }) })