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
|
|
@ -239,6 +239,7 @@ const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:517
|
||||||
// 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,7 +1110,7 @@ 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,
|
||||||
|
|
@ -1101,14 +1120,14 @@ app.post('/api/payments/notification', async (req, res) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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,6 +1185,20 @@ app.post('/api/payments/notification', async (req, res) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else if (internalStatus === 'failed') {
|
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`, {
|
logWarn(`[${mode}] webhook.failed`, {
|
||||||
order_id: orderId,
|
order_id: orderId,
|
||||||
transaction_status: body?.transaction_status,
|
transaction_status: body?.transaction_status,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue