diff --git a/docs/qa/payment-link.postman_collection.json b/docs/qa/payment-link.postman_collection.json index ad712e6..c64b10c 100644 --- a/docs/qa/payment-link.postman_collection.json +++ b/docs/qa/payment-link.postman_collection.json @@ -22,7 +22,7 @@ "url": { "raw": "{{baseUrl}}/createtransaksi", "host": ["{{baseUrl}}"], "path": ["createtransaksi"] }, "body": { "mode": "raw", - "raw": "{\n \"item_id\": \"order-demo-1\",\n \"nominal\": 150000,\n \"customer\": { \"name\": \"Demo User\", \"email\": \"demo@example.com\" },\n \"allowed_methods\": [\"bank_transfer\", \"credit_card\", \"gopay\", \"cstore\"]\n}" + "raw": "{\n \"mercant_id\": \"REFNO-001\",\n \"timestamp\": 1731300000000,\n \"deskripsi\": \"Bayar Internet\",\n \"nominal\": 200000,\n \"nama\": \"Demo User\",\n \"no_telepon\": \"081234567890\",\n \"email\": \"demo@example.com\",\n \"item\": [\n { \"item_id\": \"TKG-2511101\", \"nama\": \"Internet\", \"harga\": 200000, \"qty\": 1 }\n ]\n}" } }, "event": [ @@ -33,18 +33,16 @@ "exec": [ "let res = {};", "try { res = pm.response.json(); } catch(e) { res = {}; }", - "if (res && res.token) {", - " pm.collectionVariables.set('token', res.token);", + "const url = (res && res.data && res.data.url) ? res.data.url : (res && res.url ? res.url : '');", + "if (url) {", + " pm.collectionVariables.set('paymentLinkUrl', url);", + " const trimmed = url.replace(/\\\/$/, '');", + " const parts = trimmed.split('/');", + " const tok = parts[parts.length - 1];", + " pm.collectionVariables.set('token', tok);", "}", - "if (res && res.order_id) {", - " pm.collectionVariables.set('order_id', res.order_id);", - "}", - "if (res && res.url) {", - " pm.collectionVariables.set('paymentLinkUrl', res.url);", - "}", - "pm.test('Create Transaction returns token and order_id', function () {", - " pm.expect(res.token, 'token exists').to.be.a('string');", - " pm.expect(res.order_id, 'order_id exists').to.be.a('string');", + "pm.test('Create Transaction returns data.url and token', function () {", + " pm.expect(url, 'data.url exists').to.be.a('string');", "});" ] } @@ -77,6 +75,7 @@ "exec": [ "let res = {};", "try { res = pm.response.json(); } catch(e) { res = {}; }", + "if (res && res.order_id) { pm.collectionVariables.set('order_id', res.order_id); }", "pm.test('Resolve Token payload has order_id and nominal', function () {", " pm.expect(res.order_id, 'order_id').to.be.a('string');", " pm.expect(res.nominal, 'nominal').to.exist;", diff --git a/docs/sprint-change-proposal.md b/docs/sprint-change-proposal.md index d3073a1..dd25fba 100644 --- a/docs/sprint-change-proposal.md +++ b/docs/sprint-change-proposal.md @@ -36,7 +36,7 @@ ] } ``` -- Mapping `order_id`: gunakan `item[0].item_id` sebagai `order_id` untuk Midtrans (validasi format/unik per transaksi aktif). + - Mapping `order_id`: gunakan `"mercant_id:item_id"` bila keduanya tersedia (contoh: `MERC-001:ITEM-12345`). Jika salah satu tidak ada, fallback ke `item[0].item_id` atau `mercant_id`. Tujuan: item yang sama pada merchant berbeda tetap menghasilkan order unik sehingga link bisa diterbitkan. - Idempotensi: jika `order_id` + `nominal` sama dan status transaksi masih `pending`, kembalikan payment link sebelumnya; jika `nominal` berbeda, kembalikan `422 AMOUNT_MISMATCH`. - Response (sukses): ```json diff --git a/server/index.cjs b/server/index.cjs index b014f6f..da71cc1 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -207,6 +207,30 @@ app.post('/api/payments/charge', async (req, res) => { const pt = req?.body?.payment_type logInfo('charge.request', { id: req.id, payment_type: pt }) logDebug('charge.payload', maskPayload(req.body)) + + // Idempotency guard: if an order is already pending in Midtrans, block re-charge for the same order_id + const orderId = req?.body?.transaction_details?.order_id || req?.body?.order_id || '' + if (orderId) { + try { + const st = await core.transaction.status(orderId) + const ts = (st?.transaction_status || '').toLowerCase() + if (ts === 'pending') { + logWarn('charge.blocked.pending_exists', { id: req.id, order_id: orderId }) + return res.status(409).json({ + error: 'ORDER_ACTIVE', + message: 'Order sudah memiliki transaksi pending di Midtrans; tidak dapat membuat ulang. Gunakan instruksi pembayaran yang ada atau buat order baru.', + status: st, + }) + } + } catch (e) { + const msg = (e?.message || '').toLowerCase() + if (msg.includes('not found') || msg.includes('404')) { + logDebug('charge.status_not_found', { order_id: orderId }) + } else { + logDebug('charge.status_check_error', { order_id: orderId, message: e?.message }) + } + } + } const isBankType = pt === 'bank_transfer' || pt === 'echannel' || pt === 'permata' if (isBankType && !ENABLE.bank_transfer) { logWarn('charge.blocked', { id: req.id, reason: 'bank_transfer disabled' }) @@ -251,7 +275,7 @@ app.get('/api/payments/:orderId/status', async (req, res) => { }) // External ERP Create Transaction → issue payment link -app.post('/createtransaksi', (req, res) => { +app.post('/createtransaksi', async (req, res) => { try { if (!verifyExternalKey(req)) { logWarn('createtransaksi.unauthorized', { id: req.id }) @@ -268,7 +292,12 @@ app.post('/createtransaksi', (req, res) => { const nominalRaw = req?.body?.nominal const items = Array.isArray(req?.body?.item) ? req.body.item : [] const primaryItemId = items?.[0]?.item_id - const order_id = String(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '') + // Mapping order_id: gunakan "mercant_id:item_id" bila keduanya tersedia, + // jika tidak, fallback ke item_id atau mercant_id atau field order_id/item_id yang disediakan. + const order_id = String( + (primaryItemId && mercantId) ? `${mercantId}:${primaryItemId}` : + (primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '') + ) // Bentuk customer dari field nama/no_telepon/email const customer = { @@ -299,6 +328,28 @@ app.post('/createtransaksi', (req, res) => { return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' }) } + // Guard tambahan: cek ke Midtrans apakah order_id sudah memiliki transaksi pending + try { + const status = await core.transaction.status(order_id) + const s = (status?.transaction_status || '').toLowerCase() + if (s === 'pending') { + logWarn('createtransaksi.midtrans_pending', { order_id }) + 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) { + // Jika 404/not found, lanjut membuat payment link; error lain tetap diteruskan + const msg = (e?.message || '').toLowerCase() + if (msg.includes('not found') || msg.includes('404')) { + logDebug('createtransaksi.midtrans_status_not_found', { order_id }) + } else { + logDebug('createtransaksi.midtrans_status_check_error', { order_id, message: e?.message }) + } + } + const token = createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) const url = `${PAYMENT_LINK_BASE}/${token}` activeOrders.set(order_id, expire_at) diff --git a/tmp-createtransaksi.json b/tmp-createtransaksi.json new file mode 100644 index 0000000..49d2134 --- /dev/null +++ b/tmp-createtransaksi.json @@ -0,0 +1,12 @@ +{ + "mercant_id": "REFNO-001", + "timestamp": 1731300000000, + "deskripsi": "Bayar Internet", + "nominal": 200000, + "nama": "Demo User", + "no_telepon": "081234567890", + "email": "demo@example.com", + "item": [ + { "item_id": "TKG-2511131", "nama": "Internet", "harga": 200000, "qty": 1 } + ] +}