Initial commit

This commit is contained in:
2026-05-25 08:22:12 +07:00
commit a152c99cce
154 changed files with 39033 additions and 0 deletions

65
dist/app.js vendored Normal file
View File

@ -0,0 +1,65 @@
import express from "express";
import helmet from "helmet";
import morgan from "morgan";
import { requestContext } from "./shared/middleware/requestContext";
import { handleErrors, successResponse } from "./shared/middleware/errorMiddleware";
import adminRoutes from "./routes/admin";
import integrationRoutes from "./routes/integrations";
import deviceRoutes from "./routes/device";
import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator";
import path from "node:path";
import fs from "node:fs";
const app = express();
startNotificationOrchestrator();
app.use(helmet());
app.use(express.json());
app.use(morgan("dev"));
app.use(requestContext);
app.get("/", (_req, res) => {
res.json(successResponse(_req, { status: "ok" }));
});
function resolveUiPageFile(slug) {
const workspaceRoot = process.cwd();
const candidates = [
path.resolve(workspaceRoot, "ui", slug, "index.html"),
path.resolve(workspaceRoot, "ui", slug.replace(/_/g, "-"), "index.html"),
path.resolve(workspaceRoot, "ui", slug.replace(/-/g, "_"), "index.html")
];
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
}
app.get("/ui", (_req, res) => {
const filePath = path.resolve(process.cwd(), "ui/index.html");
res.sendFile(filePath);
});
app.get("/ui/hub", (_req, res) => {
const filePath = path.resolve(process.cwd(), "ui/hub.html");
res.sendFile(filePath);
});
app.get("/ui/:page", (req, res, next) => {
const filePath = resolveUiPageFile(req.params.page);
if (!filePath) {
return next();
}
res.sendFile(filePath);
});
app.use("/admin", adminRoutes);
app.use("/integrations", integrationRoutes);
app.use("/device", deviceRoutes);
app.use((err, _req, res, next) => {
handleErrors(err, _req, res, next);
});
app.get("/health", (req, res) => {
res.status(200).json(successResponse(req, {
status: "healthy",
time: new Date().toISOString()
}));
});
app.use((req, res) => {
res.status(404).json({
code: "NOT_FOUND",
message: `Route ${req.path} not found`,
request_id: req.requestId,
timestamp: new Date().toISOString()
});
});
export default app;

17
dist/config/env.js vendored Normal file
View File

@ -0,0 +1,17 @@
export const env = {
PORT: Number(process.env.PORT || 3000),
ADMIN_TOKEN: process.env.ADMIN_TOKEN || "admin-dev-token",
DEVICE_TOKEN: process.env.DEVICE_TOKEN || "device-dev-token",
TRACE_HEADER: process.env.TRACE_HEADER || "x-request-id",
IDEMPOTENCY_TTL_MS: Number(process.env.IDEMPOTENCY_TTL_MS || 300000),
INTEGRATION_WEBHOOK_SECRET: process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret",
MQTT_PUBLISH_FORCE_FAIL_ALL: process.env.MQTT_PUBLISH_FORCE_FAIL_ALL || "false",
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS: process.env.MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS || "",
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number(process.env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000),
DATABASE_URL: process.env.DATABASE_URL || "",
PGHOST: process.env.PGHOST || "127.0.0.1",
PGPORT: Number(process.env.PGPORT || 5432),
PGUSER: process.env.PGUSER || "postgres",
PGPASSWORD: process.env.PGPASSWORD || "postgres",
PGDATABASE: process.env.PGDATABASE || "qris_soundbox_platform"
};

13
dist/index.js vendored Normal file
View File

@ -0,0 +1,13 @@
import { createServer } from "node:http";
import app from "./app";
import { ensureSchema } from "./shared/db/pool";
import { env } from "./config/env";
const port = env.PORT;
const server = createServer(app);
async function bootstrap() {
await ensureSchema();
server.listen(port, () => {
console.log(`QRIS Soundbox Platform bootstrap ready on :${port}`);
});
}
void bootstrap();

1107
dist/routes/admin.js vendored Normal file

File diff suppressed because it is too large Load Diff

126
dist/routes/device.js vendored Normal file
View File

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

242
dist/routes/integrations.js vendored Normal file
View File

@ -0,0 +1,242 @@
import { Router } from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
import { ApiError } from "../shared/errors";
import { successResponse } from "../shared/middleware/errorMiddleware";
import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempotencyStore";
import { addTransactionEvent, findTransactionByPartnerReference, getTransactionEvents, updateTransactionStatus } from "../shared/store/transactionStore";
import { emitTransactionPaid } from "../shared/events/transactionEvents";
import { env } from "../config/env";
const router = Router();
function parsePaymentStatus(rawStatus) {
const normalized = String(rawStatus || "").toLowerCase();
if (["paid", "success", "successful", "settled", "completed"].includes(normalized)) {
return { status: "paid" };
}
if (["failed", "declined", "rejected", "error", "cancelled"].includes(normalized)) {
return { status: "failed" };
}
if (["expired", "timeout", "stale"].includes(normalized)) {
return { status: "expired" };
}
return { status: "failed", reason: "UNKNOWN_STATUS" };
}
function verifySignature(payload, signature) {
if (!signature) {
throw new ApiError("WEBHOOK_SIGNATURE_INVALID", "missing X-Partner-Signature", 401);
}
const { signature: _ignoredSignature, ...signaturePayload } = payload;
const secret = env.INTEGRATION_WEBHOOK_SECRET;
const expected = createHmac("sha256", secret)
.update(JSON.stringify(signaturePayload))
.digest("hex");
const a = Buffer.from(signature.toLowerCase(), "utf8");
const b = Buffer.from(expected.toLowerCase(), "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new ApiError("WEBHOOK_SIGNATURE_INVALID", "invalid X-Partner-Signature", 401);
}
}
function parseCallback(payload) {
const partnerReference = payload.partner_reference;
if (!partnerReference) {
throw new ApiError("CALLBACK_PARTNER_DATA_INVALID", "partner_reference is required", 400);
}
const amount = Number(payload.amount || 0);
if (!Number.isFinite(amount) || amount <= 0) {
throw new ApiError("CALLBACK_PARTNER_DATA_INVALID", "amount must be > 0", 400);
}
const parsed = {
partnerReference,
partnerTxnId: payload.partner_txn_id,
status: parsePaymentStatus(payload.payment_status || payload.status),
amount,
paidAt: payload.paid_at,
currency: typeof payload.currency === "string" && payload.currency.length > 0 ? payload.currency.toUpperCase() : "IDR"
};
return parsed;
}
function makeIdempotencyKey(payload, headerValue) {
if (headerValue) {
return headerValue;
}
return `${payload.partnerReference}:${payload.partnerTxnId || ""}:${payload.status.status}`;
}
function buildCallbackResponse(req, transactionId, eventId, note, reason) {
const response = {
status: "accepted",
event_id: eventId,
transaction_id: transactionId,
request_id: req.requestId,
timestamp: new Date().toISOString()
};
if (note) {
response.note = note;
}
if (reason) {
response.reason = reason;
}
return response;
}
function writeCallbackResult(idempotencyKey, response, transactionId) {
writeIdempotency("callback.processing", idempotencyKey, { response, transaction_id: transactionId }, env.IDEMPOTENCY_TTL_MS);
}
async function makeResponseEventId(txId, fallbackTag) {
const events = await getTransactionEvents(txId);
return events.at(-1)?.id || `${fallbackTag}_${Date.now()}`;
}
router.post("/qris/callback", async (req, res, next) => {
const incoming = req.body;
const signature = req.header("X-Partner-Signature");
let parsed;
try {
verifySignature(incoming, signature);
parsed = parseCallback(incoming);
}
catch (error) {
return next(error);
}
const idempotencyKey = makeIdempotencyKey(parsed, req.header("Idempotency-Key") || undefined);
const cache = readIdempotency("callback.processing", idempotencyKey);
if (cache && typeof cache === "object" && "response" in cache) {
const cached = cache;
const transactionId = typeof cached.transaction_id === "string" ? cached.transaction_id : null;
if (transactionId) {
await addTransactionEvent({
transaction_id: transactionId,
event_type: "CALLBACK_DUPLICATE",
source: "webhook",
payload_json: {
idempotency_key: idempotencyKey,
source_payload: incoming
}
});
}
return res.json(successResponse(req, cached.response));
}
const tx = await findTransactionByPartnerReference(parsed.partnerReference);
if (!tx) {
const response = buildCallbackResponse(req, null, `callback_no_tx_${Date.now()}`, "TRANSACTION_NOT_FOUND");
writeCallbackResult(idempotencyKey, response, null);
return res.json(successResponse(req, response));
}
if (parsed.amount !== tx.amount) {
const response = buildCallbackResponse(req, tx.id, `callback_amount_mismatch_${Date.now()}`, "AMOUNT_MISMATCH");
await addTransactionEvent({
transaction_id: tx.id,
event_type: "CALLBACK_REJECTED",
source: "webhook",
payload_json: {
idempotency_key: idempotencyKey,
received_amount: parsed.amount,
expected_amount: tx.amount
}
});
writeCallbackResult(idempotencyKey, response, tx.id);
return res.status(409).json(successResponse(req, response));
}
await addTransactionEvent({
transaction_id: tx.id,
event_type: "CALLBACK_RECEIVED",
source: "webhook",
payload_json: {
idempotency_key: idempotencyKey,
partner_reference: parsed.partnerReference,
partner_txn_id: parsed.partnerTxnId,
candidate_status: parsed.status.status,
status_reason: parsed.status.reason,
partner_payload: incoming
}
});
const eventContext = {
partner_reference: parsed.partnerReference,
partner_txn_id: parsed.partnerTxnId,
idempotency_key: idempotencyKey,
candidate_currency: parsed.currency,
reason: parsed.status.reason
};
if (parsed.status.status === "paid") {
const wasPaid = tx.status === "paid";
let updated;
try {
updated = await updateTransactionStatus(tx.id, "paid", {
source: "webhook",
eventContext,
paid_at: parsed.paidAt
});
}
catch (error) {
if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) {
const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected");
writeCallbackResult(idempotencyKey, response, tx.id);
return res.status(409).json(successResponse(req, response));
}
throw error;
}
if (!wasPaid) {
emitTransactionPaid({
transaction_id: updated.id,
merchant_id: updated.merchant_id,
outlet_id: updated.outlet_id,
terminal_id: updated.terminal_id,
device_id: updated.device_id,
amount: updated.amount,
currency: updated.currency,
partner_reference: updated.partner_reference,
paid_at: updated.paid_at
});
await addTransactionEvent({
transaction_id: updated.id,
event_type: "PUSH_QUEUED",
source: "system",
payload_json: {
event_type: "transaction.paid",
transaction_id: updated.id,
partner_reference: updated.partner_reference,
device_id: updated.device_id
}
});
}
const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event"));
writeCallbackResult(idempotencyKey, response, updated.id);
return res.json(successResponse(req, response));
}
if (parsed.status.status === "expired") {
let updated;
try {
updated = await updateTransactionStatus(tx.id, "expired", {
source: "webhook",
eventContext,
expired_at: new Date().toISOString()
});
}
catch (error) {
if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) {
const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected");
writeCallbackResult(idempotencyKey, response, tx.id);
return res.status(409).json(successResponse(req, response));
}
throw error;
}
const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event"));
writeCallbackResult(idempotencyKey, response, updated.id);
return res.json(successResponse(req, response));
}
let updated;
try {
updated = await updateTransactionStatus(tx.id, "failed", {
source: "webhook",
eventContext
});
}
catch (error) {
if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) {
const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected");
writeCallbackResult(idempotencyKey, response, tx.id);
return res.status(409).json(successResponse(req, response));
}
throw error;
}
const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event"), undefined, parsed.status.reason);
writeCallbackResult(idempotencyKey, response, updated.id);
return res.json(successResponse(req, response));
});
export default router;

195
dist/shared/db/pool.js vendored Normal file
View File

@ -0,0 +1,195 @@
import { Pool } from "pg";
import { env } from "../../config/env";
let pool = null;
function buildPoolConfig() {
if (env.DATABASE_URL) {
return {
connectionString: env.DATABASE_URL
};
}
return {
host: env.PGHOST,
port: env.PGPORT,
user: env.PGUSER,
password: env.PGPASSWORD,
database: env.PGDATABASE
};
}
export function getPool() {
if (!pool) {
const config = buildPoolConfig();
pool = new Pool(config);
}
return pool;
}
export async function withClient(work) {
const client = await getPool().connect();
try {
return await work(client);
}
finally {
client.release();
}
}
export async function ensureSchema() {
const pool = getPool();
await pool.query(MIGRATIONS_SQL);
}
const MIGRATIONS_SQL = `
BEGIN;
CREATE TABLE IF NOT EXISTS merchants (
id TEXT PRIMARY KEY,
merchant_code TEXT NOT NULL UNIQUE,
legal_name TEXT NOT NULL,
brand_name TEXT,
settlement_account_reference TEXT,
settlement_account_type TEXT,
payout_mode TEXT NOT NULL DEFAULT 'merchant_direct' CHECK (payout_mode IN ('merchant_direct', 'manual')),
fee_profile_id TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
onboarding_status TEXT NOT NULL DEFAULT 'pending' CHECK (onboarding_status IN ('pending', 'approved', 'rejected')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS outlets (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
outlet_code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
address TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS terminals (
id TEXT PRIMARY KEY,
outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE,
terminal_code TEXT NOT NULL UNIQUE,
qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')),
partner_reference TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
device_code TEXT NOT NULL UNIQUE,
serial_number TEXT,
vendor TEXT,
model TEXT,
communication_mode TEXT NOT NULL DEFAULT 'static' CHECK (communication_mode IN ('static', 'mqtt', 'api')),
capability_profile_json JSONB NOT NULL DEFAULT '{}'::jsonb,
auth_method TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
last_seen_at TIMESTAMPTZ,
firmware_version TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
CREATE TABLE IF NOT EXISTS device_bindings (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE,
terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE,
active_flag BOOLEAN NOT NULL DEFAULT false,
bound_at TIMESTAMPTZ NOT NULL,
unbound_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_device_active_binding ON device_bindings (device_id) WHERE active_flag = TRUE;
CREATE INDEX IF NOT EXISTS idx_device_bindings_terminal_active ON device_bindings (terminal_id, active_flag);
CREATE TABLE IF NOT EXISTS device_heartbeats (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
timestamp TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
firmware_version TEXT,
network_strength INTEGER,
battery_level INTEGER,
state TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_device_heartbeats_device ON device_heartbeats (device_id, received_at DESC);
CREATE TABLE IF NOT EXISTS device_commands (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
command TEXT NOT NULL,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'accepted' CHECK (status IN ('accepted', 'delivered', 'failed', 'timeout')),
requested_at TIMESTAMPTZ NOT NULL,
acknowledged_at TIMESTAMPTZ,
result_payload_json JSONB,
reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
transaction_code TEXT NOT NULL UNIQUE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE,
terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE,
device_id TEXT REFERENCES devices (id) ON DELETE SET NULL,
qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic')),
initiation_mode TEXT NOT NULL DEFAULT 'static' CHECK (initiation_mode IN ('static', 'manual', 'dynamic_api', 'dynamic_mqtt')),
partner_reference TEXT NOT NULL UNIQUE,
amount NUMERIC(20,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'IDR',
status TEXT NOT NULL DEFAULT 'initiated' CHECK (status IN ('initiated', 'awaiting_payment', 'paid', 'failed', 'expired', 'reversed')),
created_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
expired_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_partner_ref ON transactions (partner_reference);
CREATE INDEX IF NOT EXISTS idx_transactions_merchant_created ON transactions (merchant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_transactions_status_created ON transactions (status, created_at DESC);
CREATE TABLE IF NOT EXISTS transaction_events (
id TEXT PRIMARY KEY,
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
source TEXT NOT NULL,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transaction_events_tx ON transaction_events (transaction_id, created_at DESC);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
device_id TEXT REFERENCES devices (id) ON DELETE SET NULL,
delivery_channel TEXT NOT NULL DEFAULT 'mqtt' CHECK (delivery_channel IN ('mqtt')),
payload_type TEXT NOT NULL DEFAULT 'payment_success' CHECK (payload_type IN ('payment_success')),
delivery_status TEXT NOT NULL CHECK (delivery_status IN ('queued', 'sent', 'acknowledged', 'failed', 'retrying')),
retry_count INT NOT NULL DEFAULT 0,
ack_status TEXT NOT NULL DEFAULT 'not_needed' CHECK (ack_status IN ('pending', 'received', 'not_supported', 'not_needed')),
event_id TEXT NOT NULL,
reason TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ,
ack_at TIMESTAMPTZ,
next_retry_at TIMESTAMPTZ,
CONSTRAINT notifications_unique_tx_event UNIQUE (transaction_id, event_id)
);
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);
COMMIT;
`;

