RrScratch3 - Tax / Extras Improvements

This commit is contained in:
Dave
2025-12-05 13:17:25 -05:00
parent 56738f800c
commit 288c8e6347
3 changed files with 377 additions and 80 deletions

View File

@@ -18,7 +18,21 @@ export default connect(mapStateToProps, mapDispatchToProps)(RrAllocationsSummary
/** /**
* Normalize job allocations into a flat list for display / preview building. * Normalize job allocations into a flat list for display / preview building.
* @param ack * @param ack
* @returns {{center: *, sale, partsSale, laborTaxableSale, laborNonTaxableSale, extrasSale, cost, profitCenter, costCenter}[]|*[]} * @returns {{
* center: *,
* sale: *,
* partsSale: *,
* partsTaxableSale: *,
* partsNonTaxableSale: *,
* laborTaxableSale: *,
* laborNonTaxableSale: *,
* extrasSale: *,
* extrasTaxableSale: *,
* extrasNonTaxableSale: *,
* cost: *,
* profitCenter: *,
* costCenter: *
* }[]|*[]}
*/ */
function normalizeJobAllocations(ack) { function normalizeJobAllocations(ack) {
if (!ack || !Array.isArray(ack.jobAllocations)) return []; if (!ack || !Array.isArray(ack.jobAllocations)) return [];
@@ -31,9 +45,13 @@ function normalizeJobAllocations(ack) {
// bucketed sales used to build split ROGOG/ROLABOR // bucketed sales used to build split ROGOG/ROLABOR
partsSale: row.partsSale || null, partsSale: row.partsSale || null,
partsTaxableSale: row.partsTaxableSale || null,
partsNonTaxableSale: row.partsNonTaxableSale || null,
laborTaxableSale: row.laborTaxableSale || null, laborTaxableSale: row.laborTaxableSale || null,
laborNonTaxableSale: row.laborNonTaxableSale || null, laborNonTaxableSale: row.laborNonTaxableSale || null,
extrasSale: row.extrasSale || null, extrasSale: row.extrasSale || null,
extrasTaxableSale: row.extrasTaxableSale || null,
extrasNonTaxableSale: row.extrasNonTaxableSale || null,
cost: row.cost || null, cost: row.cost || null,
profitCenter: row.profitCenter || null, profitCenter: row.profitCenter || null,
@@ -111,9 +129,12 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
}, [fetchAllocations]); }, [fetchAllocations]);
const segmentLabelMap = { const segmentLabelMap = {
partsExtras: "Parts/Extras", partsTaxable: "Parts Taxable",
laborTaxable: "Taxable Labor", partsNonTaxable: "Parts Non-Taxable",
laborNonTaxable: "Non-Taxable Labor" extrasTaxable: "Extras Taxable",
extrasNonTaxable: "Extras Non-Taxable",
laborTaxable: "Labor Taxable",
laborNonTaxable: "Labor Non-Taxable"
}; };
const roggRows = useMemo(() => { const roggRows = useMemo(() => {
@@ -149,7 +170,7 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
}); });
}); });
return rows; return rows;
}, [roggPreview, opCode]); }, [roggPreview, opCode, segmentLabelMap]);
const rolaborRows = useMemo(() => { const rolaborRows = useMemo(() => {
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return []; if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
@@ -231,7 +252,8 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
<> <>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
OpCode: <strong>{effectiveOpCode}</strong>. Only centers with RR GOG mapping (rr_gogcode &amp; rr_item_type) OpCode: <strong>{effectiveOpCode}</strong>. Only centers with RR GOG mapping (rr_gogcode &amp; rr_item_type)
are included. Totals below reflect exactly what will be sent in ROGOG. are included. Totals below reflect exactly what will be sent in ROGOG, with parts, extras, and labor split
into taxable / non-taxable segments.
</Typography.Paragraph> </Typography.Paragraph>
<Table <Table

View File

@@ -63,8 +63,10 @@ function emptyCenterBucket() {
laborTaxableSale: zero, // labor that should be taxed in RR laborTaxableSale: zero, // labor that should be taxed in RR
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
// Extras // Extras (MAPA/MASH/towing/PAO/etc)
extrasSale: zero // MAPA/MASH/towing/storage/PAO/etc extrasSale: zero, // total extras (taxable + non-taxable)
extrasTaxableSale: zero,
extrasNonTaxableSale: zero
}; };
} }
@@ -162,26 +164,228 @@ function buildTaxAllocations(bodyshop, job) {
} }
/** /**
* Decide if a labor line is taxable vs non-taxable for RR. * ============================
* Tax Context & Helpers
* ============================
*/ */
function isLaborTaxable(line) {
return line.tax_part; /**
* Build a small "tax context" object from the job + current instance.
*
* This centralises all of the "is this category taxable?" logic so that
* the rest of the allocation code just asks simple yes/no questions.
*
* IMPORTANT: we are **not** calculating any tax **amounts** here that is
* still handled by job-costing. We only need to know if a given sale bucket
* should be treated as taxable vs non-taxable for RR (CustTxblNTxblFlag).
*/
function buildTaxContext(job = {}) {
const isImex = !!InstanceManager({ imex: true }); // Canada
const isRome = !!InstanceManager({ rome: true }); // US
const toNumber = (v) => (v == null ? 0 : Number(v) || 0);
const federalTaxRate = toNumber(job.federal_tax_rate);
const stateTaxRate = toNumber(job.state_tax_rate);
const localTaxRate = toNumber(job.local_tax_rate);
const hasFederalRate = federalTaxRate > 0;
const hasState = stateTaxRate > 0;
const hasLocal = localTaxRate > 0;
// "hasFederal" kept for backwards compatibility / logging (Canada only)
const hasFederal = isImex && hasFederalRate;
// Canada: if ANY of federal / state / local > 0, treat the job as
// "everything taxable by default", then let line-level flags override
// for parts where applicable.
const globalAllTaxCanada = isImex && (hasFederalRate || hasState || hasLocal);
const hasAnySalesTax = hasFederalRate || hasState || hasLocal;
// Parts tax rate map (PAA/PAC/…)
let partTaxRates = job.part_tax_rates || job.parts_tax_rates || {};
if (typeof partTaxRates === "string") {
try {
partTaxRates = JSON.parse(partTaxRates);
} catch {
partTaxRates = {};
}
}
if (!partTaxRates || typeof partTaxRates !== "object") {
partTaxRates = {};
}
const tax_lbr_rt = toNumber(job.tax_lbr_rt); // labour
const tax_paint_mat_rt = toNumber(job.tax_paint_mat_rt || job.tax_paint_mt_rate); // MAPA
const tax_shop_mat_rt = toNumber(job.tax_shop_mat_rt); // MASH
const tax_tow_rt = toNumber(job.tax_tow_rt); // towing
const tax_sub_rt = toNumber(job.tax_sub_rt); // sublet (rarely used directly)
const hasAnyPartsWithTax = Object.values(partTaxRates).some(
(entry) => entry && entry.prt_tax_in && toNumber(entry.prt_tax_rt) > 0
);
const hasAnyTax =
hasAnySalesTax ||
tax_lbr_rt > 0 ||
tax_paint_mat_rt > 0 ||
tax_shop_mat_rt > 0 ||
tax_tow_rt > 0 ||
tax_sub_rt > 0 ||
hasAnyPartsWithTax;
return {
isImex,
isRome,
federalTaxRate,
stateTaxRate,
localTaxRate,
hasFederal,
hasState,
hasLocal,
hasAnySalesTax,
globalAllTaxCanada,
partTaxRates,
tax_lbr_rt,
tax_paint_mat_rt,
tax_shop_mat_rt,
tax_tow_rt,
tax_sub_rt,
hasAnyPartsWithTax,
hasAnyTax
};
}
/**
* Resolve the "PA" / part-type code (PAA/PAC/…) from a job line.
*/
function resolvePartType(line = {}) {
return line.part_type || line.partType || line.pa_code || line.pa || null;
} }
/** /**
* Decide if a *part* line is taxable vs non-taxable for RR. * Decide if a *part* line is taxable vs non-taxable for RR.
* For now we mirror the same flag; this can be extended with CAD-specific *
* logic (federal_tax_rate, parts_tax_rate, prt_tax_in, etc.) later. * Rules:
* - Canada (IMEX):
* - If ANY of federal_tax_rate / state_tax_rate / local_tax_rate > 0
* => everything is taxable by default (globalAllTaxCanada),
* unless tax_part is explicitly false.
* - Otherwise, use part_tax_rates[part_type] (prt_tax_in && prt_tax_rt > 0),
* with tax_part as final override.
* - US (ROME):
* - Use part_tax_rates[part_type] (prt_tax_in && prt_tax_rt > 0),
* with tax_part as final override.
*
* - line.tax_part is treated as the *final* check:
* - tax_part === false => always non-taxable.
* - tax_part === true => always taxable, even if we have no table entry.
*/ */
function isPartTaxable(line) { function isPartTaxable(line = {}, taxCtx) {
return line.tax_part; if (!taxCtx) return !!line.tax_part;
const { globalAllTaxCanada, partTaxRates } = taxCtx;
// Explicit per-line override to *not* tax.
if (typeof line.tax_part === "boolean" && line.tax_part === false) {
return false;
}
// Canada: any federal/state/local tax rate set => all parts taxable,
// unless explicitly turned off above.
if (globalAllTaxCanada) {
return true;
}
let taxable = false;
const partType = resolvePartType(line);
if (partType && partTaxRates && partTaxRates[partType]) {
const entry = partTaxRates[partType];
const rate = Number(entry?.prt_tax_rt || 0);
const indicator = !!entry?.prt_tax_in;
taxable = indicator && rate > 0;
}
// tax_part === true is treated as "final yes" even if we didn't find
// a matching part_tax_rate entry.
if (typeof line.tax_part === "boolean" && line.tax_part === true) {
taxable = true;
}
return taxable;
}
/**
* Decide if *labour* for this job is taxable.
*
* - Canada (IMEX):
* - If ANY of federal_tax_rate / state_tax_rate / local_tax_rate > 0
* (globalAllTaxCanada) => all labour is taxable.
* - Else if tax_lbr_rt > 0 => labour taxable.
* - Else => non-taxable.
* - US (ROME):
* - tax_lbr_rt > 0 => labour taxable, otherwise not.
*/
function isLaborTaxable(_line, taxCtx) {
if (!taxCtx) return false;
const { isImex, globalAllTaxCanada, tax_lbr_rt } = taxCtx;
if (isImex && globalAllTaxCanada) return true;
return tax_lbr_rt > 0;
}
/**
* Taxability helpers for "extras" buckets.
* These are all job-level decisions; there are no per-line flags for them
* in the data we currently work with.
*
* Canada: if globalAllTaxCanada is true, we treat these as taxable.
*/
function isMapaTaxable(taxCtx) {
if (!taxCtx) return false;
const { isImex, globalAllTaxCanada, tax_paint_mat_rt } = taxCtx;
if (isImex && globalAllTaxCanada) return true;
return tax_paint_mat_rt > 0;
}
function isMashTaxable(taxCtx) {
if (!taxCtx) return false;
const { isImex, globalAllTaxCanada, tax_shop_mat_rt } = taxCtx;
if (isImex && globalAllTaxCanada) return true;
return tax_shop_mat_rt > 0;
}
function isTowTaxable(taxCtx) {
if (!taxCtx) return false;
const { isImex, globalAllTaxCanada, tax_tow_rt } = taxCtx;
if (isImex && globalAllTaxCanada) return true;
return tax_tow_rt > 0;
}
/**
* Helper to push an "extra" (MAPA/MASH/towing/PAO/etc) amount into the
* appropriate taxable / non-taxable buckets for a given center.
*/
function addExtras(bucket, dineroAmount, isTaxable) {
if (!bucket || !dineroAmount || typeof dineroAmount.add !== "function") return;
bucket.extrasSale = bucket.extrasSale.add(dineroAmount);
if (isTaxable) {
bucket.extrasTaxableSale = bucket.extrasTaxableSale.add(dineroAmount);
} else {
bucket.extrasNonTaxableSale = bucket.extrasNonTaxableSale.add(dineroAmount);
}
} }
/** /**
* Build profitCenterHash from joblines (parts + labor) and detect MAPA/MASH presence. * Build profitCenterHash from joblines (parts + labor) and detect MAPA/MASH presence.
* Now stores *buckets* instead of a single Dinero per center. * Now stores *buckets* instead of a single Dinero per center.
*/ */
function buildProfitCenterHash(job, debugLog) { function buildProfitCenterHash(job, debugLog, taxContext) {
let hasMapaLine = false; let hasMapaLine = false;
let hasMashLine = false; let hasMashLine = false;
@@ -231,7 +435,7 @@ function buildProfitCenterHash(job, debugLog) {
amount = amount.add(discount); amount = amount.add(discount);
} }
const taxable = isPartTaxable(val); const taxable = isPartTaxable(val, taxContext);
if (taxable) { if (taxable) {
bucket.partsTaxableSale = bucket.partsTaxableSale.add(amount); bucket.partsTaxableSale = bucket.partsTaxableSale.add(amount);
@@ -254,7 +458,7 @@ function buildProfitCenterHash(job, debugLog) {
amount: Math.round(rate * 100) amount: Math.round(rate * 100)
}).multiply(val.mod_lb_hrs); }).multiply(val.mod_lb_hrs);
if (isLaborTaxable(val)) { if (isLaborTaxable(val, taxContext)) {
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount); bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
} else { } else {
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount); bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
@@ -274,7 +478,9 @@ function buildProfitCenterHash(job, debugLog) {
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale), partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
laborTaxable: summarizeMoney(b.laborTaxableSale), laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale), laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
extras: summarizeMoney(b.extrasSale) extras: summarizeMoney(b.extrasSale),
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
})) }))
}); });
@@ -353,39 +559,48 @@ function applyMapaMashManualLines({
profitCenterHash, profitCenterHash,
hasMapaLine, hasMapaLine,
hasMashLine, hasMashLine,
debugLog debugLog,
taxContext
}) { }) {
// MAPA // MAPA (paint materials)
if (!hasMapaLine && job.job_totals.rates.mapa.total.amount > 0) { if (!hasMapaLine && job.job_totals.rates.mapa.total.amount > 0) {
const mapaAccountName = selectedDmsAllocationConfig.profits.MAPA; const mapaAccountName = selectedDmsAllocationConfig.profits.MAPA;
const mapaAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === mapaAccountName); const mapaAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === mapaAccountName);
if (mapaAccount) { if (mapaAccount) {
const amount = Dinero(job.job_totals.rates.mapa.total);
const taxable = isMapaTaxable(taxContext);
debugLog("Adding MAPA Line Manually", { debugLog("Adding MAPA Line Manually", {
mapaAccountName, mapaAccountName,
amount: summarizeMoney(Dinero(job.job_totals.rates.mapa.total)) amount: summarizeMoney(amount),
taxable
}); });
const bucket = ensureCenterBucket(profitCenterHash, mapaAccountName); const bucket = ensureCenterBucket(profitCenterHash, mapaAccountName);
bucket.extrasSale = bucket.extrasSale.add(Dinero(job.job_totals.rates.mapa.total)); addExtras(bucket, amount, taxable);
} else { } else {
debugLog("NO MAPA ACCOUNT FOUND!!", { mapaAccountName }); debugLog("NO MAPA ACCOUNT FOUND!!", { mapaAccountName });
} }
} }
// MASH // MASH (shop materials)
if (!hasMashLine && job.job_totals.rates.mash.total.amount > 0) { if (!hasMashLine && job.job_totals.rates.mash.total.amount > 0) {
const mashAccountName = selectedDmsAllocationConfig.profits.MASH; const mashAccountName = selectedDmsAllocationConfig.profits.MASH;
const mashAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === mashAccountName); const mashAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === mashAccountName);
if (mashAccount) { if (mashAccount) {
const amount = Dinero(job.job_totals.rates.mash.total);
const taxable = isMashTaxable(taxContext);
debugLog("Adding MASH Line Manually", { debugLog("Adding MASH Line Manually", {
mashAccountName, mashAccountName,
amount: summarizeMoney(Dinero(job.job_totals.rates.mash.total)) amount: summarizeMoney(amount),
taxable
}); });
const bucket = ensureCenterBucket(profitCenterHash, mashAccountName); const bucket = ensureCenterBucket(profitCenterHash, mashAccountName);
bucket.extrasSale = bucket.extrasSale.add(Dinero(job.job_totals.rates.mash.total)); addExtras(bucket, amount, taxable);
} else { } else {
debugLog("NO MASH ACCOUNT FOUND!!", { mashAccountName }); debugLog("NO MASH ACCOUNT FOUND!!", { mashAccountName });
} }
@@ -467,9 +682,17 @@ function applyMaterialsCosting({ job, bodyshop, selectedDmsAllocationConfig, cos
/** /**
* Apply non-tax extras (PVRT, towing, storage, PAO). * Apply non-tax extras (PVRT, towing, storage, PAO).
* Extras go into the extrasSale bucket. * Extras go into the extrasSale bucket (split taxable / non-taxable).
*/ */
function applyExtras({ job, bodyshop, selectedDmsAllocationConfig, profitCenterHash, taxAllocations, debugLog }) { function applyExtras({
job,
bodyshop,
selectedDmsAllocationConfig,
profitCenterHash,
taxAllocations,
debugLog,
taxContext
}) {
const { ca_bc_pvrt } = job; const { ca_bc_pvrt } = job;
// BC PVRT -> state tax // BC PVRT -> state tax
@@ -485,17 +708,19 @@ function applyExtras({ job, bodyshop, selectedDmsAllocationConfig, profitCenterH
const towAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === towAccountName); const towAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === towAccountName);
if (towAccount) { if (towAccount) {
const amount = Dinero({
amount: Math.round((job.towing_payable || 0) * 100)
});
const taxable = isTowTaxable(taxContext);
debugLog("Adding towing_payable to TOW account", { debugLog("Adding towing_payable to TOW account", {
towAccountName, towAccountName,
towing_payable: job.towing_payable towing_payable: job.towing_payable,
taxable
}); });
const bucket = ensureCenterBucket(profitCenterHash, towAccountName); const bucket = ensureCenterBucket(profitCenterHash, towAccountName);
bucket.extrasSale = bucket.extrasSale.add( addExtras(bucket, amount, taxable);
Dinero({
amount: Math.round((job.towing_payable || 0) * 100)
})
);
} else { } else {
debugLog("NO TOW ACCOUNT FOUND!!", { towAccountName }); debugLog("NO TOW ACCOUNT FOUND!!", { towAccountName });
} }
@@ -507,17 +732,19 @@ function applyExtras({ job, bodyshop, selectedDmsAllocationConfig, profitCenterH
const towAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === storageAccountName); const towAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === storageAccountName);
if (towAccount) { if (towAccount) {
const amount = Dinero({
amount: Math.round((job.storage_payable || 0) * 100)
});
const taxable = isTowTaxable(taxContext);
debugLog("Adding storage_payable to TOW account", { debugLog("Adding storage_payable to TOW account", {
storageAccountName, storageAccountName,
storage_payable: job.storage_payable storage_payable: job.storage_payable,
taxable
}); });
const bucket = ensureCenterBucket(profitCenterHash, storageAccountName); const bucket = ensureCenterBucket(profitCenterHash, storageAccountName);
bucket.extrasSale = bucket.extrasSale.add( addExtras(bucket, amount, taxable);
Dinero({
amount: Math.round((job.storage_payable || 0) * 100)
})
);
} else { } else {
debugLog("NO STORAGE/TOW ACCOUNT FOUND!!", { storageAccountName }); debugLog("NO STORAGE/TOW ACCOUNT FOUND!!", { storageAccountName });
} }
@@ -529,17 +756,19 @@ function applyExtras({ job, bodyshop, selectedDmsAllocationConfig, profitCenterH
const otherAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === otherAccountName); const otherAccount = bodyshop.md_responsibility_centers.profits.find((c) => c.name === otherAccountName);
if (otherAccount) { if (otherAccount) {
const amount = Dinero({
amount: Math.round((job.adjustment_bottom_line || 0) * 100)
});
const taxable = !!(taxContext && taxContext.hasAnyTax);
debugLog("Adding adjustment_bottom_line to PAO", { debugLog("Adding adjustment_bottom_line to PAO", {
otherAccountName, otherAccountName,
adjustment_bottom_line: job.adjustment_bottom_line adjustment_bottom_line: job.adjustment_bottom_line,
taxable
}); });
const bucket = ensureCenterBucket(profitCenterHash, otherAccountName); const bucket = ensureCenterBucket(profitCenterHash, otherAccountName);
bucket.extrasSale = bucket.extrasSale.add( addExtras(bucket, amount, taxable);
Dinero({
amount: Math.round((job.adjustment_bottom_line || 0) * 100)
})
);
} else { } else {
debugLog("NO PAO ACCOUNT FOUND!!", { otherAccountName }); debugLog("NO PAO ACCOUNT FOUND!!", { otherAccountName });
} }
@@ -558,7 +787,8 @@ function applyRomeProfileAdjustments({
selectedDmsAllocationConfig, selectedDmsAllocationConfig,
profitCenterHash, profitCenterHash,
debugLog, debugLog,
connectionData connectionData,
taxContext
}) { }) {
// Only relevant for Rome instances // Only relevant for Rome instances
if (!InstanceManager({ rome: true })) return profitCenterHash; if (!InstanceManager({ rome: true })) return profitCenterHash;
@@ -576,6 +806,8 @@ function applyRomeProfileAdjustments({
rateKeys: Object.keys(rateMap) rateKeys: Object.keys(rateMap)
}); });
const extrasTaxable = !!(taxContext && taxContext.hasAnyTax);
// Parts adjustments // Parts adjustments
Object.keys(partsAdjustments).forEach((key) => { Object.keys(partsAdjustments).forEach((key) => {
const accountName = selectedDmsAllocationConfig.profits[key]; const accountName = selectedDmsAllocationConfig.profits[key];
@@ -585,12 +817,13 @@ function applyRomeProfileAdjustments({
const bucket = ensureCenterBucket(profitCenterHash, accountName); const bucket = ensureCenterBucket(profitCenterHash, accountName);
const adjMoney = Dinero(partsAdjustments[key]); const adjMoney = Dinero(partsAdjustments[key]);
bucket.extrasSale = bucket.extrasSale.add(adjMoney); addExtras(bucket, adjMoney, extrasTaxable);
debugLog("Added parts adjustment", { debugLog("Added parts adjustment", {
key, key,
accountName, accountName,
adjustment: summarizeMoney(adjMoney) adjustment: summarizeMoney(adjMoney),
taxable: extrasTaxable
}); });
} else { } else {
CreateRRLogEvent( CreateRRLogEvent(
@@ -619,12 +852,13 @@ function applyRomeProfileAdjustments({
// Note: we intentionally use `rate.adjustments` here to mirror CDK behaviour // Note: we intentionally use `rate.adjustments` here to mirror CDK behaviour
const adjMoney = Dinero(rate.adjustments); const adjMoney = Dinero(rate.adjustments);
bucket.extrasSale = bucket.extrasSale.add(adjMoney); addExtras(bucket, adjMoney, extrasTaxable);
debugLog("Added rate adjustment", { debugLog("Added rate adjustment", {
key, key,
accountName, accountName,
adjustment: summarizeMoney(adjMoney) adjustment: summarizeMoney(adjMoney),
taxable: extrasTaxable
}); });
} else { } else {
CreateRRLogEvent( CreateRRLogEvent(
@@ -651,6 +885,8 @@ function applyRomeProfileAdjustments({
* laborTaxableSale, * laborTaxableSale,
* laborNonTaxableSale, * laborNonTaxableSale,
* extrasSale, * extrasSale,
* extrasTaxableSale,
* extrasNonTaxableSale,
* totalSale, * totalSale,
* cost, * cost,
* profitCenter, * profitCenter,
@@ -663,10 +899,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo
const jobAllocations = centers.map((center) => { const jobAllocations = centers.map((center) => {
const bucket = profitCenterHash[center] || emptyCenterBucket(); const bucket = profitCenterHash[center] || emptyCenterBucket();
const totalSale = bucket.partsSale const extrasSale = bucket.extrasSale;
.add(bucket.laborTaxableSale) const totalSale = bucket.partsSale.add(bucket.laborTaxableSale).add(bucket.laborNonTaxableSale).add(extrasSale);
.add(bucket.laborNonTaxableSale)
.add(bucket.extrasSale);
const profitCenter = bodyshop.md_responsibility_centers.profits.find((c) => c.name === center); const profitCenter = bodyshop.md_responsibility_centers.profits.find((c) => c.name === center);
const costCenter = bodyshop.md_responsibility_centers.costs.find((c) => c.name === center); const costCenter = bodyshop.md_responsibility_centers.costs.find((c) => c.name === center);
@@ -684,7 +918,9 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo
laborNonTaxableSale: bucket.laborNonTaxableSale, laborNonTaxableSale: bucket.laborNonTaxableSale,
// Extras // Extras
extrasSale: bucket.extrasSale, extrasSale,
extrasTaxableSale: bucket.extrasTaxableSale,
extrasNonTaxableSale: bucket.extrasNonTaxableSale,
totalSale, totalSale,
@@ -705,6 +941,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo
laborTaxable: summarizeMoney(row.laborTaxableSale), laborTaxable: summarizeMoney(row.laborTaxableSale),
laborNonTaxable: summarizeMoney(row.laborNonTaxableSale), laborNonTaxable: summarizeMoney(row.laborNonTaxableSale),
extras: summarizeMoney(row.extrasSale), extras: summarizeMoney(row.extrasSale),
extrasTaxable: summarizeMoney(row.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(row.extrasNonTaxableSale),
totalSale: summarizeMoney(row.totalSale), totalSale: summarizeMoney(row.totalSale),
cost: summarizeMoney(row.cost) cost: summarizeMoney(row.cost)
})) }))
@@ -812,12 +1050,35 @@ function calculateAllocations(connectionData, job) {
timetickets: Array.isArray(job.timetickets) ? job.timetickets.length : 0 timetickets: Array.isArray(job.timetickets) ? job.timetickets.length : 0
}); });
const taxContext = buildTaxContext(job);
debugLog("Tax context initialised", {
isImex: taxContext.isImex,
isRome: taxContext.isRome,
federalTaxRate: taxContext.federalTaxRate,
stateTaxRate: taxContext.stateTaxRate,
localTaxRate: taxContext.localTaxRate,
hasFederal: taxContext.hasFederal,
hasState: taxContext.hasState,
hasLocal: taxContext.hasLocal,
globalAllTaxCanada: taxContext.globalAllTaxCanada,
tax_lbr_rt: taxContext.tax_lbr_rt,
tax_paint_mat_rt: taxContext.tax_paint_mat_rt,
tax_shop_mat_rt: taxContext.tax_shop_mat_rt,
tax_tow_rt: taxContext.tax_tow_rt,
hasAnyPartsWithTax: taxContext.hasAnyPartsWithTax,
hasAnyTax: taxContext.hasAnyTax
});
// 1) Tax allocations // 1) Tax allocations
let taxAllocations = buildTaxAllocations(bodyshop, job); let taxAllocations = buildTaxAllocations(bodyshop, job);
debugLog("Initial taxAllocations", summarizeTaxAllocations(taxAllocations)); debugLog("Initial taxAllocations", summarizeTaxAllocations(taxAllocations));
// 2) Profit centers from job lines + MAPA/MASH detection // 2) Profit centers from job lines + MAPA/MASH detection
const { profitCenterHash: initialProfitHash, hasMapaLine, hasMashLine } = buildProfitCenterHash(job, debugLog); const {
profitCenterHash: initialProfitHash,
hasMapaLine,
hasMashLine
} = buildProfitCenterHash(job, debugLog, taxContext);
// 3) DMS allocation config // 3) DMS allocation config
const selectedDmsAllocationConfig = const selectedDmsAllocationConfig =
@@ -842,7 +1103,8 @@ function calculateAllocations(connectionData, job) {
profitCenterHash: initialProfitHash, profitCenterHash: initialProfitHash,
hasMapaLine, hasMapaLine,
hasMashLine, hasMashLine,
debugLog debugLog,
taxContext
}); });
// 6) Materials costing (MAPA/MASH cost side) // 6) Materials costing (MAPA/MASH cost side)
@@ -861,7 +1123,8 @@ function calculateAllocations(connectionData, job) {
selectedDmsAllocationConfig, selectedDmsAllocationConfig,
profitCenterHash, profitCenterHash,
taxAllocations, taxAllocations,
debugLog debugLog,
taxContext
})); }));
// 8) Rome-only profile-level adjustments // 8) Rome-only profile-level adjustments
@@ -871,7 +1134,8 @@ function calculateAllocations(connectionData, job) {
selectedDmsAllocationConfig, selectedDmsAllocationConfig,
profitCenterHash, profitCenterHash,
debugLog, debugLog,
connectionData connectionData,
taxContext
}); });
debugLog("profitCenterHash before jobAllocations build", { debugLog("profitCenterHash before jobAllocations build", {
@@ -882,7 +1146,9 @@ function calculateAllocations(connectionData, job) {
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale), partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
laborTaxable: summarizeMoney(b.laborTaxableSale), laborTaxable: summarizeMoney(b.laborTaxableSale),
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale), laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
extras: summarizeMoney(b.extrasSale) extras: summarizeMoney(b.extrasSale),
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
})) }))
}); });
debugLog("costCenterHash before jobAllocations build", { debugLog("costCenterHash before jobAllocations build", {

View File

@@ -56,7 +56,7 @@ const asN2 = (dineroLike) => {
* Build RO.GOG structure for the reynolds-rome-client `createRepairOrder` payload * Build RO.GOG structure for the reynolds-rome-client `createRepairOrder` payload
* from allocations. * from allocations.
* *
* Supports the new allocation shape: * Supports the allocation shape:
* { * {
* center, * center,
* partsSale, * partsSale,
@@ -65,18 +65,21 @@ const asN2 = (dineroLike) => {
* laborTaxableSale, * laborTaxableSale,
* laborNonTaxableSale, * laborNonTaxableSale,
* extrasSale, * extrasSale,
* extrasTaxableSale,
* extrasNonTaxableSale,
* totalSale, * totalSale,
* cost, * cost,
* profitCenter, * profitCenter,
* costCenter * costCenter
* } * }
* *
* For each center, we can emit up to 5 GOG *segments*: * For each center, we can emit up to 6 GOG *segments*:
* - taxable parts (CustTxblNTxblFlag="T") * - taxable parts (CustTxblNTxblFlag="T")
* - non-taxable parts (CustTxblNTxblFlag="N") * - non-taxable parts (CustTxblNTxblFlag="N")
* - extras (uses profitCenter.rr_cust_txbl_flag) * - taxable extras (CustTxblNTxblFlag="T")
* - taxable labor (CustTxblNTxblFlag="T") * - non-taxable extras (CustTxblNTxblFlag="N")
* - non-tax labor (CustTxblNTxblFlag="N") * - taxable labor (CustTxblNTxblFlag="T")
* - non-taxable labor (CustTxblNTxblFlag="N")
* *
* IMPORTANT: * IMPORTANT:
* Each segment becomes its OWN JobNo / AllGogOpCodeInfo, with exactly one * Each segment becomes its OWN JobNo / AllGogOpCodeInfo, with exactly one
@@ -153,7 +156,8 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
const partsTaxableCents = toCents(alloc.partsTaxableSale); const partsTaxableCents = toCents(alloc.partsTaxableSale);
const partsNonTaxableCents = toCents(alloc.partsNonTaxableSale); const partsNonTaxableCents = toCents(alloc.partsNonTaxableSale);
const extrasCents = toCents(alloc.extrasSale); const extrasTaxableCents = toCents(alloc.extrasTaxableSale);
const extrasNonTaxableCents = toCents(alloc.extrasNonTaxableSale);
const laborTaxableCents = toCents(alloc.laborTaxableSale); const laborTaxableCents = toCents(alloc.laborTaxableSale);
const laborNonTaxableCents = toCents(alloc.laborNonTaxableSale); const laborNonTaxableCents = toCents(alloc.laborNonTaxableSale);
const costCents = toCents(alloc.cost); const costCents = toCents(alloc.cost);
@@ -178,16 +182,25 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
}); });
} }
// 3) Extras segment (respect center's default tax flag) // 3) Taxable extras -> "T"
if (extrasCents !== 0) { if (extrasTaxableCents !== 0) {
segments.push({ segments.push({
kind: "extras", kind: "extrasTaxable",
saleCents: extrasCents, saleCents: extrasTaxableCents,
txFlag: pc.rr_cust_txbl_flag || "N" txFlag: "T"
}); });
} }
// 4) Taxable labor segment -> "T" // 4) Non-taxable extras -> "N"
if (extrasNonTaxableCents !== 0) {
segments.push({
kind: "extrasNonTaxable",
saleCents: extrasNonTaxableCents,
txFlag: "N"
});
}
// 5) Taxable labor segment -> "T"
if (laborTaxableCents !== 0) { if (laborTaxableCents !== 0) {
segments.push({ segments.push({
kind: "laborTaxable", kind: "laborTaxable",
@@ -196,7 +209,7 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
}); });
} }
// 5) Non-taxable labor segment -> "N" // 6) Non-tax labor segment -> "N"
if (laborNonTaxableCents !== 0) { if (laborNonTaxableCents !== 0) {
segments.push({ segments.push({
kind: "laborNonTaxable", kind: "laborNonTaxable",
@@ -356,10 +369,8 @@ const QueryJobData = async (ctx = {}, jobId) => {
* @param advisorNo * @param advisorNo
* @param story * @param story
* @param makeOverride * @param makeOverride
* @param bodyshop
* @param allocations * @param allocations
* @param {string} [opCode] - RR OpCode for this RO (global default / override) * @param {string} [opCode] - RR OpCode for this RO (global default / override)
* @param {string} [taxCode] - RR tax code for header tax (e.g. state/prov code)
* @returns {Object} * @returns {Object}
*/ */
const buildRRRepairOrderPayload = ({ const buildRRRepairOrderPayload = ({
@@ -370,7 +381,6 @@ const buildRRRepairOrderPayload = ({
makeOverride, makeOverride,
allocations, allocations,
opCode opCode
// taxCode
} = {}) => { } = {}) => {
const customerNo = selectedCustomer?.customerNo const customerNo = selectedCustomer?.customerNo
? String(selectedCustomer.customerNo).trim() ? String(selectedCustomer.customerNo).trim()
@@ -421,7 +431,6 @@ const buildRRRepairOrderPayload = ({
if (haveAllocations) { if (haveAllocations) {
const effectiveOpCode = (opCode && String(opCode).trim()) || null; const effectiveOpCode = (opCode && String(opCode).trim()) || null;
// const effectiveTaxCode = (taxCode && String(taxCode).trim()) || null;
if (effectiveOpCode) { if (effectiveOpCode) {
// Build RO.GOG and RO.LABOR in the new normalized shape // Build RO.GOG and RO.LABOR in the new normalized shape