import { Router } from "express"; import { createHmac, timingSafeEqual } from "node:crypto"; import { ApiError } from "../shared/errors"; import { successResponse } from "../shared/middleware/errorMiddleware"; import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempotencyStore"; import { addTransactionEvent, findTransactionByPartnerReference, getTransactionEvents, updateTransactionStatus } from "../shared/store/transactionStore"; import { emitTransactionPaid } from "../shared/events/transactionEvents"; import { env } from "../config/env"; import { createAuditLog } from "../shared/store/auditLogStore"; const router = Router(); function parsePaymentStatus(rawStatus) { const normalized = String(rawStatus || "").toLowerCase(); if (["paid", "success", "successful", "settled", "completed"].includes(normalized)) { return { status: "paid" }; } if (["failed", "declined", "rejected", "error", "cancelled"].includes(normalized)) { return { status: "failed" }; } if (["expired", "timeout", "stale"].includes(normalized)) { return { status: "expired" }; } return { status: "failed", reason: "UNKNOWN_STATUS" }; } function verifySignature(payload, signature) { if (!signature) { throw new ApiError("WEBHOOK_SIGNATURE_INVALID", "missing X-Partner-Signature", 401); } const { signature: _ignoredSignature, ...signaturePayload } = payload; const secret = env.INTEGRATION_WEBHOOK_SECRET; const expected = createHmac("sha256", secret) .update(JSON.stringify(signaturePayload)) .digest("hex"); const a = Buffer.from(signature.toLowerCase(), "utf8"); const b = Buffer.from(expected.toLowerCase(), "utf8"); if (a.length !== b.length || !timingSafeEqual(a, b)) { throw new ApiError("WEBHOOK_SIGNATURE_INVALID", "invalid X-Partner-Signature", 401); } } function parseCallback(payload) { const partnerReference = payload.partner_reference; if (!partnerReference) { throw new ApiError("CALLBACK_PARTNER_DATA_INVALID", "partner_reference is required", 400); } const amount = Number(payload.amount || 0); if (!Number.isFinite(amount) || amount <= 0) { throw new ApiError("CALLBACK_PARTNER_DATA_INVALID", "amount must be > 0", 400); } const parsed = { partnerReference, partnerTxnId: payload.partner_txn_id, status: parsePaymentStatus(payload.payment_status || payload.status), amount, paidAt: payload.paid_at, currency: typeof payload.currency === "string" && payload.currency.length > 0 ? payload.currency.toUpperCase() : "IDR" }; return parsed; } function makeIdempotencyKey(payload, headerValue) { if (headerValue) { return headerValue; } return `${payload.partnerReference}:${payload.partnerTxnId || ""}:${payload.status.status}`; } function buildCallbackResponse(req, transactionId, eventId, note, reason) { const response = { status: "accepted", event_id: eventId, transaction_id: transactionId, request_id: req.requestId, timestamp: new Date().toISOString() }; if (note) { response.note = note; } if (reason) { response.reason = reason; } return response; } function writeCallbackResult(idempotencyKey, response, transactionId) { writeIdempotency("callback.processing", idempotencyKey, { response, transaction_id: transactionId }, env.IDEMPOTENCY_TTL_MS); } async function auditWebhookAction(req, payload) { await createAuditLog({ actor_type: "webhook", actor_id: "qris_partner", action: payload.action, entity_type: "transaction", entity_id: payload.entity_id, before_json: payload.before_json, after_json: payload.after_json, source_ip: req.ip, request_id: req.requestId, trace_id: req.traceId }); } async function makeResponseEventId(txId, fallbackTag) { const events = await getTransactionEvents(txId); return events.at(-1)?.id || `${fallbackTag}_${Date.now()}`; } router.post("/qris/callback", async (req, res, next) => { const incoming = req.body; const signature = req.header("X-Partner-Signature"); let parsed; try { verifySignature(incoming, signature); parsed = parseCallback(incoming); } catch (error) { return next(error); } const idempotencyKey = makeIdempotencyKey(parsed, req.header("Idempotency-Key") || undefined); const cache = readIdempotency("callback.processing", idempotencyKey); if (cache && typeof cache === "object" && "response" in cache) { const cached = cache; const transactionId = typeof cached.transaction_id === "string" ? cached.transaction_id : null; if (transactionId) { await addTransactionEvent({ transaction_id: transactionId, event_type: "CALLBACK_DUPLICATE", source: "webhook", payload_json: { idempotency_key: idempotencyKey, source_payload: incoming } }); } return res.json(successResponse(req, cached.response)); } const tx = await findTransactionByPartnerReference(parsed.partnerReference); if (!tx) { const response = buildCallbackResponse(req, null, `callback_no_tx_${Date.now()}`, "TRANSACTION_NOT_FOUND"); writeCallbackResult(idempotencyKey, response, null); return res.json(successResponse(req, response)); } if (parsed.amount !== tx.amount) { const response = buildCallbackResponse(req, tx.id, `callback_amount_mismatch_${Date.now()}`, "AMOUNT_MISMATCH"); await addTransactionEvent({ transaction_id: tx.id, event_type: "CALLBACK_REJECTED", source: "webhook", payload_json: { idempotency_key: idempotencyKey, received_amount: parsed.amount, expected_amount: tx.amount } }); writeCallbackResult(idempotencyKey, response, tx.id); return res.status(409).json(successResponse(req, response)); } await addTransactionEvent({ transaction_id: tx.id, event_type: "CALLBACK_RECEIVED", source: "webhook", payload_json: { idempotency_key: idempotencyKey, partner_reference: parsed.partnerReference, partner_txn_id: parsed.partnerTxnId, candidate_status: parsed.status.status, status_reason: parsed.status.reason, partner_payload: incoming } }); const eventContext = { partner_reference: parsed.partnerReference, partner_txn_id: parsed.partnerTxnId, idempotency_key: idempotencyKey, candidate_currency: parsed.currency, reason: parsed.status.reason }; if (parsed.status.status === "paid") { const wasPaid = tx.status === "paid"; let updated; try { updated = await updateTransactionStatus(tx.id, "paid", { source: "webhook", eventContext, paid_at: parsed.paidAt }); } catch (error) { if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected"); writeCallbackResult(idempotencyKey, response, tx.id); return res.status(409).json(successResponse(req, response)); } throw error; } if (!wasPaid) { await auditWebhookAction(req, { action: "transaction.mark_paid", entity_id: updated.id, before_json: tx, after_json: updated }); emitTransactionPaid({ transaction_id: updated.id, merchant_id: updated.merchant_id, outlet_id: updated.outlet_id, terminal_id: updated.terminal_id, device_id: updated.device_id, amount: updated.amount, currency: updated.currency, partner_reference: updated.partner_reference, paid_at: updated.paid_at }); await addTransactionEvent({ transaction_id: updated.id, event_type: "PUSH_QUEUED", source: "system", payload_json: { event_type: "transaction.paid", transaction_id: updated.id, partner_reference: updated.partner_reference, device_id: updated.device_id } }); } const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event")); writeCallbackResult(idempotencyKey, response, updated.id); return res.json(successResponse(req, response)); } if (parsed.status.status === "expired") { let updated; try { updated = await updateTransactionStatus(tx.id, "expired", { source: "webhook", eventContext, expired_at: new Date().toISOString() }); } catch (error) { if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected"); writeCallbackResult(idempotencyKey, response, tx.id); return res.status(409).json(successResponse(req, response)); } throw error; } await auditWebhookAction(req, { action: "transaction.mark_expired", entity_id: updated.id, before_json: tx, after_json: updated }); const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event")); writeCallbackResult(idempotencyKey, response, updated.id); return res.json(successResponse(req, response)); } let updated; try { updated = await updateTransactionStatus(tx.id, "failed", { source: "webhook", eventContext }); } catch (error) { if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected"); writeCallbackResult(idempotencyKey, response, tx.id); return res.status(409).json(successResponse(req, response)); } throw error; } await auditWebhookAction(req, { action: "transaction.mark_failed", entity_id: updated.id, before_json: tx, after_json: updated }); const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event"), undefined, parsed.status.reason); writeCallbackResult(idempotencyKey, response, updated.id); return res.json(successResponse(req, response)); }); export default router;