Implement phase 1 completion and phase 2 dynamic QR
This commit is contained in:
20
dist/app.js
vendored
20
dist/app.js
vendored
@ -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
339
dist/routes/admin.js
vendored
@ -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
219
dist/routes/device.js
vendored
@ -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;
|
||||
|
||||
33
dist/routes/integrations.js
vendored
33
dist/routes/integrations.js
vendored
@ -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));
|
||||
|
||||
95
dist/shared/db/pool.js
vendored
95
dist/shared/db/pool.js
vendored
@ -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;
|
||||
`;
|
||||
|
||||
27
dist/shared/middleware/idempotency.js
vendored
27
dist/shared/middleware/idempotency.js
vendored
@ -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();
|
||||
};
|
||||
|
||||
26
dist/shared/services/deviceCapabilityResolver.js
vendored
Normal file
26
dist/shared/services/deviceCapabilityResolver.js
vendored
Normal 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")
|
||||
};
|
||||
}
|
||||
67
dist/shared/services/dynamicQrOrchestrator.js
vendored
Normal file
67
dist/shared/services/dynamicQrOrchestrator.js
vendored
Normal 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
|
||||
};
|
||||
}
|
||||
20
dist/shared/services/mqttPublisher.js
vendored
20
dist/shared/services/mqttPublisher.js
vendored
@ -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
84
dist/shared/store/auditLogStore.js
vendored
Normal 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
89
dist/shared/store/deviceConfigStore.js
vendored
Normal 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
74
dist/shared/store/ledgerStore.js
vendored
Normal 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 };
|
||||
}
|
||||
38
dist/shared/store/locationStore.js
vendored
38
dist/shared/store/locationStore.js
vendored
@ -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
74
dist/shared/store/mqttMessageStore.js
vendored
Normal 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 };
|
||||
}
|
||||
7
dist/shared/store/transactionStore.js
vendored
7
dist/shared/store/transactionStore.js
vendored
@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user