feature/IO-2885-IntelliPay-App-Postback
- Refactor / Add Tests
This commit is contained in:
@@ -1,98 +1,14 @@
|
|||||||
const queries = require("../graphql-client/queries");
|
|
||||||
const Dinero = require("dinero.js");
|
const Dinero = require("dinero.js");
|
||||||
const qs = require("query-string");
|
const qs = require("query-string");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const moment = require("moment");
|
|
||||||
const logger = require("../utils/logger");
|
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 { isEmpty, isNumber } = require("lodash");
|
||||||
|
const handleCommentBasedPayment = require("./lib/handleCommentBasedPayment");
|
||||||
const domain = process.env.NODE_ENV ? "secure" : "test";
|
const handleInvoiceBasedPayment = require("./lib/handleInvoiceBasedPayment");
|
||||||
|
const logValidationError = require("./lib/handlePaymentValidationError");
|
||||||
const client = new SecretsManagerClient({
|
const getCptellerUrl = require("./lib/getCptellerUrl");
|
||||||
region: InstanceRegion()
|
const getShopCredentials = require("./lib/getShopCredentials");
|
||||||
});
|
const decodeComment = require("./lib/decodeComment");
|
||||||
|
|
||||||
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
|
* @description Get lightbox credentials for the shop
|
||||||
@@ -443,207 +359,42 @@ const checkFee = async (req, res) => {
|
|||||||
* @param res
|
* @param res
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Handle the postback from Intellipay payment system
|
||||||
|
*/
|
||||||
const postBack = async (req, res) => {
|
const postBack = async (req, res) => {
|
||||||
const { body: values } = req;
|
const { body: values } = req;
|
||||||
const decodedComment = decodeComment(values?.comment);
|
const decodedComment = decodeComment(values?.comment);
|
||||||
|
const logMeta = { iprequest: values, decodedComment };
|
||||||
|
|
||||||
const logResponseMeta = {
|
logger.log("intellipay-postback-received", "DEBUG", "api", null, logMeta);
|
||||||
iprequest: values,
|
|
||||||
decodedComment
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.log("intellipay-postback-received", "DEBUG", "api", null, logResponseMeta);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Handle empty/invalid requests
|
||||||
if (isEmpty(values?.invoice) && !decodedComment) {
|
if (isEmpty(values?.invoice) && !decodedComment) {
|
||||||
logger.log("intellipay-postback-ignored", "DEBUG", "api", null, {
|
logger.log("intellipay-postback-ignored", "DEBUG", "api", null, {
|
||||||
message: "No invoice or comment provided",
|
message: "No invoice or comment provided",
|
||||||
...logResponseMeta
|
...logMeta
|
||||||
});
|
});
|
||||||
return res.sendStatus(200);
|
return res.sendStatus(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process payment based on data type
|
||||||
if (decodedComment) {
|
if (decodedComment) {
|
||||||
const parsedComment = decodedComment;
|
return await handleCommentBasedPayment(values, decodedComment, logger, logMeta, res);
|
||||||
|
} else if (values?.invoice) {
|
||||||
logger.log("intellipay-postback-parsed-comment", "DEBUG", "api", null, {
|
return await handleInvoiceBasedPayment(values, logger, logMeta, res);
|
||||||
parsedComment,
|
} else {
|
||||||
...logResponseMeta
|
// This should be caught by first validation, but as a safeguard
|
||||||
});
|
logValidationError("intellipay-postback-invalid", "No valid invoice or comment provided", logMeta);
|
||||||
|
return res.status(400).send("Bad Request: No valid invoice or comment provided");
|
||||||
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: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</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("<br/>")
|
|
||||||
})
|
|
||||||
}).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) {
|
} catch (error) {
|
||||||
logger.log("intellipay-postback-error", "ERROR", "api", null, {
|
logger.log("intellipay-postback-error", "ERROR", "api", null, {
|
||||||
message: error?.message,
|
message: error?.message,
|
||||||
...logResponseMeta
|
...logMeta
|
||||||
});
|
});
|
||||||
|
return res.status(400).json({ successful: false, error: error.message, ...logMeta });
|
||||||
return res.status(400).json({ successful: false, error: error.message, ...logResponseMeta });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
316
server/intellipay/intellipay.test.js
Normal file
316
server/intellipay/intellipay.test.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
const getPaymentType = require("./lib/getPaymentType");
|
||||||
|
const decodeComment = require("./lib/decodeComment");
|
||||||
|
const getCptellerUrl = require("./lib/getCptellerUrl");
|
||||||
|
const handlePaymentValidationError = require("./lib/handlePaymentValidationError");
|
||||||
|
const getShopCredentials = require("./lib/getShopCredentials");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Decode a base64-encoded JSON comment
|
||||||
|
*/
|
||||||
|
describe("decodeComment", () => {
|
||||||
|
it("decodes a valid base64-encoded JSON comment", () => {
|
||||||
|
// {"test":"data"} encoded in base64
|
||||||
|
const encoded = "eyJ0ZXN0IjoiZGF0YSJ9";
|
||||||
|
const expected = { test: "data" };
|
||||||
|
expect(decodeComment(encoded)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes a complex base64-encoded JSON with payments", () => {
|
||||||
|
// {"payments":[{"jobid":"123"}]} encoded in base64
|
||||||
|
const encoded = "eyJwYXltZW50cyI6W3siam9iaWQiOiIxMjMifV19";
|
||||||
|
const expected = { payments: [{ jobid: "123" }] };
|
||||||
|
expect(decodeComment(encoded)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when comment is null", () => {
|
||||||
|
expect(decodeComment(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when comment is undefined", () => {
|
||||||
|
expect(decodeComment(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when comment is an empty string", () => {
|
||||||
|
expect(decodeComment("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when comment is malformed base64", () => {
|
||||||
|
expect(decodeComment("!@#$%")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when comment is valid base64 but not valid JSON", () => {
|
||||||
|
// "invalid" in base64 is "aW52YWxpZA=="
|
||||||
|
expect(decodeComment("aW52YWxpZA==")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Get the payment type based on the card type
|
||||||
|
*/
|
||||||
|
describe("getPaymentType", () => {
|
||||||
|
it("returns mapped value when card type exists in mapping", () => {
|
||||||
|
const ipMapping = { visa: "Visa Card", amex: "American Express" };
|
||||||
|
expect(getPaymentType(ipMapping, "visa")).toBe("Visa Card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns original value when card type not in mapping", () => {
|
||||||
|
const ipMapping = { visa: "Visa Card" };
|
||||||
|
expect(getPaymentType(ipMapping, "mastercard")).toBe("mastercard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles lowercase conversion", () => {
|
||||||
|
const ipMapping = { visa: "Visa Card" };
|
||||||
|
expect(getPaymentType(ipMapping, "VISA")).toBe("Visa Card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null mapping", () => {
|
||||||
|
expect(getPaymentType(null, "visa")).toBe("visa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined mapping", () => {
|
||||||
|
expect(getPaymentType(undefined, "visa")).toBe("visa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string card type", () => {
|
||||||
|
const ipMapping = { visa: "Visa Card" };
|
||||||
|
expect(getPaymentType(ipMapping, "")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined card type", () => {
|
||||||
|
const ipMapping = { visa: "Visa Card" };
|
||||||
|
expect(getPaymentType(ipMapping, undefined)).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Get the CPTeller URL based on environment and parameters
|
||||||
|
*/
|
||||||
|
describe("getCptellerUrl", () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore the original NODE_ENV after each test
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses test domain in non-production environment", () => {
|
||||||
|
process.env.NODE_ENV = "";
|
||||||
|
const url = getCptellerUrl({ apiType: "webapi" });
|
||||||
|
expect(url).toEqual("https://test.cpteller.com/api/webapi.cfc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses secure domain in production environment", () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
const url = getCptellerUrl({ apiType: "webapi" });
|
||||||
|
expect(url).toEqual("https://secure.cpteller.com/api/webapi.cfc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds version number for webapi type", () => {
|
||||||
|
process.env.NODE_ENV = "";
|
||||||
|
const url = getCptellerUrl({ apiType: "webapi", version: "26" });
|
||||||
|
expect(url).toEqual("https://test.cpteller.com/api/26/webapi.cfc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs custapi URL without version number", () => {
|
||||||
|
process.env.NODE_ENV = "";
|
||||||
|
const url = getCptellerUrl({ apiType: "custapi", version: "26" });
|
||||||
|
expect(url).toEqual("https://test.cpteller.com/api/custapi.cfc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds query parameters to the URL", () => {
|
||||||
|
process.env.NODE_ENV = "";
|
||||||
|
const url = getCptellerUrl({
|
||||||
|
apiType: "webapi",
|
||||||
|
params: { method: "payment_refund", test: "value" }
|
||||||
|
});
|
||||||
|
expect(url).toEqual("https://test.cpteller.com/api/webapi.cfc?method=payment_refund&test=value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty params object", () => {
|
||||||
|
process.env.NODE_ENV = "";
|
||||||
|
const url = getCptellerUrl({ apiType: "webapi", params: {} });
|
||||||
|
expect(url).toEqual("https://test.cpteller.com/api/webapi.cfc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to webapi when no apiType is provided", () => {
|
||||||
|
process.env.NODE_ENV = "";
|
||||||
|
const url = getCptellerUrl({});
|
||||||
|
expect(url).toEqual("https://test.cpteller.com/api/webapi.cfc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines version and query parameters correctly", () => {
|
||||||
|
process.env.NODE_ENV = "";
|
||||||
|
const url = getCptellerUrl({
|
||||||
|
apiType: "webapi",
|
||||||
|
version: "26",
|
||||||
|
params: { method: "fee" }
|
||||||
|
});
|
||||||
|
expect(url).toEqual("https://test.cpteller.com/api/26/webapi.cfc?method=fee");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Get shop credentials from AWS Secrets Manager or environment variables
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("getShopCredentials", () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
let mockSend;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create a mock function for send
|
||||||
|
mockSend = vi.fn();
|
||||||
|
|
||||||
|
// Mock the entire AWS SDK module
|
||||||
|
vi.mock("@aws-sdk/client-secrets-manager", () => {
|
||||||
|
return {
|
||||||
|
SecretsManagerClient: vi.fn(() => ({
|
||||||
|
send: mockSend
|
||||||
|
})),
|
||||||
|
GetSecretValueCommand: vi.fn((input) => input)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup test environment variables
|
||||||
|
process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key";
|
||||||
|
process.env.INTELLIPAY_APIKEY = "test-api-key";
|
||||||
|
|
||||||
|
// Clear module cache to ensure fresh mock is used
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore environment and clear mocks
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unmock("@aws-sdk/client-secrets-manager");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns environment variables in non-production environment", async () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
|
||||||
|
const result = await getShopCredentials({ imexshopid: "12345" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
merchantkey: "test-merchant-key",
|
||||||
|
apikey: "test-api-key"
|
||||||
|
});
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when imexshopid is missing in production", async () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
const result = await getShopCredentials({ name: "Test Shop" });
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for null bodyshop in production", async () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
const result = await getShopCredentials(null);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for undefined bodyshop in production", async () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
const result = await getShopCredentials(undefined);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Handle payment validation errors
|
||||||
|
*/
|
||||||
|
describe("handlePaymentValidationError", () => {
|
||||||
|
it("logs error and sends 400 response", () => {
|
||||||
|
// Create mock objects
|
||||||
|
const mockLog = vi.fn();
|
||||||
|
const mockLogger = { log: mockLog };
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
send: vi.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const logCode = "test-validation-error";
|
||||||
|
const message = "Invalid data";
|
||||||
|
const logMeta = { field: "test", value: 123 };
|
||||||
|
|
||||||
|
// Call the function
|
||||||
|
const result = handlePaymentValidationError(mockRes, mockLogger, logCode, message, logMeta);
|
||||||
|
|
||||||
|
// Verify logger.log was called correctly
|
||||||
|
expect(mockLog).toHaveBeenCalledWith(logCode, "ERROR", "api", null, { message, ...logMeta });
|
||||||
|
|
||||||
|
// Verify res.status was called with 400
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||||
|
|
||||||
|
// Verify res.send was called with correct message
|
||||||
|
expect(mockRes.send).toHaveBeenCalledWith(`Bad Request: ${message}`);
|
||||||
|
|
||||||
|
// Verify the function returns the response
|
||||||
|
expect(result).toBe(mockRes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats different error messages correctly", () => {
|
||||||
|
const mockLog = vi.fn();
|
||||||
|
const mockLogger = { log: mockLog };
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
send: vi.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePaymentValidationError(mockRes, mockLogger, "error-code", "Custom error");
|
||||||
|
|
||||||
|
expect(mockRes.send).toHaveBeenCalledWith("Bad Request: Custom error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes different logCodes to logger", () => {
|
||||||
|
const mockLog = vi.fn();
|
||||||
|
const mockLogger = { log: mockLog };
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
send: vi.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePaymentValidationError(mockRes, mockLogger, "custom-log-code", "Error message");
|
||||||
|
|
||||||
|
expect(mockLog).toHaveBeenCalledWith("custom-log-code", "ERROR", "api", null, { message: "Error message" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with minimal logMeta", () => {
|
||||||
|
const mockLog = vi.fn();
|
||||||
|
const mockLogger = { log: mockLog };
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
send: vi.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePaymentValidationError(mockRes, mockLogger, "error-code", "Error message", {});
|
||||||
|
|
||||||
|
expect(mockLog).toHaveBeenCalledWith("error-code", "ERROR", "api", null, { message: "Error message" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with undefined logMeta", () => {
|
||||||
|
const mockLog = vi.fn();
|
||||||
|
const mockLogger = { log: mockLog };
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
send: vi.fn().mockReturnThis()
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePaymentValidationError(mockRes, mockLogger, "error-code", "Error message");
|
||||||
|
|
||||||
|
expect(mockLog).toHaveBeenCalledWith("error-code", "ERROR", "api", null, { message: "Error message" });
|
||||||
|
});
|
||||||
|
});
|
||||||
14
server/intellipay/lib/decodeComment.js
Normal file
14
server/intellipay/lib/decodeComment.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = decodeComment;
|
||||||
34
server/intellipay/lib/getCptellerUrl.js
Normal file
34
server/intellipay/lib/getCptellerUrl.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 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 domain = process.env?.NODE_ENV === "production" ? "secure" : "test";
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = getCptellerUrl;
|
||||||
12
server/intellipay/lib/getPaymentType.js
Normal file
12
server/intellipay/lib/getPaymentType.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @description Get payment type based on IP mapping
|
||||||
|
* @param ipMapping
|
||||||
|
* @param cardType
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
const getPaymentType = (ipMapping, cardType) => {
|
||||||
|
const normalizedCardType = (cardType || "").toLowerCase();
|
||||||
|
return ipMapping ? ipMapping[normalizedCardType] || cardType : cardType;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = getPaymentType;
|
||||||
40
server/intellipay/lib/getShopCredentials.js
Normal file
40
server/intellipay/lib/getShopCredentials.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||||
|
const { InstanceRegion } = require("../../utils/instanceMgr");
|
||||||
|
|
||||||
|
const client = new SecretsManagerClient({
|
||||||
|
region: InstanceRegion()
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = getShopCredentials;
|
||||||
81
server/intellipay/lib/handleCommentBasedPayment.js
Normal file
81
server/intellipay/lib/handleCommentBasedPayment.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail");
|
||||||
|
const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries");
|
||||||
|
const getPaymentType = require("./getPaymentType");
|
||||||
|
const moment = require("moment");
|
||||||
|
|
||||||
|
const gqlClient = require("../../graphql-client/graphql-client").client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Handle comment-based payment processing
|
||||||
|
* @param values
|
||||||
|
* @param decodedComment
|
||||||
|
* @param logger
|
||||||
|
* @param logMeta
|
||||||
|
* @param res
|
||||||
|
* @returns {Promise<*>}
|
||||||
|
*/
|
||||||
|
const handleCommentBasedPayment = async (values, decodedComment, logger, logMeta, res) => {
|
||||||
|
logger.log("intellipay-postback-parsed-comment", "DEBUG", "api", null, {
|
||||||
|
parsedComment: decodedComment,
|
||||||
|
...logMeta
|
||||||
|
});
|
||||||
|
|
||||||
|
const partialPayments = Array.isArray(decodedComment) ? decodedComment : decodedComment.payments;
|
||||||
|
|
||||||
|
// Fetch job data
|
||||||
|
const jobs = await gqlClient.request(GET_JOBS_BY_PKS, {
|
||||||
|
ids: partialPayments.map((p) => p.jobid)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch bodyshop data
|
||||||
|
const bodyshop = await gqlClient.request(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: decodedComment,
|
||||||
|
...logMeta
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create payment records
|
||||||
|
const paymentResult = await gqlClient.request(INSERT_NEW_PAYMENT, {
|
||||||
|
paymentInput: partialPayments.map((p) => ({
|
||||||
|
amount: p.amount,
|
||||||
|
transactionid: values.authcode,
|
||||||
|
payer: "Customer",
|
||||||
|
type: getPaymentType(ipMapping, 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: decodedComment,
|
||||||
|
...logMeta
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send notification email if needed
|
||||||
|
if (values?.origin === "OneLink" && decodedComment?.userEmail) {
|
||||||
|
await sendPaymentNotificationEmail(decodedComment.userEmail, jobs, partialPayments, logger, logMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.sendStatus(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handleCommentBasedPayment;
|
||||||
101
server/intellipay/lib/handleInvoiceBasedPayment.js
Normal file
101
server/intellipay/lib/handleInvoiceBasedPayment.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
const handlePaymentValidationError = require("./handlePaymentValidationError");
|
||||||
|
const {
|
||||||
|
GET_JOBID_BY_MERCHANTID_RONUMBER,
|
||||||
|
INSERT_PAYMENT_RESPONSE,
|
||||||
|
INSERT_NEW_PAYMENT
|
||||||
|
} = require("../../graphql-client/queries");
|
||||||
|
const getPaymentType = require("./getPaymentType");
|
||||||
|
const moment = require("moment");
|
||||||
|
|
||||||
|
const gqlClient = require("../../graphql-client/graphql-client").client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Handle invoice-based payment processing
|
||||||
|
* @param values
|
||||||
|
* @param logger
|
||||||
|
* @param logMeta
|
||||||
|
* @param res
|
||||||
|
* @returns {Promise<*>}
|
||||||
|
*/
|
||||||
|
const handleInvoiceBasedPayment = async (values, logger, logMeta, res) => {
|
||||||
|
// Validate required fields
|
||||||
|
if (!values.merchantid) {
|
||||||
|
return handlePaymentValidationError(
|
||||||
|
res,
|
||||||
|
logger,
|
||||||
|
"intellipay-postback-no-merchantid",
|
||||||
|
"Merchant ID is missing",
|
||||||
|
logMeta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch job data
|
||||||
|
const result = await gqlClient.request(GET_JOBID_BY_MERCHANTID_RONUMBER, {
|
||||||
|
merchantID: values.merchantid,
|
||||||
|
roNumber: values.invoice
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.jobs?.length) {
|
||||||
|
return handlePaymentValidationError(res, logger, "intellipay-postback-job-not-found", "Job not found", logMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = result.jobs[0];
|
||||||
|
const bodyshop = job?.bodyshop;
|
||||||
|
|
||||||
|
if (!bodyshop) {
|
||||||
|
return handlePaymentValidationError(
|
||||||
|
res,
|
||||||
|
logger,
|
||||||
|
"intellipay-postback-bodyshop-not-found",
|
||||||
|
"Bodyshop not found",
|
||||||
|
logMeta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipMapping = bodyshop.intellipay_config?.payment_map;
|
||||||
|
|
||||||
|
logger.log("intellipay-postback-invoice-job-fetched", "DEBUG", "api", null, {
|
||||||
|
job,
|
||||||
|
...logMeta
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create payment record
|
||||||
|
const paymentResult = await gqlClient.request(INSERT_NEW_PAYMENT, {
|
||||||
|
paymentInput: {
|
||||||
|
amount: values.total,
|
||||||
|
transactionid: values.authcode,
|
||||||
|
payer: "Customer",
|
||||||
|
type: getPaymentType(ipMapping, values.cardtype),
|
||||||
|
jobid: job.id,
|
||||||
|
date: moment(Date.now())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log("intellipay-postback-invoice-payment-success", "DEBUG", "api", null, {
|
||||||
|
paymentResult,
|
||||||
|
...logMeta
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create payment response record
|
||||||
|
const responseResults = await gqlClient.request(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,
|
||||||
|
...logMeta
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.sendStatus(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handleInvoiceBasedPayment;
|
||||||
18
server/intellipay/lib/handlePaymentValidationError.js
Normal file
18
server/intellipay/lib/handlePaymentValidationError.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* @description Log validation error and send response
|
||||||
|
* @param res
|
||||||
|
* @param logger
|
||||||
|
* @param logCode
|
||||||
|
* @param message
|
||||||
|
* @param logMeta
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
const handlePaymentValidationError = (res, logger, logCode, message, logMeta) => {
|
||||||
|
logger.log(logCode, "ERROR", "api", null, {
|
||||||
|
message,
|
||||||
|
...logMeta
|
||||||
|
});
|
||||||
|
return res.status(400).send(`Bad Request: ${message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handlePaymentValidationError;
|
||||||
41
server/intellipay/lib/sendPaymentNotificationEmail.js
Normal file
41
server/intellipay/lib/sendPaymentNotificationEmail.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const { sendTaskEmail } = require("../../email/sendemail");
|
||||||
|
const generateEmailTemplate = require("../../email/generateTemplate");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Send notification email to the user
|
||||||
|
* @param userEmail
|
||||||
|
* @param jobs
|
||||||
|
* @param partialPayments
|
||||||
|
* @param logger
|
||||||
|
* @param logMeta
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, logger, logMeta) => {
|
||||||
|
try {
|
||||||
|
await sendTaskEmail({
|
||||||
|
to: 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: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</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("<br/>")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.log("intellipay-postback-email-error", "ERROR", "api", null, {
|
||||||
|
message: error.message,
|
||||||
|
jobs,
|
||||||
|
...logMeta
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = sendPaymentNotificationEmail;
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
const { defineConfig } = require("vitest/config");
|
||||||
|
|
||||||
export default defineConfig({
|
module.exports = defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ["./server/tests/**/*.{test,spec}.[jt]s"], // Only search /tests in root
|
include: [
|
||||||
|
"./server/tests/**/*.{test,spec}.[jt]s", // Existing pattern for /server/tests
|
||||||
|
"./server/**/*.test.js" // New pattern for test.js in server and subfolders
|
||||||
|
],
|
||||||
exclude: ["**/client/**", "**/node_modules/**", "**/dist/**"] // Explicitly exclude /client
|
exclude: ["**/client/**", "**/node_modules/**", "**/dist/**"] // Explicitly exclude /client
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user