Files
Qris-Soundbox/dist/routes/integrations.js
2026-05-25 08:22:12 +07:00

243 lines
10 KiB
JavaScript

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";
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 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) {
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;
}
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;
}
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;