diff --git a/package-lock.json b/package-lock.json index b7acc98..8bf0d7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "framer-motion": "^12.23.24", + "fs": "^0.0.1-security", "midtrans-client": "^1.4.3", + "path": "^0.12.7", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.66.0", @@ -3318,6 +3320,12 @@ "node": ">= 0.8" } }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4331,6 +4339,16 @@ "node": ">= 0.8" } }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4538,6 +4556,15 @@ } } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5351,6 +5378,15 @@ "punycode": "^2.1.0" } }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5358,6 +5394,12 @@ "dev": true, "license": "MIT" }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index b022e59..06e3938 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "framer-motion": "^12.23.24", + "fs": "^0.0.1-security", "midtrans-client": "^1.4.3", + "path": "^0.12.7", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.66.0", diff --git a/server/index.cjs b/server/index.cjs index 8661045..bb9f783 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -1,11 +1,6 @@ /** - * Simaya Midtrans Payment Server - * - * Backend Express.js server untuk integrasi pembayaran Midtrans dengan sistem ERP. - * Mendukung CORE API dan SNAP modes dengan unified webhook handler. - * - * @author Simaya Team - * @version 2.0.0 + * SIMAYA MIDTRANS PAYMENT SERVER V2.0 + * EXPRESS BACKEND UNTUK INTEGRASI MIDTRANS (CORE + SNAP) DENGAN ERP */ const express = require('express') @@ -14,13 +9,14 @@ 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 +// TRANSACTION LOGGER - PAYMENT LIFECYCLE TRACKING // ============================================================================ -// Shared logger functionality untuk tracking payment lifecycle const TransactionLogger = { logPaymentInit: (mode, orderId, amount) => logInfo(`[${mode}] payment.init`, { orderId, amount }), logPaymentSuccess: (mode, orderId, transactionId) => logInfo(`[${mode}] payment.success`, { orderId, transactionId }), @@ -47,7 +43,7 @@ if (!serverKey || !clientKey) { console.warn('[Midtrans] Missing server/client keys in environment variables') } -// Initialize Midtrans Core API client +// INITIALIZE MIDTRANS CORE API INSTANCE const core = new midtransClient.CoreApi({ isProduction, serverKey, @@ -58,10 +54,7 @@ const core = new midtransClient.CoreApi({ // ERP INTEGRATION CONFIGURATION // ============================================================================ -/** - * Parse boolean-like environment variable values - * Supports: true/false, 1/0, yes/no, on/off - */ +// PARSE BOOLEAN VALUES (TRUE/FALSE, 1/0, YES/NO, ON/OFF) function parseEnable(v) { if (typeof v === 'string') { const s = v.trim().toLowerCase() @@ -72,9 +65,7 @@ function parseEnable(v) { return true } -/** - * Parse comma-separated list from environment variable - */ +// PARSE COMMA-SEPARATED LIST function parseList(value) { if (!value) return [] return String(value) @@ -83,19 +74,19 @@ function parseList(value) { .filter(Boolean) } -// ERP notification settings +// 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 || '' -// Support multiple ERP endpoints (comma-separated) +// 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 +// IN-MEMORY TRACKING UNTUK PREVENT DUPLICATE NOTIFICATIONS const notifiedOrders = new Set() // ============================================================================ @@ -106,18 +97,44 @@ 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 = [] -/** - * Check if message should be logged based on configured level - */ +// 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 current timestamp in Jakarta timezone (WIB/UTC+7) - */ +// GET TIMESTAMP IN JAKARTA TIMEZONE (WIB/UTC+7) function ts() { const now = new Date() const jakartaOffset = 7 * 60 // minutes @@ -125,16 +142,12 @@ function ts() { return localTime.toISOString().replace('Z', '+07:00') } -/** - * Sanitize object untuk logging (deep clone) - */ +// SANITIZE OBJECT FOR LOGGING (DEEP CLONE) function sanitize(obj) { try { return JSON.parse(JSON.stringify(obj)) } catch { return obj } } -/** - * Mask sensitive fields dalam payload (card numbers, CVV, tokens, etc.) - */ +// 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'] @@ -150,26 +163,36 @@ function maskPayload(obj) { return mask(o) } -/** - * Core logging function dengan in-memory buffer - */ +// CORE LOGGING FUNCTION (FILE + CONSOLE + MEMORY BUFFER) function log(level, msg, meta) { if (!shouldLog(level)) return - const line = `[${ts()}] [${level}] ${msg}` + + const timestamp = ts() + const levelUpper = level.toUpperCase().padEnd(5, ' ') + try { - const entry = { ts: ts(), level, msg, meta: sanitize(meta) } + const entry = { ts: timestamp, level, msg, meta: sanitize(meta) } recentLogs.push(entry) if (recentLogs.length > LOG_BUFFER_SIZE) recentLogs.shift() - } catch {} - if (meta) { - const data = typeof meta === 'string' ? meta : JSON.stringify(meta) - console.log(line, data) - } else { - console.log(line) + + 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) } } -// Convenience logging functions const logDebug = (m, meta) => log('debug', m, meta) const logInfo = (m, meta) => log('info', m, meta) const logWarn = (m, meta) => log('warn', m, meta) @@ -179,14 +202,11 @@ const logError = (m, meta) => log('error', m, meta) // REQUEST TRACKING MIDDLEWARE // ============================================================================ -/** - * Generate unique request ID untuk tracking - */ +// GENERATE UNIQUE REQUEST ID function newReqId() { return Math.random().toString(36).slice(2) + Date.now().toString(36) } -// Request/response logging middleware app.use((req, res, next) => { req.id = newReqId() logInfo('req.start', { id: req.id, method: req.method, url: req.originalUrl }) @@ -216,24 +236,19 @@ 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() // order_id -> expire_at -const orderMerchantId = new Map() // order_id -> mercant_id (untuk dynamic ERP notification) +// IN-MEMORY STORAGE +const activeOrders = new Map() +const orderMerchantId = new Map() -/** - * Check if running in development environment - */ +// CHECK IF DEVELOPMENT ENVIRONMENT function isDevEnv() { return (process.env.NODE_ENV || '').toLowerCase() !== 'production' } -/** - * Verify external API key dari request header - */ +// 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 - // Allow if not configured only in dev for easier local testing return isDevEnv() } @@ -241,62 +256,50 @@ function verifyExternalKey(req) { // PAYMENT LINK TOKEN UTILITIES // ============================================================================ -/** - * Encode data to base64url format (URL-safe) - */ +// ENCODE TO BASE64URL (URL-SAFE) function base64UrlEncode(buf) { return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') } -/** - * Decode base64url format to string - */ +// 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 untuk payment link token - */ +// 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 {string} Base64url-encoded signed token - */ +// CREATE SIGNED PAYMENT LINK TOKEN (RETURNS BASE64URL STRING) function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) { - const v = 1 // Token version + 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 dan validate payment link token - * @returns {object} { error?, 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 + // VALIDATE REQUIRED FIELDS if (!order_id || !nominal || !expire_at || !sig) { return { error: 'INVALID_TOKEN' } } - // Verify signature + // VERIFY SIGNATURE const expected = computeTokenSignature(order_id, nominal, expire_at) if (String(sig) !== String(expected)) { return { error: 'INVALID_SIGNATURE' } } - // Check expiration + // CHECK EXPIRATION if (Date.now() > Number(expire_at)) { return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } } } @@ -420,7 +423,7 @@ app.post('/api/payments/charge', async (req, res) => { 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 + // 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 { @@ -500,14 +503,14 @@ app.post('/api/payments/snap/token', async (req, res) => { app.get('/api/payments/:orderId/status', async (req, res) => { const { orderId } = req.params try { - // midtrans-client exposes transaction helper via CoreApi + // 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 + // RESPOND IMMEDIATELY WITH STATUS res.json(status) - // Fallback: selain webhook, jika status di sini sudah sukses (settlement/capture+accept), kirim notifikasi ke ERP + // FALLBACK: SELAIN WEBHOOK, JIKA STATUS DI SINI SUDAH SUKSES (SETTLEMENT/CAPTURE+ACCEPT), KIRIM NOTIFIKASI KE ERP setImmediate(async () => { try { if (isSuccessfulMidtransStatus(status)) { @@ -515,7 +518,8 @@ app.get('/api/payments/:orderId/status', async (req, res) => { if (!notifiedOrders.has(orderId)) { activeOrders.delete(orderId) logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status }) - const ok = await notifyERP({ orderId, nominal }) + const mercantId = resolveMercantId(orderId) + const ok = await notifyERP({ orderId, nominal, mercantId }) if (ok) { notifiedOrders.add(orderId) } else { @@ -552,28 +556,20 @@ app.post('/createtransaksi', async (req, res) => { 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, @@ -590,19 +586,17 @@ app.post('/createtransaksi', async (req, res) => { const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440 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() @@ -615,7 +609,6 @@ app.post('/createtransaksi', async (req, res) => { }) } } 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 }) @@ -629,7 +622,6 @@ app.post('/createtransaksi', async (req, res) => { 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 }) @@ -652,7 +644,6 @@ function verifyWebhookSignature(body, mode) { const grossAmount = body?.gross_amount if (mode === 'SNAP') { - // Snap signature: order_id + status_code + gross_amount + server_key const signature = req.headers['x-signature'] || req.headers['signature'] || body?.signature if (!signature) return false @@ -660,7 +651,6 @@ function verifyWebhookSignature(body, mode) { const expectedSignature = crypto.createHash('sha512').update(data).digest('hex') return signature === expectedSignature } else { - // Core signature: signature_key field in body const signatureKey = (body?.signature_key || '').toLowerCase() const expectedSig = computeMidtransSignature(orderId, statusCode, grossAmount, serverKey) return expectedSig && signatureKey === expectedSig @@ -673,26 +663,58 @@ function verifyWebhookSignature(body, mode) { /** * 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(transactionStatus, mode) { - // Unified status mapping - both Core and Snap use similar status values +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': - case 'capture': // for cards with fraud_status=accept 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 }) + logWarn(`[${mode}] webhook.unknown_status`, { + transaction_status: transactionStatus, + fraud_status: fraudStatus + }) return 'unknown' } } @@ -711,9 +733,9 @@ function updateLedger(orderId, data) { 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 + // 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 }) @@ -731,18 +753,13 @@ async function processPaymentCompletion(orderId, internalStatus, mode, body) { internal_status: internalStatus, transaction_status: body?.transaction_status }) - - // Shared business logic for successful payments if (internalStatus === 'completed') { const grossAmount = body?.gross_amount const nominal = String(grossAmount || '') - // Prevent duplicate notifications if (!notifiedOrders.has(orderId)) { - // Mark order inactive upon completion activeOrders.delete(orderId) - // Notify ERP systems const mercantId = resolveMercantId(orderId) const ok = await notifyERP({ orderId, nominal, mercantId }) @@ -789,7 +806,7 @@ function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) { 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 + // 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 @@ -876,11 +893,11 @@ function postJson(url, data, extraHeaders = {}) { /** * Compute ERP notification signature (SHA-512) */ -async function computeErpSignature(mercantId, statusCode, nominal, clientId) { +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; + const sign = crypto.createHash('sha512').update(raw).digest('hex') + return sign } catch { return '' } @@ -913,7 +930,7 @@ async function notifyERP({ orderId, nominal, mercantId }) { logInfo('erp.notify.skip', { reason: 'disabled' }) return false } - // Untuk notifikasi dinamis, hanya URL dan client secret yang wajib + 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 @@ -924,7 +941,7 @@ async function notifyERP({ orderId, nominal, mercantId }) { logWarn('erp.notify.skip', { orderId, reason: 'missing_mercant_id' }) return false } - const signature = await computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_SECRET) + const signature = computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_SECRET) const payload = { mercant_id: mId, @@ -932,7 +949,7 @@ async function notifyERP({ orderId, nominal, mercantId }) { nominal: nominal, signature: signature, } - // Tambahan debug: pastikan signature benar-benar ikut terkirim + logDebug('erp.notify.payload.outgoing', { mercant_id: mId, status_code: statusCode, @@ -973,29 +990,32 @@ async function notifyERP({ orderId, nominal, mercantId }) { * 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 - - // Mode detection: Snap has payment_type, transaction_time, settlement_time const isSnap = body?.payment_type && body?.transaction_time && body?.settlement_time const mode = isSnap ? 'SNAP' : 'CORE' TransactionLogger.logWebhookReceived(mode, orderId, body?.transaction_status) - // Unified signature verification const signatureValid = verifyWebhookSignature(body, mode) if (!signatureValid) { logError(`[${mode}] webhook.signature.invalid`, { order_id: orderId }) - return res.status(400).send('Invalid signature') + return res.status(400).json({ + ok: false, + error: 'INVALID_SIGNATURE', + message: 'Invalid signature', + statusCode: 400 + }) } - // Unified status mapping - const internalStatus = mapStatusToInternal(body?.transaction_status, mode) + const internalStatus = mapStatusToInternal(body, mode) - // Update ledger with source indicator updateLedger(orderId, { status: internalStatus, source: mode === 'SNAP' ? 'snap_webhook' : 'core_webhook', @@ -1003,15 +1023,121 @@ app.post('/api/payments/notification', async (req, res) => { payload: body }) - // Acknowledge quickly - res.json({ ok: true }) + if (internalStatus === 'completed') { + const grossAmount = body?.gross_amount + const nominal = String(grossAmount || '') - // Trigger shared business logic (ERP unfreeze, etc.) - await processPaymentCompletion(orderId, internalStatus, mode, body) + 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 }) - try { res.status(500).json({ error: 'WEBHOOK_ERROR' }) } catch {} + 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 + }) } }) @@ -1064,18 +1190,50 @@ if (LOG_EXPOSE_API) { /** * 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({ error: 'BAD_REQUEST', message: 'Provide orderId or mercant_id and nominal' }) + 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 } + }) } - const ok = await notifyERP({ orderId, nominal: String(nominal || ''), mercantId: mercant_id }) - return res.json({ ok }) } catch (e) { - logError('test.notify.error', { message: e?.message }) - return res.status(500).json({ error: 'TEST_NOTIFY_ERROR', message: e?.message || 'Notify test failed' }) + 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 + }) } }) } @@ -1086,13 +1244,22 @@ if (LOG_EXPOSE_API) { 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 + erpEndpoints: ERP_NOTIFICATION_URLS.length, + logToFile: LOG_TO_FILE, + logToConsole: LOG_TO_CONSOLE, + logFile: getLogFilename() }) })