Compare commits

..

2 Commits

Author SHA1 Message Date
Dave Richer
1ece04ed3e Merged release/2024-12-13 into feature/IO-2968-Parts-Scanning-Extension 2024-12-10 19:55:34 +00:00
Dave Richer
b8a298fc28 feature/IO-2968-Parts-Scanning-Extension 2024-12-10 11:15:24 -08:00
11 changed files with 458 additions and 537 deletions

79
.gitattributes vendored
View File

@@ -1,80 +1 @@
# Ensure all text files use LF for line endings
* text eol=lf
# Binary files should not be modified by Git
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.svg binary
# Fonts
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.eot binary
# Videos
*.mp4 binary
*.mov binary
*.avi binary
*.mkv binary
*.webm binary
# Audio
*.mp3 binary
*.wav binary
*.ogg binary
*.flac binary
# Archives and compressed files
*.zip binary
*.gz binary
*.tar binary
*.7z binary
*.rar binary
# PDF and documents
*.pdf binary
*.doc binary
*.docx binary
*.xls binary
*.xlsx binary
*.ppt binary
*.pptx binary
# Exclude JSON and other data files from text processing, if necessary
*.json text
*.xml text
*.csv text
# Scripts and code files should maintain LF endings
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.py text eol=lf
*.rb text eol=lf
*.java text eol=lf
*.php text eol=lf
# Git configuration files
.gitattributes text eol=lf
.gitignore text eol=lf
*.gitattributes text eol=lf
# Exclude some other potential binary files
*.db binary
*.sqlite binary
*.exe binary
*.dll binary

View File

