feat: add POST /api/payment-links to accept direct order_id/customer/expire_at payload
Adds a new endpoint that creates a shareable Midtrans Snap payment link
straight from { order_id, nominal, customer, expire_at } without remapping
to the mercant_id/item[] shape required by /createtransaksi.
order_id is sanitized (':' and other disallowed chars -> '.') before being
sent to Midtrans, while the mercant_id used for ERP webhook notification is
derived from the original, unsanitized order_id so ERP always gets a clean
identifier instead of the full composite order_id.
Includes a Postman collection covering link creation, resolution, status
check, and replaying a captured Midtrans notification payload for local
webhook debugging.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
80f92aba84
commit
c671468462
|
|
@ -0,0 +1,112 @@
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "Midtrans Middleware - Payment Link",
|
||||||
|
"description": "Generate & manage shareable Midtrans Snap payment links via the CIFO Midtrans middleware.\n\nFlow: 1) Create Payment Link -> 2) share the returned `data.url` with the customer -> 3) (optional) Resolve Payment Link / Check Status to track it.",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"variable": [
|
||||||
|
{ "key": "base_url", "value": "http://localhost:8000", "type": "string" },
|
||||||
|
{ "key": "api_key", "value": "dev-key", "type": "string" },
|
||||||
|
{ "key": "order_id", "value": "ERPSKRIP-2608030000000627:TKG-260803000063", "type": "string" },
|
||||||
|
{ "key": "payment_token", "value": "", "type": "string" },
|
||||||
|
{ "key": "midtrans_order_id", "value": "", "type": "string" }
|
||||||
|
],
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Create Payment Link",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{ "key": "X-API-KEY", "value": "{{api_key}}" },
|
||||||
|
{ "key": "Content-Type", "value": "application/json" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/payment-links",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["api", "payment-links"]
|
||||||
|
},
|
||||||
|
"description": "Creates a signed, shareable Midtrans Snap payment link directly from the given payload.\n\norder_id is used as-is for tracking, and sanitized (':' -> '.') for the value actually sent to Midtrans, since Midtrans order_id only allows alphanumeric and - _ ~ .\n\nexpire_at is honored if it's a future epoch-ms timestamp; otherwise it falls back to PAYMENT_LINK_TTL_MINUTES.\n\nResponse: `{ status, messages, data: { url, order_id, midtrans_order_id, expire_at } }` - `data.url` is the link to share with the customer.",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"order_id\": \"ERPSKRIP-2608030000000627:TKG-260803000063\",\n \"nominal\": 179000,\n \"customer\": {\n \"name\": \"Yusnika Nur Faidah\",\n \"phone\": \"0881022144656\",\n \"email\": \"yusnika_nur_faidah@example.com\"\n },\n \"expire_at\": 1785852063058\n}",
|
||||||
|
"options": { "raw": { "language": "json" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"type": "text/javascript",
|
||||||
|
"exec": [
|
||||||
|
"if (pm.response.code === 200) {",
|
||||||
|
" const data = pm.response.json()?.data || {};",
|
||||||
|
" const token = (data.url || '').split('/').pop();",
|
||||||
|
" if (token) pm.collectionVariables.set('payment_token', token);",
|
||||||
|
" if (data.midtrans_order_id) pm.collectionVariables.set('midtrans_order_id', data.midtrans_order_id);",
|
||||||
|
"}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Resolve Payment Link",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/payment-links/{{payment_token}}",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["api", "payment-links", "{{payment_token}}"]
|
||||||
|
},
|
||||||
|
"description": "Resolves a payment link token into the underlying order details. Returns `{ order_id, nominal, customer, expire_at, allowed_methods }`. `payment_token` is auto-filled from the \"Create Payment Link\" response."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check Payment Status",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/payments/{{midtrans_order_id}}/status",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["api", "payments", "{{midtrans_order_id}}", "status"]
|
||||||
|
},
|
||||||
|
"description": "Checks the current Midtrans transaction status. Uses `midtrans_order_id` (the Midtrans-safe id, ':' replaced with '.'), auto-filled from the \"Create Payment Link\" response."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Simulate Midtrans Notification (Local)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Content-Type", "value": "application/json" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/payments/notification",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["api", "payments", "notification"]
|
||||||
|
},
|
||||||
|
"description": "Replays a REAL webhook payload (copied from Midtrans Dashboard's notification history) straight into the local server, so you can debug the ERP-notify flow without waiting for Midtrans to reach a public URL.\n\nIMPORTANT: `signature_key` in the body must have been generated with the SAME `MIDTRANS_SERVER_KEY` as this local server's `.env` (production key vs sandbox key mismatch = signature invalid). Paste the exact JSON from Dashboard as-is; do not modify order_id/status_code/gross_amount or the signature will no longer match.",
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"transaction_type\": \"off-us\",\n \"transaction_time\": \"2026-08-03 21:26:58\",\n \"transaction_status\": \"settlement\",\n \"transaction_id\": \"5654e1a9-0d00-4e93-8629-026e6282e4d5\",\n \"status_message\": \"midtrans payment notification\",\n \"status_code\": \"200\",\n \"signature_key\": \"27bad17aac3ca8e2d22096d747d4ea6630237db179df4d3576c93a4300c6bcb72b69c9ca62f90b7af2a005100373b584051406df4f47ec51af4b55bd84de6898\",\n \"settlement_time\": \"2026-08-03 21:31:20\",\n \"pop_id\": \"7c27736b-deb2-483f-a152-2ff50803a3ba\",\n \"payment_type\": \"qris\",\n \"order_id\": \"ERPSKRIP-2608030000000637.TKG-260801001361\",\n \"merchant_id\": \"G277033254\",\n \"merchant_cross_reference_id\": \"d65f691f-4ba1-4dc6-a6eb-bd3f3303416a\",\n \"issuer\": \"BJB\",\n \"gross_amount\": \"159000.00\",\n \"fraud_status\": \"accept\",\n \"expiry_time\": \"2026-08-04 21:26:58\",\n \"customer_details\": {\n \"phone\": \"+6281952988381\",\n \"full_name\": \"Saepudin \",\n \"email\": \"saepudin@example.com\"\n },\n \"currency\": \"IDR\",\n \"acquirer\": \"gopay\"\n}",
|
||||||
|
"options": { "raw": { "language": "json" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Health Check",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/health",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["api", "health"]
|
||||||
|
},
|
||||||
|
"description": "Server + Midtrans key configuration health check."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -474,6 +474,96 @@ app.get('/api/payment-links/:token', (req, res) => {
|
||||||
res.json({ order_id: p.order_id, nominal: p.nominal, customer: p.customer, expire_at: p.expire_at, allowed_methods: p.allowed_methods })
|
res.json({ order_id: p.order_id, nominal: p.nominal, customer: p.customer, expire_at: p.expire_at, allowed_methods: p.allowed_methods })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a shareable Midtrans Snap payment link directly from
|
||||||
|
* { order_id, nominal, customer: { name, phone, email }, expire_at }
|
||||||
|
* POST /api/payment-links
|
||||||
|
* Requires: X-API-KEY header
|
||||||
|
*/
|
||||||
|
app.post('/api/payment-links', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!verifyExternalKey(req)) {
|
||||||
|
logWarn('payment-links.create.unauthorized', { id: req.id })
|
||||||
|
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'X-API-KEY invalid' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const order_id = req?.body?.order_id ? String(req.body.order_id) : ''
|
||||||
|
const nominalRaw = req?.body?.nominal
|
||||||
|
const customerIn = req?.body?.customer || {}
|
||||||
|
const expireAtRaw = req?.body?.expire_at
|
||||||
|
const allowed_methods = req?.body?.allowed_methods
|
||||||
|
|
||||||
|
if (!order_id || typeof nominalRaw === 'undefined') {
|
||||||
|
logWarn('payment-links.create.bad_request', { id: req.id })
|
||||||
|
return res.status(400).json({ error: 'BAD_REQUEST', message: 'order_id dan nominal wajib ada' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const nominal = Number(nominalRaw)
|
||||||
|
if (!Number.isFinite(nominal) || nominal <= 0) {
|
||||||
|
return res.status(400).json({ error: 'BAD_REQUEST', message: 'nominal harus berupa angka positif' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':')
|
||||||
|
const midtransOrderId = order_id.replace(/[^A-Za-z0-9\-_~.]/g, '.')
|
||||||
|
// ERP convention: "mercant_id:item_id" — take the segment before ':' as mercant_id for ERP callback matching
|
||||||
|
const mercantId = order_id.includes(':') ? order_id.split(':')[0] : order_id
|
||||||
|
|
||||||
|
const customer = {
|
||||||
|
name: customerIn.name,
|
||||||
|
phone: customerIn.phone,
|
||||||
|
email: customerIn.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
|
||||||
|
let expire_at = Number(expireAtRaw)
|
||||||
|
if (!Number.isFinite(expire_at) || expire_at <= now) {
|
||||||
|
expire_at = now + ttlMin * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifiedOrders.has(order_id)) {
|
||||||
|
logWarn('payment-links.create.completed', { order_id })
|
||||||
|
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = activeOrders.get(order_id)
|
||||||
|
if (existing && existing > now) {
|
||||||
|
logWarn('payment-links.create.active_exists', { order_id })
|
||||||
|
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await core.transaction.status(midtransOrderId)
|
||||||
|
const s = (status?.transaction_status || '').toLowerCase()
|
||||||
|
if (s === 'pending') {
|
||||||
|
logWarn('payment-links.create.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.',
|
||||||
|
status: { order_id: status?.order_id, status_code: status?.status_code, status_message: status?.status_message, payment_type: status?.payment_type },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e?.message || '').toLowerCase()
|
||||||
|
if (!msg.includes('not found') && !msg.includes('404')) {
|
||||||
|
logDebug('payment-links.create.midtrans_status_check_error', { order_id, midtrans_order_id: midtransOrderId, message: e?.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createPaymentLinkToken({ order_id: midtransOrderId, nominal, expire_at, customer, allowed_methods })
|
||||||
|
const url = `${PAYMENT_LINK_BASE}/${token}`
|
||||||
|
activeOrders.set(order_id, expire_at)
|
||||||
|
orderMerchantId.set(order_id, mercantId)
|
||||||
|
if (midtransOrderId !== order_id) orderMerchantId.set(midtransOrderId, mercantId)
|
||||||
|
|
||||||
|
logInfo('payment-links.create.issued', { order_id, midtrans_order_id: midtransOrderId, expire_at })
|
||||||
|
res.json({ status: '200', messages: 'SUCCESS', data: { url, order_id, midtrans_order_id: midtransOrderId, expire_at } })
|
||||||
|
} catch (e) {
|
||||||
|
logError('payment-links.create.error', { id: req.id, message: e?.message })
|
||||||
|
res.status(500).json({ error: 'CREATE_ERROR', message: e?.message || 'Internal error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API ENDPOINTS - PAYMENT OPERATIONS
|
// API ENDPOINTS - PAYMENT OPERATIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue