1899 lines
60 KiB
TypeScript
1899 lines
60 KiB
TypeScript
import { Router, Request, Response, NextFunction } from "express";
|
|
import { randomUUID } from "node:crypto";
|
|
import { ApiError } from "../shared/errors";
|
|
import { requireAdminToken } from "../shared/middleware/auth";
|
|
import { successResponse } from "../shared/middleware/errorMiddleware";
|
|
import { env } from "../config/env";
|
|
import { idempotency } from "../shared/middleware/idempotency";
|
|
import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMerchantPayload, PayoutMode } from "../shared/store/merchantStore";
|
|
import {
|
|
createOutlet,
|
|
createTerminal,
|
|
getOutletById,
|
|
getTerminalById,
|
|
listOutlets,
|
|
listTerminals,
|
|
patchOutlet,
|
|
patchTerminal,
|
|
toOutletPayload,
|
|
toTerminalPayload
|
|
} from "../shared/store/locationStore";
|
|
import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBindingPayload, unbindDevice } from "../shared/store/bindingStore";
|
|
import {
|
|
createDevice,
|
|
getDeviceById,
|
|
listDevices,
|
|
patchDevice,
|
|
DeviceEntity,
|
|
toDevicePayload
|
|
} from "../shared/store/deviceStore";
|
|
import {
|
|
deriveDeviceStatus,
|
|
getHeartbeatCountForDeviceLastHours,
|
|
getLatestHeartbeatByDeviceId,
|
|
listHeartbeats,
|
|
createDeviceHeartbeat
|
|
} from "../shared/store/heartbeatStore";
|
|
import {
|
|
createDeviceCommand,
|
|
getDeviceCommandById,
|
|
listDeviceCommands,
|
|
toDeviceCommandPayload,
|
|
toDeviceCommandPayloadBrief
|
|
} from "../shared/store/deviceCommandStore";
|
|
import {
|
|
createTransaction,
|
|
getTransactionById,
|
|
listTransactions,
|
|
toTransactionEventPayload,
|
|
toTransactionPayload,
|
|
getTransactionEvents
|
|
} from "../shared/store/transactionStore";
|
|
import {
|
|
getNotificationByTransactionId,
|
|
listNotifications,
|
|
listNotificationsByDevice,
|
|
toNotificationPayload
|
|
} from "../shared/store/notificationStore";
|
|
import { retryNotificationByTransactionId } from "../shared/orchestrators/notificationOrchestrator";
|
|
import { createAuditLog, listAuditLogs, toAuditLogPayload } from "../shared/store/auditLogStore";
|
|
import { listLedgerEntries, toLedgerEntryPayload } from "../shared/store/ledgerStore";
|
|
import { resolveDeviceCapabilitySummary } from "../shared/services/deviceCapabilityResolver";
|
|
import {
|
|
getOrCreateDeviceConfig,
|
|
listDeviceConfigAcks,
|
|
toDeviceConfigAckPayload,
|
|
toDeviceConfigPayload,
|
|
upsertDeviceConfig
|
|
} from "../shared/store/deviceConfigStore";
|
|
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
|
|
import { publishConfigPush } from "../shared/services/mqttPublisher";
|
|
|
|
const router = Router();
|
|
|
|
type LoginInput = { username?: string; password?: string };
|
|
|
|
type MerchantCreateInput = {
|
|
legal_name?: string;
|
|
brand_name?: string;
|
|
settlement_account_reference?: string;
|
|
settlement_account_type?: string;
|
|
payout_mode?: PayoutMode;
|
|
fee_profile_id?: string;
|
|
status?: "active" | "inactive";
|
|
onboarding_status?: "pending" | "approved" | "rejected";
|
|
};
|
|
|
|
type OutletCreateInput = {
|
|
name?: string;
|
|
address?: string;
|
|
outlet_code?: string;
|
|
status?: "active" | "inactive";
|
|
};
|
|
|
|
type TerminalCreateInput = {
|
|
terminal_code?: string;
|
|
qr_mode?: "static" | "dynamic_mqtt" | "dynamic_api";
|
|
partner_reference?: string;
|
|
status?: "active" | "inactive";
|
|
};
|
|
|
|
type DeviceCreateInput = {
|
|
device_code?: string;
|
|
serial_number?: string;
|
|
vendor?: string;
|
|
model?: string;
|
|
communication_mode?: "static" | "mqtt" | "api";
|
|
capability_profile_json?: Record<string, unknown>;
|
|
auth_method?: string;
|
|
status?: "active" | "inactive";
|
|
firmware_version?: string;
|
|
last_seen_at?: string;
|
|
};
|
|
|
|
type DeviceCommandInput = {
|
|
command?: string;
|
|
payload?: Record<string, unknown>;
|
|
};
|
|
|
|
type DeviceConfigInput = {
|
|
settings?: Record<string, unknown>;
|
|
config_version?: number;
|
|
};
|
|
|
|
type BindingInput = {
|
|
merchant_id?: string;
|
|
outlet_id?: string;
|
|
terminal_id?: string;
|
|
};
|
|
|
|
type MerchantRejectInput = {
|
|
reason?: string;
|
|
};
|
|
|
|
type SeedInput = {
|
|
include_demo_heartbeat?: boolean;
|
|
include_demo_transactions?: boolean;
|
|
};
|
|
|
|
type TransactionCreateInput = {
|
|
partner_reference?: string;
|
|
merchant_id?: string;
|
|
outlet_id?: string;
|
|
terminal_id?: string;
|
|
device_id?: string;
|
|
amount?: number;
|
|
currency?: string;
|
|
qr_mode?: "static" | "dynamic";
|
|
initiation_mode?: "manual" | "dynamic_api" | "dynamic_mqtt" | "static";
|
|
status?: "initiated" | "awaiting_payment";
|
|
};
|
|
|
|
function parseIdempotentReplay(req: Request) {
|
|
return (req.body as { __idempotentReplay?: boolean; __idempotentResponse?: unknown }).__idempotentReplay;
|
|
}
|
|
|
|
function getReplayResponse(req: Request) {
|
|
return (req.body as { __idempotentResponse?: unknown }).__idempotentResponse;
|
|
}
|
|
|
|
function isIsoDate(value: string | undefined): value is string {
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
return Number.isFinite(Date.parse(value));
|
|
}
|
|
|
|
function isTxInDateRange(tx: { created_at: string }, from?: string, to?: string): boolean {
|
|
const createdAt = Date.parse(tx.created_at);
|
|
if (Number.isNaN(createdAt)) {
|
|
return false;
|
|
}
|
|
|
|
if (from && createdAt < Date.parse(from)) {
|
|
return false;
|
|
}
|
|
|
|
if (to && createdAt > Date.parse(to)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function parseDeviceStatusFilter(value: string | undefined) {
|
|
if (value === "online" || value === "offline" || value === "degraded" || value === "stale") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseCommunicationModeFilter(value: string | undefined) {
|
|
if (value === "static" || value === "mqtt" || value === "api") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseDeviceCommunicationMode(value: string | undefined): "static" | "mqtt" | "api" | undefined {
|
|
if (value === "static" || value === "mqtt" || value === "api") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseDeviceStatusValue(value: string | undefined): "active" | "inactive" | undefined {
|
|
if (value === "active" || value === "inactive") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parsePayoutMode(value: string | undefined): "merchant_direct" | "manual" | undefined {
|
|
if (value === "merchant_direct" || value === "manual") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseOutletStatusFilter(value: string | undefined): "active" | "inactive" | undefined {
|
|
if (value === "active" || value === "inactive") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseTerminalStatusFilter(value: string | undefined): "active" | "inactive" | undefined {
|
|
if (value === "active" || value === "inactive") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseTerminalModeFilter(value: string | undefined): "static" | "dynamic_mqtt" | "dynamic_api" | undefined {
|
|
if (value === "static" || value === "dynamic_mqtt" || value === "dynamic_api") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseTransactionStatusFilter(
|
|
value: string | undefined
|
|
): "initiated" | "awaiting_payment" | "paid" | "failed" | "expired" | "reversed" | undefined {
|
|
if (
|
|
value === "initiated" ||
|
|
value === "awaiting_payment" ||
|
|
value === "paid" ||
|
|
value === "failed" ||
|
|
value === "expired" ||
|
|
value === "reversed"
|
|
) {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseCommandStatusFilter(value: string | undefined) {
|
|
if (value === "accepted" || value === "delivered" || value === "failed" || value === "timeout") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function buildBindingSummary(
|
|
binding: Awaited<ReturnType<typeof getActiveBindingByDevice>> | null
|
|
) {
|
|
if (!binding) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: binding.id,
|
|
merchant_id: binding.merchant_id,
|
|
outlet_id: binding.outlet_id,
|
|
terminal_id: binding.terminal_id
|
|
};
|
|
}
|
|
|
|
async function buildDeviceAdminPayload(device: DeviceEntity) {
|
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
|
return {
|
|
...toDevicePayload(device),
|
|
capability_summary: resolveDeviceCapabilitySummary(device),
|
|
derived_status: deriveDeviceStatus({
|
|
last_seen_at: device.last_seen_at,
|
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
battery_level: latestHeartbeat?.battery_level ?? null
|
|
}),
|
|
heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
|
|
binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
|
|
latest_heartbeat: latestHeartbeat
|
|
? {
|
|
id: latestHeartbeat.id,
|
|
timestamp: latestHeartbeat.timestamp,
|
|
received_at: latestHeartbeat.received_at,
|
|
state: latestHeartbeat.state,
|
|
network_strength: latestHeartbeat.network_strength,
|
|
battery_level: latestHeartbeat.battery_level,
|
|
firmware_version: latestHeartbeat.firmware_version
|
|
}
|
|
: null
|
|
};
|
|
}
|
|
|
|
async function deriveDeviceStatusesForDashboard() {
|
|
const devices = await listDevices();
|
|
|
|
return Promise.all(
|
|
devices.map(async (device) => {
|
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
|
return {
|
|
device,
|
|
status: deriveDeviceStatus({
|
|
last_seen_at: device.last_seen_at,
|
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
battery_level: latestHeartbeat?.battery_level ?? null
|
|
})
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
function buildDashboardRange() {
|
|
const start = new Date();
|
|
start.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(start);
|
|
end.setUTCDate(start.getUTCDate() + 1);
|
|
return { start, end };
|
|
}
|
|
|
|
function toStartEndDateFilter(from?: string, to?: string) {
|
|
if (from && Number.isNaN(Date.parse(from))) {
|
|
return null;
|
|
}
|
|
if (to && Number.isNaN(Date.parse(to))) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
fromTs: from ? Date.parse(from) : null,
|
|
toTs: to ? Date.parse(to) : null
|
|
};
|
|
}
|
|
|
|
function normalizeMerchantMode(payloadMode: MerchantCreateInput["payout_mode"]): NonNullable<MerchantCreateInput["payout_mode"]> {
|
|
return payloadMode || "merchant_direct";
|
|
}
|
|
|
|
async function auditAdminAction(
|
|
req: Request,
|
|
payload: {
|
|
action: string;
|
|
entity_type: string;
|
|
entity_id: string;
|
|
before_json?: unknown;
|
|
after_json?: unknown;
|
|
}
|
|
) {
|
|
await createAuditLog({
|
|
actor_type: "admin",
|
|
actor_id: "admin",
|
|
action: payload.action,
|
|
entity_type: payload.entity_type,
|
|
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
|
|
});
|
|
}
|
|
|
|
function validatePayoutConfig(payload: MerchantCreateInput) {
|
|
const mode = normalizeMerchantMode(payload.payout_mode);
|
|
if (mode === "merchant_direct") {
|
|
if (!payload.settlement_account_reference || !payload.settlement_account_type) {
|
|
throw new ApiError(
|
|
"BAD_REQUEST",
|
|
"settlement_account_reference and settlement_account_type required when payout_mode=merchant_direct",
|
|
400
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ensureMerchant(req: Request, next: NextFunction) {
|
|
const merchantId = req.params.merchantId;
|
|
const merchant = await getMerchantById(merchantId);
|
|
if (!merchant) {
|
|
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
|
|
}
|
|
return merchant;
|
|
}
|
|
|
|
router.post("/login", async (req: Request, res: Response, next: NextFunction) => {
|
|
const { username, password } = req.body as LoginInput;
|
|
if (username !== "admin" || password !== "admin") {
|
|
return next(new ApiError("UNAUTHORIZED", "Invalid credentials", 401));
|
|
}
|
|
|
|
const token = env.ADMIN_TOKEN;
|
|
res.json(
|
|
successResponse(req, {
|
|
token
|
|
})
|
|
);
|
|
});
|
|
|
|
router.use(async (req: Request, res: Response, next: NextFunction) => {
|
|
if (req.path === "/login") {
|
|
return next();
|
|
}
|
|
|
|
return requireAdminToken(req, res, next);
|
|
});
|
|
|
|
router.get("/health", requireAdminToken, async (_req: Request, res: Response) => {
|
|
res.json(
|
|
successResponse(_req, {
|
|
ok: true,
|
|
now: new Date().toISOString()
|
|
})
|
|
);
|
|
});
|
|
|
|
router.post(
|
|
"/sample-idempotent",
|
|
requireAdminToken,
|
|
idempotency({ scope: "admin.sample", required: false }),
|
|
async (req: Request, res: Response) => {
|
|
if (parseIdempotentReplay(req)) {
|
|
return res.status(200).json(getReplayResponse(req));
|
|
}
|
|
|
|
const id = randomUUID();
|
|
res.json(successResponse(req, { id, generated_at: new Date().toISOString() }));
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
"/merchants",
|
|
requireAdminToken,
|
|
idempotency({ scope: "merchant.create", required: false }),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
if (parseIdempotentReplay(req)) {
|
|
return res.status(200).json(getReplayResponse(req));
|
|
}
|
|
|
|
const payload = req.body as MerchantCreateInput;
|
|
if (!payload?.legal_name) {
|
|
return next(new ApiError("BAD_REQUEST", "legal_name is required", 400));
|
|
}
|
|
|
|
const normalizedPayoutMode = parsePayoutMode(payload.payout_mode);
|
|
if (payload.payout_mode && !normalizedPayoutMode) {
|
|
return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400));
|
|
}
|
|
|
|
try {
|
|
validatePayoutConfig(payload);
|
|
} catch (err) {
|
|
return next(err as Error);
|
|
}
|
|
|
|
const created = await createMerchant({
|
|
legal_name: payload.legal_name,
|
|
brand_name: payload.brand_name,
|
|
settlement_account_reference: payload.settlement_account_reference,
|
|
settlement_account_type: payload.settlement_account_type,
|
|
payout_mode: normalizeMerchantMode(payload.payout_mode),
|
|
fee_profile_id: payload.fee_profile_id,
|
|
status: payload.status,
|
|
onboarding_status: payload.onboarding_status
|
|
});
|
|
|
|
await auditAdminAction(req, {
|
|
action: "merchant.create",
|
|
entity_type: "merchant",
|
|
entity_id: created.id,
|
|
after_json: toMerchantPayload(created)
|
|
});
|
|
|
|
res.status(201).json(successResponse(req, toMerchantPayload(created)));
|
|
}
|
|
);
|
|
|
|
router.get("/merchants", requireAdminToken, async (_req: Request, res: Response) => {
|
|
res.json(successResponse(_req, (await listMerchants()).map(toMerchantPayload)));
|
|
});
|
|
|
|
router.get("/merchants/:merchantId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const merchant = await getMerchantById(req.params.merchantId);
|
|
if (!merchant) {
|
|
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
|
|
}
|
|
|
|
res.json(successResponse(req, toMerchantPayload(merchant)));
|
|
});
|
|
|
|
router.patch("/merchants/:merchantId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const payload = req.body as MerchantCreateInput;
|
|
if (!payload || Object.keys(payload).length === 0) {
|
|
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
|
|
}
|
|
|
|
const existing = await getMerchantById(req.params.merchantId);
|
|
if (!existing) {
|
|
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
|
|
}
|
|
|
|
const normalized: MerchantCreateInput = {
|
|
...payload,
|
|
payout_mode: payload.payout_mode ? payload.payout_mode : existing.payout_mode
|
|
};
|
|
|
|
if (normalized.payout_mode && !parsePayoutMode(normalized.payout_mode)) {
|
|
return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400));
|
|
}
|
|
|
|
if (normalized.payout_mode === "merchant_direct") {
|
|
normalized.settlement_account_reference =
|
|
normalized.settlement_account_reference || existing.settlement_account_reference;
|
|
normalized.settlement_account_type =
|
|
normalized.settlement_account_type || existing.settlement_account_type;
|
|
}
|
|
|
|
try {
|
|
validatePayoutConfig(normalized);
|
|
} catch (err) {
|
|
return next(err as Error);
|
|
}
|
|
|
|
const updated = await patchMerchant(req.params.merchantId, normalized);
|
|
await auditAdminAction(req, {
|
|
action: "merchant.update",
|
|
entity_type: "merchant",
|
|
entity_id: updated.id,
|
|
before_json: toMerchantPayload(existing),
|
|
after_json: toMerchantPayload(updated)
|
|
});
|
|
res.json(successResponse(req, toMerchantPayload(updated)));
|
|
});
|
|
|
|
router.post("/merchants/:merchantId/approve", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const existing = await getMerchantById(req.params.merchantId);
|
|
if (!existing) {
|
|
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
|
|
}
|
|
|
|
if (existing.onboarding_status === "approved") {
|
|
return res.json(successResponse(req, toMerchantPayload(existing)));
|
|
}
|
|
|
|
const updated = await patchMerchant(req.params.merchantId, {
|
|
onboarding_status: "approved"
|
|
});
|
|
|
|
await auditAdminAction(req, {
|
|
action: "merchant.approve",
|
|
entity_type: "merchant",
|
|
entity_id: updated.id,
|
|
before_json: toMerchantPayload(existing),
|
|
after_json: toMerchantPayload(updated)
|
|
});
|
|
|
|
res.json(successResponse(req, toMerchantPayload(updated)));
|
|
});
|
|
|
|
router.post(
|
|
"/merchants/:merchantId/reject",
|
|
requireAdminToken,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const payload = req.body as MerchantRejectInput;
|
|
if (!payload?.reason || payload.reason.trim() === "") {
|
|
return next(new ApiError("BAD_REQUEST", "reason is required", 400));
|
|
}
|
|
|
|
const existing = await getMerchantById(req.params.merchantId);
|
|
if (!existing) {
|
|
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
|
|
}
|
|
|
|
const updated = await patchMerchant(req.params.merchantId, {
|
|
onboarding_status: "rejected",
|
|
status: "inactive"
|
|
});
|
|
|
|
await auditAdminAction(req, {
|
|
action: "merchant.reject",
|
|
entity_type: "merchant",
|
|
entity_id: updated.id,
|
|
before_json: toMerchantPayload(existing),
|
|
after_json: {
|
|
...toMerchantPayload(updated),
|
|
rejection_reason: payload.reason
|
|
}
|
|
});
|
|
|
|
res.json(
|
|
successResponse(req, {
|
|
...toMerchantPayload(updated),
|
|
rejection_reason: payload.reason
|
|
})
|
|
);
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
"/seed",
|
|
requireAdminToken,
|
|
idempotency({ scope: "seed.demo", required: false }),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
if (parseIdempotentReplay(req)) {
|
|
return res.status(200).json(getReplayResponse(req));
|
|
}
|
|
|
|
const payload = (req.body as SeedInput | undefined) || {};
|
|
const includeHeartbeat = payload.include_demo_heartbeat !== false;
|
|
const includeTransactions = payload.include_demo_transactions !== false;
|
|
|
|
if ( (await listMerchants()).length > 0 || (await listDevices()).length > 0 || (await listOutlets()).length > 0 || (await listTerminals()).length > 0) {
|
|
return next(new ApiError("BAD_REQUEST", "seed requires empty demo environment", 400));
|
|
}
|
|
|
|
const merchantA = await createMerchant({
|
|
legal_name: "Seed Merchant A",
|
|
brand_name: "Seed A",
|
|
settlement_account_reference: "seed-bank:111111111",
|
|
settlement_account_type: "merchant_bank_account",
|
|
payout_mode: "merchant_direct",
|
|
status: "active",
|
|
onboarding_status: "approved"
|
|
});
|
|
const merchantB = await createMerchant({
|
|
legal_name: "Seed Merchant B",
|
|
brand_name: "Seed B",
|
|
settlement_account_reference: "seed-bank:222222222",
|
|
settlement_account_type: "merchant_bank_account",
|
|
payout_mode: "manual",
|
|
status: "active",
|
|
onboarding_status: "pending"
|
|
});
|
|
|
|
const outletA = await createOutlet({
|
|
merchant_id: merchantA.id,
|
|
name: "Outlet Seed A",
|
|
address: "Jl. Contoh Nomor 1"
|
|
});
|
|
const outletB = await createOutlet({
|
|
merchant_id: merchantB.id,
|
|
name: "Outlet Seed B",
|
|
address: "Jl. Contoh Nomor 2"
|
|
});
|
|
|
|
const terminalA = await createTerminal({
|
|
outlet_id: outletA.id,
|
|
terminal_code: "TERM_SEED_A",
|
|
qr_mode: "static"
|
|
});
|
|
const terminalB = await createTerminal({
|
|
outlet_id: outletB.id,
|
|
terminal_code: "TERM_SEED_B",
|
|
qr_mode: "static"
|
|
});
|
|
|
|
const deviceA = await createDevice({
|
|
device_code: "DEV_SEED_A",
|
|
vendor: "seed-maker",
|
|
model: "v1",
|
|
communication_mode: "static",
|
|
capability_profile_json: {
|
|
dynamic_qr: false,
|
|
flows: ["static_payment_notification"]
|
|
},
|
|
status: "active"
|
|
});
|
|
const deviceB = await createDevice({
|
|
device_code: "DEV_SEED_B",
|
|
vendor: "seed-maker",
|
|
model: "v1",
|
|
communication_mode: "mqtt",
|
|
capability_profile_json: {
|
|
dynamic_qr: {
|
|
mqtt: true,
|
|
api_direct: false
|
|
},
|
|
flows: ["dynamic_qr:mqtt", "static_payment_notification"]
|
|
},
|
|
status: "active"
|
|
});
|
|
const deviceC = await createDevice({
|
|
device_code: "DEV_SEED_C",
|
|
vendor: "seed-maker",
|
|
model: "v1",
|
|
communication_mode: "api",
|
|
capability_profile_json: {
|
|
dynamic_qr: {
|
|
api_direct: true,
|
|
mqtt: false
|
|
},
|
|
flows: ["dynamic_qr:api_direct", "static_payment_notification"]
|
|
},
|
|
status: "active"
|
|
});
|
|
|
|
await bindDevice({
|
|
device_id: deviceA.id,
|
|
merchant_id: merchantA.id,
|
|
outlet_id: outletA.id,
|
|
terminal_id: terminalA.id
|
|
});
|
|
await bindDevice({
|
|
device_id: deviceB.id,
|
|
merchant_id: merchantB.id,
|
|
outlet_id: outletB.id,
|
|
terminal_id: terminalB.id
|
|
});
|
|
|
|
if (includeHeartbeat) {
|
|
await createDeviceHeartbeat({
|
|
device_id: deviceA.id,
|
|
timestamp: new Date().toISOString(),
|
|
firmware_version: "1.0.0",
|
|
network_strength: 92,
|
|
battery_level: 89,
|
|
state: "idle"
|
|
});
|
|
await createDeviceHeartbeat({
|
|
device_id: deviceB.id,
|
|
timestamp: new Date().toISOString(),
|
|
firmware_version: "1.0.0",
|
|
network_strength: 83,
|
|
battery_level: 76,
|
|
state: "idle"
|
|
});
|
|
}
|
|
|
|
const transactions = includeTransactions
|
|
? [
|
|
await createTransaction({
|
|
merchant_id: merchantA.id,
|
|
outlet_id: outletA.id,
|
|
terminal_id: terminalA.id,
|
|
device_id: deviceA.id,
|
|
partner_reference: "seed-pr-001",
|
|
amount: 25000,
|
|
currency: "IDR",
|
|
qr_mode: "static",
|
|
initiation_mode: "static",
|
|
status: "initiated"
|
|
}),
|
|
await createTransaction({
|
|
merchant_id: merchantB.id,
|
|
outlet_id: outletB.id,
|
|
terminal_id: terminalB.id,
|
|
device_id: deviceB.id,
|
|
partner_reference: "seed-pr-002",
|
|
amount: 50000,
|
|
currency: "IDR",
|
|
qr_mode: "static",
|
|
initiation_mode: "static",
|
|
status: "awaiting_payment"
|
|
})
|
|
]
|
|
: [];
|
|
|
|
const seeded = {
|
|
merchants: [toMerchantPayload(merchantA), toMerchantPayload(merchantB)],
|
|
outlets: [outletA, outletB],
|
|
terminals: [terminalA, terminalB],
|
|
devices: [deviceA, deviceB, deviceC],
|
|
transactions: transactions.map((tx) => toTransactionPayload(tx)),
|
|
include_demo_heartbeat: includeHeartbeat,
|
|
include_demo_transactions: includeTransactions
|
|
};
|
|
|
|
res.status(201).json(successResponse(req, seeded));
|
|
}
|
|
);
|
|
|
|
router.get("/seed/status", requireAdminToken, async (_req: Request, res: Response) => {
|
|
res.json(
|
|
successResponse(_req, {
|
|
merchants: (await listMerchants()).length,
|
|
outlets: (await listOutlets()).length,
|
|
terminals: (await listTerminals()).length,
|
|
devices: (await listDevices()).length,
|
|
transactions: (await listTransactions()).length,
|
|
heartbeats: (await listHeartbeats()).length,
|
|
notifications: (await listNotifications()).length,
|
|
seed_eligible: (await listMerchants()).length === 0 && (await listDevices()).length === 0 && (await listOutlets()).length === 0 && (await listTerminals()).length === 0
|
|
})
|
|
);
|
|
});
|
|
|
|
router.post(
|
|
"/merchants/:merchantId/outlets",
|
|
requireAdminToken,
|
|
idempotency({ scope: "outlet.create", required: false }),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
if (parseIdempotentReplay(req)) {
|
|
return res.status(200).json(getReplayResponse(req));
|
|
}
|
|
|
|
const merchant = await ensureMerchant(req, next);
|
|
if (!merchant) {
|
|
return;
|
|
}
|
|
|
|
const payload = req.body as OutletCreateInput;
|
|
if (!payload?.name) {
|
|
return next(new ApiError("BAD_REQUEST", "name is required", 400));
|
|
}
|
|
|
|
if (payload.status) {
|
|
if (!parseOutletStatusFilter(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
|
}
|
|
}
|
|
|
|
const outlet = await createOutlet({
|
|
merchant_id: merchant.id,
|
|
name: payload.name,
|
|
address: payload.address,
|
|
outlet_code: payload.outlet_code,
|
|
status: payload.status
|
|
});
|
|
|
|
await auditAdminAction(req, {
|
|
action: "outlet.create",
|
|
entity_type: "outlet",
|
|
entity_id: outlet.id,
|
|
after_json: toOutletPayload(outlet)
|
|
});
|
|
|
|
res.status(201).json(successResponse(req, outlet));
|
|
}
|
|
);
|
|
|
|
router.get("/outlets", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const merchantId = (req.query.merchant_id as string | undefined)?.trim();
|
|
const statusRaw = (req.query.status as string | undefined)?.trim();
|
|
const status = parseOutletStatusFilter(statusRaw);
|
|
if (statusRaw && !status) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
|
}
|
|
const q = (req.query.q as string | undefined)?.trim();
|
|
res.json(
|
|
successResponse(
|
|
req,
|
|
(await listOutlets({
|
|
merchant_id: merchantId,
|
|
status,
|
|
q: q || undefined
|
|
})).map(toOutletPayload)
|
|
)
|
|
);
|
|
});
|
|
|
|
router.get("/outlets/:outletId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const outlet = await getOutletById(req.params.outletId);
|
|
if (!outlet) {
|
|
return next(new ApiError("NOT_FOUND", "outlet not found", 404));
|
|
}
|
|
|
|
res.json(successResponse(req, toOutletPayload(outlet)));
|
|
});
|
|
|
|
router.patch("/outlets/:outletId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const payload = req.body as OutletCreateInput;
|
|
if (!payload || Object.keys(payload).length === 0) {
|
|
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
|
|
}
|
|
if (payload.status && !parseOutletStatusFilter(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
|
}
|
|
|
|
try {
|
|
const existing = await getOutletById(req.params.outletId);
|
|
const updated = await patchOutlet(req.params.outletId, payload);
|
|
await auditAdminAction(req, {
|
|
action: "outlet.update",
|
|
entity_type: "outlet",
|
|
entity_id: updated.id,
|
|
before_json: existing ? toOutletPayload(existing) : null,
|
|
after_json: toOutletPayload(updated)
|
|
});
|
|
res.json(successResponse(req, toOutletPayload(updated)));
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message === "OUTLET_NOT_FOUND") {
|
|
return next(new ApiError("NOT_FOUND", "outlet not found", 404));
|
|
}
|
|
return next(err as Error);
|
|
}
|
|
});
|
|
|
|
router.post(
|
|
"/outlets/:outletId/terminals",
|
|
requireAdminToken,
|
|
idempotency({ scope: "terminal.create", required: false }),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
if (parseIdempotentReplay(req)) {
|
|
return res.status(201).json(getReplayResponse(req));
|
|
}
|
|
|
|
const outlet = await getOutletById(req.params.outletId);
|
|
if (!outlet) {
|
|
return next(new ApiError("NOT_FOUND", "outlet not found", 404));
|
|
}
|
|
|
|
const payload = req.body as TerminalCreateInput;
|
|
if (!payload || typeof payload !== "object") {
|
|
return next(new ApiError("BAD_REQUEST", "terminal payload required", 400));
|
|
}
|
|
|
|
if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) {
|
|
return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400));
|
|
}
|
|
if (payload.status && !parseTerminalStatusFilter(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
|
}
|
|
|
|
const terminal = await createTerminal({
|
|
outlet_id: outlet.id,
|
|
terminal_code: payload.terminal_code,
|
|
qr_mode: payload.qr_mode,
|
|
partner_reference: payload.partner_reference,
|
|
status: payload.status
|
|
});
|
|
|
|
await auditAdminAction(req, {
|
|
action: "terminal.create",
|
|
entity_type: "terminal",
|
|
entity_id: terminal.id,
|
|
after_json: toTerminalPayload(terminal)
|
|
});
|
|
|
|
res.status(201).json(successResponse(req, toTerminalPayload(terminal)));
|
|
}
|
|
);
|
|
|
|
router.get("/terminals", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const outletId = (req.query.outlet_id as string | undefined)?.trim();
|
|
const statusRaw = (req.query.status as string | undefined)?.trim();
|
|
const status = parseTerminalStatusFilter(statusRaw);
|
|
if (statusRaw && !status) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
|
}
|
|
const q = (req.query.q as string | undefined)?.trim();
|
|
res.json(
|
|
successResponse(
|
|
req,
|
|
(await listTerminals({
|
|
outlet_id: outletId,
|
|
status,
|
|
q: q || undefined
|
|
})).map(toTerminalPayload)
|
|
)
|
|
);
|
|
});
|
|
|
|
router.get("/terminals/:terminalId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const terminal = await getTerminalById(req.params.terminalId);
|
|
if (!terminal) {
|
|
return next(new ApiError("NOT_FOUND", "terminal not found", 404));
|
|
}
|
|
|
|
res.json(successResponse(req, toTerminalPayload(terminal)));
|
|
});
|
|
|
|
router.patch("/terminals/:terminalId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const payload = req.body as TerminalCreateInput;
|
|
if (!payload || Object.keys(payload).length === 0) {
|
|
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
|
|
}
|
|
if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) {
|
|
return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400));
|
|
}
|
|
if (payload.status && !parseTerminalStatusFilter(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
|
}
|
|
|
|
try {
|
|
const existing = await getTerminalById(req.params.terminalId);
|
|
const updated = await patchTerminal(req.params.terminalId, payload);
|
|
await auditAdminAction(req, {
|
|
action: "terminal.update",
|
|
entity_type: "terminal",
|
|
entity_id: updated.id,
|
|
before_json: existing ? toTerminalPayload(existing) : null,
|
|
after_json: toTerminalPayload(updated)
|
|
});
|
|
res.json(successResponse(req, toTerminalPayload(updated)));
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message === "TERMINAL_NOT_FOUND") {
|
|
return next(new ApiError("NOT_FOUND", "terminal not found", 404));
|
|
}
|
|
return next(err as Error);
|
|
}
|
|
});
|
|
|
|
router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", required: false }), async (req: Request, res: Response, next: NextFunction) => {
|
|
if (parseIdempotentReplay(req)) {
|
|
return res.status(201).json(getReplayResponse(req));
|
|
}
|
|
|
|
const payload = req.body as DeviceCreateInput;
|
|
if (!payload) {
|
|
return next(new ApiError("BAD_REQUEST", "device payload required", 400));
|
|
}
|
|
|
|
if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) {
|
|
return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400));
|
|
}
|
|
|
|
if (payload.status && !parseDeviceStatusValue(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
|
}
|
|
|
|
const created = await createDevice(payload);
|
|
await auditAdminAction(req, {
|
|
action: "device.create",
|
|
entity_type: "device",
|
|
entity_id: created.id,
|
|
after_json: toDevicePayload(created)
|
|
});
|
|
res.status(201).json(successResponse(req, toDevicePayload(created)));
|
|
});
|
|
|
|
router.get("/devices", requireAdminToken, async (req: Request, res: Response) => {
|
|
const status = parseDeviceStatusFilter(req.query.status as string | undefined);
|
|
const vendor = (req.query.vendor as string | undefined)?.trim();
|
|
const communicationMode = parseCommunicationModeFilter(req.query.communication_mode as string | undefined);
|
|
const merchantId = (req.query.merchant_id as string | undefined)?.trim();
|
|
const q = (req.query.q as string | undefined)?.trim();
|
|
|
|
const rawDevices = await listDevices();
|
|
const evaluated = await Promise.all(
|
|
rawDevices.map(async (device) => {
|
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
|
const binding = merchantId ? await getActiveBindingByDevice(device.id) : null;
|
|
return {
|
|
device,
|
|
latestHeartbeat,
|
|
binding,
|
|
derivedStatus: deriveDeviceStatus({
|
|
last_seen_at: device.last_seen_at,
|
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
battery_level: latestHeartbeat?.battery_level ?? null
|
|
})
|
|
};
|
|
})
|
|
);
|
|
|
|
const data = evaluated
|
|
.filter((entry) => {
|
|
const { device, derivedStatus, binding } = entry;
|
|
|
|
if (status && derivedStatus !== status) {
|
|
return false;
|
|
}
|
|
|
|
if (vendor && device.vendor !== vendor) {
|
|
return false;
|
|
}
|
|
|
|
if (communicationMode && device.communication_mode !== communicationMode) {
|
|
return false;
|
|
}
|
|
|
|
if (merchantId && (!binding || binding.merchant_id !== merchantId)) {
|
|
return false;
|
|
}
|
|
|
|
if (q) {
|
|
const text = q.toLowerCase();
|
|
const codeMatch = device.device_code.toLowerCase().includes(text);
|
|
const serialMatch = device.serial_number?.toLowerCase().includes(text);
|
|
if (!codeMatch && !serialMatch) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map((entry) => entry.device);
|
|
|
|
const payloads = await Promise.all(
|
|
data
|
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
|
.map((device) => buildDeviceAdminPayload(device))
|
|
);
|
|
res.json(successResponse(req, payloads));
|
|
});
|
|
|
|
router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const activeBinding = await getActiveBindingByDevice(device.id);
|
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
|
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
|
|
const notifications = (await listNotificationsByDevice(device.id))
|
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
|
.slice(0, 10)
|
|
.map(toNotificationPayload);
|
|
|
|
res.json(
|
|
successResponse(req, {
|
|
...toDevicePayload(device),
|
|
capability_summary: resolveDeviceCapabilitySummary(device),
|
|
derived_status: deriveDeviceStatus({
|
|
last_seen_at: device.last_seen_at,
|
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
battery_level: latestHeartbeat?.battery_level ?? null
|
|
}),
|
|
active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
|
|
latest_heartbeat: latestHeartbeat
|
|
? {
|
|
id: latestHeartbeat.id,
|
|
timestamp: latestHeartbeat.timestamp,
|
|
received_at: latestHeartbeat.received_at,
|
|
state: latestHeartbeat.state,
|
|
network_strength: latestHeartbeat.network_strength,
|
|
battery_level: latestHeartbeat.battery_level,
|
|
firmware_version: latestHeartbeat.firmware_version
|
|
}
|
|
: null,
|
|
heartbeat_count_24h: heartbeatCount24h,
|
|
notifications_latest: notifications
|
|
})
|
|
);
|
|
});
|
|
|
|
router.get("/devices/:deviceId/heartbeats", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const from = req.query.from as string | undefined;
|
|
const to = req.query.to as string | undefined;
|
|
const state = req.query.state as string | undefined;
|
|
|
|
if (from && Number.isNaN(Date.parse(from))) {
|
|
return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
|
|
}
|
|
|
|
if (to && Number.isNaN(Date.parse(to))) {
|
|
return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
|
|
}
|
|
|
|
res.json(
|
|
successResponse(req, {
|
|
device_id: device.id,
|
|
heartbeats: await listHeartbeats({
|
|
device_id: device.id,
|
|
from,
|
|
to,
|
|
state
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
router.patch("/devices/:deviceId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const payload = req.body as DeviceCreateInput;
|
|
if (!payload || Object.keys(payload).length === 0) {
|
|
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
|
|
}
|
|
if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) {
|
|
return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400));
|
|
}
|
|
if (payload.status && !parseDeviceStatusValue(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
|
}
|
|
|
|
try {
|
|
const existing = await getDeviceById(req.params.deviceId);
|
|
const updated = await patchDevice(req.params.deviceId, payload);
|
|
await auditAdminAction(req, {
|
|
action: "device.update",
|
|
entity_type: "device",
|
|
entity_id: updated.id,
|
|
before_json: existing ? toDevicePayload(existing) : null,
|
|
after_json: toDevicePayload(updated)
|
|
});
|
|
res.json(successResponse(req, toDevicePayload(updated)));
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message === "DEVICE_NOT_FOUND") {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
return next(err as Error);
|
|
}
|
|
});
|
|
|
|
router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "device.bind", required: false }), async (req: Request, res: Response, next: NextFunction) => {
|
|
if (parseIdempotentReplay(req)) {
|
|
return res.status(200).json(getReplayResponse(req));
|
|
}
|
|
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const payload = req.body as BindingInput;
|
|
if (!payload?.merchant_id || !payload.outlet_id || !payload.terminal_id) {
|
|
return next(new ApiError("BAD_REQUEST", "merchant_id, outlet_id, terminal_id required", 400));
|
|
}
|
|
|
|
const merchant = await getMerchantById(payload.merchant_id);
|
|
const outlet = await getOutletById(payload.outlet_id);
|
|
const terminal = await getTerminalById(payload.terminal_id);
|
|
if (!merchant || !outlet || !terminal) {
|
|
return next(new ApiError("BAD_REQUEST", "merchant/outlet/terminal reference invalid", 400));
|
|
}
|
|
|
|
if (outlet.merchant_id !== merchant.id) {
|
|
return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400));
|
|
}
|
|
|
|
if (terminal.outlet_id !== outlet.id) {
|
|
return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400));
|
|
}
|
|
|
|
const binding = await bindDevice({
|
|
device_id: device.id,
|
|
merchant_id: merchant.id,
|
|
outlet_id: outlet.id,
|
|
terminal_id: terminal.id
|
|
});
|
|
|
|
await auditAdminAction(req, {
|
|
action: "device.bind",
|
|
entity_type: "device_binding",
|
|
entity_id: binding.id,
|
|
after_json: toBindingPayload(binding)
|
|
});
|
|
|
|
res.json(successResponse(req, toBindingPayload(binding)));
|
|
});
|
|
|
|
router.post("/devices/:deviceId/unbind", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const binding = await unbindDevice(device.id);
|
|
if (!binding) {
|
|
return next(new ApiError("BAD_REQUEST", "device has no active binding", 400));
|
|
}
|
|
|
|
await auditAdminAction(req, {
|
|
action: "device.unbind",
|
|
entity_type: "device_binding",
|
|
entity_id: binding.id,
|
|
before_json: toBindingPayload(binding),
|
|
after_json: toBindingPayload(binding)
|
|
});
|
|
|
|
res.json(successResponse(req, toBindingPayload(binding)));
|
|
});
|
|
|
|
router.post(
|
|
"/devices/:deviceId/commands",
|
|
requireAdminToken,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const payload = req.body as DeviceCommandInput;
|
|
if (!payload || typeof payload.command !== "string" || payload.command.trim() === "") {
|
|
return next(new ApiError("BAD_REQUEST", "command is required", 400));
|
|
}
|
|
|
|
const command = await createDeviceCommand({
|
|
device_id: device.id,
|
|
command: payload.command.trim(),
|
|
payload: payload.payload || {}
|
|
});
|
|
|
|
res.status(201).json(successResponse(req, toDeviceCommandPayload(command)));
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
"/devices/:deviceId/commands",
|
|
requireAdminToken,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const statusFilter = parseCommandStatusFilter(req.query.status as string | undefined);
|
|
const limitRaw = req.query.limit as string | undefined;
|
|
const limit = limitRaw ? Number(limitRaw) : 50;
|
|
|
|
if (!Number.isFinite(limit) || limit <= 0) {
|
|
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
|
}
|
|
|
|
const commands = (await listDeviceCommands(device.id))
|
|
.filter((command) => !statusFilter || command.status === statusFilter)
|
|
.slice(0, Math.min(limit, 200))
|
|
.map(toDeviceCommandPayloadBrief);
|
|
|
|
res.json(successResponse(req, { device_id: device.id, commands }));
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
"/devices/:deviceId/commands/:commandId",
|
|
requireAdminToken,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const command = await getDeviceCommandById(device.id, req.params.commandId);
|
|
if (!command) {
|
|
return next(new ApiError("NOT_FOUND", "command not found", 404));
|
|
}
|
|
|
|
res.json(successResponse(req, toDeviceCommandPayload(command)));
|
|
}
|
|
);
|
|
|
|
router.get("/devices/:deviceId/notifications", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const limitRaw = req.query.limit as string | undefined;
|
|
const limit = limitRaw ? Number(limitRaw) : 50;
|
|
if (!Number.isFinite(limit) || limit <= 0) {
|
|
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
|
}
|
|
|
|
const notifications = (await listNotificationsByDevice(device.id))
|
|
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
|
|
.slice(0, Math.min(limit, 200))
|
|
.map(toNotificationPayload);
|
|
|
|
res.json(successResponse(req, { device_id: device.id, notifications }));
|
|
});
|
|
|
|
router.get("/devices/:deviceId/config", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const config = await getOrCreateDeviceConfig(device.id);
|
|
const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
|
|
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks }));
|
|
});
|
|
|
|
router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const payload = req.body as DeviceConfigInput;
|
|
if (!payload || !payload.settings || typeof payload.settings !== "object") {
|
|
return next(new ApiError("BAD_REQUEST", "settings object is required", 400));
|
|
}
|
|
|
|
const config = await upsertDeviceConfig({
|
|
device_id: device.id,
|
|
settings_json: payload.settings,
|
|
config_version: payload.config_version
|
|
});
|
|
const mqttPayload = {
|
|
message_type: "config_push" as const,
|
|
config_version: config.config_version,
|
|
settings: config.settings_json
|
|
};
|
|
const publishResult = await publishConfigPush(device.id, mqttPayload);
|
|
const outbox = await createMqttMessage({
|
|
direction: "downlink",
|
|
device_id: device.id,
|
|
topic: publishResult.topic,
|
|
message_type: "config_push",
|
|
correlation_id: `config:${config.config_version}`,
|
|
payload_json: mqttPayload,
|
|
publish_status: publishResult.ok ? "sent" : "failed",
|
|
reason: publishResult.reason
|
|
});
|
|
|
|
await auditAdminAction(req, {
|
|
action: "device.config_push",
|
|
entity_type: "device",
|
|
entity_id: device.id,
|
|
after_json: {
|
|
config,
|
|
downlink_message_id: outbox.id
|
|
}
|
|
});
|
|
|
|
res.json(
|
|
successResponse(req, {
|
|
config: toDeviceConfigPayload(config),
|
|
downlink_message: toMqttMessagePayload(outbox)
|
|
})
|
|
);
|
|
});
|
|
|
|
router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const device = await getDeviceById(req.params.deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
|
|
const directionRaw = (req.query.direction as string | undefined)?.trim();
|
|
const direction = directionRaw === "uplink" || directionRaw === "downlink" ? directionRaw : undefined;
|
|
if (directionRaw && !direction) {
|
|
return next(new ApiError("BAD_REQUEST", "direction must be uplink or downlink", 400));
|
|
}
|
|
|
|
const messageType = (req.query.message_type as string | undefined)?.trim();
|
|
const correlationId = (req.query.correlation_id as string | undefined)?.trim();
|
|
const limitRaw = req.query.limit as string | undefined;
|
|
const limit = limitRaw ? Number(limitRaw) : 100;
|
|
if (!Number.isFinite(limit) || limit <= 0) {
|
|
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
|
}
|
|
|
|
const messages = await listMqttMessages({
|
|
device_id: device.id,
|
|
direction,
|
|
message_type: messageType || undefined,
|
|
correlation_id: correlationId || undefined,
|
|
limit
|
|
});
|
|
|
|
res.json(successResponse(req, { device_id: device.id, messages: messages.map(toMqttMessagePayload) }));
|
|
});
|
|
|
|
router.post(
|
|
"/transactions",
|
|
requireAdminToken,
|
|
idempotency({ scope: "transaction.create", required: false }),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
if (parseIdempotentReplay(req)) {
|
|
return res.status(201).json(getReplayResponse(req));
|
|
}
|
|
|
|
const payload = req.body as TransactionCreateInput;
|
|
if (
|
|
!payload ||
|
|
!payload.partner_reference ||
|
|
!payload.merchant_id ||
|
|
!payload.outlet_id ||
|
|
!payload.terminal_id
|
|
) {
|
|
return next(new ApiError("BAD_REQUEST", "partner_reference, merchant_id, outlet_id, terminal_id required", 400));
|
|
}
|
|
|
|
const merchant = await getMerchantById(payload.merchant_id);
|
|
if (!merchant) {
|
|
return next(new ApiError("BAD_REQUEST", "merchant not found", 400));
|
|
}
|
|
|
|
const outlet = await getOutletById(payload.outlet_id);
|
|
if (!outlet) {
|
|
return next(new ApiError("BAD_REQUEST", "outlet not found", 400));
|
|
}
|
|
|
|
if (outlet.merchant_id !== merchant.id) {
|
|
return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400));
|
|
}
|
|
|
|
const terminal = await getTerminalById(payload.terminal_id);
|
|
if (!terminal) {
|
|
return next(new ApiError("BAD_REQUEST", "terminal not found", 400));
|
|
}
|
|
|
|
if (terminal.outlet_id !== outlet.id) {
|
|
return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400));
|
|
}
|
|
|
|
if (payload.device_id && !await getDeviceById(payload.device_id)) {
|
|
return next(new ApiError("BAD_REQUEST", "device not found", 400));
|
|
}
|
|
|
|
const amount = Number(payload.amount);
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
return next(new ApiError("BAD_REQUEST", "amount must be a positive number", 400));
|
|
}
|
|
|
|
if (payload.status && !parseTransactionStatusFilter(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
|
|
}
|
|
|
|
const created = await createTransaction({
|
|
merchant_id: merchant.id,
|
|
outlet_id: outlet.id,
|
|
terminal_id: terminal.id,
|
|
device_id: payload.device_id,
|
|
partner_reference: payload.partner_reference,
|
|
amount,
|
|
currency: payload.currency,
|
|
qr_mode: payload.qr_mode || "static",
|
|
initiation_mode: payload.initiation_mode || "static",
|
|
status: payload.status || "initiated"
|
|
});
|
|
|
|
await auditAdminAction(req, {
|
|
action: "transaction.create",
|
|
entity_type: "transaction",
|
|
entity_id: created.id,
|
|
after_json: toTransactionPayload(created)
|
|
});
|
|
|
|
res.status(201).json(successResponse(req, toTransactionPayload(created)));
|
|
}
|
|
);
|
|
|
|
router.get("/transactions", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const statusRaw = (req.query.status as string | undefined)?.trim();
|
|
const status = parseTransactionStatusFilter(statusRaw);
|
|
if (statusRaw && !status) {
|
|
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
|
|
}
|
|
|
|
const merchantId = (req.query.merchant_id as string | undefined)?.trim();
|
|
const from = req.query.from as string | undefined;
|
|
const to = req.query.to as string | undefined;
|
|
const partnerReference = req.query.partner_reference as string | undefined;
|
|
const q = (req.query.q as string | undefined)?.trim();
|
|
|
|
if (from && !isIsoDate(from)) {
|
|
return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
|
|
}
|
|
|
|
if (to && !isIsoDate(to)) {
|
|
return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
|
|
}
|
|
|
|
const normalizedPartnerRef = partnerReference?.trim();
|
|
const normalizedQ = q || "";
|
|
|
|
res.json(
|
|
successResponse(
|
|
req,
|
|
(await listTransactions({
|
|
status,
|
|
merchant_id: merchantId
|
|
}))
|
|
.filter((tx) => isTxInDateRange(tx, from, to))
|
|
.filter((tx) => !normalizedPartnerRef || tx.partner_reference === normalizedPartnerRef)
|
|
.filter((tx) => {
|
|
if (!normalizedQ) {
|
|
return true;
|
|
}
|
|
|
|
const lower = normalizedQ.toLowerCase();
|
|
return (
|
|
tx.partner_reference.toLowerCase().includes(lower) ||
|
|
tx.transaction_code.toLowerCase().includes(lower)
|
|
);
|
|
})
|
|
.map(toTransactionPayload)
|
|
)
|
|
);
|
|
});
|
|
|
|
router.get(
|
|
"/transactions/:transactionId",
|
|
requireAdminToken,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const tx = await getTransactionById(req.params.transactionId);
|
|
if (!tx) {
|
|
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
|
|
}
|
|
|
|
const events = (await getTransactionEvents(tx.id)).map(toTransactionEventPayload);
|
|
const ledger_entries = (await listLedgerEntries({ transaction_id: tx.id })).map(toLedgerEntryPayload);
|
|
const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null;
|
|
const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id;
|
|
const heartbeatHistory = heartbeatDeviceId
|
|
? (await listHeartbeats({ device_id: heartbeatDeviceId })).map((heartbeat) => ({
|
|
id: heartbeat.id,
|
|
device_id: heartbeat.device_id,
|
|
timestamp: heartbeat.timestamp,
|
|
state: heartbeat.state,
|
|
network_strength: heartbeat.network_strength,
|
|
battery_level: heartbeat.battery_level,
|
|
firmware_version: heartbeat.firmware_version,
|
|
received_at: heartbeat.received_at
|
|
}))
|
|
: [];
|
|
|
|
res.json(
|
|
successResponse(req, {
|
|
transaction: toTransactionPayload(tx),
|
|
events,
|
|
ledger_entries,
|
|
heartbeat_device_id: heartbeatDeviceId,
|
|
heartbeat_history: heartbeatHistory
|
|
})
|
|
);
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
"/transactions/:transactionId/events",
|
|
requireAdminToken,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const tx = await getTransactionById(req.params.transactionId);
|
|
if (!tx) {
|
|
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
|
|
}
|
|
|
|
const events = (await getTransactionEvents(tx.id))
|
|
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
|
|
.map(toTransactionEventPayload);
|
|
|
|
res.json(successResponse(req, { transaction_id: tx.id, events }));
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
"/transactions/:transactionId/heartbeats",
|
|
requireAdminToken,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const tx = await getTransactionById(req.params.transactionId);
|
|
if (!tx) {
|
|
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
|
|
}
|
|
|
|
const from = req.query.from as string | undefined;
|
|
const to = req.query.to as string | undefined;
|
|
const state = req.query.state as string | undefined;
|
|
const limitRaw = req.query.limit as string | undefined;
|
|
const limit = limitRaw ? Number(limitRaw) : 100;
|
|
|
|
if (from && Number.isNaN(Date.parse(from))) {
|
|
return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
|
|
}
|
|
|
|
if (to && Number.isNaN(Date.parse(to))) {
|
|
return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
|
|
}
|
|
|
|
if (!Number.isFinite(limit) || limit <= 0) {
|
|
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
|
}
|
|
|
|
const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null;
|
|
const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id;
|
|
if (!heartbeatDeviceId) {
|
|
return res.json(
|
|
successResponse(req, {
|
|
transaction_id: tx.id,
|
|
heartbeat_device_id: null,
|
|
heartbeats: []
|
|
})
|
|
);
|
|
}
|
|
|
|
const heartbeats = (await listHeartbeats({
|
|
device_id: heartbeatDeviceId,
|
|
from,
|
|
to,
|
|
state
|
|
}))
|
|
.slice(0, Math.min(limit, 500))
|
|
.map((heartbeat) => ({
|
|
id: heartbeat.id,
|
|
device_id: heartbeat.device_id,
|
|
timestamp: heartbeat.timestamp,
|
|
received_at: heartbeat.received_at,
|
|
state: heartbeat.state,
|
|
network_strength: heartbeat.network_strength,
|
|
battery_level: heartbeat.battery_level,
|
|
firmware_version: heartbeat.firmware_version
|
|
}));
|
|
|
|
res.json(
|
|
successResponse(req, {
|
|
transaction_id: tx.id,
|
|
heartbeat_device_id: heartbeatDeviceId,
|
|
heartbeats
|
|
})
|
|
);
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
"/transactions/:transactionId/retry-notification",
|
|
requireAdminToken,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const transactionId = req.params.transactionId;
|
|
try {
|
|
const tx = await getTransactionById(transactionId);
|
|
if (!tx) {
|
|
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
|
|
}
|
|
|
|
if (tx.status !== "paid") {
|
|
return next(
|
|
new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400)
|
|
);
|
|
}
|
|
|
|
const before = await getNotificationByTransactionId(transactionId);
|
|
if (!before) {
|
|
return next(
|
|
new ApiError("NOT_FOUND", "notification not found for this transaction", 404)
|
|
);
|
|
}
|
|
|
|
if (before.delivery_status === "acknowledged") {
|
|
return res.status(200).json(
|
|
successResponse(req, {
|
|
transaction_id: tx.id,
|
|
notification_id: before.id,
|
|
delivery_status: before.delivery_status,
|
|
next_retry_at: before.next_retry_at || null
|
|
})
|
|
);
|
|
}
|
|
|
|
const updated = await retryNotificationByTransactionId(transactionId);
|
|
if (!updated) {
|
|
return next(new ApiError("NOTIFICATION_PUBLISH_FAILED", "notification retry could not be executed", 500));
|
|
}
|
|
|
|
res.json(
|
|
successResponse(req, {
|
|
transaction_id: tx.id,
|
|
notification_id: updated.id,
|
|
delivery_status: updated.delivery_status,
|
|
next_retry_at: updated.next_retry_at || null
|
|
})
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "NOTIFICATION_PUBLISH_CONDITION") {
|
|
return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400));
|
|
}
|
|
|
|
return next(error as Error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.get("/dashboard/summary", requireAdminToken, async (req: Request, res: Response) => {
|
|
let dashboard = {
|
|
transactions_today: 0,
|
|
success_rate_today: 0,
|
|
active_devices: 0,
|
|
pending_notifications: 0,
|
|
devices_stale: 0,
|
|
devices_offline: 0
|
|
};
|
|
|
|
try {
|
|
const { start, end } = buildDashboardRange();
|
|
const startTs = start.getTime();
|
|
const endTs = end.getTime();
|
|
|
|
const todayTransactions = (await listTransactions()).filter((tx) => {
|
|
const createdTs = Date.parse(tx.created_at);
|
|
return createdTs >= startTs && createdTs < endTs;
|
|
});
|
|
const paidToday = todayTransactions.filter((tx) => tx.status === "paid").length;
|
|
const transactionsToday = todayTransactions.length;
|
|
const successRateToday = transactionsToday > 0 ? (paidToday / transactionsToday) * 100 : 0;
|
|
|
|
const statuses = await deriveDeviceStatusesForDashboard();
|
|
const activeDevices = statuses.filter((row) => row.status !== "offline").length;
|
|
const devicesStale = statuses.filter((row) => row.status === "stale").length;
|
|
const devicesOffline = statuses.filter((row) => row.status === "offline").length;
|
|
|
|
const pendingNotifications = (await listNotifications()).filter((notification) => {
|
|
return notification.delivery_status === "queued" || notification.delivery_status === "retrying";
|
|
}).length;
|
|
|
|
dashboard = {
|
|
transactions_today: transactionsToday,
|
|
success_rate_today: Number(successRateToday.toFixed(2)),
|
|
active_devices: activeDevices,
|
|
pending_notifications: pendingNotifications,
|
|
devices_stale: devicesStale,
|
|
devices_offline: devicesOffline
|
|
};
|
|
} catch (error) {
|
|
console.error("[dashboard/summary] fallback due calculation error", error instanceof Error ? error.message : error);
|
|
}
|
|
|
|
res.json(successResponse(req, dashboard));
|
|
});
|
|
|
|
router.get("/notifications/failed", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const deviceId = req.query.device_id as string | undefined;
|
|
const from = req.query.from as string | undefined;
|
|
const to = req.query.to as string | undefined;
|
|
|
|
const range = toStartEndDateFilter(from, to);
|
|
if ((from || to) && range === null) {
|
|
return next(new ApiError("BAD_REQUEST", "from/to must be valid ISO datetime", 400));
|
|
}
|
|
|
|
const filtered = (await listNotifications())
|
|
.filter((notification) => notification.delivery_status === "failed")
|
|
.filter((notification) => !deviceId || notification.device_id === deviceId)
|
|
.filter((notification) => {
|
|
if (!range) {
|
|
return true;
|
|
}
|
|
|
|
const createdTs = Date.parse(notification.created_at);
|
|
if (range.fromTs && createdTs < range.fromTs) {
|
|
return false;
|
|
}
|
|
|
|
if (range.toTs && createdTs > range.toTs) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
|
|
.map((notification) => ({
|
|
notification_id: notification.id,
|
|
transaction_id: notification.transaction_id,
|
|
device_id: notification.device_id,
|
|
delivery_status: notification.delivery_status,
|
|
retry_count: notification.retry_count,
|
|
reason: notification.reason
|
|
}));
|
|
|
|
res.json(successResponse(req, filtered));
|
|
});
|
|
|
|
router.get("/audit-logs", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const entityType = (req.query.entity_type as string | undefined)?.trim();
|
|
const entityId = (req.query.entity_id as string | undefined)?.trim();
|
|
const action = (req.query.action as string | undefined)?.trim();
|
|
const from = req.query.from as string | undefined;
|
|
const to = req.query.to as string | undefined;
|
|
const limitRaw = req.query.limit as string | undefined;
|
|
const limit = limitRaw ? Number(limitRaw) : 100;
|
|
|
|
if (from && Number.isNaN(Date.parse(from))) {
|
|
return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
|
|
}
|
|
|
|
if (to && Number.isNaN(Date.parse(to))) {
|
|
return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
|
|
}
|
|
|
|
if (!Number.isFinite(limit) || limit <= 0) {
|
|
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
|
}
|
|
|
|
const logs = await listAuditLogs({
|
|
entity_type: entityType || undefined,
|
|
entity_id: entityId || undefined,
|
|
action: action || undefined,
|
|
from,
|
|
to,
|
|
limit
|
|
});
|
|
|
|
res.json(successResponse(req, logs.map(toAuditLogPayload)));
|
|
});
|
|
|
|
router.get("/ledger-entries", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
|
const transactionId = (req.query.transaction_id as string | undefined)?.trim();
|
|
const merchantId = (req.query.merchant_id as string | undefined)?.trim();
|
|
const limitRaw = req.query.limit as string | undefined;
|
|
const limit = limitRaw ? Number(limitRaw) : 100;
|
|
|
|
if (!Number.isFinite(limit) || limit <= 0) {
|
|
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
|
}
|
|
|
|
const entries = await listLedgerEntries({
|
|
transaction_id: transactionId || undefined,
|
|
merchant_id: merchantId || undefined,
|
|
limit
|
|
});
|
|
|
|
res.json(successResponse(req, entries.map(toLedgerEntryPayload)));
|
|
});
|
|
|
|
export default router;
|