Implement phase 1 completion and phase 2 dynamic QR
This commit is contained in:
95
dist/shared/db/pool.js
vendored
95
dist/shared/db/pool.js
vendored
@ -134,6 +134,41 @@ CREATE TABLE IF NOT EXISTS device_commands (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mqtt_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
direction TEXT NOT NULL CHECK (direction IN ('uplink', 'downlink')),
|
||||
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
|
||||
topic TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
correlation_id TEXT,
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
publish_status TEXT NOT NULL DEFAULT 'recorded' CHECK (publish_status IN ('recorded', 'sent', 'failed')),
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mqtt_messages_device_created ON mqtt_messages (device_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_mqtt_messages_correlation ON mqtt_messages (correlation_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_configs (
|
||||
device_id TEXT PRIMARY KEY REFERENCES devices (id) ON DELETE CASCADE,
|
||||
config_version INT NOT NULL,
|
||||
settings_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_config_acks (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
|
||||
config_version INT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('applied', 'failed')),
|
||||
reason TEXT,
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
acked_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_device_config_acks_device ON device_config_acks (device_id, acked_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
transaction_code TEXT NOT NULL UNIQUE,
|
||||
@ -191,5 +226,65 @@ CREATE TABLE IF NOT EXISTS notifications (
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_device_status ON notifications (device_id, delivery_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_status_created ON notifications (delivery_status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
|
||||
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
|
||||
entry_type TEXT NOT NULL CHECK (entry_type IN ('gross_income', 'platform_fee', 'merchant_payable')),
|
||||
amount NUMERIC(20,2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'IDR',
|
||||
direction TEXT NOT NULL CHECK (direction IN ('credit', 'debit')),
|
||||
status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'voided')),
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
CONSTRAINT ledger_entries_unique_tx_type UNIQUE (transaction_id, entry_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_entries_merchant_created ON ledger_entries (merchant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_entries_tx ON ledger_entries (transaction_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
permissions_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role_id TEXT NOT NULL REFERENCES roles (id),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_type TEXT NOT NULL,
|
||||
actor_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
before_json JSONB,
|
||||
after_json JSONB,
|
||||
source_ip TEXT,
|
||||
request_id TEXT,
|
||||
trace_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs (entity_type, entity_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs (action, created_at DESC);
|
||||
|
||||
INSERT INTO roles (id, name, permissions_json, created_at)
|
||||
VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO users (id, name, email, password_hash, role_id, status, created_at)
|
||||
VALUES ('user_admin_seed', 'Admin Seed', 'admin@example.local', 'dev-only-admin-password', 'role_admin', 'active', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
`;
|
||||
|
||||
27
dist/shared/middleware/idempotency.js
vendored
27
dist/shared/middleware/idempotency.js
vendored
@ -1,6 +1,7 @@
|
||||
import { ApiError } from "../errors";
|
||||
import { readIdempotency, writeIdempotency } from "../idempotency/idempotencyStore";
|
||||
import { env } from "../../config/env";
|
||||
import { successResponse } from "./errorMiddleware";
|
||||
export function idempotency(options) {
|
||||
return function idempotencyMiddleware(req, _res, next) {
|
||||
const idempotencyKey = req.header("idempotency-key");
|
||||
@ -14,7 +15,20 @@ export function idempotency(options) {
|
||||
if (cached) {
|
||||
const cachedPayload = cached.response ?? cached;
|
||||
const cachedStatus = cached.statusCode || 200;
|
||||
return _res.status(cachedStatus).json(cachedPayload);
|
||||
const payload = (() => {
|
||||
if (cachedPayload &&
|
||||
typeof cachedPayload === "object" &&
|
||||
"data" in cachedPayload &&
|
||||
"request_id" in cachedPayload &&
|
||||
"timestamp" in cachedPayload) {
|
||||
const typed = cachedPayload;
|
||||
typed.request_id = req.requestId;
|
||||
typed.timestamp = new Date().toISOString();
|
||||
return typed;
|
||||
}
|
||||
return cachedPayload;
|
||||
})();
|
||||
return _res.status(cachedStatus).json(payload);
|
||||
}
|
||||
req.body = { ...(req.body || {}), __idempotencyKey: idempotencyKey };
|
||||
const originalJson = _res.json.bind(_res);
|
||||
@ -25,12 +39,19 @@ export function idempotency(options) {
|
||||
return originalStatus(code);
|
||||
};
|
||||
_res.json = function jsonWithStore(payload) {
|
||||
const responsePayload = payload &&
|
||||
typeof payload === "object" &&
|
||||
"data" in payload &&
|
||||
"request_id" in payload &&
|
||||
"timestamp" in payload
|
||||
? successResponse(req, payload.data)
|
||||
: payload;
|
||||
writeIdempotency(options.scope, idempotencyKey, {
|
||||
response: payload,
|
||||
response: responsePayload,
|
||||
statusCode,
|
||||
at: Date.now()
|
||||
}, options.ttlMs || env.IDEMPOTENCY_TTL_MS);
|
||||
return originalJson(payload);
|
||||
return originalJson(responsePayload);
|
||||
};
|
||||
next();
|
||||
};
|
||||
|
||||
26
dist/shared/services/deviceCapabilityResolver.js
vendored
Normal file
26
dist/shared/services/deviceCapabilityResolver.js
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
function getProfile(device) {
|
||||
return (device.capability_profile_json || {});
|
||||
}
|
||||
export function supportsDynamicQrFlow(device, flow) {
|
||||
const profile = getProfile(device);
|
||||
const flows = Array.isArray(profile.flows) ? profile.flows : [];
|
||||
if (flow === "api_direct" && device.communication_mode !== "api") {
|
||||
return false;
|
||||
}
|
||||
if (flow === "mqtt" && device.communication_mode !== "mqtt") {
|
||||
return false;
|
||||
}
|
||||
if (typeof profile.dynamic_qr === "boolean") {
|
||||
return profile.dynamic_qr || flows.includes(`dynamic_qr:${flow}`);
|
||||
}
|
||||
if (profile.dynamic_qr && typeof profile.dynamic_qr === "object") {
|
||||
return Boolean(profile.dynamic_qr[flow]) || flows.includes(`dynamic_qr:${flow}`);
|
||||
}
|
||||
return flows.includes(`dynamic_qr:${flow}`);
|
||||
}
|
||||
export function resolveDeviceCapabilitySummary(device) {
|
||||
return {
|
||||
dynamic_qr_api_direct: supportsDynamicQrFlow(device, "api_direct"),
|
||||
dynamic_qr_mqtt: supportsDynamicQrFlow(device, "mqtt")
|
||||
};
|
||||
}
|
||||
67
dist/shared/services/dynamicQrOrchestrator.js
vendored
Normal file
67
dist/shared/services/dynamicQrOrchestrator.js
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createTransaction, addTransactionEvent, toTransactionPayload } from "../store/transactionStore";
|
||||
function makePartnerReference(requestId) {
|
||||
const clean = requestId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40);
|
||||
return `DYN-${clean || randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
function makeDynamicQrPayload(input) {
|
||||
const amountMinor = Math.round(input.amount * 100);
|
||||
const encoded = Buffer.from(JSON.stringify({
|
||||
type: "QRIS_DYNAMIC_MOCK",
|
||||
transaction_id: input.transactionId,
|
||||
partner_reference: input.partnerReference,
|
||||
amount_minor: amountMinor,
|
||||
currency: input.currency,
|
||||
expires_at: input.expiresAt
|
||||
})).toString("base64url");
|
||||
return `QRIS-DYNAMIC-MOCK.${encoded}`;
|
||||
}
|
||||
export async function createDynamicQrTransaction(input) {
|
||||
const ttlSeconds = Math.min(Math.max(input.expires_in_seconds || 300, 60), 1800);
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
||||
const partnerReference = makePartnerReference(input.request_id);
|
||||
const tx = await createTransaction({
|
||||
merchant_id: input.merchant_id,
|
||||
outlet_id: input.outlet_id,
|
||||
terminal_id: input.terminal_id,
|
||||
device_id: input.device_id,
|
||||
partner_reference: partnerReference,
|
||||
amount: input.amount,
|
||||
currency: input.currency || "IDR",
|
||||
qr_mode: "dynamic",
|
||||
initiation_mode: input.initiation_mode || "dynamic_api",
|
||||
status: "awaiting_payment",
|
||||
expired_at: expiresAt
|
||||
});
|
||||
const qrPayload = makeDynamicQrPayload({
|
||||
transactionId: tx.id,
|
||||
partnerReference,
|
||||
amount: tx.amount,
|
||||
currency: tx.currency,
|
||||
expiresAt
|
||||
});
|
||||
await addTransactionEvent({
|
||||
transaction_id: tx.id,
|
||||
event_type: "DYNAMIC_QR_CREATED",
|
||||
source: "device",
|
||||
payload_json: {
|
||||
request_id: input.request_id,
|
||||
correlation_id: input.request_id,
|
||||
device_id: input.device_id,
|
||||
qr_payload: qrPayload,
|
||||
expires_at: expiresAt,
|
||||
transaction: toTransactionPayload(tx)
|
||||
}
|
||||
});
|
||||
return {
|
||||
request_id: input.request_id,
|
||||
correlation_id: input.request_id,
|
||||
transaction_id: tx.id,
|
||||
transaction_code: tx.transaction_code,
|
||||
qr_type: "dynamic",
|
||||
qr_payload: qrPayload,
|
||||
expires_at: expiresAt,
|
||||
status: "awaiting_payment",
|
||||
partner_reference: partnerReference
|
||||
};
|
||||
}
|
||||
20
dist/shared/services/mqttPublisher.js
vendored
20
dist/shared/services/mqttPublisher.js
vendored
@ -27,10 +27,15 @@ export function buildPaymentSuccessPayload(input) {
|
||||
export function makePaymentSuccessTopic(deviceId) {
|
||||
return `devices/${deviceId}/downlink/payment/success`;
|
||||
}
|
||||
export async function publishPaymentSuccess(payload) {
|
||||
export function makeDynamicQrResponseTopic(deviceId) {
|
||||
return `devices/${deviceId}/downlink/dynamic-qr/response`;
|
||||
}
|
||||
export function makeConfigPushTopic(deviceId) {
|
||||
return `devices/${deviceId}/downlink/config/push`;
|
||||
}
|
||||
async function publishMqttPayload(deviceId, topic, payload) {
|
||||
const publishedAt = new Date().toISOString();
|
||||
const topic = makePaymentSuccessTopic(payload.device_id);
|
||||
if (shouldForceFail(payload.device_id)) {
|
||||
if (shouldForceFail(deviceId)) {
|
||||
return {
|
||||
ok: false,
|
||||
topic,
|
||||
@ -50,3 +55,12 @@ export async function publishPaymentSuccess(payload) {
|
||||
payload
|
||||
};
|
||||
}
|
||||
export async function publishPaymentSuccess(payload) {
|
||||
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
|
||||
}
|
||||
export async function publishDynamicQrResponse(deviceId, payload) {
|
||||
return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload);
|
||||
}
|
||||
export async function publishConfigPush(deviceId, payload) {
|
||||
return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload);
|
||||
}
|
||||
|
||||
84
dist/shared/store/auditLogStore.js
vendored
Normal file
84
dist/shared/store/auditLogStore.js
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function mapAuditLog(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
actor_type: row.actor_type,
|
||||
actor_id: row.actor_id || undefined,
|
||||
action: row.action,
|
||||
entity_type: row.entity_type,
|
||||
entity_id: row.entity_id,
|
||||
before_json: row.before_json || null,
|
||||
after_json: row.after_json || null,
|
||||
source_ip: row.source_ip || undefined,
|
||||
request_id: row.request_id || undefined,
|
||||
trace_id: row.trace_id || undefined,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
export async function createAuditLog(payload) {
|
||||
const { rows } = await getPool().query(`INSERT INTO audit_logs (
|
||||
id,
|
||||
actor_type,
|
||||
actor_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
before_json,
|
||||
after_json,
|
||||
source_ip,
|
||||
request_id,
|
||||
trace_id,
|
||||
created_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING *`, [
|
||||
randomUUID(),
|
||||
payload.actor_type,
|
||||
payload.actor_id || null,
|
||||
payload.action,
|
||||
payload.entity_type,
|
||||
payload.entity_id,
|
||||
payload.before_json || null,
|
||||
payload.after_json || null,
|
||||
payload.source_ip || null,
|
||||
payload.request_id || null,
|
||||
payload.trace_id || null,
|
||||
nowIso()
|
||||
]);
|
||||
return mapAuditLog(rows[0]);
|
||||
}
|
||||
export async function listAuditLogs(filter) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
if (filter?.entity_type) {
|
||||
clauses.push(`entity_type = $${i++}`);
|
||||
params.push(filter.entity_type);
|
||||
}
|
||||
if (filter?.entity_id) {
|
||||
clauses.push(`entity_id = $${i++}`);
|
||||
params.push(filter.entity_id);
|
||||
}
|
||||
if (filter?.action) {
|
||||
clauses.push(`action = $${i++}`);
|
||||
params.push(filter.action);
|
||||
}
|
||||
if (filter?.from) {
|
||||
clauses.push(`created_at >= $${i++}`);
|
||||
params.push(filter.from);
|
||||
}
|
||||
if (filter?.to) {
|
||||
clauses.push(`created_at <= $${i++}`);
|
||||
params.push(filter.to);
|
||||
}
|
||||
const limit = Math.min(Math.max(filter?.limit || 100, 1), 500);
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const { rows } = await getPool().query(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ${limit}`, params);
|
||||
return rows.map(mapAuditLog);
|
||||
}
|
||||
export function toAuditLogPayload(auditLog) {
|
||||
return { ...auditLog };
|
||||
}
|
||||
89
dist/shared/store/deviceConfigStore.js
vendored
Normal file
89
dist/shared/store/deviceConfigStore.js
vendored
Normal file
@ -0,0 +1,89 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
const DEFAULT_SETTINGS = {
|
||||
volume: 80,
|
||||
language: "id-ID",
|
||||
heartbeat_interval_seconds: 60
|
||||
};
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function mapConfig(row) {
|
||||
return {
|
||||
device_id: row.device_id,
|
||||
config_version: Number(row.config_version),
|
||||
settings_json: row.settings_json || {},
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function mapAck(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
device_id: row.device_id,
|
||||
config_version: Number(row.config_version),
|
||||
status: row.status,
|
||||
reason: row.reason || undefined,
|
||||
payload_json: row.payload_json || {},
|
||||
acked_at: row.acked_at
|
||||
};
|
||||
}
|
||||
export async function getDeviceConfig(deviceId) {
|
||||
const { rows } = await getPool().query("SELECT * FROM device_configs WHERE device_id = $1", [deviceId]);
|
||||
return rows[0] ? mapConfig(rows[0]) : null;
|
||||
}
|
||||
export async function getOrCreateDeviceConfig(deviceId) {
|
||||
const existing = await getDeviceConfig(deviceId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return upsertDeviceConfig({
|
||||
device_id: deviceId,
|
||||
settings_json: DEFAULT_SETTINGS
|
||||
});
|
||||
}
|
||||
export async function upsertDeviceConfig(payload) {
|
||||
const existing = await getDeviceConfig(payload.device_id);
|
||||
const nextVersion = payload.config_version || (existing ? existing.config_version + 1 : 1);
|
||||
const { rows } = await getPool().query(`INSERT INTO device_configs (device_id, config_version, settings_json, updated_at)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (device_id) DO UPDATE
|
||||
SET config_version = EXCLUDED.config_version,
|
||||
settings_json = EXCLUDED.settings_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *`, [payload.device_id, nextVersion, payload.settings_json, nowIso()]);
|
||||
return mapConfig(rows[0]);
|
||||
}
|
||||
export async function createDeviceConfigAck(payload) {
|
||||
const { rows } = await getPool().query(`INSERT INTO device_config_acks (
|
||||
id,
|
||||
device_id,
|
||||
config_version,
|
||||
status,
|
||||
reason,
|
||||
payload_json,
|
||||
acked_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING *`, [
|
||||
`cfgack_${randomUUID()}`,
|
||||
payload.device_id,
|
||||
payload.config_version,
|
||||
payload.status,
|
||||
payload.reason || null,
|
||||
payload.payload_json || {},
|
||||
nowIso()
|
||||
]);
|
||||
return mapAck(rows[0]);
|
||||
}
|
||||
export async function listDeviceConfigAcks(deviceId, limit = 50) {
|
||||
const { rows } = await getPool().query(`SELECT * FROM device_config_acks
|
||||
WHERE device_id = $1
|
||||
ORDER BY acked_at DESC
|
||||
LIMIT $2`, [deviceId, Math.min(Math.max(limit, 1), 200)]);
|
||||
return rows.map(mapAck);
|
||||
}
|
||||
export function toDeviceConfigPayload(config) {
|
||||
return { ...config };
|
||||
}
|
||||
export function toDeviceConfigAckPayload(ack) {
|
||||
return { ...ack };
|
||||
}
|
||||
74
dist/shared/store/ledgerStore.js
vendored
Normal file
74
dist/shared/store/ledgerStore.js
vendored
Normal file
@ -0,0 +1,74 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function mapLedgerEntry(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
transaction_id: row.transaction_id,
|
||||
merchant_id: row.merchant_id,
|
||||
entry_type: row.entry_type,
|
||||
amount: Number(row.amount),
|
||||
currency: row.currency,
|
||||
direction: row.direction,
|
||||
status: row.status,
|
||||
metadata_json: row.metadata_json || {},
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
export async function createPaidLedgerPlaceholder(tx) {
|
||||
const { rows } = await getPool().query(`INSERT INTO ledger_entries (
|
||||
id,
|
||||
transaction_id,
|
||||
merchant_id,
|
||||
entry_type,
|
||||
amount,
|
||||
currency,
|
||||
direction,
|
||||
status,
|
||||
metadata_json,
|
||||
created_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (transaction_id, entry_type) DO UPDATE
|
||||
SET amount = EXCLUDED.amount,
|
||||
currency = EXCLUDED.currency,
|
||||
metadata_json = ledger_entries.metadata_json || EXCLUDED.metadata_json
|
||||
RETURNING *`, [
|
||||
randomUUID(),
|
||||
tx.id,
|
||||
tx.merchant_id,
|
||||
"gross_income",
|
||||
tx.amount,
|
||||
tx.currency,
|
||||
"credit",
|
||||
"posted",
|
||||
{
|
||||
placeholder: true,
|
||||
source: "fase1_paid_transaction",
|
||||
partner_reference: tx.partner_reference
|
||||
},
|
||||
nowIso()
|
||||
]);
|
||||
return mapLedgerEntry(rows[0]);
|
||||
}
|
||||
export async function listLedgerEntries(filter) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
if (filter?.transaction_id) {
|
||||
clauses.push(`transaction_id = $${i++}`);
|
||||
params.push(filter.transaction_id);
|
||||
}
|
||||
if (filter?.merchant_id) {
|
||||
clauses.push(`merchant_id = $${i++}`);
|
||||
params.push(filter.merchant_id);
|
||||
}
|
||||
const limit = Math.min(Math.max(filter?.limit || 100, 1), 500);
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const { rows } = await getPool().query(`SELECT * FROM ledger_entries ${where} ORDER BY created_at DESC LIMIT ${limit}`, params);
|
||||
return rows.map(mapLedgerEntry);
|
||||
}
|
||||
export function toLedgerEntryPayload(entry) {
|
||||
return { ...entry };
|
||||
}
|
||||
38
dist/shared/store/locationStore.js
vendored
38
dist/shared/store/locationStore.js
vendored
@ -65,19 +65,45 @@ export async function createTerminal(payload) {
|
||||
return mapTerminal(rows[0]);
|
||||
}
|
||||
export async function listOutlets(filter) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
if (filter?.merchant_id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM outlets WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id]);
|
||||
return rows.map(mapOutlet);
|
||||
clauses.push(`merchant_id = $${i++}`);
|
||||
params.push(filter.merchant_id);
|
||||
}
|
||||
const { rows } = await getPool().query("SELECT * FROM outlets ORDER BY created_at DESC");
|
||||
if (filter?.status) {
|
||||
clauses.push(`status = $${i++}`);
|
||||
params.push(filter.status);
|
||||
}
|
||||
if (filter?.q) {
|
||||
const value = `%${filter.q.toLowerCase()}%`;
|
||||
clauses.push(`(LOWER(name) LIKE $${i++} OR LOWER(outlet_code) LIKE $${i++})`);
|
||||
params.push(value, value);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const { rows } = await getPool().query(`SELECT * FROM outlets ${where} ORDER BY created_at DESC`, params);
|
||||
return rows.map(mapOutlet);
|
||||
}
|
||||
export async function listTerminals(filter) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
if (filter?.outlet_id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM terminals WHERE outlet_id = $1 ORDER BY created_at DESC", [filter.outlet_id]);
|
||||
return rows.map(mapTerminal);
|
||||
clauses.push(`outlet_id = $${i++}`);
|
||||
params.push(filter.outlet_id);
|
||||
}
|
||||
const { rows } = await getPool().query("SELECT * FROM terminals ORDER BY created_at DESC");
|
||||
if (filter?.status) {
|
||||
clauses.push(`status = $${i++}`);
|
||||
params.push(filter.status);
|
||||
}
|
||||
if (filter?.q) {
|
||||
const value = `%${filter.q.toLowerCase()}%`;
|
||||
clauses.push(`(LOWER(terminal_code) LIKE $${i++} OR LOWER(COALESCE(partner_reference, '')) LIKE $${i++})`);
|
||||
params.push(value, value);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const { rows } = await getPool().query(`SELECT * FROM terminals ${where} ORDER BY created_at DESC`, params);
|
||||
return rows.map(mapTerminal);
|
||||
}
|
||||
export async function getOutletById(id) {
|
||||
|
||||
74
dist/shared/store/mqttMessageStore.js
vendored
Normal file
74
dist/shared/store/mqttMessageStore.js
vendored
Normal file
@ -0,0 +1,74 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function mapMessage(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
direction: row.direction,
|
||||
device_id: row.device_id,
|
||||
topic: row.topic,
|
||||
message_type: row.message_type,
|
||||
correlation_id: row.correlation_id || undefined,
|
||||
payload_json: row.payload_json || {},
|
||||
publish_status: row.publish_status,
|
||||
reason: row.reason || undefined,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
export async function createMqttMessage(payload) {
|
||||
const { rows } = await getPool().query(`INSERT INTO mqtt_messages (
|
||||
id,
|
||||
direction,
|
||||
device_id,
|
||||
topic,
|
||||
message_type,
|
||||
correlation_id,
|
||||
payload_json,
|
||||
publish_status,
|
||||
reason,
|
||||
created_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING *`, [
|
||||
`mqtt_${randomUUID()}`,
|
||||
payload.direction,
|
||||
payload.device_id,
|
||||
payload.topic,
|
||||
payload.message_type,
|
||||
payload.correlation_id || null,
|
||||
payload.payload_json || {},
|
||||
payload.publish_status || "recorded",
|
||||
payload.reason || null,
|
||||
nowIso()
|
||||
]);
|
||||
return mapMessage(rows[0]);
|
||||
}
|
||||
export async function listMqttMessages(filter) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
if (filter?.device_id) {
|
||||
clauses.push(`device_id = $${i++}`);
|
||||
params.push(filter.device_id);
|
||||
}
|
||||
if (filter?.direction) {
|
||||
clauses.push(`direction = $${i++}`);
|
||||
params.push(filter.direction);
|
||||
}
|
||||
if (filter?.message_type) {
|
||||
clauses.push(`message_type = $${i++}`);
|
||||
params.push(filter.message_type);
|
||||
}
|
||||
if (filter?.correlation_id) {
|
||||
clauses.push(`correlation_id = $${i++}`);
|
||||
params.push(filter.correlation_id);
|
||||
}
|
||||
const limit = Math.min(Math.max(filter?.limit || 100, 1), 500);
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const { rows } = await getPool().query(`SELECT * FROM mqtt_messages ${where} ORDER BY created_at DESC LIMIT ${limit}`, params);
|
||||
return rows.map(mapMessage);
|
||||
}
|
||||
export function toMqttMessagePayload(message) {
|
||||
return { ...message };
|
||||
}
|
||||
7
dist/shared/store/transactionStore.js
vendored
7
dist/shared/store/transactionStore.js
vendored
@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
import { createPaidLedgerPlaceholder } from "./ledgerStore";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@ -161,7 +162,11 @@ export async function updateTransactionStatus(id, to, options) {
|
||||
...options.eventContext
|
||||
}
|
||||
});
|
||||
return mapTransaction(rows[0]);
|
||||
const updated = mapTransaction(rows[0]);
|
||||
if (to === "paid") {
|
||||
await createPaidLedgerPlaceholder(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
export async function getTransactionById(id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]);
|
||||
|
||||
Reference in New Issue
Block a user