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