21
dist/shared/errors/index.js vendored Normal file
View File

@ -0,0 +1,21 @@
export class ApiError extends Error {
statusCode;
code;
details;
constructor(code, message, statusCode = 400, details) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
}
export function errorEnvelope(error, requestId) {
return {
code: error.code,
message: error.message,
details: error.details,
request_id: requestId,
timestamp: new Date().toISOString()
};
}

53
dist/shared/events/transactionEvents.js vendored Normal file
View File

@ -0,0 +1,53 @@
import { randomUUID } from "node:crypto";
const transactionPaidEvents = new Map();
const transactionPaidIndex = new Map();
const transactionPaidSubscribers = new Set();
function nowIso() {
return new Date().toISOString();
}
function cloneInternalEvent(event) {
return {
...event,
payload_json: { ...event.payload_json }
};
}
export function subscribeTransactionPaid(handler) {
transactionPaidSubscribers.add(handler);
return () => transactionPaidSubscribers.delete(handler);
}
export function emitTransactionPaid(payload) {
const existingId = transactionPaidIndex.get(payload.transaction_id);
if (existingId) {
const events = transactionPaidEvents.get(payload.transaction_id) || [];
const existing = events.find((event) => event.id === existingId);
if (existing) {
return cloneInternalEvent(existing);
}
}
const event = {
id: randomUUID(),
event_type: "transaction.paid",
transaction_id: payload.transaction_id,
payload_json: payload,
created_at: nowIso()
};
const bucket = transactionPaidEvents.get(payload.transaction_id) || [];
bucket.push(event);
transactionPaidEvents.set(payload.transaction_id, bucket);
transactionPaidIndex.set(payload.transaction_id, event.id);
const frozen = cloneInternalEvent(event);
for (const listener of Array.from(transactionPaidSubscribers)) {
listener(frozen);
}
return frozen;
}
export function getTransactionPaidEvents(transactionId) {
if (transactionId) {
return (transactionPaidEvents.get(transactionId) || []).map(cloneInternalEvent);
}
return Array.from(transactionPaidEvents.values()).flatMap((bucket) => bucket.map(cloneInternalEvent));
}
export function getTransactionPaidEventByTransactionId(transactionId) {
const events = transactionPaidEvents.get(transactionId) || [];
return events.length > 0 ? cloneInternalEvent(events[events.length - 1]) : null;
}

View File

@ -0,0 +1,26 @@
const store = new Map();
export function makeIdempotencyKey(scope, key) {
return `${scope}:${key}`;
}
export function readIdempotency(scope, key) {
const entry = store.get(makeIdempotencyKey(scope, key));
if (!entry) {
return null;
}
if (entry.expiresAt < Date.now()) {
store.delete(makeIdempotencyKey(scope, key));
return null;
}
return entry.value;
}
export function writeIdempotency(scope, key, value, ttlMs) {
store.set(makeIdempotencyKey(scope, key), {
key,
scope,
value,
expiresAt: Date.now() + ttlMs
});
}
export function clearIdempotency(scope, key) {
store.delete(makeIdempotencyKey(scope, key));
}

30
dist/shared/middleware/auth.js vendored Normal file
View File

@ -0,0 +1,30 @@
import { ApiError } from "../errors";
import { env } from "../../config/env";
function extractAdminToken(req) {
const raw = req.header("authorization") || "";
if (raw.startsWith("Bearer ")) {
return raw.slice(7);
}
return raw || req.header("x-admin-token") || "";
}
export function requireAdminToken(req, _res, next) {
const token = extractAdminToken(req);
if (!token) {
return next(new ApiError("UNAUTHORIZED", "Missing admin bearer token", 401));
}
if (token !== env.ADMIN_TOKEN) {
return next(new ApiError("UNAUTHORIZED", "Invalid admin token", 401));
}
return next();
}
export function requireDeviceToken(req, _res, next) {
const raw = req.header("authorization") || "";
const token = raw.startsWith("Bearer ") ? raw.slice(7) : raw;
if (!token) {
return next(new ApiError("UNAUTHORIZED", "Missing device bearer token", 401));
}
if (token !== env.DEVICE_TOKEN) {
return next(new ApiError("UNAUTHORIZED", "Invalid device token", 401));
}
return next();
}

View File

@ -0,0 +1,22 @@
import { ApiError, errorEnvelope } from "../errors";
export function successResponse(req, data) {
return {
data,
request_id: req.requestId,
timestamp: new Date().toISOString()
};
}
export function handleErrors(err, req, res, _next) {
if (err instanceof ApiError) {
res.status(err.statusCode).json(errorEnvelope({
...err
}, req.requestId));
return;
}
res.status(500).json({
code: "INTERNAL_ERROR",
message: err.message || "Unexpected server error",
request_id: req.requestId,
timestamp: new Date().toISOString()
});
}

37
dist/shared/middleware/idempotency.js vendored Normal file
View File

@ -0,0 +1,37 @@
import { ApiError } from "../errors";
import { readIdempotency, writeIdempotency } from "../idempotency/idempotencyStore";
import { env } from "../../config/env";
export function idempotency(options) {
return function idempotencyMiddleware(req, _res, next) {
const idempotencyKey = req.header("idempotency-key");
if (!idempotencyKey) {
if (options.required === false) {
return next();
}
return next(new ApiError("DUPLICATE_REQUEST", "Missing Idempotency-Key", 400));
}
const cached = readIdempotency(options.scope, idempotencyKey);
if (cached) {
const cachedPayload = cached.response ?? cached;
const cachedStatus = cached.statusCode || 200;
return _res.status(cachedStatus).json(cachedPayload);
}
req.body = { ...(req.body || {}), __idempotencyKey: idempotencyKey };
const originalJson = _res.json.bind(_res);
const originalStatus = _res.status.bind(_res);
let statusCode = 200;
_res.status = function statusWithStore(code) {
statusCode = code;
return originalStatus(code);
};
_res.json = function jsonWithStore(payload) {
writeIdempotency(options.scope, idempotencyKey, {
response: payload,
statusCode,
at: Date.now()
}, options.ttlMs || env.IDEMPOTENCY_TTL_MS);
return originalJson(payload);
};
next();
};
}

View File

@ -0,0 +1,11 @@
import { randomUUID } from "node:crypto";
import { env } from "../../config/env";
export function requestContext(req, _res, next) {
const requestId = req.header(env.TRACE_HEADER) ||
req.header("x-trace-id") ||
randomUUID();
const traceId = req.header("x-trace-id") || requestId;
req.requestId = requestId;
req.traceId = traceId;
next();
}

View File

