const urlBuilder = require("./qbo").urlBuilder; 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 GraphQLClient = require("graphql-request").GraphQLClient; const { generateOwnerTier } = require("../qbxml/qbxml-utils"); 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, }); oauthClient.setToken(response.associations[0].qbo_auth); await refreshOauthToken(oauthClient, req); const BearerToken = req.headers.authorization; const { bills: billsToQuery } = req.body; //Query Job Info const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { headers: { Authorization: BearerToken, }, }); logger.log("qbo-payable-create", "DEBUG", req.user.email, billsToQuery); const result = await client .setHeaders({ Authorization: BearerToken }) .request(queries.QUERY_BILLS_FOR_PAYABLES_EXPORT, { bills: billsToQuery, }); const { bills } = result; for (const bill of bills) { let vendorRecord; vendorRecord = await QueryVendorRecord(oauthClient, req, bill); if (!vendorRecord) { vendorRecord = await InsertVendorRecord(oauthClient, req, bill); } const insertResults = await InsertBill(oauthClient, req, bill); } res.json({}); } catch (error) { console.log(error); logger.log("qbo-payable-create-error", "ERROR", req.user.email, { error }); res.status(400).json(error); } }; async function QueryVendorRecord(oauthClient, req, bill) { try { const result = await oauthClient.makeApiCall({ url: urlBuilder( req.cookies.qbo_realmId, "query", `select * From vendor where DisplayName = '${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, method: "QueryVendorRecord", }); throw error; } } async function InsertVendorRecord(oauthClient, req, bill) { const Vendor = { DisplayName: bill.vendor.name, }; try { const result = await oauthClient.makeApiCall({ url: urlBuilder(req.cookies.qbo_realmId, "vendor"), method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(Vendor), }); setNewRefreshToken(req.user.email, result); return result && result.Vendor; } catch (error) { logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, { error, method: "InsertVendorRecord", }); throw error; } } async function InsertBill(oauthClient, req, bill) { const vendor = { DisplayName: job.ro_number, BillAddr: { City: job.ownr_city, Line1: job.ownr_addr1, Line2: job.ownr_addr2, PostalCode: job.ownr_zip, CountrySubDivisionCode: job.ownr_st, }, ...(isThreeTier ? { Job: true, ParentRef: { value: parentTierRef.Id, }, } : {}), }; try { const result = await oauthClient.makeApiCall({ url: urlBuilder(req.cookies.qbo_realmId, "vendor"), method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(vendor), }); setNewRefreshToken(req.user.email, result); return result && result.Customer; } catch (error) { logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, { error, method: "InsertOwner", }); throw error; } } async function QueryMetaData(oauthClient, req) { const items = await oauthClient.makeApiCall({ url: urlBuilder(req.cookies.qbo_realmId, "query", `select * From Item`), method: "POST", headers: { "Content-Type": "application/json", }, }); setNewRefreshToken(req.user.email, items); const taxCodes = await oauthClient.makeApiCall({ url: urlBuilder(req.cookies.qbo_realmId, "query", `select * From TaxCode`), method: "POST", headers: { "Content-Type": "application/json", }, }); const taxCodeMapping = {}; const accounts = await oauthClient.makeApiCall({ url: urlBuilder(req.cookies.qbo_realmId, "query", `select * From Account`), method: "POST", headers: { "Content-Type": "application/json", }, }); taxCodes.json && taxCodes.json.QueryResponse && taxCodes.json.QueryResponse.TaxCode.forEach((t) => { taxCodeMapping[t.Name] = t.Id; }); const itemMapping = {}; items.json && items.json.QueryResponse && items.json.QueryResponse.Item.forEach((t) => { itemMapping[t.Name] = t.Id; }); return { items: itemMapping, taxCodes: taxCodeMapping, }; } async function InsertInvoice(oauthClient, req, job, bodyshop, parentTierRef) { const { items, taxCodes } = await QueryMetaData(oauthClient, req); const InvoiceLineAdd = []; const responsibilityCenters = bodyshop.md_responsibility_centers; const invoiceLineHash = {}; //The hash of cost and profit centers based on the center name. //Determine if there are MAPA and MASH lines already on the estimate. //If there are, don't do anything extra (mitchell estimate) //Otherwise, calculate them and add them to the default MAPA and MASH centers. let hasMapaLine = false; let hasMashLine = false; //Create the invoice lines mapping. job.joblines.map((jobline) => { //Parts Lines if (jobline.db_ref === "936008") { //If either of these DB REFs change, they also need to change in job-totals/job-costing calculations. hasMapaLine = true; } if (jobline.db_ref === "936007") { hasMashLine = true; } if (jobline.profitcenter_part && jobline.act_price) { let DineroAmount = Dinero({ amount: Math.round(jobline.act_price * 100), }).multiply(jobline.part_qty || 1); if (jobline.prt_dsmk_p && jobline.prt_dsmk_p !== 0) { // console.log("Have a part discount", jobline); DineroAmount = DineroAmount.add( DineroAmount.percentage(jobline.prt_dsmk_p || 0) ); } const account = responsibilityCenters.profits.find( (i) => jobline.profitcenter_part.toLowerCase() === i.name.toLowerCase() ); if (!account) { logger.log("qbxml-payables-no-account", "warn", null, jobline.id, null); throw new Error( `A matching account does not exist for the part allocation. Center: ${jobline.profitcenter_part}` ); } if (!invoiceLineHash[account.name]) { invoiceLineHash[account.name] = { ItemRef: { FullName: account.accountitem }, Desc: account.accountdesc, Quantity: 1, //jobline.part_qty, Amount: DineroAmount, //.toFormat(DineroQbFormat), SalesTaxCodeRef: { FullName: "E", }, }; } else { invoiceLineHash[account.name].Amount = invoiceLineHash[account.name].Amount.add(DineroAmount); } } // Labor Lines if (jobline.profitcenter_labor && jobline.mod_lb_hrs) { const DineroAmount = Dinero({ amount: Math.round( job[`rate_${jobline.mod_lbr_ty.toLowerCase()}`] * 100 ), }).multiply(jobline.mod_lb_hrs); const account = responsibilityCenters.profits.find( (i) => jobline.profitcenter_labor.toLowerCase() === i.name.toLowerCase() ); if (!account) { throw new Error( `A matching account does not exist for the labor allocation. Center: ${jobline.profitcenter_labor}` ); } if (!invoiceLineHash[account.name]) { invoiceLineHash[account.name] = { ItemRef: { FullName: account.accountitem }, Desc: account.accountdesc, Quantity: 1, // jobline.mod_lb_hrs, Amount: DineroAmount, //Amount: DineroAmount.toFormat(DineroQbFormat), SalesTaxCodeRef: { FullName: "E", }, }; } else { invoiceLineHash[account.name].Amount = invoiceLineHash[account.name].Amount.add(DineroAmount); } } }); // console.log("Done creating hash", JSON.stringify(invoiceLineHash)); if (!hasMapaLine && job.job_totals.rates.mapa.total.amount > 0) { // console.log("Adding MAPA Line Manually."); const mapaAccountName = responsibilityCenters.defaults.profits.MAPA; const mapaAccount = responsibilityCenters.profits.find( (c) => c.name === mapaAccountName ); if (mapaAccount) { InvoiceLineAdd.push({ ItemRef: { FullName: mapaAccount.accountitem }, Desc: mapaAccount.accountdesc, Quantity: 1, Amount: Dinero(job.job_totals.rates.mapa.total).toFormat( DineroQbFormat ), SalesTaxCodeRef: { FullName: "E", }, }); } else { //console.log("NO MAPA ACCOUNT FOUND!!"); } } if (!hasMashLine && job.job_totals.rates.mash.total.amount > 0) { // console.log("Adding MASH Line Manually."); const mashAccountName = responsibilityCenters.defaults.profits.MASH; const mashAccount = responsibilityCenters.profits.find( (c) => c.name === mashAccountName ); if (mashAccount) { InvoiceLineAdd.push({ ItemRef: { FullName: mashAccount.accountitem }, Desc: mashAccount.accountdesc, Quantity: 1, Amount: Dinero(job.job_totals.rates.mash.total).toFormat( DineroQbFormat ), SalesTaxCodeRef: { FullName: "E", }, }); } else { // console.log("NO MASH ACCOUNT FOUND!!"); } } //Convert the hash to an array. Object.keys(invoiceLineHash).forEach((key) => { InvoiceLineAdd.push({ ...invoiceLineHash[key], Amount: invoiceLineHash[key].Amount.toFormat(DineroQbFormat), }); }); //Add Towing, storage and adjustment lines. if (job.towing_payable && job.towing_payable !== 0) { InvoiceLineAdd.push({ ItemRef: { FullName: responsibilityCenters.profits.find( (c) => c.name === responsibilityCenters.defaults.profits["TOW"] ).accountitem, }, Desc: "Towing", Quantity: 1, Amount: Dinero({ amount: Math.round((job.towing_payable || 0) * 100), }).toFormat(DineroQbFormat), SalesTaxCodeRef: { FullName: "E", }, }); } if (job.storage_payable && job.storage_payable !== 0) { InvoiceLineAdd.push({ ItemRef: { FullName: responsibilityCenters.profits.find( (c) => c.name === responsibilityCenters.defaults.profits["TOW"] ).accountitem, }, Desc: "Storage", Quantity: 1, Amount: Dinero({ amount: Math.round((job.storage_payable || 0) * 100), }).toFormat(DineroQbFormat), SalesTaxCodeRef: { FullName: "E", }, }); } if (job.adjustment_bottom_line && job.adjustment_bottom_line !== 0) { InvoiceLineAdd.push({ ItemRef: { FullName: responsibilityCenters.profits.find( (c) => c.name === responsibilityCenters.defaults.profits["PAO"] ).accountitem, }, Desc: "Adjustment", Quantity: 1, Amount: Dinero({ amount: Math.round((job.adjustment_bottom_line || 0) * 100), }).toFormat(DineroQbFormat), SalesTaxCodeRef: { FullName: "E", }, }); } //Add tax lines const job_totals = job.job_totals; const federal_tax = Dinero(job_totals.totals.federal_tax); const state_tax = Dinero(job_totals.totals.state_tax); const local_tax = Dinero(job_totals.totals.local_tax); if (federal_tax.getAmount() > 0) { InvoiceLineAdd.push({ ItemRef: { FullName: bodyshop.md_responsibility_centers.taxes.federal.accountitem, }, Desc: bodyshop.md_responsibility_centers.taxes.federal.accountdesc, Amount: federal_tax.toFormat(DineroQbFormat), }); } if (state_tax.getAmount() > 0) { InvoiceLineAdd.push({ ItemRef: { FullName: bodyshop.md_responsibility_centers.taxes.state.accountitem, }, Desc: bodyshop.md_responsibility_centers.taxes.state.accountdesc, Amount: state_tax.toFormat(DineroQbFormat), }); } if (local_tax.getAmount() > 0) { InvoiceLineAdd.push({ ItemRef: { FullName: bodyshop.md_responsibility_centers.taxes.local.accountitem, }, Desc: bodyshop.md_responsibility_centers.taxes.local.accountdesc, Amount: local_tax.toFormat(DineroQbFormat), }); } //Region Specific const { ca_bc_pvrt } = job; if (ca_bc_pvrt) { InvoiceLineAdd.push({ ItemRef: { FullName: bodyshop.md_responsibility_centers.taxes.state.accountitem, }, Desc: "PVRT", Amount: Dinero({ amount: (ca_bc_pvrt || 0) * 100 }).toFormat( DineroQbFormat ), }); } //map each invoice line to the correct style for QBO. const invoiceObj = { Line: [ { DetailType: "SalesItemLineDetail", Amount: 100, SalesItemLineDetail: { // ItemRef: { // // name: "Services", // value: "16", // }, TaxCodeRef: { value: "2", }, Qty: 1, UnitPrice: 100, }, }, ], // Line: InvoiceLineAdd.map((i) => { // return { // DetailType: "SalesItemLineDetail", // Amount: i.Amount, // SalesItemLineDetail: { // ItemRef: { // //name: "Services", // value: items[i.ItemRef.FullName], // }, // // TaxCodeRef: { // // value: "2", // // }, // Qty: 1, // }, // }; // }), TxnTaxDetail: { TaxLine: [ { DetailType: "TaxLineDetail", TaxLineDetail: { NetAmountTaxable: 100, TaxPercent: 7, TaxRateRef: { value: "16", }, PercentBased: true, }, }, ], }, CustomerRef: { value: parentTierRef.Id, }, }; try { const result = await oauthClient.makeApiCall({ url: urlBuilder(req.cookies.qbo_realmId, "invoice"), method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(invoiceObj), }); setNewRefreshToken(req.user.email, result); return result && result.Invoice; } catch (error) { logger.log("qbo-payables-error", "DEBUG", req.user.email, job.id, { error, method: "InsertOwner", }); throw error; } }