/* ──────────────────────────────────────────────────────────────────────────── K2B DMS — fixed-ops money: ONE calculation for the parts counter ticket and the repair order (RC20, lane fops; owner-reviewer R1 / R2 / R6, spec K2B-002 / K2B-004 / K2B-008, 4.3, 8.3, 9.2). The SAME file is used twice, byte for byte: server src/lib/fixedops-calc.js — the commands POST /api/ro/close and POST /api/parts/checkout compute the committed totals, the tax and the journal with it (the server's answer wins) browser FopsCalc.jsx.txt — window.FopsCalc, for the PREVIEW only (ticket, RO editor, dispatch board, the Accounting tab), so what a station shows before it submits is what the server will commit. (tests/test-fops-api.js checks the two files are identical.) Money rules (documented, spec 4.3): • every amount is an INTEGER number of cents; a dollar figure is read through its decimal text (1.445 → 145 cents, half-up), never through float multiplication; • a tax rate is a percent with at most 4 decimals, held as an integer in parts-per-million (7.225 % = 72250 ppm); • tax is computed ONCE on the taxable subtotal of the ticket / of the RO's customer-pay work, rounded half-up to the cent (away from zero for a negative amount). Lines are never taxed one by one (RC19 rounded each line and added the pennies: a $20 part at 7.225 % showed $21.45 on the ticket but $21.44 in the journal — R6); • a quantity may have 3 decimals (fluids: 4.5 qt); a line extension = qty × unit price, rounded half-up to the cent. Pure functions only — no database, no window, no Date. Node 20 CommonJS / browser global. ──────────────────────────────────────────────────────────────────────── */ (function (root, factory) { var api = factory(); if (typeof module === "object" && module && module.exports) module.exports = api; if (root) root.FopsCalc = api; })(typeof window !== "undefined" ? window : null, function () { "use strict"; var CALC_VERSION = "fops-2"; // fops-2 (round 2): a stocked part's cost from the parts file; line bounds. RC33 (svi): roClosePosting opts.wipReliefCents / wipAcct (an imported old RO relieves 247) — no other result changes, so the version stays /* ── the posting map: the accounts DMSCore.jsx.txt ACCT already uses for closeRO / roCloseLines / sellPart (the shipped defaults — a station's own Settings › GL Flow remaps live in that station's browser and are NOT used by the server). Literal accounts are the ones sellPart writes literally (wholesale 483 / 683). Nothing here is new. ── */ var ACCT = { cashOnHand: "201", cardDeposit: "202", arCustomer: "220", warrantyRec: "263", contractRec: "264", svcCourtesy: "0675", invNewCar: "231", invUsedCar: "240", invUsedTruck: "241", invParts: "242", subletInv: "246", salesTaxPay: "324A", custLabor: "460A", contractLabor: "460B", warrLabor: "462", internalLabor: "463", subletSale: "466", partsCarRO: "467", partsCounter: "482", partsWholesale: "483", costLabor: "660A", subletCost: "666", costPartsRO: "667", costPartsCounter: "682", costPartsWholesale: "683", cardFeeIncome: "905E", laborApplied: "999", // RC33 (svi): work in process — labor (247). An OLD open repair order the import put on the board carries the old system's labor cost, which the // old system's tech pay booked into 247: that part is relieved from 247 at the K2B close (opts.wipReliefCents), the rest is labor applied (999) wipLabor: "247", // RC28 (svc): a Sunbit (pay over time) payment — the defaults the office can change in Service settings › Sunbit (the payment // carries the accounts it was taken with): the amount Sunbit funds is a receivable, Sunbit's merchant fee an expense sunbitRec: "294", sunbitFee: "0775", }; var PAY_TYPES = ["Customer", "Warranty", "Service Contract", "Internal", "Courtesy"]; var PAY_METHODS = ["cash", "card", "invoice", "warranty", "contract", "courtesy", "internal", "sunbit"]; // RC28 (svc): + sunbit var METHOD_BUCKET = { cash: "Customer", card: "Customer", invoice: "Customer", warranty: "Warranty", contract: "Service Contract", internal: "Internal", courtesy: "Courtesy", sunbit: "Customer" }; var DEFAULTS = { servicePct: "7.225", partsPct: "7.225", wholesaleExempt: true, taxSublet: true, taxFees: false, techRate: "28" }; var MAX_CENTS = 1000000000; // $10,000,000 — a ticket / RO above this is refused (keeps tax math exact in doubles) /* ── numbers ── */ // Decimal text of a value → { neg, int, frac } or null. Numbers go through toFixed(6) (what the screen shows), never * 100. function decText(v) { var s; if (typeof v === "number") { if (!isFinite(v)) return null; s = v.toFixed(6); } else if (typeof v === "string") { s = v.replace(/[,$\s]/g, ""); if (!s) return null; } else return null; var m = /^(-)?(\d*)(?:\.(\d*))?$/.exec(s); if (!m || (m[2] === "" && (m[3] === undefined || m[3] === ""))) return null; return { neg: !!m[1], int: m[2] || "0", frac: m[3] || "" }; } /** Any dollar figure → integer cents, half-up on the third decimal (1.445 → 145, -1.445 → -145). NaN when not a number. */ function toCents(v) { if (v === undefined || v === null || v === "") return 0; var d = decText(v); if (!d) return NaN; var f = (d.frac + "000").slice(0, 3); var c = Number(d.int) * 100 + Number(f.slice(0, 2)) + (Number(f.charAt(2)) >= 5 ? 1 : 0); if (!isFinite(c)) return NaN; return d.neg && c !== 0 ? -c : c; } /** A money figure typed by a person / sent by a station: at most 2 decimals. → cents | NaN | 'decimals'. */ function strictCents(v) { if (v === undefined || v === null || v === "") return NaN; var d = decText(v); if (!d) return NaN; if (typeof v === "string" && d.frac.length > 2) return "decimals"; if (typeof v === "number" && Math.abs(v * 100 - Math.round(v * 100)) > 1e-6) return "decimals"; return toCents(v); } /** Quantity → integer thousandths (4.5 → 4500). NaN when not a number or more than 3 decimals. */ function toMilli(v) { var d = decText(v); if (!d) return NaN; var fr = d.frac.replace(/0+$/, ""); if (fr.length > 3) return NaN; var n = Number(d.int) * 1000 + Number((fr + "000").slice(0, 3)); return d.neg && n !== 0 ? -n : n; } /** a × b / div rounded half-up away from zero (integers in, integer out). */ function mulDivRound(a, b, div) { var neg = (a < 0) !== (b < 0) && a !== 0 && b !== 0; var n = Math.abs(a) * Math.abs(b); var q = Math.floor(n / div), r = n - q * div; if (r * 2 >= div) q += 1; return neg ? -q : q; } /** qty (number/text) × unit price in cents → line cents. */ function extCents(qty, unitCents) { var m = toMilli(qty); if (!isFinite(m) || !isFinite(unitCents)) return NaN; return mulDivRound(m, unitCents, 1000); } /** Percent text / number (at most 4 decimals, 0–30 %) → parts per million. NaN when out of range. */ function pctToPpm(p) { var d = decText(p); if (!d || d.neg) return NaN; var fr = d.frac.replace(/0+$/, ""); if (fr.length > 4) return NaN; var ppm = Number(d.int) * 10000 + Number((fr + "0000").slice(0, 4)); return ppm >= 0 && ppm <= 300000 ? ppm : NaN; } function ppmToPct(ppm) { var s = String(ppm / 10000); return s; } /** Tax on a taxable base (cents) at a rate (ppm): ONCE, half-up to the cent. */ function taxCents(baseCents, ppm) { return baseCents && ppm ? mulDivRound(baseCents, ppm, 1000000) : 0; } function dollars(c) { return Math.round(c) / 100; } function fmt(c) { var neg = c < 0; c = Math.abs(Math.round(c)); var s = String(Math.floor(c / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, ","); return (neg ? "-$" : "$") + s + "." + String(c % 100).padStart(2, "0"); } /* ── the dealership's tax settings: ONE shared copy, serviceSettings.tax on the server (Settings gear → Parts / Service). Missing values fall back to the shipped defaults (ModSettings.jsx.txt: 7.225 %, wholesale exempt). taxSublet true = RC19's recalcRO (customer-pay sublet was taxed); taxFees false. ── */ function taxConfig(serviceSettings) { var t = serviceSettings && typeof serviceSettings === "object" && serviceSettings.tax && typeof serviceSettings.tax === "object" ? serviceSettings.tax : {}; var pick = function (k) { return t[k] !== undefined && t[k] !== null && t[k] !== "" ? t[k] : DEFAULTS[k]; }; var sp = pctToPpm(pick("servicePct")), pp = pctToPpm(pick("partsPct")); return { servicePpm: isFinite(sp) ? sp : pctToPpm(DEFAULTS.servicePct), partsPpm: isFinite(pp) ? pp : pctToPpm(DEFAULTS.partsPct), wholesaleExempt: pick("wholesaleExempt") !== false, taxSublet: pick("taxSublet") !== false, taxFees: pick("taxFees") === true, shared: !!(t && Object.keys(t).length), feeAcct: serviceSettings && serviceSettings.feeAcct ? String(serviceSettings.feeAcct).trim().toUpperCase() : "", }; } /** Validate a tax settings change (the gear / Service Setup): → null or a plain message. */ function taxConfigProblem(t) { if (!t || typeof t !== "object") return "The tax settings must be a list of values."; var bad = function (k) { return t[k] !== undefined && t[k] !== null && t[k] !== "" && !isFinite(pctToPpm(t[k])); }; if (bad("servicePct")) return "The service tax rate must be a percent between 0 and 30 with at most 4 decimals (like 7.225)."; if (bad("partsPct")) return "The parts tax rate must be a percent between 0 and 30 with at most 4 decimals (like 7.225)."; var b = ["wholesaleExempt", "taxSublet", "taxFees"]; for (var i = 0; i < b.length; i++) if (t[b[i]] !== undefined && typeof t[b[i]] !== "boolean") return "\"" + b[i] + "\" must be on or off."; return null; } /* ── PARTS COUNTER TICKET ── input.lines [{ part, desc, bin, qty (whole number), unitCents (what is charged each), baseUnitCents (list / trade), costCents (unit cost), override (bool), coreCents (unit core charge) }] input.kind 'retail' | 'wholesale'. → the ticket in cents. RC28 (fx, reviewer gate 2c): input.collectCores === true (every RC28 ticket) COLLECTS the core deposit on the ticket — qty × the core charge, added to the ticket total (not taxed: it is the customer's money held until the core comes back) and credited to the core-deposit liability account (partsSalePosting opts.coreDepositAcct); Receive Core refunds it from that account. Without the flag (a ticket stored before RC28, a catch-up) the core charge is tracked only, exactly as before. */ function partsTicket(input, cfg) { cfg = cfg || taxConfig(null); var kind = input && input.kind === "wholesale" ? "wholesale" : "retail"; var exempt = kind === "wholesale" && cfg.wholesaleExempt; var lines = []; var sub = 0, cost = 0, deposits = 0; var src = (input && input.lines) || []; for (var i = 0; i < src.length; i++) { var l = src[i]; var e = extCents(l.qty, l.unitCents), c = extCents(l.qty, l.costCents || 0); var dep = input && input.collectCores === true && l.coreCents > 0 ? extCents(l.qty, l.coreCents) : 0; lines.push({ part: l.part, desc: l.desc || "", bin: l.bin || "", qty: l.qty, unitCents: l.unitCents, baseUnitCents: l.baseUnitCents === undefined ? l.unitCents : l.baseUnitCents, override: !!l.override, extCents: e, costCents: l.costCents || 0, costExtCents: c, coreCents: l.coreCents || 0, depositCents: dep }); sub += e; cost += c; deposits += dep; } var taxable = exempt ? 0 : sub; var ppm = exempt ? 0 : cfg.partsPpm; var tax = taxCents(taxable, ppm); return { calc: CALC_VERSION, kind: kind, lines: lines, subtotalCents: sub, taxableCents: taxable, taxRatePpm: ppm, taxRatePct: ppmToPct(ppm), taxExempt: exempt, taxCents: tax, totalCents: sub + tax + deposits, costCents: cost, coreDepositCents: deposits }; } /** The ticket's journal lines (cents) — exactly sellPart's map, one entry for the whole ticket: DR tender (cash 201 / card 202 / charge A/R 220) sale + tax · CR 482 retail | 483 wholesale · DR 682 | 683 cost · CR 242 parts inventory · CR 324A sales tax. */ function partsSalePosting(t, tenderMethod, opts) { var A = ACCT, out = []; var dr = tenderMethod === "card" ? A.cardDeposit : tenderMethod === "charge" ? A.arCustomer : A.cashOnHand; var whs = t.kind === "wholesale"; if (t.totalCents) out.push({ acct: dr, dr: t.totalCents, cr: 0, desc: tenderMethod === "card" ? "Card — parts ticket" : tenderMethod === "charge" ? "Charge to account — parts ticket" : "Cash — parts ticket" }); if (t.subtotalCents) out.push({ acct: whs ? A.partsWholesale : A.partsCounter, dr: 0, cr: t.subtotalCents, desc: whs ? "Wholesale parts sale" : "Counter parts sale" }); if (t.costCents) { out.push({ acct: whs ? A.costPartsWholesale : A.costPartsCounter, dr: t.costCents, cr: 0, desc: "Cost of parts sold" }); out.push({ acct: A.invParts, dr: 0, cr: t.costCents, desc: "Relieve parts inventory" }); } if (t.taxCents) out.push({ acct: A.salesTaxPay, dr: 0, cr: t.taxCents, desc: "Sales tax payable" }); // RC28 (fx, gate 2c): the core deposit collected — held for the customer until the core comes back (the core-deposit liability account) if (t.coreDepositCents) out.push({ acct: (opts && opts.coreDepositAcct) || "331", dr: 0, cr: t.coreDepositCents, desc: "Core deposit collected (refunded when the core comes back)" }); return out; } /* ── REPAIR ORDER ── ctx: { techRateCents(techId) → cents/hr | null, partCostCents(partNo) → cents | null } (server: from the database; browser: state) The RO's own line arrays are the source: ro.ops (jobs; declined jobs excluded with their parts / sublets / fees), ro.partsLines (unit sale + unit cost), ro.sublets (sale / cost, qty default 1), ro.fees [{id, desc, amount, jobId?}]. A summary-only RO (an older RO that never opened in the editor: no ops / partsLines arrays) is read the way DMSCore.hydrateROLines would materialise it — one labor line = laborSale, one parts line = partsSale — so its money is what the board always showed. Payer of a job = job.payType, else the RO's payType, else Customer; a line without a job belongs to the RO's payType. Tax: the CUSTOMER payer's labor + parts (+ sublet when taxSublet, + fees when taxFees). */ function payTypeOf(x, ro) { var p = (x && x.payType) || (ro && ro.payType) || "Customer"; return PAY_TYPES.indexOf(p) === -1 ? "Customer" : p; } function jobLaborCents(j) { if (j && j.rateMode === "flat" && j.flat !== undefined && j.flat !== null && j.flat !== "") return toCents(j.flat); if (j && j.rateMode === "rate" && j.rate !== undefined && j.rate !== null && j.rate !== "" && isFinite(toMilli(j.hrs || 0))) return extCents(j.hrs || 0, toCents(j.rate)); return toCents(j && j.lab); } /** RC28 fix round (attack B3 — finding 1 class): a summary-only RO's labor / parts / sublet figure is read the posting's way (toCents: "1,250.00" is $1,250.00). Before, the line was decided with Number(…) > 0, so labor typed as "1,250.00" (NaN) was silently DROPPED and the RO closed with $0 labor. A figure that is not blank and can't be read now makes its line, and the line's own check refuses the close. */ function legacyAmountOn(v) { if (v === undefined || v === null || v === "") return false; var c = toCents(v); return !isFinite(c) || c > 0; } function roLines(ro) { var legacyOps = !Array.isArray(ro.ops), legacyParts = !Array.isArray(ro.partsLines); var ops = legacyOps ? (legacyAmountOn(ro.laborSale) ? [{ id: "LEGACY-LABOR", op: "LABOR", desc: ro.concern || "Labor — work performed", hrs: ro.flagHours || 0, lab: ro.laborSale, status: "DONE", legacy: true }] : []) : ro.ops; var parts = legacyParts ? (legacyAmountOn(ro.partsSale) ? [{ id: "LEGACY-PARTS", part: "PARTS", desc: "Parts — per estimate", qty: 1, cost: ro.partsCost || 0, sale: ro.partsSale, legacy: true }] : []) : ro.partsLines; var sublets = Array.isArray(ro.sublets) ? ro.sublets : (legacyAmountOn(ro.subletSale) ? [{ id: "LEGACY-SUBLET", vendor: "", desc: "Sublet", sale: ro.subletSale, cost: ro.subletCost || 0, legacy: true }] : []); var fees = Array.isArray(ro.fees) ? ro.fees : []; return { ops: ops || [], parts: parts || [], sublets: sublets, fees: fees, legacyOps: legacyOps, legacyParts: legacyParts }; } function roTotals(ro, cfg, ctx) { cfg = cfg || taxConfig(null); ctx = ctx || {}; var L = roLines(ro || {}); var declined = {}; L.ops.forEach(function (o) { if (o && o.approval === "declined") declined[String(o.id)] = true; }); var jobs = {}; L.ops.forEach(function (o) { if (o) jobs[String(o.id)] = o; }); var live = function (x) { return !(x && x.jobId !== undefined && x.jobId !== null && declined[String(x.jobId)]); }; var payerOfLine = function (x) { var j = x && x.jobId !== undefined && x.jobId !== null ? jobs[String(x.jobId)] : null; return j ? payTypeOf(j, ro) : payTypeOf(null, ro); }; var by = {}; PAY_TYPES.forEach(function (p) { by[p] = { labor: 0, parts: 0, sublet: 0, fees: 0, tax: 0, total: 0 }; }); var lines = []; var bad = []; var defaultRate = toCents(DEFAULTS.techRate); var techRate = function (id) { var r = ctx.techRateCents ? ctx.techRateCents(id) : null; return r === null || r === undefined || !isFinite(r) ? null : r; }; var laborCost = 0, partsCost = 0, subletCost = 0; var bulkByAcct = {}; // RC28 (fx, reviewer gate 2a): bulk oil / fluid dispensed on the RO, by the account it was RECEIVED into (247 / 244) var coreDep = 0, coreLines = []; // RC33 (mpx, B14): the core deposit a customer-pay part line COLLECTS (qty x the part's core charge, not taxed — held on 331 until the core comes back) // round 2 (attacker F9): every line is checked the same way — a quantity is a number with at most 3 decimals, never negative, // at most 10,000; a price / amount is never negative. A crafted line gets a plain message instead of a confusing total. var qtyProblem = function (what, q) { var m = toMilli(q); if (!isFinite(m)) return what + ": the quantity " + String(q) + " must be a number with at most 3 decimals (like 1 or 4.5)."; if (m < 0) return what + " has a negative quantity (" + String(q) + "). A repair order line can't be negative — remove the line, or enter the quantity that was used."; if (m > 10000000) return what + ": the quantity " + String(q) + " is more than 10,000. Check the line."; return null; }; var negProblem = function (what, c) { return isFinite(c) && c < 0 ? what + " is negative (" + fmt(c) + "). A repair order line can't be negative — use a courtesy payment for goodwill." : null; }; L.ops.forEach(function (o) { if (!o || declined[String(o.id)]) return; var payer = payTypeOf(o, ro), sale = jobLaborCents(o); if (!isFinite(sale)) { bad.push("Job " + (o.op || o.id) + " has a labor amount that is not a number."); sale = 0; } var nl = negProblem("The labor on job " + (o.op || o.id), sale); if (nl) { bad.push(nl); sale = 0; } var rate = techRate(o.techId) ; if (rate === null) rate = techRate(ro.techId); if (rate === null) rate = defaultRate; // RC28 fix round (attack B3): a labor cost / hours figure that can't be read is a problem — never the hours x rate fallback, never $0.00 var lcGiven = o.legacy && ro.laborCost !== undefined && ro.laborCost !== null && ro.laborCost !== ""; if (lcGiven && !isFinite(toCents(ro.laborCost))) bad.push("The labor cost on this repair order (\"" + String(ro.laborCost).slice(0, 20) + "\") is not a number — correct it."); if (o.hrs !== undefined && o.hrs !== null && o.hrs !== "" && !isFinite(toMilli(o.hrs))) bad.push("Job " + (o.op || o.id) + ": the hours (\"" + String(o.hrs).slice(0, 20) + "\") are not a number with at most 3 decimals — correct them."); var cost = lcGiven && isFinite(toCents(ro.laborCost)) ? toCents(ro.laborCost) : extCents(o.hrs || 0, rate); if (!isFinite(cost)) cost = 0; by[payer].labor += sale; laborCost += cost; lines.push({ type: "labor", id: String(o.id), op: o.op || "", desc: o.desc || "", hrs: o.hrs || 0, payer: payer, saleCents: sale, status: o.status || "", approval: o.approval || "", legacy: !!o.legacy }); }); L.parts.forEach(function (p) { if (!p || !live(p)) return; var pq = p.qty === undefined ? 1 : p.qty, pName = "Part " + (p.part || p.id); var qp = qtyProblem(pName, pq); if (qp) { bad.push(qp); return; } var payer = payerOfLine(p), unit = toCents(p.sale), sale = extCents(pq, unit); if (!isFinite(sale)) { bad.push(pName + " has a price that is not a number."); sale = 0; } var np = negProblem("The price of " + pName.toLowerCase(), unit); if (np) { bad.push(np); sale = 0; } // round 2 (attacker F4): a STOCKED part's cost is the parts file's cost (what leaves parts inventory 242) — never a figure typed // on the RO line; the line's own cost counts only for a part that is not in the parts file (special order / outside purchase) // or has no cost on file. // RC28 (fx, gate 2a): a BULK line (Bulk Fluids › Dispense) is the tank's oil, never a part of the parts file — its cost is the line's // (the tank's cost when dispensed) and it is relieved from the account the bulk was received into, never 242 var isBulk = p.bulk === true; var fileCost = !isBulk && ctx.partCostCents ? ctx.partCostCents(p.part) : null; // RC28 (fx, finding 1 class): a cost on the line that can't be read is refused — never posted as $0.00 of cost if (p.cost !== undefined && p.cost !== null && p.cost !== "" && !isFinite(toCents(p.cost)) && !(fileCost !== null && fileCost !== undefined && isFinite(fileCost) && fileCost > 0)) { bad.push(pName + " has a cost that is not a number (\"" + String(p.cost).slice(0, 20) + "\") — correct the line's cost."); return; } var uc = fileCost !== null && fileCost !== undefined && isFinite(fileCost) && fileCost > 0 ? fileCost : (p.cost !== undefined && p.cost !== null && p.cost !== "" ? toCents(p.cost) : (fileCost !== null && fileCost !== undefined && isFinite(fileCost) ? fileCost : null)); var cost = isFinite(uc) && uc !== null && uc > 0 ? extCents(pq, uc) : 0; // RC28 fix round (audit FU5 / FU7): a bulk line Dispense stamped with the delivery layers it took (bulkLayers: { acct, milli, cents }) // costs exactly their value and is relieved from EACH layer's account — the oil left in the tank stays valued in the account it came in by var bl = isBulk && Array.isArray(p.bulkLayers) && p.bulkLayers.length ? p.bulkLayers : null; if (bl) { var blc = 0, blm = 0, blOk = true; bl.forEach(function (l) { if (!l || !isFinite(l.cents) || !isFinite(l.milli) || l.cents < 0 || l.milli <= 0) blOk = false; else { blc += l.cents; blm += l.milli; } }); if (!blOk || blm !== toMilli(pq)) { bad.push(pName + ": its bulk oil layers do not match its quantity — ask the office (the line was changed after Dispense)."); return; } cost = blc; } by[payer].parts += sale; partsCost += isFinite(cost) ? cost : 0; // RC33 (mpx, B14): a line that carries the part's core charge (Add part copies it from the parts file) collects the deposit when the // CUSTOMER pays the line; a warranty / internal line returns the core under the claim / the unit (no deposit) if (p.core !== undefined && p.core !== null && p.core !== "" && !isBulk) { var coreU = toCents(p.core); if (!isFinite(coreU) || coreU < 0) bad.push(pName + " has a core charge that is not an amount (\"" + String(p.core).slice(0, 20) + "\") — correct the line."); else if (coreU > 0 && payer === "Customer") { var dep = extCents(pq, coreU); if (isFinite(dep) && dep > 0) { coreDep += dep; coreLines.push({ lineId: String(p.id), part: p.part || "", desc: p.desc || "", qty: pq, coreCents: coreU, depositCents: dep }); } } } if (bl) bl.forEach(function (l) { var la = l.acct === "244" ? "244" : "247"; if (l.cents > 0) bulkByAcct[la] = (bulkByAcct[la] || 0) + l.cents; }); else if (isBulk && isFinite(cost) && cost > 0) { var ba = ctx.bulkAcctOf ? ctx.bulkAcctOf(p) : (p.invAcct === "244" ? "244" : "247"); bulkByAcct[ba] = (bulkByAcct[ba] || 0) + cost; } lines.push({ type: "part", id: String(p.id), part: p.part || "", desc: p.desc || "", qty: p.qty === undefined ? 1 : p.qty, unitCents: unit, payer: payer, saleCents: sale, jobId: p.jobId === undefined ? null : p.jobId, legacy: !!p.legacy }); }); L.sublets.forEach(function (x) { if (!x || !live(x)) return; var payer = payerOfLine(x), q = x.qty === undefined || x.qty === null ? 1 : x.qty, sName = "Sublet " + (x.vendor || x.id); var qs = qtyProblem(sName, q); if (qs) { bad.push(qs); return; } var sale = extCents(q, toCents(x.sale)), cost = extCents(q, toCents(x.cost || 0)); if (!isFinite(sale)) { bad.push(sName + " has an amount that is not a number."); sale = 0; } if (!isFinite(cost)) { bad.push(sName + " has a cost that is not a number (\"" + String(x.cost).slice(0, 20) + "\") — correct the sublet's cost."); cost = 0; } // RC28 (fx, finding 1 class): never a silent $0.00 cost var ns = negProblem("The sale of " + sName.toLowerCase(), sale) || negProblem("The cost of " + sName.toLowerCase(), cost); if (ns) { bad.push(ns); sale = 0; cost = 0; } by[payer].sublet += sale; subletCost += isFinite(cost) ? cost : 0; lines.push({ type: "sublet", id: String(x.id), vendor: x.vendor || "", desc: x.desc || "", qty: q, payer: payer, saleCents: sale, jobId: x.jobId === undefined ? null : x.jobId }); }); L.fees.forEach(function (f) { if (!f || !live(f)) return; var payer = payerOfLine(f), amt = toCents(f.amount); if (!isFinite(amt)) { bad.push("Fee " + (f.desc || f.id) + " has an amount that is not a number."); amt = 0; } by[payer].fees += amt; lines.push({ type: "fee", id: String(f.id), desc: f.desc || "Fee", payer: payer, saleCents: amt, jobId: f.jobId === undefined ? null : f.jobId }); }); if (laborCost + partsCost + subletCost > MAX_CENTS) bad.push("The cost on this repair order is more than " + fmt(MAX_CENTS) + " — check the quantities and hours."); var C = by.Customer; var taxable = C.labor + C.parts + (cfg.taxSublet ? C.sublet : 0) + (cfg.taxFees ? C.fees : 0); // cfg.taxOverrideCents: the tax the customer was actually charged on an RO closed BEFORE RC20 (catch-up posting only) var tax = cfg.taxOverrideCents !== undefined && cfg.taxOverrideCents !== null && isFinite(cfg.taxOverrideCents) ? cfg.taxOverrideCents : taxCents(taxable, cfg.servicePpm); C.tax = tax; var labor = 0, parts = 0, sublet = 0, fees = 0, total = 0; PAY_TYPES.forEach(function (p) { var b = by[p]; b.total = b.labor + b.parts + b.sublet + b.fees + b.tax + (p === "Customer" ? coreDep : 0); labor += b.labor; parts += b.parts; sublet += b.sublet; fees += b.fees; total += b.total; }); var out = { calc: CALC_VERSION, laborCents: labor, partsCents: parts, subletCents: sublet, feesCents: fees, taxableCents: taxable, taxRatePpm: cfg.servicePpm, taxRatePct: ppmToPct(cfg.servicePpm), taxCents: tax, totalCents: total, byPayer: by, lines: lines, problems: bad, legacy: L.legacyOps && L.legacyParts, costs: { laborCents: laborCost, partsCents: partsCost, subletCents: subletCost, bulkByAcct: bulkByAcct } }; // RC28 (fx): partsCents still = ALL parts cost (bulk included); bulkByAcct = the bulk share by its inventory account if (coreDep) { out.coreDepositCents = coreDep; out.coreLines = coreLines; } // RC33 (mpx, B14): only an RO with a core charge carries them (every other RO keeps exactly the RC32 figures) return out; } /** The live payments that belong to the close (RC19 roCloseLines: not voided, not posted on their own after a close). */ function closePayments(ro) { return ((ro && ro.payments) || []).filter(function (p) { return p && !p.voided && !p.postedSeparately; }); } /** Who pays what: payments (by method) cover their payer's share; a non-customer method paying more than its share covers customer work (goodwill / a contract paying a customer job — RC19 allowed it); what stays unpaid becomes that payer's receivable: customer → A/R 220, warranty → 263 (needs a claim number), internal → the unit (needs a stock number); unpaid service-contract or courtesy work must be recorded on the Payments tab first. → { ok, problems[], applied{payer: cents}, unpaid{payer: cents}, customerBalanceCents, paidCents, feesCents } */ function roAllocate(t, payments, opts) { opts = opts || {}; var problems = [], applied = {}, unpaid = {}; PAY_TYPES.forEach(function (p) { applied[p] = 0; unpaid[p] = 0; }); var paid = 0, fees = 0; (payments || []).forEach(function (p) { var a = toCents(p.amount), f = toCents(p.fee || 0); if (!isFinite(a)) { problems.push("A payment has an amount that is not a number."); return; } if (PAY_METHODS.indexOf(p.method) === -1) { problems.push("A payment has no known method (" + String(p.method || "none") + ")."); return; } applied[METHOD_BUCKET[p.method]] += a; paid += a; fees += isFinite(f) ? f : 0; }); var over = 0; PAY_TYPES.forEach(function (p) { if (p === "Customer") return; var share = t.byPayer[p].total; if (applied[p] > share) { over += applied[p] - share; applied[p] = share; } }); applied.Customer += over; if (paid > t.totalCents) problems.push("Payments total " + fmt(paid) + " but the repair order is " + fmt(t.totalCents) + " — over-allocated by " + fmt(paid - t.totalCents) + ". Void or reduce a payment before closing."); else if (applied.Customer > t.byPayer.Customer.total) problems.push("The customer's payments (" + fmt(applied.Customer) + ") are more than the customer's share (" + fmt(t.byPayer.Customer.total) + ") — warranty and internal work are not the customer's to pay. Void or reduce a payment."); PAY_TYPES.forEach(function (p) { unpaid[p] = Math.max(0, t.byPayer[p].total - applied[p]); }); if (opts.catchUp) return { ok: problems.length === 0, problems: problems, applied: applied, unpaid: unpaid, customerBalanceCents: unpaid.Customer, paidCents: paid, feesCents: fees }; // an RO already closed before RC20: book what is unpaid to each payer's receivable if (unpaid["Service Contract"] > 0) problems.push("Service-contract work (" + fmt(unpaid["Service Contract"]) + ") has no contract payment. Record it on the Payments tab (Service contract, with the authorization number) before closing."); if (unpaid.Courtesy > 0) problems.push("Courtesy work (" + fmt(unpaid.Courtesy) + ") is not recorded. Record the courtesy on the Payments tab (with the reason) before closing."); if (unpaid.Warranty > 0 && !opts.warrantyClaim) problems.push("Warranty work (" + fmt(unpaid.Warranty) + ") has no claim number. Record the warranty claim on the Payments tab (Warranty) before closing."); if (unpaid.Internal > 0 && !opts.internalStock) problems.push("Internal work (" + fmt(unpaid.Internal) + ") needs the stock number it is charged to. Record it on the Payments tab (Internal) before closing."); return { ok: problems.length === 0, problems: problems, applied: applied, unpaid: unpaid, customerBalanceCents: unpaid.Customer, paidCents: paid, feesCents: fees }; } function laborAcctOf(payer) { return payer === "Warranty" ? ACCT.warrLabor : payer === "Internal" ? ACCT.internalLabor : payer === "Service Contract" ? ACCT.contractLabor : ACCT.custLabor; } /** The payer account of one payment (RC19 DMSCore.roPayAcct). unitAcct = the internal unit's inventory account. */ function payAcctOf(method, unitAcct) { return method === "cash" ? ACCT.cashOnHand : method === "card" ? ACCT.cardDeposit : method === "invoice" ? ACCT.arCustomer : method === "warranty" ? ACCT.warrantyRec : method === "contract" ? ACCT.contractRec : method === "courtesy" ? ACCT.svcCourtesy : method === "internal" ? (unitAcct || ACCT.invUsedCar) : method === "sunbit" ? ACCT.sunbitRec : ACCT.arCustomer; } /** RC28 (svc): the lines of a Sunbit payment — DR the Sunbit receivable for the amount applied to the RO; Sunbit's merchant fee (merchantFee, never charged to the customer) DR the fee expense / CR the receivable, so the receivable is what Sunbit deposits. The accounts are the ones the payment was taken with (p.recAcct / p.feeAcct), else the defaults. */ function sunbitLines(p, amountCents, tag) { var rec = (p && p.recAcct) || ACCT.sunbitRec, feeAcct = (p && p.feeAcct) || ACCT.sunbitFee; var mf = toCents(p && p.merchantFee || 0); if (!isFinite(mf) || mf < 0) mf = 0; var out = [{ acct: rec, dr: amountCents, cr: 0, desc: "Sunbit" + (p && p.receiptNo ? " " + p.receiptNo : "") + (p && p.sunbitRef ? " ref " + p.sunbitRef : "") + (tag ? " " + tag : ""), pay: (p && p.id) || null }]; if (mf) { out.push({ acct: feeAcct, dr: mf, cr: 0, desc: "Sunbit merchant fee" + (p && p.sunbitRef ? " ref " + p.sunbitRef : "") }); out.push({ acct: rec, dr: 0, cr: mf, desc: "Sunbit merchant fee — kept from the deposit" }); } return out; } /** The internal unit's inventory account (RC19 roPayAcct): the unit's own invAcct, new → 231, truck → 241, else 240. */ function unitInvAcct(u) { if (u && u.invAcct) return String(u.invAcct); if (u && /^n/i.test(String(u.type || ""))) return ACCT.invNewCar; return /truck|silverado|sierra|colorado|tahoe|suburban/i.test(u ? (u.mdl || "") + " " + (u.body || "") : "") ? ACCT.invUsedTruck : ACCT.invUsedCar; } /** The close entry (cents) — RC19 roCloseLines' map, plus the unpaid shares as receivables (never as cash): DR each payment to its payer account (card: amount + fee) · DR unpaid customer → 220 · warranty → 263 · internal → unit · CR labor by payer (460A / 462 / 460B / 463) · CR parts 467 · CR sublet 466 · CR fees (the account the office set) · CR sales tax 324A · CR card fee income 905E · DR 660A / CR 999 labor cost · DR 667 / CR 242 parts cost · DR 666 / CR 246 sublet cost. */ function roClosePosting(ro, t, alloc, payments, opts) { opts = opts || {}; var A = ACCT, out = [], unitAcct = opts.unitAcct || null; (payments || []).forEach(function (p) { if (p && p.method === "sunbit") { var sa = toCents(p.amount); if (sa) Array.prototype.push.apply(out, sunbitLines(p, sa)); return; } // RC28 (svc) var a = toCents(p.amount) + toCents(p.fee || 0); if (!a) return; out.push({ acct: payAcctOf(p.method, p.method === "internal" ? (opts.unitAcctFor ? opts.unitAcctFor(p) : unitAcct) : null), dr: a > 0 ? a : 0, cr: a < 0 ? -a : 0, desc: (p.method || "") + (p.receiptNo ? " " + p.receiptNo : "") + (p.claimNo ? " claim " + p.claimNo : "") + (p.ref ? " ref " + p.ref : "") + (p.account ? " acct " + p.account : ""), pay: p.id || null }); }); if (alloc.unpaid.Customer) out.push({ acct: A.arCustomer, dr: alloc.unpaid.Customer, cr: 0, desc: "Customer balance due — A/R", receivable: "Customer" }); if (alloc.unpaid.Warranty) out.push({ acct: A.warrantyRec, dr: alloc.unpaid.Warranty, cr: 0, desc: "Warranty claim receivable" + (opts.warrantyClaim ? " " + opts.warrantyClaim : ""), receivable: "Warranty" }); if (alloc.unpaid.Internal) out.push({ acct: unitAcct || A.invUsedCar, dr: alloc.unpaid.Internal, cr: 0, desc: "Internal — charge to unit " + (opts.internalStock || ""), receivable: "Internal" }); if (alloc.unpaid["Service Contract"]) out.push({ acct: A.contractRec, dr: alloc.unpaid["Service Contract"], cr: 0, desc: "Service-contract claim receivable", receivable: "Service Contract" }); // catch-up only (a close refuses it) if (alloc.unpaid.Courtesy) out.push({ acct: A.svcCourtesy, dr: alloc.unpaid.Courtesy, cr: 0, desc: "Courtesy — service dept gross", receivable: "Courtesy" }); // catch-up only var labor = {}; PAY_TYPES.forEach(function (p) { var a = laborAcctOf(p); labor[a] = (labor[a] || 0) + t.byPayer[p].labor; }); Object.keys(labor).forEach(function (a) { if (labor[a]) out.push({ acct: a, dr: 0, cr: labor[a], desc: "Labor sale" }); }); if (t.partsCents) out.push({ acct: A.partsCarRO, dr: 0, cr: t.partsCents, desc: "Parts on RO" }); if (t.subletCents) out.push({ acct: A.subletSale, dr: 0, cr: t.subletCents, desc: "Sublet / outside work" }); if (t.feesCents) out.push({ acct: opts.feeAcct || "", dr: 0, cr: t.feesCents, desc: "RO fees" }); if (t.taxCents) out.push({ acct: A.salesTaxPay, dr: 0, cr: t.taxCents, desc: "Sales tax payable" }); if (t.coreDepositCents) out.push({ acct: opts.coreDepositAcct || "331", dr: 0, cr: t.coreDepositCents, desc: "Core deposit collected (refunded when the core comes back)" }); // RC33 (mpx, B14) if (alloc.feesCents) out.push({ acct: A.cardFeeIncome, dr: 0, cr: alloc.feesCents, desc: "Card fee charged to customer" }); var c = t.costs; if (c.laborCents) { out.push({ acct: A.costLabor, dr: c.laborCents, cr: 0, desc: "Cost of labor" }); // RC33 (svi): an imported OLD open RO — its old labor (in work in process since the old system booked it) is relieved from 247; never more than this close's labor cost var wip = Math.max(0, Math.min(c.laborCents, Math.round(Number(opts.wipReliefCents) || 0))); if (c.laborCents - wip) out.push({ acct: A.laborApplied, dr: 0, cr: c.laborCents - wip, desc: "Labor applied" }); if (wip) out.push({ acct: opts.wipAcct || A.wipLabor, dr: 0, cr: wip, desc: "Relieve work in process — old-system labor on this RO" }); } if (c.partsCents) { out.push({ acct: A.costPartsRO, dr: c.partsCents, cr: 0, desc: "Cost of parts" }); // RC28 (fx, reviewer gate 2a): bulk oil / fluid is relieved from the SAME account the delivery was received into (Parts settings: 247 by // default, or 244) — never from parts inventory 242 (before RC28 the bulk share left 242 while the deliveries had been booked to 247) var bulk = c.bulkByAcct || {}, bsum = 0; Object.keys(bulk).sort().forEach(function (a) { if (bulk[a] > 0) { out.push({ acct: a, dr: 0, cr: bulk[a], desc: "Relieve bulk oil / fluid inventory" }); bsum += bulk[a]; } }); if (c.partsCents - bsum) out.push({ acct: A.invParts, dr: 0, cr: c.partsCents - bsum, desc: "Relieve parts inventory" }); } if (c.subletCents) { out.push({ acct: A.subletCost, dr: c.subletCents, cr: 0, desc: "Cost of sublet" }); out.push({ acct: A.subletInv, dr: 0, cr: c.subletCents, desc: "Relieve sublet inventory" }); } return out; } function sumSides(lines) { var d = 0, c = 0; (lines || []).forEach(function (l) { d += l.dr || 0; c += l.cr || 0; }); return { dr: d, cr: c, balanced: d === c }; } /** Journal lines in cents → the stored shape (dollars with 2 decimals, the way every entry on the books is written). */ function toJournalLines(lines) { return (lines || []).filter(function (l) { return l.dr || l.cr; }).map(function (l) { var o = { acct: l.acct, dr: dollars(l.dr), cr: dollars(l.cr) }; if (l.desc) o.desc = String(l.desc).slice(0, 120); return o; }); } return { CALC_VERSION: CALC_VERSION, ACCT: ACCT, PAY_TYPES: PAY_TYPES, PAY_METHODS: PAY_METHODS, METHOD_BUCKET: METHOD_BUCKET, DEFAULTS: DEFAULTS, MAX_CENTS: MAX_CENTS, toCents: toCents, strictCents: strictCents, toMilli: toMilli, extCents: extCents, pctToPpm: pctToPpm, ppmToPct: ppmToPct, taxCents: taxCents, mulDivRound: mulDivRound, dollars: dollars, fmt: fmt, taxConfig: taxConfig, taxConfigProblem: taxConfigProblem, partsTicket: partsTicket, partsSalePosting: partsSalePosting, payTypeOf: payTypeOf, jobLaborCents: jobLaborCents, roLines: roLines, roTotals: roTotals, closePayments: closePayments, roAllocate: roAllocate, laborAcctOf: laborAcctOf, payAcctOf: payAcctOf, unitInvAcct: unitInvAcct, roClosePosting: roClosePosting, sumSides: sumSides, toJournalLines: toJournalLines, sunbitLines: sunbitLines, }; });