Production readiness hardening and ops tooling
This commit is contained in:
84
src/app.ts
84
src/app.ts
@ -1,22 +1,45 @@
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import morgan from "morgan";
|
||||
import { env } from "./config/env";
|
||||
import { requestContext } from "./shared/middleware/requestContext";
|
||||
import { requestLogging } from "./shared/middleware/requestLogging";
|
||||
import { rateLimit } from "./shared/middleware/rateLimit";
|
||||
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 merchantRoutes from "./routes/merchant";
|
||||
import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator";
|
||||
import { startDynamicQrExpiryScheduler } from "./shared/services/dynamicQrExpiryScheduler";
|
||||
import { startExportJobWorker } from "./shared/services/exportJobWorker";
|
||||
import { startMqttSubscriber } from "./shared/services/mqttSubscriber";
|
||||
import { getDatabaseHealth } from "./shared/services/health";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const app = express();
|
||||
if (env.TRUST_PROXY === "true") {
|
||||
app.set("trust proxy", 1);
|
||||
}
|
||||
startNotificationOrchestrator();
|
||||
startDynamicQrExpiryScheduler();
|
||||
startExportJobWorker();
|
||||
startMqttSubscriber();
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
hidePoweredBy: true,
|
||||
referrerPolicy: {
|
||||
policy: "no-referrer"
|
||||
},
|
||||
hsts:
|
||||
process.env.NODE_ENV === "production"
|
||||
? {
|
||||
maxAge: 15552000,
|
||||
includeSubDomains: true
|
||||
}
|
||||
: false,
|
||||
crossOriginResourcePolicy: {
|
||||
policy: "cross-origin"
|
||||
},
|
||||
@ -35,14 +58,40 @@ app.use(
|
||||
}
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(morgan("dev"));
|
||||
app.use(express.json({ limit: env.JSON_BODY_LIMIT }));
|
||||
app.use(requestContext);
|
||||
app.use(requestLogging);
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
name: "login",
|
||||
windowMs: env.RATE_LIMIT_LOGIN_WINDOW_MS,
|
||||
max: env.RATE_LIMIT_LOGIN_MAX
|
||||
});
|
||||
const deviceLimiter = rateLimit({
|
||||
name: "device",
|
||||
windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS,
|
||||
max: env.RATE_LIMIT_DEVICE_MAX,
|
||||
key: (req) => `${req.ip}:${req.header("x-device-id") || "legacy"}`
|
||||
});
|
||||
const adminWriteLimiter = rateLimit({
|
||||
name: "admin_write",
|
||||
windowMs: env.RATE_LIMIT_ADMIN_WRITE_WINDOW_MS,
|
||||
max: env.RATE_LIMIT_ADMIN_WRITE_MAX,
|
||||
key: (req) => `${req.ip}:${req.header("authorization") || "anonymous"}`
|
||||
});
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.json(successResponse(_req, { status: "ok" }));
|
||||
});
|
||||
|
||||
app.get("/favicon.ico", (_req, res) => {
|
||||
res
|
||||
.type("image/svg+xml")
|
||||
.send(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#004ac6"/><path fill="#fff" d="M16 17h12v12H16V17Zm4 4v4h4v-4h-4Zm16-4h12v12H36V17Zm4 4v4h4v-4h-4ZM16 35h12v12H16V35Zm4 4v4h4v-4h-4Zm18-4h4v4h-4v-4Zm6 0h4v10h-4V35Zm-10 6h8v4h-8v-4Zm0 8h4v4h-4v-4Zm8 0h10v4H42v-4Z"/></svg>`
|
||||
);
|
||||
});
|
||||
|
||||
function resolveUiPageFile(slug: string) {
|
||||
const workspaceRoot = process.cwd();
|
||||
const candidates = [
|
||||
@ -74,7 +123,22 @@ app.get("/ui/:page", (req, res, next) => {
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
app.use("/admin/login", loginLimiter);
|
||||
app.use("/merchant/login", loginLimiter);
|
||||
app.use("/admin", (req, res, next) => {
|
||||
if (req.path === "/login") {
|
||||
return next();
|
||||
}
|
||||
if (["POST", "PATCH", "PUT", "DELETE"].includes(req.method)) {
|
||||
return adminWriteLimiter(req, res, next);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
app.use("/device", deviceLimiter);
|
||||
app.use("/integrations", rateLimit({ name: "integrations", windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS, max: env.RATE_LIMIT_DEVICE_MAX }));
|
||||
|
||||
app.use("/admin", adminRoutes);
|
||||
app.use("/merchant", merchantRoutes);
|
||||
app.use("/integrations", integrationRoutes);
|
||||
app.use("/device", deviceRoutes);
|
||||
|
||||
@ -91,6 +155,20 @@ app.get("/health", (req, res) => {
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/health/deep", async (req, res) => {
|
||||
const database = await getDatabaseHealth();
|
||||
const healthy = database.status === "ok";
|
||||
res.status(healthy ? 200 : 503).json(
|
||||
successResponse(req, {
|
||||
status: healthy ? "healthy" : "degraded",
|
||||
time: new Date().toISOString(),
|
||||
checks: {
|
||||
database
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
code: "NOT_FOUND",
|
||||
|
||||
@ -1,15 +1,59 @@
|
||||
import "dotenv/config";
|
||||
|
||||
export const env = {
|
||||
PORT: Number(process.env.PORT || 3000),
|
||||
TRUST_PROXY: process.env.TRUST_PROXY || "false",
|
||||
JSON_BODY_LIMIT: process.env.JSON_BODY_LIMIT || "1mb",
|
||||
LOG_FORMAT: process.env.LOG_FORMAT || "dev",
|
||||
LOG_LEVEL: process.env.LOG_LEVEL || "info",
|
||||
ADMIN_TOKEN: process.env.ADMIN_TOKEN || "admin-dev-token",
|
||||
ADMIN_AUTH_ALLOW_LEGACY_TOKEN: process.env.ADMIN_AUTH_ALLOW_LEGACY_TOKEN || "true",
|
||||
ADMIN_DEV_LOGIN_ENABLED: process.env.ADMIN_DEV_LOGIN_ENABLED || "true",
|
||||
ADMIN_SESSION_SECRET: process.env.ADMIN_SESSION_SECRET || process.env.ADMIN_TOKEN || "admin-dev-token",
|
||||
ADMIN_SESSION_TTL_SECONDS: Number(process.env.ADMIN_SESSION_TTL_SECONDS || 28800),
|
||||
MERCHANT_TOKEN: process.env.MERCHANT_TOKEN || "merchant-dev-token",
|
||||
MERCHANT_PORTAL_PASSWORD: process.env.MERCHANT_PORTAL_PASSWORD || "merchant",
|
||||
MERCHANT_AUTH_ALLOW_LEGACY_TOKEN: process.env.MERCHANT_AUTH_ALLOW_LEGACY_TOKEN || "true",
|
||||
MERCHANT_DEV_LOGIN_ENABLED: process.env.MERCHANT_DEV_LOGIN_ENABLED || "true",
|
||||
MERCHANT_SESSION_SECRET: process.env.MERCHANT_SESSION_SECRET || process.env.MERCHANT_TOKEN || "merchant-dev-token",
|
||||
MERCHANT_SESSION_TTL_SECONDS: Number(process.env.MERCHANT_SESSION_TTL_SECONDS || 28800),
|
||||
DEVICE_TOKEN: process.env.DEVICE_TOKEN || "device-dev-token",
|
||||
DEVICE_AUTH_ALLOW_LEGACY_TOKEN: process.env.DEVICE_AUTH_ALLOW_LEGACY_TOKEN || "true",
|
||||
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_MODE: process.env.MQTT_PUBLISH_MODE || "simulator",
|
||||
MQTT_BROKER_URL: process.env.MQTT_BROKER_URL || "",
|
||||
MQTT_USERNAME: process.env.MQTT_USERNAME || "",
|
||||
MQTT_PASSWORD: process.env.MQTT_PASSWORD || "",
|
||||
MQTT_CLIENT_ID: process.env.MQTT_CLIENT_ID || "qris-platform-backend",
|
||||
MQTT_CONNECT_TIMEOUT_MS: Number(process.env.MQTT_CONNECT_TIMEOUT_MS || 5000),
|
||||
MQTT_SUBSCRIBE_ENABLED: process.env.MQTT_SUBSCRIBE_ENABLED || "false",
|
||||
MQTT_SUBSCRIBE_TOPICS: process.env.MQTT_SUBSCRIBE_TOPICS || "devices/+/uplink/#",
|
||||
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
|
||||
),
|
||||
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true",
|
||||
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000),
|
||||
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100),
|
||||
EXPORT_WORKER_ENABLED: process.env.EXPORT_WORKER_ENABLED || "true",
|
||||
EXPORT_WORKER_INTERVAL_MS: Number(process.env.EXPORT_WORKER_INTERVAL_MS || 2000),
|
||||
EXPORT_WORKER_BATCH_SIZE: Number(process.env.EXPORT_WORKER_BATCH_SIZE || 2),
|
||||
EXPORT_JOB_STALE_RUNNING_MS: Number(process.env.EXPORT_JOB_STALE_RUNNING_MS || 900000),
|
||||
EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS: Number(process.env.EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS || 5000),
|
||||
EXPORT_STORAGE_DIR: process.env.EXPORT_STORAGE_DIR || "./storage/exports",
|
||||
EXPORT_RETENTION_DAYS: Number(process.env.EXPORT_RETENTION_DAYS || 7),
|
||||
RATE_LIMIT_ENABLED: process.env.RATE_LIMIT_ENABLED || "true",
|
||||
RATE_LIMIT_LOGIN_WINDOW_MS: Number(process.env.RATE_LIMIT_LOGIN_WINDOW_MS || 60000),
|
||||
RATE_LIMIT_LOGIN_MAX: Number(process.env.RATE_LIMIT_LOGIN_MAX || 20),
|
||||
RATE_LIMIT_DEVICE_WINDOW_MS: Number(process.env.RATE_LIMIT_DEVICE_WINDOW_MS || 60000),
|
||||
RATE_LIMIT_DEVICE_MAX: Number(process.env.RATE_LIMIT_DEVICE_MAX || 600),
|
||||
RATE_LIMIT_ADMIN_WRITE_WINDOW_MS: Number(process.env.RATE_LIMIT_ADMIN_WRITE_WINDOW_MS || 60000),
|
||||
RATE_LIMIT_ADMIN_WRITE_MAX: Number(process.env.RATE_LIMIT_ADMIN_WRITE_MAX || 300),
|
||||
FINANCE_PLATFORM_FEE_BPS: Number(process.env.FINANCE_PLATFORM_FEE_BPS || 70),
|
||||
SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL: process.env.SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL || "false",
|
||||
DATABASE_URL: process.env.DATABASE_URL || "",
|
||||
PGHOST: process.env.PGHOST || "127.0.0.1",
|
||||
PGPORT: Number(process.env.PGPORT || 5432),
|
||||
|
||||
@ -2,6 +2,7 @@ import { createServer } from "node:http";
|
||||
import app from "./app";
|
||||
import { ensureSchema } from "./shared/db/pool";
|
||||
import { env } from "./config/env";
|
||||
import { logger } from "./shared/services/logger";
|
||||
|
||||
const port = env.PORT;
|
||||
|
||||
@ -11,7 +12,9 @@ async function bootstrap() {
|
||||
await ensureSchema();
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`QRIS Soundbox Platform bootstrap ready on :${port}`);
|
||||
logger.info("server_started", {
|
||||
port
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
1013
src/routes/admin.ts
1013
src/routes/admin.ts
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import { ApiError } from "../shared/errors";
|
||||
import { requireDeviceToken } from "../shared/middleware/auth";
|
||||
import { getDeviceScopeError, requireDeviceToken } from "../shared/middleware/auth";
|
||||
import { successResponse } from "../shared/middleware/errorMiddleware";
|
||||
import { getDeviceById, patchDevice } from "../shared/store/deviceStore";
|
||||
import { createDeviceHeartbeat } from "../shared/store/heartbeatStore";
|
||||
@ -136,6 +136,10 @@ router.post("/heartbeat", requireDeviceToken, async (req: Request, res: Response
|
||||
if (!payload || !payload.device_id) {
|
||||
return next(new ApiError("BAD_REQUEST", "device_id is required", 400));
|
||||
}
|
||||
const scopeError = getDeviceScopeError(req, payload.device_id);
|
||||
if (scopeError) {
|
||||
return next(scopeError);
|
||||
}
|
||||
|
||||
const device = await getDeviceById(payload.device_id);
|
||||
if (!device) {
|
||||
@ -199,6 +203,10 @@ router.post("/commands/ack", requireDeviceToken, async (req: Request, res: Respo
|
||||
if (!payload || !payload.command_id || !payload.device_id || !payload.status) {
|
||||
return next(new ApiError("BAD_REQUEST", "command_id, device_id, status are required", 400));
|
||||
}
|
||||
const scopeError = getDeviceScopeError(req, payload.device_id);
|
||||
if (scopeError) {
|
||||
return next(scopeError);
|
||||
}
|
||||
|
||||
if (!["delivered", "failed", "timeout"].includes(payload.status)) {
|
||||
return next(new ApiError("BAD_REQUEST", "status must be delivered, failed, or timeout", 400));
|
||||
@ -235,6 +243,10 @@ router.post("/transactions/dynamic-qr", requireDeviceToken, async (req: Request,
|
||||
if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) {
|
||||
return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400));
|
||||
}
|
||||
const scopeError = getDeviceScopeError(req, payload.device_id);
|
||||
if (scopeError) {
|
||||
return next(scopeError);
|
||||
}
|
||||
|
||||
const amount = normalizePositiveAmount(payload.amount);
|
||||
if (amount === null) {
|
||||
@ -310,6 +322,10 @@ router.post("/mqtt/uplink/dynamic-qr/request", requireDeviceToken, async (req: R
|
||||
if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) {
|
||||
return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400));
|
||||
}
|
||||
const scopeError = getDeviceScopeError(req, payload.device_id);
|
||||
if (scopeError) {
|
||||
return next(scopeError);
|
||||
}
|
||||
|
||||
if (payload.message_type && payload.message_type !== "dynamic_qr_request") {
|
||||
return next(new ApiError("BAD_REQUEST", "message_type must be dynamic_qr_request", 400));
|
||||
@ -418,6 +434,10 @@ router.get("/config", requireDeviceToken, async (req: Request, res: Response, ne
|
||||
if (!deviceId) {
|
||||
return next(new ApiError("BAD_REQUEST", "device_id is required", 400));
|
||||
}
|
||||
const scopeError = getDeviceScopeError(req, deviceId);
|
||||
if (scopeError) {
|
||||
return next(scopeError);
|
||||
}
|
||||
|
||||
const device = await getDeviceById(deviceId);
|
||||
if (!device) {
|
||||
@ -433,6 +453,10 @@ router.post("/config/ack", requireDeviceToken, async (req: Request, res: Respons
|
||||
if (!payload || !payload.device_id || !payload.status) {
|
||||
return next(new ApiError("BAD_REQUEST", "device_id, status are required", 400));
|
||||
}
|
||||
const scopeError = getDeviceScopeError(req, payload.device_id);
|
||||
if (scopeError) {
|
||||
return next(scopeError);
|
||||
}
|
||||
|
||||
if (!["applied", "failed"].includes(payload.status)) {
|
||||
return next(new ApiError("BAD_REQUEST", "status must be applied or failed", 400));
|
||||
|
||||
348
src/routes/merchant.ts
Normal file
348
src/routes/merchant.ts
Normal file
@ -0,0 +1,348 @@
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import { ApiError } from "../shared/errors";
|
||||
import { env } from "../config/env";
|
||||
import { successResponse } from "../shared/middleware/errorMiddleware";
|
||||
import { createMerchantSessionToken, verifyMerchantSessionToken, type MerchantSessionPayload } from "../shared/services/sessionToken";
|
||||
import { getMerchantByCode, getMerchantById, toMerchantPayload } from "../shared/store/merchantStore";
|
||||
import {
|
||||
getMerchantUserByEmail,
|
||||
toMerchantUserPayload,
|
||||
verifyMerchantPassword
|
||||
} from "../shared/store/merchantUserStore";
|
||||
import {
|
||||
createSettlementBatchEvent,
|
||||
getSettlementBatchByIdForMerchant,
|
||||
getSettlementBatchReportRows,
|
||||
getSettlementFinanceSummary,
|
||||
listSettlementBatchAdjustments,
|
||||
listSettlementBatchEntries,
|
||||
listSettlementBatchEvents,
|
||||
listSettlementBatches,
|
||||
settlementBatchReportToBankGenericCsv,
|
||||
settlementBatchReportToCsv,
|
||||
SettlementBatchStatus,
|
||||
toSettlementBatchAdjustmentPayload,
|
||||
toSettlementBatchEntryPayload,
|
||||
toSettlementBatchEventPayload,
|
||||
toSettlementBatchPayload
|
||||
} from "../shared/store/settlementStore";
|
||||
import { createAuditLog } from "../shared/store/auditLogStore";
|
||||
|
||||
const router = Router();
|
||||
|
||||
type MerchantRequest = Request & {
|
||||
merchantId?: string;
|
||||
merchantAuth?: {
|
||||
mode: "legacy_token" | "session";
|
||||
merchant_id: string;
|
||||
user_id?: string;
|
||||
role_name?: string;
|
||||
session?: MerchantSessionPayload;
|
||||
};
|
||||
};
|
||||
|
||||
function extractToken(req: Request) {
|
||||
const raw = req.header("authorization") || "";
|
||||
if (raw.startsWith("Bearer ")) {
|
||||
return raw.slice(7);
|
||||
}
|
||||
return raw || req.header("x-merchant-token") || "";
|
||||
}
|
||||
|
||||
function parseSettlementBatchStatus(value: string | undefined): SettlementBatchStatus | undefined {
|
||||
if (value === "created" || value === "paid" || value === "failed" || value === "cancelled") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseSettlementExportFormat(value: string | undefined): "standard" | "bank_generic" | undefined {
|
||||
if (!value || value === "standard" || value === "general") {
|
||||
return "standard";
|
||||
}
|
||||
if (value === "bank_generic") {
|
||||
return "bank_generic";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function requireMerchantToken(req: MerchantRequest, _res: Response, next: NextFunction) {
|
||||
const token = extractToken(req);
|
||||
const merchantId = req.header("x-merchant-id") || "";
|
||||
if (!token) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Missing merchant bearer token", 401));
|
||||
}
|
||||
|
||||
const session = verifyMerchantSessionToken(token, env.MERCHANT_SESSION_SECRET);
|
||||
if (session) {
|
||||
if (merchantId && merchantId !== session.merchant_id) {
|
||||
return next(new ApiError("FORBIDDEN", "merchant session cannot access another merchant", 403));
|
||||
}
|
||||
const merchant = await getMerchantById(session.merchant_id);
|
||||
if (!merchant) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid merchant scope", 401));
|
||||
}
|
||||
req.merchantId = merchant.id;
|
||||
req.merchantAuth = {
|
||||
mode: "session",
|
||||
merchant_id: merchant.id,
|
||||
user_id: session.sub,
|
||||
role_name: session.role_name,
|
||||
session
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
const allowLegacyToken = env.MERCHANT_AUTH_ALLOW_LEGACY_TOKEN !== "false";
|
||||
if (!allowLegacyToken || token !== env.MERCHANT_TOKEN) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid merchant token", 401));
|
||||
}
|
||||
if (!merchantId) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Missing merchant scope", 401));
|
||||
}
|
||||
const merchant = await getMerchantById(merchantId);
|
||||
if (!merchant) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid merchant scope", 401));
|
||||
}
|
||||
req.merchantId = merchant.id;
|
||||
req.merchantAuth = {
|
||||
mode: "legacy_token",
|
||||
merchant_id: merchant.id
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
router.post("/login", async (req: Request, res: Response, next: NextFunction) => {
|
||||
const payload = (req.body || {}) as {
|
||||
merchant_id?: string;
|
||||
merchant_code?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
const loginId = (payload.merchant_id || payload.merchant_code || payload.username || "").trim();
|
||||
const password = payload.password || "";
|
||||
if (!loginId || !password) {
|
||||
return next(new ApiError("BAD_REQUEST", "merchant id/code and password are required", 400));
|
||||
}
|
||||
|
||||
const merchantUser = loginId.includes("@") ? await getMerchantUserByEmail(loginId) : null;
|
||||
if (merchantUser) {
|
||||
if (merchantUser.status !== "active" || !verifyMerchantPassword(merchantUser.password_hash, password)) {
|
||||
await createAuditLog({
|
||||
actor_type: "merchant",
|
||||
actor_id: merchantUser.id,
|
||||
action: "merchant.login.failed",
|
||||
entity_type: "merchant_session",
|
||||
entity_id: merchantUser.merchant_id,
|
||||
after_json: {
|
||||
reason: merchantUser.status !== "active" ? "inactive_user" : "invalid_credentials",
|
||||
auth_mode: "session"
|
||||
},
|
||||
source_ip: req.ip,
|
||||
request_id: req.requestId,
|
||||
trace_id: req.traceId
|
||||
});
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401));
|
||||
}
|
||||
const merchant = await getMerchantById(merchantUser.merchant_id);
|
||||
if (!merchant) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401));
|
||||
}
|
||||
const token = createMerchantSessionToken(
|
||||
{
|
||||
typ: "merchant",
|
||||
sub: merchantUser.id,
|
||||
merchant_id: merchant.id,
|
||||
name: merchantUser.name,
|
||||
email: merchantUser.email,
|
||||
role_name: merchantUser.role_name
|
||||
},
|
||||
env.MERCHANT_SESSION_SECRET,
|
||||
env.MERCHANT_SESSION_TTL_SECONDS
|
||||
);
|
||||
await createAuditLog({
|
||||
actor_type: "merchant",
|
||||
actor_id: merchantUser.id,
|
||||
action: "merchant.login.success",
|
||||
entity_type: "merchant_session",
|
||||
entity_id: merchant.id,
|
||||
after_json: {
|
||||
auth_mode: "session",
|
||||
role_name: merchantUser.role_name
|
||||
},
|
||||
source_ip: req.ip,
|
||||
request_id: req.requestId,
|
||||
trace_id: req.traceId
|
||||
});
|
||||
return res.json(
|
||||
successResponse(req, {
|
||||
token,
|
||||
auth_mode: "session",
|
||||
merchant: toMerchantPayload(merchant),
|
||||
user: toMerchantUserPayload(merchantUser)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (env.MERCHANT_DEV_LOGIN_ENABLED === "false" || password !== env.MERCHANT_PORTAL_PASSWORD) {
|
||||
await createAuditLog({
|
||||
actor_type: "merchant",
|
||||
actor_id: loginId || "unknown",
|
||||
action: "merchant.login.failed",
|
||||
entity_type: "merchant_session",
|
||||
entity_id: loginId || "unknown",
|
||||
after_json: {
|
||||
reason: "invalid_legacy_credentials",
|
||||
auth_mode: "legacy_token"
|
||||
},
|
||||
source_ip: req.ip,
|
||||
request_id: req.requestId,
|
||||
trace_id: req.traceId
|
||||
});
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401));
|
||||
}
|
||||
|
||||
const merchant = (await getMerchantById(loginId)) || (await getMerchantByCode(loginId));
|
||||
if (!merchant) {
|
||||
await createAuditLog({
|
||||
actor_type: "merchant",
|
||||
actor_id: loginId || "unknown",
|
||||
action: "merchant.login.failed",
|
||||
entity_type: "merchant_session",
|
||||
entity_id: loginId || "unknown",
|
||||
after_json: {
|
||||
reason: "merchant_not_found",
|
||||
auth_mode: "legacy_token"
|
||||
},
|
||||
source_ip: req.ip,
|
||||
request_id: req.requestId,
|
||||
trace_id: req.traceId
|
||||
});
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401));
|
||||
}
|
||||
await createAuditLog({
|
||||
actor_type: "merchant",
|
||||
actor_id: merchant.id,
|
||||
action: "merchant.login.success",
|
||||
entity_type: "merchant_session",
|
||||
entity_id: merchant.id,
|
||||
after_json: {
|
||||
auth_mode: "legacy_token"
|
||||
},
|
||||
source_ip: req.ip,
|
||||
request_id: req.requestId,
|
||||
trace_id: req.traceId
|
||||
});
|
||||
res.json(
|
||||
successResponse(req, {
|
||||
token: env.MERCHANT_TOKEN,
|
||||
auth_mode: "legacy_token",
|
||||
merchant: toMerchantPayload(merchant)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/profile", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => {
|
||||
const merchant = await getMerchantById(req.merchantId || "");
|
||||
if (!merchant) {
|
||||
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
|
||||
}
|
||||
res.json(
|
||||
successResponse(req, {
|
||||
merchant: toMerchantPayload(merchant),
|
||||
auth_mode: req.merchantAuth?.mode,
|
||||
user: req.merchantAuth?.session
|
||||
? {
|
||||
id: req.merchantAuth.session.sub,
|
||||
name: req.merchantAuth.session.name,
|
||||
email: req.merchantAuth.session.email,
|
||||
role_name: req.merchantAuth.session.role_name
|
||||
}
|
||||
: null
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/settlement-summary", requireMerchantToken, async (req: MerchantRequest, res: Response) => {
|
||||
const summary = await getSettlementFinanceSummary({ merchant_id: req.merchantId });
|
||||
res.json(successResponse(req, summary));
|
||||
});
|
||||
|
||||
router.get("/settlement-batches", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => {
|
||||
const statusRaw = req.query.status as string | undefined;
|
||||
const status = parseSettlementBatchStatus(statusRaw);
|
||||
const limitRaw = req.query.limit as string | undefined;
|
||||
const limit = limitRaw ? Number(limitRaw) : 100;
|
||||
|
||||
if (statusRaw && !status) {
|
||||
return next(new ApiError("BAD_REQUEST", "status must be created|paid|failed|cancelled", 400));
|
||||
}
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
||||
}
|
||||
|
||||
const batches = await listSettlementBatches({
|
||||
merchant_id: req.merchantId,
|
||||
status,
|
||||
limit
|
||||
});
|
||||
res.json(successResponse(req, batches.map(toSettlementBatchPayload)));
|
||||
});
|
||||
|
||||
router.get("/settlement-batches/:batchId", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => {
|
||||
const batch = await getSettlementBatchByIdForMerchant(req.params.batchId, req.merchantId || "");
|
||||
if (!batch) {
|
||||
return next(new ApiError("NOT_FOUND", "settlement batch not found", 404));
|
||||
}
|
||||
const entries = await listSettlementBatchEntries(batch.id);
|
||||
const adjustments = await listSettlementBatchAdjustments(batch.id);
|
||||
const events = await listSettlementBatchEvents(batch.id);
|
||||
res.json(
|
||||
successResponse(req, {
|
||||
batch: toSettlementBatchPayload(batch),
|
||||
entries: entries.map(toSettlementBatchEntryPayload),
|
||||
adjustments: adjustments.map(toSettlementBatchAdjustmentPayload),
|
||||
events: events.map(toSettlementBatchEventPayload)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/settlement-batches/:batchId/export.csv",
|
||||
requireMerchantToken,
|
||||
async (req: MerchantRequest, res: Response, next: NextFunction) => {
|
||||
const batch = await getSettlementBatchByIdForMerchant(req.params.batchId, req.merchantId || "");
|
||||
if (!batch) {
|
||||
return next(new ApiError("NOT_FOUND", "settlement batch not found", 404));
|
||||
}
|
||||
const formatRaw = req.query.format as string | undefined;
|
||||
const format = parseSettlementExportFormat(formatRaw);
|
||||
if (!format) {
|
||||
return next(new ApiError("BAD_REQUEST", "format must be standard|bank_generic", 400));
|
||||
}
|
||||
|
||||
const rows = await getSettlementBatchReportRows(batch.id);
|
||||
const csv = format === "bank_generic" ? settlementBatchReportToBankGenericCsv(rows) : settlementBatchReportToCsv(rows);
|
||||
const filename =
|
||||
format === "bank_generic"
|
||||
? `${batch.batch_code}-bank-generic-payout.csv`
|
||||
: `${batch.batch_code}-payout-report.csv`;
|
||||
await createSettlementBatchEvent({
|
||||
batch_id: batch.id,
|
||||
merchant_id: batch.merchant_id,
|
||||
event_type: "csv_exported",
|
||||
actor_type: "merchant",
|
||||
actor_id: batch.merchant_id,
|
||||
payload_json: {
|
||||
format,
|
||||
report_rows: rows.length,
|
||||
filename
|
||||
}
|
||||
});
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.send(csv);
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@ -41,6 +41,13 @@ export function getPool(): Pool {
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withClient<T>(work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
@ -73,6 +80,41 @@ CREATE TABLE IF NOT EXISTS merchants (
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS merchant_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role_name TEXT NOT NULL DEFAULT 'owner' CHECK (role_name IN ('owner', 'finance', 'ops', 'viewer')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_merchant_users_merchant ON merchant_users (merchant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS export_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
|
||||
requested_by TEXT,
|
||||
request_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
result_content_type TEXT,
|
||||
result_filename TEXT,
|
||||
result_body TEXT,
|
||||
result_storage_path TEXT,
|
||||
result_size_bytes INTEGER,
|
||||
expires_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_export_jobs_status ON export_jobs (status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_export_jobs_expires_at ON export_jobs (expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outlets (
|
||||
id TEXT PRIMARY KEY,
|
||||
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
|
||||
@ -104,6 +146,12 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
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,
|
||||
mqtt_username TEXT,
|
||||
credential_secret_fingerprint TEXT,
|
||||
credential_status TEXT NOT NULL DEFAULT 'not_issued' CHECK (credential_status IN ('not_issued', 'active', 'rotation_required', 'revoked')),
|
||||
credential_issued_at TIMESTAMPTZ,
|
||||
credential_rotated_at TIMESTAMPTZ,
|
||||
credential_revoked_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
firmware_version TEXT,
|
||||
@ -111,6 +159,13 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS mqtt_username TEXT;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_secret_fingerprint TEXT;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_status TEXT NOT NULL DEFAULT 'not_issued';
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_issued_at TIMESTAMPTZ;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_rotated_at TIMESTAMPTZ;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_revoked_at TIMESTAMPTZ;
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_credential_status ON devices (credential_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
@ -263,6 +318,151 @@ CREATE TABLE IF NOT EXISTS ledger_entries (
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_entries_merchant_created ON ledger_entries (merchant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_entries_tx ON ledger_entries (transaction_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_batches (
|
||||
id TEXT PRIMARY KEY,
|
||||
batch_code TEXT NOT NULL UNIQUE,
|
||||
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
|
||||
currency TEXT NOT NULL DEFAULT 'IDR',
|
||||
gross_amount NUMERIC(20,2) NOT NULL DEFAULT 0,
|
||||
platform_fee_amount NUMERIC(20,2) NOT NULL DEFAULT 0,
|
||||
net_payable_amount NUMERIC(20,2) NOT NULL DEFAULT 0,
|
||||
entry_count INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'paid', 'failed', 'cancelled')),
|
||||
cutoff_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
paid_at TIMESTAMPTZ,
|
||||
failure_reason TEXT,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batches_merchant_created ON settlement_batches (merchant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batches_status_created ON settlement_batches (status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_batch_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE,
|
||||
ledger_entry_id TEXT NOT NULL REFERENCES ledger_entries (id) ON DELETE CASCADE,
|
||||
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
|
||||
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
|
||||
amount NUMERIC(20,2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'IDR',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
CONSTRAINT settlement_batch_entries_unique_ledger UNIQUE (ledger_entry_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batch_entries_batch ON settlement_batch_entries (batch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batch_entries_merchant ON settlement_batch_entries (merchant_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_batch_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE,
|
||||
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL,
|
||||
actor_id TEXT,
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batch_events_batch ON settlement_batch_events (batch_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batch_events_merchant ON settlement_batch_events (merchant_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_batch_adjustments (
|
||||
id TEXT PRIMARY KEY,
|
||||
batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE,
|
||||
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
|
||||
adjustment_type TEXT NOT NULL CHECK (adjustment_type IN ('credit', 'debit')),
|
||||
amount NUMERIC(20,2) NOT NULL,
|
||||
signed_amount NUMERIC(20,2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'IDR',
|
||||
reason TEXT NOT NULL,
|
||||
note TEXT,
|
||||
approval_status TEXT NOT NULL DEFAULT 'approved' CHECK (approval_status IN ('pending', 'approved', 'rejected')),
|
||||
approved_by TEXT,
|
||||
approved_at TIMESTAMPTZ,
|
||||
rejected_by TEXT,
|
||||
rejected_at TIMESTAMPTZ,
|
||||
actor_type TEXT NOT NULL DEFAULT 'admin',
|
||||
actor_id TEXT,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approval_status TEXT NOT NULL DEFAULT 'approved';
|
||||
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approved_by TEXT;
|
||||
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
|
||||
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS rejected_by TEXT;
|
||||
ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_batch ON settlement_batch_adjustments (batch_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_merchant ON settlement_batch_adjustments (merchant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_approval ON settlement_batch_adjustments (approval_status, created_at DESC);
|
||||
|
||||
INSERT INTO settlement_batch_adjustments (
|
||||
id,
|
||||
batch_id,
|
||||
merchant_id,
|
||||
adjustment_type,
|
||||
amount,
|
||||
signed_amount,
|
||||
currency,
|
||||
reason,
|
||||
note,
|
||||
approval_status,
|
||||
approved_by,
|
||||
approved_at,
|
||||
actor_type,
|
||||
actor_id,
|
||||
metadata_json,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
COALESCE(adjustment.item->>'id', 'adj_backfill_' || sb.id || '_' || adjustment.ordinality::text) AS id,
|
||||
sb.id AS batch_id,
|
||||
sb.merchant_id,
|
||||
CASE
|
||||
WHEN adjustment.item->>'adjustment_type' IN ('credit', 'debit') THEN adjustment.item->>'adjustment_type'
|
||||
WHEN COALESCE(NULLIF(adjustment.item->>'signed_amount', '')::numeric, 0) >= 0 THEN 'credit'
|
||||
ELSE 'debit'
|
||||
END AS adjustment_type,
|
||||
ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, NULLIF(adjustment.item->>'signed_amount', '')::numeric, 0)) AS amount,
|
||||
COALESCE(
|
||||
NULLIF(adjustment.item->>'signed_amount', '')::numeric,
|
||||
CASE
|
||||
WHEN adjustment.item->>'adjustment_type' = 'debit' THEN -ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, 0))
|
||||
ELSE ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, 0))
|
||||
END
|
||||
) AS signed_amount,
|
||||
sb.currency,
|
||||
COALESCE(NULLIF(adjustment.item->>'reason', ''), 'Backfilled settlement adjustment') AS reason,
|
||||
NULLIF(adjustment.item->>'note', '') AS note,
|
||||
'approved' AS approval_status,
|
||||
COALESCE(NULLIF(adjustment.item->>'actor_id', ''), 'metadata_backfill') AS approved_by,
|
||||
COALESCE(NULLIF(adjustment.item->>'created_at', '')::timestamptz, sb.created_at) AS approved_at,
|
||||
COALESCE(NULLIF(adjustment.item->>'actor_type', ''), 'admin') AS actor_type,
|
||||
NULLIF(adjustment.item->>'actor_id', '') AS actor_id,
|
||||
jsonb_build_object('source', 'metadata_backfill', 'original', adjustment.item) AS metadata_json,
|
||||
COALESCE(NULLIF(adjustment.item->>'created_at', '')::timestamptz, sb.created_at) AS created_at
|
||||
FROM settlement_batches sb
|
||||
CROSS JOIN LATERAL jsonb_array_elements(sb.metadata_json->'adjustments') WITH ORDINALITY AS adjustment(item, ordinality)
|
||||
WHERE jsonb_typeof(sb.metadata_json->'adjustments') = 'array'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
WITH adjustment_totals AS (
|
||||
SELECT batch_id, COALESCE(SUM(signed_amount), 0) AS total_adjustment_amount
|
||||
FROM settlement_batch_adjustments
|
||||
WHERE approval_status = 'approved'
|
||||
GROUP BY batch_id
|
||||
)
|
||||
UPDATE settlement_batches sb
|
||||
SET metadata_json = sb.metadata_json || jsonb_build_object(
|
||||
'total_adjustment_amount', adjustment_totals.total_adjustment_amount,
|
||||
'adjustment_source', 'settlement_batch_adjustments',
|
||||
'adjustment_backfilled_at', NOW()
|
||||
)
|
||||
FROM adjustment_totals
|
||||
WHERE adjustment_totals.batch_id = sb.id;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@ -302,6 +502,14 @@ INSERT INTO roles (id, name, permissions_json, created_at)
|
||||
VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO roles (id, name, permissions_json, created_at)
|
||||
VALUES
|
||||
('role_finance', 'finance', '{"admin":["read"],"merchant":["read"],"device":["read"],"transaction":["read"],"settlement":"*","reconciliation":"*","audit":["read"]}'::jsonb, NOW()),
|
||||
('role_ops', 'ops', '{"admin":["read"],"merchant":"*","outlet":"*","terminal":"*","device":"*","transaction":"*","notification":"*","settlement":["read"],"reconciliation":["read"],"audit":["read"]}'::jsonb, NOW()),
|
||||
('role_support', 'support', '{"admin":["read"],"merchant":["read"],"outlet":["read"],"terminal":["read"],"device":["read"],"transaction":["read"],"notification":["read"],"settlement":["read"],"audit":["read"]}'::jsonb, NOW()),
|
||||
('role_viewer', 'viewer', '{"admin":["read"],"merchant":["read"],"outlet":["read"],"terminal":["read"],"device":["read"],"transaction":["read"],"settlement":["read"],"reconciliation":["read"]}'::jsonb, NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO users (id, name, email, password_hash, role_id, status, created_at)
|
||||
VALUES ('user_admin_seed', 'Admin Seed', 'admin@example.local', 'dev-only-admin-password', 'role_admin', 'active', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
@ -4,6 +4,7 @@ export type ErrorCode =
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "CONFLICT"
|
||||
| "RATE_LIMITED"
|
||||
| "INTERNAL_ERROR"
|
||||
| "DEVICE_UNAUTHORIZED"
|
||||
| "DUPLICATE_REQUEST"
|
||||
|
||||
@ -1,6 +1,44 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { ApiError } from "../errors";
|
||||
import { env } from "../../config/env";
|
||||
import { verifyDeviceSecret } from "../store/deviceStore";
|
||||
import { verifySessionToken, type SessionPayload } from "../services/sessionToken";
|
||||
import { hasPermission } from "../store/userStore";
|
||||
|
||||
export type DeviceAuthContext =
|
||||
| {
|
||||
mode: "legacy_token";
|
||||
}
|
||||
| {
|
||||
mode: "device_credential";
|
||||
device_id: string;
|
||||
};
|
||||
|
||||
export type RequestWithDeviceAuth = Request & {
|
||||
deviceAuth?: DeviceAuthContext;
|
||||
};
|
||||
|
||||
export type AdminAuthContext =
|
||||
| {
|
||||
mode: "legacy_token";
|
||||
user_id: "legacy_admin";
|
||||
role_name: "admin";
|
||||
permissions: { admin: "*" };
|
||||
}
|
||||
| {
|
||||
mode: "session";
|
||||
user_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role_id: string;
|
||||
role_name: string;
|
||||
permissions: unknown;
|
||||
session: SessionPayload;
|
||||
};
|
||||
|
||||
export type RequestWithAdminAuth = Request & {
|
||||
adminAuth?: AdminAuthContext;
|
||||
};
|
||||
|
||||
function extractAdminToken(req: Request) {
|
||||
const raw = req.header("authorization") || "";
|
||||
@ -11,30 +49,106 @@ function extractAdminToken(req: Request) {
|
||||
return raw || req.header("x-admin-token") || "";
|
||||
}
|
||||
|
||||
export function requireAdminToken(req: Request, _res: Response, next: NextFunction) {
|
||||
export function requireAdminToken(req: RequestWithAdminAuth, _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) {
|
||||
const session = verifySessionToken(token, env.ADMIN_SESSION_SECRET);
|
||||
if (session) {
|
||||
req.adminAuth = {
|
||||
mode: "session",
|
||||
user_id: session.sub,
|
||||
name: session.name,
|
||||
email: session.email,
|
||||
role_id: session.role_id,
|
||||
role_name: session.role_name,
|
||||
permissions: session.permissions,
|
||||
session
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
const allowLegacyToken = env.ADMIN_AUTH_ALLOW_LEGACY_TOKEN !== "false";
|
||||
if (!allowLegacyToken || token !== env.ADMIN_TOKEN) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid admin token", 401));
|
||||
}
|
||||
|
||||
req.adminAuth = {
|
||||
mode: "legacy_token",
|
||||
user_id: "legacy_admin",
|
||||
role_name: "admin",
|
||||
permissions: { admin: "*" }
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
export function requireDeviceToken(req: Request, _res: Response, next: NextFunction) {
|
||||
export function requireAdminPermission(permission: string) {
|
||||
return (req: RequestWithAdminAuth, _res: Response, next: NextFunction) => {
|
||||
if (!req.adminAuth) {
|
||||
return requireAdminToken(req, _res, (error?: unknown) => {
|
||||
if (error) {
|
||||
return next(error);
|
||||
}
|
||||
if (!hasPermission(req.adminAuth?.permissions, permission)) {
|
||||
return next(new ApiError("FORBIDDEN", "Admin role cannot perform this action", 403));
|
||||
}
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasPermission(req.adminAuth.permissions, permission)) {
|
||||
return next(new ApiError("FORBIDDEN", "Admin role cannot perform this action", 403));
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export function requireDeviceToken(req: RequestWithDeviceAuth, _res: Response, next: NextFunction) {
|
||||
const raw = req.header("authorization") || "";
|
||||
const token = raw.startsWith("Bearer ") ? raw.slice(7) : raw;
|
||||
const headerDeviceId = req.header("x-device-id") || "";
|
||||
const headerSecret = req.header("x-device-secret") || "";
|
||||
const allowLegacyToken = env.DEVICE_AUTH_ALLOW_LEGACY_TOKEN !== "false";
|
||||
|
||||
if (headerDeviceId && headerSecret) {
|
||||
verifyDeviceSecret(headerDeviceId, headerSecret)
|
||||
.then((device) => {
|
||||
if (!device) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid device credential", 401));
|
||||
}
|
||||
req.deviceAuth = {
|
||||
mode: "device_credential",
|
||||
device_id: device.id
|
||||
};
|
||||
return next();
|
||||
})
|
||||
.catch((error) => next(error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowLegacyToken) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Device bearer token fallback is disabled", 401));
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Missing device bearer token", 401));
|
||||
return next(new ApiError("UNAUTHORIZED", "Missing device credential", 401));
|
||||
}
|
||||
|
||||
if (token !== env.DEVICE_TOKEN) {
|
||||
return next(new ApiError("UNAUTHORIZED", "Invalid device token", 401));
|
||||
}
|
||||
|
||||
req.deviceAuth = {
|
||||
mode: "legacy_token"
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
export function getDeviceScopeError(req: RequestWithDeviceAuth, deviceId: string): ApiError | null {
|
||||
if (req.deviceAuth?.mode === "device_credential" && req.deviceAuth.device_id !== deviceId) {
|
||||
return new ApiError("FORBIDDEN", "device credential cannot access another device", 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { ApiError, errorEnvelope } from "../errors";
|
||||
import { logger } from "../services/logger";
|
||||
|
||||
export interface EnvelopeSuccess<T> {
|
||||
data: T;
|
||||
@ -17,6 +18,13 @@ export function successResponse<T>(req: Request, data: T): EnvelopeSuccess<T> {
|
||||
|
||||
export function handleErrors(err: Error, req: Request, res: Response, _next: NextFunction) {
|
||||
if (err instanceof ApiError) {
|
||||
logger.warn("api_error", {
|
||||
request_id: req.requestId,
|
||||
trace_id: req.traceId,
|
||||
code: err.code,
|
||||
status_code: err.statusCode,
|
||||
message: err.message
|
||||
});
|
||||
res.status(err.statusCode).json(
|
||||
errorEnvelope(
|
||||
{
|
||||
@ -28,6 +36,11 @@ export function handleErrors(err: Error, req: Request, res: Response, _next: Nex
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("unhandled_error", {
|
||||
request_id: req.requestId,
|
||||
trace_id: req.traceId,
|
||||
error: err
|
||||
});
|
||||
res.status(500).json({
|
||||
code: "INTERNAL_ERROR",
|
||||
message: err.message || "Unexpected server error",
|
||||
|
||||
66
src/shared/middleware/rateLimit.ts
Normal file
66
src/shared/middleware/rateLimit.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { env } from "../../config/env";
|
||||
import { ApiError } from "../errors";
|
||||
|
||||
type RateLimitOptions = {
|
||||
name: string;
|
||||
windowMs: number;
|
||||
max: number;
|
||||
key?: (req: Request) => string;
|
||||
};
|
||||
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
function clientIp(req: Request) {
|
||||
const forwarded = String(req.headers["x-forwarded-for"] || "");
|
||||
return forwarded.split(",")[0]?.trim() || req.ip || req.socket.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
function defaultKey(req: Request) {
|
||||
return clientIp(req);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (bucket.resetAt <= now) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60000).unref?.();
|
||||
|
||||
export function rateLimit(options: RateLimitOptions) {
|
||||
const enabled = String(env.RATE_LIMIT_ENABLED).toLowerCase() !== "false";
|
||||
const windowMs = Math.max(options.windowMs || 60000, 1000);
|
||||
const max = Math.max(options.max || 60, 1);
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!enabled) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const key = `${options.name}:${(options.key || defaultKey)(req)}`;
|
||||
const existing = buckets.get(key);
|
||||
const bucket = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + windowMs };
|
||||
bucket.count += 1;
|
||||
buckets.set(key, bucket);
|
||||
|
||||
const remaining = Math.max(max - bucket.count, 0);
|
||||
res.setHeader("RateLimit-Limit", String(max));
|
||||
res.setHeader("RateLimit-Remaining", String(remaining));
|
||||
res.setHeader("RateLimit-Reset", String(Math.ceil(bucket.resetAt / 1000)));
|
||||
|
||||
if (bucket.count > max) {
|
||||
res.setHeader("Retry-After", String(Math.ceil((bucket.resetAt - now) / 1000)));
|
||||
return next(new ApiError("RATE_LIMITED", "Too many requests, please retry later", 429));
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
34
src/shared/middleware/requestLogging.ts
Normal file
34
src/shared/middleware/requestLogging.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { logger } from "../services/logger";
|
||||
|
||||
function classifyLevel(statusCode: number) {
|
||||
if (statusCode >= 500) {
|
||||
return "error" as const;
|
||||
}
|
||||
if (statusCode >= 400) {
|
||||
return "warn" as const;
|
||||
}
|
||||
return "info" as const;
|
||||
}
|
||||
|
||||
export function requestLogging(req: Request, res: Response, next: NextFunction) {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
|
||||
res.on("finish", () => {
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
||||
const level = classifyLevel(res.statusCode);
|
||||
logger[level]("http_request", {
|
||||
request_id: req.requestId,
|
||||
trace_id: req.traceId,
|
||||
method: req.method,
|
||||
path: req.originalUrl || req.url,
|
||||
status_code: res.statusCode,
|
||||
duration_ms: Number(durationMs.toFixed(2)),
|
||||
content_length: res.getHeader("content-length") || null,
|
||||
user_agent: req.header("user-agent") || null,
|
||||
ip: req.ip
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
73
src/shared/services/dynamicQrExpiryScheduler.ts
Normal file
73
src/shared/services/dynamicQrExpiryScheduler.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { env } from "../../config/env";
|
||||
import { expireDueDynamicQrTransactions } from "./dynamicQrExpiry";
|
||||
|
||||
type SchedulerRunResult = Awaited<ReturnType<typeof expireDueDynamicQrTransactions>>;
|
||||
|
||||
type SchedulerStatus = {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
interval_ms: number;
|
||||
limit: number;
|
||||
last_started_at: string | null;
|
||||
last_finished_at: string | null;
|
||||
last_result: SchedulerRunResult | null;
|
||||
last_error: string | null;
|
||||
};
|
||||
|
||||
const enabled = String(env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED).toLowerCase() !== "false";
|
||||
const intervalMs = Math.max(env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000, 5000);
|
||||
const limit = Math.min(Math.max(env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100, 1), 500);
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let running = false;
|
||||
let lastStartedAt: string | null = null;
|
||||
let lastFinishedAt: string | null = null;
|
||||
let lastResult: SchedulerRunResult | null = null;
|
||||
let lastError: string | null = null;
|
||||
|
||||
async function runSweep() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
lastStartedAt = new Date().toISOString();
|
||||
lastError = null;
|
||||
|
||||
try {
|
||||
lastResult = await expireDueDynamicQrTransactions({
|
||||
limit,
|
||||
source: "system",
|
||||
request_id: `dynamic_qr_expiry_scheduler_${lastStartedAt}`
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
} finally {
|
||||
running = false;
|
||||
lastFinishedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export function startDynamicQrExpiryScheduler() {
|
||||
if (!enabled || timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer = setInterval(() => {
|
||||
void runSweep();
|
||||
}, intervalMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function getDynamicQrExpirySchedulerStatus(): SchedulerStatus {
|
||||
return {
|
||||
enabled,
|
||||
running,
|
||||
interval_ms: intervalMs,
|
||||
limit,
|
||||
last_started_at: lastStartedAt,
|
||||
last_finished_at: lastFinishedAt,
|
||||
last_result: lastResult,
|
||||
last_error: lastError
|
||||
};
|
||||
}
|
||||
277
src/shared/services/exportJobWorker.ts
Normal file
277
src/shared/services/exportJobWorker.ts
Normal file
@ -0,0 +1,277 @@
|
||||
import { env } from "../../config/env";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ApiError } from "../errors";
|
||||
import {
|
||||
claimNextPendingExportJob,
|
||||
completeExportJob,
|
||||
createExportJob,
|
||||
failExportJob,
|
||||
markExpiredExportJobs,
|
||||
resetStaleRunningExportJobs,
|
||||
type ExportJobEntity
|
||||
} from "../store/exportJobStore";
|
||||
import { listSettlementAdjustments, settlementAdjustmentReportToCsv } from "../store/settlementStore";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type SettlementAdjustmentExportRequest = {
|
||||
merchant_id?: string;
|
||||
adjustment_type?: string;
|
||||
approval_status?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type WorkerStatus = {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
interval_ms: number;
|
||||
batch_size: number;
|
||||
max_settlement_adjustment_rows: number;
|
||||
storage_dir: string;
|
||||
retention_days: number;
|
||||
last_started_at: string | null;
|
||||
last_finished_at: string | null;
|
||||
last_error: string | null;
|
||||
last_processed_job_id: string | null;
|
||||
last_processed_count: number;
|
||||
stale_reset_count: number;
|
||||
expired_cleanup_count: number;
|
||||
};
|
||||
|
||||
const enabled = String(env.EXPORT_WORKER_ENABLED).toLowerCase() !== "false";
|
||||
const intervalMs = Math.max(env.EXPORT_WORKER_INTERVAL_MS || 2000, 500);
|
||||
const batchSize = Math.min(Math.max(env.EXPORT_WORKER_BATCH_SIZE || 2, 1), 10);
|
||||
const staleRunningMs = Math.max(env.EXPORT_JOB_STALE_RUNNING_MS || 900000, 60000);
|
||||
const maxSettlementAdjustmentRows = Math.min(Math.max(env.EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS || 5000, 1), 10000);
|
||||
const exportStorageDir = path.resolve(process.cwd(), env.EXPORT_STORAGE_DIR || "./storage/exports");
|
||||
const retentionDays = Math.max(env.EXPORT_RETENTION_DAYS || 7, 1);
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let running = false;
|
||||
let lastStartedAt: string | null = null;
|
||||
let lastFinishedAt: string | null = null;
|
||||
let lastError: string | null = null;
|
||||
let lastProcessedJobId: string | null = null;
|
||||
let lastProcessedCount = 0;
|
||||
let staleResetCount = 0;
|
||||
let expiredCleanupCount = 0;
|
||||
let staleResetDone = false;
|
||||
|
||||
function exportExpiresAt() {
|
||||
return new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function safeFilename(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9._-]/g, "-");
|
||||
}
|
||||
|
||||
async function writeExportResult(job: ExportJobEntity, filename: string, body: string) {
|
||||
await fs.mkdir(exportStorageDir, { recursive: true });
|
||||
const safeName = safeFilename(filename);
|
||||
const filePath = path.join(exportStorageDir, `${job.id}-${safeName}`);
|
||||
await fs.writeFile(filePath, body, "utf8");
|
||||
return {
|
||||
path: filePath,
|
||||
size: Buffer.byteLength(body, "utf8")
|
||||
};
|
||||
}
|
||||
|
||||
function parseSettlementAdjustmentType(value?: string): "credit" | "debit" | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return value === "credit" || value === "debit" ? value : undefined;
|
||||
}
|
||||
|
||||
function parseSettlementAdjustmentApprovalStatus(value?: string): "pending" | "approved" | "rejected" | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return value === "pending" || value === "approved" || value === "rejected" ? value : undefined;
|
||||
}
|
||||
|
||||
function isIsoDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return !Number.isNaN(date.getTime());
|
||||
}
|
||||
|
||||
function normalizeSettlementAdjustmentExportRequest(payload: SettlementAdjustmentExportRequest) {
|
||||
const merchantId = payload.merchant_id?.trim();
|
||||
const adjustmentType = parseSettlementAdjustmentType(payload.adjustment_type);
|
||||
const approvalStatus = parseSettlementAdjustmentApprovalStatus(payload.approval_status);
|
||||
const from = payload.from?.trim();
|
||||
const to = payload.to?.trim();
|
||||
const limit = payload.limit ? Number(payload.limit) : 500;
|
||||
|
||||
if (payload.adjustment_type && !adjustmentType) {
|
||||
throw new ApiError("BAD_REQUEST", "adjustment_type must be credit|debit", 400);
|
||||
}
|
||||
if (payload.approval_status && !approvalStatus) {
|
||||
throw new ApiError("BAD_REQUEST", "approval_status must be pending|approved|rejected", 400);
|
||||
}
|
||||
if (from && !isIsoDate(from)) {
|
||||
throw new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400);
|
||||
}
|
||||
if (to && !isIsoDate(to)) {
|
||||
throw new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400);
|
||||
}
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
throw new ApiError("BAD_REQUEST", "limit must be a positive number", 400);
|
||||
}
|
||||
|
||||
return {
|
||||
merchant_id: merchantId || undefined,
|
||||
adjustment_type: adjustmentType,
|
||||
approval_status: approvalStatus,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
limit: Math.min(limit, maxSettlementAdjustmentRows),
|
||||
max_limit: maxSettlementAdjustmentRows
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSettlementAdjustmentExportJob(payload: {
|
||||
requested_by?: string;
|
||||
request: SettlementAdjustmentExportRequest;
|
||||
}) {
|
||||
const requestJson = normalizeSettlementAdjustmentExportRequest(payload.request);
|
||||
return createExportJob({
|
||||
job_type: "settlement_adjustments_csv",
|
||||
requested_by: payload.requested_by || "admin",
|
||||
request_json: requestJson
|
||||
});
|
||||
}
|
||||
|
||||
async function processSettlementAdjustmentExport(job: ExportJobEntity) {
|
||||
const report = await listSettlementAdjustments({
|
||||
...(job.request_json || {}),
|
||||
max_limit: maxSettlementAdjustmentRows
|
||||
});
|
||||
const csv = settlementAdjustmentReportToCsv(report);
|
||||
const filename = `settlement-adjustment-report-${job.id}.csv`;
|
||||
const stored = await writeExportResult(job, filename, csv);
|
||||
return completeExportJob(job.id, {
|
||||
result_content_type: "text/csv; charset=utf-8",
|
||||
result_filename: filename,
|
||||
result_storage_path: stored.path,
|
||||
result_size_bytes: stored.size,
|
||||
expires_at: exportExpiresAt()
|
||||
});
|
||||
}
|
||||
|
||||
async function processExportJob(job: ExportJobEntity) {
|
||||
if (job.job_type === "settlement_adjustments_csv") {
|
||||
return processSettlementAdjustmentExport(job);
|
||||
}
|
||||
throw new Error(`unsupported export job type: ${job.job_type}`);
|
||||
}
|
||||
|
||||
async function runWorkerOnce() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
lastStartedAt = new Date().toISOString();
|
||||
lastError = null;
|
||||
lastProcessedCount = 0;
|
||||
|
||||
try {
|
||||
if (!staleResetDone) {
|
||||
await resetStaleRunningJobs();
|
||||
await cleanupExpiredExports();
|
||||
staleResetDone = true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < batchSize; i += 1) {
|
||||
const job = await claimNextPendingExportJob();
|
||||
if (!job) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await processExportJob(job);
|
||||
lastProcessedJobId = job.id;
|
||||
lastProcessedCount += 1;
|
||||
logger.info("export_job_completed", { job_id: job.id, job_type: job.job_type });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "unknown";
|
||||
await failExportJob(job.id, message);
|
||||
lastError = message;
|
||||
logger.error("export_job_failed", { job_id: job.id, job_type: job.job_type, error });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : "unknown";
|
||||
logger.error("export_worker_run_failed", { error });
|
||||
} finally {
|
||||
running = false;
|
||||
lastFinishedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
async function resetStaleRunningJobs() {
|
||||
const cutoffIso = new Date(Date.now() - staleRunningMs).toISOString();
|
||||
const resetCount = await resetStaleRunningExportJobs(cutoffIso);
|
||||
staleResetCount += resetCount;
|
||||
if (resetCount > 0) {
|
||||
logger.warn("export_jobs_reset_stale_running", { reset_count: resetCount, cutoff_at: cutoffIso });
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupExpiredExports() {
|
||||
const expired = await markExpiredExportJobs(new Date().toISOString());
|
||||
for (const job of expired) {
|
||||
if (!job.result_storage_path) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.unlink(job.result_storage_path);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
logger.warn("export_result_file_delete_failed", {
|
||||
job_id: job.id,
|
||||
path: job.result_storage_path,
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
expiredCleanupCount += expired.length;
|
||||
if (expired.length > 0) {
|
||||
logger.info("export_results_expired", { count: expired.length });
|
||||
}
|
||||
}
|
||||
|
||||
export function startExportJobWorker() {
|
||||
if (!enabled || timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer = setInterval(() => {
|
||||
void runWorkerOnce();
|
||||
}, intervalMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function getExportJobWorkerStatus(): WorkerStatus {
|
||||
return {
|
||||
enabled,
|
||||
running,
|
||||
interval_ms: intervalMs,
|
||||
batch_size: batchSize,
|
||||
max_settlement_adjustment_rows: maxSettlementAdjustmentRows,
|
||||
storage_dir: exportStorageDir,
|
||||
retention_days: retentionDays,
|
||||
last_started_at: lastStartedAt,
|
||||
last_finished_at: lastFinishedAt,
|
||||
last_error: lastError,
|
||||
last_processed_job_id: lastProcessedJobId,
|
||||
last_processed_count: lastProcessedCount,
|
||||
stale_reset_count: staleResetCount,
|
||||
expired_cleanup_count: expiredCleanupCount
|
||||
};
|
||||
}
|
||||
42
src/shared/services/health.ts
Normal file
42
src/shared/services/health.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { getPool } from "../db/pool";
|
||||
import { getMqttPublisherStatus } from "./mqttPublisher";
|
||||
import { getMqttSubscriberStatus } from "./mqttSubscriber";
|
||||
|
||||
export async function getDatabaseHealth() {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
try {
|
||||
const result = await getPool().query("SELECT NOW() AS now");
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
||||
return {
|
||||
status: "ok" as const,
|
||||
latency_ms: Number(durationMs.toFixed(2)),
|
||||
server_time: result.rows[0]?.now || null
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
||||
return {
|
||||
status: "error" as const,
|
||||
latency_ms: Number(durationMs.toFixed(2)),
|
||||
error: error instanceof Error ? error.message : "unknown"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServiceHealth() {
|
||||
const database = await getDatabaseHealth();
|
||||
const mqttPublisher = getMqttPublisherStatus();
|
||||
const mqttSubscriber = getMqttSubscriberStatus();
|
||||
const brokerRequired = mqttPublisher.mode === "broker";
|
||||
const mqttOk = !brokerRequired || mqttPublisher.connected || mqttPublisher.publish_attempt_count === 0;
|
||||
|
||||
return {
|
||||
status: database.status === "ok" && mqttOk ? "healthy" : "degraded",
|
||||
checks: {
|
||||
database,
|
||||
mqtt: {
|
||||
publisher: mqttPublisher,
|
||||
subscriber: mqttSubscriber
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
76
src/shared/services/logger.ts
Normal file
76
src/shared/services/logger.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { env } from "../../config/env";
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const levelRank: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40
|
||||
};
|
||||
|
||||
function normalizeLevel(value: string): LogLevel {
|
||||
if (value === "debug" || value === "info" || value === "warn" || value === "error") {
|
||||
return value;
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
function shouldLog(level: LogLevel) {
|
||||
return levelRank[level] >= levelRank[normalizeLevel(env.LOG_LEVEL)];
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return error;
|
||||
}
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: env.LOG_FORMAT === "json" ? error.stack : undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function log(level: LogLevel, event: string, fields?: Record<string, unknown>) {
|
||||
if (!shouldLog(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
event,
|
||||
...(fields || {})
|
||||
};
|
||||
|
||||
if (env.LOG_FORMAT === "json") {
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(payload).map(([key, value]) => [key, key === "error" ? serializeError(value) : value])
|
||||
);
|
||||
const line = JSON.stringify(normalized);
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
} else if (level === "warn") {
|
||||
console.warn(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = fields ? ` ${JSON.stringify(fields, (_key, value) => (value instanceof Error ? serializeError(value) : value))}` : "";
|
||||
if (level === "error") {
|
||||
console.error(`[${level}] ${event}${suffix}`);
|
||||
} else if (level === "warn") {
|
||||
console.warn(`[${level}] ${event}${suffix}`);
|
||||
} else {
|
||||
console.log(`[${level}] ${event}${suffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (event: string, fields?: Record<string, unknown>) => log("debug", event, fields),
|
||||
info: (event: string, fields?: Record<string, unknown>) => log("info", event, fields),
|
||||
warn: (event: string, fields?: Record<string, unknown>) => log("warn", event, fields),
|
||||
error: (event: string, fields?: Record<string, unknown>) => log("error", event, fields)
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
import { env } from "../../config/env";
|
||||
import mqtt, { type IClientOptions, type MqttClient } from "mqtt";
|
||||
|
||||
type PaymentSuccessPayload = {
|
||||
message_type: "payment_success";
|
||||
@ -47,11 +48,102 @@ const forcedFailDevices = new Set(
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
const publishMode = String(env.MQTT_PUBLISH_MODE || "simulator").toLowerCase();
|
||||
let brokerClientPromise: Promise<MqttClient> | null = null;
|
||||
let brokerClientRef: MqttClient | null = null;
|
||||
let lastConnectedAt: string | null = null;
|
||||
let lastDisconnectedAt: string | null = null;
|
||||
let lastError: { message: string; at: string } | null = null;
|
||||
let publishAttemptCount = 0;
|
||||
let publishSuccessCount = 0;
|
||||
let publishFailureCount = 0;
|
||||
|
||||
function shouldForceFail(deviceId: string): boolean {
|
||||
return forcedFailAll || forcedFailDevices.has(deviceId);
|
||||
}
|
||||
|
||||
function getBrokerClient(): Promise<MqttClient> {
|
||||
if (brokerClientPromise) {
|
||||
return brokerClientPromise;
|
||||
}
|
||||
|
||||
brokerClientPromise = new Promise((resolve, reject) => {
|
||||
if (!env.MQTT_BROKER_URL) {
|
||||
reject(new Error("MQTT_BROKER_URL_MISSING"));
|
||||
return;
|
||||
}
|
||||
|
||||
const options: IClientOptions = {
|
||||
clientId: env.MQTT_CLIENT_ID,
|
||||
username: env.MQTT_USERNAME || undefined,
|
||||
password: env.MQTT_PASSWORD || undefined,
|
||||
connectTimeout: env.MQTT_CONNECT_TIMEOUT_MS,
|
||||
reconnectPeriod: 5000,
|
||||
clean: true
|
||||
};
|
||||
const client = mqtt.connect(env.MQTT_BROKER_URL, options);
|
||||
const timeout = setTimeout(() => {
|
||||
client.end(true);
|
||||
brokerClientPromise = null;
|
||||
reject(new Error("MQTT_BROKER_CONNECT_TIMEOUT"));
|
||||
}, env.MQTT_CONNECT_TIMEOUT_MS + 1000);
|
||||
|
||||
const failBeforeConnect = (error: Error) => {
|
||||
clearTimeout(timeout);
|
||||
brokerClientPromise = null;
|
||||
lastError = { message: error.message, at: new Date().toISOString() };
|
||||
client.end(true);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
client.once("connect", () => {
|
||||
clearTimeout(timeout);
|
||||
client.removeListener("error", failBeforeConnect);
|
||||
brokerClientRef = client;
|
||||
lastConnectedAt = new Date().toISOString();
|
||||
resolve(client);
|
||||
});
|
||||
|
||||
client.once("error", failBeforeConnect);
|
||||
client.on("error", (error) => {
|
||||
lastError = { message: error.message, at: new Date().toISOString() };
|
||||
});
|
||||
client.on("close", () => {
|
||||
if (!client.connected) {
|
||||
brokerClientPromise = null;
|
||||
brokerClientRef = null;
|
||||
lastDisconnectedAt = new Date().toISOString();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return brokerClientPromise;
|
||||
}
|
||||
|
||||
async function publishToBroker<TPayload>(
|
||||
topic: string,
|
||||
payload: TPayload
|
||||
): Promise<void> {
|
||||
const client = await getBrokerClient();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.publish(
|
||||
topic,
|
||||
JSON.stringify(payload),
|
||||
{
|
||||
qos: 1,
|
||||
retain: false
|
||||
},
|
||||
(error?: Error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPaymentSuccessPayload(
|
||||
input: {
|
||||
transaction_id: string;
|
||||
@ -101,8 +193,10 @@ async function publishMqttPayload<TPayload>(
|
||||
payload: TPayload
|
||||
): Promise<MqttPublishResult<TPayload>> {
|
||||
const publishedAt = new Date().toISOString();
|
||||
publishAttemptCount += 1;
|
||||
|
||||
if (shouldForceFail(deviceId)) {
|
||||
publishFailureCount += 1;
|
||||
return {
|
||||
ok: false,
|
||||
topic,
|
||||
@ -114,6 +208,25 @@ async function publishMqttPayload<TPayload>(
|
||||
};
|
||||
}
|
||||
|
||||
if (publishMode === "broker") {
|
||||
try {
|
||||
await publishToBroker(topic, payload);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "unknown";
|
||||
publishFailureCount += 1;
|
||||
return {
|
||||
ok: false,
|
||||
topic,
|
||||
qos: 1,
|
||||
retained: false,
|
||||
publishedAt,
|
||||
reason: `MQTT_BROKER_PUBLISH_FAILED:${message}`,
|
||||
payload
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
publishSuccessCount += 1;
|
||||
return {
|
||||
ok: true,
|
||||
topic,
|
||||
@ -135,3 +248,26 @@ export async function publishDynamicQrResponse(deviceId: string, payload: Dynami
|
||||
export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) {
|
||||
return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload);
|
||||
}
|
||||
|
||||
export function getMqttPublisherStatus() {
|
||||
const mode = publishMode === "broker" ? "broker" : "simulator";
|
||||
const brokerUrl = env.MQTT_BROKER_URL
|
||||
? env.MQTT_BROKER_URL.replace(/(mqtts?:\/\/)([^:@/]+):([^@/]+)@/i, "$1***:***@")
|
||||
: "";
|
||||
|
||||
return {
|
||||
mode,
|
||||
broker_url: mode === "broker" ? brokerUrl : null,
|
||||
client_id: mode === "broker" ? env.MQTT_CLIENT_ID : null,
|
||||
username: mode === "broker" ? env.MQTT_USERNAME || null : null,
|
||||
connected: mode === "broker" ? Boolean(brokerClientRef?.connected) : true,
|
||||
last_connected_at: lastConnectedAt,
|
||||
last_disconnected_at: lastDisconnectedAt,
|
||||
last_error: lastError,
|
||||
publish_attempt_count: publishAttemptCount,
|
||||
publish_success_count: publishSuccessCount,
|
||||
publish_failure_count: publishFailureCount,
|
||||
forced_fail_all: forcedFailAll,
|
||||
forced_fail_device_count: forcedFailDevices.size
|
||||
};
|
||||
}
|
||||
|
||||
140
src/shared/services/mqttSubscriber.ts
Normal file
140
src/shared/services/mqttSubscriber.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import mqtt, { type IClientOptions, type MqttClient } from "mqtt";
|
||||
import { env } from "../../config/env";
|
||||
import { createMqttMessage } from "../store/mqttMessageStore";
|
||||
|
||||
type SubscriberStatus = {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
topics: string[];
|
||||
client_id: string | null;
|
||||
last_connected_at: string | null;
|
||||
last_disconnected_at: string | null;
|
||||
last_message_at: string | null;
|
||||
last_error: { message: string; at: string } | null;
|
||||
received_count: number;
|
||||
recorded_count: number;
|
||||
failed_count: number;
|
||||
};
|
||||
|
||||
const status: SubscriberStatus = {
|
||||
enabled: String(env.MQTT_SUBSCRIBE_ENABLED).toLowerCase() === "true",
|
||||
connected: false,
|
||||
topics: env.MQTT_SUBSCRIBE_TOPICS.split(",").map((topic) => topic.trim()).filter(Boolean),
|
||||
client_id: null,
|
||||
last_connected_at: null,
|
||||
last_disconnected_at: null,
|
||||
last_message_at: null,
|
||||
last_error: null,
|
||||
received_count: 0,
|
||||
recorded_count: 0,
|
||||
failed_count: 0
|
||||
};
|
||||
|
||||
let clientRef: MqttClient | null = null;
|
||||
let started = false;
|
||||
|
||||
function parseTopic(topic: string) {
|
||||
const match = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
device_id: match[1],
|
||||
message_type: match[2].replace(/\//g, "_")
|
||||
};
|
||||
}
|
||||
|
||||
function parsePayload(raw: Buffer): Record<string, unknown> {
|
||||
const text = raw.toString("utf8");
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : { raw_text: text };
|
||||
} catch (_error) {
|
||||
return { raw_text: text };
|
||||
}
|
||||
}
|
||||
|
||||
export function startMqttSubscriber() {
|
||||
if (started || !status.enabled) {
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
|
||||
if (!env.MQTT_BROKER_URL) {
|
||||
status.last_error = { message: "MQTT_BROKER_URL_MISSING", at: new Date().toISOString() };
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = `${env.MQTT_CLIENT_ID}-subscriber`;
|
||||
status.client_id = clientId;
|
||||
const options: IClientOptions = {
|
||||
clientId,
|
||||
username: env.MQTT_USERNAME || undefined,
|
||||
password: env.MQTT_PASSWORD || undefined,
|
||||
connectTimeout: env.MQTT_CONNECT_TIMEOUT_MS,
|
||||
reconnectPeriod: 5000,
|
||||
clean: true
|
||||
};
|
||||
|
||||
const client = mqtt.connect(env.MQTT_BROKER_URL, options);
|
||||
clientRef = client;
|
||||
|
||||
client.on("connect", () => {
|
||||
status.connected = true;
|
||||
status.last_connected_at = new Date().toISOString();
|
||||
for (const topic of status.topics) {
|
||||
client.subscribe(topic, { qos: 1 }, (error) => {
|
||||
if (error) {
|
||||
status.last_error = { message: error.message, at: new Date().toISOString() };
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
client.on("message", (topic, raw) => {
|
||||
status.received_count += 1;
|
||||
status.last_message_at = new Date().toISOString();
|
||||
const parsedTopic = parseTopic(topic);
|
||||
if (!parsedTopic) {
|
||||
status.failed_count += 1;
|
||||
status.last_error = { message: `UNSUPPORTED_TOPIC:${topic}`, at: new Date().toISOString() };
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parsePayload(raw);
|
||||
createMqttMessage({
|
||||
direction: "uplink",
|
||||
device_id: parsedTopic.device_id,
|
||||
topic,
|
||||
message_type: String(payload.message_type || parsedTopic.message_type),
|
||||
correlation_id: typeof payload.correlation_id === "string" ? payload.correlation_id : undefined,
|
||||
payload_json: payload,
|
||||
publish_status: "recorded"
|
||||
})
|
||||
.then(() => {
|
||||
status.recorded_count += 1;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
status.failed_count += 1;
|
||||
const message = error instanceof Error ? error.message : "unknown";
|
||||
status.last_error = { message, at: new Date().toISOString() };
|
||||
});
|
||||
});
|
||||
|
||||
client.on("error", (error) => {
|
||||
status.last_error = { message: error.message, at: new Date().toISOString() };
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
status.connected = false;
|
||||
status.last_disconnected_at = new Date().toISOString();
|
||||
});
|
||||
}
|
||||
|
||||
export function getMqttSubscriberStatus() {
|
||||
return {
|
||||
...status,
|
||||
connected: status.enabled ? Boolean(clientRef?.connected) : false
|
||||
};
|
||||
}
|
||||
124
src/shared/services/sessionToken.ts
Normal file
124
src/shared/services/sessionToken.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export type SessionPayload = {
|
||||
typ: "admin";
|
||||
sub: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role_id: string;
|
||||
role_name: string;
|
||||
permissions: unknown;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export type MerchantSessionPayload = {
|
||||
typ: "merchant";
|
||||
sub: string;
|
||||
merchant_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role_name: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function base64UrlEncode(value: string | Buffer) {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string) {
|
||||
return Buffer.from(value, "base64url").toString("utf8");
|
||||
}
|
||||
|
||||
function sign(unsigned: string, secret: string) {
|
||||
return createHmac("sha256", secret).update(unsigned).digest("base64url");
|
||||
}
|
||||
|
||||
export function createSessionToken(payload: Omit<SessionPayload, "iat" | "exp">, secret: string, ttlSeconds: number) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const body: SessionPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + ttlSeconds
|
||||
};
|
||||
const encoded = base64UrlEncode(JSON.stringify(body));
|
||||
const signature = sign(encoded, secret);
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifySessionToken(token: string, secret: string): SessionPayload | null {
|
||||
const [encoded, signature] = token.split(".");
|
||||
if (!encoded || !signature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expected = sign(encoded, secret);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
const actualBuffer = Buffer.from(signature);
|
||||
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload: SessionPayload;
|
||||
try {
|
||||
payload = JSON.parse(base64UrlDecode(encoded)) as SessionPayload;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.typ !== "admin" || !payload.sub || !payload.role_id || !payload.exp) {
|
||||
return null;
|
||||
}
|
||||
if (payload.exp <= Math.floor(Date.now() / 1000)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createMerchantSessionToken(
|
||||
payload: Omit<MerchantSessionPayload, "iat" | "exp">,
|
||||
secret: string,
|
||||
ttlSeconds: number
|
||||
) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const body: MerchantSessionPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + ttlSeconds
|
||||
};
|
||||
const encoded = base64UrlEncode(JSON.stringify(body));
|
||||
const signature = sign(encoded, secret);
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyMerchantSessionToken(token: string, secret: string): MerchantSessionPayload | null {
|
||||
const [encoded, signature] = token.split(".");
|
||||
if (!encoded || !signature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expected = sign(encoded, secret);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
const actualBuffer = Buffer.from(signature);
|
||||
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload: MerchantSessionPayload;
|
||||
try {
|
||||
payload = JSON.parse(base64UrlDecode(encoded)) as MerchantSessionPayload;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.typ !== "merchant" || !payload.sub || !payload.merchant_id || !payload.exp) {
|
||||
return null;
|
||||
}
|
||||
if (payload.exp <= Math.floor(Date.now() / 1000)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { getPool } from "../db/pool";
|
||||
|
||||
export interface AuditLogEntity {
|
||||
id: string;
|
||||
actor_type: "admin" | "device" | "webhook" | "system";
|
||||
actor_type: "admin" | "merchant" | "device" | "webhook" | "system";
|
||||
actor_id?: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
@ -88,6 +88,7 @@ export async function listAuditLogs(filter?: {
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
action?: string;
|
||||
action_contains?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
@ -111,6 +112,11 @@ export async function listAuditLogs(filter?: {
|
||||
params.push(filter.action);
|
||||
}
|
||||
|
||||
if (filter?.action_contains) {
|
||||
clauses.push(`action ILIKE $${i++}`);
|
||||
params.push(`%${filter.action_contains}%`);
|
||||
}
|
||||
|
||||
if (filter?.from) {
|
||||
clauses.push(`created_at >= $${i++}`);
|
||||
params.push(filter.from);
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export type DeviceCredentialStatus = "not_issued" | "active" | "rotation_required" | "revoked";
|
||||
|
||||
export interface DeviceEntity {
|
||||
id: string;
|
||||
device_code: string;
|
||||
@ -10,6 +12,12 @@ export interface DeviceEntity {
|
||||
communication_mode?: "static" | "mqtt" | "api";
|
||||
capability_profile_json?: Record<string, unknown>;
|
||||
auth_method?: string;
|
||||
mqtt_username?: string;
|
||||
credential_secret_fingerprint?: string;
|
||||
credential_status: DeviceCredentialStatus;
|
||||
credential_issued_at?: string;
|
||||
credential_rotated_at?: string;
|
||||
credential_revoked_at?: string;
|
||||
status: "active" | "inactive";
|
||||
last_seen_at?: string;
|
||||
firmware_version?: string;
|
||||
@ -35,6 +43,12 @@ function mapDevice(row: any): DeviceEntity {
|
||||
communication_mode: row.communication_mode,
|
||||
capability_profile_json: row.capability_profile_json || {},
|
||||
auth_method: row.auth_method || undefined,
|
||||
mqtt_username: row.mqtt_username || undefined,
|
||||
credential_secret_fingerprint: row.credential_secret_fingerprint || undefined,
|
||||
credential_status: row.credential_status || "not_issued",
|
||||
credential_issued_at: row.credential_issued_at || undefined,
|
||||
credential_rotated_at: row.credential_rotated_at || undefined,
|
||||
credential_revoked_at: row.credential_revoked_at || undefined,
|
||||
status: row.status,
|
||||
last_seen_at: row.last_seen_at || undefined,
|
||||
firmware_version: row.firmware_version || undefined,
|
||||
@ -51,6 +65,12 @@ export async function createDevice(payload: {
|
||||
communication_mode?: DeviceEntity["communication_mode"];
|
||||
capability_profile_json?: Record<string, unknown>;
|
||||
auth_method?: string;
|
||||
mqtt_username?: string;
|
||||
credential_secret_fingerprint?: string;
|
||||
credential_status?: DeviceCredentialStatus;
|
||||
credential_issued_at?: string;
|
||||
credential_rotated_at?: string;
|
||||
credential_revoked_at?: string;
|
||||
status?: DeviceEntity["status"];
|
||||
firmware_version?: string;
|
||||
last_seen_at?: string;
|
||||
@ -68,12 +88,18 @@ export async function createDevice(payload: {
|
||||
communication_mode,
|
||||
capability_profile_json,
|
||||
auth_method,
|
||||
mqtt_username,
|
||||
credential_secret_fingerprint,
|
||||
credential_status,
|
||||
credential_issued_at,
|
||||
credential_rotated_at,
|
||||
credential_revoked_at,
|
||||
status,
|
||||
last_seen_at,
|
||||
firmware_version,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
@ -84,6 +110,12 @@ export async function createDevice(payload: {
|
||||
payload.communication_mode || "static",
|
||||
payload.capability_profile_json || {},
|
||||
payload.auth_method || "token",
|
||||
payload.mqtt_username || null,
|
||||
payload.credential_secret_fingerprint || null,
|
||||
payload.credential_status || "not_issued",
|
||||
payload.credential_issued_at || null,
|
||||
payload.credential_rotated_at || null,
|
||||
payload.credential_revoked_at || null,
|
||||
payload.status || "active",
|
||||
payload.last_seen_at || null,
|
||||
payload.firmware_version,
|
||||
@ -122,10 +154,16 @@ export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Pro
|
||||
communication_mode = $6,
|
||||
capability_profile_json = $7,
|
||||
auth_method = $8,
|
||||
status = $9,
|
||||
firmware_version = $10,
|
||||
last_seen_at = $11,
|
||||
updated_at = $12
|
||||
mqtt_username = $9,
|
||||
credential_secret_fingerprint = $10,
|
||||
credential_status = $11,
|
||||
credential_issued_at = $12,
|
||||
credential_rotated_at = $13,
|
||||
credential_revoked_at = $14,
|
||||
status = $15,
|
||||
firmware_version = $16,
|
||||
last_seen_at = $17,
|
||||
updated_at = $18
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
@ -137,6 +175,12 @@ export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Pro
|
||||
merged.communication_mode || "static",
|
||||
merged.capability_profile_json || {},
|
||||
merged.auth_method,
|
||||
merged.mqtt_username || null,
|
||||
merged.credential_secret_fingerprint || null,
|
||||
merged.credential_status || "not_issued",
|
||||
merged.credential_issued_at || null,
|
||||
merged.credential_rotated_at || null,
|
||||
merged.credential_revoked_at || null,
|
||||
merged.status,
|
||||
merged.firmware_version,
|
||||
merged.last_seen_at || null,
|
||||
@ -147,6 +191,58 @@ export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Pro
|
||||
return mapDevice(rows[0]);
|
||||
}
|
||||
|
||||
export function toDevicePayload(device: DeviceEntity) {
|
||||
return { ...device };
|
||||
function fingerprintSecret(secret: string) {
|
||||
return createHash("sha256").update(secret).digest("hex");
|
||||
}
|
||||
|
||||
function safeEqualHex(a: string, b: string) {
|
||||
const left = Buffer.from(a, "hex");
|
||||
const right = Buffer.from(b, "hex");
|
||||
return left.length === right.length && timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
function normalizeMqttUsername(device: DeviceEntity) {
|
||||
return device.id;
|
||||
}
|
||||
|
||||
export async function rotateDeviceMqttCredential(id: string): Promise<{
|
||||
device: DeviceEntity;
|
||||
username: string;
|
||||
password: string;
|
||||
}> {
|
||||
const existing = await getDeviceById(id);
|
||||
if (!existing) {
|
||||
throw new Error("DEVICE_NOT_FOUND");
|
||||
}
|
||||
|
||||
const password = randomBytes(24).toString("base64url");
|
||||
const username = normalizeMqttUsername(existing);
|
||||
const now = nowIso();
|
||||
const updated = await patchDevice(id, {
|
||||
communication_mode: "mqtt",
|
||||
auth_method: "mqtt_username_password",
|
||||
mqtt_username: username,
|
||||
credential_secret_fingerprint: fingerprintSecret(password),
|
||||
credential_status: "active",
|
||||
credential_issued_at: existing.credential_issued_at || now,
|
||||
credential_rotated_at: now,
|
||||
credential_revoked_at: undefined
|
||||
});
|
||||
|
||||
return { device: updated, username, password };
|
||||
}
|
||||
|
||||
export async function verifyDeviceSecret(id: string, secret: string): Promise<DeviceEntity | null> {
|
||||
const device = await getDeviceById(id);
|
||||
if (!device || device.credential_status !== "active" || !device.credential_secret_fingerprint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = fingerprintSecret(secret);
|
||||
return safeEqualHex(candidate, device.credential_secret_fingerprint) ? device : null;
|
||||
}
|
||||
|
||||
export function toDevicePayload(device: DeviceEntity) {
|
||||
const { credential_secret_fingerprint, ...payload } = device;
|
||||
return { ...payload };
|
||||
}
|
||||
|
||||
235
src/shared/store/exportJobStore.ts
Normal file
235
src/shared/store/exportJobStore.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export type ExportJobEntity = {
|
||||
id: string;
|
||||
job_type: string;
|
||||
status: "pending" | "running" | "completed" | "failed";
|
||||
requested_by?: string;
|
||||
request_json: Record<string, unknown>;
|
||||
result_content_type?: string;
|
||||
result_filename?: string;
|
||||
result_body?: string;
|
||||
result_storage_path?: string;
|
||||
result_size_bytes?: number;
|
||||
expires_at?: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
};
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function mapExportJob(row: any): ExportJobEntity {
|
||||
return {
|
||||
id: row.id,
|
||||
job_type: row.job_type,
|
||||
status: row.status,
|
||||
requested_by: row.requested_by || undefined,
|
||||
request_json: row.request_json || {},
|
||||
result_content_type: row.result_content_type || undefined,
|
||||
result_filename: row.result_filename || undefined,
|
||||
result_body: row.result_body || undefined,
|
||||
result_storage_path: row.result_storage_path || undefined,
|
||||
result_size_bytes: row.result_size_bytes === null || row.result_size_bytes === undefined ? undefined : Number(row.result_size_bytes),
|
||||
expires_at: row.expires_at || undefined,
|
||||
error_message: row.error_message || undefined,
|
||||
created_at: row.created_at,
|
||||
started_at: row.started_at || undefined,
|
||||
completed_at: row.completed_at || undefined
|
||||
};
|
||||
}
|
||||
|
||||
export async function createExportJob(payload: {
|
||||
job_type: string;
|
||||
requested_by?: string;
|
||||
request_json?: Record<string, unknown>;
|
||||
}): Promise<ExportJobEntity> {
|
||||
const { rows } = await getPool().query(
|
||||
`INSERT INTO export_jobs (id, job_type, status, requested_by, request_json, created_at)
|
||||
VALUES ($1,$2,'pending',$3,$4,$5)
|
||||
RETURNING *`,
|
||||
[randomUUID(), payload.job_type, payload.requested_by || null, payload.request_json || {}, nowIso()]
|
||||
);
|
||||
return mapExportJob(rows[0]);
|
||||
}
|
||||
|
||||
export async function getExportJobById(id: string): Promise<ExportJobEntity | null> {
|
||||
const { rows } = await getPool().query("SELECT * FROM export_jobs WHERE id = $1", [id]);
|
||||
return rows[0] ? mapExportJob(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function listExportJobs(filter?: {
|
||||
job_type?: string;
|
||||
status?: ExportJobEntity["status"];
|
||||
limit?: number;
|
||||
}): Promise<ExportJobEntity[]> {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
if (filter?.job_type) {
|
||||
clauses.push(`job_type = $${i++}`);
|
||||
params.push(filter.job_type);
|
||||
}
|
||||
if (filter?.status) {
|
||||
clauses.push(`status = $${i++}`);
|
||||
params.push(filter.status);
|
||||
}
|
||||
const limit = Math.min(Math.max(filter?.limit || 20, 1), 100);
|
||||
params.push(limit);
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT * FROM export_jobs ${where} ORDER BY created_at DESC LIMIT $${i}`,
|
||||
params
|
||||
);
|
||||
return rows.map(mapExportJob);
|
||||
}
|
||||
|
||||
export async function markExportJobRunning(id: string): Promise<ExportJobEntity> {
|
||||
const { rows } = await getPool().query(
|
||||
`UPDATE export_jobs
|
||||
SET status = 'running',
|
||||
started_at = COALESCE(started_at, $2)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, nowIso()]
|
||||
);
|
||||
return mapExportJob(rows[0]);
|
||||
}
|
||||
|
||||
export async function claimNextPendingExportJob(): Promise<ExportJobEntity | null> {
|
||||
const { rows } = await getPool().query(
|
||||
`UPDATE export_jobs
|
||||
SET status = 'running',
|
||||
started_at = COALESCE(started_at, $1),
|
||||
error_message = NULL
|
||||
WHERE id = (
|
||||
SELECT id
|
||||
FROM export_jobs
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *`,
|
||||
[nowIso()]
|
||||
);
|
||||
return rows[0] ? mapExportJob(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function resetStaleRunningExportJobs(cutoffIso: string): Promise<number> {
|
||||
const { rowCount } = await getPool().query(
|
||||
`UPDATE export_jobs
|
||||
SET status = 'pending',
|
||||
error_message = 'reset from stale running state'
|
||||
WHERE status = 'running'
|
||||
AND started_at IS NOT NULL
|
||||
AND started_at < $1`,
|
||||
[cutoffIso]
|
||||
);
|
||||
return rowCount || 0;
|
||||
}
|
||||
|
||||
export async function countExportJobsByStatus(): Promise<Record<ExportJobEntity["status"], number>> {
|
||||
const { rows } = await getPool().query("SELECT status, COUNT(*) AS count FROM export_jobs GROUP BY status");
|
||||
const counts: Record<ExportJobEntity["status"], number> = {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (row.status in counts) {
|
||||
counts[row.status as ExportJobEntity["status"]] = Number(row.count || 0);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export async function completeExportJob(
|
||||
id: string,
|
||||
payload: {
|
||||
result_content_type: string;
|
||||
result_filename: string;
|
||||
result_body?: string | null;
|
||||
result_storage_path?: string | null;
|
||||
result_size_bytes?: number;
|
||||
expires_at?: string;
|
||||
}
|
||||
): Promise<ExportJobEntity> {
|
||||
const { rows } = await getPool().query(
|
||||
`UPDATE export_jobs
|
||||
SET status = 'completed',
|
||||
result_content_type = $2,
|
||||
result_filename = $3,
|
||||
result_body = $4,
|
||||
result_storage_path = $5,
|
||||
result_size_bytes = $6,
|
||||
expires_at = $7,
|
||||
completed_at = $8
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
payload.result_content_type,
|
||||
payload.result_filename,
|
||||
payload.result_body || null,
|
||||
payload.result_storage_path || null,
|
||||
payload.result_size_bytes || null,
|
||||
payload.expires_at || null,
|
||||
nowIso()
|
||||
]
|
||||
);
|
||||
return mapExportJob(rows[0]);
|
||||
}
|
||||
|
||||
export async function markExpiredExportJobs(cutoffIso: string): Promise<ExportJobEntity[]> {
|
||||
const { rows } = await getPool().query(
|
||||
`UPDATE export_jobs
|
||||
SET result_body = NULL,
|
||||
result_storage_path = NULL,
|
||||
error_message = COALESCE(error_message, 'export result expired')
|
||||
WHERE status = 'completed'
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at < $1
|
||||
AND (result_body IS NOT NULL OR result_storage_path IS NOT NULL)
|
||||
RETURNING *`,
|
||||
[cutoffIso]
|
||||
);
|
||||
return rows.map(mapExportJob);
|
||||
}
|
||||
|
||||
export async function failExportJob(id: string, error: string): Promise<ExportJobEntity> {
|
||||
const { rows } = await getPool().query(
|
||||
`UPDATE export_jobs
|
||||
SET status = 'failed',
|
||||
error_message = $2,
|
||||
completed_at = $3
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, error, nowIso()]
|
||||
);
|
||||
return mapExportJob(rows[0]);
|
||||
}
|
||||
|
||||
export function toExportJobPayload(job: ExportJobEntity) {
|
||||
return {
|
||||
id: job.id,
|
||||
job_type: job.job_type,
|
||||
status: job.status,
|
||||
requested_by: job.requested_by,
|
||||
request_json: job.request_json,
|
||||
result_content_type: job.result_content_type,
|
||||
result_filename: job.result_filename,
|
||||
result_size_bytes: job.result_size_bytes,
|
||||
expires_at: job.expires_at,
|
||||
error_message: job.error_message,
|
||||
created_at: job.created_at,
|
||||
started_at: job.started_at,
|
||||
completed_at: job.completed_at,
|
||||
download_url: job.status === "completed" && (job.result_body || job.result_storage_path) ? `/admin/exports/${job.id}/download` : null
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { env } from "../../config/env";
|
||||
import { getPool } from "../db/pool";
|
||||
import type { TransactionEntity } from "./transactionStore";
|
||||
|
||||
@ -34,7 +35,29 @@ function mapLedgerEntry(row: any): LedgerEntryEntity {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promise<LedgerEntryEntity> {
|
||||
function roundMoney(value: number) {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function calculatePlatformFee(amount: number) {
|
||||
const feeBps = Math.max(0, env.FINANCE_PLATFORM_FEE_BPS);
|
||||
const feeAmount = roundMoney((amount * feeBps) / 10000);
|
||||
return {
|
||||
fee_bps: feeBps,
|
||||
platform_fee_amount: feeAmount,
|
||||
merchant_payable_amount: roundMoney(amount - feeAmount)
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertLedgerEntry(payload: {
|
||||
transaction_id: string;
|
||||
merchant_id: string;
|
||||
entry_type: LedgerEntryEntity["entry_type"];
|
||||
amount: number;
|
||||
currency: string;
|
||||
direction: LedgerEntryEntity["direction"];
|
||||
metadata_json: Record<string, unknown>;
|
||||
}): Promise<LedgerEntryEntity> {
|
||||
const { rows } = await getPool().query(
|
||||
`INSERT INTO ledger_entries (
|
||||
id,
|
||||
@ -51,22 +74,19 @@ export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promis
|
||||
ON CONFLICT (transaction_id, entry_type) DO UPDATE
|
||||
SET amount = EXCLUDED.amount,
|
||||
currency = EXCLUDED.currency,
|
||||
direction = EXCLUDED.direction,
|
||||
metadata_json = ledger_entries.metadata_json || EXCLUDED.metadata_json
|
||||
RETURNING *`,
|
||||
[
|
||||
randomUUID(),
|
||||
tx.id,
|
||||
tx.merchant_id,
|
||||
"gross_income",
|
||||
tx.amount,
|
||||
tx.currency,
|
||||
"credit",
|
||||
payload.transaction_id,
|
||||
payload.merchant_id,
|
||||
payload.entry_type,
|
||||
payload.amount,
|
||||
payload.currency,
|
||||
payload.direction,
|
||||
"posted",
|
||||
{
|
||||
placeholder: true,
|
||||
source: "fase1_paid_transaction",
|
||||
partner_reference: tx.partner_reference
|
||||
},
|
||||
payload.metadata_json,
|
||||
nowIso()
|
||||
]
|
||||
);
|
||||
@ -74,6 +94,61 @@ export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promis
|
||||
return mapLedgerEntry(rows[0]);
|
||||
}
|
||||
|
||||
export async function createPaidLedgerEntries(tx: TransactionEntity): Promise<LedgerEntryEntity[]> {
|
||||
const fee = calculatePlatformFee(tx.amount);
|
||||
const baseMetadata = {
|
||||
source: "fase1_finance_light",
|
||||
partner_reference: tx.partner_reference,
|
||||
fee_bps: fee.fee_bps
|
||||
};
|
||||
|
||||
const gross = await upsertLedgerEntry({
|
||||
transaction_id: tx.id,
|
||||
merchant_id: tx.merchant_id,
|
||||
entry_type: "gross_income",
|
||||
amount: tx.amount,
|
||||
currency: tx.currency,
|
||||
direction: "credit",
|
||||
metadata_json: {
|
||||
...baseMetadata,
|
||||
note: "gross paid amount"
|
||||
}
|
||||
});
|
||||
|
||||
const platformFee = await upsertLedgerEntry({
|
||||
transaction_id: tx.id,
|
||||
merchant_id: tx.merchant_id,
|
||||
entry_type: "platform_fee",
|
||||
amount: fee.platform_fee_amount,
|
||||
currency: tx.currency,
|
||||
direction: "debit",
|
||||
metadata_json: {
|
||||
...baseMetadata,
|
||||
note: "platform fee deducted from merchant payable"
|
||||
}
|
||||
});
|
||||
|
||||
const merchantPayable = await upsertLedgerEntry({
|
||||
transaction_id: tx.id,
|
||||
merchant_id: tx.merchant_id,
|
||||
entry_type: "merchant_payable",
|
||||
amount: fee.merchant_payable_amount,
|
||||
currency: tx.currency,
|
||||
direction: "credit",
|
||||
metadata_json: {
|
||||
...baseMetadata,
|
||||
note: "net merchant payable after platform fee"
|
||||
}
|
||||
});
|
||||
|
||||
return [gross, platformFee, merchantPayable];
|
||||
}
|
||||
|
||||
export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promise<LedgerEntryEntity> {
|
||||
const [gross] = await createPaidLedgerEntries(tx);
|
||||
return gross;
|
||||
}
|
||||
|
||||
export async function listLedgerEntries(filter?: {
|
||||
transaction_id?: string;
|
||||
merchant_id?: string;
|
||||
|
||||
@ -104,6 +104,14 @@ export async function getMerchantById(id: string): Promise<MerchantEntity | null
|
||||
return mapRowToMerchant(rows[0]);
|
||||
}
|
||||
|
||||
export async function getMerchantByCode(code: string): Promise<MerchantEntity | null> {
|
||||
const { rows } = await getPool().query("SELECT * FROM merchants WHERE merchant_code = $1", [code]);
|
||||
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);
|
||||
|
||||
72
src/shared/store/merchantUserStore.ts
Normal file
72
src/shared/store/merchantUserStore.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { scryptSync, timingSafeEqual } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export type MerchantUserEntity = {
|
||||
id: string;
|
||||
merchant_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role_name: "owner" | "finance" | "ops" | "viewer";
|
||||
status: "active" | "inactive";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function mapMerchantUser(row: any): MerchantUserEntity {
|
||||
return {
|
||||
id: row.id,
|
||||
merchant_id: row.merchant_id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
password_hash: row.password_hash,
|
||||
role_name: row.role_name,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMerchantUserByEmail(email: string): Promise<MerchantUserEntity | null> {
|
||||
const { rows } = await getPool().query(
|
||||
"SELECT * FROM merchant_users WHERE LOWER(email) = LOWER($1) LIMIT 1",
|
||||
[email]
|
||||
);
|
||||
return rows[0] ? mapMerchantUser(rows[0]) : null;
|
||||
}
|
||||
|
||||
function verifyScryptPassword(stored: string, password: string) {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 4 || parts[0] !== "scrypt") {
|
||||
return false;
|
||||
}
|
||||
const [, salt, keyLengthRaw, expectedHex] = parts;
|
||||
const keyLength = Number(keyLengthRaw);
|
||||
if (!salt || !Number.isFinite(keyLength) || keyLength <= 0 || !expectedHex) {
|
||||
return false;
|
||||
}
|
||||
const expected = Buffer.from(expectedHex, "hex");
|
||||
const actual = scryptSync(password, salt, keyLength);
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
export function verifyMerchantPassword(stored: string, password: string) {
|
||||
if (stored.startsWith("scrypt$")) {
|
||||
return verifyScryptPassword(stored, password);
|
||||
}
|
||||
|
||||
return stored === password;
|
||||
}
|
||||
|
||||
export function toMerchantUserPayload(user: MerchantUserEntity) {
|
||||
return {
|
||||
id: user.id,
|
||||
merchant_id: user.merchant_id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role_name: user.role_name,
|
||||
status: user.status,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at
|
||||
};
|
||||
}
|
||||
1417
src/shared/store/settlementStore.ts
Normal file
1417
src/shared/store/settlementStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
import { createPaidLedgerPlaceholder } from "./ledgerStore";
|
||||
import { createPaidLedgerEntries } from "./ledgerStore";
|
||||
|
||||
export type TransactionStatus =
|
||||
| "initiated"
|
||||
@ -269,7 +269,7 @@ export async function updateTransactionStatus(
|
||||
|
||||
const updated = mapTransaction(rows[0]);
|
||||
if (to === "paid") {
|
||||
await createPaidLedgerPlaceholder(updated);
|
||||
await createPaidLedgerEntries(updated);
|
||||
}
|
||||
|
||||
return updated;
|
||||
|
||||
107
src/shared/store/userStore.ts
Normal file
107
src/shared/store/userStore.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { scryptSync, timingSafeEqual } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
|
||||
export type RoleEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions_json: unknown;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type UserEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role_id: string;
|
||||
status: "active" | "inactive";
|
||||
created_at: string;
|
||||
role_name: string;
|
||||
permissions_json: unknown;
|
||||
};
|
||||
|
||||
function mapUser(row: any): UserEntity {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
password_hash: row.password_hash,
|
||||
role_id: row.role_id,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
role_name: row.role_name,
|
||||
permissions_json: row.permissions_json
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string): Promise<UserEntity | null> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT users.*, roles.name AS role_name, roles.permissions_json
|
||||
FROM users
|
||||
JOIN roles ON roles.id = users.role_id
|
||||
WHERE LOWER(users.email) = LOWER($1)
|
||||
LIMIT 1`,
|
||||
[email]
|
||||
);
|
||||
return rows[0] ? mapUser(rows[0]) : null;
|
||||
}
|
||||
|
||||
function verifyScryptPassword(stored: string, password: string) {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 4 || parts[0] !== "scrypt") {
|
||||
return false;
|
||||
}
|
||||
const [, salt, keyLengthRaw, expectedHex] = parts;
|
||||
const keyLength = Number(keyLengthRaw);
|
||||
if (!salt || !Number.isFinite(keyLength) || keyLength <= 0 || !expectedHex) {
|
||||
return false;
|
||||
}
|
||||
const expected = Buffer.from(expectedHex, "hex");
|
||||
const actual = scryptSync(password, salt, keyLength);
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
export function verifyPassword(stored: string, password: string) {
|
||||
if (stored.startsWith("scrypt$")) {
|
||||
return verifyScryptPassword(stored, password);
|
||||
}
|
||||
|
||||
return stored === password;
|
||||
}
|
||||
|
||||
export function hasPermission(permissions: unknown, permission: string) {
|
||||
if (!permission) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(permissions)) {
|
||||
return permissions.includes("*") || permissions.includes(permission);
|
||||
}
|
||||
if (!permissions || typeof permissions !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const adminValue = (permissions as Record<string, unknown>).admin;
|
||||
if (adminValue === "*" || adminValue === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = (permissions as Record<string, unknown>)[permission];
|
||||
if (value === true || value === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [domain, action] = permission.split(":");
|
||||
const domainValue = (permissions as Record<string, unknown>)[domain];
|
||||
if (domainValue === "*" || domainValue === true) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(domainValue)) {
|
||||
return domainValue.includes("*") || domainValue.includes(action);
|
||||
}
|
||||
if (domainValue && typeof domainValue === "object") {
|
||||
const scoped = domainValue as Record<string, unknown>;
|
||||
return scoped[action] === true || scoped[action] === "*";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user