Implement phase 1 completion and phase 2 dynamic QR

This commit is contained in:
2026-05-26 08:06:48 +07:00
parent a152c99cce
commit 5624b92872
36 changed files with 3104 additions and 71 deletions

20
dist/app.js vendored
View File

@ -11,7 +11,24 @@ import path from "node:path";
import fs from "node:fs";
const app = express();
startNotificationOrchestrator();
app.use(helmet());
app.use(helmet({
crossOriginResourcePolicy: {
policy: "cross-origin"
},
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.tailwindcss.com"],
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
imgSrc: ["'self'", "data:", "https://lh3.googleusercontent.com", "https://*.googleusercontent.com"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"]
}
}
}));
app.use(express.json());
app.use(morgan("dev"));
app.use(requestContext);
@ -35,6 +52,7 @@ app.get("/ui/hub", (_req, res) => {
const filePath = path.resolve(process.cwd(), "ui/hub.html");
res.sendFile(filePath);
});
app.use("/ui/shared", express.static(path.resolve(process.cwd(), "ui", "shared")));
app.get("/ui/:page", (req, res, next) => {
const filePath = resolveUiPageFile(req.params.page);
if (!filePath) {

339
dist/routes/admin.js vendored
View File

@ -14,6 +14,12 @@ import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDevice
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();
function parseIdempotentReplay(req) {
return req.body.__idempotentReplay;
@ -64,6 +70,12 @@ function parseDeviceStatusValue(value) {
}
return undefined;
}
function parsePayoutMode(value) {
if (value === "merchant_direct" || value === "manual") {
return value;
}
return undefined;
}
function parseOutletStatusFilter(value) {
if (value === "active" || value === "inactive") {
return value;
@ -114,6 +126,7 @@ async function buildDeviceAdminPayload(device) {
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,
@ -170,6 +183,20 @@ function toStartEndDateFilter(from, to) {
function normalizeMerchantMode(payloadMode) {
return payloadMode || "merchant_direct";
}
async function auditAdminAction(req, payload) {
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) {
const mode = normalizeMerchantMode(payload.payout_mode);
if (mode === "merchant_direct") {
@ -223,6 +250,10 @@ router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.crea
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);
}
@ -239,6 +270,12 @@ router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.crea
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, res) => {
@ -264,6 +301,9 @@ router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next)
...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;
@ -277,6 +317,13 @@ router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next)
return next(err);
}
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, res, next) => {
@ -290,6 +337,13 @@ router.post("/merchants/:merchantId/approve", requireAdminToken, async (req, res
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, res, next) => {
@ -305,6 +359,16 @@ router.post("/merchants/:merchantId/reject", requireAdminToken, async (req, res,
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
@ -362,7 +426,11 @@ router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", requir
device_code: "DEV_SEED_A",
vendor: "seed-maker",
model: "v1",
communication_mode: "mqtt",
communication_mode: "static",
capability_profile_json: {
dynamic_qr: false,
flows: ["static_payment_notification"]
},
status: "active"
});
const deviceB = await createDevice({
@ -370,14 +438,28 @@ router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", requir
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: "mqtt",
status: "inactive"
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,
@ -484,12 +566,26 @@ router.post("/merchants/:merchantId/outlets", requireAdminToken, idempotency({ s
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, res) => {
const merchantId = req.query.merchant_id;
router.get("/outlets", requireAdminToken, async (req, res, next) => {
const merchantId = req.query.merchant_id?.trim();
const statusRaw = req.query.status?.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?.trim();
res.json(successResponse(req, (await listOutlets({
merchant_id: merchantId
merchant_id: merchantId,
status,
q: q || undefined
})).map(toOutletPayload)));
});
router.get("/outlets/:outletId", requireAdminToken, async (req, res, next) => {
@ -508,7 +604,15 @@ router.patch("/outlets/:outletId", requireAdminToken, async (req, res, next) =>
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) {
@ -543,12 +647,26 @@ router.post("/outlets/:outletId/terminals", requireAdminToken, idempotency({ sco
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, res) => {
const outletId = req.query.outlet_id;
router.get("/terminals", requireAdminToken, async (req, res, next) => {
const outletId = req.query.outlet_id?.trim();
const statusRaw = req.query.status?.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?.trim();
res.json(successResponse(req, (await listTerminals({
outlet_id: outletId
outlet_id: outletId,
status,
q: q || undefined
})).map(toTerminalPayload)));
});
router.get("/terminals/:terminalId", requireAdminToken, async (req, res, next) => {
@ -570,7 +688,15 @@ router.patch("/terminals/:terminalId", requireAdminToken, async (req, res, next)
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) {
@ -595,6 +721,12 @@ router.post("/devices", requireAdminToken, idempotency({ scope: "device.create",
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, res) => {
@ -663,6 +795,7 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
.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,
@ -720,7 +853,15 @@ router.patch("/devices/:deviceId", requireAdminToken, async (req, res, next) =>
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) {
@ -760,6 +901,12 @@ router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "
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, res, next) => {
@ -771,6 +918,13 @@ router.post("/devices/:deviceId/unbind", requireAdminToken, async (req, res, nex
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, res, next) => {
@ -833,6 +987,85 @@ router.get("/devices/:deviceId/notifications", requireAdminToken, async (req, re
.map(toNotificationPayload);
res.json(successResponse(req, { device_id: device.id, notifications }));
});
router.get("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => {
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, res, next) => {
const device = await getDeviceById(req.params.deviceId);
if (!device) {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
const payload = req.body;
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",
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, res, next) => {
const device = await getDeviceById(req.params.deviceId);
if (!device) {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
const directionRaw = req.query.direction?.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?.trim();
const correlationId = req.query.correlation_id?.trim();
const limitRaw = req.query.limit;
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, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(201).json(getReplayResponse(req));
@ -885,15 +1118,25 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio
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, res, next) => {
const status = req.query.status;
const merchantId = req.query.merchant_id;
const statusRaw = req.query.status?.trim();
const status = parseTransactionStatusFilter(statusRaw);
if (statusRaw && !status) {
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
}
const merchantId = req.query.merchant_id?.trim();
const from = req.query.from;
const to = req.query.to;
const partnerReference = req.query.partner_reference;
const normalizedStatus = typeof status === "string" ? parseTransactionStatusFilter(status) : undefined;
const q = req.query.q?.trim();
if (from && !isIsoDate(from)) {
return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
}
@ -901,12 +1144,21 @@ router.get("/transactions", requireAdminToken, async (req, res, next) => {
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: normalizedStatus,
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, res, next) => {
@ -915,6 +1167,7 @@ router.get("/transactions/:transactionId", requireAdminToken, async (req, res, n
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
@ -932,6 +1185,7 @@ router.get("/transactions/:transactionId", requireAdminToken, async (req, res, n
res.json(successResponse(req, {
transaction: toTransactionPayload(tx),
events,
ledger_entries,
heartbeat_device_id: heartbeatDeviceId,
heartbeat_history: heartbeatHistory
}));
@ -1037,7 +1291,15 @@ router.post("/transactions/:transactionId/retry-notification", requireAdminToken
return next(error);
}
});
router.get("/dashboard/summary", requireAdminToken, async (req, res, next) => {
router.get("/dashboard/summary", requireAdminToken, async (req, res) => {
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();
@ -1056,18 +1318,19 @@ router.get("/dashboard/summary", requireAdminToken, async (req, res, next) => {
const pendingNotifications = (await listNotifications()).filter((notification) => {
return notification.delivery_status === "queued" || notification.delivery_status === "retrying";
}).length;
res.json(successResponse(req, {
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) {
return next(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, res, next) => {
const deviceId = req.query.device_id;
@ -1104,4 +1367,46 @@ router.get("/notifications/failed", requireAdminToken, async (req, res, next) =>
}));
res.json(successResponse(req, filtered));
});
router.get("/audit-logs", requireAdminToken, async (req, res, next) => {
const entityType = req.query.entity_type?.trim();
const entityId = req.query.entity_id?.trim();
const action = req.query.action?.trim();
const from = req.query.from;
const to = req.query.to;
const limitRaw = req.query.limit;
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, res, next) => {
const transactionId = req.query.transaction_id?.trim();
const merchantId = req.query.merchant_id?.trim();
const limitRaw = req.query.limit;
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;

219
dist/routes/device.js vendored
View File

@ -5,6 +5,15 @@ 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";
import { getActiveBindingByDevice } from "../shared/store/bindingStore";
import { getTerminalById } from "../shared/store/locationStore";
import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempotencyStore";
import { env } from "../config/env";
import { supportsDynamicQrFlow } from "../shared/services/deviceCapabilityResolver";
import { createDynamicQrTransaction } from "../shared/services/dynamicQrOrchestrator";
import { createMqttMessage } from "../shared/store/mqttMessageStore";
import { publishDynamicQrResponse } from "../shared/services/mqttPublisher";
import { createDeviceConfigAck, getOrCreateDeviceConfig, toDeviceConfigAckPayload, toDeviceConfigPayload } from "../shared/store/deviceConfigStore";
const router = Router();
function normalizeNumberOrNull(value) {
if (typeof value === "string") {
@ -19,6 +28,30 @@ function normalizeNumberOrNull(value) {
}
return null;
}
function normalizePositiveAmount(value) {
const normalized = normalizeNumberOrNull(value);
if (normalized === null || normalized <= 0) {
return null;
}
return normalized;
}
function normalizeTtl(value) {
if (value === undefined || value === null || value === "") {
return undefined;
}
const normalized = normalizeNumberOrNull(value);
if (normalized === null || normalized <= 0) {
return undefined;
}
return normalized;
}
function normalizePositiveInteger(value) {
const normalized = normalizeNumberOrNull(value);
if (normalized === null || normalized <= 0 || !Number.isInteger(normalized)) {
return null;
}
return normalized;
}
function normalizeSignalStrength(value) {
const normalized = normalizeNumberOrNull(value);
if (normalized === null) {
@ -123,4 +156,190 @@ router.post("/commands/ack", requireDeviceToken, async (req, res, next) => {
acknowledged_at: updated.acknowledged_at
}));
});
router.post("/transactions/dynamic-qr", requireDeviceToken, async (req, res, next) => {
const payload = req.body;
if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) {
return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400));
}
const amount = normalizePositiveAmount(payload.amount);
if (amount === null) {
return next(new ApiError("INVALID_AMOUNT", "amount must be a positive number", 400));
}
const currency = payload.currency && payload.currency.trim() ? payload.currency.trim().toUpperCase() : "IDR";
if (currency !== "IDR") {
return next(new ApiError("BAD_REQUEST", "currency must be IDR for QRIS dynamic MVP", 400));
}
const idempotencyKey = req.header("Idempotency-Key") || payload.request_id;
const cached = readIdempotency("device.dynamic_qr.create", idempotencyKey);
if (cached) {
return res.json(successResponse(req, cached.data ?? cached));
}
const device = await getDeviceById(payload.device_id);
if (!device) {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
if (device.status !== "active") {
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "inactive device cannot create dynamic QR", 400));
}
if (!supportsDynamicQrFlow(device, "api_direct")) {
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "device does not support API-direct dynamic QR", 400));
}
const terminal = await getTerminalById(payload.terminal_id);
if (!terminal) {
return next(new ApiError("NOT_FOUND", "terminal not found", 404));
}
if (terminal.qr_mode !== "dynamic_api") {
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "terminal is not configured for API dynamic QR", 400));
}
const binding = await getActiveBindingByDevice(device.id);
if (!binding || binding.terminal_id !== terminal.id) {
return next(new ApiError("DEVICE_NOT_BOUND", "device is not actively bound to requested terminal", 400));
}
const created = await createDynamicQrTransaction({
request_id: payload.request_id,
device_id: device.id,
merchant_id: binding.merchant_id,
outlet_id: binding.outlet_id,
terminal_id: binding.terminal_id,
amount,
currency,
expires_in_seconds: normalizeTtl(payload.expires_in_seconds)
});
const responseData = {
...created,
request_id: payload.request_id
};
writeIdempotency("device.dynamic_qr.create", idempotencyKey, { data: responseData }, env.IDEMPOTENCY_TTL_MS);
res.status(201).json(successResponse(req, responseData));
});
router.post("/mqtt/uplink/dynamic-qr/request", requireDeviceToken, async (req, res, next) => {
const payload = req.body;
if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) {
return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400));
}
if (payload.message_type && payload.message_type !== "dynamic_qr_request") {
return next(new ApiError("BAD_REQUEST", "message_type must be dynamic_qr_request", 400));
}
const amount = normalizePositiveAmount(payload.amount);
if (amount === null) {
return next(new ApiError("INVALID_AMOUNT", "amount must be a positive number", 400));
}
const currency = payload.currency && payload.currency.trim() ? payload.currency.trim().toUpperCase() : "IDR";
if (currency !== "IDR") {
return next(new ApiError("BAD_REQUEST", "currency must be IDR for QRIS dynamic MVP", 400));
}
const cached = readIdempotency("device.dynamic_qr.mqtt", payload.request_id);
if (cached) {
return res.json(successResponse(req, cached.data ?? cached));
}
const device = await getDeviceById(payload.device_id);
if (!device) {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
if (device.status !== "active" || !supportsDynamicQrFlow(device, "mqtt")) {
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "device does not support MQTT dynamic QR", 400));
}
const terminal = await getTerminalById(payload.terminal_id);
if (!terminal) {
return next(new ApiError("NOT_FOUND", "terminal not found", 404));
}
if (terminal.qr_mode !== "dynamic_mqtt") {
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "terminal is not configured for MQTT dynamic QR", 400));
}
const binding = await getActiveBindingByDevice(device.id);
if (!binding || binding.terminal_id !== terminal.id) {
return next(new ApiError("DEVICE_NOT_BOUND", "device is not actively bound to requested terminal", 400));
}
await createMqttMessage({
direction: "uplink",
device_id: device.id,
topic: `devices/${device.id}/uplink/dynamic-qr/request`,
message_type: "dynamic_qr_request",
correlation_id: payload.request_id,
payload_json: {
...payload,
amount,
currency
}
});
const created = await createDynamicQrTransaction({
request_id: payload.request_id,
device_id: device.id,
merchant_id: binding.merchant_id,
outlet_id: binding.outlet_id,
terminal_id: binding.terminal_id,
amount,
currency,
expires_in_seconds: normalizeTtl(payload.expires_in_seconds),
initiation_mode: "dynamic_mqtt"
});
const mqttPayload = {
message_type: "dynamic_qr_response",
correlation_id: payload.request_id,
transaction_id: created.transaction_id,
status: "success",
qr_payload: created.qr_payload,
expires_at: created.expires_at
};
const publishResult = await publishDynamicQrResponse(device.id, mqttPayload);
const outbox = await createMqttMessage({
direction: "downlink",
device_id: device.id,
topic: publishResult.topic,
message_type: "dynamic_qr_response",
correlation_id: payload.request_id,
payload_json: mqttPayload,
publish_status: publishResult.ok ? "sent" : "failed",
reason: publishResult.reason
});
const responseData = {
correlation_id: payload.request_id,
transaction_id: created.transaction_id,
status: "success",
qr_payload: created.qr_payload,
expires_at: created.expires_at,
downlink_message_id: outbox.id,
publish_status: outbox.publish_status,
partner_reference: created.partner_reference
};
writeIdempotency("device.dynamic_qr.mqtt", payload.request_id, { data: responseData }, env.IDEMPOTENCY_TTL_MS);
res.status(201).json(successResponse(req, responseData));
});
router.get("/config", requireDeviceToken, async (req, res, next) => {
const deviceId = req.query.device_id || req.body?.device_id;
if (!deviceId) {
return next(new ApiError("BAD_REQUEST", "device_id is required", 400));
}
const device = await getDeviceById(deviceId);
if (!device) {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
const config = await getOrCreateDeviceConfig(device.id);
res.json(successResponse(req, toDeviceConfigPayload(config)));
});
router.post("/config/ack", requireDeviceToken, async (req, res, next) => {
const payload = req.body;
if (!payload || !payload.device_id || !payload.status) {
return next(new ApiError("BAD_REQUEST", "device_id, status are required", 400));
}
if (!["applied", "failed"].includes(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be applied or failed", 400));
}
const configVersion = normalizePositiveInteger(payload.config_version);
if (configVersion === null) {
return next(new ApiError("BAD_REQUEST", "config_version must be a positive integer", 400));
}
const device = await getDeviceById(payload.device_id);
if (!device) {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
const ack = await createDeviceConfigAck({
device_id: device.id,
config_version: configVersion,
status: payload.status,
reason: payload.reason,
payload_json: payload.result_payload || {}
});
res.json(successResponse(req, toDeviceConfigAckPayload(ack)));
});
export default router;

View File

@ -6,6 +6,7 @@ import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempot
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();
@ -79,6 +80,20 @@ function buildCallbackResponse(req, transactionId, eventId, note, reason) {
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()}`;
@ -172,6 +187,12 @@ router.post("/qris/callback", async (req, res, next) => {
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,
@ -216,6 +237,12 @@ router.post("/qris/callback", async (req, res, next) => {
}
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));
@ -235,6 +262,12 @@ router.post("/qris/callback", async (req, res, next) => {
}
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));

View File

@ -134,6 +134,41 @@ CREATE TABLE IF NOT EXISTS device_commands (
CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC);
CREATE TABLE IF NOT EXISTS mqtt_messages (
id TEXT PRIMARY KEY,
direction TEXT NOT NULL CHECK (direction IN ('uplink', 'downlink')),
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
message_type TEXT NOT NULL,
correlation_id TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
publish_status TEXT NOT NULL DEFAULT 'recorded' CHECK (publish_status IN ('recorded', 'sent', 'failed')),
reason TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mqtt_messages_device_created ON mqtt_messages (device_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_mqtt_messages_correlation ON mqtt_messages (correlation_id);
CREATE TABLE IF NOT EXISTS device_configs (
device_id TEXT PRIMARY KEY REFERENCES devices (id) ON DELETE CASCADE,
config_version INT NOT NULL,
settings_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS device_config_acks (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
config_version INT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('applied', 'failed')),
reason TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
acked_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_device_config_acks_device ON device_config_acks (device_id, acked_at DESC);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
transaction_code TEXT NOT NULL UNIQUE,
@ -191,5 +226,65 @@ CREATE TABLE IF NOT EXISTS notifications (
CREATE INDEX IF NOT EXISTS idx_notifications_device_status ON notifications (device_id, delivery_status);
CREATE INDEX IF NOT EXISTS idx_notifications_status_created ON notifications (delivery_status, created_at DESC);
CREATE TABLE IF NOT EXISTS ledger_entries (
id TEXT PRIMARY KEY,
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
entry_type TEXT NOT NULL CHECK (entry_type IN ('gross_income', 'platform_fee', 'merchant_payable')),
amount NUMERIC(20,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'IDR',
direction TEXT NOT NULL CHECK (direction IN ('credit', 'debit')),
status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'voided')),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT ledger_entries_unique_tx_type UNIQUE (transaction_id, entry_type)
);
CREATE INDEX IF NOT EXISTS idx_ledger_entries_merchant_created ON ledger_entries (merchant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ledger_entries_tx ON ledger_entries (transaction_id);
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
permissions_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role_id TEXT NOT NULL REFERENCES roles (id),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL,
actor_id TEXT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
before_json JSONB,
after_json JSONB,
source_ip TEXT,
request_id TEXT,
trace_id TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs (entity_type, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs (action, created_at DESC);
INSERT INTO roles (id, name, permissions_json, created_at)
VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO users (id, name, email, password_hash, role_id, status, created_at)
VALUES ('user_admin_seed', 'Admin Seed', 'admin@example.local', 'dev-only-admin-password', 'role_admin', 'active', NOW())
ON CONFLICT (id) DO NOTHING;
COMMIT;
`;

View File

@ -1,6 +1,7 @@
import { ApiError } from "../errors";
import { readIdempotency, writeIdempotency } from "../idempotency/idempotencyStore";
import { env } from "../../config/env";
import { successResponse } from "./errorMiddleware";
export function idempotency(options) {
return function idempotencyMiddleware(req, _res, next) {
const idempotencyKey = req.header("idempotency-key");
@ -14,7 +15,20 @@ export function idempotency(options) {
if (cached) {
const cachedPayload = cached.response ?? cached;
const cachedStatus = cached.statusCode || 200;
return _res.status(cachedStatus).json(cachedPayload);
const payload = (() => {
if (cachedPayload &&
typeof cachedPayload === "object" &&
"data" in cachedPayload &&
"request_id" in cachedPayload &&
"timestamp" in cachedPayload) {
const typed = cachedPayload;
typed.request_id = req.requestId;
typed.timestamp = new Date().toISOString();
return typed;
}
return cachedPayload;
})();
return _res.status(cachedStatus).json(payload);
}
req.body = { ...(req.body || {}), __idempotencyKey: idempotencyKey };
const originalJson = _res.json.bind(_res);
@ -25,12 +39,19 @@ export function idempotency(options) {
return originalStatus(code);
};
_res.json = function jsonWithStore(payload) {
const responsePayload = payload &&
typeof payload === "object" &&
"data" in payload &&
"request_id" in payload &&
"timestamp" in payload
? successResponse(req, payload.data)
: payload;
writeIdempotency(options.scope, idempotencyKey, {
response: payload,
response: responsePayload,
statusCode,
at: Date.now()
}, options.ttlMs || env.IDEMPOTENCY_TTL_MS);
return originalJson(payload);
return originalJson(responsePayload);
};
next();
};

View File

@ -0,0 +1,26 @@
function getProfile(device) {
return (device.capability_profile_json || {});
}
export function supportsDynamicQrFlow(device, flow) {
const profile = getProfile(device);
const flows = Array.isArray(profile.flows) ? profile.flows : [];
if (flow === "api_direct" && device.communication_mode !== "api") {
return false;
}
if (flow === "mqtt" && device.communication_mode !== "mqtt") {
return false;
}
if (typeof profile.dynamic_qr === "boolean") {
return profile.dynamic_qr || flows.includes(`dynamic_qr:${flow}`);
}
if (profile.dynamic_qr && typeof profile.dynamic_qr === "object") {
return Boolean(profile.dynamic_qr[flow]) || flows.includes(`dynamic_qr:${flow}`);
}
return flows.includes(`dynamic_qr:${flow}`);
}
export function resolveDeviceCapabilitySummary(device) {
return {
dynamic_qr_api_direct: supportsDynamicQrFlow(device, "api_direct"),
dynamic_qr_mqtt: supportsDynamicQrFlow(device, "mqtt")
};
}

View File

@ -0,0 +1,67 @@
import { randomUUID } from "node:crypto";
import { createTransaction, addTransactionEvent, toTransactionPayload } from "../store/transactionStore";
function makePartnerReference(requestId) {
const clean = requestId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40);
return `DYN-${clean || randomUUID().slice(0, 12)}`;
}
function makeDynamicQrPayload(input) {
const amountMinor = Math.round(input.amount * 100);
const encoded = Buffer.from(JSON.stringify({
type: "QRIS_DYNAMIC_MOCK",
transaction_id: input.transactionId,
partner_reference: input.partnerReference,
amount_minor: amountMinor,
currency: input.currency,
expires_at: input.expiresAt
})).toString("base64url");
return `QRIS-DYNAMIC-MOCK.${encoded}`;
}
export async function createDynamicQrTransaction(input) {
const ttlSeconds = Math.min(Math.max(input.expires_in_seconds || 300, 60), 1800);
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
const partnerReference = makePartnerReference(input.request_id);
const tx = await createTransaction({
merchant_id: input.merchant_id,
outlet_id: input.outlet_id,
terminal_id: input.terminal_id,
device_id: input.device_id,
partner_reference: partnerReference,
amount: input.amount,
currency: input.currency || "IDR",
qr_mode: "dynamic",
initiation_mode: input.initiation_mode || "dynamic_api",
status: "awaiting_payment",
expired_at: expiresAt
});
const qrPayload = makeDynamicQrPayload({
transactionId: tx.id,
partnerReference,
amount: tx.amount,
currency: tx.currency,
expiresAt
});
await addTransactionEvent({
transaction_id: tx.id,
event_type: "DYNAMIC_QR_CREATED",
source: "device",
payload_json: {
request_id: input.request_id,
correlation_id: input.request_id,
device_id: input.device_id,
qr_payload: qrPayload,
expires_at: expiresAt,
transaction: toTransactionPayload(tx)
}
});
return {
request_id: input.request_id,
correlation_id: input.request_id,
transaction_id: tx.id,
transaction_code: tx.transaction_code,
qr_type: "dynamic",
qr_payload: qrPayload,
expires_at: expiresAt,
status: "awaiting_payment",
partner_reference: partnerReference
};
}

View File

@ -27,10 +27,15 @@ export function buildPaymentSuccessPayload(input) {
export function makePaymentSuccessTopic(deviceId) {
return `devices/${deviceId}/downlink/payment/success`;
}
export async function publishPaymentSuccess(payload) {
export function makeDynamicQrResponseTopic(deviceId) {
return `devices/${deviceId}/downlink/dynamic-qr/response`;
}
export function makeConfigPushTopic(deviceId) {
return `devices/${deviceId}/downlink/config/push`;
}
async function publishMqttPayload(deviceId, topic, payload) {
const publishedAt = new Date().toISOString();
const topic = makePaymentSuccessTopic(payload.device_id);
if (shouldForceFail(payload.device_id)) {
if (shouldForceFail(deviceId)) {
return {
ok: false,
topic,
@ -50,3 +55,12 @@ export async function publishPaymentSuccess(payload) {
payload
};
}
export async function publishPaymentSuccess(payload) {
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
}
export async function publishDynamicQrResponse(deviceId, payload) {
return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload);
}
export async function publishConfigPush(deviceId, payload) {
return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload);
}

84
dist/shared/store/auditLogStore.js vendored Normal file
View File

@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function mapAuditLog(row) {
return {
id: row.id,
actor_type: row.actor_type,
actor_id: row.actor_id || undefined,
action: row.action,
entity_type: row.entity_type,
entity_id: row.entity_id,
before_json: row.before_json || null,
after_json: row.after_json || null,
source_ip: row.source_ip || undefined,
request_id: row.request_id || undefined,
trace_id: row.trace_id || undefined,
created_at: row.created_at
};
}
export async function createAuditLog(payload) {
const { rows } = await getPool().query(`INSERT INTO audit_logs (
id,
actor_type,
actor_id,
action,
entity_type,
entity_id,
before_json,
after_json,
source_ip,
request_id,
trace_id,
created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`, [
randomUUID(),
payload.actor_type,
payload.actor_id || null,
payload.action,
payload.entity_type,
payload.entity_id,
payload.before_json || null,
payload.after_json || null,
payload.source_ip || null,
payload.request_id || null,
payload.trace_id || null,
nowIso()
]);
return mapAuditLog(rows[0]);
}
export async function listAuditLogs(filter) {
const clauses = [];
const params = [];
let i = 1;
if (filter?.entity_type) {
clauses.push(`entity_type = $${i++}`);
params.push(filter.entity_type);
}
if (filter?.entity_id) {
clauses.push(`entity_id = $${i++}`);
params.push(filter.entity_id);
}
if (filter?.action) {
clauses.push(`action = $${i++}`);
params.push(filter.action);
}
if (filter?.from) {
clauses.push(`created_at >= $${i++}`);
params.push(filter.from);
}
if (filter?.to) {
clauses.push(`created_at <= $${i++}`);
params.push(filter.to);
}
const limit = Math.min(Math.max(filter?.limit || 100, 1), 500);
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await getPool().query(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ${limit}`, params);
return rows.map(mapAuditLog);
}
export function toAuditLogPayload(auditLog) {
return { ...auditLog };
}

89
dist/shared/store/deviceConfigStore.js vendored Normal file
View File

@ -0,0 +1,89 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
const DEFAULT_SETTINGS = {
volume: 80,
language: "id-ID",
heartbeat_interval_seconds: 60
};
function nowIso() {
return new Date().toISOString();
}
function mapConfig(row) {
return {
device_id: row.device_id,
config_version: Number(row.config_version),
settings_json: row.settings_json || {},
updated_at: row.updated_at
};
}
function mapAck(row) {
return {
id: row.id,
device_id: row.device_id,
config_version: Number(row.config_version),
status: row.status,
reason: row.reason || undefined,
payload_json: row.payload_json || {},
acked_at: row.acked_at
};
}
export async function getDeviceConfig(deviceId) {
const { rows } = await getPool().query("SELECT * FROM device_configs WHERE device_id = $1", [deviceId]);
return rows[0] ? mapConfig(rows[0]) : null;
}
export async function getOrCreateDeviceConfig(deviceId) {
const existing = await getDeviceConfig(deviceId);
if (existing) {
return existing;
}
return upsertDeviceConfig({
device_id: deviceId,
settings_json: DEFAULT_SETTINGS
});
}
export async function upsertDeviceConfig(payload) {
const existing = await getDeviceConfig(payload.device_id);
const nextVersion = payload.config_version || (existing ? existing.config_version + 1 : 1);
const { rows } = await getPool().query(`INSERT INTO device_configs (device_id, config_version, settings_json, updated_at)
VALUES ($1,$2,$3,$4)
ON CONFLICT (device_id) DO UPDATE
SET config_version = EXCLUDED.config_version,
settings_json = EXCLUDED.settings_json,
updated_at = EXCLUDED.updated_at
RETURNING *`, [payload.device_id, nextVersion, payload.settings_json, nowIso()]);
return mapConfig(rows[0]);
}
export async function createDeviceConfigAck(payload) {
const { rows } = await getPool().query(`INSERT INTO device_config_acks (
id,
device_id,
config_version,
status,
reason,
payload_json,
acked_at
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *`, [
`cfgack_${randomUUID()}`,
payload.device_id,
payload.config_version,
payload.status,
payload.reason || null,
payload.payload_json || {},
nowIso()
]);
return mapAck(rows[0]);
}
export async function listDeviceConfigAcks(deviceId, limit = 50) {
const { rows } = await getPool().query(`SELECT * FROM device_config_acks
WHERE device_id = $1
ORDER BY acked_at DESC
LIMIT $2`, [deviceId, Math.min(Math.max(limit, 1), 200)]);
return rows.map(mapAck);
}
export function toDeviceConfigPayload(config) {
return { ...config };
}
export function toDeviceConfigAckPayload(ack) {
return { ...ack };
}

74
dist/shared/store/ledgerStore.js vendored Normal file
View File

@ -0,0 +1,74 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function mapLedgerEntry(row) {
return {
id: row.id,
transaction_id: row.transaction_id,
merchant_id: row.merchant_id,
entry_type: row.entry_type,
amount: Number(row.amount),
currency: row.currency,
direction: row.direction,
status: row.status,
metadata_json: row.metadata_json || {},
created_at: row.created_at
};
}
export async function createPaidLedgerPlaceholder(tx) {
const { rows } = await getPool().query(`INSERT INTO ledger_entries (
id,
transaction_id,
merchant_id,
entry_type,
amount,
currency,
direction,
status,
metadata_json,
created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (transaction_id, entry_type) DO UPDATE
SET amount = EXCLUDED.amount,
currency = EXCLUDED.currency,
metadata_json = ledger_entries.metadata_json || EXCLUDED.metadata_json
RETURNING *`, [
randomUUID(),
tx.id,
tx.merchant_id,
"gross_income",
tx.amount,
tx.currency,
"credit",
"posted",
{
placeholder: true,
source: "fase1_paid_transaction",
partner_reference: tx.partner_reference
},
nowIso()
]);
return mapLedgerEntry(rows[0]);
}
export async function listLedgerEntries(filter) {
const clauses = [];
const params = [];
let i = 1;
if (filter?.transaction_id) {
clauses.push(`transaction_id = $${i++}`);
params.push(filter.transaction_id);
}
if (filter?.merchant_id) {
clauses.push(`merchant_id = $${i++}`);
params.push(filter.merchant_id);
}
const limit = Math.min(Math.max(filter?.limit || 100, 1), 500);
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await getPool().query(`SELECT * FROM ledger_entries ${where} ORDER BY created_at DESC LIMIT ${limit}`, params);
return rows.map(mapLedgerEntry);
}
export function toLedgerEntryPayload(entry) {
return { ...entry };
}

View File

@ -65,19 +65,45 @@ export async function createTerminal(payload) {
return mapTerminal(rows[0]);
}
export async function listOutlets(filter) {
const clauses = [];
const params = [];
let i = 1;
if (filter?.merchant_id) {
const { rows } = await getPool().query("SELECT * FROM outlets WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id]);
return rows.map(mapOutlet);
clauses.push(`merchant_id = $${i++}`);
params.push(filter.merchant_id);
}
const { rows } = await getPool().query("SELECT * FROM outlets ORDER BY created_at DESC");
if (filter?.status) {
clauses.push(`status = $${i++}`);
params.push(filter.status);
}
if (filter?.q) {
const value = `%${filter.q.toLowerCase()}%`;
clauses.push(`(LOWER(name) LIKE $${i++} OR LOWER(outlet_code) LIKE $${i++})`);
params.push(value, value);
}
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await getPool().query(`SELECT * FROM outlets ${where} ORDER BY created_at DESC`, params);
return rows.map(mapOutlet);
}
export async function listTerminals(filter) {
const clauses = [];
const params = [];
let i = 1;
if (filter?.outlet_id) {
const { rows } = await getPool().query("SELECT * FROM terminals WHERE outlet_id = $1 ORDER BY created_at DESC", [filter.outlet_id]);
return rows.map(mapTerminal);
clauses.push(`outlet_id = $${i++}`);
params.push(filter.outlet_id);
}
const { rows } = await getPool().query("SELECT * FROM terminals ORDER BY created_at DESC");
if (filter?.status) {
clauses.push(`status = $${i++}`);
params.push(filter.status);
}
if (filter?.q) {
const value = `%${filter.q.toLowerCase()}%`;
clauses.push(`(LOWER(terminal_code) LIKE $${i++} OR LOWER(COALESCE(partner_reference, '')) LIKE $${i++})`);
params.push(value, value);
}
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await getPool().query(`SELECT * FROM terminals ${where} ORDER BY created_at DESC`, params);
return rows.map(mapTerminal);
}
export async function getOutletById(id) {

74
dist/shared/store/mqttMessageStore.js vendored Normal file
View File

@ -0,0 +1,74 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function mapMessage(row) {
return {
id: row.id,
direction: row.direction,
device_id: row.device_id,
topic: row.topic,
message_type: row.message_type,
correlation_id: row.correlation_id || undefined,
payload_json: row.payload_json || {},
publish_status: row.publish_status,
reason: row.reason || undefined,
created_at: row.created_at
};
}
export async function createMqttMessage(payload) {
const { rows } = await getPool().query(`INSERT INTO mqtt_messages (
id,
direction,
device_id,
topic,
message_type,
correlation_id,
payload_json,
publish_status,
reason,
created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *`, [
`mqtt_${randomUUID()}`,
payload.direction,
payload.device_id,
payload.topic,
payload.message_type,
payload.correlation_id || null,
payload.payload_json || {},
payload.publish_status || "recorded",
payload.reason || null,
nowIso()
]);
return mapMessage(rows[0]);
}
export async function listMqttMessages(filter) {
const clauses = [];
const params = [];
let i = 1;
if (filter?.device_id) {
clauses.push(`device_id = $${i++}`);
params.push(filter.device_id);
}
if (filter?.direction) {
clauses.push(`direction = $${i++}`);
params.push(filter.direction);
}
if (filter?.message_type) {
clauses.push(`message_type = $${i++}`);
params.push(filter.message_type);
}
if (filter?.correlation_id) {
clauses.push(`correlation_id = $${i++}`);
params.push(filter.correlation_id);
}
const limit = Math.min(Math.max(filter?.limit || 100, 1), 500);
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await getPool().query(`SELECT * FROM mqtt_messages ${where} ORDER BY created_at DESC LIMIT ${limit}`, params);
return rows.map(mapMessage);
}
export function toMqttMessagePayload(message) {
return { ...message };
}

View File

@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
import { createPaidLedgerPlaceholder } from "./ledgerStore";
function nowIso() {
return new Date().toISOString();
}
@ -161,7 +162,11 @@ export async function updateTransactionStatus(id, to, options) {
...options.eventContext
}
});
return mapTransaction(rows[0]);
const updated = mapTransaction(rows[0]);
if (to === "paid") {
await createPaidLedgerPlaceholder(updated);
}
return updated;
}
export async function getTransactionById(id) {
const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]);