const queries = require("../graphql-client/queries"); const Dinero = require("dinero.js"); const qs = require("query-string"); const axios = require("axios"); const moment = require("moment"); const logger = require("../utils/logger"); const { sendTaskEmail } = require("../email/sendemail"); const generateEmailTemplate = require("../email/generateTemplate"); const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager"); const { InstanceRegion, InstanceEndpoints } = require("../utils/instanceMgr"); const { isEmpty, isNumber } = require("lodash"); const domain = process.env.NODE_ENV ? "secure" : "test"; const client = new SecretsManagerClient({ region: InstanceRegion() }); const gqlClient = require("../graphql-client/graphql-client").client; /** * Generates a properly formatted Cpteller API URL * @param {Object} options - URL configuration options * @param {string} options.apiType - 'webapi' or 'custapi' * @param {string} [options.version] - API version (e.g., '26' for webapi) * @param {Object} [options.params] - URL query parameters * @returns {string} - The formatted Cpteller URL */ const getCptellerUrl = (options) => { const { apiType = "webapi", version, params = {} } = options; // Base URL construction let url = `https://${domain}.cpteller.com/api/`; // Add version if specified for webapi if (apiType === "webapi" && version) { url += `${version}/`; } // Add the API endpoint url += `${apiType}.cfc`; // Add query parameters if any exist const queryParams = new URLSearchParams(params).toString(); if (queryParams) { url += `?${queryParams}`; } return url; }; /** * @description Get shop credentials from AWS Secrets Manager * @param bodyshop * @returns {Promise<{error}|{merchantkey: *, apikey: *}|any>} */ const getShopCredentials = async (bodyshop) => { // In Dev/Testing we will use the environment variables if (process.env?.NODE_ENV !== "production") { return { merchantkey: process.env.INTELLIPAY_MERCHANTKEY, apikey: process.env.INTELLIPAY_APIKEY }; } // In Production we will use the AWS Secrets Manager if (bodyshop?.imexshopid) { try { const secret = await client.send( new GetSecretValueCommand({ SecretId: `intellipay-credentials-${bodyshop.imexshopid}`, VersionStage: "AWSCURRENT" // VersionStage defaults to AWSCURRENT if unspecified }) ); return JSON.parse(secret.SecretString); } catch (error) { return { error: error.message }; } } }; /** * @description Decode the comment from base64 * @param comment * @returns {any|null} */ const decodeComment = (comment) => { try { return comment ? JSON.parse(Buffer.from(comment, "base64").toString()) : null; } catch (error) { return null; // Handle malformed base64 string gracefully } }; /** * @description Get lightbox credentials for the shop * @param req * @param res * @returns {Promise} */ const lightboxCredentials = async (req, res) => { const decodedComment = decodeComment(req.body?.comment); const logMeta = { iPayData: req.body?.iPayData, decodedComment, bodyshop: { id: req.body?.bodyshop?.id, imexshopid: req.body?.bodyshop?.imexshopid, name: req.body?.bodyshop?.shopname } }; logger.log("intellipay-lightbox-credentials", "DEBUG", req.user?.email, null, logMeta); const shopCredentials = await getShopCredentials(req.body.bodyshop); if (shopCredentials?.error) { logger.log("intellipay-credentials-error", "ERROR", req.user?.email, null, { message: shopCredentials.error?.message, ...logMeta }); return res.json({ message: shopCredentials.error?.message, type: "intellipay-credentials-error", ...logMeta }); } try { const options = { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, data: qs.stringify({ ...shopCredentials, operatingenv: "businessattended" }), url: getCptellerUrl({ apiType: "custapi", params: { method: `autoterminal${req.body.refresh ? "_refresh" : ""}` } }) }; const response = await axios(options); logger.log("intellipay-lightbox-success", "DEBUG", req.user?.email, null, { requestOptions: options, ...logMeta }); return res.send(response.data); } catch (error) { logger.log("intellipay-lightbox-error", "ERROR", req.user?.email, null, { message: error?.message, ...logMeta }); return res.json({ message: error?.message, type: "intellipay-lightbox-error", ...logMeta }); } }; /** * @description Process payment refund * @param req * @param res * @returns {Promise} */ const paymentRefund = async (req, res) => { const decodedComment = decodeComment(req.body.iPayData?.comment); const logResponseMeta = { iPayData: req.body?.iPayData, bodyshop: { id: req.body.bodyshop?.id, imexshopid: req.body.bodyshop?.imexshopid, name: req.body.bodyshop?.shopname }, paymentid: req.body?.paymentid, amount: req.body?.amount, decodedComment }; logger.log("intellipay-refund-request-received", "DEBUG", req.user?.email, null, logResponseMeta); const shopCredentials = await getShopCredentials(req.body.bodyshop); if (shopCredentials?.error) { logger.log("intellipay-refund-credentials-error", "ERROR", req.user?.email, null, { credentialsError: shopCredentials.error, ...logResponseMeta }); return res.status(400).json({ credentialsError: shopCredentials.error, type: "intellipay-refund-credentials-error", ...logResponseMeta }); } try { const options = { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, data: qs.stringify({ method: "payment_refund", ...shopCredentials, paymentid: req.body.paymentid, amount: req.body.amount }), url: getCptellerUrl({ apiType: "webapi", version: "26", params: { method: "payment_refund" } }) }; logger.log("intellipay-refund-options-prepared", "DEBUG", req.user?.email, null, { requestOptions: options, ...logResponseMeta }); const response = await axios(options); logger.log("intellipay-refund-success", "DEBUG", req.user?.email, null, { requestOptions: options, ...logResponseMeta }); return res.send(response.data); } catch (error) { logger.log("intellipay-refund-error", "ERROR", req.user?.email, null, { message: error?.message, ...logResponseMeta }); return res.status(500).json({ message: error?.message, type: "intellipay-refund-error", ...logResponseMeta }); } }; /** * @description Generate payment URL for the shop * @param req * @param res * @returns {Promise} */ const generatePaymentUrl = async (req, res) => { const decodedComment = decodeComment(req.body.comment); const logResponseMeta = { iPayData: req.body?.iPayData, bodyshop: { id: req.body.bodyshop?.id, imexshopid: req.body.bodyshop?.imexshopid, name: req.body.bodyshop?.shopname }, amount: req.body?.amount, account: req.body?.account, comment: req.body?.comment, invoice: req.body?.invoice, decodedComment }; logger.log("intellipay-generate-payment-url-received", "DEBUG", req.user?.email, null, logResponseMeta); const shopCredentials = await getShopCredentials(req.body.bodyshop); if (shopCredentials?.error) { logger.log("intellipay-generate-payment-url-credentials-error", "ERROR", req.user?.email, null, { message: shopCredentials.error?.message, ...logResponseMeta }); return res.status(400).json({ message: shopCredentials.error?.message, type: "intellipay-generate-payment-url-credentials-error", ...logResponseMeta }); } try { const options = { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, data: qs.stringify({ ...shopCredentials, amount: Dinero({ amount: Math.round(req.body.amount * 100) }).toFormat("0.00"), account: req.body.account, comment: req.body.comment, invoice: req.body.invoice, createshorturl: true }), url: getCptellerUrl({ apiType: "custapi", params: { method: "generate_lightbox_url" } }) }; logger.log("intellipay-generate-payment-url-options-prepared", "DEBUG", req.user?.email, null, { requestOptions: options, ...logResponseMeta }); const response = await axios(options); logger.log("intellipay-generate-payment-url-success", "DEBUG", req.user?.email, null, { requestOptions: options, shortUrl: response.data?.shorturl, ...logResponseMeta }); return res.send(response.data); } catch (error) { logger.log("intellipay-generate-payment-url-error", "ERROR", req.user?.email, null, { message: error?.message, ...logResponseMeta }); return res.status(500).json({ message: error?.message, ...logResponseMeta }); } }; /** * @description Check the fee for a given amount * Reference: https://intellipay.com/dist/webapi26.html#operation/fee * @param req * @param res * @returns {Promise} */ const checkFee = async (req, res) => { const logResponseMeta = { bodyshop: { id: req.body?.bodyshop?.id, imexshopid: req.body?.bodyshop?.imexshopid, name: req.body?.bodyshop?.shopname, state: req.body?.bodyshop?.state }, amount: req.body?.amount }; logger.log("intellipay-checkfee-request-received", "DEBUG", req.user?.email, null, logResponseMeta); if (!isNumber(req.body?.amount) || req.body?.amount <= 0) { logger.log("intellipay-checkfee-skip", "DEBUG", req.user?.email, null, { message: "Amount is zero or undefined, skipping fee check.", ...logResponseMeta }); return res.json({ fee: 0 }); } const shopCredentials = await getShopCredentials(req.body.bodyshop); if (shopCredentials?.error) { logger.log("intellipay-checkfee-credentials-error", "ERROR", req.user?.email, null, { message: shopCredentials.error?.message, ...logResponseMeta }); return res.status(400).json({ error: shopCredentials.error?.message, ...logResponseMeta }); } try { const options = { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, data: qs.stringify( { method: "fee", ...shopCredentials, amount: req.body.amount, paymenttype: `CC`, cardnum: "4111111111111111", // Required for compatibility with API state: req.body.bodyshop?.state && req.body.bodyshop.state.length === 2 ? req.body.bodyshop.state.toUpperCase() : "ZZ" }, { sort: false } // Ensure query string order is preserved ), url: getCptellerUrl({ apiType: "webapi", version: "26" }) }; logger.log("intellipay-checkfee-options-prepared", "DEBUG", req.user?.email, null, { requestOptions: options, ...logResponseMeta }); const response = await axios(options); if (response.data?.error) { logger.log("intellipay-checkfee-api-error", "ERROR", req.user?.email, null, { message: response.data?.error, ...logResponseMeta }); return res.status(400).json({ error: response.data?.error, type: "intellipay-checkfee-api-error", ...logResponseMeta }); } if (response.data < 0) { logger.log("intellipay-checkfee-negative-fee", "ERROR", req.user?.email, null, { message: "Fee amount returned is negative.", ...logResponseMeta }); return res.json({ error: "Fee amount negative. Check API credentials & account configuration.", ...logResponseMeta, type: "intellipay-checkfee-negative-fee" }); } logger.log("intellipay-checkfee-success", "DEBUG", req.user?.email, null, { fee: response.data, ...logResponseMeta }); return res.json({ fee: response.data, ...logResponseMeta }); } catch (error) { logger.log("intellipay-checkfee-error", "ERROR", req.user?.email, null, { message: error?.message, ...logResponseMeta }); return res.status(500).json({ error: error?.message, logResponseMeta }); } }; /** * @description Handle the postback from Intellipay * @param req * @param res * @returns {Promise} */ const postBack = async (req, res) => { const { body: values } = req; const decodedComment = decodeComment(values?.comment); const logResponseMeta = { iprequest: values, decodedComment }; logger.log("intellipay-postback-received", "DEBUG", "api", null, logResponseMeta); try { if (isEmpty(values?.invoice) && !decodedComment) { logger.log("intellipay-postback-ignored", "DEBUG", "api", null, { message: "No invoice or comment provided", ...logResponseMeta }); return res.sendStatus(200); } if (decodedComment) { const parsedComment = decodedComment; logger.log("intellipay-postback-parsed-comment", "DEBUG", "api", null, { parsedComment, ...logResponseMeta }); const partialPayments = Array.isArray(parsedComment) ? parsedComment : parsedComment.payments; const jobs = await gqlClient.request(queries.GET_JOBS_BY_PKS, { ids: partialPayments.map((p) => p.jobid) }); const bodyshop = await gqlClient.request(queries.GET_BODYSHOP_BY_ID, { id: jobs.jobs[0].shopid }); const ipMapping = bodyshop.bodyshops_by_pk.intellipay_config?.payment_map; logger.log("intellipay-postback-jobs-fetched", "DEBUG", "api", null, { jobs, parsedComment, ...logResponseMeta }); const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, { paymentInput: partialPayments.map((p) => ({ amount: p.amount, transactionid: values.authcode, payer: "Customer", type: ipMapping ? ipMapping[(values.cardtype || "").toLowerCase()] || values.cardtype : values.cardtype, jobid: p.jobid, date: moment(Date.now()), payment_responses: { data: { amount: values.total, bodyshopid: bodyshop.bodyshops_by_pk.id, jobid: p.jobid, declinereason: "Approved", ext_paymentid: values.paymentid, successful: true, response: values } } })) }); logger.log("intellipay-postback-payment-success", "DEBUG", "api", null, { paymentResult, jobs, parsedComment, ...logResponseMeta }); if (values?.origin === "OneLink" && parsedComment?.userEmail) { sendTaskEmail({ to: parsedComment.userEmail, subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`, type: "html", html: generateEmailTemplate({ header: "New Payment(s) Received", subHeader: "", body: jobs.jobs .map( (job) => `Reference: ${job.ro_number || "N/A"} | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}` ) .join("
") }) }).catch((error) => { logger.log("intellipay-postback-email-error", "ERROR", "api", null, { message: error.message, jobs, paymentResult, ...logResponseMeta }); }); } return res.sendStatus(200); } if (values?.invoice) { // Early Bail on Merchant ID if (!values?.merchantid) { logger.log("intellipay-postback-no-merchantid", "ERROR", "api", null, { message: "Merchant ID is missing", ...logResponseMeta }); return res.status(400).send("Bad Request: Merchant ID is missing"); } const result = await gqlClient.request(queries.GET_JOBID_BY_MERCHANTID_RONUMBER, { merchantID: values.merchantid, roNumber: values.invoice }); // Early Bail on No Jobs Found if (!result?.jobs?.length) { logger.log("intellipay-postback-job-not-found", "ERROR", "api", null, { message: "Job not found", ...logResponseMeta }); return res.status(400).send("Bad Request: Job not found"); } const job = result?.jobs?.[0]; const bodyshop = job?.bodyshop; // Early Bail on no Bodyshop Found if (!bodyshop) { logger.log("intellipay-postback-bodyshop-not-found", "ERROR", "api", null, { message: "Bodyshop not found", ...logResponseMeta }); return res.status(400).send("Bad Request: Bodyshop not found"); } const ipMapping = bodyshop?.intellipay_config?.payment_map; logger.log("intellipay-postback-invoice-job-fetched", "DEBUG", "api", null, { job, ...logResponseMeta }); const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, { paymentInput: { amount: values.total, transactionid: values.authcode, payer: "Customer", type: ipMapping ? ipMapping[(values.cardtype || "").toLowerCase()] || values.cardtype : values.cardtype, jobid: job.id, date: moment(Date.now()) } }); logger.log("intellipay-postback-invoice-payment-success", "DEBUG", "api", null, { paymentResult, ...logResponseMeta }); const responseResults = await gqlClient.request(queries.INSERT_PAYMENT_RESPONSE, { paymentResponse: { amount: values.total, bodyshopid: bodyshop.id, paymentid: paymentResult.id, jobid: job.id, declinereason: "Approved", ext_paymentid: values.paymentid, successful: true, response: values } }); logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, { responseResults, ...logResponseMeta }); return res.sendStatus(200); } // Default case: no valid conditions met logger.log("intellipay-postback-invalid", "WARN", "api", null, { message: "No valid invoice or comment provided", ...logResponseMeta }); return res.status(400).send("Bad Request: No valid invoice or comment provided"); } catch (error) { logger.log("intellipay-postback-error", "ERROR", "api", null, { message: error?.message, ...logResponseMeta }); return res.status(400).json({ successful: false, error: error.message, ...logResponseMeta }); } }; module.exports = { lightboxCredentials, paymentRefund, generatePaymentUrl, checkFee, postBack };