@ -0,0 +1,274 @@
import { getActiveBindingByDevice, getActiveBindingByTerminal } from "../store/bindingStore";
import { createNotification, getNotificationByTransactionAndEvent, getNotificationByTransactionId, listNotifications, toNotificationPayload, updateNotification } from "../store/notificationStore";
import { getMerchantById } from "../store/merchantStore";
import { getTransactionById, listTransactions, toTransactionPayload } from "../store/transactionStore";
import { buildPaymentSuccessPayload, publishPaymentSuccess } from "../services/mqttPublisher";
import { subscribeTransactionPaid } from "../events/transactionEvents";
import { env } from "../../config/env";
const RETRY_INTERVAL_MS = [
env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000,
(env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000) * 2,
(env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000) * 4
];
const RETRY_POLL_INTERVAL_MS = 5000;
const MAX_RETRY = 3;
async function resolveNotificationDevice(payload) {
if (payload.device_id) {
const binding = await getActiveBindingByDevice(payload.device_id);
if (binding && binding.terminal_id === payload.terminal_id) {
return {
deviceId: payload.device_id,
source: "tx_device"
};
}
}
const terminalBinding = await getActiveBindingByTerminal(payload.terminal_id);
if (!terminalBinding) {
return null;
}
return {
deviceId: terminalBinding.device_id,
source: "binding_device"
};
}
function buildNotificationPayload(txPayload, deviceId) {
return {
message_type: "payment_success",
transaction_id: txPayload.transaction_id,
merchant_id: txPayload.merchant_id,
terminal_id: txPayload.terminal_id,
amount: txPayload.amount,
currency: txPayload.currency,
paid_at: txPayload.paid_at,
partner_reference: txPayload.partner_reference,
target_device_id: deviceId
};
}
function resolveDeliveryStatus(bindingResolved) {
return bindingResolved ? "queued" : "failed";
}
function resolveFailureReason(bindingResolved) {
if (!bindingResolved) {
return "NOTIFICATION_NO_ACTIVE_BINDING";
}
return undefined;
}
function makeNextRetryDate(retryCount) {
const intervalMs = RETRY_INTERVAL_MS[Math.min(retryCount, RETRY_INTERVAL_MS.length - 1)] || 60000;
return new Date(Date.now() + intervalMs).toISOString();
}
async function getNotificationMerchantName(merchantId) {
const merchant = await getMerchantById(merchantId);
return merchant?.brand_name || merchant?.legal_name || merchantId;
}
function mapMqttFailureState(retryCount, reason) {
const nextRetryCount = retryCount + 1;
if (nextRetryCount >= MAX_RETRY) {
return {
status: "failed",
retry_count: nextRetryCount,
reason: reason || "NOTIFICATION_RETRY_EXHAUSTED"
};
}
return {
status: "retrying",
retry_count: nextRetryCount,
next_retry_at: makeNextRetryDate(retryCount),
reason
};
}
async function markNotificationSent(notification, publishResult) {
await updateNotification(notification.id, {
delivery_status: "sent",
retry_count: notification.retry_count,
ack_status: "not_supported",
sent_at: publishResult.publishedAt
});
}
async function markNotificationFailed(notification, publishResult) {
const next = mapMqttFailureState(notification.retry_count, publishResult.reason);
await updateNotification(notification.id, {
delivery_status: next.status,
retry_count: next.retry_count,
reason: next.reason,
ack_status: "not_supported",
next_retry_at: next.next_retry_at
});
}
async function markNoDeviceFailure(notification) {
await updateNotification(notification.id, {
delivery_status: "failed",
retry_count: 0,
reason: "NOTIFICATION_NO_ACTIVE_BINDING",
ack_status: "not_needed"
});
}
async function resolveNotificationFromTransaction(notification) {
const tx = await getTransactionById(notification.transaction_id);
if (!tx) {
return null;
}
return {
transaction_id: tx.id,
merchant_id: tx.merchant_id,
outlet_id: tx.outlet_id,
terminal_id: tx.terminal_id,
device_id: tx.device_id,
amount: tx.amount,
currency: tx.currency,
paid_at: tx.paid_at,
partner_reference: tx.partner_reference
};
}
async function getMqttPayloadFromNotification(notification, paidAt) {
return buildPaymentSuccessPayload({
transaction_id: String(notification.payload_json.transaction_id || ""),
merchant_id: String(notification.payload_json.merchant_id || ""),
merchant_name: await getNotificationMerchantName(String(notification.payload_json.merchant_id || "")),
device_id: String(notification.device_id || ""),
amount: Number(notification.payload_json.amount || 0),
currency: String(notification.payload_json.currency || "IDR"),
paid_at: paidAt,
partner_reference: String(notification.payload_json.partner_reference || ""),
event_id: notification.event_id
});
}
async function publishNotificationNow(notification, eventPayload) {
if (!notification.device_id) {
const resolvedFromTransaction = await resolveNotificationFromTransaction(notification);
if (!resolvedFromTransaction) {
await markNoDeviceFailure(notification);
return;
}
const resolved = await resolveNotificationDevice(resolvedFromTransaction);
if (!resolved) {
await markNoDeviceFailure(notification);
return;
}
notification = await updateNotification(notification.id, {
device_id: resolved.deviceId,
delivery_status: "queued",
reason: undefined
});
}
const effectivePayload = eventPayload ?? (await resolveNotificationFromTransaction(notification));
if (!effectivePayload) {
await markNoDeviceFailure(notification);
return;
}
const mqttPayload = await buildPaymentSuccessPayload({
transaction_id: effectivePayload.transaction_id,
merchant_id: effectivePayload.merchant_id,
merchant_name: await getNotificationMerchantName(effectivePayload.merchant_id),
device_id: notification.device_id || String(effectivePayload.device_id || ""),
amount: effectivePayload.amount,
currency: effectivePayload.currency,
paid_at: effectivePayload.paid_at,
partner_reference: effectivePayload.partner_reference,
event_id: notification.event_id
});
const result = await publishPaymentSuccess(mqttPayload);
if (!result.ok) {
await markNotificationFailed(notification, result);
return;
}
await markNotificationSent(notification, result);
}
async function onTransactionPaid(event) {
const payload = event.payload_json;
const existing = await getNotificationByTransactionAndEvent(payload.transaction_id, event.id);
if (existing) {
return;
}
const resolved = await resolveNotificationDevice(payload);
const deliveryStatus = resolveDeliveryStatus(resolved);
const created = await createNotification({
transaction_id: payload.transaction_id,
device_id: resolved?.deviceId || null,
event_id: event.id,
delivery_status: deliveryStatus,
reason: resolveFailureReason(resolved),
payload_json: buildNotificationPayload(payload, resolved?.deviceId || ""),
ack_status: resolved ? "not_supported" : "not_needed"
});
await publishNotificationNow(created, payload);
}
async function bootstrapNotificationForPaidTransaction(transaction) {
const existing = await getNotificationByTransactionId(transaction.id);
if (existing) {
return;
}
const payload = {
transaction_id: transaction.id,
merchant_id: transaction.merchant_id,
outlet_id: transaction.outlet_id,
terminal_id: transaction.terminal_id,
device_id: transaction.device_id,
amount: transaction.amount,
currency: transaction.currency,
partner_reference: transaction.partner_reference,
paid_at: transaction.paid_at
};
const eventId = `bootstrap_${transaction.id}`;
const resolved = await resolveNotificationDevice(payload);
const deliveryStatus = resolveDeliveryStatus(resolved);
const created = await createNotification({
transaction_id: transaction.id,
device_id: resolved?.deviceId || null,
event_id: eventId,
delivery_status: deliveryStatus,
reason: resolveFailureReason(resolved),
payload_json: buildNotificationPayload(payload, resolved?.deviceId || ""),
ack_status: resolved ? "not_supported" : "not_needed"
});
await publishNotificationNow(created, payload);
}
async function seedPaidTransactions() {
const paidTransactions = await listTransactions({ status: "paid" });
for (const tx of paidTransactions) {
await bootstrapNotificationForPaidTransaction(toTransactionPayload(tx));
}
}
async function processRetryCycle() {
const now = new Date().toISOString();
const retrying = await listNotifications({
delivery_status: "retrying"
});
const due = retrying.filter((notification) => {
if (!notification.next_retry_at) {
return true;
}
return notification.next_retry_at <= now;
});
for (const notification of due) {
await publishNotificationNow(notification, null);
}
}
export async function retryNotificationByTransactionId(transactionId) {
const tx = await getTransactionById(transactionId);
if (!tx) {
return null;
}
if (tx.status !== "paid") {
throw new Error("NOTIFICATION_PUBLISH_CONDITION");
}
const notification = await getNotificationByTransactionId(transactionId);
if (!notification) {
return null;
}
if (notification.delivery_status === "acknowledged") {
return toNotificationPayload(notification);
}
await publishNotificationNow(notification, null);
const updated = await getNotificationByTransactionId(transactionId);
return updated ? toNotificationPayload(updated) : null;
}
export function startNotificationOrchestrator() {
void seedPaidTransactions();
void subscribeTransactionPaid((event) => {
void onTransactionPaid(event);
});
setInterval(() => {
void processRetryCycle();
}, RETRY_POLL_INTERVAL_MS);
}

