Merged in release/2025-04-11 (pull request #2267)

Release/2025-04-11 into master-AIO - IO-2769, IO-2885, IO-3045, IO-3066, IO-3193, IO-3198, IO-3202
This commit is contained in:
Dave Richer
2025-04-12 01:27:08 +00:00
69 changed files with 2995 additions and 1457 deletions

2
.gitignore vendored
View File

@@ -127,4 +127,4 @@ vitest-report*/
vitest-coverage/
*.vitest.log
test-output.txt
server/job/test/fixtures

View File

@@ -56,4 +56,5 @@ COPY . .
EXPOSE 4000 9229
# Start the application
CMD ["nodemon", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]
RUN echo "Starting the application..."
CMD ["nodemon", "--ignore", "./server/job/test/fixtures", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]

644
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,23 +8,23 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@ant-design/pro-layout": "^7.22.3",
"@apollo/client": "^3.13.5",
"@ant-design/pro-layout": "^7.22.4",
"@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.12",
"@firebase/app": "^0.11.3",
"@firebase/auth": "^1.9.1",
"@firebase/app": "^0.11.4",
"@firebase/auth": "^1.10.0",
"@firebase/firestore": "^4.7.10",
"@firebase/messaging": "^0.12.17",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.1",
"@sentry/cli": "^2.42.4",
"@sentry/react": "^9.9.0",
"@sentry/vite-plugin": "^3.2.2",
"@splitsoftware/splitio-react": "^2.0.1",
"@sentry/cli": "^2.43.0",
"@sentry/react": "^9.11.0",
"@sentry/vite-plugin": "^3.3.1",
"@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53",
"antd": "^5.24.5",
"antd": "^5.24.6",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0",
"autosize": "^6.0.1",
@@ -57,7 +57,7 @@
"react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4",
"react-grid-layout": "^1.3.4",
"react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
@@ -70,16 +70,16 @@
"react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.5",
"recharts": "^2.15.0",
"recharts": "^2.15.2",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.86.0",
"sass": "^1.86.3",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.16",
"styled-components": "^6.1.17",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"vite-plugin-ejs": "^1.7.0",
@@ -130,22 +130,22 @@
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.26.3",
"@dotenvx/dotenvx": "^1.39.0",
"@dotenvx/dotenvx": "^1.39.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.23.0",
"@eslint/js": "^9.24.0",
"@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.2.2",
"@sentry/webpack-plugin": "^3.3.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.3.4",
"browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
"eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"memfs": "^4.17.0",
@@ -154,13 +154,13 @@
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^6.2.3",
"vite": "^6.2.5",
"vite-plugin-babel": "^1.3.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^0.21.2",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^3.0.9",
"vitest": "^3.1.1",
"workbox-window": "^7.3.0"
}
}

View File

@@ -21,8 +21,8 @@ import "./App.styles.scss";
import Eula from "../components/eula/eula.component";
import InstanceRenderMgr from "../utils/instanceRenderMgr";
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
import { SocketProvider } from "../contexts/SocketIO/useSocket.jsx";
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
import SocketProvider from "../contexts/SocketIO/socketProvider.jsx";
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
@@ -142,11 +142,10 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
>
<ProductFruitsWrapper
currentUser={currentUser}
workspaceCode={InstanceRenderMgr({
imex: null,
rome: "9BkbEseqNqxw8jUH"
})}
bodyshop={bodyshop}
workspaceCode={bodyshop?.tours_enabled ? "9BkbEseqNqxw8jUH" : ""}
/>
<NotificationProvider>
<Routes>
<Route

View File

@@ -1,8 +1,16 @@
import React from "react";
import { ProductFruits } from "react-product-fruits";
import PropTypes from "prop-types";
import { ProductFruits } from "react-product-fruits";
import dayjs from "dayjs";
const ProductFruitsWrapper = React.memo(({ currentUser, bodyshop, workspaceCode }) => {
const featureProps = bodyshop?.features
? Object.entries(bodyshop.features).reduce((acc, [key, value]) => {
acc[key] = value === true || (typeof value === "string" && dayjs(value).isAfter(dayjs()));
return acc;
}, {})
: {};
const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
return (
workspaceCode &&
currentUser?.authorized === true &&
@@ -14,7 +22,8 @@ const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
language="en"
user={{
email: currentUser.email,
username: currentUser.email
username: currentUser.email,
props: featureProps
}}
/>
)
@@ -28,5 +37,6 @@ ProductFruitsWrapper.propTypes = {
authorized: PropTypes.bool,
email: PropTypes.string
}),
workspaceCode: PropTypes.string
workspaceCode: PropTypes.string,
bodyshop: PropTypes.object
};

View File

@@ -3,11 +3,11 @@ import { getToken } from "@firebase/messaging";
import axios from "axios";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();

View File

@@ -3,10 +3,10 @@ import { Button } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -4,10 +4,10 @@ import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -3,11 +3,11 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,

View File

