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:
parent
8578f1622b
commit
80f92aba84
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue