diff --git a/server.js b/server.js index efe533182..368fdf632 100644 --- a/server.js +++ b/server.js @@ -149,6 +149,7 @@ var qbo = require("./server/accounting/qbo/qbo"); app.post("/qbo/authorize", fb.validateFirebaseIdToken, qbo.authorize); app.get("/qbo/callback", qbo.callback); app.post("/qbo/receivables", fb.validateFirebaseIdToken, qbo.receivables); +app.post("/qbo/payables", fb.validateFirebaseIdToken, qbo.payables); var data = require("./server/data/data"); app.post("/data/ah", data.autohouse); diff --git a/server/accounting/qbo/qbo-payables.js b/server/accounting/qbo/qbo-payables.js new file mode 100644 index 000000000..4a42c9e87 --- /dev/null +++ b/server/accounting/qbo/qbo-payables.js @@ -0,0 +1,549 @@ +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; + } +} diff --git a/server/accounting/qbo/qbo-payments.js b/server/accounting/qbo/qbo-payments.js new file mode 100644 index 000000000..e69de29bb diff --git a/server/accounting/qbo/qbo-receivables.js b/server/accounting/qbo/qbo-receivables.js index d2e105393..04b3853f0 100644 --- a/server/accounting/qbo/qbo-receivables.js +++ b/server/accounting/qbo/qbo-receivables.js @@ -36,7 +36,6 @@ exports.default = async (req, res) => { }); oauthClient.setToken(response.associations[0].qbo_auth); - const getToken = oauthClient.getToken(); await refreshOauthToken(oauthClient, req); @@ -48,6 +47,7 @@ exports.default = async (req, res) => { Authorization: BearerToken, }, }); + logger.log("qbo-payable-create", "DEBUG", req.user.email, jobIds); const result = await client .setHeaders({ Authorization: BearerToken }) .request(queries.QUERY_JOBS_FOR_RECEIVABLES_EXPORT, { @@ -111,6 +111,7 @@ exports.default = async (req, res) => { res.sendStatus(200); } catch (error) { console.log(error); + logger.log("qbo-payable-create-error", "ERROR", req.user.email, { error }); res.status(400).json(error); } }; @@ -412,11 +413,7 @@ async function InsertInvoice(oauthClient, req, job, bodyshop, parentTierRef) { } } // Labor Lines - if ( - jobline.profitcenter_labor && - jobline.mod_lb_hrs && - jobline.mod_lb_hrs > 0 - ) { + if (jobline.profitcenter_labor && jobline.mod_lb_hrs) { const DineroAmount = Dinero({ amount: Math.round( job[`rate_${jobline.mod_lbr_ty.toLowerCase()}`] * 100 diff --git a/server/accounting/qbo/qbo.js b/server/accounting/qbo/qbo.js index ab785a9fc..190e5aa07 100644 --- a/server/accounting/qbo/qbo.js +++ b/server/accounting/qbo/qbo.js @@ -21,3 +21,4 @@ exports.callback = require("./qbo-callback").default; exports.authorize = require("./qbo-authorize").default; exports.refresh = require("./qbo-callback").refresh; exports.receivables = require("./qbo-receivables").default; +exports.payables = require("./qbo-payables").default;