Initial commit
This commit is contained in:
84
src/app.ts
Normal file
84
src/app.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import morgan from "morgan";
|
||||
import { env } from "./config/env";
|
||||
import { requestContext } from "./shared/middleware/requestContext";
|
||||
import { handleErrors, successResponse } from "./shared/middleware/errorMiddleware";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
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: string) {
|
||||
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.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) {
|
||||
return next();
|
||||
}
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
app.use("/admin", adminRoutes);
|
||||
app.use("/integrations", integrationRoutes);
|
||||
app.use("/device", deviceRoutes);
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response, next: NextFunction) => {
|
||||
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;
|
||||
19
src/config/env.ts
Normal file
19
src/config/env.ts
Normal file
@ -0,0 +1,19 @@
|
||||
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"
|
||||
};
|
||||
18
src/index.ts
Normal file
18
src/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
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();
|
||||
1591
src/routes/admin.ts
Normal file
1591
src/routes/admin.ts
Normal file
File diff suppressed because it is too large
Load Diff
168
src/routes/device.ts
Normal file
168
src/routes/device.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { Router, Request, Response, NextFunction } 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();
|
||||
|
||||
type HeartbeatInput = {
|
||||
device_id?: string;
|
||||
timestamp?: string;
|
||||
firmware_version?: string;
|
||||
network_strength?: unknown;
|
||||
battery_level?: unknown;
|
||||
state?: string;
|
||||
};
|
||||
|
||||
type CommandAckInput = {
|
||||
device_id?: string;
|
||||
command_id?: string;
|
||||
status?: "delivered" | "failed" | "timeout";
|
||||
reason?: string;
|
||||
result_payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function normalizeNumberOrNull(value: unknown): number | null {
|
||||
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: unknown): number | null {
|
||||
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: unknown): number | null {
|
||||
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: Request, res: Response, next: NextFunction) => {
|
||||
const payload = req.body as HeartbeatInput;
|
||||
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: number | null;
|
||||
let payloadBattery: number | null;
|
||||
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 as 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 as unknown as { requestId: string }).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: Request, res: Response, next: NextFunction) => {
|
||||
const payload = req.body as CommandAckInput;
|
||||
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;
|
||||
374
src/routes/integrations.ts
Normal file
374
src/routes/integrations.ts
Normal file
@ -0,0 +1,374 @@
|
||||
import { Router, Request, Response, NextFunction } 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();
|
||||
|
||||
type CallbackPayload = {
|
||||
partner_reference?: string;
|
||||
partner_txn_id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
status?: string;
|
||||
payment_status?: string;
|
||||
paid_at?: string;
|
||||
merchant_id?: string;
|
||||
terminal_id?: string;
|
||||
signature?: string;
|
||||
};
|
||||
|
||||
type PaymentStatus = "paid" | "failed" | "expired";
|
||||
type CallbackStatus = {
|
||||
status: PaymentStatus;
|
||||
reason?: "UNKNOWN_STATUS";
|
||||
};
|
||||
|
||||
type CallbackParsed = {
|
||||
partnerReference: string;
|
||||
partnerTxnId?: string;
|
||||
status: CallbackStatus;
|
||||
amount: number;
|
||||
paidAt?: string;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
type CallbackResponse = {
|
||||
status: "accepted";
|
||||
event_id: string;
|
||||
transaction_id: string | null;
|
||||
request_id: string;
|
||||
timestamp: string;
|
||||
note?: string;
|
||||
reason?: "AMOUNT_MISMATCH" | "TRANSACTION_NOT_FOUND" | "UNKNOWN_STATUS" | "STATE_TRANSITION_REJECTED";
|
||||
};
|
||||
|
||||
type CallbackStoredEntry = {
|
||||
response: CallbackResponse;
|
||||
transaction_id?: string;
|
||||
};
|
||||
|
||||
function parsePaymentStatus(rawStatus: string | undefined): CallbackStatus {
|
||||
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: CallbackPayload, signature: string | undefined) {
|
||||
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: CallbackPayload): CallbackParsed {
|
||||
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: CallbackParsed = {
|
||||
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: CallbackParsed, headerValue: string | undefined) {
|
||||
if (headerValue) {
|
||||
return headerValue;
|
||||
}
|
||||
|
||||
return `${payload.partnerReference}:${payload.partnerTxnId || ""}:${payload.status.status}`;
|
||||
}
|
||||
|
||||
function buildCallbackResponse(
|
||||
req: Request,
|
||||
transactionId: string | null,
|
||||
eventId: string,
|
||||
note?: CallbackResponse["note"],
|
||||
reason?: CallbackResponse["reason"]
|
||||
): CallbackResponse {
|
||||
const response: CallbackResponse = {
|
||||
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: string, response: CallbackResponse, transactionId: string | null) {
|
||||
writeIdempotency(
|
||||
"callback.processing",
|
||||
idempotencyKey,
|
||||
{ response, transaction_id: transactionId },
|
||||
env.IDEMPOTENCY_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function makeResponseEventId(txId: string, fallbackTag: string) {
|
||||
const events = await getTransactionEvents(txId);
|
||||
return events.at(-1)?.id || `${fallbackTag}_${Date.now()}`;
|
||||
}
|
||||
|
||||
router.post("/qris/callback", async (req: Request, res: Response, next: NextFunction) => {
|
||||
const incoming = req.body as CallbackPayload;
|
||||
const signature = req.header("X-Partner-Signature");
|
||||
let parsed: CallbackParsed;
|
||||
|
||||
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 as CallbackStoredEntry;
|
||||
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;
|
||||
215
src/shared/db/pool.ts
Normal file
215
src/shared/db/pool.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
import { env } from "../../config/env";
|
||||
|
||||
type PoolConfigWithConnectionString = {
|
||||
connectionString: string;
|
||||
};
|
||||
|
||||
type PoolConfigWithCredentials = {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
};
|
||||
|
||||
let pool: Pool | null = null;
|
||||
|
||||
function buildPoolConfig(): PoolConfigWithConnectionString | PoolConfigWithCredentials {
|
||||
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(): Pool {
|
||||
if (!pool) {
|
||||
const config = buildPoolConfig();
|
||||
|
||||
pool = new Pool(config);
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function withClient<T>(work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
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;
|
||||
`;
|
||||
52
src/shared/errors/index.ts
Normal file
52
src/shared/errors/index.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export type ErrorCode =
|
||||
| "BAD_REQUEST"
|
||||
| "UNAUTHORIZED"
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "CONFLICT"
|
||||
| "INTERNAL_ERROR"
|
||||
| "DEVICE_UNAUTHORIZED"
|
||||
| "DUPLICATE_REQUEST"
|
||||
| "TRACE_MISSING"
|
||||
| "WEBHOOK_SIGNATURE_INVALID"
|
||||
| "TRANSACTION_NOT_FOUND"
|
||||
| "PAYMENT_STATUS_INVALID"
|
||||
| "DUPLICATE_WEBHOOK"
|
||||
| "CALLBACK_PARTNER_DATA_INVALID"
|
||||
| "IDEMPOTENCY_MISSING_KEY"
|
||||
| "NOTIFICATION_DEVICE_UNAVAILABLE"
|
||||
| "NOTIFICATION_NO_ACTIVE_BINDING"
|
||||
| "NOTIFICATION_PUBLISH_FAILED"
|
||||
| "NOTIFICATION_RETRY_EXHAUSTED";
|
||||
|
||||
export interface ApiErrorShape {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
request_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
statusCode: number;
|
||||
code: ErrorCode;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
constructor(code: ErrorCode, message: string, statusCode = 400, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorEnvelope(error: ApiError, requestId: string): ApiErrorShape {
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
request_id: requestId,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
87
src/shared/events/transactionEvents.ts
Normal file
87
src/shared/events/transactionEvents.ts
Normal file
@ -0,0 +1,87 @@
|
||||
export type TransactionPaidPayload = {
|
||||
transaction_id: string;
|
||||
merchant_id: string;
|
||||
outlet_id: string;
|
||||
terminal_id: string;
|
||||
device_id: string | null | undefined;
|
||||
amount: number;
|
||||
currency: string;
|
||||
partner_reference: string;
|
||||
paid_at?: string;
|
||||
};
|
||||
|
||||
type TransactionPaidInternalEvent = {
|
||||
id: string;
|
||||
event_type: "transaction.paid";
|
||||
transaction_id: string;
|
||||
payload_json: TransactionPaidPayload;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TransactionPaidEvent = TransactionPaidInternalEvent;
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const transactionPaidEvents = new Map<string, TransactionPaidInternalEvent[]>();
|
||||
const transactionPaidIndex = new Map<string, string>();
|
||||
const transactionPaidSubscribers = new Set<(event: TransactionPaidEvent) => void>();
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function cloneInternalEvent(event: TransactionPaidInternalEvent): TransactionPaidInternalEvent {
|
||||
return {
|
||||
...event,
|
||||
payload_json: { ...event.payload_json }
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeTransactionPaid(handler: (event: TransactionPaidEvent) => void) {
|
||||
transactionPaidSubscribers.add(handler);
|
||||
return () => transactionPaidSubscribers.delete(handler);
|
||||
}
|
||||
|
||||
export function emitTransactionPaid(payload: TransactionPaidPayload): TransactionPaidEvent {
|
||||
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: TransactionPaidInternalEvent = {
|
||||
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?: string): TransactionPaidEvent[] {
|
||||
if (transactionId) {
|
||||
return (transactionPaidEvents.get(transactionId) || []).map(cloneInternalEvent);
|
||||
}
|
||||
|
||||
return Array.from(transactionPaidEvents.values()).flatMap((bucket) => bucket.map(cloneInternalEvent));
|
||||
}
|
||||
|
||||
export function getTransactionPaidEventByTransactionId(transactionId: string): TransactionPaidEvent | null {
|
||||
const events = transactionPaidEvents.get(transactionId) || [];
|
||||
return events.length > 0 ? cloneInternalEvent(events[events.length - 1]) : null;
|
||||
}
|
||||
39
src/shared/idempotency/idempotencyStore.ts
Normal file
39
src/shared/idempotency/idempotencyStore.ts
Normal file
@ -0,0 +1,39 @@
|
||||
interface IdempotentEntry {
|
||||
key: string;
|
||||
scope: string;
|
||||
value: unknown;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, IdempotentEntry>();
|
||||
|
||||
export function makeIdempotencyKey(scope: string, key: string): string {
|
||||
return `${scope}:${key}`;
|
||||
}
|
||||
|
||||
export function readIdempotency(scope: string, key: string): unknown | null {
|
||||
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: string, key: string, value: unknown, ttlMs: number): void {
|
||||
store.set(makeIdempotencyKey(scope, key), {
|
||||
key,
|
||||
scope,
|
||||
value,
|
||||
expiresAt: Date.now() + ttlMs
|
||||
});
|
||||
}
|
||||
|
||||
export function clearIdempotency(scope: string, key: string): void {
|
||||
store.delete(makeIdempotencyKey(scope, key));
|
||||
}
|
||||
40
src/shared/middleware/auth.ts
Normal file
40
src/shared/middleware/auth.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { ApiError } from "../errors";
|
||||
import { env } from "../../config/env";
|
||||
|
||||
function extractAdminToken(req: Request) {
|
||||
const raw = req.header("authorization") || "";
|
||||
if (raw.startsWith("Bearer ")) {
|
||||
return raw.slice(7);
|
||||
}
|
||||
|
||||
return raw || req.header("x-admin-token") || "";
|
||||
}
|
||||
|
||||
export function requireAdminToken(req: Request, _res: Response, next: NextFunction) {
|
||||
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: Request, _res: Response, next: NextFunction) {
|
||||
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();
|
||||
}
|
||||
37
src/shared/middleware/errorMiddleware.ts
Normal file
37
src/shared/middleware/errorMiddleware.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { ApiError, errorEnvelope } from "../errors";
|
||||
|
||||
export interface EnvelopeSuccess<T> {
|
||||
data: T;
|
||||
request_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function successResponse<T>(req: Request, data: T): EnvelopeSuccess<T> {
|
||||
return {
|
||||
data,
|
||||
request_id: req.requestId,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function handleErrors(err: Error, req: Request, res: Response, _next: NextFunction) {
|
||||
if (err instanceof ApiError) {
|
||||
res.status(err.statusCode).json(
|
||||
errorEnvelope(
|
||||
{
|
||||
...err
|
||||
} as ApiError,
|
||||
req.requestId
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
code: "INTERNAL_ERROR",
|
||||
message: err.message || "Unexpected server error",
|
||||
request_id: req.requestId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
78
src/shared/middleware/idempotency.ts
Normal file
78
src/shared/middleware/idempotency.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { ApiError } from "../errors";
|
||||
import { readIdempotency, writeIdempotency } from "../idempotency/idempotencyStore";
|
||||
import { env } from "../../config/env";
|
||||
import { successResponse } from "./errorMiddleware";
|
||||
|
||||
interface IdempotencyOptions {
|
||||
scope: string;
|
||||
ttlMs?: number;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export function idempotency(options: IdempotencyOptions) {
|
||||
return function idempotencyMiddleware(req: Request, _res: Response, next: NextFunction) {
|
||||
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 as { response?: unknown; statusCode?: number; payload?: unknown }).response ?? cached;
|
||||
const cachedStatus = (cached as { statusCode?: number }).statusCode || 200;
|
||||
const payload = (() => {
|
||||
if (
|
||||
cachedPayload &&
|
||||
typeof cachedPayload === "object" &&
|
||||
"data" in cachedPayload &&
|
||||
"request_id" in cachedPayload &&
|
||||
"timestamp" in cachedPayload
|
||||
) {
|
||||
const typed = cachedPayload as Record<string, unknown> & { request_id?: string; timestamp?: string };
|
||||
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 } as Request["body"];
|
||||
|
||||
const originalJson = _res.json.bind(_res);
|
||||
const originalStatus = _res.status.bind(_res);
|
||||
let statusCode = 200;
|
||||
_res.status = function statusWithStore(code: number) {
|
||||
statusCode = code;
|
||||
return originalStatus(code);
|
||||
};
|
||||
_res.json = function jsonWithStore(payload: unknown) {
|
||||
const responsePayload = payload &&
|
||||
typeof payload === "object" &&
|
||||
"data" in payload &&
|
||||
"request_id" in payload &&
|
||||
"timestamp" in payload
|
||||
? successResponse(req, (payload as { data: unknown }).data)
|
||||
: payload;
|
||||
writeIdempotency(
|
||||
options.scope,
|
||||
idempotencyKey,
|
||||
{
|
||||
response: responsePayload,
|
||||
statusCode,
|
||||
at: Date.now()
|
||||
},
|
||||
options.ttlMs || env.IDEMPOTENCY_TTL_MS
|
||||
);
|
||||
return originalJson(responsePayload);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
25
src/shared/middleware/requestContext.ts
Normal file
25
src/shared/middleware/requestContext.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { env } from "../../config/env";
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
requestId: string;
|
||||
traceId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export function requestContext(req: Request, _res: Response, next: NextFunction) {
|
||||
const requestId =
|
||||
(req.header(env.TRACE_HEADER) as string | undefined) ||
|
||||
req.header("x-trace-id") ||
|
||||
randomUUID();
|
||||
|
||||
const traceId =
|
||||
(req.header("x-trace-id") as string | undefined) || requestId;
|
||||
|
||||
req.requestId = requestId;
|
||||
req.traceId = traceId;
|
||||
|
||||
next();
|
||||
}
|
||||
340
src/shared/orchestrators/notificationOrchestrator.ts
Normal file
340
src/shared/orchestrators/notificationOrchestrator.ts
Normal file
@ -0,0 +1,340 @@
|
||||
import { getActiveBindingByDevice, getActiveBindingByTerminal } from "../store/bindingStore";
|
||||
import {
|
||||
NotificationDeliveryStatus,
|
||||
NotificationEntity,
|
||||
createNotification,
|
||||
getNotificationByTransactionAndEvent,
|
||||
getNotificationByTransactionId,
|
||||
listNotifications,
|
||||
toNotificationPayload,
|
||||
updateNotification
|
||||
} from "../store/notificationStore";
|
||||
import { getMerchantById } from "../store/merchantStore";
|
||||
import { getTransactionById, listTransactions, toTransactionPayload, TransactionEntity } from "../store/transactionStore";
|
||||
import { buildPaymentSuccessPayload, publishPaymentSuccess, MqttPublishResult } from "../services/mqttPublisher";
|
||||
import type { TransactionPaidEvent, TransactionPaidPayload } from "../events/transactionEvents";
|
||||
import { subscribeTransactionPaid } from "../events/transactionEvents";
|
||||
import { env } from "../../config/env";
|
||||
|
||||
type ResolveDeviceResult = {
|
||||
deviceId: string;
|
||||
source: "tx_device" | "binding_device";
|
||||
} | null;
|
||||
|
||||
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: TransactionPaidPayload): Promise<ResolveDeviceResult> {
|
||||
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: TransactionPaidPayload, deviceId: string) {
|
||||
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: ResolveDeviceResult | null): NotificationDeliveryStatus {
|
||||
return bindingResolved ? "queued" : "failed";
|
||||
}
|
||||
|
||||
function resolveFailureReason(bindingResolved: ResolveDeviceResult | null) {
|
||||
if (!bindingResolved) {
|
||||
return "NOTIFICATION_NO_ACTIVE_BINDING";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function makeNextRetryDate(retryCount: number) {
|
||||
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: string): Promise<string> {
|
||||
const merchant = await getMerchantById(merchantId);
|
||||
return merchant?.brand_name || merchant?.legal_name || merchantId;
|
||||
}
|
||||
|
||||
function mapMqttFailureState(
|
||||
retryCount: number,
|
||||
reason?: string
|
||||
): { status: NotificationDeliveryStatus; retry_count: number; next_retry_at?: string; reason?: string } {
|
||||
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: NotificationEntity, publishResult: MqttPublishResult) {
|
||||
await updateNotification(notification.id, {
|
||||
delivery_status: "sent",
|
||||
retry_count: notification.retry_count,
|
||||
ack_status: "not_supported",
|
||||
sent_at: publishResult.publishedAt
|
||||
});
|
||||
}
|
||||
|
||||
async function markNotificationFailed(notification: NotificationEntity, publishResult: MqttPublishResult) {
|
||||
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: NotificationEntity) {
|
||||
await updateNotification(notification.id, {
|
||||
delivery_status: "failed",
|
||||
retry_count: 0,
|
||||
reason: "NOTIFICATION_NO_ACTIVE_BINDING",
|
||||
ack_status: "not_needed"
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveNotificationFromTransaction(notification: NotificationEntity): Promise<TransactionPaidPayload | null> {
|
||||
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: NotificationEntity, paidAt?: string) {
|
||||
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: NotificationEntity, eventPayload: TransactionPaidPayload | null) {
|
||||
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: TransactionPaidEvent) {
|
||||
const payload: TransactionPaidPayload = 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: TransactionEntity) {
|
||||
const existing = await getNotificationByTransactionId(transaction.id);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TransactionPaidPayload = {
|
||||
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: string): Promise<NotificationEntity | null> {
|
||||
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);
|
||||
}
|
||||
99
src/shared/services/mqttPublisher.ts
Normal file
99
src/shared/services/mqttPublisher.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { env } from "../../config/env";
|
||||
|
||||
type PaymentSuccessPayload = {
|
||||
message_type: "payment_success";
|
||||
device_id: string;
|
||||
event_id: string;
|
||||
transaction_id: string;
|
||||
merchant_id: string;
|
||||
merchant_name: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paid_at?: string;
|
||||
partner_reference?: string;
|
||||
audio_text: string;
|
||||
display_text: string;
|
||||
};
|
||||
|
||||
export type MqttPublishResult = {
|
||||
ok: boolean;
|
||||
topic: string;
|
||||
qos: 1;
|
||||
retained: false;
|
||||
publishedAt: string;
|
||||
reason?: string;
|
||||
payload: PaymentSuccessPayload;
|
||||
};
|
||||
|
||||
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: string): boolean {
|
||||
return forcedFailAll || forcedFailDevices.has(deviceId);
|
||||
}
|
||||
|
||||
export function buildPaymentSuccessPayload(
|
||||
input: {
|
||||
transaction_id: string;
|
||||
merchant_id: string;
|
||||
merchant_name: string;
|
||||
device_id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paid_at?: string;
|
||||
partner_reference?: string;
|
||||
event_id: string;
|
||||
}
|
||||
): PaymentSuccessPayload {
|
||||
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: string) {
|
||||
return `devices/${deviceId}/downlink/payment/success`;
|
||||
}
|
||||
|
||||
export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Promise<MqttPublishResult> {
|
||||
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
|
||||
};
|
||||
}
|
||||
153
src/shared/store/bindingStore.ts
Normal file
153
src/shared/store/bindingStore.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool, withClient } from "../db/pool";
|
||||
|
||||
export interface DeviceBindingEntity {
|
||||
id: string;
|
||||
device_id: string;
|
||||
merchant_id: string;
|
||||
outlet_id: string;
|
||||
terminal_id: string;
|
||||
active_flag: boolean;
|
||||
bound_at: string;
|
||||
unbound_at?: string;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function mapBinding(row: any): DeviceBindingEntity {
|
||||
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: string): Promise<DeviceBindingEntity | null> {
|
||||
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: string): Promise<DeviceBindingEntity | null> {
|
||||
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: string): Promise<DeviceBindingEntity[]> {
|
||||
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: {
|
||||
device_id: string;
|
||||
merchant_id: string;
|
||||
outlet_id: string;
|
||||
terminal_id: string;
|
||||
}): Promise<DeviceBindingEntity> {
|
||||
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: string): Promise<DeviceBindingEntity | null> {
|
||||
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: string): Promise<DeviceBindingEntity | null> {
|
||||
const { rows } = await getPool().query("SELECT * FROM device_bindings WHERE id = $1", [id]);
|
||||
return rows[0] ? mapBinding(rows[0]) : null;
|
||||
}
|
||||
|
||||
export function toBindingPayload(binding: DeviceBindingEntity) {
|
||||
return { ...binding };
|
||||
}
|
||||
147
src/shared/store/deviceCommandStore.ts
Normal file
147
src/shared/store/deviceCommandStore.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export type DeviceCommandStatus = "accepted" | "delivered" | "failed" | "timeout";
|
||||
|
||||
export interface DeviceCommandEntity {
|
||||
id: string;
|
||||
device_id: string;
|
||||
command: string;
|
||||
payload: Record<string, unknown>;
|
||||
status: DeviceCommandStatus;
|
||||
requested_at: string;
|
||||
acknowledged_at: string | null;
|
||||
result_payload: Record<string, unknown> | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeStatus(status: DeviceCommandStatus): DeviceCommandStatus {
|
||||
if (status === "accepted" || status === "delivered" || status === "failed" || status === "timeout") {
|
||||
return status;
|
||||
}
|
||||
return "accepted";
|
||||
}
|
||||
|
||||
function mapCommand(row: any): DeviceCommandEntity {
|
||||
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: {
|
||||
device_id: string;
|
||||
command: string;
|
||||
payload?: Record<string, unknown>;
|
||||
status?: DeviceCommandStatus;
|
||||
}) {
|
||||
const entity: DeviceCommandEntity = {
|
||||
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: string): Promise<DeviceCommandEntity[]> {
|
||||
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: string,
|
||||
commandId: string
|
||||
): Promise<DeviceCommandEntity | null> {
|
||||
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: DeviceCommandEntity) {
|
||||
return mapCommand(command);
|
||||
}
|
||||
|
||||
export function toDeviceCommandPayloadBrief(command: DeviceCommandEntity) {
|
||||
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: {
|
||||
device_id: string;
|
||||
command_id: string;
|
||||
status: "delivered" | "failed" | "timeout";
|
||||
result_payload?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
152
src/shared/store/deviceStore.ts
Normal file
152
src/shared/store/deviceStore.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export interface DeviceEntity {
|
||||
id: string;
|
||||
device_code: string;
|
||||
serial_number?: string;
|
||||
vendor?: string;
|
||||
model?: string;
|
||||
communication_mode?: "static" | "mqtt" | "api";
|
||||
capability_profile_json?: Record<string, unknown>;
|
||||
auth_method?: string;
|
||||
status: "active" | "inactive";
|
||||
last_seen_at?: string;
|
||||
firmware_version?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeCode(id: string) {
|
||||
return `d_${id.slice(0, 6)}`;
|
||||
}
|
||||
|
||||
function mapDevice(row: any): DeviceEntity {
|
||||
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: {
|
||||
device_code?: string;
|
||||
serial_number?: string;
|
||||
vendor?: string;
|
||||
model?: string;
|
||||
communication_mode?: DeviceEntity["communication_mode"];
|
||||
capability_profile_json?: Record<string, unknown>;
|
||||
auth_method?: string;
|
||||
status?: DeviceEntity["status"];
|
||||
firmware_version?: string;
|
||||
last_seen_at?: string;
|
||||
}): Promise<DeviceEntity> {
|
||||
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(): Promise<DeviceEntity[]> {
|
||||
const { rows } = await getPool().query("SELECT * FROM devices ORDER BY created_at DESC");
|
||||
return rows.map(mapDevice);
|
||||
}
|
||||
|
||||
export async function getDeviceById(id: string): Promise<DeviceEntity | null> {
|
||||
const { rows } = await getPool().query("SELECT * FROM devices WHERE id = $1", [id]);
|
||||
return rows[0] ? mapDevice(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Promise<DeviceEntity> {
|
||||
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: DeviceEntity) {
|
||||
return { ...device };
|
||||
}
|
||||
173
src/shared/store/heartbeatStore.ts
Normal file
173
src/shared/store/heartbeatStore.ts
Normal file
@ -0,0 +1,173 @@
|
||||
export type DeviceHealthStatus = "online" | "offline" | "degraded" | "stale";
|
||||
|
||||
export interface DeviceHeartbeatEntity {
|
||||
id: string;
|
||||
device_id: string;
|
||||
timestamp: string;
|
||||
received_at: string;
|
||||
firmware_version?: string;
|
||||
network_strength?: number | null;
|
||||
battery_level?: number | null;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
type CreateDeviceHeartbeatPayload = {
|
||||
device_id: string;
|
||||
timestamp: string;
|
||||
firmware_version?: string;
|
||||
network_strength?: number | null;
|
||||
battery_level?: number | null;
|
||||
state?: string;
|
||||
payload_json?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function mapHeartbeat(row: any): DeviceHeartbeatEntity {
|
||||
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: CreateDeviceHeartbeatPayload): Promise<DeviceHeartbeatEntity> {
|
||||
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: string): Promise<DeviceHeartbeatEntity | null> {
|
||||
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: string, hours = 24): Promise<number> {
|
||||
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?: {
|
||||
device_id?: string;
|
||||
state?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
}): Promise<DeviceHeartbeatEntity[]> {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
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?: {
|
||||
last_seen_at?: string | null;
|
||||
network_strength?: number | null;
|
||||
battery_level?: number | null;
|
||||
}
|
||||
): DeviceHealthStatus {
|
||||
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";
|
||||
}
|
||||
264
src/shared/store/locationStore.ts
Normal file
264
src/shared/store/locationStore.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool, withClient } from "../db/pool";
|
||||
|
||||
export interface OutletEntity {
|
||||
id: string;
|
||||
merchant_id: string;
|
||||
outlet_code: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
status: "active" | "inactive";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TerminalEntity {
|
||||
id: string;
|
||||
outlet_id: string;
|
||||
terminal_code: string;
|
||||
qr_mode: "static" | "dynamic_mqtt" | "dynamic_api";
|
||||
partner_reference?: string;
|
||||
status: "active" | "inactive";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeCode(prefix: string, id: string) {
|
||||
return `${prefix}_${id.slice(0, 6)}`;
|
||||
}
|
||||
|
||||
function mapOutlet(row: any): OutletEntity {
|
||||
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: any): TerminalEntity {
|
||||
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: {
|
||||
merchant_id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
outlet_code?: string;
|
||||
status?: OutletEntity["status"];
|
||||
}): Promise<OutletEntity> {
|
||||
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: {
|
||||
outlet_id: string;
|
||||
terminal_code?: string;
|
||||
qr_mode?: TerminalEntity["qr_mode"];
|
||||
partner_reference?: string;
|
||||
status?: TerminalEntity["status"];
|
||||
}): Promise<TerminalEntity> {
|
||||
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?: {
|
||||
merchant_id?: string;
|
||||
status?: OutletEntity["status"];
|
||||
q?: string;
|
||||
}): Promise<OutletEntity[]> {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (filter?.merchant_id) {
|
||||
clauses.push(`merchant_id = $${i++}`);
|
||||
params.push(filter.merchant_id);
|
||||
}
|
||||
|
||||
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?: {
|
||||
outlet_id?: string;
|
||||
status?: TerminalEntity["status"];
|
||||
q?: string;
|
||||
}): Promise<TerminalEntity[]> {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (filter?.outlet_id) {
|
||||
clauses.push(`outlet_id = $${i++}`);
|
||||
params.push(filter.outlet_id);
|
||||
}
|
||||
|
||||
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: string): Promise<OutletEntity | null> {
|
||||
const { rows } = await getPool().query("SELECT * FROM outlets WHERE id = $1", [id]);
|
||||
return rows[0] ? mapOutlet(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getTerminalById(id: string): Promise<TerminalEntity | null> {
|
||||
const { rows } = await getPool().query("SELECT * FROM terminals WHERE id = $1", [id]);
|
||||
return rows[0] ? mapTerminal(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function patchOutlet(id: string, patch: Partial<OutletEntity>): Promise<OutletEntity> {
|
||||
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: string, patch: Partial<TerminalEntity>): Promise<TerminalEntity> {
|
||||
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: OutletEntity) {
|
||||
return { ...outlet };
|
||||
}
|
||||
|
||||
export function toTerminalPayload(terminal: TerminalEntity) {
|
||||
return { ...terminal };
|
||||
}
|
||||
177
src/shared/store/merchantStore.ts
Normal file
177
src/shared/store/merchantStore.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export type PayoutMode = "merchant_direct" | "manual";
|
||||
|
||||
export interface MerchantEntity {
|
||||
id: string;
|
||||
merchant_code: string;
|
||||
legal_name: string;
|
||||
brand_name?: string;
|
||||
settlement_account_reference?: string;
|
||||
settlement_account_type?: string;
|
||||
payout_mode: PayoutMode;
|
||||
fee_profile_id?: string;
|
||||
status: "active" | "inactive";
|
||||
onboarding_status: "pending" | "approved" | "rejected";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeCode(id: string) {
|
||||
return `m_${id.slice(0, 6)}`;
|
||||
}
|
||||
|
||||
function toPublic(entity: MerchantEntity) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
function mapRowToMerchant(row: any): MerchantEntity {
|
||||
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 as PayoutMode,
|
||||
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: {
|
||||
legal_name: string;
|
||||
brand_name?: string;
|
||||
settlement_account_reference?: string;
|
||||
settlement_account_type?: string;
|
||||
payout_mode?: PayoutMode;
|
||||
fee_profile_id?: string;
|
||||
status?: MerchantEntity["status"];
|
||||
onboarding_status?: MerchantEntity["onboarding_status"];
|
||||
}): Promise<MerchantEntity> {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
const payoutMode: 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: string): Promise<MerchantEntity | null> {
|
||||
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(): Promise<MerchantEntity[]> {
|
||||
const { rows } = await getPool().query("SELECT * FROM merchants ORDER BY created_at DESC");
|
||||
return rows.map(mapRowToMerchant);
|
||||
}
|
||||
|
||||
export async function patchMerchant(
|
||||
id: string,
|
||||
patch: {
|
||||
legal_name?: string;
|
||||
brand_name?: string;
|
||||
settlement_account_reference?: string;
|
||||
settlement_account_type?: string;
|
||||
payout_mode?: PayoutMode;
|
||||
fee_profile_id?: string;
|
||||
status?: MerchantEntity["status"];
|
||||
onboarding_status?: MerchantEntity["onboarding_status"];
|
||||
}
|
||||
): Promise<MerchantEntity> {
|
||||
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: MerchantEntity) {
|
||||
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
|
||||
};
|
||||
}
|
||||
247
src/shared/store/notificationStore.ts
Normal file
247
src/shared/store/notificationStore.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export type NotificationDeliveryStatus = "queued" | "sent" | "acknowledged" | "failed" | "retrying";
|
||||
export type NotificationAckStatus = "pending" | "received" | "not_supported" | "not_needed";
|
||||
|
||||
export interface NotificationEntity {
|
||||
id: string;
|
||||
transaction_id: string;
|
||||
device_id: string | null;
|
||||
delivery_channel: "mqtt";
|
||||
payload_type: "payment_success";
|
||||
delivery_status: NotificationDeliveryStatus;
|
||||
retry_count: number;
|
||||
ack_status: NotificationAckStatus;
|
||||
event_id: string;
|
||||
reason?: string;
|
||||
payload_json: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sent_at?: string;
|
||||
ack_at?: string;
|
||||
next_retry_at?: string;
|
||||
}
|
||||
|
||||
type CreateNotificationPayload = {
|
||||
transaction_id: string;
|
||||
device_id: string | null;
|
||||
event_id: string;
|
||||
delivery_status: NotificationDeliveryStatus;
|
||||
reason?: string;
|
||||
payload_json?: Record<string, unknown>;
|
||||
ack_status?: NotificationAckStatus;
|
||||
};
|
||||
|
||||
type UpdateNotificationPayload = {
|
||||
delivery_status?: NotificationDeliveryStatus;
|
||||
retry_count?: number;
|
||||
ack_status?: NotificationAckStatus;
|
||||
device_id?: string | null;
|
||||
reason?: string;
|
||||
sent_at?: string;
|
||||
ack_at?: string;
|
||||
next_retry_at?: string;
|
||||
};
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function cloneNotification(notification: NotificationEntity): NotificationEntity {
|
||||
return {
|
||||
...notification,
|
||||
payload_json: { ...notification.payload_json }
|
||||
};
|
||||
}
|
||||
|
||||
function mapNotification(row: any): NotificationEntity {
|
||||
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: CreateNotificationPayload): Promise<NotificationEntity> {
|
||||
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: string): Promise<NotificationEntity | null> {
|
||||
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: string,
|
||||
patch: UpdateNotificationPayload
|
||||
): Promise<NotificationEntity> {
|
||||
const existing = await getNotificationById(notificationId);
|
||||
if (!existing) {
|
||||
throw new Error("NOTIFICATION_NOT_FOUND");
|
||||
}
|
||||
|
||||
const next: NotificationEntity = {
|
||||
...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: string): Promise<NotificationEntity | null> {
|
||||
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: string,
|
||||
eventId: string
|
||||
): Promise<NotificationEntity | null> {
|
||||
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: string): Promise<NotificationEntity[]> {
|
||||
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?: {
|
||||
transaction_id?: string;
|
||||
device_id?: string;
|
||||
delivery_status?: NotificationDeliveryStatus;
|
||||
}): Promise<NotificationEntity[]> {
|
||||
const filters: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
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: NotificationEntity) {
|
||||
return cloneNotification(notification);
|
||||
}
|
||||
330
src/shared/store/transactionStore.ts
Normal file
330
src/shared/store/transactionStore.ts
Normal file
@ -0,0 +1,330 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export type TransactionStatus =
|
||||
| "initiated"
|
||||
| "awaiting_payment"
|
||||
| "paid"
|
||||
| "failed"
|
||||
| "expired"
|
||||
| "reversed";
|
||||
|
||||
export type TransactionEventType =
|
||||
| "INITIATED"
|
||||
| "STATE_CHANGED"
|
||||
| "CALLBACK_RECEIVED"
|
||||
| "CALLBACK_REJECTED"
|
||||
| "CALLBACK_DUPLICATE"
|
||||
| "PUSH_QUEUED";
|
||||
|
||||
export type TransactionEventSource = "webhook" | "system" | "admin";
|
||||
|
||||
export interface TransactionEntity {
|
||||
id: string;
|
||||
transaction_code: string;
|
||||
merchant_id: string;
|
||||
outlet_id: string;
|
||||
terminal_id: string;
|
||||
device_id?: string;
|
||||
qr_mode: "static" | "dynamic";
|
||||
initiation_mode: "static" | "manual" | "dynamic_api" | "dynamic_mqtt";
|
||||
partner_reference: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: TransactionStatus;
|
||||
created_at: string;
|
||||
paid_at?: string;
|
||||
expired_at?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TransactionEventEntity {
|
||||
id: string;
|
||||
transaction_id: string;
|
||||
event_type: TransactionEventType;
|
||||
source: TransactionEventSource;
|
||||
payload_json: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeCode(id: string) {
|
||||
return `tx_${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function mapTransaction(row: any): TransactionEntity {
|
||||
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: any): TransactionEventEntity {
|
||||
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: Record<TransactionStatus, TransactionStatus[]> = {
|
||||
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: TransactionStatus, to: TransactionStatus) {
|
||||
return TRANSACTION_STATE_TRANSITIONS[from]?.includes(to) ?? false;
|
||||
}
|
||||
|
||||
export async function createTransaction(payload: {
|
||||
merchant_id: string;
|
||||
outlet_id: string;
|
||||
terminal_id: string;
|
||||
device_id?: string;
|
||||
qr_mode?: "static" | "dynamic";
|
||||
initiation_mode?: "static" | "manual" | "dynamic_api" | "dynamic_mqtt";
|
||||
partner_reference: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
status?: TransactionStatus;
|
||||
paid_at?: string;
|
||||
expired_at?: string;
|
||||
}): Promise<TransactionEntity> {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
|
||||
const entity: TransactionEntity = {
|
||||
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: {
|
||||
transaction_id: string;
|
||||
event_type: TransactionEventType;
|
||||
source: TransactionEventSource;
|
||||
payload_json?: Record<string, unknown>;
|
||||
}): Promise<TransactionEventEntity> {
|
||||
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: string,
|
||||
to: TransactionStatus,
|
||||
options: {
|
||||
source: TransactionEventSource;
|
||||
eventContext?: Record<string, unknown>;
|
||||
paid_at?: string;
|
||||
expired_at?: string;
|
||||
}
|
||||
): Promise<TransactionEntity> {
|
||||
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: TransactionEntity = {
|
||||
...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: string): Promise<TransactionEntity | null> {
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]);
|
||||
return rows[0] ? mapTransaction(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function findTransactionByPartnerReference(partnerReference: string): Promise<TransactionEntity | null> {
|
||||
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?: {
|
||||
status?: TransactionStatus;
|
||||
merchant_id?: string;
|
||||
}): Promise<TransactionEntity[]> {
|
||||
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: string): Promise<TransactionEventEntity[]> {
|
||||
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: TransactionEntity) {
|
||||
return { ...transaction };
|
||||
}
|
||||
|
||||
export function toTransactionEventPayload(event: TransactionEventEntity) {
|
||||
return { ...event, payload_json: { ...event.payload_json } };
|
||||
}
|
||||
Reference in New Issue
Block a user