Implement phase 1 completion and phase 2 dynamic QR

This commit is contained in:
2026-05-26 08:06:48 +07:00
parent a152c99cce
commit 5624b92872
36 changed files with 3104 additions and 71 deletions

View File

@ -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;
`;

View File

@ -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();
};

View 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")
};
}

View 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
};
}

View File

@ -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
View 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
View 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
View 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 };
}

View File

@ -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
View 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 };
}

View File

@ -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]);