@@ -4,11 +4,11 @@ import { Input, Spin, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -5,7 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser

View File

@@ -7,7 +7,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -12,9 +12,9 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import "./chat-popup.styles.scss";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,

View File

@@ -8,10 +8,10 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -20,6 +20,7 @@ function FeatureWrapper({
children,
upsellComponent,
bypass,
// eslint-disable-next-line no-unused-vars
...restProps
}) {
const { t } = useTranslation();
@@ -78,7 +79,11 @@ export function HasFeatureAccess({ featureName, bodyshop, bypass, debug = false
}
return true;
}
return bodyshop?.features?.allAccess || dayjs(bodyshop?.features[featureName]).isAfter(dayjs());
return (
bodyshop?.features?.allAccess ||
bodyshop?.features?.[featureName] ||
dayjs(bodyshop?.features[featureName]).isAfter(dayjs())
);
}
export default connect(mapStateToProps, null)(FeatureWrapper);

View File

@@ -40,7 +40,6 @@ import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
@@ -51,6 +50,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
// Redux mappings
const mapStateToProps = createStructuredSelector({

View File

@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";

View File

@@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { QUERY_JOB_CARD_DETAILS, UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";

View File

@@ -1,9 +1,9 @@
import { Col, Row } from "antd";
import React, { useState } from "react";
import { useState } from "react";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobReconciliationBillsTable from "../job-reconciliation-bills-table/job-reconciliation-bills-table.component";
import JobReconciliationPartsTable from "../job-reconciliation-parts-table/job-reconciliation-parts-table.component";
import JobReconciliationTotals from "../job-reconciliation-totals/job-reconciliation-totals.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
export default function JobReconciliationModalComponent({ job, bills }) {
const jobLineState = useState([]);
@@ -20,7 +20,7 @@ export default function JobReconciliationModalComponent({ job, bills }) {
const filterFunction = InstanceRenderManager({
imex: (j) =>
(j.part_type !== null && j.part_type !== "PAE") ||
(j.part_type !== null && j.part_type !== "PAE" && j.act_price !== 0 && j.part_qty !== 0) ||
(j.line_desc && j.line_desc.toLowerCase().includes("towing") && j.lbr_op === "OP13") ||
j.db_ref === "936004", //ADD SHIPPING LINE.
rome: (j) =>

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
@@ -133,6 +133,16 @@ export function JobsDetailHeaderActions({
const { socket } = useSocket();
const notification = useNotification();
const isDevEnv = import.meta.env.DEV;
const isProdEnv = import.meta.env.PROD;
const userEmail = currentUser?.email || "";
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canSubmitForTesting = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
const {
treatments: { ImEXPay }
} = useSplitTreatments({
@@ -171,7 +181,7 @@ export function JobsDetailHeaderActions({
{ defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
(newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification["success"]({
notification.success({
message: t("jobs.successes.duplicated")
});
},
@@ -181,7 +191,7 @@ export function JobsDetailHeaderActions({
const handleDuplicateConfirm = () =>
DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification["success"]({
notification.success({
message: t("jobs.successes.duplicated")
});
});
@@ -217,13 +227,13 @@ export function JobsDetailHeaderActions({
const result = await deleteJob({ variables: { id: job.id } });
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.delete")
});
//go back to jobs list.
history(`/manage/`);
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.deleted", {
error: JSON.stringify(result.errors)
})
@@ -275,9 +285,9 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({ message: t("csi.successes.created") });
notification.success({ message: t("csi.successes.created") });
} else {
notification["error"]({
notification.error({
message: t("csi.errors.creating", {
message: JSON.stringify(result.errors)
})
@@ -316,7 +326,7 @@ export function JobsDetailHeaderActions({
`${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}`
);
} else {
notification["error"]({
notification.error({
message: t("messaging.error.invalidphone")
});
}
@@ -328,7 +338,7 @@ export function JobsDetailHeaderActions({
);
}
} else {
notification["error"]({
notification.error({
message: t("csi.errors.notconfigured")
});
}
@@ -358,7 +368,7 @@ export function JobsDetailHeaderActions({
});
setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`);
} else {
notification["error"]({
notification.error({
message: t("messaging.error.invalidphone")
});
}
@@ -398,7 +408,7 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.voided")
});
insertAuditTrail({
@@ -409,7 +419,7 @@ export function JobsDetailHeaderActions({
//go back to jobs list.
history(`/manage/`);
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.voiding", {
error: JSON.stringify(result.errors)
})
@@ -442,7 +452,7 @@ export function JobsDetailHeaderActions({
console.log("handle -> XML", QbXmlResponse);
} catch (error) {
console.log("Error getting QBXML from Server.", error);
notification["error"]({
notification.error({
message: t("jobs.errors.exporting", {
error: "Unable to retrieve QBXML. " + JSON.stringify(error.message)
})
@@ -460,7 +470,7 @@ export function JobsDetailHeaderActions({
});
} catch (error) {
console.log("Error connecting to quickbooks or partner.", error);
notification["error"]({
notification.error({
message: t("jobs.errors.exporting-partner")
});
@@ -556,7 +566,7 @@ export function JobsDetailHeaderActions({
}
});
if (!jobUpdate.errors) {
notification["success"]({
notification.success({
message: t("appointments.successes.canceled")
});
insertAuditTrail({
@@ -931,11 +941,11 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.partsqueue")
});
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.saving", {
error: JSON.stringify(result.errors)
})
@@ -1111,6 +1121,27 @@ export function JobsDetailHeaderActions({
});
}
if (canSubmitForTesting) {
menuItems.push({
key: "submitfortesting",
id: "job-actions-submitfortesting",
label: t("menus.jobsactions.submit-for-testing"),
onClick: async () => {
try {
await axios.post("/job/totals-recorder", { id: job.id });
notification.success({
message: t("general.messages.submit-for-testing")
});
} catch (err) {
console.error(`Error submitting job for testing: ${err?.message}`);
notification.error({
message: t("general.errors.submit-for-testing-error")
});
}
}
});
}
const menu = {
items: menuItems,
key: "popovermenu"

View File

@@ -3,10 +3,10 @@ import { useQuery } from "@apollo/client";
import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import day from "../../utils/day.js";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js";
// This will be used to poll for notifications when the socket is disconnected
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;

View File

@@ -10,7 +10,7 @@ import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -12,7 +12,7 @@ import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { logImEXEvent } from "../../firebase/firebase.utils.js";
import { QUERY_JOB_CARD_DETAILS, UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";

View File

@@ -10,7 +10,7 @@ import {
import ProductionListTable from "./production-list-table.component";
import _ from "lodash";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
const client = useApolloClient();

View File

@@ -8,7 +8,7 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
// SocketProvider.js
import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
@@ -15,10 +16,7 @@ import {
import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10;
import { SocketContext, INITIAL_NOTIFICATIONS } from "./useSocket.js";
/**
* Socket Provider - Scenario Notifications / Web Socket related items
@@ -216,7 +214,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleNotification = (data) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -336,7 +333,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleSyncNotificationRead = ({ notificationId, timestamp }) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -378,7 +374,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleSyncAllNotificationsRead = ({ timestamp }) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -490,11 +485,4 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
);
};
const useSocket = () => {
const context = useContext(SocketContext);
// NOTE: Not sure if we absolutely require this, does cause slipups on dev env
if (!context) throw new Error("useSocket must be used within a SocketProvider");
return context;
};
export { SocketContext, SocketProvider, INITIAL_NOTIFICATIONS, useSocket };
export default SocketProvider;

View File

@@ -0,0 +1,13 @@
import { createContext, useContext } from "react";
const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10;
const useSocket = () => {
const context = useContext(SocketContext);
if (!context) throw new Error("useSocket must be used within a SocketProvider");
return context;
};
export { SocketContext, INITIAL_NOTIFICATIONS, useSocket };

View File

@@ -57,6 +57,7 @@ export const QUERY_BODYSHOP = gql`
logo_img_path
md_ro_statuses
md_order_statuses
tours_enabled
md_functionality_toggles
shopname
state
@@ -186,6 +187,7 @@ export const UPDATE_SHOP = gql`
phone
federal_tax_id
id
tours_enabled
insurance_vendor_id
logo_img_path
md_ro_statuses

View File

@@ -78,6 +78,9 @@ export const QUERY_PARTS_ORDER_OEC = gql`
}
ro_number
clm_no
cieca_stl
cieca_ttl
cieca_pfl
asgn_no
asgn_date
state_tax_rate
@@ -164,6 +167,7 @@ export const QUERY_PARTS_ORDER_OEC = gql`
loss_desc
loss_of_use
loss_type
materials
ownr_addr1
ownr_addr2
ownr_city

View File

@@ -56,7 +56,7 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import UndefinedToNull from "../../utils/undefinedtonull";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -20,7 +20,6 @@ import PartnerPingComponent from "../../components/partner-ping/partner-ping.com
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
import { requestForToken } from "../../firebase/firebase.utils";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
import UpdateAlert from "../../components/update-alert/update-alert.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -29,6 +28,7 @@ import WssStatusDisplayComponent from "../../components/wss-status-display/wss-s
import { selectAlerts } from "../../redux/application/application.selectors.js";
import { addAlerts } from "../../redux/application/application.actions.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const JobsPage = lazy(() => import("../jobs/jobs.page"));

View File

@@ -1220,7 +1220,8 @@
"errors": {
"fcm": "You must allow notification permissions to have real time messaging. Click to try again.",
"notfound": "No record was found.",
"sizelimit": "The selected items exceed the size limit."
"sizelimit": "The selected items exceed the size limit.",
"submit-for-testing": "Error submitting Job for testing."
},
"itemtypes": {
"contract": "CC Contract",
@@ -1322,7 +1323,8 @@
"partnernotrunning": "{{app}} has detected that the partner is not running. Please ensure it is running to enable full functionality.",
"rbacunauth": "You are not authorized to view this content. Please reach out to your shop manager to change your access level.",
"unsavedchanges": "You have unsaved changes.",
"unsavedchangespopup": "You have unsaved changes. Are you sure you want to leave?"
"unsavedchangespopup": "You have unsaved changes. Are you sure you want to leave?",
"submit-for-testing": "Submitted Job for testing successfully."
},
"validation": {
"dateRangeExceeded": "The date range has been exceeded.",
@@ -2314,7 +2316,8 @@
"duplicate": "Duplicate this Job",
"duplicatenolines": "Duplicate this Job without Repair Data",
"newcccontract": "Create Courtesy Car Contract",
"void": "Void Job"
"void": "Void Job",
"submit-for-testing": "Submit for Testing"
},
"jobsdetail": {
"claimdetail": "Claim Details",

View File

@@ -1220,7 +1220,8 @@
"errors": {
"fcm": "",
"notfound": "",
"sizelimit": ""
"sizelimit": "",
"submit-for-testing": ""
},
"itemtypes": {
"contract": "",
@@ -1322,7 +1323,8 @@
"partnernotrunning": "",
"rbacunauth": "",
"unsavedchanges": "Usted tiene cambios no guardados.",
"unsavedchangespopup": ""
"unsavedchangespopup": "",
"submit-for-testing": ""
},
"validation": {
"dateRangeExceeded": "",
@@ -2314,7 +2316,8 @@
"duplicate": "",
"duplicatenolines": "",
"newcccontract": "",
"void": ""
"void": "",
"submit-for-testing": ""
},
"jobsdetail": {
"claimdetail": "Detalles de la reclamación",

View File

@@ -1220,7 +1220,8 @@
"errors": {
"fcm": "",
"notfound": "",
"sizelimit": ""
"sizelimit": "",
"submit-for-testing": ""
},
"itemtypes": {
"contract": "",
@@ -1322,7 +1323,9 @@
"partnernotrunning": "",
"rbacunauth": "",
"unsavedchanges": "Vous avez des changements non enregistrés.",
"unsavedchangespopup": ""
"unsavedchangespopup": "",
"submit-for-testing": ""
},
"validation": {
"dateRangeExceeded": "",
@@ -2314,7 +2317,8 @@
"duplicate": "",
"duplicatenolines": "",
"newcccontract": "",
"void": ""
"void": "",
"submit-for-testing": ""
},
"jobsdetail": {
"claimdetail": "Détails de la réclamation",

View File

@@ -117,6 +117,7 @@ services:
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
"
# Node App: The Main IMEX API
node-app:

View File

@@ -0,0 +1,77 @@
const fs = require("fs");
const path = require("path");
const logger = require("./server/utils/logger"); // Assuming same logger utility
const s3Client = require("./server/utils/s3"); // Using the S3 client utilities with LocalStack support
// Set bucket name for development with LocalStack
const S3_BUCKET_NAME = "imex-job-totals";
// Set fixtures directory path
const FIXTURES_DIR = path.join(__dirname, "server", "job", "test", "fixtures", "job-totals");
const ensureFixturesDirectory = () => {
if (!fs.existsSync(FIXTURES_DIR)) {
fs.mkdirSync(FIXTURES_DIR, { recursive: true });
logger.log(`Created fixtures directory: ${FIXTURES_DIR}`, "info");
}
};
const downloadJsonFiles = async (userInfo = { email: "system" }) => {
logger.log(`Starting download of JSON files from bucket: ${S3_BUCKET_NAME}`, "debug", userInfo.email);
try {
ensureFixturesDirectory();
const contents = await s3Client.listFilesInS3Bucket(S3_BUCKET_NAME);
if (!contents.length) {
logger.log("No files found in bucket", "info", userInfo.email);
return;
}
logger.log(`Found ${contents.length} files in bucket`, "info", userInfo.email);
for (const item of contents) {
if (!item.Key.endsWith(".json")) {
logger.log(`Skipping non-JSON file: ${item.Key}`, "debug", userInfo.email);
continue;
}
logger.log(`Downloading: ${item.Key}`, "debug", userInfo.email);
const fileData = await s3Client.downloadFileFromS3({
bucketName: S3_BUCKET_NAME,
key: item.Key
});
const fileContent = await fileData.transformToString();
const fileName = path.basename(item.Key);
const filePath = path.join(FIXTURES_DIR, fileName);
fs.writeFileSync(filePath, fileContent);
logger.log(`Saved: ${filePath}`, "info", userInfo.email);
}
logger.log("Download completed successfully", "info", userInfo.email);
} catch (error) {
logger.log("Failed to download JSON files", "error", userInfo.email, null, {
error: error?.message,
stack: error?.stack
});
throw error; // Re-throw to trigger process exit with error code
}
};
// Run the download if script is executed directly
if (require.main === module) {
(async () => {
try {
await downloadJsonFiles();
console.log("Script completed successfully");
process.exit(0); // Explicitly exit with success code
} catch (error) {
console.error("Fatal error downloading files:", error);
process.exit(1); // Explicitly exit with error code
}
})();
}
module.exports = downloadJsonFiles;

View File

@@ -965,6 +965,7 @@
- insurance_vendor_id
- intakechecklist
- intellipay_config
- intellipay_merchant_id
- jc_hourly_rates
- jobsizelimit
- last_name_first
@@ -1023,6 +1024,7 @@
- template_header
- textid
- timezone
- tours_enabled
- tt_allow_post_to_invoiced
- tt_enforce_hours_for_tech_console
- updated_at

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "intellipay_merchant_id" text
-- null unique;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "intellipay_merchant_id" text
null unique;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "tours_enabled" boolean
-- not null default 'true';

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "tours_enabled" boolean
not null default 'true';

1774
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,17 +12,18 @@
"start": "node server.js",
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\"",
"test:unit": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.772.0",
"@aws-sdk/client-elasticache": "^3.772.0",
"@aws-sdk/client-s3": "^3.772.0",
"@aws-sdk/client-secrets-manager": "^3.772.0",
"@aws-sdk/client-ses": "^3.772.0",
"@aws-sdk/credential-provider-node": "^3.772.0",
"@aws-sdk/lib-storage": "^3.774.0",
"@aws-sdk/s3-request-presigner": "^3.774.0",
"@aws-sdk/client-cloudwatch-logs": "^3.782.0",
"@aws-sdk/client-elasticache": "^3.782.0",
"@aws-sdk/client-s3": "^3.782.0",
"@aws-sdk/client-secrets-manager": "^3.782.0",
"@aws-sdk/client-ses": "^3.782.0",
"@aws-sdk/credential-provider-node": "^3.782.0",
"@aws-sdk/lib-storage": "^3.782.0",
"@aws-sdk/s3-request-presigner": "^3.782.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -32,8 +33,7 @@
"bee-queue": "^1.7.1",
"better-queue": "^3.8.12",
"bluebird": "^3.7.2",
"body-parser": "^1.20.3",
"bullmq": "^5.44.4",
"bullmq": "^5.48.0",
"chart.js": "^4.4.8",
"cloudinary": "^2.6.0",
"compression": "^1.8.0",
@@ -41,7 +41,7 @@
"cors": "2.8.5",
"crisp-status-reporter": "^1.2.2",
"csrf": "^3.1.0",
"dd-trace": "^5.43.0",
"dd-trace": "^5.45.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
@@ -69,7 +69,7 @@
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.5.1",
"twilio": "^5.5.2",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0",
@@ -77,15 +77,16 @@
"xmlbuilder2": "^3.1.1"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
"@eslint/js": "^9.24.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"eslint": "^9.23.0",
"eslint-plugin-react": "^7.37.4",
"eslint": "^9.24.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.5.3",
"source-map-explorer": "^2.5.2",
"supertest": "^7.1.0",
"vitest": "^3.0.9"
"vitest": "^3.1.1"
}
}

View File

@@ -16,7 +16,6 @@ const cors = require("cors");
const http = require("http");
const Redis = require("ioredis");
const express = require("express");
const bodyParser = require("body-parser");
const compression = require("compression");
const cookieParser = require("cookie-parser");
const { Server } = require("socket.io");
@@ -84,8 +83,8 @@ const SOCKETIO_CORS_ORIGIN_DEV = ["http://localhost:3333", "https://localhost:33
const applyMiddleware = ({ app }) => {
app.use(compression());
app.use(cookieParser());
app.use(bodyParser.json({ limit: "50mb" }));
app.use(bodyParser.urlencoded({ limit: "50mb", extended: true }));
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
app.use(cors({ credentials: true, exposedHeaders: ["set-cookie"] }));
// Helper middleware

View File

@@ -55,7 +55,12 @@ exports.default = async (req, res) => {
const csv = converter.json2csv(shopList, { emptyFieldValue: "" });
emailer
.sendTaskEmail({
to: ["patrick.fic@convenient-brands.com", "bradley.rhoades@convenient-brands.com", "jrome@rometech.com"],
to: [
"patrick.fic@convenient-brands.com",
"bradley.rhoades@convenient-brands.com",
"jrome@rometech.com",
"ivana@imexsystems.ca"
],
subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`,
text: `
Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers.

View File

@@ -2832,3 +2832,24 @@ exports.GET_DOCUMENTS_BY_IDS = `
takenat
}
}`;
exports.GET_JOBID_BY_MERCHANTID_RONUMBER = `
query GET_JOBID_BY_MERCHANTID_RONUMBER($merchantID: String!, $roNumber: String!) {
jobs(where: {ro_number: {_eq: $roNumber}, bodyshop: {intellipay_merchant_id: {_eq: $merchantID}}}) {
id
shopid
bodyshop {
id
intellipay_config
email
}
}
}`;
exports.GET_BODYSHOP_BY_MERCHANT_ID = `
query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) {
bodyshops(where: {intellipay_merchant_id: {_eq: $merchantID}}) {
id
email
}
}`;

View File

@@ -1,64 +1,22 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
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 { isEmpty, isNumber } = require("lodash");
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");
const domain = process.env.NODE_ENV ? "secure" : "test";
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { InstanceRegion, InstanceEndpoints } = require("../utils/instanceMgr");
const client = new SecretsManagerClient({
region: InstanceRegion()
});
const gqlClient = require("../graphql-client/graphql-client").client;
const getShopCredentials = async (bodyshop) => {
// Development only
if (process.env.NODE_ENV === undefined) {
return {
merchantkey: process.env.INTELLIPAY_MERCHANTKEY,
apikey: process.env.INTELLIPAY_APIKEY
};
}
// Production code
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
};
}
}
};
const decodeComment = (comment) => {
try {
return comment ? JSON.parse(Buffer.from(comment, "base64").toString()) : null;
// eslint-disable-next-line no-unused-vars
} catch (error) {
return null; // Handle malformed base64 string gracefully
}
};
exports.lightbox_credentials = async (req, res) => {
/**
* @description Get lightbox credentials for the shop
* @param req
* @param res
* @returns {Promise<void>}
*/
const lightboxCredentials = async (req, res) => {
const decodedComment = decodeComment(req.body?.comment);
const logMeta = {
iPayData: req.body?.iPayData,
@@ -74,17 +32,17 @@ exports.lightbox_credentials = async (req, res) => {
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
if (shopCredentials?.error) {
logger.log("intellipay-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logMeta
});
res.json({
return res.json({
message: shopCredentials.error?.message,
type: "intellipay-credentials-error",
...logMeta
});
return;
}
try {
@@ -95,7 +53,10 @@ exports.lightbox_credentials = async (req, res) => {
...shopCredentials,
operatingenv: "businessattended"
}),
url: `https://${domain}.cpteller.com/api/custapi.cfc?method=autoterminal${req.body.refresh ? "_refresh" : ""}` //autoterminal_refresh
url: getCptellerUrl({
apiType: "custapi",
params: { method: `autoterminal${req.body.refresh ? "_refresh" : ""}` }
})
};
const response = await axios(options);
@@ -105,13 +66,14 @@ exports.lightbox_credentials = async (req, res) => {
...logMeta
});
res.send(response.data);
return res.send(response.data);
} catch (error) {
logger.log("intellipay-lightbox-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logMeta
});
res.json({
return res.json({
message: error?.message,
type: "intellipay-lightbox-error",
...logMeta
@@ -119,7 +81,13 @@ exports.lightbox_credentials = async (req, res) => {
}
};
exports.payment_refund = async (req, res) => {
/**
* @description Process payment refund
* @param req
* @param res
* @returns {Promise<void>}
*/
const paymentRefund = async (req, res) => {
const decodedComment = decodeComment(req.body.iPayData?.comment);
const logResponseMeta = {
iPayData: req.body?.iPayData,
@@ -137,18 +105,17 @@ exports.payment_refund = async (req, res) => {
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
if (shopCredentials?.error) {
logger.log("intellipay-refund-credentials-error", "ERROR", req.user?.email, null, {
credentialsError: shopCredentials.error,
...logResponseMeta
});
res.status(400).json({
return res.status(400).json({
credentialsError: shopCredentials.error,
type: "intellipay-refund-credentials-error",
...logResponseMeta
});
return;
}
try {
@@ -161,7 +128,11 @@ exports.payment_refund = async (req, res) => {
paymentid: req.body.paymentid,
amount: req.body.amount
}),
url: `https://${domain}.cpteller.com/api/26/webapi.cfc?method=payment_refund`
url: getCptellerUrl({
apiType: "webapi",
version: "26",
params: { method: "payment_refund" }
})
};
logger.log("intellipay-refund-options-prepared", "DEBUG", req.user?.email, null, {
@@ -176,13 +147,14 @@ exports.payment_refund = async (req, res) => {
...logResponseMeta
});
res.send(response.data);
return res.send(response.data);
} catch (error) {
logger.log("intellipay-refund-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
});
res.status(500).json({
return res.status(500).json({
message: error?.message,
type: "intellipay-refund-error",
...logResponseMeta
@@ -190,7 +162,13 @@ exports.payment_refund = async (req, res) => {
}
};
exports.generate_payment_url = async (req, res) => {
/**
* @description Generate payment URL for the shop
* @param req
* @param res
* @returns {Promise<void>}
*/
const generatePaymentUrl = async (req, res) => {
const decodedComment = decodeComment(req.body.comment);
const logResponseMeta = {
iPayData: req.body?.iPayData,
@@ -210,17 +188,17 @@ exports.generate_payment_url = async (req, res) => {
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
if (shopCredentials?.error) {
logger.log("intellipay-generate-payment-url-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logResponseMeta
});
res.status(400).json({
return res.status(400).json({
message: shopCredentials.error?.message,
type: "intellipay-generate-payment-url-credentials-error",
...logResponseMeta
});
return;
}
try {
@@ -235,7 +213,10 @@ exports.generate_payment_url = async (req, res) => {
invoice: req.body.invoice,
createshorturl: true
}),
url: `https://${domain}.cpteller.com/api/custapi.cfc?method=generate_lightbox_url`
url: getCptellerUrl({
apiType: "custapi",
params: { method: "generate_lightbox_url" }
})
};
logger.log("intellipay-generate-payment-url-options-prepared", "DEBUG", req.user?.email, null, {
@@ -251,18 +232,25 @@ exports.generate_payment_url = async (req, res) => {
...logResponseMeta
});
res.send(response.data);
return res.send(response.data);
} catch (error) {
logger.log("intellipay-generate-payment-url-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
});
res.status(500).json({ message: error?.message, ...logResponseMeta });
return res.status(500).json({ message: error?.message, ...logResponseMeta });
}
};
//Reference: https://intellipay.com/dist/webapi26.html#operation/fee
exports.checkfee = async (req, res) => {
/**
* @description Check the fee for a given amount
* Reference: https://intellipay.com/dist/webapi26.html#operation/fee
* @param req
* @param res
* @returns {Promise<void>}
*/
const checkFee = async (req, res) => {
const logResponseMeta = {
bodyshop: {
id: req.body?.bodyshop?.id,
@@ -275,24 +263,24 @@ exports.checkfee = async (req, res) => {
logger.log("intellipay-checkfee-request-received", "DEBUG", req.user?.email, null, logResponseMeta);
if (!req.body.amount || req.body.amount <= 0) {
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
});
res.json({ fee: 0 });
return;
return res.json({ fee: 0 });
}
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
if (shopCredentials?.error) {
logger.log("intellipay-checkfee-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logResponseMeta
});
res.status(400).json({ error: shopCredentials.error?.message, ...logResponseMeta });
return;
return res.status(400).json({ error: shopCredentials.error?.message, ...logResponseMeta });
}
try {
@@ -313,7 +301,7 @@ exports.checkfee = async (req, res) => {
},
{ sort: false } // Ensure query string order is preserved
),
url: `https://${domain}.cpteller.com/api/26/webapi.cfc`
url: getCptellerUrl({ apiType: "webapi", version: "26" })
};
logger.log("intellipay-checkfee-options-prepared", "DEBUG", req.user?.email, null, {
@@ -328,200 +316,92 @@ exports.checkfee = async (req, res) => {
message: response.data?.error,
...logResponseMeta
});
res.status(400).json({
return res.status(400).json({
error: response.data?.error,
type: "intellipay-checkfee-api-error",
...logResponseMeta
});
} else if (response.data < 0) {
}
if (response.data < 0) {
logger.log("intellipay-checkfee-negative-fee", "ERROR", req.user?.email, null, {
message: "Fee amount returned is negative.",
...logResponseMeta
});
res.json({
return res.json({
error: "Fee amount negative. Check API credentials & account configuration.",
...logResponseMeta,
type: "intellipay-checkfee-negative-fee"
});
} else {
logger.log("intellipay-checkfee-success", "DEBUG", req.user?.email, null, {
fee: response.data,
...logResponseMeta
});
res.json({ fee: response.data, ...logResponseMeta });
}
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
});
res.status(500).json({ error: error?.message, logResponseMeta });
return res.status(500).json({ error: error?.message, logResponseMeta });
}
};
exports.postback = async (req, res) => {
/**
* @description Handle the postback from Intellipay
* @param req
* @param res
* @returns {Promise<void>}
*/
/**
* 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 {
if ((!values.invoice || values.invoice === "") && !decodedComment) {
//invoice is specified through the pay link. Comment by IO.
// 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
});
res.sendStatus(200);
return;
return res.sendStatus(200);
}
// Process payment based on data type
if (decodedComment) {
//Shifted the order to have this first to retain backwards compatibility for the old style of short link.
//This has been triggered by IO and may have multiple jobs.
const parsedComment = decodedComment;
logger.log("intellipay-postback-parsed-comment", "DEBUG", "api", null, {
parsedComment,
...logResponseMeta
});
//Adding in the user email to the short pay email.
//Need to check this to ensure backwards compatibility for clients that don't update.
const partialPayments = Array.isArray(parsedComment) ? parsedComment : parsedComment.payments;
// Fetch jobs by job IDs
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
});
// Insert new payments
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
});
});
}
res.sendStatus(200);
} else if (values.invoice) {
const job = await gqlClient.request(queries.GET_JOB_BY_PK, {
id: values.invoice
});
const bodyshop = await gqlClient.request(queries.GET_BODYSHOP_BY_ID, {
id: job.jobs_by_pk.shopid
});
const ipMapping = bodyshop.bodyshops_by_pk.intellipay_config?.payment_map;
logger.log("intellipay-postback-invoice-job-fetched", "DEBUG", "api", null, {
job,
bodyshop,
...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: values.invoice,
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.bodyshops_by_pk.id,
paymentid: paymentResult.id,
jobid: values.invoice,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
});
logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, {
responseResults,
...logResponseMeta
});
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");
}
} catch (error) {
logger.log("intellipay-postback-error", "ERROR", "api", null, {
message: error?.message,
...logResponseMeta
...logMeta
});
res.status(400).json({ successful: false, error: error.message, ...logResponseMeta });
return res.status(400).json({ successful: false, error: error.message, ...logMeta });
}
};
module.exports = {
lightboxCredentials,
paymentRefund,
generatePaymentUrl,
checkFee,
postBack
};

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,131 @@
const handlePaymentValidationError = require("./handlePaymentValidationError");
const {
GET_JOBID_BY_MERCHANTID_RONUMBER,
INSERT_PAYMENT_RESPONSE,
INSERT_NEW_PAYMENT,
GET_BODYSHOP_BY_MERCHANT_ID
} = require("../../graphql-client/queries");
const { sendTaskEmail } = require("../../email/sendemail");
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) {
// Fetch bodyshop data
const bodyshop = await gqlClient.request(GET_BODYSHOP_BY_MERCHANT_ID, {
merchantID: values.merchantid
});
if (bodyshop?.bodyshops?.[0]) {
// Note: changed bodyshops to bodyshop to match query name
const email = bodyshop.bodyshops[0].email;
await sendTaskEmail({
to: email,
subject: `Failed to Insert Payment`,
text: `The system has attempted to insert a payment that was generated by your merchant terminal but could not find an associated invoice. Transaction details are below. Please input this payment to your system manually.\n\n${Object.keys(
values
)
.map((key) => `${key}: ${values[key]}`)
.join("\n")}`
});
}
return handlePaymentValidationError(
res,
logger,
"intellipay-postback-job-not-found",
"Job not found",
logMeta,
200
);
}
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;

View File

@@ -0,0 +1,19 @@
/**
* @description Log validation error and send response
* @param res
* @param logger
* @param logCode
* @param message
* @param logMeta
* @param returnCode
* @returns {*}
*/
const handlePaymentValidationError = (res, logger, logCode, message, logMeta, returnCode) => {
logger.log(logCode, "ERROR", "api", null, {
message,
...logMeta
});
return res.status(returnCode || 400).send(`Bad Request: ${message}`);
};
module.exports = handlePaymentValidationError;

View 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;

View File

@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import mockRequire from "mock-require";
const gqlRequestMock = { request: vi.fn() };
const getPaymentTypeMock = vi.fn(() => "American Express");
const sendPaymentNotificationEmailMock = vi.fn();
let handleCommentBasedPayment;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
// Mock dependencies using mock-require BEFORE requiring the target module
mockRequire("../../../graphql-client/graphql-client", {
client: gqlRequestMock
});
mockRequire("../getPaymentType", getPaymentTypeMock);
mockRequire("../sendPaymentNotificationEmail", sendPaymentNotificationEmailMock);
// Now require the module under test
handleCommentBasedPayment = require("../handleCommentBasedPayment");
// Chain your GraphQL mocks
gqlRequestMock.request
.mockResolvedValueOnce({
jobs: [
{
id: "c1ffe09c-e7d4-46b3-aac5-f23e39563181",
shopid: "bfec8c8c-b7f1-49e0-be4c-524455f4e582"
}
]
})
.mockResolvedValueOnce({
bodyshops_by_pk: {
id: "bfec8c8c-b7f1-49e0-be4c-524455f4e582",
intellipay_config: {
payment_map: {
amex: "American Express"
}
}
}
})
.mockResolvedValueOnce({
insert_payments: {
returning: [{ id: "5dfda3c4-c0a6-4b09-a73d-176ed0ac6499" }]
}
});
});
describe("handleCommentBasedPayment", () => {
const mockLogger = { log: vi.fn() };
const mockRes = { sendStatus: vi.fn() };
const values = {
authcode: "5557301",
total: "0.01",
origin: "Dejavoo",
paymentid: "24294378",
cardtype: "Amex"
};
const decodedComment = {
payments: [{ jobid: "c1ffe09c-e7d4-46b3-aac5-f23e39563181", amount: 0.01 }],
userEmail: "test@example.com"
};
const logMeta = { op: "xyz123" };
it("processes comment-based payment and returns 200", async () => {
await handleCommentBasedPayment(values, decodedComment, mockLogger, logMeta, mockRes);
expect(gqlRequestMock.request).toHaveBeenCalledTimes(3);
expect(getPaymentTypeMock).toHaveBeenCalledWith({ amex: "American Express" }, "Amex");
expect(sendPaymentNotificationEmailMock).not.toHaveBeenCalled();
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
it("sends notification if origin is OneLink and userEmail exists", async () => {
const oneLinkValues = { ...values, origin: "OneLink" };
await handleCommentBasedPayment(oneLinkValues, decodedComment, mockLogger, logMeta, mockRes);
expect(sendPaymentNotificationEmailMock).toHaveBeenCalledWith(
"test@example.com",
expect.anything(),
expect.anything(),
mockLogger,
logMeta
);
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
it("handles decodedComment as a direct array", async () => {
const arrayComment = [{ jobid: "c1ffe09c-e7d4-46b3-aac5-f23e39563181", amount: 0.01 }];
await handleCommentBasedPayment(values, arrayComment, mockLogger, logMeta, mockRes);
expect(gqlRequestMock.request).toHaveBeenCalledTimes(3);
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
it("does not send email if origin is OneLink but userEmail is missing", async () => {
const commentWithoutEmail = {
payments: decodedComment.payments
// no userEmail
};
const oneLinkValues = { ...values, origin: "OneLink" };
await handleCommentBasedPayment(oneLinkValues, commentWithoutEmail, mockLogger, logMeta, mockRes);
expect(sendPaymentNotificationEmailMock).not.toHaveBeenCalled();
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
it("logs important stages of the process", async () => {
await handleCommentBasedPayment(values, decodedComment, mockLogger, logMeta, mockRes);
const logCalls = mockLogger.log.mock.calls.map(([tag]) => tag);
expect(logCalls).toContain("intellipay-postback-parsed-comment");
expect(logCalls).toContain("intellipay-postback-payment-success");
});
it("handles missing payment_map safely", async () => {
gqlRequestMock.request.mockReset(); // 🧹 Clear previous .mockResolvedValueOnce calls
gqlRequestMock.request
.mockResolvedValueOnce({
jobs: [{ id: "job1", shopid: "shop1" }]
})
.mockResolvedValueOnce({
bodyshops_by_pk: {
id: "shop1",
intellipay_config: null
}
})
.mockResolvedValueOnce({
insert_payments: {
returning: [{ id: "payment1" }]
}
});
await handleCommentBasedPayment(values, decodedComment, mockLogger, logMeta, mockRes);
expect(getPaymentTypeMock).toHaveBeenCalledWith(undefined, "Amex");
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
});

View File

@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import mockRequire from "mock-require";
const gqlRequestMock = { request: vi.fn() };
const getPaymentTypeMock = vi.fn(() => "Visa");
const handlePaymentValidationErrorMock = vi.fn();
let handleInvoiceBasedPayment;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockRequire("../../../graphql-client/graphql-client", {
client: gqlRequestMock
});
mockRequire("../getPaymentType", getPaymentTypeMock);
mockRequire("../handlePaymentValidationError", handlePaymentValidationErrorMock);
handleInvoiceBasedPayment = require("../handleInvoiceBasedPayment");
gqlRequestMock.request
.mockResolvedValueOnce({
jobs: [
{
id: "job123",
bodyshop: {
id: "shop123",
intellipay_config: {
payment_map: {
visa: "Visa"
}
}
}
}
]
})
.mockResolvedValueOnce({
id: "payment123"
})
.mockResolvedValueOnce({
insert_payment_response: {
returning: [{ id: "response123" }]
}
});
});
describe("handleInvoiceBasedPayment", () => {
const mockLogger = { log: vi.fn() };
const mockRes = { sendStatus: vi.fn() };
const values = {
merchantid: "m123",
invoice: "INV-001",
total: 100.0,
authcode: "AUTH123",
cardtype: "visa",
paymentid: "P789"
};
const logMeta = { op: "abc123" };
it("processes a valid invoice-based payment", async () => {
await handleInvoiceBasedPayment(values, mockLogger, logMeta, mockRes);
expect(gqlRequestMock.request).toHaveBeenCalledTimes(3);
expect(getPaymentTypeMock).toHaveBeenCalledWith({ visa: "Visa" }, "visa");
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
expect(handlePaymentValidationErrorMock).not.toHaveBeenCalled();
});
it("handles missing merchantid with validation error", async () => {
const invalidValues = { ...values, merchantid: undefined };
await handleInvoiceBasedPayment(invalidValues, mockLogger, logMeta, mockRes);
expect(handlePaymentValidationErrorMock).toHaveBeenCalledWith(
mockRes,
mockLogger,
"intellipay-postback-no-merchantid",
"Merchant ID is missing",
logMeta
);
expect(gqlRequestMock.request).not.toHaveBeenCalled();
});
it("handles job not found with validation error", async () => {
gqlRequestMock.request.mockReset();
gqlRequestMock.request.mockResolvedValueOnce({ jobs: [] });
await handleInvoiceBasedPayment(values, mockLogger, logMeta, mockRes);
expect(handlePaymentValidationErrorMock).toHaveBeenCalledWith(
mockRes,
mockLogger,
"intellipay-postback-job-not-found",
"Job not found",
logMeta,
200
);
});
it("handles missing bodyshop with validation error", async () => {
gqlRequestMock.request.mockReset();
gqlRequestMock.request.mockResolvedValueOnce({
jobs: [{ id: "job123", bodyshop: null }]
});
await handleInvoiceBasedPayment(values, mockLogger, logMeta, mockRes);
expect(handlePaymentValidationErrorMock).toHaveBeenCalledWith(
mockRes,
mockLogger,
"intellipay-postback-bodyshop-not-found",
"Bodyshop not found",
logMeta
);
});
it("logs all expected stages of the process", async () => {
await handleInvoiceBasedPayment(values, mockLogger, logMeta, mockRes);
const logTags = mockLogger.log.mock.calls.map(([tag]) => tag);
expect(logTags).toContain("intellipay-postback-invoice-job-fetched");
expect(logTags).toContain("intellipay-postback-invoice-payment-success");
expect(logTags).toContain("intellipay-postback-invoice-response-success");
});
});

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const getPaymentType = require("../getPaymentType");
const decodeComment = require("../decodeComment");
const getCptellerUrl = require("../getCptellerUrl");
const handlePaymentValidationError = require("../handlePaymentValidationError");
const getShopCredentials = require("../getShopCredentials");
describe("Payment Processing Functions", () => {
// DecodeComment Tests
describe("decodeComment", () => {
it("decodes a valid base64-encoded JSON comment", () => {
const encoded = "eyJ0ZXN0IjoiZGF0YSJ9";
const expected = { test: "data" };
expect(decodeComment(encoded)).toEqual(expected);
});
it("decodes a complex base64-encoded JSON with payments", () => {
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", () => {
expect(decodeComment("aW52YWxpZA==")).toBeNull();
});
});
// GetPaymentType Tests
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);
});
});
// GetCptellerUrl Tests
describe("getCptellerUrl", () => {
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
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");
});
});
// GetShopCredentials Tests
describe("getShopCredentials", () => {
const originalEnv = { ...process.env };
let mockSend;
beforeEach(() => {
mockSend = vi.fn();
vi.mock("@aws-sdk/client-secrets-manager", () => {
return {
SecretsManagerClient: vi.fn(() => ({
send: mockSend
})),
GetSecretValueCommand: vi.fn((input) => input)
};
});
process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key";
process.env.INTELLIPAY_APIKEY = "test-api-key";
vi.resetModules();
});
afterEach(() => {
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();
});
});
// HandlePaymentValidationError Tests
describe("handlePaymentValidationError", () => {
it("logs error and sends 400 response", () => {
const mockLog = vi.fn();
const mockLogger = { log: mockLog };
const mockRes = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis()
};
const logCode = "test-validation-error";
const message = "Invalid data";
const logMeta = { field: "test", value: 123 };
const result = handlePaymentValidationError(mockRes, mockLogger, logCode, message, logMeta);
expect(mockLog).toHaveBeenCalledWith(logCode, "ERROR", "api", null, {
message,
...logMeta
});
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith(`Bad Request: ${message}`);
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" });
});
});
});

View File

@@ -1,7 +1,5 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
// const adminClient = require("../graphql-client/graphql-client").client;
// const _ = require("lodash");
const logger = require("../utils/logger");
const InstanceMgr = require("../utils/instanceMgr").default;
@@ -45,9 +43,11 @@ exports.totalsSsu = async function (req, res) {
}
});
if (result) {
res.status(200).send();
if (!result) {
throw new Error("Failed to update job totals");
}
res.status(200).send();
} catch (error) {
logger.log("job-totals-ssu-USA-error", "ERROR", req?.user?.email, id, {
jobid: id,
@@ -58,7 +58,7 @@ exports.totalsSsu = async function (req, res) {
}
};
//IMPORTANT*** These two functions MUST be mirrrored.
//IMPORTANT*** These two functions MUST be mirrored.
async function TotalsServerSide(req, res) {
const { job, client } = req.body;
await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user });
@@ -133,6 +133,9 @@ async function TotalsServerSide(req, res) {
}
}
// Exported for testing purposes.
exports.TotalsServerSide = TotalsServerSide;
async function Totals(req, res) {
const { job, id } = req.body;

View File

@@ -0,0 +1,139 @@
const logger = require("../utils/logger");
const queries = require("../graphql-client/queries");
const moment = require("moment");
const { captureFixture } = require("./utils/seralizeHelper");
const { TotalsServerSide: totalsServerSideCA } = require("./job-totals"); // Canadian version (imex)
const { TotalsServerSide: totalsServerSideUS } = require("./job-totals-USA");
const InstanceMgr = require("../utils/instanceMgr").default;
const { uploadFileToS3 } = require("../utils/s3");
// requires two buckets be made per env, job-totals-test, job-totals-production, locally it will
// use `job-totals` in the owncloud stack
/**
* Returns the environment prefix based on NODE_ENV
* @returns {string}
*/
const getEnvPrefix = () => {
switch (process.env?.NODE_ENV) {
case "test":
return "test";
case "production":
return "production";
default:
return "test";
}
};
const envPrefix = getEnvPrefix();
const S3_BUCKET_NAME =
process.env?.NODE_ENV === "development"
? "imex-job-totals"
: InstanceMgr({
imex: `job-totals-${envPrefix}`,
rome: `job-totals-${envPrefix}-rome`
});
/**
* Generates a unique S3 key for the job totals file
* @param {string} jobId - The job ID
* @returns {string} - S3 key with timestamp
*/
const generateS3Key = (jobId) => `${jobId}-${moment().toISOString()}.json`;
/**
* Uploads job totals data to S3
* @param {object} data - The data to upload
* @param {string} jobId - The job ID
* @param {object} userInfo - User information for logging
* @returns {Promise<string>} - The S3 key
*/
const uploadJobTotalsToS3 = async (data, jobId, userInfo) => {
const key = generateS3Key(jobId);
try {
await uploadFileToS3({
bucketName: S3_BUCKET_NAME,
key: key,
content: JSON.stringify(data, null, 2),
contentType: "application/json"
});
logger.log(`Job totals uploaded successfully to ${key}`, "info", userInfo.email, jobId);
return key;
} catch (error) {
logger.log("Failed to upload job totals to S3", "error", userInfo.email, jobId, {
error: error?.message,
stack: error?.stack
});
throw error; // Re-throw for the main handler to catch
}
};
/**
* Fetches job data using GraphQL
* @param {object} client - GraphQL client
* @param {string} token - Bearer token
* @param {string} jobId - Job ID to fetch
* @returns {Promise<object>} - Job data
*/
const fetchJobData = async (client, token, jobId) => {
return client
.setHeaders({ Authorization: token })
.request(queries.GET_JOB_BY_PK, { id: jobId })
.then((response) => response.jobs_by_pk);
};
/**
* This function is used to capture job totals json files.
* @param {object} req - Express request
* @param {object} res - Express response
* @returns {Promise<void>}
*/
const jobTotalsRecorder = async (req, res) => {
const { id: jobId } = req.body;
const bearerToken = req.BearerToken;
const client = req.userGraphQLClient;
const userEmail = req?.user?.email;
logger.log("Starting job totals recording", "debug", userEmail, jobId);
try {
// Fetch job data
const jobData = await fetchJobData(client, bearerToken, jobId);
// Get the appropriate totals function based on instance
const totalsFunction = InstanceMgr({
imex: totalsServerSideCA,
rome: totalsServerSideUS
});
// Calculate the totals
const calculatedTotals = await totalsFunction({ body: { job: jobData, client }, req }, res, true);
// Prepare data for storage
const dataToSave = captureFixture(jobData, calculatedTotals);
// Upload to S3
await uploadJobTotalsToS3(dataToSave, jobId, { email: userEmail });
res.status(200).json({ success: true, message: "Job totals recorded successfully" });
} catch (error) {
logger.log("Failed to record job totals", "error", userEmail, jobId, {
error: error?.message,
stack: error?.stack
});
// Avoid sending response if it's already been sent
if (!res.headersSent) {
res.status(503).json({
success: false,
message: "Error processing job totals",
error: error.message
});
}
}
};
module.exports = jobTotalsRecorder;

View File

@@ -30,6 +30,7 @@ exports.totalsSsu = async function (req, res) {
id: id
});
// Capture the output of TotalsServerSide
const newTotals = await TotalsServerSide({ body: { job: job.jobs_by_pk, client: client } }, res, true);
const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_JOB, {
@@ -57,7 +58,7 @@ exports.totalsSsu = async function (req, res) {
}
};
//IMPORTANT*** These two functions MUST be mirrrored.
//IMPORTANT*** These two functions MUST be mirrored.
async function TotalsServerSide(req, res) {
const { job, client } = req.body;
await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user });
@@ -81,6 +82,9 @@ async function TotalsServerSide(req, res) {
}
}
// Exported for testing purposes
exports.TotalsServerSide = TotalsServerSide;
async function Totals(req, res) {
const { job, id } = req.body;

View File

@@ -0,0 +1,72 @@
import fs from "fs";
import path from "path";
import { describe, it, expect } from "vitest";
import { TotalsServerSide as TotalsServerSideCA } from "../job-totals"; // Canadian version (imex)
import { TotalsServerSide as TotalsServerSideUS } from "../job-totals-USA";
import { isFunction } from "lodash"; // US version (rome)
/**
* This function is used to replace the values in the object with their toObject() representation.
* @param key
* @param value
* @returns {*}
*/
const dineroReplacer = (key, value) => {
if (isFunction(value)) {
return value.toObject();
}
return value;
};
/**
* Normalizes the output of the TotalsServerSide function by converting
* @param obj
* @returns {any}
*/
const normalizeOutput = (obj) => {
return JSON.parse(JSON.stringify(obj, dineroReplacer));
};
/**
* This test suite is designed to validate the functionality of the TotalsServerSide function
*/
describe("TotalsServerSide fixture tests", () => {
const fixturesDir = path.join(__dirname, "fixtures", "job-totals");
const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith(".json"));
const dummyClient = {
request: async () => {
return {};
}
};
const dummyRes = {
status: () => ({ send: () => {} })
};
fixtureFiles.forEach((file) => {
it(`should produce matching output for fixture file ${file}`, async () => {
const fixturePath = path.join(fixturesDir, file);
const fixtureData = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
const { environment, input, output: expectedOutput } = fixtureData;
const req = {
body: {
job: input,
client: dummyClient
},
user: {}
};
const computedOutput =
environment === "us" ? await TotalsServerSideUS(req, dummyRes) : await TotalsServerSideCA(req, dummyRes);
const normalizedComputed = normalizeOutput(computedOutput);
const normalizedExpected = normalizeOutput(expectedOutput);
expect(normalizedComputed).toEqual(normalizedExpected);
});
});
});

View File

@@ -0,0 +1,58 @@
const fs = require("fs");
const path = require("path");
const { default: InstanceMgr } = require("../../utils/instanceMgr");
const fixtureDir = path.join(__dirname, "..", "test", "fixtures", "job-totals");
/**
* Custom serializer for Dinero.js objects.
* @param key
* @param value
* @returns {*}
*/
const serializeDinero = (key, value) => {
if (value && typeof value === "object" && typeof value.toObject === "function") {
return value.toObject();
}
return value;
};
/**
* Capture a fixture for job totals.
* @param inputData
* @param outputData
* @param saveLocally
*/
const captureFixture = (inputData, outputData, saveLocally) => {
const fileName = `${inputData.id}.json`;
const filePath = path.join(fixtureDir, fileName);
const dataToSave = {
environment: InstanceMgr({
imex: "ca",
rome: "us"
}),
meta: {
ro_number: inputData.ro_number,
updated_at: inputData.updated_at
},
input: inputData,
output: outputData
};
// Save the file using our custom serializer.
if (saveLocally) {
if (!fs.existsSync(fixtureDir)) {
fs.mkdirSync(fixtureDir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(dataToSave, serializeDinero, 2), "utf8");
}
return dataToSave;
};
module.exports = {
captureFixture,
serializeDinero
};

View File

@@ -1,12 +1,18 @@
const express = require("express");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { lightbox_credentials, payment_refund, generate_payment_url, postback, checkfee } = require("../intellipay/intellipay");
const {
lightboxCredentials,
paymentRefund,
generatePaymentUrl,
postBack,
checkFee
} = require("../intellipay/intellipay");
router.post("/lightbox_credentials", validateFirebaseIdTokenMiddleware, lightbox_credentials);
router.post("/payment_refund", validateFirebaseIdTokenMiddleware, payment_refund);
router.post("/generate_payment_url", validateFirebaseIdTokenMiddleware, generate_payment_url);
router.post("/checkfee", validateFirebaseIdTokenMiddleware, checkfee);
router.post("/postback", postback);
router.post("/lightbox_credentials", validateFirebaseIdTokenMiddleware, lightboxCredentials);
router.post("/payment_refund", validateFirebaseIdTokenMiddleware, paymentRefund);
router.post("/generate_payment_url", validateFirebaseIdTokenMiddleware, generatePaymentUrl);
router.post("/checkfee", validateFirebaseIdTokenMiddleware, checkFee);
router.post("/postback", postBack);
module.exports = router;

View File

@@ -7,6 +7,7 @@ const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMI
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { totals, statustransition, totalsSsu, costing, lifecycle, costingmulti, jobUpdated } = require("../job/job");
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
const jobTotalsRecorder = require("../job/job-totals-recorder");
router.post("/totals", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, totals);
router.post("/statustransition", eventAuthorizationMiddleware, statustransition);
@@ -17,5 +18,6 @@ router.post("/costingmulti", validateFirebaseIdTokenMiddleware, withUserGraphQLC
router.post("/partsscan", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, partsScan);
router.post("/ppc", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, ppc.generatePpc);
router.post("/job-updated", eventAuthorizationMiddleware, jobUpdated);
router.post("/totals-recorder", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, jobTotalsRecorder);
module.exports = router;

View File

@@ -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
}
});