52
dist/shared/services/mqttPublisher.js vendored Normal file
View File

@ -0,0 +1,52 @@
import { env } from "../../config/env";
const forcedFailAll = String(env.MQTT_PUBLISH_FORCE_FAIL_ALL).toLowerCase() === "true";
const forcedFailDevices = new Set(String(env.MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS)
.split(",")
.map((item) => item.trim())
.filter(Boolean));
function shouldForceFail(deviceId) {
return forcedFailAll || forcedFailDevices.has(deviceId);
}
export function buildPaymentSuccessPayload(input) {
const displayAmount = `${input.amount.toLocaleString("id-ID")}`;
return {
message_type: "payment_success",
device_id: input.device_id,
event_id: input.event_id,
transaction_id: input.transaction_id,
merchant_id: input.merchant_id,
merchant_name: input.merchant_name,
amount: input.amount,
currency: input.currency,
paid_at: input.paid_at,
partner_reference: input.partner_reference,
audio_text: `Pembayaran diterima ${input.currency} ${displayAmount}`,
display_text: `Pembayaran diterima Rp${displayAmount}`
};
}
export function makePaymentSuccessTopic(deviceId) {
return `devices/${deviceId}/downlink/payment/success`;
}
export async function publishPaymentSuccess(payload) {
const publishedAt = new Date().toISOString();
const topic = makePaymentSuccessTopic(payload.device_id);
if (shouldForceFail(payload.device_id)) {
return {
ok: false,
topic,
qos: 1,
retained: false,
publishedAt,
reason: "MQTT_PUBLISH_SIMULATED_FAILURE",
payload
};
}
return {
ok: true,
topic,
qos: 1,
retained: false,
publishedAt,
payload
};
}

97
dist/shared/store/bindingStore.js vendored Normal file
View File

@ -0,0 +1,97 @@
import { randomUUID } from "node:crypto";
import { getPool, withClient } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function mapBinding(row) {
return {
id: row.id,
device_id: row.device_id,
merchant_id: row.merchant_id,
outlet_id: row.outlet_id,
terminal_id: row.terminal_id,
active_flag: row.active_flag,
bound_at: row.bound_at,
unbound_at: row.unbound_at || undefined
};
}
export async function getActiveBindingByDevice(deviceId) {
const { rows } = await getPool().query(`SELECT * FROM device_bindings
WHERE device_id = $1 AND active_flag = TRUE
ORDER BY bound_at DESC
LIMIT 1`, [deviceId]);
return rows[0] ? mapBinding(rows[0]) : null;
}
export async function getActiveBindingByTerminal(terminalId) {
const { rows } = await getPool().query(`SELECT * FROM device_bindings
WHERE terminal_id = $1 AND active_flag = TRUE
ORDER BY bound_at DESC
LIMIT 1`, [terminalId]);
return rows[0] ? mapBinding(rows[0]) : null;
}
export async function getBindingsByDeviceId(deviceId) {
const { rows } = await getPool().query(`SELECT * FROM device_bindings
WHERE device_id = $1
ORDER BY bound_at DESC`, [deviceId]);
return rows.map(mapBinding);
}
export async function bindDevice(payload) {
const now = nowIso();
const result = await withClient(async (client) => {
const existing = await client.query(`SELECT * FROM device_bindings
WHERE device_id = $1 AND active_flag = TRUE
ORDER BY bound_at DESC
LIMIT 1`, [payload.device_id]);
const same = existing.rows[0]
? mapBinding(existing.rows[0])
: null;
if (same &&
same.merchant_id === payload.merchant_id &&
same.outlet_id === payload.outlet_id &&
same.terminal_id === payload.terminal_id) {
return same;
}
await client.query("BEGIN");
try {
if (existing.rows[0]) {
await client.query(`UPDATE device_bindings
SET active_flag = FALSE, unbound_at = $2
WHERE id = $1`, [same.id, now]);
}
const id = randomUUID();
const inserted = await client.query(`INSERT INTO device_bindings (
id,
device_id,
merchant_id,
outlet_id,
terminal_id,
active_flag,
bound_at
) VALUES ($1,$2,$3,$4,$5,TRUE,$6)
RETURNING *`, [id, payload.device_id, payload.merchant_id, payload.outlet_id, payload.terminal_id, now]);
await client.query("COMMIT");
return mapBinding(inserted.rows[0]);
}
catch (error) {
await client.query("ROLLBACK");
throw error;
}
});
return result;
}
export async function unbindDevice(deviceId) {
const now = nowIso();
const { rows } = await getPool().query(`UPDATE device_bindings
SET active_flag = FALSE,
unbound_at = $2
WHERE device_id = $1 AND active_flag = TRUE
RETURNING *`, [deviceId, now]);
return rows[0] ? mapBinding(rows[0]) : null;
}
export async function getBindingById(id) {
const { rows } = await getPool().query("SELECT * FROM device_bindings WHERE id = $1", [id]);
return rows[0] ? mapBinding(rows[0]) : null;
}
export function toBindingPayload(binding) {
return { ...binding };
}

92
dist/shared/store/deviceCommandStore.js vendored Normal file
View File

@ -0,0 +1,92 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function normalizeStatus(status) {
if (status === "accepted" || status === "delivered" || status === "failed" || status === "timeout") {
return status;
}
return "accepted";
}
function mapCommand(row) {
return {
id: row.id,
device_id: row.device_id,
command: row.command,
payload: row.payload_json || {},
status: row.status,
requested_at: row.requested_at,
acknowledged_at: row.acknowledged_at || null,
result_payload: row.result_payload_json || null,
reason: row.reason || null
};
}
export async function createDeviceCommand(payload) {
const entity = {
id: `cmd_${randomUUID()}`,
device_id: payload.device_id,
command: payload.command,
payload: payload.payload || {},
status: normalizeStatus(payload.status || "accepted"),
requested_at: nowIso(),
acknowledged_at: null,
result_payload: null,
reason: null
};
const { rows } = await getPool().query(`INSERT INTO device_commands (
id,
device_id,
command,
payload_json,
status,
requested_at,
acknowledged_at,
result_payload_json,
reason
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`, [
entity.id,
entity.device_id,
entity.command,
entity.payload,
entity.status,
entity.requested_at,
entity.acknowledged_at,
entity.result_payload,
entity.reason
]);
return mapCommand(rows[0]);
}
export async function listDeviceCommands(deviceId) {
const { rows } = await getPool().query("SELECT * FROM device_commands WHERE device_id = $1 ORDER BY requested_at DESC", [deviceId]);
return rows.map(mapCommand);
}
export async function getDeviceCommandById(deviceId, commandId) {
const { rows } = await getPool().query("SELECT * FROM device_commands WHERE device_id = $1 AND id = $2", [deviceId, commandId]);
return rows[0] ? mapCommand(rows[0]) : null;
}
export function toDeviceCommandPayload(command) {
return mapCommand(command);
}
export function toDeviceCommandPayloadBrief(command) {
return {
command_id: command.id,
device_id: command.device_id,
command: command.command,
status: command.status,
requested_at: command.requested_at,
acknowledged_at: command.acknowledged_at
};
}
export async function acknowledgeDeviceCommand(payload) {
const now = nowIso();
const { rows } = await getPool().query(`UPDATE device_commands
SET status = $3,
acknowledged_at = $4,
result_payload_json = $5,
reason = $6
WHERE device_id = $1 AND id = $2
RETURNING *`, [payload.device_id, payload.command_id, payload.status, now, payload.result_payload || null, payload.reason || null]);
return rows[0] ? mapCommand(rows[0]) : null;
}

