Files
bodyshop/server/accounting/qbo/qbo-receivables.js
Allan Carr faf9fb75c5 IO-3601 Additional QBO Logging
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-05 18:01:53 -08:00

806 lines
24 KiB
JavaScript

const urlBuilder = require("./qbo").urlBuilder;
const StandardizeName = require("./qbo").StandardizeName;
const logger = require("../../utils/logger");
const apiGqlClient = require("../../graphql-client/graphql-client").client;
const queries = require("../../graphql-client/queries");
const { refresh: refreshOauthToken } = require("./qbo-callback");
const OAuthClient = require("intuit-oauth");
const CreateInvoiceLines = require("../qb-receivables-lines").default;
const moment = require("moment-timezone");
const { generateOwnerTier } = require("../qbxml/qbxml-utils");
const { createMultiQbPayerLines } = require("../qb-receivables-lines");
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
});
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];
if (!qbo_realmId) {
res.status(401).json({ error: "No company associated." });
return;
}
oauthClient.setToken(response.associations[0].qbo_auth);
await refreshOauthToken(oauthClient, req);
const { jobIds, elgen } = req.body;
//Query Job Info
const BearerToken = req.BearerToken;
const client = req.userGraphQLClient;
logger.log("qbo-receivable-create", "DEBUG", req.user.email, jobIds);
const result = await client
.setHeaders({ Authorization: BearerToken })
.request(queries.QUERY_JOBS_FOR_RECEIVABLES_EXPORT, {
ids: jobIds
});
const { jobs, bodyshops } = result;
const bodyshop = bodyshops[0];
const ret = [];
for (const job of jobs) {
//const job = jobs[0];
try {
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 || (!isThreeTier && twoTierPref === "source")) {
//Insert the insurance company tier.
//Query for top level customer, the insurance company name.
insCoCustomerTier = await QueryInsuranceCo(oauthClient, qbo_realmId, req, job);
if (!insCoCustomerTier) {
//Creating the Insurance Customer.
insCoCustomerTier = await InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop);
}
}
if (isThreeTier || (!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, qbo_realmId, req, job, insCoCustomerTier);
//Query for the owner itself.
if (!ownerCustomerTier) {
ownerCustomerTier = await InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, insCoCustomerTier);
}
}
//Query for the Job or Create it.
jobTier = await QueryJob(
oauthClient,
qbo_realmId,
req,
job,
isThreeTier ? ownerCustomerTier : twoTierPref === "source" ? insCoCustomerTier : ownerCustomerTier
);
// Need to validate that the job tier is associated to the right individual?
if (!jobTier) {
jobTier = await InsertJob(
oauthClient,
qbo_realmId,
req,
job,
ownerCustomerTier || insCoCustomerTier
);
}
if (!req.body.custDataOnly) {
await InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, jobTier);
if (job.qb_multiple_payers?.length > 0) {
for (const [index, payer] of job.qb_multiple_payers.entries()) {
//do the thing.
//Create the source level.
let insCoCustomerTier, jobTier;
//Insert the insurance company tier.
//Query for top level customer, the insurance company name.
insCoCustomerTier = await QueryInsuranceCo(oauthClient, qbo_realmId, req, {
...job,
ins_co_nm: payer.name
});
if (!insCoCustomerTier) {
//Creating the Insurance Customer.
insCoCustomerTier = await InsertInsuranceCo(
oauthClient,
qbo_realmId,
req,
{ ...job, ins_co_nm: payer.name },
bodyshop
);
}
//Query for the Job or Create it.
jobTier = await QueryJob(oauthClient, qbo_realmId, req, job, insCoCustomerTier);
// Need to validate that the job tier is associated to the right individual?
if (!jobTier) {
jobTier = await InsertJob(oauthClient, qbo_realmId, req, job, insCoCustomerTier);
}
//Create the RO level
await InsertInvoiceMultiPayerInvoice(
oauthClient,
qbo_realmId,
req,
job,
bodyshop,
jobTier,
payer,
`-${index + 1}`
);
}
}
// //No error. Mark the job exported & insert export log.
if (elgen) {
await client.setHeaders({ Authorization: BearerToken }).request(queries.QBO_MARK_JOB_EXPORTED, {
jobId: job.id,
job: {
status: bodyshop.md_ro_statuses.default_exported || "Exported*",
date_exported: moment().tz(bodyshop.timezone)
},
logs: [
{
bodyshopid: bodyshop.id,
jobid: job.id,
successful: true,
useremail: req.user.email
}
]
});
}
}
ret.push({ jobid: job.id, success: true });
} catch (error) {
ret.push({
jobid: job.id,
success: false,
errorMessage:
error?.authResponse?.body ||
error?.response?.data?.Fault?.Error.map((e) => e.Detail).join(", ") ||
error?.response?.data ||
error?.message
});
//console.log(error);
logger.log("qbo-receivable-create-error", "ERROR", req.user.email, {
error: error.message,
stack: error.stack
});
//Add the export log error.
if (elgen) {
await client.setHeaders({ Authorization: BearerToken }).request(queries.INSERT_EXPORT_LOG, {
logs: [
{
bodyshopid: bodyshop.id,
jobid: job.id,
successful: false,
message: JSON.stringify([error?.authResponse?.body || error?.message]),
useremail: req.user.email
}
]
});
}
}
}
res.status(200).json(ret);
} catch (error) {
//console.log(error);
logger.log("qbo-receivable-create-error", "ERROR", req.user.email, {
error: error.message,
stack: error.stack
});
res.status(400).json(error);
}
};
async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
try {
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(job.ins_co_nm.trim())}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryInsuranceCo",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Customer?.[0];
} catch (error) {
logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, {
error,
method: "QueryInsuranceCo"
});
throw error;
}
}
exports.QueryInsuranceCo = QueryInsuranceCo;
async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
const insCo = bodyshop.md_ins_cos.find((i) => i.name === job.ins_co_nm);
if (!insCo) {
throw new Error(
`Insurance Company '${job.ins_co_nm}' not found in shop configuration. Please make sure it exists or change the insurance company name on the job to one that exists.`
);
}
const Customer = {
DisplayName: job.ins_co_nm.trim(),
BillWithParent: true,
BillAddr: {
City: job.ownr_city,
Line1: insCo.street1,
Line2: insCo.street2,
PostalCode: insCo.zip,
CountrySubDivisionCode: insCo.state
}
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(Customer)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertInsuranceCo",
call: url,
customerObj: Customer,
result: result.json
});
return result.json?.Customer;
} catch (error) {
logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, {
error,
method: "InsertInsuranceCo"
});
throw error;
}
}
exports.InsertInsuranceCo = InsertInsuranceCo;
async function QueryOwner(oauthClient, qbo_realmId, req, job, parentTierRef) {
const ownerName = generateOwnerTier(job, true, null);
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${StandardizeName(ownerName)}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryOwner",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Customer?.find((x) => x.ParentRef?.value === parentTierRef?.Id);
}
exports.QueryOwner = QueryOwner;
async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, parentTierRef) {
const ownerName = generateOwnerTier(job, true, null);
const Customer = {
DisplayName: ownerName,
BillWithParent: true,
BillAddr: {
City: job.ownr_city,
Line1: job.ownr_addr1,
Line2: job.ownr_addr2,
PostalCode: job.ownr_zip,
CountrySubDivisionCode: job.ownr_st
},
...(job.ownr_ea ? { PrimaryEmailAddr: { Address: job.ownr_ea.trim() } } : {}),
...(isThreeTier
? {
Job: true,
ParentRef: {
value: parentTierRef.Id
}
}
: {})
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(Customer)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertOwner",
call: url,
customerObj: Customer,
result: result.json
});
return result.json?.Customer;
} catch (error) {
logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, {
error,
method: "InsertOwner"
});
throw error;
}
}
exports.InsertOwner = InsertOwner;
async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
const url = urlBuilder(
qbo_realmId,
"query",
`select * From Customer where DisplayName = '${job.ro_number}' and Active = true`
);
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-query", "DEBUG", req.user.email, job.id, {
method: "QueryJob",
call: url,
result: result.json
});
const customers = result.json?.QueryResponse?.Customer;
return customers && (parentTierRef ? customers.find((x) => x.ParentRef.value === parentTierRef.Id) : customers[0]);
}
exports.QueryJob = QueryJob;
async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
const Customer = {
DisplayName: job.ro_number,
BillWithParent: true,
BillAddr: {
City: job.ownr_city,
Line1: job.ownr_addr1,
Line2: job.ownr_addr2,
PostalCode: job.ownr_zip,
CountrySubDivisionCode: job.ownr_st
},
...(job.ownr_ea ? { PrimaryEmailAddr: { Address: job.ownr_ea.trim() } } : {}),
Job: true,
ParentRef: {
value: parentTierRef.Id
}
};
try {
const url = urlBuilder(qbo_realmId, "customer");
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(Customer)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
});
logger.log("qbo-receivables-insert", "DEBUG", req.user.email, job.id, {
method: "InsertJob",
call: url,
customerObj: Customer,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
}
if (result.status === 200) return result?.json.Customer;
} catch (error) {
logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, {
method: "InsertOwner",
validationError: error.message,
stack: error.stack
});
throw error;
}
}
exports.InsertJob = InsertJob;
async function QueryMetaData(oauthClient, qbo_realmId, req, bodyshopid, jobid) {
const items = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Item where active = true maxresults 1000`),
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryItems",
status: items.status,
bodyshopid,
jobid: jobid,
email: req.user.email
});
const taxCodes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From TaxCode where active = true`),
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCodes",
status: taxCodes.status,
bodyshopid,
jobid: jobid,
email: req.user.email
});
const classes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Class`),
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryClasses",
status: classes.status,
bodyshopid,
jobid: jobid,
email: req.user.email
});
const taxCodeMapping = Object.fromEntries((taxCodes.json?.QueryResponse?.TaxCode || []).map((t) => [t.Name, t.Id]));
const itemMapping = Object.fromEntries((items.json?.QueryResponse?.Item || []).map((item) => [item.Name, item.Id]));
const classMapping = Object.fromEntries((classes.json?.QueryResponse?.Class || []).map((c) => [c.Name, c.Id]));
return {
items: itemMapping,
taxCodes: taxCodeMapping,
classes: classMapping
};
}
async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, parentTierRef) {
const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, job.shopid, job.id);
const InvoiceLineAdd = CreateInvoiceLines({
bodyshop,
jobs_by_pk: job,
qbo: true,
items,
taxCodes,
classes
});
const invoiceObj = {
Line: InvoiceLineAdd,
TxnDate: moment(job.date_invoiced).tz(bodyshop.timezone).format("YYYY-MM-DD"),
DocNumber: job.ro_number,
...(job.class ? { ClassRef: { value: classes[job.class] } } : {}),
CustomerMemo: {
value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${
job.po_number ? `PO No: ${job.po_number}` : ``
} Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${
job.v_model_desc || ""
} ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
},
CustomerRef: {
value: parentTierRef?.Id
},
...(bodyshop.accountingconfig.qbo_departmentid?.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
CustomField: [
...(bodyshop.accountingconfig.ReceivableCustomField1
? [
{
DefinitionId: "1",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1],
Type: "StringType"
}
]
: []),
...(bodyshop.accountingconfig.ReceivableCustomField2
? [
{
DefinitionId: "2",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2],
Type: "StringType"
}
]
: []),
...(bodyshop.accountingconfig.ReceivableCustomField3
? [
{
DefinitionId: "3",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3],
Type: "StringType"
}
]
: [])
],
...(bodyshop.accountingconfig?.qbo &&
bodyshop.accountingconfig?.qbo_usa && {
TxnTaxDetail: {
TxnTaxCodeRef: {
value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem]
}
}
}),
...(bodyshop.accountingconfig.printlater ? { PrintStatus: "NeedToPrint" } : {}),
...(bodyshop.accountingconfig.emaillater && job.ownr_ea ? { EmailStatus: "NeedToSend" } : {}),
BillAddr: {
Line3: `${job.ownr_city || ""}, ${job.ownr_st || ""} ${job.ownr_zip || ""}`.trim(),
Line2: job.ownr_addr1 || "",
Line1: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`
},
...(job.ownr_ea ? { BillEmail: { Address: job.ownr_ea.trim() } } : {})
};
logger.log("qbo-receivable-objectlog", "DEBUG", req.user.email, job.id, {
invoiceObj
});
try {
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "invoice"),
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(invoiceObj)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertInvoice",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
}
if (result.status === 200) return result?.json;
} catch (error) {
logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, {
method: "InsertInvoice",
validationError: error.message,
accountmeta: JSON.stringify({ items, taxCodes, classes }),
stack: error.stack
});
throw error;
}
}
async function InsertInvoiceMultiPayerInvoice(
oauthClient,
qbo_realmId,
req,
job,
bodyshop,
parentTierRef,
payer,
suffix
) {
const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, job.shopid);
const InvoiceLineAdd = createMultiQbPayerLines({
bodyshop,
jobs_by_pk: job,
qbo: true,
items,
taxCodes,
classes,
payer,
suffix
});
const invoiceObj = {
Line: InvoiceLineAdd,
TxnDate: moment(job.date_invoiced).tz(bodyshop.timezone).format("YYYY-MM-DD"),
DocNumber: job.ro_number + suffix,
...(job.class ? { ClassRef: { value: classes[job.class] } } : {}),
CustomerMemo: {
value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${
job.po_number ? `PO No: ${job.po_number}` : ``
} Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${
job.v_model_desc || ""
} ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
},
CustomerRef: {
value: parentTierRef.Id
},
...(bodyshop.accountingconfig.qbo_departmentid?.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
CustomField: [
...(bodyshop.accountingconfig.ReceivableCustomField1
? [
{
DefinitionId: "1",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1],
Type: "StringType"
}
]
: []),
...(bodyshop.accountingconfig.ReceivableCustomField2
? [
{
DefinitionId: "2",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2],
Type: "StringType"
}
]
: []),
...(bodyshop.accountingconfig.ReceivableCustomField3
? [
{
DefinitionId: "3",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3],
Type: "StringType"
}
]
: [])
],
...(bodyshop.accountingconfig?.qbo &&
bodyshop.accountingconfig?.qbo_usa &&
bodyshop.region_config.includes("CA_") && {
TxnTaxDetail: {
TxnTaxCodeRef: {
value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem]
}
}
}),
...(bodyshop.accountingconfig.printlater ? { PrintStatus: "NeedToPrint" } : {}),
...(bodyshop.accountingconfig.emaillater && job.ownr_ea ? { EmailStatus: "NeedToSend" } : {}),
BillAddr: {
Line3: `${job.ownr_city || ""}, ${job.ownr_st || ""} ${job.ownr_zip || ""}`.trim(),
Line2: job.ownr_addr1 || "",
Line1: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`
},
...(job.ownr_ea ? { BillEmail: { Address: job.ownr_ea.trim() } } : {})
};
logger.log("qbo-receivable-objectlog", "DEBUG", req.user.email, job.id, {
invoiceObj
});
try {
const result = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "invoice"),
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(invoiceObj)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertInvoiceMultiPayerInvoice",
status: result.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
}
if (result.status === 200) return result?.json;
} catch (error) {
logger.log("qbo-receivables-error", "DEBUG", req.user.email, job.id, {
method: "InsertInvoiceMultiPayerInvoice",
validationError: error.message,
accountmeta: JSON.stringify({ items, taxCodes, classes }),
stack: error.stack
});
throw error;
}
}