ERP: gunakan ERP_CLIENT_SECRET untuk signature; perbaiki fallback; tambah log detail HTTP request/response; endpoint GET /api/logs untuk akses log via browser; log payload ERP dengan signature length dan presence #10

Merged
root merged 1 commits from feat/payment-link-flow into main 2025-11-17 06:08:56 +00:00
2 changed files with 86 additions and 28 deletions

View File

@ -1,15 +0,0 @@
// Utility to compute Midtrans webhook signature: sha512(order_id + status_code + gross_amount + server_key)
const crypto = require('crypto')
function main() {
const [orderId, statusCode, grossAmount, serverKey] = process.argv.slice(2)
if (!orderId || !statusCode || !grossAmount || !serverKey) {
console.error('Usage: node scripts/midtrans-sig.js <order_id> <status_code> <gross_amount> <server_key>')
process.exit(1)
}
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(serverKey)
const sig = crypto.createHash('sha512').update(raw).digest('hex')
process.stdout.write(sig)
}
main()

View File

@ -37,12 +37,16 @@ function parseEnable(v) {
} }
const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || '' const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || ''
const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF) const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF)
const ERP_CLIENT_ID = process.env.ERP_CLIENT_ID || '' // Gunakan secret untuk signature; fallback ke CLIENT_ID bila SECRET belum ada
const ERP_CLIENT_SECRET = process.env.ERP_CLIENT_SECRET || process.env.ERP_CLIENT_ID || ''
const notifiedOrders = new Set() const notifiedOrders = new Set()
// --- Logger utilities // --- Logger utilities
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase() const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 } 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 recentLogs = []
function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) } function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) }
function ts() { return new Date().toISOString() } function ts() { return new Date().toISOString() }
function sanitize(obj) { function sanitize(obj) {
@ -65,6 +69,11 @@ function maskPayload(obj) {
function log(level, msg, meta) { function log(level, msg, meta) {
if (!shouldLog(level)) return if (!shouldLog(level)) return
const line = `[${ts()}] [${level}] ${msg}` const line = `[${ts()}] [${level}] ${msg}`
try {
const entry = { ts: ts(), level, msg, meta: sanitize(meta) }
recentLogs.push(entry)
if (recentLogs.length > LOG_BUFFER_SIZE) recentLogs.shift()
} catch {}
if (meta) { if (meta) {
const data = typeof meta === 'string' ? meta : JSON.stringify(meta) const data = typeof meta === 'string' ? meta : JSON.stringify(meta)
console.log(line, data) console.log(line, data)
@ -163,6 +172,26 @@ app.get('/api/config', (_req, res) => {
res.json(payload) res.json(payload)
}) })
// Logs endpoint (dev/debug): 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 })
})
// Dev-only: allow updating toggles at runtime without restart // Dev-only: allow updating toggles at runtime without restart
app.post('/api/config', (req, res) => { app.post('/api/config', (req, res) => {
const isDev = process.env.NODE_ENV !== 'production' const isDev = process.env.NODE_ENV !== 'production'
@ -411,33 +440,64 @@ function isSuccessfulMidtransStatus(body) {
return false return false
} }
function postJson(url, data) { class HttpError extends Error {
constructor(statusCode, body) {
super(`HTTP ${statusCode}`)
this.statusCode = statusCode
this.body = body
}
}
function postJson(url, data, extraHeaders = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const u = new URL(url) const u = new URL(url)
const body = JSON.stringify(data) 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 = { const opts = {
method: 'POST', method: 'POST',
hostname: u.hostname, hostname: u.hostname,
path: u.pathname + (u.search || ''), path: u.pathname + (u.search || ''),
port: u.port || (u.protocol === 'https:' ? 443 : 80), port: u.port || (u.protocol === 'https:' ? 443 : 80),
headers: { headers,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
} }
const req = (u.protocol === 'https:' ? https : require('http')).request(opts, (res) => { const req = (u.protocol === 'https:' ? https : require('http')).request(opts, (res) => {
let chunks = '' let chunks = ''
res.on('data', (d) => { chunks += d.toString() }) res.on('data', (d) => { chunks += d.toString() })
res.on('end', () => { res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) resolve({ status: res.statusCode, body: chunks }) const info = {
else reject(new Error(`HTTP ${res.statusCode}: ${chunks}`)) 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', reject) req.on('error', (e) => {
logError('http.request.error', { url, message: e?.message })
reject(e)
})
req.write(body) req.write(body)
req.end() req.end()
} catch (e) { } catch (e) {
logError('http.build.error', { url, message: e?.message })
reject(e) reject(e)
} }
}) })
@ -474,8 +534,8 @@ async function notifyERP({ orderId, nominal, mercantId }) {
return false return false
} }
// Untuk notifikasi dinamis, hanya URL dan client secret yang wajib // Untuk notifikasi dinamis, hanya URL dan client secret yang wajib
if (!ERP_NOTIFICATION_URL || !ERP_CLIENT_ID) { if (!ERP_NOTIFICATION_URL || !ERP_CLIENT_SECRET) {
logWarn('erp.notify.missing_config', { hasUrl: !!ERP_NOTIFICATION_URL, hasClientId: !!ERP_CLIENT_ID }) logWarn('erp.notify.missing_config', { hasUrl: !!ERP_NOTIFICATION_URL, hasClientSecret: !!ERP_CLIENT_SECRET })
return false return false
} }
const statusCode = '200' const statusCode = '200'
@ -484,7 +544,7 @@ async function notifyERP({ orderId, nominal, mercantId }) {
logWarn('erp.notify.skip', { orderId, reason: 'missing_mercant_id' }) logWarn('erp.notify.skip', { orderId, reason: 'missing_mercant_id' })
return false return false
} }
const signature = await computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_ID) const signature = await computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_SECRET)
const payload = { const payload = {
mercant_id: mId, mercant_id: mId,
@ -492,13 +552,26 @@ async function notifyERP({ orderId, nominal, mercantId }) {
nominal: nominal, nominal: nominal,
signature: signature, signature: signature,
} }
// Tambahan debug: pastikan signature benar-benar ikut terkirim
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, url: ERP_NOTIFICATION_URL }) logInfo('erp.notify.start', { orderId, url: ERP_NOTIFICATION_URL })
try { try {
const res = await postJson(ERP_NOTIFICATION_URL, payload) const res = await postJson(ERP_NOTIFICATION_URL, payload)
logInfo('erp.notify.success', { orderId, status: res.status }) logInfo('erp.notify.success', { orderId, status: res.status })
return true return true
} catch (e) { } catch (e) {
logError('erp.notify.error', { orderId, message: e?.message }) logError('erp.notify.error', {
orderId,
status: e?.statusCode,
message: e?.message,
body: typeof e?.body === 'string' ? e.body.slice(0, 256) : undefined,
})
return false return false
} }
} }