From cdd1a6a6cde1841dc4277d3cb8c2c0cd854976f2 Mon Sep 17 00:00:00 2001 From: Agustin Date: Mon, 5 Jan 2026 16:08:51 +0100 Subject: [PATCH 1/2] feat: liquid sampler command --- bin/cli.js | 36 ++++++ lib/api/sfApi.js | 26 ++++ lib/liquidSamplerRunner.js | 247 +++++++++++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 lib/liquidSamplerRunner.js diff --git a/bin/cli.js b/bin/cli.js index 79858a1..fba632a 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -4,6 +4,7 @@ const toolkit = require("../index"); const liquidTestGenerator = require("../lib/liquidTestGenerator"); const liquidTestRunner = require("../lib/liquidTestRunner"); const { ExportFileInstanceGenerator } = require("../lib/exportFileInstanceGenerator"); +const { LiquidSamplerRunner } = require("../lib/liquidSamplerRunner"); const stats = require("../lib/cli/stats"); const { Command, Option } = require("commander"); const pkg = require("../package.json"); @@ -514,6 +515,41 @@ program ); }); +// Run Liquid Sampler +program + .command("run-sampler") + .description("Run Liquid Sampler for partner templates (reconciliation texts, account detail templates, and/or shared parts)") + .requiredOption("-p, --partner ", "Specify the partner to be used") + .option("-h, --handle ", "Specify reconciliation text handle(s) - can specify multiple") + .option("-at, --account-template ", "Specify account detail template name(s) - can specify multiple") + .option("-s, --shared-part ", "Specify shared part name(s) - can specify multiple") + .option("-i, --id ", "Specify an existing sampler ID to fetch results for (optional)") + .action(async (options) => { + // If an existing sampler ID is provided, fetch and display results + if (options.id) { + await new LiquidSamplerRunner(options.partner).checkStatus(options.id); + return; + } + + // Validate: at least one template specified + const handles = options.handle || []; + const accountTemplates = options.accountTemplate || []; + const sharedParts = options.sharedPart || []; + + if (handles.length === 0 && accountTemplates.length === 0 && sharedParts.length === 0) { + consola.error("You need to specify at least one template using -h, -at, or -s"); + process.exit(1); + } + + const templateHandles = { + reconciliationTexts: handles, + accountTemplates: accountTemplates, + sharedParts: sharedParts, + }; + + await new LiquidSamplerRunner(options.partner).run(templateHandles); + }); + // Create Liquid Test program .command("create-test") diff --git a/lib/api/sfApi.js b/lib/api/sfApi.js index 6e54717..56e2697 100644 --- a/lib/api/sfApi.js +++ b/lib/api/sfApi.js @@ -717,6 +717,30 @@ async function getExportFileInstance(firmId, companyId, periodId, exportFileInst } } +async function createSamplerRun(partnerId, attributes) { + const instance = AxiosFactory.createInstance("partner", partnerId); + try { + const response = await instance.post("liquid_sampler/run", attributes); + apiUtils.responseSuccessHandler(response); + return response; + } catch (error) { + const response = await apiUtils.responseErrorHandler(error); + return response; + } +} + +async function readSamplerRun(partnerId, samplerId) { + const instance = AxiosFactory.createInstance("partner", partnerId); + try { + const response = await instance.get(`liquid_sampler/${samplerId}`); + apiUtils.responseSuccessHandler(response); + return response; + } catch (error) { + const response = await apiUtils.responseErrorHandler(error); + return response; + } +} + module.exports = { authorizeFirm, refreshFirmTokens, @@ -771,4 +795,6 @@ module.exports = { getFirmDetails, createExportFileInstance, getExportFileInstance, + createSamplerRun, + readSamplerRun, }; diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js new file mode 100644 index 0000000..a0953fd --- /dev/null +++ b/lib/liquidSamplerRunner.js @@ -0,0 +1,247 @@ +const { UrlHandler } = require("./utils/urlHandler"); +const errorUtils = require("./utils/errorUtils"); +const { spinner } = require("./cli/spinner"); +const SF = require("./api/sfApi"); +const fsUtils = require("./utils/fsUtils"); +const { consola } = require("consola"); + +const { ReconciliationText } = require("./templates/reconciliationText"); +const { AccountTemplate } = require("./templates/accountTemplate"); +const { SharedPart } = require("./templates/sharedPart"); + +/** + * Class to run liquid samplers for partner templates + */ +class LiquidSamplerRunner { + constructor(partnerId) { + this.partnerId = partnerId; + } + + /** + * Run liquid sampler for partner templates + * @param {Object} templateHandles - Object containing arrays of template identifiers + * @param {Array} templateHandles.reconciliationTexts - Array of reconciliation text handles + * @param {Array} templateHandles.accountTemplates - Array of account template names + * @param {Array} templateHandles.sharedParts - Array of shared part names + * @returns {Promise} + */ + async run(templateHandles = {}) { + try { + // Validate at least one template specified + const { reconciliationTexts = [], accountTemplates = [], sharedParts = [] } = templateHandles; + if (reconciliationTexts.length === 0 && accountTemplates.length === 0 && sharedParts.length === 0) { + consola.error("You need to specify at least one template using -h, -at, or -s"); + process.exit(1); + } + + // Build payload + const samplerParams = await this.#buildSamplerParams(templateHandles); + + consola.info(`Starting sampler run with ${samplerParams.templates.length} template(s)...`); + + // Start sampler run + const samplerResponse = await SF.createSamplerRun(this.partnerId, samplerParams); + const samplerId = samplerResponse.data.id || samplerResponse.data; + + if (!samplerId) { + consola.error("Failed to start sampler run - no ID returned"); + process.exit(1); + } + + consola.info(`Sampler run started with ID: ${samplerId}`); + + // Poll for completion + const samplerRun = await this.#fetchAndWaitSamplerResult(samplerId); + + // Process results + await this.#handleSamplerResponse(samplerRun); + } catch (error) { + errorUtils.errorHandler(error); + } + } + + /** + * Fetch the status of an existing sampler run + * @param {string} samplerId - The sampler run ID + * @returns {Promise} + */ + async checkStatus(samplerId) { + try { + consola.info(`Fetching status for sampler run ID: ${samplerId}`); + + const response = await SF.readSamplerRun(this.partnerId, samplerId); + + await this.#handleSamplerResponse(response.data); + } catch (error) { + errorUtils.errorHandler(error); + } + } + + /** + * Build sampler parameters from local template files + * @param {Object} templateHandles - Object containing arrays of template identifiers + * @param {Array} templateHandles.reconciliationTexts - Array of reconciliation text handles + * @param {Array} templateHandles.accountTemplates - Array of account template names + * @param {Array} templateHandles.sharedParts - Array of shared part names + * @returns {Object} Sampler payload with templates array + */ + async #buildSamplerParams(templateHandles = {}) { + const templates = []; + const { reconciliationTexts = [], accountTemplates = [], sharedParts = [] } = templateHandles; + + // Process reconciliation texts + for (const handle of reconciliationTexts) { + const templateType = "reconciliationText"; + const configPresent = fsUtils.configExists(templateType, handle); + + if (!configPresent) { + consola.error(`Config file for reconciliation text "${handle}" not found`); + process.exit(1); + } + + const config = fsUtils.readConfig(templateType, handle); + + // Validate partner_id exists in config + if (!config.partner_id || !config.partner_id[this.partnerId]) { + consola.error(`Template '${handle}' has no partner_id entry for partner ${this.partnerId}. Import the template to this partner first.`); + process.exit(1); + } + + const templateId = config.partner_id[this.partnerId]; + const templateContent = ReconciliationText.read(handle); + + templates.push({ + type: "Global::Partner::ReconciliationText", + id: String(templateId), + text: templateContent.text, + text_parts: templateContent.text_parts, + }); + } + + // Process account templates + for (const name of accountTemplates) { + const templateType = "accountTemplate"; + const configPresent = fsUtils.configExists(templateType, name); + + if (!configPresent) { + consola.error(`Config file for account template "${name}" not found`); + process.exit(1); + } + + const config = fsUtils.readConfig(templateType, name); + + // Validate partner_id exists in config + if (!config.partner_id || !config.partner_id[this.partnerId]) { + consola.error(`Template '${name}' has no partner_id entry for partner ${this.partnerId}. Import the template to this partner first.`); + process.exit(1); + } + + const templateId = config.partner_id[this.partnerId]; + const templateContent = AccountTemplate.read(name); + + templates.push({ + type: "Global::Partner::AccountDetailTemplate", + id: String(templateId), + text: templateContent.text, + text_parts: templateContent.text_parts, + }); + } + + // Process shared parts + for (const name of sharedParts) { + const templateType = "sharedPart"; + const configPresent = fsUtils.configExists(templateType, name); + + if (!configPresent) { + consola.error(`Config file for shared part "${name}" not found`); + process.exit(1); + } + + const config = fsUtils.readConfig(templateType, name); + + // Validate partner_id exists in config + if (!config.partner_id || !config.partner_id[this.partnerId]) { + consola.error(`Shared part '${name}' has no partner_id entry for partner ${this.partnerId}. Import the shared part to this partner first.`); + process.exit(1); + } + + const templateId = config.partner_id[this.partnerId]; + const templateContent = await SharedPart.read(name); + + templates.push({ + type: "Global::Partner::SharedPart", + id: String(templateId), + text: templateContent.text, + }); + } + + return { templates }; + } + + /** + * Poll for sampler run completion + * @param {number} partnerId - The partner ID + * @param {string} samplerId - The sampler run ID + * @returns {Promise} The completed sampler run + */ + async #fetchAndWaitSamplerResult(samplerId) { + let samplerRun = { status: "pending" }; + const pollingDelay = 10000; // 10 seconds + const waitingLimit = 2000000; // 2000 seconds + + spinner.spin("Running sampler..."); + let waitingTime = 0; + + while (samplerRun.status === "pending" || samplerRun.status === "running") { + await new Promise((resolve) => setTimeout(resolve, pollingDelay)); + + const response = await SF.readSamplerRun(this.partnerId, samplerId); + samplerRun = response.data; + + waitingTime += pollingDelay; + // pollingDelay *= 1.05; + + if (waitingTime >= waitingLimit) { + spinner.stop(); + consola.error("Timeout. Try to run your sampler again"); + process.exit(1); + } + } + + spinner.stop(); + return samplerRun; + } + + /** + * Process and display sampler run results + * @param {Object} response - The sampler run response + */ + async #handleSamplerResponse(response) { + if (!response || !response.status) { + consola.error("Invalid sampler response"); + process.exit(1); + } + + switch (response.status) { + case "failed": + consola.error(`Sampler run failed: ${response.error_message || "Unknown error"}`); + break; + + case "completed": + consola.success("Sampler run completed successfully"); + + if (response && response.result_url) { + await new UrlHandler(response.content_url).openFile(); + } else { + consola.warn("No URL returned"); + } + break; + + default: + consola.error(`Unexpected sampler status: ${response.status}`); + process.exit(1); + } + } +} + +module.exports = { LiquidSamplerRunner }; From 43f855be7397973759854e0749e7bd6cfd182409 Mon Sep 17 00:00:00 2001 From: Agustin Date: Thu, 29 Jan 2026 11:46:43 +0100 Subject: [PATCH 2/2] feat: firm_ids parameter --- bin/cli.js | 7 +++++-- lib/liquidSamplerRunner.js | 28 ++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index fba632a..5fadcfa 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -523,7 +523,8 @@ program .option("-h, --handle ", "Specify reconciliation text handle(s) - can specify multiple") .option("-at, --account-template ", "Specify account detail template name(s) - can specify multiple") .option("-s, --shared-part ", "Specify shared part name(s) - can specify multiple") - .option("-i, --id ", "Specify an existing sampler ID to fetch results for (optional)") + .option("--firm-ids ", "Specify firm ID(s) to run the sampler against - can specify multiple (optional)") + .option("--id ", "Specify an existing sampler ID to fetch results for (optional)") .action(async (options) => { // If an existing sampler ID is provided, fetch and display results if (options.id) { @@ -547,7 +548,9 @@ program sharedParts: sharedParts, }; - await new LiquidSamplerRunner(options.partner).run(templateHandles); + const firmIds = options.firmIds || []; + + await new LiquidSamplerRunner(options.partner).run(templateHandles, firmIds); }); // Create Liquid Test diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index a0953fd..e43517b 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -23,9 +23,10 @@ class LiquidSamplerRunner { * @param {Array} templateHandles.reconciliationTexts - Array of reconciliation text handles * @param {Array} templateHandles.accountTemplates - Array of account template names * @param {Array} templateHandles.sharedParts - Array of shared part names + * @param {Array} firmIds - Array of firm IDs to use in the sampler * @returns {Promise} */ - async run(templateHandles = {}) { + async run(templateHandles = {}, firmIds = []) { try { // Validate at least one template specified const { reconciliationTexts = [], accountTemplates = [], sharedParts = [] } = templateHandles; @@ -35,7 +36,7 @@ class LiquidSamplerRunner { } // Build payload - const samplerParams = await this.#buildSamplerParams(templateHandles); + const samplerParams = await this.#buildSamplerParams(templateHandles, firmIds); consola.info(`Starting sampler run with ${samplerParams.templates.length} template(s)...`); @@ -71,6 +72,11 @@ class LiquidSamplerRunner { const response = await SF.readSamplerRun(this.partnerId, samplerId); + if (!response || !response.data) { + consola.error("Failed to fetch sampler run status. Is staging running?"); + process.exit(1); + } + await this.#handleSamplerResponse(response.data); } catch (error) { errorUtils.errorHandler(error); @@ -83,9 +89,10 @@ class LiquidSamplerRunner { * @param {Array} templateHandles.reconciliationTexts - Array of reconciliation text handles * @param {Array} templateHandles.accountTemplates - Array of account template names * @param {Array} templateHandles.sharedParts - Array of shared part names + * @param {Array} firmIds - Array of firm IDs to use in the sampler * @returns {Object} Sampler payload with templates array */ - async #buildSamplerParams(templateHandles = {}) { + async #buildSamplerParams(templateHandles = {}, firmIds = []) { const templates = []; const { reconciliationTexts = [], accountTemplates = [], sharedParts = [] } = templateHandles; @@ -175,7 +182,7 @@ class LiquidSamplerRunner { }); } - return { templates }; + return { templates, firm_ids: firmIds }; } /** @@ -186,8 +193,8 @@ class LiquidSamplerRunner { */ async #fetchAndWaitSamplerResult(samplerId) { let samplerRun = { status: "pending" }; - const pollingDelay = 10000; // 10 seconds - const waitingLimit = 2000000; // 2000 seconds + const pollingDelay = 15000; // 15 seconds + const waitingLimit = 3600000; // 1 hour spinner.spin("Running sampler..."); let waitingTime = 0; @@ -203,7 +210,7 @@ class LiquidSamplerRunner { if (waitingTime >= waitingLimit) { spinner.stop(); - consola.error("Timeout. Try to run your sampler again"); + consola.error("Timeout. Try to fetch the status by using the --id flag, if not run your sampler again"); process.exit(1); } } @@ -231,12 +238,17 @@ class LiquidSamplerRunner { consola.success("Sampler run completed successfully"); if (response && response.result_url) { - await new UrlHandler(response.content_url).openFile(); + await new UrlHandler(response.result_url).openFile(); } else { consola.warn("No URL returned"); } break; + case "pending": + case "running": + consola.info(`Sampler run is still in progress. Current status: "${response.status}". Please check again later.`); + break; + default: consola.error(`Unexpected sampler status: ${response.status}`); process.exit(1);