@@ -1,6 +1,6 @@
import { CopyFilled, DeleteFilled } from "@ant-design/icons";
import { DeleteFilled, CopyFilled } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client";
import { Button, Card, Col, Form, Input, message, notification, Row, Space, Spin, Statistic } from "antd";
import { Button, Card, Col, Form, Input, Row, Space, Spin, Statistic, message, notification } from "antd";
import axios from "axios";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -23,14 +23,7 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })),
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment"))
});
@@ -46,6 +39,7 @@ const CardPaymentModalComponent = ({
const [form] = Form.useForm();
const [paymentLink, setPaymentLink] = useState();
const [loading, setLoading] = useState(false);
// const [insertPayment] = useMutation(INSERT_NEW_PAYMENT);
const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE);
const { t } = useTranslation();
@@ -54,33 +48,24 @@ const CardPaymentModalComponent = ({
skip: !context?.jobid
});
const collectIPayFields = () => {
const iPayFields = document.querySelectorAll(".ipayfield");
const iPayData = {};
iPayFields.forEach((field) => {
iPayData[field.dataset.ipayname] = field.value;
});
return iPayData;
};
//Initialize the intellipay window.
const SetIntellipayCallbackFunctions = () => {
console.log("*** Set IntelliPay callback functions.");
window.intellipay.runOnClose(() => {
//window.intellipay.initialize();
});
window.intellipay.runOnApproval(() => {
window.intellipay.runOnApproval(async function (response) {
//2024-04-25: Nothing is going to happen here anymore. We'll completely rely on the callback.
//Add a slight delay to allow the refetch to properly get the data.
setTimeout(() => {
if (actions?.refetch) actions.refetch();
if (actions && actions.refetch && typeof actions.refetch === "function") actions.refetch();
setLoading(false);
toggleModalVisible();
}, 750);
});
window.intellipay.runOnNonApproval(async (response) => {
window.intellipay.runOnNonApproval(async function (response) {
// Mutate unsuccessful payment
const { payments } = form.getFieldsValue();
@@ -113,21 +98,16 @@ const CardPaymentModalComponent = ({
//Validate
try {
await form.validateFields();
} catch {
} catch (error) {
setLoading(false);
return;
}
const iPayData = collectIPayFields();
const { payments } = form.getFieldsValue();
try {
const response = await axios.post("/intellipay/lightbox_credentials", {
bodyshop,
refresh: !!window.intellipay,
paymentSplitMeta: form.getFieldsValue(),
iPayData: iPayData,
comment: btoa(JSON.stringify({ payments, userEmail: currentUser.email }))
paymentSplitMeta: form.getFieldsValue()
});
if (window.intellipay) {
@@ -136,8 +116,8 @@ const CardPaymentModalComponent = ({
SetIntellipayCallbackFunctions();
window.intellipay.autoOpen();
} else {
const rg = document.createRange();
const node = rg.createContextualFragment(response.data);
var rg = document.createRange();
let node = rg.createContextualFragment(response.data);
document.documentElement.appendChild(node);
SetIntellipayCallbackFunctions();
window.intellipay.isAutoOpen = true;
@@ -157,27 +137,25 @@ const CardPaymentModalComponent = ({
//Validate
try {
await form.validateFields();
} catch {
} catch (error) {
setLoading(false);
return;
}
const iPayData = collectIPayFields();
try {
const { payments } = form.getFieldsValue();
const response = await axios.post("/intellipay/generate_payment_url", {
bodyshop,
amount: payments.reduce((acc, val) => acc + (val?.amount || 0), 0),
account: payments && data?.jobs?.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null,
amount: payments?.reduce((acc, val) => {
return acc + (val?.amount || 0);
}, 0),
account: payments && data && data.jobs.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null,
comment: btoa(JSON.stringify({ payments, userEmail: currentUser.email })),
paymentSplitMeta: form.getFieldsValue(),
iPayData: iPayData
paymentSplitMeta: form.getFieldsValue()
});
if (response?.data?.shorUrl) {
setPaymentLink(response.data.shorUrl);
await navigator.clipboard.writeText(response.data.shorUrl);
if (response.data) {
setPaymentLink(response.data?.shorUrl);
navigator.clipboard.writeText(response.data?.shorUrl);
message.success(t("general.actions.copied"));
}
setLoading(false);
@@ -201,44 +179,67 @@ const CardPaymentModalComponent = ({
}}
>
<Form.List name={["payments"]}>
{(fields, { add, remove }) => (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Row gutter={[16, 16]}>
<Col span={16}>
<Form.Item
key={`${index}jobid`}
label={t("jobs.fields.ro_number")}
name={[field.name, "jobid"]}
rules={[{ required: true }]}
>
<JobSearchSelectComponent notExported={false} clm_no />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
key={`${index}amount`}
label={t("payments.fields.amount")}
name={[field.name, "amount"]}
rules={[{ required: true }]}
>
<CurrencyFormItemComponent />
</Form.Item>
</Col>
<Col span={2}>
<DeleteFilled style={{ margin: "1rem" }} onClick={() => remove(field.name)} />
</Col>
</Row>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Row gutter={[16, 16]}>
<Col span={16}>
<Form.Item
key={`${index}jobid`}
label={t("jobs.fields.ro_number")}
name={[field.name, "jobid"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<JobSearchSelectComponent notExported={false} clm_no />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
key={`${index}amount`}
label={t("payments.fields.amount")}
name={[field.name, "amount"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyFormItemComponent />
</Form.Item>
</Col>
<Col span={2}>
<DeleteFilled
style={{ margin: "1rem" }}
onClick={() => {
remove(field.name);
}}
/>
</Col>
</Row>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("general.actions.add")}
</Button>
</Form.Item>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} style={{ width: "100%" }}>
{t("general.actions.add")}
</Button>
</Form.Item>
</div>
)}
</div>
);
}}
</Form.List>
<Form.Item
@@ -282,7 +283,9 @@ const CardPaymentModalComponent = ({
>
{() => {
const { payments } = form.getFieldsValue();
const totalAmountToCharge = payments?.reduce((acc, val) => acc + (val?.amount || 0), 0);
const totalAmountToCharge = payments?.reduce((acc, val) => {
return acc + (val?.amount || 0);
}, 0);
return (
<Space style={{ float: "right" }}>
<Statistic title="Amount To Charge" value={totalAmountToCharge} precision={2} />

View File

@@ -1,11 +1,4 @@
import isBetween from "dayjs/plugin/isBetween";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import localeData from "dayjs/plugin/localeData";
import localizedFormat from "dayjs/plugin/localizedFormat";
import minMax from "dayjs/plugin/minMax";
import utc from "dayjs/plugin/utc";
import { DateLocalizer } from "react-big-calendar";
import {DateLocalizer} from "react-big-calendar";
function arrayWithHoles(arr) {
if (Array.isArray(arr)) return arr;
@@ -29,7 +22,9 @@ function iterableToArrayLimit(arr, i) {
try {
if (!_n && _i["return"] != null) _i["return"]();
} finally {
if (_d) throw _e;
if (_d) { // noinspection ThrowInsideFinallyBlockJS
throw _e;
}
}
}
return _arr;

View File

@@ -1,75 +1,188 @@
import { DeleteFilled } from "@ant-design/icons";
import { Button, Form, Input, Space } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import {DeleteFilled} from "@ant-design/icons";
import {Button, Col, Form, Input, Row, Select, Space, Switch} from "antd";
import React, {useMemo} from "react";
import {useTranslation} from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function ShopInfoPartsScan({ form }) {
const { t } = useTranslation();
const predefinedPartTypes = [
"PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"
];
const predefinedModLbrTypes = [
"LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU",
"LA1", "LA2", "LA3", "LA4"
];
const getFieldType = (field) => {
if (["line_desc", "part_number"].includes(field)) return "string";
if (["act_price", "part_qty", "mod_lb_hrs"].includes(field)) return "number";
if (["part_type", "mod_lbr_ty"].includes(field)) return "predefined";
return null;
};
export default function ShopInfoPartsScan({form}) {
const {t} = useTranslation();
const watchedFields = Form.useWatch("md_parts_scan", form);
const operationOptions = useMemo(() => ({
string: [
{label: t("bodyshop.operations.contains"), value: "contains"},
{label: t("bodyshop.operations.equals"), value: "equals"},
{label: t("bodyshop.operations.starts_with"), value: "startsWith"},
{label: t("bodyshop.operations.ends_with"), value: "endsWith"},
],
number: [
{label: t("bodyshop.operations.equals"), value: "="},
{label: t("bodyshop.operations.greater_than"), value: ">"},
{label: t("bodyshop.operations.less_than"), value: "<"},
],
}), [t]);
return (
<div>
<LayoutFormRow header={t("bodyshop.labels.md_parts_scan")}>
<Form.List name={["md_parts_scan"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<LayoutFormRow noDivider>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.expression")}
key={`${index}expression`}
name={[field.name, "expression"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.flags")}
key={`${index}flags`}
name={[field.name, "flags"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
{(fields, {add, remove, move}) => (
<div>
{fields.map((field, index) => {
const selectedField = watchedFields?.[index]?.field || "line_desc";
const fieldType = getFieldType(selectedField);
<Space wrap>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
return (
<Form.Item key={field.key}>
<Row gutter={[16, 16]} align="middle">
{/* Select Field */}
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.field")}
name={[field.name, "field"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.field"),
}),
},
]}
>
<Select
options={[
{label: t("joblines.fields.line_desc"), value: "line_desc"},
{label: t("joblines.fields.part_type"), value: "part_type"},
{label: t("joblines.fields.act_price"), value: "act_price"},
{label: t("joblines.fields.part_qty"), value: "part_qty"},
{label: t("joblines.fields.mod_lbr_ty"), value: "mod_lbr_ty"},
{label: t("joblines.fields.mod_lb_hrs"), value: "mod_lb_hrs"},
{
label: `${t("joblines.fields.oem_partno")} / ${t("joblines.fields.alt_partno")}`,
value: "part_number"
},
]}
onChange={() => {
form.setFields([
{name: ["md_parts_scan", index, "operation"], value: "contains"},
{name: ["md_parts_scan", index, "value"], value: undefined},
]);
}}
/>
</Form.Item>
</Col>
{/* Operation */}
{fieldType !== "predefined" && fieldType && (
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.operation")}
name={[field.name, "operation"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.operation"),
}),
},
]}
>
<Select options={operationOptions[fieldType]}/>
</Form.Item>
</Col>
)}
{/* Value */}
{fieldType && (
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.value")}
name={[field.name, "value"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.value"),
}),
},
]}
>
{fieldType === "predefined" ? (
<Select
options={
selectedField === "part_type"
? predefinedPartTypes.map((type) => ({
label: type,
value: type
}))
: predefinedModLbrTypes.map((type) => ({
label: type,
value: type
}))
}
/>
) : (
<Input/>
)}
</Form.Item>
</Col>
)}
{/* Case Sensitivity */}
{fieldType === "string" && (
<Col span={4}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.caseInsensitive")}
name={[field.name, "caseInsensitive"]}
valuePropName="checked"
labelCol={{span: 14}}
wrapperCol={{span: 10}}
>
<Switch defaultChecked={true}/>
</Form.Item>
</Col>
)}
{/* Actions */}
<Col span={2}>
<Space>
<DeleteFilled onClick={() => remove(field.name)}/>
<FormListMoveArrows move={move} index={index} total={fields.length}/>
</Space>
</Col>
</Row>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("bodyshop.actions.addpartsrule")}
</Button>
</Form.Item>
</div>
);
}}
);
})}
<Form.Item>
<Button
type="dashed"
onClick={() => add({field: "line_desc", operation: "contains"})}
style={{width: "100%"}}
>
{t("bodyshop.actions.addpartsrule")}
</Button>
</Form.Item>
</div>
)}
</Form.List>
</LayoutFormRow>
</div>

View File

@@ -1,31 +1,32 @@
import { Alert, Form, InputNumber, Switch } from "antd";
import {Alert, Form, Switch} from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import {useTranslation} from "react-i18next";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import {connect} from "react-redux";
import {createStructuredSelector} from "reselect";
import {selectBodyshop} from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoIntellipay);
export function ShopInfoIntellipay({ bodyshop, form }) {
const { t } = useTranslation();
// noinspection JSUnusedLocalSymbols
export function ShopInfoIntellipay({bodyshop, form}) {
const {t} = useTranslation();
return (
<>
<Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}>
{() => {
const { intellipay_config } = form.getFieldsValue();
const {intellipay_config} = form.getFieldsValue();
if (intellipay_config?.enable_cash_discount)
return <Alert message={t("bodyshop.labels.intellipay_cash_discount")} />;
return <Alert message={t("bodyshop.labels.intellipay_cash_discount")}/>;
}}
</Form.Item>
@@ -35,7 +36,7 @@ export function ShopInfoIntellipay({ bodyshop, form }) {
valuePropName="checked"
name={["intellipay_config", "enable_cash_discount"]}
>
<Switch />
<Switch/>
</Form.Item>
</LayoutFormRow>
</>

View File

@@ -256,6 +256,15 @@
}
},
"bodyshop": {
"operations": {
"starts_with": "Starts With",
"contains": "Contains",
"ends_with": "Ends With",
"equals": "Equals",
"not_equals": "Not Equals",
"greater_than": "Greater Than",
"less_than": "Less Than"
},
"actions": {
"add_task_preset": "Add Task Preset",
"addapptcolor": "Add Appointment Color",
@@ -379,8 +388,10 @@
"md_lost_sale_reasons": "Lost Sale Reasons",
"md_parts_order_comment": "Parts Orders Comments",
"md_parts_scan": {
"expression": "RegEX Expression",
"flags": "Flags"
"field": "Field",
"operation": "Operation",
"value": "Value",
"caseInsensitive": "Case Insensitive"
},
"md_payment_types": "Payment Types",
"md_referral_sources": "Referral Sources",
@@ -1451,6 +1462,7 @@
"mod_lbr_ty": "Labor Type",
"notes": "Notes",
"oem_partno": "OEM Part #",
"alt_partno": "Alt Part #",
"op_code_desc": "Op Code Description",
"part_qty": "Qty.",
"part_type": "Part Type",

View File

@@ -256,6 +256,15 @@
}
},
"bodyshop": {
"operations": {
"starts_with": "",
"contains": "",
"ends_with": "",
"equals": "",
"not_equals": "",
"greater_than": "",
"less_than": ""
},
"actions": {
"add_task_preset": "",
"addapptcolor": "",
@@ -379,8 +388,10 @@
"md_lost_sale_reasons": "",
"md_parts_order_comment": "",
"md_parts_scan": {
"expression": "",
"flags": ""
"field": "",
"operation": "",
"value": "",
"caseInsensitive": ""
},
"md_payment_types": "",
"md_referral_sources": "",
@@ -1451,6 +1462,7 @@
"mod_lbr_ty": "Tipo de trabajo",
"notes": "",
"oem_partno": "OEM parte #",
"alt_partno": "",
"op_code_desc": "",
"part_qty": "",
"part_type": "Tipo de parte",

View File

@@ -256,6 +256,15 @@
}
},
"bodyshop": {
"operations": {
"starts_with": "",
"contains": "",
"ends_with": "",
"equals": "",
"not_equals": "",
"greater_than": "",
"less_than": ""
},
"actions": {
"add_task_preset": "",
"addapptcolor": "",
@@ -379,8 +388,10 @@
"md_lost_sale_reasons": "",
"md_parts_order_comment": "",
"md_parts_scan": {
"expression": "",
"flags": ""
"field": "",
"operation": "",
"value": "",
"caseInsensitive": ""
},
"md_payment_types": "",
"md_referral_sources": "",
@@ -1451,6 +1462,7 @@
"mod_lbr_ty": "Type de travail",
"notes": "",
"oem_partno": "Pièce OEM #",
"alt_partno": "",
"op_code_desc": "",
"part_qty": "",
"part_type": "Type de pièce",

View File

@@ -2234,18 +2234,25 @@ exports.QUERY_PARTS_SCAN = `query QUERY_PARTS_SCAN ($id: uuid!) {
id
line_desc
critical
part_type
act_price
part_qty
mod_lbr_ty
mod_lb_hrs
oem_partno
alt_partno
}
}
}`;
exports.UPDATE_PARTS_CRITICAL = `mutation UPDATE_PARTS_CRITICAL ($IdsToMarkCritical:[uuid!]!, $jobid: uuid!){
critical: update_joblines(where:{id:{_in:$IdsToMarkCritical}}, _set:{critical: true}){
exports.UPDATE_PARTS_CRITICAL = `mutation UPDATE_PARTS_CRITICAL ($IdsToMarkCritical:[uuid!]!, $jobid: uuid!) {
critical: update_joblines(where: {id: {_in: $IdsToMarkCritical}}, _set: {critical: true}) {
affected_rows
}
notcritical: update_joblines(where:{id:{_nin:$IdsToMarkCritical}, jobid: {_eq: $jobid}}, _set:{critical: false}){
notcritical: update_joblines(where: {id: {_nin: $IdsToMarkCritical}, jobid: {_eq: $jobid}}, _set: {critical: false}) {
affected_rows
}
}`;
}`
exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) {
associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) {

View File

@@ -1,16 +1,18 @@
const GraphQLClient = require("graphql-request").GraphQLClient;
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 InstanceManager = require("../utils/instanceMgr").default;
const { sendTaskEmail } = require("../email/sendemail");
const generateEmailTemplate = require("../email/generateTemplate");
const { getEndpoints } = require("../email/tasksEmails");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const domain = process.env.NODE_ENV ? "secure" : "test";
@@ -50,43 +52,15 @@ const getShopCredentials = async (bodyshop) => {
}
};
const decodeComment = (comment) => {
try {
return comment ? JSON.parse(Buffer.from(comment, "base64").toString()) : null;
} catch (error) {
return null; // Handle malformed base64 string gracefully
}
};
exports.lightbox_credentials = async (req, res) => {
const decodedComment = decodeComment(req.body?.comment);
const logMeta = {
iPayData: req.body?.iPayData,
decodedComment,
bodyshop: {
id: req.body?.bodyshop?.id,
imexshopid: req.body?.bodyshop?.imexshopid,
name: req.body?.bodyshop?.shopname
}
};
logger.log("intellipay-lightbox-credentials", "DEBUG", req.user?.email, null, logMeta);
logger.log("intellipay-lightbox-credentials", "DEBUG", req.user?.email, null, null);
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
logger.log("intellipay-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logMeta
});
res.json({
message: shopCredentials.error?.message,
type: "intellipay-credentials-error",
...logMeta
});
res.json(shopCredentials);
return;
}
try {
const options = {
method: "POST",
@@ -100,61 +74,26 @@ exports.lightbox_credentials = async (req, res) => {
const response = await axios(options);
logger.log("intellipay-lightbox-success", "DEBUG", req.user?.email, null, {
requestOptions: options,
...logMeta
});
res.send(response.data);
} catch (error) {
logger.log("intellipay-lightbox-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logMeta
});
res.json({
message: error?.message,
type: "intellipay-lightbox-error",
...logMeta
//console.log(error);
logger.log("intellipay-lightbox-credentials-error", "ERROR", req.user?.email, null, {
error: JSON.stringify(error)
});
res.json({ error });
}
};
exports.payment_refund = async (req, res) => {
const decodedComment = decodeComment(req.body.iPayData?.comment);
const logResponseMeta = {
iPayData: req.body?.iPayData,
bodyshop: {
id: req.body.bodyshop?.id,
imexshopid: req.body.bodyshop?.imexshopid,
name: req.body.bodyshop?.shopname
},
paymentid: req.body?.paymentid,
amount: req.body?.amount,
decodedComment
};
logger.log("intellipay-refund-request-received", "DEBUG", req.user?.email, null, logResponseMeta);
logger.log("intellipay-refund", "DEBUG", req.user?.email, null, null);
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
logger.log("intellipay-refund-credentials-error", "ERROR", req.user?.email, null, {
credentialsError: shopCredentials.error,
...logResponseMeta
});
res.status(400).json({
credentialsError: shopCredentials.error,
type: "intellipay-refund-credentials-error",
...logResponseMeta
});
return;
}
try {
const options = {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
data: qs.stringify({
method: "payment_refund",
...shopCredentials,
@@ -164,255 +103,132 @@ exports.payment_refund = async (req, res) => {
url: `https://${domain}.cpteller.com/api/26/webapi.cfc?method=payment_refund`
};
logger.log("intellipay-refund-options-prepared", "DEBUG", req.user?.email, null, {
requestOptions: options,
...logResponseMeta
});
const response = await axios(options);
logger.log("intellipay-refund-success", "DEBUG", req.user?.email, null, {
requestOptions: options,
...logResponseMeta
});
res.send(response.data);
} catch (error) {
//console.log(error);
logger.log("intellipay-refund-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
});
res.status(500).json({
message: error?.message,
type: "intellipay-refund-error",
...logResponseMeta
error: JSON.stringify(error)
});
res.json({ error });
}
};
exports.generate_payment_url = async (req, res) => {
const decodedComment = decodeComment(req.body.comment);
const logResponseMeta = {
iPayData: req.body?.iPayData,
bodyshop: {
id: req.body.bodyshop?.id,
imexshopid: req.body.bodyshop?.imexshopid,
name: req.body.bodyshop?.shopname
},
amount: req.body?.amount,
account: req.body?.account,
comment: req.body?.comment,
invoice: req.body?.invoice,
decodedComment
};
logger.log("intellipay-generate-payment-url-received", "DEBUG", req.user?.email, null, logResponseMeta);
logger.log("intellipay-payment-url", "DEBUG", req.user?.email, null, null);
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
logger.log("intellipay-generate-payment-url-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logResponseMeta
});
res.status(400).json({
message: shopCredentials.error?.message,
type: "intellipay-generate-payment-url-credentials-error",
...logResponseMeta
});
return;
}
try {
const options = {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
//TODO: Move these to environment variables/database.
data: qs.stringify({
...shopCredentials,
//...req.body,
amount: Dinero({ amount: Math.round(req.body.amount * 100) }).toFormat("0.00"),
account: req.body.account,
comment: req.body.comment,
invoice: req.body.invoice,
createshorturl: true
//The postback URL is set at the CP teller global terminal settings page.
}),
url: `https://${domain}.cpteller.com/api/custapi.cfc?method=generate_lightbox_url`
};
logger.log("intellipay-generate-payment-url-options-prepared", "DEBUG", req.user?.email, null, {
requestOptions: options,
...logResponseMeta
});
const response = await axios(options);
logger.log("intellipay-generate-payment-url-success", "DEBUG", req.user?.email, null, {
requestOptions: options,
shortUrl: response.data?.shorturl,
...logResponseMeta
});
res.send(response.data);
} catch (error) {
logger.log("intellipay-generate-payment-url-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
//console.log(error);
logger.log("intellipay-payment-url-error", "ERROR", req.user?.email, null, {
error: JSON.stringify(error)
});
res.status(500).json({ message: error?.message, ...logResponseMeta });
res.json({ error });
}
};
//Reference: https://intellipay.com/dist/webapi26.html#operation/fee
exports.checkfee = async (req, res) => {
const logResponseMeta = {
bodyshop: {
id: req.body?.bodyshop?.id,
imexshopid: req.body?.bodyshop?.imexshopid,
name: req.body?.bodyshop?.shopname,
state: req.body?.bodyshop?.state
},
amount: req.body?.amount
};
logger.log("intellipay-checkfee-request-received", "DEBUG", req.user?.email, null, logResponseMeta);
// Requires amount, bodyshop.imexshopid, and state? to get data.
logger.log("intellipay-fee-check", "DEBUG", req.user?.email, null, null);
//If there's no amount, there can't be a fee. Skip the call.
if (!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;
}
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
logger.log("intellipay-checkfee-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logResponseMeta
});
res.status(400).json({ error: shopCredentials.error?.message, ...logResponseMeta });
return;
}
try {
const options = {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
//TODO: Move these to environment variables/database.
data: qs.stringify(
{
method: "fee",
...shopCredentials,
amount: req.body.amount,
paymenttype: `CC`,
cardnum: "4111111111111111", // Required for compatibility with API
cardnum: "4111111111111111", //Not needed per documentation, but incorrect values come back without it.
state:
req.body.bodyshop?.state && req.body.bodyshop.state.length === 2
req.body.bodyshop?.state && req.body.bodyshop.state?.length === 2
? req.body.bodyshop.state.toUpperCase()
: "ZZ"
: "ZZ" //Same as above
},
{ sort: false } // Ensure query string order is preserved
{ sort: false } //ColdFusion Query Strings depend on order. This preserves it.
),
url: `https://${domain}.cpteller.com/api/26/webapi.cfc`
};
logger.log("intellipay-checkfee-options-prepared", "DEBUG", req.user?.email, null, {
requestOptions: options,
...logResponseMeta
});
const response = await axios(options);
if (response.data?.error) {
logger.log("intellipay-checkfee-api-error", "ERROR", req.user?.email, null, {
message: response.data?.error,
...logResponseMeta
});
res.status(400).json({
error: response.data?.error,
type: "intellipay-checkfee-api-error",
...logResponseMeta
});
res.status(400).json({ error: response.data.error });
} else if (response.data < 0) {
logger.log("intellipay-checkfee-negative-fee", "ERROR", req.user?.email, null, {
message: "Fee amount returned is negative.",
...logResponseMeta
});
res.json({
error: "Fee amount negative. Check API credentials & account configuration.",
...logResponseMeta,
type: "intellipay-checkfee-negative-fee"
});
res.json({ error: "Fee amount negative. Check API credentials & account configuration." });
} else {
logger.log("intellipay-checkfee-success", "DEBUG", req.user?.email, null, {
fee: response.data,
...logResponseMeta
});
res.json({ fee: response.data, ...logResponseMeta });
res.json({ fee: response.data });
}
} catch (error) {
logger.log("intellipay-checkfee-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
//console.log(error);
logger.log("intellipay-fee-check-error", "ERROR", req.user?.email, null, {
error: error.message
});
res.status(500).json({ error: error?.message, logResponseMeta });
res.status(400).json({ error });
}
};
exports.postback = async (req, res) => {
const { body: values } = req;
const decodedComment = decodeComment(values?.comment);
const logResponseMeta = {
bodyshop: {
id: req.body?.bodyshop?.id,
imexshopid: req.body?.bodyshop?.imexshopid,
name: req.body?.bodyshop?.shopname,
state: req.body?.bodyshop?.state
},
iprequest: values,
decodedComment
};
logger.log("intellipay-postback-received", "DEBUG", req.user?.email, null, logResponseMeta);
try {
if ((!values.invoice || values.invoice === "") && !decodedComment) {
logger.log("intellipay-postback", "DEBUG", req.user?.email, null, req.body);
const { body: values } = req;
const comment = Buffer.from(values?.comment, "base64").toString();
if ((!values.invoice || values.invoice === "") && !comment) {
//invoice is specified through the pay link. Comment by IO.
logger.log("intellipay-postback-ignored", "DEBUG", req.user?.email, null, {
message: "No invoice or comment provided",
...logResponseMeta
});
logger.log("intellipay-postback-ignored", "DEBUG", req.user?.email, null, req.body);
res.sendStatus(200);
return;
}
if (decodedComment) {
if (comment) {
//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 = JSON.parse(decodedComment);
logger.log("intellipay-postback-parsed-comment", "DEBUG", req.user?.email, null, {
parsedComment,
...logResponseMeta
});
const parsedComment = JSON.parse(comment);
//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)
});
logger.log("intellipay-postback-jobs-fetched", "DEBUG", req.user?.email, null, {
jobs,
parsedComment,
...logResponseMeta
});
// Insert new payments
const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, {
paymentInput: partialPayments.map((p) => ({
amount: p.amount,
@@ -434,15 +250,13 @@ exports.postback = async (req, res) => {
}
}))
});
logger.log("intellipay-postback-payment-success", "DEBUG", req.user?.email, null, {
paymentResult,
jobs,
parsedComment,
...logResponseMeta
logger.log("intellipay-postback-app-success", "DEBUG", req.user?.email, JSON.stringify(jobs), {
iprequest: values,
paymentResult
});
if (values.origin === "OneLink" && parsedComment.userEmail) {
//Send an email, it was a text to pay link.
try {
const endPoints = getEndpoints();
sendTaskEmail({
@@ -461,25 +275,20 @@ exports.postback = async (req, res) => {
})
});
} catch (error) {
logger.log("intellipay-postback-email-error", "ERROR", req.user?.email, null, {
message: error.message,
jobs,
logger.log("intellipay-postback-app-email-error", "DEBUG", req.user?.email, JSON.stringify(jobs), {
iprequest: values,
paymentResult,
...logResponseMeta
error: error.message
});
}
}
res.sendStatus(200);
} else if (values.invoice) {
//This is a link email that's been sent out.
const job = await gqlClient.request(queries.GET_JOB_BY_PK, {
id: values.invoice
});
logger.log("intellipay-postback-invoice-job-fetched", "DEBUG", req.user?.email, null, {
job,
...logResponseMeta
});
const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, {
paymentInput: {
amount: values.total,
@@ -491,11 +300,6 @@ exports.postback = async (req, res) => {
}
});
logger.log("intellipay-postback-invoice-payment-success", "DEBUG", req.user?.email, null, {
paymentResult,
...logResponseMeta
});
const responseResults = await gqlClient.request(queries.INSERT_PAYMENT_RESPONSE, {
paymentResponse: {
amount: values.total,
@@ -509,17 +313,18 @@ exports.postback = async (req, res) => {
}
});
logger.log("intellipay-postback-invoice-response-success", "DEBUG", req.user?.email, null, {
logger.log("intellipay-postback-link-success", "DEBUG", req.user?.email, values.invoice, {
iprequest: values,
responseResults,
...logResponseMeta
paymentResult
});
res.sendStatus(200);
}
} catch (error) {
logger.log("intellipay-postback-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
logger.log("intellipay-postback-total-error", "ERROR", req.user?.email, null, {
error: JSON.stringify(error),
body: req.body
});
res.status(400).json({ successful: false, error: error.message, ...logResponseMeta });
res.status(400).json({ succesful: false, error: error.message });
}
};

View File

@@ -1,50 +1,90 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { job } = require("../scheduling/scheduling-job");
const _ = require("lodash");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
const predefinedPartTypes = [
"PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG",
];
const predefinedModLbrTypes = [
"LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU",
"LA1", "LA2", "LA3", "LA4",
];
exports.partsScan = async function (req, res) {
console.log('hello')
const { jobid } = req.body;
const BearerToken = req.BearerToken;
const client = req.userGraphQLClient;
logger.log("job-parts-scan", "DEBUG", req.user?.email, jobid, null);
try {
//Query all jobline data using the user's authorization.
const data = await client.setHeaders({ Authorization: BearerToken }).request(queries.QUERY_PARTS_SCAN, {
id: jobid
});
const data = await client.setHeaders({ Authorization: BearerToken }).request(
queries.QUERY_PARTS_SCAN,
{ id: jobid }
);
//Create RegExps once for better performance.
const IdsToMarkCritical = [];
const RegExpressions = data.jobs_by_pk.bodyshop.md_parts_scan.map((r) => new RegExp(r.expression, r.flags));
const rules = data.jobs_by_pk.bodyshop.md_parts_scan || [];
if (!Array.isArray(rules)) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("Invalid md_parts_scan format. Expected an array of rules.");
}
//Check each line against each regex rule.
data.jobs_by_pk.joblines.forEach((jobline) => {
RegExpressions.forEach((rExp) => {
if (jobline.line_desc.match(rExp)) {
IdsToMarkCritical.push(jobline);
const compiledRules = rules.map((rule) => ({
...rule,
regex:
typeof rule.value === "string" && rule.caseInsensitive
? new RegExp(rule.value, "i")
: typeof rule.value === "string"
? new RegExp(rule.value)
: null,
}));
const criticalIds = new Set();
for (const jobline of data.jobs_by_pk.joblines) {
for (const { field, regex, operation, value } of compiledRules) {
if (criticalIds.has(jobline.id)) break; // Skip further evaluation if already critical
let jobValue = jobline[field];
let match = false;
if (field === "part_number") {
match = regex
? regex.test(jobline.oem_partno || '') || regex.test(jobline.alt_partno || '')
: jobline.oem_partno === value || jobline.alt_partno === value;
} else if (field === "part_type") {
match = predefinedPartTypes.includes(value) && value === jobValue;
} else if (field === "mod_lbr_ty") {
match = predefinedModLbrTypes.includes(value) && value === jobValue;
} else if (regex && typeof jobValue === "string") {
if (operation === "contains") match = regex.test(jobValue);
if (operation === "startsWith") match = new RegExp(`^${value}`).test(jobValue);
if (operation === "endsWith") match = new RegExp(`${value}$`).test(jobValue);
if (operation === "equals") match = jobValue === value;
} else if (typeof jobValue === "number") {
if (operation === ">") match = jobValue > value;
if (operation === "<") match = jobValue < value;
if (operation === "=") match = jobValue === value;
}
});
});
const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_PARTS_CRITICAL, {
IdsToMarkCritical: _.uniqBy(IdsToMarkCritical, "id").map((i) => i.id),
jobid: jobid
});
if (match) {
criticalIds.add(jobline.id);
break; // No need to evaluate further rules for this jobline
}
}
}
const result = await client.setHeaders({ Authorization: BearerToken }).request(
queries.UPDATE_PARTS_CRITICAL,
{ IdsToMarkCritical: Array.from(criticalIds), jobid }
);
res.status(200).json(result);
} catch (error) {
logger.log("job-parts-scan-error", "ERROR", req.user.email, jobid, {
logger.log("job-parts-scan-error", "ERROR", req.user?.email, jobid, {
jobid,
error
error: error.message,
});
res.status(400).json(JSON.stringify(error));
res.status(400).json(JSON.stringify({ message: error?.message }));
}
};