Initial commit
This commit is contained in:
97
dist/shared/store/bindingStore.js
vendored
Normal file
97
dist/shared/store/bindingStore.js
vendored
Normal file
@ -0,0 +1,97 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool, withClient } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function mapBinding(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
device_id: row.device_id,
|
||||
merchant_id: row.merchant_id,
|
||||
outlet_id: row.outlet_id,
|
||||
terminal_id: row.terminal_id,
|
||||
active_flag: row.active_flag,
|
||||
bound_at: row.bound_at,
|
||||
unbound_at: row.unbound_at || undefined
|
||||
};
|
||||
}
|
||||
export async function getActiveBindingByDevice(deviceId) {
|
||||
const { rows } = await getPool().query(`SELECT * FROM device_bindings
|
||||
WHERE device_id = $1 AND active_flag = TRUE
|
||||
ORDER BY bound_at DESC
|
||||
LIMIT 1`, [deviceId]);
|
||||
return rows[0] ? mapBinding(rows[0]) : null;
|
||||
}
|
||||
export async function getActiveBindingByTerminal(terminalId) {
|
||||
const { rows } = await getPool().query(`SELECT * FROM device_bindings
|
||||
WHERE terminal_id = $1 AND active_flag = TRUE
|
||||
ORDER BY bound_at DESC
|
||||
LIMIT 1`, [terminalId]);
|
||||
return rows[0] ? mapBinding(rows[0]) : null;
|
||||
}
|
||||
export async function getBindingsByDeviceId(deviceId) {
|
||||
const { rows } = await getPool().query(`SELECT * FROM device_bindings
|
||||
WHERE device_id = $1
|
||||
ORDER BY bound_at DESC`, [deviceId]);
|
||||
return rows.map(mapBinding);
|
||||
}
|
||||
export async function bindDevice(payload) {
|
||||
const now = nowIso();
|
||||
const result = await withClient(async (client) => {
|
||||
const existing = await client.query(`SELECT * FROM device_bindings
|
||||
WHERE device_id = $1 AND active_flag = TRUE
|
||||
ORDER BY bound_at DESC
|
||||
LIMIT 1`, [payload.device_id]);
|
||||
const same = existing.rows[0]
|
||||
? mapBinding(existing.rows[0])
|
||||
: null;
|
||||
if (same &&
|
||||
same.merchant_id === payload.merchant_id &&
|
||||
same.outlet_id === payload.outlet_id &&
|
||||
same.terminal_id === payload.terminal_id) {
|
||||
return same;
|
||||
}
|
||||
await client.query("BEGIN");
|
||||
try {
|
||||
if (existing.rows[0]) {
|
||||
await client.query(`UPDATE device_bindings
|
||||
SET active_flag = FALSE, unbound_at = $2
|
||||
WHERE id = $1`, [same.id, now]);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const inserted = await client.query(`INSERT INTO device_bindings (
|
||||
id,
|
||||
device_id,
|
||||
merchant_id,
|
||||
outlet_id,
|
||||
terminal_id,
|
||||
active_flag,
|
||||
bound_at
|
||||
) VALUES ($1,$2,$3,$4,$5,TRUE,$6)
|
||||
RETURNING *`, [id, payload.device_id, payload.merchant_id, payload.outlet_id, payload.terminal_id, now]);
|
||||
await client.query("COMMIT");
|
||||
return mapBinding(inserted.rows[0]);
|
||||
}
|
||||
catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
export async function unbindDevice(deviceId) {
|
||||
const now = nowIso();
|
||||
const { rows } = await getPool().query(`UPDATE device_bindings
|
||||
SET active_flag = FALSE,
|
||||
unbound_at = $2
|
||||
WHERE device_id = $1 AND active_flag = TRUE
|
||||
RETURNING *`, [deviceId, now]);
|
||||
return rows[0] ? mapBinding(rows[0]) : null;
|
||||
}
|
||||
export async function getBindingById(id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM device_bindings WHERE id = $1", [id]);
|
||||
return rows[0] ? mapBinding(rows[0]) : null;
|
||||
}
|
||||
export function toBindingPayload(binding) {
|
||||
return { ...binding };
|
||||
}
|
||||
92
dist/shared/store/deviceCommandStore.js
vendored
Normal file
92
dist/shared/store/deviceCommandStore.js
vendored
Normal file
@ -0,0 +1,92 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function normalizeStatus(status) {
|
||||
if (status === "accepted" || status === "delivered" || status === "failed" || status === "timeout") {
|
||||
return status;
|
||||
}
|
||||
return "accepted";
|
||||
}
|
||||
function mapCommand(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
device_id: row.device_id,
|
||||
command: row.command,
|
||||
payload: row.payload_json || {},
|
||||
status: row.status,
|
||||
requested_at: row.requested_at,
|
||||
acknowledged_at: row.acknowledged_at || null,
|
||||
result_payload: row.result_payload_json || null,
|
||||
reason: row.reason || null
|
||||
};
|
||||
}
|
||||
export async function createDeviceCommand(payload) {
|
||||
const entity = {
|
||||
id: `cmd_${randomUUID()}`,
|
||||
device_id: payload.device_id,
|
||||
command: payload.command,
|
||||
payload: payload.payload || {},
|
||||
status: normalizeStatus(payload.status || "accepted"),
|
||||
requested_at: nowIso(),
|
||||
acknowledged_at: null,
|
||||
result_payload: null,
|
||||
reason: null
|
||||
};
|
||||
const { rows } = await getPool().query(`INSERT INTO device_commands (
|
||||
id,
|
||||
device_id,
|
||||
command,
|
||||
payload_json,
|
||||
status,
|
||||
requested_at,
|
||||
acknowledged_at,
|
||||
result_payload_json,
|
||||
reason
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING *`, [
|
||||
entity.id,
|
||||
entity.device_id,
|
||||
entity.command,
|
||||
entity.payload,
|
||||
entity.status,
|
||||
entity.requested_at,
|
||||
entity.acknowledged_at,
|
||||
entity.result_payload,
|
||||
entity.reason
|
||||
]);
|
||||
return mapCommand(rows[0]);
|
||||
}
|
||||
export async function listDeviceCommands(deviceId) {
|
||||
const { rows } = await getPool().query("SELECT * FROM device_commands WHERE device_id = $1 ORDER BY requested_at DESC", [deviceId]);
|
||||
return rows.map(mapCommand);
|
||||
}
|
||||
export async function getDeviceCommandById(deviceId, commandId) {
|
||||
const { rows } = await getPool().query("SELECT * FROM device_commands WHERE device_id = $1 AND id = $2", [deviceId, commandId]);
|
||||
return rows[0] ? mapCommand(rows[0]) : null;
|
||||
}
|
||||
export function toDeviceCommandPayload(command) {
|
||||
return mapCommand(command);
|
||||
}
|
||||
export function toDeviceCommandPayloadBrief(command) {
|
||||
return {
|
||||
command_id: command.id,
|
||||
device_id: command.device_id,
|
||||
command: command.command,
|
||||
status: command.status,
|
||||
requested_at: command.requested_at,
|
||||
acknowledged_at: command.acknowledged_at
|
||||
};
|
||||
}
|
||||
export async function acknowledgeDeviceCommand(payload) {
|
||||
const now = nowIso();
|
||||
const { rows } = await getPool().query(`UPDATE device_commands
|
||||
SET status = $3,
|
||||
acknowledged_at = $4,
|
||||
result_payload_json = $5,
|
||||
reason = $6
|
||||
WHERE device_id = $1 AND id = $2
|
||||
RETURNING *`, [payload.device_id, payload.command_id, payload.status, now, payload.result_payload || null, payload.reason || null]);
|
||||
return rows[0] ? mapCommand(rows[0]) : null;
|
||||
}
|
||||
106
dist/shared/store/deviceStore.js
vendored
Normal file
106
dist/shared/store/deviceStore.js
vendored
Normal file
@ -0,0 +1,106 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function makeCode(id) {
|
||||
return `d_${id.slice(0, 6)}`;
|
||||
}
|
||||
function mapDevice(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
device_code: row.device_code,
|
||||
serial_number: row.serial_number || undefined,
|
||||
vendor: row.vendor || undefined,
|
||||
model: row.model || undefined,
|
||||
communication_mode: row.communication_mode,
|
||||
capability_profile_json: row.capability_profile_json || {},
|
||||
auth_method: row.auth_method || undefined,
|
||||
status: row.status,
|
||||
last_seen_at: row.last_seen_at || undefined,
|
||||
firmware_version: row.firmware_version || undefined,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
export async function createDevice(payload) {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
const { rows } = await getPool().query(`INSERT INTO devices (
|
||||
id,
|
||||
device_code,
|
||||
serial_number,
|
||||
vendor,
|
||||
model,
|
||||
communication_mode,
|
||||
capability_profile_json,
|
||||
auth_method,
|
||||
status,
|
||||
last_seen_at,
|
||||
firmware_version,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING *`, [
|
||||
id,
|
||||
payload.device_code || makeCode(id),
|
||||
payload.serial_number,
|
||||
payload.vendor,
|
||||
payload.model,
|
||||
payload.communication_mode || "static",
|
||||
payload.capability_profile_json || {},
|
||||
payload.auth_method || "token",
|
||||
payload.status || "active",
|
||||
payload.last_seen_at || null,
|
||||
payload.firmware_version,
|
||||
now,
|
||||
now
|
||||
]);
|
||||
return mapDevice(rows[0]);
|
||||
}
|
||||
export async function listDevices() {
|
||||
const { rows } = await getPool().query("SELECT * FROM devices ORDER BY created_at DESC");
|
||||
return rows.map(mapDevice);
|
||||
}
|
||||
export async function getDeviceById(id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM devices WHERE id = $1", [id]);
|
||||
return rows[0] ? mapDevice(rows[0]) : null;
|
||||
}
|
||||
export async function patchDevice(id, patch) {
|
||||
const existing = await getDeviceById(id);
|
||||
if (!existing) {
|
||||
throw new Error("DEVICE_NOT_FOUND");
|
||||
}
|
||||
const merged = { ...existing, ...patch, updated_at: nowIso() };
|
||||
const { rows } = await getPool().query(`UPDATE devices
|
||||
SET device_code = $2,
|
||||
serial_number = $3,
|
||||
vendor = $4,
|
||||
model = $5,
|
||||
communication_mode = $6,
|
||||
capability_profile_json = $7,
|
||||
auth_method = $8,
|
||||
status = $9,
|
||||
firmware_version = $10,
|
||||
last_seen_at = $11,
|
||||
updated_at = $12
|
||||
WHERE id = $1
|
||||
RETURNING *`, [
|
||||
id,
|
||||
merged.device_code,
|
||||
merged.serial_number,
|
||||
merged.vendor,
|
||||
merged.model,
|
||||
merged.communication_mode || "static",
|
||||
merged.capability_profile_json || {},
|
||||
merged.auth_method,
|
||||
merged.status,
|
||||
merged.firmware_version,
|
||||
merged.last_seen_at || null,
|
||||
merged.updated_at
|
||||
]);
|
||||
return mapDevice(rows[0]);
|
||||
}
|
||||
export function toDevicePayload(device) {
|
||||
return { ...device };
|
||||
}
|
||||
102
dist/shared/store/heartbeatStore.js
vendored
Normal file
102
dist/shared/store/heartbeatStore.js
vendored
Normal file
@ -0,0 +1,102 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function mapHeartbeat(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
device_id: row.device_id,
|
||||
timestamp: row.timestamp,
|
||||
received_at: row.received_at,
|
||||
firmware_version: row.firmware_version || undefined,
|
||||
network_strength: row.network_strength,
|
||||
battery_level: row.battery_level,
|
||||
state: row.state || undefined
|
||||
};
|
||||
}
|
||||
export async function createDeviceHeartbeat(payload) {
|
||||
const now = nowIso();
|
||||
const id = randomUUID();
|
||||
const { rows } = await getPool().query(`INSERT INTO device_heartbeats (
|
||||
id,
|
||||
device_id,
|
||||
timestamp,
|
||||
received_at,
|
||||
firmware_version,
|
||||
network_strength,
|
||||
battery_level,
|
||||
state,
|
||||
payload_json
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING *`, [
|
||||
id,
|
||||
payload.device_id,
|
||||
payload.timestamp,
|
||||
now,
|
||||
payload.firmware_version,
|
||||
payload.network_strength,
|
||||
payload.battery_level,
|
||||
payload.state,
|
||||
payload.payload_json || {}
|
||||
]);
|
||||
return mapHeartbeat(rows[0]);
|
||||
}
|
||||
export async function getLatestHeartbeatByDeviceId(deviceId) {
|
||||
const { rows } = await getPool().query(`SELECT * FROM device_heartbeats
|
||||
WHERE device_id = $1
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 1`, [deviceId]);
|
||||
return rows[0] ? mapHeartbeat(rows[0]) : null;
|
||||
}
|
||||
export async function getHeartbeatCountForDeviceLastHours(deviceId, hours = 24) {
|
||||
const { rows } = await getPool().query(`SELECT COUNT(*)::INT AS cnt
|
||||
FROM device_heartbeats
|
||||
WHERE device_id = $1
|
||||
AND (NOW() AT TIME ZONE 'utc' - timestamp) <= ($2 || ' hours')::interval`, [deviceId, hours]);
|
||||
return Number(rows[0]?.cnt || 0);
|
||||
}
|
||||
export async function listHeartbeats(filter) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
if (filter?.device_id) {
|
||||
clauses.push(`device_id = $${i++}`);
|
||||
params.push(filter.device_id);
|
||||
}
|
||||
if (filter?.state) {
|
||||
clauses.push(`state = $${i++}`);
|
||||
params.push(filter.state);
|
||||
}
|
||||
if (filter?.from) {
|
||||
clauses.push(`timestamp >= $${i++}`);
|
||||
params.push(filter.from);
|
||||
}
|
||||
if (filter?.to) {
|
||||
clauses.push(`timestamp <= $${i++}`);
|
||||
params.push(filter.to);
|
||||
}
|
||||
const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const limitSql = filter?.limit ? `LIMIT ${Number(filter.limit)}` : "";
|
||||
const { rows } = await getPool().query(`SELECT * FROM device_heartbeats ${whereSql} ORDER BY received_at DESC ${limitSql}`, params);
|
||||
return rows.map(mapHeartbeat);
|
||||
}
|
||||
export function deriveDeviceStatus(input) {
|
||||
const now = Date.now();
|
||||
const lastSeen = Date.parse(input?.last_seen_at || "");
|
||||
if (!Number.isFinite(lastSeen)) {
|
||||
return "offline";
|
||||
}
|
||||
const ageSeconds = (now - lastSeen) / 1000;
|
||||
if (ageSeconds > 900) {
|
||||
return "offline";
|
||||
}
|
||||
if (ageSeconds > 90) {
|
||||
return "stale";
|
||||
}
|
||||
if ((typeof input?.network_strength === "number" && input.network_strength < 40) ||
|
||||
(typeof input?.battery_level === "number" && input.battery_level < 20)) {
|
||||
return "degraded";
|
||||
}
|
||||
return "online";
|
||||
}
|
||||
146
dist/shared/store/locationStore.js
vendored
Normal file
146
dist/shared/store/locationStore.js
vendored
Normal file
@ -0,0 +1,146 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function makeCode(prefix, id) {
|
||||
return `${prefix}_${id.slice(0, 6)}`;
|
||||
}
|
||||
function mapOutlet(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
merchant_id: row.merchant_id,
|
||||
outlet_code: row.outlet_code,
|
||||
name: row.name,
|
||||
address: row.address || undefined,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function mapTerminal(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
outlet_id: row.outlet_id,
|
||||
terminal_code: row.terminal_code,
|
||||
qr_mode: row.qr_mode,
|
||||
partner_reference: row.partner_reference || undefined,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
export async function createOutlet(payload) {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
const { rows } = await getPool().query(`INSERT INTO outlets (id, merchant_id, outlet_code, name, address, status, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
RETURNING *`, [
|
||||
id,
|
||||
payload.merchant_id,
|
||||
payload.outlet_code || makeCode("out", id),
|
||||
payload.name,
|
||||
payload.address,
|
||||
payload.status || "active",
|
||||
now,
|
||||
now
|
||||
]);
|
||||
return mapOutlet(rows[0]);
|
||||
}
|
||||
export async function createTerminal(payload) {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
const { rows } = await getPool().query(`INSERT INTO terminals (id, outlet_id, terminal_code, qr_mode, partner_reference, status, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
RETURNING *`, [
|
||||
id,
|
||||
payload.outlet_id,
|
||||
payload.terminal_code || makeCode("term", id),
|
||||
payload.qr_mode || "static",
|
||||
payload.partner_reference || null,
|
||||
payload.status || "active",
|
||||
now,
|
||||
now
|
||||
]);
|
||||
return mapTerminal(rows[0]);
|
||||
}
|
||||
export async function listOutlets(filter) {
|
||||
if (filter?.merchant_id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM outlets WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id]);
|
||||
return rows.map(mapOutlet);
|
||||
}
|
||||
const { rows } = await getPool().query("SELECT * FROM outlets ORDER BY created_at DESC");
|
||||
return rows.map(mapOutlet);
|
||||
}
|
||||
export async function listTerminals(filter) {
|
||||
if (filter?.outlet_id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM terminals WHERE outlet_id = $1 ORDER BY created_at DESC", [filter.outlet_id]);
|
||||
return rows.map(mapTerminal);
|
||||
}
|
||||
const { rows } = await getPool().query("SELECT * FROM terminals ORDER BY created_at DESC");
|
||||
return rows.map(mapTerminal);
|
||||
}
|
||||
export async function getOutletById(id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM outlets WHERE id = $1", [id]);
|
||||
return rows[0] ? mapOutlet(rows[0]) : null;
|
||||
}
|
||||
export async function getTerminalById(id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM terminals WHERE id = $1", [id]);
|
||||
return rows[0] ? mapTerminal(rows[0]) : null;
|
||||
}
|
||||
export async function patchOutlet(id, patch) {
|
||||
const existing = await getOutletById(id);
|
||||
if (!existing) {
|
||||
throw new Error("OUTLET_NOT_FOUND");
|
||||
}
|
||||
const merged = { ...existing, ...patch, updated_at: nowIso() };
|
||||
const { rows } = await getPool().query(`UPDATE outlets
|
||||
SET merchant_id = $2,
|
||||
outlet_code = $3,
|
||||
name = $4,
|
||||
address = $5,
|
||||
status = $6,
|
||||
updated_at = $7
|
||||
WHERE id = $1
|
||||
RETURNING *`, [
|
||||
id,
|
||||
merged.merchant_id,
|
||||
merged.outlet_code,
|
||||
merged.name,
|
||||
merged.address || null,
|
||||
merged.status,
|
||||
merged.updated_at
|
||||
]);
|
||||
return mapOutlet(rows[0]);
|
||||
}
|
||||
export async function patchTerminal(id, patch) {
|
||||
const existing = await getTerminalById(id);
|
||||
if (!existing) {
|
||||
throw new Error("TERMINAL_NOT_FOUND");
|
||||
}
|
||||
const merged = { ...existing, ...patch, updated_at: nowIso() };
|
||||
const { rows } = await getPool().query(`UPDATE terminals
|
||||
SET outlet_id = $2,
|
||||
terminal_code = $3,
|
||||
qr_mode = $4,
|
||||
partner_reference = $5,
|
||||
status = $6,
|
||||
updated_at = $7
|
||||
WHERE id = $1
|
||||
RETURNING *`, [
|
||||
id,
|
||||
merged.outlet_id,
|
||||
merged.terminal_code,
|
||||
merged.qr_mode,
|
||||
merged.partner_reference || null,
|
||||
merged.status,
|
||||
merged.updated_at
|
||||
]);
|
||||
return mapTerminal(rows[0]);
|
||||
}
|
||||
export function toOutletPayload(outlet) {
|
||||
return { ...outlet };
|
||||
}
|
||||
export function toTerminalPayload(terminal) {
|
||||
return { ...terminal };
|
||||
}
|
||||
119
dist/shared/store/merchantStore.js
vendored
Normal file
119
dist/shared/store/merchantStore.js
vendored
Normal file
@ -0,0 +1,119 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function makeCode(id) {
|
||||
return `m_${id.slice(0, 6)}`;
|
||||
}
|
||||
function toPublic(entity) {
|
||||
return entity;
|
||||
}
|
||||
function mapRowToMerchant(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
merchant_code: row.merchant_code,
|
||||
legal_name: row.legal_name,
|
||||
brand_name: row.brand_name || undefined,
|
||||
settlement_account_reference: row.settlement_account_reference || undefined,
|
||||
settlement_account_type: row.settlement_account_type || undefined,
|
||||
payout_mode: row.payout_mode,
|
||||
fee_profile_id: row.fee_profile_id || undefined,
|
||||
status: row.status,
|
||||
onboarding_status: row.onboarding_status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
export async function createMerchant(payload) {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
const payoutMode = payload.payout_mode || "merchant_direct";
|
||||
const { rows } = await getPool().query(`INSERT INTO merchants (
|
||||
id,
|
||||
merchant_code,
|
||||
legal_name,
|
||||
brand_name,
|
||||
settlement_account_reference,
|
||||
settlement_account_type,
|
||||
payout_mode,
|
||||
fee_profile_id,
|
||||
status,
|
||||
onboarding_status,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING *`, [
|
||||
id,
|
||||
makeCode(id),
|
||||
payload.legal_name,
|
||||
payload.brand_name,
|
||||
payload.settlement_account_reference,
|
||||
payload.settlement_account_type,
|
||||
payoutMode,
|
||||
payload.fee_profile_id,
|
||||
payload.status || "active",
|
||||
payload.onboarding_status || "pending",
|
||||
now,
|
||||
now
|
||||
]);
|
||||
return toPublic(mapRowToMerchant(rows[0]));
|
||||
}
|
||||
export async function getMerchantById(id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM merchants WHERE id = $1", [id]);
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return mapRowToMerchant(rows[0]);
|
||||
}
|
||||
export async function listMerchants() {
|
||||
const { rows } = await getPool().query("SELECT * FROM merchants ORDER BY created_at DESC");
|
||||
return rows.map(mapRowToMerchant);
|
||||
}
|
||||
export async function patchMerchant(id, patch) {
|
||||
const existing = await getMerchantById(id);
|
||||
if (!existing) {
|
||||
throw new Error("MERCHANT_NOT_FOUND");
|
||||
}
|
||||
const merged = { ...existing, ...patch, updated_at: nowIso() };
|
||||
const { rows } = await getPool().query(`UPDATE merchants
|
||||
SET legal_name = $2,
|
||||
brand_name = $3,
|
||||
settlement_account_reference = $4,
|
||||
settlement_account_type = $5,
|
||||
payout_mode = $6,
|
||||
fee_profile_id = $7,
|
||||
status = $8,
|
||||
onboarding_status = $9,
|
||||
updated_at = $10
|
||||
WHERE id = $1
|
||||
RETURNING *`, [
|
||||
id,
|
||||
merged.legal_name,
|
||||
merged.brand_name,
|
||||
merged.settlement_account_reference,
|
||||
merged.settlement_account_type,
|
||||
merged.payout_mode,
|
||||
merged.fee_profile_id,
|
||||
merged.status,
|
||||
merged.onboarding_status,
|
||||
merged.updated_at
|
||||
]);
|
||||
return mapRowToMerchant(rows[0]);
|
||||
}
|
||||
export function toMerchantPayload(m) {
|
||||
return {
|
||||
id: m.id,
|
||||
merchant_code: m.merchant_code,
|
||||
legal_name: m.legal_name,
|
||||
brand_name: m.brand_name,
|
||||
settlement_account_reference: m.settlement_account_reference,
|
||||
settlement_account_type: m.settlement_account_type,
|
||||
payout_mode: m.payout_mode,
|
||||
fee_profile_id: m.fee_profile_id,
|
||||
status: m.status,
|
||||
onboarding_status: m.onboarding_status,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at
|
||||
};
|
||||
}
|
||||
151
dist/shared/store/notificationStore.js
vendored
Normal file
151
dist/shared/store/notificationStore.js
vendored
Normal file
@ -0,0 +1,151 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function cloneNotification(notification) {
|
||||
return {
|
||||
...notification,
|
||||
payload_json: { ...notification.payload_json }
|
||||
};
|
||||
}
|
||||
function mapNotification(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
transaction_id: row.transaction_id,
|
||||
device_id: row.device_id || null,
|
||||
delivery_channel: "mqtt",
|
||||
payload_type: "payment_success",
|
||||
delivery_status: row.delivery_status,
|
||||
retry_count: row.retry_count,
|
||||
ack_status: row.ack_status,
|
||||
event_id: row.event_id,
|
||||
reason: row.reason || undefined,
|
||||
payload_json: row.payload_json || {},
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
sent_at: row.sent_at || undefined,
|
||||
ack_at: row.ack_at || undefined,
|
||||
next_retry_at: row.next_retry_at || undefined
|
||||
};
|
||||
}
|
||||
export async function createNotification(payload) {
|
||||
const now = nowIso();
|
||||
const insert = await getPool().query(`INSERT INTO notifications (
|
||||
id,
|
||||
transaction_id,
|
||||
device_id,
|
||||
delivery_status,
|
||||
retry_count,
|
||||
ack_status,
|
||||
event_id,
|
||||
reason,
|
||||
payload_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
ON CONFLICT (transaction_id, event_id) DO UPDATE
|
||||
SET updated_at = EXCLUDED.updated_at
|
||||
RETURNING *`, [
|
||||
randomUUID(),
|
||||
payload.transaction_id,
|
||||
payload.device_id,
|
||||
payload.delivery_status,
|
||||
0,
|
||||
payload.ack_status || "not_needed",
|
||||
payload.event_id,
|
||||
payload.reason || null,
|
||||
payload.payload_json || {},
|
||||
now,
|
||||
now
|
||||
]);
|
||||
if (insert.rowCount && insert.rowCount > 0) {
|
||||
return mapNotification(insert.rows[0]);
|
||||
}
|
||||
const { rows } = await getPool().query("SELECT * FROM notifications WHERE transaction_id = $1 AND event_id = $2", [payload.transaction_id, payload.event_id]);
|
||||
return mapNotification(rows[0]);
|
||||
}
|
||||
export async function getNotificationById(notificationId) {
|
||||
const { rows } = await getPool().query("SELECT * FROM notifications WHERE id = $1", [notificationId]);
|
||||
return rows[0] ? cloneNotification(mapNotification(rows[0])) : null;
|
||||
}
|
||||
export async function updateNotification(notificationId, patch) {
|
||||
const existing = await getNotificationById(notificationId);
|
||||
if (!existing) {
|
||||
throw new Error("NOTIFICATION_NOT_FOUND");
|
||||
}
|
||||
const next = {
|
||||
...existing,
|
||||
...patch,
|
||||
id: existing.id,
|
||||
transaction_id: existing.transaction_id,
|
||||
device_id: existing.device_id,
|
||||
delivery_channel: existing.delivery_channel,
|
||||
payload_type: existing.payload_type,
|
||||
event_id: existing.event_id,
|
||||
payload_json: existing.payload_json,
|
||||
created_at: existing.created_at,
|
||||
updated_at: nowIso()
|
||||
};
|
||||
const { rows } = await getPool().query(`UPDATE notifications
|
||||
SET delivery_status = $2,
|
||||
retry_count = $3,
|
||||
ack_status = $4,
|
||||
device_id = COALESCE($5, device_id),
|
||||
reason = $6,
|
||||
sent_at = $7,
|
||||
ack_at = $8,
|
||||
next_retry_at = $9,
|
||||
updated_at = $10
|
||||
WHERE id = $1
|
||||
RETURNING *`, [
|
||||
notificationId,
|
||||
next.delivery_status,
|
||||
next.retry_count,
|
||||
next.ack_status,
|
||||
next.device_id ?? null,
|
||||
next.reason || null,
|
||||
next.sent_at || null,
|
||||
next.ack_at || null,
|
||||
next.next_retry_at || null,
|
||||
next.updated_at
|
||||
]);
|
||||
return cloneNotification(mapNotification(rows[0]));
|
||||
}
|
||||
export async function getNotificationByTransactionId(transactionId) {
|
||||
const { rows } = await getPool().query(`SELECT * FROM notifications
|
||||
WHERE transaction_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`, [transactionId]);
|
||||
return rows[0] ? mapNotification(rows[0]) : null;
|
||||
}
|
||||
export async function getNotificationByTransactionAndEvent(transactionId, eventId) {
|
||||
const { rows } = await getPool().query("SELECT * FROM notifications WHERE transaction_id = $1 AND event_id = $2", [transactionId, eventId]);
|
||||
return rows[0] ? mapNotification(rows[0]) : null;
|
||||
}
|
||||
export async function listNotificationsByDevice(deviceId) {
|
||||
const { rows } = await getPool().query("SELECT * FROM notifications WHERE device_id = $1 ORDER BY created_at DESC", [deviceId]);
|
||||
return rows.map(mapNotification);
|
||||
}
|
||||
export async function listNotifications(filter) {
|
||||
const filters = [];
|
||||
const params = [];
|
||||
if (filter?.transaction_id) {
|
||||
params.push(filter.transaction_id);
|
||||
filters.push(`transaction_id = $${params.length}`);
|
||||
}
|
||||
if (filter?.device_id) {
|
||||
params.push(filter.device_id);
|
||||
filters.push(`device_id = $${params.length}`);
|
||||
}
|
||||
if (filter?.delivery_status) {
|
||||
params.push(filter.delivery_status);
|
||||
filters.push(`delivery_status = $${params.length}`);
|
||||
}
|
||||
const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
|
||||
const { rows } = await getPool().query(`SELECT * FROM notifications ${where} ORDER BY created_at DESC`, params);
|
||||
return rows.map(mapNotification);
|
||||
}
|
||||
export function toNotificationPayload(notification) {
|
||||
return cloneNotification(notification);
|
||||
}
|
||||
199
dist/shared/store/transactionStore.js
vendored
Normal file
199
dist/shared/store/transactionStore.js
vendored
Normal file
@ -0,0 +1,199 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPool } from "../db/pool";
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function makeCode(id) {
|
||||
return `tx_${id.slice(0, 8)}`;
|
||||
}
|
||||
function mapTransaction(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
transaction_code: row.transaction_code,
|
||||
merchant_id: row.merchant_id,
|
||||
outlet_id: row.outlet_id,
|
||||
terminal_id: row.terminal_id,
|
||||
device_id: row.device_id || undefined,
|
||||
qr_mode: row.qr_mode,
|
||||
initiation_mode: row.initiation_mode,
|
||||
partner_reference: row.partner_reference,
|
||||
amount: Number(row.amount),
|
||||
currency: row.currency,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
paid_at: row.paid_at || undefined,
|
||||
expired_at: row.expired_at || undefined,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function mapEvent(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
transaction_id: row.transaction_id,
|
||||
event_type: row.event_type,
|
||||
source: row.source,
|
||||
payload_json: row.payload_json || {},
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
const TRANSACTION_STATE_TRANSITIONS = {
|
||||
initiated: ["initiated", "awaiting_payment", "paid", "failed", "expired", "reversed"],
|
||||
awaiting_payment: ["awaiting_payment", "paid", "failed", "expired", "reversed"],
|
||||
paid: ["paid", "reversed"],
|
||||
failed: ["failed", "reversed"],
|
||||
expired: ["expired", "reversed"],
|
||||
reversed: ["reversed"]
|
||||
};
|
||||
function isValidTransactionTransition(from, to) {
|
||||
return TRANSACTION_STATE_TRANSITIONS[from]?.includes(to) ?? false;
|
||||
}
|
||||
export async function createTransaction(payload) {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
const entity = {
|
||||
id,
|
||||
transaction_code: makeCode(id),
|
||||
merchant_id: payload.merchant_id,
|
||||
outlet_id: payload.outlet_id,
|
||||
terminal_id: payload.terminal_id,
|
||||
device_id: payload.device_id,
|
||||
qr_mode: payload.qr_mode || "static",
|
||||
initiation_mode: payload.initiation_mode || "static",
|
||||
partner_reference: payload.partner_reference,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency || "IDR",
|
||||
status: payload.status || "initiated",
|
||||
created_at: now,
|
||||
paid_at: payload.paid_at,
|
||||
expired_at: payload.expired_at,
|
||||
updated_at: now
|
||||
};
|
||||
const txResult = await getPool().query(`INSERT INTO transactions (
|
||||
id,
|
||||
transaction_code,
|
||||
merchant_id,
|
||||
outlet_id,
|
||||
terminal_id,
|
||||
device_id,
|
||||
qr_mode,
|
||||
initiation_mode,
|
||||
partner_reference,
|
||||
amount,
|
||||
currency,
|
||||
status,
|
||||
created_at,
|
||||
paid_at,
|
||||
expired_at,
|
||||
updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
RETURNING *`, [
|
||||
entity.id,
|
||||
entity.transaction_code,
|
||||
entity.merchant_id,
|
||||
entity.outlet_id,
|
||||
entity.terminal_id,
|
||||
entity.device_id || null,
|
||||
entity.qr_mode,
|
||||
entity.initiation_mode,
|
||||
entity.partner_reference,
|
||||
entity.amount,
|
||||
entity.currency,
|
||||
entity.status,
|
||||
entity.created_at,
|
||||
entity.paid_at || null,
|
||||
entity.expired_at || null,
|
||||
entity.updated_at
|
||||
]);
|
||||
await addTransactionEvent({
|
||||
transaction_id: txResult.rows[0].id,
|
||||
event_type: "INITIATED",
|
||||
source: "system",
|
||||
payload_json: { status: txResult.rows[0].status, partner_reference: payload.partner_reference }
|
||||
});
|
||||
return mapTransaction(txResult.rows[0]);
|
||||
}
|
||||
export async function addTransactionEvent(payload) {
|
||||
const id = randomUUID();
|
||||
const { rows } = await getPool().query(`INSERT INTO transaction_events (id, transaction_id, event_type, source, payload_json, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING *`, [id, payload.transaction_id, payload.event_type, payload.source, payload.payload_json || {}, nowIso()]);
|
||||
return mapEvent(rows[0]);
|
||||
}
|
||||
export async function updateTransactionStatus(id, to, options) {
|
||||
const entity = await getTransactionById(id);
|
||||
if (!entity) {
|
||||
throw new Error("TRANSACTION_NOT_FOUND");
|
||||
}
|
||||
if (!isValidTransactionTransition(entity.status, to)) {
|
||||
throw new Error(`INVALID_TRANSACTION_STATE_TRANSITION:${entity.status}->${to}`);
|
||||
}
|
||||
if (entity.status === to) {
|
||||
return entity;
|
||||
}
|
||||
const now = nowIso();
|
||||
const next = {
|
||||
...entity,
|
||||
status: to,
|
||||
paid_at: options.paid_at || entity.paid_at,
|
||||
expired_at: options.expired_at || entity.expired_at,
|
||||
updated_at: now
|
||||
};
|
||||
if (to === "paid" && !next.paid_at) {
|
||||
next.paid_at = now;
|
||||
}
|
||||
if (to === "expired" && !next.expired_at) {
|
||||
next.expired_at = now;
|
||||
}
|
||||
const { rows } = await getPool().query(`UPDATE transactions
|
||||
SET status = $2,
|
||||
paid_at = $3,
|
||||
expired_at = $4,
|
||||
updated_at = $5
|
||||
WHERE id = $1
|
||||
RETURNING *`, [id, next.status, next.paid_at || null, next.expired_at || null, next.updated_at]);
|
||||
await addTransactionEvent({
|
||||
transaction_id: id,
|
||||
event_type: "STATE_CHANGED",
|
||||
source: options.source,
|
||||
payload_json: {
|
||||
from: entity.status,
|
||||
to,
|
||||
...options.eventContext
|
||||
}
|
||||
});
|
||||
return mapTransaction(rows[0]);
|
||||
}
|
||||
export async function getTransactionById(id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]);
|
||||
return rows[0] ? mapTransaction(rows[0]) : null;
|
||||
}
|
||||
export async function findTransactionByPartnerReference(partnerReference) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions WHERE partner_reference = $1", [partnerReference]);
|
||||
return rows[0] ? mapTransaction(rows[0]) : null;
|
||||
}
|
||||
export async function listTransactions(filter) {
|
||||
if (filter?.status && filter?.merchant_id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions WHERE status = $1 AND merchant_id = $2 ORDER BY created_at DESC", [filter.status, filter.merchant_id]);
|
||||
return rows.map(mapTransaction);
|
||||
}
|
||||
if (filter?.status) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions WHERE status = $1 ORDER BY created_at DESC", [filter.status]);
|
||||
return rows.map(mapTransaction);
|
||||
}
|
||||
if (filter?.merchant_id) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id]);
|
||||
return rows.map(mapTransaction);
|
||||
}
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC");
|
||||
return rows.map(mapTransaction);
|
||||
}
|
||||
export async function getTransactionEvents(transactionId) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", [transactionId]);
|
||||
return rows.map(mapEvent);
|
||||
}
|
||||
export function toTransactionPayload(transaction) {
|
||||
return { ...transaction };
|
||||
}
|
||||
export function toTransactionEventPayload(event) {
|
||||
return { ...event, payload_json: { ...event.payload_json } };
|
||||
}
|
||||
Reference in New Issue
Block a user