Initial commit
This commit is contained in:
1107
dist/routes/admin.js
vendored
Normal file
1107
dist/routes/admin.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
126
dist/routes/device.js
vendored
Normal file
126
dist/routes/device.js
vendored
Normal file
@ -0,0 +1,126 @@
|
||||
import { Router } from "express";
|
||||
import { ApiError } from "../shared/errors";
|
||||
import { requireDeviceToken } from "../shared/middleware/auth";
|
||||
import { successResponse } from "../shared/middleware/errorMiddleware";
|
||||
import { getDeviceById, patchDevice } from "../shared/store/deviceStore";
|
||||
import { createDeviceHeartbeat } from "../shared/store/heartbeatStore";
|
||||
import { acknowledgeDeviceCommand } from "../shared/store/deviceCommandStore";
|
||||
const router = Router();
|
||||
function normalizeNumberOrNull(value) {
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function normalizeSignalStrength(value) {
|
||||
const normalized = normalizeNumberOrNull(value);
|
||||
if (normalized === null) {
|
||||
return null;
|
||||
}
|
||||
if (normalized < 0 || normalized > 100) {
|
||||
throw new Error("NETWORK_STRENGTH_OUT_OF_RANGE");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
function normalizeBatteryLevel(value) {
|
||||
const normalized = normalizeNumberOrNull(value);
|
||||
if (normalized === null) {
|
||||
return null;
|
||||
}
|
||||
if (normalized < 0 || normalized > 100) {
|
||||
throw new Error("BATTERY_LEVEL_OUT_OF_RANGE");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
router.post("/heartbeat", requireDeviceToken, async (req, res, next) => {
|
||||
const payload = req.body;
|
||||
if (!payload || !payload.device_id) {
|
||||
return next(new ApiError("BAD_REQUEST", "device_id is required", 400));
|
||||
}
|
||||
const device = await getDeviceById(payload.device_id);
|
||||
if (!device) {
|
||||
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||
}
|
||||
const eventTs = payload.timestamp ? new Date(payload.timestamp) : new Date();
|
||||
if (Number.isNaN(eventTs.getTime())) {
|
||||
return next(new ApiError("BAD_REQUEST", "timestamp must be valid ISO datetime", 400));
|
||||
}
|
||||
let payloadNetworkStrength;
|
||||
let payloadBattery;
|
||||
try {
|
||||
payloadNetworkStrength = normalizeSignalStrength(payload.network_strength);
|
||||
payloadBattery = normalizeBatteryLevel(payload.battery_level);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message === "NETWORK_STRENGTH_OUT_OF_RANGE") {
|
||||
return next(new ApiError("BAD_REQUEST", "network_strength must be between 0 and 100", 400));
|
||||
}
|
||||
if (error instanceof Error && error.message === "BATTERY_LEVEL_OUT_OF_RANGE") {
|
||||
return next(new ApiError("BAD_REQUEST", "battery_level must be between 0 and 100", 400));
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
const heartbeat = await createDeviceHeartbeat({
|
||||
device_id: payload.device_id,
|
||||
timestamp: eventTs.toISOString(),
|
||||
firmware_version: payload.firmware_version,
|
||||
network_strength: payloadNetworkStrength,
|
||||
battery_level: payloadBattery,
|
||||
state: payload.state,
|
||||
payload_json: {
|
||||
network_strength_raw: payload.network_strength,
|
||||
battery_level_raw: payload.battery_level,
|
||||
state: payload.state,
|
||||
firmware_version: payload.firmware_version,
|
||||
timestamp: payload.timestamp,
|
||||
request_id: req.requestId
|
||||
}
|
||||
});
|
||||
await patchDevice(payload.device_id, {
|
||||
last_seen_at: heartbeat.timestamp,
|
||||
firmware_version: payload.firmware_version || device.firmware_version
|
||||
});
|
||||
res.json(successResponse(req, {
|
||||
heartbeat_id: heartbeat.id,
|
||||
device_id: heartbeat.device_id,
|
||||
request_id: req.requestId,
|
||||
server_time: heartbeat.received_at
|
||||
}));
|
||||
});
|
||||
router.post("/commands/ack", requireDeviceToken, async (req, res, next) => {
|
||||
const payload = req.body;
|
||||
if (!payload || !payload.command_id || !payload.device_id || !payload.status) {
|
||||
return next(new ApiError("BAD_REQUEST", "command_id, device_id, status are required", 400));
|
||||
}
|
||||
if (!["delivered", "failed", "timeout"].includes(payload.status)) {
|
||||
return next(new ApiError("BAD_REQUEST", "status must be delivered, failed, or timeout", 400));
|
||||
}
|
||||
const device = await getDeviceById(payload.device_id);
|
||||
if (!device) {
|
||||
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||
}
|
||||
const updated = await acknowledgeDeviceCommand({
|
||||
device_id: device.id,
|
||||
command_id: payload.command_id,
|
||||
status: payload.status,
|
||||
reason: payload.reason,
|
||||
result_payload: payload.result_payload
|
||||
});
|
||||
if (!updated) {
|
||||
return next(new ApiError("NOT_FOUND", "command not found", 404));
|
||||
}
|
||||
res.json(successResponse(req, {
|
||||
command_id: updated.id,
|
||||
device_id: updated.device_id,
|
||||
status: updated.status,
|
||||
acknowledged_at: updated.acknowledged_at
|
||||
}));
|
||||
});
|
||||
export default router;
|
||||
242
dist/routes/integrations.js
vendored
Normal file
242
dist/routes/integrations.js
vendored
Normal file
@ -0,0 +1,242 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user