313 lines
11 KiB
JavaScript
313 lines
11 KiB
JavaScript
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')
|
|
|
|
dotenv.config()
|
|
|
|
const app = express()
|
|
app.use(cors())
|
|
app.use(express.json())
|
|
|
|
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')
|
|
}
|
|
|
|
const core = new midtransClient.CoreApi({
|
|
isProduction,
|
|
serverKey,
|
|
clientKey,
|
|
})
|
|
|
|
// --- ERP Notification Config
|
|
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
|
|
}
|
|
const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || ''
|
|
const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF)
|
|
const ERP_CLIENT_ID = process.env.ERP_CLIENT_ID || ''
|
|
const ERP_MERCANT_ID = process.env.ERP_MERCANT_ID || process.env.ERP_MERCHANT_ID || ''
|
|
const notifiedOrders = new Set()
|
|
|
|
// --- Logger utilities
|
|
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
|
|
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
|
|
function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) }
|
|
function ts() { return new Date().toISOString() }
|
|
function sanitize(obj) {
|
|
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
|
|
}
|
|
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)
|
|
}
|
|
function log(level, msg, meta) {
|
|
if (!shouldLog(level)) return
|
|
const line = `[${ts()}] [${level}] ${msg}`
|
|
if (meta) {
|
|
const data = typeof meta === 'string' ? meta : JSON.stringify(meta)
|
|
console.log(line, data)
|
|
} else {
|
|
console.log(line)
|
|
}
|
|
}
|
|
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 ID + basic request/response logging
|
|
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()
|
|
})
|
|
|
|
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),
|
|
}
|
|
|
|
// Health check
|
|
app.get('/api/health', (_req, res) => {
|
|
logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey })
|
|
res.json({ ok: true, env: { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey } })
|
|
})
|
|
|
|
// Runtime config (feature toggles)
|
|
app.get('/api/config', (_req, res) => {
|
|
const payload = {
|
|
paymentToggles: { ...ENABLE },
|
|
midtransEnv: isProduction ? 'production' : 'sandbox',
|
|
clientKey,
|
|
}
|
|
logDebug('config.get', payload)
|
|
res.json(payload)
|
|
})
|
|
|
|
// Dev-only: allow updating toggles at runtime without restart
|
|
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)
|
|
})
|
|
|
|
// Charge endpoint (pass-through to Midtrans Core API)
|
|
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))
|
|
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)
|
|
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'
|
|
logError('charge.error', { id: req.id, message: msg })
|
|
res.status(400).json({ error: 'CHARGE_ERROR', message: msg })
|
|
}
|
|
})
|
|
|
|
// Status endpoint (by order_id)
|
|
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 })
|
|
res.json(status)
|
|
} 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 })
|
|
}
|
|
})
|
|
|
|
// --- Helpers: Midtrans signature verification & ERP notify
|
|
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 ''
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function postJson(url, data) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const u = new URL(url)
|
|
const body = JSON.stringify(data)
|
|
const opts = {
|
|
method: 'POST',
|
|
hostname: u.hostname,
|
|
path: u.pathname + (u.search || ''),
|
|
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
},
|
|
}
|
|
const req = (u.protocol === 'https:' ? https : require('http')).request(opts, (res) => {
|
|
let chunks = ''
|
|
res.on('data', (d) => { chunks += d.toString() })
|
|
res.on('end', () => {
|
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) resolve({ status: res.statusCode, body: chunks })
|
|
else reject(new Error(`HTTP ${res.statusCode}: ${chunks}`))
|
|
})
|
|
})
|
|
req.on('error', reject)
|
|
req.write(body)
|
|
req.end()
|
|
} catch (e) {
|
|
reject(e)
|
|
}
|
|
})
|
|
}
|
|
|
|
function computeErpSignature(mercantId, statusCode, nominal, clientId) {
|
|
try {
|
|
const raw = String(mercantId) + String(statusCode) + String(nominal) + String(clientId)
|
|
return crypto.createHash('sha512').update(raw).digest('hex')
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
async function notifyERP({ orderId, nominal }) {
|
|
if (!ERP_ENABLE_NOTIF) {
|
|
logInfo('erp.notify.skip', { reason: 'disabled' })
|
|
return
|
|
}
|
|
if (!ERP_NOTIFICATION_URL || !ERP_CLIENT_ID || !ERP_MERCANT_ID) {
|
|
logWarn('erp.notify.missing_config', { hasUrl: !!ERP_NOTIFICATION_URL, hasClientId: !!ERP_CLIENT_ID, hasMercantId: !!ERP_MERCANT_ID })
|
|
return
|
|
}
|
|
const statusCode = '200'
|
|
const signature = computeErpSignature(ERP_MERCANT_ID, statusCode, nominal, ERP_CLIENT_ID)
|
|
const payload = {
|
|
data: {
|
|
mercant_id: ERP_MERCANT_ID,
|
|
status_code: statusCode,
|
|
nominal: nominal,
|
|
client_id: ERP_CLIENT_ID,
|
|
},
|
|
signature,
|
|
}
|
|
logInfo('erp.notify.start', { orderId, url: ERP_NOTIFICATION_URL })
|
|
try {
|
|
const res = await postJson(ERP_NOTIFICATION_URL, payload)
|
|
logInfo('erp.notify.success', { orderId, status: res.status })
|
|
} catch (e) {
|
|
logError('erp.notify.error', { orderId, message: e?.message })
|
|
}
|
|
}
|
|
|
|
// Webhook endpoint for Midtrans notifications
|
|
app.post('/api/payments/webhook', async (req, res) => {
|
|
try {
|
|
const body = req.body || {}
|
|
const orderId = body?.order_id
|
|
const statusCode = body?.status_code
|
|
const grossAmount = body?.gross_amount
|
|
const signatureKey = (body?.signature_key || '').toLowerCase()
|
|
logInfo('webhook.receive', { order_id: orderId, transaction_status: body?.transaction_status })
|
|
|
|
// Verify signature
|
|
const expectedSig = computeMidtransSignature(orderId, statusCode, grossAmount, serverKey)
|
|
if (!expectedSig || signatureKey !== expectedSig) {
|
|
logWarn('webhook.signature.invalid', { order_id: orderId })
|
|
return res.status(401).json({ error: 'INVALID_SIGNATURE' })
|
|
}
|
|
|
|
// Acknowledge quickly
|
|
res.json({ ok: true })
|
|
|
|
// Process success callbacks asynchronously
|
|
if (isSuccessfulMidtransStatus(body)) {
|
|
const nominal = String(grossAmount)
|
|
if (!notifiedOrders.has(orderId)) {
|
|
notifiedOrders.add(orderId)
|
|
await notifyERP({ orderId, nominal })
|
|
} else {
|
|
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
|
|
}
|
|
} else {
|
|
logInfo('webhook.non_success', { order_id: orderId, transaction_status: body?.transaction_status })
|
|
}
|
|
} catch (e) {
|
|
logError('webhook.error', { message: e?.message })
|
|
try { res.status(500).json({ error: 'WEBHOOK_ERROR' }) } catch {}
|
|
}
|
|
})
|
|
|
|
const port = process.env.PORT || 8000
|
|
app.listen(port, () => {
|
|
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)
|
|
}) |