From 9bf6ba9cf082c1e9e00986b00b021057161630b2 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 2 Apr 2025 11:09:03 -0400 Subject: [PATCH] feature/IO-2885-IntelliPay-App-Postback - Refactor / Add Tests --- server/intellipay/intellipay.js | 295 ++-------------- server/intellipay/intellipay.test.js | 316 ++++++++++++++++++ .../{ => lib}/aws-secrets-manager.js | 0 server/intellipay/lib/decodeComment.js | 14 + server/intellipay/lib/getCptellerUrl.js | 34 ++ server/intellipay/lib/getPaymentType.js | 12 + server/intellipay/lib/getShopCredentials.js | 40 +++ .../lib/handleCommentBasedPayment.js | 81 +++++ .../lib/handleInvoiceBasedPayment.js | 101 ++++++ .../lib/handlePaymentValidationError.js | 18 + .../lib/sendPaymentNotificationEmail.js | 41 +++ vitest.config.js | 9 +- 12 files changed, 686 insertions(+), 275 deletions(-) create mode 100644 server/intellipay/intellipay.test.js rename server/intellipay/{ => lib}/aws-secrets-manager.js (100%) create mode 100644 server/intellipay/lib/decodeComment.js create mode 100644 server/intellipay/lib/getCptellerUrl.js create mode 100644 server/intellipay/lib/getPaymentType.js create mode 100644 server/intellipay/lib/getShopCredentials.js create mode 100644 server/intellipay/lib/handleCommentBasedPayment.js create mode 100644 server/intellipay/lib/handleInvoiceBasedPayment.js create mode 100644 server/intellipay/lib/handlePaymentValidationError.js create mode 100644 server/intellipay/lib/sendPaymentNotificationEmail.js diff --git a/server/intellipay/intellipay.js b/server/intellipay/intellipay.js index be8d9f24c..29f8e978a 100644 --- a/server/intellipay/intellipay.js +++ b/server/intellipay/intellipay.js @@ -1,98 +1,14 @@ -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 - } -}; +const handleCommentBasedPayment = require("./lib/handleCommentBasedPayment"); +const handleInvoiceBasedPayment = require("./lib/handleInvoiceBasedPayment"); +const logValidationError = require("./lib/handlePaymentValidationError"); +const getCptellerUrl = require("./lib/getCptellerUrl"); +const getShopCredentials = require("./lib/getShopCredentials"); +const decodeComment = require("./lib/decodeComment"); /** * @description Get lightbox credentials for the shop @@ -443,207 +359,42 @@ const checkFee = async (req, res) => { * @param res * @returns {Promise} */ +/** + * Handle the postback from Intellipay payment system + */ const postBack = async (req, res) => { const { body: values } = req; const decodedComment = decodeComment(values?.comment); + const logMeta = { iprequest: values, decodedComment }; - const logResponseMeta = { - iprequest: values, - decodedComment - }; - - logger.log("intellipay-postback-received", "DEBUG", "api", null, logResponseMeta); + logger.log("intellipay-postback-received", "DEBUG", "api", null, logMeta); try { + // Handle empty/invalid requests if (isEmpty(values?.invoice) && !decodedComment) { logger.log("intellipay-postback-ignored", "DEBUG", "api", null, { message: "No invoice or comment provided", - ...logResponseMeta + ...logMeta }); return res.sendStatus(200); } + // Process payment based on data type 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); + return await handleCommentBasedPayment(values, decodedComment, logger, logMeta, res); + } else if (values?.invoice) { + return await handleInvoiceBasedPayment(values, logger, logMeta, res); + } else { + // 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"); } - - 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 + ...logMeta }); - - return res.status(400).json({ successful: false, error: error.message, ...logResponseMeta }); + return res.status(400).json({ successful: false, error: error.message, ...logMeta }); } }; diff --git a/server/intellipay/intellipay.test.js b/server/intellipay/intellipay.test.js new file mode 100644 index 000000000..472e2ea0f --- /dev/null +++ b/server/intellipay/intellipay.test.js @@ -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" }); + }); +}); diff --git a/server/intellipay/aws-secrets-manager.js b/server/intellipay/lib/aws-secrets-manager.js similarity index 100% rename from server/intellipay/aws-secrets-manager.js rename to server/intellipay/lib/aws-secrets-manager.js diff --git a/server/intellipay/lib/decodeComment.js b/server/intellipay/lib/decodeComment.js new file mode 100644 index 000000000..324e4e643 --- /dev/null +++ b/server/intellipay/lib/decodeComment.js @@ -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; diff --git a/server/intellipay/lib/getCptellerUrl.js b/server/intellipay/lib/getCptellerUrl.js new file mode 100644 index 000000000..358ce1c5e --- /dev/null +++ b/server/intellipay/lib/getCptellerUrl.js @@ -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; diff --git a/server/intellipay/lib/getPaymentType.js b/server/intellipay/lib/getPaymentType.js new file mode 100644 index 000000000..08be4f81b --- /dev/null +++ b/server/intellipay/lib/getPaymentType.js @@ -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; diff --git a/server/intellipay/lib/getShopCredentials.js b/server/intellipay/lib/getShopCredentials.js new file mode 100644 index 000000000..da18b9d25 --- /dev/null +++ b/server/intellipay/lib/getShopCredentials.js @@ -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; diff --git a/server/intellipay/lib/handleCommentBasedPayment.js b/server/intellipay/lib/handleCommentBasedPayment.js new file mode 100644 index 000000000..535e92ab8 --- /dev/null +++ b/server/intellipay/lib/handleCommentBasedPayment.js @@ -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; diff --git a/server/intellipay/lib/handleInvoiceBasedPayment.js b/server/intellipay/lib/handleInvoiceBasedPayment.js new file mode 100644 index 000000000..f99378d56 --- /dev/null +++ b/server/intellipay/lib/handleInvoiceBasedPayment.js @@ -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; diff --git a/server/intellipay/lib/handlePaymentValidationError.js b/server/intellipay/lib/handlePaymentValidationError.js new file mode 100644 index 000000000..fccf7a75f --- /dev/null +++ b/server/intellipay/lib/handlePaymentValidationError.js @@ -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; diff --git a/server/intellipay/lib/sendPaymentNotificationEmail.js b/server/intellipay/lib/sendPaymentNotificationEmail.js new file mode 100644 index 000000000..2f83d3a3b --- /dev/null +++ b/server/intellipay/lib/sendPaymentNotificationEmail.js @@ -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} + */ +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: ${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, + ...logMeta + }); + } +}; + +module.exports = sendPaymentNotificationEmail; diff --git a/vitest.config.js b/vitest.config.js index 7757f9a7e..c4b856ce5 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,10 +1,13 @@ -import { defineConfig } from "vitest/config"; +const { defineConfig } = require("vitest/config"); -export default defineConfig({ +module.exports = defineConfig({ test: { environment: "node", 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 } });