diff --git a/server/index.cjs b/server/index.cjs index 023688a..21ff849 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -237,8 +237,9 @@ const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES | const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay' // IN-MEMORY STORAGE -const activeOrders = new Map() +const activeOrders = new Map() const orderMerchantId = new Map() +const orderRetryCount = new Map() // CHECK IF DEVELOPMENT ENVIRONMENT function isDevEnv() { @@ -579,13 +580,14 @@ app.get('/api/payments/:orderId/status', async (req, res) => { try { if (isSuccessfulMidtransStatus(status)) { const nominal = String(status?.gross_amount || '') - if (!notifiedOrders.has(orderId)) { - activeOrders.delete(orderId) + const baseOId = getBaseOrderId(orderId) + if (!notifiedOrders.has(baseOId)) { + activeOrders.delete(baseOId) logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status }) const mercantId = resolveMercantId(orderId) const ok = await notifyERP({ orderId, nominal, mercantId }) if (ok) { - notifiedOrders.add(orderId) + notifiedOrders.add(baseOId) } else { logWarn('erp.notify.defer', { orderId, reason: 'post_failed_or_missing_data' }) } @@ -625,13 +627,21 @@ app.post('/createtransaksi', async (req, res) => { const items = Array.isArray(req?.body?.item) ? req.body.item : [] const primaryItemId = items?.[0]?.item_id + // Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':') const order_id = String( - (primaryItemId && mercantId) ? `${mercantId}:${primaryItemId}` : + (primaryItemId && mercantId) ? `${mercantId}.${primaryItemId}` : (primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '') ) + // Compute Midtrans-specific order_id — append retry suffix when the previous attempt expired + const retryCount = orderRetryCount.get(order_id) || 0 + const midtransOrderId = retryCount > 0 ? `${order_id}.r${retryCount}` : order_id + if (mercantId) { - try { orderMerchantId.set(order_id, mercantId) } catch {} + try { + orderMerchantId.set(order_id, mercantId) + if (midtransOrderId !== order_id) orderMerchantId.set(midtransOrderId, mercantId) + } catch {} } const customer = { @@ -662,10 +672,10 @@ app.post('/createtransaksi', async (req, res) => { } try { - const status = await core.transaction.status(order_id) + const status = await core.transaction.status(midtransOrderId) const s = (status?.transaction_status || '').toLowerCase() if (s === 'pending') { - logWarn('createtransaksi.midtrans_pending', { order_id }) + logWarn('createtransaksi.midtrans_pending', { order_id, midtrans_order_id: midtransOrderId }) 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.', @@ -675,16 +685,16 @@ app.post('/createtransaksi', async (req, res) => { } catch (e) { const msg = (e?.message || '').toLowerCase() if (msg.includes('not found') || msg.includes('404')) { - logDebug('createtransaksi.midtrans_status_not_found', { order_id }) + logDebug('createtransaksi.midtrans_status_not_found', { order_id, midtrans_order_id: midtransOrderId }) } else { - logDebug('createtransaksi.midtrans_status_check_error', { order_id, message: e?.message }) + logDebug('createtransaksi.midtrans_status_check_error', { order_id, midtrans_order_id: midtransOrderId, message: e?.message }) } } - const token = createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) + const token = createPaymentLinkToken({ order_id: midtransOrderId, 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 }) + logInfo('createtransaksi.issued', { order_id, midtrans_order_id: midtransOrderId, retry_count: retryCount, expire_at }) res.json({ status: '200', messages: 'SUCCESS', data: { url } }) } catch (e) { @@ -967,18 +977,26 @@ function computeErpSignature(mercantId, statusCode, nominal, clientId) { } } +/** + * Strip retry suffix (.rN) from order_id to get the base ERP order_id + */ +function getBaseOrderId(orderId) { + if (typeof orderId !== 'string') return orderId + return orderId.replace(/\.r\d+$/, '') +} + /** * Resolve mercant_id from order_id * Strategy: * 1. Check in-memory map from createtransaksi - * 2. Parse "mercant_id:item_id" pattern + * 2. Parse "mercant_id.item_id" pattern * 3. Return empty string if not found */ function resolveMercantId(orderId) { try { if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId) - if (typeof orderId === 'string' && orderId.includes(':')) { - const [m] = orderId.split(':') + if (typeof orderId === 'string' && orderId.includes('.')) { + const [m] = orderId.split('.') if (m) return m } } catch {} @@ -1079,6 +1097,7 @@ app.post('/api/payments/notification', async (req, res) => { } const internalStatus = mapStatusToInternal(body, mode) + const baseOrderId = getBaseOrderId(orderId) updateLedger(orderId, { status: internalStatus, @@ -1091,24 +1110,24 @@ app.post('/api/payments/notification', async (req, res) => { const grossAmount = body?.gross_amount const nominal = String(grossAmount || '') - if (notifiedOrders.has(orderId)) { + if (notifiedOrders.has(baseOrderId)) { logInfo(`[${mode}] webhook.already_notified`, { order_id: orderId }) - return res.json({ - ok: true, + return res.json({ + ok: true, status: 'already_notified', message: 'Payment already processed and ERP notified', statusCode: 200 }) } - activeOrders.delete(orderId) + activeOrders.delete(baseOrderId) 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) + notifiedOrders.add(baseOrderId) logInfo(`[${mode}] webhook.erp_success`, { order_id: orderId }) return res.json({ ok: true, @@ -1166,13 +1185,27 @@ app.post('/api/payments/notification', async (req, res) => { }) } else if (internalStatus === 'failed') { - logWarn(`[${mode}] webhook.failed`, { - order_id: orderId, + const txStatus = (body?.transaction_status || '').toLowerCase() + + if (txStatus === 'expire') { + // Clear state so /createtransaksi can issue a new payment link for this order + activeOrders.delete(baseOrderId) + const nextRetry = (orderRetryCount.get(baseOrderId) || 0) + 1 + orderRetryCount.set(baseOrderId, nextRetry) + logInfo(`[${mode}] webhook.expire.retry_ready`, { + order_id: orderId, + base_order_id: baseOrderId, + next_retry: nextRetry + }) + } + + logWarn(`[${mode}] webhook.failed`, { + order_id: orderId, transaction_status: body?.transaction_status, fraud_status: body?.fraud_status }) - return res.status(400).json({ - ok: true, + return res.status(400).json({ + ok: true, status: 'failed', message: 'Payment failed, no further action needed', statusCode: 400