Files
bodyshop/server/accounting/qbo/qbo-payables.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

490 lines
14 KiB
JavaScript

const urlBuilder = require("./qbo").urlBuilder;
const StandardizeName = require("./qbo").StandardizeName;
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 } = 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
});
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);
}
await InsertBill(oauthClient, qbo_realmId, req, bill, vendorRecord, bodyshop);
// //No error. Mark the job exported & insert export log.
if (elgen) {
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?.authResponse?.body ||
error?.response?.data?.Fault?.Error.map((e) => e.Detail).join(", ") ||
error?.message
});
ret.push({
billid: bill.id,
success: false,
errorMessage:
error?.authResponse?.body ||
error?.response?.data?.Fault?.Error.map((e) => e.Detail).join(", ") ||
error?.message
});
//Add the export log error.
if (elgen) {
await client.setHeaders({ Authorization: BearerToken }).request(queries.INSERT_EXPORT_LOG, {
logs: [
{
bodyshopid: bodyshop.id,
billid: bill.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-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 url = urlBuilder(
qbo_realmId,
"query",
`select * From vendor where DisplayName = '${StandardizeName(bill.vendor.name)}'`
);
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryVendorRecord",
billid: bill.id,
status: result.status,
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payables-query", "DEBUG", req.user.email, null, {
method: "QueryVendorRecord",
call: url,
result: result.json
});
return result.json?.QueryResponse?.Vendor?.[0];
} catch (error) {
logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, {
method: "QueryVendorRecord",
error: error.message,
stack: error.stack
});
throw error;
}
}
async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
const Vendor = {
DisplayName: StandardizeName(bill.vendor.name)
};
try {
const url = urlBuilder(qbo_realmId, "vendor");
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(Vendor)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertVendorRecord",
billid: bill.id,
status: result.status,
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payments-insert", "DEBUG", req.user.email, null, {
method: "InsertVendorRecord",
call: url,
Vendor: Vendor,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
}
if (result.status === 200) return result?.json.Vendor;
} catch (error) {
logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, {
method: "InsertVendorRecord",
validationError: error.message,
stack: error.stack
});
throw error;
}
}
async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop) {
const { accounts, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, bill.job.shopid);
let lines;
if (bodyshop.accountingconfig.accumulatePayableLines === true) {
lines = Object.values(
bill.billlines.reduce((acc, il) => {
const { cost_center, actual_cost, quantity = 1 } = il;
if (!acc[cost_center]) {
acc[cost_center] = { ...il, actual_cost: 0, quantity: 1 };
}
acc[cost_center].actual_cost += Math.round(actual_cost * quantity * 100);
return acc;
}, {})
).map((il) => {
il.actual_cost /= 100;
return 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
);
});
} else {
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?.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)
});
}
let billQbo, VendorCredit;
const billObject = {
VendorRef: {
value: vendor.Id
},
...(vendor.TermRef &&
!bill.is_credit_memo && {
SalesTermRef: {
value: vendor.TermRef.value
}
}),
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?.qbo &&
bodyshop.accountingconfig?.qbo_usa &&
bodyshop.region_config.includes("CA_")
)
? { GlobalTaxCalculation: "TaxExcluded" }
: {}),
...(bodyshop.accountingconfig.qbo_departmentid?.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}),
PrivateNote: `RO ${bill.job.ro_number || ""}`,
Line: lines
};
if (bill.is_credit_memo) {
VendorCredit = billObject;
} else {
billQbo = billObject;
}
const logKey = bill.is_credit_memo ? "VendorCredit" : "billQbo";
const logValue = bill.is_credit_memo ? VendorCredit : billQbo;
logger.log("qbo-payable-objectlog", "DEBUG", req.user.email, bill.id, {
[logKey]: logValue
});
try {
const url = urlBuilder(qbo_realmId, bill.is_credit_memo ? "vendorcredit" : "bill");
const result = await oauthClient.makeApiCall({
url: url,
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(bill.is_credit_memo ? VendorCredit : billQbo)
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertBill",
billid: bill.id,
status: result.status,
bodyshopid: bill.job.shopid,
email: req.user.email
});
logger.log("qbo-payables-insert", "DEBUG", req.user.email, null, {
method: "InsertBill",
call: url,
postingObj: bill.is_credit_memo ? VendorCredit : billQbo,
result: result.json
});
if (result.status >= 400) {
throw new Error(JSON.stringify(result.json.Fault));
}
if (result.status === 200) return result?.json;
} catch (error) {
logger.log("qbo-payables-error", "DEBUG", req.user.email, bill.id, {
method: "InsertBill",
validationError: error.message,
accountmeta: JSON.stringify({ accounts, taxCodes, classes }),
stack: error.stack
});
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, bodyshopid) {
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"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryAccountType",
status: accounts.status,
bodyshopid,
email: req.user.email
});
const taxCodes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From TaxCode`),
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCode",
status: taxCodes.status,
bodyshopid,
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,
email: req.user.email
});
const taxCodeMapping = Object.fromEntries((taxCodes.json?.QueryResponse?.TaxCode || []).map((t) => [t.Name, t.Id]));
const accountMapping = Object.fromEntries(
(accounts.json?.QueryResponse?.Account || []).map((t) => [t.FullyQualifiedName, t.Id])
);
const classMapping = Object.fromEntries((classes.json?.QueryResponse?.Class || []).map((c) => [c.Name, c.Id]));
return {
accounts: accountMapping,
taxCodes: taxCodeMapping,
classes: classMapping
};
}