|
|
|
@ -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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|