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); const getToken = oauthClient.getToken(); await refreshOauthToken(oauthClient, req); const BearerToken = req.headers.authorization; const { jobIds } = req.body; //Query Job Info const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { headers: { Authorization: BearerToken, }, }); const result = await client .setHeaders({ Authorization: BearerToken }) .request(queries.QUERY_JOBS_FOR_RECEIVABLES_EXPORT, { ids: ["966dc7f9-2acd-44dc-9df5-d07c5578070a"], //jobIds }); const { jobs, bodyshops } = result; const job = jobs[0]; const bodyshop = bodyshops[0]; const isThreeTier = bodyshop.accountingconfig.tiers === 3; const twoTierPref = bodyshop.accountingconfig.twotierpref; //Replace this with a for-each loop to check every single Job that's included in the list. let insCoCustomerTier, ownerCustomerTier, jobTier; if (isThreeTier || twoTierPref === "source") { //Insert the insurance company tier. //Query for top level customer, the insurance company name. insCoCustomerTier = await QueryInsuranceCo(oauthClient, req, job); if (!insCoCustomerTier) { //Creating the Insurance Customer. insCoCustomerTier = await InsertInsuranceCo( oauthClient, req, job, bodyshop ); } } if (isThreeTier || twoTierPref === "name") { //Insert the name/owner and account for whether the source should be the ins co in 3 tier.. ownerCustomerTier = await QueryOwner(oauthClient, req, job); //Query for the owner itself. if (!ownerCustomerTier) { ownerCustomerTier = await InsertOwner( oauthClient, req, job, isThreeTier, insCoCustomerTier ); } } //Query for the Job or Create it. jobTier = await QueryJob(oauthClient, req, job); // Need to validate that the job tier is associated to the right individual? if (!jobTier) { jobTier = await InsertJob( oauthClient, req, job, isThreeTier, ownerCustomerTier ); } await InsertInvoice(oauthClient, req, job, bodyshop, jobTier); res.sendStatus(200); } catch (error) { console.log(error); res.status(400).json(error); } }; async function QueryInsuranceCo(oauthClient, req, job) { try { const result = await oauthClient.makeApiCall({ url: urlBuilder( req.cookies.qbo_realmId, "query", `select * From Customer where DisplayName = '${job.ins_co_nm}'` ), method: "POST", headers: { "Content-Type": "application/json", }, }); setNewRefreshToken(req.user.email, result); return ( result.json && result.json.QueryResponse && result.json.QueryResponse.Customer && result.json.QueryResponse.Customer[0] ); } catch (error) { logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, { error, method: "QueryInsuranceCo", }); throw error; } } async function InsertInsuranceCo(oauthClient, req, job, bodyshop) { const insCo = bodyshop.md_ins_cos.find((i) => i.name === job.ins_co_nm); const Customer = { DisplayName: job.ins_co_nm, BillAddr: { City: job.ownr_city, Line1: insCo.street1, Line2: insCo.street2, PostalCode: insCo.zip, CountrySubDivisionCode: insCo.state, }, }; try { const result = await oauthClient.makeApiCall({ url: urlBuilder(req.cookies.qbo_realmId, "customer"), method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(Customer), }); setNewRefreshToken(req.user.email, result); return result && result.Customer; } catch (error) { logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, { error, method: "InsertInsuranceCo", }); throw error; } } async function QueryOwner(oauthClient, req, job) { const ownerName = generateOwnerTier(job, true, null); const result = await oauthClient.makeApiCall({ url: urlBuilder( req.cookies.qbo_realmId, "query", `select * From Customer where DisplayName = '${ownerName}'` ), method: "POST", headers: { "Content-Type": "application/json", }, }); setNewRefreshToken(req.user.email, result); return ( result.json && result.json.QueryResponse && result.json.QueryResponse.Customer && result.json.QueryResponse.Customer[0] ); } async function InsertOwner(oauthClient, req, job, isThreeTier, parentTierRef) { const ownerName = generateOwnerTier(job, true, null); const Customer = { DisplayName: ownerName, 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, "customer"), method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(Customer), }); setNewRefreshToken(req.user.email, result); return result && result.Customer; } catch (error) { logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, { error, method: "InsertOwner", }); throw error; } } async function QueryJob(oauthClient, req, job) { const result = await oauthClient.makeApiCall({ url: urlBuilder( req.cookies.qbo_realmId, "query", `select * From Customer where DisplayName = '${job.ro_number}'` ), method: "POST", headers: { "Content-Type": "application/json", }, }); setNewRefreshToken(req.user.email, result); return ( result.json && result.json.QueryResponse && result.json.QueryResponse.Customer && result.json.QueryResponse.Customer[0] ); } async function InsertJob(oauthClient, req, job, isThreeTier, parentTierRef) { const Customer = { 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, "customer"), method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(Customer), }); setNewRefreshToken(req.user.email, result); return result && result.Customer; } catch (error) { logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.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-receivables-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 && jobline.mod_lb_hrs > 0 ) { 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-receivables-error", "DEBUG", req.user.email, job.id, { error, method: "InsertOwner", }); throw error; } }