Merged in feature/IO-2933-ip-short-url-email (pull request #1728)

Feature/IO-2933 ip short url email
This commit is contained in:
Patrick Fic
2024-09-18 18:21:53 +00:00
10 changed files with 3791 additions and 3655 deletions

View File

@@ -1,4 +1,4 @@
<babeledit_project be_version="2.7.1" version="1.2"> <babeledit_project version="1.2" be_version="2.7.1">
<!-- <!--
BabelEdit project file BabelEdit project file
@@ -22361,6 +22361,27 @@
<folder_node> <folder_node>
<name>buttons</name> <name>buttons</name>
<children> <children>
<concept_node>
<name>create_short_link</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>goback</name> <name>goback</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled, CopyFilled } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client"; import { useLazyQuery, useMutation } from "@apollo/client";
import { Button, Card, Col, Form, Input, Row, Space, Spin, Statistic, notification } from "antd"; import { Button, Card, Col, Form, Input, Row, Space, Spin, Statistic, message, notification } from "antd";
import axios from "axios"; import axios from "axios";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -14,10 +14,12 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component"; import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component"; import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
import { getCurrentUser } from "../../firebase/firebase.utils";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
cardPaymentModal: selectCardPayment, cardPaymentModal: selectCardPayment,
bodyshop: selectBodyshop bodyshop: selectBodyshop,
currentUser: getCurrentUser
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
@@ -25,11 +27,17 @@ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment")) toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment"))
}); });
const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisible, insertAuditTrail }) => { const CardPaymentModalComponent = ({
bodyshop,
currentUser,
cardPaymentModal,
toggleModalVisible,
insertAuditTrail
}) => {
const { context, actions } = cardPaymentModal; const { context, actions } = cardPaymentModal;
const [form] = Form.useForm(); const [form] = Form.useForm();
const [paymentLink, setPaymentLink] = useState();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// const [insertPayment] = useMutation(INSERT_NEW_PAYMENT); // const [insertPayment] = useMutation(INSERT_NEW_PAYMENT);
const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE); const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE);
@@ -51,8 +59,7 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
//2024-04-25: Nothing is going to happen here anymore. We'll completely rely on the callback. //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. //Add a slight delay to allow the refetch to properly get the data.
setTimeout(() => { setTimeout(() => {
if (actions && actions.refetch && typeof actions.refetch === "function") if (actions && actions.refetch && typeof actions.refetch === "function") actions.refetch();
actions.refetch();
setLoading(false); setLoading(false);
toggleModalVisible(); toggleModalVisible();
}, 750); }, 750);
@@ -86,7 +93,6 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
}); });
}; };
const handleIntelliPayCharge = async () => { const handleIntelliPayCharge = async () => {
setLoading(true); setLoading(true);
//Validate //Validate
@@ -101,7 +107,7 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
const response = await axios.post("/intellipay/lightbox_credentials", { const response = await axios.post("/intellipay/lightbox_credentials", {
bodyshop, bodyshop,
refresh: !!window.intellipay, refresh: !!window.intellipay,
paymentSplitMeta: form.getFieldsValue(), paymentSplitMeta: form.getFieldsValue()
}); });
if (window.intellipay) { if (window.intellipay) {
@@ -126,6 +132,42 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
} }
}; };
const handleIntelliPayChargeShortLink = async () => {
setLoading(true);
//Validate
try {
await form.validateFields();
} catch (error) {
setLoading(false);
return;
}
try {
const { payments } = form.getFieldsValue();
const response = await axios.post("/intellipay/generate_payment_url", {
bodyshop,
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()
});
if (response.data) {
setPaymentLink(response.data?.shorUrl);
navigator.clipboard.writeText(response.data?.shorUrl);
message.success(t("general.actions.copied"));
}
setLoading(false);
} catch (error) {
notification.open({
type: "error",
message: t("job_payments.notifications.error.openingip")
});
setLoading(false);
}
};
return ( return (
<Card title="Card Payment"> <Card title="Card Payment">
<Spin spinning={loading}> <Spin spinning={loading}>
@@ -208,10 +250,7 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
{() => { {() => {
//If all of the job ids have been fileld in, then query and update the IP field. //If all of the job ids have been fileld in, then query and update the IP field.
const { payments } = form.getFieldsValue(); const { payments } = form.getFieldsValue();
if ( if (payments?.length > 0 && payments?.filter((p) => p?.jobid).length === payments?.length) {
payments?.length > 0 &&
payments?.filter((p) => p?.jobid).length === payments?.length
) {
refetch({ jobids: payments.map((p) => p.jobid) }); refetch({ jobids: payments.map((p) => p.jobid) });
} }
return ( return (
@@ -246,7 +285,6 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
const totalAmountToCharge = payments?.reduce((acc, val) => { const totalAmountToCharge = payments?.reduce((acc, val) => {
return acc + (val?.amount || 0); return acc + (val?.amount || 0);
}, 0); }, 0);
return ( return (
<Space style={{ float: "right" }}> <Space style={{ float: "right" }}>
<Statistic title="Amount To Charge" value={totalAmountToCharge} precision={2} /> <Statistic title="Amount To Charge" value={totalAmountToCharge} precision={2} />
@@ -273,11 +311,36 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
> >
{t("job_payments.buttons.proceedtopayment")} {t("job_payments.buttons.proceedtopayment")}
</Button> </Button>
<Space direction="vertical" align="center">
<Button
type="primary"
// data-ipayname="submit"
className="ipayfield"
loading={queryLoading || loading}
disabled={!(totalAmountToCharge > 0)}
onClick={handleIntelliPayChargeShortLink}
>
{t("job_payments.buttons.create_short_link")}
</Button>
</Space>
</Space> </Space>
); );
}} }}
</Form.Item> </Form.Item>
</Form> </Form>
{paymentLink && (
<Space
style={{ cursor: "pointer", float: "right" }}
align="end"
onClick={() => {
navigator.clipboard.writeText(paymentLink);
message.success(t("general.actions.copied"));
}}
>
<div>{paymentLink}</div>
<CopyFilled />
</Space>
)}
</Spin> </Spin>
</Card> </Card>
); );

View File

@@ -8,11 +8,12 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions"; import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component"; import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop,
currentUser: selectCurrentUser
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
@@ -20,7 +21,7 @@ const mapDispatchToProps = (dispatch) => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(PaymentsGenerateLink); export default connect(mapStateToProps, mapDispatchToProps)(PaymentsGenerateLink);
export function PaymentsGenerateLink({ bodyshop, callback, job, openChatByPhone, setMessage }) { export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, openChatByPhone, setMessage }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -30,29 +31,35 @@ export function PaymentsGenerateLink({ bodyshop, callback, job, openChatByPhone,
const handleFinish = async ({ amount }) => { const handleFinish = async ({ amount }) => {
setLoading(true); setLoading(true);
let p;
const p = parsePhoneNumber(job.ownr_ph1, "CA"); try {
p = parsePhoneNumber(job.ownr_ph1 || "", "CA");
} catch (error) {
console.log("Unable to parse phone number");
}
setLoading(true); setLoading(true);
const response = await axios.post("/intellipay/generate_payment_url", { const response = await axios.post("/intellipay/generate_payment_url", {
bodyshop, bodyshop,
amount: amount, amount: amount,
account: job.ro_number, account: job.ro_number,
invoice: job.id comment: btoa(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email }))
}); });
setLoading(false); setLoading(false);
setPaymentLink(response.data.shorUrl); setPaymentLink(response.data.shorUrl);
openChatByPhone({ if (p) {
phone_num: p.formatInternational(), openChatByPhone({
jobid: job.id phone_num: p.formatInternational(),
}); jobid: job.id
setMessage( });
t("payments.labels.smspaymentreminder", { setMessage(
shopname: bodyshop.shopname, t("payments.labels.smspaymentreminder", {
amount: amount, shopname: bodyshop.shopname,
payment_link: response.data.shorUrl amount: amount,
}) payment_link: response.data.shorUrl
); })
);
}
//Add in confirmation & errors. //Add in confirmation & errors.
if (callback) callback(); if (callback) callback();

View File

@@ -1373,6 +1373,7 @@
}, },
"job_payments": { "job_payments": {
"buttons": { "buttons": {
"create_short_link": "Generate Short Link",
"goback": "Go Back", "goback": "Go Back",
"proceedtopayment": "Proceed to Payment", "proceedtopayment": "Proceed to Payment",
"refundpayment": "Refund Payment" "refundpayment": "Refund Payment"

File diff suppressed because it is too large Load Diff

View File

@@ -1373,6 +1373,7 @@
}, },
"job_payments": { "job_payments": {
"buttons": { "buttons": {
"create_short_link": "",
"goback": "", "goback": "",
"proceedtopayment": "", "proceedtopayment": "",
"refundpayment": "" "refundpayment": ""

View File

@@ -96,7 +96,7 @@ const sendServerEmail = async ({ subject, text }) => {
} }
}; };
const sendTaskEmail = async ({ to, subject, text, attachments }) => { const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => {
try { try {
transporter.sendMail( transporter.sendMail(
{ {
@@ -107,7 +107,7 @@ const sendTaskEmail = async ({ to, subject, text, attachments }) => {
}), }),
to: to, to: to,
subject: subject, subject: subject,
text: text, ...(type === "text" ? { text } : { html }),
attachments: attachments || null attachments: attachments || null
}, },
(err, info) => { (err, info) => {

View File

@@ -94,8 +94,9 @@ const formatPriority = (priority) => {
* @param taskId * @param taskId
* @returns {{header, body: string, subHeader: string}} * @returns {{header, body: string, subHeader: string}}
*/ */
const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, job, taskId) => {
const endPoints = InstanceManager({ const getEndpoints = () =>
InstanceManager({
imex: process.env?.NODE_ENV === "test" ? "https://test.imex.online" : "https://imex.online", imex: process.env?.NODE_ENV === "test" ? "https://test.imex.online" : "https://imex.online",
rome: rome:
bodyshop.convenient_company === "promanager" bodyshop.convenient_company === "promanager"
@@ -106,6 +107,9 @@ const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, j
? "https//test.romeonline.io" ? "https//test.romeonline.io"
: "https://romeonline.io" : "https://romeonline.io"
}); });
const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, job, taskId) => {
const endPoints = getEndpoints();
return { return {
header: title, header: title,
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)}`, subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)}`,
@@ -333,5 +337,6 @@ const tasksRemindEmail = async (req, res) => {
module.exports = { module.exports = {
taskAssignedEmail, taskAssignedEmail,
tasksRemindEmail tasksRemindEmail,
getEndpoints
}; };

View File

@@ -2502,6 +2502,13 @@ exports.GET_JOBS_BY_PKS = `query GET_JOBS_BY_PKS($ids: [uuid!]!) {
jobs(where: {id: {_in: $ids}}) { jobs(where: {id: {_in: $ids}}) {
id id
shopid shopid
ro_number
ownr_co_nm
ownr_fn
ownr_ln
v_make_desc
v_model_yr
v_model_desc
} }
} }
`; `;

View File

@@ -7,7 +7,9 @@ const axios = require("axios");
const moment = require("moment"); const moment = require("moment");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default; const InstanceManager = require("../utils/instanceMgr").default;
const { sendTaskEmail } = require("../email/sendemail");
const generateEmailTemplate = require("../email/generateTemplate");
const { getEndpoints } = require("../email/tasksEmails");
require("dotenv").config({ require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
}); });
@@ -129,6 +131,7 @@ exports.generate_payment_url = async (req, res) => {
//...req.body, //...req.body,
amount: Dinero({ amount: Math.round(req.body.amount * 100) }).toFormat("0.00"), amount: Dinero({ amount: Math.round(req.body.amount * 100) }).toFormat("0.00"),
account: req.body.account, account: req.body.account,
comment: req.body.comment,
invoice: req.body.invoice, invoice: req.body.invoice,
createshorturl: true createshorturl: true
//The postback URL is set at the CP teller global terminal settings page. //The postback URL is set at the CP teller global terminal settings page.
@@ -162,7 +165,67 @@ exports.postback = async (req, res) => {
return; return;
} }
if (values.invoice) { 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(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;
const jobs = await gqlClient.request(queries.GET_JOBS_BY_PKS, {
ids: partialPayments.map((p) => p.jobid)
});
const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, {
paymentInput: partialPayments.map((p) => ({
amount: p.amount,
transactionid: values.authcode,
payer: "Customer",
type: values.cardtype,
jobid: p.jobid,
date: moment(Date.now()),
payment_responses: {
data: {
amount: values.total,
bodyshopid: jobs.jobs[0].shopid,
jobid: p.jobid,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
}
}))
});
logger.log("intellipay-postback-app-success", "DEBUG", req.user?.email, null, {
iprequest: values,
paymentResult
});
if (values.origin === "OneLink" && parsedComment.userEmail) {
//Send an email, it was a text to pay link.
const endPoints = getEndpoints();
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="${endPoints}/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/>")
})
});
res.sendStatus(200);
}
} else if (values.invoice) {
//This is a link email that's been sent out. //This is a link email that's been sent out.
const job = await gqlClient.request(queries.GET_JOB_BY_PK, { const job = await gqlClient.request(queries.GET_JOB_BY_PK, {
id: values.invoice id: values.invoice
@@ -198,39 +261,6 @@ exports.postback = async (req, res) => {
paymentResult paymentResult
}); });
res.sendStatus(200); res.sendStatus(200);
} else if (comment) {
//This has been triggered by IO and may have multiple jobs.
const partialPayments = JSON.parse(comment);
const jobs = await gqlClient.request(queries.GET_JOBS_BY_PKS, {
ids: partialPayments.map((p) => p.jobid)
});
const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, {
paymentInput: partialPayments.map((p) => ({
amount: p.amount,
transactionid: values.authcode,
payer: "Customer",
type: values.cardtype,
jobid: p.jobid,
date: moment(Date.now()),
payment_responses: {
data: {
amount: values.total,
bodyshopid: jobs.jobs[0].shopid,
jobid: p.jobid,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
}
}))
});
logger.log("intellipay-postback-app-success", "DEBUG", req.user?.email, null, {
iprequest: values,
paymentResult
});
res.sendStatus(200);
} }
} catch (error) { } catch (error) {
logger.log("intellipay-postback-error", "ERROR", req.user?.email, null, { logger.log("intellipay-postback-error", "ERROR", req.user?.email, null, {