const urlBuilder = require("./qbo").urlBuilder; const StandardizeName = require("./qbo").StandardizeName; const path = require("path"); require("dotenv").config({ path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) }); const logger = require("../../utils/logger"); const Dinero = require("dinero.js"); const DineroQbFormat = require("../accounting-constants").DineroQbFormat; const apiGqlClient = require("../../graphql-client/graphql-client").client; const queries = require("../../graphql-client/queries"); const { refresh: refreshOauthToken, setNewRefreshToken } = require("./qbo-callback"); const OAuthClient = require("intuit-oauth"); const moment = require("moment-timezone"); const findTaxCode = require("../qb-receivables-lines").findTaxCode; exports.default = async (req, res) => { const oauthClient = new OAuthClient({ clientId: process.env.QBO_CLIENT_ID, clientSecret: process.env.QBO_SECRET, environment: process.env.NODE_ENV === "production" ? "production" : "sandbox", redirectUri: process.env.QBO_REDIRECT_URI, logging: true }); try { //Fetch the API Access Tokens & Set them for the session. const response = await apiGqlClient.request(queries.GET_QBO_AUTH, { email: req.user.email }); const { qbo_realmId } = response.associations[0]; oauthClient.setToken(response.associations[0].qbo_auth); if (!qbo_realmId) { res.status(401).json({ error: "No company associated." }); return; } await refreshOauthToken(oauthClient, req); const { bills: billsToQuery, elgen } = req.body; const BearerToken = req.BearerToken; const client = req.userGraphQLClient; logger.log("qbo-payable-create", "DEBUG", req.user.email, null, { billsToQuery }); const result = await client .setHeaders({ Authorization: BearerToken }) .request(queries.QUERY_BILLS_FOR_PAYABLES_EXPORT, { bills: billsToQuery }); const { bills, bodyshops } = result; const ret = []; const bodyshop = bodyshops[0]; for (const bill of bills) { try { let vendorRecord; vendorRecord = await QueryVendorRecord(oauthClient, qbo_realmId, req, bill); if (!vendorRecord) { vendorRecord = await InsertVendorRecord(oauthClient, qbo_realmId, req, bill); } const insertResults = await InsertBill(oauthClient, qbo_realmId, req, bill, vendorRecord, bodyshop); // //No error. Mark the job exported & insert export log. if (elgen) { const result = await client .setHeaders({ Authorization: BearerToken }) .request(queries.QBO_MARK_BILL_EXPORTED, { billId: bill.id, bill: { exported: true, exported_at: moment().tz(bodyshop.timezone) }, logs: [ { bodyshopid: bodyshop.id, billid: bill.id, successful: true, useremail: req.user.email } ] }); } ret.push({ billid: bill.id, success: true }); } catch (error) { logger.log("qbo-paybles-create-error", "ERROR", req.user.email, null, { error: (error && error.authResponse && error.authResponse.body) || error.response?.data?.Fault?.Error.map((e) => e.Detail).join(", ") || (error && error.message) }); ret.push({ billid: bill.id, success: false, errorMessage: (error && error.authResponse && error.authResponse.body) || error.response?.data?.Fault?.Error.map((e) => e.Detail).join(", ") || (error && error.message) }); //Add the export log error. if (elgen) { const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.INSERT_EXPORT_LOG, { logs: [ { bodyshopid: bodyshop.id, billid: bill.id, successful: false, message: JSON.stringify([ (error && error.authResponse && error.authResponse.body) || (error && error.message) ]), useremail: req.user.email } ] }); } } } res.status(200).json(ret); } catch (error) { //console.log(error); logger.log("qbo-payable-create-error", "ERROR", req.user.email, null, { error: error.message, stack: error.stack }); res.status(400).json(error); } }; async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) { try { const result = await oauthClient.makeApiCall({ url: urlBuilder( qbo_realmId, "query", `select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'` ), method: "POST", headers: { "Content-Type": "application/json" } }); setNewRefreshToken(req.user.email, result); return ( result.json && result.json.QueryResponse && result.json.QueryResponse.Vendor && result.json.QueryResponse.Vendor[0] ); } catch (error) { logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, { error: (error && error.authResponse && error.authResponse.body) || (error && error.message), method: "QueryVendorRecord" }); throw error; } } async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) { const Vendor = { DisplayName: bill.vendor.name }; try { const result = await oauthClient.makeApiCall({ url: urlBuilder(qbo_realmId, "vendor"), method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Vendor) }); setNewRefreshToken(req.user.email, result); return result && result.json && result.json.Vendor; } catch (error) { logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, { error: (error && error.authResponse && error.authResponse.body) || (error && error.message), method: "InsertVendorRecord" }); throw error; } } async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop) { const { accounts, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req); const lines = bill.billlines.map((il) => generateBillLine( il, accounts, bill.job.class, bodyshop.md_responsibility_centers.sales_tax_codes, classes, taxCodes, bodyshop.md_responsibility_centers.costs, bodyshop.accountingconfig, bodyshop.region_config ) ); //QB USA with GST //This was required for the No. 1 Collision Group. if ( bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && bodyshop.accountingconfig.qbo_usa && bodyshop.region_config.includes("CA_") ) { lines.push({ DetailType: "AccountBasedExpenseLineDetail", AccountBasedExpenseLineDetail: { ...(bill.job.class ? { ClassRef: { value: classes[bill.job.class] } } : {}), AccountRef: { value: accounts[bodyshop.md_responsibility_centers.taxes.federal_itc.accountdesc] } }, Amount: Dinero({ amount: Math.round( bill.billlines.reduce((acc, val) => { return acc + (val.applicable_taxes?.federal ? (val.actual_cost * val.quantity ?? 0) : 0); }, 0) * 100 ) }) .percentage(bill.federal_tax_rate) .toFormat(DineroQbFormat) }); } const billQbo = { VendorRef: { value: vendor.Id }, TxnDate: moment(bill.date) //.tz(bill.job.bodyshop.timezone) .format("YYYY-MM-DD"), ...(!bill.is_credit_memo && bill.vendor.due_date && { DueDate: moment(bill.date) //.tz(bill.job.bodyshop.timezone) .add(bill.vendor.due_date, "days") .format("YYYY-MM-DD") }), DocNumber: bill.invoice_number, //...(bill.job.class ? { ClassRef: { Id: classes[bill.job.class] } } : {}), ...(!( bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && bodyshop.accountingconfig.qbo_usa && bodyshop.region_config.includes("CA_") ) ? { GlobalTaxCalculation: "TaxExcluded" } : {}), ...(bodyshop.accountingconfig.qbo_departmentid && bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && { DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid } }), PrivateNote: `RO ${bill.job.ro_number || ""}`, Line: lines }; logger.log("qbo-payable-objectlog", "DEBUG", req.user.email, bill.id, { billQbo }); try { const result = await oauthClient.makeApiCall({ url: urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill"), method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(billQbo) }); setNewRefreshToken(req.user.email, result); return result && result.json && result.json.Bill; } catch (error) { logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, { error: error, //(error && error.authResponse && error.authResponse.body) || (error && error.message), validationError: JSON.stringify(error?.response?.data), accountmeta: JSON.stringify({ accounts, taxCodes, classes }), method: "InsertBill" }); throw error; } } // [ // { // DetailType: "AccountBasedExpenseLineDetail", // Amount: 200.0, // Id: "1", // AccountBasedExpenseLineDetail: { // AccountRef: { // value: "7", // }, // }, // }, // ], const generateBillLine = ( billLine, accounts, jobClass, ioSalesTaxCodes, classes, taxCodes, costCenters, accountingconfig, region_config ) => { const account = costCenters.find((c) => c.name === billLine.cost_center); return { DetailType: "AccountBasedExpenseLineDetail", AccountBasedExpenseLineDetail: { ...(jobClass ? { ClassRef: { value: classes[jobClass] } } : {}), TaxCodeRef: accountingconfig.qbo && accountingconfig.qbo_usa && region_config.includes("CA_") ? {} : { value: taxCodes[findTaxCode(billLine.applicable_taxes, ioSalesTaxCodes)] }, AccountRef: { value: accounts[account.accountname] } }, Amount: Dinero({ amount: Math.round(billLine.actual_cost * 100) }) .multiply(billLine.quantity || 1) .toFormat(DineroQbFormat) }; }; async function QueryMetaData(oauthClient, qbo_realmId, req) { const accounts = await oauthClient.makeApiCall({ url: urlBuilder( qbo_realmId, "query", `select * From Account where AccountType in ('Cost of Goods Sold', 'Other Current Liability')` ), method: "POST", headers: { "Content-Type": "application/json" } }); setNewRefreshToken(req.user.email, accounts); const taxCodes = await oauthClient.makeApiCall({ url: urlBuilder(qbo_realmId, "query", `select * From TaxCode`), method: "POST", headers: { "Content-Type": "application/json" } }); const classes = await oauthClient.makeApiCall({ url: urlBuilder(qbo_realmId, "query", `select * From Class`), method: "POST", headers: { "Content-Type": "application/json" } }); const taxCodeMapping = {}; taxCodes.json && taxCodes.json.QueryResponse && taxCodes.json.QueryResponse.TaxCode && taxCodes.json.QueryResponse.TaxCode.forEach((t) => { taxCodeMapping[t.Name] = t.Id; }); const accountMapping = {}; accounts.json && accounts.json.QueryResponse && accounts.json.QueryResponse.Account && accounts.json.QueryResponse.Account.forEach((t) => { accountMapping[t.FullyQualifiedName] = t.Id; }); const classMapping = {}; classes.json && classes.json.QueryResponse && classes.json.QueryResponse.Class && classes.json.QueryResponse.Class.forEach((t) => { classMapping[t.Name] = t.Id; }); return { accounts: accountMapping, taxCodes: taxCodeMapping, classes: classMapping }; }