Files
Qris-Soundbox/scripts/smoke-qf100-adapter.mjs
2026-06-04 11:20:16 +07:00

270 lines
8.5 KiB
JavaScript

import { createHmac } from "node:crypto";
const BASE = process.env.BASE_URL || `http://127.0.0.1:${process.env.PORT || "3000"}`;
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "admin-dev-token";
const DEVICE_TOKEN = process.env.DEVICE_TOKEN || "device-dev-token";
const SECRET = process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret";
const STATIC_SN = process.env.QF100_STATIC_SN || `QF100-STATIC-${Date.now()}`;
const DYNAMIC_SN = process.env.QF100_DYNAMIC_SN || `QF100-DYNAMIC-${Date.now()}`;
function short(data) {
const text = typeof data === "string" ? data : JSON.stringify(data || {});
return text.length > 220 ? `${text.slice(0, 220)}...` : text;
}
async function req(path, options = {}) {
const response = await fetch(`${BASE}${path}`, {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
...(options.headers || {})
},
body: Object.prototype.hasOwnProperty.call(options, "body") ? JSON.stringify(options.body) : undefined
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
if (!response.ok) {
throw new Error(`${options._label || path} failed: ${response.status} ${short(body)}`);
}
console.log(`${options._label || `${options.method || "GET"} ${path}`} => ${response.status} ${short(body)}`);
return body?.data !== undefined ? body.data : body;
}
function reqAdmin(path, options = {}) {
return req(path, {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${ADMIN_TOKEN}`
}
});
}
function reqDevice(path, options = {}) {
return req(path, {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${DEVICE_TOKEN}`
}
});
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function createBundle({ ts, suffix, serialNumber, terminalMode, capability }) {
const merchant = await reqAdmin("/admin/merchants", {
method: "POST",
body: {
legal_name: `QF100 Smoke Merchant ${suffix} ${ts}`,
brand_name: `QF100-${suffix}-${ts}`,
settlement_account_reference: `bank:qf100:${suffix}:${ts}`,
settlement_account_type: "merchant_bank_account",
payout_mode: "merchant_direct"
},
_label: `POST /admin/merchants ${suffix}`
});
const outlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, {
method: "POST",
body: { name: `QF100 Smoke Outlet ${suffix} ${ts}` },
_label: `POST /admin/merchants/:id/outlets ${suffix}`
});
const terminal = await reqAdmin(`/admin/outlets/${outlet.id}/terminals`, {
method: "POST",
body: { terminal_code: `QF100-${suffix}-TERM-${ts}`, qr_mode: terminalMode },
_label: `POST /admin/outlets/:id/terminals ${suffix}`
});
const device = await reqAdmin("/admin/devices", {
method: "POST",
body: {
device_code: `QF100-${suffix}-DEV-${ts}`,
serial_number: serialNumber,
vendor: "QF",
model: "QF100",
communication_mode: "mqtt",
status: "active",
capability_profile_json: {
mqtt_payload_profile: "qf100",
...(capability || {})
}
},
_label: `POST /admin/devices ${suffix}`
});
await reqAdmin(`/admin/devices/${device.id}/bind`, {
method: "POST",
body: {
merchant_id: merchant.id,
outlet_id: outlet.id,
terminal_id: terminal.id
},
_label: `POST /admin/devices/:id/bind ${suffix}`
});
return { merchant, outlet, terminal, device };
}
async function pullQf100Config(serialNumber, label) {
const query = new URLSearchParams({
"dev-model": "QF100",
"item-number": "00",
"dev-sn": serialNumber,
"fw-version": "1.0.0",
"fw-build": "1",
"app-config-version": "1",
imei: `${label}-imei`,
imsi: `${label}-imsi`,
iccid: `${label}-iccid`
});
const config = await req(`/speaker/dev-config?${query.toString()}`, {
_label: `GET /speaker/dev-config ${label}`
});
assert(config["error-code"] === 0, `${label} config error-code must be 0`);
assert(config.mqtt?.["broker-ip"], `${label} config must include mqtt.broker-ip`);
assert(config.mqtt?.["broker-port"], `${label} config must include mqtt.broker-port`);
assert(config.mqtt?.["subscribe-topic"]?.includes("/downlink/qf100"), `${label} must subscribe qf100 topic`);
return config;
}
async function waitForQf100PaymentMessage(deviceId) {
for (let i = 0; i < 20; i += 1) {
const data = await reqAdmin(`/admin/devices/${deviceId}/mqtt-messages?direction=downlink&message_type=payment_success&limit=10`, {
_label: `GET /admin/devices/:id/mqtt-messages attempt ${i + 1}`
});
const found = data.messages?.find((message) => message.topic === `devices/${deviceId}/downlink/qf100`);
if (found) {
return found;
}
await sleep(250);
}
throw new Error("QF100 payment downlink message not found");
}
async function triggerStaticPayment({ bundle, ts }) {
const partnerReference = `QF100-PR-${ts}`;
await reqAdmin("/admin/transactions", {
method: "POST",
body: {
partner_reference: partnerReference,
merchant_id: bundle.merchant.id,
outlet_id: bundle.outlet.id,
terminal_id: bundle.terminal.id,
device_id: bundle.device.id,
amount: 15000,
currency: "IDR",
qr_mode: "static",
initiation_mode: "static",
status: "initiated"
},
_label: "POST /admin/transactions static"
});
const callback = {
partner_reference: partnerReference,
partner_txn_id: `QF100-PTX-${ts}`,
amount: 15000,
currency: "IDR",
payment_status: "paid",
status: "paid",
paid_at: new Date().toISOString()
};
const signature = createHmac("sha256", SECRET).update(JSON.stringify(callback)).digest("hex");
await req("/integrations/qris/callback", {
method: "POST",
headers: { "X-Partner-Signature": signature },
body: { ...callback, signature },
_label: "POST /integrations/qris/callback static paid"
});
const message = await waitForQf100PaymentMessage(bundle.device.id);
assert(message.payload_json?.header?.category === 1, "QF100 payment payload header.category must be 1");
assert(message.payload_json?.data?.["pay-amount"] === 15000, "QF100 payment payload pay-amount must match");
return message;
}
async function triggerDynamicMqttQr({ bundle, ts }) {
const requestId = `QF100-DYN-${ts}`;
const response = await reqDevice("/device/mqtt/uplink/dynamic-qr/request", {
method: "POST",
body: {
message_type: "dynamic_qr_request",
request_id: requestId,
device_id: bundle.device.id,
terminal_id: bundle.terminal.id,
amount: 27500,
currency: "IDR",
created_at: new Date().toISOString()
},
_label: "POST /device/mqtt/uplink/dynamic-qr/request"
});
assert(response.status === "success", "dynamic MQTT QR response must be success");
assert(response.qr_payload, "dynamic MQTT QR response must include qr_payload");
return response;
}
async function main() {
await req("/health", { _label: "GET /health" });
const ts = Date.now();
const staticBundle = await createBundle({
ts,
suffix: "STATIC",
serialNumber: STATIC_SN,
terminalMode: "static",
capability: {}
});
const dynamicBundle = await createBundle({
ts,
suffix: "DYNAMIC",
serialNumber: DYNAMIC_SN,
terminalMode: "dynamic_mqtt",
capability: {
dynamic_qr: {
mqtt: true
}
}
});
const staticConfig = await pullQf100Config(STATIC_SN, "static");
const dynamicConfig = await pullQf100Config(DYNAMIC_SN, "dynamic");
assert(staticConfig.mqtt["client-id"] === staticBundle.device.id, "static client-id must match device id");
assert(dynamicConfig.mqtt["client-id"] === dynamicBundle.device.id, "dynamic client-id must match device id");
const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts });
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });
console.log("\nQF100 adapter smoke passed");
console.log(`static_sn=${STATIC_SN}`);
console.log(`static_device_id=${staticBundle.device.id}`);
console.log(`static_payment_topic=${staticPaymentMessage.topic}`);
console.log(`dynamic_sn=${DYNAMIC_SN}`);
console.log(`dynamic_device_id=${dynamicBundle.device.id}`);
console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});