From 73c457e972e782a4753ada4d50dd1f1792dcbf1b Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Tue, 2 Jun 2020 15:41:47 -0700 Subject: [PATCH] Created export payables screen + basic invoice exporting BOD-139 --- bodyshop_translations.babel | 84 +++++++++ .../accounting-payables-table.component.jsx | 159 ++++++++++++++++++ .../invoice-export-button.component.jsx | 98 +++++++++++ .../jobs-close-export-button.component.jsx | 4 +- client/src/graphql/accounting.queries.js | 16 ++ client/src/graphql/invoices.queries.js | 2 + .../accounting-payables.container.jsx | 48 ++++++ .../pages/manage/manage.page.component.jsx | 8 + client/src/translations/en_us/common.json | 4 + client/src/translations/es/common.json | 4 + client/src/translations/fr/common.json | 4 + server.js | 3 +- server/accounting/qbxml/qbxml-payables.js | 107 ++++++++++++ server/accounting/qbxml/qbxml-receivables.js | 14 +- server/accounting/qbxml/qbxml-utils.js | 6 + server/accounting/qbxml/qbxml.js | 3 +- server/graphql-client/queries.js | 38 +++++ 17 files changed, 588 insertions(+), 14 deletions(-) create mode 100644 client/src/components/accounting-payables-table/accounting-payables-table.component.jsx create mode 100644 client/src/components/invoice-export-button/invoice-export-button.component.jsx create mode 100644 client/src/pages/accounting-payables/accounting-payables.container.jsx create mode 100644 server/accounting/qbxml/qbxml-payables.js create mode 100644 server/accounting/qbxml/qbxml-utils.js diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index d5d1f8cd0..72edcd926 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -6206,6 +6206,48 @@ + + exporting + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + exporting-partner + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + invalidro false @@ -14918,6 +14960,27 @@ titles + + accounting-payables + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + accounting-receivables false @@ -14963,6 +15026,27 @@ bc + + accounting-payables + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + accounting-receivables false diff --git a/client/src/components/accounting-payables-table/accounting-payables-table.component.jsx b/client/src/components/accounting-payables-table/accounting-payables-table.component.jsx new file mode 100644 index 000000000..7a26b14bf --- /dev/null +++ b/client/src/components/accounting-payables-table/accounting-payables-table.component.jsx @@ -0,0 +1,159 @@ +import { Input, Table, Checkbox } from "antd"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import CurrencyFormatter from "../../utils/CurrencyFormatter"; +import { alphaSort } from "../../utils/sorters"; +import InvoiceExportButton from "../invoice-export-button/invoice-export-button.component"; +import { DateFormatter } from "../../utils/DateFormatter"; +import queryString from "query-string"; + +export default function AccountingPayablesTableComponent({ + loading, + invoices, +}) { + const { t } = useTranslation(); + + const [state, setState] = useState({ + sortedInfo: {}, + search: "", + }); + + const handleTableChange = (pagination, filters, sorter) => { + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); + }; + + const columns = [ + { + title: t("invoices.fields.vendorname"), + dataIndex: "vendorname", + key: "vendorname", + sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name), + sortOrder: + state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order, + render: (text, record) => ( + + {record.vendor.name} + + ), + }, + { + title: t("invoices.fields.invoice_number"), + dataIndex: "invoice_number", + key: "invoice_number", + sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number), + sortOrder: + state.sortedInfo.columnKey === "invoice_number" && + state.sortedInfo.order, + render: (text, record) => ( + + {record.invoice_number} + + ), + }, + { + title: t("invoices.fields.date"), + dataIndex: "date", + key: "date", + + sorter: (a, b) => a.date - b.date, + sortOrder: + state.sortedInfo.columnKey === "date" && state.sortedInfo.order, + render: (text, record) => {record.date}, + }, + { + title: t("invoices.fields.total"), + dataIndex: "total", + key: "total", + + sorter: (a, b) => a.total - b.total, + sortOrder: + state.sortedInfo.columnKey === "total" && state.sortedInfo.order, + render: (text, record) => ( + {record.total} + ), + }, + { + title: t("invoices.fields.is_credit_memo"), + dataIndex: "is_credit_memo", + key: "is_credit_memo", + sorter: (a, b) => a.is_credit_memo - b.is_credit_memo, + sortOrder: + state.sortedInfo.columnKey === "is_credit_memo" && + state.sortedInfo.order, + render: (text, record) => ( + + ), + }, + { + title: t("general.labels.actions"), + dataIndex: "actions", + key: "actions", + sorter: (a, b) => a.clm_total - b.clm_total, + + render: (text, record) => ( +
+ +
+ ), + }, + ]; + + const handleSearch = (e) => { + setState({ ...state, search: e.target.value }); + }; + + const dataSource = state.search + ? invoices.filter( + (v) => + (v.vendor.name || "") + .toLowerCase() + .includes(state.search.toLowerCase()) || + (v.invoice_number || "") + .toLowerCase() + .includes(state.search.toLowerCase()) + ) + : invoices; + + return ( +
+ { + return ( +
+ +
+ ); + }} + dataSource={dataSource} + size="small" + pagination={{ position: "top" }} + columns={columns} + rowKey="id" + onChange={handleTableChange} + /> + + ); +} diff --git a/client/src/components/invoice-export-button/invoice-export-button.component.jsx b/client/src/components/invoice-export-button/invoice-export-button.component.jsx new file mode 100644 index 000000000..e1496c560 --- /dev/null +++ b/client/src/components/invoice-export-button/invoice-export-button.component.jsx @@ -0,0 +1,98 @@ +import { useMutation } from "@apollo/react-hooks"; +import { Button, notification } from "antd"; +import axios from "axios"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { auth } from "../../firebase/firebase.utils"; +import { UPDATE_INVOICE } from "../../graphql/invoices.queries"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, +}); + +export function InvoiceExportButton({ bodyshop, invoiceId, disabled }) { + const { t } = useTranslation(); + const [updateInvoice] = useMutation(UPDATE_INVOICE); + const [loading, setLoading] = useState(false); + + const handleQbxml = async () => { + setLoading(true); + let QbXmlResponse; + try { + QbXmlResponse = await axios.post( + "/accounting/qbxml/payables", + { invoices: [invoiceId] }, + { + headers: { + Authorization: `Bearer ${await auth.currentUser.getIdToken(true)}`, + }, + } + ); + console.log("handle -> XML", QbXmlResponse); + } catch (error) { + console.log("Error getting QBXML from Server.", error); + notification["error"]({ + message: t("invoices.errors.exporting", { + error: "Unable to retrieve QBXML. " + JSON.stringify(error.message), + }), + }); + setLoading(false); + return; + } + + let PartnerResponse; + try { + PartnerResponse = await axios.post( + "http://e9c5a8ed9079.ngrok.io/qb/receivables", + QbXmlResponse.data + ); + } catch (error) { + console.log("Error connecting to quickbooks or partner.", error); + notification["error"]({ + message: t("invoices.errors.exporting-partner"), + }); + setLoading(false); + return; + } + + console.log("PartnerResponse", PartnerResponse); + // const invoiceUpdateResponse = await updateInvoice({ + // variables: { + // invoiceId: invoiceId, + // invoice: { + // exported: true, + // exported_at: new Date(), + // }, + // }, + // }); + + // if (!!!invoiceUpdateResponse.errors) { + // notification["success"]({ + // message: t("jobs.successes.exported"), + // }); + // } else { + // notification["error"]({ + // message: t("jobs.errors.exporting", { + // error: JSON.stringify(invoiceUpdateResponse.error), + // }), + // }); + // } + + setLoading(false); + }; + + return ( + + ); +} + +export default connect(mapStateToProps, null)(InvoiceExportButton); diff --git a/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx b/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx index e63d4a318..4784397ed 100644 --- a/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx +++ b/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx @@ -44,7 +44,7 @@ export function JobsCloseExportButton({ bodyshop, jobId, disabled }) { let PartnerResponse; try { PartnerResponse = await axios.post( - "http://e9c5a8ed9079.ngrok.io/qb/receivables", + "http://localhost:1337/qb/receivables", QbXmlResponse.data, { headers: { @@ -60,7 +60,7 @@ export function JobsCloseExportButton({ bodyshop, jobId, disabled }) { setLoading(false); return; } - + console.log("PartnerResponse", PartnerResponse); const jobUpdateResponse = await updateJob({ variables: { diff --git a/client/src/graphql/accounting.queries.js b/client/src/graphql/accounting.queries.js index 61b210363..8519e1f6d 100644 --- a/client/src/graphql/accounting.queries.js +++ b/client/src/graphql/accounting.queries.js @@ -27,3 +27,19 @@ export const QUERY_JOBS_FOR_EXPORT = gql` } } `; + +export const QUERY_INVOICES_FOR_EXPORT = gql` + query QUERY_INVOICES_FOR_EXPORT { + invoices(where: { exported: { _eq: false } }) { + id + exported + date + invoice_number + total + vendor { + name + id + } + } + } +`; diff --git a/client/src/graphql/invoices.queries.js b/client/src/graphql/invoices.queries.js index 40bd2430f..539a95339 100644 --- a/client/src/graphql/invoices.queries.js +++ b/client/src/graphql/invoices.queries.js @@ -129,6 +129,8 @@ export const UPDATE_INVOICE = gql` update_invoices(where: { id: { _eq: $invoiceId } }, _set: $invoice) { returning { id + exported + exported_at } } } diff --git a/client/src/pages/accounting-payables/accounting-payables.container.jsx b/client/src/pages/accounting-payables/accounting-payables.container.jsx new file mode 100644 index 000000000..87d188e94 --- /dev/null +++ b/client/src/pages/accounting-payables/accounting-payables.container.jsx @@ -0,0 +1,48 @@ +import { useQuery } from "@apollo/react-hooks"; +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import AccountingPayablesTable from "../../components/accounting-payables-table/accounting-payables-table.component"; +import AlertComponent from "../../components/alert/alert.component"; +import { QUERY_INVOICES_FOR_EXPORT } from "../../graphql/accounting.queries"; +import { setBreadcrumbs } from "../../redux/application/application.actions"; +import { selectBodyshop } from "../../redux/user/user.selectors"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, +}); + +const mapDispatchToProps = (dispatch) => ({ + setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), +}); +export function AccountingPayablesContainer({ bodyshop, setBreadcrumbs }) { + const { t } = useTranslation(); + + useEffect(() => { + document.title = t("titles.accounting-payables"); + setBreadcrumbs([ + { + link: "/manage/accounting/payables", + label: t("titles.bc.accounting-payables"), + }, + ]); + }, [t, setBreadcrumbs]); + + const { loading, error, data } = useQuery(QUERY_INVOICES_FOR_EXPORT); + + if (error) return ; + + return ( +
+ +
+ ); +} +export default connect( + mapStateToProps, + mapDispatchToProps +)(AccountingPayablesContainer); diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx index e123393d6..829adfbb4 100644 --- a/client/src/pages/manage/manage.page.component.jsx +++ b/client/src/pages/manage/manage.page.component.jsx @@ -94,6 +94,9 @@ const JobIntake = lazy(() => const AccountingReceivables = lazy(() => import("../accounting-receivables/accounting-receivables.container") ); +const AccountingPayables = lazy(() => + import("../accounting-payables/accounting-payables.container") +); const AllJobs = lazy(() => import("../jobs-all/jobs-all.container")); const JobsClose = lazy(() => import("../jobs-close/jobs-close.container")); @@ -277,6 +280,11 @@ export function Manage({ match, conflict }) { path={`${match.path}/accounting/receivables`} component={AccountingReceivables} /> + )} diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 041eef326..1ecccb897 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -425,6 +425,8 @@ }, "errors": { "creating": "Error adding invoice.", + "exporting": "Error exporting invoice {{error}}", + "exporting-partner": "Unable to connect to ImEX Partner. Please ensure it is running and logged in.", "invalidro": "Not a valid RO.", "invalidvendor": "Not a valid vendor.", "validation": "Please ensure all fields are entered correctly. " @@ -950,9 +952,11 @@ } }, "titles": { + "accounting-payables": "Payables | $t(titles.app)", "accounting-receivables": "Receivables | $t(titles.app)", "app": "ImEX Online", "bc": { + "accounting-payables": "Payables", "accounting-receivables": "Receivables", "availablejobs": "Available Jobs", "contracts": "Contracts", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index f1a38e61f..123be1be9 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -425,6 +425,8 @@ }, "errors": { "creating": "", + "exporting": "", + "exporting-partner": "", "invalidro": "", "invalidvendor": "", "validation": "" @@ -950,9 +952,11 @@ } }, "titles": { + "accounting-payables": "", "accounting-receivables": "", "app": "ImEX Online", "bc": { + "accounting-payables": "", "accounting-receivables": "", "availablejobs": "", "contracts": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 609bd4282..cbb57cfb5 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -425,6 +425,8 @@ }, "errors": { "creating": "", + "exporting": "", + "exporting-partner": "", "invalidro": "", "invalidvendor": "", "validation": "" @@ -950,9 +952,11 @@ } }, "titles": { + "accounting-payables": "", "accounting-receivables": "", "app": "ImEX Online", "bc": { + "accounting-payables": "", "accounting-receivables": "", "availablejobs": "", "contracts": "", diff --git a/server.js b/server.js index e04a7e12b..43dad4574 100644 --- a/server.js +++ b/server.js @@ -43,9 +43,10 @@ app.post("/test", async function (req, res) { const accountingIIF = require("./server/accounting/iif/iif"); app.post("/accounting/iif/receivables", accountingIIF.receivables); -//Invoicing-IIF +//Accounting Qbxml const accountQbxml = require("./server/accounting/qbxml/qbxml"); app.post("/accounting/qbxml/receivables", accountQbxml.receivables); +app.post("/accounting/qbxml/payables", accountQbxml.payables); //Cloudinary Media Paths var media = require("./server/media/media"); diff --git a/server/accounting/qbxml/qbxml-payables.js b/server/accounting/qbxml/qbxml-payables.js new file mode 100644 index 000000000..7198e7627 --- /dev/null +++ b/server/accounting/qbxml/qbxml-payables.js @@ -0,0 +1,107 @@ +const GraphQLClient = require("graphql-request").GraphQLClient; +const path = require("path"); +const DineroQbFormat = require("../accounting-constants").DineroQbFormat; +const queries = require("../../graphql-client/queries"); +const Dinero = require("dinero.js"); +var builder = require("xmlbuilder"); +const QbXmlUtils = require("./qbxml-utils"); +require("dotenv").config({ + path: path.resolve( + process.cwd(), + `.env.${process.env.NODE_ENV || "development"}` + ), +}); + +exports.default = async (req, res) => { + const BearerToken = req.headers.authorization; + const { invoices: invoicesToQuery } = req.body; + + const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { + headers: { + Authorization: BearerToken, + }, + }); + + try { + const result = await client + .setHeaders({ Authorization: BearerToken }) + .request(queries.QUERY_INVOICES_FOR_PAYABLES_EXPORT, { + invoices: invoicesToQuery, + }); + const { invoices } = result; + + const QbXmlToExecute = []; + invoices.map((i) => { + QbXmlToExecute.push(generateBill(i)); + }); + + //For each invoice. + + res.status(200).json(QbXmlToExecute); + } catch (error) { + console.log("error", error); + res.status(400).send(JSON.stringify(error)); + } +}; + +const generateBill = (invoice) => { + const billQbxmlObj = { + QBXML: { + QBXMLMsgsRq: { + "@onError": "continueOnError", + BillAddRq: { + BillAdd: { + VendorRef: { + FullName: invoice.vendor.name, + }, + TxnDate: invoice.date, + DueDate: invoice.due_date, + RefNumber: invoice.invoice_number, + Memo: `RO ${invoice.job.ro_number || ""} OWNER ${ + invoice.job.ownr_fn || "" + } ${invoice.job.ownr_ln || ""} ${invoice.job.ownr_co_nm || ""}`, + ExpenseLineAdd: invoice.invoicelines.map((il) => + generateBillLine( + il, + invoice.job.bodyshop.md_responsibility_centers + ) + ), + }, + }, + }, + }, + }; + + var billQbxml_partial = builder + .create(billQbxmlObj, { + version: "1.30", + encoding: "UTF-8", + headless: true, + }) + .end({ pretty: true }); + + const billQbxml_Full = QbXmlUtils.addQbxmlHeader(billQbxml_partial); + console.log("generateBill -> billQbxml_Full", billQbxml_Full); + + return billQbxml_Full; +}; + +const generateBillLine = (invoiceLine, responsibilityCenters) => { + return { + AccountRef: { + FullName: responsibilityCenters.costs.find( + (c) => c.name === invoiceLine.cost_center + ).accountname, + }, + Amount: Dinero({ + amount: Math.round(invoiceLine.actual_cost * 100), + }).toFormat(DineroQbFormat), + }; +}; + +// [ +// { +// AccountRef: { FullName: "BODY SHOP COST:SUBLET" }, +// Amount: invoice.amount, +// }, +// ], diff --git a/server/accounting/qbxml/qbxml-receivables.js b/server/accounting/qbxml/qbxml-receivables.js index 528f39ecf..e65fd1d47 100644 --- a/server/accounting/qbxml/qbxml-receivables.js +++ b/server/accounting/qbxml/qbxml-receivables.js @@ -4,6 +4,7 @@ const DineroQbFormat = require("../accounting-constants").DineroQbFormat; const queries = require("../../graphql-client/queries"); const Dinero = require("dinero.js"); var builder = require("xmlbuilder"); +const QbXmlUtils = require("./qbxml-utils"); require("dotenv").config({ path: path.resolve( process.cwd(), @@ -77,7 +78,7 @@ const generateSourceCustomerQbxml = (jobs_by_pk, bodyshop) => { }) .end({ pretty: true }); - const customerQbxml_Full = addQbxmlHeader(customerQbxml_partial); + const customerQbxml_Full = QbXmlUtils.addQbxmlHeader(customerQbxml_partial); return customerQbxml_Full; }; @@ -138,7 +139,7 @@ const generateJobQbxml = (jobs_by_pk, bodyshop, isThreeTier, tierLevel) => { }) .end({ pretty: true }); - const jobQbxml_Full = addQbxmlHeader(jobQbxml_partial); + const jobQbxml_Full = QbXmlUtils.addQbxmlHeader(jobQbxml_partial); console.log("jobQbxml_Full", jobQbxml_Full); return jobQbxml_Full; }; @@ -262,7 +263,7 @@ const generateInvoiceQbxml = (jobs_by_pk, bodyshop) => { }) .end({ pretty: true }); - const invoiceQbxml_Full = addQbxmlHeader(invoiceQbxml_partial); + const invoiceQbxml_Full = QbXmlUtils.addQbxmlHeader(invoiceQbxml_partial); return invoiceQbxml_Full; }; @@ -291,10 +292,3 @@ const generateInvoiceLine = (job, allocation, responsibilityCenters) => { }, }; }; - -const addQbxmlHeader = (xml) => { - return ` - - ${xml} - `; -}; diff --git a/server/accounting/qbxml/qbxml-utils.js b/server/accounting/qbxml/qbxml-utils.js new file mode 100644 index 000000000..2849fa8fb --- /dev/null +++ b/server/accounting/qbxml/qbxml-utils.js @@ -0,0 +1,6 @@ +exports.addQbxmlHeader = addQbxmlHeader = (xml) => { + return ` + + ${xml} + `; +}; diff --git a/server/accounting/qbxml/qbxml.js b/server/accounting/qbxml/qbxml.js index 95086ba6e..94c689dca 100644 --- a/server/accounting/qbxml/qbxml.js +++ b/server/accounting/qbxml/qbxml.js @@ -1 +1,2 @@ -exports.receivables = require("./qbxml-receivables").default \ No newline at end of file +exports.receivables = require("./qbxml-receivables").default; +exports.payables = require("./qbxml-payables").default; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index fc8423a7a..5216760d0 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -70,3 +70,41 @@ query QUERY_JOBS_FOR_RECEIVABLES_EXPORT($id: uuid!) { } } `; + +exports.QUERY_INVOICES_FOR_PAYABLES_EXPORT = ` +query QUERY_INVOICES_FOR_PAYABLES_EXPORT($invoices: [uuid!]!) { + invoices(where: {id: {_in: $invoices}}) { + id + date + due_date + federal_tax_rate + invoice_number + is_credit_memo + job { + id + ro_number + clm_no + ownr_fn + ownr_ln + ownr_co_nm + bodyshop{ + md_responsibility_centers + } + } + invoicelines{ + id + cost_center + actual_cost + applicable_taxes + } + state_tax_rate + local_tax_rate + total + vendor{ + id + name + } + } +} + + `;