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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -127,4 +127,4 @@ vitest-report*/
|
||||
vitest-coverage/
|
||||
*.vitest.log
|
||||
test-output.txt
|
||||
|
||||
server/job/test/fixtures
|
||||
|
||||
@@ -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
644
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
13
client/src/contexts/SocketIO/useSocket.js
Normal file
13
client/src/contexts/SocketIO/useSocket.js
Normal 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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
77
download-job-totals-fixtures.js
Normal file
77
download-job-totals-fixtures.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."bodyshops" add column "intellipay_merchant_id" text
|
||||
null unique;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."bodyshops" add column "tours_enabled" boolean
|
||||
not null default 'true';
|
||||
1774
package-lock.json
generated
1774
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}`;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
14
server/intellipay/lib/decodeComment.js
Normal file
14
server/intellipay/lib/decodeComment.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @description Decode the comment from base64
|
||||
* @param comment
|
||||
* @returns {any|null}
|
||||
*/
|
||||
const decodeComment = (comment) => {
|
||||
try {
|
||||
return comment ? JSON.parse(Buffer.from(comment, "base64").toString()) : null;
|
||||
} catch (error) {
|
||||
return null; // Handle malformed base64 string gracefully
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = decodeComment;
|
||||
34
server/intellipay/lib/getCptellerUrl.js
Normal file
34
server/intellipay/lib/getCptellerUrl.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Generates a properly formatted Cpteller API URL
|
||||
* @param {Object} options - URL configuration options
|
||||
* @param {string} options.apiType - 'webapi' or 'custapi'
|
||||
* @param {string} [options.version] - API version (e.g., '26' for webapi)
|
||||
* @param {Object} [options.params] - URL query parameters
|
||||
* @returns {string} - The formatted Cpteller URL
|
||||
*/
|
||||
const getCptellerUrl = (options) => {
|
||||
const domain = process.env?.NODE_ENV === "production" ? "secure" : "test";
|
||||
|
||||
const { apiType = "webapi", version, params = {} } = options;
|
||||
|
||||
// Base URL construction
|
||||
let url = `https://${domain}.cpteller.com/api/`;
|
||||
|
||||
// Add version if specified for webapi
|
||||
if (apiType === "webapi" && version) {
|
||||
url += `${version}/`;
|
||||
}
|
||||
|
||||
// Add the API endpoint
|
||||
url += `${apiType}.cfc`;
|
||||
|
||||
// Add query parameters if any exist
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
if (queryParams) {
|
||||
url += `?${queryParams}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
module.exports = getCptellerUrl;
|
||||
12
server/intellipay/lib/getPaymentType.js
Normal file
12
server/intellipay/lib/getPaymentType.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @description Get payment type based on IP mapping
|
||||
* @param ipMapping
|
||||
* @param cardType
|
||||
* @returns {*}
|
||||
*/
|
||||
const getPaymentType = (ipMapping, cardType) => {
|
||||
const normalizedCardType = (cardType || "").toLowerCase();
|
||||
return ipMapping ? ipMapping[normalizedCardType] || cardType : cardType;
|
||||
};
|
||||
|
||||
module.exports = getPaymentType;
|
||||
40
server/intellipay/lib/getShopCredentials.js
Normal file
40
server/intellipay/lib/getShopCredentials.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||
const { InstanceRegion } = require("../../utils/instanceMgr");
|
||||
|
||||
const client = new SecretsManagerClient({
|
||||
region: InstanceRegion()
|
||||
});
|
||||
|
||||
/**
|
||||
* @description Get shop credentials from AWS Secrets Manager
|
||||
* @param bodyshop
|
||||
* @returns {Promise<{error}|{merchantkey: *, apikey: *}|any>}
|
||||
*/
|
||||
const getShopCredentials = async (bodyshop) => {
|
||||
// In Dev/Testing we will use the environment variables
|
||||
if (process.env?.NODE_ENV !== "production") {
|
||||
return {
|
||||
merchantkey: process.env.INTELLIPAY_MERCHANTKEY,
|
||||
apikey: process.env.INTELLIPAY_APIKEY
|
||||
};
|
||||
}
|
||||
|
||||
// In Production, we will use the AWS Secrets Manager
|
||||
if (bodyshop?.imexshopid) {
|
||||
try {
|
||||
const secret = await client.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: `intellipay-credentials-${bodyshop.imexshopid}`,
|
||||
VersionStage: "AWSCURRENT" // VersionStage defaults to AWSCURRENT if unspecified
|
||||
})
|
||||
);
|
||||
return JSON.parse(secret.SecretString);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = getShopCredentials;
|
||||
81
server/intellipay/lib/handleCommentBasedPayment.js
Normal file
81
server/intellipay/lib/handleCommentBasedPayment.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail");
|
||||
const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries");
|
||||
const getPaymentType = require("./getPaymentType");
|
||||
const moment = require("moment");
|
||||
|
||||
const gqlClient = require("../../graphql-client/graphql-client").client;
|
||||
|
||||
/**
|
||||
* @description Handle comment-based payment processing
|
||||
* @param values
|
||||
* @param decodedComment
|
||||
* @param logger
|
||||
* @param logMeta
|
||||
* @param res
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
const handleCommentBasedPayment = async (values, decodedComment, logger, logMeta, res) => {
|
||||
logger.log("intellipay-postback-parsed-comment", "DEBUG", "api", null, {
|
||||
parsedComment: decodedComment,
|
||||
...logMeta
|
||||
});
|
||||
|
||||
const partialPayments = Array.isArray(decodedComment) ? decodedComment : decodedComment.payments;
|
||||
|
||||
// Fetch job data
|
||||
const jobs = await gqlClient.request(GET_JOBS_BY_PKS, {
|
||||
ids: partialPayments.map((p) => p.jobid)
|
||||
});
|
||||
|
||||
// Fetch bodyshop data
|
||||
const bodyshop = await gqlClient.request(GET_BODYSHOP_BY_ID, {
|
||||
id: jobs.jobs[0].shopid
|
||||
});
|
||||
|
||||
const ipMapping = bodyshop.bodyshops_by_pk.intellipay_config?.payment_map;
|
||||
|
||||
logger.log("intellipay-postback-jobs-fetched", "DEBUG", "api", null, {
|
||||
jobs,
|
||||
parsedComment: decodedComment,
|
||||
...logMeta
|
||||
});
|
||||
|
||||
// Create payment records
|
||||
const paymentResult = await gqlClient.request(INSERT_NEW_PAYMENT, {
|
||||
paymentInput: partialPayments.map((p) => ({
|
||||
amount: p.amount,
|
||||
transactionid: values.authcode,
|
||||
payer: "Customer",
|
||||
type: getPaymentType(ipMapping, values.cardtype),
|
||||
jobid: p.jobid,
|
||||
date: moment(Date.now()),
|
||||
payment_responses: {
|
||||
data: {
|
||||
amount: values.total,
|
||||
bodyshopid: bodyshop.bodyshops_by_pk.id,
|
||||
jobid: p.jobid,
|
||||
declinereason: "Approved",
|
||||
ext_paymentid: values.paymentid,
|
||||
successful: true,
|
||||
response: values
|
||||
}
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
logger.log("intellipay-postback-payment-success", "DEBUG", "api", null, {
|
||||
paymentResult,
|
||||
jobs,
|
||||
parsedComment: decodedComment,
|
||||
...logMeta
|
||||
});
|
||||
|
||||
// Send notification email if needed
|
||||
if (values?.origin === "OneLink" && decodedComment?.userEmail) {
|
||||
await sendPaymentNotificationEmail(decodedComment.userEmail, jobs, partialPayments, logger, logMeta);
|
||||
}
|
||||
|
||||
return res.sendStatus(200);
|
||||
};
|
||||
|
||||
module.exports = handleCommentBasedPayment;
|
||||
131
server/intellipay/lib/handleInvoiceBasedPayment.js
Normal file
131
server/intellipay/lib/handleInvoiceBasedPayment.js
Normal 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;
|
||||
19
server/intellipay/lib/handlePaymentValidationError.js
Normal file
19
server/intellipay/lib/handlePaymentValidationError.js
Normal 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;
|
||||
41
server/intellipay/lib/sendPaymentNotificationEmail.js
Normal file
41
server/intellipay/lib/sendPaymentNotificationEmail.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { sendTaskEmail } = require("../../email/sendemail");
|
||||
const generateEmailTemplate = require("../../email/generateTemplate");
|
||||
|
||||
/**
|
||||
* @description Send notification email to the user
|
||||
* @param userEmail
|
||||
* @param jobs
|
||||
* @param partialPayments
|
||||
* @param logger
|
||||
* @param logMeta
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, logger, logMeta) => {
|
||||
try {
|
||||
await sendTaskEmail({
|
||||
to: userEmail,
|
||||
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
|
||||
type: "html",
|
||||
html: generateEmailTemplate({
|
||||
header: "New Payment(s) Received",
|
||||
subHeader: "",
|
||||
body: jobs.jobs
|
||||
.map(
|
||||
(job) =>
|
||||
`Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${
|
||||
job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()
|
||||
} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
|
||||
)
|
||||
.join("<br/>")
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log("intellipay-postback-email-error", "ERROR", "api", null, {
|
||||
message: error.message,
|
||||
jobs,
|
||||
...logMeta
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = sendPaymentNotificationEmail;
|
||||
152
server/intellipay/lib/tests/handleCommentBasedPayment.test.js
Normal file
152
server/intellipay/lib/tests/handleCommentBasedPayment.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
130
server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js
Normal file
130
server/intellipay/lib/tests/handleInvoiceBasedPayment.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
277
server/intellipay/lib/tests/intelliPayGeneralLibs.test.js
Normal file
277
server/intellipay/lib/tests/intelliPayGeneralLibs.test.js
Normal 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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
139
server/job/job-totals-recorder.js
Normal file
139
server/job/job-totals-recorder.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
72
server/job/test/job-totals.test.js
Normal file
72
server/job/test/job-totals.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
server/job/utils/seralizeHelper.js
Normal file
58
server/job/utils/seralizeHelper.js
Normal 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
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user