/** * STAR-only SOAP transport + template rendering for Reynolds & Reynolds (Rome/RCI). * - Renders Mustache STAR business templates (rey_*Req rooted with STAR ns) * - Builds STAR SOAP envelope (ProcessMessage/payload/content + ApplicationArea) * - Posts to RCI endpoint with STAR SOAPAction (full URI) * - Parses XML response (faults + STAR payload result) */ const fs = require("fs/promises"); const path = require("path"); const axios = require("axios"); const mustache = require("mustache"); const { XMLParser } = require("fast-xml-parser"); const RRLogger = require("./rr-logger"); const { RR_ACTIONS, RR_SOAP_HEADERS, RR_STAR_SOAP_ACTION, RR_NS, getBaseRRConfig } = require("./rr-constants"); const { RrApiError } = require("./rr-error"); const xmlFormatter = require("xml-formatter"); /** * Remove XML decl, collapse inter-tag whitespace, strip empty lines, * then pretty-print. Safe for XML because we only touch whitespace * BETWEEN tags, not inside text nodes. /** * Collapse Mustache-induced whitespace and pretty print. * - strips XML decl (inner) * - removes lines that are only whitespace * - collapses inter-tag whitespace * - formats with consistent indentation */ function prettyPrintXml(xml) { let s = xml; // strip any inner XML declaration s = s.replace(/^\s*<\?xml[^>]*\?>\s*/i, ""); // remove lines that are only whitespace s = s.replace(/^[\t ]*(?:\r?\n)/gm, ""); // collapse whitespace strictly between tags (not inside text nodes) s = s.replace(/>\s+<"); // final pretty print return xmlFormatter(s, { indentation: " ", collapseContent: true, // keep short elements on one line lineSeparator: "\n", strictMode: false }); } // ---------- Public action map (compat with rr-test.js) ---------- const RRActions = Object.fromEntries(Object.entries(RR_ACTIONS).map(([k]) => [k, { action: k }])); // ---------- Template cache ---------- const templateCache = new Map(); async function loadTemplate(templateName) { if (templateCache.has(templateName)) return templateCache.get(templateName); const filePath = path.join(__dirname, "xml-templates", `${templateName}.xml`); const tpl = await fs.readFile(filePath, "utf8"); templateCache.set(templateName, tpl); return tpl; } async function renderXmlTemplate(templateName, data) { const tpl = await loadTemplate(templateName); // Render and strip any XML declaration to keep a single root element for the BOD const rendered = mustache.render(tpl, data || {}); return rendered.replace(/^\s*<\?xml[^>]*\?>\s*/i, ""); } // ---------- Config resolution (STAR only) ---------- async function resolveRRConfig(_socket, bodyshopConfig) { const envCfg = getBaseRRConfig(); if (bodyshopConfig && typeof bodyshopConfig === "object") { return { ...envCfg, baseUrl: bodyshopConfig.baseUrl || envCfg.baseUrl, username: bodyshopConfig.username || envCfg.username, password: bodyshopConfig.password || envCfg.password, ppsysId: bodyshopConfig.ppsysId || envCfg.ppsysId, dealerNumber: bodyshopConfig.dealer_number || envCfg.dealerNumber, storeNumber: bodyshopConfig.store_number || envCfg.storeNumber, branchNumber: bodyshopConfig.branch_number || envCfg.branchNumber, wssePasswordType: bodyshopConfig.wssePasswordType || envCfg.wssePasswordType || "Text", timeout: envCfg.timeout }; } return envCfg; } // ---------- Response parsing ---------- function parseRRResponse(xml) { const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true }); const doc = parser.parse(xml); // Envelope/Body const body = doc?.Envelope?.Body || doc?.["soapenv:Envelope"]?.["soapenv:Body"] || doc?.["SOAP-ENV:Envelope"]?.["SOAP-ENV:Body"] || doc?.["S:Envelope"]?.["S:Body"] || doc?.Body || doc; // SOAP Fault? const fault = body?.Fault || body?.["soap:Fault"]; if (fault) { return { success: false, code: fault.faultcode || "SOAP_FAULT", message: fault.faultstring || "Unknown SOAP Fault", raw: xml }; } // STAR transport path: ProcessMessage/payload/content const processMessage = body?.ProcessMessage || body?.["ns0:ProcessMessage"] || body?.["ProcessMessageResponse"]; if (processMessage?.payload?.content) { const content = processMessage.payload.content; if (content && typeof content === "object") { const keys = Object.keys(content).filter((k) => k !== "@_id"); const respKey = keys.find((k) => /Resp$/.test(k)) || (keys[0] === "ApplicationArea" && keys[1]) || keys[0]; const respNode = respKey ? content[respKey] : content; const resultCode = respNode?.ResultCode || respNode?.ResponseCode || respNode?.StatusCode || "OK"; const resultMessage = respNode?.ResultMessage || respNode?.ResponseMessage || respNode?.StatusMessage || null; return { success: ["OK", "Success"].includes(String(resultCode)), code: resultCode, message: resultMessage, raw: xml, parsed: respNode }; } } // Fallback: first element under Body (just in case) const keys = body && typeof body === "object" ? Object.keys(body) : []; const respNode = keys.length ? body[keys[0]] : body; const resultCode = respNode?.ResultCode || respNode?.ResponseCode || "OK"; const resultMessage = respNode?.ResultMessage || respNode?.ResponseMessage || null; return { success: resultCode === "OK" || resultCode === "Success", code: resultCode, message: resultMessage, raw: xml, parsed: respNode }; } // ---------- STAR envelope helpers ---------- function wrapWithApplicationArea(innerXml, { CreationDateTime, BODId, Sender, Destination }) { // Make sure we inject *inside* the STAR root, not before it. // 1) Strip any XML declaration just in case (idempotent) let xml = innerXml.replace(/^\s*<\?xml[^>]*\?>\s*/i, ""); const appArea = ` ${CreationDateTime} ${BODId} ${Sender?.Component ? `${Sender.Component}` : ""} ${Sender?.Task ? `${Sender.Task}` : ""} ${Sender?.ReferenceId ? `${Sender.ReferenceId}` : ""} RR ${Destination?.DealerNumber ? `${Destination.DealerNumber}` : ""} ${Destination?.StoreNumber ? `${Destination.StoreNumber}` : ""} ${Destination?.AreaNumber ? `${Destination.AreaNumber}` : ""} `.trim(); // Inject right after the opening tag of the root element (skip processing instructions) // e.g. ==> insert ApplicationArea here xml = xml.replace(/^(\s*<[^!?][^>]*>)/, `$1\n${appArea}\n`); return xml; } async function buildStarEnvelope(innerBusinessXml, creds, appArea = {}) { const now = new Date().toISOString(); const payloadWithAppArea = wrapWithApplicationArea(innerBusinessXml, { CreationDateTime: appArea.CreationDateTime || now, BODId: appArea.BODId || `BOD-${Date.now()}`, Sender: appArea.Sender || { Component: "Rome", Task: "SV", ReferenceId: "Update" }, Destination: appArea.Destination || { DealerNumber: creds.dealerNumber, StoreNumber: String(creds.storeNumber ?? "").padStart(2, "0"), AreaNumber: String(creds.branchNumber || "01").padStart(2, "0") } }); return ` ${creds.username} ${creds.password} ${payloadWithAppArea} `; } // ---------- Main transport (STAR only) ---------- async function MakeRRCall({ action, body, socket, dealerConfig, // optional per-shop overrides retries = 1, jobid }) { if (!action || !RR_ACTIONS[action]) { throw new Error(`Invalid RR action: ${action}`); } const cfg = dealerConfig || (await resolveRRConfig(socket, undefined)); const baseUrl = cfg.baseUrl; if (!baseUrl) throw new Error("Missing RR base URL"); // Render STAR business body const templateName = body?.template || action; const renderedBusiness = await renderXmlTemplate(templateName, body?.data || {}); // Build STAR envelope let envelope = await buildStarEnvelope(renderedBusiness, cfg, body?.appArea); const formattedEnvelope = prettyPrintXml(envelope); // Guardrails if (!formattedEnvelope.includes("")) { throw new Error("STAR envelope malformed: missing ProcessMessage/ApplicationArea"); } const headers = { ...RR_SOAP_HEADERS, SOAPAction: RR_STAR_SOAP_ACTION }; RRLogger(socket, "debug", `Sending RR SOAP request`, { action, soapAction: RR_STAR_SOAP_ACTION, endpoint: baseUrl, jobid, mode: "STAR" }); try { const { data: responseXml } = await axios.post(baseUrl, formattedEnvelope, { headers, timeout: cfg.timeout // Some RCI tenants require Basic in addition to WSSE // auth: { username: cfg.username, password: cfg.password } }); const parsed = parseRRResponse(responseXml); if (!parsed.success) { RRLogger(socket, "error", `RR ${action} failed`, { code: parsed.code, message: parsed.message }); throw new RrApiError(parsed.message || `RR ${action} failed`, parsed.code || "RR_ERROR"); } RRLogger(socket, "info", `RR ${action} success`, { result: parsed.code, message: parsed.message }); return responseXml; } catch (err) { if (retries > 0) { RRLogger(socket, "warn", `Retrying RR ${action} (${retries - 1} left)`, { error: err.message }); return MakeRRCall({ action, body, socket, dealerConfig: cfg, retries: retries - 1, jobid }); } RRLogger(socket, "error", `RR ${action} failed permanently`, { error: err.message }); throw err; } } module.exports = { MakeRRCall, renderXmlTemplate, resolveRRConfig, parseRRResponse, buildStarEnvelope, RRActions };