106
dist/shared/store/deviceStore.js vendored Normal file
View File

@ -0,0 +1,106 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function makeCode(id) {
return `d_${id.slice(0, 6)}`;
}
function mapDevice(row) {
return {
id: row.id,
device_code: row.device_code,
serial_number: row.serial_number || undefined,
vendor: row.vendor || undefined,
model: row.model || undefined,
communication_mode: row.communication_mode,
capability_profile_json: row.capability_profile_json || {},
auth_method: row.auth_method || undefined,
status: row.status,
last_seen_at: row.last_seen_at || undefined,
firmware_version: row.firmware_version || undefined,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export async function createDevice(payload) {
const id = randomUUID();
const now = nowIso();
const { rows } = await getPool().query(`INSERT INTO devices (
id,
device_code,
serial_number,
vendor,
model,
communication_mode,
capability_profile_json,
auth_method,
status,
last_seen_at,
firmware_version,
created_at,
updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`, [
id,
payload.device_code || makeCode(id),
payload.serial_number,
payload.vendor,
payload.model,
payload.communication_mode || "static",
payload.capability_profile_json || {},
payload.auth_method || "token",
payload.status || "active",
payload.last_seen_at || null,
payload.firmware_version,
now,
now
]);
return mapDevice(rows[0]);
}
export async function listDevices() {
const { rows } = await getPool().query("SELECT * FROM devices ORDER BY created_at DESC");
return rows.map(mapDevice);
}
export async function getDeviceById(id) {
const { rows } = await getPool().query("SELECT * FROM devices WHERE id = $1", [id]);
return rows[0] ? mapDevice(rows[0]) : null;
}
export async function patchDevice(id, patch) {
const existing = await getDeviceById(id);
if (!existing) {
throw new Error("DEVICE_NOT_FOUND");
}
const merged = { ...existing, ...patch, updated_at: nowIso() };
const { rows } = await getPool().query(`UPDATE devices
SET device_code = $2,
serial_number = $3,
vendor = $4,
model = $5,
communication_mode = $6,
capability_profile_json = $7,
auth_method = $8,
status = $9,
firmware_version = $10,
last_seen_at = $11,
updated_at = $12
WHERE id = $1
RETURNING *`, [
id,
merged.device_code,
merged.serial_number,
merged.vendor,
merged.model,
merged.communication_mode || "static",
merged.capability_profile_json || {},
merged.auth_method,
merged.status,
merged.firmware_version,
merged.last_seen_at || null,
merged.updated_at
]);
return mapDevice(rows[0]);
}
export function toDevicePayload(device) {
return { ...device };
}

102
dist/shared/store/heartbeatStore.js vendored Normal file
View File

@ -0,0 +1,102 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function mapHeartbeat(row) {
return {
id: row.id,
device_id: row.device_id,
timestamp: row.timestamp,
received_at: row.received_at,
firmware_version: row.firmware_version || undefined,
network_strength: row.network_strength,
battery_level: row.battery_level,
state: row.state || undefined
};
}
export async function createDeviceHeartbeat(payload) {
const now = nowIso();
const id = randomUUID();
const { rows } = await getPool().query(`INSERT INTO device_heartbeats (
id,
device_id,
timestamp,
received_at,
firmware_version,
network_strength,
battery_level,
state,
payload_json
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`, [
id,
payload.device_id,
payload.timestamp,
now,
payload.firmware_version,
payload.network_strength,
payload.battery_level,
payload.state,
payload.payload_json || {}
]);
return mapHeartbeat(rows[0]);
}
export async function getLatestHeartbeatByDeviceId(deviceId) {
const { rows } = await getPool().query(`SELECT * FROM device_heartbeats
WHERE device_id = $1
ORDER BY received_at DESC
LIMIT 1`, [deviceId]);
return rows[0] ? mapHeartbeat(rows[0]) : null;
}
export async function getHeartbeatCountForDeviceLastHours(deviceId, hours = 24) {
const { rows } = await getPool().query(`SELECT COUNT(*)::INT AS cnt
FROM device_heartbeats
WHERE device_id = $1
AND (NOW() AT TIME ZONE 'utc' - timestamp) <= ($2 || ' hours')::interval`, [deviceId, hours]);
return Number(rows[0]?.cnt || 0);
}
export async function listHeartbeats(filter) {
const clauses = [];
const params = [];
let i = 1;
if (filter?.device_id) {
clauses.push(`device_id = $${i++}`);
params.push(filter.device_id);
}
if (filter?.state) {
clauses.push(`state = $${i++}`);
params.push(filter.state);
}
if (filter?.from) {
clauses.push(`timestamp >= $${i++}`);
params.push(filter.from);
}
if (filter?.to) {
clauses.push(`timestamp <= $${i++}`);
params.push(filter.to);
}
const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
const limitSql = filter?.limit ? `LIMIT ${Number(filter.limit)}` : "";
const { rows } = await getPool().query(`SELECT * FROM device_heartbeats ${whereSql} ORDER BY received_at DESC ${limitSql}`, params);
return rows.map(mapHeartbeat);
}
export function deriveDeviceStatus(input) {
const now = Date.now();
const lastSeen = Date.parse(input?.last_seen_at || "");
if (!Number.isFinite(lastSeen)) {
return "offline";
}
const ageSeconds = (now - lastSeen) / 1000;
if (ageSeconds > 900) {
return "offline";
}
if (ageSeconds > 90) {
return "stale";
}
if ((typeof input?.network_strength === "number" && input.network_strength < 40) ||
(typeof input?.battery_level === "number" && input.battery_level < 20)) {
return "degraded";
}
return "online";
}

146
dist/shared/store/locationStore.js vendored Normal file
View File

@ -0,0 +1,146 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function makeCode(prefix, id) {
return `${prefix}_${id.slice(0, 6)}`;
}
function mapOutlet(row) {
return {
id: row.id,
merchant_id: row.merchant_id,
outlet_code: row.outlet_code,
name: row.name,
address: row.address || undefined,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function mapTerminal(row) {
return {
id: row.id,
outlet_id: row.outlet_id,
terminal_code: row.terminal_code,
qr_mode: row.qr_mode,
partner_reference: row.partner_reference || undefined,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export async function createOutlet(payload) {
const id = randomUUID();
const now = nowIso();
const { rows } = await getPool().query(`INSERT INTO outlets (id, merchant_id, outlet_code, name, address, status, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`, [
id,
payload.merchant_id,
payload.outlet_code || makeCode("out", id),
payload.name,
payload.address,
payload.status || "active",
now,
now
]);
return mapOutlet(rows[0]);
}
export async function createTerminal(payload) {
const id = randomUUID();
const now = nowIso();
const { rows } = await getPool().query(`INSERT INTO terminals (id, outlet_id, terminal_code, qr_mode, partner_reference, status, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`, [
id,
payload.outlet_id,
payload.terminal_code || makeCode("term", id),
payload.qr_mode || "static",
payload.partner_reference || null,
payload.status || "active",
now,
now
]);
return mapTerminal(rows[0]);
}
export async function listOutlets(filter) {
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);
}
const { rows } = await getPool().query("SELECT * FROM outlets ORDER BY created_at DESC");
return rows.map(mapOutlet);
}
export async function listTerminals(filter) {
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);
}
const { rows } = await getPool().query("SELECT * FROM terminals ORDER BY created_at DESC");
return rows.map(mapTerminal);
}
export async function getOutletById(id) {
const { rows } = await getPool().query("SELECT * FROM outlets WHERE id = $1", [id]);
return rows[0] ? mapOutlet(rows[0]) : null;
}
export async function getTerminalById(id) {
const { rows } = await getPool().query("SELECT * FROM terminals WHERE id = $1", [id]);
return rows[0] ? mapTerminal(rows[0]) : null;
}
export async function patchOutlet(id, patch) {
const existing = await getOutletById(id);
if (!existing) {
throw new Error("OUTLET_NOT_FOUND");
}
const merged = { ...existing, ...patch, updated_at: nowIso() };
const { rows } = await getPool().query(`UPDATE outlets
SET merchant_id = $2,
outlet_code = $3,
name = $4,
address = $5,
status = $6,
updated_at = $7
WHERE id = $1
RETURNING *`, [
id,
merged.merchant_id,
merged.outlet_code,
merged.name,
merged.address || null,
merged.status,
merged.updated_at
]);
return mapOutlet(rows[0]);
}
export async function patchTerminal(id, patch) {
const existing = await getTerminalById(id);
if (!existing) {
throw new Error("TERMINAL_NOT_FOUND");
}
const merged = { ...existing, ...patch, updated_at: nowIso() };
const { rows } = await getPool().query(`UPDATE terminals
SET outlet_id = $2,
terminal_code = $3,
qr_mode = $4,
partner_reference = $5,
status = $6,
updated_at = $7
WHERE id = $1
RETURNING *`, [
id,
merged.outlet_id,
merged.terminal_code,
merged.qr_mode,
merged.partner_reference || null,
merged.status,
merged.updated_at
]);
return mapTerminal(rows[0]);
}
export function toOutletPayload(outlet) {
return { ...outlet };
}
export function toTerminalPayload(terminal) {
return { ...terminal };
}

119
dist/shared/store/merchantStore.js vendored Normal file
View File

@ -0,0 +1,119 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function makeCode(id) {
return `m_${id.slice(0, 6)}`;
}
function toPublic(entity) {
return entity;
}
function mapRowToMerchant(row) {
return {
id: row.id,
merchant_code: row.merchant_code,
legal_name: row.legal_name,
brand_name: row.brand_name || undefined,
settlement_account_reference: row.settlement_account_reference || undefined,
settlement_account_type: row.settlement_account_type || undefined,
payout_mode: row.payout_mode,
fee_profile_id: row.fee_profile_id || undefined,
status: row.status,
onboarding_status: row.onboarding_status,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export async function createMerchant(payload) {
const id = randomUUID();
const now = nowIso();
const payoutMode = payload.payout_mode || "merchant_direct";
const { rows } = await getPool().query(`INSERT INTO merchants (
id,
merchant_code,
legal_name,
brand_name,
settlement_account_reference,
settlement_account_type,
payout_mode,
fee_profile_id,
status,
onboarding_status,
created_at,
updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`, [
id,
makeCode(id),
payload.legal_name,
payload.brand_name,
payload.settlement_account_reference,
payload.settlement_account_type,
payoutMode,
payload.fee_profile_id,
payload.status || "active",
payload.onboarding_status || "pending",
now,
now
]);
return toPublic(mapRowToMerchant(rows[0]));
}
export async function getMerchantById(id) {
const { rows } = await getPool().query("SELECT * FROM merchants WHERE id = $1", [id]);
if (rows.length === 0) {
return null;
}
return mapRowToMerchant(rows[0]);
}
export async function listMerchants() {
const { rows } = await getPool().query("SELECT * FROM merchants ORDER BY created_at DESC");
return rows.map(mapRowToMerchant);
}
export async function patchMerchant(id, patch) {
const existing = await getMerchantById(id);
if (!existing) {
throw new Error("MERCHANT_NOT_FOUND");
}
const merged = { ...existing, ...patch, updated_at: nowIso() };
const { rows } = await getPool().query(`UPDATE merchants
SET legal_name = $2,
brand_name = $3,
settlement_account_reference = $4,
settlement_account_type = $5,
payout_mode = $6,
fee_profile_id = $7,
status = $8,
onboarding_status = $9,
updated_at = $10
WHERE id = $1
RETURNING *`, [
id,
merged.legal_name,
merged.brand_name,
merged.settlement_account_reference,
merged.settlement_account_type,
merged.payout_mode,
merged.fee_profile_id,
merged.status,
merged.onboarding_status,
merged.updated_at
]);
return mapRowToMerchant(rows[0]);
}
export function toMerchantPayload(m) {
return {
id: m.id,
merchant_code: m.merchant_code,
legal_name: m.legal_name,
brand_name: m.brand_name,
settlement_account_reference: m.settlement_account_reference,
settlement_account_type: m.settlement_account_type,
payout_mode: m.payout_mode,
fee_profile_id: m.fee_profile_id,
status: m.status,
onboarding_status: m.onboarding_status,
created_at: m.created_at,
updated_at: m.updated_at
};
}

151
dist/shared/store/notificationStore.js vendored Normal file
View File

@ -0,0 +1,151 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function cloneNotification(notification) {
return {
...notification,
payload_json: { ...notification.payload_json }
};
}
function mapNotification(row) {
return {
id: row.id,
transaction_id: row.transaction_id,
device_id: row.device_id || null,
delivery_channel: "mqtt",
payload_type: "payment_success",
delivery_status: row.delivery_status,
retry_count: row.retry_count,
ack_status: row.ack_status,
event_id: row.event_id,
reason: row.reason || undefined,
payload_json: row.payload_json || {},
created_at: row.created_at,
updated_at: row.updated_at,
sent_at: row.sent_at || undefined,
ack_at: row.ack_at || undefined,
next_retry_at: row.next_retry_at || undefined
};
}
export async function createNotification(payload) {
const now = nowIso();
const insert = await getPool().query(`INSERT INTO notifications (
id,
transaction_id,
device_id,
delivery_status,
retry_count,
ack_status,
event_id,
reason,
payload_json,
created_at,
updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (transaction_id, event_id) DO UPDATE
SET updated_at = EXCLUDED.updated_at
RETURNING *`, [
randomUUID(),
payload.transaction_id,
payload.device_id,
payload.delivery_status,
0,
payload.ack_status || "not_needed",
payload.event_id,
payload.reason || null,
payload.payload_json || {},
now,
now
]);
if (insert.rowCount && insert.rowCount > 0) {
return mapNotification(insert.rows[0]);
}
const { rows } = await getPool().query("SELECT * FROM notifications WHERE transaction_id = $1 AND event_id = $2", [payload.transaction_id, payload.event_id]);
return mapNotification(rows[0]);
}
export async function getNotificationById(notificationId) {
const { rows } = await getPool().query("SELECT * FROM notifications WHERE id = $1", [notificationId]);
return rows[0] ? cloneNotification(mapNotification(rows[0])) : null;
}
export async function updateNotification(notificationId, patch) {
const existing = await getNotificationById(notificationId);
if (!existing) {
throw new Error("NOTIFICATION_NOT_FOUND");
}
const next = {
...existing,
...patch,
id: existing.id,
transaction_id: existing.transaction_id,
device_id: existing.device_id,
delivery_channel: existing.delivery_channel,
payload_type: existing.payload_type,
event_id: existing.event_id,
payload_json: existing.payload_json,
created_at: existing.created_at,
updated_at: nowIso()
};
const { rows } = await getPool().query(`UPDATE notifications
SET delivery_status = $2,
retry_count = $3,
ack_status = $4,
device_id = COALESCE($5, device_id),
reason = $6,
sent_at = $7,
ack_at = $8,
next_retry_at = $9,
updated_at = $10
WHERE id = $1
RETURNING *`, [
notificationId,
next.delivery_status,
next.retry_count,
next.ack_status,
next.device_id ?? null,
next.reason || null,
next.sent_at || null,
next.ack_at || null,
next.next_retry_at || null,
next.updated_at
]);
return cloneNotification(mapNotification(rows[0]));
}
export async function getNotificationByTransactionId(transactionId) {
const { rows } = await getPool().query(`SELECT * FROM notifications
WHERE transaction_id = $1
ORDER BY created_at DESC
LIMIT 1`, [transactionId]);
return rows[0] ? mapNotification(rows[0]) : null;
}
export async function getNotificationByTransactionAndEvent(transactionId, eventId) {
const { rows } = await getPool().query("SELECT * FROM notifications WHERE transaction_id = $1 AND event_id = $2", [transactionId, eventId]);
return rows[0] ? mapNotification(rows[0]) : null;
}
export async function listNotificationsByDevice(deviceId) {
const { rows } = await getPool().query("SELECT * FROM notifications WHERE device_id = $1 ORDER BY created_at DESC", [deviceId]);
return rows.map(mapNotification);
}
export async function listNotifications(filter) {
const filters = [];
const params = [];
if (filter?.transaction_id) {
params.push(filter.transaction_id);
filters.push(`transaction_id = $${params.length}`);
}
if (filter?.device_id) {
params.push(filter.device_id);
filters.push(`device_id = $${params.length}`);
}
if (filter?.delivery_status) {
params.push(filter.delivery_status);
filters.push(`delivery_status = $${params.length}`);
}
const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
const { rows } = await getPool().query(`SELECT * FROM notifications ${where} ORDER BY created_at DESC`, params);
return rows.map(mapNotification);
}
export function toNotificationPayload(notification) {
return cloneNotification(notification);
}

199
dist/shared/store/transactionStore.js vendored Normal file
View File

@ -0,0 +1,199 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function makeCode(id) {
return `tx_${id.slice(0, 8)}`;
}
function mapTransaction(row) {
return {
id: row.id,
transaction_code: row.transaction_code,
merchant_id: row.merchant_id,
outlet_id: row.outlet_id,
terminal_id: row.terminal_id,
device_id: row.device_id || undefined,
qr_mode: row.qr_mode,
initiation_mode: row.initiation_mode,
partner_reference: row.partner_reference,
amount: Number(row.amount),
currency: row.currency,
status: row.status,
created_at: row.created_at,
paid_at: row.paid_at || undefined,
expired_at: row.expired_at || undefined,
updated_at: row.updated_at
};
}
function mapEvent(row) {
return {
id: row.id,
transaction_id: row.transaction_id,
event_type: row.event_type,
source: row.source,
payload_json: row.payload_json || {},
created_at: row.created_at
};
}
const TRANSACTION_STATE_TRANSITIONS = {
initiated: ["initiated", "awaiting_payment", "paid", "failed", "expired", "reversed"],
awaiting_payment: ["awaiting_payment", "paid", "failed", "expired", "reversed"],
paid: ["paid", "reversed"],
failed: ["failed", "reversed"],
expired: ["expired", "reversed"],
reversed: ["reversed"]
};
function isValidTransactionTransition(from, to) {
return TRANSACTION_STATE_TRANSITIONS[from]?.includes(to) ?? false;
}
export async function createTransaction(payload) {
const id = randomUUID();
const now = nowIso();
const entity = {
id,
transaction_code: makeCode(id),
merchant_id: payload.merchant_id,
outlet_id: payload.outlet_id,
terminal_id: payload.terminal_id,
device_id: payload.device_id,
qr_mode: payload.qr_mode || "static",
initiation_mode: payload.initiation_mode || "static",
partner_reference: payload.partner_reference,
amount: payload.amount,
currency: payload.currency || "IDR",
status: payload.status || "initiated",
created_at: now,
paid_at: payload.paid_at,
expired_at: payload.expired_at,
updated_at: now
};
const txResult = await getPool().query(`INSERT INTO transactions (
id,
transaction_code,
merchant_id,
outlet_id,
terminal_id,
device_id,
qr_mode,
initiation_mode,
partner_reference,
amount,
currency,
status,
created_at,
paid_at,
expired_at,
updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING *`, [
entity.id,
entity.transaction_code,
entity.merchant_id,
entity.outlet_id,
entity.terminal_id,
entity.device_id || null,
entity.qr_mode,
entity.initiation_mode,
entity.partner_reference,
entity.amount,
entity.currency,
entity.status,
entity.created_at,
entity.paid_at || null,
entity.expired_at || null,
entity.updated_at
]);
await addTransactionEvent({
transaction_id: txResult.rows[0].id,
event_type: "INITIATED",
source: "system",
payload_json: { status: txResult.rows[0].status, partner_reference: payload.partner_reference }
});
return mapTransaction(txResult.rows[0]);
}
export async function addTransactionEvent(payload) {
const id = randomUUID();
const { rows } = await getPool().query(`INSERT INTO transaction_events (id, transaction_id, event_type, source, payload_json, created_at)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING *`, [id, payload.transaction_id, payload.event_type, payload.source, payload.payload_json || {}, nowIso()]);
return mapEvent(rows[0]);
}
export async function updateTransactionStatus(id, to, options) {
const entity = await getTransactionById(id);
if (!entity) {
throw new Error("TRANSACTION_NOT_FOUND");
}
if (!isValidTransactionTransition(entity.status, to)) {
throw new Error(`INVALID_TRANSACTION_STATE_TRANSITION:${entity.status}->${to}`);
}
if (entity.status === to) {
return entity;
}
const now = nowIso();
const next = {
...entity,
status: to,
paid_at: options.paid_at || entity.paid_at,
expired_at: options.expired_at || entity.expired_at,
updated_at: now
};
if (to === "paid" && !next.paid_at) {
next.paid_at = now;
}
if (to === "expired" && !next.expired_at) {
next.expired_at = now;
}
const { rows } = await getPool().query(`UPDATE transactions
SET status = $2,
paid_at = $3,
expired_at = $4,
updated_at = $5
WHERE id = $1
RETURNING *`, [id, next.status, next.paid_at || null, next.expired_at || null, next.updated_at]);
await addTransactionEvent({
transaction_id: id,
event_type: "STATE_CHANGED",
source: options.source,
payload_json: {
from: entity.status,
to,
...options.eventContext
}
});
return mapTransaction(rows[0]);
}
export async function getTransactionById(id) {
const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]);
return rows[0] ? mapTransaction(rows[0]) : null;
}
export async function findTransactionByPartnerReference(partnerReference) {
const { rows } = await getPool().query("SELECT * FROM transactions WHERE partner_reference = $1", [partnerReference]);
return rows[0] ? mapTransaction(rows[0]) : null;
}
export async function listTransactions(filter) {
if (filter?.status && filter?.merchant_id) {
const { rows } = await getPool().query("SELECT * FROM transactions WHERE status = $1 AND merchant_id = $2 ORDER BY created_at DESC", [filter.status, filter.merchant_id]);
return rows.map(mapTransaction);
}
if (filter?.status) {
const { rows } = await getPool().query("SELECT * FROM transactions WHERE status = $1 ORDER BY created_at DESC", [filter.status]);
return rows.map(mapTransaction);
}
if (filter?.merchant_id) {
const { rows } = await getPool().query("SELECT * FROM transactions WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id]);
return rows.map(mapTransaction);
}
const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC");
return rows.map(mapTransaction);
}
export async function getTransactionEvents(transactionId) {
const { rows } = await getPool().query("SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", [transactionId]);
return rows.map(mapEvent);
}
export function toTransactionPayload(transaction) {
return { ...transaction };
}
export function toTransactionEventPayload(event) {
return { ...event, payload_json: { ...event.payload_json } };
}