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

@ -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' const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
// IN-MEMORY STORAGE // IN-MEMORY STORAGE
const activeOrders = new Map() const activeOrders = new Map()
const orderMerchantId = new Map() const orderMerchantId = new Map()
const orderRetryCount = new Map()
// CHECK IF DEVELOPMENT ENVIRONMENT // CHECK IF DEVELOPMENT ENVIRONMENT
function isDevEnv() { function isDevEnv() {
@ -579,13 +580,14 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
try { try {
if (isSuccessfulMidtransStatus(status)) { if (isSuccessfulMidtransStatus(status)) {
const nominal = String(status?.gross_amount || '') const nominal = String(status?.gross_amount || '')
if (!notifiedOrders.has(orderId)) { const baseOId = getBaseOrderId(orderId)
activeOrders.delete(orderId) if (!notifiedOrders.has(baseOId)) {
activeOrders.delete(baseOId)
logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status }) logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status })
const mercantId = resolveMercantId(orderId) const mercantId = resolveMercantId(orderId)
const ok = await notifyERP({ orderId, nominal, mercantId }) const ok = await notifyERP({ orderId, nominal, mercantId })
if (ok) { if (ok) {
notifiedOrders.add(orderId) notifiedOrders.add(baseOId)
} else { } else {
logWarn('erp.notify.defer', { orderId, reason: 'post_failed_or_missing_data' }) 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 items = Array.isArray(req?.body?.item) ? req.body.item : []
const primaryItemId = items?.[0]?.item_id const primaryItemId = items?.[0]?.item_id
// Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':')
const order_id = String( const order_id = String(
(primaryItemId && mercantId) ? `${mercantId}:${primaryItemId}` : (primaryItemId && mercantId) ? `${mercantId}.${primaryItemId}` :
(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '') (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) { 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 = { const customer = {
@ -662,10 +672,10 @@ app.post('/createtransaksi', async (req, res) => {
} }
try { try {
const status = await core.transaction.status(order_id) const status = await core.transaction.status(midtransOrderId)
const s = (status?.transaction_status || '').toLowerCase() const s = (status?.transaction_status || '').toLowerCase()
if (s === 'pending') { if (s === 'pending') {
logWarn('createtransaksi.midtrans_pending', { order_id }) logWarn('createtransaksi.midtrans_pending', { order_id, midtrans_order_id: midtransOrderId })
return res.status(409).json({ return res.status(409).json({
error: 'ORDER_ACTIVE', error: 'ORDER_ACTIVE',
message: 'Order sudah memiliki transaksi pending di Midtrans; gunakan instruksi pembayaran yang ada atau buat order baru.', 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) { } catch (e) {
const msg = (e?.message || '').toLowerCase() const msg = (e?.message || '').toLowerCase()
if (msg.includes('not found') || msg.includes('404')) { 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 { } 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}` const url = `${PAYMENT_LINK_BASE}/${token}`
activeOrders.set(order_id, expire_at) 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 } }) res.json({ status: '200', messages: 'SUCCESS', data: { url } })
} catch (e) { } 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 * Resolve mercant_id from order_id
* Strategy: * Strategy:
* 1. Check in-memory map from createtransaksi * 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 * 3. Return empty string if not found
*/ */
function resolveMercantId(orderId) { function resolveMercantId(orderId) {
try { try {
if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId) if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId)
if (typeof orderId === 'string' && orderId.includes(':')) { if (typeof orderId === 'string' && orderId.includes('.')) {
const [m] = orderId.split(':') const [m] = orderId.split('.')
if (m) return m if (m) return m
} }
} catch {} } catch {}
@ -1079,6 +1097,7 @@ app.post('/api/payments/notification', async (req, res) => {
} }
const internalStatus = mapStatusToInternal(body, mode) const internalStatus = mapStatusToInternal(body, mode)
const baseOrderId = getBaseOrderId(orderId)
updateLedger(orderId, { updateLedger(orderId, {
status: internalStatus, status: internalStatus,
@ -1091,24 +1110,24 @@ app.post('/api/payments/notification', async (req, res) => {
const grossAmount = body?.gross_amount const grossAmount = body?.gross_amount
const nominal = String(grossAmount || '') const nominal = String(grossAmount || '')
if (notifiedOrders.has(orderId)) { if (notifiedOrders.has(baseOrderId)) {
logInfo(`[${mode}] webhook.already_notified`, { order_id: orderId }) logInfo(`[${mode}] webhook.already_notified`, { order_id: orderId })
return res.json({ return res.json({
ok: true, ok: true,
status: 'already_notified', status: 'already_notified',
message: 'Payment already processed and ERP notified', message: 'Payment already processed and ERP notified',
statusCode: 200 statusCode: 200
}) })
} }
activeOrders.delete(orderId) activeOrders.delete(baseOrderId)
const mercantId = resolveMercantId(orderId) const mercantId = resolveMercantId(orderId)
logInfo(`[${mode}] webhook.notifying_erp`, { order_id: orderId, mercant_id: mercantId }) logInfo(`[${mode}] webhook.notifying_erp`, { order_id: orderId, mercant_id: mercantId })
const erpSuccess = await notifyERP({ orderId, nominal, mercantId }) const erpSuccess = await notifyERP({ orderId, nominal, mercantId })
if (erpSuccess) { if (erpSuccess) {
notifiedOrders.add(orderId) notifiedOrders.add(baseOrderId)
logInfo(`[${mode}] webhook.erp_success`, { order_id: orderId }) logInfo(`[${mode}] webhook.erp_success`, { order_id: orderId })
return res.json({ return res.json({
ok: true, ok: true,
@ -1166,13 +1185,27 @@ app.post('/api/payments/notification', async (req, res) => {
}) })
} }
else if (internalStatus === 'failed') { else if (internalStatus === 'failed') {
logWarn(`[${mode}] webhook.failed`, { const txStatus = (body?.transaction_status || '').toLowerCase()
order_id: orderId,
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, transaction_status: body?.transaction_status,
fraud_status: body?.fraud_status fraud_status: body?.fraud_status
}) })
return res.status(400).json({ return res.status(400).json({
ok: true, ok: true,
status: 'failed', status: 'failed',
message: 'Payment failed, no further action needed', message: 'Payment failed, no further action needed',
statusCode: 400 statusCode: 400