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 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), } // --- Payment Link Config 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 || '30', 10) const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay' const activeOrders = new Map() // order_id -> expire_at // Map untuk menyimpan mercant_id per order_id agar notifikasi ERP bisa dinamis const orderMerchantId = new Map() // order_id -> mercant_id function isDevEnv() { return (process.env.NODE_ENV || '').toLowerCase() !== 'production' } 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 // Allow if not configured only in dev for easier local testing return isDevEnv() } function base64UrlEncode(buf) { return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') } 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') } 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') } function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) { const v = 1 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)) } function resolvePaymentLinkToken(token) { try { const json = JSON.parse(base64UrlDecode(token)) const { order_id, nominal, expire_at, sig } = json || {} if (!order_id || !nominal || !expire_at || !sig) return { error: 'INVALID_TOKEN' } const expected = computeTokenSignature(order_id, nominal, expire_at) if (String(sig) !== String(expected)) return { error: 'INVALID_SIGNATURE' } 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 } } } // 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) }) // Payment Link Resolver: 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 : 30 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 }) }) // 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)) // 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) 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 }) // 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)) { notifiedOrders.add(orderId) activeOrders.delete(orderId) logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status }) await notifyERP({ orderId, nominal }) } 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 }) } }) // External ERP Create Transaction → issue payment link 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' }) } // Skema baru: // { // mercant_id, timestamp, deskripsi, nominal, // nama, no_telepon, email, // item: [{ item_id, nama, harga, qty }, ...] // } 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 // Mapping order_id: gunakan "mercant_id:item_id" bila keduanya tersedia, // jika tidak, fallback ke item_id atau mercant_id atau field order_id/item_id yang disediakan. const order_id = String( (primaryItemId && mercantId) ? `${mercantId}:${primaryItemId}` : (primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '') ) // Simpan mercant_id per order agar dapat digunakan saat notifikasi ERP if (mercantId) { try { orderMerchantId.set(order_id, mercantId) } catch {} } // Bentuk customer dari field nama/no_telepon/email 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 : 30 const expire_at = now + ttlMin * 60 * 1000 // Block jika sudah selesai if (notifiedOrders.has(order_id)) { logWarn('createtransaksi.completed', { order_id }) return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' }) } // Block jika ada link aktif belum expired 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' }) } // Guard tambahan: cek ke Midtrans apakah order_id sudah memiliki transaksi pending 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) { // Jika 404/not found, lanjut membuat payment link; error lain tetap diteruskan 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 }) // Respons mengikuti format yang diminta 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' }) } }) // --- 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 '' } } // Resolve mercant_id untuk sebuah order_id: // 1) gunakan map yang tersimpan dari createtransaksi // 2) jika order_id memakai skema "mercant_id:item_id", ambil prefix sebelum ':' // 3) fallback ke ERP_MERCANT_ID dari env (untuk kasus lama) 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 '' } async function notifyERP({ orderId, nominal, mercantId }) { if (!ERP_ENABLE_NOTIF) { logInfo('erp.notify.skip', { reason: 'disabled' }) return } // Untuk notifikasi dinamis, hanya URL dan client secret yang wajib if (!ERP_NOTIFICATION_URL || !ERP_CLIENT_ID) { logWarn('erp.notify.missing_config', { hasUrl: !!ERP_NOTIFICATION_URL, hasClientId: !!ERP_CLIENT_ID }) return } const statusCode = '200' const mId = mercantId || resolveMercantId(orderId) if (!mId) { logWarn('erp.notify.skip', { orderId, reason: 'missing_mercant_id' }) return } const signature = computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_ID) // Payload ERP harus flat: { mercant_id, nominal, status_code, signature } const payload = { mercant_id: mId, status_code: statusCode, nominal: nominal, 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) // Mark order inactive upon completion activeOrders.delete(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})`) })