fix: replace colon separator in order_id with Midtrans-allowed character

Midtrans rejects order_id values containing ':' (only alphanumeric and
- _ ~ . are allowed). The composite mercant_id:item_id order_id and the
:rN retry suffix violated this, causing Snap token requests to fail
with a 400 once both a mercant_id and item_id were present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tengku Achmad 2026-08-03 20:14:00 +07:00
parent 8578f1622b
commit 80f92aba84
1 changed files with 58 additions and 25 deletions

View File

@ -239,6 +239,7 @@ const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:517
// IN-MEMORY STORAGE
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,7 +1110,7 @@ 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,
@ -1101,14 +1120,14 @@ app.post('/api/payments/notification', async (req, res) => {
})
}
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,6 +1185,20 @@ app.post('/api/payments/notification', async (req, res) => {
})
}
else if (internalStatus === 'failed') {
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,